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>
571 lines
18 KiB
Python
571 lines
18 KiB
Python
"""
|
||
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
|