diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 385b4f90..178e1246 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -233,6 +233,16 @@ jobs: # уезжает в main зелёной. Ровно то, что осуждает комментарий выше. - '.forgejo/workflows/deploy-metrics.yml' - 'docker-compose.metrics.yml' + # #3443: тот же класс, третий раз. Гейт + # backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает + # ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть + # все содержательные регрессии живут в самом скрипте, а не в + # deploy.yml. PR, правящий только ops/**, без этой строки давал бы + # backend=false: джоба пропускается, гейт не исполняется, и + # «пересоздавать всегда» (окно 67 с на всех доменах) или + # «не пересоздавать никогда» (правка конфига беззвучно не доезжает) + # уезжает в main зелёным. + - 'ops/**' frontend: - 'frontend/**' - '.forgejo/workflows/ci.yml' 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..35ba8f12 --- /dev/null +++ b/backend/tests/ops/test_3443_caddy_reload_not_recreate.py @@ -0,0 +1,463 @@ +"""Полный деплой не пересоздаёт 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" +WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows" +DEPLOY = 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, код из $FAKE_INSPECT_RC; +# 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) + if [ "${FAKE_INSPECT_RC:-0}" != "0" ]; then + echo "Error: No such object" >&2 + exit "$FAKE_INSPECT_RC" + fi + 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_unreadable_mount_list_falls_back_to_recreate(prod_tree: Path) -> None: + """`docker inspect` не ответил → пересоздать, а не «расхождений нет». + + Статус `$(docker inspect … | while …)` — это статус `while`, то есть всегда + 0, а `pipefail` в POSIX-sh не существует. Провал команды давал бы пустой + список маунтов, ветку «всё доехало» и зелёную строку «окна недоступности + нет» при прокси, работающем по СТАРОМУ конфигу — тот самый беззвучный отказ, + ради которого написан скрипт. + """ + rc, out, calls = _run(prod_tree, FAKE_INSPECT_RC="1") + assert rc == 0, out + assert _recreated(calls), f"непрочитанный список маунтов сочли «всё доехало»: {calls}\n{out}" + assert not _reloaded(calls), f"reload вместо пересоздания: {calls}" + + +def test_no_file_mounts_is_not_silence(prod_tree: Path) -> None: + """Ноль пофайловых маунтов — не «сверка прошла», а «сверять было нечем». + + Так выглядит, например, перевод Caddyfile на именованный том: фильтр + `{{if eq .Type "bind"}}` перестаёт что-либо отбирать, и сверка становится + тавтологически успешной. + """ + root = prod_tree.parent.parent + (root / "mounts").write_text( + "".join(f"{prod_tree / rel}|{dst}\n" for rel, dst in DIR_MOUNTS.items()), encoding="utf-8" + ) + rc, out, calls = _run(prod_tree) + assert rc == 0, out + assert _recreated(calls), f"пустая сверка сочтена успешной: {calls}\n{out}" + + +def test_missing_host_file_is_not_skipped_as_a_directory(prod_tree: Path) -> None: + """Файла на хосте нет — это расхождение, а не «нечего сверять». + + Пропуск по `[ -f "$src" ] || continue` склеивает два разных случая: каталог + (пропустить верно — инод он не держит) и исчезнувший/нечитаемый файл, для + которого в контейнере как раз живёт старый инод со старым текстом. Файл + удаляется после проверки конфига (в тесте она подставная) — проверяется + именно ветка сверки. + """ + (prod_tree / "Caddyfile").unlink() + _, out, calls = _run(prod_tree) + assert _recreated(calls), f"исчезнувший файл сочли доехавшим: {calls}\n{out}" + + +def test_log_says_how_many_mounts_were_compared(prod_tree: Path) -> None: + """В логе должно быть ЧИСЛО сверенных файлов, а не только вердикт. + + «Сверили пять» и «сверили ноль» обязаны различаться: иначе строка + «перезагружен без пересоздания» одинаково означает и проверку, и её + отсутствие. + """ + _, out, _ = _run(prod_tree) + assert re.search(r"сверено пофайловых маунтов[^\n]*: 5", out), ( + f"скрипт не печатает число сверенных маунтов (их пять):\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_failures_are_not_swallowed() -> None: + """Ни применение конфига, ни сам reload не гасятся `|| true`. + + Строка `caddy reload … || true` в репозитории уже живёт + (deploy-tradein.yml), то есть это не гипотеза: с ней отказ применения + перестаёт краснеть, и «конфиг доехал» становится неотличимо от «команда + упала, а мы продолжили». Проверяется и вызов скрипта из обеих джоб, и + строка reload внутри самого скрипта. + """ + swallow = re.compile(r"\|\|\s*(true|:)\s*$") + offenders = [] + for where, text in [ + ("deploy", _commands(_ssh_script("deploy"))), + ("deploy-caddy", _commands(_ssh_script("deploy-caddy"))), + (SCRIPT.name, SCRIPT.read_text(encoding="utf-8")), + ]: + for line in text.splitlines(): + code = line.split("#", 1)[0] if not line.lstrip().startswith("#") else "" + if ("caddy-apply.sh" in code or "caddy reload" in code) and swallow.search(code): + offenders.append(f"{where}: {line.strip()}") + assert not offenders, "отказ применения конфига проглочен:\n " + "\n ".join(offenders) + + +def test_gate_runs_on_changes_to_the_script_itself() -> None: + """CI-фильтр обязан пускать backend-тесты на правку ops/**. + + Все содержательные регрессии живут в ops/caddy-apply.sh: проверки выше его + ИСПОЛНЯЮТ. Без `ops/**` в фильтре PR, правящий только скрипт, даёт + backend=false — джоба пропускается, гейт не исполняется, и «пересоздавать + всегда» уезжает в main зелёным. Тот же класс, что #2950/#3448/#3467. + """ + spec = yaml.safe_load((WORKFLOWS / "ci.yml").read_text(encoding="utf-8")) + steps = [ + s + for job in spec["jobs"].values() + for s in job.get("steps") or [] + if str(s.get("uses", "")).startswith("dorny/paths-filter") + ] + assert steps, "в ci.yml не найден paths-filter — проверка прошла бы вхолостую" + patterns = [p for s in steps for p in yaml.safe_load(s["with"]["filters"]).get("backend") or []] + assert "ops/**" in patterns, ( + f"фильтр backend не покрывает ops/** (сейчас: {patterns}) — гейт #3443 не " + "побежит на правке ops/caddy-apply.sh, то есть ровно на той правке, от " + "которой стережёт" + ) + + +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..69648513 --- /dev/null +++ b/ops/caddy-apply.sh @@ -0,0 +1,138 @@ +#!/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 синтаксически валиден»). +# +# ГРАНИЦА ЭТОЙ ПРОВЕРКИ. Она обрывает применение до того, как конфиг попадёт в +# работающий Caddy, — но только на быстром пути. В полном деплое ВЫШЕ по +# скрипту (deploy.yml, `up -d $UP_SERVICES`) уже прошёл общий подъём всех +# сервисов, и если правка одновременно ломает Caddyfile И меняет блок caddy в +# docker-compose.prod.yml, контейнер пересоздастся там — с непроверенным +# конфигом и раньше этой строки. Первая линия против этого — гейт на PR (#2913). +echo "→ проверяю конфиг прокси одноразовым контейнером…" +if ! docker run --rm -v "$PWD:/work:ro" -w /work caddy:2 \ + caddy validate --config /work/Caddyfile --adapter caddyfile; then + echo "ОШИБКА: конфиг прокси не применён — проверка не пройдена ЛИБО не удалось" + echo " запустить проверочный контейнер (нет образа caddy:2, занят демон," + 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. Доехало ли содержимое пофайловых маунтов ────────────────────────────── +# Список маунтов читается ОТДЕЛЬНОЙ командой, а не в конвейере с циклом: в +# `$(docker inspect … | while …)` статус подстановки — это статус `while`, то +# есть всегда 0 (`pipefail` в POSIX-sh нет вовсе). Провал `docker inspect` +# давал бы пустой список → «расхождений нет» → `caddy reload` → зелёная джоба с +# надписью «окна недоступности нет», а прокси работал бы по СТАРОМУ конфигу. +# Это ровно тот беззвучный отказ, ради которого написан весь скрипт. +mounts=$(docker inspect "$after" \ + --format '{{range .Mounts}}{{if eq .Type "bind"}}{{.Source}}|{{.Destination}}{{println}}{{end}}{{end}}') \ + || mounts='' + +if [ -z "$mounts" ]; then + echo "WARNING: список маунтов Caddy не прочитан (docker inspect молчит или упал) —" + echo " сверить нечем, считаю расхождением: fail-safe в прежнее поведение." + verdicts="" + stale="(маунты не прочитаны)" +else + verdicts=$(printf '%s\n' "$mounts" | while IFS='|' read -r src dst; do + [ -n "${dst:-}" ] || continue + # Каталог инод не держит — пропускаем. Именно `-d`, а не `-f`: + # отсутствующий/нечитаемый ФАЙЛ — не повод молча пропустить, в + # контейнере в этот момент живёт старый инод со старым текстом. + if [ -d "$src" ]; then continue; fi + host_sum=$(sha256sum "$src" 2>/dev/null | cut -d' ' -f1) + seen_sum=$(docker exec "$after" sha256sum "$dst" 2>/dev/null | cut -d' ' -f1) + if [ -n "$host_sum" ] && [ "$host_sum" = "${seen_sum:-НЕ_ПРОЧИТАНО}" ]; then + echo "OK $dst" + else + echo "STALE $dst" + fi + done) + # «Сверили пять файлов» и «сверили ноль» обязаны различаться в логе — иначе + # зелёная строка ниже одинаково означает и проверку, и её отсутствие. + checked=$(printf '%s\n' "$verdicts" | grep -c . || true) + echo "→ сверено пофайловых маунтов с тем, что видит контейнер: $checked" + stale=$(printf '%s\n' "$verdicts" | sed -n 's/^STALE //p' | tr '\n' ' ') + if [ "$checked" -eq 0 ]; then + echo "WARNING: ни одного пофайлового bind-маунта не найдено — у Caddy их пять" + echo " (Caddyfile + 4 сниппета). Считаю расхождением: fail-safe." + stale="(пофайловых маунтов не найдено)" + fi +fi + +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 "✓ конфиг прокси перезагружен без пересоздания контейнера — окна недоступности нет."