Files
chatballs/compose.yaml
T
AndreyandClaude Fable 5.1 5396dda8ad ✨ feat(updates): обновление установки из интерфейса
Установка раз в шесть часов и по кнопке проверяет страницу релизов
GitHub. Администратор установки видит баннер о новой версии и ставит
её одной кнопкой; карточка «Обновления» — в «Настройки → Платформа».

Установку выполняет отдельный сервис updater с Docker-сокетом:
backend-app общается с ним только файлами в томе chatballs-updates
(heartbeat, request.json, status.json). Updater принимает лишь релизы
своего репозитория с образами по digest и применяет compose.yaml в
одноразовом контейнере-помощнике, после чего кладёт файл в каталог
установки. Запрос установки попадает в аудит.

Релизный workflow собирает образ updater и пришпиливает его в
compose.yaml; версия бэкенда зашивается в образ (CHATBALLS_VERSION).
Миграции updates.0001 и tenancy.0034 (гранты на таблицу состояния).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:43:11 +03:00

358 lines
18 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Канонический production-манифест Chatballs (ADR-CHATBALLS-0028).
# Один Compose для: установки клиента, staging, smoke.
# Локальная разработка — через override compose.dev.yaml; этот файл не содержит
# dev source mounts, dev-команд и host-портов (кроме public 80/443 у gateway).
#
# Этот файл — весь дистрибутив. Рядом с ним не должно лежать ничего: ни
# Caddyfile, ни init-скриптов базы, ни генератора секретов — всё это внутри
# образов (deploy/docker/*.Dockerfile). Поэтому установка на чистый хост
# выглядит так:
#
# curl -fsSL <ссылка на compose.yaml со страницы релиза> -o compose.yaml
# docker compose up -d --wait
#
# В релизной копии ссылки на образы уже закреплены по digest (их подставляет
# CI). Значения по умолчанию ниже — для сборки из исходников: с
# compose.dev.yaml они собираются локально.
#
# У продукта нет .env: секреты инстанса генерирует сервис secrets при первом
# старте, всё остальное человек настраивает в UI.
services:
# Секреты инстанса (ключ подписи, пароли ролей БД) генерирует первый старт;
# они живут в томе chatballs-secrets. Человек их не вводит и не хранит:
# установка — одна команда, всё остальное настраивается в UI. Скрипт
# идемпотентен — перезапуск не меняет пароли работающей базы.
#
# Крутится на backend-образе: он всё равно нужен стеку, а скрипт запечён в
# него (apps/backend/Dockerfile.production).
secrets:
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
entrypoint: ["/bin/sh", "/usr/local/bin/chatballs-generate-secrets.sh"]
# Скрипт создаёт каталог и выставляет права — это делает root, а
# production-образ работает под hub.
user: root
restart: "no"
volumes:
- chatballs-secrets:/run/chatballs/secrets
- chatballs-secrets-platform:/run/chatballs/secrets/platform
- chatballs-secrets-schema:/run/chatballs/secrets/schema
postgres:
image: ${CHATBALLS_POSTGRES_IMAGE:-chatballs-postgres:dev}
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-chatballs}
POSTGRES_USER: ${POSTGRES_USER:-chatballs_bootstrap}
POSTGRES_PASSWORD_FILE: /run/chatballs/secrets/schema/postgres_password
POSTGRES_APP_USER: ${POSTGRES_APP_USER:-chatballs_app}
POSTGRES_APP_PASSWORD_FILE: /run/chatballs/secrets/postgres_app_password
POSTGRES_PLATFORM_USER: ${POSTGRES_PLATFORM_USER:-chatballs_platform}
POSTGRES_PLATFORM_PASSWORD_FILE: /run/chatballs/secrets/platform/postgres_platform_password
POSTGRES_MIGRATION_USER: ${POSTGRES_MIGRATION_USER:-chatballs_migration}
POSTGRES_MIGRATION_PASSWORD_FILE: /run/chatballs/secrets/schema/postgres_migration_password
depends_on:
secrets:
condition: service_completed_successfully
volumes:
- chatballs-secrets:/run/chatballs/secrets:ro
- chatballs-secrets-platform:/run/chatballs/secrets/platform:ro
- chatballs-secrets-schema:/run/chatballs/secrets/schema:ro
- chatballs-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: ${CHATBALLS_REDIS_IMAGE:-redis:7-alpine}
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- chatballs-redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
# One-shot init: только миграции (ADR-CHATBALLS-0028 §one-shot init). Владельца и
# демо-данные создаёт мастер первого запуска в браузере (/api/v1/setup/).
# collectstatic испечён в backend-образ, здесь не вызывается.
init:
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
volumes:
- chatballs-secrets:/run/chatballs/secrets:ro
- chatballs-secrets-schema:/run/chatballs/secrets/schema:ro
command: ["python", "manage.py", "migrate", "--noinput"]
environment:
CHATBALLS_DB_ROLE: migration
restart: "no"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
backend-app:
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
restart: unless-stopped
command: >
sh -c "gunicorn chatballs_backend.asgi_app:application
--worker-class uvicorn.workers.UvicornWorker
--bind 0.0.0.0:8000
--workers $${CHATBALLS_GUNICORN_WORKERS:-3}
--timeout $${CHATBALLS_GUNICORN_TIMEOUT:-60}"
environment:
CHATBALLS_DB_ROLE: app
volumes:
# Только общий том: паролей platform и migration у публичного процесса
# нет даже в файловой системе (они в своих томах, см. сервис secrets).
- chatballs-secrets:/run/chatballs/secrets:ro
# Локальные файлы — именованный том, а не каталог с хоста. Том наследует
# владельца из образа (hub), поэтому загрузки работают и на Linux, где
# bind-mount достался бы контейнеру как root:root и падал с EACCES.
- chatballs-media:/app/apps/backend/media
# Обмен с сервисом updater: сюда кладётся запрос на обновление, отсюда
# читается его статус. Доступа к Docker у приложения нет и не будет.
- chatballs-updates:/run/chatballs/updates
depends_on:
init:
condition: service_completed_successfully
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import os, urllib.request; req = urllib.request.Request('http://127.0.0.1:8000/api/v1/health/live/', headers={'Host': os.environ.get('CHATBALLS_APP_HEALTHCHECK_HOST', 'app.localhost'), 'X-Forwarded-Proto': 'https'}); urllib.request.urlopen(req, timeout=3)",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
backend-platform:
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
volumes:
- chatballs-secrets:/run/chatballs/secrets:ro
- chatballs-secrets-platform:/run/chatballs/secrets/platform:ro
restart: unless-stopped
command: >
sh -c "gunicorn chatballs_backend.asgi_platform:application
--worker-class uvicorn.workers.UvicornWorker
--bind 0.0.0.0:8000
--workers $${CHATBALLS_GUNICORN_WORKERS:-3}
--timeout $${CHATBALLS_GUNICORN_TIMEOUT:-60}"
environment:
CHATBALLS_DB_ROLE: platform
depends_on:
init:
condition: service_completed_successfully
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import os, urllib.request; req = urllib.request.Request('http://127.0.0.1:8000/api/v1/health/live/', headers={'Host': os.environ.get('CHATBALLS_PLATFORM_HEALTHCHECK_HOST', 'platform.localhost'), 'X-Forwarded-Proto': 'https'}); urllib.request.urlopen(req, timeout=3)",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
# Django admin не подключён к gateway. Единственный host bind — loopback;
# удалённый доступ допускается только через SSH tunnel (ADR-CHATBALLS-0031).
backend-admin:
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
volumes:
- chatballs-secrets:/run/chatballs/secrets:ro
- chatballs-secrets-schema:/run/chatballs/secrets/schema:ro
restart: unless-stopped
command: >
sh -c "gunicorn chatballs_backend.asgi_admin:application
--worker-class uvicorn.workers.UvicornWorker
--bind 0.0.0.0:8000
--workers $${CHATBALLS_GUNICORN_WORKERS:-2}
--timeout $${CHATBALLS_GUNICORN_TIMEOUT:-60}"
# Break-glass technical surface only: schema credentials are never used by
# public app/platform runtimes and this service remains loopback-only.
environment:
CHATBALLS_DB_ROLE: migration
ports:
- "127.0.0.1:${CHATBALLS_ADMIN_PORT:-18001}:8000"
depends_on:
init:
condition: service_completed_successfully
postgres:
condition: service_healthy
redis:
condition: service_healthy
worker:
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
restart: unless-stopped
command: ["python", "manage.py", "run_worker"]
environment:
CHATBALLS_DB_ROLE: app
# Захват outbox идёт по всем организациям сразу — воркеру нужен алиас
# platform. У backend-app его нет: он работает только ролью app.
CHATBALLS_DB_PLATFORM_ALIAS: "1"
volumes:
- chatballs-secrets:/run/chatballs/secrets:ro
- chatballs-secrets-platform:/run/chatballs/secrets/platform:ro
- chatballs-media:/app/apps/backend/media
depends_on:
backend-app:
condition: service_healthy
# Обновление по кнопке из интерфейса (ADR-CHATBALLS-0049). Единственный
# сервис с доступом к docker.sock: забирает запрос из тома chatballs-updates,
# проверяет, что это официальный релиз (адрес со страницы релизов, образы по
# digest), и запускает `docker compose pull && up` отдельным контейнером вне
# проекта. Без сокета (rootless, podman) просто спит — кнопка в интерфейсе
# тогда сообщает, что обновление из интерфейса недоступно.
updater:
image: ${CHATBALLS_UPDATER_IMAGE:-chatballs-updater:dev}
restart: unless-stopped
environment:
CHATBALLS_UPDATE_REPO: ${CHATBALLS_UPDATE_REPO:-dartdavros/chatballs}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- chatballs-updates:/run/chatballs/updates
depends_on:
backend-app:
condition: service_healthy
# Frontend: один nginx-образ со static-сборкой internal-ui и web-chat,
# внутренняя маршрутизация (/api/, /ws/, /chat-widget.js,
# /chat/, /calls/) — в deploy/nginx/frontend.production.conf. Не публикует
# host-порт: public boundary — gateway (Caddy).
frontend:
image: ${CHATBALLS_FRONTEND_IMAGE:-chatballs-frontend:dev}
restart: unless-stopped
depends_on:
backend-app:
condition: service_healthy
healthcheck:
# Без него `up --wait` считает контейнер готовым сразу после старта, и
# человек открывает адрес раньше, чем nginx поднял конфигурацию.
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
# Gateway: единственный HTTP/HTTPS public boundary (ADR-CHATBALLS-0028 §gateway).
# Caddy: TLS termination, ACME, HTTP->HTTPS redirect, WebSocket upgrade (native).
# Caddyfile запечён в образ (deploy/docker/gateway.Dockerfile) — с хоста
# ничего не монтируется. Маршрутизация публичных путей делегируется frontend
# (internal). Coturn не проксируется через Caddy — отдельная network boundary
# (profile calls).
gateway:
image: ${CHATBALLS_GATEWAY_IMAGE:-chatballs-gateway:dev}
restart: unless-stopped
environment:
CHATBALLS_PLATFORM_DOMAIN: ${CHATBALLS_PLATFORM_DOMAIN:-platform.localhost}
ports:
- "${CHATBALLS_WEB_LISTENING_IP:-0.0.0.0}:80:80"
- "${CHATBALLS_WEB_LISTENING_IP:-0.0.0.0}:443:443"
volumes:
- chatballs-caddy-data:/data
- chatballs-caddy-config:/config
depends_on:
frontend:
condition: service_started
backend-app:
condition: service_healthy
backend-platform:
condition: service_healthy
# Coturn — медиа-relay для TURN fallback (SPEC-CHATBALLS-0013 §11).
# Опциональный profile `calls` (ADR-CHATBALLS-0028): включается через COMPOSE_PROFILES=calls.
# Host networking: relay использует широкий UDP-диапазон и реальный внешний IP,
# публикуется напрямую и не проходит через HTTP reverse proxy. credentials
# выдаёт backend по общему CHATBALLS_CALL_TURN_SECRET (static-auth-secret).
coturn:
profiles: ["calls"]
image: ${CHATBALLS_COTURN_IMAGE:-coturn/coturn:4.6}
restart: unless-stopped
network_mode: host
command:
- -n
- --log-file=stdout
- --no-cli
- --fingerprint
- --use-auth-secret
# Общий секрет приходит файлом из тома секретов: его генерирует первый
# старт, человек его не вводит и не дублирует в двух местах.
- -c
- /run/chatballs/secrets/turnserver-secret.conf
- --realm=${CHATBALLS_CALL_TURN_REALM:-}
# Весь TURN живёт на выделенном публичном IP (отдельный порт/NIC), Caddy — на
# основном IP. Это освобождает 443 под TURN-over-TLS без конфликта с web.
- --listening-ip=${CHATBALLS_TURN_LISTENING_IP:-0.0.0.0}
- --relay-ip=${CHATBALLS_TURN_EXTERNAL_IP:-}
- --external-ip=${CHATBALLS_TURN_EXTERNAL_IP:-}
- --listening-port=${CHATBALLS_TURN_LISTENING_PORT:-3478}
# TURN-over-TLS на 443: проходит через VPN/строгие сети, где UDP и 3478 режут.
- --tls-listening-port=${CHATBALLS_TURN_TLS_PORT:-443}
- --cert=/etc/coturn/certs/fullchain.pem
- --pkey=/etc/coturn/certs/privkey.pem
- --min-port=${CHATBALLS_TURN_MIN_PORT:-49160}
# Relay-диапазон UDP (1 порт на media-endpoint; ~2 порта на звонок).
# 49160-49999 = 840 портов ≈ 420 одновременных TURN-звонков на хосте.
# Расширяется через CHATBALLS_TURN_MIN_PORT/CHATBALLS_TURN_MAX_PORT; все порты диапазона
# должны быть открыты на firewall и не пересекаться с ephemeral-диапазоном ОС.
- --max-port=${CHATBALLS_TURN_MAX_PORT:-49999}
# Запрет анонимного и внутрисетевого relay (SPEC §11: без пересечения с хостом).
- --no-multicast-peers
- --no-tcp-relay
- --denied-peer-ip=10.0.0.0-10.255.255.255
- --denied-peer-ip=172.16.0.0-172.31.255.255
- --denied-peer-ip=192.168.0.0-192.168.255.255
- --no-tlsv1
- --no-tlsv1_1
volumes:
# LE-сертификат TURN-хоста (turns:) кладёт на хост renewal deploy-hook —
# это единственное место, где стек смотрит наружу файлом, и только при
# включённом профиле calls. Каталог задаётся явно.
- chatballs-secrets:/run/chatballs/secrets:ro
- ${CHATBALLS_TURN_CERTS_DIR:-./data/coturn-certs}:/etc/coturn/certs:ro
healthcheck:
# Allocation smoke: STUN binding к собственному listener на выделенном IP.
test: ["CMD", "turnutils_stunclient", "-p", "${CHATBALLS_TURN_LISTENING_PORT:-3478}", "${CHATBALLS_TURN_LISTENING_IP:-0.0.0.0}"]
interval: 30s
timeout: 5s
retries: 5
start_period: 15s
volumes:
# Секреты инстанса: генерируются при первом старте, живут только здесь.
# Три тома, чтобы каждый процесс видел лишь свои пароли: общий, роли
# platform и роли миграций (вместе с паролем владельца кластера).
chatballs-secrets:
chatballs-secrets-platform:
chatballs-secrets-schema:
# Состояние установки. Именованные тома вместо каталогов с хоста: установка
# не зависит от того, из какого каталога её запустили, и не упирается в
# владельца каталога на хосте.
chatballs-postgres:
chatballs-redis:
chatballs-media:
chatballs-caddy-data:
chatballs-caddy-config:
# Обмен приложения с сервисом updater: запрос и статус обновления.
chatballs-updates: