diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index baf5c736..b38276cc 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -98,6 +98,24 @@ jobs: python3 scripts/check-compose-ambiguous-hosts.py --selftest python3 scripts/check-compose-ambiguous-hosts.py + - name: "Guard: подмена фронта МЕРЫ без окна недоступности (#3274)" + # Тем же шагом-соседом и по той же причине: секунды на PR, падение + # блокирует merge. + # + # ЗАЧЕМ. Публичный лендинг лежал 30–90 с на КАЖДОМ деплое МЕРЫ — + # не потому, что подмена контейнера медленная (0,5 с), а потому, что + # `up -d` со списком сервисов делает create всех (старые контейнеры + # УДАЛЯЮТСЯ) и только потом start, дождавшись зависимостей. Лечение — + # две половинки в разных файлах: `frontend` вынесен из общей пачки в + # deploy-tradein.yml + ретрай подключения в caddy/sites/apps.caddy. + # Обе обратимы молча и незаметно (дописать frontend обратно в SERVICES + # «за компанию»; скопировать новый публичный путь с блока без импорта), + # а отказ виден только непрерывной пробой во время деплоя — то есть + # никогда, если её никто не запустил. + run: | + python3 scripts/check-frontend-swap-window.py --selftest + python3 scripts/check-frontend-swap-window.py + - name: "Guard: Caddyfile синтаксически валиден" # Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит # на КАЖДОМ PR, стоит секунды, падение блокирует merge. diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy index b01dbdea..5a47e1bd 100644 --- a/caddy/sites/apps.caddy +++ b/caddy/sites/apps.caddy @@ -42,6 +42,40 @@ # код. # ═══════════════════════════════════════════════════════════════════════════ +# ── (tradein_frontend_retry) — подмена контейнера фронта без 502 (#3274) ───── +# +# ЧТО ЭТО. Ретрай ПОДКЛЮЧЕНИЯ к tradein-frontend: пока идёт подмена +# контейнера, Caddy не отдаёт ошибку сразу, а до 2 с переспрашивает апстрим с +# шагом 100 мс. Импортируется ВНУТРЬ каждого `reverse_proxy tradein-frontend` +# в этом файле (9 блоков: gendsgn.ru/trade-in/* и все публичные пути +# meraocenka.ru). +# +# ПОЧЕМУ ЭТО НЕ ТО, ЧТО ОТВЕРГНУТО В #3274. Там `lb_try_duration` отвергнут +# для окна 30–90 с — держать посетителя минуту в ожидании хуже честной +# ошибки, инструмент рассчитан на разрыв в сотни миллисекунд. Правка в +# deploy-tradein.yml (frontend вынесен из общего `up -d`) СНАЧАЛА сводит окно +# к этим сотням миллисекунд, и только после этого ретрай становится +# применим. Числа, на которых это стоит (замеры 11.09): +# - прод, ОДИН сервис в `up -d`: контейнер создан 16:42:17.5 → запущен +# 16:42:18.0 — 0,5 с; +# - прод, ПАЧКА сервисов в одном `up -d`: создан 15:01:40 → запущен +# 15:02:10 — 30 с (фаза start ждёт готовности зависимостей, а старый +# контейнер снесён ещё в фазе create); +# - стенд (реальный образ фронта + Caddy 2), одиночная подмена: без ретрая +# 1 × 502, с ретраем 241/241 × 200, один запрос подождал 0,55 с. +# +# ПОТОЛОК. 2 с — это ЦЕНА ОЖИДАНИЯ при настоящей аварии: если фронт лежит +# долго, каждый запрос сначала висит 2 с и лишь потом получает 503-заглушку +# (../deploy-window.caddy.snippet). Поэтому не «побольше на всякий случай»: +# запас над измеренными 0,5 с четырёхкратный, дальше растёт только вред. +# Бэкенду (tradein-backend) этот приём НАМЕРЕННО не дан: его старт — единицы +# секунд (uvicorn + подключение к БД), там 2 с не хватит, а больше — уже тот +# самый вред. Его окно закрывается отдельно, не этой правкой. +(tradein_frontend_retry) { + lb_try_duration 2s + lb_try_interval 100ms +} + # Caddy config. # # - gendsgn.ru — main production site, auto-TLS via Let's Encrypt. @@ -137,6 +171,7 @@ gendsgn.ru { @uipreview path /trade-in/ui-preview/* /trade-in/_next/static/* handle @uipreview { reverse_proxy tradein-frontend:3000 { + import tradein_frontend_retry # #2558 review: тот же периметр-scrub, что и у /trade-in/api/* и # @tradein ниже — этот блок тоже теперь ДО basic_auth, клиент # мог бы прислать свой X-Authenticated-User. Сейчас инертно @@ -240,6 +275,7 @@ gendsgn.ru { handle @tradein { # Next.js basePath=/trade-in — фронт сам ждёт префикса в URL reverse_proxy tradein-frontend:3000 { + import tradein_frontend_retry # См. комментарий над /trade-in/api/* выше — та же логика (явное # удаление вместо Set с пустым {http.auth.user.id}), и по той же # причине здесь больше нет инжекта X-Internal-Auth-Secret @@ -355,6 +391,7 @@ meraocenka.ru { handle / { rewrite * /trade-in/mera-public reverse_proxy tradein-frontend:3000 { + import tradein_frontend_retry # Тот же периметр-скраб, что у @uipreview (:87) и @tradein ниже. # Этот блок вообще не под basic_auth, поэтому анонимный клиент # тем более может прислать свой X-Authenticated-User. Сейчас @@ -405,6 +442,7 @@ meraocenka.ru { handle @meraPages { rewrite * /trade-in/mera-public{path} reverse_proxy tradein-frontend:3000 { + import tradein_frontend_retry header_up -X-Authenticated-User } } @@ -571,6 +609,7 @@ meraocenka.ru { respond @foreignRouteChunk 404 reverse_proxy tradein-frontend:3000 { + import tradein_frontend_retry header_up -X-Authenticated-User } } @@ -581,6 +620,7 @@ meraocenka.ru { handle /favicon.ico { rewrite * /trade-in/favicon.ico reverse_proxy tradein-frontend:3000 { + import tradein_frontend_retry header_up -X-Authenticated-User } } @@ -594,6 +634,7 @@ meraocenka.ru { handle /robots.txt { rewrite * /trade-in/robots.txt reverse_proxy tradein-frontend:3000 { + import tradein_frontend_retry header_up -X-Authenticated-User } } @@ -613,6 +654,7 @@ meraocenka.ru { handle /og-mera.png { rewrite * /trade-in/og-mera.png reverse_proxy tradein-frontend:3000 { + import tradein_frontend_retry header_up -X-Authenticated-User } } @@ -624,6 +666,7 @@ meraocenka.ru { handle /logo-mera.png { rewrite * /trade-in/logo-mera.png reverse_proxy tradein-frontend:3000 { + import tradein_frontend_retry header_up -X-Authenticated-User } } diff --git a/scripts/check-frontend-swap-window.py b/scripts/check-frontend-swap-window.py new file mode 100644 index 00000000..2dc5e5f3 --- /dev/null +++ b/scripts/check-frontend-swap-window.py @@ -0,0 +1,187 @@ +#!/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())