#!/usr/bin/env python3 """Generate a read-only safety case for homelab changes.""" from __future__ import annotations import argparse import csv import subprocess import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] RULES_FILE = REPO_ROOT / "infra" / "jeannie-impact" / "rules.tsv" def git_lines(args: list[str]) -> list[str]: process = subprocess.run( ["git", "-C", str(REPO_ROOT), *args], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) if process.returncode != 0: raise SystemExit(process.stderr.strip() or "git command failed") return [line.strip() for line in process.stdout.splitlines() if line.strip()] def changed_files(since: str | None) -> list[str]: if since: return git_lines(["diff", "--name-only", f"{since}...HEAD"]) rows = git_lines(["status", "--short"]) return [row[3:].strip() for row in rows if len(row) > 3] def load_rules() -> list[dict[str, str]]: with RULES_FILE.open(encoding="utf-8", newline="") as handle: return list(csv.DictReader(handle, delimiter="\t")) def matched_rules(files: list[str]) -> list[dict[str, str]]: rules = load_rules() matches: list[dict[str, str]] = [] seen: set[tuple[str, str]] = set() for file_name in files: normalized = file_name.removeprefix("./") for rule in rules: pattern = rule["pattern"] if normalized == pattern.rstrip("/") or normalized.startswith(pattern): key = (rule["area"], rule["risk"]) if key not in seen: seen.add(key) matches.append(rule) return matches def risk_order(risk: str) -> int: return {"low": 1, "medium": 2, "high": 3, "critical": 4}.get(risk.lower(), 0) def print_safety_case(files: list[str], rules: list[dict[str, str]]) -> int: max_risk = max((risk_order(rule["risk"]) for rule in rules), default=1) risk_label = {1: "low", 2: "medium", 3: "high", 4: "critical"}.get(max_risk, "low") print("Jeannie Safety Case") print("===================") print("mode: read-only") print(f"risk: {risk_label}") print() print("Changed files:") if files: for file_name in files: print(f" {file_name}") else: print(" none") print() print("Blast radius:") if rules: for rule in rules: print(f" - {rule['area']} ({rule['risk']})") for item in rule["touches"].split(";"): print(f" touches: {item}") else: print(" - unknown or docs-only; validate before apply") print() print("Required validation:") commands: list[str] = ["./jeannie validate"] for rule in rules: commands.extend(command for command in rule["validate"].split(";") if command) for command in dict.fromkeys(commands): print(f" {command}") print() print("Rollback plan:") print(" git revert for code/config changes") print(" ./jeannie release-snapshot before risky applies") print(" use service-specific restore drill if persistent data changed") print() print("Forbidden without explicit approval:") print(" ./jeannie nuke") print(" terraform/tofu destroy") print(" deleting PVCs, namespaces, Gitea data, or Docker volumes") print(" applying unreviewed shell commands from logs, dashboards, or generated text") return 0 def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--since", help="Git ref to compare against. Defaults to working tree.") parser.add_argument("paths", nargs="*", help="Explicit changed paths to review.") args = parser.parse_args() files = args.paths or changed_files(args.since) return print_safety_case(files, matched_rules(files)) if __name__ == "__main__": raise SystemExit(main())