From 4defe99bd8a97ecf9e8947a6ed969340495d65c5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 29 Jun 2026 14:00:47 +0800 Subject: [PATCH] feat(recovery): add p0 dr escrow checklist --- docs/LOGBOOK.md | 15 ++ ...priority-work-order-readback.snapshot.json | 30 ++-- .../dr-escrow-evidence-checklist.py | 170 ++++++++++++++++++ .../test_dr_escrow_evidence_checklist.py | 75 ++++++++ 4 files changed, 277 insertions(+), 13 deletions(-) create mode 100644 scripts/reboot-recovery/dr-escrow-evidence-checklist.py create mode 100644 scripts/reboot-recovery/tests/test_dr_escrow_evidence_checklist.py diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index f6827202f..641608395 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -49349,3 +49349,18 @@ production browser smoke: - 沒有讀、複製、貼上、外送 runner token / secret / `.env` / raw sessions / SQLite / auth。 - 沒有啟動 188 runner service、沒有重開 110 runner、沒有 GitHub API / gh / GitHub Actions、沒有 force push。 - Gitea CD 仍需等 non-110 runner 完成外部安全註冊並讀回 `AWOOOI_NON110_RUNNER_READY=1` 後才可承接。 + +## 2026-06-29 — 13:56 P0-005 DR escrow evidence checklist single intake + +**完成內容**: +- 新增 `scripts/reboot-recovery/dr-escrow-evidence-checklist.py`,把 P0-005 五個缺口收斂成單一 no-secret checklist:`restic_repository_password`、`offsite_provider_credentials`、`break_glass_admin_credentials`、`dns_registrar_recovery`、`oauth_ai_provider_recovery`。 +- checklist 同時輸出 owner packet、owner response skeleton、單一 preflight command、scorecard command 與 exit criteria;所有 runtime / host write / secret collection / credential marker write 授權欄位預設維持 `false`。 +- 更新 `docs/operations/awoooi-priority-work-order-readback.snapshot.json`,把 P0-005 safe next step 從「建立 checklist」推進為「填五個非秘密 evidence refs 後跑單一 preflight」,並修正 exit criteria 為 single response `owner_response_accepted_count=1`。 + +**驗證目標**: +- `python3.11 -m pytest scripts/reboot-recovery/tests/test_dr_escrow_evidence_checklist.py -q` +- `python3 scripts/reboot-recovery/dr-escrow-evidence-checklist.py --output /tmp/awoooi-dr-escrow-evidence-checklist-20260629.json` +- `jq empty /tmp/awoooi-dr-escrow-evidence-checklist-20260629.json` + +**未做**: +- 沒有讀 secret、token、`.env`、raw sessions / SQLite / auth;沒有寫 credential marker;沒有 host / Docker / Nginx / firewall / K3s / DB 操作;沒有使用 GitHub。 diff --git a/docs/operations/awoooi-priority-work-order-readback.snapshot.json b/docs/operations/awoooi-priority-work-order-readback.snapshot.json index 824d2b2c8..cda956f09 100644 --- a/docs/operations/awoooi-priority-work-order-readback.snapshot.json +++ b/docs/operations/awoooi-priority-work-order-readback.snapshot.json @@ -1,7 +1,7 @@ { "schema_version": "awoooi_priority_work_order_readback_v1", - "generated_at": "2026-06-29T13:28:25+08:00", - "status": "integrated_p0_register_next_blocker_credential_escrow", + "generated_at": "2026-06-29T14:00:19+08:00", + "status": "p0_005_dr_escrow_checklist_ready_waiting_redacted_refs", "source_refs": { "global_scorecard": "~/.codex/product-runtime-governance-completion-scorecard.snapshot.json", "workstation_dashboard": "~/.codex/codex-workstation-sync-dashboard.snapshot.json", @@ -9,12 +9,13 @@ "full_stack_cold_start_check": "scripts/reboot-recovery/full-stack-cold-start-check.sh --monitor-read-only --no-color", "delivery_closure_workbench": "https://awoooi.wooo.work/api/v1/agents/delivery-closure-workbench", "public_gitea_queue_readback": "ops/runner/read-public-gitea-actions-queue.py --json", - "credential_escrow_scorecard": "/tmp/awoooi-credential-escrow-intake-scorecard-20260629-1200-priority.json" + "credential_escrow_scorecard": "/tmp/awoooi-credential-escrow-intake-scorecard-20260629-1200-priority.json", + "dr_escrow_evidence_checklist_generator": "scripts/reboot-recovery/dr-escrow-evidence-checklist.py" }, "current_head": { - "gitea_main_sha": "780587810c8b241ae60a153bdea1e557ba06218f", - "latest_successful_deploy_marker": "780587810 chore(cd): deploy a19b659 [skip ci]", - "latest_successful_deployed_source_sha": "a19b659f07c8f212a0514bf4af1affe657fa0fe1", + "gitea_main_sha": "bd55386e6edc46ce0b188011b171191b7773c5ba", + "latest_successful_deploy_marker": "9362588ce chore(cd): deploy a423301 [skip ci]", + "latest_successful_deployed_source_sha": "a4233017ad5fd03977233f3db6a4bb45d71507ed", "latest_source_readiness_commit_sha": "0c8d4e88c39157b92322fa41a92e6b15c317ac49", "latest_source_readiness_cd_run_id": "3882", "latest_source_readiness_cd_run_status": "Success", @@ -102,8 +103,8 @@ { "workplan_id": "P0-005", "title": "產品資料與備份 contract", - "status": "blocked_waiting_non_secret_credential_escrow_evidence", - "reason": "Backup core and product freshness are green, but DR completion still requires five non-secret credential escrow evidence markers.", + "status": "checklist_ready_waiting_non_secret_credential_escrow_evidence", + "reason": "Backup core and product freshness are green, and the P0-005 lane now has one no-secret checklist packet for the five remaining credential escrow evidence markers.", "evidence": { "product_data_green": true, "stock_freshness_status": "ok", @@ -115,7 +116,9 @@ "rclone_configured": true, "script_missing_count": 0, "credential_marker_write_authorized_count": 0, - "secret_value_collection_allowed": false + "secret_value_collection_allowed": false, + "checklist_generator_present": true, + "checklist_schema_version": "awoooi_dr_escrow_evidence_checklist_v1" }, "missing_items": [ "restic_repository_password", @@ -126,10 +129,11 @@ ], "professional_fix": { "owner": "DR evidence lane", - "action": "Create one DR escrow checklist packet with the five required item ids, accept only redacted evidence refs, then run the existing preflight once.", + "action": "Use the single DR escrow checklist packet with the five required item ids, accept only redacted evidence refs, then run the existing preflight once.", "exit_criteria": [ "effective_escrow_missing_count=0", - "owner_response_accepted_count=5", + "owner_response_accepted_count=1", + "forbidden_true_field_count=0", "secret_value_collection_allowed=false", "post_reboot_readiness OVERALL_DECLARATION no longer includes DR_ESCROW_BLOCKED" ], @@ -138,7 +142,7 @@ "Do not create additional owner-package variants for the same five refs." ] }, - "safe_next_step": "create_single_dr_escrow_evidence_checklist_then_rerun_preflight" + "safe_next_step": "fill_single_dr_escrow_evidence_checklist_with_five_non_secret_refs_then_run_one_preflight" }, { "workplan_id": "P0-003", @@ -240,7 +244,7 @@ "database_write_or_restore_performed": false }, "next_execution_order": [ - "P0-005: create a single DR escrow evidence checklist for the five missing refs and rerun one preflight.", + "P0-005: fill the single DR escrow evidence checklist with five non-secret refs and rerun one preflight.", "P0-003: convert private/internal inventory to Gitea-only readback and remove retired GitHub from active P0 blocker math.", "P0-006: run source-to-runtime drift cleanup using product manifest, committed runtime sources, production readback, and public route evidence." ] diff --git a/scripts/reboot-recovery/dr-escrow-evidence-checklist.py b/scripts/reboot-recovery/dr-escrow-evidence-checklist.py new file mode 100644 index 000000000..e70e88420 --- /dev/null +++ b/scripts/reboot-recovery/dr-escrow-evidence-checklist.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Build the single P0-005 DR escrow evidence checklist.""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime +from pathlib import Path +from typing import Any + + +SCHEMA_VERSION = "awoooi_dr_escrow_evidence_checklist_v1" +OWNER_PACKET_SCHEMA = "awoooi_post_reboot_next_gate_owner_packets_v1" +OWNER_RESPONSE_SCHEMA = "awoooi_post_reboot_next_gate_owner_response_v1" +GATE_ID = "credential_escrow_evidence" +ITEMS = [ + "restic_repository_password", + "offsite_provider_credentials", + "break_glass_admin_credentials", + "dns_registrar_recovery", + "oauth_ai_provider_recovery", +] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Emit one no-secret checklist for the P0-005 DR escrow lane.", + ) + parser.add_argument("--output", type=Path, help="Write JSON to this path.") + return parser.parse_args() + + +def evidence_item(item_id: str) -> dict[str, Any]: + return { + "item_id": item_id, + "required_fields": [ + "non_secret_evidence_ref", + "recovery_owner", + "reviewer", + "last_reviewed_at", + "contains_secret_value=false", + ], + "accepted_ref_examples": [ + f"vault-item-id-for-{item_id}", + f"sealed-envelope-id-for-{item_id}", + f"recovery-checklist-id-for-{item_id}", + f"ticket-id-for-{item_id}", + ], + "rejected_values": [ + "passwords", + "tokens", + "private keys", + "recovery codes", + "secret URLs", + "session cookies", + ], + "marker_dry_run_command": ( + "/backup/scripts/mark-credential-escrow-verified.sh " + f"--item {item_id} --evidence-id --dry-run" + ), + } + + +def owner_packet() -> dict[str, Any]: + return { + "schema_version": OWNER_PACKET_SCHEMA, + "source": {"next_required_gates": [GATE_ID]}, + "owner_packets": [ + { + "packet_id": GATE_ID, + "title": "P0-005 DR credential escrow evidence", + "priority": "P0", + "required_items": ITEMS, + } + ], + } + + +def owner_response_skeleton() -> dict[str, Any]: + return { + "schema_version": OWNER_RESPONSE_SCHEMA, + "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "responses": [ + { + "gate_id": GATE_ID, + "owner_role": "backup_dr_owner", + "owner_team": "platform_security", + "decision": "pending", + "decision_reason": "fill_only_after_all_redacted_refs_are_present", + "affected_scope": "P0-005 DR credential escrow evidence", + "redacted_evidence_refs": [""], + "followup_owner": "backup_dr_owner", + "runtime_action_requested": False, + "runtime_action_authorized": False, + "host_write_requested": False, + "host_write_authorized": False, + "secret_value_included": False, + "secret_value_collection_allowed": False, + "credential_marker_write_requested": False, + "credential_marker_write_authorized": False, + "escrow_items": [ + { + "item_id": item_id, + "non_secret_evidence_ref": f"", + "recovery_owner": "backup_dr_owner", + "reviewer": "security_reviewer", + "last_reviewed_at": "YYYY-MM-DD", + "contains_secret_value": False, + } + for item_id in ITEMS + ], + } + ], + } + + +def build_payload() -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "workplan_id": "P0-005", + "status": "waiting_for_five_redacted_non_secret_evidence_refs", + "active_gate": GATE_ID, + "required_item_count": len(ITEMS), + "required_items": [evidence_item(item_id) for item_id in ITEMS], + "owner_packet": owner_packet(), + "owner_response_skeleton": owner_response_skeleton(), + "single_preflight_command": ( + "python3 scripts/reboot-recovery/post-reboot-owner-response-preflight.py " + "--owner-packet-file " + "--response-file --json --no-color" + ), + "scorecard_command": ( + "python3 scripts/reboot-recovery/post-reboot-credential-escrow-intake-scorecard.py " + "--summary-file --owner-packet-file " + "--response-file " + "--offsite-report-file " + "--escrow-status-file --json --no-color" + ), + "exit_criteria": [ + "preflight_status=ready_for_independent_reviewer_acceptance", + "owner_response_received_count=1", + "owner_response_accepted_count=1", + "forbidden_true_field_count=0", + "effective_escrow_missing_count=0 after marker dry-runs and accepted marker writes", + ], + "execution_rules": [ + "Use this as the only P0-005 intake packet.", + "Do not create per-item owner packets.", + "Do not include secret values in evidence refs.", + "Do not reopen cold-start or CI/CD while this checklist is pending.", + ], + } + + +def main() -> int: + args = parse_args() + payload = build_payload() + text = json.dumps(payload, indent=2, ensure_ascii=False) + "\n" + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text, encoding="utf-8") + else: + print(text, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/reboot-recovery/tests/test_dr_escrow_evidence_checklist.py b/scripts/reboot-recovery/tests/test_dr_escrow_evidence_checklist.py new file mode 100644 index 000000000..76f9735ed --- /dev/null +++ b/scripts/reboot-recovery/tests/test_dr_escrow_evidence_checklist.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "scripts" / "reboot-recovery" / "dr-escrow-evidence-checklist.py" + +ITEMS = { + "restic_repository_password", + "offsite_provider_credentials", + "break_glass_admin_credentials", + "dns_registrar_recovery", + "oauth_ai_provider_recovery", +} + + +def load_checklist() -> dict: + result = subprocess.run( + [sys.executable, str(SCRIPT)], + text=True, + capture_output=True, + check=True, + ) + return json.loads(result.stdout) + + +def test_checklist_is_single_p0_005_intake_packet() -> None: + payload = load_checklist() + + assert payload["schema_version"] == "awoooi_dr_escrow_evidence_checklist_v1" + assert payload["workplan_id"] == "P0-005" + assert payload["active_gate"] == "credential_escrow_evidence" + assert payload["required_item_count"] == 5 + assert {item["item_id"] for item in payload["required_items"]} == ITEMS + assert payload["owner_packet"]["source"]["next_required_gates"] == [ + "credential_escrow_evidence" + ] + assert len(payload["owner_packet"]["owner_packets"]) == 1 + assert len(payload["owner_response_skeleton"]["responses"]) == 1 + + +def test_checklist_never_authorizes_runtime_or_secret_collection() -> None: + payload = load_checklist() + response = payload["owner_response_skeleton"]["responses"][0] + + forbidden_true_fields = [ + "runtime_action_requested", + "runtime_action_authorized", + "host_write_requested", + "host_write_authorized", + "secret_value_included", + "secret_value_collection_allowed", + "credential_marker_write_requested", + "credential_marker_write_authorized", + ] + for field in forbidden_true_fields: + assert response[field] is False + for item in response["escrow_items"]: + assert item["contains_secret_value"] is False + + +def test_checklist_outputs_marker_dry_run_commands_only() -> None: + payload = load_checklist() + + for item in payload["required_items"]: + command = item["marker_dry_run_command"] + assert "--dry-run" in command + assert "--item " + item["item_id"] in command + assert " --evidence-id