Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 1m34s
critic PR review 681b5ac9 揭示 4 Major 問題(無 Critical),全部修復。
## Major #1 — generic_fallback wildcard 污染 RAG 語料
位置:rule_to_playbook_migrator.py:128 `_build_symptom_pattern`
問題:generic_fallback 規則的 `alert_names=["*"]` 會原樣寫入 PlaybookRecord,
進 playbook_rag 向量化文字「告警: *」變成普通 token,每筆查詢都會跟它算相似度
→ RAG top-k 可能回 fallback DRAFT 誤導推薦。
修法:在 `_build_symptom_pattern` 過濾 `["*"]`(與 keywords 一致對待)。
## Major #2 — CLI --commit 無二次確認
位置:scripts/migrate_rules_to_playbooks.py
問題:`--commit` 直接寫 prod DB 25 筆 DRAFT,誤跑無法回頭。
修法:
- 加 `--yes` flag(CI / 自動化用)
- 沒帶 `--yes` 時 stdin prompt: "Type 'yes' to confirm"
## Major #3 — yaml_rule kubectl_command 未過 SPF-2 action_parser
位置:rule_to_playbook_migrator.py:153 `_build_repair_steps`
問題:DRAFT 不會自動 promote(門檻 0.9),但人工 review 路徑無安全攔截器。
若有人 UI 一鍵 promote → 含 {target} placeholder 的危險指令直接到 prod。
修法:在 step dict 加 metadata:
- unverified_command: True
- needs_action_parser_review: True
- source: "yaml_rule_migration"
(promote 流程須強制走 action_parser,由 SPF-2 落地時實作)
## Minor 修
- 刪除 dead import `import re`(未使用)
- `enumerate([:3], start=2)` 取代 `if idx >= 4: break`(邊界寫法易誤讀)
## 驗證
- 23 個 PR-R1 測試全綠(修法不破壞既有行為)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
418 lines
14 KiB
Python
418 lines
14 KiB
Python
"""
|
||
Rule → Playbook Migrator
|
||
========================
|
||
將 alert_rules.yaml 中的 25 條規則遷移為 DRAFT Playbook,讓飛輪 RAG 有料可查。
|
||
|
||
設計原則:
|
||
- status=DRAFT(不直接 APPROVED — 違反「禁寫死」鐵律)
|
||
- ai_confidence=0.3(誠實標示,非假 1.0 — 違反 feedback_confidence_truthfulness)
|
||
- source=PlaybookSource.YAML_RULE(現有 enum,不新增 RULE_MIGRATED)
|
||
- 冪等:name LIKE 'AutoMigrated: %' 已存在則跳過
|
||
- INSERT ON CONFLICT → repo.create() UPSERT(playbook_id 唯一鍵)
|
||
- 與 playbook_seed_service.py 完全解耦(不擾動既有 seed 機制)
|
||
|
||
name 格式: "AutoMigrated: {rule.id}" — 與 seed_service 用 description 作 name 的格式區隔
|
||
|
||
W1 PR-R1 — 規則 → Playbook 遷移
|
||
2026-04-28 ogt + Claude Sonnet 4.6
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import structlog
|
||
import yaml
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
# 告警 severity → risk 等級
|
||
_SEVERITY_TO_RISK: dict[str, str] = {
|
||
"low": "LOW",
|
||
"medium": "MEDIUM",
|
||
"high": "HIGH",
|
||
"critical": "CRITICAL",
|
||
}
|
||
|
||
# yaml risk 欄位允許 "high" 但 RiskLevel enum 有 HIGH;seed_service 用的 map 少了 high
|
||
_YAML_RISK_MAP: dict[str, str] = {
|
||
"low": "LOW",
|
||
"medium": "MEDIUM",
|
||
"high": "HIGH",
|
||
"critical": "CRITICAL",
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class MigrationReport:
|
||
"""遷移報告"""
|
||
total_rules: int = 0
|
||
created: int = 0
|
||
skipped: int = 0
|
||
failed: int = 0
|
||
dry_run: bool = False
|
||
errors: list[str] = field(default_factory=list)
|
||
created_names: list[str] = field(default_factory=list)
|
||
skipped_names: list[str] = field(default_factory=list)
|
||
|
||
def summary(self) -> str:
|
||
mode = "[DRY-RUN] " if self.dry_run else ""
|
||
return (
|
||
f"{mode}遷移完成 — "
|
||
f"總計 {self.total_rules} 條規則,"
|
||
f"建立 {self.created},跳過 {self.skipped},失敗 {self.failed}"
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# 命令類型判斷(不依賴 SPF-2 action_parser,用既有 regex 守門)
|
||
# =============================================================================
|
||
|
||
def _infer_action_type(kubectl_command: str) -> str:
|
||
"""
|
||
從指令字串推斷 ActionType(字串形式,對應 ActionType enum 值)
|
||
|
||
規則:
|
||
- 空字串 → "manual"
|
||
- 以 "ssh " 開頭 → "ssh_command"
|
||
- 其他有指令 → "kubectl"
|
||
"""
|
||
cmd = (kubectl_command or "").strip()
|
||
if not cmd:
|
||
return "manual"
|
||
if cmd.startswith("ssh "):
|
||
return "ssh_command"
|
||
return "kubectl"
|
||
|
||
|
||
def _infer_risk_level(risk_str: str) -> str:
|
||
"""
|
||
YAML risk 欄位 → RiskLevel 字串
|
||
|
||
alert_rules.yaml 的 risk 欄位值: low / medium / high / critical
|
||
"""
|
||
return _YAML_RISK_MAP.get((risk_str or "medium").lower(), "MEDIUM")
|
||
|
||
|
||
def _build_symptom_pattern(rule: dict[str, Any]) -> dict[str, Any]:
|
||
"""
|
||
從規則 match block 推導 SymptomPattern dict
|
||
|
||
symptom_pattern 包含:
|
||
- alert_names: match.alertname list
|
||
- affected_services: 從 id/description 推導關鍵字(保守策略:留空,讓 RAG 學習)
|
||
- severity_range: 從 risk 反推 ["P1"] / ["P2"] / ["P3"]
|
||
- keywords: match.message list(部分匹配關鍵字)
|
||
"""
|
||
match_block = rule.get("match", {})
|
||
alertnames: list[str] = match_block.get("alertname", [])
|
||
messages: list[str] = match_block.get("message", [])
|
||
alert_types: list[str] = match_block.get("alert_type", [])
|
||
|
||
# risk → severity_range 反推
|
||
risk_str = (rule.get("response", {}).get("risk", "medium") or "medium").lower()
|
||
if risk_str == "critical":
|
||
severity_range = ["P1", "P2"]
|
||
elif risk_str in ("high", "medium"):
|
||
severity_range = ["P2", "P3"]
|
||
else:
|
||
severity_range = ["P3"]
|
||
|
||
# keywords: message + alert_type 列表合併(最多 15 個)
|
||
keywords = list(messages) + list(alert_types)
|
||
# 過濾萬用符(generic_fallback 有 "*")
|
||
keywords = [k for k in keywords if k != "*"][:15]
|
||
|
||
# 2026-04-29 ogt + Claude Opus 4.7: critic Major #1 修
|
||
# 過濾 alert_names 中的 "*" wildcard(generic_fallback)— 否則進 RAG 向量化
|
||
# 後變成「告警: *」污染語料,每筆查詢都會跟它算相似度
|
||
raw_names = alertnames if isinstance(alertnames, list) else [alertnames]
|
||
filtered_names = [n for n in raw_names if n and n != "*"]
|
||
return {
|
||
"alert_names": filtered_names,
|
||
"affected_services": [],
|
||
"severity_range": severity_range,
|
||
"keywords": keywords,
|
||
"label_patterns": {},
|
||
}
|
||
|
||
|
||
def _build_repair_steps(rule: dict[str, Any]) -> list[dict[str, Any]]:
|
||
"""
|
||
從規則 response block 建立 RepairStep dict list
|
||
|
||
策略:
|
||
- kubectl_command 存在且非空 → step 1
|
||
- 若 optimization list 存在 → 每項追加為額外步驟
|
||
- 若 kubectl_command 空 (NO_ACTION) → step 1 action_type=manual,command=描述文字
|
||
"""
|
||
resp = rule.get("response", {})
|
||
kubectl_cmd = (resp.get("kubectl_command", "") or "").strip()
|
||
risk_level = _infer_risk_level(resp.get("risk", "medium"))
|
||
suggested_action = resp.get("suggested_action", "NO_ACTION") or "NO_ACTION"
|
||
|
||
steps: list[dict[str, Any]] = []
|
||
|
||
if kubectl_cmd:
|
||
action_type = _infer_action_type(kubectl_cmd)
|
||
steps.append({
|
||
"step_number": 1,
|
||
"action_type": action_type,
|
||
"command": kubectl_cmd,
|
||
"expected_result": resp.get("action_title", ""),
|
||
"risk_level": risk_level,
|
||
"requires_approval": risk_level == "CRITICAL" or suggested_action in ("RESTART_DEPLOYMENT", "DELETE_POD", "SCALE_DEPLOYMENT"),
|
||
# 2026-04-29 ogt + Claude Opus 4.7: critic Major #3 修
|
||
# yaml_rule 來源的 kubectl_command 未經 SPF-2 action_parser 驗證
|
||
# promote 流程(DRAFT → APPROVED)必須強制走 action_parser,否則危險指令直達 prod
|
||
"metadata": {
|
||
"unverified_command": True,
|
||
"needs_action_parser_review": True,
|
||
"source": "yaml_rule_migration",
|
||
},
|
||
})
|
||
else:
|
||
# NO_ACTION — 記錄診斷描述為 manual step,讓 RAG 至少有症狀可查
|
||
description_text = resp.get("description", rule.get("description", "人工診斷"))
|
||
steps.append({
|
||
"step_number": 1,
|
||
"action_type": "manual",
|
||
"command": description_text[:500],
|
||
"expected_result": resp.get("action_title", ""),
|
||
"risk_level": risk_level,
|
||
"requires_approval": True,
|
||
})
|
||
|
||
# 追加 optimization steps(最多 3 個,step_number 2/3/4)
|
||
# 2026-04-29 critic Minor 修:原 `if idx >= 4: break` 寫在 append 後易誤讀
|
||
# 改用 [:3] slice 明確限制最多 3 個
|
||
for idx, opt in enumerate((resp.get("optimization", []) or [])[:3], start=2):
|
||
opt_cmd = (opt.get("command", "") or "").strip()
|
||
if not opt_cmd or opt_cmd.startswith("#"):
|
||
continue
|
||
steps.append({
|
||
"step_number": idx,
|
||
"action_type": _infer_action_type(opt_cmd),
|
||
"command": opt_cmd,
|
||
"expected_result": opt.get("description", ""),
|
||
"risk_level": "LOW",
|
||
"requires_approval": False,
|
||
})
|
||
|
||
return steps
|
||
|
||
|
||
def _estimated_duration(risk_level: str, suggested_action: str) -> int:
|
||
"""估算修復時間(分鐘)"""
|
||
if suggested_action in ("NO_ACTION",):
|
||
return 15
|
||
if risk_level == "CRITICAL":
|
||
return 5
|
||
return 3
|
||
|
||
|
||
def _build_tags(rule: dict[str, Any]) -> list[str]:
|
||
"""從規則提取標籤"""
|
||
tags: set[str] = {"yaml_rule", "auto_migrated"}
|
||
|
||
rule_id = rule.get("id", "")
|
||
resp = rule.get("response", {})
|
||
responsibility = resp.get("responsibility", "")
|
||
if responsibility:
|
||
tags.add(responsibility.lower())
|
||
|
||
# 從 alertname 推導類型標籤
|
||
alertnames = rule.get("match", {}).get("alertname", [])
|
||
for name in alertnames:
|
||
name_lower = (name or "").lower()
|
||
if "cpu" in name_lower:
|
||
tags.add("cpu")
|
||
if "memory" in name_lower or "oom" in name_lower:
|
||
tags.add("memory")
|
||
if "disk" in name_lower or "storage" in name_lower:
|
||
tags.add("disk")
|
||
if "pod" in name_lower or "k8s" in name_lower or "kube" in name_lower:
|
||
tags.add("kubernetes")
|
||
if "ssl" in name_lower or "cert" in name_lower:
|
||
tags.add("ssl")
|
||
if "backup" in name_lower:
|
||
tags.add("backup")
|
||
if "postgresql" in name_lower or "postgres" in name_lower:
|
||
tags.add("database")
|
||
if "redis" in name_lower:
|
||
tags.add("cache")
|
||
if "ollama" in name_lower:
|
||
tags.add("ai")
|
||
|
||
return list(tags)[:10]
|
||
|
||
|
||
def parse_yaml_rules(yaml_path: Path) -> list[dict[str, Any]]:
|
||
"""
|
||
讀取並解析 alert_rules.yaml,回傳 rules list
|
||
|
||
Raises:
|
||
FileNotFoundError: yaml 不存在
|
||
yaml.YAMLError: yaml 格式錯誤
|
||
"""
|
||
data = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
|
||
rules = data.get("rules", [])
|
||
return [r for r in rules if isinstance(r, dict)]
|
||
|
||
|
||
def build_playbook_dict(rule: dict[str, Any]) -> dict[str, Any]:
|
||
"""
|
||
從單條規則建立 Playbook 初始化 dict(不寫 DB)
|
||
|
||
Returns dict 可直接傳給 Playbook(**dict)
|
||
"""
|
||
rule_id = rule.get("id", "unknown")
|
||
resp = rule.get("response", {})
|
||
description = resp.get("description", rule.get("description", f"規則 {rule_id} 自動遷移"))
|
||
risk_str = (resp.get("risk", "medium") or "medium").lower()
|
||
suggested_action = resp.get("suggested_action", "NO_ACTION") or "NO_ACTION"
|
||
|
||
symptom_pattern = _build_symptom_pattern(rule)
|
||
repair_steps = _build_repair_steps(rule)
|
||
tags = _build_tags(rule)
|
||
risk_level = _infer_risk_level(risk_str)
|
||
duration = _estimated_duration(risk_level, suggested_action)
|
||
|
||
return {
|
||
"name": f"AutoMigrated: {rule_id}",
|
||
"description": description[:2000],
|
||
"status": "draft",
|
||
"source": "yaml_rule",
|
||
"symptom_pattern": symptom_pattern,
|
||
"repair_steps": repair_steps,
|
||
"estimated_duration_minutes": duration,
|
||
"ai_confidence": 0.3,
|
||
"trust_score": 0.3,
|
||
"tags": tags,
|
||
"notes": f"自動從 alert_rules.yaml rule.id={rule_id} 遷移。priority={rule.get('priority', 999)}",
|
||
"created_by_agent": "migrator",
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 核心遷移函式(async,依賴 DB)
|
||
# =============================================================================
|
||
|
||
async def migrate_yaml_rules_to_playbooks(
|
||
yaml_path: Path,
|
||
dry_run: bool = True,
|
||
enable_migration: bool = True,
|
||
) -> MigrationReport:
|
||
"""
|
||
將 alert_rules.yaml 遷移為 DRAFT Playbook
|
||
|
||
Args:
|
||
yaml_path: alert_rules.yaml 路徑
|
||
dry_run: True=只印計畫不寫 DB,False=真實寫入
|
||
enable_migration: feature flag(ENABLE_RULE_MIGRATION_DRAFT),False 時直接 return
|
||
|
||
Returns:
|
||
MigrationReport
|
||
|
||
設計:
|
||
- 冪等:name LIKE 'AutoMigrated: %' 已存在任何狀態的 playbook 即跳過
|
||
- 不依賴 seed_service(source=yaml_rule 但 name prefix 不同,互不干擾)
|
||
- generic_fallback 規則(id=generic_fallback)也遷移,讓 RAG 能學到「兜底症狀」
|
||
"""
|
||
report = MigrationReport(dry_run=dry_run)
|
||
|
||
if not enable_migration:
|
||
logger.info("rule_migration_disabled_by_flag")
|
||
return report
|
||
|
||
if not yaml_path.exists():
|
||
logger.error("rule_migration_yaml_not_found", path=str(yaml_path))
|
||
report.errors.append(f"yaml 不存在: {yaml_path}")
|
||
return report
|
||
|
||
# 1. 解析 yaml
|
||
try:
|
||
rules = parse_yaml_rules(yaml_path)
|
||
except Exception as e:
|
||
logger.error("rule_migration_parse_error", error=str(e))
|
||
report.errors.append(f"yaml 解析失敗: {e}")
|
||
return report
|
||
|
||
report.total_rules = len(rules)
|
||
|
||
if dry_run:
|
||
# Dry-run:只建立 dict,不查 DB、不寫 DB
|
||
for rule in rules:
|
||
rule_id = rule.get("id", "unknown")
|
||
try:
|
||
pb_dict = build_playbook_dict(rule)
|
||
report.created_names.append(pb_dict["name"])
|
||
report.created += 1
|
||
logger.info(
|
||
"rule_migration_dry_run_would_create",
|
||
rule_id=rule_id,
|
||
name=pb_dict["name"],
|
||
alert_names=pb_dict["symptom_pattern"]["alert_names"],
|
||
)
|
||
except Exception as e:
|
||
report.failed += 1
|
||
report.errors.append(f"rule_id={rule_id} 建立 dict 失敗: {e}")
|
||
logger.warning("rule_migration_dry_run_error", rule_id=rule_id, error=str(e))
|
||
return report
|
||
|
||
# 2. 查詢現有 AutoMigrated Playbook(冪等去重)
|
||
from src.db.base import get_db_context
|
||
from sqlalchemy import text as sa_text
|
||
|
||
async with get_db_context() as db:
|
||
rows = await db.execute(
|
||
sa_text("SELECT name FROM playbooks WHERE name LIKE 'AutoMigrated: %'")
|
||
)
|
||
existing_names: set[str] = {r[0] for r in rows.fetchall()}
|
||
|
||
# 3. 逐條遷移
|
||
from src.models.playbook import Playbook
|
||
from src.repositories.playbook_repository import get_playbook_repository
|
||
|
||
repo = get_playbook_repository()
|
||
|
||
for rule in rules:
|
||
rule_id = rule.get("id", "unknown")
|
||
try:
|
||
pb_dict = build_playbook_dict(rule)
|
||
name = pb_dict["name"]
|
||
|
||
if name in existing_names:
|
||
report.skipped += 1
|
||
report.skipped_names.append(name)
|
||
logger.debug("rule_migration_skip_existing", rule_id=rule_id, name=name)
|
||
continue
|
||
|
||
playbook = Playbook(**pb_dict)
|
||
await repo.create(playbook)
|
||
report.created += 1
|
||
report.created_names.append(name)
|
||
existing_names.add(name) # 防止同 session 重複建立
|
||
|
||
logger.info(
|
||
"rule_migration_created",
|
||
rule_id=rule_id,
|
||
playbook_id=playbook.playbook_id,
|
||
name=name,
|
||
)
|
||
|
||
except Exception as e:
|
||
report.failed += 1
|
||
report.errors.append(f"rule_id={rule_id} 失敗: {e}")
|
||
logger.warning("rule_migration_create_error", rule_id=rule_id, error=str(e))
|
||
|
||
logger.info(
|
||
"rule_migration_complete",
|
||
total=report.total_rules,
|
||
created=report.created,
|
||
skipped=report.skipped,
|
||
failed=report.failed,
|
||
)
|
||
return report
|