145 lines
5.5 KiB
Python
145 lines
5.5 KiB
Python
# 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
|