Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02994-7685-7940-bf34-3555b8256018
165 lines
5.6 KiB
Python
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())
|