""" Playbook SSH 修復 KM 萃取測試 ================================= Task 3.3: SSH 修復路徑 Playbook 萃取 測試範圍: - _parse_ssh_command() — 各種 SSH 格式解析 - PlaybookService._extract_repair_steps() — SSH / kubectl 路徑 - PlaybookService._generate_name() — [SSH] 前綴 - PlaybookService._extract_tags() — ssh / host_layer 標籤 🔴 遵循「禁止 Mock 測試鐵律」 - 純 Python 邏輯:不需要 DB/Redis/Telegram - 使用真實模型物件,不 Mock 建立: 2026-04-14 (台北時區) Claude Sonnet 4.6 (Task 3.3) """ from datetime import datetime, timezone import pytest from src.models.incident import AIDecisionChain, Incident, IncidentOutcome, Severity, Signal from src.models.playbook import ActionType, RiskLevel from src.services.playbook_service import PlaybookService, _parse_ssh_command _TZ_TAIPEI = timezone.utc # 測試用 UTC,不影響邏輯 def _make_incident(**kwargs) -> Incident: """建立最小化 Incident(純記憶體)""" now = datetime.now(_TZ_TAIPEI) signal = Signal( alert_name=kwargs.pop("alert_name", "MinioDown"), severity=Severity.P2, source="alertmanager", fired_at=now, labels={}, annotations={}, ) return Incident( incident_id=kwargs.pop("incident_id", "INC-20260414-TEST"), title=kwargs.pop("title", "Test Incident"), severity=Severity.P2, signals=[signal], affected_services=kwargs.pop("affected_services", ["minio"]), proposal_ids=[], **kwargs, ) # ============================================================================= # _parse_ssh_command # ============================================================================= class TestParseSshCommand: """SSH 指令解析邏輯""" @pytest.mark.parametrize("cmd,expected_host,expected_inner", [ ( "ssh 192.168.0.188 'docker restart minio'", "192.168.0.188", "docker restart minio", ), ( "ssh root@192.168.0.110 'systemctl restart ollama || docker restart ollama'", "192.168.0.110", "systemctl restart ollama || docker restart ollama", ), ( "ssh {host} \"cd /data/harbor && docker-compose up -d\"", "{host}", "cd /data/harbor && docker-compose up -d", ), ]) def test_parse_standard_ssh(self, cmd, expected_host, expected_inner): """標準 SSH 格式解析""" host, inner = _parse_ssh_command(cmd) assert host == expected_host assert inner == expected_inner def test_parse_unrecognized_returns_empty_host(self): """無法解析的格式 → 空 host,保留原始命令""" cmd = "ssh host_without_quotes do_something" host, inner = _parse_ssh_command(cmd) assert host == "" assert inner == cmd def test_parse_empty_string(self): """空字串不崩潰""" host, inner = _parse_ssh_command("") assert host == "" # ============================================================================= # _extract_repair_steps — SSH path (last_repair_action) # ============================================================================= class TestExtractRepairStepsSSH: """SSH 修復路徑萃取(Task 3.3 新增)""" def _svc(self) -> PlaybookService: return PlaybookService.__new__(PlaybookService) # 不觸發 __init__ def test_ssh_command_in_last_repair_action(self): """last_repair_action 含 SSH → ActionType.SSH_COMMAND""" incident = _make_incident() incident.outcome = IncidentOutcome() incident.outcome.learning_notes = "ssh 192.168.0.188 'docker restart minio'" svc = self._svc() steps = svc._extract_repair_steps(incident) assert len(steps) == 1 assert steps[0].action_type == ActionType.SSH_COMMAND assert "docker restart minio" in steps[0].command def test_ssh_fallback_when_no_decision_chain(self): """無 decision_chain 時,SSH last_repair_action 能補位""" incident = _make_incident() incident.decision_chain = None incident.outcome = IncidentOutcome() incident.outcome.learning_notes = "ssh root@192.168.0.110 'systemctl restart ollama'" svc = self._svc() steps = svc._extract_repair_steps(incident) assert steps assert steps[0].action_type == ActionType.SSH_COMMAND def test_kubectl_in_last_repair_action(self): """last_repair_action 含 kubectl → ActionType.KUBECTL""" incident = _make_incident() incident.outcome = IncidentOutcome() incident.outcome.learning_notes = ( "kubectl rollout restart deployment/awoooi-api -n awoooi-prod" ) svc = self._svc() steps = svc._extract_repair_steps(incident) assert len(steps) == 1 assert steps[0].action_type == ActionType.KUBECTL assert "kubectl rollout restart" in steps[0].command def test_decision_chain_takes_priority_over_learning_notes(self): """decision_chain.reasoning_steps 有 kubectl → learning_notes SSH 不覆蓋""" now = datetime.now(_TZ_TAIPEI) incident = _make_incident() incident.decision_chain = AIDecisionChain( model_used="deepseek-r1:14b", hypothesis="Pod crash", confidence=0.85, reasoning_steps=["kubectl rollout restart deployment/test -n prod"], inference_started_at=now, inference_completed_at=now, latency_ms=100, ) incident.outcome = IncidentOutcome() incident.outcome.learning_notes = "ssh 192.168.0.188 'docker restart minio'" svc = self._svc() steps = svc._extract_repair_steps(incident) # decision_chain 優先 assert steps[0].action_type == ActionType.KUBECTL def test_no_action_returns_empty(self): """無任何修復來源 → 空列表""" incident = _make_incident() incident.decision_chain = None incident.outcome = None svc = self._svc() steps = svc._extract_repair_steps(incident) assert steps == [] def test_ssh_step_risk_level_is_medium(self): """SSH 步驟預設 MEDIUM 風險""" incident = _make_incident() incident.outcome = IncidentOutcome() incident.outcome.learning_notes = "ssh {host} 'docker restart minio'" svc = self._svc() steps = svc._extract_repair_steps(incident) assert steps[0].risk_level == RiskLevel.MEDIUM def test_ssh_step_number_is_1(self): """SSH 步驟編號從 1 開始""" incident = _make_incident() incident.outcome = IncidentOutcome() incident.outcome.learning_notes = "ssh 192.168.0.188 'docker restart minio'" svc = self._svc() steps = svc._extract_repair_steps(incident) assert steps[0].step_number == 1 # ============================================================================= # _generate_name — [SSH] 前綴 # ============================================================================= class TestGenerateNameSSH: """SSH 修復時名稱包含 [SSH] 前綴""" def _svc(self) -> PlaybookService: return PlaybookService.__new__(PlaybookService) def test_ssh_name_has_prefix(self): """SSH 修復 → 名稱含 [SSH]""" incident = _make_incident(alert_name="MinioDown", affected_services=["minio"]) incident.outcome = IncidentOutcome() incident.outcome.learning_notes = "ssh 192.168.0.188 'docker restart minio'" svc = self._svc() name = svc._generate_name(incident) assert name.startswith("[SSH]") assert "MinioDown" in name def test_kubectl_name_no_prefix(self): """kubectl 修復 → 名稱無 [SSH] 前綴""" incident = _make_incident(alert_name="KubePodCrashLooping") incident.outcome = IncidentOutcome() incident.outcome.learning_notes = ( "kubectl rollout restart deployment/awoooi-api -n prod" ) svc = self._svc() name = svc._generate_name(incident) assert not name.startswith("[SSH]") def test_no_outcome_no_prefix(self): """無 outcome → 名稱無前綴""" incident = _make_incident() incident.outcome = None svc = self._svc() name = svc._generate_name(incident) assert not name.startswith("[SSH]") # ============================================================================= # _extract_tags — ssh / host_layer 標籤 # ============================================================================= class TestExtractTagsSSH: """SSH 修復時自動加 ssh/host_layer 標籤""" def _svc(self) -> PlaybookService: return PlaybookService.__new__(PlaybookService) def test_ssh_tags_added(self): """SSH last_repair_action → tags 含 ssh + host_layer""" incident = _make_incident(affected_services=["minio"]) incident.outcome = IncidentOutcome() incident.outcome.learning_notes = "ssh 192.168.0.188 'docker restart minio'" svc = self._svc() tags = svc._extract_tags(incident) assert "ssh" in tags assert "host_layer" in tags def test_non_ssh_no_ssh_tag(self): """kubectl 修復 → 無 ssh 標籤""" incident = _make_incident() incident.outcome = IncidentOutcome() incident.outcome.learning_notes = ( "kubectl rollout restart deployment/awoooi-api -n prod" ) svc = self._svc() tags = svc._extract_tags(incident) assert "ssh" not in tags def test_no_outcome_no_ssh_tag(self): """無 outcome → 無 ssh 標籤""" incident = _make_incident() incident.outcome = None svc = self._svc() tags = svc._extract_tags(incident) assert "ssh" not in tags