my-homelab-configs/scripts/safety-case

117 lines
3.9 KiB
Python
Executable File

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