feat(api): 新增復原演練批准包模板
All checks were successful
CD Pipeline / tests (push) Successful in 1m24s
Code Review / ai-code-review (push) Successful in 10s
CD Pipeline / build-and-deploy (push) Successful in 3m59s
CD Pipeline / post-deploy-checks (push) Successful in 2m23s

This commit is contained in:
Your Name
2026-06-05 01:20:50 +08:00
parent 2857da80b4
commit a367227d3a
12 changed files with 1609 additions and 34 deletions

View File

@@ -53,6 +53,9 @@ from src.services.backup_dr_readiness_matrix import (
from src.services.backup_notification_policy import (
load_latest_backup_notification_policy,
)
from src.services.backup_restore_drill_approval_package_template import (
load_latest_backup_restore_drill_approval_package_template,
)
from src.services.package_supply_chain_inventory import (
load_latest_package_supply_chain_inventory,
)
@@ -551,6 +554,35 @@ async def get_backup_notification_policy() -> dict[str, Any]:
) from exc
@router.get(
"/backup-restore-drill-approval-package-template",
response_model=dict[str, Any],
summary="取得 Backup / DR 復原演練批准包模板",
description=(
"讀取最新已提交的 Backup / DR restore drill、credential escrow review、"
"K8s resource recovery、observability recovery 與 route reconstruction 批准包模板;"
"此端點只回傳 read-only template不執行 backup、restore、offsite sync、"
"不寫 credential marker、不改排程、不寫 workflow、不送 Telegram 測試通知、"
"不輸出 secret 明文、不做破壞性 prune、不呼叫付費 API、不建立 shadow/canary、不改生產路由。"
),
)
async def get_backup_restore_drill_approval_package_template() -> dict[str, Any]:
"""Return the latest read-only Backup / DR restore drill approval package template."""
try:
return await asyncio.to_thread(load_latest_backup_restore_drill_approval_package_template)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
except (json.JSONDecodeError, ValueError) as exc:
logger.error("backup_restore_drill_approval_package_template_invalid", error=str(exc))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Backup / DR 復原演練批准包模板快照無效",
) from exc
@router.get(
"/package-supply-chain-inventory",
response_model=dict[str, Any],

View File

@@ -0,0 +1,145 @@
"""
Backup / DR restore drill approval package template snapshot.
Loads the latest committed, read-only approval package template for restore
drills, credential escrow review, K8s resource recovery, observability
recovery, and route reconstruction. The template never runs backups, restores,
offsite sync, credential marker writes, schedule changes, workflow writes,
Telegram test notifications, destructive prune, secret plaintext export, paid
API calls, shadow/canary, or production routing changes.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from src.services.snapshot_paths import default_evaluations_dir
_DEFAULT_EVALUATIONS_DIR = default_evaluations_dir(Path(__file__))
_SNAPSHOT_PATTERN = "backup_restore_drill_approval_package_template_*.json"
_SCHEMA_VERSION = "backup_restore_drill_approval_package_template_v1"
def load_latest_backup_restore_drill_approval_package_template(
evaluations_dir: Path | None = None,
) -> dict[str, Any]:
"""Load the newest committed Backup / DR restore drill approval package template."""
directory = evaluations_dir or _DEFAULT_EVALUATIONS_DIR
candidates = sorted(directory.glob(_SNAPSHOT_PATTERN))
if not candidates:
raise FileNotFoundError(
f"no Backup / DR restore drill approval package template snapshots found in {directory}"
)
latest = candidates[-1]
with latest.open(encoding="utf-8") as handle:
payload = json.load(handle)
if not isinstance(payload, dict):
raise ValueError(f"{latest}: expected JSON object")
_require_schema(payload, _SCHEMA_VERSION, str(latest))
_require_read_only_boundaries(payload, str(latest))
_require_operation_boundaries(payload, str(latest))
_require_rollup_consistency(payload, str(latest))
return payload
def _require_schema(payload: dict[str, Any], expected: str, label: str) -> None:
actual = payload.get("schema_version")
if actual != expected:
raise ValueError(f"{label}: expected schema_version={expected}, got {actual!r}")
def _require_read_only_boundaries(payload: dict[str, Any], label: str) -> None:
program_status = payload.get("program_status") or {}
if program_status.get("read_only_mode") is not True:
raise ValueError(f"{label}: program_status.read_only_mode must be true")
boundaries = payload.get("approval_boundaries") or {}
blocked_flags = {
"sdk_installation_allowed",
"paid_api_call_allowed",
"shadow_or_canary_allowed",
"production_routing_allowed",
"destructive_operation_allowed",
"restore_execution_allowed",
"offsite_sync_execution_allowed",
"credential_marker_write_allowed",
}
allowed = sorted(flag for flag in blocked_flags if boundaries.get(flag) is not False)
if allowed:
raise ValueError(f"{label}: approval boundaries must remain false: {allowed}")
def _require_operation_boundaries(payload: dict[str, Any], label: str) -> None:
boundaries = payload.get("operation_boundaries") or {}
if boundaries.get("read_only_template_allowed") is not True:
raise ValueError(f"{label}: read_only_template_allowed must be true")
blocked_flags = {
"backup_execution_allowed",
"restore_execution_allowed",
"offsite_sync_execution_allowed",
"credential_marker_write_allowed",
"schedule_change_allowed",
"workflow_write_allowed",
"telegram_test_notification_allowed",
"destructive_prune_allowed",
"secret_plaintext_allowed",
"production_routing_allowed",
"sdk_installation_allowed",
"paid_api_call_allowed",
"shadow_or_canary_allowed",
}
allowed = sorted(flag for flag in blocked_flags if boundaries.get(flag) is not False)
if allowed:
raise ValueError(f"{label}: operation boundaries must remain false: {allowed}")
def _require_rollup_consistency(payload: dict[str, Any], label: str) -> None:
templates = payload.get("package_templates") or []
rollups = payload.get("rollups") or {}
if rollups.get("total_templates") != len(templates):
raise ValueError(f"{label}: rollups.total_templates must match package_templates")
ready_ids = {
template.get("template_id")
for template in templates
if template.get("status") == "template_ready"
}
if set(rollups.get("template_ready_ids") or []) != ready_ids:
raise ValueError(f"{label}: rollups.template_ready_ids must match template_ready templates")
hitl_ids = {
template.get("template_id")
for template in templates
if "HITL approval" in (template.get("manual_approvals") or [])
}
if set(rollups.get("hitl_required_template_ids") or []) != hitl_ids:
raise ValueError(f"{label}: rollups.hitl_required_template_ids must match HITL templates")
blocked_target_ids = _target_ids_by_readiness(templates, "blocked")
if set(rollups.get("blocked_source_target_ids") or []) != blocked_target_ids:
raise ValueError(
f"{label}: rollups.blocked_source_target_ids must match blocked source targets"
)
action_required_target_ids = _target_ids_by_readiness(templates, "action_required")
if set(rollups.get("action_required_source_target_ids") or []) != action_required_target_ids:
raise ValueError(
f"{label}: rollups.action_required_source_target_ids must match action_required source targets"
)
if (payload.get("decision_gate_contract") or {}).get("hitl_required") is not True:
raise ValueError(f"{label}: decision_gate_contract.hitl_required must be true")
def _target_ids_by_readiness(templates: list[dict[str, Any]], readiness: str) -> set[str]:
return {
target.get("target_id")
for template in templates
for target in template.get("source_target_statuses", [])
if target.get("readiness") == readiness
}

View File

@@ -18,11 +18,12 @@ def test_ai_agent_automation_backlog_snapshot_endpoint_returns_committed_snapsho
assert data["schema_version"] == "ai_agent_automation_backlog_v1"
assert data["program_status"]["overall_completion_percent"] == 100
assert data["program_status"]["read_only_mode"] is True
assert data["program_status"]["current_task_id"] == "P1-104"
assert data["program_status"]["next_task_id"] == "P1-105"
assert data["rollups"]["total_items"] == len(data["backlog_items"]) == 19
assert data["rollups"]["by_priority"]["P1"] == 17
assert data["rollups"]["by_status"]["done"] == 12
assert data["program_status"]["current_task_id"] == "P1-105"
assert data["program_status"]["next_task_id"] == "P1-106"
assert data["rollups"]["total_items"] == len(data["backlog_items"]) == 20
assert data["rollups"]["by_priority"]["P1"] == 18
assert data["rollups"]["by_status"]["done"] == 13
assert data["rollups"]["by_gate_status"]["read_only_allowed"] == 17
assert data["approval_boundaries"]["sdk_installation_allowed"] is False
assert data["approval_boundaries"]["paid_api_call_allowed"] is False
assert data["approval_boundaries"]["production_routing_allowed"] is False
@@ -31,4 +32,5 @@ def test_ai_agent_automation_backlog_snapshot_endpoint_returns_committed_snapsho
assert any(item["item_id"] == "AUTO-P1-206" for item in data["backlog_items"])
assert any(item["item_id"] == "AUTO-P1-103" for item in data["backlog_items"])
assert any(item["item_id"] == "AUTO-P1-104" for item in data["backlog_items"])
assert any(item["item_id"] == "AUTO-P1-105" for item in data["backlog_items"])
assert any(item["item_id"] == "AUTO-P3-001" for item in data["backlog_items"])

View File

@@ -18,8 +18,8 @@ def test_ai_agent_automation_inventory_snapshot_endpoint_returns_committed_snaps
assert data["schema_version"] == "ai_agent_automation_inventory_snapshot_v1"
assert data["program_status"]["overall_completion_percent"] == 100
assert data["program_status"]["read_only_mode"] is True
assert data["program_status"]["current_task_id"] == "P1-104"
assert data["program_status"]["next_task_id"] == "P1-105"
assert data["program_status"]["current_task_id"] == "P1-105"
assert data["program_status"]["next_task_id"] == "P1-106"
assert data["approval_boundaries"]["sdk_installation_allowed"] is False
assert data["approval_boundaries"]["paid_api_call_allowed"] is False
assert data["approval_boundaries"]["production_routing_allowed"] is False
@@ -29,6 +29,7 @@ def test_ai_agent_automation_inventory_snapshot_endpoint_returns_committed_snaps
assert any(task["task_id"] == "P1-206" for task in data["tasks"])
assert any(task["task_id"] == "P1-103" for task in data["tasks"])
assert any(task["task_id"] == "P1-104" for task in data["tasks"])
assert any(task["task_id"] == "P1-105" for task in data["tasks"])
assert any(evidence["evidence_id"] == "dependency_risk_policy_api" for evidence in data["evidence"])
assert any(evidence["evidence_id"] == "dependency_drift_check_plan_api" for evidence in data["evidence"])
assert any(
@@ -37,3 +38,7 @@ def test_ai_agent_automation_inventory_snapshot_endpoint_returns_committed_snaps
)
assert any(evidence["evidence_id"] == "backup_notification_policy_api" for evidence in data["evidence"])
assert any(evidence["evidence_id"] == "backup_dr_evidence_ui" for evidence in data["evidence"])
assert any(
evidence["evidence_id"] == "backup_restore_drill_approval_package_template_api"
for evidence in data["evidence"]
)

View File

@@ -0,0 +1,236 @@
from __future__ import annotations
import json
import pytest
from src.services.backup_restore_drill_approval_package_template import (
load_latest_backup_restore_drill_approval_package_template,
)
def test_load_latest_backup_restore_drill_approval_package_template_reads_newest_file(tmp_path):
older = _snapshot(generated_at="2026-06-04T00:00:00+08:00", completion=75)
newer = _snapshot(generated_at="2026-06-05T00:00:00+08:00", completion=100)
(tmp_path / "backup_restore_drill_approval_package_template_2026-06-04.json").write_text(
json.dumps(older),
encoding="utf-8",
)
(tmp_path / "backup_restore_drill_approval_package_template_2026-06-05.json").write_text(
json.dumps(newer),
encoding="utf-8",
)
loaded = load_latest_backup_restore_drill_approval_package_template(tmp_path)
assert loaded["generated_at"] == "2026-06-05T00:00:00+08:00"
assert loaded["program_status"]["overall_completion_percent"] == 100
assert loaded["rollups"]["total_templates"] == 2
assert loaded["operation_boundaries"]["restore_execution_allowed"] is False
def test_backup_restore_drill_approval_package_template_requires_read_only_mode(tmp_path):
snapshot = _snapshot()
snapshot["program_status"]["read_only_mode"] = False
(tmp_path / "backup_restore_drill_approval_package_template_2026-06-05.json").write_text(
json.dumps(snapshot),
encoding="utf-8",
)
with pytest.raises(ValueError, match="read_only_mode"):
load_latest_backup_restore_drill_approval_package_template(tmp_path)
def test_backup_restore_drill_approval_package_template_requires_blocked_operations(tmp_path):
snapshot = _snapshot()
snapshot["operation_boundaries"]["restore_execution_allowed"] = True
(tmp_path / "backup_restore_drill_approval_package_template_2026-06-05.json").write_text(
json.dumps(snapshot),
encoding="utf-8",
)
with pytest.raises(ValueError, match="operation boundaries"):
load_latest_backup_restore_drill_approval_package_template(tmp_path)
def test_backup_restore_drill_approval_package_template_requires_blocked_approval_boundaries(tmp_path):
snapshot = _snapshot()
snapshot["approval_boundaries"]["credential_marker_write_allowed"] = True
(tmp_path / "backup_restore_drill_approval_package_template_2026-06-05.json").write_text(
json.dumps(snapshot),
encoding="utf-8",
)
with pytest.raises(ValueError, match="approval boundaries"):
load_latest_backup_restore_drill_approval_package_template(tmp_path)
def test_backup_restore_drill_approval_package_template_requires_total_consistency(tmp_path):
snapshot = _snapshot()
snapshot["rollups"]["total_templates"] = 999
(tmp_path / "backup_restore_drill_approval_package_template_2026-06-05.json").write_text(
json.dumps(snapshot),
encoding="utf-8",
)
with pytest.raises(ValueError, match="total_templates"):
load_latest_backup_restore_drill_approval_package_template(tmp_path)
def test_backup_restore_drill_approval_package_template_requires_ready_id_consistency(tmp_path):
snapshot = _snapshot()
snapshot["rollups"]["template_ready_ids"] = []
(tmp_path / "backup_restore_drill_approval_package_template_2026-06-05.json").write_text(
json.dumps(snapshot),
encoding="utf-8",
)
with pytest.raises(ValueError, match="template_ready_ids"):
load_latest_backup_restore_drill_approval_package_template(tmp_path)
def test_backup_restore_drill_approval_package_template_requires_hitl_consistency(tmp_path):
snapshot = _snapshot()
snapshot["rollups"]["hitl_required_template_ids"] = []
(tmp_path / "backup_restore_drill_approval_package_template_2026-06-05.json").write_text(
json.dumps(snapshot),
encoding="utf-8",
)
with pytest.raises(ValueError, match="hitl_required_template_ids"):
load_latest_backup_restore_drill_approval_package_template(tmp_path)
def test_backup_restore_drill_approval_package_template_requires_source_target_rollups(tmp_path):
snapshot = _snapshot()
snapshot["rollups"]["blocked_source_target_ids"] = []
(tmp_path / "backup_restore_drill_approval_package_template_2026-06-05.json").write_text(
json.dumps(snapshot),
encoding="utf-8",
)
with pytest.raises(ValueError, match="blocked_source_target_ids"):
load_latest_backup_restore_drill_approval_package_template(tmp_path)
def test_backup_restore_drill_approval_package_template_requires_hitl_gate(tmp_path):
snapshot = _snapshot()
snapshot["decision_gate_contract"]["hitl_required"] = False
(tmp_path / "backup_restore_drill_approval_package_template_2026-06-05.json").write_text(
json.dumps(snapshot),
encoding="utf-8",
)
with pytest.raises(ValueError, match="hitl_required"):
load_latest_backup_restore_drill_approval_package_template(tmp_path)
def test_backup_restore_drill_approval_package_template_fails_when_missing(tmp_path):
with pytest.raises(FileNotFoundError):
load_latest_backup_restore_drill_approval_package_template(tmp_path)
def _snapshot(
*,
generated_at: str = "2026-06-05T00:00:00+08:00",
completion: int = 100,
) -> dict:
return {
"schema_version": "backup_restore_drill_approval_package_template_v1",
"generated_at": generated_at,
"source_refs": ["docs/evaluations/backup_dr_readiness_matrix_2026-06-04.json"],
"program_status": {
"overall_completion_percent": completion,
"current_priority": "P1",
"current_task_id": "P1-105",
"next_task_id": "P1-106",
"read_only_mode": True,
},
"rollups": {
"total_templates": 2,
"by_domain": {"database_restore": 1, "k8s_resource_restore": 1},
"template_ready_ids": [
"database_restore_drill_approval_package",
"velero_k8s_restore_drill_package",
],
"hitl_required_template_ids": [
"database_restore_drill_approval_package",
"velero_k8s_restore_drill_package",
],
"blocked_source_target_ids": ["configs_capture"],
"action_required_source_target_ids": ["velero_k8s_resources"],
},
"approval_fields": [
{
"field_id": "manual_approval",
"required": True,
"description": "approval",
}
],
"package_templates": [
_template(
"database_restore_drill_approval_package",
"database_restore",
[{"target_id": "configs_capture", "readiness": "blocked"}],
),
_template(
"velero_k8s_restore_drill_package",
"k8s_resource_restore",
[{"target_id": "velero_k8s_resources", "readiness": "action_required"}],
),
],
"decision_gate_contract": {
"openclaw_role": "arbitrate",
"hermes_role": "summarize",
"nemotron_role": "offline review",
"hitl_required": True,
"expires_after": "7 days",
"invalidated_by": ["snapshot change"],
},
"operation_boundaries": {
"read_only_template_allowed": True,
"backup_execution_allowed": False,
"restore_execution_allowed": False,
"offsite_sync_execution_allowed": False,
"credential_marker_write_allowed": False,
"schedule_change_allowed": False,
"workflow_write_allowed": False,
"telegram_test_notification_allowed": False,
"destructive_prune_allowed": False,
"secret_plaintext_allowed": False,
"production_routing_allowed": False,
"sdk_installation_allowed": False,
"paid_api_call_allowed": False,
"shadow_or_canary_allowed": False,
},
"approval_boundaries": {
"sdk_installation_allowed": False,
"paid_api_call_allowed": False,
"shadow_or_canary_allowed": False,
"production_routing_allowed": False,
"destructive_operation_allowed": False,
"restore_execution_allowed": False,
"offsite_sync_execution_allowed": False,
"credential_marker_write_allowed": False,
},
}
def _template(template_id: str, domain: str, source_target_statuses: list[dict]) -> dict:
return {
"template_id": template_id,
"domain": domain,
"status": "template_ready",
"owner_agent": "openclaw",
"purpose": "approval package",
"source_target_statuses": source_target_statuses,
"required_evidence": ["docs/evaluations/backup_dr_readiness_matrix_2026-06-04.json"],
"required_decisions": ["approve or reject"],
"required_prechecks": ["precheck"],
"required_tests": ["schema validation"],
"rollback_requirements": ["rollback plan"],
"abort_criteria": ["abort"],
"manual_approvals": ["OpenClaw arbitration", "HITL approval"],
"prohibited_without_approval": ["restore execution"],
"evidence_refs": ["docs/evaluations/backup_dr_readiness_matrix_2026-06-04.json"],
}

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.api.v1.agents import router
def test_backup_restore_drill_approval_package_template_endpoint_returns_committed_snapshot():
app = FastAPI()
app.include_router(router, prefix="/api/v1")
client = TestClient(app)
response = client.get("/api/v1/agents/backup-restore-drill-approval-package-template")
assert response.status_code == 200
data = response.json()
assert data["schema_version"] == "backup_restore_drill_approval_package_template_v1"
assert data["program_status"]["overall_completion_percent"] == 100
assert data["program_status"]["read_only_mode"] is True
assert data["program_status"]["current_task_id"] == "P1-105"
assert data["program_status"]["next_task_id"] == "P1-106"
assert data["rollups"]["total_templates"] == len(data["package_templates"]) == 6
assert len(data["rollups"]["hitl_required_template_ids"]) == 6
assert data["rollups"]["blocked_source_target_ids"] == [
"configs_capture",
"credential_escrow_markers",
]
assert data["rollups"]["action_required_source_target_ids"] == [
"signoz",
"velero_k8s_resources",
]
assert data["operation_boundaries"]["read_only_template_allowed"] is True
assert data["operation_boundaries"]["backup_execution_allowed"] is False
assert data["operation_boundaries"]["restore_execution_allowed"] is False
assert data["operation_boundaries"]["offsite_sync_execution_allowed"] is False
assert data["operation_boundaries"]["credential_marker_write_allowed"] is False
assert data["operation_boundaries"]["workflow_write_allowed"] is False
assert data["operation_boundaries"]["telegram_test_notification_allowed"] is False
assert data["operation_boundaries"]["secret_plaintext_allowed"] is False
assert data["operation_boundaries"]["production_routing_allowed"] is False
assert data["decision_gate_contract"]["hitl_required"] is True
assert any(
template["template_id"] == "credential_escrow_review_package"
for template in data["package_templates"]
)
assert any(
template["template_id"] == "velero_k8s_restore_drill_package"
for template in data["package_templates"]
)