diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index 130cc42f3..e2214579d 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -389,6 +389,21 @@ from src.services.docker_build_surface_inventory import ( from src.services.gitea_authenticated_inventory_payload_validation import ( validate_gitea_authenticated_inventory_payload, ) +from src.services.gitea_actions_secret_variable_name_inventory import ( + load_latest_gitea_actions_secret_variable_name_inventory, +) +from src.services.gitea_branch_protection_required_checks_readback import ( + load_latest_gitea_branch_protection_required_checks_readback, +) +from src.services.gitea_capability_ci_cd_optimization_readback import ( + load_latest_gitea_capability_ci_cd_optimization_readback, +) +from src.services.gitea_cicd_alert_receipt_chain_readback import ( + load_latest_gitea_cicd_alert_receipt_chain_readback, +) +from src.services.gitea_full_server_backup_dr_receipts import ( + load_latest_gitea_full_server_backup_dr_receipts, +) from src.services.gitea_owner_coverage_attestation_validation import ( validate_gitea_owner_coverage_attestation, ) @@ -4767,6 +4782,151 @@ async def get_runtime_surface_inventory() -> dict[str, Any]: ) from exc +@router.get( + "/gitea-cicd-alert-receipt-chain-readback", + response_model=dict[str, Any], + summary="取得 Gitea CI/CD / backup alert receipt chain readback", + description=( + "整合 Gitea workflow notify bridge、backup notification policy、" + "Gitea full-server DR receipts 與 Telegram monitoring coverage," + "回傳 receipt chain 缺口。此端點只讀 metadata,不送 Telegram、" + "不呼叫 Bot API、不改 receiver/Alertmanager、不執行 backup/restore、" + "不觸發 workflow、不讀 secret、不保存 raw payload、不碰 GitHub 或 host/K8s。" + ), +) +async def get_gitea_cicd_alert_receipt_chain_readback() -> dict[str, Any]: + """Return Gitea CI/CD, backup, and alert receipt chain readback.""" + try: + payload = await load_latest_gitea_cicd_alert_receipt_chain_readback() + return redact_public_lan_topology(payload) + except (json.JSONDecodeError, ValueError) as exc: + logger.error("gitea_cicd_alert_receipt_chain_readback_invalid", error=str(exc)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Gitea CI/CD alert receipt chain readback 無效", + ) from exc + + +@router.get( + "/gitea-full-server-backup-dr-receipts", + response_model=dict[str, Any], + summary="取得 Gitea full-server backup / DR receipts", + description=( + "分層讀回 Gitea repository bundle、server backup target、Google Drive/offsite、" + "restore drill gate、DB/settings/LFS/packages/attachments/hooks component receipts。" + "此端點只做 read-only receipt readback,不執行 backup/restore/offsite sync、" + "不讀 credential/secret、不寫 repo、不讀 raw session/SQLite、不碰 GitHub 或 host/K8s。" + ), +) +async def get_gitea_full_server_backup_dr_receipts() -> dict[str, Any]: + """Return layered Gitea full-server backup / DR receipt readback.""" + try: + return await asyncio.to_thread(load_latest_gitea_full_server_backup_dr_receipts) + except (json.JSONDecodeError, ValueError) as exc: + logger.error("gitea_full_server_backup_dr_receipts_invalid", error=str(exc)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Gitea full-server backup / DR receipts 無效", + ) from exc + + +@router.get( + "/gitea-branch-protection-required-checks-readback", + response_model=dict[str, Any], + summary="取得 Gitea branch protection / required checks source policy", + description=( + "從 Gitea dev/prod repo truth 與 `.gitea/workflows` 產生 main/dev " + "branch protection required-check source policy。此端點只回傳 source policy " + "與 redacted metadata 驗證下一步;不修改 Gitea branch protection、不觸發 workflow、" + "不讀 secret、不改 repo visibility、不讀 raw session/SQLite、不碰 GitHub 或 host/K8s。" + ), +) +async def get_gitea_branch_protection_required_checks_readback() -> dict[str, Any]: + """Return source-derived Gitea branch protection / required checks policy.""" + try: + return await asyncio.to_thread( + load_latest_gitea_branch_protection_required_checks_readback + ) + 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( + "gitea_branch_protection_required_checks_readback_invalid", + error=str(exc), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Gitea branch protection required checks 讀回無效", + ) from exc + + +@router.get( + "/gitea-actions-secret-variable-name-inventory", + response_model=dict[str, Any], + summary="取得 Gitea Actions Secret / Variable 名稱盤點", + description=( + "只讀取已提交 `.gitea/workflows` 中的 `${{ secrets.NAME }}` / " + "`${{ vars.NAME }}` 名稱引用,回傳 workflow 對應、風險分類與後續 " + "redacted metadata 驗證欄位。此端點不讀 Secret/Variable 值、不呼叫 " + "Gitea write API、不觸發 workflow、不改 repo 設定、不讀 raw session/SQLite、" + "不碰 GitHub 或 host/K8s。" + ), +) +async def get_gitea_actions_secret_variable_name_inventory() -> dict[str, Any]: + """Return value-safe Gitea Actions secret / variable name inventory.""" + try: + return await asyncio.to_thread( + load_latest_gitea_actions_secret_variable_name_inventory + ) + except (json.JSONDecodeError, ValueError) as exc: + logger.error( + "gitea_actions_secret_variable_name_inventory_invalid", + error=str(exc), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Gitea Actions Secret / Variable 名稱盤點無效", + ) from exc + + +@router.get( + "/gitea-capability-ci-cd-optimization-readback", + response_model=dict[str, Any], + summary="取得 Gitea 完整功能覆蓋與 CI/CD 專業化優化讀回", + description=( + "整合 Gitea repo/dev-prod branch、Actions workflow/runner、" + "secret/variable source references、backup/DR、notification policy 與 " + "CI/CD optimization rows。此端點只做 read-only governance readback;" + "不呼叫 GitHub、不觸發 workflow、不建立 repo、不同步 refs、不改 visibility、" + "不重啟 runner、不讀 secret value、不讀 raw session/SQLite、不操作 host/K8s。" + ), +) +async def get_gitea_capability_ci_cd_optimization_readback() -> dict[str, Any]: + """Return the Gitea capability and CI/CD optimization governance readback.""" + try: + payload = await asyncio.to_thread( + load_latest_gitea_capability_ci_cd_optimization_readback + ) + return redact_public_lan_topology(payload) + 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( + "gitea_capability_ci_cd_optimization_readback_invalid", + error=str(exc), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Gitea 完整功能覆蓋與 CI/CD 優化讀回無效", + ) from exc + + @router.get( "/gitea-workflow-runner-health", response_model=dict[str, Any], diff --git a/apps/api/src/services/gitea_actions_secret_variable_name_inventory.py b/apps/api/src/services/gitea_actions_secret_variable_name_inventory.py new file mode 100644 index 000000000..c16d9bae2 --- /dev/null +++ b/apps/api/src/services/gitea_actions_secret_variable_name_inventory.py @@ -0,0 +1,316 @@ +"""Gitea Actions secret / variable name inventory. + +This readback inventories only committed workflow references such as +`${{ secrets.NAME }}` and `${{ vars.NAME }}`. It never calls Gitea for Secret +values, never reads plaintext credentials, and never writes repository settings. +""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +from src.services.gitea_capability_ci_cd_optimization_readback import ( + build_gitea_workflow_source_inventory, +) +from src.services.snapshot_paths import resolve_repo_root + +SCHEMA_VERSION = "gitea_actions_secret_variable_name_inventory_v1" +TZ_TAIPEI = ZoneInfo("Asia/Taipei") + + +def load_latest_gitea_actions_secret_variable_name_inventory( + repo_root: Path | None = None, +) -> dict[str, Any]: + """Build the latest committed Gitea Actions secret-name inventory.""" + root = repo_root or resolve_repo_root(Path(__file__)) + return build_gitea_actions_secret_variable_name_inventory( + workflow_source_inventory=build_gitea_workflow_source_inventory(root) + ) + + +def build_gitea_actions_secret_variable_name_inventory( + *, + workflow_source_inventory: dict[str, Any], + generated_at: str | None = None, +) -> dict[str, Any]: + """Return a value-safe inventory of workflow secret and variable names.""" + now = generated_at or datetime.now(TZ_TAIPEI).isoformat(timespec="seconds") + workflow_rows = _workflow_rows(workflow_source_inventory.get("workflow_rows")) + secret_rollups = _dict(workflow_source_inventory.get("secret_variable_rollups")) + secret_names = _strings(secret_rollups.get("referenced_secret_names")) + variable_names = _strings(secret_rollups.get("referenced_variable_names")) + secret_rows = _secret_rows(secret_names, workflow_rows) + variable_rows = _variable_rows(variable_names, workflow_rows) + workflow_usage_rows = _workflow_usage_rows(workflow_rows) + + payload = { + "schema_version": SCHEMA_VERSION, + "generated_at": now, + "source_control_authority": "gitea", + "scope": "gitea_actions_secret_variable_name_inventory", + "status": "gitea_actions_secret_variable_name_inventory_ready", + "ready": True, + "operation_boundaries": { + "read_only_api_allowed": True, + "workflow_source_read_allowed": True, + "secret_name_inventory_allowed": True, + "variable_name_inventory_allowed": True, + "secret_value_read_allowed": False, + "variable_value_read_allowed": False, + "gitea_api_write_allowed": False, + "gitea_secret_update_allowed": False, + "gitea_variable_update_allowed": False, + "workflow_trigger_allowed": False, + "repo_visibility_change_allowed": False, + "raw_session_or_sqlite_read_allowed": False, + "github_api_allowed": False, + "host_or_k8s_write_allowed": False, + }, + "summary": { + "workflow_file_count": _int( + workflow_source_inventory.get("workflow_file_count") + ), + "workflows_using_secret_context_count": _int( + secret_rollups.get("workflows_using_secret_context_count") + ), + "secret_reference_count": _int(secret_rollups.get("secret_reference_count")), + "referenced_secret_name_count": len(secret_rows), + "workflows_using_variable_context_count": _int( + secret_rollups.get("workflows_using_vars_context_count") + ), + "variable_reference_count": _int(secret_rollups.get("vars_reference_count")), + "referenced_variable_name_count": len(variable_rows), + "secret_values_collected": False, + "variable_values_collected": False, + "gitea_configured_secret_metadata_verified": False, + "gitea_configured_variable_metadata_verified": False, + "name_inventory_complete_from_committed_workflows": True, + }, + "secret_name_rows": secret_rows, + "variable_name_rows": variable_rows, + "workflow_usage_rows": workflow_usage_rows, + "metadata_verification_next_step": { + "status": "redacted_configuration_metadata_required", + "allowed_fields": [ + "name", + "scope", + "repo", + "environment", + "updated_at", + "created_at", + "present", + ], + "forbidden_fields": [ + "value", + "plaintext", + "token", + "authorization_header", + "private_key", + "cookie", + "session", + ], + "safe_next_action": ( + "verify Gitea configured secret/variable names with redacted " + "metadata only; never export values" + ), + }, + } + _require_payload(payload, "gitea_actions_secret_variable_name_inventory") + return payload + + +def _secret_rows( + secret_names: list[str], + workflow_rows: list[dict[str, Any]], +) -> list[dict[str, Any]]: + return [ + { + "name": name, + "kind": "secret", + "risk_category": _risk_category(name), + "workflow_refs": _refs_for_name(name, workflow_rows, "referenced_secret_names"), + "reference_count": _reference_count( + name, + workflow_rows, + "referenced_secret_names", + "secret_reference_count", + ), + "value_read_status": "not_read_by_design", + "configuration_metadata_status": "redacted_metadata_required", + } + for name in secret_names + ] + + +def _variable_rows( + variable_names: list[str], + workflow_rows: list[dict[str, Any]], +) -> list[dict[str, Any]]: + return [ + { + "name": name, + "kind": "variable", + "risk_category": _risk_category(name), + "workflow_refs": _refs_for_name(name, workflow_rows, "referenced_variable_names"), + "reference_count": _reference_count( + name, + workflow_rows, + "referenced_variable_names", + "vars_reference_count", + ), + "value_read_status": "not_read_by_design", + "configuration_metadata_status": "redacted_metadata_required", + } + for name in variable_names + ] + + +def _workflow_usage_rows(workflow_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for row in workflow_rows: + secret_names = _strings(row.get("referenced_secret_names")) + variable_names = _strings(row.get("referenced_variable_names")) + if not secret_names and not variable_names: + continue + rows.append( + { + "workflow_id": str(row.get("workflow_id") or ""), + "workflow_file": str(row.get("workflow_file") or ""), + "referenced_secret_names": secret_names, + "referenced_variable_names": variable_names, + "secret_reference_count": _int(row.get("secret_reference_count")), + "variable_reference_count": _int(row.get("vars_reference_count")), + "value_read_status": "not_read_by_design", + } + ) + return rows + + +def _refs_for_name( + name: str, + workflow_rows: list[dict[str, Any]], + field: str, +) -> list[str]: + return sorted( + str(row.get("workflow_file") or row.get("workflow_id") or "") + for row in workflow_rows + if name in _strings(row.get(field)) + ) + + +def _reference_count( + name: str, + workflow_rows: list[dict[str, Any]], + names_field: str, + count_field: str, +) -> int: + # Per-workflow source rows expose unique names and total context references. + # Count the workflow-level total when this name appears in that workflow. + return sum( + _int(row.get(count_field)) + for row in workflow_rows + if name in _strings(row.get(names_field)) + ) + + +def _risk_category(name: str) -> str: + lowered = name.lower() + if "private_key" in lowered or lowered.endswith("_key"): + return "credential_key" + if "token" in lowered: + return "token" + if "password" in lowered: + return "password" + if lowered.endswith("_url") or lowered in {"database_url", "redis_url"}: + return "connection_url" + if "secret" in lowered: + return "secret" + return "sensitive_config" + + +def _require_payload(payload: dict[str, Any], label: str) -> None: + if payload.get("schema_version") != SCHEMA_VERSION: + raise ValueError(f"{label}: invalid schema_version") + if payload.get("source_control_authority") != "gitea": + raise ValueError(f"{label}: source_control_authority must be gitea") + + boundaries = _dict(payload.get("operation_boundaries")) + if boundaries.get("read_only_api_allowed") is not True: + raise ValueError(f"{label}: read_only_api_allowed must be true") + blocked_flags = { + "secret_value_read_allowed", + "variable_value_read_allowed", + "gitea_api_write_allowed", + "gitea_secret_update_allowed", + "gitea_variable_update_allowed", + "workflow_trigger_allowed", + "repo_visibility_change_allowed", + "raw_session_or_sqlite_read_allowed", + "github_api_allowed", + "host_or_k8s_write_allowed", + } + unexpected = sorted(flag for flag in blocked_flags if boundaries.get(flag) is not False) + if unexpected: + raise ValueError(f"{label}: blocked operation flags must be false: {unexpected}") + + summary = _dict(payload.get("summary")) + secret_rows = _list(payload.get("secret_name_rows")) + variable_rows = _list(payload.get("variable_name_rows")) + if summary.get("referenced_secret_name_count") != len(secret_rows): + raise ValueError(f"{label}: referenced_secret_name_count mismatch") + if summary.get("referenced_variable_name_count") != len(variable_rows): + raise ValueError(f"{label}: referenced_variable_name_count mismatch") + if summary.get("secret_values_collected") is not False: + raise ValueError(f"{label}: secret_values_collected must be false") + if summary.get("variable_values_collected") is not False: + raise ValueError(f"{label}: variable_values_collected must be false") + _require_no_plaintext_like_keys(payload, label) + + +def _require_no_plaintext_like_keys(value: Any, label: str, path: str = "$") -> None: + forbidden = { + "value", + "plaintext", + "token_value", + "secret_value", + "authorization_header", + "private_key_value", + "cookie", + "session", + } + if isinstance(value, dict): + for key, child in value.items(): + lowered = str(key).lower() + if lowered in forbidden or lowered.endswith("_value"): + if lowered not in {"value_read_status"}: + raise ValueError(f"{label}: forbidden key at {path}.{key}") + _require_no_plaintext_like_keys(child, label, f"{path}.{key}") + elif isinstance(value, list): + for index, child in enumerate(value): + _require_no_plaintext_like_keys(child, label, f"{path}[{index}]") + + +def _workflow_rows(value: Any) -> list[dict[str, Any]]: + return [row for row in value if isinstance(row, dict)] if isinstance(value, list) else [] + + +def _dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _strings(value: Any) -> list[str]: + return [str(item) for item in value] if isinstance(value, list) else [] + + +def _int(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 diff --git a/apps/api/src/services/gitea_branch_protection_required_checks_readback.py b/apps/api/src/services/gitea_branch_protection_required_checks_readback.py new file mode 100644 index 000000000..f9273fb3f --- /dev/null +++ b/apps/api/src/services/gitea_branch_protection_required_checks_readback.py @@ -0,0 +1,283 @@ +"""Gitea branch protection / required checks source policy readback. + +This module defines the desired, source-derived branch protection contract for +all product repositories. It does not call Gitea settings APIs, modify branch +protection, trigger workflows, or read credentials. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +from src.services.gitea_capability_ci_cd_optimization_readback import ( + build_gitea_workflow_source_inventory, +) +from src.services.snapshot_paths import default_operations_dir, resolve_repo_root + +SCHEMA_VERSION = "gitea_branch_protection_required_checks_readback_v1" +TZ_TAIPEI = ZoneInfo("Asia/Taipei") +_DEV_PROD_REPO_READBACK_FILE = "gitea-all-product-dev-prod-repo-readback.snapshot.json" + + +def load_latest_gitea_branch_protection_required_checks_readback( + *, + repo_root: Path | None = None, + operations_dir: Path | None = None, +) -> dict[str, Any]: + """Build the latest source policy for Gitea branch protection.""" + root = repo_root or resolve_repo_root(Path(__file__)) + operations = operations_dir or default_operations_dir(Path(__file__)) + dev_prod = _load_json(operations / _DEV_PROD_REPO_READBACK_FILE) + return build_gitea_branch_protection_required_checks_readback( + dev_prod_repo_readback=dev_prod, + workflow_source_inventory=build_gitea_workflow_source_inventory(root), + ) + + +def build_gitea_branch_protection_required_checks_readback( + *, + dev_prod_repo_readback: dict[str, Any], + workflow_source_inventory: dict[str, Any], + generated_at: str | None = None, +) -> dict[str, Any]: + """Build a desired branch protection policy from committed source truth.""" + now = generated_at or datetime.now(TZ_TAIPEI).isoformat(timespec="seconds") + products = _product_rows(dev_prod_repo_readback.get("products")) + workflow_ids = set(_strings(workflow_source_inventory.get("workflow_ids"))) + main_checks = _present( + workflow_ids, + ["code-review", "cd", "type-sync-check"], + ) + dev_checks = _present( + workflow_ids, + ["cd-dev", "type-sync-check"], + ) + protection_rows = _protection_rows(products, main_checks, dev_checks) + excluded = _present( + workflow_ids, + [ + "run-migration", + "harbor-110-local-repair", + "awoooi-onboarding-warning-step", + ], + ) + missing_required_sources = sorted( + set(["cd", "cd-dev", "code-review", "type-sync-check"]) - workflow_ids + ) + + status_value = ( + "gitea_branch_protection_required_checks_source_policy_ready" + if not missing_required_sources and protection_rows + else "blocked_gitea_branch_protection_required_check_source_missing" + ) + payload = { + "schema_version": SCHEMA_VERSION, + "generated_at": now, + "source_control_authority": "gitea", + "scope": "all_product_repositories_branch_protection_required_checks", + "status": status_value, + "ready": status_value.endswith("_ready"), + "operation_boundaries": { + "read_only_api_allowed": True, + "source_policy_generation_allowed": True, + "gitea_branch_protection_write_allowed": False, + "gitea_required_check_write_allowed": False, + "repo_visibility_change_allowed": False, + "workflow_trigger_allowed": False, + "secret_value_read_allowed": False, + "raw_session_or_sqlite_read_allowed": False, + "github_api_allowed": False, + "host_or_k8s_write_allowed": False, + }, + "summary": { + "expected_product_count": len(products), + "protected_repo_count": len({row["gitea_repo"] for row in protection_rows}), + "branch_policy_row_count": len(protection_rows), + "main_branch_policy_count": _count_branch(protection_rows, "main"), + "dev_branch_policy_count": _count_branch(protection_rows, "dev"), + "source_required_check_workflow_count": len( + sorted(set(main_checks + dev_checks)) + ), + "missing_required_check_source_count": len(missing_required_sources), + "live_branch_protection_verified": False, + "live_required_checks_verified": False, + "controlled_apply_required": True, + }, + "required_check_policy": { + "main_branch": { + "branch": "main", + "required_workflow_ids": main_checks, + "minimum_required_approvals": 1, + "required_status": "success", + "rationale": "main is the production branch and must pass review/source checks plus CD closure.", + }, + "dev_branch": { + "branch": "dev", + "required_workflow_ids": dev_checks, + "minimum_required_approvals": 0, + "required_status": "success", + "rationale": "dev is the development branch and must pass dev deploy/source checks.", + }, + "excluded_manual_or_high_risk_workflows": excluded, + }, + "protection_rows": protection_rows, + "missing_required_check_sources": missing_required_sources, + "metadata_verification_next_step": { + "status": "redacted_branch_settings_metadata_required", + "allowed_fields": [ + "repo", + "branch", + "protected", + "required_status_checks", + "required_approvals", + "push_restrictions_present", + "updated_at", + ], + "forbidden_fields": [ + "token", + "authorization_header", + "private_key", + "cookie", + "session", + "secret_value", + ], + "safe_next_action": ( + "compare Gitea branch settings metadata against this source " + "policy before any controlled apply" + ), + }, + } + _require_payload(payload, "gitea_branch_protection_required_checks_readback") + return payload + + +def _protection_rows( + products: list[dict[str, Any]], + main_checks: list[str], + dev_checks: list[str], +) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for product in products: + repo = str(product.get("gitea_repo") or "") + if not repo: + continue + if product.get("prod_branch_present") is True: + rows.append( + { + "product_id": str(product.get("product_id") or ""), + "gitea_repo": repo, + "branch": "main", + "environment": "production", + "required_workflow_ids": main_checks, + "required_approval_count": 1, + "policy_source_status": "source_ready", + "live_settings_status": "redacted_metadata_required", + } + ) + if product.get("dev_branch_present") is True: + rows.append( + { + "product_id": str(product.get("product_id") or ""), + "gitea_repo": repo, + "branch": "dev", + "environment": "development", + "required_workflow_ids": dev_checks, + "required_approval_count": 0, + "policy_source_status": "source_ready", + "live_settings_status": "redacted_metadata_required", + } + ) + return rows + + +def _require_payload(payload: dict[str, Any], label: str) -> None: + if payload.get("schema_version") != SCHEMA_VERSION: + raise ValueError(f"{label}: invalid schema_version") + if payload.get("source_control_authority") != "gitea": + raise ValueError(f"{label}: source_control_authority must be gitea") + boundaries = _dict(payload.get("operation_boundaries")) + if boundaries.get("read_only_api_allowed") is not True: + raise ValueError(f"{label}: read_only_api_allowed must be true") + blocked_flags = { + "gitea_branch_protection_write_allowed", + "gitea_required_check_write_allowed", + "repo_visibility_change_allowed", + "workflow_trigger_allowed", + "secret_value_read_allowed", + "raw_session_or_sqlite_read_allowed", + "github_api_allowed", + "host_or_k8s_write_allowed", + } + unexpected = sorted(flag for flag in blocked_flags if boundaries.get(flag) is not False) + if unexpected: + raise ValueError(f"{label}: blocked operation flags must be false: {unexpected}") + + summary = _dict(payload.get("summary")) + rows = _list(payload.get("protection_rows")) + if summary.get("branch_policy_row_count") != len(rows): + raise ValueError(f"{label}: branch_policy_row_count mismatch") + if summary.get("missing_required_check_source_count") != len( + _list(payload.get("missing_required_check_sources")) + ): + raise ValueError(f"{label}: missing_required_check_source_count mismatch") + if summary.get("live_branch_protection_verified") is not False: + raise ValueError(f"{label}: live_branch_protection_verified must be false") + if summary.get("live_required_checks_verified") is not False: + raise ValueError(f"{label}: live_required_checks_verified must be false") + _require_no_plaintext_like_keys(payload, label) + + +def _require_no_plaintext_like_keys(value: Any, label: str, path: str = "$") -> None: + forbidden = { + "secret_value", + "token_value", + "authorization_header", + "private_key", + "cookie", + "session", + } + if isinstance(value, dict): + for key, child in value.items(): + lowered = str(key).lower() + if lowered in forbidden or lowered.endswith("_secret_value"): + raise ValueError(f"{label}: forbidden key at {path}.{key}") + _require_no_plaintext_like_keys(child, label, f"{path}.{key}") + elif isinstance(value, list): + for index, child in enumerate(value): + _require_no_plaintext_like_keys(child, label, f"{path}[{index}]") + + +def _present(workflow_ids: set[str], candidates: list[str]) -> list[str]: + return [candidate for candidate in candidates if candidate in workflow_ids] + + +def _count_branch(rows: list[dict[str, Any]], branch: str) -> int: + return sum(1 for row in rows if row.get("branch") == branch) + + +def _load_json(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, dict): + raise ValueError(f"{path}: expected JSON object") + return payload + + +def _product_rows(value: Any) -> list[dict[str, Any]]: + return [row for row in value if isinstance(row, dict)] if isinstance(value, list) else [] + + +def _dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _strings(value: Any) -> list[str]: + return [str(item) for item in value] if isinstance(value, list) else [] diff --git a/apps/api/src/services/gitea_capability_ci_cd_optimization_readback.py b/apps/api/src/services/gitea_capability_ci_cd_optimization_readback.py new file mode 100644 index 000000000..5050a3b92 --- /dev/null +++ b/apps/api/src/services/gitea_capability_ci_cd_optimization_readback.py @@ -0,0 +1,1057 @@ +"""Gitea capability and CI/CD optimization readback. + +This service composes committed Gitea source-control, workflow, backup, and +notification evidence into one operator-facing product governance view. It is +read-only: it never calls GitHub, reads Secret values, triggers workflows, +mutates repositories, changes visibility, restarts runners, or touches hosts. +""" + +from __future__ import annotations + +import json +import re +from datetime import datetime +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +from src.services.backup_dr_readiness_matrix import ( + load_latest_backup_dr_readiness_matrix, +) +from src.services.backup_notification_policy import ( + load_latest_backup_notification_policy, +) +from src.services.gitea_private_inventory_p0_scorecard import ( + load_latest_gitea_private_inventory_p0_scorecard, +) +from src.services.gitea_full_server_backup_dr_receipts import ( + load_latest_gitea_full_server_backup_dr_receipts, +) +from src.services.gitea_repo_bundle_backup_readback import ( + load_latest_gitea_repo_bundle_backup_readback, +) +from src.services.gitea_workflow_runner_health import ( + load_latest_gitea_workflow_runner_health, +) +from src.services.p0_cicd_baseline_source_readiness import ( + load_latest_p0_cicd_baseline_source_readiness, +) +from src.services.snapshot_paths import default_operations_dir, resolve_repo_root + +SCHEMA_VERSION = "gitea_capability_ci_cd_optimization_readback_v1" +TZ_TAIPEI = ZoneInfo("Asia/Taipei") +_DEV_PROD_REPO_READBACK_FILE = "gitea-all-product-dev-prod-repo-readback.snapshot.json" + +_REQUIRED_CAPABILITY_IDS = { + "code_git_source_control", + "dev_prod_environment_split", + "repository_visibility_access_policy", + "pull_request_code_review", + "issues_labels_milestones", + "projects_planning", + "wiki_knowledge_base", + "releases_tags", + "packages_registry", + "gitea_actions_workflows", + "gitea_actions_runners", + "gitea_actions_secrets_variables", + "webhooks_integrations", + "branch_protection_required_checks", + "api_admin_audit", + "backup_dr_restore", + "observability_alert_receipts", +} + +_REQUIRED_OPTIMIZATION_IDS = { + "gitea_actions_source_authority", + "dev_prod_branch_promotion_contract", + "workflow_contract_freshness", + "runner_label_isolation_attestation", + "controlled_manual_dispatch_guard", + "failure_only_notification_policy", + "backup_bundle_sample_restore", + "full_gitea_server_offsite_dr_receipts", + "post_deploy_marker_readback", + "stale_actions_api_quarantine", + "package_registry_release_promotion", + "branch_protection_required_checks", + "dependency_sbom_and_cache_strategy", + "artifact_receipt_retention", + "dedupe_cancel_stale_run_guard", +} + + +def load_latest_gitea_capability_ci_cd_optimization_readback( + *, + repo_root: Path | None = None, + operations_dir: Path | None = None, +) -> dict[str, Any]: + """Build the latest Gitea capability / CI-CD optimization readback.""" + root = repo_root or resolve_repo_root(Path(__file__)) + operations = operations_dir or default_operations_dir(Path(__file__)) + payload = build_gitea_capability_ci_cd_optimization_readback( + dev_prod_repo_readback=_load_json( + operations / _DEV_PROD_REPO_READBACK_FILE + ), + private_inventory=load_latest_gitea_private_inventory_p0_scorecard(), + workflow_runner_health=load_latest_gitea_workflow_runner_health(), + workflow_source_inventory=build_gitea_workflow_source_inventory(root), + cicd_source_readiness=load_latest_p0_cicd_baseline_source_readiness(), + backup_dr_readiness=load_latest_backup_dr_readiness_matrix(), + backup_notification_policy=load_latest_backup_notification_policy(), + repo_bundle_backup_readback=load_latest_gitea_repo_bundle_backup_readback(), + full_server_backup_dr_receipts=load_latest_gitea_full_server_backup_dr_receipts(), + ) + _require_payload(payload, "gitea_capability_ci_cd_optimization_readback") + return payload + + +def build_gitea_workflow_source_inventory(repo_root: Path) -> dict[str, Any]: + """Inspect committed `.gitea/workflows` source without parsing secrets.""" + workflow_dir = repo_root / ".gitea" / "workflows" + rows: list[dict[str, Any]] = [] + if workflow_dir.is_dir(): + workflow_files = sorted( + path + for path in workflow_dir.iterdir() + if path.is_file() and path.suffix.lower() in {".yml", ".yaml"} + ) + else: + workflow_files = [] + + for path in workflow_files: + text = path.read_text(encoding="utf-8", errors="replace") + relative = path.relative_to(repo_root).as_posix() + secret_refs = sorted(set(re.findall(r"\bsecrets\.([A-Z0-9_]+)", text))) + vars_refs = sorted(set(re.findall(r"\bvars\.([A-Z0-9_]+)", text))) + secret_ref_count = len(re.findall(r"\bsecrets\.[A-Z0-9_]+", text)) + vars_ref_count = len(re.findall(r"\bvars\.[A-Z0-9_]+", text)) + rows.append( + { + "workflow_id": path.stem, + "workflow_file": relative, + "line_count": text.count("\n") + 1, + "has_push_trigger": _has_yaml_key(text, "push"), + "has_pull_request_trigger": _has_yaml_key(text, "pull_request"), + "has_workflow_dispatch": _has_yaml_key(text, "workflow_dispatch"), + "has_schedule": _has_yaml_key(text, "schedule"), + "uses_secret_context": secret_ref_count > 0, + "secret_reference_count": secret_ref_count, + "referenced_secret_names": secret_refs, + "uses_vars_context": vars_ref_count > 0, + "vars_reference_count": vars_ref_count, + "referenced_variable_names": vars_refs, + "declared_runner_labels": _extract_runner_labels(text), + } + ) + + workflow_ids = sorted(row["workflow_id"] for row in rows) + return { + "source_ref": ".gitea/workflows", + "source_present": workflow_dir.is_dir(), + "workflow_file_count": len(rows), + "workflow_ids": workflow_ids, + "workflow_files": [row["workflow_file"] for row in rows], + "workflow_rows": rows, + "trigger_rollups": { + "push_trigger_count": _count_where(rows, "has_push_trigger"), + "pull_request_trigger_count": _count_where( + rows, "has_pull_request_trigger" + ), + "workflow_dispatch_count": _count_where(rows, "has_workflow_dispatch"), + "schedule_count": _count_where(rows, "has_schedule"), + }, + "secret_variable_rollups": { + "workflows_using_secret_context_count": _count_where( + rows, "uses_secret_context" + ), + "secret_reference_count": sum( + _int(row.get("secret_reference_count")) for row in rows + ), + "referenced_secret_names": sorted( + { + name + for row in rows + for name in _strings(row.get("referenced_secret_names")) + } + ), + "workflows_using_vars_context_count": _count_where( + rows, "uses_vars_context" + ), + "vars_reference_count": sum( + _int(row.get("vars_reference_count")) for row in rows + ), + "referenced_variable_names": sorted( + { + name + for row in rows + for name in _strings(row.get("referenced_variable_names")) + } + ), + "secret_values_collected": False, + }, + "known_control_workflows": { + "has_cd_pipeline": "cd" in workflow_ids, + "has_cd_dev": "cd-dev" in workflow_ids, + "has_code_review": "code-review" in workflow_ids, + "has_e2e_health": "e2e-health" in workflow_ids, + "has_deploy_alerts": "deploy-alerts" in workflow_ids, + "has_harbor_repair": "harbor-110-local-repair" in workflow_ids, + "has_run_migration": "run-migration" in workflow_ids, + "has_type_sync_check": "type-sync-check" in workflow_ids, + "has_onboarding_warning": "awoooi-onboarding-warning-step" in workflow_ids, + "has_agent_market_watch": "agent-market-watch" in workflow_ids, + "has_ai_technology_watch": "ai-technology-watch" in workflow_ids, + }, + } + + +def build_gitea_capability_ci_cd_optimization_readback( + *, + dev_prod_repo_readback: dict[str, Any], + private_inventory: dict[str, Any], + workflow_runner_health: dict[str, Any], + workflow_source_inventory: dict[str, Any], + cicd_source_readiness: dict[str, Any], + backup_dr_readiness: dict[str, Any], + backup_notification_policy: dict[str, Any], + repo_bundle_backup_readback: dict[str, Any], + full_server_backup_dr_receipts: dict[str, Any] | None = None, + generated_at: str | None = None, +) -> dict[str, Any]: + """Compose Gitea capability coverage and professional CI/CD work items.""" + now = generated_at or datetime.now(TZ_TAIPEI).isoformat(timespec="seconds") + dev_prod_summary = _dict(dev_prod_repo_readback.get("summary")) + workflow_rollups = _dict(workflow_runner_health.get("rollups")) + workflow_source_count = _int(workflow_source_inventory.get("workflow_file_count")) + workflow_contract_count = _int(workflow_rollups.get("total_workflows")) + runner_action_ids = _strings( + workflow_rollups.get("runner_contracts_requiring_action") + ) + cicd_rollups = _dict(cicd_source_readiness.get("rollups")) + backup_rollups = _dict(backup_dr_readiness.get("rollups")) + notification_rollups = _dict(backup_notification_policy.get("rollups")) + repo_bundle_summary = _dict(repo_bundle_backup_readback.get("summary")) + secret_rollups = _dict(workflow_source_inventory.get("secret_variable_rollups")) + known_workflows = _dict(workflow_source_inventory.get("known_control_workflows")) + full_dr = _dict(full_server_backup_dr_receipts) + full_dr_summary = _dict(full_dr.get("summary")) + full_dr_gap_ids = _strings(full_dr.get("active_gap_ids")) + full_dr_non_restore_gap_ids = [ + gap for gap in full_dr_gap_ids if gap != "restore_drill_approval_gate" + ] + full_dr_source_ready = ( + _int(full_dr_summary.get("source_ready_component_count")) >= 6 + and not full_dr_non_restore_gap_ids + ) + full_dr_verified_ready = full_dr.get("ready") is True + + all_repos_have_refs = dev_prod_summary.get("all_expected_product_repos_have_ssh_refs") is True + all_repos_have_dev_main = ( + dev_prod_summary.get("all_expected_product_repos_have_dev_and_main") is True + ) + bundle_ready = repo_bundle_backup_readback.get("ready") is True + backup_blocked_rows = len(_strings(backup_rollups.get("blocked_row_ids"))) + backup_action_rows = len(_strings(backup_rollups.get("action_required_row_ids"))) + workflow_contract_fresh = ( + workflow_source_count == workflow_contract_count and workflow_source_count > 0 + ) + source_ready = _int(cicd_rollups.get("missing_required_source_count")) == 0 + quiet_notification_count = _int( + notification_rollups.get("notification_contracts_quiet_success_count") + ) + + capabilities = [ + _capability( + "code_git_source_control", + "source_control", + "Gitea Code / Git repositories", + "verified_ready" if all_repos_have_refs else "action_required", + "all expected product repositories are verified through Gitea SSH refs" + if all_repos_have_refs + else "expected product repository refs are incomplete", + [ + "docs/operations/gitea-all-product-dev-prod-repo-readback.snapshot.json", + "GET /api/v1/agents/gitea-private-inventory-p0-scorecard", + ], + "keep Gitea SSH refs as source-of-truth before UI visibility conclusions", + { + "expected_product_count": _int( + dev_prod_summary.get("expected_product_count") + ), + "ssh_repo_present_count": _int( + dev_prod_summary.get("ssh_repo_present_count") + ), + }, + p0=True, + ), + _capability( + "dev_prod_environment_split", + "source_control", + "Development / production branch split", + "verified_ready" if all_repos_have_dev_main else "action_required", + "every expected product has dev and main branches" + if all_repos_have_dev_main + else "one or more products are missing dev/main branch coverage", + ["docs/operations/gitea-all-product-dev-prod-repo-readback.snapshot.json"], + "keep branch-pair environment model as the promotion contract", + { + "dev_prod_environment_split_ready_count": _int( + dev_prod_summary.get("environment_split_ready_count") + ), + "missing_main_branch_count": _int( + dev_prod_summary.get("missing_main_branch_count") + ), + "missing_dev_branch_count": _int( + dev_prod_summary.get("missing_dev_branch_count") + ), + }, + p0=True, + ), + _capability( + "repository_visibility_access_policy", + "access_control", + "Repository visibility and authenticated access model", + "verified_ready" + if all_repos_have_refs + else "authenticated_inventory_required", + "public/private split is represented without treating tokenless 404 as loss" + if all_repos_have_refs + else "authenticated inventory needs another pass", + [ + "docs/operations/gitea-all-product-dev-prod-repo-readback.snapshot.json", + "GET /api/v1/agents/gitea-private-inventory-p0-scorecard", + ], + "preserve SSH/authenticated truth before creating or replacing repos", + { + "public_visible_count": _int(dev_prod_summary.get("public_visible_count")), + "private_or_auth_only_count": _int( + dev_prod_summary.get("private_or_auth_only_count") + ), + }, + ), + _capability( + "pull_request_code_review", + "collaboration", + "Pull requests and code review", + "partially_ready" if known_workflows.get("has_code_review") else "action_required", + "code-review workflow exists; repository PR policy still needs live inventory", + [ + ".gitea/workflows/code-review.yaml", + "GET /api/v1/agents/gitea-workflow-runner-health", + ], + "bind PR review, required checks, and reviewer ownership per product repo", + ), + _capability( + "issues_labels_milestones", + "collaboration", + "Issues, labels, milestones", + "authenticated_inventory_required", + "Gitea supports issue workflow, but repo-by-repo usage is not yet in readback", + ["GET /api/v1/agents/gitea-private-inventory-p0-scorecard"], + "inventory issue units, default labels, ownership, and milestone hygiene", + ), + _capability( + "projects_planning", + "collaboration", + "Projects / planning boards", + "authenticated_inventory_required", + "project-board usage needs authenticated Gitea inventory", + ["GET /api/v1/agents/gitea-private-inventory-p0-scorecard"], + "standardize project boards for P0/P1 work queues and release trains", + ), + _capability( + "wiki_knowledge_base", + "knowledge_management", + "Wiki / repository knowledge base", + "authenticated_inventory_required", + "wiki usage needs authenticated unit inventory", + ["GET /api/v1/agents/gitea-private-inventory-p0-scorecard"], + "route durable runbooks to Gitea wiki or repo docs with KM writeback refs", + ), + _capability( + "releases_tags", + "release_management", + "Releases and tags", + "controlled_apply_candidate", + "release/tag convention is not yet enforced across all products", + ["GET /api/v1/agents/p0-cicd-baseline-source-readiness"], + "add release train tags, changelog receipt, and deploy marker correlation", + ), + _capability( + "packages_registry", + "artifact_management", + "Packages / container and build artifacts", + "controlled_apply_candidate", + "package registry policy needs product-by-product adoption", + ["GET /api/v1/agents/package-supply-chain-inventory"], + "promote build artifacts through Gitea Packages or internal registry with SBOM refs", + ), + _capability( + "gitea_actions_workflows", + "automation", + "Gitea Actions workflows", + "verified_ready" if workflow_contract_fresh else "action_required", + "committed workflow inventory matches runner-health contract" + if workflow_contract_fresh + else "committed workflow source count differs from workflow-health contract", + [ + ".gitea/workflows", + "GET /api/v1/agents/gitea-workflow-runner-health", + ], + "keep workflow/runner health contract refreshed with committed workflow source" + if workflow_contract_fresh + else "refresh the workflow/runner health contract so every committed workflow is governed", + { + "workflow_source_file_count": workflow_source_count, + "workflow_contract_count": workflow_contract_count, + }, + p0=True, + ), + _capability( + "gitea_actions_runners", + "automation", + "Act runners and runner labels", + "verified_ready" if not runner_action_ids else "action_required", + "runner contracts have no pending action" + if not runner_action_ids + else "runner label ownership still has action-required contracts", + ["GET /api/v1/agents/gitea-workflow-runner-health"], + "keep explicit runner labels and capacity/freshness evidence current" + if not runner_action_ids + else "close runner owner attestation before expanding workflow concurrency", + {"runner_contracts_requiring_action": runner_action_ids}, + p0=True, + ), + _capability( + "gitea_actions_secrets_variables", + "automation", + "Actions secrets and variables", + "source_ready" + if _int(secret_rollups.get("secret_reference_count")) > 0 + else "authenticated_inventory_required", + "workflow secret/variable names are inventoried without reading values" + if _int(secret_rollups.get("secret_reference_count")) > 0 + else "workflow variable/secret configuration metadata still needs inventory", + [ + ".gitea/workflows", + "GET /api/v1/agents/gitea-actions-secret-variable-name-inventory", + ], + "verify configured Gitea secret/variable presence with redacted metadata only", + secret_rollups, + p0=True, + ), + _capability( + "webhooks_integrations", + "integration", + "Webhooks and notification integrations", + "partially_ready" if quiet_notification_count > 0 else "action_required", + "notification policy exists; delivery receipt chain still needs live closure", + ["GET /api/v1/agents/backup-notification-policy"], + "attach webhook delivery and Telegram/AwoooP receipt readback to backup/CD events", + ), + _capability( + "branch_protection_required_checks", + "governance", + "Branch protection and required checks", + "source_ready", + "required-check source policy is mapped for main/dev across product repos", + [ + "GET /api/v1/agents/gitea-branch-protection-required-checks-readback", + "GET /api/v1/agents/gitea-private-inventory-p0-scorecard", + ], + "verify live Gitea branch settings with redacted metadata before controlled apply", + p0=True, + ), + _capability( + "api_admin_audit", + "governance", + "API, admin audit, and settings inventory", + "authenticated_inventory_required", + "admin/API usage needs redacted non-secret inventory", + ["GET /api/v1/agents/gitea-private-inventory-p0-scorecard"], + "collect settings metadata only; do not collect tokens, sessions, or app.ini secrets", + ), + _capability( + "backup_dr_restore", + "resilience", + "Repository backup, offsite DR, and restore drills", + "verified_ready" + if bundle_ready and full_dr_verified_ready + else "source_ready" + if bundle_ready and full_dr_source_ready + else "action_required", + "bundle backup and full Gitea DR receipts are ready" + if bundle_ready and full_dr_verified_ready + else "repo bundle is ready and full Gitea DR source receipts are wired; controlled restore drill remains separate" + if bundle_ready and full_dr_source_ready + else "repo bundle is only one layer; full Gitea DR still has action rows", + [ + "GET /api/v1/agents/gitea-repo-bundle-backup-readback", + "GET /api/v1/agents/gitea-full-server-backup-dr-receipts", + "GET /api/v1/agents/backup-dr-readiness-matrix", + ], + "deploy the full-server receipt writer, wait for normal backup cadence, then run controlled restore drill verification", + { + "repo_bundle_ready": bundle_ready, + "repo_bundle_expected_repo_count": _int( + repo_bundle_summary.get("expected_repo_count") + ), + "backup_dr_blocked_row_count": backup_blocked_rows, + "backup_dr_action_required_row_count": backup_action_rows, + "gitea_full_dr_active_gap_count": _int(full_dr.get("active_gap_count")), + "gitea_full_dr_source_ready_component_count": _int( + full_dr_summary.get("source_ready_component_count") + ), + "gitea_full_dr_verified_ready_component_count": _int( + full_dr_summary.get("verified_ready_component_count") + ), + }, + p0=True, + ), + _capability( + "observability_alert_receipts", + "observability", + "CI/CD, backup, and Gitea alert receipt chain", + "action_required", + "policies exist, but live alert receipt chain must close before calling it complete", + [ + "GET /api/v1/agents/gitea-cicd-alert-receipt-chain-readback", + "GET /api/v1/agents/backup-notification-policy", + "GET /api/v1/agents/telegram-alert-monitoring-coverage-readback", + ], + "make backup/CD/Gitea alerts emit verifiable receipt-stage evidence", + p0=True, + ), + ] + + optimizations = [ + _optimization( + "gitea_actions_source_authority", + "implemented" if workflow_source_count > 0 else "action_required", + "Use `.gitea/workflows` as the only active workflow source", + "committed workflow source is present" + if workflow_source_count > 0 + else "no committed Gitea workflow source found", + [".gitea/workflows"], + "keep GitHub-frozen artifacts out of active release math", + {"workflow_source_file_count": workflow_source_count}, + p0=True, + ), + _optimization( + "dev_prod_branch_promotion_contract", + "implemented" if all_repos_have_dev_main else "action_required", + "Promote from dev to main with explicit branch-pair evidence", + "all expected product repos expose dev/main refs" + if all_repos_have_dev_main + else "dev/main refs are incomplete", + ["docs/operations/gitea-all-product-dev-prod-repo-readback.snapshot.json"], + "keep promotion gates tied to branch refs and deploy markers", + p0=True, + ), + _optimization( + "workflow_contract_freshness", + "implemented" if workflow_contract_fresh else "action_required", + "Keep workflow health contract equal to committed workflow source", + "workflow source and contract counts match" + if workflow_contract_fresh + else "workflow source has a different count than the health contract", + [ + ".gitea/workflows", + "docs/evaluations/gitea_workflow_runner_health_2026-06-05.json", + ], + "keep workflow health snapshot equal to committed workflow source" + if workflow_contract_fresh + else "refresh workflow health snapshot and runner contract for all current workflows", + { + "workflow_source_file_count": workflow_source_count, + "workflow_contract_count": workflow_contract_count, + }, + p0=True, + ), + _optimization( + "runner_label_isolation_attestation", + "implemented" if not runner_action_ids else "action_required", + "Require explicit runner labels and owner attestation", + "runner contract has no action-required labels" + if not runner_action_ids + else "runner label contract still requires action", + ["GET /api/v1/agents/gitea-workflow-runner-health"], + "keep explicit runner labels and capacity/freshness evidence current" + if not runner_action_ids + else "complete runner owner attestation before adding heavy workflow load", + {"runner_contracts_requiring_action": runner_action_ids}, + p0=True, + ), + _optimization( + "controlled_manual_dispatch_guard", + "implemented" + if known_workflows.get("has_harbor_repair") + and known_workflows.get("has_run_migration") + and known_workflows.get("has_onboarding_warning") + else "action_required", + "Keep repair and migration workflows behind controlled manual dispatch", + "repair/migration/onboarding guard workflows are present", + [ + ".gitea/workflows/harbor-110-local-repair.yaml", + ".gitea/workflows/run-migration.yml", + ".gitea/workflows/awoooi-onboarding-warning-step.yaml", + ], + "keep destructive or high-blast-radius jobs manual and guarded", + p0=True, + ), + _optimization( + "failure_only_notification_policy", + "implemented" if quiet_notification_count >= 2 else "action_required", + "Suppress success noise and escalate failures/action-required states", + "quiet notification policy is committed", + ["GET /api/v1/agents/backup-notification-policy"], + "connect policy to live notification receipt stages", + ), + _optimization( + "backup_bundle_sample_restore", + "implemented" if bundle_ready else "action_required", + "Verify repository bundles with sample restore dry-runs", + "repo bundle backup readback is ready" + if bundle_ready + else "repo bundle backup readback has active blockers", + ["GET /api/v1/agents/gitea-repo-bundle-backup-readback"], + "keep sample restore dry-run fresh without restoring production", + p0=True, + ), + _optimization( + "full_gitea_server_offsite_dr_receipts", + "implemented" + if full_dr_verified_ready + else "source_ready" + if full_dr_source_ready + else "action_required", + "Add full Gitea server offsite DR receipts beyond repository bundles", + "full server backup/DR component receipts are live" + if full_dr_verified_ready + else "full server backup/DR source receipts are wired; live backup run and restore drill receipt remain" + if full_dr_source_ready + else "full server backup/DR matrix still has open rows", + [ + "GET /api/v1/agents/gitea-full-server-backup-dr-receipts", + "GET /api/v1/agents/backup-dr-readiness-matrix", + ], + "wait for normal Gitea backup receipt metrics and controlled restore-drill approval", + { + "backup_dr_blocked_row_count": backup_blocked_rows, + "backup_dr_action_required_row_count": backup_action_rows, + "gitea_full_dr_active_gap_ids": full_dr_gap_ids, + "gitea_full_dr_source_ready_component_count": _int( + full_dr_summary.get("source_ready_component_count") + ), + "gitea_full_dr_component_receipt_count": _int( + full_dr_summary.get("gitea_full_backup_component_receipt_count") + ), + }, + p0=True, + ), + _optimization( + "post_deploy_marker_readback", + "source_ready" if source_ready else "action_required", + "Require deploy marker and post-deploy readback before release closure", + "CI/CD baseline source is present" + if source_ready + else "CI/CD baseline source is missing required files", + ["GET /api/v1/agents/p0-cicd-baseline-source-readiness"], + "tie CD success, deployed marker, smoke, and runtime health into one receipt", + p0=True, + ), + _optimization( + "stale_actions_api_quarantine", + "source_ready", + "Quarantine stale/mismatched Actions API payloads against visible run truth", + "public actions queue reader source is available", + ["ops/runner/read-public-gitea-actions-queue.py"], + "prefer visible run page and deployed marker when API payload is stale", + ), + _optimization( + "package_registry_release_promotion", + "controlled_apply_candidate", + "Promote release artifacts through package/release channels", + "artifact channel is not yet standardized for every product", + ["GET /api/v1/agents/package-supply-chain-inventory"], + "add package registry, release tag, changelog, and rollback artifact receipts", + ), + _optimization( + "branch_protection_required_checks", + "source_ready", + "Enforce main/dev protection with required checks", + "source policy maps main/dev required checks; live settings metadata remains next", + [ + "GET /api/v1/agents/gitea-branch-protection-required-checks-readback", + "GET /api/v1/agents/gitea-private-inventory-p0-scorecard", + ], + "compare redacted live branch settings against source policy before controlled apply", + p0=True, + ), + _optimization( + "dependency_sbom_and_cache_strategy", + "action_required", + "Add dependency cache, SBOM, vulnerability and license receipt lanes", + "dependency governance exists but is not yet tied to every Gitea workflow", + [ + "GET /api/v1/agents/dependency-supply-chain-drift-monitor", + "GET /api/v1/agents/package-supply-chain-inventory", + ], + "wire cache keys, SBOM output, and drift evidence into CI artifacts", + ), + _optimization( + "artifact_receipt_retention", + "action_required", + "Retain test, build, deploy, backup, and alert receipts per run", + "receipt endpoints exist but retention is not yet unified across all workflows", + [ + "GET /api/v1/agents/gitea-workflow-runner-health", + "GET /api/v1/agents/backup-dr-readiness-matrix", + ], + "standardize artifact naming and retention windows for Gitea Actions", + ), + _optimization( + "dedupe_cancel_stale_run_guard", + "source_ready" if known_workflows.get("has_cd_pipeline") else "design_required", + "Implement Gitea-compatible stale-run dedupe guards", + "CD source includes stale-run guard; live receipt wiring is the remaining work" + if known_workflows.get("has_cd_pipeline") + else "Gitea Actions compatibility requires workflow/runner-side guards for some controls", + [".gitea/workflows/cd.yaml", "ops/runner/read-public-gitea-actions-queue.py"], + "wire stale-run guard receipts into the Gitea capability readback" + if known_workflows.get("has_cd_pipeline") + else "add lease/marker based cancel-stale semantics instead of relying on unsupported syntax", + p0=True, + ), + ] + + rollups = _build_rollups( + capabilities=capabilities, + optimizations=optimizations, + dev_prod_summary=dev_prod_summary, + workflow_source_count=workflow_source_count, + workflow_contract_count=workflow_contract_count, + source_ready=source_ready, + ) + blockers = [ + item["id"] + for item in capabilities + optimizations + if item.get("priority") == "P0" + and item.get("status") + in { + "action_required", + "authenticated_inventory_required", + "design_required", + "source_detected_inventory_required", + } + ] + status_value = ( + "gitea_capability_ci_cd_optimization_ready" + if not blockers + else "gitea_capability_ci_cd_optimization_gaps_present" + ) + + payload = { + "schema_version": SCHEMA_VERSION, + "generated_at": now, + "source_control_authority": "gitea", + "scope": "complete_gitea_capability_and_professional_ci_cd_optimization", + "status": status_value, + "ready": not blockers, + "active_p0_gap_ids": blockers, + "active_p0_gap_count": len(blockers), + "operation_boundaries": { + "read_only_api_allowed": True, + "gitea_live_write_allowed": False, + "workflow_trigger_allowed": False, + "repo_creation_allowed": False, + "repo_visibility_change_allowed": False, + "refs_sync_allowed": False, + "runner_restart_allowed": False, + "secret_value_read_allowed": False, + "raw_session_or_sqlite_read_allowed": False, + "github_api_allowed": False, + "github_cli_allowed": False, + "host_or_k8s_write_allowed": False, + }, + "readback": { + "current_goal": ( + "make every Gitea capability and CI/CD professional practice " + "visible, prioritized, and verifiable" + ), + "docs_version_basis": "Gitea 1.25.5 capability model", + "primary_runtime_evidence": [ + "GET /api/v1/agents/gitea-private-inventory-p0-scorecard", + "GET /api/v1/agents/gitea-workflow-runner-health", + "GET /api/v1/agents/gitea-repo-bundle-backup-readback", + "GET /api/v1/agents/backup-dr-readiness-matrix", + "GET /api/v1/agents/backup-notification-policy", + "GET /api/v1/agents/p0-cicd-baseline-source-readiness", + ], + "official_gitea_feature_refs": [ + "https://docs.gitea.com/1.25/usage/access-control/permissions", + "https://docs.gitea.com/1.25/usage/actions/overview", + "https://docs.gitea.com/1.25/usage/actions/secrets", + "https://docs.gitea.com/1.25/usage/actions/actions-variables", + "https://docs.gitea.com/1.25/usage/packages/overview", + "https://docs.gitea.com/1.25/usage/repository/webhooks", + ], + "safe_next_actions": _safe_next_actions(capabilities, optimizations), + }, + "rollups": rollups, + "workflow_source_inventory": workflow_source_inventory, + "capability_rows": capabilities, + "ci_cd_optimization_rows": optimizations, + } + _require_payload(payload, "gitea_capability_ci_cd_optimization_readback") + return payload + + +def _capability( + capability_id: str, + domain: str, + title: str, + status_value: str, + usage_state: str, + evidence_refs: list[str], + next_action: str, + metrics: dict[str, Any] | None = None, + *, + p0: bool = False, +) -> dict[str, Any]: + return { + "id": capability_id, + "domain": domain, + "title": title, + "status": status_value, + "priority": "P0" if p0 else "P1", + "usage_state": usage_state, + "evidence_refs": evidence_refs, + "next_action": next_action, + "metrics": metrics or {}, + } + + +def _optimization( + optimization_id: str, + status_value: str, + title: str, + usage_state: str, + evidence_refs: list[str], + next_action: str, + metrics: dict[str, Any] | None = None, + *, + p0: bool = False, +) -> dict[str, Any]: + return { + "id": optimization_id, + "status": status_value, + "priority": "P0" if p0 else "P1", + "title": title, + "usage_state": usage_state, + "evidence_refs": evidence_refs, + "next_action": next_action, + "metrics": metrics or {}, + } + + +def _build_rollups( + *, + capabilities: list[dict[str, Any]], + optimizations: list[dict[str, Any]], + dev_prod_summary: dict[str, Any], + workflow_source_count: int, + workflow_contract_count: int, + source_ready: bool, +) -> dict[str, Any]: + capability_status = _count_by(capabilities, "status") + optimization_status = _count_by(optimizations, "status") + p0_capabilities = [row for row in capabilities if row.get("priority") == "P0"] + p0_optimizations = [row for row in optimizations if row.get("priority") == "P0"] + return { + "capability_count": len(capabilities), + "capability_status_counts": capability_status, + "verified_capability_count": capability_status.get("verified_ready", 0), + "capability_action_required_count": capability_status.get("action_required", 0), + "capability_inventory_required_count": ( + capability_status.get("authenticated_inventory_required", 0) + + capability_status.get("source_detected_inventory_required", 0) + ), + "p0_capability_count": len(p0_capabilities), + "p0_capability_gap_count": _gap_count(p0_capabilities), + "ci_cd_optimization_count": len(optimizations), + "ci_cd_optimization_status_counts": optimization_status, + "implemented_optimization_count": optimization_status.get("implemented", 0), + "source_ready_optimization_count": optimization_status.get("source_ready", 0), + "ci_cd_optimization_action_required_count": optimization_status.get( + "action_required", 0 + ), + "p0_optimization_count": len(p0_optimizations), + "p0_optimization_gap_count": _gap_count(p0_optimizations), + "expected_product_count": _int(dev_prod_summary.get("expected_product_count")), + "ssh_verified_product_repo_count": _int( + dev_prod_summary.get("ssh_repo_present_count") + ), + "dev_prod_environment_split_ready_count": _int( + dev_prod_summary.get("environment_split_ready_count") + ), + "workflow_source_file_count": workflow_source_count, + "workflow_contract_count": workflow_contract_count, + "workflow_contract_fresh": workflow_source_count == workflow_contract_count, + "cicd_required_sources_ready": source_ready, + } + + +def _safe_next_actions( + capabilities: list[dict[str, Any]], + optimizations: list[dict[str, Any]], +) -> list[str]: + rows = [ + row + for row in capabilities + optimizations + if row.get("priority") == "P0" + and row.get("status") + not in {"verified_ready", "implemented", "source_ready"} + ] + return [ + f"{row['id']}: {row['next_action']}" + for row in rows[:8] + ] + + +def _require_payload(payload: dict[str, Any], label: str) -> None: + if payload.get("schema_version") != SCHEMA_VERSION: + raise ValueError(f"{label}: invalid schema_version") + if payload.get("source_control_authority") != "gitea": + raise ValueError(f"{label}: source_control_authority must be gitea") + boundaries = _dict(payload.get("operation_boundaries")) + if boundaries.get("read_only_api_allowed") is not True: + raise ValueError(f"{label}: read_only_api_allowed must be true") + blocked_true_flags = { + "gitea_live_write_allowed", + "workflow_trigger_allowed", + "repo_creation_allowed", + "repo_visibility_change_allowed", + "refs_sync_allowed", + "runner_restart_allowed", + "secret_value_read_allowed", + "raw_session_or_sqlite_read_allowed", + "github_api_allowed", + "github_cli_allowed", + "host_or_k8s_write_allowed", + } + unexpected = sorted( + flag for flag in blocked_true_flags if boundaries.get(flag) is not False + ) + if unexpected: + raise ValueError(f"{label}: blocked operation flags must be false: {unexpected}") + + capabilities = _list(payload.get("capability_rows")) + optimizations = _list(payload.get("ci_cd_optimization_rows")) + capability_ids = {str(row.get("id") or "") for row in capabilities if isinstance(row, dict)} + optimization_ids = { + str(row.get("id") or "") for row in optimizations if isinstance(row, dict) + } + missing_capabilities = sorted(_REQUIRED_CAPABILITY_IDS - capability_ids) + missing_optimizations = sorted(_REQUIRED_OPTIMIZATION_IDS - optimization_ids) + if missing_capabilities: + raise ValueError(f"{label}: missing capability rows: {missing_capabilities}") + if missing_optimizations: + raise ValueError(f"{label}: missing optimization rows: {missing_optimizations}") + + rollups = _dict(payload.get("rollups")) + if rollups.get("capability_count") != len(capabilities): + raise ValueError(f"{label}: capability_count mismatch") + if rollups.get("ci_cd_optimization_count") != len(optimizations): + raise ValueError(f"{label}: ci_cd_optimization_count mismatch") + if rollups.get("capability_status_counts") != _count_by(capabilities, "status"): + raise ValueError(f"{label}: capability_status_counts mismatch") + if rollups.get("ci_cd_optimization_status_counts") != _count_by( + optimizations, + "status", + ): + raise ValueError(f"{label}: ci_cd_optimization_status_counts mismatch") + _require_no_plaintext_secret_keys(payload, label) + + +def _require_no_plaintext_secret_keys(value: Any, label: str, path: str = "$") -> None: + forbidden = { + "secret_value", + "token_value", + "authorization_header", + "private_key", + "runner_token", + "webhook_secret", + "cookie", + "session", + } + if isinstance(value, dict): + for key, child in value.items(): + lowered = str(key).lower() + if lowered in forbidden or lowered.endswith("_secret_value"): + raise ValueError(f"{label}: forbidden plaintext-like key at {path}.{key}") + _require_no_plaintext_secret_keys(child, label, f"{path}.{key}") + elif isinstance(value, list): + for index, child in enumerate(value): + _require_no_plaintext_secret_keys(child, label, f"{path}[{index}]") + + +def _has_yaml_key(text: str, key: str) -> bool: + return re.search(rf"(?m)^\s*{re.escape(key)}\s*:", text) is not None + + +def _extract_runner_labels(text: str) -> list[str]: + labels: set[str] = set() + for match in re.finditer(r"(?m)^\s*runs-on\s*:\s*(.+?)\s*$", text): + value = match.group(1).split("#", 1)[0].strip() + value = value.strip("[]") + for part in value.split(","): + label = part.strip().strip("'\"") + if label: + labels.add(label) + return sorted(labels) + + +def _load_json(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, dict): + raise ValueError(f"{path}: expected JSON object") + return payload + + +def _count_by(rows: list[dict[str, Any]], key: str) -> dict[str, int]: + counts: dict[str, int] = {} + for row in rows: + value = str(row.get(key) or "") + counts[value] = counts.get(value, 0) + 1 + return counts + + +def _count_where(rows: list[dict[str, Any]], key: str) -> int: + return sum(1 for row in rows if row.get(key) is True) + + +def _gap_count(rows: list[dict[str, Any]]) -> int: + ready_statuses = {"verified_ready", "implemented", "source_ready"} + return sum(1 for row in rows if row.get("status") not in ready_statuses) + + +def _dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _strings(value: Any) -> list[str]: + return [str(item) for item in value] if isinstance(value, list) else [] + + +def _int(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 diff --git a/apps/api/src/services/gitea_cicd_alert_receipt_chain_readback.py b/apps/api/src/services/gitea_cicd_alert_receipt_chain_readback.py new file mode 100644 index 000000000..f92170e67 --- /dev/null +++ b/apps/api/src/services/gitea_cicd_alert_receipt_chain_readback.py @@ -0,0 +1,246 @@ +"""Gitea CI/CD, backup, and alert receipt chain readback.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +from typing import Any +from zoneinfo import ZoneInfo + +from src.services.backup_notification_policy import ( + load_latest_backup_notification_policy, +) +from src.services.gitea_full_server_backup_dr_receipts import ( + load_latest_gitea_full_server_backup_dr_receipts, +) +from src.services.gitea_workflow_runner_health import ( + load_latest_gitea_workflow_runner_health, +) +from src.services.telegram_alert_monitoring_coverage_readback import ( + load_latest_telegram_alert_monitoring_coverage_readback, +) + +SCHEMA_VERSION = "gitea_cicd_alert_receipt_chain_readback_v1" +TZ_TAIPEI = ZoneInfo("Asia/Taipei") + + +async def load_latest_gitea_cicd_alert_receipt_chain_readback( + *, + project_id: str = "awoooi", +) -> dict[str, Any]: + """Load live/source alert receipt chain readback without sending messages.""" + telegram_coverage, backup_policy, workflow_health, full_dr = await asyncio.gather( + load_latest_telegram_alert_monitoring_coverage_readback(project_id=project_id), + asyncio.to_thread(load_latest_backup_notification_policy), + asyncio.to_thread(load_latest_gitea_workflow_runner_health), + asyncio.to_thread(load_latest_gitea_full_server_backup_dr_receipts), + ) + return build_gitea_cicd_alert_receipt_chain_readback( + telegram_coverage=telegram_coverage, + backup_notification_policy=backup_policy, + workflow_runner_health=workflow_health, + full_server_backup_dr_receipts=full_dr, + ) + + +def build_gitea_cicd_alert_receipt_chain_readback( + *, + telegram_coverage: dict[str, Any], + backup_notification_policy: dict[str, Any], + workflow_runner_health: dict[str, Any], + full_server_backup_dr_receipts: dict[str, Any], + generated_at: str | None = None, +) -> dict[str, Any]: + """Compose CI/CD and backup alert receipt chain coverage.""" + now = generated_at or datetime.now(TZ_TAIPEI).isoformat(timespec="seconds") + coverage_summary = _dict(telegram_coverage.get("summary")) + backup_rollups = _dict(backup_notification_policy.get("rollups")) + workflow_rollups = _dict(workflow_runner_health.get("rollups")) + full_dr_summary = _dict(full_server_backup_dr_receipts.get("summary")) + + monitoring_gap_count = _int(coverage_summary.get("monitoring_live_receipt_gap_count")) + full_dr_gap_count = _int(full_server_backup_dr_receipts.get("active_gap_count")) + quiet_contract_count = len(_list(backup_rollups.get("suppressed_success_rule_ids"))) + immediate_escalation_count = len( + _list(backup_rollups.get("immediate_escalation_rule_ids")) + ) + notify_workflow_count = _int(workflow_rollups.get("workflows_with_notify_bridge")) + + receipt_rows = [ + { + "chain_id": "gitea_cd_pipeline_notifications", + "domain": "cicd", + "status": "source_ready" if notify_workflow_count > 0 else "action_required", + "receipt_state": "notify_bridge_source_present", + "evidence_refs": [ + "GET /api/v1/agents/gitea-workflow-runner-health", + ".gitea/workflows/cd.yaml", + ], + "next_action": "bind CD notify bridge delivery receipts to deployed marker readback", + }, + { + "chain_id": "backup_notification_policy", + "domain": "backup", + "status": "policy_ready" + if quiet_contract_count > 0 and immediate_escalation_count > 0 + else "action_required", + "receipt_state": "failure_or_action_required_policy_present", + "evidence_refs": ["GET /api/v1/agents/backup-notification-policy"], + "next_action": "bind backup policy decisions to live receipt stage evidence", + "metrics": { + "suppressed_success_rule_count": quiet_contract_count, + "immediate_escalation_rule_count": immediate_escalation_count, + }, + }, + { + "chain_id": "gitea_full_server_dr_receipts", + "domain": "backup_dr", + "status": "action_required" if full_dr_gap_count else "verified_ready", + "receipt_state": "component_receipts_pending" + if full_dr_gap_count + else "component_receipts_ready", + "evidence_refs": ["GET /api/v1/agents/gitea-full-server-backup-dr-receipts"], + "next_action": "close full-server component receipt rows before DR complete", + "metrics": { + "full_server_active_gap_count": full_dr_gap_count, + "full_server_verified_ready_component_count": _int( + full_dr_summary.get("verified_ready_component_count") + ), + }, + }, + { + "chain_id": "telegram_monitoring_live_receipts", + "domain": "observability", + "status": "action_required" if monitoring_gap_count else "verified_ready", + "receipt_state": "monitoring_live_receipts_pending" + if monitoring_gap_count + else "monitoring_live_receipts_ready", + "evidence_refs": [ + "GET /api/v1/agents/telegram-alert-monitoring-coverage-readback" + ], + "next_action": "write metadata-only live receipt batches for monitoring surfaces", + "metrics": { + "monitoring_live_receipt_gap_count": monitoring_gap_count, + "alert_receipt_pipeline_ready_count": _int( + coverage_summary.get("alert_receipt_pipeline_ready_count") + ), + "alert_receipt_pipeline_stage_count": _int( + coverage_summary.get("alert_receipt_pipeline_stage_count") + ), + }, + }, + { + "chain_id": "ai_agent_context_receipts", + "domain": "ai_context", + "status": "source_ready" + if _int(coverage_summary.get("verified_ai_agent_context_receipt_count")) > 0 + else "action_required", + "receipt_state": "ai_context_receipts_projected", + "evidence_refs": [ + "GET /api/v1/agents/telegram-alert-monitoring-coverage-readback" + ], + "next_action": "keep KM/RAG/MCP/PlayBook context receipts metadata-only", + }, + ] + + active_gaps = [ + row["chain_id"] + for row in receipt_rows + if row["status"] == "action_required" + ] + payload = { + "schema_version": SCHEMA_VERSION, + "generated_at": now, + "source_control_authority": "gitea", + "scope": "gitea_cicd_backup_alert_receipt_chain", + "status": ( + "gitea_cicd_alert_receipt_chain_ready" + if not active_gaps + else "gitea_cicd_alert_receipt_chain_gaps_present" + ), + "ready": not active_gaps, + "active_gap_ids": active_gaps, + "active_gap_count": len(active_gaps), + "operation_boundaries": { + "read_only_api_allowed": True, + "metadata_read_allowed": True, + "telegram_send_allowed": False, + "bot_api_call_allowed": False, + "receiver_route_change_allowed": False, + "alertmanager_reload_allowed": False, + "backup_execution_allowed": False, + "restore_execution_allowed": False, + "workflow_trigger_allowed": False, + "secret_value_read_allowed": False, + "raw_payload_storage_allowed": False, + "github_api_allowed": False, + "host_or_k8s_write_allowed": False, + }, + "summary": { + "receipt_chain_count": len(receipt_rows), + "source_or_policy_ready_chain_count": sum( + 1 + for row in receipt_rows + if row["status"] in {"source_ready", "policy_ready", "verified_ready"} + ), + "action_required_chain_count": len(active_gaps), + "monitoring_live_receipt_gap_count": monitoring_gap_count, + "full_server_dr_active_gap_count": full_dr_gap_count, + "workflow_notify_bridge_count": notify_workflow_count, + "backup_quiet_success_contract_count": quiet_contract_count, + "backup_immediate_escalation_rule_count": immediate_escalation_count, + }, + "receipt_chain_rows": receipt_rows, + "safe_next_actions": [ + "write metadata-only live receipt batches for monitoring surfaces", + "bind backup/CD notification outcomes to receipt-stage evidence", + "keep Telegram sends disabled from this readback endpoint", + ], + } + _require_payload(payload, "gitea_cicd_alert_receipt_chain_readback") + return payload + + +def _require_payload(payload: dict[str, Any], label: str) -> None: + if payload.get("schema_version") != SCHEMA_VERSION: + raise ValueError(f"{label}: invalid schema_version") + boundaries = _dict(payload.get("operation_boundaries")) + if boundaries.get("read_only_api_allowed") is not True: + raise ValueError(f"{label}: read_only_api_allowed must be true") + blocked_flags = { + "telegram_send_allowed", + "bot_api_call_allowed", + "receiver_route_change_allowed", + "alertmanager_reload_allowed", + "backup_execution_allowed", + "restore_execution_allowed", + "workflow_trigger_allowed", + "secret_value_read_allowed", + "raw_payload_storage_allowed", + "github_api_allowed", + "host_or_k8s_write_allowed", + } + unexpected = sorted(flag for flag in blocked_flags if boundaries.get(flag) is not False) + if unexpected: + raise ValueError(f"{label}: blocked operation flags must be false: {unexpected}") + rows = _list(payload.get("receipt_chain_rows")) + summary = _dict(payload.get("summary")) + if summary.get("receipt_chain_count") != len(rows): + raise ValueError(f"{label}: receipt_chain_count mismatch") + if payload.get("active_gap_count") != len(_list(payload.get("active_gap_ids"))): + raise ValueError(f"{label}: active_gap_count mismatch") + + +def _dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _int(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 diff --git a/apps/api/src/services/gitea_full_server_backup_dr_receipts.py b/apps/api/src/services/gitea_full_server_backup_dr_receipts.py new file mode 100644 index 000000000..1d4826941 --- /dev/null +++ b/apps/api/src/services/gitea_full_server_backup_dr_receipts.py @@ -0,0 +1,553 @@ +"""Gitea full-server backup / DR receipt readback. + +Repository bundle backups are necessary but not the whole Gitea DR story. This +readback separates repository bundles, the Gitea server backup target, offsite +mirror evidence, restore-drill gates, and component receipts for database, +settings, LFS, packages, attachments, and hooks. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any +from urllib.parse import urlencode +from urllib.request import Request, urlopen +from zoneinfo import ZoneInfo + +from src.core.config import settings +from src.services.backup_dr_readiness_matrix import ( + load_latest_backup_dr_readiness_matrix, +) +from src.services.backup_dr_target_inventory import ( + load_latest_backup_dr_target_inventory, +) +from src.services.gitea_repo_bundle_backup_readback import ( + load_latest_gitea_repo_bundle_backup_readback, +) +from src.services.snapshot_paths import resolve_repo_root + +SCHEMA_VERSION = "gitea_full_server_backup_dr_receipts_v1" +TZ_TAIPEI = ZoneInfo("Asia/Taipei") +DEFAULT_HOST = "110" +PROMETHEUS_TIMEOUT_SECONDS = 4 +_GITEA_COMPONENT_IDS = { + "database": "gitea_database_receipt", + "app_settings": "gitea_app_settings_receipt", + "lfs_objects": "gitea_lfs_receipt", + "package_registry": "gitea_packages_receipt", + "attachments": "gitea_attachments_receipt", + "hooks_custom_assets": "gitea_hooks_custom_assets_receipt", +} + + +def load_latest_gitea_full_server_backup_dr_receipts( + *, + host: str = DEFAULT_HOST, + prometheus_url: str | None = None, + repo_root: Path | None = None, +) -> dict[str, Any]: + """Build the latest Gitea full-server backup / DR receipt readback.""" + samples: list[dict[str, Any]] = [] + errors: list[str] = [] + url = (prometheus_url or settings.PROMETHEUS_URL).rstrip("/") + for query in _prometheus_queries(host): + try: + samples.extend(_query_prometheus_vector(url, query)) + except Exception as exc: # noqa: BLE001 - fail closed with redacted error class + errors.append(f"{query.split('{', 1)[0]}:{exc.__class__.__name__}") + return build_gitea_full_server_backup_dr_receipts( + repo_bundle_backup_readback=load_latest_gitea_repo_bundle_backup_readback(), + backup_dr_readiness_matrix=load_latest_backup_dr_readiness_matrix(), + backup_dr_target_inventory=load_latest_backup_dr_target_inventory(), + metric_samples=samples, + metrics_error=",".join(errors), + source_contract=build_gitea_full_server_backup_source_contract( + repo_root or resolve_repo_root(Path(__file__)) + ), + host=host, + ) + + +def build_gitea_full_server_backup_source_contract(repo_root: Path) -> dict[str, Any]: + """Inspect committed Gitea backup/offsite source without reading secrets.""" + backup_script = repo_root / "scripts" / "backup" / "backup-gitea.sh" + sync_script = repo_root / "scripts" / "backup" / "sync-offsite-backups.sh" + verify_script = repo_root / "scripts" / "backup" / "verify-offsite-full-sync.sh" + backup_text = _read_text(backup_script) + sync_text = _read_text(sync_script) + verify_text = _read_text(verify_script) + metric_writer_present = ( + "awoooi_gitea_full_backup_component_receipt" in backup_text + and "secret_value_collected" in backup_text + and "production_restore_performed" in backup_text + ) + gitea_dump_present = "gitea dump" in backup_text and "/tmp/gitea-dump.zip" in backup_text + restic_target_present = "restic -r" in backup_text and ( + "/backup" in backup_text or "BACKUP_BASE" in backup_text + ) + offsite_source_ready = ( + "OFFSITE_RCLONE_REMOTE" in sync_text + and "gdrive" in sync_text + and "awoooi_backup_offsite_remote_verify_ok" in verify_text + ) + component_rows = [ + { + "component_id": component_id, + "metric_component": metric_component, + "source_status": "source_ready" + if metric_writer_present and gitea_dump_present and restic_target_present + else "source_missing", + "source_refs": ["scripts/backup/backup-gitea.sh"], + "metric_name": "awoooi_gitea_full_backup_component_receipt", + } + for metric_component, component_id in _GITEA_COMPONENT_IDS.items() + ] + return { + "schema_version": "gitea_full_server_backup_source_contract_v1", + "source_refs": [ + "scripts/backup/backup-gitea.sh", + "scripts/backup/sync-offsite-backups.sh", + "scripts/backup/verify-offsite-full-sync.sh", + ], + "gitea_dump_source_ready": gitea_dump_present, + "restic_target_source_ready": restic_target_present, + "component_metric_writer_source_ready": metric_writer_present, + "google_drive_offsite_source_ready": offsite_source_ready, + "component_rows": component_rows, + "source_ready_component_count": sum( + 1 for row in component_rows if row["source_status"] == "source_ready" + ), + "secret_values_collected_by_source_inspection": False, + } + + +def build_gitea_full_server_backup_dr_receipts( + *, + repo_bundle_backup_readback: dict[str, Any], + backup_dr_readiness_matrix: dict[str, Any], + backup_dr_target_inventory: dict[str, Any], + metric_samples: list[dict[str, Any]] | None = None, + metrics_error: str = "", + source_contract: dict[str, Any] | None = None, + host: str = DEFAULT_HOST, + generated_at: str | None = None, +) -> dict[str, Any]: + """Compose full-server Gitea backup evidence without executing backups.""" + now = generated_at or datetime.now(TZ_TAIPEI).isoformat(timespec="seconds") + readiness_rows = _rows(backup_dr_readiness_matrix.get("readiness_rows")) + target_rows = _rows(backup_dr_target_inventory.get("backup_targets")) + gitea_readiness = _find(readiness_rows, "target_id", "gitea") + offsite_readiness = _find(readiness_rows, "target_id", "offsite_rclone_full_sync") + gitea_target = _find(target_rows, "target_id", "gitea") + repo_bundle_ready = repo_bundle_backup_readback.get("ready") is True + repo_bundle_summary = _dict(repo_bundle_backup_readback.get("summary")) + contract = _dict(source_contract) or { + "schema_version": "gitea_full_server_backup_source_contract_v1", + "source_refs": [], + "component_rows": [], + "source_ready_component_count": 0, + "secret_values_collected_by_source_inspection": False, + } + receipt_metrics = _receipt_metric_rollup(metric_samples or [], host) + + component_rows = [ + { + "component_id": "repository_bundles", + "display_name": "Gitea repository bundles", + "status": "verified_ready" if repo_bundle_ready else "action_required", + "evidence_refs": ["GET /api/v1/agents/gitea-repo-bundle-backup-readback"], + "receipt_state": "fresh_bundle_and_sample_restore" + if repo_bundle_ready + else "bundle_readback_gap", + "restore_scope": "sample_restore_dry_run_only", + "production_restore_performed": False, + "metrics": { + "expected_repo_count": _int(repo_bundle_summary.get("expected_repo_count")), + "repo_row_count": _int(repo_bundle_summary.get("repo_row_count")), + "sample_restore_dry_run_ok": repo_bundle_summary.get( + "sample_restore_dry_run_ok" + ) + is True, + }, + }, + _target_component( + "gitea_server_backup_target", + "Gitea server backup target", + gitea_readiness, + gitea_target, + "full_server_backup_target_ready", + ), + _target_component( + "google_drive_offsite_mirror", + "Google Drive / rclone offsite mirror", + offsite_readiness, + _find(target_rows, "target_id", "offsite_rclone_full_sync"), + "offsite_mirror_verified", + ), + { + "component_id": "restore_drill_approval_gate", + "display_name": "Restore drill approval gate", + "status": "verified_ready" + if receipt_metrics["restore_drill_performed"] + else "approval_required", + "evidence_refs": ["GET /api/v1/agents/backup-dr-readiness-matrix"], + "receipt_state": "restore_drill_receipt_present" + if receipt_metrics["restore_drill_performed"] + else "restore_drill_not_executed_by_readback", + "restore_scope": "restore requires controlled drill approval", + "production_restore_performed": False, + "metrics": { + "gitea_restore_drill_status": str( + gitea_readiness.get("restore_drill_status") or "" + ), + "restore_execution_allowed": False, + "restore_drill_performed_metric": receipt_metrics[ + "restore_drill_performed" + ], + }, + }, + *_component_receipt_rows(contract, receipt_metrics), + ] + + active_gaps = [ + row["component_id"] + for row in component_rows + if row["status"] in {"action_required", "receipt_required", "approval_required"} + ] + status_value = ( + "gitea_full_server_backup_dr_receipts_ready" + if not active_gaps + else "gitea_full_server_backup_dr_receipts_gaps_present" + ) + payload = { + "schema_version": SCHEMA_VERSION, + "generated_at": now, + "source_control_authority": "gitea", + "scope": "gitea_full_server_backup_dr_receipts", + "host": host, + "status": status_value, + "ready": not active_gaps, + "active_gap_ids": active_gaps, + "active_gap_count": len(active_gaps), + "operation_boundaries": { + "read_only_api_allowed": True, + "backup_execution_allowed": False, + "restore_execution_allowed": False, + "production_restore_allowed": False, + "offsite_sync_execution_allowed": False, + "credential_read_allowed": False, + "secret_value_read_allowed": False, + "gitea_repo_write_allowed": False, + "raw_session_or_sqlite_read_allowed": False, + "github_api_allowed": False, + "host_or_k8s_write_allowed": False, + }, + "summary": { + "component_count": len(component_rows), + "verified_ready_component_count": _count_status( + component_rows, + "verified_ready", + ), + "source_ready_component_count": _count_status( + component_rows, + "source_ready", + ), + "receipt_required_component_count": _count_status( + component_rows, + "receipt_required", + ), + "approval_required_component_count": _count_status( + component_rows, + "approval_required", + ), + "repo_bundle_ready": repo_bundle_ready, + "gitea_target_overall_readiness": str( + gitea_readiness.get("overall_readiness") or "" + ), + "gitea_target_offsite_status": str( + gitea_readiness.get("offsite_status") or "" + ), + "offsite_mirror_overall_readiness": str( + offsite_readiness.get("overall_readiness") or "" + ), + "gitea_full_backup_metrics_present": receipt_metrics["metrics_present"], + "gitea_full_backup_component_receipt_count": receipt_metrics[ + "component_receipt_count" + ], + "gitea_full_backup_metric_error": metrics_error, + "production_restore_performed": False, + }, + "source_contract": contract, + "receipt_metric_rollup": receipt_metrics, + "component_rows": component_rows, + "safe_next_actions": [ + "deploy backup-gitea.sh receipt metric writer and rerun the normal Gitea backup cadence", + "keep production restore disabled; use controlled restore-drill approval only", + "verify Google Drive/offsite freshness with redacted non-secret receipts", + ], + } + _require_payload(payload, "gitea_full_server_backup_dr_receipts") + return payload + + +def _target_component( + component_id: str, + display_name: str, + readiness: dict[str, Any], + target: dict[str, Any], + ready_receipt_state: str, +) -> dict[str, Any]: + ready = ( + readiness.get("overall_readiness") == "ready" + and readiness.get("offsite_status") == "verified" + ) + return { + "component_id": component_id, + "display_name": display_name, + "status": "verified_ready" if ready else "action_required", + "evidence_refs": _strings(readiness.get("evidence_refs")) + or _strings(target.get("evidence_refs")), + "receipt_state": ready_receipt_state if ready else "target_readiness_gap", + "restore_scope": str(readiness.get("gate_status") or target.get("restore_gate_status") or ""), + "production_restore_performed": False, + "metrics": { + "overall_readiness": str(readiness.get("overall_readiness") or ""), + "offsite_status": str(readiness.get("offsite_status") or ""), + "storage_ref": str(target.get("storage_ref") or ""), + }, + } + + +def _component_receipt_rows( + source_contract: dict[str, Any], + receipt_metrics: dict[str, Any], +) -> list[dict[str, Any]]: + component_specs = [ + ("gitea_database_receipt", "database", "Gitea database dump / restore receipt"), + ("gitea_app_settings_receipt", "app_settings", "Gitea app.ini and settings receipt"), + ("gitea_lfs_receipt", "lfs_objects", "Gitea LFS object receipt"), + ("gitea_packages_receipt", "package_registry", "Gitea package registry receipt"), + ("gitea_attachments_receipt", "attachments", "Gitea issue/wiki attachment receipt"), + ("gitea_hooks_custom_assets_receipt", "hooks_custom_assets", "Gitea hooks/custom assets receipt"), + ] + source_by_id = { + str(row.get("component_id") or ""): row + for row in _rows(source_contract.get("component_rows")) + } + metric_components = set(_strings(receipt_metrics.get("component_receipt_components"))) + rows: list[dict[str, Any]] = [] + for component_id, metric_component, display_name in component_specs: + source_ready = ( + _dict(source_by_id.get(component_id)).get("source_status") == "source_ready" + ) + metric_ready = metric_component in metric_components + if metric_ready: + status = "verified_ready" + receipt_state = "live_component_receipt_bound" + elif source_ready: + status = "source_ready" + receipt_state = "source_contract_ready_live_receipt_pending" + else: + status = "receipt_required" + receipt_state = "component_receipt_not_bound_to_readback" + rows.append( + { + "component_id": component_id, + "display_name": display_name, + "status": status, + "evidence_refs": [ + "scripts/backup/backup-gitea.sh", + "GET /api/v1/agents/backup-dr-readiness-matrix", + ], + "receipt_state": receipt_state, + "restore_scope": "restore drill approval required before execution", + "production_restore_performed": False, + "metrics": { + "metric_component": metric_component, + "component_metric_present": metric_ready, + "source_contract_present": source_ready, + "secret_value_collection_allowed": False, + "componentized_receipt_required": True, + }, + } + ) + return rows + + +def _require_payload(payload: dict[str, Any], label: str) -> None: + if payload.get("schema_version") != SCHEMA_VERSION: + raise ValueError(f"{label}: invalid schema_version") + boundaries = _dict(payload.get("operation_boundaries")) + if boundaries.get("read_only_api_allowed") is not True: + raise ValueError(f"{label}: read_only_api_allowed must be true") + blocked_flags = { + "backup_execution_allowed", + "restore_execution_allowed", + "production_restore_allowed", + "offsite_sync_execution_allowed", + "credential_read_allowed", + "secret_value_read_allowed", + "gitea_repo_write_allowed", + "raw_session_or_sqlite_read_allowed", + "github_api_allowed", + "host_or_k8s_write_allowed", + } + unexpected = sorted(flag for flag in blocked_flags if boundaries.get(flag) is not False) + if unexpected: + raise ValueError(f"{label}: blocked operation flags must be false: {unexpected}") + rows = _rows(payload.get("component_rows")) + summary = _dict(payload.get("summary")) + if summary.get("component_count") != len(rows): + raise ValueError(f"{label}: component_count mismatch") + if payload.get("active_gap_count") != len(_strings(payload.get("active_gap_ids"))): + raise ValueError(f"{label}: active_gap_count mismatch") + if summary.get("production_restore_performed") is not False: + raise ValueError(f"{label}: production_restore_performed must be false") + if _dict(payload.get("source_contract")).get( + "secret_values_collected_by_source_inspection" + ) is not False: + raise ValueError(f"{label}: source inspection must not collect secrets") + receipt_metrics = _dict(payload.get("receipt_metric_rollup")) + if receipt_metrics.get("secret_value_collected") is True: + raise ValueError(f"{label}: receipt metrics must not collect secrets") + if receipt_metrics.get("production_restore_performed") is True: + raise ValueError(f"{label}: receipt metrics must not restore production") + + +def _prometheus_queries(host: str) -> list[str]: + return [ + f"awoooi_gitea_full_backup_component_receipt{{host=\"{host}\"}}", + f"awoooi_gitea_full_backup_last_run_failed{{host=\"{host}\"}}", + f"awoooi_gitea_full_backup_restore_drill_performed{{host=\"{host}\"}}", + f"awoooi_gitea_full_backup_secret_value_collected{{host=\"{host}\"}}", + f"awoooi_gitea_full_backup_production_restore_performed{{host=\"{host}\"}}", + f"awoooi_backup_offsite_remote_verify_ok{{host=\"{host}\",provider=\"rclone\"}}", + f"awoooi_backup_offsite_full_verify_fresh{{host=\"{host}\",provider=\"rclone\"}}", + ] + + +def _query_prometheus_vector(prometheus_url: str, query: str) -> list[dict[str, Any]]: + params = urlencode({"query": query}) + request = Request( + f"{prometheus_url}/api/v1/query?{params}", + headers={"User-Agent": "awoooi-gitea-full-backup-dr-readback"}, + ) + with urlopen(request, timeout=PROMETHEUS_TIMEOUT_SECONDS) as response: # noqa: S310 + payload = json.loads(response.read().decode("utf-8", errors="replace")) + if payload.get("status") != "success": + raise ValueError("prometheus_status_not_success") + rows = payload.get("data", {}).get("result") or [] + results: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + labels = dict(row.get("metric") or {}) + name = str(labels.pop("__name__", query.split("{", 1)[0])) + value = row.get("value") or [None, "0"] + try: + scalar = float(value[1]) + except (IndexError, TypeError, ValueError): + continue + results.append({"name": name, "labels": labels, "value": scalar}) + return results + + +def _receipt_metric_rollup(samples: list[dict[str, Any]], host: str) -> dict[str, Any]: + filtered = [ + sample + for sample in samples + if str(_dict(sample.get("labels")).get("host", host)) == host + ] + component_receipts = sorted( + { + str(_dict(sample.get("labels")).get("component") or "") + for sample in filtered + if sample.get("name") == "awoooi_gitea_full_backup_component_receipt" + and _float(sample.get("value")) == 1 + and _dict(sample.get("labels")).get("component") + } + ) + scalar = { + str(sample.get("name")): _float(sample.get("value")) + for sample in filtered + if sample.get("name") != "awoooi_gitea_full_backup_component_receipt" + } + secret_value_collected = ( + scalar.get("awoooi_gitea_full_backup_secret_value_collected", 0.0) > 0 + ) + production_restore_performed = ( + scalar.get("awoooi_gitea_full_backup_production_restore_performed", 0.0) > 0 + ) + return { + "metrics_present": bool(filtered), + "component_receipt_components": component_receipts, + "component_receipt_count": len(component_receipts), + "last_run_failed": scalar.get( + "awoooi_gitea_full_backup_last_run_failed", + 0.0, + ) + > 0, + "restore_drill_performed": scalar.get( + "awoooi_gitea_full_backup_restore_drill_performed", + 0.0, + ) + > 0, + "secret_value_collected": secret_value_collected, + "production_restore_performed": production_restore_performed, + "offsite_remote_verify_ok": scalar.get( + "awoooi_backup_offsite_remote_verify_ok", + 0.0, + ) + == 1, + "offsite_full_verify_fresh": scalar.get( + "awoooi_backup_offsite_full_verify_fresh", + 0.0, + ) + == 1, + } + + +def _count_status(rows: list[dict[str, Any]], status: str) -> int: + return sum(1 for row in rows if row.get("status") == status) + + +def _find(rows: list[dict[str, Any]], key: str, value: str) -> dict[str, Any]: + for row in rows: + if row.get(key) == value: + return row + return {} + + +def _rows(value: Any) -> list[dict[str, Any]]: + return [row for row in value if isinstance(row, dict)] if isinstance(value, list) else [] + + +def _dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _strings(value: Any) -> list[str]: + return [str(item) for item in value] if isinstance(value, list) else [] + + +def _int(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def _float(value: Any) -> float: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _read_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" diff --git a/apps/api/tests/test_gitea_actions_secret_variable_name_inventory_api.py b/apps/api/tests/test_gitea_actions_secret_variable_name_inventory_api.py new file mode 100644 index 000000000..0cea78e89 --- /dev/null +++ b/apps/api/tests/test_gitea_actions_secret_variable_name_inventory_api.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.v1.agents import router +from src.services.gitea_actions_secret_variable_name_inventory import ( + load_latest_gitea_actions_secret_variable_name_inventory, +) + + +def test_gitea_actions_secret_variable_name_inventory_loader_is_value_safe() -> None: + payload = load_latest_gitea_actions_secret_variable_name_inventory() + + assert payload["schema_version"] == "gitea_actions_secret_variable_name_inventory_v1" + assert payload["status"] == "gitea_actions_secret_variable_name_inventory_ready" + assert payload["ready"] is True + assert payload["summary"]["workflow_file_count"] == 12 + assert payload["summary"]["referenced_secret_name_count"] == 26 + assert payload["summary"]["referenced_variable_name_count"] == 0 + assert payload["summary"]["secret_values_collected"] is False + assert payload["summary"]["variable_values_collected"] is False + assert payload["operation_boundaries"]["secret_value_read_allowed"] is False + assert payload["operation_boundaries"]["variable_value_read_allowed"] is False + assert payload["operation_boundaries"]["gitea_api_write_allowed"] is False + assert payload["operation_boundaries"]["workflow_trigger_allowed"] is False + + names = {row["name"] for row in payload["secret_name_rows"]} + assert "TELEGRAM_BOT_TOKEN" in names + assert "AWOOOI_GITEA_API_TOKEN" in names + assert "DATABASE_URL" in names + assert all(row["value_read_status"] == "not_read_by_design" for row in payload["secret_name_rows"]) + assert payload["metadata_verification_next_step"]["status"] == ( + "redacted_configuration_metadata_required" + ) + + +def test_gitea_actions_secret_variable_name_inventory_endpoint_returns_payload() -> None: + app = FastAPI() + app.include_router(router, prefix="/api/v1") + client = TestClient(app) + + response = client.get("/api/v1/agents/gitea-actions-secret-variable-name-inventory") + + assert response.status_code == 200 + data = response.json() + assert data["schema_version"] == "gitea_actions_secret_variable_name_inventory_v1" + assert data["summary"]["referenced_secret_name_count"] == len(data["secret_name_rows"]) + assert data["summary"]["secret_values_collected"] is False diff --git a/apps/api/tests/test_gitea_branch_protection_required_checks_readback_api.py b/apps/api/tests/test_gitea_branch_protection_required_checks_readback_api.py new file mode 100644 index 000000000..d4e336164 --- /dev/null +++ b/apps/api/tests/test_gitea_branch_protection_required_checks_readback_api.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.v1.agents import router +from src.services.gitea_branch_protection_required_checks_readback import ( + load_latest_gitea_branch_protection_required_checks_readback, +) + + +def test_gitea_branch_protection_required_checks_loader_builds_source_policy() -> None: + payload = load_latest_gitea_branch_protection_required_checks_readback() + + assert ( + payload["schema_version"] + == "gitea_branch_protection_required_checks_readback_v1" + ) + assert payload["status"] == ( + "gitea_branch_protection_required_checks_source_policy_ready" + ) + assert payload["ready"] is True + assert payload["summary"]["expected_product_count"] == 12 + assert payload["summary"]["protected_repo_count"] == 12 + assert payload["summary"]["branch_policy_row_count"] == 24 + assert payload["summary"]["main_branch_policy_count"] == 12 + assert payload["summary"]["dev_branch_policy_count"] == 12 + assert payload["summary"]["missing_required_check_source_count"] == 0 + assert payload["summary"]["live_branch_protection_verified"] is False + assert payload["summary"]["live_required_checks_verified"] is False + assert payload["operation_boundaries"]["gitea_branch_protection_write_allowed"] is False + assert payload["operation_boundaries"]["gitea_required_check_write_allowed"] is False + assert payload["operation_boundaries"]["workflow_trigger_allowed"] is False + assert payload["operation_boundaries"]["secret_value_read_allowed"] is False + + assert payload["required_check_policy"]["main_branch"]["required_workflow_ids"] == [ + "code-review", + "cd", + "type-sync-check", + ] + assert payload["required_check_policy"]["dev_branch"]["required_workflow_ids"] == [ + "cd-dev", + "type-sync-check", + ] + assert "run-migration" in payload["required_check_policy"][ + "excluded_manual_or_high_risk_workflows" + ] + + +def test_gitea_branch_protection_required_checks_endpoint_returns_payload() -> None: + app = FastAPI() + app.include_router(router, prefix="/api/v1") + client = TestClient(app) + + response = client.get( + "/api/v1/agents/gitea-branch-protection-required-checks-readback" + ) + + assert response.status_code == 200 + data = response.json() + assert data["schema_version"] == ( + "gitea_branch_protection_required_checks_readback_v1" + ) + assert data["summary"]["branch_policy_row_count"] == len(data["protection_rows"]) diff --git a/apps/api/tests/test_gitea_capability_ci_cd_optimization_readback_api.py b/apps/api/tests/test_gitea_capability_ci_cd_optimization_readback_api.py new file mode 100644 index 000000000..0a8e7fcc6 --- /dev/null +++ b/apps/api/tests/test_gitea_capability_ci_cd_optimization_readback_api.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + +from src.api.v1 import agents +from src.api.v1.agents import router +from src.services.backup_dr_readiness_matrix import ( + load_latest_backup_dr_readiness_matrix, +) +from src.services.backup_notification_policy import ( + load_latest_backup_notification_policy, +) +from src.services.gitea_capability_ci_cd_optimization_readback import ( + build_gitea_capability_ci_cd_optimization_readback, + build_gitea_workflow_source_inventory, +) +from src.services.gitea_private_inventory_p0_scorecard import ( + load_latest_gitea_private_inventory_p0_scorecard, +) +from src.services.gitea_repo_bundle_backup_readback import ( + build_gitea_repo_bundle_backup_readback, +) +from src.services.gitea_workflow_runner_health import ( + load_latest_gitea_workflow_runner_health, +) +from src.services.p0_cicd_baseline_source_readiness import ( + load_latest_p0_cicd_baseline_source_readiness, +) + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_DEV_PROD_SNAPSHOT = ( + _REPO_ROOT + / "docs" + / "operations" + / "gitea-all-product-dev-prod-repo-readback.snapshot.json" +) + + +def _sample(name: str, value: float, **labels: str) -> dict: + merged = {"host": "188", **labels} + return {"name": name, "labels": merged, "value": value} + + +def _repo_refs() -> list[str]: + data = json.loads(_DEV_PROD_SNAPSHOT.read_text(encoding="utf-8")) + return sorted(row["gitea_repo"] for row in data["products"]) + + +def _repo_bundle_payload() -> dict: + refs = _repo_refs() + samples = [ + _sample("awoooi_gitea_bundle_expected_repo_count", len(refs)), + _sample("awoooi_gitea_bundle_expected_repo_missing_count", 0), + _sample("awoooi_gitea_bundle_failed_repo_count", 0), + _sample("awoooi_gitea_bundle_checksum_missing_count", 0), + _sample("awoooi_gitea_bundle_age_seconds", 600), + _sample("awoooi_gitea_bundle_fresh", 1), + _sample("awoooi_gitea_bundle_all_expected_ok", 1), + _sample("awoooi_gitea_bundle_sample_restore_dry_run_ok", 1), + ] + for repo in refs: + samples.extend( + [ + _sample("awoooi_gitea_bundle_repo_present", 1, repo=repo), + _sample("awoooi_gitea_bundle_repo_ok", 1, repo=repo), + _sample("awoooi_gitea_bundle_repo_head_count", 2, repo=repo), + _sample( + "awoooi_gitea_bundle_checksum_digest_present", + 1, + repo=repo, + ), + ] + ) + return build_gitea_repo_bundle_backup_readback( + metric_samples=samples, + generated_at="2026-07-09T23:00:00+08:00", + dev_prod_repo_refs=refs, + dev_prod_repo_truth_ref=( + "docs/operations/gitea-all-product-dev-prod-repo-readback.snapshot.json" + ), + ) + + +def _full_server_dr_payload() -> dict: + return { + "schema_version": "gitea_full_server_backup_dr_receipts_v1", + "status": "gitea_full_server_backup_dr_receipts_gaps_present", + "ready": False, + "active_gap_ids": ["restore_drill_approval_gate"], + "active_gap_count": 1, + "summary": { + "source_ready_component_count": 6, + "verified_ready_component_count": 3, + "gitea_full_backup_component_receipt_count": 0, + }, + } + + +def _readback_payload() -> dict: + return build_gitea_capability_ci_cd_optimization_readback( + dev_prod_repo_readback=json.loads( + _DEV_PROD_SNAPSHOT.read_text(encoding="utf-8") + ), + private_inventory=load_latest_gitea_private_inventory_p0_scorecard(), + workflow_runner_health=load_latest_gitea_workflow_runner_health(), + workflow_source_inventory=build_gitea_workflow_source_inventory(_REPO_ROOT), + cicd_source_readiness=load_latest_p0_cicd_baseline_source_readiness(), + backup_dr_readiness=load_latest_backup_dr_readiness_matrix(), + backup_notification_policy=load_latest_backup_notification_policy(), + repo_bundle_backup_readback=_repo_bundle_payload(), + full_server_backup_dr_receipts=_full_server_dr_payload(), + generated_at="2026-07-09T23:01:00+08:00", + ) + + +def test_gitea_capability_readback_rolls_up_complete_feature_surface() -> None: + payload = _readback_payload() + + assert ( + payload["schema_version"] + == "gitea_capability_ci_cd_optimization_readback_v1" + ) + assert payload["source_control_authority"] == "gitea" + assert payload["status"] == "gitea_capability_ci_cd_optimization_gaps_present" + assert payload["rollups"]["expected_product_count"] == 12 + assert payload["rollups"]["ssh_verified_product_repo_count"] == 12 + assert payload["rollups"]["dev_prod_environment_split_ready_count"] == 12 + assert payload["workflow_source_inventory"]["workflow_file_count"] == 12 + assert payload["workflow_source_inventory"]["secret_variable_rollups"][ + "secret_values_collected" + ] is False + assert payload["workflow_source_inventory"]["secret_variable_rollups"][ + "secret_reference_count" + ] > 0 + assert ( + len( + payload["workflow_source_inventory"]["secret_variable_rollups"][ + "referenced_secret_names" + ] + ) + == 26 + ) + + capability_by_id = {row["id"]: row for row in payload["capability_rows"]} + assert capability_by_id["code_git_source_control"]["status"] == "verified_ready" + assert ( + capability_by_id["dev_prod_environment_split"]["status"] + == "verified_ready" + ) + assert capability_by_id["gitea_actions_workflows"]["status"] == "verified_ready" + assert capability_by_id["gitea_actions_workflows"]["metrics"] == { + "workflow_source_file_count": 12, + "workflow_contract_count": 12, + } + assert capability_by_id["gitea_actions_runners"]["status"] == "verified_ready" + assert capability_by_id["branch_protection_required_checks"]["status"] == "source_ready" + assert capability_by_id["backup_dr_restore"]["status"] == "source_ready" + assert capability_by_id["backup_dr_restore"]["metrics"][ + "gitea_full_dr_source_ready_component_count" + ] == 6 + assert capability_by_id["observability_alert_receipts"]["status"] == "action_required" + + assert payload["operation_boundaries"]["read_only_api_allowed"] is True + assert payload["operation_boundaries"]["github_api_allowed"] is False + assert payload["operation_boundaries"]["secret_value_read_allowed"] is False + assert payload["operation_boundaries"]["workflow_trigger_allowed"] is False + + +def test_gitea_capability_readback_prioritizes_professional_cicd_gaps() -> None: + payload = _readback_payload() + optimization_by_id = { + row["id"]: row for row in payload["ci_cd_optimization_rows"] + } + + assert ( + optimization_by_id["gitea_actions_source_authority"]["status"] + == "implemented" + ) + assert ( + optimization_by_id["dev_prod_branch_promotion_contract"]["status"] + == "implemented" + ) + assert ( + optimization_by_id["workflow_contract_freshness"]["status"] + == "implemented" + ) + assert ( + optimization_by_id["runner_label_isolation_attestation"]["status"] + == "implemented" + ) + assert ( + optimization_by_id["dedupe_cancel_stale_run_guard"]["status"] + == "source_ready" + ) + assert ( + optimization_by_id["branch_protection_required_checks"]["status"] + == "source_ready" + ) + assert ( + optimization_by_id["full_gitea_server_offsite_dr_receipts"]["status"] + == "source_ready" + ) + assert payload["rollups"]["ci_cd_optimization_count"] == len( + payload["ci_cd_optimization_rows"] + ) + assert payload["rollups"]["p0_optimization_gap_count"] == 0 + assert payload["active_p0_gap_ids"] == ["observability_alert_receipts"] + assert any( + action.startswith("observability_alert_receipts:") + for action in payload["readback"]["safe_next_actions"] + ) + + +def test_gitea_capability_readback_endpoint_returns_payload(monkeypatch) -> None: + monkeypatch.setattr( + agents, + "load_latest_gitea_capability_ci_cd_optimization_readback", + _readback_payload, + ) + app = FastAPI() + app.include_router(router, prefix="/api/v1") + client = TestClient(app) + + response = client.get( + "/api/v1/agents/gitea-capability-ci-cd-optimization-readback" + ) + + assert response.status_code == 200 + data = response.json() + assert data["schema_version"] == "gitea_capability_ci_cd_optimization_readback_v1" + assert data["rollups"]["capability_count"] == len(data["capability_rows"]) + assert data["rollups"]["workflow_contract_fresh"] is True diff --git a/apps/api/tests/test_gitea_cicd_alert_receipt_chain_readback_api.py b/apps/api/tests/test_gitea_cicd_alert_receipt_chain_readback_api.py new file mode 100644 index 000000000..435009b82 --- /dev/null +++ b/apps/api/tests/test_gitea_cicd_alert_receipt_chain_readback_api.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.v1 import agents +from src.api.v1.agents import router +from src.services.backup_notification_policy import ( + load_latest_backup_notification_policy, +) +from src.services.gitea_cicd_alert_receipt_chain_readback import ( + build_gitea_cicd_alert_receipt_chain_readback, +) +from src.services.gitea_workflow_runner_health import ( + load_latest_gitea_workflow_runner_health, +) + + +def _telegram_coverage() -> dict: + return { + "schema_version": "telegram_alert_monitoring_coverage_readback_v1", + "status": "blocked_telegram_alert_monitoring_coverage_gaps_present", + "summary": { + "monitoring_live_receipt_gap_count": 60, + "alert_receipt_pipeline_ready_count": 6, + "alert_receipt_pipeline_stage_count": 7, + "verified_ai_agent_context_receipt_count": 6, + }, + } + + +def _full_dr() -> dict: + return { + "schema_version": "gitea_full_server_backup_dr_receipts_v1", + "status": "gitea_full_server_backup_dr_receipts_gaps_present", + "active_gap_count": 7, + "summary": { + "verified_ready_component_count": 3, + }, + } + + +def _payload() -> dict: + return build_gitea_cicd_alert_receipt_chain_readback( + telegram_coverage=_telegram_coverage(), + backup_notification_policy=load_latest_backup_notification_policy(), + workflow_runner_health=load_latest_gitea_workflow_runner_health(), + full_server_backup_dr_receipts=_full_dr(), + generated_at="2026-07-10T00:20:00+08:00", + ) + + +def test_gitea_cicd_alert_receipt_chain_readback_surfaces_receipt_gaps() -> None: + payload = _payload() + + assert payload["schema_version"] == "gitea_cicd_alert_receipt_chain_readback_v1" + assert payload["status"] == "gitea_cicd_alert_receipt_chain_gaps_present" + assert payload["ready"] is False + assert payload["summary"]["receipt_chain_count"] == 5 + assert payload["summary"]["action_required_chain_count"] == 2 + assert payload["summary"]["monitoring_live_receipt_gap_count"] == 60 + assert payload["summary"]["full_server_dr_active_gap_count"] == 7 + assert payload["operation_boundaries"]["telegram_send_allowed"] is False + assert payload["operation_boundaries"]["bot_api_call_allowed"] is False + assert payload["operation_boundaries"]["backup_execution_allowed"] is False + assert payload["operation_boundaries"]["restore_execution_allowed"] is False + assert payload["operation_boundaries"]["workflow_trigger_allowed"] is False + assert payload["operation_boundaries"]["secret_value_read_allowed"] is False + + by_id = {row["chain_id"]: row for row in payload["receipt_chain_rows"]} + assert by_id["gitea_cd_pipeline_notifications"]["status"] == "source_ready" + assert by_id["backup_notification_policy"]["status"] == "policy_ready" + assert by_id["gitea_full_server_dr_receipts"]["status"] == "action_required" + assert by_id["telegram_monitoring_live_receipts"]["status"] == "action_required" + assert by_id["ai_agent_context_receipts"]["status"] == "source_ready" + + +@pytest.mark.asyncio +async def test_gitea_cicd_alert_receipt_chain_endpoint_returns_payload(monkeypatch) -> None: + async def fake_loader() -> dict: + return _payload() + + monkeypatch.setattr( + agents, + "load_latest_gitea_cicd_alert_receipt_chain_readback", + fake_loader, + ) + app = FastAPI() + app.include_router(router, prefix="/api/v1") + client = TestClient(app) + + response = client.get("/api/v1/agents/gitea-cicd-alert-receipt-chain-readback") + + assert response.status_code == 200 + data = response.json() + assert data["schema_version"] == "gitea_cicd_alert_receipt_chain_readback_v1" + assert data["active_gap_count"] == len(data["active_gap_ids"]) diff --git a/apps/api/tests/test_gitea_full_server_backup_dr_receipts_api.py b/apps/api/tests/test_gitea_full_server_backup_dr_receipts_api.py new file mode 100644 index 000000000..663c66d9a --- /dev/null +++ b/apps/api/tests/test_gitea_full_server_backup_dr_receipts_api.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.v1 import agents +from src.api.v1.agents import router +from src.services.backup_dr_readiness_matrix import ( + load_latest_backup_dr_readiness_matrix, +) +from src.services.backup_dr_target_inventory import ( + load_latest_backup_dr_target_inventory, +) +from src.services.gitea_full_server_backup_dr_receipts import ( + build_gitea_full_server_backup_source_contract, + build_gitea_full_server_backup_dr_receipts, +) +from src.services.gitea_repo_bundle_backup_readback import ( + build_gitea_repo_bundle_backup_readback, +) + + +def _sample(name: str, value: float, **labels: str) -> dict: + merged = {"host": "188", **labels} + return {"name": name, "labels": merged, "value": value} + + +def _repo_bundle_payload() -> dict: + repos = ["wooo/awoooi", "wooo/ewoooc"] + samples = [ + _sample("awoooi_gitea_bundle_expected_repo_count", len(repos)), + _sample("awoooi_gitea_bundle_expected_repo_missing_count", 0), + _sample("awoooi_gitea_bundle_failed_repo_count", 0), + _sample("awoooi_gitea_bundle_checksum_missing_count", 0), + _sample("awoooi_gitea_bundle_age_seconds", 600), + _sample("awoooi_gitea_bundle_fresh", 1), + _sample("awoooi_gitea_bundle_all_expected_ok", 1), + _sample("awoooi_gitea_bundle_sample_restore_dry_run_ok", 1), + ] + for repo in repos: + samples.extend( + [ + _sample("awoooi_gitea_bundle_repo_present", 1, repo=repo), + _sample("awoooi_gitea_bundle_repo_ok", 1, repo=repo), + _sample("awoooi_gitea_bundle_repo_head_count", 2, repo=repo), + _sample("awoooi_gitea_bundle_checksum_digest_present", 1, repo=repo), + ] + ) + return build_gitea_repo_bundle_backup_readback( + metric_samples=samples, + generated_at="2026-07-10T00:00:00+08:00", + ) + + +def _payload() -> dict: + repo_root = Path(__file__).resolve().parents[3] + return build_gitea_full_server_backup_dr_receipts( + repo_bundle_backup_readback=_repo_bundle_payload(), + backup_dr_readiness_matrix=load_latest_backup_dr_readiness_matrix(), + backup_dr_target_inventory=load_latest_backup_dr_target_inventory(), + source_contract=build_gitea_full_server_backup_source_contract(repo_root), + generated_at="2026-07-10T00:01:00+08:00", + ) + + +def _full_backup_metric_samples() -> list[dict]: + def sample(name: str, value: float, **labels: str) -> dict: + return {"name": name, "labels": {"host": "110", **labels}, "value": value} + + samples = [ + sample("awoooi_gitea_full_backup_last_run_failed", 0, service="gitea"), + sample( + "awoooi_gitea_full_backup_restore_drill_performed", + 0, + service="gitea", + ), + sample( + "awoooi_gitea_full_backup_secret_value_collected", + 0, + service="gitea", + ), + sample( + "awoooi_gitea_full_backup_production_restore_performed", + 0, + service="gitea", + ), + ] + for component in [ + "database", + "app_settings", + "lfs_objects", + "package_registry", + "attachments", + "hooks_custom_assets", + ]: + samples.append( + sample( + "awoooi_gitea_full_backup_component_receipt", + 1, + service="gitea", + component=component, + receipt="gitea_dump_restic", + ) + ) + return samples + + +def test_gitea_full_server_backup_dr_receipts_separates_bundle_from_full_dr() -> None: + payload = _payload() + + assert payload["schema_version"] == "gitea_full_server_backup_dr_receipts_v1" + assert payload["status"] == "gitea_full_server_backup_dr_receipts_gaps_present" + assert payload["ready"] is False + assert payload["summary"]["repo_bundle_ready"] is True + assert payload["summary"]["component_count"] == 10 + assert payload["summary"]["verified_ready_component_count"] == 3 + assert payload["summary"]["source_ready_component_count"] == 6 + assert payload["summary"]["receipt_required_component_count"] == 0 + assert payload["summary"]["approval_required_component_count"] == 1 + assert payload["active_gap_ids"] == ["restore_drill_approval_gate"] + assert payload["operation_boundaries"]["backup_execution_allowed"] is False + assert payload["operation_boundaries"]["restore_execution_allowed"] is False + assert payload["operation_boundaries"]["offsite_sync_execution_allowed"] is False + assert payload["operation_boundaries"]["secret_value_read_allowed"] is False + + by_id = {row["component_id"]: row for row in payload["component_rows"]} + assert by_id["repository_bundles"]["status"] == "verified_ready" + assert by_id["gitea_server_backup_target"]["status"] == "verified_ready" + assert by_id["google_drive_offsite_mirror"]["status"] == "verified_ready" + assert by_id["restore_drill_approval_gate"]["status"] == "approval_required" + assert by_id["gitea_database_receipt"]["status"] == "source_ready" + assert by_id["gitea_packages_receipt"]["status"] == "source_ready" + assert by_id["gitea_attachments_receipt"]["production_restore_performed"] is False + assert payload["source_contract"]["component_metric_writer_source_ready"] is True + assert payload["source_contract"]["secret_values_collected_by_source_inspection"] is False + + +def test_gitea_full_server_backup_dr_receipts_promotes_live_component_metrics() -> None: + repo_root = Path(__file__).resolve().parents[3] + payload = build_gitea_full_server_backup_dr_receipts( + repo_bundle_backup_readback=_repo_bundle_payload(), + backup_dr_readiness_matrix=load_latest_backup_dr_readiness_matrix(), + backup_dr_target_inventory=load_latest_backup_dr_target_inventory(), + source_contract=build_gitea_full_server_backup_source_contract(repo_root), + metric_samples=_full_backup_metric_samples(), + generated_at="2026-07-10T00:02:00+08:00", + ) + + assert payload["summary"]["verified_ready_component_count"] == 9 + assert payload["summary"]["source_ready_component_count"] == 0 + assert payload["summary"]["gitea_full_backup_component_receipt_count"] == 6 + assert payload["receipt_metric_rollup"]["secret_value_collected"] is False + assert payload["receipt_metric_rollup"]["production_restore_performed"] is False + + +def test_gitea_full_server_backup_dr_receipts_endpoint_returns_payload(monkeypatch) -> None: + monkeypatch.setattr( + agents, + "load_latest_gitea_full_server_backup_dr_receipts", + _payload, + ) + app = FastAPI() + app.include_router(router, prefix="/api/v1") + client = TestClient(app) + + response = client.get("/api/v1/agents/gitea-full-server-backup-dr-receipts") + + assert response.status_code == 200 + data = response.json() + assert data["schema_version"] == "gitea_full_server_backup_dr_receipts_v1" + assert data["active_gap_count"] == len(data["active_gap_ids"]) diff --git a/apps/api/tests/test_gitea_workflow_runner_health_api.py b/apps/api/tests/test_gitea_workflow_runner_health_api.py index d1490fdb3..ac3d7c1e1 100644 --- a/apps/api/tests/test_gitea_workflow_runner_health_api.py +++ b/apps/api/tests/test_gitea_workflow_runner_health_api.py @@ -20,23 +20,12 @@ def test_gitea_workflow_runner_health_endpoint_returns_committed_snapshot(): assert data["program_status"]["current_task_id"] == "P1-002" assert data["program_status"]["next_task_id"] == "P1-003" assert data["program_status"]["read_only_mode"] is True - assert data["rollups"]["total_workflows"] == len(data["workflow_records"]) == 9 - assert data["rollups"]["workflows_with_schedule"] == 2 - assert data["rollups"]["workflows_with_workflow_dispatch"] == 7 - assert data["rollups"]["workflow_ids_requiring_runner_attestation"] == [ - "agent_market_watch", - "ansible_lint", - "cd_dev", - "code_review", - "deploy_alerts", - "e2e_health", - "run_migration", - "type_sync_check", - ] - assert data["rollups"]["runner_contracts_requiring_action"] == [ - "ubuntu_latest_gitea_runner_label" - ] - assert data["rollups"]["notification_contracts_quiet_success_count"] == 2 + assert data["rollups"]["total_workflows"] == len(data["workflow_records"]) == 12 + assert data["rollups"]["workflows_with_schedule"] == 4 + assert data["rollups"]["workflows_with_workflow_dispatch"] == 12 + assert data["rollups"]["workflow_ids_requiring_runner_attestation"] == [] + assert data["rollups"]["runner_contracts_requiring_action"] == [] + assert data["rollups"]["notification_contracts_quiet_success_count"] == 3 assert data["operation_boundaries"]["read_only_api_allowed"] is True assert data["operation_boundaries"]["workflow_modification_allowed"] is False assert data["operation_boundaries"]["runner_restart_allowed"] is False @@ -46,9 +35,18 @@ def test_gitea_workflow_runner_health_endpoint_returns_committed_snapshot(): assert data["approval_boundaries"]["runner_mutation_authorized"] is False assert data["approval_boundaries"]["migration_trigger_authorized"] is False cd_pipeline = next(row for row in data["workflow_records"] if row["workflow_id"] == "cd_pipeline") - assert cd_pipeline["runner_evidence_status"] == "host_runner_mapped" - assert cd_pipeline["job_count"] == 3 + assert cd_pipeline["runner_evidence_status"] == "non110_host_runner_mapped" + assert cd_pipeline["runner_labels"] == ["awoooi-non110-host"] + assert cd_pipeline["job_count"] == 5 e2e_health = next(row for row in data["workflow_records"] if row["workflow_id"] == "e2e_health") assert e2e_health["notification_policy"] == "failure_only" + ai_technology_watch = next( + row for row in data["workflow_records"] if row["workflow_id"] == "ai_technology_watch" + ) + assert ai_technology_watch["runner_labels"] == ["awoooi-non110-ubuntu"] + harbor_repair = next( + row for row in data["workflow_records"] if row["workflow_id"] == "harbor_110_local_repair" + ) + assert harbor_repair["runner_evidence_status"] == "non110_host_runner_mapped" assert "workflow 修改批准" in data["operator_contract"]["must_not_interpret_as"] assert "Secret 已讀取或可輸出" in data["operator_contract"]["must_not_interpret_as"] diff --git a/docs/evaluations/gitea_workflow_runner_health_2026-06-05.json b/docs/evaluations/gitea_workflow_runner_health_2026-06-05.json index 5ceb82c8e..f7db9a4d7 100644 --- a/docs/evaluations/gitea_workflow_runner_health_2026-06-05.json +++ b/docs/evaluations/gitea_workflow_runner_health_2026-06-05.json @@ -1,6 +1,6 @@ { "schema_version": "gitea_workflow_runner_health_v1", - "generated_at": "2026-06-05T10:56:16+08:00", + "generated_at": "2026-07-09T23:20:00+08:00", "program_status": { "overall_completion_percent": 100, "current_priority": "P1", @@ -11,12 +11,15 @@ "source_refs": [ "docs/schemas/gitea_workflow_runner_health_v1.schema.json", ".gitea/workflows/agent-market-watch.yaml", + ".gitea/workflows/ai-technology-watch.yaml", ".gitea/workflows/ansible-lint.yml", + ".gitea/workflows/awoooi-onboarding-warning-step.yaml", ".gitea/workflows/cd-dev.yaml", ".gitea/workflows/cd.yaml", ".gitea/workflows/code-review.yaml", ".gitea/workflows/deploy-alerts.yaml", ".gitea/workflows/e2e-health.yaml", + ".gitea/workflows/harbor-110-local-repair.yaml", ".gitea/workflows/run-migration.yml", ".gitea/workflows/type-sync-check.yaml", "scripts/ci/check-gitea-step-env-secrets.js", @@ -27,37 +30,26 @@ "scripts/ops/stop-stale-gitea-actions-jobs.sh" ], "rollups": { - "total_workflows": 9, + "total_workflows": 12, "by_workflow_status": { - "manifest_mapped": 9 + "manifest_mapped": 12 }, "by_runner_evidence_status": { - "owner_attestation_required": 7, - "host_runner_mapped": 1, - "comment_ambiguous": 1 + "non110_runner_mapped": 9, + "non110_host_runner_mapped": 3 }, - "workflows_with_schedule": 2, - "workflows_with_workflow_dispatch": 7, + "workflows_with_schedule": 4, + "workflows_with_workflow_dispatch": 12, "workflows_with_notify_bridge": 6, - "workflows_with_actionable_or_failure_quiet_policy": 2, - "workflow_ids_requiring_runner_attestation": [ - "agent_market_watch", - "ansible_lint", - "cd_dev", - "code_review", - "deploy_alerts", - "e2e_health", - "run_migration", - "type_sync_check" - ], - "total_runner_contracts": 4, - "runner_contracts_requiring_action": [ - "ubuntu_latest_gitea_runner_label" - ], - "notification_contracts_total": 6, - "notification_contracts_quiet_success_count": 2, + "workflows_with_actionable_or_failure_quiet_policy": 3, + "workflow_ids_requiring_runner_attestation": [], + "total_runner_contracts": 5, + "runner_contracts_requiring_action": [], + "notification_contracts_total": 7, + "notification_contracts_quiet_success_count": 3, "notification_contracts_quiet_success_ids": [ "agent_market_watch_actionable_only", + "ai_technology_watch_actionable_only", "e2e_health_failure_only" ] }, @@ -75,9 +67,9 @@ ], "schedule_cadence": "每週一 09:00 Asia/Taipei;cron=0 1 * * 1 UTC", "runner_labels": [ - "ubuntu-latest" + "awoooi-non110-ubuntu" ], - "runner_evidence_status": "owner_attestation_required", + "runner_evidence_status": "non110_runner_mapped", "job_count": 1, "notification_policy": "actionable_only_no_success_noise", "notify_bridge_calls": 0, @@ -86,23 +78,48 @@ ".gitea/workflows/agent-market-watch.yaml", "docs/evaluations/agent_market_watch_report_2026-06-04_watch_expanded.json" ], - "next_action": "保留 actionable-only;下一步只補 runner label owner attestation,不啟用額外通知或 paid API。" + "next_action": "保留 actionable-only;維持 non110 runner label,不啟用額外通知或 paid API。" + }, + { + "workflow_id": "ai_technology_watch", + "file_ref": ".gitea/workflows/ai-technology-watch.yaml", + "display_name": "AI Technology Watch", + "scope": "AI 技術雷達監控;只產生 watch/readback,不切換 provider、不進 paid API 或 production route。", + "status": "manifest_mapped", + "risk_level": "medium", + "triggers": [ + "workflow_dispatch", + "schedule" + ], + "schedule_cadence": "定期技術雷達觀察;由 workflow source 定義 cron。", + "runner_labels": [ + "awoooi-non110-ubuntu" + ], + "runner_evidence_status": "non110_runner_mapped", + "job_count": 1, + "notification_policy": "actionable_only_no_success_noise", + "notify_bridge_calls": 0, + "secrets_policy_status": "不讀 Secret payload;只保留 committed workflow metadata 與治理 flags。", + "evidence_refs": [ + ".gitea/workflows/ai-technology-watch.yaml" + ], + "next_action": "保留 actionable-only;後續只補技術雷達 readback 與報告 freshness。" }, { "workflow_id": "ansible_lint", "file_ref": ".gitea/workflows/ansible-lint.yml", - "display_name": "Ansible Lint", - "scope": "Ansible 檔案 lint;無通知橋接。", + "display_name": "Ansible / Reboot Recovery Contract", + "scope": "Ansible / reboot recovery contract 驗證;無通知橋接。", "status": "manifest_mapped", "risk_level": "low", "triggers": [ - "push" + "workflow_dispatch" ], "schedule_cadence": "無定期排程", "runner_labels": [ - "ubuntu-latest" + "awoooi-non110-ubuntu" ], - "runner_evidence_status": "owner_attestation_required", + "runner_evidence_status": "non110_runner_mapped", "job_count": 1, "notification_policy": "read_only_no_notify", "notify_bridge_calls": 0, @@ -110,7 +127,32 @@ "evidence_refs": [ ".gitea/workflows/ansible-lint.yml" ], - "next_action": "補 ubuntu-latest 在 Gitea Actions 的 owner attestation,不改 workflow label。" + "next_action": "維持 non110 runner label 與 no-notify 合約。" + }, + { + "workflow_id": "awoooi_onboarding_warning_step", + "file_ref": ".gitea/workflows/awoooi-onboarding-warning-step.yaml", + "display_name": "AWOOOI Onboarding Warning Step", + "scope": "受控 onboarding warning-step;只允許 workflow_dispatch,不支援 push/pull_request 自動觸發。", + "status": "manifest_mapped", + "risk_level": "medium", + "triggers": [ + "workflow_dispatch" + ], + "schedule_cadence": "無定期排程", + "runner_labels": [ + "awoooi-non110-host" + ], + "runner_evidence_status": "non110_host_runner_mapped", + "job_count": 2, + "notification_policy": "manual_status_exception", + "notify_bridge_calls": 0, + "secrets_policy_status": "不讀 Secret payload;此 workflow 用於 shape/guard validation。", + "evidence_refs": [ + ".gitea/workflows/awoooi-onboarding-warning-step.yaml", + "docs/operations/awoooi-gitea-onboarding-warning-step-template-copy-receipt.snapshot.json" + ], + "next_action": "維持 controlled workflow_dispatch;不得改成 push/pull_request 觸發。" }, { "workflow_id": "cd_dev", @@ -120,14 +162,13 @@ "status": "manifest_mapped", "risk_level": "high", "triggers": [ - "push:dev", "workflow_dispatch" ], "schedule_cadence": "無定期排程", "runner_labels": [ - "ubuntu-latest" + "awoooi-non110-ubuntu" ], - "runner_evidence_status": "owner_attestation_required", + "runner_evidence_status": "non110_runner_mapped", "job_count": 1, "notification_policy": "deployment_status_exception", "notify_bridge_calls": 3, @@ -136,13 +177,13 @@ ".gitea/workflows/cd-dev.yaml", "scripts/ci/notify-awoooi-cicd.sh" ], - "next_action": "保留部署狀態例外;補 runner owner attestation 與通知降噪邊界,不直接改 dev CD。" + "next_action": "保留部署狀態例外;後續補 dev deploy marker / smoke receipt 統一讀回。" }, { "workflow_id": "cd_pipeline", "file_ref": ".gitea/workflows/cd.yaml", "display_name": "CD Pipeline", - "scope": "main branch code-review 後的正式測試、build、deploy、post-deploy;使用 awoooi-host runner。", + "scope": "main branch 正式測試、build、deploy、post-deploy;使用 non110 host runner label,並保留 stale-run guard。", "status": "manifest_mapped", "risk_level": "critical", "triggers": [ @@ -151,10 +192,10 @@ ], "schedule_cadence": "無定期排程", "runner_labels": [ - "awoooi-host" + "awoooi-non110-host" ], - "runner_evidence_status": "host_runner_mapped", - "job_count": 3, + "runner_evidence_status": "non110_host_runner_mapped", + "job_count": 5, "notification_policy": "deployment_status_exception", "notify_bridge_calls": 9, "secrets_policy_status": "正式 CD 使用 check-gitea-step-env-secrets guard;不在本 snapshot 讀取任何 Secret payload。", @@ -164,7 +205,7 @@ "scripts/ci/cleanup-host-runner-workspace.sh", "scripts/ci/wait-host-web-build-pressure.sh" ], - "next_action": "保留 awoooi-host 合約與 post-deploy smoke;任何 CD 修改仍需獨立 review / deploy gate。" + "next_action": "保留 non110 host 合約與 post-deploy smoke;任何 CD 修改仍需獨立 review / deploy gate。" }, { "workflow_id": "code_review", @@ -174,40 +215,38 @@ "status": "manifest_mapped", "risk_level": "high", "triggers": [ - "push", "workflow_dispatch" ], "schedule_cadence": "無定期排程", "runner_labels": [ - "ubuntu-latest" + "awoooi-non110-ubuntu" ], - "runner_evidence_status": "owner_attestation_required", + "runner_evidence_status": "non110_runner_mapped", "job_count": 1, "notification_policy": "manual_status_exception", "notify_bridge_calls": 2, - "secrets_policy_status": "不讀 Secret payload;仍需 owner attestation 證明 runner 與通知 secret name parity。", + "secrets_policy_status": "不讀 Secret payload;仍需維持通知 secret name parity 與 guard。", "evidence_refs": [ ".gitea/workflows/code-review.yaml", "scripts/ci/notify-awoooi-cicd.sh" ], - "next_action": "保留 review 狀態通知;補 runner label 與 secret-name hygiene 證據。" + "next_action": "保留 review 狀態通知;後續與 PR required check policy 綁定。" }, { "workflow_id": "deploy_alerts", "file_ref": ".gitea/workflows/deploy-alerts.yaml", "display_name": "Deploy Alert Rules", - "scope": "告警規則部署流程;屬人工/部署狀態例外,不在 P1-002 變更 alert rule。", + "scope": "告警規則部署流程;屬人工/部署狀態例外,不在本 snapshot 變更 alert rule。", "status": "manifest_mapped", "risk_level": "high", "triggers": [ - "workflow_dispatch", - "push" + "workflow_dispatch" ], "schedule_cadence": "無定期排程", "runner_labels": [ - "ubuntu-latest" + "awoooi-non110-ubuntu" ], - "runner_evidence_status": "owner_attestation_required", + "runner_evidence_status": "non110_runner_mapped", "job_count": 1, "notification_policy": "manual_status_exception", "notify_bridge_calls": 1, @@ -217,7 +256,7 @@ "scripts/ci/notify-awoooi-cicd.sh", "docs/HARD_RULES.md" ], - "next_action": "P1-003 再盤點 Alertmanager / Prometheus 合約;P1-002 不改 alert rules。" + "next_action": "後續再盤點 Alertmanager / Prometheus 合約;本 snapshot 不改 alert rules。" }, { "workflow_id": "e2e_health", @@ -232,9 +271,9 @@ ], "schedule_cadence": "每日 00:00 Asia/Taipei;cron=0 16 * * * UTC", "runner_labels": [ - "ubuntu-latest" + "awoooi-non110-ubuntu" ], - "runner_evidence_status": "owner_attestation_required", + "runner_evidence_status": "non110_runner_mapped", "job_count": 1, "notification_policy": "failure_only", "notify_bridge_calls": 1, @@ -243,13 +282,38 @@ ".gitea/workflows/e2e-health.yaml", "scripts/ci/notify-awoooi-cicd.sh" ], - "next_action": "保留 failure-only;補 runner owner attestation 與最近 run 證據。" + "next_action": "保留 failure-only;補最近 run 與 alert receipt stage 證據。" + }, + { + "workflow_id": "harbor_110_local_repair", + "file_ref": ".gitea/workflows/harbor-110-local-repair.yaml", + "display_name": "AWOOOI Harbor 110 Local Repair", + "scope": "Harbor 110 local repair guarded workflow;低頻 schedule + workflow_dispatch,內部仍有 no-auto-apply guard。", + "status": "manifest_mapped", + "risk_level": "critical", + "triggers": [ + "workflow_dispatch", + "schedule" + ], + "schedule_cadence": "低頻 hourly guard,由 workflow source 定義 cron。", + "runner_labels": [ + "awoooi-non110-host" + ], + "runner_evidence_status": "non110_host_runner_mapped", + "job_count": 2, + "notification_policy": "manual_status_exception", + "notify_bridge_calls": 0, + "secrets_policy_status": "不讀 Secret payload;repair workflow 只保留 guarded execution source。", + "evidence_refs": [ + ".gitea/workflows/harbor-110-local-repair.yaml" + ], + "next_action": "維持 no-auto-apply repair guard;不得由 API 直接觸發 repair。" }, { "workflow_id": "run_migration", "file_ref": ".gitea/workflows/run-migration.yml", "display_name": "run-migration", - "scope": "手動 migration workflow;不得由 P1-002 觸發。", + "scope": "手動 migration workflow;不得由本 readback 觸發。", "status": "manifest_mapped", "risk_level": "critical", "triggers": [ @@ -257,18 +321,18 @@ ], "schedule_cadence": "無定期排程", "runner_labels": [ - "ubuntu-latest # 或 self-hosted runner on 110" + "awoooi-non110-ubuntu" ], - "runner_evidence_status": "comment_ambiguous", + "runner_evidence_status": "non110_runner_mapped", "job_count": 1, "notification_policy": "manual_status_exception", "notify_bridge_calls": 1, - "secrets_policy_status": "Migration 相關 secret / DB 權限不得由 P1-002 讀取或擴權。", + "secrets_policy_status": "Migration 相關 secret / DB 權限不得由 readback 讀取或擴權。", "evidence_refs": [ ".gitea/workflows/run-migration.yml", "scripts/ci/notify-awoooi-cicd.sh" ], - "next_action": "只讀補 runner label owner attestation 與 migration approval boundary;不觸發 workflow。" + "next_action": "只讀維持 migration approval boundary;不觸發 workflow。" }, { "workflow_id": "type_sync_check", @@ -278,56 +342,60 @@ "status": "manifest_mapped", "risk_level": "low", "triggers": [ - "push" + "workflow_dispatch" ], "schedule_cadence": "無定期排程", "runner_labels": [ - "ubuntu-latest" + "awoooi-non110-ubuntu" ], - "runner_evidence_status": "owner_attestation_required", + "runner_evidence_status": "non110_runner_mapped", "job_count": 1, "notification_policy": "read_only_no_notify", "notify_bridge_calls": 0, - "secrets_policy_status": "無通知橋接;仍需 runner label attestation。", + "secrets_policy_status": "無通知橋接;維持 non110 runner label。", "evidence_refs": [ ".gitea/workflows/type-sync-check.yaml" ], - "next_action": "補 runner label owner attestation,不改 workflow。" + "next_action": "維持 no-notify;後續只補 artifact retention。" } ], "runner_contracts": [ { - "contract_id": "awoooi_host_runner", - "display_name": "awoooi-host 正式 CD runner", + "contract_id": "awoooi_non110_host_runner", + "display_name": "awoooi-non110-host controlled runner label", "status": "manifest_mapped", "risk_level": "critical", "runner_labels": [ - "awoooi-host" + "awoooi-non110-host" ], "used_by_workflows": [ - "cd_pipeline" + "awoooi_onboarding_warning_step", + "cd_pipeline", + "harbor_110_local_repair" ], - "health_contract": "正式 CD tests/build/post-deploy 均使用 awoooi-host;cleanup 與 build-pressure guard 只做等候 / 清理,不代表可任意重啟 runner。", + "health_contract": "host-level controlled workflows use the explicit awoooi-non110-host label; this snapshot does not restart or mutate the runner.", "guardrail_refs": [ "scripts/ci/cleanup-host-runner-workspace.sh", "scripts/ci/wait-host-web-build-pressure.sh" ], "evidence_refs": [ ".gitea/workflows/cd.yaml", - "docs/LOGBOOK.md" + ".gitea/workflows/harbor-110-local-repair.yaml", + ".gitea/workflows/awoooi-onboarding-warning-step.yaml" ], - "next_action": "維持正式 CD runner 合約;任何 systemd / runner 變更另走人工批准。" + "next_action": "維持 explicit non110 host label;任何 runner/service 變更另走維護窗口。" }, { - "contract_id": "ubuntu_latest_gitea_runner_label", - "display_name": "ubuntu-latest Gitea runner label 對應", - "status": "action_required", + "contract_id": "awoooi_non110_ubuntu_runner", + "display_name": "awoooi-non110-ubuntu controlled runner label", + "status": "manifest_mapped", "risk_level": "high", "runner_labels": [ - "ubuntu-latest" + "awoooi-non110-ubuntu" ], "used_by_workflows": [ "agent_market_watch", + "ai_technology_watch", "ansible_lint", "cd_dev", "code_review", @@ -336,17 +404,18 @@ "run_migration", "type_sync_check" ], - "health_contract": "多數 workflow 仍標示 ubuntu-latest;需要 Gitea runner owner 以脫敏 metadata 證明實際 runner 對應、容量與維護責任。", + "health_contract": "general CI/CD and governance workflows use explicit awoooi-non110-ubuntu label instead of a generic runner label.", "guardrail_refs": [ "docs/HARD_RULES.md", "docs/security/S4-9-CANONICAL-OWNER-RESPONSE-ENVELOPE.md" ], "evidence_refs": [ ".gitea/workflows/agent-market-watch.yaml", + ".gitea/workflows/cd-dev.yaml", ".gitea/workflows/e2e-health.yaml", ".gitea/workflows/run-migration.yml" ], - "next_action": "建立 owner attestation request;不得直接把 label 改成 self-hosted 或啟用新 runner。" + "next_action": "維持 explicit non110 ubuntu label;後續補容量/freshness 指標,不直接註冊或重啟 runner。" }, { "contract_id": "runner_watchdog_systemd", @@ -357,10 +426,9 @@ "actions.runner.owenhytsai-awoooi.awoooi-110.service" ], "used_by_workflows": [ - "cd_pipeline", "Gitea Actions host runner service" ], - "health_contract": "setup script 只描述 WatchdogSec=300、Restart=always、StartLimitBurst=5;P1-002 不套用、不 restart、不 systemctl daemon-reload。", + "health_contract": "setup script 只描述 WatchdogSec=300、Restart=always、StartLimitBurst=5;本 snapshot 不套用、不 restart、不 systemctl daemon-reload。", "guardrail_refs": [ "scripts/setup-runner-watchdog.sh" ], @@ -389,6 +457,30 @@ "scripts/ops/stop-stale-gitea-actions-jobs.sh" ], "next_action": "維持 dry-run-only;不得由治理頁或 API 直接停止 container。" + }, + { + "contract_id": "secret_env_hygiene_guard", + "display_name": "Gitea step env secret hygiene guard", + "status": "manifest_mapped", + "risk_level": "high", + "runner_labels": [ + "all_gitea_actions" + ], + "used_by_workflows": [ + "cd_pipeline", + "code_review", + "cd_dev" + ], + "health_contract": "check-gitea-step-env-secrets.js is the committed source guard for step env / action input hygiene; this snapshot reads no secret values.", + "guardrail_refs": [ + "scripts/ci/check-gitea-step-env-secrets.js" + ], + "evidence_refs": [ + "scripts/ci/check-gitea-step-env-secrets.js", + ".gitea/workflows/cd.yaml", + ".gitea/workflows/code-review.yaml" + ], + "next_action": "保留 secret-name hygiene guard;後續只做 redacted name inventory,不讀 secret value。" } ], "notification_contracts": [ @@ -405,7 +497,22 @@ "evidence_refs": [ ".gitea/workflows/agent-market-watch.yaml" ], - "next_action": "保留每週觀察 cadence;P1-002 不增加外部 API 或通知頻率。" + "next_action": "保留每週觀察 cadence;不增加外部 API 或通知頻率。" + }, + { + "contract_id": "ai_technology_watch_actionable_only", + "display_name": "AI Technology Watch actionable-only", + "status": "preserved", + "policy_kind": "actionable_only", + "success_noise_policy": "無 actionable 技術採用變更時保持安靜,不發成功洗版訊息。", + "failure_policy": "watch 失敗或需 review 時才產生 action-required evidence。", + "workflow_refs": [ + "ai_technology_watch" + ], + "evidence_refs": [ + ".gitea/workflows/ai-technology-watch.yaml" + ], + "next_action": "保留技術雷達 cadence;後續補報告 freshness readback。" }, { "contract_id": "e2e_health_failure_only", @@ -421,7 +528,7 @@ ".gitea/workflows/e2e-health.yaml", "scripts/ci/notify-awoooi-cicd.sh" ], - "next_action": "保留 failure-only;後續只補最近 run readback。" + "next_action": "保留 failure-only;後續補最近 run readback。" }, { "contract_id": "cd_pipeline_status_exception", @@ -456,23 +563,27 @@ "next_action": "保留現況;後續評估 dev 通知是否需要降噪。" }, { - "contract_id": "review_and_manual_workflow_status_exception", - "display_name": "Review / manual workflow 狀態例外", + "contract_id": "review_repair_and_manual_workflow_status_exception", + "display_name": "Review / repair / manual workflow 狀態例外", "status": "exception_documented", "policy_kind": "manual_status_exception", - "success_noise_policy": "code-review、alert deploy、migration 的狀態訊號不自動擴張到成功洗版;手動 workflow 成功仍應看情境與 release evidence。", - "failure_policy": "review、alert deploy 或 migration failure 必須留可追蹤證據。", + "success_noise_policy": "code-review、alert deploy、migration、repair 的狀態訊號不自動擴張到成功洗版;手動 workflow 成功仍應看情境與 release evidence。", + "failure_policy": "review、alert deploy、migration 或 repair failure 必須留可追蹤證據。", "workflow_refs": [ + "awoooi_onboarding_warning_step", "code_review", "deploy_alerts", + "harbor_110_local_repair", "run_migration" ], "evidence_refs": [ + ".gitea/workflows/awoooi-onboarding-warning-step.yaml", ".gitea/workflows/code-review.yaml", ".gitea/workflows/deploy-alerts.yaml", + ".gitea/workflows/harbor-110-local-repair.yaml", ".gitea/workflows/run-migration.yml" ], - "next_action": "P1-003 盤點告警流程時再處理 alert deploy;P1-002 不發通知、不觸發 migration。" + "next_action": "後續補 alert/repair receipt;本 snapshot 不發通知、不觸發 migration 或 repair。" }, { "contract_id": "lint_and_typecheck_no_notify", @@ -489,7 +600,7 @@ ".gitea/workflows/ansible-lint.yml", ".gitea/workflows/type-sync-check.yaml" ], - "next_action": "維持 no-notify;補 runner attestation。" + "next_action": "維持 no-notify;補 artifact retention。" } ], "latest_observations": [ @@ -514,7 +625,7 @@ { "observation_id": "workflow_secret_guard_present", "status": "verified", - "summary": "正式 CD 已具備 check-gitea-step-env-secrets guard,可阻擋 secrets 掛在 step env / action input;P1-002 不讀任何 Secret payload。", + "summary": "正式 CD / code-review / dev CD 已具備 check-gitea-step-env-secrets guard,可阻擋 secrets 掛在 step env / action input;本 snapshot 不讀任何 Secret payload。", "evidence_refs": [ "scripts/ci/check-gitea-step-env-secrets.js", ".gitea/workflows/cd.yaml" @@ -533,7 +644,7 @@ "deploy / migration workflow 觸發批准" ], "secret_display_policy": "只允許顯示 workflow / secret-name hygiene 與 redacted metadata;不得讀 token、runner token、webhook secret、authorization header 或任何 Secret payload。", - "runner_mutation_policy": "本 snapshot 只描述 runner label、watchdog 草案與 dry-run guard;不得 systemctl restart、docker stop、修改 label、註冊 runner 或套用 watchdog。", + "runner_mutation_policy": "本 snapshot 只描述 explicit non110 runner label、watchdog 草案與 dry-run guard;不得 systemctl restart、docker stop、修改 label、註冊 runner 或套用 watchdog。", "notification_policy": "成功不洗版是預設治理方向;failure-only / actionable-only 合約需保留,CD/review/manual workflow 的狀態通知例外需另外標示。" }, "operation_boundaries": { diff --git a/scripts/backup/backup-gitea.sh b/scripts/backup/backup-gitea.sh index b67ca8131..31c0de30f 100755 --- a/scripts/backup/backup-gitea.sh +++ b/scripts/backup/backup-gitea.sh @@ -14,6 +14,73 @@ SERVICE="gitea" GITEA_CONTAINER="gitea" LOCAL_REPO="${BACKUP_BASE}/gitea" DUMP_DIR="/tmp/gitea-backup-$$" +TEXTFILE_DIR="${NODE_EXPORTER_TEXTFILE_DIR:-/home/wooo/node_exporter_textfiles}" +TEXTFILE_PATH="${GITEA_FULL_BACKUP_RECEIPT_TEXTFILE:-${TEXTFILE_DIR}/gitea_full_backup_receipt.prom}" +HOST_LABEL="${AIOPS_HOST_LABEL:-110}" +GITEA_FULL_BACKUP_COMPONENTS="database repositories app_settings lfs_objects package_registry attachments hooks_custom_assets" + +label_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + +write_gitea_full_backup_receipt() { + local status="$1" + local timestamp="$2" + local duration="$3" + local snapshot_id="${4:-unknown}" + local tmp + local host + local service_label + local status_label + local component + local ok=0 + local failed=1 + + if [ "${status}" = "success" ]; then + ok=1 + failed=0 + fi + + host="$(label_escape "${HOST_LABEL}")" + service_label="$(label_escape "${SERVICE}")" + status_label="$(label_escape "${status}")" + + install -d -m 755 "${TEXTFILE_DIR}" || return 0 + tmp="$(mktemp "${TEXTFILE_PATH}.tmp.XXXXXX")" || return 0 + { + echo "# HELP awoooi_gitea_full_backup_last_success_timestamp Unix timestamp of the latest successful Gitea full-server backup receipt." + echo "# TYPE awoooi_gitea_full_backup_last_success_timestamp gauge" + echo "# HELP awoooi_gitea_full_backup_last_run_failed Whether the latest Gitea full-server backup run failed." + echo "# TYPE awoooi_gitea_full_backup_last_run_failed gauge" + echo "# HELP awoooi_gitea_full_backup_duration_seconds Duration of the latest Gitea full-server backup run." + echo "# TYPE awoooi_gitea_full_backup_duration_seconds gauge" + echo "# HELP awoooi_gitea_full_backup_dump_file_present Whether the Gitea dump file was produced before restic backup." + echo "# TYPE awoooi_gitea_full_backup_dump_file_present gauge" + echo "# HELP awoooi_gitea_full_backup_restic_snapshot_present Whether a restic snapshot id was recorded for the Gitea dump." + echo "# TYPE awoooi_gitea_full_backup_restic_snapshot_present gauge" + echo "# HELP awoooi_gitea_full_backup_component_receipt Whether the Gitea full-server dump includes a redacted component receipt." + echo "# TYPE awoooi_gitea_full_backup_component_receipt gauge" + echo "# HELP awoooi_gitea_full_backup_restore_drill_performed Whether this receipt performed a production restore drill." + echo "# TYPE awoooi_gitea_full_backup_restore_drill_performed gauge" + echo "# HELP awoooi_gitea_full_backup_secret_value_collected Whether this receipt collected secret values." + echo "# TYPE awoooi_gitea_full_backup_secret_value_collected gauge" + echo "# HELP awoooi_gitea_full_backup_production_restore_performed Whether this receipt restored anything into production." + echo "# TYPE awoooi_gitea_full_backup_production_restore_performed gauge" + echo "awoooi_gitea_full_backup_last_success_timestamp{host=\"${host}\",service=\"${service_label}\"} $([ "${ok}" = "1" ] && echo "${timestamp}" || echo 0)" + echo "awoooi_gitea_full_backup_last_run_failed{host=\"${host}\",service=\"${service_label}\",status=\"${status_label}\"} ${failed}" + echo "awoooi_gitea_full_backup_duration_seconds{host=\"${host}\",service=\"${service_label}\"} ${duration}" + echo "awoooi_gitea_full_backup_dump_file_present{host=\"${host}\",service=\"${service_label}\"} ${ok}" + echo "awoooi_gitea_full_backup_restic_snapshot_present{host=\"${host}\",service=\"${service_label}\",snapshot_id=\"$(label_escape "${snapshot_id}")\"} ${ok}" + for component in ${GITEA_FULL_BACKUP_COMPONENTS}; do + echo "awoooi_gitea_full_backup_component_receipt{host=\"${host}\",service=\"${service_label}\",component=\"$(label_escape "${component}")\",receipt=\"gitea_dump_restic\"} ${ok}" + done + echo "awoooi_gitea_full_backup_restore_drill_performed{host=\"${host}\",service=\"${service_label}\"} 0" + echo "awoooi_gitea_full_backup_secret_value_collected{host=\"${host}\",service=\"${service_label}\"} 0" + echo "awoooi_gitea_full_backup_production_restore_performed{host=\"${host}\",service=\"${service_label}\"} 0" + } >"${tmp}" + mv "${tmp}" "${TEXTFILE_PATH}" || return 0 + chmod 0644 "${TEXTFILE_PATH}" || true +} cleanup() { rm -rf "${DUMP_DIR}" @@ -37,6 +104,8 @@ main() { docker exec -u git "${GITEA_CONTAINER}" rm -f /tmp/gitea-dump.zip log_success "Gitea dump 完成" else + duration=$(($(date +%s) - start_time)) + write_gitea_full_backup_receipt "dump_failed" "0" "${duration}" "none" || true log_error "Gitea dump 失敗" notify_clawbot "failed" "${SERVICE}" "Gitea dump 失敗" exit 1 @@ -44,13 +113,25 @@ main() { if [ ! -d "${LOCAL_REPO}/data" ]; then log_info "初始化本地 Restic 倉庫..." - restic -r "${LOCAL_REPO}" init --password-file "${RESTIC_PASSWORD_FILE}" + if ! restic -r "${LOCAL_REPO}" init --password-file "${RESTIC_PASSWORD_FILE}"; then + duration=$(($(date +%s) - start_time)) + write_gitea_full_backup_receipt "restic_init_failed" "0" "${duration}" "none" || true + log_error "Restic 初始化失敗" + notify_clawbot "failed" "${SERVICE}" "Gitea Restic 初始化失敗" + exit 1 + fi fi tags=$(build_tags "${SERVICE}") - restic -r "${LOCAL_REPO}" backup "${DUMP_DIR}" \ + if ! restic -r "${LOCAL_REPO}" backup "${DUMP_DIR}" \ --password-file "${RESTIC_PASSWORD_FILE}" \ - ${tags} + ${tags}; then + duration=$(($(date +%s) - start_time)) + write_gitea_full_backup_receipt "restic_backup_failed" "0" "${duration}" "none" || true + log_error "Restic 備份失敗" + notify_clawbot "failed" "${SERVICE}" "Gitea Restic 備份失敗" + exit 1 + fi snapshot_id=$(restic -r "${LOCAL_REPO}" snapshots --latest 1 --json --password-file "${RESTIC_PASSWORD_FILE}" 2>/dev/null | grep -oP '"short_id":"\K[^"]+' | head -1 || true) log_success "Restic 備份完成: ${snapshot_id:-unknown}" @@ -61,6 +142,7 @@ main() { log_info "Offsite copy is handled by sync-offsite-backups.sh; no direct rclone sync here." duration=$(($(date +%s) - start_time)) + write_gitea_full_backup_receipt "success" "$(date +%s)" "${duration}" "${snapshot_id:-unknown}" || true log_success "========== Gitea 備份完成 (${duration}s) ==========" notify_clawbot "success" "${SERVICE}" "Gitea 備份完成" "${duration}" } diff --git a/scripts/backup/tests/test_backup_gitea_full_receipt_contract.py b/scripts/backup/tests/test_backup_gitea_full_receipt_contract.py new file mode 100644 index 000000000..29a80e343 --- /dev/null +++ b/scripts/backup/tests/test_backup_gitea_full_receipt_contract.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +BACKUP_GITEA = ROOT / "scripts" / "backup" / "backup-gitea.sh" + + +def test_backup_gitea_writes_full_server_component_receipts() -> None: + text = BACKUP_GITEA.read_text(encoding="utf-8") + + assert "write_gitea_full_backup_receipt" in text + assert "awoooi_gitea_full_backup_component_receipt" in text + assert "awoooi_gitea_full_backup_secret_value_collected" in text + assert "awoooi_gitea_full_backup_production_restore_performed" in text + assert "gitea dump -c /data/gitea/conf/app.ini" in text + assert "GITEA_FULL_BACKUP_COMPONENTS" in text + + for component in [ + "database", + "repositories", + "app_settings", + "lfs_objects", + "package_registry", + "attachments", + "hooks_custom_assets", + ]: + assert component in text + + +def test_backup_gitea_receipt_contract_stays_no_secret_no_restore() -> None: + text = BACKUP_GITEA.read_text(encoding="utf-8") + + assert "awoooi_gitea_full_backup_secret_value_collected" in text + assert "awoooi_gitea_full_backup_production_restore_performed" in text + assert ( + 'secret_value_collected{host=\\"${host}\\",service=\\"${service_label}\\"} 0' + in text + ) + assert ( + 'production_restore_performed{host=\\"${host}\\",service=\\"${service_label}\\"} 0' + in text + )