#!/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())