Initial commit: omnichannel-mcp — MCP-сервер управления платформой Omnichannel
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
# Демо и e2e
|
||||
|
||||
Два сценария для локальной проверки без прода.
|
||||
|
||||
## 1. Сквозной пример через MCP (`demo.py`)
|
||||
|
||||
Поднимает только `config_server`; задачи создаются, но их никто не исполняет —
|
||||
показывает вызовы инструментов и ответы.
|
||||
|
||||
```bash
|
||||
docker compose -f examples/demo/docker-compose.yml up -d
|
||||
go build -o omnichannel-mcp .
|
||||
python3 examples/demo/demo.py
|
||||
```
|
||||
|
||||
## 2. Полный e2e деплоя с агентом (`e2e.py`)
|
||||
|
||||
Дополнительно запускает настоящий **config-agent**, который опрашивает
|
||||
`config_server` и реально выполняет задачи через `docker-compose`. Проверяется
|
||||
жизненный цикл: `set_env` → `set_compose` → `deploy` → `restart` → `down`, а
|
||||
также защита (`down` без `confirm` отклоняется).
|
||||
|
||||
```bash
|
||||
docker compose -f examples/demo/docker-compose.yml up -d
|
||||
go build -o omnichannel-mcp .
|
||||
python3 examples/demo/e2e.py
|
||||
```
|
||||
|
||||
Что делает `e2e.py`:
|
||||
|
||||
1. создаёт каталог `/tmp/omni-e2e/opt/demo_e2e` с `docker-compose.yml`;
|
||||
2. поднимает контейнер `omni-e2e-agent` (образ `config-agent:v1.1.0`) с
|
||||
проброшенным `docker.sock` и `/opt`;
|
||||
3. ждёт регистрации приложения агентом;
|
||||
4. гоняет жизненный цикл через MCP и проверяет состояние контейнеров;
|
||||
5. удаляет тестовый агент.
|
||||
|
||||
Требуется доступ к `registry.devlexicom.ru` (образ агента).
|
||||
|
||||
## Очистка
|
||||
|
||||
```bash
|
||||
docker compose -f examples/demo/docker-compose.yml down -v
|
||||
docker rm -f omni-e2e-agent 2>/dev/null; docker network rm demo_e2e_default 2>/dev/null
|
||||
rm -rf /tmp/omni-e2e
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"servers": [
|
||||
{
|
||||
"alias": "demo",
|
||||
"base_url": "http://127.0.0.1:5005",
|
||||
"username": "admin",
|
||||
"password": "LexP@ssw0rd_1"
|
||||
}
|
||||
],
|
||||
"default": "demo",
|
||||
"read_only": false,
|
||||
"allow_hosts": ["127.0.0.1"]
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Сквозной демо-пример использования omnichannel-mcp.
|
||||
|
||||
Что делает:
|
||||
1) засевает демо-сервис в локальный config_server (агентский эндпоинт);
|
||||
2) запускает omnichannel-mcp по stdio;
|
||||
3) выполняет типичный сценарий эксплуатации/деплоя:
|
||||
server_info -> list_applications -> get_application -> set_env ->
|
||||
set_compose -> deploy -> get_task -> overview.
|
||||
|
||||
Зависимостей нет (только stdlib). Требуется поднятый стенд:
|
||||
docker compose -f examples/demo/docker-compose.yml up -d
|
||||
и собранный бинарник:
|
||||
go build -o omnichannel-mcp .
|
||||
|
||||
Запуск: python3 examples/demo/demo.py
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(os.path.dirname(HERE)) # корень репозитория
|
||||
BINARY = os.path.join(ROOT, "omnichannel-mcp")
|
||||
CONFIG = os.path.join(HERE, "config.demo.json")
|
||||
|
||||
BASE_URL = "http://127.0.0.1:5005"
|
||||
AGENT_SECRET = "demo-agent-secret"
|
||||
DEMO_APP_ID = 1
|
||||
|
||||
|
||||
def http_json(method, path, payload=None):
|
||||
data = json.dumps(payload).encode() if payload is not None else None
|
||||
req = urllib.request.Request(BASE_URL + path, data=data, method=method,
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def seed_demo_app():
|
||||
"""Регистрирует демо-сервис так, как это делает агент ноды."""
|
||||
body = {
|
||||
"secret": AGENT_SECRET,
|
||||
"name": "demo_web",
|
||||
"host_ip": "10.0.0.10",
|
||||
"hostname": "demo-node",
|
||||
"path": "/opt/omnichannel/demo_web",
|
||||
"status": "OK",
|
||||
"compose_content": "services:\n web:\n image: nginx:1.25\n",
|
||||
"env_files": {".env": "FOO=bar\n"},
|
||||
}
|
||||
result = http_json("POST", "/api/register", body)
|
||||
print(f"засеян демо-сервис demo_web: app_id={result.get('app_id')}")
|
||||
|
||||
|
||||
class MCP:
|
||||
"""Минимальный MCP-клиент по stdio (JSON-RPC, newline-delimited)."""
|
||||
|
||||
def __init__(self):
|
||||
env = dict(os.environ, OMNI_LOG_LEVEL="warn")
|
||||
self.proc = subprocess.Popen(
|
||||
[BINARY, "-config", CONFIG], cwd=ROOT, env=env,
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, bufsize=1,
|
||||
)
|
||||
self.id = 0
|
||||
self._request("initialize", {
|
||||
"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"clientInfo": {"name": "demo", "version": "0"},
|
||||
})
|
||||
self._notify("notifications/initialized", {})
|
||||
|
||||
def _id(self):
|
||||
self.id += 1
|
||||
return self.id
|
||||
|
||||
def _send(self, obj):
|
||||
self.proc.stdin.write(json.dumps(obj) + "\n")
|
||||
self.proc.stdin.flush()
|
||||
|
||||
def _recv(self, want_id, timeout=30):
|
||||
while True:
|
||||
ready, _, _ = select.select([self.proc.stdout], [], [], timeout)
|
||||
if not ready:
|
||||
raise TimeoutError(f"нет ответа на id={want_id}")
|
||||
line = self.proc.stdout.readline()
|
||||
if not line:
|
||||
raise RuntimeError("сервер закрыл stdout:\n" + self.proc.stderr.read())
|
||||
msg = json.loads(line)
|
||||
if msg.get("id") == want_id:
|
||||
return msg
|
||||
|
||||
def _request(self, method, params):
|
||||
rid = self._id()
|
||||
self._send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
|
||||
return self._recv(rid)
|
||||
|
||||
def _notify(self, method, params):
|
||||
self._send({"jsonrpc": "2.0", "method": method, "params": params})
|
||||
|
||||
def tools(self):
|
||||
return self._request("tools/list", {})["result"]["tools"]
|
||||
|
||||
def call(self, name, arguments):
|
||||
resp = self._request("tools/call", {"name": name, "arguments": arguments})
|
||||
result = resp.get("result", {})
|
||||
text = "".join(c.get("text", "") for c in result.get("content", []))
|
||||
return text, result.get("isError", False)
|
||||
|
||||
def close(self):
|
||||
self.proc.stdin.close()
|
||||
self.proc.terminate()
|
||||
|
||||
|
||||
def show(title, name, arguments, mcp):
|
||||
text, is_error = mcp.call(name, arguments)
|
||||
print(f"\n=== {title} ({'ошибка' if is_error else 'ok'}) ===")
|
||||
print(text[:600])
|
||||
|
||||
|
||||
def main():
|
||||
seed_demo_app()
|
||||
|
||||
mcp = MCP()
|
||||
tools = mcp.tools()
|
||||
print(f"\nMCP-сервер отдал инструментов: {len(tools)}")
|
||||
|
||||
show("Версия API и режим", "server_info", {}, mcp)
|
||||
show("Список сервисов", "list_applications", {}, mcp)
|
||||
show("Детали демо-сервиса", "get_application", {"app_id": DEMO_APP_ID}, mcp)
|
||||
|
||||
show("Обновляем env", "set_env",
|
||||
{"app_id": DEMO_APP_ID, "filename": ".env", "content": "FOO=baz\n"}, mcp)
|
||||
show("Обновляем compose", "set_compose",
|
||||
{"app_id": DEMO_APP_ID, "content": "services:\n web:\n image: nginx:1.27\n"}, mcp)
|
||||
|
||||
deploy_text, _ = mcp.call("deploy", {"app_id": DEMO_APP_ID})
|
||||
print("\n=== Запуск сервиса (deploy) ===")
|
||||
print(deploy_text[:400])
|
||||
|
||||
try:
|
||||
task_id = json.loads(deploy_text)["task_id"]
|
||||
show("Статус задачи деплоя", "get_task", {"task_id": task_id}, mcp)
|
||||
except (ValueError, KeyError):
|
||||
print("(не удалось извлечь task_id — пропускаем get_task)")
|
||||
|
||||
show("Сводка состояния стенда", "overview", {}, mcp)
|
||||
|
||||
mcp.close()
|
||||
print("\nDemo завершён.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not os.path.exists(BINARY):
|
||||
sys.exit(f"нет бинарника {BINARY}: сначала выполните `go build -o omnichannel-mcp .`")
|
||||
main()
|
||||
@@ -0,0 +1,55 @@
|
||||
# Демонстрационный стенд config_server для локальной проверки omnichannel-mcp.
|
||||
#
|
||||
# Поднимает config_server v1.1.0 + PostgreSQL. Данные — эфемерные.
|
||||
#
|
||||
# docker compose -f examples/demo/docker-compose.yml up -d
|
||||
# curl -s http://localhost:5005/health # -> OK
|
||||
# python3 examples/demo/demo.py # сквозной пример деплоя через MCP
|
||||
# docker compose -f examples/demo/docker-compose.yml down -v # удалить и данные
|
||||
#
|
||||
# init-db.sh — схема БД и учётная запись admin/LexP@ssw0rd_1 (из поставки
|
||||
# config_server). Для демо этого достаточно.
|
||||
|
||||
name: omni-mcp-demo
|
||||
|
||||
services:
|
||||
db:
|
||||
image: registry.devlexicom.ru/templates/docker/docker_images/postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: config_server
|
||||
POSTGRES_USER: config_user
|
||||
POSTGRES_PASSWORD: 8fj439fjdk48
|
||||
TZ: Europe/Moscow
|
||||
PGTZ: Europe/Moscow
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
- ./init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U config_user -d config_server"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
config-server:
|
||||
image: registry.devlexicom.ru/omnichannel_platform/config_server/config-server:v1.1.0
|
||||
ports:
|
||||
- "5005:5000"
|
||||
environment:
|
||||
POSTGRES_DB: config_server
|
||||
POSTGRES_USER: config_user
|
||||
POSTGRES_PASSWORD: 8fj439fjdk48
|
||||
POSTGRES_HOST: db
|
||||
POSTGRES_PORT: "5432"
|
||||
SECRET_KEY: demo-secret-key
|
||||
AGENT_SECRET: demo-agent-secret
|
||||
DEPLOYMENT_DATA_DIR: /data/deployment
|
||||
TZ: Europe/Moscow
|
||||
volumes:
|
||||
- server_data:/data/deployment
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
server_data:
|
||||
@@ -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()
|
||||
@@ -0,0 +1,272 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
ADMIN_LOGIN=${ADMIN_LOGIN:-admin}
|
||||
ADMIN_PASSWORD=${ADMIN_PASSWORD:-LexP@ssw0rd_1}
|
||||
|
||||
# Ждем пока PostgreSQL будет готов принимать подключения
|
||||
until pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"; do
|
||||
echo "Waiting for PostgreSQL to be ready..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Создаем базу данных и пользователя если не существуют
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||
-- Создаем расширение если не существует
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Даем все права пользователю на базу данных
|
||||
GRANT ALL PRIVILEGES ON DATABASE $POSTGRES_DB TO $POSTGRES_USER;
|
||||
|
||||
-- Создаем таблицы если их нет (для первоначальной инициализации)
|
||||
DO \$\$
|
||||
BEGIN
|
||||
-- Таблица приложений
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'application') THEN
|
||||
CREATE TABLE application (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
host_ip VARCHAR(45) NOT NULL,
|
||||
hostname VARCHAR(100) NOT NULL,
|
||||
path VARCHAR(500) NOT NULL,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
status VARCHAR(20) DEFAULT 'UNKNOWN',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Индексы для application
|
||||
CREATE INDEX idx_application_host_ip ON application(host_ip);
|
||||
CREATE INDEX idx_application_active ON application(active);
|
||||
CREATE INDEX idx_application_status ON application(status);
|
||||
CREATE INDEX idx_application_created_at ON application(created_at);
|
||||
|
||||
RAISE NOTICE 'Table application created successfully';
|
||||
ELSE
|
||||
RAISE NOTICE 'Table application already exists';
|
||||
END IF;
|
||||
|
||||
-- Гарантируем наличие колонки status в application
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'application' AND column_name = 'status'
|
||||
) THEN
|
||||
ALTER TABLE application ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'UNKNOWN';
|
||||
UPDATE application SET status = 'UNKNOWN' WHERE status IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_application_status ON application(status);
|
||||
RAISE NOTICE 'Added status column to application table';
|
||||
END IF;
|
||||
|
||||
-- Таблица docker-compose файлов
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'compose_file') THEN
|
||||
CREATE TABLE compose_file (
|
||||
id SERIAL PRIMARY KEY,
|
||||
application_id INTEGER NOT NULL REFERENCES application(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_current BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Индексы для compose_file
|
||||
CREATE INDEX idx_compose_file_application_id ON compose_file(application_id);
|
||||
CREATE INDEX idx_compose_file_is_current ON compose_file(is_current);
|
||||
CREATE INDEX idx_compose_file_created_at ON compose_file(created_at);
|
||||
|
||||
RAISE NOTICE 'Table compose_file created successfully';
|
||||
ELSE
|
||||
RAISE NOTICE 'Table compose_file already exists';
|
||||
END IF;
|
||||
|
||||
-- Таблица env файлов (обновленная структура)
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'env_file') THEN
|
||||
CREATE TABLE env_file (
|
||||
id SERIAL PRIMARY KEY,
|
||||
application_id INTEGER NOT NULL REFERENCES application(id) ON DELETE CASCADE,
|
||||
filename VARCHAR(100) NOT NULL DEFAULT '.env',
|
||||
content TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_current BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Уникальный constraint ТОЛЬКО для текущих версий (используем partial unique index)
|
||||
CREATE UNIQUE INDEX unique_current_env ON env_file (application_id, filename)
|
||||
WHERE is_current = true;
|
||||
|
||||
-- Индексы для env_file
|
||||
CREATE INDEX idx_env_file_application_id ON env_file(application_id);
|
||||
CREATE INDEX idx_env_file_filename ON env_file(filename);
|
||||
CREATE INDEX idx_env_file_is_current ON env_file(is_current);
|
||||
CREATE INDEX idx_env_file_created_at ON env_file(created_at);
|
||||
|
||||
RAISE NOTICE 'Table env_file created successfully';
|
||||
ELSE
|
||||
-- Если таблица уже существует, проверяем наличие поля filename
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'env_file' AND column_name = 'filename') THEN
|
||||
-- Добавляем поле filename
|
||||
ALTER TABLE env_file ADD COLUMN filename VARCHAR(100) NOT NULL DEFAULT '.env';
|
||||
|
||||
-- Создаем индексы для нового поля
|
||||
CREATE INDEX idx_env_file_filename ON env_file(filename);
|
||||
|
||||
RAISE NOTICE 'Added filename column to env_file table';
|
||||
ELSE
|
||||
RAISE NOTICE 'Table env_file already exists with filename column';
|
||||
END IF;
|
||||
|
||||
-- Удаляем старый constraint если он существует
|
||||
IF EXISTS (SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_name = 'env_file' AND constraint_name = 'unique_current_env') THEN
|
||||
ALTER TABLE env_file DROP CONSTRAINT unique_current_env;
|
||||
RAISE NOTICE 'Dropped old unique_current_env constraint';
|
||||
END IF;
|
||||
|
||||
-- Удаляем старый index если он существует
|
||||
IF EXISTS (SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'env_file' AND indexname = 'unique_current_env') THEN
|
||||
DROP INDEX unique_current_env;
|
||||
RAISE NOTICE 'Dropped old unique_current_env index';
|
||||
END IF;
|
||||
|
||||
-- Создаем новый partial unique index
|
||||
CREATE UNIQUE INDEX unique_current_env ON env_file (application_id, filename)
|
||||
WHERE is_current = true;
|
||||
|
||||
RAISE NOTICE 'Created new partial unique index for current env files';
|
||||
END IF;
|
||||
|
||||
-- Таблица логов миграций
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'migration_log') THEN
|
||||
CREATE TABLE migration_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
application_id INTEGER NOT NULL REFERENCES application(id) ON DELETE CASCADE,
|
||||
command VARCHAR(500) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
output TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Индексы для migration_log
|
||||
CREATE INDEX idx_migration_log_application_id ON migration_log(application_id);
|
||||
CREATE INDEX idx_migration_log_created_at ON migration_log(created_at);
|
||||
|
||||
RAISE NOTICE 'Table migration_log created successfully';
|
||||
ELSE
|
||||
RAISE NOTICE 'Table migration_log already exists';
|
||||
END IF;
|
||||
|
||||
-- Таблица задач агента
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'agent_task') THEN
|
||||
CREATE TABLE agent_task (
|
||||
id SERIAL PRIMARY KEY,
|
||||
task_id VARCHAR(36) UNIQUE NOT NULL,
|
||||
application_id INTEGER NOT NULL REFERENCES application(id) ON DELETE CASCADE,
|
||||
task_type VARCHAR(50) NOT NULL,
|
||||
command VARCHAR(500) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
host_ip VARCHAR(45) NOT NULL,
|
||||
payload TEXT,
|
||||
output TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Создаем индексы для часто используемых полей
|
||||
CREATE INDEX idx_agent_task_task_id ON agent_task(task_id);
|
||||
CREATE INDEX idx_agent_task_application_id ON agent_task(application_id);
|
||||
CREATE INDEX idx_agent_task_status ON agent_task(status);
|
||||
CREATE INDEX idx_agent_task_host_ip ON agent_task(host_ip);
|
||||
CREATE INDEX idx_agent_task_created_at ON agent_task(created_at);
|
||||
CREATE INDEX idx_agent_task_completed_at ON agent_task(completed_at);
|
||||
|
||||
-- Комментарии к таблице и полям
|
||||
COMMENT ON TABLE agent_task IS 'Таблица для хранения задач агента развертывания';
|
||||
COMMENT ON COLUMN agent_task.task_id IS 'Уникальный идентификатор задачи (UUID)';
|
||||
COMMENT ON COLUMN agent_task.task_type IS 'Тип задачи: deploy, migration, update_config';
|
||||
COMMENT ON COLUMN agent_task.status IS 'Статус задачи: pending, in_progress, completed, failed';
|
||||
COMMENT ON COLUMN agent_task.payload IS 'Дополнительные данные для задачи в формате JSON';
|
||||
COMMENT ON COLUMN agent_task.output IS 'Вывод выполнения команды';
|
||||
|
||||
RAISE NOTICE 'Table agent_task created successfully';
|
||||
ELSE
|
||||
RAISE NOTICE 'Table agent_task already exists';
|
||||
END IF;
|
||||
|
||||
-- Таблица пользователей веб-интерфейса
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'auth_user') THEN
|
||||
CREATE TABLE auth_user (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(100) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(128) NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_auth_user_username ON auth_user(username);
|
||||
CREATE INDEX idx_auth_user_active ON auth_user(is_active);
|
||||
RAISE NOTICE 'Table auth_user created successfully';
|
||||
ELSE
|
||||
RAISE NOTICE 'Table auth_user already exists';
|
||||
END IF;
|
||||
|
||||
-- Обновляем длину поля path в application если нужно
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'application' AND column_name = 'path'
|
||||
AND character_maximum_length < 500) THEN
|
||||
ALTER TABLE application ALTER COLUMN path TYPE VARCHAR(500);
|
||||
RAISE NOTICE 'Updated application.path column length to 500';
|
||||
END IF;
|
||||
|
||||
-- Обновляем длину поля command в migration_log если нужно
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'migration_log' AND column_name = 'command'
|
||||
AND character_maximum_length < 500) THEN
|
||||
ALTER TABLE migration_log ALTER COLUMN command TYPE VARCHAR(500);
|
||||
RAISE NOTICE 'Updated migration_log.command column length to 500';
|
||||
END IF;
|
||||
|
||||
-- Обновляем длину поля command в agent_task если нужно
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'agent_task' AND column_name = 'command'
|
||||
AND character_maximum_length < 500) THEN
|
||||
ALTER TABLE agent_task ALTER COLUMN command TYPE VARCHAR(500);
|
||||
RAISE NOTICE 'Updated agent_task.command column length to 500';
|
||||
END IF;
|
||||
|
||||
END
|
||||
\$\$;
|
||||
|
||||
-- Создаем функцию для автоматического обновления updated_at
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS \$\$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
\$\$ language 'plpgsql';
|
||||
|
||||
-- Создаем триггер для автоматического обновления updated_at в application
|
||||
DROP TRIGGER IF EXISTS update_application_updated_at ON application;
|
||||
CREATE TRIGGER update_application_updated_at
|
||||
BEFORE UPDATE ON application
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Анализируем таблицы для оптимизации
|
||||
ANALYZE application;
|
||||
ANALYZE compose_file;
|
||||
ANALYZE env_file;
|
||||
ANALYZE migration_log;
|
||||
ANALYZE agent_task;
|
||||
ANALYZE auth_user;
|
||||
|
||||
-- Инициализация единственной учетной записи (логин/пароль задаются env ADMIN_LOGIN/ADMIN_PASSWORD)
|
||||
INSERT INTO auth_user (username, password_hash, is_active)
|
||||
VALUES ('$ADMIN_LOGIN', md5('$ADMIN_PASSWORD'), TRUE)
|
||||
ON CONFLICT (username) DO UPDATE
|
||||
SET password_hash = EXCLUDED.password_hash,
|
||||
is_active = TRUE;
|
||||
EOSQL
|
||||
|
||||
echo "Database initialization completed!"
|
||||
Reference in New Issue
Block a user