Compare commits

...

3 commits

Author SHA1 Message Date
2d4f669d4b Merge pull request 'fix(caddy): окно деплоя — 503 «Сервис обновляется» с Retry-After вместо голого 502 на публичных путях МЕРЫ' (#3348) from fix/3274-deploy-window-error-page into main
All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 8s
Deploy / changes (push) Successful in 13s
perimeter-smoke-mera / smoke (push) Successful in 21s
Deploy / build-backend (push) Successful in 54s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-worker (push) Successful in 52s
Deploy / build-frontend (push) Successful in 48s
Deploy / deploy (push) Successful in 5m31s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 17s
2026-09-05 18:12:35 +00:00
3e481b0276 fix(caddy): смонтировать deploy-window сниппет — иначе Caddy не адаптирует конфиг
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 18s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Каталог caddy/ пробрасывается в контейнер ПОФАЙЛОВО (users + metrics-*, плюс
каталоги sites/ и local/). apps.caddy импортирует `../deploy-window.caddy.snippet`,
которого без этой строки в контейнере нет: Caddy падает на 'File to import not
found', уходит в restart-loop и роняет ВСЕ домены хоста — ровно постмортем #3102.
Гейт scripts/check-caddy-snippet-mounts.py на ветке был красный (две строки
apps.caddy + сам сниппет), после этой строки зелёный.

Смоук периметра: 503 на payments/notify перестал что-либо доказывать — тот же
код теперь отдаёт заглушка окна деплоя. check_post получил необязательный
шаблон-дискриминатор: код совпал, но в ответе `Retry-After: 30` или
`service_unavailable` — это Caddy, а не приложение, и это FAIL.

Refs #3274
2026-09-05 23:06:54 +05:00
d177b916e3 fix(caddy): страница «сервис обновляется» вместо голого 502 в окне деплоя
Some checks failed
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Failing after 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Каждый мерж в tradein оставляет 30-90 с, в которые контейнера нет и Caddy
набирает несуществующий апстрим: 15 x 502 на meraocenka.ru и 23 на gendsgn.ru
за 4 суток, все на окнах деплоя (duration < 2 мс = мгновенный отказ
соединения, а не перегрузка).

handle_errors 502/503/504 подменяет это на 503 + Retry-After: 30 и читаемое
тело. 502 читается как постоянная поломка — поисковик выкидывает страницу из
индекса, клиенты не ретраят; 503 + Retry-After означает ровно то, что
происходит. Для /trade-in/api/* тело JSON, а не HTML: эти ручки вызывают из JS
и внешних клиентов, HTML у них превращается в ошибку разбора.

На gendsgn.ru перехват гейтится по пути /trade-in* — Site Finder («Птица»)
деплоится отдельным пайплайном и в задачу не входит; вне матчера ошибка
остаётся необработанной и поведение прежнее.

Окно простоя это НЕ убирает — zero-downtime деплой (п.1 issue) требует
решения владельца. Меняется только то, что видно внутри окна.

Refs #3274
2026-09-05 22:48:54 +05:00
4 changed files with 118 additions and 7 deletions

View file

@ -0,0 +1,54 @@
# ═══════════════════════════════════════════════════════════════════════════
# caddy/deploy-window.caddy.snippet — ответ на окно деплоя (#3274)
#
# Импортируется ВНУТРЬ `handle_errors 502 503 504 { ... }` (см. apps.caddy):
# сам по себе снипет ничего не перехватывает, он только решает, ЧТО отдать,
# когда апстрим не отвечает.
#
# ЧТО ЭТО ЛЕЧИТ, А ЧТО НЕТ. Каждый деплой tradein-frontend/tradein-backend
# оставляет окно 3090 с, в котором контейнера просто нет: Caddy набирает
# новый апстрим сразу, тот ещё не слушает (замер по access-логам, #3274 —
# все 502 кластеризуются на окнах мержа, duration < 2 мс = мгновенный отказ
# соединения). Снипет НЕ УБИРАЕТ окно — он меняет то, что видит человек и
# клиент внутри окна. Настоящее лечение (готовность нового контейнера до
# переключения) — п.1 issue, решение владельца, здесь его нет.
#
# ПОЧЕМУ 503, А НЕ 502. 502 значит «апстрим ответил мусором» — постоянная
# поломка; поисковик по нему выкидывает страницу из индекса, клиентские
# библиотеки не ретраят. 503 + `Retry-After: 30` — стандартный код «временно
# недоступен, приходи через 30 секунд»: Googlebot держит страницу в индексе,
# HTTP-клиенты понимают, что повтор осмыслен.
#
# ПОЧЕМУ ДВА ТЕЛА. `/trade-in/api/*` вызывают из JS и внешних клиентов — они
# парсят JSON, и HTML-страница у них превращается в ошибку разбора вместо
# читаемого статуса. Всё остальное открывает человек браузером.
#
# ВНЕШНИХ РЕСУРСОВ В СТРАНИЦЕ НЕТ ВООБЩЕ — ни шрифта, ни CSS-файла, ни
# картинки. В окне деплоя они пришли бы с того же мёртвого апстрима, и
# страница-заглушка отрисовалась бы голым текстом. Отсюда же инлайновые
# `style=` вместо блока `<style>`: фигурные скобки в теле `respond` Caddy
# пытается разобрать как плейсхолдеры.
# ═══════════════════════════════════════════════════════════════════════════
@deployWindowApi path /trade-in/api/*
handle @deployWindowApi {
header Content-Type "application/json; charset=utf-8"
header Retry-After "30"
respond `{"detail":"Сервис обновляется, повторите запрос через минуту","error":"service_unavailable","retry_after":30}` 503
}
handle {
header Content-Type "text/html; charset=utf-8"
header Retry-After "30"
respond `<!doctype html>
<html lang="ru">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Сервис обновляется</title>
<body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;background:#fff;color:#111">
<main style="max-width:30rem;padding:2rem;text-align:center">
<h1 style="font-size:1.25rem;font-weight:600;margin:0 0 .75rem">Сервис обновляется</h1>
<p style="margin:0;line-height:1.6;color:#444">Это занимает около минуты. Обновите страницу чуть позже — введённые данные не потеряются.</p>
</main>
` 503
}

View file

@ -267,6 +267,32 @@ gendsgn.ru {
}
}
}
# Окно деплоя (#3274) — ТОЛЬКО для /trade-in. Каждый мерж в tradein
# оставляет 3090 с, в которые контейнера нет и посетитель видит голый 502
# (замер по access-логам: 23 × 502 на этом домене за 4 суток, все —
# на окнах деплоя). Снипет подменяет это на 503 + Retry-After и читаемое
# тело; разбор «что лечится, а что нет» — в самом снипете.
#
# ГЕЙТ ПО ПУТИ ОБЯЗАТЕЛЕН: `handle_errors` объявляется на весь site-блок,
# а этот блок обслуживает ещё и Site Finder («Птица») — его апстримы
# (backend, frontend) деплоятся отдельным пайплайном и в задачу не входят.
# Пути вне матчера не попадают ни в один вложенный handle, ошибка остаётся
# необработанной, и Caddy отдаёт ровно то, что отдавал раньше. Проверено на
# живом Caddy 2.11.3 (dead-upstream 127.0.0.1:9): `/` и `/api/v1/*` —
# прежний пустой 502, `/trade-in/*` — новый 503.
#
# Матчер здесь сверяется с ИСХОДНЫМ путём запроса, а не с переписанным:
# для error-маршрута Caddy восстанавливает запрос, каким он пришёл. Поэтому
# `/trade-in/api/*` внутри снипета матчится, хотя на основном маршруте до
# падения апстрима уже отработал `uri strip_prefix /trade-in`. Тоже
# проверено на стенде, а не выведено из документации.
handle_errors 502 503 504 {
@tradeinScope path /trade-in /trade-in/*
handle @tradeinScope {
import ../deploy-window.caddy.snippet
}
}
}
www.gendsgn.ru {
@ -571,6 +597,19 @@ meraocenka.ru {
handle {
respond 404
}
# Окно деплоя (#3274). В отличие от gendsgn.ru гейт по пути не нужен: все
# апстримы этого блока — контейнеры МЕРЫ (tradein-frontend/tradein-backend),
# чей деплой и создаёт окно. 15 × 502 за 4 суток, все на окнах мержа.
#
# Белый список выше это НЕ ослабляет. Во-первых, `handle_errors`
# срабатывает только на перечисленные статусы, а отказ белого списка —
# 404. Во-вторых, `respond 404` пишет ответ напрямую и ошибкой маршрута
# вообще не является. Проверено на стенде: при мёртвом апстриме `/admin`
# по-прежнему отдаёт 404, а не страницу обновления.
handle_errors 502 503 504 {
import ../deploy-window.caddy.snippet
}
}
# Домены-спутники МЕРА → 301 на канонический meraocenka.ru.

View file

@ -778,6 +778,9 @@ services:
# роняя ВСЕ сайты хоста, а не только metrics.gendsgn.ru.
- ./caddy/metrics-ingest.caddy.snippet:/etc/caddy/caddy/metrics-ingest.caddy.snippet:ro
- ./caddy/metrics-ui.caddy.snippet:/etc/caddy/caddy/metrics-ui.caddy.snippet:ro
# То же самое для страницы окна деплоя (#3274): caddy/sites/apps.caddy
# импортирует её как `import ../deploy-window.caddy.snippet`.
- ./caddy/deploy-window.caddy.snippet:/etc/caddy/caddy/deploy-window.caddy.snippet:ro
# Untracked локальные site-блоки (см. import в конце Caddyfile). Каталог
# держится в git через caddy/local/.gitignore — иначе docker создал бы
# отсутствующий bind-source сам, root-owned пустышкой.

View file

@ -52,15 +52,22 @@ check() {
}
check_post() {
local desc="$1" url="$2" body="$3" expected="$4"
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
local desc="$1" url="$2" body="$3" expected="$4" reject="${5:-}"
local out code head_and_body
# -i: заголовки попадают в вывод вместе с телом — по ним отличаем ответ
# приложения от заглушки Caddy (см. $reject у вызова payments/notify).
out=$(curl -s -i -w '\n%{http_code}' --max-time 15 \
-X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null)
if [ "$code" = "$expected" ]; then
echo "PASS: $desc ($url -> $code)"
else
code=${out##*$'\n'}
head_and_body=${out%$'\n'*}
if [ "$code" != "$expected" ]; then
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
fail=1
elif [ -n "$reject" ] && printf '%s' "$head_and_body" | grep -qEi "$reject"; then
echo "FAIL: $desc ($url -> $code, но ответ от заглушки окна деплоя, а не от приложения)"
fail=1
else
echo "PASS: $desc ($url -> $code)"
fi
}
@ -251,8 +258,16 @@ check "meraocenka.ru payments/checkout — must 404 (Caddy не проксиру
# маршрут существует (не 404 — старый образ) И что приём платежей выключен
# (`payments_enabled=False`). 200 здесь означал бы, что флаг включили, не тронув
# этот смоук. GET → 405 закрепляет, что путь принимает только POST.
#
# С #3274 голый код 503 больше не доказывает ничего: Caddy сам отдаёт 503 на
# окне деплоя (`handle_errors` -> caddy/deploy-window.caddy.snippet), и тогда
# запрос до приложения не дошёл вовсе. Отличаем по признакам заглушки —
# заголовок `Retry-After: 30` и `"error":"service_unavailable"` в теле; у
# приложения тело `{"detail":"payments are disabled"}` и никакого Retry-After.
# Совпал код, но пришла заглушка → FAIL, а не молчаливый PASS.
check_post "trade-in payments/notify — 503 anonymous (маршрут есть, приём выключен)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" '{}' 503
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" '{}' 503 \
'service_unavailable|^retry-after: *30'
check "trade-in payments/notify — 405 на GET (только POST)" \
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 405
check "trade-in payments/checkout — 401 anonymous (не публичный по построению)" \