Files
awoooi/scripts/security/backup-restore-post-incident-readback-plan.py
Your Name 1b9d44cfa7
All checks were successful
Code Review / ai-code-review (push) Successful in 12s
CD Pipeline / tests (push) Successful in 1m39s
CD Pipeline / build-and-deploy (push) Successful in 4m8s
CD Pipeline / post-deploy-checks (push) Successful in 3m59s
feat(iwooos): 新增備份復原事故回讀 gate
2026-06-18 09:11:39 +08:00

543 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
IwoooS Backup / Restore / Escrow post-incident readback 只讀計畫產生器。
本工具讀取 Backup / Restore / Escrow owner response acceptance snapshot
建立事故後回讀計畫:誰觸發或觀察到 backup / restore / offsite / escrow
異常、改前改後 freshness / restore / offsite / retention 狀態、是否有
no-false-green 與 rollback / stop condition。它不執行 backup、不 restore、
不 rclone / restic / Velero、不寫 escrow marker、不 SSH、不 kubectl、不讀
secret value、不保存 raw backup listing、不寫 production。
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
TAIPEI = timezone(timedelta(hours=8))
READBACK_FIELDS = [
"post_incident_readback_candidate_id",
"source_acceptance_candidate_id",
"request_id",
"surface_id",
"config_kind",
"backup_scope",
"control_tier",
"write_capable_surface",
"requires_live_evidence",
"backup_incident_or_change_ref",
"actor_attribution_ref",
"change_or_outage_time_window_ref",
"change_intent_or_break_glass_ref",
"before_backup_freshness_state_ref",
"after_backup_freshness_state_ref",
"backup_status_readback_ref",
"restore_drill_readback_ref",
"restore_target_isolation_readback_ref",
"offsite_sync_readback_ref",
"offsite_remote_delete_guard_readback_ref",
"credential_escrow_non_secret_readback_ref",
"credential_recovery_drill_readback_ref",
"retention_runway_readback_ref",
"retention_or_prune_decision_ref",
"backup_dependency_map_readback_ref",
"data_classification_readback_ref",
"restore_observer_stop_condition_ref",
"backup_health_no_false_green_readback_ref",
"alert_textfile_readback_ref",
"cold_start_dr_scorecard_ref",
"cross_project_sync_ref",
"rollback_validation_ref",
"post_change_monitoring_ref",
"postcheck_readback_ref",
"recurrence_guard_ref",
"maintenance_window",
"rollback_owner",
"followup_owner",
"redacted_evidence_refs",
"reviewer_outcome",
"no_secret_value_attestation",
"no_raw_backup_payload_attestation",
"no_production_restore_attestation",
"no_false_green_attestation",
"not_approval",
]
REQUIRED_READBACK_FIELDS = [
"backup_incident_or_change_ref",
"actor_attribution_ref",
"change_or_outage_time_window_ref",
"change_intent_or_break_glass_ref",
"before_backup_freshness_state_ref",
"after_backup_freshness_state_ref",
"backup_status_readback_ref",
"restore_drill_readback_ref",
"restore_target_isolation_readback_ref",
"offsite_sync_readback_ref",
"offsite_remote_delete_guard_readback_ref",
"credential_escrow_non_secret_readback_ref",
"credential_recovery_drill_readback_ref",
"retention_runway_readback_ref",
"retention_or_prune_decision_ref",
"backup_dependency_map_readback_ref",
"data_classification_readback_ref",
"restore_observer_stop_condition_ref",
"backup_health_no_false_green_readback_ref",
"alert_textfile_readback_ref",
"cold_start_dr_scorecard_ref",
"cross_project_sync_ref",
"rollback_validation_ref",
"post_change_monitoring_ref",
"postcheck_readback_ref",
"recurrence_guard_ref",
"maintenance_window",
"rollback_owner",
"followup_owner",
"redacted_evidence_refs",
"no_secret_value_attestation",
"no_raw_backup_payload_attestation",
"no_production_restore_attestation",
"no_false_green_attestation",
]
REVIEWER_CHECKS = [
{"check_id": "source_owner_response_acceptance_current", "instruction": "來源 owner response acceptance snapshot 必須是目前版本。"},
{"check_id": "incident_or_change_ref_present", "instruction": "必須有 incident、change、outage、ticket 或 maintenance ref不能只寫備份正常。"},
{"check_id": "actor_attribution_present", "instruction": "必須標示 actor role / team不接受匿名 backup、restore、sync、prune 或 escrow marker write。"},
{"check_id": "change_or_outage_time_window_present", "instruction": "必須有變更 / 異常時間窗,供 freshness、offsite、alert 與 cold-start scorecard 對齊。"},
{"check_id": "intent_or_break_glass_present", "instruction": "正常變更需有 change intent緊急變更需有 break-glass reason但 break-glass 不等於事前批准。"},
{"check_id": "before_after_freshness_present", "instruction": "必須有 before / after backup freshness state ref不得只看最新一次成功。"},
{"check_id": "backup_status_readback_present", "instruction": "backup status 只能收 owner-provided redacted ref不得讀 raw backup store 或未脫敏 listing。"},
{"check_id": "restore_drill_readback_present", "instruction": "restore drill 必須是 readback / approval metadata不得是 production restore 執行要求。"},
{"check_id": "restore_target_isolation_present", "instruction": "必須有隔離 restore target 或明確 no-production-write 邊界。"},
{"check_id": "offsite_sync_readback_present", "instruction": "offsite sync 必須有 redacted state ref不得保存 raw remote path、object listing 或 rclone config。"},
{"check_id": "remote_delete_guard_present", "instruction": "任何 latest-only、remote delete 或 retention policy 必須有 remote delete guard 與 owner ref。"},
{"check_id": "credential_escrow_non_secret_present", "instruction": "credential escrow 只能是 non-secret proof / marker ref不得包含 value、hash、seed、recovery code 或 partial token。"},
{"check_id": "credential_recovery_drill_metadata_only", "instruction": "credential recovery drill 必須以 metadata / evidence id 呈現,不保存 secret derivative。"},
{"check_id": "retention_runway_present", "instruction": "retention / prune 必須有可恢復窗口、runway、撤回條件與 owner。"},
{"check_id": "retention_or_prune_decision_present", "instruction": "retention 或 prune decision 必須獨立列出,不得與 offsite sync 成功混在一起。"},
{"check_id": "dependency_map_present", "instruction": "必須列出 DB、repo、registry、object storage、K8s、monitoring、credential escrow 與 DR runbook 依賴圖 ref。"},
{"check_id": "data_classification_present", "instruction": "必須標示備份集資料分級;不得要求 raw customer data、payload 或 unredacted archive listing。"},
{"check_id": "restore_observer_stop_condition_present", "instruction": "restore / drill 必須有 observer、stop condition、rollback owner 與停損條件。"},
{"check_id": "backup_health_no_false_green_present", "instruction": "backup health / textfile / alert evidence 必須防止 false green不能只看單一 healthy、route 200 或 dashboard up。"},
{"check_id": "alert_textfile_readback_present", "instruction": "若涉及告警或 textfile exporter必須回讀 stale / partial / missing marker不得只看 alert quiet。"},
{"check_id": "cold_start_scorecard_present", "instruction": "若影響冷啟動或 DR必須有 cold-start / scorecard ref且不能偽造 credential escrow evidence。"},
{"check_id": "cross_project_sync_present", "instruction": "若影響 AwoooP、IwoooS、momo、Gitea、Harbor、monitoring 或公開站,需有跨專案同步 ref。"},
{"check_id": "rollback_validation_present", "instruction": "必須提供 rollback validation ref包含 rollback owner 與回復方式。"},
{"check_id": "post_change_monitoring_present", "instruction": "必須有 post-change monitoring window觀察 freshness、offsite、restore target、alerts 與 scorecard。"},
{"check_id": "postcheck_independent", "instruction": "post-check 必須獨立於原操作人、單一 backup status 與 UI 顯示。"},
{"check_id": "recurrence_guard_present", "instruction": "必須提出防再發 guard、owner review、retention freeze、remote-delete block 或 automation block。"},
{"check_id": "maintenance_window_present", "instruction": "後續任何 backup、restore、offsite sync、prune、escrow marker 或 Velero 動作都需維護窗口。"},
{"check_id": "redacted_refs_only", "instruction": "evidence 只能是脫敏 ref、hash、ticket、commit 或 artifact pointer。"},
{"check_id": "secret_value_absent", "instruction": "不得出現 token、private key、rclone config、restic password、seed、kubeconfig、recovery code、credential URL 或 partial secret。"},
{"check_id": "raw_payload_absent", "instruction": "不得保存 raw backup listing、raw restore payload、raw archive content、raw object path、raw DB dump 或未脫敏 screenshot。"},
{"check_id": "no_false_green", "instruction": "不得只用 backup success、route 200、dashboard up、alert quiet、textfile present、UI 可見或 CD success 當 DR / backup 驗收。"},
{"check_id": "runtime_stays_zero", "instruction": "readback plan 不得觸發 backup、restore、offsite sync、remote delete、retention change、prune、Velero、kubectl、SSH 或 production write。"},
]
OUTCOME_LANES = [
{"lane_id": "waiting_post_incident_readback", "meaning": "尚未收到 Backup / Restore / Escrow 事故回讀包;所有 accepted / runtime count 維持 0。"},
{"lane_id": "request_actor_or_time_supplement", "meaning": "缺 actor、change / outage time window、intent 或 break-glass reason 時要求補件。"},
{"lane_id": "request_backup_freshness_supplement", "meaning": "缺 before / after freshness、backup status、alert textfile 或 scorecard 時要求補件。"},
{"lane_id": "request_restore_isolation_supplement", "meaning": "缺 restore drill、隔離目標、observer、stop condition 或 rollback validation 時要求補件。"},
{"lane_id": "request_offsite_retention_supplement", "meaning": "缺 offsite sync、remote delete guard、retention runway 或 prune decision 時要求補件。"},
{"lane_id": "request_escrow_non_secret_supplement", "meaning": "缺 credential escrow non-secret proof 或 recovery drill metadata 時要求補件。"},
{"lane_id": "quarantine_raw_payload", "meaning": "收到 secret、raw backup listing、raw restore payload、raw DB dump、rclone config 或未脫敏截圖時只能隔離。"},
{"lane_id": "reject_false_green_claim", "meaning": "把 backup success、route 200、dashboard up、alert quiet、textfile present 或 UI 可見當驗收時拒收。"},
{"lane_id": "ready_for_backup_restore_post_incident_review", "meaning": "metadata 合格後,只能進 backup / restore reviewer review。"},
{"lane_id": "recurrence_guard_backfill_required", "meaning": "需補防再發 guard、retention freeze、remote-delete block、owner review 或 automation block。"},
{"lane_id": "waiting_runtime_gate", "meaning": "即使 readback acceptedruntime gate 仍需獨立人工批准。"},
]
BLOCKED_ACTIONS = [
"backup_run",
"restore_run",
"restore_drill",
"production_restore",
"offsite_sync",
"offsite_remote_delete",
"credential_escrow_marker_write",
"credential_recovery_execution",
"retention_change",
"retention_prune",
"restic_prune",
"rclone_config_read",
"rclone_config_change",
"velero_restore",
"velero_backup",
"kubectl_action",
"ssh_read",
"ssh_write",
"secret_value_collection",
"secret_hash_collection",
"partial_token_collection",
"restic_password_collection",
"rclone_token_collection",
"kubeconfig_collection",
"host_write",
"active_scan",
"production_write",
"runtime_gate_open",
"raw_backup_payload_storage",
"raw_restore_payload_storage",
"raw_object_listing_storage",
"raw_db_dump_storage",
"accept_secret_value_evidence",
"accept_credential_derivative_evidence",
"mark_readback_accepted_without_reviewer_record",
"accept_backup_success_without_freshness_slo",
"accept_restore_without_isolated_target",
"accept_offsite_without_remote_delete_guard",
"accept_retention_without_runway",
"accept_credential_recovery_without_non_secret_proof",
"accept_backup_health_false_green",
"accept_alert_quiet_as_backup_healthy",
"accept_textfile_present_as_dr_complete",
"skip_dependency_map_review",
"skip_data_classification_review",
"skip_restore_observer_stop_condition",
"skip_cross_project_sync",
"skip_rollback_validation",
"skip_post_change_monitoring",
"forge_credential_escrow_evidence",
"add_action_button",
]
def git_short_sha(root: Path) -> str:
try:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=root,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
except Exception:
return "unknown"
def load_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def readback_candidate(item: dict[str, Any]) -> dict[str, Any]:
surface_id = item["surface_id"]
candidate_id = f"backup_restore_post_incident_readback:{surface_id}"
return {
"post_incident_readback_candidate_id": candidate_id,
"status": "waiting_post_incident_readback",
"source_acceptance_candidate_id": item["acceptance_candidate_id"],
"request_id": item["request_id"],
"surface_id": surface_id,
"config_kind": item["config_kind"],
"backup_scope": item["backup_scope"],
"control_tier": item["control_tier"],
"write_capable_surface": item["write_capable_surface"],
"requires_live_evidence": True,
"backup_incident_or_change_ref": None,
"actor_attribution_ref": None,
"change_or_outage_time_window_ref": None,
"change_intent_or_break_glass_ref": None,
"before_backup_freshness_state_ref": None,
"after_backup_freshness_state_ref": None,
"backup_status_readback_ref": None,
"restore_drill_readback_ref": None,
"restore_target_isolation_readback_ref": None,
"offsite_sync_readback_ref": None,
"offsite_remote_delete_guard_readback_ref": None,
"credential_escrow_non_secret_readback_ref": None,
"credential_recovery_drill_readback_ref": None,
"retention_runway_readback_ref": None,
"retention_or_prune_decision_ref": None,
"backup_dependency_map_readback_ref": None,
"data_classification_readback_ref": None,
"restore_observer_stop_condition_ref": None,
"backup_health_no_false_green_readback_ref": None,
"alert_textfile_readback_ref": None,
"cold_start_dr_scorecard_ref": None,
"cross_project_sync_ref": None,
"rollback_validation_ref": None,
"post_change_monitoring_ref": None,
"postcheck_readback_ref": None,
"recurrence_guard_ref": None,
"maintenance_window": "pending_post_incident_readback",
"rollback_owner": "pending_post_incident_readback",
"followup_owner": "pending_post_incident_readback",
"redacted_evidence_refs": [],
"reviewer_outcome": "waiting_post_incident_readback",
"readback_fields": READBACK_FIELDS,
"required_readback_fields": REQUIRED_READBACK_FIELDS,
"reviewer_checks": [entry["check_id"] for entry in REVIEWER_CHECKS],
"outcome_lanes": [entry["lane_id"] for entry in OUTCOME_LANES],
"blocked_actions": BLOCKED_ACTIONS,
"not_approval": True,
"post_incident_readback_received": False,
"post_incident_readback_accepted": False,
"actor_attribution_accepted": False,
"before_after_freshness_accepted": False,
"backup_status_readback_accepted": False,
"restore_drill_readback_accepted": False,
"restore_target_isolation_readback_accepted": False,
"offsite_sync_readback_accepted": False,
"offsite_remote_delete_guard_accepted": False,
"credential_escrow_non_secret_readback_accepted": False,
"credential_recovery_drill_readback_accepted": False,
"retention_runway_readback_accepted": False,
"retention_or_prune_decision_accepted": False,
"backup_dependency_map_readback_accepted": False,
"data_classification_readback_accepted": False,
"restore_observer_stop_condition_accepted": False,
"backup_health_no_false_green_readback_accepted": False,
"alert_textfile_readback_accepted": False,
"cold_start_dr_scorecard_accepted": False,
"cross_project_sync_accepted": False,
"rollback_validation_accepted": False,
"post_change_monitoring_accepted": False,
"postcheck_readback_accepted": False,
"recurrence_guard_accepted": False,
"no_false_green_accepted": False,
"backup_run_authorized": False,
"restore_run_authorized": False,
"offsite_sync_authorized": False,
"offsite_remote_delete_authorized": False,
"credential_escrow_marker_write_authorized": False,
"credential_recovery_execution_authorized": False,
"retention_change_authorized": False,
"restic_prune_authorized": False,
"rclone_config_authorized": False,
"velero_restore_authorized": False,
"velero_backup_authorized": False,
"kubectl_action_authorized": False,
"ssh_read_authorized": False,
"ssh_write_authorized": False,
"secret_value_collection_allowed": False,
"host_write_authorized": False,
"active_scan_authorized": False,
"production_write_authorized": False,
"runtime_gate": False,
"action_buttons_allowed": False,
}
def searchable_candidate_text(item: dict[str, Any]) -> str:
scopes = item.get("backup_scope", [])
return " ".join(
[
str(item.get("surface_id", "")),
str(item.get("config_kind", "")),
" ".join(str(scope) for scope in scopes),
]
).lower()
def candidate_has_any(item: dict[str, Any], keywords: set[str]) -> bool:
text = searchable_candidate_text(item)
return any(keyword in text for keyword in keywords)
def build_report(root: Path, source: dict[str, Any], generated_at: str | None) -> dict[str, Any]:
report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds")
source_candidates = source.get("acceptance_candidates", [])
candidates = [readback_candidate(item) for item in source_candidates]
def count_true(key: str) -> int:
return sum(1 for item in candidates if item[key])
summary = {
"source_acceptance_candidate_count": source["summary"]["acceptance_candidate_count"],
"source_write_capable_acceptance_candidate_count": source["summary"]["write_capable_acceptance_candidate_count"],
"source_live_evidence_required_candidate_count": source["summary"]["live_evidence_required_candidate_count"],
"source_required_owner_field_count": source["summary"]["required_owner_field_count"],
"source_reviewer_check_count": source["summary"]["reviewer_check_count"],
"source_blocked_action_count": source["summary"]["blocked_action_count"],
"source_owner_response_accepted_count": source["summary"]["owner_response_accepted_count"],
"source_runtime_gate_count": source["summary"]["runtime_gate_count"],
"readback_candidate_count": len(candidates),
"write_capable_readback_candidate_count": count_true("write_capable_surface"),
"live_evidence_required_readback_candidate_count": count_true("requires_live_evidence"),
"restore_drill_readback_required_candidate_count": len(candidates),
"offsite_or_escrow_readback_required_candidate_count": sum(
1
for item in candidates
if candidate_has_any(
item,
{
"offsite",
"escrow",
"credential",
"rclone",
"b2",
"velero",
"object storage",
},
)
),
"retention_or_remote_delete_readback_required_candidate_count": sum(
1
for item in candidates
if candidate_has_any(
item,
{
"retention",
"prune",
"remote_delete",
"latest-only",
"restic",
"offsite",
},
)
),
"cross_project_sync_required_candidate_count": len(candidates),
"no_false_green_required_candidate_count": len(candidates),
"readback_field_count": len(READBACK_FIELDS),
"required_readback_field_count": len(REQUIRED_READBACK_FIELDS),
"reviewer_check_count": len(REVIEWER_CHECKS),
"outcome_lane_count": len(OUTCOME_LANES),
"blocked_action_count": len(BLOCKED_ACTIONS),
"post_incident_readback_received_count": 0,
"post_incident_readback_accepted_count": 0,
"actor_attribution_accepted_count": 0,
"before_after_freshness_accepted_count": 0,
"backup_status_readback_accepted_count": 0,
"restore_drill_readback_accepted_count": 0,
"restore_target_isolation_readback_accepted_count": 0,
"offsite_sync_readback_accepted_count": 0,
"offsite_remote_delete_guard_accepted_count": 0,
"credential_escrow_non_secret_readback_accepted_count": 0,
"credential_recovery_drill_readback_accepted_count": 0,
"retention_runway_readback_accepted_count": 0,
"retention_or_prune_decision_accepted_count": 0,
"backup_dependency_map_readback_accepted_count": 0,
"data_classification_readback_accepted_count": 0,
"restore_observer_stop_condition_accepted_count": 0,
"backup_health_no_false_green_readback_accepted_count": 0,
"alert_textfile_readback_accepted_count": 0,
"cold_start_dr_scorecard_accepted_count": 0,
"cross_project_sync_accepted_count": 0,
"rollback_validation_accepted_count": 0,
"post_change_monitoring_accepted_count": 0,
"postcheck_readback_accepted_count": 0,
"recurrence_guard_accepted_count": 0,
"no_false_green_accepted_count": 0,
"backup_run_authorized_count": 0,
"restore_run_authorized_count": 0,
"offsite_sync_authorized_count": 0,
"offsite_remote_delete_authorized_count": 0,
"credential_escrow_marker_write_authorized_count": 0,
"credential_recovery_execution_authorized_count": 0,
"retention_change_authorized_count": 0,
"restic_prune_authorized_count": 0,
"rclone_config_authorized_count": 0,
"velero_restore_authorized_count": 0,
"velero_backup_authorized_count": 0,
"kubectl_action_authorized_count": 0,
"ssh_read_authorized_count": 0,
"ssh_write_authorized_count": 0,
"secret_value_collection_allowed_count": 0,
"host_write_authorized_count": 0,
"active_scan_authorized_count": 0,
"production_write_authorized_count": 0,
"runtime_gate_count": 0,
"action_button_count": 0,
"coverage_percent_after_readback_plan": 66,
}
return {
"schema_version": "backup_restore_post_incident_readback_plan_v1",
"generated_at": report_time,
"git_commit": git_short_sha(root),
"mode": "metadata_only_no_backup_no_restore_no_secret_value",
"source_schema_version": source["schema_version"],
"source_status": source["status"],
"source_paths": [
"docs/security/BACKUP-RESTORE-ESCROW-INVENTORY.md",
"docs/security/backup-restore-escrow-inventory.snapshot.json",
"docs/security/BACKUP-RESTORE-OWNER-RESPONSE-ACCEPTANCE.md",
"docs/security/backup-restore-owner-response-acceptance.snapshot.json",
],
"status": "post_incident_readback_plan_ready_no_runtime_action",
"readback_candidates": candidates,
"readback_fields": READBACK_FIELDS,
"required_readback_fields": REQUIRED_READBACK_FIELDS,
"reviewer_checks": REVIEWER_CHECKS,
"outcome_lanes": OUTCOME_LANES,
"blocked_actions": BLOCKED_ACTIONS,
"execution_boundaries": {
"not_authorization": True,
"backup_run_authorized": False,
"restore_run_authorized": False,
"offsite_sync_authorized": False,
"offsite_remote_delete_authorized": False,
"credential_escrow_marker_write_authorized": False,
"credential_recovery_execution_authorized": False,
"retention_change_authorized": False,
"restic_prune_authorized": False,
"rclone_config_authorized": False,
"velero_restore_authorized": False,
"velero_backup_authorized": False,
"kubectl_action_authorized": False,
"ssh_read_authorized": False,
"ssh_write_authorized": False,
"secret_value_collection_allowed": False,
"host_write_authorized": False,
"active_scan_authorized": False,
"production_write_authorized": False,
"runtime_gate": False,
"action_buttons_allowed": False,
"raw_backup_payload_storage_allowed": False,
},
"operator_interpretation": [
"此計畫只定義 Backup / Restore / Escrow 事故後回讀欄位,不代表 live backup store 已讀取或可讀取。",
"backup success、route 200、dashboard up、alert quiet、textfile present、UI 可見或 CD success 都不能單獨當成 DR / backup 健康。",
"未來若要 backup、restore、offsite sync、remote delete、retention / prune、escrow marker、Velero、kubectl、SSH 或 production write必須另有維護窗口、rollback owner 與人工批准。",
],
"summary": summary,
}
def write_report(report: dict[str, Any], output: Path | None) -> None:
text = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
if output:
output.write_text(text, encoding="utf-8")
else:
sys.stdout.write(text)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--root", default=".", help="Repository root")
parser.add_argument("--generated-at", default=None, help="Override generated_at")
parser.add_argument("--output", default=None, help="Output snapshot path")
args = parser.parse_args()
root = Path(args.root).resolve()
source = load_json(root / "docs/security/backup-restore-owner-response-acceptance.snapshot.json")
report = build_report(root, source, args.generated_at)
output = Path(args.output).resolve() if args.output else None
write_report(report, output)
summary = report["summary"]
print(
"BACKUP_RESTORE_POST_INCIDENT_READBACK_PLAN_OK "
f"candidates={summary['readback_candidate_count']} "
f"write_capable={summary['write_capable_readback_candidate_count']} "
f"checks={summary['reviewer_check_count']} "
f"lanes={summary['outcome_lane_count']} "
f"accepted={summary['post_incident_readback_accepted_count']} "
f"runtime_gate={summary['runtime_gate_count']}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())