fix(cr): Code Review P0/P1 全修補 — 積木化+SSH路由+安全守衛順序
Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 2m30s
Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 2m30s
P0-1: classify_alert_early 移至 incident_service (Service層),webhooks.py import 修正 P0-2: _ssh_execute() 改用 self._ssh,移除冗餘 SSHProvider() 實例化 P1-1: infrastructure SSH routing 移至 kubectl safety guard 之前,docker指令不再被攔截 P1-2: alert_rule_engine 新增 get_risk_for_alertname() public API P1-3: classify_notification() docstring 修正 ORM→Pydantic Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,7 @@ from src.services.approval_db import get_approval_service
|
||||
# Phase 17 P0: Service 層 (消除 Router 直接存取 Redis)
|
||||
# C2 修正 (首席架構師審查 2026-04-10): create_incident_for_approval + extract_affected_services 已移入 Service 層
|
||||
from src.services.incident_service import (
|
||||
classify_alert_early,
|
||||
create_incident_for_approval,
|
||||
extract_affected_services,
|
||||
get_incident_service,
|
||||
@@ -970,30 +971,6 @@ class AlertmanagerPayload(BaseModel):
|
||||
alerts: list[AlertmanagerAlert]
|
||||
|
||||
|
||||
def classify_alert_early(alertname: str, severity: str, _labels: dict) -> tuple[str, str]:
|
||||
"""
|
||||
ADR-073 Phase 2-2: 早期分診,在 LLM 分析前決定 alert_category + notification_type。
|
||||
2026-04-12 ogt: 防止 HostBackupFailed 等被誤路由到 K8s executor。
|
||||
|
||||
Returns:
|
||||
tuple[str, str]: (alert_category, notification_type)
|
||||
"""
|
||||
alertname_lower = alertname.lower()
|
||||
if alertname in ("ConfigurationDrift", "KubeConfigDrift"):
|
||||
return "config_drift", "TYPE-4D"
|
||||
if severity in ("info", "none"):
|
||||
return "info", "TYPE-1"
|
||||
if any(kw in alertname_lower for kw in ("backup", "heartbeat")):
|
||||
return "backup", "TYPE-1"
|
||||
if alertname.startswith(("Docker", "Host")):
|
||||
return "infrastructure", "TYPE-3"
|
||||
if alertname.startswith(("Kube", "Pod", "Deploy", "Node", "Velero", "ArgoCD")):
|
||||
return "kubernetes", "TYPE-3"
|
||||
if alertname.startswith(("Postgres", "Redis")):
|
||||
return "database", "TYPE-3"
|
||||
return "general", "TYPE-3"
|
||||
|
||||
|
||||
def is_internal_ip(client_ip: str) -> bool:
|
||||
"""檢查是否為內網 IP"""
|
||||
import ipaddress
|
||||
|
||||
@@ -145,6 +145,20 @@ def _is_generic(rule: dict) -> bool:
|
||||
# ── 公開 API ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_risk_for_alertname(alertname: str) -> str | None:
|
||||
"""
|
||||
根據 alertname 從 alert_rules.yaml 取得 risk 等級。
|
||||
ADR-073 Phase 3-6: 供 decision_manager 做 YAML risk override。
|
||||
|
||||
Returns:
|
||||
risk 字串 ("low"/"medium"/"critical") 或 None(無匹配規則)
|
||||
"""
|
||||
for rule in _load_rules():
|
||||
if _matches(rule, alertname, "", "", ""):
|
||||
return rule.get("response", {}).get("risk")
|
||||
return None
|
||||
|
||||
|
||||
def match_rule(alert_context: dict) -> dict[str, Any] | None:
|
||||
"""
|
||||
根據 alert_context 匹配規則,回傳填充後的 response dict。
|
||||
|
||||
@@ -1246,6 +1246,15 @@ class DecisionManager:
|
||||
action = _re.sub(r"<deployment_name>", _target, action)
|
||||
action = _re.sub(r"<[^>]+>", _target, action)
|
||||
|
||||
# ADR-073 Phase 3-2: infrastructure 告警 (Docker/Host) → SSH MCP routing (2026-04-12 ogt)
|
||||
# alert_category = "infrastructure" 表示 Docker/Host 告警,不走 K8s executor
|
||||
# action 格式應為 "docker restart <container>" 或 "systemctl restart <service>"
|
||||
# P1-1 fix 2026-04-12: 必須在 kubectl safety guard 之前 routing,否則 docker 指令被 _action_safe=False 攔截
|
||||
_alert_category = getattr(incident, "alert_category", None) or ""
|
||||
if _alert_category == "infrastructure" and action and not action.startswith("kubectl"):
|
||||
await self._ssh_execute(incident, token, action, _target)
|
||||
return
|
||||
|
||||
# 安全守衛: 替換後仍含 "unknown" 或未替換的 <...>/{...} → 拒絕執行
|
||||
# 另外:若 target 等於 alertname,代表 LLM 把告警名稱填入 deployment_name,也拒絕
|
||||
_alertname = incident.signals[0].labels.get("alertname", "") if incident.signals else ""
|
||||
@@ -1274,14 +1283,6 @@ class DecisionManager:
|
||||
)
|
||||
return
|
||||
|
||||
# ADR-073 Phase 3-2: infrastructure 告警 (Docker/Host) → SSH MCP routing (2026-04-12 ogt)
|
||||
# alert_category = "infrastructure" 表示 Docker/Host 告警,不走 K8s executor
|
||||
# action 格式應為 "docker restart <container>" 或 "systemctl restart <service>"
|
||||
_alert_category = getattr(incident, "alert_category", None) or ""
|
||||
if _alert_category == "infrastructure" and action and not action.startswith("kubectl"):
|
||||
await self._ssh_execute(incident, token, action, _target)
|
||||
return
|
||||
|
||||
# BUG-003 修復 2026-04-11: 加入 K8s deployment 存在性驗證,
|
||||
# 避免 LLM 產生的無效 deployment name(<placeholder>/alertname/unknown)通過 safety guard
|
||||
# 但仍對 K8s 發出錯誤指令
|
||||
@@ -1596,24 +1597,21 @@ class DecisionManager:
|
||||
# ADR-073 Phase 3-6: YAML rule risk_level 優先於 LLM 輸出 (2026-04-12 ogt)
|
||||
# LLM 有時把 critical 告警估為 medium,YAML 規則是由人工審閱過的,優先採用
|
||||
try:
|
||||
from src.services.alert_rule_engine import _load_rules, _matches
|
||||
from src.services.alert_rule_engine import get_risk_for_alertname
|
||||
_alertname_for_risk = (
|
||||
incident.signals[0].labels.get("alertname", "")
|
||||
if incident.signals else ""
|
||||
)
|
||||
if _alertname_for_risk:
|
||||
for _rule in _load_rules():
|
||||
if _matches(_rule, _alertname_for_risk, "", "", ""):
|
||||
_yaml_risk = _rule.get("response", {}).get("risk")
|
||||
if _yaml_risk and _yaml_risk != result.get("risk_level"):
|
||||
logger.info(
|
||||
"risk_level_yaml_override",
|
||||
incident_id=incident.incident_id,
|
||||
llm_risk=result.get("risk_level"),
|
||||
yaml_risk=_yaml_risk,
|
||||
)
|
||||
result["risk_level"] = _yaml_risk
|
||||
break
|
||||
_yaml_risk = get_risk_for_alertname(_alertname_for_risk)
|
||||
if _yaml_risk and _yaml_risk != result.get("risk_level"):
|
||||
logger.info(
|
||||
"risk_level_yaml_override",
|
||||
incident_id=incident.incident_id,
|
||||
llm_risk=result.get("risk_level"),
|
||||
yaml_risk=_yaml_risk,
|
||||
)
|
||||
result["risk_level"] = _yaml_risk
|
||||
except Exception as _re:
|
||||
logger.debug("risk_level_yaml_override_failed", error=str(_re))
|
||||
|
||||
@@ -2030,7 +2028,6 @@ class DecisionManager:
|
||||
- systemctl restart <service>
|
||||
- docker rm -f <container> (含 docker start)
|
||||
"""
|
||||
from src.plugins.mcp.providers.ssh_provider import SSHProvider
|
||||
import os as _os
|
||||
|
||||
# 取得主機 — 從 instance label 或 SSH_MCP_ALLOWED_HOSTS 第一台
|
||||
@@ -2076,7 +2073,6 @@ class DecisionManager:
|
||||
_fire_and_forget(_push_decision_to_telegram(incident, token.proposal_data))
|
||||
return
|
||||
|
||||
ssh = SSHProvider()
|
||||
params: dict = {"host": _host}
|
||||
if _tool == "docker_restart":
|
||||
params["container"] = _container
|
||||
@@ -2084,7 +2080,7 @@ class DecisionManager:
|
||||
params["service"] = _service
|
||||
|
||||
try:
|
||||
result = await ssh.execute(tool_name=_tool, parameters=params)
|
||||
result = await self._ssh.execute(tool_name=_tool, parameters=params)
|
||||
success = result.success
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -101,6 +101,42 @@ def extract_affected_services(labels: dict, target_resource: str) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def classify_alert_early(alertname: str, severity: str, _labels: dict) -> tuple[str, str]:
|
||||
"""
|
||||
ADR-073 Phase 2-2: 早期分診,在 LLM 分析前決定 alert_category + notification_type。
|
||||
防止 HostBackupFailed 等告警被誤路由到 K8s executor。
|
||||
|
||||
規則優先順序(由高到低):
|
||||
1. ConfigurationDrift / KubeConfigDrift → TYPE-4D (Config Drift 卡片)
|
||||
2. severity=info/none → TYPE-1 (純資訊,無按鈕)
|
||||
3. backup/heartbeat 關鍵字 → TYPE-1
|
||||
4. Docker/Host 前綴 → infrastructure TYPE-3
|
||||
5. Kube/Pod/Deploy/Node/Velero/ArgoCD 前綴 → kubernetes TYPE-3
|
||||
6. Postgres/Redis 前綴 → database TYPE-3
|
||||
7. 預設 → general TYPE-3
|
||||
|
||||
C3 修正 (首席架構師 CR 2026-04-13): 從 Router 層 (webhooks.py) 移入 Service 層
|
||||
原違規: 業務邏輯函數定義在 api/v1/webhooks.py
|
||||
|
||||
Returns:
|
||||
tuple[str, str]: (alert_category, notification_type)
|
||||
"""
|
||||
alertname_lower = alertname.lower()
|
||||
if alertname in ("ConfigurationDrift", "KubeConfigDrift"):
|
||||
return "config_drift", "TYPE-4D"
|
||||
if severity in ("info", "none"):
|
||||
return "info", "TYPE-1"
|
||||
if any(kw in alertname_lower for kw in ("backup", "heartbeat")):
|
||||
return "backup", "TYPE-1"
|
||||
if alertname.startswith(("Docker", "Host")):
|
||||
return "infrastructure", "TYPE-3"
|
||||
if alertname.startswith(("Kube", "Pod", "Deploy", "Node", "Velero", "ArgoCD")):
|
||||
return "kubernetes", "TYPE-3"
|
||||
if alertname.startswith(("Postgres", "Redis")):
|
||||
return "database", "TYPE-3"
|
||||
return "general", "TYPE-3"
|
||||
|
||||
|
||||
async def create_incident_for_approval(
|
||||
approval_id: str,
|
||||
risk_level: str,
|
||||
|
||||
@@ -1127,7 +1127,7 @@ def classify_notification(
|
||||
TYPE-4D > TYPE-1 > TYPE-2 > TYPE-4 > TYPE-3(預設)
|
||||
|
||||
Args:
|
||||
incident: Incident ORM 物件(需要 signals[].labels + title)
|
||||
incident: Incident Pydantic 模型(需要 signals[].labels + title)
|
||||
confidence: AI 決策信心值 (0.0~1.0)
|
||||
auto_executed: 是否已自動修復執行完成
|
||||
mcp_all_failed: 所有 MCP provider 是否全失敗
|
||||
|
||||
Reference in New Issue
Block a user