✨ 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

+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Готовит релизную копию compose.yaml: ссылки на образы закреплены по digest.
Установка на чистый хост — это один файл: человек скачивает compose.yaml со
страницы релиза и делает `docker compose up -d --wait`. Значит в этом файле не
должно остаться ни одной подстановки, которая молча возьмёт `:dev` или плавающий
тег, если переменной в окружении нет.
Скрипт переписывает ровно значения по умолчанию внутри ``${VAR:-...}`` для
известных ключей образов и падает, если хоть один ключ не найден или пришёл без
digest. Переопределение переменной окружения остаётся возможным — это нужно
staging и облаку, которые ведут конфигурацию сами.
Использование:
pin-release-compose.py --source compose.yaml --output dist/compose.yaml \\
--version 1.0.0 --pin CHATBALLS_BACKEND_IMAGE=ghcr.io/...@sha256:... ...
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
IMAGE_KEYS = (
"CHATBALLS_BACKEND_IMAGE",
"CHATBALLS_FRONTEND_IMAGE",
"CHATBALLS_POSTGRES_IMAGE",
"CHATBALLS_REDIS_IMAGE",
"CHATBALLS_GATEWAY_IMAGE",
"CHATBALLS_COTURN_IMAGE",
)
DIGEST_RE = re.compile(r"@sha256:[0-9a-fA-F]{64}$")
HEADER = """# Chatballs {version} — релизная копия compose.yaml.
#
# Установка на чистый хост с одним докером:
#
# docker compose up -d --wait
#
# Больше рядом ничего не нужно: Caddyfile, init-скрипты базы и генератор
# секретов лежат внутри образов. Файл сгенерирован автоматически из
# compose.yaml релиза {version}; править его руками не нужно — обновление
# сводится к тому, чтобы скачать этот файл новой версии и повторить команду.
#
"""
def pin(source: str, pins: dict[str, str], version: str) -> str:
text = source
for key, ref in pins.items():
pattern = re.compile(r"\$\{" + re.escape(key) + r":-[^}]*\}")
replaced, count = pattern.subn(ref, text)
if count == 0:
raise SystemExit(f"{key}: подстановка ${{{key}:-...}} не найдена в compose.yaml")
text = replaced
leftover = [key for key in IMAGE_KEYS if f"${{{key}" in text]
if leftover:
raise SystemExit("не закреплены ссылки на образы: " + ", ".join(leftover))
return HEADER.format(version=version) + text
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--source", default="compose.yaml")
parser.add_argument("--output", required=True)
parser.add_argument("--version", required=True)
parser.add_argument(
"--pin",
action="append",
default=[],
metavar="KEY=REF",
help="ссылка на образ по digest, по одной на каждый ключ",
)
args = parser.parse_args(argv)
pins: dict[str, str] = {}
for item in args.pin:
key, _, ref = item.partition("=")
if key not in IMAGE_KEYS:
raise SystemExit(f"неизвестный ключ образа: {key}")
if not DIGEST_RE.search(ref):
raise SystemExit(f"{key}: ссылка обязана быть закреплена по @sha256")
pins[key] = ref
missing = [key for key in IMAGE_KEYS if key not in pins]
if missing:
raise SystemExit("не переданы ссылки на образы: " + ", ".join(missing))
source = Path(args.source).read_text(encoding="utf-8")
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(pin(source, pins, args.version), encoding="utf-8", newline="\n")
print(f"{output}: закреплено ссылок — {len(pins)}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))