All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m8s
CI / openapi-codegen-check (pull_request) Successful in 2m3s
CI / backend-tests (pull_request) Successful in 17m40s
Две дыры из 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 <noreply@anthropic.com>
463 lines
25 KiB
Python
463 lines
25 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"
|
||
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/<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)
|
||
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), (
|
||
"маска поиска не видит дословно ту строку, ради которой заведён гейт"
|
||
)
|