Files
awoooi/docs/superpowers/plans/2026-04-04-p2-config-drift-detection.md
OG T 0b41df45d6 docs(plans): 三方向實作計畫 P0/P1/P2
- 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>
2026-04-04 12:31:36 +08:00

49 KiB
Raw Blame History

P2Config 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分級DriftInterpreterNemotron 意圖分析DriftRemediator確定性修復。API endpoint 作為統一入口,同時服務 Gitea CD Webhook 與每小時 K8s CronJob。Telegram 按鈕互動沿用現有 telegram_gateway.py 的 callback 機制。

Tech Stack: Python 3.11, asyncio, structlog, subprocesskubectl/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 1DB 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 2Commit
git add apps/api/migrations/phase9_drift_reports.sql
git commit -m "feat(migration): drift_reports 表 (P2)"

Task 2Drift 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

預期FAILmodels.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 5Commit
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 3DriftAnalyzer — 白名單過濾 + 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 5Commit
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 4DriftInterpreter — 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 5Commit
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 5DriftRemediator — 確定性修復

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 5Commit
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 6DriftDetector — 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:
        """
        執行完整掃描,回傳 DriftReportitems 尚未分級)

        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 4Commit
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 7API 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 4Commit
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