Initial commit: omnichannel-mcp — MCP-сервер управления платформой Omnichannel
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user