feat(gitea): add capability and backup receipt readbacks
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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
|
||||
@@ -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 []
|
||||
File diff suppressed because it is too large
Load Diff
246
apps/api/src/services/gitea_cicd_alert_receipt_chain_readback.py
Normal file
246
apps/api/src/services/gitea_cicd_alert_receipt_chain_readback.py
Normal file
@@ -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
|
||||
553
apps/api/src/services/gitea_full_server_backup_dr_receipts.py
Normal file
553
apps/api/src/services/gitea_full_server_backup_dr_receipts.py
Normal file
@@ -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 ""
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
173
apps/api/tests/test_gitea_full_server_backup_dr_receipts_api.py
Normal file
173
apps/api/tests/test_gitea_full_server_backup_dr_receipts_api.py
Normal file
@@ -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"])
|
||||
@@ -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"]
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user