#!/usr/bin/env python3 """ Red Queen — C5 CLI (C5A + C5B + C5C): kontrollierte Inspektion & Dry-Run. KEIN produktiver Poll-Daemon. KEINE daemon/start-forever-Funktion. Nur explizite, begrenzte Befehle: * poll-once — ein kontrollierter Poll-Zyklus (read-only) * show-pending — ausstehende Commits anzeigen * show-commit — Details eines Commits anzeigen * dry-run-diff — Diff eines Commits anzeigen (read-only) * health — Health-Contract anzeigen * no-write-check — No-Downstream-Write-Guarantee pruefen * propagate-plan — Propagation-Plan fuer einen Commit (read-only, KEIN Write) * live-dry-run — Read-only Live-Dry-Run gegen realen Forgejo + Tolaria Stand * c5c-guarantees — No-Search- und No-Master-Write-Guarantee pruefen (statisch) * apply-commit — Commit vervollstaendigen (Search -> APPLIED) * c5d-plan — C5D-Plan (UPDATING_SEARCH / RETRY_PENDING) anzeigen * c5d-guarantees — C5D-Guarantees pruefen (statisch) * c5e-recover — C5E: Recovery-Entscheidung (read-only) * c5e-replay — C5E: Replay/Recovery (fail-closed, kein Write ohne --allow-writes) * c5e-reconcile — C5E: Read-only Reconciliation (Diagnose, KEIN Repair) * c5e-observability— C5E: Observability-Felder (metadata-minimal) * c5e-health — C5E: Ehrlicher Health-Contract (HEALTHY/DEGRADED/BLOCKED) * c5e-evidence — C5E: Fehler-Evidence fuer einen Commit * c5e-guarantees — C5E: No-Production-Activation-Garantie pruefen (statisch) ALLE C5E-Befehle sind fail-closed: standardmaessig KEIN produktiver Write. """ from __future__ import annotations import argparse import json import os import sys from typing import Any, Dict, List, Optional from rq_c5a import C5AStore, ST_APPLIED, ST_DELETE_APPROVED, OP_DELETE_REQUEST from rq_c5b import ( C5BPoller, GitReader, assert_no_downstream_write, DEFAULT_POLL_INTERVAL_SECONDS, ENV_POLL_INTERVAL, ) from rq_c5c import ( C5CPropagator, C5CDryRun, TolariaClient, assert_no_search_calls, assert_no_master_write, DEFAULT_TOLARIA_BASE, ENV_TOLARIA_BASE, ENV_TOLARIA_SAVE_TOKEN, ENV_TOLARIA_DELETE_TOKEN, ) from rq_c5d import ( C5DEngine, C5DDryRun, SearchClient, assert_no_tolaria_write, assert_no_production_activation, DEFAULT_SEARCH_BASE, ENV_SEARCH_BASE, ENV_SEARCH_TOKEN, ) from rq_c5e import ( C5EStore, C5EEngine, C5EReconciler, observability, health_contract, failure_evidence, ) from rq_c5_delete import ( DeleteExecutor, DeleteApprovalError, DeleteExecutionError, ) def _store(db_path: str) -> C5AStore: return C5AStore(db_path) def _c5e_store(db_path: str) -> C5EStore: return C5EStore(db_path) def _poller(store: C5AStore, repo_path: str, interval: Optional[int]) -> C5BPoller: return C5BPoller(store, repo_path, poll_interval_seconds=interval) def cmd_poll_once(args: argparse.Namespace) -> int: store = _store(args.db) poller = _poller(store, args.repo, args.interval) result = poller.poll_once() print(json.dumps(result, ensure_ascii=False, indent=2)) store.close() return 0 def cmd_show_pending(args: argparse.Namespace) -> int: store = _store(args.db) pending = store.list_commits() print(json.dumps(pending, ensure_ascii=False, indent=2)) store.close() return 0 def cmd_show_commit(args: argparse.Namespace) -> int: store = _store(args.db) commit = store.get_commit(args.sha) if commit is None: print(f"Commit nicht gefunden: {args.sha}") store.close() return 1 print(json.dumps(commit, ensure_ascii=False, indent=2)) objs = store.list_object_changes(args.sha) if objs: print("--- Object Changes ---") print(json.dumps(objs, ensure_ascii=False, indent=2)) store.close() return 0 def cmd_dry_run_diff(args: argparse.Namespace) -> int: reader = GitReader(args.repo) changes = reader.diff_name_status(args.sha) print(json.dumps(changes, ensure_ascii=False, indent=2)) return 0 def cmd_health(args: argparse.Namespace) -> int: store = _store(args.db) print(json.dumps(store.health(), ensure_ascii=False, indent=2)) store.close() return 0 def cmd_no_write_check(args: argparse.Namespace) -> int: result = assert_no_downstream_write() print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 if result["no_downstream_write"] else 1 def _c5c_client(args: argparse.Namespace) -> TolariaClient: return TolariaClient(base_url=args.tolaria_base) def cmd_propagate_plan(args: argparse.Namespace) -> int: """Erzeugt den Propagation-Plan fuer einen Commit (read-only, KEIN Write).""" store = _store(args.db) reader = GitReader(args.repo) dry = C5CDryRun(store, reader, client=_c5c_client(args)) plan = dry.plan_commit(args.sha) print(json.dumps(plan, ensure_ascii=False, indent=2)) store.close() return 0 def cmd_live_dry_run(args: argparse.Namespace) -> int: """Read-only Live-Dry-Run gegen realen Forgejo + Tolaria Stand. Erzeugt einen Propagation Plan (WOULD_WRITE | ALREADY_AT_TARGET | HUMAN_REVIEW | DRIFT | LEGACY_SPECIAL). Fuehrt KEINE produktive Propagation aus. KEINE Writes. """ store = _store(args.db) reader = GitReader(args.repo) dry = C5CDryRun(store, reader, client=_c5c_client(args)) commits = store.list_commits() report = { "TOTAL_OBJECTS_CHECKED": 0, "ALREADY_AT_TARGET": 0, "WOULD_WRITE": 0, "UNEXPECTED_DRIFT": 0, "LEGACY_SPECIAL": 0, "HUMAN_REVIEW": 0, "commits": [], } for c in commits: sha = c.get("commit_sha") or c.get("sha") if not sha: continue plan = dry.plan_commit(sha) for item in plan.get("plan", []): report["TOTAL_OBJECTS_CHECKED"] += 1 p = item.get("plan") if p == "ALREADY_AT_TARGET": report["ALREADY_AT_TARGET"] += 1 elif p == "WOULD_WRITE": report["WOULD_WRITE"] += 1 elif p == "DRIFT": report["UNEXPECTED_DRIFT"] += 1 elif p == "LEGACY_SPECIAL": report["LEGACY_SPECIAL"] += 1 else: report["HUMAN_REVIEW"] += 1 report["commits"].append({"commit_sha": sha, "plan": plan.get("plan", [])}) print(json.dumps(report, ensure_ascii=False, indent=2)) store.close() return 0 def cmd_c5c_guarantees(args: argparse.Namespace) -> int: """Prueft No-Search- und No-Master-Write-Guarantee (statisch).""" search = assert_no_search_calls() master = assert_no_master_write() result = {"no_search_calls": search, "no_master_write": master} print(json.dumps(result, ensure_ascii=False, indent=2)) ok = search["no_search_calls"] and master["no_master_write"] return 0 if ok else 1 def _c5d_search_client(args: argparse.Namespace) -> SearchClient: return SearchClient(base_url=args.search_base, token=args.search_token) def cmd_apply_commit(args: argparse.Namespace) -> int: """Vervollstaendigt einen Commit: Search-Rebuild -> Health/Integrity -> APPLIED. NUR aus ST_UPDATING_SEARCH (Tolaria verifiziert) oder ST_RETRY_PENDING (Search-Retry-Replay). Fuehrt KEINEN Tolaria-Write aus. """ store = _store(args.db) engine = C5DEngine(store, search=_c5d_search_client(args)) result = engine.apply_commit(args.sha) print(json.dumps(result, ensure_ascii=False, indent=2)) store.close() return 0 if result.get("status") == ST_APPLIED else 1 def cmd_c5d_plan(args: argparse.Namespace) -> int: """Read-only C5D-Plan: Commits in UPDATING_SEARCH / RETRY_PENDING anzeigen.""" store = _store(args.db) dry = C5DDryRun(store) print(json.dumps(dry.plan(), ensure_ascii=False, indent=2)) store.close() return 0 def cmd_c5d_guarantees(args: argparse.Namespace) -> int: """Prueft C5D-Guarantees (statisch): No-Tolaria-Write, No-Master-Write, No-Production.""" tol = assert_no_tolaria_write() master = assert_no_master_write() prod = assert_no_production_activation() result = {"no_tolaria_write": tol, "no_master_write": master, "no_production_activation": prod} print(json.dumps(result, ensure_ascii=False, indent=2)) ok = tol["no_tolaria_write"] and master["no_master_write"] and prod["no_production_activation"] return 0 if ok else 1 # --- C5E-Befehle ----------------------------------------------------------- def cmd_c5e_recover(args: argparse.Namespace) -> int: """Recovery-Entscheidung fuer einen Commit (read-only, basierend auf persistiertem State).""" store = _c5e_store(args.db) engine = C5EEngine(store) print(json.dumps(engine.recover(args.sha), ensure_ascii=False, indent=2)) store.close() return 0 def cmd_c5e_replay(args: argparse.Namespace) -> int: """Replay/Recovery fuer einen Commit. FAIL-CLOSED: ohne --allow-writes + injizierte Engine wird KEIN produktiver Write ausgefuehrt (Standard).""" store = _c5e_store(args.db) engine = C5EEngine(store, allow_writes=args.allow_writes) result = engine.replay(args.sha) print(json.dumps(result, ensure_ascii=False, indent=2)) store.close() return 0 def cmd_c5e_reconcile(args: argparse.Namespace) -> int: """Read-only Reconciliation: Forgejo Master <-> Tolaria <-> Search (Diagnose, KEIN Repair).""" store = _c5e_store(args.db) reader = GitReader(args.repo) recon = C5EReconciler( store, reader=reader, tol_client=_c5c_client(args), search_client=_c5d_search_client(args), ) print(json.dumps(recon.reconcile(), ensure_ascii=False, indent=2)) store.close() return 0 def cmd_c5e_observability(args: argparse.Namespace) -> int: """Observability-Felder anzeigen (metadata-minimal, KEINE Knowledge-Inhalte/Secrets).""" store = _c5e_store(args.db) print(json.dumps(observability(store), ensure_ascii=False, indent=2)) store.close() return 0 def cmd_c5e_health(args: argparse.Namespace) -> int: """Ehrlicher Health-Contract (C5E §10): HEALTHY / DEGRADED / BLOCKED.""" store = _c5e_store(args.db) print(json.dumps(health_contract(store), ensure_ascii=False, indent=2)) store.close() return 0 def cmd_c5e_evidence(args: argparse.Namespace) -> int: """Fehler-Evidence fuer einen Commit (C5E §11): commit_sha, object_id, state, reason_code, ...""" store = _c5e_store(args.db) print(json.dumps(failure_evidence(args.sha, store, object_id=args.object_id), ensure_ascii=False, indent=2)) store.close() return 0 def cmd_c5e_guarantees(args: argparse.Namespace) -> int: """Prueft C5E-Guarantees (statisch): No-Production-Activation (fail-closed).""" import rq_c5e src = open(rq_c5e.__file__, "r").read() # Produktive Aktivierung ist nur mit allow_writes=True + injizierter Engine moeglich. # Statische Pruefung: Default-Pfad ist fail-closed; kein poll/daemon/forever. # Echte Code-Zuweisung `allow_writes=True` als DEFAULT wuerde fail-closed brechen. banned = ["def poll(", "while True", "threading.Thread", "schedule.every"] flagged = [b for b in banned if b in src] # Pruefe, ob es eine echte Default-Zuweisung `allow_writes=True` im # Funktions-Signatur-Kontext gibt (nicht nur Docstring-Erwaehnung). signature_default_writes = "allow_writes: bool = True" in src result = { "no_production_activation": True, "fail_closed_default": "allow_writes: bool = False" in src, "signature_default_writes_true": signature_default_writes, "no_polling_daemon": not any(b in src for b in banned), "banned_patterns_found": flagged, } ok = result["fail_closed_default"] and not signature_default_writes \ and result["no_polling_daemon"] print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 if ok else 1 # -- C5 DELETE-Execution (Human-Gated) -------------------------------------- def _delete_executor(args: argparse.Namespace) -> DeleteExecutor: store = _store(args.db) # AUTH.3A: DELETE-Caller liest NUR das DELETE-Credential aus der # Runtime-Konfiguration (Least Privilege, AUTH.1 §3). Kein SAVE-Token. client = TolariaClient( base_url=args.tolaria_base, delete_token=os.environ.get(ENV_TOLARIA_DELETE_TOKEN), ) return DeleteExecutor(store, client) def cmd_c5_delete_approve(args: argparse.Namespace) -> int: """ Persistiert eine explizite Human-Approval fuer einen DELETE-ObjectChange. NUR dieser Befehl erzeugt eine Approval (Human Gate). Er fuehrt KEINEN DELETE aus. Der Human-Gate-Beweis (persistierte Approval) geht dadurch nie verloren (Mission §3: kein undifferenzierter approve+delete-Blackbox). """ store = _store(args.db) try: # ObjectChange muss existieren und operation == DELETE sein. changes = store.list_object_changes(args.commit) deletes = [c for c in changes if c.get("operation") == OP_DELETE_REQUEST] if len(deletes) != 1: print(json.dumps({ "error": f"Erwarte genau einen DELETE-ObjectChange in {args.commit}, " f"gefunden: {len(deletes)}", "reason_code": "DELETE_APPROVAL_MISMATCH", }, ensure_ascii=False, indent=2)) return 1 change = deletes[0] oid = change.get("object_id") path = change.get("path_before") or change.get("path_after") if oid is None or path is None: print(json.dumps({ "error": f"DELETE-ObjectChange {change.get('id')} hat keine object_id/path", "reason_code": "DELETE_APPROVAL_MISMATCH", }, ensure_ascii=False, indent=2)) return 1 # Optional: --object-id / --path zur Bestaetigung (muss exakt passen). if args.object_id and args.object_id != oid: print(json.dumps({ "error": f"--object-id {args.object_id} passt nicht zum ObjectChange {oid}", "reason_code": "DELETE_APPROVAL_MISMATCH", }, ensure_ascii=False, indent=2)) return 1 if args.path and args.path != path: print(json.dumps({ "error": f"--path {args.path} passt nicht zum ObjectChange {path}", "reason_code": "DELETE_APPROVAL_MISMATCH", }, ensure_ascii=False, indent=2)) return 1 approval = store.create_delete_approval( workflow_commit=args.commit, object_change_id=change["id"], object_id=oid, path=path, approved_by=args.approved_by, ) # Commit -> ST_DELETE_APPROVED (nur via expliziter Approval) store.transition_commit(args.commit, ST_DELETE_APPROVED) print(json.dumps({ "status": "APPROVED", "approval_id": approval["approval_id"], "workflow_commit": args.commit, "object_change_id": change["id"], "object_id": oid, "path": path, "approved_by": args.approved_by, "note": "Approval persistiert. DELETE wird NICHT automatisch ausgefuehrt.", }, ensure_ascii=False, indent=2)) return 0 except Exception as e: # noqa: BLE001 - CLI-Fehlerbehandlung print(json.dumps({"error": str(e)}, ensure_ascii=False, indent=2)) return 1 finally: store.close() def cmd_c5_delete_execute(args: argparse.Namespace) -> int: """Fuehrt den kontrollierten Tolaria-DELETE aus (verlangt persistierte Approval).""" store = _store(args.db) executor = _delete_executor(args) try: result = executor.execute(args.commit) print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 except (DeleteApprovalError, DeleteExecutionError) as e: print(json.dumps({ "error": e.message, "reason_code": e.reason_code, "fail_closed": True, }, ensure_ascii=False, indent=2)) return 1 finally: store.close() def cmd_c5_delete_status(args: argparse.Namespace) -> int: """Read-only Status: Commit + zugehoerige Approvals.""" store = _store(args.db) try: commit = store.get_commit(args.commit) approvals = store.list_delete_approvals(args.commit) print(json.dumps({ "commit": commit, "approvals": approvals, }, ensure_ascii=False, indent=2)) return 0 finally: store.close() def cmd_c5_delete_replay(args: argparse.Namespace) -> int: """Idempotenter Replay nach Crash (verlangt persistierte Approval).""" store = _store(args.db) executor = _delete_executor(args) try: result = executor.replay(args.commit) print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 except (DeleteApprovalError, DeleteExecutionError) as e: print(json.dumps({ "error": e.message, "reason_code": e.reason_code, "fail_closed": True, }, ensure_ascii=False, indent=2)) return 1 finally: store.close() def main(argv: Optional[List[str]] = None) -> int: parser = argparse.ArgumentParser( prog="rq_c5_cli", description="Red Queen C5 CLI (C5A + C5B) — kontrollierte Inspektion & Dry-Run", ) parser.add_argument( "--db", default=os.environ.get("C5A_DB", "c5a.db"), help="Pfad zur C5A-SQLite-DB (Env C5A_DB, Default c5a.db)", ) parser.add_argument( "--repo", default=os.environ.get("C5_REPO", "."), help="Pfad zum Forgejo-Clone (Env C5_REPO, Default .)", ) parser.add_argument( "--interval", type=int, default=None, help=f"Poll-Intervall in Sekunden (Env {ENV_POLL_INTERVAL}, Default {DEFAULT_POLL_INTERVAL_SECONDS})", ) parser.add_argument( "--tolaria-base", default=os.environ.get(ENV_TOLARIA_BASE, DEFAULT_TOLARIA_BASE), help=f"Tolaria-Vault-API-Basis (Env {ENV_TOLARIA_BASE}, Default {DEFAULT_TOLARIA_BASE})", ) parser.add_argument( "--search-base", default=os.environ.get(ENV_SEARCH_BASE, DEFAULT_SEARCH_BASE), help=f"Search-API-Basis (Env {ENV_SEARCH_BASE}, Default {DEFAULT_SEARCH_BASE})", ) parser.add_argument( "--search-token", default=os.environ.get(ENV_SEARCH_TOKEN, ""), help=f"Search-Rebuild-Token (Env {ENV_SEARCH_TOKEN})", ) sub = parser.add_subparsers(dest="command", required=True) p = sub.add_parser("poll-once", help="Ein kontrollierter Poll-Zyklus (read-only)") p.set_defaults(func=cmd_poll_once) p = sub.add_parser("show-pending", help="Ausstehende Commits anzeigen") p.set_defaults(func=cmd_show_pending) p = sub.add_parser("show-commit", help="Details eines Commits anzeigen") p.add_argument("sha", help="Commit-SHA") p.set_defaults(func=cmd_show_commit) p = sub.add_parser("dry-run-diff", help="Diff eines Commits anzeigen (read-only)") p.add_argument("sha", help="Commit-SHA") p.set_defaults(func=cmd_dry_run_diff) p = sub.add_parser("health", help="Health-Contract anzeigen") p.set_defaults(func=cmd_health) p = sub.add_parser("no-write-check", help="No-Downstream-Write-Guarantee pruefen") p.set_defaults(func=cmd_no_write_check) p = sub.add_parser("propagate-plan", help="Propagation-Plan fuer einen Commit (read-only, KEIN Write)") p.add_argument("sha", help="Commit-SHA") p.set_defaults(func=cmd_propagate_plan) p = sub.add_parser("live-dry-run", help="Read-only Live-Dry-Run gegen realen Forgejo + Tolaria Stand") p.set_defaults(func=cmd_live_dry_run) p = sub.add_parser("c5c-guarantees", help="No-Search- und No-Master-Write-Guarantee pruefen (statisch)") p.set_defaults(func=cmd_c5c_guarantees) p = sub.add_parser("apply-commit", help="Commit vervollstaendigen: Search-Rebuild -> Health/Integrity -> APPLIED") p.add_argument("sha", help="Commit-SHA") p.set_defaults(func=cmd_apply_commit) p = sub.add_parser("c5d-plan", help="Read-only C5D-Plan: Commits in UPDATING_SEARCH / RETRY_PENDING") p.set_defaults(func=cmd_c5d_plan) p = sub.add_parser("c5d-guarantees", help="C5D-Guarantees pruefen (statisch): No-Tolaria-Write, No-Master-Write, No-Production") p.set_defaults(func=cmd_c5d_guarantees) # --- C5E --- p = sub.add_parser("c5e-recover", help="C5E: Recovery-Entscheidung fuer einen Commit (read-only)") p.add_argument("sha", help="Commit-SHA") p.set_defaults(func=cmd_c5e_recover) p = sub.add_parser("c5e-replay", help="C5E: Replay/Recovery fuer einen Commit (fail-closed, kein Write ohne --allow-writes)") p.add_argument("sha", help="Commit-SHA") p.add_argument("--allow-writes", action="store_true", help="NUR im Test-/Canary-Scope: produktive Writes erlauben (braucht injizierte Engine)") p.set_defaults(func=cmd_c5e_replay) p = sub.add_parser("c5e-reconcile", help="C5E: Read-only Reconciliation (Forgejo<->Tolaria<->Search, KEIN Repair)") p.set_defaults(func=cmd_c5e_reconcile) p = sub.add_parser("c5e-observability", help="C5E: Observability-Felder anzeigen (metadata-minimal)") p.set_defaults(func=cmd_c5e_observability) p = sub.add_parser("c5e-health", help="C5E: Ehrlicher Health-Contract (HEALTHY/DEGRADED/BLOCKED)") p.set_defaults(func=cmd_c5e_health) p = sub.add_parser("c5e-evidence", help="C5E: Fehler-Evidence fuer einen Commit (C5E §11)") p.add_argument("sha", help="Commit-SHA") p.add_argument("--object-id", default=None, help="Optional: Objekt-ID") p.set_defaults(func=cmd_c5e_evidence) p = sub.add_parser("c5e-guarantees", help="C5E-Guarantees pruefen (statisch): No-Production-Activation (fail-closed)") p.set_defaults(func=cmd_c5e_guarantees) # --- C5 DELETE-Execution (Human-Gated) --- p = sub.add_parser("c5-delete-approve", help="Persistiert eine explizite Human-Approval fuer einen DELETE-ObjectChange (NUR Approval, KEIN DELETE)") p.add_argument("commit", help="Commit-SHA") p.add_argument("--object-id", default=None, help="Optional: Objekt-ID zur Bestaetigung") p.add_argument("--path", default=None, help="Optional: Vault-Pfad zur Bestaetigung") p.add_argument("--approved-by", default="human:christian", help="Human-Gate-Provenance") p.set_defaults(func=cmd_c5_delete_approve) p = sub.add_parser("c5-delete-execute", help="Fuehrt den kontrollierten Tolaria-DELETE aus (verlangt persistierte Approval)") p.add_argument("commit", help="Commit-SHA") p.set_defaults(func=cmd_c5_delete_execute) p = sub.add_parser("c5-delete-status", help="Read-only Status: Commit + Approvals") p.add_argument("commit", help="Commit-SHA") p.set_defaults(func=cmd_c5_delete_status) p = sub.add_parser("c5-delete-replay", help="Idempotenter Replay nach Crash (verlangt persistierte Approval)") p.add_argument("commit", help="Commit-SHA") p.set_defaults(func=cmd_c5_delete_replay) args = parser.parse_args(argv) return args.func(args) if __name__ == "__main__": sys.exit(main())