179 lines
6.1 KiB
Python
179 lines
6.1 KiB
Python
"""外部 MCP stack 部署前檢查 preview。
|
||
|
||
本模組只讀本機 compose 設計與環境變數狀態;不執行 docker、SSH、DB migration、
|
||
RBAC 建立或任何 MCP server health call。
|
||
"""
|
||
|
||
import os
|
||
from pathlib import Path
|
||
|
||
|
||
MCP_COMPOSE_PATH = "docker-compose.mcp.yml"
|
||
EXPECTED_MCP_SERVICES = (
|
||
"postgres-mcp",
|
||
"mcp-omnisearch",
|
||
"firecrawl-self",
|
||
"firecrawl-redis",
|
||
"firecrawl-playwright",
|
||
"chrome-reaper",
|
||
"filesystem-mcp",
|
||
)
|
||
EXPECTED_MCP_CONTAINERS = (
|
||
"momo-mcp-postgres",
|
||
"momo-mcp-omnisearch",
|
||
"momo-mcp-firecrawl",
|
||
"momo-mcp-firecrawl-redis",
|
||
"momo-mcp-firecrawl-playwright",
|
||
"momo-mcp-chrome-reaper",
|
||
"momo-mcp-filesystem",
|
||
)
|
||
EXPECTED_LOCALHOST_PORTS = ("3001", "3002", "3003", "3004")
|
||
REQUIRED_ENV_VARS = ("MCP_POSTGRES_PASSWORD", "TAVILY_API_KEY", "EXA_API_KEY")
|
||
OPTIONAL_ENV_VARS = ("FIRECRAWL_AUTH_KEY",)
|
||
|
||
|
||
def _read_compose_file(path):
|
||
compose_path = Path(path)
|
||
if not compose_path.exists():
|
||
return compose_path, ""
|
||
return compose_path, compose_path.read_text(encoding="utf-8")
|
||
|
||
|
||
def _build_env_status(env):
|
||
statuses = []
|
||
for name in REQUIRED_ENV_VARS:
|
||
statuses.append(
|
||
{
|
||
"name": name,
|
||
"required": True,
|
||
"present": bool(env.get(name)),
|
||
"value_redacted": bool(env.get(name)),
|
||
}
|
||
)
|
||
for name in OPTIONAL_ENV_VARS:
|
||
statuses.append(
|
||
{
|
||
"name": name,
|
||
"required": False,
|
||
"present": bool(env.get(name)),
|
||
"value_redacted": bool(env.get(name)),
|
||
}
|
||
)
|
||
return statuses
|
||
|
||
|
||
def build_mcp_deploy_preflight_plan(*, compose_path=MCP_COMPOSE_PATH, env=None):
|
||
"""建立外部 MCP 部署 preflight;不執行任何部署動作。"""
|
||
env = env or os.environ
|
||
compose_file, compose_text = _read_compose_file(compose_path)
|
||
service_statuses = [
|
||
{
|
||
"service": service,
|
||
"declared": f" {service}:" in compose_text,
|
||
}
|
||
for service in EXPECTED_MCP_SERVICES
|
||
]
|
||
container_statuses = [
|
||
{
|
||
"container": container,
|
||
"declared": f"container_name: {container}" in compose_text,
|
||
}
|
||
for container in EXPECTED_MCP_CONTAINERS
|
||
]
|
||
port_statuses = [
|
||
{
|
||
"port": port,
|
||
"localhost_only": f"127.0.0.1:{port}:" in compose_text,
|
||
"declared": f":{port}" in compose_text,
|
||
}
|
||
for port in EXPECTED_LOCALHOST_PORTS
|
||
]
|
||
env_statuses = _build_env_status(env)
|
||
env_required_present = all(
|
||
item["present"]
|
||
for item in env_statuses
|
||
if item["required"]
|
||
)
|
||
router_enabled = env.get("MCP_ROUTER_ENABLED", "false").strip().lower() in (
|
||
"true",
|
||
"1",
|
||
"yes",
|
||
"on",
|
||
)
|
||
checks = {
|
||
"compose_file_present": compose_file.exists(),
|
||
"all_expected_services_declared": all(item["declared"] for item in service_statuses),
|
||
"all_expected_containers_declared": all(item["declared"] for item in container_statuses),
|
||
"all_public_mcp_ports_localhost_only": all(
|
||
item["localhost_only"] for item in port_statuses
|
||
),
|
||
"required_env_vars_present": env_required_present,
|
||
"postgres_readonly_role_required": "POSTGRES_USER=mcp_readonly" in compose_text,
|
||
"postgres_allowed_tables_limited": "ALLOWED_TABLES=" in compose_text,
|
||
"filesystem_mounts_read_only": "./data:/data:ro" in compose_text and "./logs:/logs:ro" in compose_text,
|
||
"firecrawl_resource_guard_present": "memory: 2g" in compose_text and "PLAYWRIGHT_BROWSER_POOL_MAX=3" in compose_text,
|
||
"chrome_reaper_declared": "chrome-reaper:" in compose_text,
|
||
"router_flag_still_off_before_health": not router_enabled,
|
||
"no_deployment_action_executed": True,
|
||
"no_database_lifecycle_action_executed": True,
|
||
}
|
||
blocked_reasons = [
|
||
key for key, passed in checks.items()
|
||
if not passed
|
||
]
|
||
|
||
return {
|
||
"mode": "mcp_external_deploy_preflight_preview",
|
||
"compose_path": str(compose_file),
|
||
"compose_file_present": compose_file.exists(),
|
||
"expected_services": list(EXPECTED_MCP_SERVICES),
|
||
"service_statuses": service_statuses,
|
||
"container_statuses": container_statuses,
|
||
"port_statuses": port_statuses,
|
||
"env_statuses": env_statuses,
|
||
"checks": checks,
|
||
"ready_to_start_stack": not blocked_reasons,
|
||
"blocked_reasons": blocked_reasons,
|
||
"operator_command_preview": (
|
||
"docker compose -f docker-compose.mcp.yml up -d"
|
||
if compose_file.exists()
|
||
else None
|
||
),
|
||
"post_start_health_targets": [
|
||
"http://localhost:3001/health",
|
||
"http://localhost:3002/health",
|
||
"http://localhost:3003/health",
|
||
"http://localhost:3004/health",
|
||
],
|
||
"fallback_plan": [
|
||
{
|
||
"key": "keep_router_disabled",
|
||
"label": "MCP_ROUTER_ENABLED 維持 false,直到四個 health endpoint 全部 200",
|
||
},
|
||
{
|
||
"key": "stop_mcp_stack_only",
|
||
"label": "若 MCP stack 異常,只停止 docker-compose.mcp.yml 管理的 momo-mcp-* 容器",
|
||
},
|
||
{
|
||
"key": "no_momo_db_lifecycle_change",
|
||
"label": "不得 stop/rm/recreate momo-db;postgres-mcp 只能使用 mcp_readonly",
|
||
},
|
||
],
|
||
"safe_deploy_boundaries": [
|
||
"do_not_use_remove_orphans",
|
||
"do_not_touch_momo_db_container",
|
||
"do_not_enable_mcp_router_before_health_passes",
|
||
"do_not_run_external_crawlers_without_operator_approval",
|
||
],
|
||
"deployment_actions_executed": False,
|
||
"docker_command_executed": False,
|
||
"ssh_command_executed": False,
|
||
"database_session_created": False,
|
||
"database_write_executed": False,
|
||
"database_commit_executed": False,
|
||
"external_network_executed": False,
|
||
"scheduler_attached": False,
|
||
"writes_executed": False,
|
||
"would_write_database": False,
|
||
}
|