From a2e6672424477a13ada024f04b984b3cadbd7e44 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 8 Sep 2026 17:22:29 +0300 Subject: [PATCH] =?UTF-8?q?:fire:=20chore:=20.env=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=BE=D1=82=D0=BE=D0=B2=D1=81=D1=8E=D0=B4=D1=83?= =?UTF-8?q?=20=E2=80=94=20=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D1=84=D0=B0=D0=B9=D0=BB=20=D1=81=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Переход на запуск без .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 --- .dockerignore | 1 - .gitignore | 5 +- README.md | 6 +- apps/backend/Dockerfile.production | 3 +- .../backend/chatballs_backend/settings_env.py | 5 +- chatballs | 2 +- deploy/cli/lib/common.sh | 7 +- deploy/cli/lib/compose.sh | 11 +- deploy/cli/lib/deploy.sh | 34 +- deploy/cli/lib/doctor.sh | 58 +- deploy/cli/lib/status.sh | 2 +- env.example | 121 ---- tests/cli/conftest.py | 33 +- tests/cli/test_custocrm_cli.py | 590 ++++++++++-------- 14 files changed, 394 insertions(+), 484 deletions(-) delete mode 100644 env.example diff --git a/.dockerignore b/.dockerignore index fb26f53..563b713 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,5 @@ .env .env.* -!.env.production.example .git .venv __pycache__ diff --git a/.gitignore b/.gitignore index 99008b9..078349e 100644 --- a/.gitignore +++ b/.gitignore @@ -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__/ diff --git a/README.md b/README.md index 60466a4..ca5869b 100644 --- a/README.md +++ b/README.md @@ -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` явно. Задавать что-либо при установке не +нужно и негде — файла с переменными у продукта нет. ### Что поднимается diff --git a/apps/backend/Dockerfile.production b/apps/backend/Dockerfile.production index 4a1e025..ebb1651 100644 --- a/apps/backend/Dockerfile.production +++ b/apps/backend/Dockerfile.production @@ -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 \ diff --git a/apps/backend/chatballs_backend/settings_env.py b/apps/backend/chatballs_backend/settings_env.py index dd566e8..045385e 100644 --- a/apps/backend/chatballs_backend/settings_env.py +++ b/apps/backend/chatballs_backend/settings_env.py @@ -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: diff --git a/chatballs b/chatballs index 520ddd9..5ce2c33 100644 --- a/chatballs +++ b/chatballs @@ -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 } diff --git a/deploy/cli/lib/common.sh b/deploy/cli/lib/common.sh index 71d81a2..334e154 100644 --- a/deploy/cli/lib/common.sh +++ b/deploy/cli/lib/common.sh @@ -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" diff --git a/deploy/cli/lib/compose.sh b/deploy/cli/lib/compose.sh index dad0428..1289c3c 100644 --- a/deploy/cli/lib/compose.sh +++ b/deploy/cli/lib/compose.sh @@ -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,"* ]] } diff --git a/deploy/cli/lib/deploy.sh b/deploy/cli/lib/deploy.sh index 787ee77..41fb935 100644 --- a/deploy/cli/lib/deploy.sh +++ b/deploy/cli/lib/deploy.sh @@ -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 …». diff --git a/deploy/cli/lib/doctor.sh b/deploy/cli/lib/doctor.sh index dc504fe..7c46e6d 100644 --- a/deploy/cli/lib/doctor.sh +++ b/deploy/cli/lib/doctor.sh @@ -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 diff --git a/deploy/cli/lib/status.sh b/deploy/cli/lib/status.sh index b6b2305..6026ab8 100644 --- a/deploy/cli/lib/status.sh +++ b/deploy/cli/lib/status.sh @@ -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)" diff --git a/env.example b/env.example deleted file mode 100644 index dcaa0bb..0000000 --- a/env.example +++ /dev/null @@ -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 - -# --- Gunicorn --- -CHATBALLS_GUNICORN_WORKERS=3 -CHATBALLS_GUNICORN_TIMEOUT=60 diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 7635fdf..c202c17 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -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 diff --git a/tests/cli/test_custocrm_cli.py b/tests/cli/test_custocrm_cli.py index e8cdfb0..5f4e17e 100644 --- a/tests/cli/test_custocrm_cli.py +++ b/tests/cli/test_custocrm_cli.py @@ -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 +