""" Resource Resolver - ADR-016 K8s 資源動態驗證 ============================================= 在 AI 產生 kubectl 指令後,動態驗證資源是否存在於 K8s 叢集中。 若不存在,嘗試模糊匹配或回報需人工確認。 流程: 1. 正規化資源名稱 (k8s_naming.py) 2. 調用 MCP Tool 驗證資源存在性 3. 模糊匹配 namespace 內的 Deployments 4. 回傳匹配結果或候選列表 版本: v1.1 建立: 2026-03-26 (台北時區) 建立者: Claude Code (首席架構師) 修改: 2026-03-26 (台北) - 新增 IResourceResolver Protocol (P2 架構改進) @see docs/adr/ADR-016-k8s-resource-naming.md """ from dataclasses import dataclass, field from difflib import SequenceMatcher from typing import Protocol, runtime_checkable import structlog from src.utils.k8s_naming import ( ResourceType, extract_resource_hints, normalize_resource_name, ) logger = structlog.get_logger(__name__) # ============================================================================= # Types # ============================================================================= @dataclass class ResolveResult: """資源解析結果""" success: bool resource_name: str | None namespace: str | None resource_type: ResourceType confidence: float # 0.0 - 1.0 is_k8s_resource: bool = True requires_confirmation: bool = False candidates: list[str] = field(default_factory=list) note: str | None = None original_input: str = "" @dataclass class K8sResource: """K8s 資源資訊""" name: str namespace: str kind: str # Deployment, StatefulSet, Pod, etc. replicas: int | None = None ready: bool = True # ============================================================================= # Protocol Interface (Phase 17 P2 - 架構審查) # ============================================================================= @runtime_checkable class IResourceResolver(Protocol): """ ResourceResolver 介面定義 用途: - 依賴注入 (DI) 時的型別約束 - 測試時 Mock 的型別檢查 - 符合 leWOOOgo 積木化規範 @see feedback_resource_resolver_di.md """ async def resolve( self, raw_resource: str, namespace: str = "awoooi-prod", resource_kind: str = "deployment", ) -> ResolveResult: """解析原始資源名稱為有效的 K8s 資源""" ... # ============================================================================= # Resource Resolver Implementation # ============================================================================= class ResourceResolver: """ K8s 資源名稱解析器 - 確保 kubectl 指令有效 整合: - 靜態正規化 (k8s_naming.py) - 動態驗證 (MCP K8s Tool) - 模糊匹配 (Levenshtein distance) """ def __init__(self): self._cached_resources: dict[str, list[K8sResource]] = {} self._cache_ttl: int = 60 # 快取 60 秒 async def resolve( self, raw_resource: str, namespace: str = "awoooi-prod", resource_kind: str = "deployment", ) -> ResolveResult: """ 解析原始資源名稱為有效的 K8s 資源 Args: raw_resource: 原始資源名稱 (可能是 URL、域名、或 K8s 名稱) namespace: 目標命名空間 resource_kind: 資源類型 (deployment, statefulset, pod) Returns: ResolveResult: 解析結果 """ logger.info( "resource_resolve_start", raw=raw_resource, namespace=namespace, kind=resource_kind, ) # Step 1: 靜態正規化 normalized = normalize_resource_name(raw_resource, namespace) # 非 K8s 資源直接返回 if not normalized.is_k8s_resource: return ResolveResult( success=True, resource_name=normalized.normalized, namespace=None, resource_type=ResourceType.UNKNOWN, confidence=normalized.confidence, is_k8s_resource=False, note=normalized.note, original_input=raw_resource, ) # 正規化失敗 if not normalized.success or not normalized.normalized: return ResolveResult( success=False, resource_name=None, namespace=namespace, resource_type=ResourceType.UNKNOWN, confidence=0.0, requires_confirmation=True, note=normalized.note, original_input=raw_resource, ) # Step 2: 動態驗證 (調用 K8s API) resource_exists = await self._check_resource_exists( normalized.normalized, normalized.namespace or namespace, resource_kind, ) if resource_exists: logger.info( "resource_verified", resource=normalized.normalized, namespace=normalized.namespace or namespace, ) return ResolveResult( success=True, resource_name=normalized.normalized, namespace=normalized.namespace or namespace, resource_type=normalized.resource_type, confidence=1.0, note="Verified via K8s API", original_input=raw_resource, ) # Step 3: 模糊匹配 candidates = await self._fuzzy_match( raw_resource, normalized.namespace or namespace, resource_kind, ) if len(candidates) == 1: best_match = candidates[0] logger.info( "resource_fuzzy_matched", original=raw_resource, matched=best_match, ) return ResolveResult( success=True, resource_name=best_match, namespace=normalized.namespace or namespace, resource_type=normalized.resource_type, confidence=0.0, # 🔴 模糊匹配,非 AI note=f"Fuzzy matched from '{raw_resource}'", original_input=raw_resource, ) if len(candidates) > 1: logger.warning( "resource_multiple_matches", original=raw_resource, candidates=candidates, ) return ResolveResult( success=False, resource_name=None, namespace=normalized.namespace or namespace, resource_type=normalized.resource_type, confidence=0.0, requires_confirmation=True, candidates=candidates, note=f"Multiple matches for '{raw_resource}': {candidates}", original_input=raw_resource, ) # Step 4: 無匹配 logger.warning( "resource_not_found", original=raw_resource, normalized=normalized.normalized, namespace=normalized.namespace or namespace, ) return ResolveResult( success=False, resource_name=normalized.normalized, namespace=normalized.namespace or namespace, resource_type=normalized.resource_type, confidence=0.0, requires_confirmation=True, note=f"Resource '{normalized.normalized}' not found in namespace '{normalized.namespace or namespace}'", original_input=raw_resource, ) async def _check_resource_exists( self, name: str, namespace: str, kind: str = "deployment", ) -> bool: """ 透過 MCP K8s Tool 檢查資源是否存在 Args: name: 資源名稱 namespace: 命名空間 kind: 資源類型 Returns: bool: 是否存在 """ try: # 嘗試導入 MCP Registry from src.plugins.mcp.registry import get_mcp_registry registry = get_mcp_registry() result = await registry.call_tool( tool_name="kubectl_get", arguments={ "resource": f"{kind}s", # deployments, statefulsets, pods "name": name, "namespace": namespace, }, ) if result.success and result.data: # 檢查是否真的找到資源 data = result.data if isinstance(data, dict): # 單一資源 return data.get("metadata", {}).get("name") == name elif isinstance(data, list): # 資源列表 return any( r.get("metadata", {}).get("name") == name for r in data ) return False except ImportError: logger.warning( "mcp_registry_not_available", note="Falling back to static validation only", ) return False except Exception as e: logger.warning( "k8s_check_failed", resource=name, namespace=namespace, error=str(e), ) return False async def _fuzzy_match( self, raw_resource: str, namespace: str, kind: str = "deployment", ) -> list[str]: """ 在 namespace 內模糊匹配資源 Args: raw_resource: 原始輸入 namespace: 命名空間 kind: 資源類型 Returns: list[str]: 匹配的資源名稱列表 (按相似度排序) """ try: # 取得 namespace 內所有資源 resources = await self._list_resources(namespace, kind) if not resources: return [] # 提取關鍵字 hints = extract_resource_hints(raw_resource) # 計算相似度 scored: list[tuple[str, float]] = [] for res in resources: score = self._calculate_similarity(res.name, hints, raw_resource) if score > 0.3: # 閾值 scored.append((res.name, score)) # 排序並返回 scored.sort(key=lambda x: x[1], reverse=True) return [name for name, _ in scored[:5]] # 最多 5 個候選 except Exception as e: logger.warning( "fuzzy_match_failed", error=str(e), ) return [] async def _list_resources( self, namespace: str, kind: str = "deployment", ) -> list[K8sResource]: """ 列出 namespace 內所有指定類型的資源 """ try: from src.plugins.mcp.registry import get_mcp_registry registry = get_mcp_registry() result = await registry.call_tool( tool_name="kubectl_get", arguments={ "resource": f"{kind}s", "namespace": namespace, }, ) if result.success and result.data: resources: list[K8sResource] = [] items = result.data if isinstance(result.data, list) else [result.data] for item in items: if isinstance(item, dict): metadata = item.get("metadata", {}) spec = item.get("spec", {}) resources.append(K8sResource( name=metadata.get("name", ""), namespace=metadata.get("namespace", namespace), kind=kind, replicas=spec.get("replicas"), )) return resources return [] except Exception as e: logger.warning( "list_resources_failed", namespace=namespace, kind=kind, error=str(e), ) return [] def _calculate_similarity( self, resource_name: str, hints: list[str], original: str, ) -> float: """ 計算資源名稱與輸入的相似度 綜合考慮: 1. 直接子字串匹配 2. 關鍵字匹配 3. Levenshtein 距離 """ score = 0.0 name_lower = resource_name.lower() original_lower = original.lower() # 1. 直接包含關係 if name_lower in original_lower or original_lower in name_lower: score += 0.5 # 2. 關鍵字匹配 matched_hints = sum(1 for h in hints if h in name_lower) if hints: score += (matched_hints / len(hints)) * 0.3 # 3. 序列相似度 ratio = SequenceMatcher(None, name_lower, original_lower).ratio() score += ratio * 0.2 return min(score, 1.0) # ============================================================================= # Dependency Injection Support (Phase 17 P2 改進) # ============================================================================= _resolver: IResourceResolver | None = None def get_resource_resolver() -> IResourceResolver: """ 取得 ResourceResolver 實例 支援兩種模式: 1. 預設: 使用內部 singleton (向後相容) 2. DI: 透過 set_resource_resolver() 注入自訂實例 測試時可使用 set_resource_resolver(mock_resolver) 注入 mock Returns: IResourceResolver: 實作 IResourceResolver Protocol 的實例 """ global _resolver if _resolver is None: _resolver = ResourceResolver() return _resolver def set_resource_resolver(resolver: IResourceResolver | None) -> None: """ 注入 ResourceResolver 實例 (用於 DI 或測試) Args: resolver: 自訂 ResourceResolver 實例,None 則重置為預設 Example: # 測試時注入 mock mock_resolver = MockResourceResolver() set_resource_resolver(mock_resolver) # 測試後重置 set_resource_resolver(None) """ global _resolver _resolver = resolver