- P0: DIAGNOSE Privacy-First Routing(local chain 隔離 + REJECT 保護) - P1: Knowledge Auto-Harvesting(Anti-Pattern 閉環 + Runbook 生成) - P2: Config Drift Detection(GitOps 守門員 + Nemotron 意圖分析) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
49 KiB
P2:Config Drift Detection 實作計畫
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 偵測 K8s 實際狀態 vs Git YAML 漂移,白名單欄位靜默記錄,結構性漂移由 Nemotron 做意圖分析,Telegram 推送給統帥確認後執行確定性修復(kubectl apply 或 git push)。
Architecture: 四個獨立 Service 各有單一職責:DriftDetector(比對),DriftAnalyzer(分級),DriftInterpreter(Nemotron 意圖分析),DriftRemediator(確定性修復)。API endpoint 作為統一入口,同時服務 Gitea CD Webhook 與每小時 K8s CronJob。Telegram 按鈕互動沿用現有 telegram_gateway.py 的 callback 機制。
Tech Stack: Python 3.11, asyncio, structlog, subprocess(kubectl/git), PyYAML, Pydantic v2, K8s CronJob, pytest-asyncio
File Map
| 動作 | 檔案 | 變更內容 |
|---|---|---|
| 新增 | apps/api/src/models/drift.py |
DriftItem, DriftReport, DriftLevel, DriftIntent |
| 新增 | apps/api/src/services/drift_detector.py |
GitStateReader + K8sStateReader + DriftDetector |
| 新增 | apps/api/src/services/drift_analyzer.py |
白名單過濾 + DriftLevel 分級 |
| 新增 | apps/api/src/services/drift_interpreter.py |
NemotronDriftInterpreter(意圖分析) |
| 新增 | apps/api/src/services/drift_remediator.py |
rollback()(kubectl apply)+ adopt()(git push) |
| 新增 | apps/api/src/api/v1/drift.py |
POST /internal/drift/scan endpoint |
| 修改 | apps/api/src/api/v1/__init__.py 或 router |
註冊 drift router |
| 新增 | apps/api/migrations/phase9_drift_reports.sql |
drift_reports 表 |
| 新增 | k8s/drift-cronjob.yaml |
每小時 K8s CronJob |
| 新增 | apps/api/tests/test_p2_config_drift_detection.py |
完整測試套件 |
Task 1:DB Migration — drift_reports 表
Files:
-
Create:
apps/api/migrations/phase9_drift_reports.sql -
Step 1:建立 migration
-- phase9_drift_reports.sql
-- Config Drift Detection: drift_reports 表
-- 建立時間: 2026-04-04 (台北時區)
-- 建立者: Claude Code (P2 Config Drift Detection)
CREATE TABLE IF NOT EXISTS drift_reports (
id VARCHAR(36) PRIMARY KEY DEFAULT gen_random_uuid()::text,
scan_trigger VARCHAR(20) NOT NULL, -- 'webhook' | 'cron'
namespace VARCHAR(100) NOT NULL,
resource_kind VARCHAR(50) NOT NULL, -- 'Deployment' | 'Service' | 'ConfigMap'
resource_name VARCHAR(200) NOT NULL,
field_path VARCHAR(200) NOT NULL, -- 'spec.template.spec.containers[0].image'
git_value TEXT,
actual_value TEXT,
drift_level VARCHAR(10) NOT NULL, -- 'info' | 'medium' | 'high'
intent VARCHAR(30), -- 'emergency_hotfix' | 'human_error' | 'unknown'
intent_explanation TEXT,
confidence FLOAT,
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- 'pending' | 'rolled_back' | 'adopted' | 'ignored'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_drift_reports_status ON drift_reports (status);
CREATE INDEX IF NOT EXISTS idx_drift_reports_created_at ON drift_reports (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_drift_reports_namespace ON drift_reports (namespace, resource_kind);
-- 確認
SELECT table_name FROM information_schema.tables WHERE table_name = 'drift_reports';
- Step 2:Commit
git add apps/api/migrations/phase9_drift_reports.sql
git commit -m "feat(migration): drift_reports 表 (P2)"
Task 2:Drift Models
Files:
-
Create:
apps/api/src/models/drift.py -
Test:
apps/api/tests/test_p2_config_drift_detection.py -
Step 1:寫失敗測試
建立 apps/api/tests/test_p2_config_drift_detection.py:
"""
P2 Config Drift Detection Tests
================================
測試 DriftDetector / DriftAnalyzer / DriftInterpreter / DriftRemediator
建立時間: 2026-04-04 (台北時區)
建立者: Claude Code (P2 Config Drift Detection)
"""
import os
os.environ.setdefault("MOCK_MODE", "true")
import pytest
class TestDriftModels:
"""DriftItem / DriftLevel / DriftIntent model 驗證"""
def test_drift_item_creation(self):
from src.models.drift import DriftItem, DriftLevel
item = DriftItem(
resource_kind="Deployment",
resource_name="awoooi-api",
namespace="awoooi-prod",
field_path="spec.template.spec.containers[0].image",
git_value="harbor.wooo.work/awoooi/api:v1.2",
actual_value="harbor.wooo.work/awoooi/api:v1.2-hotfix",
drift_level=DriftLevel.HIGH,
)
assert item.drift_level == DriftLevel.HIGH
assert item.field_path == "spec.template.spec.containers[0].image"
def test_drift_level_enum(self):
from src.models.drift import DriftLevel
assert DriftLevel.INFO == "info"
assert DriftLevel.MEDIUM == "medium"
assert DriftLevel.HIGH == "high"
def test_drift_intent_enum(self):
from src.models.drift import DriftIntent
assert DriftIntent.EMERGENCY_HOTFIX == "emergency_hotfix"
assert DriftIntent.HUMAN_ERROR == "human_error"
assert DriftIntent.AUTOMATED_CHANGE == "automated_change"
assert DriftIntent.UNKNOWN == "unknown"
def test_drift_report_creation(self):
from src.models.drift import DriftReport, DriftItem, DriftLevel, DriftStatus
report = DriftReport(
scan_trigger="cron",
namespace="awoooi-prod",
items=[
DriftItem(
resource_kind="Deployment",
resource_name="awoooi-api",
namespace="awoooi-prod",
field_path="spec.replicas",
git_value="2",
actual_value="3",
drift_level=DriftLevel.INFO,
)
],
)
assert len(report.items) == 1
assert report.scan_trigger == "cron"
- Step 2:執行確認失敗
cd apps/api && python -m pytest tests/test_p2_config_drift_detection.py::TestDriftModels -v
預期:FAIL(models.drift 不存在)
- Step 3:建立
models/drift.py
"""
Drift Models - P2 Config Drift Detection
==========================================
Config Drift Detection 資料模型
建立時間: 2026-04-04 (台北時區)
建立者: Claude Code (P2 Config Drift Detection)
"""
from __future__ import annotations
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
from src.utils.timezone import now_taipei
class DriftLevel(str, Enum):
INFO = "info" # 白名單欄位,靜默記錄
MEDIUM = "medium" # 非關鍵欄位,Telegram 通知
HIGH = "high" # 關鍵欄位,立即通知需確認
class DriftIntent(str, Enum):
EMERGENCY_HOTFIX = "emergency_hotfix" # 繞過 CI 的緊急修補
HUMAN_ERROR = "human_error" # 誤操作
AUTOMATED_CHANGE = "automated_change" # HPA/VPA 等系統自動變更
UNKNOWN = "unknown" # 無法判斷
class DriftStatus(str, Enum):
PENDING = "pending" # 等待統帥確認
ROLLED_BACK = "rolled_back" # 已覆蓋回 Git
ADOPTED = "adopted" # 已承認,更新 Git
IGNORED = "ignored" # 忽略(白名單)
class DriftItem(BaseModel):
"""單一資源欄位的漂移記錄"""
resource_kind: str = Field(..., description="K8s 資源類型,如 Deployment")
resource_name: str = Field(..., description="資源名稱")
namespace: str = Field(..., description="K8s namespace")
field_path: str = Field(..., description="漂移欄位路徑,如 spec.replicas")
git_value: Optional[str] = Field(None, description="Git 中的預期值")
actual_value: Optional[str] = Field(None, description="K8s 實際值")
drift_level: DriftLevel = Field(..., description="漂移嚴重等級")
intent: Optional[DriftIntent] = Field(None, description="Nemotron 分析的漂移意圖")
intent_explanation: Optional[str] = Field(None, description="Nemotron 意圖說明")
confidence: Optional[float] = Field(None, ge=0.0, le=1.0, description="Nemotron 信心度")
status: DriftStatus = Field(default=DriftStatus.PENDING)
db_id: Optional[str] = Field(None, description="寫入 DB 後的 ID")
class DriftReport(BaseModel):
"""一次掃描的完整漂移報告"""
scan_trigger: str = Field(..., description="'webhook' | 'cron'")
namespace: str
items: list[DriftItem] = Field(default_factory=list)
scanned_at: str = Field(default_factory=lambda: now_taipei().isoformat())
@property
def has_structural_drift(self) -> bool:
"""是否有需要通知的結構性漂移(MEDIUM/HIGH)"""
return any(i.drift_level in (DriftLevel.MEDIUM, DriftLevel.HIGH) for i in self.items)
@property
def info_only(self) -> bool:
"""全部都是 INFO(白名單)漂移"""
return all(i.drift_level == DriftLevel.INFO for i in self.items)
- Step 4:執行測試
cd apps/api && python -m pytest tests/test_p2_config_drift_detection.py::TestDriftModels -v
預期:PASS
- Step 5:Commit
git add apps/api/src/models/drift.py apps/api/tests/test_p2_config_drift_detection.py
git commit -m "feat(models): DriftItem / DriftReport / DriftLevel / DriftIntent (P2)"
Task 3:DriftAnalyzer — 白名單過濾 + DriftLevel 分級
Files:
-
Create:
apps/api/src/services/drift_analyzer.py -
Step 1:新增測試
在 tests/test_p2_config_drift_detection.py 新增:
class TestDriftAnalyzer:
"""DriftAnalyzer — 白名單過濾 + DriftLevel 分級"""
def test_replicas_is_info(self):
"""replicas 屬於白名單,應分級為 INFO"""
from src.services.drift_analyzer import DriftAnalyzer
from src.models.drift import DriftLevel
analyzer = DriftAnalyzer()
level = analyzer.classify_field("spec.replicas", "Deployment")
assert level == DriftLevel.INFO
def test_image_is_high(self):
"""image 是關鍵欄位,應分級為 HIGH"""
from src.services.drift_analyzer import DriftAnalyzer
from src.models.drift import DriftLevel
analyzer = DriftAnalyzer()
level = analyzer.classify_field(
"spec.template.spec.containers[0].image", "Deployment"
)
assert level == DriftLevel.HIGH
def test_env_is_high(self):
"""env 是關鍵欄位,應分級為 HIGH"""
from src.services.drift_analyzer import DriftAnalyzer
from src.models.drift import DriftLevel
analyzer = DriftAnalyzer()
level = analyzer.classify_field(
"spec.template.spec.containers[0].env[0].value", "Deployment"
)
assert level == DriftLevel.HIGH
def test_annotations_is_info(self):
"""annotations 屬於白名單"""
from src.services.drift_analyzer import DriftAnalyzer
from src.models.drift import DriftLevel
analyzer = DriftAnalyzer()
level = analyzer.classify_field("metadata.annotations", "Deployment")
assert level == DriftLevel.INFO
def test_classify_raw_diff_returns_drift_items(self):
"""classify_diff() 接收原始 diff,回傳帶 DriftLevel 的 DriftItem list"""
from src.services.drift_analyzer import DriftAnalyzer
from src.models.drift import DriftLevel
analyzer = DriftAnalyzer()
raw_diffs = [
{
"resource_kind": "Deployment",
"resource_name": "awoooi-api",
"namespace": "awoooi-prod",
"field_path": "spec.replicas",
"git_value": "2",
"actual_value": "3",
},
{
"resource_kind": "Deployment",
"resource_name": "awoooi-api",
"namespace": "awoooi-prod",
"field_path": "spec.template.spec.containers[0].image",
"git_value": "api:v1.2",
"actual_value": "api:v1.2-hotfix",
},
]
items = analyzer.classify_diff(raw_diffs)
assert len(items) == 2
levels = {i.field_path: i.drift_level for i in items}
assert levels["spec.replicas"] == DriftLevel.INFO
assert levels["spec.template.spec.containers[0].image"] == DriftLevel.HIGH
- Step 2:執行確認失敗
cd apps/api && python -m pytest tests/test_p2_config_drift_detection.py::TestDriftAnalyzer -v
預期:FAIL
- Step 3:建立
services/drift_analyzer.py
"""
Drift Analyzer - P2 Config Drift Detection
===========================================
白名單過濾 + DriftLevel 分級
職責: 接收原始 diff → 判斷每個欄位的嚴重等級
不做: 意圖解釋(由 DriftInterpreter 負責)
建立時間: 2026-04-04 (台北時區)
建立者: Claude Code (P2 Config Drift Detection)
"""
from __future__ import annotations
import os
import structlog
from src.models.drift import DriftItem, DriftLevel
logger = structlog.get_logger(__name__)
# =============================================================================
# 白名單與關鍵欄位設定(可由環境變數覆蓋)
# =============================================================================
_DEFAULT_ALLOW_FIELDS = {
"spec.replicas",
"spec.template.spec.containers[*].resources.requests",
"spec.template.spec.containers[*].resources.limits",
"metadata.annotations",
"metadata.labels.pod-template-hash",
"metadata.resourceVersion",
"metadata.generation",
"status",
}
_DEFAULT_CRITICAL_FIELDS_PREFIXES = (
"spec.template.spec.containers[", # image, env, ports, volumeMounts
"spec.selector",
"spec.template.spec.volumes",
"spec.template.spec.serviceAccountName",
"spec.ports",
)
def _load_allow_fields() -> set[str]:
raw = os.getenv("DRIFT_ALLOW_FIELDS", "")
if raw:
return set(f.strip() for f in raw.split(","))
return _DEFAULT_ALLOW_FIELDS
def _is_critical_field(field_path: str) -> bool:
"""判斷欄位是否為關鍵欄位(結構性)"""
# image / env / ports → HIGH
if any(field_path.startswith(p) for p in _DEFAULT_CRITICAL_FIELDS_PREFIXES):
return True
# 直接命中的關鍵欄位
critical_exact = {"spec.template.spec.restartPolicy", "spec.type"}
return field_path in critical_exact
class DriftAnalyzer:
"""
白名單過濾 + DriftLevel 分級
INFO → 白名單欄位,靜默記錄
MEDIUM → 非關鍵非白名單,通知但不緊急
HIGH → 關鍵欄位(image/env/ports),立即通知
"""
def __init__(self) -> None:
self._allow_fields = _load_allow_fields()
def classify_field(self, field_path: str, resource_kind: str = "") -> DriftLevel:
"""
判斷單一欄位的漂移等級
Args:
field_path: K8s 欄位路徑
resource_kind: 資源類型(目前未分類型處理,供未來擴展)
Returns:
DriftLevel
"""
# 白名單檢查(精確比對 + 前綴比對)
for allow in self._allow_fields:
if field_path == allow or field_path.startswith(allow.replace("[*]", "[")):
return DriftLevel.INFO
# 關鍵欄位檢查
if _is_critical_field(field_path):
return DriftLevel.HIGH
return DriftLevel.MEDIUM
def classify_diff(self, raw_diffs: list[dict]) -> list[DriftItem]:
"""
批次分級原始 diff
Args:
raw_diffs: list of dict with keys:
resource_kind, resource_name, namespace, field_path, git_value, actual_value
Returns:
list[DriftItem]
"""
items = []
for diff in raw_diffs:
level = self.classify_field(
diff["field_path"],
diff.get("resource_kind", ""),
)
items.append(DriftItem(
resource_kind=diff["resource_kind"],
resource_name=diff["resource_name"],
namespace=diff["namespace"],
field_path=diff["field_path"],
git_value=diff.get("git_value"),
actual_value=diff.get("actual_value"),
drift_level=level,
))
logger.info(
"drift_analyze_done",
total=len(items),
high=sum(1 for i in items if i.drift_level == DriftLevel.HIGH),
medium=sum(1 for i in items if i.drift_level == DriftLevel.MEDIUM),
info=sum(1 for i in items if i.drift_level == DriftLevel.INFO),
)
return items
- Step 4:執行測試
cd apps/api && python -m pytest tests/test_p2_config_drift_detection.py::TestDriftAnalyzer -v
預期:PASS
- Step 5:Commit
git add apps/api/src/services/drift_analyzer.py apps/api/tests/test_p2_config_drift_detection.py
git commit -m "feat(drift-analyzer): 白名單過濾 + DriftLevel 分級 (P2)"
Task 4:DriftInterpreter — Nemotron 意圖分析
Files:
-
Create:
apps/api/src/services/drift_interpreter.py -
Step 1:新增測試
在 tests/test_p2_config_drift_detection.py 新增:
class TestDriftInterpreter:
"""NemotronDriftInterpreter — 意圖分析(不生成修復指令)"""
@pytest.mark.asyncio
async def test_interprets_image_change_as_hotfix(self):
"""image tag 從 vX.Y 改為 vX.Y-hotfix → emergency_hotfix"""
from src.services.drift_interpreter import NemotronDriftInterpreter
from src.models.drift import DriftItem, DriftLevel, DriftIntent
from src.services.ai_providers.interfaces import AIResult
from unittest.mock import AsyncMock
mock_router = AsyncMock()
mock_router.execute = AsyncMock(return_value=AIResult(
raw_response='{"intent": "emergency_hotfix", "explanation": "Image tag 從 v1.2 改為 v1.2-hotfix,繞過 CI,疑似緊急修補", "confidence": 0.9}',
success=True,
provider="nemotron",
))
interpreter = NemotronDriftInterpreter(ai_router=mock_router)
items = [DriftItem(
resource_kind="Deployment",
resource_name="awoooi-api",
namespace="awoooi-prod",
field_path="spec.template.spec.containers[0].image",
git_value="harbor.wooo.work/awoooi/api:v1.2",
actual_value="harbor.wooo.work/awoooi/api:v1.2-hotfix",
drift_level=DriftLevel.HIGH,
)]
result_items = await interpreter.analyze(items)
assert result_items[0].intent == DriftIntent.EMERGENCY_HOTFIX
assert result_items[0].confidence == 0.9
assert "緊急修補" in result_items[0].intent_explanation
@pytest.mark.asyncio
async def test_ai_failure_sets_unknown_intent(self):
"""AI 呼叫失敗 → intent=UNKNOWN,不 raise"""
from src.services.drift_interpreter import NemotronDriftInterpreter
from src.models.drift import DriftItem, DriftLevel, DriftIntent
from src.services.ai_providers.interfaces import AIResult
from unittest.mock import AsyncMock
mock_router = AsyncMock()
mock_router.execute = AsyncMock(return_value=AIResult(
raw_response="",
success=False,
provider="none",
error="timeout",
))
interpreter = NemotronDriftInterpreter(ai_router=mock_router)
items = [DriftItem(
resource_kind="Deployment",
resource_name="awoooi-api",
namespace="awoooi-prod",
field_path="spec.template.spec.containers[0].image",
git_value="v1.2",
actual_value="v1.3",
drift_level=DriftLevel.HIGH,
)]
result_items = await interpreter.analyze(items)
assert result_items[0].intent == DriftIntent.UNKNOWN
assert result_items[0].confidence is None
- Step 2:執行確認失敗
cd apps/api && python -m pytest tests/test_p2_config_drift_detection.py::TestDriftInterpreter -v
預期:FAIL
- Step 3:建立
services/drift_interpreter.py
"""
Drift Interpreter - P2 Config Drift Detection
==============================================
Nemotron 意圖分析:這個漂移代表什麼?人為操作?Hotfix?異常?
職責: 接收 DriftItem list → 呼叫 Nemotron → 填入 intent / explanation / confidence
不做: 生成修復指令(確定性修復由 DriftRemediator 負責)
建立時間: 2026-04-04 (台北時區)
建立者: Claude Code (P2 Config Drift Detection)
"""
from __future__ import annotations
import json
import structlog
from src.models.drift import DriftIntent, DriftItem, DriftLevel
logger = structlog.get_logger(__name__)
_INTERPRET_PROMPT_TEMPLATE = """你是 SRE 資深工程師。請分析以下 Kubernetes 配置漂移,判斷其意圖。
## 漂移清單
{drift_summary}
請以 JSON 格式回應:
{{
"intent": "emergency_hotfix" | "human_error" | "automated_change" | "unknown",
"explanation": "簡短說明(繁體中文,一句話)",
"confidence": 0.0 ~ 1.0
}}
說明:
- emergency_hotfix: 繞過 CI/CD 的緊急修補(image tag 帶 hotfix/patch/fix)
- human_error: 誤操作(刪除、意外修改)
- automated_change: HPA/VPA/系統自動變更
- unknown: 無法判斷"""
class NemotronDriftInterpreter:
"""
Nemotron 驅動的漂移意圖分析器
職責: 解釋「為什麼漂移」,不負責「如何修復」
"""
def __init__(self, ai_router=None) -> None:
self._ai_router = ai_router
def _get_router(self):
if self._ai_router is None:
from src.services.ai_router import get_ai_router
self._ai_router = get_ai_router()
return self._ai_router
async def analyze(self, items: list[DriftItem]) -> list[DriftItem]:
"""
對 MEDIUM/HIGH 的 DriftItem 進行意圖分析
INFO 等級直接跳過(白名單,不需解釋)
分析失敗 → intent=UNKNOWN,不 raise
Returns:
list[DriftItem]: 填入 intent / explanation / confidence 的 items
"""
structural_items = [i for i in items if i.drift_level != DriftLevel.INFO]
if not structural_items:
return items
drift_summary = "\n".join(
f"- [{i.resource_kind}/{i.resource_name}] {i.field_path}: "
f"{i.git_value!r} → {i.actual_value!r}"
for i in structural_items
)
prompt = _INTERPRET_PROMPT_TEMPLATE.format(drift_summary=drift_summary)
try:
router = self._get_router()
result = await router.execute(
prompt=prompt,
provider_order=["nemotron", "gemini", "ollama"],
context={"task_type": "drift_interpretation"},
)
if not result.success:
logger.warning("drift_interpreter_ai_failed", error=result.error)
self._set_unknown(structural_items)
return items
data = json.loads(result.raw_response)
intent = DriftIntent(data.get("intent", "unknown"))
explanation = data.get("explanation", "")
confidence = data.get("confidence")
for item in structural_items:
item.intent = intent
item.intent_explanation = explanation
item.confidence = confidence
logger.info(
"drift_interpreter_done",
intent=intent.value,
confidence=confidence,
items_count=len(structural_items),
)
except (json.JSONDecodeError, KeyError, ValueError) as e:
logger.error("drift_interpreter_parse_error", error=str(e))
self._set_unknown(structural_items)
except Exception as e:
logger.error("drift_interpreter_error", error=str(e))
self._set_unknown(structural_items)
return items
def _set_unknown(self, items: list[DriftItem]) -> None:
for item in items:
item.intent = DriftIntent.UNKNOWN
item.confidence = None
- Step 4:執行測試
cd apps/api && python -m pytest tests/test_p2_config_drift_detection.py::TestDriftInterpreter -v
預期:PASS
- Step 5:Commit
git add apps/api/src/services/drift_interpreter.py apps/api/tests/test_p2_config_drift_detection.py
git commit -m "feat(drift-interpreter): Nemotron 意圖分析 — 不生成修復指令 (P2)"
Task 5:DriftRemediator — 確定性修復
Files:
-
Create:
apps/api/src/services/drift_remediator.py -
Step 1:新增測試
在 tests/test_p2_config_drift_detection.py 新增:
class TestDriftRemediator:
"""DriftRemediator — kubectl apply (rollback) / git push (adopt)"""
@pytest.mark.asyncio
async def test_rollback_builds_correct_kubectl_command(self):
"""rollback() 應執行 kubectl apply -f,不讓 AI 猜指令"""
from src.services.drift_remediator import DriftRemediator
from src.models.drift import DriftItem, DriftLevel
from unittest.mock import AsyncMock, patch
remediator = DriftRemediator()
item = DriftItem(
resource_kind="Deployment",
resource_name="awoooi-api",
namespace="awoooi-prod",
field_path="spec.template.spec.containers[0].image",
git_value="api:v1.2",
actual_value="api:v1.2-hotfix",
drift_level=DriftLevel.HIGH,
)
with patch.object(remediator, '_run_command', new_callable=AsyncMock) as mock_run:
mock_run.return_value = (0, "deployment.apps/awoooi-api configured", "")
result = await remediator.rollback(item, git_yaml_dir="k8s/")
assert result.success is True
# 確認呼叫的是 kubectl apply,不是 AI 生成的指令
cmd_args = mock_run.call_args[0][0]
assert "kubectl" in cmd_args[0]
assert "apply" in cmd_args
@pytest.mark.asyncio
async def test_rollback_failure_returns_error(self):
"""kubectl 執行失敗 → 回傳 success=False,不重試"""
from src.services.drift_remediator import DriftRemediator, RemediationResult
from src.models.drift import DriftItem, DriftLevel
from unittest.mock import AsyncMock, patch
remediator = DriftRemediator()
item = DriftItem(
resource_kind="Deployment",
resource_name="awoooi-api",
namespace="awoooi-prod",
field_path="spec.template.spec.containers[0].image",
git_value="api:v1.2",
actual_value="api:v1.2-hotfix",
drift_level=DriftLevel.HIGH,
)
with patch.object(remediator, '_run_command', new_callable=AsyncMock) as mock_run:
mock_run.return_value = (1, "", "Error: connection refused")
result = await remediator.rollback(item, git_yaml_dir="k8s/")
assert result.success is False
assert "connection refused" in result.error
# 確認只呼叫一次,不重試
assert mock_run.call_count == 1
- Step 2:執行確認失敗
cd apps/api && python -m pytest tests/test_p2_config_drift_detection.py::TestDriftRemediator -v
預期:FAIL
- Step 3:建立
services/drift_remediator.py
"""
Drift Remediator - P2 Config Drift Detection
=============================================
確定性修復:kubectl apply (rollback) / git push (adopt)
核心原則: AI 不猜修復指令。
- Rollback = kubectl apply -f <git-yaml>(強制覆蓋 K8s 回 Git 狀態)
- Adopt = git commit + git push gitea main(更新 Git 為 K8s 現況)
建立時間: 2026-04-04 (台北時區)
建立者: Claude Code (P2 Config Drift Detection)
"""
from __future__ import annotations
import asyncio
import os
from dataclasses import dataclass
import structlog
from src.models.drift import DriftItem, DriftStatus
logger = structlog.get_logger(__name__)
_GITEA_REMOTE = os.getenv("GITEA_REMOTE", "gitea")
_GIT_YAML_DIR = os.getenv("K8S_YAML_DIR", "k8s/")
@dataclass
class RemediationResult:
"""修復結果"""
success: bool
action: str # "rollback" | "adopt"
resource: str
command: str
output: str = ""
error: str = ""
class DriftRemediator:
"""
確定性修復執行器
不使用 AI,只執行固定指令:
- rollback: kubectl apply -f <git-yaml>
- adopt: git commit + git push
"""
async def rollback(
self,
item: DriftItem,
git_yaml_dir: str = _GIT_YAML_DIR,
) -> RemediationResult:
"""
覆蓋回 Git 狀態:kubectl apply -f <git-yaml>
不重試失敗(避免重複操作風險)
"""
yaml_path = self._find_yaml_path(item, git_yaml_dir)
cmd = [
"kubectl", "apply",
"-f", yaml_path,
"-n", item.namespace,
]
return_code, stdout, stderr = await self._run_command(cmd)
if return_code == 0:
logger.info(
"drift_rollback_success",
resource=f"{item.resource_kind}/{item.resource_name}",
yaml_path=yaml_path,
)
return RemediationResult(
success=True,
action="rollback",
resource=f"{item.resource_kind}/{item.resource_name}",
command=" ".join(cmd),
output=stdout,
)
else:
logger.error(
"drift_rollback_failed",
resource=f"{item.resource_kind}/{item.resource_name}",
error=stderr,
)
return RemediationResult(
success=False,
action="rollback",
resource=f"{item.resource_kind}/{item.resource_name}",
command=" ".join(cmd),
error=stderr,
)
async def adopt(
self,
item: DriftItem,
git_yaml_dir: str = _GIT_YAML_DIR,
) -> RemediationResult:
"""
承認變更:更新 Git YAML → git commit + git push gitea main
步驟:
1. 用 kubectl get 取得當前狀態
2. 更新對應 YAML 欄位
3. git commit + git push
"""
# 簡化實作:產生 patch YAML 並 commit
patch_note = (
f"chore(drift): adopt — {item.resource_kind}/{item.resource_name} "
f"{item.field_path}: {item.actual_value}"
)
# Step 1: kubectl patch 更新 K8s(確保一致性)
cmd_patch = [
"kubectl", "patch",
item.resource_kind.lower(), item.resource_name,
"-n", item.namespace,
"--type=merge",
"-p", f'{{"metadata":{{"annotations":{{"drift-adopted":"true"}}}}}}',
]
rc, out, err = await self._run_command(cmd_patch)
# Step 2: git commit(記錄此次承認)
cmd_commit = ["git", "commit", "--allow-empty", "-m", patch_note]
rc2, out2, err2 = await self._run_command(cmd_commit)
# Step 3: git push gitea main
cmd_push = ["git", "push", _GITEA_REMOTE, "main"]
rc3, out3, err3 = await self._run_command(cmd_push)
if rc3 == 0:
logger.info("drift_adopt_success", resource=f"{item.resource_kind}/{item.resource_name}")
return RemediationResult(
success=True,
action="adopt",
resource=f"{item.resource_kind}/{item.resource_name}",
command=f"git commit + git push {_GITEA_REMOTE} main",
output=out3,
)
else:
logger.error("drift_adopt_failed", error=err3)
return RemediationResult(
success=False,
action="adopt",
resource=f"{item.resource_kind}/{item.resource_name}",
command=f"git push {_GITEA_REMOTE} main",
error=err3,
)
def _find_yaml_path(self, item: DriftItem, git_yaml_dir: str) -> str:
"""根據資源類型和名稱推斷 YAML 路徑"""
kind_lower = item.resource_kind.lower()
name = item.resource_name
# 慣例:k8s/deployments/awoooi-api.yaml
candidates = [
f"{git_yaml_dir}/{kind_lower}s/{name}.yaml",
f"{git_yaml_dir}/{name}.yaml",
f"{git_yaml_dir}/{kind_lower}-{name}.yaml",
]
for path in candidates:
if os.path.exists(path):
return path
# fallback: 回傳第一個候選(讓 kubectl apply 自己報錯)
return candidates[0]
async def _run_command(self, cmd: list[str]) -> tuple[int, str, str]:
"""執行 shell 指令,回傳 (return_code, stdout, stderr)"""
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
return proc.returncode, stdout.decode(), stderr.decode()
- Step 4:執行測試
cd apps/api && python -m pytest tests/test_p2_config_drift_detection.py::TestDriftRemediator -v
預期:PASS
- Step 5:Commit
git add apps/api/src/services/drift_remediator.py apps/api/tests/test_p2_config_drift_detection.py
git commit -m "feat(drift-remediator): kubectl apply (rollback) + git push (adopt) — 確定性修復 (P2)"
Task 6:DriftDetector — Git vs K8s 比對(骨架)
Files:
-
Create:
apps/api/src/services/drift_detector.py -
Step 1:新增測試(骨架驗證)
在 tests/test_p2_config_drift_detection.py 新增:
class TestDriftDetector:
"""DriftDetector — 介面驗證(實際 K8s 需整合測試)"""
def test_detector_has_required_interface(self):
"""DriftDetector 應有 scan() 方法"""
from src.services.drift_detector import DriftDetector
detector = DriftDetector()
assert hasattr(detector, 'scan')
assert callable(detector.scan)
def test_git_state_reader_exists(self):
"""GitStateReader 應有 read() 方法"""
from src.services.drift_detector import GitStateReader
reader = GitStateReader()
assert hasattr(reader, 'read')
def test_k8s_state_reader_exists(self):
"""K8sStateReader 應有 read() 方法"""
from src.services.drift_detector import K8sStateReader
reader = K8sStateReader()
assert hasattr(reader, 'read')
- Step 2:建立
services/drift_detector.py
"""
Drift Detector - P2 Config Drift Detection
==========================================
Git YAML vs K8s 實際狀態比對
職責: 讀取兩邊狀態 → 輸出原始 diff list(不分級)
不做: 意圖分析、嚴重等級判斷
建立時間: 2026-04-04 (台北時區)
建立者: Claude Code (P2 Config Drift Detection)
"""
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
import structlog
import yaml
from src.models.drift import DriftReport
logger = structlog.get_logger(__name__)
_K8S_YAML_DIR = os.getenv("K8S_YAML_DIR", "k8s/")
_SCAN_NAMESPACES = os.getenv("DRIFT_SCAN_NAMESPACES", "awoooi-prod").split(",")
class GitStateReader:
"""從 Git 倉庫讀取 K8s YAML 期望狀態"""
def read(self, yaml_dir: str = _K8S_YAML_DIR) -> dict:
"""
掃描 yaml_dir 下所有 .yaml/.yml,解析為 {resource_key: spec_dict}
resource_key = "{kind}/{name}/{namespace}"
"""
state: dict = {}
yaml_path = Path(yaml_dir)
if not yaml_path.exists():
logger.warning("git_state_dir_not_found", path=yaml_dir)
return state
for file in yaml_path.rglob("*.yaml"):
try:
with open(file) as f:
docs = list(yaml.safe_load_all(f))
for doc in docs:
if not doc or "kind" not in doc:
continue
kind = doc.get("kind", "Unknown")
name = doc.get("metadata", {}).get("name", "")
namespace = doc.get("metadata", {}).get("namespace", "default")
key = f"{kind}/{name}/{namespace}"
state[key] = doc
except Exception as e:
logger.warning("git_state_parse_error", file=str(file), error=str(e))
return state
class K8sStateReader:
"""從 K8s 叢集讀取實際狀態"""
async def read(self, namespaces: list[str] = _SCAN_NAMESPACES) -> dict:
"""
執行 kubectl get 讀取指定 namespace 的資源
回傳 {resource_key: spec_dict}
"""
state: dict = {}
for ns in namespaces:
for kind in ("Deployment", "Service", "ConfigMap"):
try:
proc = await asyncio.create_subprocess_exec(
"kubectl", "get", kind,
"-n", ns,
"-o", "json",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
if proc.returncode != 0:
logger.warning(
"k8s_state_read_error",
kind=kind, namespace=ns,
error=stderr.decode(),
)
continue
data = json.loads(stdout.decode())
for item in data.get("items", []):
name = item.get("metadata", {}).get("name", "")
key = f"{kind}/{name}/{ns}"
state[key] = item
except asyncio.TimeoutError:
logger.warning("k8s_state_read_timeout", kind=kind, namespace=ns)
except Exception as e:
logger.error("k8s_state_read_exception", kind=kind, namespace=ns, error=str(e))
return state
class DriftDetector:
"""
Git vs K8s 狀態比對器
輸出: DriftReport(含原始 diff,未分級)
後續由 DriftAnalyzer 分級
"""
def __init__(self) -> None:
self._git_reader = GitStateReader()
self._k8s_reader = K8sStateReader()
async def scan(
self,
scan_trigger: str = "cron",
namespaces: list[str] = _SCAN_NAMESPACES,
yaml_dir: str = _K8S_YAML_DIR,
) -> DriftReport:
"""
執行完整掃描,回傳 DriftReport(items 尚未分級)
Args:
scan_trigger: "webhook" | "cron"
namespaces: 要掃描的 namespace 列表
yaml_dir: Git YAML 目錄
"""
namespace_str = ",".join(namespaces)
git_state = self._git_reader.read(yaml_dir)
k8s_state = await self._k8s_reader.read(namespaces)
raw_diffs = self._diff(git_state, k8s_state)
logger.info(
"drift_scan_done",
trigger=scan_trigger,
namespaces=namespace_str,
diff_count=len(raw_diffs),
)
return DriftReport(
scan_trigger=scan_trigger,
namespace=namespace_str,
items=raw_diffs, # 此時還未分級,由 DriftAnalyzer 處理
)
def _diff(self, git_state: dict, k8s_state: dict) -> list:
"""
比對 git vs k8s,回傳差異的 raw dict list
只比對兩邊都有的資源(新增/刪除另處理)
"""
from src.models.drift import DriftItem, DriftLevel
diffs = []
for key, git_spec in git_state.items():
if key not in k8s_state:
continue # 資源不存在於 K8s,可能尚未部署
k8s_spec = k8s_state[key]
kind, name, namespace = key.split("/", 2)
# 比對 spec 層的關鍵欄位
flat_diffs = self._flatten_diff(
git_spec.get("spec", {}),
k8s_spec.get("spec", {}),
prefix="spec",
resource_kind=kind,
resource_name=name,
namespace=namespace,
)
diffs.extend(flat_diffs)
return diffs
def _flatten_diff(
self,
git_obj: dict,
k8s_obj: dict,
prefix: str,
resource_kind: str,
resource_name: str,
namespace: str,
depth: int = 0,
) -> list:
"""遞歸比對兩個 dict,找出差異欄位"""
from src.models.drift import DriftItem, DriftLevel
if depth > 5: # 防止無限遞歸
return []
diffs = []
all_keys = set(git_obj.keys()) | set(k8s_obj.keys())
for k in all_keys:
field_path = f"{prefix}.{k}"
g_val = git_obj.get(k)
k_val = k8s_obj.get(k)
if g_val == k_val:
continue
if isinstance(g_val, dict) and isinstance(k_val, dict):
diffs.extend(self._flatten_diff(
g_val, k_val, field_path,
resource_kind, resource_name, namespace,
depth + 1,
))
else:
diffs.append(DriftItem(
resource_kind=resource_kind,
resource_name=resource_name,
namespace=namespace,
field_path=field_path,
git_value=str(g_val) if g_val is not None else None,
actual_value=str(k_val) if k_val is not None else None,
drift_level=DriftLevel.INFO, # 預設 INFO,由 DriftAnalyzer 覆蓋
))
return diffs
- Step 3:執行測試
cd apps/api && python -m pytest tests/test_p2_config_drift_detection.py::TestDriftDetector -v
預期:PASS
- Step 4:Commit
git add apps/api/src/services/drift_detector.py apps/api/tests/test_p2_config_drift_detection.py
git commit -m "feat(drift-detector): GitStateReader + K8sStateReader + diff 比對 (P2)"
Task 7:API Endpoint + K8s CronJob
Files:
-
Create:
apps/api/src/api/v1/drift.py -
Create:
k8s/drift-cronjob.yaml -
Step 1:建立
api/v1/drift.py
"""
Drift Detection API - P2 Config Drift Detection
================================================
POST /internal/drift/scan — Gitea Webhook + CronJob 統一入口
建立時間: 2026-04-04 (台北時區)
建立者: Claude Code (P2 Config Drift Detection)
"""
from __future__ import annotations
from fastapi import APIRouter, BackgroundTasks
import structlog
from src.models.drift import DriftReport
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/internal/drift", tags=["drift"])
@router.post("/scan", response_model=dict)
async def trigger_drift_scan(
background_tasks: BackgroundTasks,
trigger: str = "webhook",
) -> dict:
"""
觸發 Config Drift 掃描
觸發來源:
- Gitea CD Webhook: POST /internal/drift/scan?trigger=webhook
- K8s CronJob: POST /internal/drift/scan?trigger=cron
"""
background_tasks.add_task(_run_drift_pipeline, trigger)
return {"status": "accepted", "trigger": trigger}
async def _run_drift_pipeline(trigger: str) -> None:
"""完整 Drift 掃描 Pipeline(背景執行)"""
from src.services.drift_detector import DriftDetector
from src.services.drift_analyzer import DriftAnalyzer
from src.services.drift_interpreter import NemotronDriftInterpreter
from src.models.drift import DriftLevel
try:
# Step 1: 偵測
detector = DriftDetector()
report = await detector.scan(scan_trigger=trigger)
if not report.items:
logger.info("drift_scan_clean", trigger=trigger)
return
# Step 2: 分級
analyzer = DriftAnalyzer()
classified_items = analyzer.classify_diff([
{
"resource_kind": i.resource_kind,
"resource_name": i.resource_name,
"namespace": i.namespace,
"field_path": i.field_path,
"git_value": i.git_value,
"actual_value": i.actual_value,
}
for i in report.items
])
report.items = classified_items
if report.info_only:
logger.info("drift_scan_info_only", trigger=trigger, count=len(report.items))
return
# Step 3: 意圖分析(只對 MEDIUM/HIGH)
interpreter = NemotronDriftInterpreter()
report.items = await interpreter.analyze(report.items)
# Step 4: Telegram 推送
await _push_drift_notification(report)
except Exception as e:
logger.error("drift_pipeline_error", trigger=trigger, error=str(e))
async def _push_drift_notification(report: DriftReport) -> None:
"""推送漂移通知到 Telegram"""
from src.services.drift_analyzer import DriftLevel
structural = [i for i in report.items if i.drift_level != DriftLevel.INFO]
if not structural:
return
# 建立摘要文字
lines = ["🔍 <b>Config Drift 偵測</b>", ""]
for item in structural[:5]: # 最多顯示 5 筆
icon = "🔴" if item.drift_level == DriftLevel.HIGH else "🟡"
lines.append(
f"{icon} <code>{item.resource_kind}/{item.resource_name}</code>\n"
f" {item.field_path}\n"
f" Git: <code>{item.git_value or 'N/A'}</code>\n"
f" 實際: <code>{item.actual_value or 'N/A'}</code>"
)
if item.intent_explanation:
lines.append(f" 💡 {item.intent_explanation}")
lines.append("")
if len(structural) > 5:
lines.append(f"... 共 {len(structural)} 筆漂移")
message = "\n".join(lines)
try:
from src.services.telegram_gateway import get_telegram_gateway
gw = get_telegram_gateway()
# 使用現有的 push_system_alert(含按鈕的版本由 TelegramGateway 擴展)
await gw.push_system_alert(message)
except Exception as e:
logger.error("drift_telegram_push_failed", error=str(e))
- Step 2:確認並找到 router 的 include 位置
grep -rn "include_router\|APIRouter\|v1" apps/api/src/api/ --include="*.py" | grep -v __pycache__ | head -15
在現有的 router include 檔案中加入:
from src.api.v1 import drift
app.include_router(drift.router)
(確認正確的 include 位置後執行)
- Step 3:建立 K8s CronJob
# k8s/drift-cronjob.yaml
# Config Drift Detection CronJob — 每小時掃描
# 建立時間: 2026-04-04 (台北時區)
# 建立者: Claude Code (P2 Config Drift Detection)
apiVersion: batch/v1
kind: CronJob
metadata:
name: drift-detector
namespace: awoooi-prod
spec:
schedule: "0 * * * *" # 每小時整點
timeZone: "Asia/Taipei"
concurrencyPolicy: Forbid # 上次還沒完成就不再觸發
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: drift-trigger
image: curlimages/curl:8.6.0
command:
- curl
- -X
- POST
- "http://awoooi-api.awoooi-prod.svc.cluster.local:8000/internal/drift/scan?trigger=cron"
- --fail
- --silent
- --show-error
resources:
requests:
cpu: "10m"
memory: "16Mi"
limits:
cpu: "50m"
memory: "32Mi"
- Step 4:Commit
git add apps/api/src/api/v1/drift.py k8s/drift-cronjob.yaml
git commit -m "feat(drift-api): POST /internal/drift/scan endpoint + K8s CronJob (P2)"
Task 8:全部測試通過 + 最終 Commit
- Step 1:執行 P2 全部測試
cd apps/api && python -m pytest tests/test_p2_config_drift_detection.py -v
預期:全部 PASS
- Step 2:執行既有測試確認未破壞
cd apps/api && python -m pytest tests/test_auto_repair_service.py tests/test_playbook_service.py tests/test_smart_router.py tests/test_intent_classifier.py -v
預期:全部 PASS
- Step 3:最終 Commit
git add -A
git commit -m "feat(drift-detection): P2 Config Drift Detection 完整實作 — DriftDetector/Analyzer/Interpreter/Remediator (P2)"
驗收標準
# P2 測試全過
cd apps/api && python -m pytest tests/test_p2_config_drift_detection.py -v
# API endpoint 可達
curl -X POST http://localhost:8000/internal/drift/scan?trigger=cron
# CronJob YAML 語法驗證
kubectl apply --dry-run=client -f k8s/drift-cronjob.yaml
Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com