Backend entrypoint sources certs.sh for CA certificate installation,
but the file was never copied into the Docker image — causing
container crash loop on startup (regression in 8fd23f7e).
Changes:
- docker/backend.Dockerfile, docker/all-in-one.Dockerfile: COPY certs.sh
- docker/backend.entrypoint.sh: remove dead install_llm_ca_certs,
install_certificates (replaced by docker/certs.sh); update @INVARIANT
- backend/src/core/ssl.py: fix describe_context() to report actual
system cert count (SSLContext.capath attr does not exist in Python 3)
- __tests__: rewrite stale tests asserting LLM_SSL_VERIFY=false →
verify=False; behaviour is permanently removed — always CERT_REQUIRED
- backend/tests/integration/test_backend_container.py [new]: 5 tests
verifying certs.sh presence, sourceability, and full-stack smoke
(testcontainers PG → entrypoint → migrations → health)
- conftest.py: restore health-wait loop and fixtures in superset_container
- ADR-0009, README.md, scripts, .env: align docs with centralized SSL
306 lines
14 KiB
Bash
Executable File
306 lines
14 KiB
Bash
Executable File
#!/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"
|
||
|
||
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 "$@"
|