All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m56s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Successful in 17m31s
Полный деплой ПТИЦЫ каждый раз делал `up -d --force-recreate --no-deps caddy`
и сносил единственный процесс, слушающий 80/443: замер 05.09 (#3274) — 67 с
`code=000` на ВСЕХ доменах хоста, включая публичный лендинг МЕРЫ. Заглушка
окна деплоя здесь бессильна по построению: её отдаёт тот же Caddy.
Что установлено, а не принято на веру:
* Безусловный флаг появился 17.05 (11e78d73) ради нового bind-маунта
`./preview` — «иначе новые volume mounts не появляются». Довод неверен:
`docker compose up -d` БЕЗ `--force-recreate` пересоздаёт контейнер сам при
смене описания сервиса или образа. Проверено на живом демоне (docker 28.4):
добавлен volume → новый id; тег переставлен на другой образ → новый id;
не менялось ничего → `Container … Running`, id тот же.
* Compose не видит только одного: СОДЕРЖИМОГО пофайлового bind-маунта.
`git reset --hard` пишет новый инод, контейнер держит прежний, и `caddy
reload` перечитывает старый текст (тот же механизм — Alertmanager 27.08 и
Alloy #3380). У Caddy так смонтированы пять путей: Caddyfile и четыре
сниппета; каталоги (caddy/sites, caddy/local, preview) этим не страдают —
самый частый случай, caddy/sites/apps.caddy, пересоздания НЕ требует.
* Отсюда же второй, беззвучный дефект: быстрый путь `deploy-caddy` делал голый
`exec caddy reload` после `git reset --hard`, то есть правка Caddyfile или
сниппета до контейнера не доезжала вовсе, а джоба уходила зелёной.
Оба пути деплоя теперь зовут ops/caddy-apply.sh: `caddy validate` одноразовым
контейнером по файлам С ХОСТА (битый конфиг не применяется и прокси не
трогает) → `up -d` без `--force-recreate` → если контейнер тот же, сверка
sha256 каждого пофайлового маунта с тем, что видит контейнер → пересоздание
ТОЛЬКО при расхождении, иначе `caddy reload` без разрыва соединений.
Не прочиталось — считаем расхождением: fail-safe в сторону прежнего поведения.
Гейт backend/tests/ops/test_3443_caddy_reload_not_recreate.py исполняет скрипт
с подставным `docker` и смотрит на совершённые действия, а не на его текст:
ничего не менялось → reload без пересоздания; правлен Caddyfile или любой из
сниппетов → пересоздание; правка в каталоге → без пересоздания; битый конфиг →
не тронуто ничего; compose пересоздал сам → второго пересоздания нет.
Отдельно — проводка в deploy.yml и запрет безусловного `--force-recreate` для
Caddy в полном деплое.
Приёмка (#3443) снимается ПОСЛЕ мержа, на живом деплое: непрерывная проба
`scripts/probe-deploy-window.sh` с хоста — максимальная серия `000` меньше 2 с
против нынешних 67 с.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
351 lines
18 KiB
Python
351 lines
18 KiB
Python
"""Полный деплой не пересоздаёт Caddy без нужды (#3443).
|
||
|
||
ЧТО СЛУЧИЛОСЬ. Каждый полный деплой ПТИЦЫ делал `up -d --force-recreate
|
||
--no-deps caddy`, то есть сносил единственный процесс, слушающий 80/443.
|
||
Замер 05.09 (#3274): 67 с `code=000` на ВСЕХ доменах хоста — gendsgn.ru,
|
||
meraocenka.ru и зеркала. Не 502/503: принимающего процесса нет вовсе, поэтому
|
||
заглушка окна деплоя бессильна по построению — её отдаёт тот же Caddy.
|
||
|
||
ЧТО УСТАНОВЛЕНО. Безусловный флаг появился 17.05 (11e78d73) ради нового
|
||
bind-маунта `./preview`, который «не появлялся в running container». Довод
|
||
неверен: `docker compose up -d` БЕЗ `--force-recreate` пересоздаёт контейнер
|
||
сам, как только меняется описание сервиса или образ (проверено на живом демоне
|
||
docker 28.4). Единственное, чего compose не видит, — СОДЕРЖИМОЕ пофайлового
|
||
bind-маунта: `git reset --hard` пишет новый инод, контейнер держит прежний, и
|
||
`caddy reload` перечитывает старый текст. У Caddy так смонтированы Caddyfile и
|
||
четыре сниппета; каталоги (caddy/sites, caddy/local, preview) этим не страдают.
|
||
|
||
ЗАЧЕМ ЭТОТ ФАЙЛ. У правки нет отрицательного признака: вернуть `--force-recreate`
|
||
«на всякий случай» — одна строка, все деплои останутся зелёными, а окно в минуту
|
||
увидит только тот, кто в этот момент держал непрерывную пробу. Проверки ниже
|
||
ИСПОЛНЯЮТ ops/caddy-apply.sh с подставным `docker` и смотрят на СОВЕРШЁННЫЕ
|
||
действия (пересоздал / перезагрузил / не тронул), а не на текст скрипта.
|
||
Отдельно проверяется проводка в deploy.yml — что оба пути деплоя зовут именно
|
||
его.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import shutil
|
||
import stat
|
||
import subprocess
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
import yaml
|
||
|
||
# backend/tests/ops/<этот файл> → корень репозитория
|
||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||
SCRIPT = REPO_ROOT / "ops" / "caddy-apply.sh"
|
||
DEPLOY = REPO_ROOT / ".forgejo" / "workflows" / "deploy.yml"
|
||
|
||
CID = "caddy-cid-0001"
|
||
|
||
# Маунты Caddy ровно как на проде (`docker inspect gendesign-caddy-1`, 12.09):
|
||
# пять ПОФАЙЛОВЫХ bind-маунтов и три каталога. Тома (caddy_data и соседи) в
|
||
# сверку не входят — их фильтрует `{{if eq .Type "bind"}}`.
|
||
FILE_MOUNTS = {
|
||
"Caddyfile": "/etc/caddy/Caddyfile",
|
||
"caddy/users.caddy.snippet": "/etc/caddy/caddy/users.caddy.snippet",
|
||
"caddy/metrics-ui.caddy.snippet": "/etc/caddy/caddy/metrics-ui.caddy.snippet",
|
||
"caddy/metrics-ingest.caddy.snippet": "/etc/caddy/caddy/metrics-ingest.caddy.snippet",
|
||
"caddy/deploy-window.caddy.snippet": "/etc/caddy/caddy/deploy-window.caddy.snippet",
|
||
}
|
||
DIR_MOUNTS = {
|
||
"caddy/sites": "/etc/caddy/caddy/sites",
|
||
"caddy/local": "/etc/caddy/caddy/local",
|
||
"preview": "/srv/preview",
|
||
}
|
||
|
||
# Подставной `docker`. Пишет каждый вызов в $FAKE_LOG и отвечает по сценарию:
|
||
# run — одноразовый `caddy validate`, код из $FAKE_VALIDATE_RC;
|
||
# inspect — список маунтов из $FAKE_MOUNTS;
|
||
# exec — sha256sum ФАЙЛА, КОТОРЫЙ ВИДИТ КОНТЕЙНЕР ($FAKE_VIEW/<slug>);
|
||
# compose … ps — текущий id контейнера из $FAKE_CID_FILE;
|
||
# compose … up — при $FAKE_UP_RECREATES=1 подменяет id (compose пересоздал сам).
|
||
FAKE_DOCKER = r"""#!/bin/bash
|
||
printf '%s\n' "$*" >> "$FAKE_LOG"
|
||
cmd="$1"; shift
|
||
case "$cmd" in
|
||
run) exit "${FAKE_VALIDATE_RC:-0}" ;;
|
||
inspect) cat "$FAKE_MOUNTS" ;;
|
||
exec)
|
||
dst="$3"
|
||
view="$FAKE_VIEW/$(printf '%s' "$dst" | tr '/' '_')"
|
||
[ -f "$view" ] || exit 1
|
||
sha256sum "$view"
|
||
;;
|
||
compose)
|
||
case " $* " in
|
||
*" ps "*) cat "$FAKE_CID_FILE" ;;
|
||
*--force-recreate*) echo "recreated caddy (forced)" ;;
|
||
*" up "*)
|
||
if [ "${FAKE_UP_RECREATES:-0}" = "1" ]; then
|
||
printf 'caddy-cid-NEW\n' > "$FAKE_CID_FILE"
|
||
echo "Container gendesign-caddy-1 Started"
|
||
else
|
||
echo "Container gendesign-caddy-1 Running"
|
||
fi
|
||
;;
|
||
*) echo "(compose $*)" ;;
|
||
esac
|
||
;;
|
||
esac
|
||
exit 0
|
||
"""
|
||
|
||
# macOS несёт shasum вместо sha256sum; на раннере (ubuntu) и на проде утилита
|
||
# настоящая. Шим ставится только при её отсутствии — иначе гейт не запускался бы
|
||
# локально вовсе.
|
||
SHA_SHIM = '#!/bin/sh\nexec shasum -a 256 "$@"\n'
|
||
|
||
|
||
def _write_exec(path: Path, text: str) -> None:
|
||
path.write_text(text, encoding="utf-8")
|
||
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||
|
||
|
||
@pytest.fixture
|
||
def prod_tree(tmp_path: Path) -> Path:
|
||
"""Копия боевого дерева: скрипт + конфиги + «взгляд контейнера»."""
|
||
tree = tmp_path / "opt" / "gendesign"
|
||
(tree / "ops").mkdir(parents=True)
|
||
shutil.copy(SCRIPT, tree / "ops" / SCRIPT.name)
|
||
for rel in [*FILE_MOUNTS, "caddy/sites/apps.caddy", "caddy/local/.gitignore"]:
|
||
path = tree / rel
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(f"# {rel} версия НОВАЯ\n", encoding="utf-8")
|
||
(tree / "preview").mkdir()
|
||
|
||
view = tmp_path / "view"
|
||
view.mkdir()
|
||
# По умолчанию контейнер видит ровно то же, что лежит на диске.
|
||
for rel, dst in FILE_MOUNTS.items():
|
||
(view / dst.replace("/", "_")).write_text(
|
||
(tree / rel).read_text(encoding="utf-8"), encoding="utf-8"
|
||
)
|
||
|
||
mounts = tmp_path / "mounts"
|
||
mounts.write_text(
|
||
"".join(f"{tree / rel}|{dst}\n" for rel, dst in {**FILE_MOUNTS, **DIR_MOUNTS}.items()),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
bin_dir = tmp_path / "bin"
|
||
bin_dir.mkdir()
|
||
_write_exec(bin_dir / "docker", FAKE_DOCKER)
|
||
if shutil.which("sha256sum") is None:
|
||
_write_exec(bin_dir / "sha256sum", SHA_SHIM)
|
||
(tmp_path / "cid").write_text(CID + "\n", encoding="utf-8")
|
||
(tmp_path / "log").write_text("", encoding="utf-8")
|
||
return tree
|
||
|
||
|
||
def _run(tree: Path, **env_extra: str) -> tuple[int, str, list[str]]:
|
||
root = tree.parent.parent
|
||
env = {
|
||
"PATH": f"{root / 'bin'}:/usr/bin:/bin:/usr/sbin:/sbin",
|
||
"FAKE_LOG": str(root / "log"),
|
||
"FAKE_CID_FILE": str(root / "cid"),
|
||
"FAKE_MOUNTS": str(root / "mounts"),
|
||
"FAKE_VIEW": str(root / "view"),
|
||
**env_extra,
|
||
}
|
||
proc = subprocess.run(
|
||
["sh", str(tree / "ops" / "caddy-apply.sh")],
|
||
cwd=str(tree),
|
||
env=env,
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
calls = [c for c in (root / "log").read_text(encoding="utf-8").splitlines() if c]
|
||
return proc.returncode, proc.stdout + proc.stderr, calls
|
||
|
||
|
||
def _stale(tree: Path, dst: str) -> None:
|
||
"""Контейнер остался на старом иноде этого маунта."""
|
||
view = tree.parent.parent / "view" / dst.replace("/", "_")
|
||
view.write_text("# версия СТАРАЯ\n", encoding="utf-8")
|
||
|
||
|
||
def _recreated(calls: list[str]) -> bool:
|
||
return any("--force-recreate" in c for c in calls)
|
||
|
||
|
||
def _reloaded(calls: list[str]) -> bool:
|
||
return any("caddy reload" in c for c in calls)
|
||
|
||
|
||
# ── Что скрипт делает на самом деле ──────────────────────────────────────────
|
||
|
||
|
||
def test_nothing_changed_reloads_without_recreate(prod_tree: Path) -> None:
|
||
"""Обычный полный деплой (конфиг прокси не трогали): reload, без окна."""
|
||
rc, out, calls = _run(prod_tree)
|
||
assert rc == 0, out
|
||
assert not _recreated(calls), (
|
||
f"Caddy пересоздан, хотя ничего не изменилось: {calls}. "
|
||
"Это и есть #3443: 67 с code=000 на всех доменах при каждом деплое."
|
||
)
|
||
assert _reloaded(calls), f"конфиг не применён вовсе: {calls}"
|
||
|
||
|
||
def test_changed_file_mount_forces_recreate(prod_tree: Path) -> None:
|
||
"""Caddyfile правлен: reload перечитал бы старый инод — нужен recreate."""
|
||
_stale(prod_tree, "/etc/caddy/Caddyfile")
|
||
rc, out, calls = _run(prod_tree)
|
||
assert rc == 0, out
|
||
assert _recreated(calls), (
|
||
f"пересоздания нет: {calls}. Пофайловый bind-маунт держит инод — правка "
|
||
"Caddyfile не доехала бы до контейнера, а деплой ушёл бы зелёным."
|
||
)
|
||
assert "/etc/caddy/Caddyfile" in out, f"решение не названо в логе:\n{out}"
|
||
|
||
|
||
@pytest.mark.parametrize("dst", sorted(set(FILE_MOUNTS.values()) - {"/etc/caddy/Caddyfile"}))
|
||
def test_changed_snippet_forces_recreate(prod_tree: Path, dst: str) -> None:
|
||
"""Каждый из четырёх сниппетов — тот же класс, не только Caddyfile."""
|
||
_stale(prod_tree, dst)
|
||
_, _out, calls = _run(prod_tree)
|
||
assert _recreated(calls), f"{dst}: правка сниппета не доехала бы: {calls}"
|
||
|
||
|
||
def test_directory_mount_change_does_not_recreate(prod_tree: Path) -> None:
|
||
"""caddy/sites/apps.caddy — самый частый случай; каталог инод не держит.
|
||
|
||
Если сюда приползёт пересоздание «за компанию», окно недоступности вернётся
|
||
ровно на тех правках, ради которых заведён быстрый путь #2916.
|
||
"""
|
||
(prod_tree / "caddy" / "sites" / "apps.caddy").write_text("# новый блок\n", encoding="utf-8")
|
||
rc, out, calls = _run(prod_tree)
|
||
assert rc == 0, out
|
||
assert not _recreated(calls), f"правка в КАТАЛОГЕ вызвала пересоздание: {calls}"
|
||
assert _reloaded(calls), f"правка в каталоге не применена: {calls}"
|
||
|
||
|
||
def test_broken_config_touches_nothing(prod_tree: Path) -> None:
|
||
"""Битый конфиг: ни up, ни reload, ни пересоздания — прокси не тронут.
|
||
|
||
Иначе опечатка в Caddyfile уводит контейнер в crash-loop и роняет все
|
||
домены сразу (ровно то, чем опасен `--force-recreate` вслепую).
|
||
"""
|
||
rc, out, calls = _run(prod_tree, FAKE_VALIDATE_RC="1")
|
||
assert rc != 0, f"скрипт не упал на битом конфиге:\n{out}"
|
||
assert not _recreated(calls), f"битый конфиг поехал в пересоздание: {calls}"
|
||
assert not _reloaded(calls), f"битый конфиг поехал в reload: {calls}"
|
||
assert not any(" up " in f" {c} " for c in calls), f"был `up` при битом конфиге: {calls}"
|
||
|
||
|
||
def test_compose_recreate_is_not_doubled(prod_tree: Path) -> None:
|
||
"""compose пересоздал сам (сменилось описание сервиса/образ) — хватит.
|
||
|
||
Второй `--force-recreate` поверх — ещё одно окно недоступности на ровном
|
||
месте, а новый контейнер и так читает свежие файлы.
|
||
"""
|
||
rc, out, calls = _run(prod_tree, FAKE_UP_RECREATES="1")
|
||
assert rc == 0, out
|
||
assert not _recreated(calls), f"пересоздание сделано дважды: {calls}"
|
||
assert not _reloaded(calls), f"reload поверх свежего контейнера: {calls}"
|
||
|
||
|
||
def test_unreadable_container_view_falls_back_to_recreate(prod_tree: Path) -> None:
|
||
"""Сверка не отработала (контейнер не отвечает) → прежнее поведение.
|
||
|
||
Fail-safe направлен в сторону пересоздания: лучше окно в секунды, чем
|
||
беззвучно не применённая правка конфига прокси.
|
||
"""
|
||
(prod_tree.parent.parent / "view" / "_etc_caddy_Caddyfile").unlink()
|
||
_, out, calls = _run(prod_tree)
|
||
assert _recreated(calls), f"непрочитанный маунт сочли доехавшим: {calls}\n{out}"
|
||
|
||
|
||
def test_validation_precedes_any_action(prod_tree: Path) -> None:
|
||
"""Проверка конфига идёт ПЕРВЫМ вызовом, до любого изменения состояния."""
|
||
_, out, calls = _run(prod_tree)
|
||
assert calls, f"скрипт не сделал ни одного вызова docker:\n{out}"
|
||
assert calls[0].startswith("run "), f"первым идёт не проверка конфига: {calls}"
|
||
assert "caddy validate" in calls[0], f"первый вызов — не validate: {calls[0]}"
|
||
|
||
|
||
# ── Проводка: оба пути деплоя зовут именно этот скрипт ───────────────────────
|
||
|
||
|
||
def _ssh_script(job: str) -> str:
|
||
spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8"))
|
||
steps = [s for s in spec["jobs"][job]["steps"] if "ssh-action" in str(s.get("uses"))]
|
||
assert len(steps) == 1, f"в job `{job}` нет ровно одного ssh-шага — гейт #3443 ослеп"
|
||
script = steps[0]["with"]["script"]
|
||
assert script.strip(), f"ssh-скрипт job `{job}` пуст"
|
||
return script
|
||
|
||
|
||
def _commands(script: str) -> str:
|
||
"""Только команды: комментарии выкинуты, продолжения строк склеены.
|
||
|
||
Комментарии — потому что разбор дефекта живёт в тех же файлах и содержит
|
||
его формулировку дословно: гейт по голому тексту краснел бы от объяснения,
|
||
а не от кода. Склейка `\\` — потому что `--force-recreate` и имя сервиса
|
||
легко оказываются на РАЗНЫХ физических строках, и построчный поиск такую
|
||
запись не увидел бы (зелено по построению).
|
||
"""
|
||
kept = [ln for ln in script.splitlines() if not ln.lstrip().startswith("#")]
|
||
return re.sub(r"\\\n\s*", " ", "\n".join(kept))
|
||
|
||
|
||
def _forced_caddy_recreates(commands: str) -> list[str]:
|
||
"""Строки, которые пересоздают именно сервис caddy."""
|
||
return [
|
||
ln
|
||
for ln in commands.splitlines()
|
||
if "--force-recreate" in ln and re.search(r"\bcaddy\b", ln)
|
||
]
|
||
|
||
|
||
def test_script_exists_and_is_the_one_under_test() -> None:
|
||
"""Признак непустоты: без скрипта проверки выше проходили бы вхолостую."""
|
||
assert SCRIPT.is_file(), f"нет {SCRIPT} — проводка ниже проверяла бы пустоту"
|
||
|
||
|
||
@pytest.mark.parametrize("job", ["deploy", "deploy-caddy"])
|
||
def test_deploy_applies_caddy_config_through_the_script(job: str) -> None:
|
||
assert "ops/caddy-apply.sh" in _commands(_ssh_script(job)), (
|
||
f"job `{job}` не зовёт ops/caddy-apply.sh — конфиг прокси применяется "
|
||
"мимо разбора #3443 (или безусловным пересозданием, или reload'ом, "
|
||
"который на пофайловом маунте читает старый инод)"
|
||
)
|
||
|
||
|
||
def test_full_deploy_has_no_unconditional_caddy_recreate() -> None:
|
||
"""Главный инвариант: в полном деплое нет безусловного пересоздания Caddy.
|
||
|
||
Возврат одной строки `up -d --force-recreate --no-deps caddy` в job `deploy`
|
||
возвращает 67-секундное окно `code=000` на всех доменах — и не краснит
|
||
ничего: деплой остаётся зелёным, а увидеть отказ может только непрерывная
|
||
проба, запущенная ровно в эту минуту.
|
||
"""
|
||
bad = _forced_caddy_recreates(_commands(_ssh_script("deploy")))
|
||
assert not bad, (
|
||
"в полный деплой вернулось безусловное пересоздание Caddy:\n "
|
||
+ "\n ".join(bad)
|
||
+ "\nПересоздание обязано быть УСЛОВНЫМ — см. ops/caddy-apply.sh: compose "
|
||
"сам пересоздаёт контейнер при смене описания сервиса или образа, а "
|
||
"вручную это нужно только когда до контейнера не доехал пофайловый "
|
||
"bind-маунт (#3443)."
|
||
)
|
||
|
||
|
||
def test_gate_would_notice_the_regression() -> None:
|
||
"""Сам гейт обязан краснеть на возвращённом дефекте — проверка на себя.
|
||
|
||
Без этого «не нашли force-recreate» неотличимо от «искали не там»: маска
|
||
поиска, промахнувшаяся мимо строки, выглядит зелёной ровно так же.
|
||
"""
|
||
regressed = _commands(
|
||
" # безусловное пересоздание caddy вернулось сюда\n"
|
||
" docker compose -p gendesign -f docker-compose.prod.yml up -d \\\n"
|
||
" --force-recreate --no-deps caddy\n"
|
||
)
|
||
assert _forced_caddy_recreates(regressed), (
|
||
"маска поиска не видит дословно ту строку, ради которой заведён гейт"
|
||
)
|