feat(api): Phase 18.1 K8s 資源名稱驗證 (ADR-016)
三層防禦架構確保 kubectl 指令有效: 1. Webhook 入口正規化 (webhooks.py) 2. OpenClaw 產生指令前驗證 (openclaw.py) 3. 靜態映射表 + 模糊匹配 (k8s_naming.py, resource_resolver.py) 新增: - src/utils/k8s_naming.py: RFC 1123 正規化 + 靜態映射 - src/services/resource_resolver.py: MCP K8s Tool 動態驗證 - docs/adr/ADR-016-k8s-resource-naming.md: 契約文檔 - scripts/e2e_tool_call_verification.py: E2E 驗證腳本 v2.0 修改: - webhooks.py: Phase 18.1.7 入口正規化 - openclaw.py: Phase 18.1.6 產生指令前驗證 - Skill 03 v1.4: 新增 K8s 資源驗證章節 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,7 +34,9 @@ from src.models.ai import (
|
||||
OpenClawDecision,
|
||||
)
|
||||
from src.services.langfuse_client import langfuse_trace
|
||||
from src.services.resource_resolver import get_resource_resolver
|
||||
from src.services.signoz_client import GoldMetrics, get_signoz_client
|
||||
from src.utils.k8s_naming import normalize_resource_name
|
||||
from src.utils.timezone import now_taipei_iso
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -284,38 +286,68 @@ class OpenClawService:
|
||||
|
||||
Shadow Mode: 僅生成指令,不執行
|
||||
|
||||
Phase 18.1.6: 整合 K8s 資源名稱驗證 (ADR-016)
|
||||
|
||||
Returns:
|
||||
{command: str, description: str, type: str}
|
||||
"""
|
||||
# 根據告警類型選擇調優策略
|
||||
# Phase 18.1.6: 先正規化資源名稱
|
||||
normalized = normalize_resource_name(target_resource, namespace)
|
||||
|
||||
if not normalized.is_k8s_resource:
|
||||
# 非 K8s 資源,返回提示訊息
|
||||
logger.info(
|
||||
"non_k8s_resource_detected",
|
||||
original=target_resource,
|
||||
note=normalized.note,
|
||||
)
|
||||
return {
|
||||
"type": "MANUAL",
|
||||
"command": f"# 非 K8s 資源: {target_resource}",
|
||||
"description": f"此資源不在 K8s 中,需人工處理。{normalized.note or ''}",
|
||||
}
|
||||
|
||||
# 使用正規化後的名稱
|
||||
resolved_name = normalized.normalized or target_resource
|
||||
resolved_ns = normalized.namespace or namespace
|
||||
|
||||
if normalized.confidence < 0.8:
|
||||
logger.warning(
|
||||
"low_confidence_resource_name",
|
||||
original=target_resource,
|
||||
resolved=resolved_name,
|
||||
confidence=normalized.confidence,
|
||||
)
|
||||
|
||||
# 根據告警類型選擇調優策略 (使用正規化後的名稱)
|
||||
if "cpu" in alert_type.lower() or "high_cpu" in alert_type.lower():
|
||||
# CPU 高 → 擴容或調整 limit
|
||||
if metrics and metrics.rps > 100:
|
||||
# 高流量場景 → HPA
|
||||
return {
|
||||
"type": "HPA",
|
||||
"command": f"kubectl autoscale deployment {target_resource} --cpu-percent=70 --min=2 --max=10 -n {namespace}",
|
||||
"command": f"kubectl autoscale deployment {resolved_name} --cpu-percent=70 --min=2 --max=10 -n {resolved_ns}",
|
||||
"description": f"SignOz RPS={metrics.rps:.0f},配置 HPA 應對流量波動",
|
||||
}
|
||||
else:
|
||||
# 低流量但 CPU 高 → 調整資源
|
||||
return {
|
||||
"type": "RESOURCE_LIMIT",
|
||||
"command": f"kubectl set resources deployment/{target_resource} --limits=cpu=2000m -n {namespace}",
|
||||
"command": f"kubectl set resources deployment/{resolved_name} --limits=cpu=2000m -n {resolved_ns}",
|
||||
"description": "增加 CPU limit 緩解資源競爭",
|
||||
}
|
||||
|
||||
elif "memory" in alert_type.lower() or "oom" in alert_type.lower():
|
||||
return {
|
||||
"type": "RESOURCE_LIMIT",
|
||||
"command": f"kubectl set resources deployment/{target_resource} --limits=memory=1Gi -n {namespace}",
|
||||
"command": f"kubectl set resources deployment/{resolved_name} --limits=memory=1Gi -n {resolved_ns}",
|
||||
"description": "增加 Memory limit 防止 OOM",
|
||||
}
|
||||
|
||||
elif "pod_crash" in alert_type.lower() or "crash" in alert_type.lower():
|
||||
return {
|
||||
"type": "RESTART",
|
||||
"command": f"kubectl rollout restart deployment/{target_resource} -n {namespace}",
|
||||
"command": f"kubectl rollout restart deployment/{resolved_name} -n {resolved_ns}",
|
||||
"description": "滾動重啟清除異常狀態",
|
||||
}
|
||||
|
||||
@@ -323,7 +355,7 @@ class OpenClawService:
|
||||
if metrics and metrics.p99_latency_ms > 500:
|
||||
return {
|
||||
"type": "SCALE",
|
||||
"command": f"kubectl scale deployment {target_resource} --replicas=+2 -n {namespace}",
|
||||
"command": f"kubectl scale deployment {resolved_name} --replicas=+2 -n {resolved_ns}",
|
||||
"description": f"SignOz P99={metrics.p99_latency_ms:.0f}ms,擴容分散負載",
|
||||
}
|
||||
else:
|
||||
@@ -337,7 +369,7 @@ class OpenClawService:
|
||||
# 通用: 滾動重啟
|
||||
return {
|
||||
"type": "RESTART",
|
||||
"command": f"kubectl rollout restart deployment/{target_resource} -n {namespace}",
|
||||
"command": f"kubectl rollout restart deployment/{resolved_name} -n {resolved_ns}",
|
||||
"description": "滾動重啟恢復服務",
|
||||
}
|
||||
|
||||
|
||||
419
apps/api/src/services/resource_resolver.py
Normal file
419
apps/api/src/services/resource_resolver.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""
|
||||
Resource Resolver - ADR-016 K8s 資源動態驗證
|
||||
=============================================
|
||||
|
||||
在 AI 產生 kubectl 指令後,動態驗證資源是否存在於 K8s 叢集中。
|
||||
若不存在,嘗試模糊匹配或回報需人工確認。
|
||||
|
||||
流程:
|
||||
1. 正規化資源名稱 (k8s_naming.py)
|
||||
2. 調用 MCP Tool 驗證資源存在性
|
||||
3. 模糊匹配 namespace 內的 Deployments
|
||||
4. 回傳匹配結果或候選列表
|
||||
|
||||
版本: v1.0
|
||||
建立: 2026-03-26 (台北時區)
|
||||
建立者: Claude Code (首席架構師)
|
||||
|
||||
@see docs/adr/ADR-016-k8s-resource-naming.md
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from src.utils.k8s_naming import (
|
||||
NormalizeResult,
|
||||
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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Resource Resolver
|
||||
# =============================================================================
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Singleton Instance
|
||||
# =============================================================================
|
||||
|
||||
_resolver: ResourceResolver | None = None
|
||||
|
||||
|
||||
def get_resource_resolver() -> ResourceResolver:
|
||||
"""取得 ResourceResolver 單例"""
|
||||
global _resolver
|
||||
if _resolver is None:
|
||||
_resolver = ResourceResolver()
|
||||
return _resolver
|
||||
Reference in New Issue
Block a user