From bbdcfeb8255fe19d3107953b20bca56c25d6fdf9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 01:26:26 +0500 Subject: [PATCH] =?UTF-8?q?ci(#3274):=20=D0=B3=D0=B5=D0=B9=D1=82=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=82=D0=B8=D0=B2=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B2=20=D0=BE=D0=B1=D1=89=D1=83=D1=8E=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD=D0=B4=D1=83=20=D0=B8=20=D1=81=D0=BD=D1=8F=D1=82?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=80=D0=B5=D1=82=D1=80=D0=B0=D1=8F=20(=D1=87?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=8C=202b/3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Только scripts/ + ci.yml: ни deploy.yml, ни deploy-tradein.yml по этим путям не триггерятся. Мержить ПОСЛЕ частей 1 и 2a — гейт проверяет обе половины и на main без них покраснеет. --- .forgejo/workflows/ci.yml | 18 +++ scripts/check-frontend-swap-window.py | 187 ++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 scripts/check-frontend-swap-window.py 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/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())