mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-23 18:29:18 -05:00
Security: Remove Docker socket mounting to eliminate CVE-2025-9074 risk
- Replace Docker socket-based MCP status monitoring with HTTP health checks - Remove Docker socket volume mount from docker-compose.yml (CVE-2025-9074 - CVSS 9.3) - Add MCPMonitoringConfig for secure-by-default HTTP mode - Make docker dependency optional in pyproject.toml - Add ENABLE_DOCKER_SOCKET_MONITORING env var for backward compatibility - Implement get_container_status_http() using httpx for health checks - Add comprehensive test suite (13 tests, 100% passing) - Update .env.example with security documentation Security Benefits: - Eliminates root-equivalent host access vulnerability - Prevents container escape attacks - Portable across Docker, Kubernetes, and bare metal - Defaults to secure mode, legacy Docker mode optional 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
23
.env.example
23
.env.example
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -3,23 +3,92 @@ 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
|
||||||
|
return {
|
||||||
|
"status": "running" if data.get("success") else "unhealthy",
|
||||||
|
"uptime": int(data.get("uptime_seconds")) if data.get("uptime_seconds") 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 +103,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 +117,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 +144,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 +183,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 +210,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 +226,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 +241,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 +257,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 +275,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")
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
)
|
||||||
|
|||||||
354
python/tests/server/api_routes/test_mcp_api.py
Normal file
354
python/tests/server/api_routes/test_mcp_api.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
"""
|
||||||
|
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_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
24
python/uv.lock
generated
@@ -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]]
|
||||||
|
|||||||
Reference in New Issue
Block a user