fix(api): route auto repair docker restart through mcp
Some checks failed
CD Pipeline / tests (push) Successful in 1m21s
Code Review / ai-code-review (push) Successful in 13s
run-migration / migrate (push) Successful in 9s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled

This commit is contained in:
Your Name
2026-06-01 14:35:06 +08:00
parent ea8e2b1106
commit 2faa167ed2
5 changed files with 503 additions and 6 deletions

View File

@@ -26,6 +26,7 @@ from collections.abc import Callable
from dataclasses import dataclass
import re
from typing import Any, Protocol
from uuid import NAMESPACE_URL, UUID, uuid5
import structlog
@@ -88,6 +89,7 @@ class _SshMcpRoute:
tool_name: str
params: dict[str, Any]
required_scope: str = "read"
_SHORT_HOST_MAP: dict[str, str] = {
@@ -130,6 +132,12 @@ _SSH_WRITE_KEYWORDS = (
"bash ",
)
_AUTO_REPAIR_GATEWAY_AGENT_ID = "auto_repair_executor"
_AUTO_REPAIR_GATEWAY_PROJECT_ID = "awoooi"
_AUTO_REPAIR_GATEWAY_APPROVAL_TTL_SECONDS = 600
_SAFE_DOCKER_CONTAINER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
_UNSAFE_LEGACY_WRITE_PATTERN = re.compile(r"[;|<>`\n]|(\$\{|\$\()")
# =============================================================================
# Auto Repair Service Interface
@@ -1003,6 +1011,52 @@ class AutoRepairService:
return _SshMcpRoute(tool_name="ssh_diagnose", params=params)
def _route_legacy_ssh_write_command_to_mcp(
self,
incident: Incident,
command: str,
) -> _SshMcpRoute | None:
"""Map safe legacy Docker restart steps to the write-scoped MCP tool.
This intentionally supports only the historical pattern used by
DockerContainerUnhealthy playbooks:
``ssh {host} 'docker inspect {container} ... && docker restart {container}'``.
Broader shell, command substitution, exporter discovery, and systemd
fallbacks stay blocked so they can move through approval/manual review.
"""
raw_command = (command or "").strip()
lowered = raw_command.lower()
if not lowered.startswith("ssh ") or "docker restart" not in lowered:
return None
if _UNSAFE_LEGACY_WRITE_PATTERN.search(raw_command):
return None
host = self._resolve_ssh_host_for_incident(incident, raw_command)
if not host:
return None
container_name = (
self._resolve_container_name_for_incident(incident, raw_command)
or self._extract_docker_restart_container(raw_command)
)
if not self._is_safe_docker_container_name(container_name):
return None
if not self._has_simple_docker_restart_tail(raw_command, container_name):
return None
return _SshMcpRoute(
tool_name="ssh_docker_restart",
params={
"host": host,
"container_name": container_name,
"trust_score": 0.85,
},
required_scope="write",
)
def preview_read_only_ssh_mcp_route(
self,
incident: Incident,
@@ -1077,11 +1131,35 @@ class AutoRepairService:
return value
match = re.search(
r"docker\s+(?:stats\s+--no-stream|inspect|logs|top|ps\s+-a\s+--filter\s+name=)\s+([a-zA-Z0-9._-]+)",
r"docker\s+(?:stats\s+--no-stream|inspect|logs|top|restart|ps\s+-a\s+--filter\s+name=)\s+([a-zA-Z0-9._-]+)",
command,
)
return match.group(1) if match else ""
@staticmethod
def _extract_docker_restart_container(command: str) -> str:
match = re.search(
r"docker\s+restart\s+([A-Za-z0-9][A-Za-z0-9_.-]{0,127})(?:\s*['\"])?\s*$",
command,
flags=re.IGNORECASE,
)
return match.group(1) if match else ""
@staticmethod
def _is_safe_docker_container_name(container_name: str) -> bool:
return bool(container_name and _SAFE_DOCKER_CONTAINER_RE.fullmatch(container_name))
@staticmethod
def _has_simple_docker_restart_tail(command: str, container_name: str) -> bool:
target = rf"(?:{re.escape(container_name)}|\{{container\}}|\{{target\}})"
return bool(
re.search(
rf"docker\s+restart\s+{target}(?:\s*['\"])?\s*$",
command,
flags=re.IGNORECASE,
)
)
@staticmethod
def _incident_labels(incident: Incident) -> dict[str, Any]:
for signal in incident.signals or []:
@@ -1094,6 +1172,8 @@ class AutoRepairService:
self,
incident: Incident,
route: _SshMcpRoute,
*,
approved: bool = True,
) -> str:
"""Execute a routed SSH diagnostic through AwoooP MCP Gateway."""
@@ -1103,22 +1183,38 @@ class AutoRepairService:
from src.services.mcp_audit_context import with_mcp_audit_context
incident_id = incident.incident_id
run_id: UUID | None = None
if route.required_scope != "read":
if not approved:
return (
f"FAILED: mcp:{route.tool_name} approval required for "
f"{route.required_scope} scope"
)
run_id = self._mcp_auto_repair_run_id(incident_id, route)
approval_projected = await self._project_auto_repair_mcp_approval(
route=route,
run_id=run_id,
)
if not approval_projected:
return f"FAILED: mcp:{route.tool_name} approval projection failed"
params = with_mcp_audit_context(
route.params,
session_id=f"incident:{incident_id}:auto_repair_execute",
incident_id=incident_id,
flywheel_node="execute",
agent_role="auto_repair_executor",
agent_role=_AUTO_REPAIR_GATEWAY_AGENT_ID,
)
async with get_db_context("awoooi") as db:
ctx = GatewayContext(
project_id="awoooi",
agent_id="auto_repair_executor",
project_id=_AUTO_REPAIR_GATEWAY_PROJECT_ID,
agent_id=_AUTO_REPAIR_GATEWAY_AGENT_ID,
tool_name=route.tool_name,
run_id=run_id,
trace_id=incident_id,
is_shadow=False,
environment={"env": "prod"},
required_scope="read",
required_scope=route.required_scope,
)
result = await McpGateway(db).call(ctx, params)
except McpGatewayError as exc:
@@ -1137,6 +1233,55 @@ class AutoRepairService:
return f"SUCCESS: mcp:{route.tool_name} {preview}".strip()
return f"FAILED: mcp:{route.tool_name} {result.error or 'execution failed'}"
@staticmethod
def _mcp_auto_repair_run_id(incident_id: str, route: _SshMcpRoute) -> UUID:
stable_payload = (
f"{_AUTO_REPAIR_GATEWAY_PROJECT_ID}:"
f"{_AUTO_REPAIR_GATEWAY_AGENT_ID}:"
f"{route.tool_name}:"
f"{incident_id}:"
f"{route.params.get('host', '')}:"
f"{route.params.get('container_name', '')}"
)
return uuid5(NAMESPACE_URL, stable_payload)
async def _project_auto_repair_mcp_approval(
self,
route: _SshMcpRoute,
run_id: UUID,
) -> bool:
"""Project policy-approved auto repair into Gateway's Gate 5 key."""
try:
from src.core.redis_client import get_redis
redis = get_redis()
approval_key = (
f"mcp_approval:{_AUTO_REPAIR_GATEWAY_PROJECT_ID}:"
f"{_AUTO_REPAIR_GATEWAY_AGENT_ID}:{route.tool_name}:{run_id}"
)
await redis.set(
approval_key,
"approved:auto_repair_policy",
ex=_AUTO_REPAIR_GATEWAY_APPROVAL_TTL_SECONDS,
)
logger.info(
"auto_repair_mcp_approval_projected",
tool=route.tool_name,
required_scope=route.required_scope,
run_id=str(run_id),
)
return True
except Exception as exc:
logger.warning(
"auto_repair_mcp_approval_projection_failed",
tool=route.tool_name,
required_scope=route.required_scope,
run_id=str(run_id),
error=str(exc),
)
return False
async def _execute_step(self, incident: Incident, step) -> str:
"""
執行單一修復步驟
@@ -1172,9 +1317,17 @@ class AutoRepairService:
if route is not None:
return await self._execute_ssh_mcp_route(incident, route)
approved = not getattr(step, "requires_approval", False)
route = self._route_legacy_ssh_write_command_to_mcp(incident, step.command)
if route is not None:
return await self._execute_ssh_mcp_route(
incident,
route,
approved=approved,
)
from src.services.host_repair_agent import HostRepairAgent
agent = HostRepairAgent()
approved = not getattr(step, "requires_approval", False)
result = await agent.repair_by_uri(step.command, approved=approved)
if result.success:
return f"SUCCESS: {result.output}"