From d5f0557ca89162d1967ae52b6b753b96197c85a6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 20:49:34 +0500 Subject: [PATCH] =?UTF-8?q?fix(deploy):=20=D1=81=D0=B2=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BC=D0=B0=D1=83=D0=BD=D1=82=D0=BE=D0=B2=20Caddy=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BC=D0=BE=D0=B6=D0=B5=D1=82=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BB=D0=B8=D1=82=D1=8C=D1=81=D1=8F=20=D0=B2?= =?UTF-8?q?=D1=82=D0=B8=D1=85=D1=83=D1=8E=20(#3443,=20=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Две дыры из deep-ревью PR #3506 — обе про «отказ выглядит как успех». M1. `stale=$(docker inspect … | while …)` под `set -eu` без `pipefail` (в POSIX-sh его нет) отдаёт статус `while`, то есть всегда 0. Провал `docker inspect` или пустой вывод давали пустой список → ветка «всё доехало» → `caddy reload` → зелёная джоба с надписью «окна недоступности нет» при прокси, работающем по СТАРОМУ конфигу. Ровно тот беззвучный отказ, ради которого написан скрипт. Теперь список читается отдельной командой, провал и пустой вывод считаются расхождением (fail-safe в прежнее поведение), число сверенных файлов печатается — «сверили пять» и «сверили ноль» в логе больше не выглядят одинаково. Ноль пофайловых маунтов (например, если Caddyfile переведут на именованный том) — тоже расхождение, а не тавтологически успешная сверка. M2. В фильтре `backend` (ci.yml) не было `ops/**`, а все содержательные регрессии живут в самом ops/caddy-apply.sh: гейт его ИСПОЛНЯЕТ. PR, правящий только скрипт, давал backend=false — джоба пропускается, гейт не исполняется, «пересоздавать всегда» уезжает в main зелёным. Тот же класс, что уже осуждён комментариями рядом (#2950/#3448/#3467). Мелочи оттуда же: * `[ -d "$src" ] && continue` вместо `[ -f "$src" ] || continue` — пропуск по `-f` склеивал «это каталог» (пропустить верно) и «файла на хосте нет», для которого в контейнере как раз живёт старый инод; * сообщение об отказе `caddy validate` больше не называет причиной битый конфиг, когда упасть мог и сам запуск проверочного контейнера; * в комментарии к проверке записана её граница: в полном деплое общий `up -d $UP_SERVICES` (deploy.yml:959) поднимает и caddy за ~110 строк до вызова скрипта, поэтому правка, которая одновременно ломает Caddyfile и меняет блок caddy в compose, пересоздаст контейнер раньше проверки. Гейт дорос с 16 до 22 проверок: `docker inspect` не ответил → пересоздание, ноль пофайловых маунтов → пересоздание, исчезнувший файл на хосте → пересоздание, число сверенных маунтов печатается, `caddy reload`/вызов скрипта не проглочены `|| true`, ci.yml-фильтр покрывает ops/**. Все 11 мутантов (7 новых + 4 прежних) краснеют, контроль зелёный. Co-Authored-By: Claude Opus 5 --- .forgejo/workflows/ci.yml | 10 ++ .../test_3443_caddy_reload_not_recreate.py | 118 +++++++++++++++++- ops/caddy-apply.sh | 64 ++++++++-- 3 files changed, 177 insertions(+), 15 deletions(-) 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/backend/tests/ops/test_3443_caddy_reload_not_recreate.py b/backend/tests/ops/test_3443_caddy_reload_not_recreate.py index 25de7952..35ba8f12 100644 --- a/backend/tests/ops/test_3443_caddy_reload_not_recreate.py +++ b/backend/tests/ops/test_3443_caddy_reload_not_recreate.py @@ -38,7 +38,8 @@ 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" +WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows" +DEPLOY = WORKFLOWS / "deploy.yml" CID = "caddy-cid-0001" @@ -60,7 +61,7 @@ DIR_MOUNTS = { # Подставной `docker`. Пишет каждый вызов в $FAKE_LOG и отвечает по сценарию: # run — одноразовый `caddy validate`, код из $FAKE_VALIDATE_RC; -# inspect — список маунтов из $FAKE_MOUNTS; +# inspect — список маунтов из $FAKE_MOUNTS, код из $FAKE_INSPECT_RC; # exec — sha256sum ФАЙЛА, КОТОРЫЙ ВИДИТ КОНТЕЙНЕР ($FAKE_VIEW/); # compose … ps — текущий id контейнера из $FAKE_CID_FILE; # compose … up — при $FAKE_UP_RECREATES=1 подменяет id (compose пересоздал сам). @@ -69,7 +70,13 @@ printf '%s\n' "$*" >> "$FAKE_LOG" cmd="$1"; shift case "$cmd" in run) exit "${FAKE_VALIDATE_RC:-0}" ;; - inspect) cat "$FAKE_MOUNTS" ;; + 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 '/' '_')" @@ -260,6 +267,64 @@ def test_unreadable_container_view_falls_back_to_recreate(prod_tree: Path) -> No 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) @@ -335,6 +400,53 @@ def test_full_deploy_has_no_unconditional_caddy_recreate() -> None: ) +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: """Сам гейт обязан краснеть на возвращённом дефекте — проверка на себя. diff --git a/ops/caddy-apply.sh b/ops/caddy-apply.sh index da35d92d..69648513 100755 --- a/ops/caddy-apply.sh +++ b/ops/caddy-apply.sh @@ -52,11 +52,20 @@ caddy_cid() { $COMPOSE ps -aq caddy 2>/dev/null | tail -n1; } # работающий 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 не тронут, домены живы. Чинить конфиг и повторять." + echo "ОШИБКА: конфиг прокси не применён — проверка не пройдена ЛИБО не удалось" + echo " запустить проверочный контейнер (нет образа caddy:2, занят демон," + echo " недоступен реестр). Причина — в выводе выше, не гадать по этой строке." + echo " Работающий Caddy не тронут, домены живы." exit 1 fi @@ -74,16 +83,47 @@ if [ "$before" != "$after" ]; then 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) +# Список маунтов читается ОТДЕЛЬНОЙ командой, а не в конвейере с циклом: в +# `$(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"