Files
ss-tools/docker/backend.entrypoint.sh
2026-06-01 16:34:07 +03:00

541 lines
27 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
set -euo pipefail
# #region docker.backend.entrypoint [C:4] [TYPE Module] [SEMANTICS docker,entrypoint,enterprise,certs]
# @BRIEF Container entrypoint — установка корп. сертификатов, wait-for-DB,
# Alembic миграции, bootstrap admin, затем запуск backend (exec "$@").
# @PRE Python runtime и backend source доступны в /app/backend.
# Переменная CERTS_PATH указывает на директорию с .crt файлами (или пусто).
# DATABASE_URL установлен и указывает на доступный PostgreSQL.
# @POST Сертификаты установлены в системное хранилище и NSS базу Chromium.
# БД подтверждена как доступная. Alembic миграции применены.
# Admin создан (если INITIAL_ADMIN_CREATE=true). Backend запущен через exec "$@".
# @SIDE_EFFECT Модифицирует /usr/local/share/ca-certificates/ и вызывает update-ca-certificates.
# Импортирует CA в NSS DB (~/.pki/nssdb) для Chromium.
# Выполняет Alembic upgrade head.
# @LAYER Infrastructure
# @RELATION DEPENDS_ON -> [backend/src/scripts/create_admin.py]
# @RELATION DEPENDS_ON -> [backend/src/scripts/init_auth_db.py]
# @INVARIANT Если CERTS_PATH пуст или нет .crt файлов — install_certificates не падает.
# @INVARIANT Существующий admin аккаунт НЕ перезаписывается при перезапуске контейнера
# (create_admin.py идемпотентен).
# @INVARIANT Повторный запуск install_llm_ca_certs не дублирует сертификаты в bundle.
# @INVARIANT Повторный запуск install_ca_to_nss не дублирует сертификаты в NSS DB.
# @INVARIANT wait_for_db exit(1) если БД недоступна после DB_WAIT_ATTEMPTS попыток —
# контейнер не продолжит запуск с недоступной БД.
# @INVARIANT Alembic миграции выполняются ТОЛЬКО в entrypoint, НЕ в app.py lifespan.
# #endregion docker.backend.entrypoint
# ======================================================================
# install_certificates — корпоративные CA-сертификаты
# ======================================================================
# #region docker.backend.entrypoint.install_certificates [C:3] [TYPE Function]
# @BRIEF Устанавливает корпоративные .crt сертификаты из CERTS_PATH в системное хранилище Debian.
# @PRE CERTS_PATH указывает на директорию (может быть пуста или не существовать).
# В системе установлен пакет ca-certificates.
# @POST .crt файлы скопированы в /usr/local/share/ca-certificates/custom/ и
# добавлены в системное хранилище через update-ca-certificates.
# @SIDE_EFFECT Запускает update-ca-certificates — модифицирует /etc/ssl/certs/.
# @LAYER Infrastructure
# @RELATION DEPENDS_ON -> [ca-certificates (system package)]
# @EXAMPLE:
# CERTS_PATH=/opt/certs docker run ... # монтируем ./certs в /opt/certs
# В ./certs кладём: my-corporate-ca.crt, another-ca.crt
# entrypoint установит оба в доверенные.
install_certificates() {
local certs_dir="${CERTS_PATH:-/opt/certs}"
if [ ! -d "$certs_dir" ]; then
echo "[entrypoint] CERTS_PATH=${certs_dir} не существует — пропускаем установку сертификатов"
return 0
fi
local cert_count
cert_count="$(find "$certs_dir" -maxdepth 1 \( -name '*.crt' -o -name '*.pem' \) 2>/dev/null | wc -l)"
if [ "$cert_count" -eq 0 ]; then
echo "[entrypoint] Нет .crt/.pem файлов в ${certs_dir} — пропускаем установку сертификатов"
return 0
fi
local target="/usr/local/share/ca-certificates/custom"
mkdir -p "$target"
echo "[entrypoint] Устанавливаем корпоративные сертификаты из ${certs_dir}..."
local copied=0
# nullglob: если файлов нет, цикл не выполняется
shopt -s nullglob
for cert in "$certs_dir"/*.crt "$certs_dir"/*.pem; do
cp -v "$cert" "$target/" 2>&1 | sed 's/^/[entrypoint] /'
copied=$((copied + 1))
done
shopt -u nullglob
if [ "$copied" -eq 0 ]; then
echo "[entrypoint] Нет файлов для копирования — пропускаем"
return 0
fi
echo "[entrypoint] Обновляем системное хранилище CA-сертификатов..."
update-ca-certificates --fresh 2>&1 | sed 's/^/[entrypoint] /'
echo "[entrypoint] ✅ Установлено ${copied} корпоративных сертификатов"
}
# #endregion docker.backend.entrypoint.install_certificates
# ======================================================================
# install_llm_ca_certs — скачка и установка CA-сертификатов по URL
# ======================================================================
# #region docker.backend.entrypoint.install_llm_ca_certs [C:4] [TYPE Function]
# @BRIEF Скачивает PEM/DER-сертификаты из LLM_CA_CERT_URLS, конвертирует DER→PEM,
# устанавливает в системное хранилище, создаёт хеш-симлинки и при необходимости
# добавляет в ca-certificates.crt напрямую (fallback для OpenSSL 3-совместимости).
# @RATIONALE DER→PEM конвертация необходима, т.к. update-ca-certificates ожидает
# PEM-формат (-----BEGIN CERTIFICATE-----). Корпоративные PKI часто отдают
# сертификаты в DER (бинарном) формате через HTTP.
# @RATIONALE Хеш-симлинки создаются с поддержкой коллизий (.0, .1, .2...), т.к.
# update-ca-certificates в Debian Bookworm иногда пропускает создание ссылок
# для сертификатов из поддиректорий. Fingerprint (SHA256) используется для
# детекции дубликатов вместо имени файла — защита от ложных срабатываний grep.
# @RATIONALE Fallback cat в ca-certificates.crt гарантирует что OpenSSL 3.x
# увидит сертификаты даже если update-ca-certificates их пропустит.
# Перед добавлением проверяется fingerprint — при повторном запуске
# сертификат не дублируется.
# @REJECTED Вариант "только контейнерный volume с .crt файлами" отвергнут —
# требует ручного копирования сертификатов на хост перед запуском.
# URL-based подход позволяет централизованно управлять сертификатами через PKI.
# @REJECTED Использование grep по имени файла/серийному номеру для детекции
# дубликатов отвергнуто — имя файла может совпадать с содержимым PEM,
# а серийный номер не уникален при ротации CA. Fingerprint (SHA256)
# является криптографически уникальным идентификатором.
# @PRE LLM_CA_CERT_URLS — строка с URL через пробел; curl и openssl установлены.
# @POST Сертификаты доступны в /etc/ssl/certs/ca-certificates.crt.
# Хеш-симлинки созданы для каждого сертификата (с поддержкой коллизий).
# @SIDE_EFFECT Скачивает файлы из внешних URL. Модифицирует /etc/ssl/certs/.
# @LAYER Infrastructure
# @EXAMPLE:
# LLM_CA_CERT_URLS="http://pki.company.com/root.crt http://pki.company.com/intermediate.crt"
install_llm_ca_certs() {
local urls="${LLM_CA_CERT_URLS:-}"
if [ -z "$urls" ]; then
echo "[entrypoint] LLM_CA_CERT_URLS не задан — пропускаем скачку сертификатов"
return 0
fi
local work_dir="/tmp/llm-ca-certs"
local target_dir="/usr/local/share/ca-certificates/llm"
mkdir -p "$work_dir" "$target_dir"
echo "[entrypoint] === Скачивание и установка LLM CA-сертификатов ==="
local index=0
for url in $urls; do
url="$(echo "$url" | xargs)" # trim
[ -z "$url" ] && continue
index=$((index + 1))
# Определяем имя файла из URL, или генерируем
local filename
filename="$(basename "$url" | sed 's/[?#].*//')"
if [ -z "$filename" ] || [ "$filename" = "." ] || [ "$filename" = ".." ]; then
filename="llm-ca-${index}.crt"
fi
local raw_file="${work_dir}/${filename}"
local pem_file="${target_dir}/$(echo "$filename" | sed 's/\.[^.]*$//').crt"
echo "[entrypoint] [${index}] Скачивание: ${url}"
# Скачиваем с retry 3 раза
local attempt=0
local success=0
while [ $attempt -lt 3 ]; do
attempt=$((attempt + 1))
# Сначала пробуем с verify, если не получилось — с --insecure
# (chicken-and-egg: PKI сервер может использовать тот же CA, который мы скачиваем)
if curl -sS --connect-timeout 10 --max-time 30 -o "$raw_file" "$url" 2>/dev/null; then
success=1
break
fi
# TLS verify failed? Пробуем без проверки (корп. PKI bootstrap)
if [ $attempt -eq 1 ]; then
echo "[entrypoint] ↻ TLS verify failed, retrying with --insecure..."
if curl -sS -k --connect-timeout 10 --max-time 30 -o "$raw_file" "$url" 2>/dev/null; then
echo "[entrypoint] ✓ Скачано с --insecure (проверка fingerprint в конце)"
success=1
break
fi
fi
echo "[entrypoint] ⚠ Попытка ${attempt}/3 не удалась, повтор через 2с..."
sleep 2
done
if [ "$success" -eq 0 ]; then
echo "[entrypoint] ❌ Не удалось скачать: ${url} — пропускаем"
continue
fi
# Определяем формат: PEM или DER
if openssl x509 -in "$raw_file" -inform PEM -noout 2>/dev/null; then
echo "[entrypoint] ✓ Формат: PEM"
cp "$raw_file" "$pem_file"
elif openssl x509 -in "$raw_file" -inform DER -noout 2>/dev/null; then
echo "[entrypoint] ↻ Формат: DER — конвертируем в PEM"
if ! openssl x509 -in "$raw_file" -inform DER -out "$pem_file" -outform PEM 2>&1; then
echo "[entrypoint] ❌ Ошибка конвертации DER→PEM — пропускаем"
rm -f "$raw_file" "${pem_file}"
continue
fi
else
echo "[entrypoint] ❌ Неизвестный формат (не PEM и не DER) — пропускаем"
# Выводим первые 20 байт для диагностики
local first_bytes
first_bytes="$(head -c 20 "$raw_file" 2>/dev/null | cat -v)"
echo "[entrypoint] Первые 20 байт: ${first_bytes}"
rm -f "$raw_file"
continue
fi
chmod 644 "$pem_file"
echo "[entrypoint] ✅ Установлен: $(basename "$pem_file")"
rm -f "$raw_file"
done
if [ -z "$(ls -A "$target_dir" 2>/dev/null)" ]; then
echo "[entrypoint] Нет скачанных сертификатов — пропускаем"
rm -rf "$work_dir"
return 0
fi
# Обновляем системное хранилище
echo "[entrypoint] --- Обновление системного хранилища CA-сертификатов ---"
update-ca-certificates --fresh 2>&1 | sed 's/^/[entrypoint] /'
# Проверяем, попали ли сертификаты в бандл, и создаём хеш-симлинки
echo "[entrypoint] --- Валидация установки сертификатов ---"
for cert_file in "$target_dir"/*.crt; do
[ -f "$cert_file" ] || continue
local cert_name
cert_name="$(basename "$cert_file" .crt)"
local cert_subject
cert_subject="$(openssl x509 -in "$cert_file" -noout -subject 2>/dev/null | head -1 || echo "unknown")"
local cert_fingerprint
cert_fingerprint="$(openssl x509 -in "$cert_file" -noout -fingerprint -sha256 2>/dev/null | sed 's/.*=//' || echo "")"
# Проверяем есть ли в ca-certificates.crt по fingerprint (криптографически уникален)
if [ -n "$cert_fingerprint" ] && \
grep -qF "$cert_fingerprint" /etc/ssl/certs/ca-certificates.crt 2>/dev/null; then
echo "[entrypoint] ✅ ${cert_name} — в ca-certificates.crt (${cert_subject})"
else
echo "[entrypoint] ⚠ ${cert_name}НЕ в ca-certificates.crt. Добавляем напрямую..."
cat "$cert_file" >> /etc/ssl/certs/ca-certificates.crt
echo "[entrypoint] Добавлен напрямую в ca-certificates.crt"
fi
# Создаём хеш-симлинку с поддержкой коллизий (.0, .1, .2...)
local hash_val
hash_val="$(openssl x509 -in "$cert_file" -noout -hash 2>/dev/null || true)"
if [ -n "$hash_val" ]; then
local suffix=0
local link_path="/etc/ssl/certs/${hash_val}.${suffix}"
# Ищем первый свободный suffix или symlink уже указывающий на наш файл
while [ -L "$link_path" ]; do
if [ "$(readlink "$link_path")" = "$cert_file" ]; then
break 2 # уже есть, ничего не делаем
fi
suffix=$((suffix + 1))
link_path="/etc/ssl/certs/${hash_val}.${suffix}"
done
if [ ! -L "$link_path" ]; then
ln -sf "$cert_file" "$link_path"
echo "[entrypoint] 🔗 Создана симлинка: ${hash_val}.${suffix}${cert_name}"
fi
fi
done
# Проверяем итоговое количество
local total_in_bundle
total_in_bundle="$(awk -v cmd='openssl x509 -noout -subject' '/BEGIN/{close(cmd)};{print | cmd}' \
< /etc/ssl/certs/ca-certificates.crt 2>/dev/null | wc -l || echo "0")"
echo "[entrypoint] ✅ Всего сертификатов в bundle: ${total_in_bundle}"
rm -rf "$work_dir"
echo "[entrypoint] === Установка LLM CA-сертификатов завершена ==="
}
# #endregion docker.backend.entrypoint.install_llm_ca_certs
# ======================================================================
# bootstrap_admin — создание admin пользователя
# ======================================================================
# #region docker.backend.entrypoint.bootstrap_admin [C:2] [TYPE Function]
# @BRIEF Создание admin пользователя из переменных окружения (идемпотентно).
# @PRE INITIAL_ADMIN_CREATE=true, INITIAL_ADMIN_USERNAME и INITIAL_ADMIN_PASSWORD заданы.
# @POST Admin пользователь создан в БД (если ещё не существовал).
# @LAYER Infrastructure
bootstrap_admin() {
local create_flag="${INITIAL_ADMIN_CREATE:-false}"
local username="${INITIAL_ADMIN_USERNAME:-}"
local password="${INITIAL_ADMIN_PASSWORD:-}"
local email="${INITIAL_ADMIN_EMAIL:-}"
case "${create_flag,,}" in
true|1|yes|y)
;;
*)
echo "[entrypoint] INITIAL_ADMIN_CREATE disabled — пропускаем bootstrap admin"
return 0
;;
esac
if [[ -z "${username}" ]]; then
echo "[entrypoint] INITIAL_ADMIN_USERNAME required when INITIAL_ADMIN_CREATE=true" >&2
return 1
fi
if [[ -z "${password}" ]]; then
echo "[entrypoint] INITIAL_ADMIN_PASSWORD required when INITIAL_ADMIN_CREATE=true" >&2
return 1
fi
echo "[entrypoint] Initializing auth database..."
python3 src/scripts/init_auth_db.py
echo "[entrypoint] Creating admin user '${username}' (idempotent)..."
if [[ -n "${email}" ]]; then
python3 src/scripts/create_admin.py --username "${username}" --password "${password}" --email "${email}"
else
python3 src/scripts/create_admin.py --username "${username}" --password "${password}"
fi
echo "[entrypoint] ✅ Admin bootstrap complete"
}
# #endregion docker.backend.entrypoint.bootstrap_admin
# ======================================================================
# install_ca_to_nss — импорт корпоративных CA в NSS базу Chromium
# ======================================================================
# #region docker.backend.entrypoint.install_ca_to_nss [C:3] [TYPE Function]
# @BRIEF Импортирует PEM-сертификаты из /usr/local/share/ca-certificates/
# в NSS Shared DB (~/.pki/nssdb), чтобы Chromium (Playwright) доверял
# корпоративным CA (исправляет ERR_CERT_AUTHORITY_INVALID).
# @RATIONALE Playwright's Chromium использует NSS Shared DB (~/.pki/nssdb)
# для CA trust, отдельно от системного OpenSSL bundle. update-ca-certificates
# добавляет сертификаты в /etc/ssl/certs/, но Chromium их не видит.
# certutil -A -t "C,," импортирует PEM как доверенный CA для TLS.
# @RATIONALE NSS импорт выполняется после install_llm_ca_certs (скачивает .pem)
# и update-ca-certificates (устанавливает в систему). Этот порядок
# гарантирует, что PEM-файлы уже есть на диске до создания NSS DB.
# @RATIONALE Для дедупликации используется SHA256 fingerprint сертификата,
# а не nickname — nickname может совпадать для файлов из разных директорий.
# Nickname формируется как "llm-имя" или "custom-имя" для избежания коллизий.
# @REJECTED Использование Playwright --ignore-certificate-errors отвергнуто —
# отключает ВСЕ SSL проверки глобально, а не только для корп. CA.
# @REJECTED Nickname-only дедупликация отвергнута — при разных файлах
# с одинаковым именем из разных директорий второй молча пропускается.
# @PRE certutil (libnss3-tools) установлен. PEM-файлы есть в target_dirs.
# @POST Сертификаты добавлены в NSS базу Chromium с trust-атрибутами "C,,".
# Повторный вызов не дублирует сертификаты.
# @SIDE_EFFECT Создаёт ~/.pki/nssdb/ если не существовал.
# @LAYER Infrastructure
install_ca_to_nss() {
if ! command -v certutil >/dev/null 2>&1; then
echo "[entrypoint] certutil (libnss3-tools) не найден — пропускаем NSS импорт"
return 0
fi
local nss_db_dir="${HOME:-/root}/.pki/nssdb"
local nss_db="sql:${nss_db_dir}"
# CA certs могут быть в llm/ или custom/
local target_dirs=(
"/usr/local/share/ca-certificates/llm"
"/usr/local/share/ca-certificates/custom"
)
# Собираем все PEM-файлы
local certs=()
for dir in "${target_dirs[@]}"; do
if [ -d "$dir" ]; then
shopt -s nullglob
for f in "$dir"/*.pem "$dir"/*.crt; do
certs+=("$f")
done
shopt -u nullglob
fi
done
if [ ${#certs[@]} -eq 0 ]; then
echo "[entrypoint] Нет PEM-файлов для NSS импорта — пропускаем"
return 0
fi
# Создаём NSS DB если нет или если не открывается
if [ ! -d "$nss_db_dir" ]; then
mkdir -p "$nss_db_dir"
echo "[entrypoint] Создание NSS DB: ${nss_db}"
certutil -N -d "$nss_db" --empty-password 2>/dev/null || true
elif ! certutil -L -d "$nss_db" >/dev/null 2>&1; then
# DB существует но повреждена / неверный формат
echo "[entrypoint] NSS DB повреждена, пересоздаём..."
rm -f "${nss_db_dir}"/*.db "${nss_db_dir}"/pkcs11.txt 2>/dev/null || true
certutil -N -d "$nss_db" --empty-password 2>/dev/null || true
fi
# Проверяем что DB работает
if ! certutil -L -d "$nss_db" >/dev/null 2>&1; then
echo "[entrypoint] ⚠ NSS DB не создалась — пропускаем"
return 0
fi
local imported=0
for cert in "${certs[@]}"; do
local fingerprint
fingerprint="$(openssl x509 -in "$cert" -noout -fingerprint -sha256 2>/dev/null | sed 's/.*=//' || true)"
[ -z "$fingerprint" ] && continue
# Используем директорию как префикс для nickname
local dir_prefix
dir_prefix="$(basename "$(dirname "$cert")")"
local nickname="${dir_prefix}-$(basename "$cert" | sed 's/\.[^.]*$//')"
# Дедупликация по fingerprint (не по nickname)
if certutil -L -d "$nss_db" 2>/dev/null | grep -qF "$fingerprint"; then
echo "[entrypoint] ✓ NSS: ${nickname} уже импортирован (fingerprint совпадает)"
continue
fi
# Если nickname занят другим сертификатом — добавляем с уникальным именем
local actual_nickname="$nickname"
if certutil -L -d "$nss_db" -n "$actual_nickname" >/dev/null 2>&1; then
actual_nickname="${nickname}-$(echo "$fingerprint" | cut -c1-8)"
fi
# Добавляем с trust-атрибутами "C,," (CA для TLS)
if certutil -A -d "$nss_db" -n "$actual_nickname" -t "C,," -i "$cert" -a 2>/dev/null; then
echo "[entrypoint] NSS импортирован: ${actual_nickname}"
imported=$((imported + 1))
else
echo "[entrypoint] ⚠ NSS ошибка импорта: ${nickname}"
fi
done
if [ "$imported" -gt 0 ]; then
echo "[entrypoint] ✅ NSS: импортировано ${imported} сертификатов"
fi
}
# #endregion docker.backend.entrypoint.install_ca_to_nss
# ======================================================================
# wait_for_db — проверка доступности БД с retry
# ======================================================================
# #region docker.backend.entrypoint.wait_for_db [C:3] [TYPE Function]
# @BRIEF Проверяет доступность PostgreSQL с retry-логикой перед запуском миграций.
# @PRE DATABASE_URL установлен и указывает на валидный PostgreSQL.
# @POST Соединение с БД подтверждено (SELECT 1) или скрипт exit(1) после таймаута.
# @RATIONALE Без wait-for-db enterprise-деплой с внешним PostgreSQL падает
# мгновенно — entrypoint интерпретирует connection refused как "пустая БД".
# @REJECTED Использование pg_isready напрямую отвергнуто — клиент psql может
# отсутствовать в python:3.11-slim образе. SQLAlchemy-проверка универсальна.
wait_for_db() {
local max_attempts="${DB_WAIT_ATTEMPTS:-30}"
local delay="${DB_WAIT_DELAY:-2}"
local attempt=0
echo "[entrypoint] Waiting for database (max ${max_attempts} attempts, ${delay}s delay)..."
while [ $attempt -lt $max_attempts ]; do
attempt=$((attempt + 1))
# Capture stderr to detect permanent errors (KeyError, ArgumentError)
local _err_output
_err_output="$(python3 -c "
import os, sys
from sqlalchemy import create_engine, text
try:
url = os.environ['DATABASE_URL']
except KeyError:
print('FATAL: DATABASE_URL environment variable is not set', file=sys.stderr)
sys.exit(99)
try:
e = create_engine(url)
with e.connect() as c:
c.execute(text('SELECT 1'))
except Exception as exc:
# Permanent errors — no point retrying
err_str = str(exc).lower()
if 'database_url' in err_str or 'could not parse' in err_str or 'invalid' in err_str:
print(f'FATAL: {exc}', file=sys.stderr)
sys.exit(99)
sys.exit(1)
" 2>&1)"
local _rc=$?
if [ $_rc -eq 0 ]; then
echo "[entrypoint] ✅ Database is reachable"
return 0
fi
# Permanent error — exit immediately, don't waste retries
if [ $_rc -eq 99 ]; then
echo "[entrypoint] ❌ $_err_output" >&2
return 1
fi
echo "[entrypoint] Attempt ${attempt}/${max_attempts} — retrying in ${delay}s..."
sleep "$delay"
done
# P0: Mask credentials — never print full DATABASE_URL to logs
local _masked_url
_masked_url="$(echo "$DATABASE_URL" | sed -E 's|://([^:]+):([^@]+)@|://\1:***@|')"
echo "[entrypoint] ❌ Database not reachable after ${max_attempts} attempts" >&2
echo "[entrypoint] DATABASE_URL=${_masked_url}" >&2
return 1
}
# #endregion docker.backend.entrypoint.wait_for_db
# ======================================================================
# MAIN
# ======================================================================
install_certificates
install_llm_ca_certs
install_ca_to_nss
# ── C1: Wait for database before any migration logic ──
wait_for_db
# ── C2: DB state detection (now safe — DB is confirmed reachable) ──
_db_state=0
python3 -c "
import os
from sqlalchemy import create_engine, inspect
e = create_engine(os.environ['DATABASE_URL'])
insp = inspect(e)
if 'alembic_version' in insp.get_table_names():
exit(0)
if not insp.get_table_names():
exit(1)
exit(2)
" 2>&1 || _db_state=$?
if [ $_db_state -eq 0 ]; then
echo "[entrypoint] Running Alembic migrations (incremental)..."
alembic upgrade head 2>&1 | sed 's/^/[entrypoint] /'
echo "[entrypoint] ✅ Alembic migrations applied"
elif [ $_db_state -eq 1 ]; then
echo "[entrypoint] Empty database — running full Alembic upgrade..."
alembic upgrade head 2>&1 | sed 's/^/[entrypoint] /'
echo "[entrypoint] ✅ Alembic initial migration applied"
else
echo "[entrypoint] Legacy database — running Alembic upgrade (not stamp)..."
# H2: Запускаем полноценный upgrade вместо stamp head — legacy-БД может
# отсутствовать таблицы, добавленные миграциями после написания entrypoint.
alembic upgrade head 2>&1 | sed 's/^/[entrypoint] /'
echo "[entrypoint] ✅ Alembic upgrade complete for legacy database"
fi
bootstrap_admin
echo "[entrypoint] Starting backend: $*"
exec "$@"