diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index f6711423a..18b3e7650 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -406,6 +406,10 @@ jobs: ;; apps/api/src/services/gitea_workflow_runner_owner_attestation_request.py) ;; + apps/api/src/services/gitea_cicd_alert_receipt_chain_readback.py) + ;; + apps/api/src/services/gitea_capability_ci_cd_optimization_readback.py) + ;; apps/api/src/services/reboot_auto_recovery_slo_scorecard.py) ;; apps/api/src/services/windows99_vmx_source_locator_readback.py) @@ -542,6 +546,10 @@ jobs: ;; apps/api/tests/test_gitea_repo_bundle_backup_readback_api.py) ;; + apps/api/tests/test_gitea_cicd_alert_receipt_chain_readback_api.py) + ;; + apps/api/tests/test_gitea_capability_ci_cd_optimization_readback_api.py) + ;; apps/api/tests/test_knowledge_repository_read_model.py) ;; apps/api/tests/test_credential_escrow_evidence_intake_readiness_api.py) @@ -918,6 +926,8 @@ jobs: src/services/gitea_private_inventory_closeout_validation.py \ src/services/gitea_private_inventory_p0_scorecard.py \ src/services/gitea_workflow_runner_owner_attestation_request.py \ + src/services/gitea_cicd_alert_receipt_chain_readback.py \ + src/services/gitea_capability_ci_cd_optimization_readback.py \ src/services/reboot_auto_recovery_slo_scorecard.py \ src/services/reboot_auto_recovery_drill_preflight.py \ src/services/stockplatform_public_api_runtime_readback.py \ @@ -1039,6 +1049,8 @@ jobs: tests/test_backup_dr_readiness_matrix.py \ tests/test_backup_dr_readiness_matrix_api.py \ tests/test_gitea_repo_bundle_backup_readback_api.py \ + tests/test_gitea_cicd_alert_receipt_chain_readback_api.py \ + tests/test_gitea_capability_ci_cd_optimization_readback_api.py \ tests/test_credential_escrow_evidence_intake_readiness_api.py \ tests/test_gitea_private_inventory_p0_scorecard_api.py \ tests/test_gitea_workflow_runner_owner_attestation_request_api.py \ @@ -1091,6 +1103,16 @@ jobs: 2>&1 | tee /tmp/pytest-output.txt; PYTEST_EXIT=${PIPESTATUS[0]} else python3.11 ../../scripts/ci/generate-cicd-artifact-receipts.py --repo-root ../.. --out-dir /tmp/awoooi-cicd-artifacts + echo "✅ full profile: running learning-chain E2E in isolated pytest process" + DATABASE_URL="${DATABASE_URL:-postgresql+asyncpg://ci:ci@localhost/ci}" \ + PYTHONFAULTHANDLER=1 python3.11 -m pytest tests/test_learning_chain_e2e.py -v --tb=short -p no:cacheprovider \ + 2>&1 | tee /tmp/pytest-learning-chain-output.txt; LEARNING_CHAIN_EXIT=${PIPESTATUS[0]} + cat /tmp/pytest-learning-chain-output.txt > /tmp/pytest-output.txt + if [ "$LEARNING_CHAIN_EXIT" -ne 0 ]; then + tail -60 /tmp/pytest-output.txt + cleanup_pytest_workspace_cache + exit "$LEARNING_CHAIN_EXIT" + fi DATABASE_URL="${DATABASE_URL:-postgresql+asyncpg://ci:ci@localhost/ci}" \ PYTHONFAULTHANDLER=1 python3.11 -m pytest tests/ -v --tb=short -x -p no:cacheprovider \ --ignore=tests/integration \ @@ -1099,8 +1121,9 @@ jobs: --ignore=tests/test_redis_multisig.py \ --ignore=tests/test_model_regression.py \ --ignore=tests/test_prompt_validation.py \ + --ignore=tests/test_learning_chain_e2e.py \ --ignore=tests/e2e_network_test.py \ - 2>&1 | tee /tmp/pytest-output.txt; PYTEST_EXIT=${PIPESTATUS[0]} + 2>&1 | tee -a /tmp/pytest-output.txt; PYTEST_EXIT=${PIPESTATUS[0]} fi tail -60 /tmp/pytest-output.txt cleanup_pytest_workspace_cache diff --git a/apps/api/tests/test_learning_chain_e2e.py b/apps/api/tests/test_learning_chain_e2e.py index 06f6cbef9..35afea849 100644 --- a/apps/api/tests/test_learning_chain_e2e.py +++ b/apps/api/tests/test_learning_chain_e2e.py @@ -32,7 +32,6 @@ from src.models.playbook import ( from src.services.auto_repair_service import AutoRepairService from src.utils.timezone import now_taipei - # ============================================================================= # Stubs # ============================================================================= @@ -54,7 +53,11 @@ class StubVerifier: warmup_sec: float = 0.0, ) -> str: self.calls.append( - {"incident_id": incident.incident_id, "snapshot": snapshot, "action_taken": action_taken} + { + "incident_id": incident.incident_id, + "snapshot": snapshot, + "action_taken": action_taken, + } ) if self.raise_exc is not None: raise self.raise_exc @@ -113,6 +116,26 @@ class StubPlaybookService: return playbook is not None +class StubRunbookGenerator: + """No-network/no-DB runbook generator for auto-repair fire-and-forget tasks.""" + + async def generate_runbook(self, *args, **kwargs) -> None: + return None + + async def generate_anti_pattern(self, *args, **kwargs) -> None: + return None + + +class StubAutoRepairExecutionRepository: + async def create(self, *args, **kwargs) -> dict: + return {"stored": False} + + +class StubAnomalyCounter: + async def record_disposition(self, *args, **kwargs) -> bool: + return True + + class StubRecommendation: def __init__(self, playbook: Playbook, similarity_score: float = 0.9) -> None: self.playbook = playbook @@ -179,6 +202,46 @@ async def _no_cooldown(*args, **kwargs) -> tuple[bool, str]: return True, "允許修復 (test bypass)" +@pytest.fixture(autouse=True) +def isolate_auto_repair_side_effects(monkeypatch): + """Keep this E2E chain in-process only: no DB, network, KM, or runbook writes.""" + + async def _record_global_repair_action() -> None: + return None + + import src.services.auto_repair_service as _ars_mod + + monkeypatch.setattr( + _ars_mod, "record_global_repair_action", _record_global_repair_action + ) + + import src.services.runbook_generator as _rg_mod + + monkeypatch.setattr( + _rg_mod, "get_runbook_generator", lambda: StubRunbookGenerator() + ) + + import src.repositories.audit_log_repository as _audit_mod + + monkeypatch.setattr( + _audit_mod, + "get_auto_repair_execution_repository", + lambda: StubAutoRepairExecutionRepository(), + ) + + import src.services.anomaly_counter as _counter_mod + + monkeypatch.setattr( + _counter_mod, "get_anomaly_counter", lambda: StubAnomalyCounter() + ) + + +async def _drain_service_tasks(service: AutoRepairService) -> None: + drain = await service.drain_pending_tasks(timeout=1.0) + assert drain.get("timeout") is False, drain + assert not service._pending_tasks + + # ============================================================================= # Tests # ============================================================================= @@ -195,12 +258,15 @@ async def test_auto_repair_success_triggers_verify_and_learn(monkeypatch): # 替換 module-level getters(pure Python, no MagicMock) import src.services.auto_repair_service as _ars_mod + monkeypatch.setattr(_ars_mod, "_verifier_getter", None, raising=False) import src.services.post_execution_verifier as _pev_mod + monkeypatch.setattr(_pev_mod, "_verifier", stub_verifier) import src.services.learning_service as _ls_mod + monkeypatch.setattr(_ls_mod, "_learning_service", stub_learning) playbook = _make_playbook() @@ -225,15 +291,20 @@ async def test_auto_repair_success_triggers_verify_and_learn(monkeypatch): assert len(stub_verifier.calls) == 1, "verifier.verify() 應被呼叫一次" assert stub_verifier.calls[0]["incident_id"] == incident.incident_id assert stub_verifier.calls[0]["snapshot"] is None - assert stub_verifier.calls[0]["action_taken"].startswith(f"auto_repair:{playbook.playbook_id}") + assert stub_verifier.calls[0]["action_taken"].startswith( + f"auto_repair:{playbook.playbook_id}" + ) assert "steps=Step 1:" in stub_verifier.calls[0]["action_taken"] - assert len(stub_learning.verification_calls) == 1, "record_verification_result 應被呼叫一次" + assert ( + len(stub_learning.verification_calls) == 1 + ), "record_verification_result 應被呼叫一次" call = stub_learning.verification_calls[0] assert call["incident_id"] == incident.incident_id assert call["action_taken"] == stub_verifier.calls[0]["action_taken"] assert call["verification_result"] == "success" assert call["matched_playbook_id"] == playbook.playbook_id + await _drain_service_tasks(service) @pytest.mark.asyncio @@ -246,9 +317,11 @@ async def test_auto_repair_can_delegate_post_verification(monkeypatch): stub_learning = StubLearningService() import src.services.post_execution_verifier as _pev_mod + monkeypatch.setattr(_pev_mod, "_verifier", stub_verifier) import src.services.learning_service as _ls_mod + monkeypatch.setattr(_ls_mod, "_learning_service", stub_learning) playbook = _make_playbook() @@ -273,6 +346,7 @@ async def test_auto_repair_can_delegate_post_verification(monkeypatch): assert stub_verifier.calls == [] assert stub_learning.verification_calls == [] + await _drain_service_tasks(service) @pytest.mark.asyncio @@ -283,9 +357,11 @@ async def test_auto_repair_failure_does_not_call_verifier(monkeypatch): stub_verifier = StubVerifier(result="success") import src.services.post_execution_verifier as _pev_mod + monkeypatch.setattr(_pev_mod, "_verifier", stub_verifier) import src.services.learning_service as _ls_mod + stub_learning = StubLearningService() monkeypatch.setattr(_ls_mod, "_learning_service", stub_learning) @@ -320,7 +396,10 @@ async def test_auto_repair_failure_does_not_call_verifier(monkeypatch): # 失敗路徑不進入 verify-and-learn 塊 assert len(stub_verifier.calls) == 0, "執行失敗時不應呼叫 verifier" - assert len(stub_learning.verification_calls) == 0, "執行失敗時不應呼叫 record_verification_result" + assert ( + len(stub_learning.verification_calls) == 0 + ), "執行失敗時不應呼叫 record_verification_result" + await _drain_service_tasks(service) @pytest.mark.asyncio @@ -330,9 +409,11 @@ async def test_auto_repair_failed_step_string_marks_execution_failure(monkeypatc stub_learning = StubLearningService() import src.services.post_execution_verifier as _pev_mod + monkeypatch.setattr(_pev_mod, "_verifier", stub_verifier) import src.services.learning_service as _ls_mod + monkeypatch.setattr(_ls_mod, "_learning_service", stub_learning) playbook = _make_playbook() @@ -355,6 +436,7 @@ async def test_auto_repair_failed_step_string_marks_execution_failure(monkeypatc assert "simulated executor failure" in (result.error or "") assert len(stub_verifier.calls) == 0 assert len(stub_learning.verification_calls) == 0 + await _drain_service_tasks(service) @pytest.mark.asyncio @@ -421,9 +503,11 @@ async def test_verifier_exception_does_not_block_repair(monkeypatch): stub_learning = StubLearningService() import src.services.post_execution_verifier as _pev_mod + monkeypatch.setattr(_pev_mod, "_verifier", stub_verifier) import src.services.learning_service as _ls_mod + monkeypatch.setattr(_ls_mod, "_learning_service", stub_learning) playbook = _make_playbook() @@ -446,4 +530,7 @@ async def test_verifier_exception_does_not_block_repair(monkeypatch): # verifier 被呼叫(但拋了例外) assert len(stub_verifier.calls) == 1 # learning 不應被呼叫(因為 verifier raise 中斷了 _verify_and_learn) - assert len(stub_learning.verification_calls) == 0, "verifier 拋例外後 learning 不應被呼叫" + assert ( + len(stub_learning.verification_calls) == 0 + ), "verifier 拋例外後 learning 不應被呼叫" + await _drain_service_tasks(service)