"""Полный деплой не пересоздаёт 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), ( "маска поиска не видит дословно ту строку, ради которой заведён гейт" )