Initial commit: omnichannel-mcp — MCP-сервер управления платформой Omnichannel
ci / test (push) Successful in 2m22s
ci / lint (push) Successful in 4m31s

This commit is contained in:
Maksim Totmin
2026-10-07 20:13:23 +07:00
commit fdbd4a4fb6
54 changed files with 5672 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""E2E-тест деплоя omnichannel-mcp с реальным config-agent.
Поднимает контейнер config-agent, который опрашивает config_server и реально
выполняет задачи через docker-compose, затем гоняет жизненный цикл сервиса
через MCP: set_env -> set_compose -> deploy -> restart -> down.
Требуется:
- поднятый стенд: docker compose -f examples/demo/docker-compose.yml up -d
- собранный бинарник: go build -o omnichannel-mcp .
- доступ к образу config-agent (registry.devlexicom.ru).
Запуск: python3 examples/demo/e2e.py
"""
import json
import os
import subprocess
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from demo import MCP # noqa: E402 (общий минимальный MCP-клиент)
AGENT_IMAGE = "registry.devlexicom.ru/omnichannel_platform/config_server/config-agent:v1.1.0"
AGENT_NAME = "omni-e2e-agent"
NETWORK = "omni-mcp-demo_default"
OPT_DIR = "/tmp/omni-e2e/opt"
APP = "demo_e2e"
APP_DIR = os.path.join(OPT_DIR, APP)
HOST_IP = "10.0.0.10"
INITIAL_COMPOSE = """services:
web:
image: alpine:3.20
command: ["sleep", "3600"]
"""
UPDATED_COMPOSE = """services:
web:
image: alpine:3.20
command: ["sleep", "7200"]
"""
def sh(*args, check=True):
return subprocess.run(args, capture_output=True, text=True, check=check)
def docker(*args, check=True):
return sh("docker", *args, check=check)
def prepare_app_dir():
os.makedirs(APP_DIR, exist_ok=True)
with open(os.path.join(APP_DIR, "docker-compose.yml"), "w") as fh:
fh.write(INITIAL_COMPOSE)
def start_agent():
docker("rm", "-f", AGENT_NAME, check=False)
docker(
"run", "-d", "--name", AGENT_NAME,
"--network", NETWORK,
"-v", "/var/run/docker.sock:/var/run/docker.sock",
"-v", "/usr/bin/docker:/usr/bin/docker:ro",
"-v", f"{OPT_DIR}:/opt",
"-e", "SERVER_URL=http://config-server:5000",
"-e", "AGENT_SECRET=demo-agent-secret",
"-e", f"AGENT_REGISTER_IP={HOST_IP}",
"-e", "TASK_CHECK_INTERVAL=3",
"-e", "SCAN_INTERVAL=15",
AGENT_IMAGE,
)
print(f"config-agent запущен: {AGENT_NAME}")
def stop_agent():
docker("rm", "-f", AGENT_NAME, check=False)
print("config-agent остановлен")
def find_app(mcp, timeout=60):
deadline = time.time() + timeout
while time.time() < deadline:
text, _ = mcp.call("list_applications", {})
try:
apps = json.loads(text)
except ValueError:
apps = []
for app in apps:
if app.get("name") == APP:
return app["id"]
time.sleep(2)
raise RuntimeError(f"агент не зарегистрировал приложение {APP} за {timeout}s")
def wait_task(mcp, task_id, timeout=120):
deadline = time.time() + timeout
while time.time() < deadline:
text, _ = mcp.call("get_task", {"task_id": task_id})
task = json.loads(text)
if task["status"] in ("completed", "failed"):
return task
time.sleep(2)
raise RuntimeError(f"задача {task_id} не завершилась за {timeout}s")
def call_json(mcp, tool, args):
text, is_error = mcp.call(tool, args)
if is_error:
raise RuntimeError(f"{tool}: {text}")
return json.loads(text)
def container_running(app):
out = docker("ps", "--filter", f"name={app}", "--format", "{{.Names}}").stdout
return app in out
def wait_file_contains(path, needle, timeout=40):
deadline = time.time() + timeout
while time.time() < deadline:
try:
if needle in open(path).read():
return True
except OSError:
pass
time.sleep(2)
return False
CHECKS = []
def check(name, ok):
CHECKS.append((name, ok))
print(f" [{'PASS' if ok else 'FAIL'}] {name}")
def main():
mcp = MCP()
try:
app_id = find_app(mcp)
print(f"Приложение найдено: {APP} (app_id={app_id})")
# 1. Изменение конфигурации через MCP и доставка агентом.
call_json(mcp, "set_env", {"app_id": app_id, "filename": ".env", "content": "E2E=1\n"})
call_json(mcp, "set_compose", {"app_id": app_id, "content": UPDATED_COMPOSE})
check("set_env/set_compose приняты сервером", True)
check("compose доставлен агентом на диск",
wait_file_contains(os.path.join(APP_DIR, "docker-compose.yml"), "7200"))
# 2. Deploy.
ack = call_json(mcp, "deploy", {"app_id": app_id, "wait": True})
task = ack.get("task", {})
check("deploy завершён (completed)", task.get("status") == "completed")
check("контейнер demo_e2e поднят", container_running(APP))
print(f" вывод deploy: {task.get('output','')[:160].strip()}")
# 3. Restart.
ack = call_json(mcp, "restart", {"app_id": app_id, "wait": True})
check("restart завершён (completed)", ack.get("task", {}).get("status") == "completed")
check("контейнер жив после restart", container_running(APP))
# 4. Down.
ack = call_json(mcp, "down", {"app_id": app_id, "confirm": True, "wait": True})
check("down завершён (completed)", ack.get("task", {}).get("status") == "completed")
time.sleep(2)
check("контейнер остановлен", not container_running(APP))
# 5. Защита без confirm.
text, is_error = mcp.call("down", {"app_id": app_id})
check("down без confirm отклонён", is_error)
finally:
mcp.close()
stop_agent()
failed = [name for name, ok in CHECKS if not ok]
print(f"\nE2E: {len(CHECKS) - len(failed)}/{len(CHECKS)} проверок пройдено")
if failed:
print("Провалено: " + "; ".join(failed))
sys.exit(1)
if __name__ == "__main__":
prepare_app_dir()
start_agent()
main()