From db03e937813707fb8ad3157dca078a4026fe0280 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 19 Aug 2026 12:56:06 +0500 Subject: [PATCH] =?UTF-8?q?ci:=20=D1=81=D0=BC=D0=BE=D1=83=D0=BA=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B8=D0=BC=D0=B5=D1=82=D1=80=D0=B0=20=D0=9C=D0=95?= =?UTF-8?q?=D0=A0=D0=AB=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D1=81=D1=80=D0=B0=D0=B7=D1=83=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/smoke-mera-perimeter.sh — единственная проверка, которая видит публичный периметр целиком (короткие адреса, 301 с длинных, публичный API, закрытость B2B-путей на публичном домене). Запускался он только ночным cron'ом 06:17 UTC, поэтому регресс жил до суток, и находил его либо тот же cron, либо владелец — ровно тот сценарий, против которого проверки и писались. Добавлен job `perimeter-smoke` в ОБА пайплайна: конфиг прокси (deploy.yml) и фронт МЕРЫ (deploy-tradein.yml) едут раздельно, сломать периметр может каждый. Отдельный job, а не шаг внутри deploy: «выкатили» и «периметр цел» — два разных вердикта, красный смоук не должен читаться как неудавшийся деплой. Идёт только при deploy.result == 'success' — поверх несостоявшейся выкатки проверять нечего. Перед смоуком — ожидание готовности по ДВУМ признакам (лэндинг 200 и API 401): `up -d --force-recreate` отдаёт управление раньше, чем бэкенд начинает отвечать, и без ожидания смоук ловил бы гонку, а не регресс. По истечении 150 с ожидание не падает, а печатает warning и пускает смоук — иначе «не успел подняться» и «периметр сломан» слились бы в один красный шаг. Дублирование 20 строк в двух пайплайнах осознанное: `workflow_call` под act_runner не гарантирован, а зависимость, которая может молча не сработать, здесь хуже повтора. Заодно: - ci.yml: гейт `bash -n` на все shell-скрипты (14 файлов) — shellcheck'а в репозитории нет, а опечатка в смоуке обнаружилась бы следующим утром и выглядела бы как регресс периметра. Гейт падает, если не нашёл ни одного файла: пустая маска дала бы зелёный шаг, который ничего не проверяет. - perimeter-smoke.yml: push-триггер на сам скрипт — правка проверяется сразу. Проверено: смоук против прода сейчас зелёный целиком, 27 из 27 проверок, то есть в пайплайн въезжает работающий гейт, а не заведомо красный. Опасение из задачи про внешний геокодер проверено по коду и оказалось у́же: `geocoder.suggest` — цепочка «кадастр → DaData → Nominatim → []», каждый внешний тир под `except Exception`, отказ и квота дают 200 с пустым списком. Покраснеть проверка может только на зависании дольше 15 с. Записал это в самом скрипте, ожидание не ослаблял. Closes #2917 --- .forgejo/workflows/ci.yml | 30 ++++++++++++++ .forgejo/workflows/deploy-tradein.yml | 55 ++++++++++++++++++++++++++ .forgejo/workflows/deploy.yml | 55 ++++++++++++++++++++++++++ .forgejo/workflows/perimeter-smoke.yml | 9 +++++ scripts/smoke-mera-perimeter.sh | 9 +++++ 5 files changed, 158 insertions(+) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index c29ee38d..89305795 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -113,6 +113,36 @@ jobs: python3 scripts/check-migration-lock-timeout.py --selftest python3 scripts/check-migration-lock-timeout.py + - name: "Guard: shell-скрипты синтаксически валидны (#2917)" + # Соседям по этому job'у (caddy validate, lock_timeout) — тот же довод: + # дёшево, на каждом PR, ловит опечатку до прода. + # + # ЗАЧЕМ ИМЕННО ЭТО. scripts/smoke-mera-perimeter.sh — единственная + # проверка, которая видит публичный периметр МЕРЫ целиком, и до этого + # PR она запускалась только ночным cron'ом. Опечатка в ней обнаружилась + # бы следующим утром — и выглядела бы как регресс периметра, а не как + # сломанный скрипт. Ни один линтер шелла в репозитории не стоит + # (shellcheck нет), поэтому берём то, что есть в каждом образе: `bash -n` + # разбирает файл, не исполняя его. + # + # ГРАНИЦА: `bash -n` ловит СИНТАКСИС, а не смысл — неверный URL или + # перепутанный ожидаемый код он не увидит. Это не замена прогона, + # а защита от того, что скрипт вообще не запустится. + run: | + set -euo pipefail + found=0 + for f in $(git ls-files 'scripts/*.sh' 'ops/*.sh' 'ops/**/*.sh'); do + found=$((found + 1)) + bash -n "$f" || { echo "::error file=$f::синтаксическая ошибка в shell-скрипте"; exit 1; } + done + # Ноль файлов означал бы, что гейт молча ничего не проверяет — + # ровно тот случай, когда зелёный шаг не значит ничего (#2871). + if [ "$found" -eq 0 ]; then + echo "::error::не найдено ни одного .sh — гейт бы прошёл впустую, проверь маску" + exit 1 + fi + echo "✓ синтаксис проверен у $found shell-скриптов" + - uses: dorny/paths-filter@v3 id: filter with: diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index c53f67c0..16274c2f 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -1121,6 +1121,61 @@ jobs: # неважно, пропущен он (test/build упали) или упал сам (SSH/миграция/ # health-check/сверка образов #2679). Красная точка встаёт именно там, где # решение реально принято, а не там, где она случайно оказалась по цепочке if. + # ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ────────────────── + # + # ЗАЧЕМ ЗДЕСЬ. scripts/smoke-mera-perimeter.sh — единственная проверка, которая + # видит периметр целиком (короткие адреса, 301 с длинных, публичный API, + # закрытость B2B-путей на публичном домене). До этого PR он запускался только + # по cron'у 06:17 UTC, то есть регресс жил до суток и находил его либо ночной + # прогон, либо владелец. Для правки, чья логика живёт в конфиге прокси, это + # единственный настоящий гейт — и он был асинхронным. + # + # ПОЧЕМУ ОТДЕЛЬНЫЙ JOB, А НЕ ШАГ В deploy. Вердикты разные: «выкатили» и + # «периметр цел» — два разных факта, и красный смоук не должен читаться как + # неудавшийся деплой. Деплой к этому моменту уже прошёл; смоук говорит, что + # именно получилось. + # + # ПОЧЕМУ ДУБЛИРУЕТСЯ В ДВУХ ПАЙПЛАЙНАХ. Конфиг прокси (deploy.yml) и фронт + # МЕРЫ (deploy-tradein.yml) едут раздельно, и сломать периметр может каждый. + # `workflow_call` под act_runner не гарантирован, поэтому 20 строк повторены + # осознанно вместо зависимости, которая может молча не сработать. + perimeter-smoke: + runs-on: ubuntu-latest + needs: deploy + # Только после РЕАЛЬНОЙ выкатки: при skipped/failed проверять нечего, а + # красный смоук поверх несостоявшегося деплоя увёл бы разбор не туда. + if: always() && needs.deploy.result == 'success' + timeout-minutes: 6 + steps: + - uses: actions/checkout@v4 + + - name: Дождаться, пока периметр отвечает после пересоздания контейнеров + # `up -d --force-recreate` возвращает управление раньше, чем бэкенд + # начинает отвечать. Без ожидания смоук ловил бы не регресс, а гонку. + # Ждём ДВА признака: лэндинг (Caddy + фронт) и API (бэкенд поднялся) — + # одного мало, Caddy отвечает раньше апстрима. + run: | + set -uo pipefail + for i in $(seq 1 30); do + page=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://meraocenka.ru/ || true) + api=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://gendsgn.ru/trade-in/api/v1/me || true) + if [ "$page" = "200" ] && [ "$api" = "401" ]; then + echo "периметр отвечает (попытка $i): лэндинг $page, API $api" + exit 0 + fi + echo "ждём готовности, попытка $i/30: лэндинг '${page:-нет ответа}', API '${api:-нет ответа}'" + sleep 5 + done + # НЕ падаем здесь: вердикт должен вынести смоук, а не таймаут ожидания. + # Иначе «не успел подняться» и «периметр сломан» слились бы в один + # красный шаг без разбора. + echo "::warning::за 150 с периметр так и не ответил ожидаемо — запускаем смоук, его вывод и будет диагнозом" + + - name: Смоук периметра + run: | + chmod +x scripts/smoke-mera-perimeter.sh + ./scripts/smoke-mera-perimeter.sh + deploy-status: runs-on: ubuntu-latest needs: [test, build-backend, build-frontend, build-browser, deploy] diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 451a6bb4..e61bee58 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -749,6 +749,61 @@ jobs: # если deploy не завершился success — неважно, пропущен он (build упал) или # упал сам (SSH/миграция/health-check). Красная точка встаёт именно там, где # решение реально принято, а не там, где она случайно оказалась по цепочке if. + # ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ────────────────── + # + # ЗАЧЕМ ЗДЕСЬ. scripts/smoke-mera-perimeter.sh — единственная проверка, которая + # видит периметр целиком (короткие адреса, 301 с длинных, публичный API, + # закрытость B2B-путей на публичном домене). До этого PR он запускался только + # по cron'у 06:17 UTC, то есть регресс жил до суток и находил его либо ночной + # прогон, либо владелец. Для правки, чья логика живёт в конфиге прокси, это + # единственный настоящий гейт — и он был асинхронным. + # + # ПОЧЕМУ ОТДЕЛЬНЫЙ JOB, А НЕ ШАГ В deploy. Вердикты разные: «выкатили» и + # «периметр цел» — два разных факта, и красный смоук не должен читаться как + # неудавшийся деплой. Деплой к этому моменту уже прошёл; смоук говорит, что + # именно получилось. + # + # ПОЧЕМУ ДУБЛИРУЕТСЯ В ДВУХ ПАЙПЛАЙНАХ. Конфиг прокси (deploy.yml) и фронт + # МЕРЫ (deploy-tradein.yml) едут раздельно, и сломать периметр может каждый. + # `workflow_call` под act_runner не гарантирован, поэтому 20 строк повторены + # осознанно вместо зависимости, которая может молча не сработать. + perimeter-smoke: + runs-on: ubuntu-latest + needs: deploy + # Только после РЕАЛЬНОЙ выкатки: при skipped/failed проверять нечего, а + # красный смоук поверх несостоявшегося деплоя увёл бы разбор не туда. + if: always() && needs.deploy.result == 'success' + timeout-minutes: 6 + steps: + - uses: actions/checkout@v4 + + - name: Дождаться, пока периметр отвечает после пересоздания контейнеров + # `up -d --force-recreate` возвращает управление раньше, чем бэкенд + # начинает отвечать. Без ожидания смоук ловил бы не регресс, а гонку. + # Ждём ДВА признака: лэндинг (Caddy + фронт) и API (бэкенд поднялся) — + # одного мало, Caddy отвечает раньше апстрима. + run: | + set -uo pipefail + for i in $(seq 1 30); do + page=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://meraocenka.ru/ || true) + api=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://gendsgn.ru/trade-in/api/v1/me || true) + if [ "$page" = "200" ] && [ "$api" = "401" ]; then + echo "периметр отвечает (попытка $i): лэндинг $page, API $api" + exit 0 + fi + echo "ждём готовности, попытка $i/30: лэндинг '${page:-нет ответа}', API '${api:-нет ответа}'" + sleep 5 + done + # НЕ падаем здесь: вердикт должен вынести смоук, а не таймаут ожидания. + # Иначе «не успел подняться» и «периметр сломан» слились бы в один + # красный шаг без разбора. + echo "::warning::за 150 с периметр так и не ответил ожидаемо — запускаем смоук, его вывод и будет диагнозом" + + - name: Смоук периметра + run: | + chmod +x scripts/smoke-mera-perimeter.sh + ./scripts/smoke-mera-perimeter.sh + deploy-status: runs-on: ubuntu-latest needs: [build-backend, build-worker, build-frontend, deploy] diff --git a/.forgejo/workflows/perimeter-smoke.yml b/.forgejo/workflows/perimeter-smoke.yml index db0304ba..1afdf167 100644 --- a/.forgejo/workflows/perimeter-smoke.yml +++ b/.forgejo/workflows/perimeter-smoke.yml @@ -18,6 +18,15 @@ on: schedule: # Раз в сутки, 06:17 UTC — вне пиков, время произвольное. - cron: '17 6 * * *' + # #2917: правка самого смоука должна проверяться сразу, а не следующим утром. + # Проверки read-only (curl по публичным адресам), поэтому прогонять их на + # push в main безопасно и дёшево. Синтаксис скрипта отдельно гейтится в + # ci.yml на каждом PR — здесь проверяется уже поведение против прода. + push: + branches: [main] + paths: + - 'scripts/smoke-mera-perimeter.sh' + - '.forgejo/workflows/perimeter-smoke.yml' concurrency: group: perimeter-smoke-mera diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh index ea45311a..d15ff7db 100644 --- a/scripts/smoke-mera-perimeter.sh +++ b/scripts/smoke-mera-perimeter.sh @@ -121,6 +121,15 @@ check "meraocenka.ru/_next/image — must 404 (не открываем опти # `/trade-in/api/*`. Зелёная только первая = API открыт целиком и тест это # пропустил (ровно та ошибка, ради которой в Caddyfile выбран отдельный # префикс, а не поимённый проброс v1-путей). +# ПРО ВНЕШНЮЮ ЗАВИСИМОСТЬ (#2917). Опасение «упадёт DaData — покраснеет +# смоук без всякого регресса» проверено по коду и оказалось у́же, чем +# звучит: `geocoder.suggest` — это цепочка «кадастровый тир → DaData → +# Nominatim → []», и КАЖДЫЙ внешний тир обёрнут в `except Exception` +# (services/geocoder.py). Отказ, квота и 5xx провайдера дают пустой список +# и HTTP 200 — проверка остаётся зелёной. Покраснеть она может только если +# провайдер ВИСНЕТ дольше 15 с (--max-time у curl), то есть на зависании, +# а не на отказе. Ослаблять ожидание не стали: 200 здесь проверяет +# открытость пути анониму, ради которой проверка и написана. check_post "meraocenka.ru public suggest — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/suggest" '{"q":"Малышева"}' 200 check_post "meraocenka.ru public coverage — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/coverage" '{"lat":56.838,"lon":60.597,"rooms":2,"area_m2":54}' 200 check "meraocenka.ru v1 geocode — must stay 404" "$BASE_MERA/trade-in/api/v1/geocode/suggest?q=test" 404 -- 2.45.3