#!/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) 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 while [ $# -gt 0 ]; do case "$1" in --preset) PRESET="$2" shift 2 ;; --yes|-y) ASSUME_YES=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 } # --------------------------------------------------------------------------- # Автодетект железа (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 <&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" # Профили 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 echo "[install] .env обновлён (пресет ${PRESET}, COMPOSE_PROFILES=$(get_env_var COMPOSE_PROFILES))" # --------------------------------------------------------------------------- # Подъём стека и миграции/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" docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run alembic upgrade head docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run python -m scripts.seed echo "[install] docker compose up -d --wait (backend/worker/nginx и остальные сервисы профиля)" # Первый старт backend/worker включает `uv run` (синхронизация окружения + # компиляция байткода) — на слабой/загруженной машине 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 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)" cat <