feat(auto-repair): route ssh diagnostics through mcp gateway
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
run-migration / migrate (push) Successful in 9s
CD Pipeline / tests (push) Successful in 1m11s
CD Pipeline / build-and-deploy (push) Successful in 3m17s
CD Pipeline / post-deploy-checks (push) Successful in 1m16s

This commit is contained in:
Your Name
2026-05-14 21:13:05 +08:00
parent 0567135647
commit 813d088339
8 changed files with 662 additions and 6 deletions

View File

@@ -19,6 +19,7 @@ from src.models.playbook import (
RiskLevel,
SymptomPattern,
)
from src.plugins.mcp.interfaces import MCPToolResult
from src.services.auto_repair_service import AutoRepairService
from src.utils.timezone import now_taipei
@@ -120,6 +121,14 @@ async def _no_cooldown(*args, **kwargs) -> tuple[bool, str]:
return True, "允許自動修復 (test bypass)"
class _DbContext:
async def __aenter__(self) -> object:
return object()
async def __aexit__(self, *_args: object) -> None:
return None
class TestAutoRepairService:
"""Auto Repair Service unit tests"""
@@ -415,6 +424,90 @@ class TestAutoRepairService:
assert service._should_escalate_failed_verification(incident, playbook) is False
def test_legacy_ssh_diagnostic_routes_to_mcp_gateway(self, service):
"""Legacy YAML_RULE SSH diagnostics become governed read-only MCP calls."""
incident = create_test_incident(
severity=Severity.P2,
alert_category="infrastructure",
alert_name="DockerContainerMemoryLimitPressure",
)
incident.signals[0].labels.update({
"host": "110",
"container_name": "sentry-self-hosted-clickhouse-1",
})
route = service._route_legacy_ssh_command_to_mcp(
incident,
'ssh {host} \'echo "=== LOAD ==="; uptime; docker stats --no-stream\'',
)
assert route is not None
assert route.tool_name == "ssh_diagnose"
assert route.params == {
"host": "192.168.0.110",
"container_name": "sentry-self-hosted-clickhouse-1",
}
def test_legacy_ssh_write_action_does_not_route_to_read_mcp(self, service):
"""Write operations must stay out of the read-only diagnostic grant."""
incident = create_test_incident(severity=Severity.P2)
route = service._route_legacy_ssh_command_to_mcp(
incident,
"ssh {host} 'docker restart minio'",
)
assert route is None
@pytest.mark.asyncio
async def test_execute_legacy_ssh_diagnostic_uses_mcp_gateway(
self,
service,
monkeypatch,
):
incident = create_test_incident(
severity=Severity.P2,
alert_category="infrastructure",
alert_name="DockerContainerMemoryLimitPressure",
)
incident.signals[0].labels.update({
"host": "110",
"container_name": "momo-scheduler",
})
step = RepairStep(
step_number=1,
action_type=ActionType.SSH_COMMAND,
command='ssh {host} \'echo "=== LOAD ==="; uptime; docker stats --no-stream\'',
risk_level=RiskLevel.LOW,
)
calls = []
class FakeGateway:
def __init__(self, db: object) -> None:
self.db = db
async def call(self, ctx, params):
calls.append({"ctx": ctx, "params": params, "db": self.db})
return MCPToolResult(
success=True,
execution_id="gw-ok",
output={"stdout": "=== CPU TOP ==="},
)
monkeypatch.setattr("src.db.base.get_db_context", lambda _project_id: _DbContext())
monkeypatch.setattr("src.plugins.mcp.gateway.McpGateway", FakeGateway)
result = await service._execute_step(incident, step)
assert result.startswith("SUCCESS: mcp:ssh_diagnose")
assert calls
assert calls[0]["ctx"].agent_id == "auto_repair_executor"
assert calls[0]["ctx"].required_scope == "read"
assert calls[0]["ctx"].is_shadow is False
assert calls[0]["params"]["host"] == "192.168.0.110"
assert calls[0]["params"]["container_name"] == "momo-scheduler"
assert calls[0]["params"]["_mcp_audit"]["flywheel_node"] == "execute"
@pytest.mark.asyncio
async def test_evaluate_low_risk_allowed(self, service, mock_playbook_service):
"""Test that LOW risk actions are allowed"""

View File

@@ -384,6 +384,25 @@ class _FakeRegistry:
]
class _PrometheusRegistry:
def __init__(self, provider: _CaptureProvider) -> None:
self.provider = provider
def suggest_tools(self, alertname: str = "", incident_labels: dict | None = None) -> list[RegisteredTool]:
return [
RegisteredTool(
tool=MCPTool(
name="prometheus_query",
description="",
input_schema={},
server_name="capture",
),
provider=self.provider,
dimensions=[SensorDimension.D3_METRICS],
)
]
class _DbContext:
async def __aenter__(self) -> object:
return object()
@@ -448,3 +467,39 @@ class TestCollectPostStateAuditContext:
assert ctx.required_scope == "read"
assert calls[0]["parameters"]["_mcp_audit"]["incident_id"] == "INC-TEST"
assert calls[0]["parameters"]["_mcp_audit"]["flywheel_node"] == "verify"
@pytest.mark.asyncio
async def test_collect_post_state_sends_prometheus_query_parameter(self):
provider = _CaptureProvider()
verifier = PostExecutionVerifier()
verifier._registry = _PrometheusRegistry(provider)
incident = _stub_incident(alertname="DockerContainerMemoryLimitPressure")
incident.signals[0].labels.update({
"host": "110",
"container_name": "momo-scheduler",
})
await verifier._collect_post_state(incident)
assert provider.seen_parameters is not None
query = provider.seen_parameters["query"]
assert "docker_container_memory_usage_bytes" in query
assert 'host="110"' in query
assert 'container_name="momo-scheduler"' in query
class TestPrometheusQueryBuilder:
def test_docker_memory_alert_query_is_not_empty(self):
query = pev_module._build_prometheus_query(
"DockerContainerMemoryLimitPressure",
{"host": "110", "container_name": "momo-scheduler"},
)
assert "docker_container_memory_usage_bytes" in query
assert "docker_container_memory_limit_bytes" in query
def test_canary_alert_uses_generic_non_empty_query(self):
query = pev_module._build_prometheus_query("AwoooPAutoRepairCanaryT16", {})
assert query
assert "up" in query

View File

@@ -21,6 +21,18 @@ async def test_ssh_diagnose_is_registered_read_only_tool():
assert "df -h" in command
def test_ssh_diagnose_can_include_container_read_only_context():
provider = SSHProvider()
command = provider._build_command(
"ssh_diagnose",
{"container_name": "sentry-self-hosted-clickhouse-1"},
)
assert "docker stats --no-stream sentry-self-hosted-clickhouse-1" in command
assert "docker inspect sentry-self-hosted-clickhouse-1" in command
def test_ssh_provider_uses_ollama_user_for_188():
provider = SSHProvider()
@@ -32,6 +44,8 @@ def test_ssh_provider_uses_ollama_user_for_188():
"raw,expected",
[
("192.168.0.110:9100", "192.168.0.110"),
("110:9100", "192.168.0.110"),
("188", "192.168.0.188"),
("wooo@192.168.0.110", "192.168.0.110"),
("ssh://wooo@192.168.0.110:22", "192.168.0.110"),
("192.168.0.188", "192.168.0.188"),