# apps/api/src/services/service_registry.py # Service Registry Client — 讀取 ops/config/service-registry.yaml # 撰寫: Claude Sonnet 4.6 / 2026-04-08 Asia/Taipei # 修正: 2026-04-08 Claude Sonnet 4.6 — _find_registry_path() 相容 Docker 環境 # 架構: leWOOOgo 積木化,純 Service 層,無 Router/DB 依賴 # 參考: ADR-062, ADR-063 from __future__ import annotations import structlog from enum import Enum from pathlib import Path from typing import Any import yaml logger = structlog.get_logger(__name__) # YAML 路徑 — 延遲載入,import 時不 crash # Docker 容器不包含 ops/ 目錄,所以 registry 在 K8s 環境中不可用 # 功能降級: 找不到 YAML 時 get_service_registry() 回傳空 registry _DEFAULT_REGISTRY_PATH: Path | None = None def _find_registry_path() -> Path | None: """安全搜尋 service-registry.yaml,找不到回傳 None""" current = Path(__file__).resolve().parent for _ in range(10): for subdir in ["ops/config", "ops/monitoring"]: candidate = current / subdir / "service-registry.yaml" if candidate.exists(): return candidate if current == current.parent: break current = current.parent return None class StatefulLevel(str, Enum): BLOCK = "BLOCK" # 禁止,僅告警 CRITICAL_HITL = "CRITICAL_HITL" # 2 票 MultiSig STANDARD_HITL = "STANDARD_HITL" # 1 票 AUTO = "AUTO" # 自動執行 class ServiceInfo: def __init__(self, data: dict[str, Any]) -> None: self.name: str = data["name"] self.display_name: str = data.get("display_name", self.name) self.host: str = data.get("host", "unknown") self.stateful_level: StatefulLevel = StatefulLevel(data.get("stateful_level", "AUTO")) self.reason: str = data.get("reason", "") self.alert_only: bool = data.get("alert_only", False) self.requires_pre_backup: bool = data.get("requires_pre_backup", False) self.restart_command: str = data.get("restart_command", "docker restart") self.containers: list[str] = data.get("containers", []) class ServiceRegistryClient: """ Service Registry 客戶端 讀取 ops/config/service-registry.yaml,提供服務 Stateful 分級查詢 設計原則: 純讀取,不寫入;失敗時 fallback AUTO(防護不應阻擋告警流程) """ def __init__(self, registry_path: Path | None = None) -> None: self._path = registry_path or _find_registry_path() self._services: dict[str, ServiceInfo] = {} self._backup_policies: dict[str, Any] = {} self._multisig_config: dict[str, Any] = {} self._loaded = False def _load(self) -> None: if self._loaded: return if self._path is None or not self._path.exists(): logger.warning("service_registry_not_found", path=str(self._path), fallback="all services treated as AUTO") self._loaded = True return try: with open(self._path) as f: data = yaml.safe_load(f) for svc in data.get("services", []): info = ServiceInfo(svc) self._services[info.name] = info # 也按 container 名稱建立索引 for container in info.containers: self._services[container] = info self._backup_policies = data.get("backup_policies", {}) self._multisig_config = data.get("multisig", {}) self._loaded = True logger.info(f"Service Registry 載入完成: {len(self._services)} 個服務") except Exception as e: logger.error(f"Service Registry 載入失敗: {e},所有服務 fallback AUTO") self._loaded = True # 防止重複嘗試 def get_service(self, name: str) -> ServiceInfo | None: self._load() return self._services.get(name) def get_stateful_level(self, service_name: str) -> StatefulLevel: """查詢服務分級,未知服務 fallback AUTO""" info = self.get_service(service_name) if info is None: logger.warning(f"未知服務 '{service_name}',fallback AUTO") return StatefulLevel.AUTO return info.stateful_level def is_blocked(self, service_name: str) -> bool: return self.get_stateful_level(service_name) == StatefulLevel.BLOCK def requires_multisig(self, service_name: str) -> bool: return self.get_stateful_level(service_name) == StatefulLevel.CRITICAL_HITL def get_required_votes(self, service_name: str) -> int: self._load() level = self.get_stateful_level(service_name) if level == StatefulLevel.CRITICAL_HITL: return self._multisig_config.get("critical_required_votes", 2) return self._multisig_config.get("standard_required_votes", 1) def get_backup_policies(self) -> dict[str, Any]: self._load() return self._backup_policies def get_restart_command(self, service_name: str) -> str: info = self.get_service(service_name) return info.restart_command if info else "docker restart" # Singleton _registry_client: ServiceRegistryClient | None = None def get_service_registry() -> ServiceRegistryClient: global _registry_client if _registry_client is None: _registry_client = ServiceRegistryClient() return _registry_client def set_service_registry(client: ServiceRegistryClient) -> None: """測試注入用 (P4 規範)""" global _registry_client _registry_client = client