All checks were successful
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m32s
CD Pipeline / build-and-deploy (push) Successful in 6m50s
CD Pipeline / post-deploy-checks (push) Successful in 2m22s
175 lines
5.8 KiB
Python
175 lines
5.8 KiB
Python
"""Webhook AI decisions must route through the single-writer broker."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from src.api.v1 import webhooks
|
|
from src.models.ai import (
|
|
AIBlastRadius,
|
|
AIDataImpact,
|
|
OpenClawDecision,
|
|
SuggestedAction,
|
|
)
|
|
from src.models.approval import RiskLevel
|
|
from src.services.approval_execution import ApprovalExecutionService
|
|
|
|
|
|
def _make_analysis(
|
|
*,
|
|
confidence: float = 0.90,
|
|
kubectl_command: str = "kubectl rollout restart deployment/api -n prod",
|
|
suggested_action: SuggestedAction = SuggestedAction.RESTART_DEPLOYMENT,
|
|
) -> OpenClawDecision:
|
|
return OpenClawDecision(
|
|
action_title="Restart deployment",
|
|
kubectl_command=kubectl_command,
|
|
description="Controlled restart candidate",
|
|
risk_level="low",
|
|
suggested_action=suggested_action,
|
|
confidence=confidence,
|
|
blast_radius=AIBlastRadius(
|
|
affected_pods=1,
|
|
estimated_downtime="~30s",
|
|
related_services=[],
|
|
data_impact=AIDataImpact.NONE,
|
|
),
|
|
target_resource="deployment/api",
|
|
affected_services=[],
|
|
deviation_analysis="none",
|
|
primary_responsibility="COLLAB",
|
|
)
|
|
|
|
|
|
def _can_route(analysis: OpenClawDecision, risk_level: RiskLevel) -> bool:
|
|
from src.services.action_parser import is_safe_kubectl_action
|
|
|
|
non_actions = {"NO_ACTION", "INVESTIGATE", "OBSERVE"}
|
|
action_name = analysis.suggested_action.value
|
|
command = (analysis.kubectl_command or "").strip()
|
|
return (
|
|
bool(command)
|
|
and analysis.confidence >= 0.85
|
|
and risk_level != RiskLevel.CRITICAL
|
|
and action_name not in non_actions
|
|
and is_safe_kubectl_action(command)
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("analysis", "risk_level", "expected"),
|
|
[
|
|
(_make_analysis(), RiskLevel.LOW, True),
|
|
(_make_analysis(confidence=0.70), RiskLevel.LOW, False),
|
|
(_make_analysis(confidence=0.85), RiskLevel.MEDIUM, True),
|
|
(_make_analysis(), RiskLevel.CRITICAL, False),
|
|
(
|
|
_make_analysis(
|
|
kubectl_command="kubectl delete pods --all -n prod",
|
|
),
|
|
RiskLevel.LOW,
|
|
False,
|
|
),
|
|
(
|
|
_make_analysis(suggested_action=SuggestedAction.NO_ACTION),
|
|
RiskLevel.LOW,
|
|
False,
|
|
),
|
|
],
|
|
)
|
|
def test_cs1_controlled_queue_eligibility(
|
|
analysis: OpenClawDecision,
|
|
risk_level: RiskLevel,
|
|
expected: bool,
|
|
) -> None:
|
|
assert _can_route(analysis, risk_level) is expected
|
|
|
|
|
|
def test_active_webhook_paths_do_not_call_direct_executor() -> None:
|
|
receive_source = inspect.getsource(webhooks.receive_alert)
|
|
alertmanager_source = inspect.getsource(webhooks._process_new_alert_background)
|
|
router_source = inspect.getsource(webhooks._try_auto_repair_background)
|
|
legacy_source = inspect.getsource(
|
|
webhooks._legacy_try_auto_repair_background_disabled
|
|
)
|
|
|
|
for source in (receive_source, alertmanager_source, router_source):
|
|
assert "execute_approved_action" not in source
|
|
assert "ApprovalExecutionService" not in source
|
|
assert "execute_auto_repair(" not in source
|
|
assert "_try_auto_repair_background" in receive_source
|
|
assert "_try_auto_repair_background" in alertmanager_source
|
|
assert "enqueue_ai_decision_ansible_candidate" in router_source
|
|
assert "webhook_direct_auto_repair_superseded" in legacy_source
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_webhook_router_writes_queue_receipt_without_side_effect(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
incident = SimpleNamespace(project_id="awoooi")
|
|
incident_service = SimpleNamespace(
|
|
get_from_working_memory=AsyncMock(return_value=incident)
|
|
)
|
|
append = AsyncMock()
|
|
|
|
queue_kwargs: dict = {}
|
|
|
|
async def queue(**kwargs):
|
|
queue_kwargs.update(kwargs)
|
|
return {
|
|
"schema_version": "ai_decision_controlled_executor_handoff_v1",
|
|
"status": "controlled_check_mode_queued",
|
|
"automation_run_id": "00000000-0000-0000-0000-000000000101",
|
|
"queued": True,
|
|
"side_effect_performed": False,
|
|
"single_writer_executor": "awoooi-ansible-executor-broker",
|
|
"active_blockers": [],
|
|
}
|
|
|
|
monkeypatch.setattr(webhooks, "get_incident_service", lambda: incident_service)
|
|
monkeypatch.setattr(
|
|
"src.jobs.awooop_ansible_candidate_backfill_job.enqueue_ai_decision_ansible_candidate",
|
|
queue,
|
|
)
|
|
monkeypatch.setattr(
|
|
"src.repositories.alert_operation_log_repository.get_alert_operation_log_repository",
|
|
lambda: SimpleNamespace(append=append),
|
|
)
|
|
|
|
handoff = await webhooks._try_auto_repair_background(
|
|
incident_id="INC-QUEUE",
|
|
approval_id="00000000-0000-0000-0000-000000000201",
|
|
alert_type="MomoPostgresBackupFailed",
|
|
target_resource="momo-postgres",
|
|
namespace="awoooi-prod",
|
|
risk_level="low",
|
|
)
|
|
|
|
assert handoff["queued"] is True
|
|
assert handoff["side_effect_performed"] is False
|
|
assert handoff["execution_priority"] == 30
|
|
assert queue_kwargs["proposal_data"]["execution_priority"] == 30
|
|
append.assert_awaited_once()
|
|
assert append.await_args.kwargs["context"]["side_effect_performed"] is False
|
|
assert append.await_args.kwargs["context"]["execution_priority"] == 30
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approval_executor_rejects_auto_approved_direct_caller() -> None:
|
|
approval = SimpleNamespace(
|
|
id="00000000-0000-0000-0000-000000000301",
|
|
incident_id="INC-DIRECT-BLOCK",
|
|
requested_by="auto_approve_rule_engine",
|
|
)
|
|
|
|
with pytest.raises(
|
|
RuntimeError,
|
|
match="auto_approved_direct_execution_disabled_use_single_writer_broker",
|
|
):
|
|
await ApprovalExecutionService().execute_approved_action(approval)
|