""" K8s Resource Naming Utilities - ADR-016 資源名稱規範 ===================================================== 提供 K8s 資源名稱正規化與驗證功能: 1. URL/域名 → 有效 K8s 名稱 2. 格式驗證 (RFC 1123) 3. 靜態映射表查詢 K8s 命名規則 (RFC 1123): - 最多 63 字元 - 只能包含小寫字母、數字、連字號 - 必須以字母或數字開頭和結尾 版本: v1.0 建立: 2026-03-26 (台北時區) 建立者: Claude Code (首席架構師) @see docs/adr/ADR-016-k8s-resource-naming.md """ import re from dataclasses import dataclass from enum import Enum from typing import Final import structlog logger = structlog.get_logger(__name__) # ============================================================================= # Constants # ============================================================================= # K8s 名稱正則 (RFC 1123 subdomain) K8S_NAME_PATTERN: Final[re.Pattern] = re.compile( r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" ) # 最大長度 K8S_NAME_MAX_LENGTH: Final[int] = 63 # ============================================================================= # Static Mapping Table (Fallback) # ============================================================================= # URL/域名 → K8s Deployment 映射 # 當動態查詢失敗時使用 RESOURCE_MAPPING: Final[dict[str, tuple[str, str]]] = { # 域名 → (deployment_name, namespace) "api.awoooi.wooo.work": ("awoooi-api", "awoooi-prod"), "awoooi.wooo.work": ("awoooi-web", "awoooi-prod"), "wooo.work": ("awoooi-web", "awoooi-prod"), # 服務別名 "awoooi-api": ("awoooi-api", "awoooi-prod"), "awoooi-web": ("awoooi-web", "awoooi-prod"), "openclaw": ("openclaw", "awoooi-prod"), # 內部服務 "signoz": ("signoz-otel-collector", "signoz"), "langfuse": ("langfuse-web", "langfuse"), } # 非 K8s 資源標記 (這些主機不在 K8s 中) NON_K8S_HOSTS: Final[set[str]] = { "prod-docker-188", "192.168.0.188", "192.168.0.110", "192.168.0.112", } # ============================================================================= # Types # ============================================================================= class ResourceType(str, Enum): """資源類型""" DEPLOYMENT = "deployment" STATEFULSET = "statefulset" POD = "pod" SERVICE = "service" UNKNOWN = "unknown" @dataclass class NormalizeResult: """正規化結果""" success: bool original: str normalized: str | None namespace: str | None resource_type: ResourceType is_k8s_resource: bool confidence: float # 0.0 - 1.0 note: str | None = None # ============================================================================= # Normalization Functions # ============================================================================= def is_valid_k8s_name(name: str) -> bool: """ 檢查是否為有效的 K8s 資源名稱 (RFC 1123) Args: name: 資源名稱 Returns: bool: 是否有效 """ if not name: return False if len(name) > K8S_NAME_MAX_LENGTH: return False return bool(K8S_NAME_PATTERN.match(name)) def strip_url_scheme(raw: str) -> str: """ 移除 URL scheme 和路徑 Examples: https://api.awoooi.wooo.work/v1/health → api.awoooi.wooo.work http://192.168.0.188:8000 → 192.168.0.188 """ # 移除 scheme result = re.sub(r"^https?://", "", raw) # 移除 port result = re.sub(r":\d+.*$", "", result) # 移除路徑 result = result.split("/")[0] return result.strip() def to_k8s_safe_name(raw: str) -> str: """ 轉換為 K8s 安全名稱 Examples: api.awoooi.wooo.work → api-awoooi-wooo-work My_Service_Name → my-service-name """ # 轉小寫 result = raw.lower() # 替換不允許的字元為連字號 result = re.sub(r"[^a-z0-9-]", "-", result) # 合併多個連字號 result = re.sub(r"-+", "-", result) # 移除開頭和結尾的連字號 result = result.strip("-") # 截斷到最大長度 if len(result) > K8S_NAME_MAX_LENGTH: result = result[:K8S_NAME_MAX_LENGTH].rstrip("-") return result def normalize_resource_name(raw: str, default_namespace: str = "awoooi-prod") -> NormalizeResult: """ 正規化資源名稱 - 主入口函數 流程: 1. 檢查是否為非 K8s 資源 2. 移除 URL scheme 3. 查詢靜態映射表 4. 轉換為 K8s 安全名稱 5. 驗證格式 Args: raw: 原始資源名稱 (可能是 URL、域名、或 K8s 名稱) default_namespace: 預設命名空間 Returns: NormalizeResult: 正規化結果 """ if not raw: return NormalizeResult( success=False, original=raw, normalized=None, namespace=None, resource_type=ResourceType.UNKNOWN, is_k8s_resource=False, confidence=0.0, note="Empty resource name", ) # Step 1: 檢查非 K8s 資源 stripped = strip_url_scheme(raw) if stripped in NON_K8S_HOSTS or raw in NON_K8S_HOSTS: logger.info( "resource_is_non_k8s", original=raw, stripped=stripped, ) return NormalizeResult( success=True, original=raw, normalized=stripped, namespace=None, resource_type=ResourceType.UNKNOWN, is_k8s_resource=False, confidence=1.0, note="Non-K8s host (VM/Container)", ) # Step 2: 查詢靜態映射表 lookup_key = stripped.lower() if lookup_key in RESOURCE_MAPPING: deployment, namespace = RESOURCE_MAPPING[lookup_key] logger.info( "resource_mapped_from_table", original=raw, deployment=deployment, namespace=namespace, ) return NormalizeResult( success=True, original=raw, normalized=deployment, namespace=namespace, resource_type=ResourceType.DEPLOYMENT, is_k8s_resource=True, confidence=1.0, note="Mapped from static table", ) # Step 3: 檢查是否已經是有效的 K8s 名稱 if is_valid_k8s_name(raw): logger.info( "resource_already_valid", original=raw, ) return NormalizeResult( success=True, original=raw, normalized=raw, namespace=default_namespace, resource_type=ResourceType.DEPLOYMENT, is_k8s_resource=True, confidence=0.0, # 🔴 規則驗證,非 AI note="Already valid K8s name", ) # Step 4: 嘗試轉換 converted = to_k8s_safe_name(stripped) if is_valid_k8s_name(converted): logger.info( "resource_converted", original=raw, converted=converted, ) return NormalizeResult( success=True, original=raw, normalized=converted, namespace=default_namespace, resource_type=ResourceType.DEPLOYMENT, is_k8s_resource=True, confidence=0.0, # 🔴 規則轉換,非 AI note=f"Converted from '{raw}' (requires validation)", ) # Step 5: 無法處理 logger.warning( "resource_normalization_failed", original=raw, attempted=converted, ) return NormalizeResult( success=False, original=raw, normalized=None, namespace=None, resource_type=ResourceType.UNKNOWN, is_k8s_resource=False, confidence=0.0, note=f"Cannot normalize '{raw}' to valid K8s name", ) def extract_resource_hints(raw: str) -> list[str]: """ 從原始名稱提取可能的資源關鍵字 用於模糊匹配時的候選生成 Examples: https://api.awoooi.wooo.work → ["api", "awoooi", "wooo", "work"] prod-docker-188 → ["prod", "docker", "188"] """ stripped = strip_url_scheme(raw) # 分割所有非字母數字字元 parts = re.split(r"[^a-z0-9]+", stripped.lower()) # 過濾空字串和太短的詞 return [p for p in parts if len(p) >= 2]