- Import sorting (I001) - Unused imports (F401) - f-string without placeholders (F541) - Loop variable unused (B007) - zip() strict parameter (B905) - Exception chaining (B904) - collections.abc imports (UP035) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
477 lines
14 KiB
Python
477 lines
14 KiB
Python
"""
|
||
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.8,
|
||
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
|