CI Pipeline / best-practices (push) Successful in 10s
CI Pipeline / documentation (push) Successful in 4s
CI Pipeline / canary (push) Successful in 37s
CI Pipeline / lint (push) Successful in 46s
deploy / deploy (push) Skipped
CI Pipeline / type-check (push) Successful in 28s
CI Pipeline / gitea-offline (push) Successful in 15s
CI Pipeline / unit-tests (push) Failing after 2m51s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Successful in 6s
- scripts/rollback_generation.py: canonical rollback_generation() on the deploy_promotion machinery (shared lock, refuse-before-mutation, ancestor-only eligibility, identity+health qualification, durable history, revert on failed health). Live B->A->B proven with refusals. - tool_executor._resolve_execution_cwd: automatic project execution context (workdir > project registry resolve_cwd > process); unknown projects refused pre-execution. Live A-F proven. - construction_interface.close(): auto material_change + revision + drift/revise flow (rule 19Q.2 integrated, proven live). - spine: entry-point seeding (__main__ is a runtime path invisible to the import graph); subsystem belongs_to membership via coverage audit. - scripts/structural_coverage_audit.py: 237 modules A157/B41/C28/D11/E0, 100% meaningful coverage, 100% impl ownership, 0 significant unknowns. - Scaffolding internal 1.0.2 -> 1.0.3 (PATCH, contract unchanged). - Gate object T21.4 persisted (12 mandatory gates, GateAccounting).
260 lines
11 KiB
Python
260 lines
11 KiB
Python
#!/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())
|