- LLM_SSL_VERIFY env var to disable SSL verification for corporate proxies - install_llm_ca_certs() entrypoint function — downloads PEM/DER certs from LLM_CA_CERT_URLS, converts DER→PEM, installs to system CA store - _format_connection_error() — detailed exception chain logging - 7 new unit tests for _get_ssl_verify, _format_connection_error, verify= param - LLM_CA_CERT_URLS and LLM_SSL_VERIFY in .env.enterprise-clean - Removed duplicate @RELATION DEPENDS_ON -> [EXT:Library:tenacity]
341 lines
15 KiB
Bash
Executable File
341 lines
15 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 — установка корп. сертификатов, Playwright (lazy), bootstrap admin,
|
||
# затем запуск backend (exec "$@").
|
||
# @PRE Python runtime и backend source доступны в /app/backend.
|
||
# Переменная CERTS_PATH указывает на директорию с .crt файлами (или пусто).
|
||
# @POST Сертификаты установлены в системное хранилище. Playwright Chromium установлен (lazy).
|
||
# Admin создан (если INITIAL_ADMIN_CREATE=true). Backend запущен через exec "$@".
|
||
# @SIDE_EFFECT Модифицирует /usr/local/share/ca-certificates/ и вызывает update-ca-certificates.
|
||
# Скачивает Chromium ~377MB при первом запуске.
|
||
# @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 Playwright Chromium устанавливается только если отсутствует в кеше.
|
||
# #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
|
||
for cert in "$certs_dir"/*.crt "$certs_dir"/*.pem; do
|
||
if [ -f "$cert" ]; then
|
||
cp -v "$cert" "$target/" 2>&1 | sed 's/^/[entrypoint] /'
|
||
copied=$((copied + 1))
|
||
fi
|
||
done
|
||
|
||
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-совместимости).
|
||
# @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
|
||
local downloaded_names=""
|
||
|
||
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/\.[^.]*$//').pem"
|
||
|
||
echo "[entrypoint] [${index}] Скачивание: ${url}"
|
||
|
||
# Скачиваем с retry 3 раза
|
||
local attempt=0
|
||
local success=0
|
||
while [ $attempt -lt 3 ]; do
|
||
attempt=$((attempt + 1))
|
||
if curl -sS --connect-timeout 10 --max-time 30 -o "$raw_file" "$url" 2>/dev/null; then
|
||
success=1
|
||
break
|
||
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"
|
||
openssl x509 -in "$raw_file" -inform DER -out "$pem_file" -outform PEM
|
||
else
|
||
echo "[entrypoint] ❌ Неизвестный формат (не PEM и не DER) — пропускаем"
|
||
rm -f "$raw_file"
|
||
continue
|
||
fi
|
||
|
||
chmod 644 "$pem_file"
|
||
echo "[entrypoint] ✅ Установлен: $(basename "$pem_file")"
|
||
downloaded_names="$downloaded_names $(basename "$pem_file" .pem)"
|
||
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] --- Валидация установки сертификатов ---"
|
||
local all_ok=true
|
||
|
||
for pem in "$target_dir"/*.pem; do
|
||
[ -f "$pem" ] || continue
|
||
local cert_name
|
||
cert_name="$(basename "$pem" .pem)"
|
||
local cert_subject
|
||
cert_subject="$(openssl x509 -in "$pem" -noout -subject 2>/dev/null | head -1 || echo "unknown")"
|
||
|
||
# Проверяем есть ли в ca-certificates.crt
|
||
if grep -q "$cert_name" /etc/ssl/certs/ca-certificates.crt 2>/dev/null || \
|
||
grep -qF "$(openssl x509 -in "$pem" -noout -serial 2>/dev/null)" /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 "$pem" >> /etc/ssl/certs/ca-certificates.crt
|
||
echo "[entrypoint] ➕ Добавлен напрямую в ca-certificates.crt"
|
||
fi
|
||
|
||
# Создаём хеш-симлинку в /etc/ssl/certs/
|
||
local hash_val
|
||
hash_val="$(openssl x509 -in "$pem" -noout -hash 2>/dev/null || true)"
|
||
if [ -n "$hash_val" ]; then
|
||
local link_path="/etc/ssl/certs/${hash_val}.0"
|
||
if [ ! -L "$link_path" ]; then
|
||
ln -sf "$pem" "$link_path"
|
||
echo "[entrypoint] 🔗 Создана симлинка: ${hash_val}.0 → ${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_playwright — lazy установка Chromium
|
||
# ======================================================================
|
||
# #region docker.backend.entrypoint.install_playwright [C:2] [TYPE Function]
|
||
# @BRIEF Lazy установка Playwright Chromium (~377 MB) при первом запуске.
|
||
# @PRE ~/.cache/ms-playwright доступен (можно закешировать volume).
|
||
# @POST Chromium установлен в PLAYWRIGHT_BROWSERS_PATH.
|
||
# @LAYER Infrastructure
|
||
install_playwright() {
|
||
local pw_dir="${PLAYWRIGHT_BROWSERS_PATH:-$HOME/.cache/ms-playwright}"
|
||
if ls "${pw_dir}"/chromium-*/chrome-linux64/chrome 1>/dev/null 2>&1; then
|
||
echo "[entrypoint] Playwright Chromium already installed at ${pw_dir}"
|
||
return 0
|
||
fi
|
||
|
||
echo "[entrypoint] Installing Playwright Chromium (first start only, ~377 MB)..."
|
||
echo "[entrypoint] Cache with: -v playwright_cache:/root/.cache/ms-playwright"
|
||
python3 -m playwright install chromium --with-deps 2>&1
|
||
echo "[entrypoint] ✅ Playwright Chromium installed"
|
||
}
|
||
# #endregion docker.backend.entrypoint.install_playwright
|
||
|
||
# ======================================================================
|
||
# MAIN
|
||
# ======================================================================
|
||
|
||
install_certificates
|
||
install_llm_ca_certs
|
||
install_playwright
|
||
|
||
# Check DB state: empty, legacy (tables exist but no alembic), or managed
|
||
# || prevents set -e from killing the script on non-zero exit from detection script
|
||
_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 — adding critical migration columns..."
|
||
# Add boot-critical columns that Alembic migrations would add
|
||
python3 -c "
|
||
import os
|
||
from sqlalchemy import create_engine, inspect, text
|
||
e = create_engine(os.environ['DATABASE_URL'])
|
||
with e.begin() as conn:
|
||
inspector = inspect(conn)
|
||
roles_cols = {c['name'] for c in inspector.get_columns('roles')}
|
||
if 'is_admin' not in roles_cols:
|
||
conn.execute(text('ALTER TABLE roles ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE'))
|
||
conn.execute(text(\"UPDATE roles SET is_admin = TRUE WHERE name = 'Admin'\"))
|
||
print(' roles.is_admin column added')
|
||
else:
|
||
print(' roles.is_admin already exists')
|
||
"
|
||
echo "[entrypoint] Stamping Alembic head (tables already exist)..."
|
||
alembic stamp head 2>&1 | sed 's/^/[entrypoint] /'
|
||
echo "[entrypoint] ✅ Alembic stamped head, schema ready"
|
||
fi
|
||
|
||
bootstrap_admin
|
||
|
||
echo "[entrypoint] Starting backend: $*"
|
||
exec "$@"
|