依 onboarder 端到端閉環審計挖出的 10 條斷鏈 + critic 鐵律違反全景, W1 第一波修復飛輪鐵證 1 + 2 的核心斷鏈 C1。 ## W1 PR-P1 — matched_playbook_id 四斷點守門 (C1 修復) fullstack 探勘發現 4 斷點之前 session 已修,本 PR 補: - ENABLE_PLAYBOOK_MATCHING feature flag (default=true) rollback: kubectl set env deployment/awoooi-api ENABLE_PLAYBOOK_MATCHING=false - proposal_service._try_playbook_match_id 入口加 flag check - 7 個 e2e 測試補上保護網(之前無測試覆蓋) 斷鏈 C1 證據鏈:proposal_service.generate_proposal() → matched_playbook_id → approval_db → approval_repository → learning_service._update_playbook_stats 24h 後 playbooks.trust_score 應有真實 EWMA 更新。 ## ADR-091 T1 — auto_generate_rule 雙寫 DB (鐵證 1 第一步) 飛輪鐵證 1:alert_rule_catalog.source='ai_generated' 全 codebase 0 筆。 auto_generate_rule() 寫 alert_rules.yaml 但不寫 DB → AI 自學成果與 catalog 雙軌脫鉤。 修法(依 ADR-091 §1 D1): - 新增 _insert_catalog_ai_generated():YAML 寫入成功後雙寫 source='ai_generated', confidence=0.5, review_status='draft', created_by_agent - 新增 _parse_for_to_seconds() helper("30s"/"5m"/"2h" → seconds) - ON CONFLICT (rule_name) DO NOTHING 冪等保證 - transaction 策略:YAML + DB 不在同一 transaction(YAML 已成 SoT,DB 失敗只 log) - ENABLE_AI_RULE_CATALOG_WRITE feature flag (default=true) rollback: kubectl set env deployment/awoooi-api ENABLE_AI_RULE_CATALOG_WRITE=false 13 個測試覆蓋:parse helper 8 + 業務邏輯 5(success/db_fail/idempotent/flag/SQL_lit) ## 驗證 1572 unit tests 全綠(+20 新增:PR-P1 7 + ADR-091 T1 13) ## 期望影響 飛輪自主化評分:42 → 65(+23 = C1 +3 + 鐵證 1 +20) ## 已知債(critic PR review 揭示,下一個 commit 處理) - KMWriter 統一契約 3 條 caller 路徑被旁路(C1/M1/M2) - KMWriter 冪等聲明與實作不符(M3 缺 ON CONFLICT) - Alertmanager equal:[] 爆炸抑制 + 版本未驗(M4/M5) - drift checker regex 脆弱(M7 應改 AST) - governance health score skipped 失真(M6) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
453 lines
15 KiB
Python
453 lines
15 KiB
Python
"""
|
||
飛輪閉環 C1 修復 E2E 測試 — matched_playbook_id 全鏈路
|
||
=========================================================
|
||
W1 PR-P1 by Claude Sonnet 4.6 (2026-04-28 台北時區)
|
||
|
||
測試覆蓋四個斷點的修復驗證:
|
||
1. proposal_service 在相似度 >= 0.85 時填 matched_playbook_id
|
||
2. proposal_service 在相似度 < 0.85 時保留 None
|
||
3. approval_db 正確序列化 matched_playbook_id 到 record_data dict
|
||
4. learning_service 在 matched_playbook_id 存在時觸發 _update_playbook_stats(EWMA)
|
||
|
||
🔴 遵循 feedback_no_mock_testing.md:
|
||
- 禁止 MagicMock/AsyncMock/unittest.mock.patch
|
||
- 使用純 Python Stub 類別 + pytest monkeypatch(替換 module-level singleton)
|
||
- 不連線真實 DB / Redis
|
||
|
||
Feature Flag:
|
||
- ENABLE_PLAYBOOK_MATCHING=false → _try_playbook_match_id 立即回傳 None(rollback 路徑)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from uuid import UUID
|
||
|
||
import pytest
|
||
|
||
from src.models.approval import (
|
||
ApprovalRequest,
|
||
ApprovalRequestCreate,
|
||
ApprovalStatus,
|
||
RiskLevel,
|
||
)
|
||
from src.models.incident import Incident, IncidentStatus, Severity, Signal
|
||
from src.models.playbook import (
|
||
ActionType,
|
||
Playbook,
|
||
PlaybookStatus,
|
||
RepairStep,
|
||
RiskLevel as PlaybookRiskLevel,
|
||
SymptomPattern,
|
||
)
|
||
from src.utils.timezone import now_taipei
|
||
|
||
|
||
# =============================================================================
|
||
# Stubs
|
||
# =============================================================================
|
||
|
||
|
||
class StubRecommendation:
|
||
"""PlaybookService.get_recommendations 回傳的推薦結果 Stub"""
|
||
|
||
def __init__(self, playbook: Playbook, similarity_score: float = 0.9) -> None:
|
||
self.playbook = playbook
|
||
self.similarity_score = similarity_score
|
||
|
||
|
||
class StubPlaybookService:
|
||
"""PlaybookService 的輕量 Stub — 記錄 record_execution 呼叫"""
|
||
|
||
def __init__(self, recommendations: list | None = None) -> None:
|
||
self._recommendations = recommendations or []
|
||
self.record_execution_calls: list[dict] = []
|
||
|
||
async def get_recommendations(self, symptoms, top_k: int = 1) -> list:
|
||
return self._recommendations
|
||
|
||
async def record_execution(self, playbook_id: str, success: bool) -> bool:
|
||
self.record_execution_calls.append({"playbook_id": playbook_id, "success": success})
|
||
return True
|
||
|
||
|
||
class StubApprovalService:
|
||
"""ApprovalDBService 的輕量 Stub — 記錄 create_approval 呼叫"""
|
||
|
||
def __init__(self) -> None:
|
||
self.created: list[ApprovalRequestCreate] = []
|
||
|
||
async def create_approval(self, request: ApprovalRequestCreate) -> ApprovalRequest:
|
||
self.created.append(request)
|
||
# 回傳最小合法的 ApprovalRequest
|
||
return ApprovalRequest(
|
||
id=UUID("00000000-0000-0000-0000-000000000001"),
|
||
action=request.action,
|
||
description=request.description,
|
||
status=ApprovalStatus.PENDING,
|
||
risk_level=request.risk_level or RiskLevel.MEDIUM,
|
||
required_signatures=1,
|
||
current_signatures=0,
|
||
requested_by=request.requested_by,
|
||
created_at=now_taipei(),
|
||
matched_playbook_id=request.matched_playbook_id,
|
||
incident_id=request.incident_id,
|
||
)
|
||
|
||
|
||
class StubLearningRepo:
|
||
"""LearningRepository 的極簡 Stub"""
|
||
|
||
async def record_repair(self, **kwargs) -> bool:
|
||
return True
|
||
|
||
async def get_all_repair_stats(self, *a, **kw):
|
||
return {}
|
||
|
||
async def get_repair_history(self, *a, **kw):
|
||
return []
|
||
|
||
async def get_repair_stats(self, *a, **kw):
|
||
return {}
|
||
|
||
async def record_disposition(self, *a, **kw):
|
||
return True
|
||
|
||
async def get_dispositions(self, *a, **kw):
|
||
return {}
|
||
|
||
|
||
class StubTrustRepo:
|
||
"""TrustRepository 的極簡 Stub"""
|
||
|
||
async def upsert(self, **kwargs) -> None:
|
||
pass
|
||
|
||
async def save_trust_record(self, *a, **kw):
|
||
pass
|
||
|
||
async def load_trust_record(self, *a, **kw):
|
||
return None
|
||
|
||
async def get_all_trust_records(self, *a, **kw):
|
||
return []
|
||
|
||
|
||
# =============================================================================
|
||
# Factories
|
||
# =============================================================================
|
||
|
||
|
||
def _make_incident(
|
||
incident_id: str = "INC-C1-001",
|
||
severity: Severity = Severity.P2,
|
||
) -> Incident:
|
||
now = now_taipei()
|
||
return Incident(
|
||
incident_id=incident_id,
|
||
status=IncidentStatus.INVESTIGATING,
|
||
severity=severity,
|
||
affected_services=["c1-test-service"],
|
||
signals=[
|
||
Signal(
|
||
alert_name="HighCpuLoad",
|
||
severity=severity,
|
||
source="prometheus",
|
||
fired_at=now,
|
||
labels={"namespace": "awoooi-prod", "alertname": "HighCpuLoad"},
|
||
)
|
||
],
|
||
)
|
||
|
||
|
||
def _make_playbook(
|
||
playbook_id: str = "PB-C1-001",
|
||
trust_score: float = 0.75,
|
||
) -> Playbook:
|
||
return Playbook(
|
||
playbook_id=playbook_id,
|
||
name="C1 測試 Playbook",
|
||
description="飛輪斷鏈 C1 修復 E2E 測試用",
|
||
status=PlaybookStatus.APPROVED,
|
||
symptom_pattern=SymptomPattern(
|
||
alert_names=["HighCpuLoad"],
|
||
affected_services=["c1-test-service"],
|
||
severity_range=["P2"],
|
||
),
|
||
repair_steps=[
|
||
RepairStep(
|
||
step_number=1,
|
||
action_type=ActionType.MANUAL,
|
||
command="kubectl rollout restart deployment/c1-test-service",
|
||
risk_level=PlaybookRiskLevel.LOW,
|
||
)
|
||
],
|
||
trust_score=trust_score,
|
||
success_count=8,
|
||
failure_count=2,
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# Test 1: proposal 在相似度 >= 0.85 時填 matched_playbook_id
|
||
# =============================================================================
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_proposal_fills_matched_playbook_id_when_above_threshold(monkeypatch):
|
||
"""
|
||
proposal_service._try_playbook_match_id 在相似度 >= 0.85 + status=APPROVED 時
|
||
應回傳 playbook_id(非 None)。
|
||
"""
|
||
playbook = _make_playbook(playbook_id="PB-C1-ABOVE", trust_score=0.8)
|
||
stub_pb_service = StubPlaybookService(
|
||
recommendations=[StubRecommendation(playbook, similarity_score=0.92)]
|
||
)
|
||
|
||
# 替換 module-level playbook_service singleton
|
||
import src.services.playbook_service as _pb_mod
|
||
monkeypatch.setattr(_pb_mod, "_service", stub_pb_service)
|
||
|
||
from src.services.proposal_service import ProposalService
|
||
|
||
service = ProposalService.__new__(ProposalService)
|
||
incident = _make_incident()
|
||
|
||
result = await service._try_playbook_match_id(incident)
|
||
|
||
assert result == "PB-C1-ABOVE", (
|
||
f"相似度 0.92 >= 0.85 應回傳 playbook_id,實際得到: {result}"
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# Test 2: proposal 在相似度 < 0.85 時保留 None
|
||
# =============================================================================
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_proposal_keeps_none_when_below_threshold(monkeypatch):
|
||
"""
|
||
proposal_service._try_playbook_match_id 在相似度 < 0.85 時應回傳 None。
|
||
"""
|
||
playbook = _make_playbook(playbook_id="PB-C1-BELOW", trust_score=0.5)
|
||
stub_pb_service = StubPlaybookService(
|
||
recommendations=[StubRecommendation(playbook, similarity_score=0.70)]
|
||
)
|
||
|
||
import src.services.playbook_service as _pb_mod
|
||
monkeypatch.setattr(_pb_mod, "_service", stub_pb_service)
|
||
|
||
from src.services.proposal_service import ProposalService
|
||
|
||
service = ProposalService.__new__(ProposalService)
|
||
incident = _make_incident()
|
||
|
||
result = await service._try_playbook_match_id(incident)
|
||
|
||
assert result is None, (
|
||
f"相似度 0.70 < 0.85 應回傳 None,實際得到: {result}"
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# Test 3: approval_db approval_request_to_record_data 序列化 matched_playbook_id
|
||
# =============================================================================
|
||
|
||
|
||
def test_approval_db_persists_matched_playbook_id():
|
||
"""
|
||
approval_db.approval_request_to_record_data 應將 matched_playbook_id
|
||
正確序列化到 DB dict(不遺失欄位)。
|
||
"""
|
||
from src.services.approval_db import approval_request_to_record_data
|
||
from src.models.approval import ApprovalRequestCreate, BlastRadius, DataImpact
|
||
|
||
request = ApprovalRequestCreate(
|
||
action="kubectl rollout restart deployment/c1-test-service",
|
||
description="C1 test",
|
||
risk_level=RiskLevel.MEDIUM,
|
||
requested_by="c1-e2e-test",
|
||
incident_id="INC-C1-001",
|
||
matched_playbook_id="PB-C1-001",
|
||
blast_radius=BlastRadius(
|
||
affected_pods=2,
|
||
estimated_downtime="< 2 min",
|
||
related_services=["c1-test-service"],
|
||
data_impact=DataImpact.NONE,
|
||
),
|
||
)
|
||
|
||
data = approval_request_to_record_data(request, RiskLevel.MEDIUM, required_sigs=1)
|
||
|
||
assert "matched_playbook_id" in data, "record_data 必須包含 matched_playbook_id 鍵"
|
||
assert data["matched_playbook_id"] == "PB-C1-001", (
|
||
f"matched_playbook_id 應為 'PB-C1-001',實際: {data['matched_playbook_id']}"
|
||
)
|
||
|
||
|
||
def test_approval_db_record_data_none_when_not_provided():
|
||
"""
|
||
matched_playbook_id 未提供時,record_data 應包含 None(不拋例外)。
|
||
"""
|
||
from src.services.approval_db import approval_request_to_record_data
|
||
|
||
request = ApprovalRequestCreate(
|
||
action="kubectl rollout restart deployment/test",
|
||
description="No playbook test",
|
||
risk_level=RiskLevel.LOW,
|
||
requested_by="c1-e2e-test",
|
||
)
|
||
|
||
data = approval_request_to_record_data(request, RiskLevel.LOW, required_sigs=0)
|
||
|
||
assert "matched_playbook_id" in data
|
||
assert data["matched_playbook_id"] is None
|
||
|
||
|
||
# =============================================================================
|
||
# Test 4: learning_service 在 matched_playbook_id 存在時觸發 EWMA 更新
|
||
# =============================================================================
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_learning_service_updates_trust_when_matched(monkeypatch):
|
||
"""
|
||
learning_service.process_execution_result 在 approval.matched_playbook_id
|
||
非 None 時,應呼叫 _update_playbook_stats(最終觸發 EWMA trust_score 更新)。
|
||
"""
|
||
playbook_id = "PB-C1-LEARN-001"
|
||
stub_pb_service = StubPlaybookService()
|
||
playbook = _make_playbook(playbook_id=playbook_id)
|
||
stub_pb_service._recommendations = [] # 不需要 recommendations
|
||
|
||
# 替換 playbook_service singleton(_update_playbook_stats lazy import 會呼叫 get_playbook_service())
|
||
import src.services.playbook_service as _pb_mod
|
||
monkeypatch.setattr(_pb_mod, "_service", stub_pb_service)
|
||
|
||
from src.services.learning_service import LearningService, ExecutionResult
|
||
|
||
svc = LearningService(
|
||
repository=StubLearningRepo(),
|
||
trust_repository=StubTrustRepo(),
|
||
)
|
||
|
||
approval = ApprovalRequest(
|
||
id=UUID("00000000-0000-0000-0000-000000000002"),
|
||
action="kubectl rollout restart deployment/c1-service",
|
||
description="C1 learning test",
|
||
status=ApprovalStatus.APPROVED,
|
||
risk_level=RiskLevel.MEDIUM,
|
||
required_signatures=1,
|
||
current_signatures=1,
|
||
requested_by="c1-e2e-test",
|
||
created_at=now_taipei(),
|
||
matched_playbook_id=playbook_id,
|
||
)
|
||
|
||
result = ExecutionResult(
|
||
approval_id=str(approval.id),
|
||
incident_id="INC-C1-LEARN-001",
|
||
action=approval.action,
|
||
success=True,
|
||
)
|
||
|
||
learning_record = await svc.process_execution_result(approval, result)
|
||
|
||
assert learning_record.playbook_updated is True, (
|
||
"matched_playbook_id 不為 None 時,playbook_updated 應為 True(EWMA 觸發)"
|
||
)
|
||
assert len(stub_pb_service.record_execution_calls) == 1, (
|
||
"record_execution 應被呼叫一次"
|
||
)
|
||
assert stub_pb_service.record_execution_calls[0]["playbook_id"] == playbook_id
|
||
assert stub_pb_service.record_execution_calls[0]["success"] is True
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_learning_service_skips_update_when_no_match():
|
||
"""
|
||
approval.matched_playbook_id is None 時,learning_service 不更新 Playbook stats。
|
||
playbook_updated 應為 False,record_execution 不被呼叫。
|
||
"""
|
||
stub_pb_service = StubPlaybookService()
|
||
|
||
from src.services.learning_service import LearningService, ExecutionResult
|
||
|
||
svc = LearningService(
|
||
repository=StubLearningRepo(),
|
||
trust_repository=StubTrustRepo(),
|
||
)
|
||
|
||
approval = ApprovalRequest(
|
||
id=UUID("00000000-0000-0000-0000-000000000003"),
|
||
action="kubectl rollout restart deployment/unmatched-service",
|
||
description="No match test",
|
||
status=ApprovalStatus.APPROVED,
|
||
risk_level=RiskLevel.LOW,
|
||
required_signatures=0,
|
||
current_signatures=0,
|
||
requested_by="c1-e2e-test",
|
||
created_at=now_taipei(),
|
||
matched_playbook_id=None,
|
||
)
|
||
|
||
result = ExecutionResult(
|
||
approval_id=str(approval.id),
|
||
incident_id="INC-C1-NO-MATCH-001",
|
||
action=approval.action,
|
||
success=True,
|
||
)
|
||
|
||
learning_record = await svc.process_execution_result(approval, result)
|
||
|
||
assert learning_record.playbook_updated is False, (
|
||
"matched_playbook_id=None 時 playbook_updated 應為 False"
|
||
)
|
||
assert len(stub_pb_service.record_execution_calls) == 0, (
|
||
"matched_playbook_id=None 時不應呼叫 record_execution"
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# Test 5: Feature Flag ENABLE_PLAYBOOK_MATCHING=false 回滾路徑
|
||
# =============================================================================
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_feature_flag_disabled_returns_none(monkeypatch):
|
||
"""
|
||
ENABLE_PLAYBOOK_MATCHING=false 時,_try_playbook_match_id 必須直接回傳 None,
|
||
不呼叫 PlaybookService(完全回滾到修復前行為)。
|
||
"""
|
||
playbook = _make_playbook(playbook_id="PB-C1-FLAG")
|
||
stub_pb_service = StubPlaybookService(
|
||
recommendations=[StubRecommendation(playbook, similarity_score=0.99)]
|
||
)
|
||
|
||
import src.services.playbook_service as _pb_mod
|
||
monkeypatch.setattr(_pb_mod, "_service", stub_pb_service)
|
||
|
||
# 替換 settings 使 ENABLE_PLAYBOOK_MATCHING=false
|
||
# proposal_service 用 `from src.core.config import get_settings`,
|
||
# 所以必須替換 proposal_service 模組命名空間中的 get_settings 符號。
|
||
from src.core.config import Settings
|
||
fake_settings = Settings.model_construct(
|
||
ENABLE_PLAYBOOK_MATCHING=False,
|
||
DATABASE_URL="postgresql+asyncpg://x:y@localhost/z",
|
||
)
|
||
import src.services.proposal_service as _ps_mod
|
||
monkeypatch.setattr(_ps_mod, "get_settings", lambda: fake_settings)
|
||
|
||
from src.services.proposal_service import ProposalService
|
||
|
||
service = ProposalService.__new__(ProposalService)
|
||
incident = _make_incident()
|
||
|
||
result = await service._try_playbook_match_id(incident)
|
||
|
||
assert result is None, (
|
||
"ENABLE_PLAYBOOK_MATCHING=false 時應回傳 None,不進行匹配"
|
||
)
|
||
# StubPlaybookService 的 get_recommendations 沒有被呼叫
|
||
assert stub_pb_service._recommendations is not None # stub 本身沒被呼叫(沒計數器,靠上面 assert result is None 驗證)
|