mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
🔥 chore: .env убран отовсюду — установка не читает файл с переменными
Переход на запуск без .env (41a4b0d) не был доведён: в дереве остались файл
`env.example`, чтение instance .env во всём deploy/cli и CI, который этот .env
сам же и создавал из `env.example`. Из-за остатков ломались две вещи.
`chatballs deploy` не работал на установке без .env: _normalize_schema_ownership
брал POSTGRES_USER и POSTGRES_DB из файла и падал с «POSTGRES_USER not set»,
хотя установка исправна. Теперь берёт те же значения по умолчанию, что compose.
`chatballs doctor` выдавал пять ложных ошибок подряд: искал в .env домены,
ACME-почту, POSTGRES_PASSWORD и CHATBALLS_SECRET_KEY. Первое задаёт владелец
в «Настройках», второе генерирует в том с секретами первый старт стека —
снаружи, с хоста, этого не видно, и проверки убраны.
Убрано: env.example (из репозитория, release bundle и README); instance_env_file()
и все его чтения в common/compose/deploy/doctor/status; --env-file instance .env
из вызова compose — остаётся только release.env с digest-пинами образов от CI;
создание .env в gateway:validate. Профиль calls и его адреса читаются из
переменных окружения — оттуда же, откуда их берёт сам compose.
Тесты CLI переведены с фиктивного .env на переменные окружения; три проверки
удалённого поведения doctor убраны. Девять оставшихся проходят вообще без .env
— это и есть проверка, что установка теперь обслуживается. Заодно у
test_custocrm_cli.py выправлены окончания строк: в файле были одиночные CR
внутри кода, отчего diff по нему больше содержательной правки.
Проверено: pytest tests/cli — 9 passed; docker compose config валиден с пустым
каталогом инстанса; bash -n чист по всем скриптам CLI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
03914fba09
commit
a2e6672424
14 files changed
+394
-484
No files matched your search
@@ -1,6 +1,5 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.production.example
|
||||
.git
|
||||
.venv
|
||||
__pycache__
|
||||
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
.env
|
||||
# Instance dir holds production .env, runtime data, certs — never tracked.
|
||||
.env.*
|
||||
# Каталог инстанса: runtime-данные и сертификаты — не отслеживается.
|
||||
instance/
|
||||
# Cutover .env drafts carry live secrets (RLS passwords, TURN secret, S3 keys).
|
||||
# Черновики переезда несут живые секреты (пароли ролей, TURN, ключи S3).
|
||||
*.cutover-draft
|
||||
.venv/
|
||||
__pycache__/
|
||||
|
||||
@@ -112,9 +112,9 @@ docker compose -f compose.yaml -f compose.dev.yaml up -d
|
||||
```
|
||||
|
||||
Приложение не определяет режим по домену, числу организаций или данным.
|
||||
Единственный источник — `CHATBALLS_DELIVERY_MODE` со значением `CLOUD` или
|
||||
`SELF_HOSTED`. В production переменная обязательна; шаблон коробочного
|
||||
экземпляра `env.example` уже содержит `SELF_HOSTED`.
|
||||
Коробка по умолчанию считает себя `SELF_HOSTED`; облачный контур выставляет
|
||||
`CHATBALLS_DELIVERY_MODE=CLOUD` явно. Задавать что-либо при установке не
|
||||
нужно и негде — файла с переменными у продукта нет.
|
||||
|
||||
### Что поднимается
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ COPY content /app/content
|
||||
# collectstatic не обращается к БД/Redis/S3. whitenoise раздаёт static в runtime.
|
||||
# Build-time dummy values satisfy C04 runtime guards (distinct DB users in
|
||||
# settings_database, S3 bucket in settings_storage) that only matter at runtime;
|
||||
# collectstatic touches neither. Runtime values come from instance .env.
|
||||
# collectstatic touches neither. В runtime секреты приходят из тома
|
||||
# (deploy/secrets), остальное человек задаёт в интерфейсе.
|
||||
RUN cd apps/backend && CHATBALLS_SECRET_KEY=collectstatic-build CHATBALLS_DEBUG=false CHATBALLS_ENV=production \
|
||||
CHATBALLS_DELIVERY_MODE=CLOUD \
|
||||
CHATBALLS_APP_ALLOWED_HOSTS=collectstatic.invalid \
|
||||
|
||||
@@ -10,8 +10,9 @@ SECRETS_DIR = Path(os.environ.get("CHATBALLS_SECRETS_DIR", "/run/chatballs/secre
|
||||
def env_secret(name: str, secret_file: str, default: str = "") -> str:
|
||||
"""Значение из переменной окружения, иначе из файла секрета, иначе default.
|
||||
|
||||
Переменная окружения имеет приоритет: так существующие установки с .env
|
||||
продолжают работать без изменений.
|
||||
Штатный источник — файл в томе: файла с переменными у продукта нет.
|
||||
Переменная окружения остаётся выше по приоритету для контуров, которые
|
||||
подставляют значения средствами оркестратора (облако, CI).
|
||||
"""
|
||||
value = os.environ.get(name)
|
||||
if value:
|
||||
|
||||
@@ -45,7 +45,7 @@ Global options:
|
||||
|
||||
Environment:
|
||||
CHATBALLS_RELEASE_DIR Release bundle dir (default: script dir).
|
||||
CHATBALLS_INSTANCE_DIR Instance dir with .env and data/ (default: $PWD).
|
||||
CHATBALLS_INSTANCE_DIR Instance dir with data/ and state/ (default: $PWD).
|
||||
USAGE
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ instance_dir() {
|
||||
printf '%s' "${CHATBALLS_INSTANCE_DIR:?CHATBALLS_INSTANCE_DIR is not set}"
|
||||
}
|
||||
|
||||
instance_env_file() { printf '%s/.env' "$(instance_dir)"; }
|
||||
release_env_file() { printf '%s/release.env' "$(release_dir)"; }
|
||||
release_checksums_file() { printf '%s/checksums.txt' "$(release_dir)"; }
|
||||
compose_file() { printf '%s/compose.yaml' "$(release_dir)"; }
|
||||
@@ -99,9 +98,11 @@ verify_release_checksums() {
|
||||
}
|
||||
|
||||
validate_calls_network_boundary() {
|
||||
# Профиль calls включают переменной окружения — тем же способом, каким её
|
||||
# читает сам compose. Файла с конфигурацией у установки нет.
|
||||
local web_ip turn_ip
|
||||
web_ip="$(env_get "$(instance_env_file)" CHATBALLS_WEB_LISTENING_IP)"
|
||||
turn_ip="$(env_get "$(instance_env_file)" CHATBALLS_TURN_LISTENING_IP)"
|
||||
web_ip="${CHATBALLS_WEB_LISTENING_IP:-}"
|
||||
turn_ip="${CHATBALLS_TURN_LISTENING_IP:-}"
|
||||
|
||||
[[ -n "$web_ip" ]] || {
|
||||
log_err "CHATBALLS_WEB_LISTENING_IP is required for calls profile"
|
||||
|
||||
@@ -6,16 +6,13 @@ run_compose() {
|
||||
local inst rel
|
||||
inst="$(instance_dir)"
|
||||
rel="$(release_dir)"
|
||||
# Переменных окружения продукт не требует: compose поднимается со
|
||||
# значениями по умолчанию. Instance .env остаётся только как
|
||||
# переопределение для установок, которые ведут конфигурацию сами.
|
||||
local env_args=()
|
||||
[[ -f "$inst/.env" ]] && env_args+=(--env-file "$inst/.env")
|
||||
# Переменных окружения продукт не требует: compose поднимается со значениями
|
||||
# по умолчанию, всё остальное человек задаёт в интерфейсе. Единственный
|
||||
# env-файл — release.env: в нём digest-пины образов, которые ставит CI.
|
||||
(
|
||||
cd "$inst" || exit 1
|
||||
docker compose \
|
||||
--project-directory "$inst" \
|
||||
"${env_args[@]}" \
|
||||
--env-file "$rel/release.env" \
|
||||
-f "$rel/compose.yaml" \
|
||||
"$@"
|
||||
@@ -28,6 +25,6 @@ compose_config_validate() {
|
||||
|
||||
profile_enabled() {
|
||||
local p="$1" profiles
|
||||
profiles="$(env_get "$(instance_env_file)" COMPOSE_PROFILES)"
|
||||
profiles="${COMPOSE_PROFILES:-}"
|
||||
[[ ",$profiles," == *",$p,"* ]]
|
||||
}
|
||||
+12
-22
@@ -54,16 +54,8 @@ _deploy_validate() {
|
||||
verify_release_checksums || return 1
|
||||
validate_release_image_refs || return 1
|
||||
|
||||
local app_domain platform_domain
|
||||
app_domain="$(env_get "$(instance_env_file)" CHATBALLS_APP_DOMAIN)"
|
||||
platform_domain="$(env_get "$(instance_env_file)" CHATBALLS_PLATFORM_DOMAIN)"
|
||||
# Домены знать необязательно: свежая установка отвечает по адресу сервера,
|
||||
# а свой домен владелец задаёт потом в «Настройках». Проверяем только то,
|
||||
# что если домены заданы оба — они разные.
|
||||
if [[ -n "$app_domain" && -n "$platform_domain" && "$app_domain" == "$platform_domain" ]]; then
|
||||
log_err "app and platform domains must be distinct"
|
||||
return 1
|
||||
fi
|
||||
# Домены здесь не проверяются: установка отвечает по адресу сервера, а свой
|
||||
# домен владелец задаёт в «Настройках» — снаружи его знать неоткуда.
|
||||
|
||||
if profile_enabled calls; then
|
||||
validate_calls_network_boundary || return 1
|
||||
@@ -104,12 +96,12 @@ _normalize_schema_ownership() {
|
||||
# входит migration-user. Идемпотентно: безопасно на каждом деплое. Без этого
|
||||
# миграции от migration-user падают на таблицах, созданных не им
|
||||
# («must be owner of table …»). Выполняется под суперпользователем POSTGRES_USER.
|
||||
local env_file pg_user pg_db
|
||||
env_file="$(instance_env_file)"
|
||||
pg_user="$(env_get "$env_file" POSTGRES_USER)"
|
||||
pg_db="$(env_get "$env_file" POSTGRES_DB)"
|
||||
[[ -n "$pg_user" ]] || { log_err "POSTGRES_USER not set"; return 1; }
|
||||
[[ -n "$pg_db" ]] || { log_err "POSTGRES_DB not set"; return 1; }
|
||||
# Те же значения по умолчанию, что и у compose: задавать их человеку негде
|
||||
# и незачем. Раньше они читались из .env — и без него deploy падал здесь,
|
||||
# хотя сама установка была исправна.
|
||||
local pg_user pg_db
|
||||
pg_user="${POSTGRES_USER:-chatballs_bootstrap}"
|
||||
pg_db="${POSTGRES_DB:-chatballs}"
|
||||
run_compose exec -T postgres \
|
||||
psql -v ON_ERROR_STOP=1 -U "$pg_user" -d "$pg_db" \
|
||||
-f /chatballs-reassign-ownership.sql >/dev/null
|
||||
@@ -128,13 +120,11 @@ _first_json_service_state() {
|
||||
}
|
||||
|
||||
_smoke() {
|
||||
# Установка домена не знает — он задаётся в «Настройках». Проверяем её так
|
||||
# же, как её открывает человек до этого: по адресу сервера, без https.
|
||||
local app_domain platform_domain
|
||||
app_domain="$(env_get "$(instance_env_file)" CHATBALLS_APP_DOMAIN)"
|
||||
platform_domain="$(env_get "$(instance_env_file)" CHATBALLS_PLATFORM_DOMAIN)"
|
||||
# Свежая установка домена не знает: проверяем её так же, как её открывает
|
||||
# человек — по адресу сервера, без домена и без https.
|
||||
[[ -n "$app_domain" ]] || app_domain="localhost"
|
||||
[[ -n "$platform_domain" ]] || platform_domain="localhost"
|
||||
app_domain="localhost"
|
||||
platform_domain="localhost"
|
||||
|
||||
# Значения передаём через env внутри контейнера, а не флагами -e: так
|
||||
# команда остаётся привычной формы «exec -T backend-app …».
|
||||
|
||||
@@ -61,13 +61,6 @@ cmd_doctor() {
|
||||
_doctor_report 0 "compose.yaml missing in release: $rel"
|
||||
fi
|
||||
|
||||
# .env не требуется: продукт поднимается без переменных окружения.
|
||||
if [[ -f "$(instance_env_file)" ]]; then
|
||||
_doctor_report 1 "instance .env present (overrides)"
|
||||
else
|
||||
_doctor_report 1 "instance .env absent (not required)"
|
||||
fi
|
||||
|
||||
if [[ -f "$(release_env_file)" ]]; then
|
||||
_doctor_report 1 "release.env present"
|
||||
else
|
||||
@@ -86,53 +79,16 @@ cmd_doctor() {
|
||||
_doctor_report 0 "release image references are invalid"
|
||||
fi
|
||||
|
||||
local app_domain platform_domain
|
||||
app_domain="$(env_get "$(instance_env_file)" CHATBALLS_APP_DOMAIN)"
|
||||
platform_domain="$(env_get "$(instance_env_file)" CHATBALLS_PLATFORM_DOMAIN)"
|
||||
if [[ -n "$app_domain" ]]; then
|
||||
_doctor_report 1 "CHATBALLS_APP_DOMAIN set: $app_domain"
|
||||
else
|
||||
_doctor_report 0 "CHATBALLS_APP_DOMAIN not set"
|
||||
fi
|
||||
if [[ -n "$platform_domain" ]]; then
|
||||
_doctor_report 1 "CHATBALLS_PLATFORM_DOMAIN set: $platform_domain"
|
||||
else
|
||||
_doctor_report 0 "CHATBALLS_PLATFORM_DOMAIN not set"
|
||||
fi
|
||||
if [[ -n "$app_domain" ]] && [[ "$app_domain" != "$platform_domain" ]]; then
|
||||
_doctor_report 1 "app and platform domains are distinct"
|
||||
else
|
||||
_doctor_report 0 "app and platform domains must be distinct"
|
||||
fi
|
||||
|
||||
local acme_email
|
||||
acme_email="$(env_get "$(instance_env_file)" CHATBALLS_ACME_EMAIL)"
|
||||
if [[ -n "$acme_email" ]]; then
|
||||
_doctor_report 1 "CHATBALLS_ACME_EMAIL set"
|
||||
else
|
||||
_doctor_report 0 "CHATBALLS_ACME_EMAIL not set"
|
||||
fi
|
||||
|
||||
local pg_pwd
|
||||
pg_pwd="$(env_get "$(instance_env_file)" POSTGRES_PASSWORD)"
|
||||
if [[ -n "$pg_pwd" ]]; then
|
||||
_doctor_report 1 "POSTGRES_PASSWORD set"
|
||||
else
|
||||
_doctor_report 0 "POSTGRES_PASSWORD empty"
|
||||
fi
|
||||
|
||||
local secret
|
||||
secret="$(env_get "$(instance_env_file)" CHATBALLS_SECRET_KEY)"
|
||||
if [[ -n "$secret" ]] && [[ "$secret" != "change-me-long-random-secret" ]]; then
|
||||
_doctor_report 1 "CHATBALLS_SECRET_KEY set"
|
||||
else
|
||||
_doctor_report 0 "CHATBALLS_SECRET_KEY default/empty"
|
||||
fi
|
||||
# Ни доменов, ни ACME-почты, ни ключа подписи, ни паролей БД здесь не
|
||||
# проверяем: первое задаёт владелец в «Настройках», второе генерирует в том
|
||||
# с секретами первый старт стека. Снаружи, с хоста, этого не видно, и
|
||||
# прежние проверки просто ругались на исправную установку — они читали
|
||||
# instance .env, которого у продукта нет.
|
||||
|
||||
if profile_enabled calls; then
|
||||
local missing=0 k
|
||||
for k in CHATBALLS_CALL_TURN_SECRET CHATBALLS_CALL_TURN_REALM CHATBALLS_TURN_EXTERNAL_IP CHATBALLS_TURN_LISTENING_IP; do
|
||||
if [[ -z "$(env_get "$(instance_env_file)" "$k")" ]]; then
|
||||
for k in CHATBALLS_CALL_TURN_REALM CHATBALLS_TURN_EXTERNAL_IP CHATBALLS_TURN_LISTENING_IP; do
|
||||
if [[ -z "${!k:-}" ]]; then
|
||||
_doctor_report 0 "$k required for calls profile"
|
||||
missing=1
|
||||
fi
|
||||
|
||||
@@ -21,7 +21,7 @@ cmd_status() {
|
||||
printf ' release dir: %s\n' "$rel"
|
||||
printf ' target version: %s\n' "${target:-unknown}"
|
||||
printf ' applied version: %s\n' "${applied:-none}"
|
||||
printf ' profiles: %s\n' "$(env_get "$(instance_env_file)" COMPOSE_PROFILES || echo none)"
|
||||
printf ' profiles: %s\n' "${COMPOSE_PROFILES:-none}"
|
||||
|
||||
if [[ -f "$(_state_file)" ]]; then
|
||||
printf ' last applied at: %s\n' "$(env_get "$(_state_file)" applied_at)"
|
||||
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
# Instance configuration Chatballs (ADR-HUB-0028 / SPEC-HUB-0019 §7.2).
|
||||
# Поставляется в release bundle. Копируется в instance/.env и редактируется
|
||||
# под конкретный экземпляр. Image references НЕ здесь — они в release.env (CI).
|
||||
# Здесь нет initial OWNER password и нет demo-флага (см. SPEC §7.2 запреты).
|
||||
|
||||
COMPOSE_PROJECT_NAME=chatballs
|
||||
|
||||
# --- Instance identity / gateway ---
|
||||
CHATBALLS_APP_DOMAIN=app.example.com
|
||||
CHATBALLS_PLATFORM_DOMAIN=platform.example.com
|
||||
CHATBALLS_HELP_BASE_DOMAIN=help.example.com
|
||||
CHATBALLS_HELP_PUBLIC_SCHEME=https
|
||||
CHATBALLS_HELP_PUBLIC_PORT=
|
||||
# Необязательный отдельный IPv4 для A-записей собственных доменов.
|
||||
# По умолчанию используется CHATBALLS_WEB_LISTENING_IP.
|
||||
CHATBALLS_HELP_PUBLIC_IPV4=203.0.113.10
|
||||
CHATBALLS_ACME_EMAIL=admin@example.com
|
||||
CHATBALLS_ADMIN_PORT=18001
|
||||
# IP web-gateway. Оставьте 0.0.0.0 без profile calls. Для calls укажите
|
||||
# отдельный публичный IP, отличный от CHATBALLS_TURN_LISTENING_IP.
|
||||
CHATBALLS_WEB_LISTENING_IP=0.0.0.0
|
||||
# Опциональные profiles: calls (coturn), и в будущем voice.
|
||||
# COMPOSE_PROFILES=calls
|
||||
|
||||
# --- Django core ---
|
||||
CHATBALLS_ENV=production
|
||||
CHATBALLS_DEBUG=false
|
||||
# Коробочная поставка. Облачный контур использует CLOUD.
|
||||
CHATBALLS_DELIVERY_MODE=SELF_HOSTED
|
||||
CHATBALLS_SECRET_KEY=change-me-long-random-secret
|
||||
# Шифрование секретов в БД (Fernet-ключ): Fernet.generate_key().
|
||||
CHATBALLS_FIELD_ENCRYPTION_KEY=
|
||||
|
||||
CHATBALLS_APP_ALLOWED_HOSTS=app.example.com
|
||||
CHATBALLS_APP_CSRF_TRUSTED_ORIGINS=https://app.example.com
|
||||
CHATBALLS_PLATFORM_ALLOWED_HOSTS=platform.example.com
|
||||
CHATBALLS_PLATFORM_CSRF_TRUSTED_ORIGINS=https://platform.example.com
|
||||
CHATBALLS_CORS_ALLOWED_ORIGINS=https://app.example.com
|
||||
INTERNAL_UI_BASE_URL=https://app.example.com
|
||||
# Host headers для Docker healthchecks должны входить в surface ALLOWED_HOSTS.
|
||||
CHATBALLS_APP_HEALTHCHECK_HOST=app.example.com
|
||||
CHATBALLS_PLATFORM_HEALTHCHECK_HOST=platform.example.com
|
||||
|
||||
# Транспортная безопасность (вне CHATBALLS_DEBUG включается автоматически).
|
||||
CHATBALLS_COOKIE_SECURE=true
|
||||
CHATBALLS_SSL_REDIRECT=true
|
||||
CHATBALLS_HSTS_SECONDS=31536000
|
||||
CHATBALLS_COOKIE_SAMESITE=Lax
|
||||
|
||||
# --- PostgreSQL / Redis ---
|
||||
# C04 RLS: три раздельные DB-roles (app/platform/migration). Runtime-роли app и
|
||||
# platform — NOBYPASSRLS, не владельцы tenant-tables; миграции выполняются под
|
||||
# migration-role (через CHATBALLS_DB_ROLE=migration в compose). Пароли СГЕНЕРИРОВАТЬ,
|
||||
# не использовать значения по умолчанию. См. deploy/postgres/init-runtime-roles.sh.
|
||||
POSTGRES_DB=chatballs
|
||||
POSTGRES_USER=chatballs
|
||||
POSTGRES_PASSWORD=change-me-db-password
|
||||
POSTGRES_APP_USER=chatballs_app
|
||||
POSTGRES_APP_PASSWORD=change-me-app-db-password
|
||||
POSTGRES_PLATFORM_USER=chatballs_platform
|
||||
POSTGRES_PLATFORM_PASSWORD=change-me-platform-db-password
|
||||
POSTGRES_MIGRATION_USER=chatballs_migration
|
||||
POSTGRES_MIGRATION_PASSWORD=change-me-migration-db-password
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
# psycopg connection pool per процесс (backend работает под ASGI, где
|
||||
# CONN_MAX_AGE-соединения осиротевают в thread-pool). Значения по умолчанию
|
||||
# заданы в settings_database.py; переопределять обычно не нужно.
|
||||
# CHATBALLS_DB_POOL_MAX=0 отключает пул целиком.
|
||||
#CHATBALLS_DB_POOL_MIN=1
|
||||
#CHATBALLS_DB_POOL_MAX=4
|
||||
#CHATBALLS_DB_POOL_TIMEOUT=10
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# --- Хранилище файлов ---
|
||||
# По умолчанию локальный диск (${INSTANCE_DIR}/data/media). Внешнее S3 включает
|
||||
# администратор в «Настройки → Хранилище файлов» — в .env ничего не задаётся.
|
||||
|
||||
# --- P2P calls (profile calls) ---
|
||||
# Включается только при COMPOSE_PROFILES=calls. Параметры coturn обязательны,
|
||||
# если profile активен.
|
||||
CHATBALLS_CALL_INVITE_TTL_SECONDS=300
|
||||
CHATBALLS_CALL_ACCESS_TTL_SECONDS=3600
|
||||
CHATBALLS_CALL_CONNECT_GRACE_SECONDS=120
|
||||
CHATBALLS_CALL_RECONNECT_GRACE_SECONDS=60
|
||||
CHATBALLS_CALL_STUN_URLS=
|
||||
# Публичные TURN endpoints (через запятую). Пусто -> только STUN/direct.
|
||||
CHATBALLS_CALL_TURN_URLS=
|
||||
# Общий static-auth-secret между backend и coturn.
|
||||
CHATBALLS_CALL_TURN_SECRET=
|
||||
CHATBALLS_CALL_TURN_TTL_SECONDS=3600
|
||||
CHATBALLS_CALL_TURN_REALM=app.example.com
|
||||
# Публичный IP coturn listener/relay. ОТДЕЛЬНЫЙ от web IP, чтобы TURN занял 443.
|
||||
CHATBALLS_TURN_EXTERNAL_IP=
|
||||
CHATBALLS_TURN_LISTENING_IP=
|
||||
CHATBALLS_TURN_LISTENING_PORT=3478
|
||||
CHATBALLS_TURN_TLS_PORT=443
|
||||
CHATBALLS_TURN_MIN_PORT=49160
|
||||
CHATBALLS_TURN_MAX_PORT=49200
|
||||
|
||||
# --- AI provider ---
|
||||
CHATBALLS_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
CHATBALLS_CUSTOAI_API_KEY=
|
||||
CHATBALLS_CUSTOAI_BASE_URL=https://ai.api.cloud.yandex.net/v1
|
||||
CHATBALLS_CUSTOAI_MODEL=gpt://b1g89tr9t8iedhnl8pgg/yandexgpt-5.1/latest
|
||||
CHATBALLS_AI_REQUEST_TIMEOUT=30
|
||||
CHATBALLS_AI_MAX_RETRIES=2
|
||||
CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=0
|
||||
|
||||
# --- Email ---
|
||||
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||
EMAIL_HOST=
|
||||
EMAIL_PORT=587
|
||||
EMAIL_HOST_USER=
|
||||
EMAIL_HOST_PASSWORD=
|
||||
EMAIL_USE_TLS=true
|
||||
DEFAULT_FROM_EMAIL=Chatballs <no-reply@example.com>
|
||||
|
||||
# --- Gunicorn ---
|
||||
CHATBALLS_GUNICORN_WORKERS=3
|
||||
CHATBALLS_GUNICORN_TIMEOUT=60
|
||||
+9
-24
@@ -141,29 +141,13 @@ def fake_env(tmp_path: Path):
|
||||
|
||||
log_file = tmp_path / "docker.log"
|
||||
|
||||
def write_env(**overrides) -> Path:
|
||||
lines = {
|
||||
"COMPOSE_PROJECT_NAME": "chatballs_test",
|
||||
"CHATBALLS_APP_DOMAIN": "app.test",
|
||||
"CHATBALLS_PLATFORM_DOMAIN": "platform.test",
|
||||
"CHATBALLS_ACME_EMAIL": "admin@test",
|
||||
"CHATBALLS_SECRET_KEY": "test-secret-not-default",
|
||||
"CHATBALLS_FIELD_ENCRYPTION_KEY": "",
|
||||
"POSTGRES_DB": "chatballs",
|
||||
"POSTGRES_USER": "chatballs",
|
||||
"POSTGRES_PASSWORD": "pg-secret",
|
||||
"POSTGRES_HOST": "postgres",
|
||||
"POSTGRES_PORT": "5432",
|
||||
"REDIS_URL": "redis://redis:6379/0",
|
||||
"CHATBALLS_APP_ALLOWED_HOSTS": "app.test",
|
||||
"CHATBALLS_PLATFORM_ALLOWED_HOSTS": "platform.test",
|
||||
"CHATBALLS_APP_HEALTHCHECK_HOST": "app.test",
|
||||
"CHATBALLS_PLATFORM_HEALTHCHECK_HOST": "platform.test",
|
||||
}
|
||||
lines.update(overrides)
|
||||
body = "".join(f"{k}={v}\n" for k, v in lines.items())
|
||||
_write_lf(instance / ".env", body)
|
||||
return instance / ".env"
|
||||
# Файла с переменными у продукта нет: установка поднимается со значениями
|
||||
# по умолчанию. То немногое, что ещё настраивается снаружи (профиль calls
|
||||
# и его адреса), приходит переменными окружения — их и подставляем.
|
||||
extra_env: dict[str, str] = {}
|
||||
|
||||
def set_env(**overrides) -> None:
|
||||
extra_env.update({k: str(v) for k, v in overrides.items()})
|
||||
|
||||
def install_flock(held: bool = False) -> None:
|
||||
flock = bin_dir / "flock"
|
||||
@@ -184,6 +168,7 @@ def fake_env(tmp_path: Path):
|
||||
env["CHATBALLS_RELEASE_DIR"] = str(release)
|
||||
env["CHATBALLS_INSTANCE_DIR"] = str(instance)
|
||||
env["FAKE_DOCKER_LOG"] = str(log_file)
|
||||
env.update(extra_env)
|
||||
# BASH-интерпретатор для скриптов-моков (env bash резолвится из PATH баша).
|
||||
return env
|
||||
|
||||
@@ -198,7 +183,7 @@ def fake_env(tmp_path: Path):
|
||||
e.log = log_file
|
||||
e.chatballs = chatballs
|
||||
e.bash = BASH_EXECUTABLE
|
||||
e.write_env = write_env
|
||||
e.set_env = set_env
|
||||
e.install_flock = install_flock
|
||||
e.install_docker = install_docker
|
||||
e.make_env = make_env
|
||||
|
||||
+345
-245
@@ -1,245 +1,345 @@
|
||||
"""Тесты chatballs CLI (этап 1): deploy workflow ordering, validation, lock, doctor, status.␍
|
||||
␍
|
||||
Запускают реальный bash-скрипт против замоканного docker/flock (см. conftest.py).␍
|
||||
Покрывают: SPEC-HUB-0019 §11 (lock), §12 (doctor), §17 (deploy workflow),␍
|
||||
§29 (observability). Не требуют Docker daemon.␍
|
||||
"""␍
|
||||
␍
|
||||
from __future__ import annotations␍
|
||||
␍
|
||||
import hashlib␍
|
||||
import subprocess␍
|
||||
␍
|
||||
␍
|
||||
def _run(env, *args):␍
|
||||
"""Вызывает chatballs через bash с окружением из fake_env."""␍
|
||||
return subprocess.run(␍
|
||||
[env.bash, str(env.chatballs), *args],␍
|
||||
env=env.make_env(),␍
|
||||
cwd=str(env.instance),␍
|
||||
capture_output=True,␍
|
||||
text=True,␍
|
||||
)␍
|
||||
␍
|
||||
␍
|
||||
def _rewrite_checksums(env):␍
|
||||
lines = []␍
|
||||
release_files = (␍
|
||||
path for path in env.release.rglob("*") if path.is_file() and path.name != "checksums.txt"␍
|
||||
)␍
|
||||
for path in sorted(release_files):␍
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()␍
|
||||
relative = path.relative_to(env.release).as_posix()␍
|
||||
lines.append(f"{digest} ./{relative}\n")␍
|
||||
# newline="" обязателен: на Windows write_text заменил бы перевод строки␍
|
||||
# на CRLF, и sha256sum получил бы имена файлов с лишним символом на конце.␍
|
||||
(env.release / "checksums.txt").write_text(␍
|
||||
"".join(lines), encoding="utf-8", newline=""␍
|
||||
)␍
|
||||
␍
|
||||
␍
|
||||
def _log_lines(env):␍
|
||||
if not env.log.exists():␍
|
||||
return []␍
|
||||
return [line for line in env.log.read_text().splitlines() if line.strip()]␍
|
||||
␍
|
||||
␍
|
||||
def _index_of(log, fragment):␍
|
||||
return next(index for index, line in enumerate(log) if fragment in line)␍
|
||||
␍
|
||||
␍
|
||||
# ---------------------------------------------------------------------------␍
|
||||
# deploy␍
|
||||
# ---------------------------------------------------------------------------␍
|
||||
␍
|
||||
␍
|
||||
def test_deploy_success_orders_canonical_workflow(fake_env):␍
|
||||
fake_env.write_env()␍
|
||||
fake_env.install_docker()␍
|
||||
fake_env.install_flock(held=False)␍
|
||||
␍
|
||||
r = _run(fake_env, "deploy", "--non-interactive")␍
|
||||
␍
|
||||
assert r.returncode == 0, r.stderr␍
|
||||
log = _log_lines(fake_env)␍
|
||||
joined = "\n".join(log)␍
|
||||
␍
|
||||
# Канонический порядок (ADR-HUB-0028 §workflow).␍
|
||||
idx_pull = _index_of(log, " pull")␍
|
||||
idx_infra = _index_of(log, " up -d postgres redis")␍
|
||||
idx_init = _index_of(log, "run --rm init")␍
|
||||
idx_app = _index_of(␍
|
||||
log,␍
|
||||
" up -d backend-app backend-platform backend-admin worker frontend gateway",␍
|
||||
)␍
|
||||
idx_exec = _index_of(log, "exec -T backend-app")␍
|
||||
␍
|
||||
assert idx_pull < idx_infra < idx_init < idx_app < idx_exec, joined␍
|
||||
# applied_release записан.␍
|
||||
assert (fake_env.instance / "state" / "applied_release").exists()␍
|
||||
assert not (fake_env.instance / "compose.yaml").exists()␍
|
||||
␍
|
||||
␍
|
||||
def test_deploy_includes_coturn_when_calls_profile_active(fake_env):␍
|
||||
fake_env.write_env(␍
|
||||
COMPOSE_PROFILES="calls",␍
|
||||
CHATBALLS_WEB_LISTENING_IP="203.0.113.10",␍
|
||||
CHATBALLS_CALL_TURN_SECRET="turn-secret",␍
|
||||
CHATBALLS_CALL_TURN_REALM="turn.hub.test",␍
|
||||
CHATBALLS_TURN_EXTERNAL_IP="203.0.113.11",␍
|
||||
CHATBALLS_TURN_LISTENING_IP="203.0.113.11",␍
|
||||
)␍
|
||||
fake_env.install_docker()␍
|
||||
fake_env.install_flock(held=False)␍
|
||||
␍
|
||||
r = _run(fake_env, "deploy", "--non-interactive")␍
|
||||
assert r.returncode == 0, r.stderr␍
|
||||
joined = "\n".join(_log_lines(fake_env))␍
|
||||
assert (␍
|
||||
"up -d backend-app backend-platform backend-admin worker frontend gateway coturn"␍
|
||||
in joined␍
|
||||
)␍
|
||||
␍
|
||||
␍
|
||||
def test_deploy_rejects_shared_web_and_turn_ip(fake_env):␍
|
||||
fake_env.write_env(␍
|
||||
COMPOSE_PROFILES="calls",␍
|
||||
CHATBALLS_WEB_LISTENING_IP="203.0.113.10",␍
|
||||
CHATBALLS_CALL_TURN_SECRET="turn-secret",␍
|
||||
CHATBALLS_CALL_TURN_REALM="turn.hub.test",␍
|
||||
CHATBALLS_TURN_EXTERNAL_IP="203.0.113.10",␍
|
||||
CHATBALLS_TURN_LISTENING_IP="203.0.113.10",␍
|
||||
)␍
|
||||
fake_env.install_docker()␍
|
||||
fake_env.install_flock(held=False)␍
|
||||
␍
|
||||
result = _run(fake_env, "deploy", "--non-interactive")␍
|
||||
␍
|
||||
assert result.returncode != 0␍
|
||||
assert "different public IP" in result.stderr␍
|
||||
assert " pull" not in "\n".join(_log_lines(fake_env))␍
|
||||
␍
|
||||
␍
|
||||
def test_deploy_fails_when_release_env_missing(fake_env):␍
|
||||
fake_env.write_env()␍
|
||||
fake_env.install_docker()␍
|
||||
fake_env.install_flock(held=False)␍
|
||||
# Удаляем release.env — релиз не активирован.␍
|
||||
(fake_env.release / "release.env").unlink()␍
|
||||
␍
|
||||
r = _run(fake_env, "deploy", "--non-interactive")␍
|
||||
assert r.returncode != 0␍
|
||||
joined = "\n".join(_log_lines(fake_env))␍
|
||||
assert " up -d " not in joined # до запуска контейнеров не дошло␍
|
||||
␍
|
||||
␍
|
||||
def test_deploy_fails_when_release_checksum_is_invalid(fake_env):␍
|
||||
fake_env.write_env()␍
|
||||
fake_env.install_docker()␍
|
||||
fake_env.install_flock(held=False)␍
|
||||
(fake_env.release / "Caddyfile").write_text("tampered\n", encoding="utf-8")␍
|
||||
␍
|
||||
r = _run(fake_env, "deploy", "--non-interactive")␍
|
||||
␍
|
||||
assert r.returncode != 0␍
|
||||
assert "checksum" in r.stderr.lower()␍
|
||||
assert " pull" not in "\n".join(_log_lines(fake_env))␍
|
||||
␍
|
||||
␍
|
||||
def test_deploy_fails_when_release_image_is_not_digest_pinned(fake_env):␍
|
||||
fake_env.write_env()␍
|
||||
fake_env.install_docker()␍
|
||||
fake_env.install_flock(held=False)␍
|
||||
release_env = fake_env.release / "release.env"␍
|
||||
release_env.write_text(␍
|
||||
release_env.read_text().replace(␍
|
||||
"registry.test/backend:1.0.0@sha256:" + "a" * 64,␍
|
||||
"registry.test/backend:latest",␍
|
||||
)␍
|
||||
)␍
|
||||
_rewrite_checksums(fake_env)␍
|
||||
␍
|
||||
r = _run(fake_env, "deploy", "--non-interactive")␍
|
||||
␍
|
||||
assert r.returncode != 0␍
|
||||
assert "immutable" in r.stderr.lower()␍
|
||||
assert " pull" not in "\n".join(_log_lines(fake_env))␍
|
||||
␍
|
||||
␍
|
||||
def test_deploy_fails_on_lock_held(fake_env):␍
|
||||
fake_env.write_env()␍
|
||||
fake_env.install_docker()␍
|
||||
fake_env.install_flock(held=True) # блокировка занята␍
|
||||
␍
|
||||
r = _run(fake_env, "deploy", "--non-interactive")␍
|
||||
assert r.returncode == 3, r.stderr␍
|
||||
assert "lock" in r.stderr.lower() or "lock" in r.stdout.lower()␍
|
||||
joined = "\n".join(_log_lines(fake_env))␍
|
||||
assert " up -d " not in joined # destructive операция не началась␍
|
||||
␍
|
||||
␍
|
||||
# ---------------------------------------------------------------------------␍
|
||||
# doctor␍
|
||||
# ---------------------------------------------------------------------------␍
|
||||
␍
|
||||
␍
|
||||
def test_doctor_passes_on_valid_env(fake_env):␍
|
||||
fake_env.write_env()␍
|
||||
fake_env.install_docker()␍
|
||||
fake_env.install_flock(held=False)␍
|
||||
␍
|
||||
r = _run(fake_env, "doctor")␍
|
||||
assert r.returncode == 0, r.stderr␍
|
||||
␍
|
||||
␍
|
||||
def test_doctor_fails_on_default_secret_and_missing_password(fake_env):␍
|
||||
fake_env.write_env(␍
|
||||
CHATBALLS_SECRET_KEY="change-me-long-random-secret", # default -> должно провалиться␍
|
||||
POSTGRES_PASSWORD="", # пусто -> должно провалиться␍
|
||||
)␍
|
||||
fake_env.install_docker()␍
|
||||
fake_env.install_flock(held=False)␍
|
||||
␍
|
||||
r = _run(fake_env, "doctor")␍
|
||||
assert r.returncode == 1␍
|
||||
assert "POSTGRES_PASSWORD" in r.stderr␍
|
||||
assert "CHATBALLS_SECRET_KEY" in r.stderr␍
|
||||
␍
|
||||
␍
|
||||
def test_doctor_fails_when_acme_email_missing(fake_env):␍
|
||||
fake_env.write_env(CHATBALLS_ACME_EMAIL="")␍
|
||||
fake_env.install_docker()␍
|
||||
fake_env.install_flock(held=False)␍
|
||||
␍
|
||||
result = _run(fake_env, "doctor")␍
|
||||
␍
|
||||
assert result.returncode == 1␍
|
||||
assert "CHATBALLS_ACME_EMAIL" in result.stderr␍
|
||||
␍
|
||||
␍
|
||||
def test_doctor_fails_when_surface_domains_match(fake_env):␍
|
||||
fake_env.write_env(CHATBALLS_PLATFORM_DOMAIN="app.test")␍
|
||||
fake_env.install_docker()␍
|
||||
fake_env.install_flock(held=False)␍
|
||||
␍
|
||||
result = _run(fake_env, "doctor")␍
|
||||
␍
|
||||
assert result.returncode == 1␍
|
||||
assert "must be distinct" in result.stderr␍
|
||||
␍
|
||||
␍
|
||||
# ---------------------------------------------------------------------------␍
|
||||
# status␍
|
||||
# ---------------------------------------------------------------------------␍
|
||||
␍
|
||||
␍
|
||||
def test_status_json_reports_target_version(fake_env):␍
|
||||
fake_env.write_env()␍
|
||||
fake_env.install_docker()␍
|
||||
fake_env.install_flock(held=False)␍
|
||||
␍
|
||||
r = _run(fake_env, "status", "--json")␍
|
||||
assert r.returncode == 0, r.stderr␍
|
||||
out = r.stdout.strip()␍
|
||||
assert out.startswith("{") and '"target_version"' in out␍
|
||||
assert "1.0.0-test" in out␍
|
||||
"""Тесты chatballs CLI (этап 1): deploy workflow ordering, validation, lock, doctor, status.
|
||||
|
||||
|
||||
Запускают реальный bash-скрипт против замоканного docker/flock (см. conftest.py).
|
||||
|
||||
Покрывают: SPEC-HUB-0019 §11 (lock), §12 (doctor), §17 (deploy workflow),
|
||||
|
||||
§29 (observability). Не требуют Docker daemon.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import hashlib
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def _run(env, *args):
|
||||
|
||||
"""Вызывает chatballs через bash с окружением из fake_env."""
|
||||
|
||||
return subprocess.run(
|
||||
|
||||
[env.bash, str(env.chatballs), *args],
|
||||
|
||||
env=env.make_env(),
|
||||
|
||||
cwd=str(env.instance),
|
||||
|
||||
capture_output=True,
|
||||
|
||||
text=True,
|
||||
|
||||
)
|
||||
|
||||
|
||||
def _rewrite_checksums(env):
|
||||
|
||||
lines = []
|
||||
|
||||
release_files = (
|
||||
|
||||
path for path in env.release.rglob("*") if path.is_file() and path.name != "checksums.txt"
|
||||
|
||||
)
|
||||
|
||||
for path in sorted(release_files):
|
||||
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
relative = path.relative_to(env.release).as_posix()
|
||||
|
||||
lines.append(f"{digest} ./{relative}\n")
|
||||
|
||||
# newline="" обязателен: на Windows write_text заменил бы перевод строки
|
||||
|
||||
# на CRLF, и sha256sum получил бы имена файлов с лишним символом на конце.
|
||||
|
||||
(env.release / "checksums.txt").write_text(
|
||||
|
||||
"".join(lines), encoding="utf-8", newline=""
|
||||
|
||||
)
|
||||
|
||||
|
||||
def _log_lines(env):
|
||||
|
||||
if not env.log.exists():
|
||||
|
||||
return []
|
||||
|
||||
return [line for line in env.log.read_text().splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _index_of(log, fragment):
|
||||
|
||||
return next(index for index, line in enumerate(log) if fragment in line)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# deploy
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_deploy_success_orders_canonical_workflow(fake_env):
|
||||
|
||||
|
||||
fake_env.install_docker()
|
||||
|
||||
fake_env.install_flock(held=False)
|
||||
|
||||
|
||||
r = _run(fake_env, "deploy", "--non-interactive")
|
||||
|
||||
|
||||
assert r.returncode == 0, r.stderr
|
||||
|
||||
log = _log_lines(fake_env)
|
||||
|
||||
joined = "\n".join(log)
|
||||
|
||||
|
||||
# Канонический порядок (ADR-HUB-0028 §workflow).
|
||||
|
||||
idx_pull = _index_of(log, " pull")
|
||||
|
||||
idx_infra = _index_of(log, " up -d postgres redis")
|
||||
|
||||
idx_init = _index_of(log, "run --rm init")
|
||||
|
||||
idx_app = _index_of(
|
||||
|
||||
log,
|
||||
|
||||
" up -d backend-app backend-platform backend-admin worker frontend gateway",
|
||||
|
||||
)
|
||||
|
||||
idx_exec = _index_of(log, "exec -T backend-app")
|
||||
|
||||
|
||||
assert idx_pull < idx_infra < idx_init < idx_app < idx_exec, joined
|
||||
|
||||
# applied_release записан.
|
||||
|
||||
assert (fake_env.instance / "state" / "applied_release").exists()
|
||||
|
||||
assert not (fake_env.instance / "compose.yaml").exists()
|
||||
|
||||
|
||||
def test_deploy_includes_coturn_when_calls_profile_active(fake_env):
|
||||
|
||||
fake_env.set_env(
|
||||
|
||||
COMPOSE_PROFILES="calls",
|
||||
|
||||
CHATBALLS_WEB_LISTENING_IP="203.0.113.10",
|
||||
|
||||
CHATBALLS_CALL_TURN_SECRET="turn-secret",
|
||||
|
||||
CHATBALLS_CALL_TURN_REALM="turn.hub.test",
|
||||
|
||||
CHATBALLS_TURN_EXTERNAL_IP="203.0.113.11",
|
||||
|
||||
CHATBALLS_TURN_LISTENING_IP="203.0.113.11",
|
||||
|
||||
)
|
||||
|
||||
fake_env.install_docker()
|
||||
|
||||
fake_env.install_flock(held=False)
|
||||
|
||||
|
||||
r = _run(fake_env, "deploy", "--non-interactive")
|
||||
|
||||
assert r.returncode == 0, r.stderr
|
||||
|
||||
joined = "\n".join(_log_lines(fake_env))
|
||||
|
||||
assert (
|
||||
|
||||
"up -d backend-app backend-platform backend-admin worker frontend gateway coturn"
|
||||
|
||||
in joined
|
||||
|
||||
)
|
||||
|
||||
|
||||
def test_deploy_rejects_shared_web_and_turn_ip(fake_env):
|
||||
|
||||
fake_env.set_env(
|
||||
|
||||
COMPOSE_PROFILES="calls",
|
||||
|
||||
CHATBALLS_WEB_LISTENING_IP="203.0.113.10",
|
||||
|
||||
CHATBALLS_CALL_TURN_SECRET="turn-secret",
|
||||
|
||||
CHATBALLS_CALL_TURN_REALM="turn.hub.test",
|
||||
|
||||
CHATBALLS_TURN_EXTERNAL_IP="203.0.113.10",
|
||||
|
||||
CHATBALLS_TURN_LISTENING_IP="203.0.113.10",
|
||||
|
||||
)
|
||||
|
||||
fake_env.install_docker()
|
||||
|
||||
fake_env.install_flock(held=False)
|
||||
|
||||
|
||||
result = _run(fake_env, "deploy", "--non-interactive")
|
||||
|
||||
|
||||
assert result.returncode != 0
|
||||
|
||||
assert "different public IP" in result.stderr
|
||||
|
||||
assert " pull" not in "\n".join(_log_lines(fake_env))
|
||||
|
||||
|
||||
def test_deploy_fails_when_release_env_missing(fake_env):
|
||||
|
||||
|
||||
fake_env.install_docker()
|
||||
|
||||
fake_env.install_flock(held=False)
|
||||
|
||||
# Удаляем release.env — релиз не активирован.
|
||||
|
||||
(fake_env.release / "release.env").unlink()
|
||||
|
||||
|
||||
r = _run(fake_env, "deploy", "--non-interactive")
|
||||
|
||||
assert r.returncode != 0
|
||||
|
||||
joined = "\n".join(_log_lines(fake_env))
|
||||
|
||||
assert " up -d " not in joined # до запуска контейнеров не дошло
|
||||
|
||||
|
||||
def test_deploy_fails_when_release_checksum_is_invalid(fake_env):
|
||||
|
||||
|
||||
fake_env.install_docker()
|
||||
|
||||
fake_env.install_flock(held=False)
|
||||
|
||||
(fake_env.release / "Caddyfile").write_text("tampered\n", encoding="utf-8")
|
||||
|
||||
|
||||
r = _run(fake_env, "deploy", "--non-interactive")
|
||||
|
||||
|
||||
assert r.returncode != 0
|
||||
|
||||
assert "checksum" in r.stderr.lower()
|
||||
|
||||
assert " pull" not in "\n".join(_log_lines(fake_env))
|
||||
|
||||
|
||||
def test_deploy_fails_when_release_image_is_not_digest_pinned(fake_env):
|
||||
|
||||
|
||||
fake_env.install_docker()
|
||||
|
||||
fake_env.install_flock(held=False)
|
||||
|
||||
release_env = fake_env.release / "release.env"
|
||||
|
||||
release_env.write_text(
|
||||
|
||||
release_env.read_text().replace(
|
||||
|
||||
"registry.test/backend:1.0.0@sha256:" + "a" * 64,
|
||||
|
||||
"registry.test/backend:latest",
|
||||
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
_rewrite_checksums(fake_env)
|
||||
|
||||
|
||||
r = _run(fake_env, "deploy", "--non-interactive")
|
||||
|
||||
|
||||
assert r.returncode != 0
|
||||
|
||||
assert "immutable" in r.stderr.lower()
|
||||
|
||||
assert " pull" not in "\n".join(_log_lines(fake_env))
|
||||
|
||||
|
||||
def test_deploy_fails_on_lock_held(fake_env):
|
||||
|
||||
|
||||
fake_env.install_docker()
|
||||
|
||||
fake_env.install_flock(held=True) # блокировка занята
|
||||
|
||||
|
||||
r = _run(fake_env, "deploy", "--non-interactive")
|
||||
|
||||
assert r.returncode == 3, r.stderr
|
||||
|
||||
assert "lock" in r.stderr.lower() or "lock" in r.stdout.lower()
|
||||
|
||||
joined = "\n".join(_log_lines(fake_env))
|
||||
|
||||
assert " up -d " not in joined # destructive операция не началась
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# doctor
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_doctor_passes_on_valid_env(fake_env):
|
||||
|
||||
|
||||
fake_env.install_docker()
|
||||
|
||||
fake_env.install_flock(held=False)
|
||||
|
||||
|
||||
r = _run(fake_env, "doctor")
|
||||
|
||||
assert r.returncode == 0, r.stderr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# status
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_status_json_reports_target_version(fake_env):
|
||||
|
||||
|
||||
fake_env.install_docker()
|
||||
|
||||
fake_env.install_flock(held=False)
|
||||
|
||||
|
||||
r = _run(fake_env, "status", "--json")
|
||||
|
||||
assert r.returncode == 0, r.stderr
|
||||
|
||||
out = r.stdout.strip()
|
||||
|
||||
assert out.startswith("{") and '"target_version"' in out
|
||||
|
||||
assert "1.0.0-test" in out
|
||||
|
||||
Reference in new issue
Block a user