GuestOps/deploy/incident_exercise.py
wolf-demon 4dd33c90e1
Some checks are pending
Build and verify web migration / verify (push) Waiting to run
milestone 19 &20
2026-09-29 20:50:40 +01:00

133 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Validate a restricted GuestOps incident and rollback exercise record."""
from __future__ import annotations
import argparse
import datetime as dt
import json
from pathlib import Path
import re
from urllib.parse import urlparse
GATE_B_SCENARIOS = {
"alert-and-escalate",
"disable-worker-writes",
"google-uncertain-send",
"restore-readiness",
"image-rollback",
"controlled-recovery",
}
GATE_C_SCENARIOS = GATE_B_SCENARIOS | {
"pms-ambiguous-write",
"payment-ambiguous-create",
}
def require(condition: bool, message: str) -> None:
if not condition:
raise ValueError(message)
def utc_timestamp(value: object, field: str) -> dt.datetime:
require(isinstance(value, str) and value.endswith("Z"), f"{field} must be a UTC timestamp ending in Z.")
try:
parsed = dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
except ValueError as error:
raise ValueError(f"{field} is not a valid timestamp.") from error
require(parsed.tzinfo == dt.timezone.utc, f"{field} must be UTC.")
return parsed
def safe_name(value: object, field: str) -> str:
name = str(value or "").strip()
require(2 <= len(name) <= 120 and "@" not in name, f"{field} requires a name without an email address.")
return name
def validate(record: object) -> None:
require(isinstance(record, dict), "Exercise record must be a JSON object.")
require(record.get("schemaVersion") == 1, "Unsupported exercise record schema.")
require(record.get("system") == "guestops-incident-exercise",
"Exercise record system must be guestops-incident-exercise.")
gate = record.get("targetGate")
require(gate in ("B", "C"), "targetGate must be B or C.")
require(record.get("dataClassification") == "synthetic-only",
"Incident exercises must use synthetic data only.")
require(re.fullmatch(r"[0-9a-f]{40}", str(record.get("releaseCommit", ""))) is not None,
"releaseCommit must be a full lowercase Git SHA.")
require(re.fullmatch(r"[0-9a-f]{64}", str(record.get("releaseRecordSha256", ""))) is not None,
"releaseRecordSha256 must be a SHA-256 digest.")
environment = str(record.get("environment", ""))
parsed_url = urlparse(environment)
require(parsed_url.scheme == "https" and parsed_url.hostname and parsed_url.path in ("", "/")
and not parsed_url.query and not parsed_url.fragment and parsed_url.username is None
and parsed_url.password is None,
"environment must be an HTTPS origin without credentials, path, query or fragment.")
operator = safe_name(record.get("operator"), "operator")
commander = safe_name(record.get("incidentCommander"), "incidentCommander")
reviewer = safe_name(record.get("reviewedBy"), "reviewedBy")
require(len({operator.casefold(), commander.casefold(), reviewer.casefold()}) == 3,
"operator, incidentCommander and reviewedBy must be different people.")
started = utc_timestamp(record.get("startedAt"), "startedAt")
ended = utc_timestamp(record.get("endedAt"), "endedAt")
reviewed = utc_timestamp(record.get("reviewedAt"), "reviewedAt")
require(started <= ended <= reviewed, "Exercise timestamps are out of order.")
targets = record.get("targetsMinutes")
observed = record.get("observedMinutes")
require(isinstance(targets, dict) and isinstance(observed, dict),
"targetsMinutes and observedMinutes are required.")
metric_keys = {"detection", "containment", "recovery"}
require(set(targets) == metric_keys and set(observed) == metric_keys,
"Timing records require exactly detection, containment and recovery.")
for metric in sorted(metric_keys):
target = targets[metric]
actual = observed[metric]
require(isinstance(target, (int, float)) and not isinstance(target, bool) and 0 < target <= 1440,
f"{metric} target must be greater than zero and no more than 1440 minutes.")
require(isinstance(actual, (int, float)) and not isinstance(actual, bool) and 0 <= actual <= target,
f"Observed {metric} time must meet its pre-agreed target.")
scenarios = record.get("scenarios")
require(isinstance(scenarios, list), "scenarios must be a list.")
ids = [item.get("id") for item in scenarios if isinstance(item, dict)]
required = GATE_C_SCENARIOS if gate == "C" else GATE_B_SCENARIOS
require(len(ids) == len(scenarios) and len(ids) == len(set(ids)) and set(ids) == required,
f"Gate {gate} requires the exact incident scenario set.")
for item in scenarios:
scenario_id = item["id"]
require(item.get("status") == "pass", f"Scenario {scenario_id} has not passed.")
evidence = item.get("evidence")
require(isinstance(evidence, list) and 1 <= len(evidence) <= 10 and all(
isinstance(value, str) and 3 <= len(value) <= 200 and "@" not in value
for value in evidence
), f"Scenario {scenario_id} requires safe opaque evidence references without email addresses.")
final_state = record.get("postExerciseState")
require(isinstance(final_state, dict) and final_state == {
"externalWrites": "disabled",
"faqMode": "off",
"unresolvedOperations": 0,
}, "Exercise must end with writes disabled, FAQ mode off and no unresolved operations.")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("record", type=Path)
args = parser.parse_args()
validate(json.loads(args.record.read_text(encoding="utf-8")))
print("Incident exercise record is structurally complete and passed. This validates the record, not its restricted evidence.")
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"Incident exercise record rejected: {error}", file=__import__("sys").stderr)
raise SystemExit(1)