#!/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 пуст или нет сертификатов — certs.sh:install_all_certs не падает. # @INVARIANT Существующий admin аккаунт НЕ перезаписывается при перезапуске контейнера # (create_admin.py идемпотентен). # @INVARIANT Повторный запуск certs.sh:install_all_certs не дублирует сертификаты в bundle. # @INVARIANT Повторный запуск certs.sh: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 # NOTE: install_certificates and install_llm_ca_certs removed — replaced by docker/certs.sh:install_all_certs. # Centralized CERTS_PATH is the only certificate source. LLM_CA_CERT_URLS env var is removed (refs ADR-0009). # ====================================================================== # 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 импорт выполняется после certs.sh:install_all_certs # (устанавливает .crt в /usr/local/share/ca-certificates/custom/) # и update-ca-certificates (добавляет в систему). Этот порядок # гарантирует, что PEM-файлы уже есть на диске до создания NSS DB. # @RATIONALE Для дедупликации используется SHA256 fingerprint сертификата, # а не nickname — nickname может совпадать для файлов из разных директорий. # Nickname формируется как "custom-имя" (все сертификаты из CERTS_PATH). # @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}" # All CA certs are installed via docker/certs.sh into custom/ dir local target_dirs=( "/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 # ====================================================================== source "$(dirname "$0")/certs.sh" download_llm_ca_certs install_all_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 "$@"