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:
OG T
2026-03-26 11:22:47 +08:00
parent fe7fd7a3e0
commit 96c3ddd8c4
7 changed files with 1478 additions and 13 deletions

View File

@@ -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": "滾動重啟恢復服務",
}

View 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