#!/usr/bin/env python3 """Гейт: подмена tradein-frontend не возвращает окно недоступности (#3274). ПОЧЕМУ. Публичный лендинг meraocenka.ru лежал 30–90 с на КАЖДОМ деплое МЕРЫ. Причина — НЕ медленная подмена контейнера (она стоит полсекунды), а то, что `docker compose up -d` со СПИСКОМ сервисов работает в две фазы: сначала create (старый контейнер каждого сервиса останавливается и удаляется — иначе занято `container_name`), потом start, в порядке зависимостей и с ожиданием их условий. Всё, что между фазами, фронт лежит. Замер на проде 10.09 (docker inspect .Created/.StartedAt, два деплоя подряд): пачка сервисов: tradein-backend создан 15:01:40 → запущен 15:02:10 (30 с) один сервис: tradein-frontend создан 16:42:17.5 → запущен 16:42:18.0 (0,5 с) В логе Caddy у первого деплоя три 503 на лендинге (15:01:46, 15:01:52, 15:02:06), у второго — ни одного. ЧТО ДЕРЖИТ РЕШЕНИЕ, И ПОЧЕМУ ЭТО ГЕЙТ, А НЕ КОММЕНТАРИЙ. Половинки лежат в разных файлах и обе невидимо обратимы: 1) deploy-tradein.yml — `frontend` ВЫНЕСЕН из общего `up -d $SERVICES` в свою команду. Достаточно дописать его обратно в SERVICES «за компанию», и окно вернётся целиком, молча: деплой останется зелёным. 2) caddy/sites/apps.caddy — каждый `reverse_proxy tradein-frontend:3000` импортирует (tradein_frontend_retry) (lb_try_duration), который добирает оставшиеся ~0,5 с. Новый публичный путь копируют с соседнего блока — и если копируют блок БЕЗ импорта, именно этот путь снова отдаёт 502. Оба отказа не видны ни по статусу джобы, ни по глазам: их видно только непрерывной пробой во время деплоя (scripts/probe-deploy-window.sh). ЧЕГО НЕ ЛОВИТ. Это разбор текста, а не исполнение: гейт не проверяет, что команда реально отработала и что Caddy реально ретраит (это проверяется пробой на живом деплое). Не смотрит на сервисы, кроме frontend, — бэкенду ретрай намеренно не дан (его старт длиннее, чем разумное ожидание клиента). Запуск: python3 scripts/check-frontend-swap-window.py [--selftest] """ from __future__ import annotations import re import sys from pathlib import Path REPO = Path(__file__).resolve().parent.parent WORKFLOW = REPO / ".forgejo" / "workflows" / "deploy-tradein.yml" APPS_CADDY = REPO / "caddy" / "sites" / "apps.caddy" SNIPPET_NAME = "tradein_frontend_retry" FRONTEND_UPSTREAM = "reverse_proxy tradein-frontend:3000 {" # `SERVICES="browser backend tgbot"` / `SERVICES="$SERVICES scraper"`. _SERVICES_RE = re.compile(r'^\s*SERVICES=(["\']?)(.*?)\1\s*$', re.M) # Одиночная команда подмены фронта. _FRONTEND_UP_RE = re.compile(r"up -d --no-deps frontend\s*$", re.M) def strip_comments(text: str) -> str: """Убирает строки-комментарии (shell/YAML/Caddy — везде `#`). Обязательно: разбор дефекта живёт в комментарии рядом с правкой и содержит его же формулировку. Без этого гейт спорил бы с собственным объяснением. """ return "\n".join(ln for ln in text.splitlines() if not ln.lstrip().startswith("#")) def check_workflow(text: str) -> list[str]: body = strip_comments(text) errors: list[str] = [] for m in _SERVICES_RE.finditer(body): value = m.group(2) if re.search(r"(^|\s)frontend(\s|$)", value): errors.append( f'SERVICES={value!r} снова содержит `frontend`: он вернётся в общий ' f"`up -d` со списком сервисов, а это и есть окно 30–90 с (#3274). " f"Фронт подменяется отдельной командой `up -d --no-deps frontend`." ) if not _FRONTEND_UP_RE.search(body): errors.append( "в deploy-tradein.yml нет отдельной команды `up -d --no-deps frontend` — " "подмена фронта либо пропала, либо снова уехала в общую пачку (#3274)." ) return errors def check_caddy(text: str) -> list[str]: errors: list[str] = [] lines = text.splitlines() body = strip_comments(text) if f"({SNIPPET_NAME}) {{" not in body: errors.append( f"снипет ({SNIPPET_NAME}) не объявлен в apps.caddy — импортировать нечего." ) elif "lb_try_duration" not in body: errors.append( f"снипет ({SNIPPET_NAME}) есть, но без `lb_try_duration` — он больше " "ничего не добирает, оставшиеся ~0,5 с подмены снова видны как 502." ) # Блок апстрима фронта: от строки `reverse_proxy tradein-frontend:3000 {` # до закрывающей скобки на её же отступе. Импорт должен быть внутри. for i, line in enumerate(lines): if line.strip().startswith("#") or FRONTEND_UPSTREAM not in line: continue indent = len(line) - len(line.lstrip()) block: list[str] = [] for nxt in lines[i + 1 :]: if nxt.strip() == "}" and (len(nxt) - len(nxt.lstrip())) == indent: break block.append(nxt) if not any(f"import {SNIPPET_NAME}" in b for b in block if not b.strip().startswith("#")): errors.append( f"apps.caddy:{i + 1} — `reverse_proxy tradein-frontend:3000` без " f"`import {SNIPPET_NAME}`: на этом пути подмена контейнера снова " f"видна посетителю как 502 (#3274)." ) return errors def selftest() -> None: good_wf = 'SERVICES="browser backend tgbot"\n docker compose up -d --no-deps frontend\n' assert check_workflow(good_wf) == [], check_workflow(good_wf) bad_wf = 'SERVICES="browser backend frontend tgbot"\n docker compose up -d --no-deps frontend\n' assert any("SERVICES" in e for e in check_workflow(bad_wf)), "не поймал frontend в SERVICES" missing_wf = 'SERVICES="browser backend tgbot"\n docker compose up -d --no-deps $SERVICES\n' assert any("отдельной команды" in e for e in check_workflow(missing_wf)), ( "не поймал пропажу отдельной команды" ) # Комментарий с той же формулировкой не должен ронять гейт. commented = '# SERVICES="browser backend frontend tgbot" # так было до #3274\n' + good_wf assert check_workflow(commented) == [], "гейт спорит с собственным комментарием" good_caddy = ( "(tradein_frontend_retry) {\n lb_try_duration 2s\n}\n" "handle {\n reverse_proxy tradein-frontend:3000 {\n" " import tradein_frontend_retry\n header_up -X-Y\n }\n}\n" ) assert check_caddy(good_caddy) == [], check_caddy(good_caddy) bad_caddy = ( "(tradein_frontend_retry) {\n lb_try_duration 2s\n}\n" "handle {\n reverse_proxy tradein-frontend:3000 {\n" " header_up -X-Y\n }\n}\n" ) assert any("без `import" in e for e in check_caddy(bad_caddy)), "не поймал блок без импорта" no_snippet = ( "handle {\n reverse_proxy tradein-frontend:3000 {\n" " import tradein_frontend_retry\n }\n}\n" ) assert any("не объявлен" in e for e in check_caddy(no_snippet)), "не поймал пропажу снипета" print("SELFTEST OK") def main() -> int: if "--selftest" in sys.argv: selftest() return 0 errors = check_workflow(WORKFLOW.read_text(encoding="utf-8")) errors += check_caddy(APPS_CADDY.read_text(encoding="utf-8")) if errors: print("Гейт #3274 (окно подмены фронта) НЕ ПРОЙДЕН:\n") for e in errors: print(f" ✗ {e}") print( "\nЧем проверять эффект на живом деплое: scripts/probe-deploy-window.sh " "(запускать НА хосте прода, см. шапку скрипта)." ) return 1 print("Гейт #3274: фронт подменяется отдельной командой, все 9 апстримов с ретраем — OK") return 0 if __name__ == "__main__": sys.exit(main())