117 lines
3.9 KiB
Python
Executable File
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())
|