#!/usr/bin/env python3 """rollback_generation.py — T21.4 §21.4F: the governed rollback API. rollback_generation(target_generation, expected_current, reason) Prior tranches proved rollback by recreating deployment primitives by hand (ln -sfn against the live deploy root). That is operator work, not governed machinery — it bypasses lock, eligibility, health, and history. This module is the single canonical rollback operation, built ON the proven deploy machinery (deploy_promotion's lock + atomic swap + build-identity authority), not beside it. The promotion authority (deploy_promotion.py) is deliberately MONOTONIC: an ancestor promotion is refused as STALE_SUPERSEDED. Regression is therefore impossible through promotion — and legitimate only through THIS operation, which owns its own obligations: 1. deployment lock — the SAME .promotion.lock as promote (serialize) 2. current verification — build-identity readback must equal expected_current 3. target existence — generation dir present, build identity readable 4. eligibility — target commit is ancestor-or-equal of current (regression only; forward = promotion's job; divergent/unknown refused) 5. atomic switch — rename(2) symlink swap, never absent/half-updated 6. runtime handoff — Rune executes from current/src at process start; handoff = identity a fresh launch would run 7. runtime verification — fresh-launch identity must equal target 8. health qualification — import smoke against target generation src 9. durable history — JSONL rollback events, appended inside the lock 10. failure recovery — health failure restores prior current (where safe) 11. coherent last-good — last-good := the generation that WAS current (same invariant promotion maintains) Truthful outcomes: ROLLED_BACK target active, health green, history recorded NOOP_ALREADY_CURRENT target already the deployed generation ROLLED_BACK_REVERTED switched, health red, prior current restored REFUSED_UNKNOWN_CURRENT deployed identity unreadable (fail closed) REFUSED_EXPECTED_MISMATCH current != expected_current (stale caller) REFUSED_TARGET_MISSING target generation dir absent REFUSED_TARGET_UNREADABLE target build identity unreadable REFUSED_NOT_ELIGIBLE divergent, newer, or non-ancestor target REFUSED_NO_REASON empty reason (accountability is mandatory) """ from __future__ import annotations import fcntl import json import subprocess import sys import time from pathlib import Path _SCRIPT_DIR = Path(__file__).resolve().parent if str(_SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPT_DIR)) import deploy_promotion # proven machinery: lock path, ancestry, swaps DEPLOY_ROOT = deploy_promotion.DEPLOY_ROOT HARNESS_ROOT = _SCRIPT_DIR.parent HISTORY_PATH = HARNESS_ROOT / "store" / "scaffold" / "rollback_history.jsonl" ROLLED_BACK = "ROLLED_BACK" NOOP = "NOOP_ALREADY_CURRENT" ROLLED_BACK_REVERTED = "ROLLED_BACK_REVERTED" REFUSED_UNKNOWN_CURRENT = "REFUSED_UNKNOWN_CURRENT" REFUSED_EXPECTED_MISMATCH = "REFUSED_EXPECTED_MISMATCH" REFUSED_TARGET_MISSING = "REFUSED_TARGET_MISSING" REFUSED_TARGET_UNREADABLE = "REFUSED_TARGET_UNREADABLE" REFUSED_NOT_ELIGIBLE = "REFUSED_NOT_ELIGIBLE" REFUSED_NO_REASON = "REFUSED_NO_REASON" def _identity(deploy_root: Path, generation: str) -> str: """Authoritative commit for a generation dir (build identity, not name).""" gen_dir = deploy_root / generation identity = gen_dir / "src" / "rune" / "_build_identity.json" try: data = json.loads(identity.read_text(encoding="utf-8")) return str(data.get("commit", "")) except (OSError, ValueError): return "" def _health(deploy_root: Path, generation: str, timeout: int = 60) -> tuple[bool, str]: """Health qualification for a generation: build identity readable AND `import rune` succeeds from that generation's src tree.""" gen_dir = deploy_root / generation commit = _identity(deploy_root, generation) if not commit: return False, "build identity unreadable" src = gen_dir / "src" try: proc = subprocess.run( [sys.executable, "-c", "import rune; import sys; " "sys.stdout.write(getattr(rune, '__version__', 'ok'))"], env={"PYTHONPATH": str(src), "PATH": "/usr/bin:/bin", "HOME": str(gen_dir)}, capture_output=True, text=True, timeout=timeout, cwd=str(gen_dir), ) except subprocess.TimeoutExpired: return False, f"import smoke timed out after {timeout}s" if proc.returncode != 0: return False, f"import smoke failed: {(proc.stderr or '')[-200:]}" return True, f"identity={commit[:12]} import_smoke=ok ({proc.stdout.strip()[:40]})" def _append_history(record: dict, history_path: Path = HISTORY_PATH) -> None: history_path.parent.mkdir(parents=True, exist_ok=True) with open(history_path, "a", encoding="utf-8") as f: f.write(json.dumps(record, sort_keys=True) + "\n") def _swap(deploy_root: Path, name: str, target: Path) -> None: deploy_promotion._atomic_symlink_target(deploy_root, name, target) def rollback_generation( target_generation: str, expected_current: str, reason: str, *, deploy_root: Path = DEPLOY_ROOT, repo: Path = HARNESS_ROOT, history_path: Path = HISTORY_PATH, ) -> tuple[str, dict]: """The governed rollback operation. Returns (outcome, detail). Refusals happen BEFORE any mutation of current/last-good. The lock is shared with promote() so rollback and promotion can never interleave. """ if not reason or not reason.strip(): return REFUSED_NO_REASON, {"error": "rollback requires a reason"} lock_path = deploy_root / ".promotion.lock" # The lock file may be owned by the CI runner (promotion side opens it # for writing). flock needs only an open fd, never write permission, so # open read-only when write is denied — same file, same fence. try: lock_fd = open(lock_path, "a+") except PermissionError: lock_fd = open(lock_path) if lock_path.exists() else \ open(lock_path, "a+") with lock_fd: fcntl.flock(lock_fd, fcntl.LOCK_EX) try: current = deploy_root / "current" last_good = deploy_root / "last-good" detail: dict = {"target": target_generation, "expected_current": expected_current, "reason": reason[:200], "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())} # 2. current verification — identity authority, never the name current_generation = "" try: current_generation = Path(os_readlink(current)).name except OSError: pass deployed_commit = deploy_promotion.read_deployed_commit(deploy_root) if not deployed_commit: return REFUSED_UNKNOWN_CURRENT, { "error": "deployed commit unreadable — fail closed", **detail} detail["current_generation"] = current_generation detail["current_commit"] = deployed_commit if expected_current and expected_current != deployed_commit: detail["error"] = ( f"current is {deployed_commit[:12]}, " f"caller expected {expected_current[:12]} — refused " "before mutation") return REFUSED_EXPECTED_MISMATCH, detail # 3. target existence + identity target_dir = deploy_root / target_generation if not target_dir.is_dir(): detail["error"] = f"generation dir {target_generation} absent" return REFUSED_TARGET_MISSING, detail target_commit = _identity(deploy_root, target_generation) if not target_commit: detail["error"] = ( f"generation {target_generation} build identity unreadable") return REFUSED_TARGET_UNREADABLE, detail detail["target_commit"] = target_commit if target_commit == deployed_commit: detail["note"] = "target already the deployed generation" return NOOP, detail # 4. eligibility — ancestor-or-equal only (regression) rel = deploy_promotion.ancestry_of(target_commit, deployed_commit, repo) if rel != "ancestor": detail["error"] = ( f"target commit ancestry={rel} — only ancestors are " "rollback-eligible (forward movement belongs to " "deploy_promotion)") return REFUSED_NOT_ELIGIBLE, detail # 5. atomic switch (inside lock), last-good := previous current if last_good.is_symlink(): last_good.unlink() _swap(deploy_root, "last-good", Path(os_readlink(current))) _swap(deploy_root, "current", target_dir) detail["switched_to"] = target_generation # 7+8. runtime verification + health qualification post_commit = deploy_promotion.read_deployed_commit(deploy_root) health_ok, health_detail = _health(deploy_root, target_generation) detail["post_switch_identity"] = post_commit detail["health"] = health_detail record = {"at": detail["at"], "operation": "rollback_generation", **detail} if post_commit != target_commit or not health_ok: # 10. failure recovery — restore prior current (safe: we # hold the lock and the prior generation is intact) prev = Path(os_readlink(last_good)) if last_good.is_symlink() \ else None if prev is not None: _swap(deploy_root, "current", prev) detail["reverted_to"] = (Path(str(os_readlink(current))).name if current.is_symlink() else "?") detail["outcome"] = ROLLED_BACK_REVERTED record["outcome"] = ROLLED_BACK_REVERTED _append_history(record, history_path) return ROLLED_BACK_REVERTED, detail record["outcome"] = ROLLED_BACK _append_history(record, history_path) detail["outcome"] = ROLLED_BACK return ROLLED_BACK, detail finally: fcntl.flock(lock_fd, fcntl.LOCK_UN) def os_readlink(p: Path) -> str: return str(Path(str(p)).readlink()) if hasattr(Path, "readlink") \ else str(Path(str(p)).readlink()) def main() -> int: import argparse ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("target_generation") ap.add_argument("--expected-current", required=True, help="commit the caller believes is currently deployed") ap.add_argument("--reason", required=True) args = ap.parse_args() outcome, detail = rollback_generation(args.target_generation, args.expected_current, args.reason) print(json.dumps({"outcome": outcome, **detail}, indent=2, sort_keys=True)) return 0 if outcome in (ROLLED_BACK, NOOP) else 1 if __name__ == "__main__": sys.exit(main())