Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

450
install.sh Executable file
View File

@@ -0,0 +1,450 @@
#!/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
}
# ---------------------------------------------------------------------------
# Автодетект железа (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 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 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"
# Профили 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] docker compose pull (лучше сразу, но необязательный шаг — образы, которых нет в реестре под текущим тегом, пропускаются)"
docker compose -f "$COMPOSE_FILE" "${PROFILE_ARGS[@]}" pull --ignore-buildable 2>/dev/null || true
echo "[install] docker compose build"
docker compose -f "$COMPOSE_FILE" "${PROFILE_ARGS[@]}" build
echo "[install] Поднимаю postgres/redis (--wait) — нужны для миграций до старта backend"
docker compose -f "$COMPOSE_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" run --rm backend uv run alembic upgrade head
docker compose -f "$COMPOSE_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" "${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 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" 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 <<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}
(смените пароль сразу после первого входа)
Мониторинг (Grafana/Prometheus) — отдельный профиль compose, не входит
в базовые пресеты:
docker compose -f ${COMPOSE_FILE} --profile monitoring up -d
Grafana: http://localhost:3001 (${GRAFANA_USER:-admin} / см. .env GRAFANA_ADMIN_PASSWORD)
Настройки модулей инстанса (чат, уровень AI) синхронизированы под
пресет ${PRESET} (админка → Настройки, при желании переключите вручную).
Повторный запуск ./install.sh с другим --preset — апгрейд/даунгрейд
на месте (модели докачиваются, .env обновляется точечно, секреты и
пользовательские правки сохраняются; настройки модулей обновляются
только с вашего подтверждения или флагом --yes).
============================================================
EOF