✨ feat(release): установка на чистый хост одной командой

Раньше compose монтировал с хоста Caddyfile, init-скрипты базы и генератор
секретов. Из-за этого установка требовала рядом распакованный репозиторий, а
файл, забытый при сборке релиза, Docker молча подменял пустым каталогом — и
стек падал на первом старте у человека. Плюс production-образ backend вообще
не собирался: COPY content ссылался на каталог, которого в репозитории нет,
так что релиза не существовало ни на GitHub, ни на GitLab.

Теперь весь дистрибутив — один compose.yaml со страницы релиза:

  curl -fsSL .../compose.yaml -o compose.yaml
  docker compose up -d --wait

- Caddyfile переехал в свой образ шлюза (caddy validate — в сборке),
  init-скрипты базы — в свой образ postgres, генератор секретов — в
  backend-образ. Bind-mount'ов в production-манифесте не осталось.
- Состояние установки — именованные тома вместо каталогов рабочего каталога.
  Заодно чинит загрузку файлов на Linux: том наследует владельца из образа
  (hub), тогда как bind-mount доставался контейнеру как root:root.
- scripts/pin-release-compose.py закрепляет ссылки на образы по digest и
  падает, если хоть один ключ остался подстановкой.
- GitHub-workflow собирает четыре образа и прикладывает к релизу compose.yaml
  (основной путь) и release.env (для `chatballs deploy`).
- tests/cli/test_release_compose.py держит свойство: манифест без bind-mount'ов
  и полностью закрепляем по digest.
- Из окружения шлюза убраны CHATBALLS_APP_DOMAIN и CHATBALLS_ACME_EMAIL —
  Caddyfile их не читает.

Dev-контур не меняется по смыслу: compose.dev.yaml по-прежнему собирает всё
из исходников и держит состояние в ./data.

AUDIT-TODO.md — временный список остального из аудита; удаляется целиком,
когда закрыт последний пункт.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-09 04:55:17 +03:00
1 parent a141cd4f50
commit 8fe66385d6
19 files changed
+832 -137

No files matched your search

+3 -1
View File
@@ -21,7 +21,9 @@ import pytest
REPO_RELEASE_ROOT = Path(__file__).resolve().parents[2] # code/chatballs
RELEASE_FILES = ["compose.yaml", "Caddyfile"]
# Релиз — это один compose.yaml. Caddyfile, init-скрипты базы и генератор
# секретов лежат внутри образов, поэтому в бандле их нет.
RELEASE_FILES = ["compose.yaml"]
LIB_GLOB_DIR = "deploy/cli/lib"
+1 -1
View File
@@ -232,7 +232,7 @@ def test_deploy_fails_when_release_checksum_is_invalid(fake_env):
fake_env.install_flock(held=False)
(fake_env.release / "Caddyfile").write_text("tampered\n", encoding="utf-8")
(fake_env.release / "compose.yaml").write_text("tampered\n", encoding="utf-8")
r = _run(fake_env, "deploy", "--non-interactive")
+121
View File
@@ -0,0 +1,121 @@
"""compose.yaml обязан быть самодостаточным: установка — один файл.
Раньше стек монтировал с хоста Caddyfile, init-скрипты базы и генератор
секретов. Из-за этого установка требовала рядом распакованный репозиторий, а
файл, забытый при сборке релиза, Docker молча подменял пустым каталогом — и
стек падал на первом старте, уже у человека.
Эти тесты держат свойство, а не текущий текст файла: в production-манифесте нет
ни одного bind-mount (кроме сертификатов TURN у опционального профиля calls), а
все ссылки на образы поддаются закреплению по digest.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
COMPOSE = REPO_ROOT / "compose.yaml"
PIN_SCRIPT = REPO_ROOT / "scripts" / "pin-release-compose.py"
# Единственное исключение: сертификат TURN-хоста кладёт на хост renewal-хук,
# и только при включённом профиле calls.
BIND_MOUNT_EXCEPTIONS = {"coturn"}
DIGEST = "sha256:" + "a" * 64
IMAGE_KEYS = (
"CHATBALLS_BACKEND_IMAGE",
"CHATBALLS_FRONTEND_IMAGE",
"CHATBALLS_POSTGRES_IMAGE",
"CHATBALLS_REDIS_IMAGE",
"CHATBALLS_GATEWAY_IMAGE",
"CHATBALLS_COTURN_IMAGE",
)
def _compose() -> dict:
return yaml.safe_load(COMPOSE.read_text(encoding="utf-8"))
def _volume_source(entry) -> str:
if isinstance(entry, str):
return entry.split(":", 1)[0]
return str(entry.get("source", ""))
def _is_bind(source: str) -> bool:
"""Bind-mount — всё, что указывает на путь, а не на именованный том."""
return source.startswith((".", "/", "~")) or "${" in source
@pytest.mark.parametrize("service", sorted(_compose()["services"]))
def test_service_has_no_host_bind_mounts(service: str) -> None:
definition = _compose()["services"][service]
binds = [
source
for entry in definition.get("volumes", [])
if _is_bind(source := _volume_source(entry))
]
if service in BIND_MOUNT_EXCEPTIONS:
pytest.skip(f"{service}: bind-mount разрешён явно")
assert binds == [], (
f"{service}: манифест монтирует с хоста {binds}. "
"Установка — один compose.yaml: всё, что нужно сервису, кладётся в образ."
)
def test_named_volumes_are_declared() -> None:
compose = _compose()
declared = set(compose.get("volumes") or {})
used = {
source
for definition in compose["services"].values()
for entry in definition.get("volumes", [])
if not _is_bind(source := _volume_source(entry))
}
assert used <= declared, f"не объявлены тома: {sorted(used - declared)}"
def test_every_image_reference_can_be_pinned(tmp_path: Path) -> None:
"""Все шесть ключей образов присутствуют и закрепляются по digest.
Если из манифеста уйдёт (или переименуется) хоть один ключ, релизный
compose.yaml уедет с плавающим тегом — а найдётся это уже у человека.
"""
output = tmp_path / "compose.yaml"
pins: list[str] = []
for key in IMAGE_KEYS:
pins += ["--pin", f"{key}=registry.test/{key.lower()}:1.0.0@{DIGEST}"]
result = subprocess.run(
[sys.executable, str(PIN_SCRIPT), "--source", str(COMPOSE),
"--output", str(output), "--version", "1.0.0-test", *pins],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
pinned = output.read_text(encoding="utf-8")
for key in IMAGE_KEYS:
assert f"${{{key}" not in pinned, f"{key} остался подстановкой в релизном файле"
def test_pinning_rejects_floating_tag(tmp_path: Path) -> None:
pins: list[str] = []
for key in IMAGE_KEYS:
ref = "registry.test/x:1.0.0" if key == "CHATBALLS_BACKEND_IMAGE" else f"registry.test/x:1.0.0@{DIGEST}"
pins += ["--pin", f"{key}={ref}"]
result = subprocess.run(
[sys.executable, str(PIN_SCRIPT), "--source", str(COMPOSE),
"--output", str(tmp_path / "compose.yaml"), "--version", "1.0.0-test", *pins],
capture_output=True,
text=True,
)
assert result.returncode != 0
assert "@sha256" in result.stdout + result.stderr