Merge pull request #834 from coleam00/security/remove-docker-socket-risk

Security: Remove Docker socket mounting to eliminate CVE-2025-9074 risk
This commit is contained in:
DIY Smart Code
2025-11-06 08:45:50 +01:00
committed by GitHub
7 changed files with 576 additions and 56 deletions

View File

@@ -80,6 +80,29 @@ SERVICE_DISCOVERY_MODE=local
STATE_STORAGE_TYPE=file STATE_STORAGE_TYPE=file
FILE_STATE_DIRECTORY=agent-work-orders-state FILE_STATE_DIRECTORY=agent-work-orders-state
# MCP Server Monitoring (Security Configuration)
# Controls how archon-server monitors MCP server status
#
# HTTP Mode (Recommended - Default):
# - Secure: No Docker socket access required
# - Portable: Works in Docker, Kubernetes, bare metal
# - Set: ENABLE_DOCKER_SOCKET_MONITORING=false (or leave unset)
#
# Docker Socket Mode (Legacy - Security Risk):
# - Requires: Docker socket mounted (root-equivalent host access)
# - Security Risk: CVE-2025-9074 demonstrates container escape vulnerabilities
# - Only use if: You specifically need Docker container uptime details
# - Set: ENABLE_DOCKER_SOCKET_MONITORING=true
# - Also requires: Uncommenting Docker socket volume in docker-compose.yml (line 36)
#
# Default: false (HTTP mode, secure)
ENABLE_DOCKER_SOCKET_MONITORING=false
# MCP Health Check Timeout (seconds)
# Timeout for HTTP health check requests to MCP server
# Default: 5
MCP_HEALTH_CHECK_TIMEOUT=5
# Frontend Configuration # Frontend Configuration
# VITE_ALLOWED_HOSTS: Comma-separated list of additional hosts allowed for Vite dev server # VITE_ALLOWED_HOSTS: Comma-separated list of additional hosts allowed for Vite dev server
# Example: VITE_ALLOWED_HOSTS=192.168.1.100,myhost.local,example.com # Example: VITE_ALLOWED_HOSTS=192.168.1.100,myhost.local,example.com

View File

@@ -33,7 +33,12 @@ services:
networks: networks:
- app-network - app-network
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock # Docker socket for MCP container control # SECURITY: Docker socket mounting removed (CVE-2025-9074 - CVSS 9.3)
# MCP status now monitored via HTTP health checks (secure, portable)
# To re-enable Docker socket mode (not recommended):
# 1. Set ENABLE_DOCKER_SOCKET_MONITORING=true in .env
# 2. Uncomment the line below
# - /var/run/docker.sock:/var/run/docker.sock # SECURITY RISK: root-equivalent host access
- ./python/src:/app/src # Mount source code for hot reload - ./python/src:/app/src # Mount source code for hot reload
- ./python/tests:/app/tests # Mount tests for UI test execution - ./python/tests:/app/tests # Mount tests for UI test execution
- ./migration:/app/migration # Mount migration files for version tracking - ./migration:/app/migration # Mount migration files for version tracking

View File

@@ -58,7 +58,9 @@ server = [
"httpx>=0.24.0", "httpx>=0.24.0",
"pydantic>=2.0.0", "pydantic>=2.0.0",
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
"docker>=6.1.0", # OPTIONAL: Docker SDK only needed for legacy Docker socket monitoring mode
# Uncomment if ENABLE_DOCKER_SOCKET_MONITORING=true (not recommended - security risk)
# "docker>=6.1.0",
"tldextract>=5.0.0", "tldextract>=5.0.0",
# Logging # Logging
"logfire>=0.30.0", "logfire>=0.30.0",
@@ -128,7 +130,9 @@ all = [
"python-jose[cryptography]>=3.3.0", "python-jose[cryptography]>=3.3.0",
"cryptography>=41.0.0", "cryptography>=41.0.0",
"slowapi>=0.1.9", "slowapi>=0.1.9",
"docker>=6.1.0", # OPTIONAL: Docker SDK only needed for legacy Docker socket monitoring mode
# Uncomment if ENABLE_DOCKER_SOCKET_MONITORING=true (not recommended - security risk)
# "docker>=6.1.0",
"tldextract>=5.0.0", "tldextract>=5.0.0",
"logfire>=0.30.0", "logfire>=0.30.0",
# MCP specific (mcp version) # MCP specific (mcp version)

View File

@@ -3,23 +3,93 @@ MCP API endpoints for Archon
Provides status and configuration endpoints for the MCP service. Provides status and configuration endpoints for the MCP service.
The MCP container is managed by docker-compose, not by this API. The MCP container is managed by docker-compose, not by this API.
Status monitoring uses HTTP health checks by default (secure, portable).
Docker socket mode available via ENABLE_DOCKER_SOCKET_MONITORING (legacy, security risk).
""" """
import os import os
from typing import Any from typing import Any
import docker import httpx
from docker.errors import NotFound
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
# Import unified logging # Import unified logging
from ..config.config import get_mcp_monitoring_config
from ..config.logfire_config import api_logger, safe_set_attribute, safe_span from ..config.logfire_config import api_logger, safe_set_attribute, safe_span
from ..config.service_discovery import get_mcp_url
router = APIRouter(prefix="/api/mcp", tags=["mcp"]) router = APIRouter(prefix="/api/mcp", tags=["mcp"])
def get_container_status() -> dict[str, Any]: async def get_container_status_http() -> dict[str, Any]:
"""Get simple MCP container status without Docker management.""" """Get MCP server status via HTTP health check endpoint.
This is the secure, recommended approach that doesn't require Docker socket.
Works across all deployment environments (Docker, Kubernetes, bare metal).
Returns:
Status dict: {"status": str, "uptime": int|None, "logs": []}
"""
config = get_mcp_monitoring_config()
mcp_url = get_mcp_url()
try:
# Use async context manager for proper connection cleanup
async with httpx.AsyncClient(timeout=config.health_check_timeout) as client:
response = await client.get(f"{mcp_url}/health")
response.raise_for_status()
# MCP health endpoint returns: {"success": bool, "uptime_seconds": int, "health": {...}}
data = response.json()
# Transform to expected API contract
uptime_value = data.get("uptime_seconds")
return {
"status": "running" if data.get("success") else "unhealthy",
"uptime": int(uptime_value) if uptime_value is not None else None,
"logs": [], # Historical artifact, kept for API compatibility
}
except httpx.ConnectError:
# MCP container not running or unreachable
api_logger.warning("MCP server unreachable via HTTP health check")
return {
"status": "unreachable",
"uptime": None,
"logs": [],
}
except httpx.TimeoutException:
# MCP responding too slowly
api_logger.warning(f"MCP server health check timed out after {config.health_check_timeout}s")
return {
"status": "unhealthy",
"uptime": None,
"logs": [],
}
except Exception:
# Unexpected error
api_logger.error("Failed to check MCP server health via HTTP", exc_info=True)
return {
"status": "error",
"uptime": None,
"logs": [],
}
def get_container_status_docker() -> dict[str, Any]:
"""Get MCP container status via Docker socket (legacy mode).
SECURITY WARNING: Requires Docker socket mounted, granting root-equivalent host access.
Only enable this mode if you specifically need Docker container status details.
Set ENABLE_DOCKER_SOCKET_MONITORING=true to use this mode.
Returns:
Status dict: {"status": str, "uptime": int|None, "logs": []}
"""
import docker
from docker.errors import NotFound
docker_client = None docker_client = None
try: try:
docker_client = docker.from_env() docker_client = docker.from_env()
@@ -34,6 +104,7 @@ def get_container_status() -> dict[str, Any]:
# Try to get uptime from container info # Try to get uptime from container info
try: try:
from datetime import datetime from datetime import datetime
started_at = container.attrs["State"]["StartedAt"] started_at = container.attrs["State"]["StartedAt"]
started_time = datetime.fromisoformat(started_at.replace("Z", "+00:00")) started_time = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
uptime = int((datetime.now(started_time.tzinfo) - started_time).total_seconds()) uptime = int((datetime.now(started_time.tzinfo) - started_time).total_seconds())
@@ -47,27 +118,26 @@ def get_container_status() -> dict[str, Any]:
"status": status, "status": status,
"uptime": uptime, "uptime": uptime,
"logs": [], # No log streaming anymore "logs": [], # No log streaming anymore
"container_status": container_status
} }
except NotFound: except NotFound:
api_logger.warning("MCP container not found via Docker socket")
return { return {
"status": "not_found", "status": "not_found",
"uptime": None, "uptime": None,
"logs": [], "logs": [],
"container_status": "not_found", "message": "MCP container not found. Run: docker compose up -d archon-mcp",
"message": "MCP container not found. Run: docker compose up -d archon-mcp"
} }
except Exception as e: except Exception as e:
api_logger.error("Failed to get container status", exc_info=True) api_logger.error("Failed to get MCP container status via Docker", exc_info=True)
return { return {
"status": "error", "status": "error",
"uptime": None, "uptime": None,
"logs": [], "logs": [],
"container_status": "error", "error": str(e),
"error": str(e)
} }
finally: finally:
# CRITICAL: Always close Docker client to prevent connection leaks
if docker_client is not None: if docker_client is not None:
try: try:
docker_client.close() docker_client.close()
@@ -75,15 +145,38 @@ def get_container_status() -> dict[str, Any]:
pass pass
async def get_container_status() -> dict[str, Any]:
"""Get MCP server status using configured monitoring strategy.
Routes to HTTP health check (secure, default) or Docker socket (legacy).
Returns:
Status dict: {"status": str, "uptime": int|None, "logs": []}
"""
config = get_mcp_monitoring_config()
if config.enable_docker_socket:
api_logger.info("Using Docker socket monitoring (ENABLE_DOCKER_SOCKET_MONITORING=true)")
# Docker mode is synchronous
return get_container_status_docker()
else:
# HTTP mode is asynchronous (default)
return await get_container_status_http()
@router.get("/status") @router.get("/status")
async def get_status(): async def get_status():
"""Get MCP server status.""" """Get MCP server status.
Returns container/server status, uptime, and logs (empty).
Monitoring strategy controlled by ENABLE_DOCKER_SOCKET_MONITORING env var.
"""
with safe_span("api_mcp_status") as span: with safe_span("api_mcp_status") as span:
safe_set_attribute(span, "endpoint", "/api/mcp/status") safe_set_attribute(span, "endpoint", "/api/mcp/status")
safe_set_attribute(span, "method", "GET") safe_set_attribute(span, "method", "GET")
try: try:
status = get_container_status() status = await get_container_status()
api_logger.debug(f"MCP server status checked - status={status.get('status')}") api_logger.debug(f"MCP server status checked - status={status.get('status')}")
safe_set_attribute(span, "status", status.get("status")) safe_set_attribute(span, "status", status.get("status"))
safe_set_attribute(span, "uptime", status.get("uptime")) safe_set_attribute(span, "uptime", status.get("uptime"))
@@ -91,7 +184,7 @@ async def get_status():
except Exception as e: except Exception as e:
api_logger.error(f"MCP server status API failed - error={str(e)}") api_logger.error(f"MCP server status API failed - error={str(e)}")
safe_set_attribute(span, "error", str(e)) safe_set_attribute(span, "error", str(e))
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e)) from e
@router.get("/config") @router.get("/config")
@@ -118,9 +211,7 @@ async def get_mcp_config():
try: try:
from ..services.credential_service import credential_service from ..services.credential_service import credential_service
model_choice = await credential_service.get_credential( model_choice = await credential_service.get_credential("MODEL_CHOICE", "gpt-4o-mini")
"MODEL_CHOICE", "gpt-4o-mini"
)
config["model_choice"] = model_choice config["model_choice"] = model_choice
except Exception: except Exception:
# Fallback to default model # Fallback to default model
@@ -136,7 +227,7 @@ async def get_mcp_config():
except Exception as e: except Exception as e:
api_logger.error("Failed to get MCP configuration", exc_info=True) api_logger.error("Failed to get MCP configuration", exc_info=True)
safe_set_attribute(span, "error", str(e)) safe_set_attribute(span, "error", str(e))
raise HTTPException(status_code=500, detail={"error": str(e)}) raise HTTPException(status_code=500, detail={"error": str(e)}) from e
@router.get("/clients") @router.get("/clients")
@@ -151,18 +242,11 @@ async def get_mcp_clients():
# For now, return empty array as expected by frontend # For now, return empty array as expected by frontend
api_logger.debug("Getting MCP clients - returning empty array") api_logger.debug("Getting MCP clients - returning empty array")
return { return {"clients": [], "total": 0}
"clients": [],
"total": 0
}
except Exception as e: except Exception as e:
api_logger.error(f"Failed to get MCP clients - error={str(e)}") api_logger.error(f"Failed to get MCP clients - error={str(e)}")
safe_set_attribute(span, "error", str(e)) safe_set_attribute(span, "error", str(e))
return { return {"clients": [], "total": 0, "error": str(e)}
"clients": [],
"total": 0,
"error": str(e)
}
@router.get("/sessions") @router.get("/sessions")
@@ -174,7 +258,7 @@ async def get_mcp_sessions():
try: try:
# Basic session info for now # Basic session info for now
status = get_container_status() status = await get_container_status()
session_info = { session_info = {
"active_sessions": 0, # TODO: Implement real session tracking "active_sessions": 0, # TODO: Implement real session tracking
@@ -192,7 +276,7 @@ async def get_mcp_sessions():
except Exception as e: except Exception as e:
api_logger.error(f"Failed to get MCP sessions - error={str(e)}") api_logger.error(f"Failed to get MCP sessions - error={str(e)}")
safe_set_attribute(span, "error", str(e)) safe_set_attribute(span, "error", str(e))
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e)) from e
@router.get("/health") @router.get("/health")

View File

@@ -38,6 +38,23 @@ class RAGStrategyConfig:
use_reranking: bool = True use_reranking: bool = True
@dataclass
class MCPMonitoringConfig:
"""Configuration for MCP server monitoring strategy.
Controls how archon-server monitors MCP server status - via HTTP health checks
(secure, default) or Docker socket (legacy, security risk).
Attributes:
enable_docker_socket: Whether to use Docker socket for container status.
Default False for security (uses HTTP health checks).
health_check_timeout: Timeout in seconds for HTTP health check requests.
"""
enable_docker_socket: bool = False
health_check_timeout: int = 5
def validate_openai_api_key(api_key: str) -> bool: def validate_openai_api_key(api_key: str) -> bool:
"""Validate OpenAI API key format.""" """Validate OpenAI API key format."""
if not api_key: if not api_key:
@@ -68,14 +85,14 @@ def validate_supabase_key(supabase_key: str) -> tuple[bool, str]:
# Also skip all other validations (aud, exp, etc) since we only care about the role # Also skip all other validations (aud, exp, etc) since we only care about the role
decoded = jwt.decode( decoded = jwt.decode(
supabase_key, supabase_key,
'', "",
options={ options={
"verify_signature": False, "verify_signature": False,
"verify_aud": False, "verify_aud": False,
"verify_exp": False, "verify_exp": False,
"verify_nbf": False, "verify_nbf": False,
"verify_iat": False "verify_iat": False,
} },
) )
role = decoded.get("role") role = decoded.get("role")
@@ -232,3 +249,29 @@ def get_rag_strategy_config() -> RAGStrategyConfig:
use_agentic_rag=str_to_bool(os.getenv("USE_AGENTIC_RAG")), use_agentic_rag=str_to_bool(os.getenv("USE_AGENTIC_RAG")),
use_reranking=str_to_bool(os.getenv("USE_RERANKING")), use_reranking=str_to_bool(os.getenv("USE_RERANKING")),
) )
def get_mcp_monitoring_config() -> MCPMonitoringConfig:
"""Load MCP monitoring configuration from environment variables.
Environment Variables:
ENABLE_DOCKER_SOCKET_MONITORING: "true"/"false" (default: false)
Controls whether to use Docker socket for status monitoring.
Default is false for security (uses HTTP health checks instead).
MCP_HEALTH_CHECK_TIMEOUT: Timeout in seconds (default: 5)
Timeout for HTTP health check requests to MCP server.
Returns:
MCPMonitoringConfig with parsed settings.
"""
def str_to_bool(value: str | None) -> bool:
"""Convert string environment variable to boolean."""
if value is None:
return False
return value.lower() in ("true", "1", "yes", "on")
return MCPMonitoringConfig(
enable_docker_socket=str_to_bool(os.getenv("ENABLE_DOCKER_SOCKET_MONITORING")),
health_check_timeout=int(os.getenv("MCP_HEALTH_CHECK_TIMEOUT", "5")),
)

View File

@@ -0,0 +1,381 @@
"""
Tests for MCP API endpoints with HTTP and Docker socket modes.
"""
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from src.server.api_routes.mcp_api import (
get_container_status,
get_container_status_docker,
get_container_status_http,
)
from src.server.config.config import MCPMonitoringConfig
@pytest.fixture
def mock_mcp_url():
"""Mock MCP URL for testing."""
return "http://test-mcp:8051"
@pytest.fixture
def mock_config_http():
"""Mock configuration with HTTP mode enabled."""
return MCPMonitoringConfig(enable_docker_socket=False, health_check_timeout=5)
@pytest.fixture
def mock_config_docker():
"""Mock configuration with Docker socket mode enabled."""
return MCPMonitoringConfig(enable_docker_socket=True, health_check_timeout=5)
# HTTP Mode Tests
@pytest.mark.asyncio
async def test_get_container_status_http_running(mock_mcp_url):
"""Test HTTP health check when MCP server is running."""
mock_response = MagicMock()
mock_response.json.return_value = {"success": True, "uptime_seconds": 123.45, "health": {}}
mock_response.status_code = 200
with (
patch("src.server.api_routes.mcp_api.get_mcp_url", return_value=mock_mcp_url),
patch("src.server.api_routes.mcp_api.get_mcp_monitoring_config") as mock_get_config,
patch("httpx.AsyncClient") as mock_client_class,
):
mock_get_config.return_value = MCPMonitoringConfig(enable_docker_socket=False, health_check_timeout=5)
# Create mock async context manager
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await get_container_status_http()
assert result["status"] == "running"
assert result["uptime"] == 123
assert result["logs"] == []
mock_client.get.assert_called_once_with(f"{mock_mcp_url}/health")
@pytest.mark.asyncio
async def test_get_container_status_http_unreachable(mock_mcp_url):
"""Test HTTP health check when MCP server is unreachable."""
with (
patch("src.server.api_routes.mcp_api.get_mcp_url", return_value=mock_mcp_url),
patch("src.server.api_routes.mcp_api.get_mcp_monitoring_config") as mock_get_config,
patch("httpx.AsyncClient") as mock_client_class,
):
mock_get_config.return_value = MCPMonitoringConfig(enable_docker_socket=False, health_check_timeout=5)
# Mock connection error
mock_client = MagicMock()
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await get_container_status_http()
assert result["status"] == "unreachable"
assert result["uptime"] is None
assert result["logs"] == []
@pytest.mark.asyncio
async def test_get_container_status_http_timeout(mock_mcp_url):
"""Test HTTP health check when MCP server times out."""
with (
patch("src.server.api_routes.mcp_api.get_mcp_url", return_value=mock_mcp_url),
patch("src.server.api_routes.mcp_api.get_mcp_monitoring_config") as mock_get_config,
patch("httpx.AsyncClient") as mock_client_class,
):
mock_get_config.return_value = MCPMonitoringConfig(enable_docker_socket=False, health_check_timeout=5)
# Mock timeout error
mock_client = MagicMock()
mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("Timeout"))
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await get_container_status_http()
assert result["status"] == "unhealthy"
assert result["uptime"] is None
assert result["logs"] == []
@pytest.mark.asyncio
async def test_get_container_status_http_unhealthy(mock_mcp_url):
"""Test HTTP health check when MCP server reports unhealthy."""
mock_response = MagicMock()
mock_response.json.return_value = {"success": False, "error": "Service unavailable"}
mock_response.status_code = 200
with (
patch("src.server.api_routes.mcp_api.get_mcp_url", return_value=mock_mcp_url),
patch("src.server.api_routes.mcp_api.get_mcp_monitoring_config") as mock_get_config,
patch("httpx.AsyncClient") as mock_client_class,
):
mock_get_config.return_value = MCPMonitoringConfig(enable_docker_socket=False, health_check_timeout=5)
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await get_container_status_http()
assert result["status"] == "unhealthy"
assert result["uptime"] is None
assert result["logs"] == []
@pytest.mark.asyncio
async def test_get_container_status_http_zero_uptime(mock_mcp_url):
"""Test HTTP health check preserves 0 uptime for freshly-launched MCP."""
mock_response = MagicMock()
mock_response.json.return_value = {"success": True, "uptime_seconds": 0, "health": {}}
mock_response.status_code = 200
with (
patch("src.server.api_routes.mcp_api.get_mcp_url", return_value=mock_mcp_url),
patch("src.server.api_routes.mcp_api.get_mcp_monitoring_config") as mock_get_config,
patch("httpx.AsyncClient") as mock_client_class,
):
mock_get_config.return_value = MCPMonitoringConfig(enable_docker_socket=False, health_check_timeout=5)
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await get_container_status_http()
assert result["status"] == "running"
assert result["uptime"] == 0 # Important: 0 should be preserved, not None
assert result["logs"] == []
mock_client.get.assert_called_once_with(f"{mock_mcp_url}/health")
@pytest.mark.asyncio
async def test_get_container_status_http_error(mock_mcp_url):
"""Test HTTP health check when an unexpected error occurs."""
with (
patch("src.server.api_routes.mcp_api.get_mcp_url", return_value=mock_mcp_url),
patch("src.server.api_routes.mcp_api.get_mcp_monitoring_config") as mock_get_config,
patch("httpx.AsyncClient") as mock_client_class,
):
mock_get_config.return_value = MCPMonitoringConfig(enable_docker_socket=False, health_check_timeout=5)
# Mock unexpected error
mock_client = MagicMock()
mock_client.get = AsyncMock(side_effect=Exception("Unexpected error"))
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await get_container_status_http()
assert result["status"] == "error"
assert result["uptime"] is None
assert result["logs"] == []
# Docker Mode Tests
def test_get_container_status_docker_running():
"""Test Docker socket check when container is running."""
mock_container = MagicMock()
mock_container.status = "running"
mock_container.attrs = {
"State": {"StartedAt": "2025-01-01T00:00:00.000000000Z"},
}
mock_docker_client = MagicMock()
mock_docker_client.containers.get.return_value = mock_container
# Create mock docker module with errors submodule
mock_docker = MagicMock()
mock_docker.from_env.return_value = mock_docker_client
mock_docker_errors = MagicMock()
mock_docker_errors.NotFound = type("NotFound", (Exception,), {})
with patch.dict("sys.modules", {"docker": mock_docker, "docker.errors": mock_docker_errors}):
result = get_container_status_docker()
assert result["status"] == "running"
assert result["uptime"] is not None # Uptime should be calculated
assert result["logs"] == []
mock_docker_client.containers.get.assert_called_once_with("archon-mcp")
mock_docker_client.close.assert_called_once()
def test_get_container_status_docker_stopped():
"""Test Docker socket check when container is stopped."""
mock_container = MagicMock()
mock_container.status = "exited"
mock_docker_client = MagicMock()
mock_docker_client.containers.get.return_value = mock_container
# Create mock docker module with errors submodule
mock_docker = MagicMock()
mock_docker.from_env.return_value = mock_docker_client
mock_docker_errors = MagicMock()
mock_docker_errors.NotFound = type("NotFound", (Exception,), {})
with patch.dict("sys.modules", {"docker": mock_docker, "docker.errors": mock_docker_errors}):
result = get_container_status_docker()
assert result["status"] == "stopped"
assert result["uptime"] is None
assert result["logs"] == []
mock_docker_client.close.assert_called_once()
def test_get_container_status_docker_not_found():
"""Test Docker socket check when container is not found."""
# Create a mock NotFound exception
mock_not_found = type("NotFound", (Exception,), {})
mock_docker_client = MagicMock()
mock_docker_client.containers.get.side_effect = mock_not_found("Container not found")
mock_docker = MagicMock()
mock_docker.from_env.return_value = mock_docker_client
mock_docker.errors = MagicMock()
mock_docker.errors.NotFound = mock_not_found
with patch.dict("sys.modules", {"docker": mock_docker, "docker.errors": mock_docker.errors}):
result = get_container_status_docker()
assert result["status"] == "not_found"
assert result["uptime"] is None
assert result["logs"] == []
assert "message" in result
mock_docker_client.close.assert_called_once()
def test_get_container_status_docker_error():
"""Test Docker socket check when an error occurs."""
mock_docker_client = MagicMock()
mock_docker_client.containers.get.side_effect = Exception("Docker error")
# Create mock docker module with errors submodule
mock_docker = MagicMock()
mock_docker.from_env.return_value = mock_docker_client
mock_docker_errors = MagicMock()
mock_docker_errors.NotFound = type("NotFound", (Exception,), {})
with patch.dict("sys.modules", {"docker": mock_docker, "docker.errors": mock_docker_errors}):
result = get_container_status_docker()
assert result["status"] == "error"
assert result["uptime"] is None
assert result["logs"] == []
assert "error" in result
mock_docker_client.close.assert_called_once()
# Routing Tests
@pytest.mark.asyncio
async def test_get_container_status_routes_to_http(mock_mcp_url):
"""Test that get_container_status routes to HTTP mode by default."""
mock_response = MagicMock()
mock_response.json.return_value = {"success": True, "uptime_seconds": 100, "health": {}}
mock_response.status_code = 200
with (
patch("src.server.api_routes.mcp_api.get_mcp_url", return_value=mock_mcp_url),
patch("src.server.api_routes.mcp_api.get_mcp_monitoring_config") as mock_get_config,
patch("httpx.AsyncClient") as mock_client_class,
):
mock_get_config.return_value = MCPMonitoringConfig(enable_docker_socket=False, health_check_timeout=5)
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await get_container_status()
assert result["status"] == "running"
assert result["uptime"] == 100
mock_client.get.assert_called_once()
@pytest.mark.asyncio
async def test_get_container_status_routes_to_docker():
"""Test that get_container_status routes to Docker mode when enabled."""
mock_container = MagicMock()
mock_container.status = "running"
mock_container.attrs = {
"State": {"StartedAt": "2025-01-01T00:00:00.000000000Z"},
}
mock_docker_client = MagicMock()
mock_docker_client.containers.get.return_value = mock_container
# Create mock docker module with errors submodule
mock_docker = MagicMock()
mock_docker.from_env.return_value = mock_docker_client
mock_docker_errors = MagicMock()
mock_docker_errors.NotFound = type("NotFound", (Exception,), {})
with (
patch("src.server.api_routes.mcp_api.get_mcp_monitoring_config") as mock_get_config,
patch.dict("sys.modules", {"docker": mock_docker, "docker.errors": mock_docker_errors}),
):
mock_get_config.return_value = MCPMonitoringConfig(enable_docker_socket=True, health_check_timeout=5)
result = await get_container_status()
assert result["status"] == "running"
mock_docker_client.containers.get.assert_called_once_with("archon-mcp")
mock_docker_client.close.assert_called_once()
# Environment Variable Tests
@pytest.mark.asyncio
async def test_config_defaults_to_http_mode():
"""Test that configuration defaults to secure HTTP mode."""
# Clear any environment variables
os.environ.pop("ENABLE_DOCKER_SOCKET_MONITORING", None)
os.environ.pop("MCP_HEALTH_CHECK_TIMEOUT", None)
from src.server.config.config import get_mcp_monitoring_config
config = get_mcp_monitoring_config()
assert config.enable_docker_socket is False
assert config.health_check_timeout == 5
@pytest.mark.asyncio
async def test_config_respects_environment_variables():
"""Test that configuration respects environment variables."""
os.environ["ENABLE_DOCKER_SOCKET_MONITORING"] = "true"
os.environ["MCP_HEALTH_CHECK_TIMEOUT"] = "10"
from src.server.config.config import get_mcp_monitoring_config
config = get_mcp_monitoring_config()
assert config.enable_docker_socket is True
assert config.health_check_timeout == 10
# Cleanup
os.environ.pop("ENABLE_DOCKER_SOCKET_MONITORING", None)
os.environ.pop("MCP_HEALTH_CHECK_TIMEOUT", None)

24
python/uv.lock generated
View File

@@ -188,7 +188,6 @@ all = [
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "crawl4ai" }, { name = "crawl4ai" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "docker" },
{ name = "factory-boy" }, { name = "factory-boy" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
@@ -213,6 +212,7 @@ all = [
{ name = "sse-starlette" }, { name = "sse-starlette" },
{ name = "structlog" }, { name = "structlog" },
{ name = "supabase" }, { name = "supabase" },
{ name = "tldextract" },
{ name = "uvicorn" }, { name = "uvicorn" },
{ name = "watchfiles" }, { name = "watchfiles" },
] ]
@@ -240,7 +240,6 @@ server = [
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "crawl4ai" }, { name = "crawl4ai" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "docker" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "logfire" }, { name = "logfire" },
@@ -295,7 +294,6 @@ all = [
{ name = "asyncpg", specifier = ">=0.29.0" }, { name = "asyncpg", specifier = ">=0.29.0" },
{ name = "crawl4ai", specifier = "==0.7.4" }, { name = "crawl4ai", specifier = "==0.7.4" },
{ name = "cryptography", specifier = ">=41.0.0" }, { name = "cryptography", specifier = ">=41.0.0" },
{ name = "docker", specifier = ">=6.1.0" },
{ name = "factory-boy", specifier = ">=3.3.0" }, { name = "factory-boy", specifier = ">=3.3.0" },
{ name = "fastapi", specifier = ">=0.104.0" }, { name = "fastapi", specifier = ">=0.104.0" },
{ name = "httpx", specifier = ">=0.24.0" }, { name = "httpx", specifier = ">=0.24.0" },
@@ -320,6 +318,7 @@ all = [
{ name = "sse-starlette", specifier = ">=2.3.3" }, { name = "sse-starlette", specifier = ">=2.3.3" },
{ name = "structlog", specifier = ">=23.1.0" }, { name = "structlog", specifier = ">=23.1.0" },
{ name = "supabase", specifier = "==2.15.1" }, { name = "supabase", specifier = "==2.15.1" },
{ name = "tldextract", specifier = ">=5.0.0" },
{ name = "uvicorn", specifier = ">=0.24.0" }, { name = "uvicorn", specifier = ">=0.24.0" },
{ name = "watchfiles", specifier = ">=0.18" }, { name = "watchfiles", specifier = ">=0.18" },
] ]
@@ -347,7 +346,6 @@ server = [
{ name = "asyncpg", specifier = ">=0.29.0" }, { name = "asyncpg", specifier = ">=0.29.0" },
{ name = "crawl4ai", specifier = "==0.7.4" }, { name = "crawl4ai", specifier = "==0.7.4" },
{ name = "cryptography", specifier = ">=41.0.0" }, { name = "cryptography", specifier = ">=41.0.0" },
{ name = "docker", specifier = ">=6.1.0" },
{ name = "fastapi", specifier = ">=0.104.0" }, { name = "fastapi", specifier = ">=0.104.0" },
{ name = "httpx", specifier = ">=0.24.0" }, { name = "httpx", specifier = ">=0.24.0" },
{ name = "logfire", specifier = ">=0.30.0" }, { name = "logfire", specifier = ">=0.30.0" },
@@ -819,20 +817,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 },
] ]
[[package]]
name = "docker"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "requests" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774 },
]
[[package]] [[package]]
name = "ecdsa" name = "ecdsa"
version = "0.19.1" version = "0.19.1"
@@ -2263,10 +2247,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183 }, { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183 },
{ url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542 }, { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542 },
{ url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897 }, { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897 },
{ url = "https://files.pythonhosted.org/packages/c4/48/ae937e5a831b7c0dc646b2ef788c27cd003894882415300ed21927c21efa/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537", size = 2112087 },
{ url = "https://files.pythonhosted.org/packages/5e/db/6db8073e3d32dae017da7e0d16a9ecb897d0a4d92e00634916e486097961/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94", size = 1920387 },
{ url = "https://files.pythonhosted.org/packages/0d/c1/dd3542d072fcc336030d66834872f0328727e3b8de289c662faa04aa270e/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c", size = 1951495 },
{ url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008 },
] ]
[[package]] [[package]]