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
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:
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user