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