From 9fedaa45822558c094ac55a41d557d963ff4ed4e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 19:38:48 +0500 Subject: [PATCH] =?UTF-8?q?fix(deploy):=20Caddy=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D1=91=D1=82=D1=81=D1=8F=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BA=D0=BE=D0=B3=D0=B4=D0=B0?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D1=87=D0=B5=20=D0=BD=D0=B5=20=D0=B4=D0=BE=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D1=82=20(#3443)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Полный деплой ПТИЦЫ каждый раз делал `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 --- .forgejo/workflows/deploy.yml | 47 ++- .../test_3443_caddy_reload_not_recreate.py | 351 ++++++++++++++++++ ops/caddy-apply.sh | 98 +++++ 3 files changed, 477 insertions(+), 19 deletions(-) create mode 100644 backend/tests/ops/test_3443_caddy_reload_not_recreate.py create mode 100755 ops/caddy-apply.sh diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 3e8cd771..7e2b4a2e 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -1058,12 +1058,17 @@ jobs: docker compose -p gendesign -f docker-compose.prod.yml up -d \ --force-recreate --no-deps $WORKER_SERVICES - # Caddy: force-recreate чтобы подхватить изменения в Caddyfile - # И в особенности новые volume mounts из docker-compose.prod.yml - # (`reload` не пересоздаёт container, поэтому новые binds не появляются — - # был случай 2026-05-17 с PR #268 preview/ — потребовался manual SSH fix). - docker compose -p gendesign -f docker-compose.prod.yml up -d \ - --force-recreate --no-deps caddy + # Caddy: пересоздание ТОЛЬКО когда без него правка не доедет (#3443). + # Здесь стоял безусловный `up -d --force-recreate --no-deps caddy` — + # то есть КАЖДЫЙ полный деплой сносил единственный процесс, слушающий + # 80/443, и все домены хоста отдавали `code=000` (замер 05.09: 67 с). + # Довод той правки (17.05, 11e78d73 — «иначе новые volume mounts не + # появляются») не подтвердился: `up -d` БЕЗ флага пересоздаёт + # контейнер сам, как только меняется описание сервиса или образ. + # Разбор и проверки — в шапке ops/caddy-apply.sh; там же сверка + # пофайловых bind-маунтов (Caddyfile + 4 сниппета держат инод) и + # `caddy validate` до применения. + sh ops/caddy-apply.sh # Forwarder: force-recreate чтобы новый image / новые env подхватывались. # Без --force-recreate обычный `up -d` НЕ recreate'ит при image rebuild @@ -1290,14 +1295,13 @@ jobs: # Публичный периметр МЕРЫ живёт в этом файле и будет меняться часто: новая # страница = новая строка allowlist'а. # - # ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Полный деплой - # осознанно пересоздаёт контейнер (комментарий в ci.yml: `reload` отказался бы - # принять битый конфиг и оставил бы работать старый — на общем деплое это - # скрыло бы поломку). Здесь наоборот: правится ТОЛЬКО конфиг, и отказ - # применить битый — ровно то, что нужно. `caddy reload` возвращает ненулевой - # код → job краснеет, а домены продолжают обслуживаться старым конфигом. - # Альтернатива (`--force-recreate`) на опечатке уводит контейнер в crash-loop - # и роняет ВСЕ домены сразу. + # ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Опечатка в конфиге на + # пересоздании уводит контейнер в crash-loop и роняет ВСЕ домены сразу, а + # `caddy reload` её просто не принимает: job краснеет, домены продолжают + # обслуживаться прежним конфигом. С #3443 ровно тот же порядок действует и на + # полном деплое — оба пути зовут ops/caddy-apply.sh, который сперва проверяет + # конфиг одноразовым контейнером и пересоздаёт Caddy, только если правка иначе + # не доедет (пофайловый bind-маунт держит инод). # # Гейт `caddy validate` на PR (#2913) остаётся первой линией; этот шаг — # вторая, уже против боевого файла после `git reset`. @@ -1400,11 +1404,16 @@ jobs: exit 1 fi git reset --hard origin/main - # Конфиг примонтирован read-only с хоста, пересборка не нужна — - # контейнер читает тот же файл, что только что обновил git. - docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \ - caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile - echo "✓ конфиг прокси перезагружен без пересборки и без миграций" + # #3443: тот же скрипт, что и в полном деплое. Голый `exec caddy + # reload` здесь был ВЕРЕН только для каталогов (caddy/sites/**, + # caddy/local/**). Caddyfile и четыре сниппета смонтированы + # ПОФАЙЛОВО, а `git reset --hard` выше пишет новый инод — контейнер + # остаётся на прежнем, и reload перечитывает СТАРЫЙ текст. Отказ + # беззвучный: джоба зелёная, конфиг на диске новый, прокси работает + # по старому. Скрипт сверяет, что именно видит контейнер, и + # пересоздаёт его только в этом случае. + sh ops/caddy-apply.sh + echo "✓ быстрый путь завершён: без пересборки образов и без миграций" # ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ────────────────── # diff --git a/backend/tests/ops/test_3443_caddy_reload_not_recreate.py b/backend/tests/ops/test_3443_caddy_reload_not_recreate.py new file mode 100644 index 00000000..25de7952 --- /dev/null +++ b/backend/tests/ops/test_3443_caddy_reload_not_recreate.py @@ -0,0 +1,351 @@ +"""Полный деплой не пересоздаёт 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/); +# 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), ( + "маска поиска не видит дословно ту строку, ради которой заведён гейт" + ) diff --git a/ops/caddy-apply.sh b/ops/caddy-apply.sh new file mode 100755 index 00000000..da35d92d --- /dev/null +++ b/ops/caddy-apply.sh @@ -0,0 +1,98 @@ +#!/bin/sh +# Применить текущий конфиг прокси к работающему Caddy (#3443). +# +# ЗАЧЕМ. Полный деплой ПТИЦЫ пересоздавал сам Caddy БЕЗУСЛОВНО +# (`up -d --force-recreate --no-deps caddy`), а вместе с контейнером исчезал +# единственный процесс, слушающий 80/443. Замер 05.09 (#3274): 67 с `code=000` +# на ВСЕХ доменах хоста — gendsgn.ru, meraocenka.ru и зеркала, включая +# публичный лендинг МЕРЫ. Это не 502/503: принимающего процесса нет вовсе, +# поэтому заглушка окна деплоя (caddy/sites/deploy-window.caddy.snippet) здесь +# бессильна по построению — её отдаёт тот же Caddy. +# +# ЧТО НА САМОМ ДЕЛЕ ТРЕБУЕТ ПЕРЕСОЗДАНИЯ. Безусловный флаг появился 17.05 +# (11e78d73) ради нового bind-маунта `./preview` из docker-compose.prod.yml, +# который «не появлялся в running container». Довод неверен: `docker compose +# up -d` БЕЗ `--force-recreate` пересоздаёт контейнер сам, как только меняется +# описание сервиса или образ. Проверено на живом демоне (docker 28.4): +# добавлен volume → `Container … Starting/Started`, id контейнера новый; +# тег указан на др. образ → id новый; +# не менялось ничего → `Container … Running`, id тот же. +# +# Остаётся ровно один класс изменений, которого compose не видит: СОДЕРЖИМОЕ +# пофайлового bind-маунта. `git reset --hard` не правит файл на месте, а пишет +# новый инод; контейнер держит примонтированным прежний и продолжает читать +# его — `caddy reload` перечитает ровно тот же старый инод. Тот же механизм уже +# ловили на Alertmanager (27.08, deploy-metrics.yml) и на Alloy (#3380). У Caddy +# так смонтированы пять путей: Caddyfile и четыре сниппета. Каталоги +# (caddy/sites, caddy/local, preview) этим не страдают — правка внутри каталога +# видна контейнеру сразу, поэтому самый частый случай (caddy/sites/apps.caddy) +# пересоздания НЕ требует. +# +# ОТСЮДА ПОРЯДОК: проверить конфиг → `up -d` без `--force-recreate` → если +# контейнер остался тем же, сверить, видит ли он текущее содержимое пофайловых +# маунтов → пересоздать ТОЛЬКО при расхождении, иначе `caddy reload`, который +# не рвёт соединения. +# +# ГРАНИЦА. Сверка по СОДЕРЖИМОМУ, а не по иноду: файл, переписанный тем же +# текстом, пересоздания не требует. Не прочиталось (контейнер не запущен, в +# образе нет sha256sum) — считаем расхождением: fail-safe в сторону прежнего +# поведения, то есть пересоздания. +set -eu + +# Оба вызывающих (job `deploy` и job `deploy-caddy` в .forgejo/workflows/deploy.yml) +# работают в /opt/gendesign, но не зависеть от cwd дешевле, чем это помнить. +cd "$(dirname "$0")/.." + +COMPOSE="docker compose -p gendesign -f docker-compose.prod.yml" + +caddy_cid() { $COMPOSE ps -aq caddy 2>/dev/null | tail -n1; } + +# ── 1. Проверка ДО применения ──────────────────────────────────────────────── +# Одноразовый контейнер читает файлы С ХОСТА — то есть ровно то, что поедет в +# работающий Caddy. `exec caddy validate` здесь не годится: он проверил бы +# старый инод, то есть предыдущую версию конфига. Образ и парсер те же, что на +# PR-гейте (ci.yml «Guard: Caddyfile синтаксически валиден»). +echo "→ проверяю конфиг прокси одноразовым контейнером…" +if ! docker run --rm -v "$PWD:/work:ro" -w /work caddy:2 \ + caddy validate --config /work/Caddyfile --adapter caddyfile; then + echo "ОШИБКА: конфиг прокси не проходит проверку — НЕ применён." + echo " Работающий Caddy не тронут, домены живы. Чинить конфиг и повторять." + exit 1 +fi + +# ── 2. Описание сервиса и образ ────────────────────────────────────────────── +before=$(caddy_cid) +$COMPOSE up -d --no-deps caddy +after=$(caddy_cid) +if [ -z "$after" ]; then + echo 'ОШИБКА: после `up -d` контейнера caddy нет — смотри вывод compose выше.' + exit 1 +fi +if [ "$before" != "$after" ]; then + echo "✓ Caddy пересоздан compose'ом: изменилось описание сервиса или образ (${before:-нет}→${after})." + exit 0 +fi + +# ── 3. Доехало ли содержимое пофайловых маунтов ────────────────────────────── +stale=$(docker inspect "$after" \ + --format '{{range .Mounts}}{{if eq .Type "bind"}}{{.Source}}|{{.Destination}}{{println}}{{end}}{{end}}' \ + | while IFS='|' read -r src dst; do + [ -n "${dst:-}" ] || continue + # Каталог инод не держит — пропускаем. + [ -f "$src" ] || continue + host_sum=$(sha256sum "$src" 2>/dev/null | cut -d' ' -f1) + seen_sum=$(docker exec "$after" sha256sum "$dst" 2>/dev/null | cut -d' ' -f1) + [ -n "$host_sum" ] && [ "$host_sum" = "${seen_sum:-НЕ_ПРОЧИТАНО}" ] || printf '%s ' "$dst" + done) + +if [ -n "$stale" ]; then + echo "→ до контейнера НЕ доехали пофайловые маунты: $stale" + echo " (bind-маунт файла держит инод: reload перечитал бы старую версию — нужен recreate)" + $COMPOSE up -d --force-recreate --no-deps caddy + echo "✓ Caddy пересоздан — иначе правка осталась бы неприменённой." + exit 0 +fi + +# ── 4. Всё доехало — перезагрузка без разрыва соединений ───────────────────── +$COMPOSE exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile +echo "✓ конфиг прокси перезагружен без пересоздания контейнера — окна недоступности нет."