Files
vidconf/install.sh
Max Ronzhin 9bc8d6174d feat(coturn): TURN over TLS на 5349 — сертификаты, монтирование, анонс клиентам
coturn (nobody:nogroup, без root-фазы в entrypoint) не может сам прочитать
приватный ключ Let's Encrypt — coturn-certs-init (по образцу
recordings-init/llm-models-init) копирует fullchain/privkey в отдельный
volume под правами 644, не трогая права на ключ на хосте.

TURN_TLS_HOST в .env — единственный переключатель фичи: пусто (dev-дефолт)
вырезает TLS-блоки из turnserver.conf и rtc.turn_servers целиком (маркеры
BEGIN/END-TLS-* в *.template, render-templates.sh), непустое значение
включает оба сразу — TLS без анонса LiveKit клиентам не имеет смысла
(история 0.0.14: coturn работал healthy, но клиенты о нём не знали, и не
было ни одной аллокации). Значение обязано быть доменом сертификата, а не
IP — иначе браузер не пройдёт TLS-валидацию по имени хоста для turns:.

TLS-запись в rtc.turn_servers стоит последней в списке (фолбэк дороже
прямого UDP/TCP).
2026-08-02 20:26:56 +03:00

630 lines
35 KiB
Bash
Executable File
Raw Permalink 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.
#!/usr/bin/env bash
# Инсталлятор VidConf.
#
# Опросник из 5 пресетов поставки:
# 1) MVP-ядро (лобби, конференции, календарь, закреплённые, гости)
# 2) 1 + чат
# 3) 2 + AI-уровень «мин» (CPU, faster-whisper small + Qwen3.5-4B)
# 4) 2 + AI-уровень «средний» (CPU, faster-whisper medium + Qwen3.5-9B)
# 5) 2 + AI-уровень «макс» (GPU ОБЯЗАТЕЛЕН, faster-whisper large-v3 + Qwen3.5-35B-A3B)
# Модели/железо — ADR-004 (docs/architecture/adr/004-ai-tier-matrix.md),
# единственный источник истины. Запись конференций сюда НЕ входит — она
# появится в одной из следующих версий.
#
# Идемпотентность: повторный запуск = апгрейд/даунгрейд пресета на месте.
# Секреты и пользовательские правки .env НЕ теряются — значения пишутся
# точечно (set_env_var ниже), а не перезаписью всего файла. Seed-скрипт
# backend (scripts/seed.py) и бутстрап instance_settings (backend, lifespan)
# идемпотентны сами по себе — существующие настройки не перетираются.
#
# Использование:
# ./install.sh # интерактивный опросник
# ./install.sh --preset 3 # неинтерактивно, пресет 3
# ./install.sh --preset 3 --yes # без подтверждений (скрипты/CI)
# ./install.sh --preset 2 --monitoring --yes # + профиль monitoring (Grafana/Prometheus)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
COMPOSE_FILE="deploy/docker-compose.yml"
ENV_FILE="$SCRIPT_DIR/.env"
ENV_EXAMPLE="$SCRIPT_DIR/.env.example"
VERSION_FILE="$SCRIPT_DIR/VERSION"
PRESET=""
ASSUME_YES=0
# По умолчанию 0: `set_env_var COMPOSE_PROFILES` ниже (по каждому пресету)
# перезаписывает значение целиком, затирая `media,monitoring` из
# .env.example — без этого флага мониторинг остаётся не поднятым, пока
# оператор не допишет профиль вручную (см. .forcc/deploy/SESSION4-FINDINGS.md,
# ГРАБЛИ 2).
MONITORING=0
while [ $# -gt 0 ]; do
case "$1" in
--preset)
PRESET="$2"
shift 2
;;
--yes|-y)
ASSUME_YES=1
shift
;;
--monitoring)
MONITORING=1
shift
;;
-h|--help)
grep -E '^#( |$)' "$0" | sed 's/^#//'
exit 0
;;
*)
echo "Неизвестный аргумент: $1" >&2
exit 1
;;
esac
done
# ---------------------------------------------------------------------------
# Утилиты работы с .env: точечное обновление ключа (не трогает остальные
# строки/комментарии/порядок) — идемпотентность и сохранность пользовательских
# правок при повторном запуске.
# ---------------------------------------------------------------------------
FRESH_ENV=0
# `.env.example` в git хранит ПЛЕЙСХОЛДЕРЫ секретов (`change-me...`,
# `POSTGRES_PASSWORD=vidconf` и т.п.) — не пустые строки. Если после
# `cp .env.example .env` оставить их как есть, `ensure_secret` ниже (генерация
# «только при отсутствии») их не тронет, и на проде осядут секреты из
# публичного репозитория. Поэтому при ПЕРВОМ создании .env явно очищаем
# управляемые секреты перед вызовом ensure_secret — на повторных запусках
# (файл уже существует) эта функция не трогает ничего, ensure_secret
# сохраняет и сгенерированные, и вручную заданные пользователем значения.
ensure_env_file() {
if [ ! -f "$ENV_FILE" ]; then
echo "[install] .env не найден — создаю из .env.example"
cp "$ENV_EXAMPLE" "$ENV_FILE"
FRESH_ENV=1
fi
}
get_env_var() {
key="$1"
[ -f "$ENV_FILE" ] || { echo ""; return 0; }
grep -E "^${key}=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || true
}
# Установить/заменить KEY=VALUE в .env; добавить строку, если ключа ещё нет.
# `sed -i.bak ... && rm -f *.bak` — переносимо между GNU sed (Linux) и BSD
# sed (macOS): оба принимают суффикс бэкапа сразу после -i, поведение без
# суффикса на BSD sed отличается (требует отдельного аргумента) — этим и
# отличаются друг от друга, суффикс убирает разницу.
set_env_var() {
key="$1"
value="$2"
esc_value=$(printf '%s' "$value" | sed -e 's/[&|\\]/\\&/g')
if grep -qE "^${key}=" "$ENV_FILE"; then
sed -i.bak "s|^${key}=.*|${key}=${esc_value}|" "$ENV_FILE" && rm -f "$ENV_FILE.bak"
else
printf '%s=%s\n' "$key" "$value" >> "$ENV_FILE"
fi
}
# Установить секрет ТОЛЬКО если он ещё не задан (не перезаписывать
# сгенерированные ранее/вручную заданные пользователем значения).
ensure_secret() {
key="$1"
bytes="${2:-32}"
current="$(get_env_var "$key")"
if [ -z "$current" ]; then
generated="$(openssl rand -hex "$bytes" 2>/dev/null || head -c "$bytes" /dev/urandom | od -An -tx1 | tr -d ' \n')"
set_env_var "$key" "$generated"
echo "[install] ${key}: сгенерирован новый секрет"
fi
}
# Установить дефолт для НЕ-секретного ключа только если он ещё не задан —
# аналог ensure_secret, но без генерации случайного значения. Не трогает
# значение, уже заданное вручную (реальный домен/IP боевого сервера).
ensure_default() {
key="$1"
default="$2"
if [ -z "$(get_env_var "$key")" ]; then
set_env_var "$key" "$default"
fi
}
# ---------------------------------------------------------------------------
# Fail-fast валидация прод-значений .env перед подъёмом стека.
#
# `ensure_secret`/`ensure_default` пишут значение только если ключ ПУСТ, а
# `.env.example` хранит непустые dev-плейсхолдеры (`NGINX_SERVER_NAMES=
# example.com`, `LIVEKIT_PUBLIC_URL=ws://localhost:7880`, `LIVEKIT_API_KEY=
# devkey` и т.д.) — при `cp .env.example .env` без ручной правки install.sh
# доходил до конца и рапортовал об успехе, а на выходе получался прод с
# mixed-content (браузер режет `ws://` на HTTPS-странице) и nginx, слушающим
# несуществующий домен `example.com` (см. .forcc/deploy/SESSION4-FINDINGS.md,
# ГРАБЛИ 1 — реальный инцидент этого проекта).
#
# Признак «это прод, не dev»: `NGINX_SERVER_NAMES` — реальный домен, не
# `localhost`/`127.0.0.1` (дефолт `ensure_default` для локальной разработки).
# Плейсхолдер `example.com` из .env.example — ошибка ВСЕГДА (ни для прода,
# ни как осознанный dev-дефолт: тот — `localhost`), проверяется отдельно и
# раньше остальных.
validate_prod_env() {
nginx_names="$(get_env_var NGINX_SERVER_NAMES)"
case "$nginx_names" in
*example.com*)
echo "[install] ОШИБКА: NGINX_SERVER_NAMES='${nginx_names}' — непочатый плейсхолдер из .env.example." >&2
echo "[install] Перед запуском install.sh задайте в .env реальный домен(ы) (боевой сервер)" >&2
echo "[install] или 'localhost' (локальная разработка) — см. docs/deploy/DEPLOYMENT.md." >&2
exit 1
;;
esac
case "$nginx_names" in
localhost|127.0.0.1) return 0 ;;
esac
# Дальше — прод-ветка: NGINX_SERVER_NAMES указывает на реальный домен,
# остальные прод-значения обязаны быть с ним согласованы.
fail=0
fail_check() {
echo "[install] ОШИБКА: $1" >&2
fail=1
}
livekit_public_url="$(get_env_var LIVEKIT_PUBLIC_URL)"
case "$livekit_public_url" in
wss://*) ;;
*)
fail_check "LIVEKIT_PUBLIC_URL='${livekit_public_url}' — должен быть wss://<домен>/livekit/ (иначе mixed-content на HTTPS, конференции не стартуют — см. .forcc/deploy/SESSION2-FINDINGS.md)."
;;
esac
frontend_url="$(get_env_var FRONTEND_URL)"
case "$frontend_url" in
https://*) ;;
*)
fail_check "FRONTEND_URL='${frontend_url}' — должен быть https://<домен> (иначе битые ссылки подтверждения email)."
;;
esac
if [ "$(get_env_var AUTH_COOKIE_SECURE)" != "true" ]; then
fail_check "AUTH_COOKIE_SECURE должен быть 'true' на HTTPS-проде (сейчас: '$(get_env_var AUTH_COOKIE_SECURE)')."
fi
if [ "$(get_env_var LIVEKIT_API_KEY)" = "devkey" ]; then
fail_check "LIVEKIT_API_KEY='devkey' — публично известный ключ из .env.example, замените на реальный."
fi
node_ip="$(get_env_var LIVEKIT_NODE_IP)"
if [ "$node_ip" = "127.0.0.1" ] || [ -z "$node_ip" ]; then
fail_check "LIVEKIT_NODE_IP='${node_ip}' — задайте внешний IP/домен сервера (иначе ICE-кандидаты недостижимы извне)."
fi
cert_name="$(get_env_var NGINX_CERT_NAME)"
case "$cert_name" in
*example.com*)
fail_check "NGINX_CERT_NAME='${cert_name}' — непочатый плейсхолдер из .env.example."
;;
esac
# НЕ хард-фейл: отсутствие реального сертификата — не тихий провал вроде
# mixed-content (тот ломает WS в консоли браузера, легко пропустить).
# docker-entrypoint-certs.sh сгенерирует самоподписанный и nginx стартует
# штатно — HTTPS с явным предупреждением браузера, что сразу заметно. Это
# ОСОЗНАННЫЙ путь бутстрапа: сначала поднять стек (self-signed), потом
# certbot --webroot (challenge идёт через уже работающий nginx), см.
# docs/deploy/DEPLOYMENT.md, раздел «Выпуск SSL».
if [ ! -d "/etc/letsencrypt/live/${cert_name}" ]; then
echo "[install] ПРЕДУПРЕЖДЕНИЕ: /etc/letsencrypt/live/${cert_name} не найден — nginx поднимется с самоподписанным сертификатом." >&2
echo "[install] Это ожидаемо при первом бутстрапе (см. docs/deploy/DEPLOYMENT.md, «Выпуск SSL» — certbot --webroot ПОСЛЕ первого запуска install.sh)." >&2
fi
# TURN понижен до опционального (Сессия 5: реальные сети в тестировании
# прошли без раздачи TURN клиентам) — только предупреждение, не блокер.
turn_ip="$(get_env_var TURN_EXTERNAL_IP)"
if [ "$turn_ip" = "127.0.0.1" ]; then
echo "[install] ПРЕДУПРЕЖДЕНИЕ: TURN_EXTERNAL_IP=127.0.0.1 — coturn-релей не будет работать для внешних клиентов." >&2
echo "[install] Не блокер (TURN опционален, см. docs/deploy/DEPLOYMENT.md, раздел «TURN для экстремального NAT»), но проверьте, что это осознанный выбор." >&2
fi
if [ "$fail" = "1" ]; then
echo "[install] Остановлено: .env содержит dev-плейсхолдеры на боевом домене (NGINX_SERVER_NAMES='${nginx_names}')." >&2
echo "[install] Полный разбор переменных — docs/deploy/DEPLOYMENT.md." >&2
exit 1
fi
}
# ---------------------------------------------------------------------------
# Автодетект железа (ADR-004, таблица требований).
# ---------------------------------------------------------------------------
detect_cpus() {
if command -v nproc >/dev/null 2>&1; then
nproc
elif command -v sysctl >/dev/null 2>&1; then
sysctl -n hw.ncpu 2>/dev/null || echo 0
else
echo 0
fi
}
detect_ram_mb() {
if command -v free >/dev/null 2>&1; then
free -m | awk '/^Mem:/{print $2}'
elif command -v sysctl >/dev/null 2>&1; then
bytes=$(sysctl -n hw.memsize 2>/dev/null || echo 0)
echo $((bytes / 1024 / 1024))
else
echo 0
fi
}
# Печатает "имя_gpu|vram_mb" через `|` (имя может содержать пробелы) или
# ничего, если nvidia-smi недоступен/GPU не найден.
detect_gpu() {
if command -v nvidia-smi >/dev/null 2>&1; then
nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits 2>/dev/null \
| head -1 \
| awk -F', *' '{print $1"|"$2}'
fi
}
HW_CPUS="$(detect_cpus)"
HW_RAM_MB="$(detect_ram_mb)"
HW_GPU_LINE="$(detect_gpu || true)"
HW_GPU_NAME="${HW_GPU_LINE%%|*}"
HW_VRAM_MB="${HW_GPU_LINE##*|}"
if [ "$HW_GPU_LINE" = "" ] || [ "$HW_GPU_NAME" = "$HW_VRAM_MB" ]; then
HW_GPU_NAME=""
HW_VRAM_MB=""
fi
echo "[install] Обнаружено железо: CPU=${HW_CPUS} vCPU, RAM=${HW_RAM_MB} МБ, GPU=${HW_GPU_NAME:-нет}${HW_VRAM_MB:+, VRAM=${HW_VRAM_MB} МБ}"
# ---------------------------------------------------------------------------
# Рекомендация пресета по обнаруженному железу (таблица ADR-004).
# ---------------------------------------------------------------------------
recommend_preset() {
if [ -n "$HW_VRAM_MB" ] && [ "$HW_VRAM_MB" -ge 16000 ] 2>/dev/null \
&& [ "$HW_RAM_MB" -ge 65536 ] 2>/dev/null && [ "$HW_CPUS" -ge 16 ] 2>/dev/null; then
echo 5
elif [ "$HW_RAM_MB" -ge 32768 ] 2>/dev/null && [ "$HW_CPUS" -ge 12 ] 2>/dev/null; then
echo 4
elif [ "$HW_RAM_MB" -ge 16384 ] 2>/dev/null && [ "$HW_CPUS" -ge 8 ] 2>/dev/null; then
echo 3
else
echo 2
fi
}
RECOMMENDED_PRESET="$(recommend_preset)"
# ---------------------------------------------------------------------------
# Опросник (интерактивный, если --preset не передан).
# ---------------------------------------------------------------------------
print_menu() {
cat <<EOF
Выберите пресет поставки VidConf:
1) MVP-ядро — лобби, конференции, календарь, закреплённые, гости
2) 1 + чат — то же + групповой чат конференции
3) 2 + AI «мин» — CPU: faster-whisper small + Qwen3.5-4B (8 vCPU / 16 ГБ RAM / 100 ГБ)
4) 2 + AI «средний» — CPU: faster-whisper medium + Qwen3.5-9B (12-16 vCPU / 32 ГБ RAM / 150 ГБ)
5) 2 + AI «макс» — GPU ОБЯЗАТЕЛЕН: faster-whisper large-v3 + Qwen3.5-35B-A3B (16+ vCPU / 64 ГБ RAM / GPU NVIDIA ≥16 ГБ VRAM / 250 ГБ)
Рекомендация по обнаруженному железу: пресет ${RECOMMENDED_PRESET}.
Подробности требований — docs/architecture/adr/004-ai-tier-matrix.md.
EOF
}
if [ -z "$PRESET" ]; then
print_menu
printf "Номер пресета [%s]: " "$RECOMMENDED_PRESET"
read -r answer
PRESET="${answer:-$RECOMMENDED_PRESET}"
fi
case "$PRESET" in
1|2|3|4|5) ;;
*)
echo "Пресет должен быть числом от 1 до 5 (получено: '${PRESET}')" >&2
exit 1
;;
esac
# Пресет 5 (max) требует GPU ОБЯЗАТЕЛЬНО (ADR-004) — если детект его не
# нашёл, явно предупреждаем и просим подтверждение (можно ставить заранее,
# до подключения GPU-хоста, но AI-контейнеры не поднимутся здоровыми).
if [ "$PRESET" = "5" ] && { [ -z "$HW_VRAM_MB" ] || [ "$HW_VRAM_MB" -lt 16000 ]; }; then
echo "[install] ВНИМАНИЕ: пресет 5 требует NVIDIA GPU ≥16 ГБ VRAM — не обнаружен (или недостаточно VRAM)." >&2
if [ "$ASSUME_YES" != "1" ]; then
printf "Продолжить всё равно? [y/N]: "
read -r confirm
case "$confirm" in
y|Y|yes|Yes) ;;
*) echo "Отменено."; exit 1 ;;
esac
fi
fi
# ---------------------------------------------------------------------------
# Заполнение .env: секреты (только при отсутствии), железо, версия, модели,
# профили compose — точечно, см. set_env_var.
# ---------------------------------------------------------------------------
ensure_env_file
if [ "$FRESH_ENV" = "1" ]; then
for key in JWT_SECRET POSTGRES_PASSWORD REDIS_PASSWORD TURN_STATIC_AUTH_SECRET \
LIVEKIT_API_SECRET GRAFANA_ADMIN_PASSWORD SEED_ADMIN_PASSWORD; do
set_env_var "$key" ""
done
fi
ensure_secret JWT_SECRET 32
ensure_secret POSTGRES_PASSWORD 16
# DATABASE_URL хранит пароль ВНУТРИ строки подключения — пересобираем её из
# POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB на каждом запуске (не только
# при первом), иначе после регенерации POSTGRES_PASSWORD backend/worker
# продолжили бы стучаться со старым паролем из .env.example и не
# подключились бы к БД. Хост — `postgres` (имя сервиса в docker-сети
# compose), не `localhost` (тот годится только для запуска backend на хосте
# вне контейнера, см. docs/deploy/dev-setup.md).
set_env_var DATABASE_URL "postgresql+asyncpg://$(get_env_var POSTGRES_USER):$(get_env_var POSTGRES_PASSWORD)@postgres:5432/$(get_env_var POSTGRES_DB)"
ensure_secret REDIS_PASSWORD 24
# REDIS_URL здесь — только для запуска backend/worker НА ХОСТЕ вне
# docker-сети (см. docs/deploy/dev-setup.md): внутри контейнеров
# docker-compose.yml всегда подставляет redis://:${REDIS_PASSWORD}@redis:6379/0
# сам, независимо от этого значения. Пересобираем на каждом запуске по
# тому же принципу, что и DATABASE_URL выше — иначе после регенерации
# REDIS_PASSWORD это значение осталось бы со старым паролем.
set_env_var REDIS_URL "redis://:$(get_env_var REDIS_PASSWORD)@localhost:6379/0"
ensure_secret TURN_STATIC_AUTH_SECRET 24
ensure_secret LIVEKIT_API_SECRET 32
ensure_secret GRAFANA_ADMIN_PASSWORD 16
# SEED_ADMIN_PASSWORD — единственный секрет с человекочитаемым дефолтом
# (сообщается пользователю в конце) вместо случайной генерации: это учётные
# данные, которыми оператор будет логиниться вручную сразу после установки.
if [ -z "$(get_env_var SEED_ADMIN_PASSWORD)" ]; then
generated_admin_pw="$(openssl rand -hex 8 2>/dev/null || head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n')"
set_env_var SEED_ADMIN_PASSWORD "$generated_admin_pw"
fi
set_env_var VIDCONF_VERSION "$(cat "$VERSION_FILE")"
set_env_var HW_CPUS "$HW_CPUS"
set_env_var HW_RAM_MB "$HW_RAM_MB"
set_env_var HW_GPU_NAME "$HW_GPU_NAME"
set_env_var HW_VRAM_MB "$HW_VRAM_MB"
# nginx/coturn/livekit — дефолты для локальной разработки (localhost/127.0.0.1),
# только если ещё не заданы. На боевом сервере ДО первого запуска install.sh
# задайте в .env реальные значения (домены, внешний IP) — ensure_default их
# не перезапишет.
ensure_default NGINX_SERVER_NAMES "localhost"
ensure_default NGINX_CERT_NAME "localhost"
ensure_default LIVEKIT_USE_EXTERNAL_IP "false"
ensure_default LIVEKIT_NODE_IP "127.0.0.1"
ensure_default TURN_EXTERNAL_IP "127.0.0.1"
# Пусто = TURN over TLS выключен (см. docs/deploy/DEPLOYMENT.md §8) — не
# генерируем и не требуем здесь, только гарантируем, что ключ явно есть в
# .env (для discoverability), а не отсутствует молча.
ensure_default TURN_TLS_HOST ""
# Профили compose и модели по пресету. GPU-профили — ТОЛЬКО для пресета 5
# (max): в текущей матрице уровней (backend/services/ai_tiers.py, ADR-004)
# уровень `medium` фиксированно CPU-only (GPU для него — ручная настройка
# администратора вне детекта), поэтому отдельного GPU-варианта для пресета 4
# нет — не путать с прозой ADR-004 про «опциональный GPU для medium».
#
# BOOTSTRAP_CHAT_ENABLED/BOOTSTRAP_TRANSCRIPTION_ENABLED/BOOTSTRAP_AI_LEVEL:
# без них
# бутстрап настроек инстанса (backend, lifespan) брал ВСЁ enabled: true из
# config/plugins.yaml независимо от пресета, и на пресетах 1/2 (без AI)
# сеанс конференции навсегда зависал в очереди transcribe, которую некому
# обслуживать. Значения читает backend при первом бутстрапе И скрипт
# scripts.apply_preset_settings (принудительное применение, см. ниже).
case "$PRESET" in
1)
set_env_var COMPOSE_PROFILES "media"
set_env_var BOOTSTRAP_CHAT_ENABLED "false"
set_env_var BOOTSTRAP_TRANSCRIPTION_ENABLED "false"
set_env_var BOOTSTRAP_AI_LEVEL "min"
;;
2)
set_env_var COMPOSE_PROFILES "media"
set_env_var BOOTSTRAP_CHAT_ENABLED "true"
set_env_var BOOTSTRAP_TRANSCRIPTION_ENABLED "false"
set_env_var BOOTSTRAP_AI_LEVEL "min"
;;
3)
set_env_var COMPOSE_PROFILES "media,transcribe,llm"
set_env_var WHISPER_MODEL "small"
set_env_var LLM_MODEL_FILE "qwen3.5-4b-instruct-q4_k_m.gguf"
set_env_var LLM_MODEL_URL "https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/Qwen3.5-4B-Q4_K_M.gguf"
set_env_var LLM_MODEL_MIN_SIZE "2000000000"
set_env_var LLM_TOKENIZER_FILE "qwen3.5-4b-instruct.tokenizer.json"
set_env_var LLM_TOKENIZER_URL "https://huggingface.co/Qwen/Qwen3.5-4B/resolve/main/tokenizer.json"
set_env_var BOOTSTRAP_CHAT_ENABLED "true"
set_env_var BOOTSTRAP_TRANSCRIPTION_ENABLED "true"
set_env_var BOOTSTRAP_AI_LEVEL "min"
;;
4)
set_env_var COMPOSE_PROFILES "media,transcribe,llm"
set_env_var WHISPER_MODEL "medium"
set_env_var LLM_MODEL_FILE "qwen3.5-9b-instruct-q4_k_m.gguf"
set_env_var LLM_MODEL_URL "https://huggingface.co/unsloth/Qwen3.5-9B-GGUF/resolve/main/Qwen3.5-9B-Q4_K_M.gguf"
set_env_var LLM_MODEL_MIN_SIZE "5000000000"
set_env_var LLM_TOKENIZER_FILE "qwen3.5-9b-instruct.tokenizer.json"
set_env_var LLM_TOKENIZER_URL "https://huggingface.co/Qwen/Qwen3.5-9B/resolve/main/tokenizer.json"
set_env_var BOOTSTRAP_CHAT_ENABLED "true"
set_env_var BOOTSTRAP_TRANSCRIPTION_ENABLED "true"
set_env_var BOOTSTRAP_AI_LEVEL "medium"
;;
5)
set_env_var COMPOSE_PROFILES "media,transcribe-gpu,llm-gpu"
set_env_var WHISPER_MODEL "large-v3"
set_env_var LLM_MODEL_FILE "qwen3.5-35b-a3b-instruct-q4_k_m.gguf"
set_env_var LLM_MODEL_URL "https://huggingface.co/unsloth/Qwen3.5-35B-A3B-GGUF/resolve/main/Qwen3.5-35B-A3B-Q4_K_M.gguf"
set_env_var LLM_MODEL_MIN_SIZE "20000000000"
set_env_var LLM_TOKENIZER_FILE "qwen3.5-35b-a3b-instruct.tokenizer.json"
set_env_var LLM_TOKENIZER_URL "https://huggingface.co/Qwen/Qwen3.5-35B-A3B/resolve/main/tokenizer.json"
set_env_var BOOTSTRAP_CHAT_ENABLED "true"
set_env_var BOOTSTRAP_TRANSCRIPTION_ENABLED "true"
set_env_var BOOTSTRAP_AI_LEVEL "max"
;;
esac
# --monitoring: дописать профиль monitoring к тому, что выставил пресет
# выше (set_env_var перезаписывает значение целиком, поэтому мониторинг
# нельзя было получить только сменой пресета — см. комментарий у MONITORING).
if [ "$MONITORING" = "1" ]; then
current_profiles="$(get_env_var COMPOSE_PROFILES)"
case ",${current_profiles}," in
*,monitoring,*) ;;
*) set_env_var COMPOSE_PROFILES "${current_profiles},monitoring" ;;
esac
fi
echo "[install] .env обновлён (пресет ${PRESET}, COMPOSE_PROFILES=$(get_env_var COMPOSE_PROFILES))"
echo "[install] Проверяю .env на dev-плейсхолдеры перед подъёмом стека"
validate_prod_env
# ---------------------------------------------------------------------------
# Подъём стека и миграции/seed.
# ---------------------------------------------------------------------------
# `COMPOSE_PROFILES` в .env НЕ подхватывается автоматически командой
# `docker compose ... up` в этой версии compose (проверено на v5.3.1) —
# нужен явный повторяемый флаг `--profile`, иначе профильные сервисы
# (livekit/coturn для media, транскрибация/LLM для остальных пресетов)
# молча не поднимаются, хотя .env выглядит корректно заполненным.
PROFILE_ARGS=()
IFS=',' read -r -a _profiles <<< "$(get_env_var COMPOSE_PROFILES)"
for _p in "${_profiles[@]}"; do
[ -n "$_p" ] && PROFILE_ARGS+=(--profile "$_p")
done
echo "[install] Рендерю coturn/livekit/egress из шаблонов (deploy/render-templates.sh)"
"$SCRIPT_DIR/deploy/render-templates.sh"
echo "[install] docker compose pull (лучше сразу, но необязательный шаг — образы, которых нет в реестре под текущим тегом, пропускаются)"
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "${PROFILE_ARGS[@]}" pull --ignore-buildable 2>/dev/null || true
echo "[install] docker compose build"
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "${PROFILE_ARGS[@]}" build
echo "[install] Поднимаю postgres/redis (--wait) — нужны для миграций до старта backend"
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --wait postgres redis
# backend.lifespan бутстрапит instance_settings при КАЖДОМ старте приложения,
# поэтому миграции обязаны применяться ДО первого запуска backend/worker —
# иначе на чистой БД backend падает на "relation instance_settings does not
# exist" и healthcheck (--wait) никогда не проходит. `compose run` запускает
# одноразовый контейнер с нужной командой, не поднимая uvicorn/lifespan.
echo "[install] Применяю миграции Alembic и seed (админ/справочники) — до старта backend"
# `--no-sync`: окружение собрано в образе, повторная синхронизация в рантайме
# только тянула бы dev-группу (см. комментарий в backend/Dockerfile).
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run --no-sync alembic upgrade head
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run --no-sync python -m scripts.seed
echo "[install] docker compose up -d --wait (backend/worker/nginx и остальные сервисы профиля)"
# На слабой/загруженной машине healthcheck может не успеть пройти за отведённые
# retries, и `--wait` вернёт "container is
# unhealthy", хотя сервис через несколько секунд становится healthy. Команда
# идемпотентна, поэтому повторяем её несколько раз: повтор лишь дожидается
# уже стартующих контейнеров, ничего не пересоздавая.
UP_ATTEMPTS=3
for attempt in $(seq 1 "$UP_ATTEMPTS"); do
if docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "${PROFILE_ARGS[@]}" up -d --wait; then
break
fi
if [ "$attempt" -eq "$UP_ATTEMPTS" ]; then
echo "[install] ОШИБКА: сервисы не стали healthy за $UP_ATTEMPTS попыток." >&2
echo "[install] Проверьте логи: docker compose -f $COMPOSE_FILE --env-file $ENV_FILE logs backend worker" >&2
exit 1
fi
echo "[install] Не все сервисы healthy (попытка $attempt/$UP_ATTEMPTS) — жду 10с и повторяю..."
sleep 10
done
# ---------------------------------------------------------------------------
# Настройки модулей инстанса (чат/AI-уровень) под пресет — только на живой
# установке (fix/installer-preset-modules): при СВЕЖЕМ .env (FRESH_ENV=1)
# они уже применятся сами при бутстрапе backend (lifespan читает
# BOOTSTRAP_* из .env), повторно накатывать не нужно. При ПОВТОРНОМ запуске
# .env уже существовал ДО этого запуска — настройки уже в БД и могли быть
# вручную изменены администратором, поэтому перезаписываем их только с
# явным подтверждением (или --yes для неинтерактивного режима).
# ---------------------------------------------------------------------------
if [ "$FRESH_ENV" != "1" ]; then
APPLY_PRESET_SETTINGS=0
if [ "$ASSUME_YES" = "1" ]; then
APPLY_PRESET_SETTINGS=1
else
printf "Обновить настройки модулей инстанса (чат, уровень AI) под пресет %s? [Y/n]: " "$PRESET"
read -r update_settings_answer
case "$update_settings_answer" in
n|N|no|No) APPLY_PRESET_SETTINGS=0 ;;
*) APPLY_PRESET_SETTINGS=1 ;;
esac
fi
if [ "$APPLY_PRESET_SETTINGS" = "1" ]; then
echo "[install] Применяю настройки модулей инстанса под пресет ${PRESET}"
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec -T backend uv run --no-sync python -m scripts.apply_preset_settings --force
else
echo "[install] Настройки модулей инстанса НЕ изменены — сохранены ручные правки администратора"
fi
fi
VERSION_VALUE="$(cat "$VERSION_FILE")"
ADMIN_EMAIL="$(get_env_var SEED_ADMIN_EMAIL)"
ADMIN_PASSWORD="$(get_env_var SEED_ADMIN_PASSWORD)"
GRAFANA_USER="$(get_env_var GRAFANA_ADMIN_USER)"
if [ "$MONITORING" = "1" ]; then
MONITORING_BLOCK="$(cat <<EOF2
Мониторинг (Grafana/Prometheus) поднят вместе со стеком (--monitoring):
Prometheus: http://127.0.0.1:9090 (только localhost, см. docs/deploy/DEPLOYMENT.md)
Grafana: http://127.0.0.1:3001 (${GRAFANA_USER:-admin} / см. .env GRAFANA_ADMIN_PASSWORD)
EOF2
)"
else
MONITORING_BLOCK="$(cat <<EOF2
Мониторинг (Grafana/Prometheus) — отдельный профиль compose, не входит
в базовые пресеты. Поднять сейчас: docker compose -f ${COMPOSE_FILE} --env-file ${ENV_FILE} --profile monitoring up -d
(или перезапустите install.sh с флагом --monitoring).
Grafana: http://localhost:3001 (${GRAFANA_USER:-admin} / см. .env GRAFANA_ADMIN_PASSWORD)
EOF2
)"
fi
cat <<EOF
============================================================
VidConf ${VERSION_VALUE} установлен (пресет ${PRESET}).
============================================================
Backend: http://localhost/api/health
Frontend: http://localhost/
Админ: ${ADMIN_EMAIL:-см. .env SEED_ADMIN_EMAIL} / ${ADMIN_PASSWORD:-см. .env SEED_ADMIN_PASSWORD}
(смените пароль сразу после первого входа)
${MONITORING_BLOCK}
Настройки модулей инстанса (чат, уровень AI) синхронизированы под
пресет ${PRESET} (админка → Настройки, при желании переключите вручную).
Повторный запуск ./install.sh с другим --preset — апгрейд/даунгрейд
на месте (модели докачиваются, .env обновляется точечно, секреты и
пользовательские правки сохраняются; настройки модулей обновляются
только с вашего подтверждения или флагом --yes).
============================================================
EOF