#!/usr/bin/env python3 """Review primary archive generations; no provider or local deletion capability.""" from __future__ import annotations import argparse import json import re from datetime import UTC, datetime from pathlib import Path from primary_application_archive import PROFILES, require_version, validate_key def identity(receipt): return { "object_key": receipt["object_key"], "version_id": receipt["version_id"], "ciphertext_sha256": receipt["ciphertext_sha256"], "ciphertext_bytes": receipt["ciphertext_bytes"], } def plan(application, inventory, recovery, now=None): now = now or datetime.now(UTC) if ( recovery.get("status") != "application_restored_verified" or recovery.get("application") != application or recovery.get("application_restore_verified") is not True ): raise ValueError("attended_application_recovery_proof_required") protected = recovery.get("object_key", "") validate_key(application, protected) require_version(recovery.get("version_id")) items, ignored = {}, [] for receipt in inventory: key = receipt.get("object_key", "") if ( receipt.get("application") != application or receipt.get("status") != "offsite_fetched_verified" or receipt.get("remote_retrieval_verified") is not True or not isinstance(key, str) or not key.startswith(PROFILES[application]) ): ignored.append(key) continue validate_key(application, key) require_version(receipt.get("version_id")) captured = datetime.fromisoformat(receipt["captured_at"].replace("Z", "+00:00")) if ( captured.tzinfo is None or captured > now or key in items or not re.fullmatch(r"[a-f0-9]{64}", receipt.get("ciphertext_sha256", "")) or not isinstance(receipt.get("ciphertext_bytes"), int) or receipt["ciphertext_bytes"] <= 0 ): raise ValueError("unverified_archive_inventory") items[key] = (captured.astimezone(UTC), receipt) if ( protected not in items or items[protected][1]["version_id"] != recovery["version_id"] or items[protected][1]["ciphertext_sha256"] != recovery.get("ciphertext_sha256") ): raise ValueError("protected_recovery_point_missing_or_changed") ordered = sorted(items, key=lambda key: (items[key][0], key), reverse=True) daily, weekly = {}, {} for key in ordered: stamp = items[key][0] daily.setdefault(stamp.date(), key) weekly.setdefault(stamp.isocalendar()[:2], key) keep = {protected, ordered[0]} | set(list(daily.values())[:14]) | set(list(weekly.values())[:4]) return { "schema": "platform.application-retention-review.v1", "status": "reviewed", "application": application, "daily_generations": 14, "weekly_generations": 4, "protected": identity(items[protected][1]), "keep": [identity(items[key][1]) for key in ordered if key in keep], "expiration_candidates": [identity(items[key][1]) for key in ordered if key not in keep], "ignored": ignored, "provider_mutations": False, "local_mutations": False, "activation_gate": "Separate owner must admit lifecycle/capacity, revalidate protected " "recovery and exact versions before any expiration.", } def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--application", choices=PROFILES, required=True) parser.add_argument("--inventory", type=Path, required=True) parser.add_argument("--recovery-receipt", type=Path, required=True) args = parser.parse_args() print( json.dumps( plan( args.application, json.loads(args.inventory.read_text()), json.loads(args.recovery_receipt.read_text()), ), indent=2, ) ) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as error: print(json.dumps({"status": "failed", "failure_type": type(error).__name__})) raise SystemExit(1)