railiance-infra/scripts/s1_handoff.py
codex 40e295e3bd
All checks were successful
CI Smoke / source-contract (push) Successful in 8s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Make S1 handoff read-only by default
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02994-7685-7940-bf34-3555b8256018
2026-08-23 12:41:23 +02:00

165 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""Run the fail-closed S1 verification gate and emit a metadata-only receipt."""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import subprocess
import sys
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from baseline_contract import load_spec, profile_hostvars, validate_repo_consumers
from handoff_contract import validate_repository_surface
from inventory_contract import load_inventory
from s1_receipt import validate_receipt
ROOT = Path(__file__).resolve().parents[1]
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _git(*args: str) -> str:
return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip()
def _timestamp(value: datetime) -> str:
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _selected_hosts(inventory: dict[str, Any], requested: list[str]) -> list[dict[str, Any]]:
hosts = inventory["servers"]
if not requested:
return hosts
known = {host["name"]: host for host in hosts}
missing = sorted(set(requested) - set(known))
if missing:
raise ValueError(f"unknown hosts: {', '.join(missing)}")
return [known[name] for name in requested]
def build_receipt(
*,
revision: str,
inventory_digest: str,
hosts: list[dict[str, Any]],
results: dict[str, int] | None,
observed_at: datetime,
freshness_hours: int,
evidence: list[dict[str, str]],
) -> dict[str, Any]:
ran = results is not None
passed = ran and all(results[host["name"]] == 0 for host in hosts)
receipt: dict[str, Any] = {
"schema_version": "1.0",
"receipt_id": str(uuid.uuid4()),
"event_type": "verification",
"synthetic": False,
"created_at": _timestamp(datetime.now(timezone.utc)),
"source_revision": revision,
"inventory_sha256": inventory_digest,
"status": "pass" if passed else ("fail" if ran else "not-run"),
"hosts": [host["name"] for host in hosts],
"profiles": {host["name"]: host["baseline_profile"] for host in hosts},
"observed_at": _timestamp(observed_at),
"fresh_until": _timestamp(observed_at + timedelta(hours=freshness_hours)),
"evidence": evidence,
}
if ran:
receipt["host_exit_status"] = results
validate_receipt(receipt)
return receipt
def _new_reports(started: float, host_names: list[str]) -> list[dict[str, str]]:
evidence = []
for path in sorted((ROOT / "reports").glob("goss-*.tap")):
if path.stat().st_mtime < started:
continue
if not any(f"goss-{name}-" in path.name for name in host_names):
continue
evidence.append(
{
"kind": "goss-tap",
"path": str(path.relative_to(ROOT)),
"sha256": _sha256(path),
}
)
return evidence
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", action="append", default=[])
parser.add_argument("--freshness-hours", type=int, default=24)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--output", type=Path)
args = parser.parse_args()
if not 1 <= args.freshness_hours <= 168:
parser.error("--freshness-hours must be between 1 and 168")
inventory_path = ROOT / "inventory" / "servers.yaml"
spec_path = ROOT / "spec" / "server-baseline.yaml"
inventory = load_inventory(inventory_path)
baseline = load_spec(spec_path)
validate_repo_consumers(ROOT)
validate_repository_surface(ROOT)
hosts = _selected_hosts(inventory, args.host)
for host in hosts:
profile_hostvars(baseline, host["baseline_profile"])
revision = _git("rev-parse", "HEAD")
dirty = _git("status", "--porcelain")
if dirty and not args.dry_run:
print("handoff gate requires a clean checkout so the receipt pins all inputs", file=sys.stderr)
return 2
observed = datetime.now(timezone.utc)
results: dict[str, int] | None = None
evidence: list[dict[str, str]] = []
if not args.dry_run:
if shutil.which("ansible-playbook") is None:
print("handoff gate requires ansible-playbook", file=sys.stderr)
return 2
started = datetime.now(timezone.utc).timestamp()
results = {}
for host in hosts:
completed = subprocess.run(
["ansible-playbook", "playbooks/verify.yaml", "--limit", host["name"]],
cwd=ROOT / "ansible",
check=False,
)
results[host["name"]] = completed.returncode
evidence = _new_reports(started, [host["name"] for host in hosts])
receipt = build_receipt(
revision=revision,
inventory_digest=_sha256(inventory_path),
hosts=hosts,
results=results,
observed_at=observed,
freshness_hours=args.freshness_hours,
evidence=evidence,
)
output = args.output
if output is None:
stamp = observed.strftime("%Y%m%dT%H%M%SZ")
output = ROOT / "reports" / f"s1-handoff-{stamp}.json"
elif not output.is_absolute():
output = ROOT / output
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(json.dumps({"receipt": str(output), "status": receipt["status"]}, sort_keys=True))
return 0 if receipt["status"] in {"pass", "not-run"} else 1
if __name__ == "__main__":
raise SystemExit(main())