Files
awoooi/apps/api/src/services/service_registry.py
OG T c9f1bcd122
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 11m37s
fix(api): service_registry 安全降級 — Docker 無 YAML 時不 crash,fallback AUTO
2026-04-08 21:47:38 +08:00

145 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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