🔥 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:
AndreyandClaude Opus 5 committed 2026-09-08 17:22:29 +03:00
1 parent 03914fba09
commit a2e6672424
14 files changed
+394 -484

No files matched your search

-1
View File
@@ -1,6 +1,5 @@
.env
.env.*
!.env.production.example
.git
.venv
__pycache__
+3 -2
View File
@@ -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__/
+3 -3
View File
@@ -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` явно. Задавать что-либо при установке не
нужно и негде — файла с переменными у продукта нет.
### Что поднимается
+2 -1
View File
@@ -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:
+1 -1
View File
@@ -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
}
+4 -3
View File
@@ -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"
+4 -7
View File
@@ -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
View File
@@ -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 …».
+7 -51
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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