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
+46
View File
@@ -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
```
+13
View File
@@ -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"]
}
+159
View File
@@ -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()
+55
View File
@@ -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:
+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()
+272
View File
@@ -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!"