Add safety case generator
This commit is contained in:
parent
14aa2555c1
commit
c7956638b3
12
README.md
12
README.md
|
|
@ -194,6 +194,18 @@ lists the next read-only Jeannie commands:
|
||||||
Incident fixtures live under `infra/incident-commander`. Use them to turn
|
Incident fixtures live under `infra/incident-commander`. Use them to turn
|
||||||
repeat outages into repeatable triage behavior before adding any self-healing.
|
repeat outages into repeatable triage behavior before adding any self-healing.
|
||||||
|
|
||||||
|
## Safety Cases
|
||||||
|
|
||||||
|
Before high-blast-radius changes, generate a safety case from the Git diff:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./jeannie safety-case --since HEAD~1
|
||||||
|
```
|
||||||
|
|
||||||
|
It summarizes risk, blast radius, required validation, rollback, and forbidden
|
||||||
|
actions. The implementation lives under `infra/safety-cases` and reuses the
|
||||||
|
same impact rules as `./jeannie impact`.
|
||||||
|
|
||||||
## Deploying
|
## Deploying
|
||||||
|
|
||||||
From the Debian server:
|
From the Debian server:
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,18 @@ lists the next read-only Jeannie commands:
|
||||||
Incident fixtures live under `infra/incident-commander`. Use them to turn
|
Incident fixtures live under `infra/incident-commander`. Use them to turn
|
||||||
repeat outages into repeatable triage behavior before adding any self-healing.
|
repeat outages into repeatable triage behavior before adding any self-healing.
|
||||||
|
|
||||||
|
## Safety Cases
|
||||||
|
|
||||||
|
Before high-blast-radius changes, generate a safety case from the Git diff:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./{{ main_script }} safety-case --since HEAD~1
|
||||||
|
```
|
||||||
|
|
||||||
|
It summarizes risk, blast radius, required validation, rollback, and forbidden
|
||||||
|
actions. The implementation lives under `infra/safety-cases` and reuses the
|
||||||
|
same impact rules as `./{{ main_script }} impact`.
|
||||||
|
|
||||||
## Deploying
|
## Deploying
|
||||||
|
|
||||||
From the Debian server:
|
From the Debian server:
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,10 @@ Gitea, RPi DNS, Pimox workers, Kubernetes, GitOps/apps, and edge/public checks.
|
||||||
hypothesis, runbook, next read-only commands, and actions blocked until
|
hypothesis, runbook, next read-only commands, and actions blocked until
|
||||||
explicitly approved.
|
explicitly approved.
|
||||||
|
|
||||||
|
`safety-case [--since REF] [PATH...]`
|
||||||
|
: Generate a read-only safety case for a Git diff or explicit paths, including
|
||||||
|
risk, blast radius, required validation, rollback, and forbidden actions.
|
||||||
|
|
||||||
`scorecard`
|
`scorecard`
|
||||||
: Roll up backup readiness, GitOps health, DNS/RPi health, cluster health, edge
|
: Roll up backup readiness, GitOps health, DNS/RPi health, cluster health, edge
|
||||||
health, capacity pressure, security posture, public certificates, inventory, and
|
health, capacity pressure, security posture, public certificates, inventory, and
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Safety Cases
|
||||||
|
|
||||||
|
Safety cases are read-only change reviews for the homelab pipeline. They turn a
|
||||||
|
Git diff into a short deployment argument:
|
||||||
|
|
||||||
|
- what changed
|
||||||
|
- likely risk and blast radius
|
||||||
|
- tests required before apply
|
||||||
|
- rollback path
|
||||||
|
- actions that should stay forbidden without explicit approval
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./jeannie safety-case --since HEAD~1
|
||||||
|
```
|
||||||
|
|
||||||
8
jeannie
8
jeannie
|
|
@ -6330,6 +6330,10 @@ incident_commander() {
|
||||||
"${REPO_ROOT}/scripts/incident-commander" "${@:2}"
|
"${REPO_ROOT}/scripts/incident-commander" "${@:2}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
safety_case() {
|
||||||
|
"${REPO_ROOT}/scripts/safety-case" "${@:2}"
|
||||||
|
}
|
||||||
|
|
||||||
validate_homelab() {
|
validate_homelab() {
|
||||||
"${REPO_ROOT}/scripts/validate-homelab"
|
"${REPO_ROOT}/scripts/validate-homelab"
|
||||||
}
|
}
|
||||||
|
|
@ -6524,6 +6528,7 @@ Operator View And Reports
|
||||||
recover-plan Print disaster recovery order and prerequisites.
|
recover-plan Print disaster recovery order and prerequisites.
|
||||||
recover-power [--dry-run] Run or preview post-outage recovery.
|
recover-power [--dry-run] Run or preview post-outage recovery.
|
||||||
incident {list|replay|triage} Classify incident text and suggest read-only next steps.
|
incident {list|replay|triage} Classify incident text and suggest read-only next steps.
|
||||||
|
safety-case [--since REF] [PATH...] Generate risk, blast-radius, test, and rollback case.
|
||||||
impact [--since REF] [PATH...] Explain affected lab areas before changes.
|
impact [--since REF] [PATH...] Explain affected lab areas before changes.
|
||||||
review-last-change [REF] Summarize changed files, impact, validation.
|
review-last-change [REF] Summarize changed files, impact, validation.
|
||||||
map [--dot] Print the homelab dependency map.
|
map [--dot] Print the homelab dependency map.
|
||||||
|
|
@ -6692,6 +6697,9 @@ case "${1:-}" in
|
||||||
incident)
|
incident)
|
||||||
incident_commander "$@"
|
incident_commander "$@"
|
||||||
;;
|
;;
|
||||||
|
safety-case)
|
||||||
|
safety_case "$@"
|
||||||
|
;;
|
||||||
scorecard)
|
scorecard)
|
||||||
scorecard
|
scorecard
|
||||||
;;
|
;;
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ EOF
|
||||||
|
|
||||||
is_read_only_command() {
|
is_read_only_command() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
status|capacity|capacity-advisor|capacity-limits|recover-plan|incident|scorecard|gitops-status|cert-check|backup-status|synthetic-checks|resource-budget|route-inventory|golden-ledger|workers|change-journal|map|validate|access-audit|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|preflight|doctor-preapply|inventory-check|secrets-check|tailnet-policy-check|ai-check|ask|ai-evals|impact|review-last-change|security-scan|security-host|security-trivy|security-secrets|security-nuclei|security-web|security-logs|security-attack-path|prompt-injection-lab|control-plane)
|
status|capacity|capacity-advisor|capacity-limits|recover-plan|incident|safety-case|scorecard|gitops-status|cert-check|backup-status|synthetic-checks|resource-budget|route-inventory|golden-ledger|workers|change-journal|map|validate|access-audit|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|preflight|doctor-preapply|inventory-check|secrets-check|tailnet-policy-check|ai-check|ask|ai-evals|impact|review-last-change|security-scan|security-host|security-trivy|security-secrets|security-nuclei|security-web|security-logs|security-attack-path|prompt-injection-lab|control-plane)
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
#!/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())
|
||||||
Loading…
Reference in New Issue