Files
awoooi/apps/api/src/services/auto_repair_service.py
OG T 27509db212 feat(api): Wave 1 安全網 - Circuit Breaker + Global Repair Cooldown
ADR-038: OpenClaw 雙層保護
- Layer 1: Circuit Breaker (5 failures → 60s cooldown)
- Layer 2: Concurrency Semaphore (max 3 concurrent)
- 新增 src/core/circuit_breaker.py

ADR-039: 全域修復熔斷
- Global Cooldown: 5 repairs/15min → freeze
- StatefulSet Blacklist: postgres/redis/clickhouse 禁止自動重啟
- 新增 src/services/global_repair_cooldown.py
- 整合到 auto_repair_service.py

測試:
- test_circuit_breaker.py (狀態轉換 + Semaphore)
- test_global_repair_cooldown.py (黑名單 + 計數閾值)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-29 15:48:03 +08:00

571 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Auto Repair Service - #8 自動升級決策
=====================================
高品質 Playbook 自動修復執行
Phase 8: 自動化層實作
建立時間: 2026-03-26 17:30 (台北時區)
建立者: Claude Code (#8 自動升級決策)
遵循 leWOOOgo 積木化原則:
- Service 層只依賴 Repository/Service Interface
- 不直接存取 Redis/DB
- 封裝所有自動修復邏輯
觸發條件 (AND):
1. 有匹配的高品質 Playbook (is_high_quality = True)
2. Playbook 中的動作風險等級 <= MEDIUM
3. Incident 嚴重度 <= P2
安全邊界:
- HIGH/CRITICAL 風險動作永遠需要人工審核
- P0/P1 嚴重度 Incident 需要人工確認
"""
from dataclasses import dataclass
from typing import Protocol
import structlog
from src.models.incident import Incident, Severity
from src.models.playbook import (
ActionType,
Playbook,
RiskLevel,
SymptomPattern,
)
from src.services.anomaly_counter import AnomalyFrequency, get_anomaly_counter
from src.services.executor import get_executor
from src.services.global_repair_cooldown import (
check_global_repair_cooldown,
record_global_repair_action,
)
from src.services.playbook_service import IPlaybookService, get_playbook_service
logger = structlog.get_logger(__name__)
# =============================================================================
# Types
# =============================================================================
@dataclass
class AutoRepairDecision:
"""自動修復決策結果"""
can_auto_repair: bool
playbook: Playbook | None = None
reason: str = ""
risk_level: RiskLevel = RiskLevel.MEDIUM
blocked_by: str | None = None # 阻擋原因 (如 HIGH_RISK, P1_SEVERITY)
@dataclass
class AutoRepairResult:
"""自動修復執行結果"""
success: bool
playbook_id: str
incident_id: str
executed_steps: list[str]
error: str | None = None
execution_time_ms: int = 0
# =============================================================================
# Auto Repair Service Interface
# =============================================================================
class IAutoRepairService(Protocol):
"""自動修復服務介面"""
async def evaluate_auto_repair(
self,
incident: Incident,
) -> AutoRepairDecision:
"""
評估是否可自動修復
Args:
incident: 待處理的 Incident
Returns:
AutoRepairDecision: 決策結果
"""
...
async def execute_auto_repair(
self,
incident: Incident,
playbook: Playbook,
) -> AutoRepairResult:
"""
執行自動修復
Args:
incident: 待處理的 Incident
playbook: 要執行的 Playbook
Returns:
AutoRepairResult: 執行結果
"""
...
# =============================================================================
# Auto Repair Service Implementation
# =============================================================================
class AutoRepairService:
"""
自動修復服務實作
職責:
- 評估 Incident 是否可自動修復
- 執行高品質 Playbook
- 更新執行統計
"""
# === 安全邊界常數 ===
MAX_AUTO_REPAIR_RISK = RiskLevel.MEDIUM # 最高允許自動修復的風險等級
MAX_AUTO_REPAIR_SEVERITY = Severity.P2 # 最高允許自動修復的嚴重度
MIN_SIMILARITY_SCORE = 0.7 # 最低相似度門檻
def __init__(
self,
playbook_service: IPlaybookService | None = None,
):
self._playbook_service = playbook_service or get_playbook_service()
async def evaluate_auto_repair(
self,
incident: Incident,
) -> AutoRepairDecision:
"""
評估是否可自動修復
決策流程:
1. 檢查 Incident 嚴重度 (P0/P1 需人工)
2. 從 Playbook 找匹配項
3. 檢查 Playbook 是否為高品質
4. 檢查動作風險等級
"""
logger.info(
"auto_repair_evaluate_start",
incident_id=incident.incident_id,
severity=incident.severity.value if incident.severity else None,
)
# 0. 全域熔斷檢查ADR-039 最優先)
can_repair, cooldown_reason = await check_global_repair_cooldown(
incident_id=incident.incident_id,
affected_services=incident.affected_services or [],
)
if not can_repair:
logger.warning(
"auto_repair_blocked_global_cooldown",
incident_id=incident.incident_id,
reason=cooldown_reason,
)
return AutoRepairDecision(
can_auto_repair=False,
reason=cooldown_reason,
blocked_by="GLOBAL_GUARDRAIL",
)
# 1. 檢查 Incident 嚴重度
if incident.severity and incident.severity.value in ["P0", "P1"]:
logger.info(
"auto_repair_blocked_severity",
incident_id=incident.incident_id,
severity=incident.severity.value,
)
return AutoRepairDecision(
can_auto_repair=False,
reason=f"Incident 嚴重度 {incident.severity.value} 需要人工審核",
blocked_by="HIGH_SEVERITY",
)
# 2. 提取症狀模式
symptoms = self._extract_symptoms(incident)
# 3. 找匹配的 Playbook
recommendations = await self._playbook_service.get_recommendations(
symptoms=symptoms,
top_k=3,
)
if not recommendations:
logger.info(
"auto_repair_no_playbook_match",
incident_id=incident.incident_id,
)
return AutoRepairDecision(
can_auto_repair=False,
reason="未找到匹配的 Playbook",
blocked_by="NO_MATCH",
)
# 4. 檢查最佳匹配
best_match = recommendations[0]
# 相似度檢查
if best_match.similarity_score < self.MIN_SIMILARITY_SCORE:
return AutoRepairDecision(
can_auto_repair=False,
playbook=best_match.playbook,
reason=f"相似度 {best_match.similarity_score:.0%} 低於門檻 {self.MIN_SIMILARITY_SCORE:.0%}",
blocked_by="LOW_SIMILARITY",
)
# 高品質檢查
if not best_match.playbook.is_high_quality:
return AutoRepairDecision(
can_auto_repair=False,
playbook=best_match.playbook,
reason=f"Playbook 尚未達到高品質標準 (成功率: {best_match.playbook.success_rate:.0%}, 執行次數: {best_match.playbook.total_executions})",
blocked_by="NOT_HIGH_QUALITY",
)
# 5. 檢查動作風險等級
max_risk = self._get_max_risk_level(best_match.playbook)
if self._risk_exceeds_threshold(max_risk):
return AutoRepairDecision(
can_auto_repair=False,
playbook=best_match.playbook,
reason=f"Playbook 包含 {max_risk.value} 風險動作,需要人工審核",
risk_level=max_risk,
blocked_by="HIGH_RISK",
)
# 6. 可以自動修復
logger.info(
"auto_repair_approved",
incident_id=incident.incident_id,
playbook_id=best_match.playbook.playbook_id,
similarity=best_match.similarity_score,
success_rate=best_match.playbook.success_rate,
)
return AutoRepairDecision(
can_auto_repair=True,
playbook=best_match.playbook,
reason=f"匹配高品質 Playbook: {best_match.playbook.name} (成功率 {best_match.playbook.success_rate:.0%})",
risk_level=max_risk,
)
async def execute_auto_repair(
self,
incident: Incident,
playbook: Playbook,
) -> AutoRepairResult:
"""
執行自動修復
流程:
1. 依序執行 Playbook 中的 repair_steps
2. 記錄執行結果
3. 更新 Playbook 統計
"""
import time
start_time = time.perf_counter()
executed_steps: list[str] = []
logger.info(
"auto_repair_execute_start",
incident_id=incident.incident_id,
playbook_id=playbook.playbook_id,
steps_count=len(playbook.repair_steps),
)
# ADR-039: 記錄全域修復計數(用於熔斷檢查)
await record_global_repair_action()
try:
# 執行每個步驟
for step in playbook.repair_steps:
# 安全檢查: 跳過高風險步驟
if self._risk_exceeds_threshold(step.risk_level):
logger.warning(
"auto_repair_skip_high_risk_step",
step_number=step.step_number,
risk_level=step.risk_level.value,
)
continue
# 執行步驟
step_result = await self._execute_step(incident, step)
executed_steps.append(
f"Step {step.step_number}: {step.command[:50]}... -> {step_result}"
)
# 更新 Playbook 統計
await self._playbook_service.record_execution(
playbook_id=playbook.playbook_id,
success=True,
)
execution_time = int((time.perf_counter() - start_time) * 1000)
logger.info(
"auto_repair_execute_success",
incident_id=incident.incident_id,
playbook_id=playbook.playbook_id,
executed_steps=len(executed_steps),
execution_time_ms=execution_time,
)
return AutoRepairResult(
success=True,
playbook_id=playbook.playbook_id,
incident_id=incident.incident_id,
executed_steps=executed_steps,
execution_time_ms=execution_time,
)
except Exception as e:
# 更新失敗統計
await self._playbook_service.record_execution(
playbook_id=playbook.playbook_id,
success=False,
)
execution_time = int((time.perf_counter() - start_time) * 1000)
logger.error(
"auto_repair_execute_failed",
incident_id=incident.incident_id,
playbook_id=playbook.playbook_id,
error=str(e),
)
return AutoRepairResult(
success=False,
playbook_id=playbook.playbook_id,
incident_id=incident.incident_id,
executed_steps=executed_steps,
error=str(e),
execution_time_ms=execution_time,
)
# === Private Helpers ===
def _extract_symptoms(self, incident: Incident) -> SymptomPattern:
"""從 Incident 提取症狀模式"""
alert_names = []
keywords = []
if incident.signals:
for signal in incident.signals:
alert_names.append(signal.alert_name)
# 從 annotations 提取關鍵字
if signal.annotations:
for value in signal.annotations.values():
if isinstance(value, str) and len(value) < 50:
keywords.append(value)
return SymptomPattern(
alert_names=alert_names,
affected_services=incident.affected_services or [],
severity_range=[incident.severity.value] if incident.severity else ["P2"],
keywords=keywords[:10],
)
def _get_max_risk_level(self, playbook: Playbook) -> RiskLevel:
"""取得 Playbook 中最高的風險等級"""
risk_order = {
RiskLevel.LOW: 0,
RiskLevel.MEDIUM: 1,
RiskLevel.HIGH: 2,
RiskLevel.CRITICAL: 3,
}
max_risk = RiskLevel.LOW
for step in playbook.repair_steps:
if risk_order.get(step.risk_level, 0) > risk_order.get(max_risk, 0):
max_risk = step.risk_level
return max_risk
def _risk_exceeds_threshold(self, risk: RiskLevel) -> bool:
"""檢查風險是否超過自動修復門檻"""
high_risks = {RiskLevel.HIGH, RiskLevel.CRITICAL}
return risk in high_risks
async def _execute_step(self, incident: Incident, step) -> str:
"""
執行單一修復步驟
目前整合:
- kubectl 命令: 透過 ActionExecutor
- script: 透過 subprocess
- manual: 跳過 (需人工)
"""
if step.action_type == ActionType.MANUAL:
return "SKIPPED (manual step)"
if step.action_type == ActionType.KUBECTL:
# 整合 ActionExecutor
try:
executor = get_executor()
# 替換 {target} 為實際目標
command = step.command
if incident.affected_services:
command = command.replace("{target}", incident.affected_services[0])
result = await executor.execute_kubectl_command(command)
return "SUCCESS" if result.success else f"FAILED: {result.error}"
except ImportError:
logger.warning("action_executor_not_available")
return "SKIPPED (executor not available)"
return "UNKNOWN_ACTION_TYPE"
# === ADR-037: Tier-based Repair (2026-03-29) ===
# Tier 分級動作映射
TIER_ACTIONS = {
1: ["restart_pod", "restart_container"], # 臨時修復
2: ["scale_up", "increase_memory", "adjust_limits"], # 緩解修復
3: ["apply_hotfix", "update_config", "patch_deployment"], # 根因修復
4: ["create_issue", "notify_team", "schedule_fix"], # 架構修復
}
async def determine_repair_tier(
self,
anomaly_key: str,
frequency: AnomalyFrequency,
) -> int:
"""
根據頻率決定修復 Tier (ADR-037)
統帥指示 (2026-03-29):
- "重啟只是治標,不是治本!太常發生的異常必須徹底解決"
- 根據異常頻率和修復歷史決定應該嘗試的修復層級
Returns:
1: 臨時修復 (重啟)
2: 緩解修復 (擴容)
3: 根因修復 (配置變更)
4: 架構修復 (需開發)
"""
# 取得修復歷史
counter = get_anomaly_counter()
stats = await counter.get_all_repair_stats(anomaly_key)
# 計算重啟次數
restart_count = stats.get("restart_pod", {}).get("total", 0)
restart_count += stats.get("restart_container", {}).get("total", 0)
# Tier 決策邏輯
if frequency.permanent_fix_applied:
# 已有永久修復但仍出問題 → 需架構級修復
logger.info(
"tier_decision",
anomaly_key=anomaly_key,
tier=4,
reason="permanent_fix_still_failing",
)
return 4
if frequency.escalation_level == "PERMANENT_FIX":
# 24h 內 ≥10 次 → 根因修復
logger.info(
"tier_decision",
anomaly_key=anomaly_key,
tier=3,
reason="escalation_permanent_fix",
)
return 3
if frequency.escalation_level == "ESCALATE":
# 24h 內 ≥5 次 → 緩解修復
logger.info(
"tier_decision",
anomaly_key=anomaly_key,
tier=2,
reason="escalation_escalate",
)
return 2
if restart_count >= 2:
# 已重啟 2 次 → 升級到緩解
logger.info(
"tier_decision",
anomaly_key=anomaly_key,
tier=2,
reason=f"restart_count_{restart_count}",
)
return 2
# 預設臨時修復
return 1
def get_tier_actions(self, tier: int) -> list[str]:
"""
根據 Tier 返回可用修復動作 (ADR-037)
"""
return self.TIER_ACTIONS.get(tier, self.TIER_ACTIONS[1])
async def record_repair_result(
self,
anomaly_key: str,
action: str,
success: bool,
tier: int = 1,
) -> None:
"""
記錄修復結果到 AnomalyCounter (ADR-037)
Args:
anomaly_key: 異常 key
action: 修復動作
success: 是否成功
tier: 修復 Tier
"""
counter = get_anomaly_counter()
await counter.record_repair_attempt(anomaly_key, action, success)
# 如果是 Tier 3 永久修復成功,標記已套用
if tier >= 3 and success:
await counter.mark_permanent_fix_applied(
anomaly_key=anomaly_key,
fix_description=f"Tier {tier} repair: {action}",
)
logger.info(
"repair_result_recorded",
anomaly_key=anomaly_key,
action=action,
success=success,
tier=tier,
)
# =============================================================================
# Singleton
# =============================================================================
_service: AutoRepairService | None = None
def get_auto_repair_service() -> IAutoRepairService:
"""取得 AutoRepairService 單例"""
global _service
if _service is None:
_service = AutoRepairService()
return _service
def set_auto_repair_service(service: AutoRepairService | None) -> None:
"""注入 AutoRepairService 實例 (用於 DI 或測試)"""
global _service
_service = service