Compare commits
No commits in common. "main" and "fix/3339-thin-market-badge" have entirely different histories.
main
...
fix/3339-t
490 changed files with 5094 additions and 61188 deletions
|
|
@ -98,24 +98,6 @@ jobs:
|
||||||
python3 scripts/check-compose-ambiguous-hosts.py --selftest
|
python3 scripts/check-compose-ambiguous-hosts.py --selftest
|
||||||
python3 scripts/check-compose-ambiguous-hosts.py
|
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 синтаксически валиден"
|
- name: "Guard: Caddyfile синтаксически валиден"
|
||||||
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
|
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
|
||||||
# на КАЖДОМ PR, стоит секунды, падение блокирует merge.
|
# на КАЖДОМ PR, стоит секунды, падение блокирует merge.
|
||||||
|
|
@ -219,34 +201,6 @@ jobs:
|
||||||
- '.forgejo/workflows/deploy.yml'
|
- '.forgejo/workflows/deploy.yml'
|
||||||
- '.forgejo/workflows/deploy-tradein.yml'
|
- '.forgejo/workflows/deploy-tradein.yml'
|
||||||
- '.forgejo/workflows/ci.yml'
|
- '.forgejo/workflows/ci.yml'
|
||||||
# #3448: тот же класс, ещё раз. Гейт про исключающие `!`-шаблоны
|
|
||||||
# в paths-filter проверяет ВСЕ воркфлоу, а paths-filter живёт и
|
|
||||||
# здесь — без этой строки правка ci-tradein.yml с таким шаблоном
|
|
||||||
# не запустила бы backend-tests, то есть гейт не побежал бы ровно
|
|
||||||
# на той правке, от которой стережёт.
|
|
||||||
- '.forgejo/workflows/ci-tradein.yml'
|
|
||||||
# #3467/#3475: гейт backend/tests/ops/test_3467_prometheus_reload.py
|
|
||||||
# читает оба файла ниже. Без них правка, трогающая ТОЛЬКО
|
|
||||||
# deploy-metrics.yml (скажем, дописывающая `|| true` к шагу
|
|
||||||
# перезагрузки Prometheus), даёт backend=false — джоба
|
|
||||||
# backend-tests пропускается, гейт не исполняется, регрессия
|
|
||||||
# уезжает в main зелёной. Ровно то, что осуждает комментарий выше.
|
|
||||||
- '.forgejo/workflows/deploy-metrics.yml'
|
|
||||||
- 'docker-compose.metrics.yml'
|
|
||||||
# #3486: гейт test_metrics_single_file_mounts.py читает и
|
|
||||||
# compose агентов — правка ТОЛЬКО его (новый пофайловый маунт у
|
|
||||||
# экспортёра) без этой строки прошла бы мимо гейта.
|
|
||||||
- 'docker-compose.metrics-agent.yml'
|
|
||||||
# #3443: тот же класс, третий раз. Гейт
|
|
||||||
# backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает
|
|
||||||
# ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть
|
|
||||||
# все содержательные регрессии живут в самом скрипте, а не в
|
|
||||||
# deploy.yml. PR, правящий только ops/**, без этой строки давал бы
|
|
||||||
# backend=false: джоба пропускается, гейт не исполняется, и
|
|
||||||
# «пересоздавать всегда» (окно 67 с на всех доменах) или
|
|
||||||
# «не пересоздавать никогда» (правка конфига беззвучно не доезжает)
|
|
||||||
# уезжает в main зелёным.
|
|
||||||
- 'ops/**'
|
|
||||||
frontend:
|
frontend:
|
||||||
- 'frontend/**'
|
- 'frontend/**'
|
||||||
- '.forgejo/workflows/ci.yml'
|
- '.forgejo/workflows/ci.yml'
|
||||||
|
|
@ -260,12 +214,10 @@ jobs:
|
||||||
# #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки
|
# #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки
|
||||||
# не бежали ни разу с момента написания.
|
# не бежали ни разу с момента написания.
|
||||||
#
|
#
|
||||||
# postgis/postgis:16-3.4, схема НЕ собирается: тесты строят себе временные
|
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
|
||||||
# таблицы (CREATE TEMP TABLE), которые затеняют боевые, и гоняют настоящий SQL
|
# таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему —
|
||||||
# модулей. PostGIS нужен с #2962: SQL конкурентов ищет «тот же ЖК» через
|
# проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не
|
||||||
# ST_DWithin по geography, без расширения его не исполнить. На plain
|
# нужен, в отличие от tradein-лэйна.
|
||||||
# postgres:16 тест #2962 молча пропускался, то есть мост gap-fill в CI не
|
|
||||||
# проверялся вовсе. Образ сам создаёт расширение в POSTGRES_DB.
|
|
||||||
#
|
#
|
||||||
# TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/
|
# TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/
|
||||||
# (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по
|
# (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по
|
||||||
|
|
@ -296,14 +248,15 @@ jobs:
|
||||||
# ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД
|
# ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД
|
||||||
# ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере.
|
# ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере.
|
||||||
#
|
#
|
||||||
# postgis, а не plain postgres (#2962) — см. комментарий у job'а. Bootstrap
|
# plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные
|
||||||
# схемы здесь не нужен вовсе, в отличие от tradein-лэйна.
|
# таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы
|
||||||
|
# здесь не нужен вовсе, в отличие от tradein-лэйна.
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
|
docker rm -fv "$CI_PG" >/dev/null 2>&1 || true
|
||||||
docker run -d --name "$CI_PG" \
|
docker run -d --name "$CI_PG" \
|
||||||
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
|
-e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \
|
||||||
postgis/postgis:16-3.4
|
postgres:16
|
||||||
|
|
||||||
ready=""
|
ready=""
|
||||||
for _ in $(seq 1 45); do
|
for _ in $(seq 1 45); do
|
||||||
|
|
|
||||||
|
|
@ -90,17 +90,8 @@ jobs:
|
||||||
METRICS_TELEGRAM_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_TOPIC_ID }}
|
METRICS_TELEGRAM_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_TOPIC_ID }}
|
||||||
METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }}
|
METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }}
|
||||||
METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }}
|
METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }}
|
||||||
ALERT_ACK_GLITCHTIP_SECRET: ${{ secrets.ALERT_ACK_GLITCHTIP_SECRET }}
|
|
||||||
# #3589: URL внешнего deadman-приёмника Watchdog (healthchecks.io и
|
|
||||||
# аналоги). Пусто — watchdog-ping остаётся без конфигов, см. блок
|
|
||||||
# METRICS_WATCHDOG_PING_BLOCK ниже.
|
|
||||||
METRICS_WATCHDOG_PING_URL: ${{ secrets.METRICS_WATCHDOG_PING_URL }}
|
|
||||||
# #3471: секрет ретранслятора Telegram Bot API (tg-relay). Пусто —
|
|
||||||
# профиль relay не включаем (см. PROFILES ниже), а не падаем в
|
|
||||||
# рестарт-луп: контейнер сам делает SystemExit на пустом секрете.
|
|
||||||
TG_RELAY_SECRET: ${{ secrets.TG_RELAY_SECRET }}
|
|
||||||
with:
|
with:
|
||||||
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL,ALERT_ACK_GLITCHTIP_SECRET,TG_RELAY_SECRET,METRICS_WATCHDOG_PING_URL
|
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL
|
||||||
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
|
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
|
||||||
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
|
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
|
||||||
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
|
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
|
||||||
|
|
@ -197,32 +188,6 @@ jobs:
|
||||||
echo "Инфраструктура: тема ${INFRA_TOPIC_ID} по умолчанию (METRICS_TELEGRAM_INFRA_TOPIC_ID не задана)."
|
echo "Инфраструктура: тема ${INFRA_TOPIC_ID} по умолчанию (METRICS_TELEGRAM_INFRA_TOPIC_ID не задана)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Watchdog-пинг во внешний deadman-приёмник (healthchecks.io и
|
|
||||||
# аналоги) — заменяет регулярные сообщения в Telegram, см.
|
|
||||||
# комментарий у watchdog-ping в alertmanager.yml.tmpl. Блок
|
|
||||||
# целиком, а не значение — та же причина, что у
|
|
||||||
# METRICS_TELEGRAM_INFRA_TOPIC_LINE: пустой `url:` в
|
|
||||||
# receiver'е не деградирует, а валит amtool check-config
|
|
||||||
# целиком, то есть роняет ВЕСЬ алертинг из-за одного
|
|
||||||
# необязательного получателя. Секрет пока не заведён —
|
|
||||||
# деградация корректна: receiver остаётся без `*_configs` и
|
|
||||||
# молча ничего никуда не шлёт, amtool это пропускает.
|
|
||||||
if [ -n "${METRICS_WATCHDOG_PING_URL:-}" ]; then
|
|
||||||
METRICS_WATCHDOG_PING_BLOCK=$(printf ' webhook_configs:\n - url: "%s"\n send_resolved: false' "${METRICS_WATCHDOG_PING_URL}")
|
|
||||||
echo "Watchdog: внешний deadman-пинг настроен."
|
|
||||||
else
|
|
||||||
METRICS_WATCHDOG_PING_BLOCK=""
|
|
||||||
echo "::warning title=Watchdog без deadman-пинга::METRICS_WATCHDOG_PING_URL пуст — сторож мониторинга никуда не сообщает о своей живости. Заведи аккаунт healthchecks.io (или аналог) и секрет, иначе обрыв канала доставки не заметит никто (#3589)."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Резервный приёмник GlitchTip (#3471) отвечает 503 на любой
|
|
||||||
# запрос, пока секрет пуст: тихо принимать чужие алерты настежь
|
|
||||||
# хуже, чем не принимать вовсе. Молчаливого отказа тут быть не
|
|
||||||
# должно — деплой обязан сказать, что канал не поднялся.
|
|
||||||
if [ -z "${ALERT_ACK_GLITCHTIP_SECRET:-}" ]; then
|
|
||||||
echo "::warning title=Резервный канал GlitchTip выключен::ALERT_ACK_GLITCHTIP_SECRET пуст — alert-ack отвечает 503 на /glitchtip, и при падении продуктового бэкенда его ошибки доставлять будет нечем."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "${METRICS_TELEGRAM_ONCALL:-}" ]; then
|
if [ -n "${METRICS_TELEGRAM_ONCALL:-}" ]; then
|
||||||
echo "Клиентские инциденты: зовём ${METRICS_TELEGRAM_ONCALL} поимённо."
|
echo "Клиентские инциденты: зовём ${METRICS_TELEGRAM_ONCALL} поимённо."
|
||||||
else
|
else
|
||||||
|
|
@ -243,8 +208,7 @@ jobs:
|
||||||
METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \
|
METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \
|
||||||
METRICS_TELEGRAM_INFRA_TOPIC_LINE="$METRICS_TELEGRAM_INFRA_TOPIC_LINE" \
|
METRICS_TELEGRAM_INFRA_TOPIC_LINE="$METRICS_TELEGRAM_INFRA_TOPIC_LINE" \
|
||||||
METRICS_TELEGRAM_ONCALL="${METRICS_TELEGRAM_ONCALL:-}" \
|
METRICS_TELEGRAM_ONCALL="${METRICS_TELEGRAM_ONCALL:-}" \
|
||||||
METRICS_WATCHDOG_PING_BLOCK="$METRICS_WATCHDOG_PING_BLOCK" \
|
envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_INFRA_TOPIC_LINE} ${METRICS_TELEGRAM_ONCALL}' \
|
||||||
envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_INFRA_TOPIC_LINE} ${METRICS_TELEGRAM_ONCALL} ${METRICS_WATCHDOG_PING_BLOCK}' \
|
|
||||||
< ops/metrics/alertmanager/alertmanager.yml.tmpl \
|
< ops/metrics/alertmanager/alertmanager.yml.tmpl \
|
||||||
> ops/metrics/alertmanager/alertmanager.yml
|
> ops/metrics/alertmanager/alertmanager.yml
|
||||||
chmod 600 ops/metrics/alertmanager/alertmanager.yml
|
chmod 600 ops/metrics/alertmanager/alertmanager.yml
|
||||||
|
|
@ -286,19 +250,6 @@ jobs:
|
||||||
echo "::warning title=Алерты выключены::METRICS_TELEGRAM_BOT_TOKEN/CHAT_ID не заданы. Метрики и логи собираются, но при срабатывании правила НИКТО не будет уведомлён. Канал доставки — открытый вопрос #3078."
|
echo "::warning title=Алерты выключены::METRICS_TELEGRAM_BOT_TOKEN/CHAT_ID не заданы. Метрики и логи собираются, но при срабатывании правила НИКТО не будет уведомлён. Канал доставки — открытый вопрос #3078."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ретранслятор Telegram Bot API (#3471, PR #3487 сломал прод: сервис
|
|
||||||
# без profiles уходил в SystemExit на пустом секрете и висел в
|
|
||||||
# Restarting). Профиль relay включаем НЕЗАВИСИМО от alerts — это
|
|
||||||
# разные каналы (один шлёт алерты боту, другой ретранслирует
|
|
||||||
# продуктовый Bot API трафик с Selectel). PROFILES — список через
|
|
||||||
# запятую, как того требует COMPOSE_PROFILES.
|
|
||||||
if [ -n "${TG_RELAY_SECRET:-}" ]; then
|
|
||||||
PROFILES="${PROFILES:+$PROFILES,}relay"
|
|
||||||
echo "Ретранслятор Telegram: секрет задан, профиль relay включён."
|
|
||||||
else
|
|
||||||
echo "::warning title=Резервный ретранслятор Telegram выключен::TG_RELAY_SECRET пуст — tg-relay не поднимается (профиль relay выключен). Продуктовый Telegram-трафик пойдёт напрямую с Selectel, где теряется примерно каждый четвёртый короткий запрос."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Цели file_sd для Prometheus (#3155) ────────────────────────
|
# ── Цели file_sd для Prometheus (#3155) ────────────────────────
|
||||||
# Включатель профиля и цель для Prometheus обязаны стоять в ОДНОМ
|
# Включатель профиля и цель для Prometheus обязаны стоять в ОДНОМ
|
||||||
# условии. Пока они жили порознь, вышло так: 27.08 профиль alerts
|
# условии. Пока они жили порознь, вышло так: 27.08 профиль alerts
|
||||||
|
|
@ -317,27 +268,19 @@ jobs:
|
||||||
AM_TARGETS_FILE=ops/metrics/prometheus/alertmanager_targets.gen.yml
|
AM_TARGETS_FILE=ops/metrics/prometheus/alertmanager_targets.gen.yml
|
||||||
: > "$AM_TARGETS_FILE"
|
: > "$AM_TARGETS_FILE"
|
||||||
echo "# Файл рендерится деплоем (deploy-metrics.yml), правки руками затрутся." >> "$AM_TARGETS_FILE"
|
echo "# Файл рендерится деплоем (deploy-metrics.yml), правки руками затрутся." >> "$AM_TARGETS_FILE"
|
||||||
# Сравнение через case, а не "=": PROFILES теперь может быть
|
if [ "$PROFILES" = "alerts" ]; then
|
||||||
# комбинацией через запятую ("alerts,relay") с тех пор, как #3471
|
|
||||||
# завёл независимый профиль relay — точное равенство строке
|
|
||||||
# "alerts" сломалось бы молча в тот момент, когда оба профиля
|
|
||||||
# включены разом.
|
|
||||||
case ",$PROFILES," in
|
|
||||||
*,alerts,*)
|
|
||||||
echo '- targets: ["alertmanager:9093"]' >> "$AM_TARGETS_FILE"
|
echo '- targets: ["alertmanager:9093"]' >> "$AM_TARGETS_FILE"
|
||||||
echo " labels:" >> "$AM_TARGETS_FILE"
|
echo " labels:" >> "$AM_TARGETS_FILE"
|
||||||
echo " host: infra" >> "$AM_TARGETS_FILE"
|
echo " host: infra" >> "$AM_TARGETS_FILE"
|
||||||
echo "Prometheus: приёмник alertmanager:9093 прописан в целях."
|
echo "Prometheus: приёмник alertmanager:9093 прописан в целях."
|
||||||
;;
|
else
|
||||||
*)
|
|
||||||
# Пустой список, а НЕ отсутствующий файл: одиночный бинд-маунт
|
# Пустой список, а НЕ отсутствующий файл: одиночный бинд-маунт
|
||||||
# несуществующего пути docker подменяет каталогом, и Prometheus
|
# несуществующего пути docker подменяет каталогом, и Prometheus
|
||||||
# не стартует вовсе.
|
# не стартует вовсе.
|
||||||
echo "# Профиль alerts выключен — приёмников нет." >> "$AM_TARGETS_FILE"
|
echo "# Профиль alerts выключен — приёмников нет." >> "$AM_TARGETS_FILE"
|
||||||
echo "[]" >> "$AM_TARGETS_FILE"
|
echo "[]" >> "$AM_TARGETS_FILE"
|
||||||
echo "Prometheus: профиль alerts выключен — целей нет, это штатно."
|
echo "Prometheus: профиль alerts выключен — целей нет, это штатно."
|
||||||
;;
|
fi
|
||||||
esac
|
|
||||||
|
|
||||||
# ── read-only роль для датасорса GlitchTip ─────────────────────
|
# ── read-only роль для датасорса GlitchTip ─────────────────────
|
||||||
# Идемпотентно. Прав на запись не выдаём вовсе: датасорс Grafana
|
# Идемпотентно. Прав на запись не выдаём вовсе: датасорс Grafana
|
||||||
|
|
@ -349,43 +292,6 @@ jobs:
|
||||||
COMPOSE_PROFILES="$PROFILES" \
|
COMPOSE_PROFILES="$PROFILES" \
|
||||||
docker compose -p gendesign-metrics -f docker-compose.metrics.yml up -d --remove-orphans
|
docker compose -p gendesign-metrics -f docker-compose.metrics.yml up -d --remove-orphans
|
||||||
|
|
||||||
# ── alert-ack / tg-relay: код монтируется с хоста ────────────────
|
|
||||||
# Тот же класс бага, что у Alertmanager (см. ниже) и Caddyfile:
|
|
||||||
# `up -d` сравнивает ОПИСАНИЕ сервиса, а не содержимое бинд-маунта.
|
|
||||||
# alert-ack и tg-relay получают код именно бинд-маунтом файла
|
|
||||||
# (./ops/metrics/{alert-ack,tg-relay}/app.py:/app/app.py:ro), а не
|
|
||||||
# сборкой образа — правка app.py оставляет уже запущенный
|
|
||||||
# контейнер работать на СТАРОМ коде в памяти интерпретатора сколько
|
|
||||||
# угодно, и `up -d` этого не видит вовсе.
|
|
||||||
#
|
|
||||||
# Пойман на проде 12.09.2026: PR #3490 (фикс alert-ack) слился,
|
|
||||||
# `git reset --hard` обновил файл на диске (grep по новому
|
|
||||||
# комментарию находил его), а gendesign-alert-ack, запущенный за
|
|
||||||
# 25 минут до этого, продолжал отвечать по старой логике —
|
|
||||||
# зелёный деплой, тихо неверное поведение. Починил только ручной
|
|
||||||
# `docker restart gendesign-alert-ack`. force-recreate здесь —
|
|
||||||
# замена этому ручному шагу.
|
|
||||||
#
|
|
||||||
# case ",$PROFILES," — пересоздаём только если профиль сервиса
|
|
||||||
# реально включён в ЭТОМ прогоне, иначе force-recreate ругается на
|
|
||||||
# несуществующий контейнер (сервис не создан вовсе).
|
|
||||||
case ",$PROFILES," in
|
|
||||||
*,alerts,*)
|
|
||||||
COMPOSE_PROFILES="$PROFILES" \
|
|
||||||
docker compose -p gendesign-metrics -f docker-compose.metrics.yml \
|
|
||||||
up -d --force-recreate alert-ack
|
|
||||||
echo "alert-ack: контейнер пересоздан — код монтируется с хоста, up -d его не подхватывает (#3490)."
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
case ",$PROFILES," in
|
|
||||||
*,relay,*)
|
|
||||||
COMPOSE_PROFILES="$PROFILES" \
|
|
||||||
docker compose -p gendesign-metrics -f docker-compose.metrics.yml \
|
|
||||||
up -d --force-recreate tg-relay
|
|
||||||
echo "tg-relay: контейнер пересоздан — код монтируется с хоста, up -d его не подхватывает (#3490)."
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# ── Alertmanager: пересоздать, если конфиг перерисовали ─────────
|
# ── Alertmanager: пересоздать, если конфиг перерисовали ─────────
|
||||||
# `up -d` выше СЧИТАЕТ alertmanager неизменившимся: он сравнивает
|
# `up -d` выше СЧИТАЕТ alertmanager неизменившимся: он сравнивает
|
||||||
# описание сервиса, а содержимое бинд-маунта в это сравнение не
|
# описание сервиса, а содержимое бинд-маунта в это сравнение не
|
||||||
|
|
@ -411,15 +317,6 @@ jobs:
|
||||||
echo "Alertmanager: контейнер пересоздан — иначе читал бы конфиг по старому иноду."
|
echo "Alertmanager: контейнер пересоздан — иначе читал бы конфиг по старому иноду."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Loki: конфиг — бинд-маунт ОДНОГО файла (#3467) ─────────────
|
|
||||||
# Та же ловушка инода, что у Alertmanager выше, а перезагрузки
|
|
||||||
# основного конфига у Loki нет вовсе: правка loki-config.yml ложилась
|
|
||||||
# на диск и не вступала в силу до случайного пересоздания.
|
|
||||||
# Пересоздаём только при расхождении инода.
|
|
||||||
COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \
|
|
||||||
gendesign-metrics docker-compose.metrics.yml \
|
|
||||||
loki ops/metrics/loki/loki-config.yml /etc/loki/loki-config.yml
|
|
||||||
|
|
||||||
# ── Caddy: СНАЧАЛА проверить, потом применять ──────────────────
|
# ── Caddy: СНАЧАЛА проверить, потом применять ──────────────────
|
||||||
# На этом хосте тот же Caddy обслуживает git., errors. и obsidian.
|
# На этом хосте тот же Caddy обслуживает git., errors. и obsidian.
|
||||||
# Синтаксическая ошибка в infra.caddy положила бы их все, включая
|
# Синтаксическая ошибка в infra.caddy положила бы их все, включая
|
||||||
|
|
@ -438,98 +335,14 @@ jobs:
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Prometheus: конфиг/правила лежат на диске, `up -d` их не
|
# ── Приёмка ────────────────────────────────────────────────────
|
||||||
# перечитывает ────────────────────────────────────────────────
|
for i in $(seq 1 30); do
|
||||||
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше:
|
if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null; then
|
||||||
# docker compose сравнивает описание сервиса, а НЕ содержимое
|
break
|
||||||
# бинд-маунта, поэтому уже работающий контейнер продолжает жить
|
|
||||||
# со старым конфигом сколько угодно — на проде дошло до 16 суток
|
|
||||||
# незамеченными (#3467): lastConfigTime совпадал со startTime
|
|
||||||
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
|
|
||||||
#
|
|
||||||
# /-/reload перечитывает правила: они подключены КАТАЛОГОМ, и новый
|
|
||||||
# файл контейнер видит сразу. А prometheus.yml — бинд-маунт ОДНОГО
|
|
||||||
# файла: после `git reset --hard` за путём в контейнере остаётся
|
|
||||||
# СТАРЫЙ инод, и reload честно перечитывает старый текст с rc=0 и
|
|
||||||
# новым lastConfigTime (проверено на prom/prometheus:v3.1.0, 17.09).
|
|
||||||
# Поэтому при расхождении инода контейнер пересоздаётся (#3467).
|
|
||||||
# --web.enable-lifecycle включён в compose ради этого шага.
|
|
||||||
#
|
|
||||||
# promtool проверяет конфиг, правила и их юнит-тесты (#3493) ДО
|
|
||||||
# любого применения, и именно ФАЙЛЫ С ДИСКА — одноразовым
|
|
||||||
# контейнером того же образа. `docker exec` в работающий проверял
|
|
||||||
# бы тот самый старый инод, а не то, что сейчас применится.
|
|
||||||
# Синтаксически верное правило может врать по смыслу —
|
|
||||||
# `count(x == 1) == 0` от пустого вектора не срабатывает никогда, —
|
|
||||||
# отсюда test rules.
|
|
||||||
if docker run --rm --entrypoint sh \
|
|
||||||
-v /opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro \
|
|
||||||
"$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \
|
|
||||||
-c 'promtool check config /etc/prometheus/prometheus.yml && promtool check rules /etc/prometheus/rules/*.yml && promtool test rules /etc/prometheus/tests/infra_test.yml'; then
|
|
||||||
COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \
|
|
||||||
gendesign-metrics docker-compose.metrics.yml \
|
|
||||||
prometheus ops/metrics/prometheus/prometheus.yml /etc/prometheus/prometheus.yml
|
|
||||||
|
|
||||||
# ── Приёмка ────────────────────────────────────────────────
|
|
||||||
# После возможного пересоздания выше — ждём оба процесса,
|
|
||||||
# которые дальше перечитывают конфиг по HTTP.
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null \
|
|
||||||
&& docker exec gendesign-grafana wget -q --spider http://localhost:3000/api/health 2>/dev/null; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
|
|
||||||
|
|
||||||
LAST_CONFIG_BEFORE="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
|
|
||||||
|
|
||||||
docker exec gendesign-prometheus wget -q -O /dev/null --post-data='' http://localhost:9090/-/reload
|
|
||||||
|
|
||||||
# lastConfigTime обновляется на КАЖДЫЙ успешный reload, даже
|
|
||||||
# если содержимое конфига не поменялось — значит сравнение
|
|
||||||
# "было/стало" надёжно ловит и несостоявшийся reload, и
|
|
||||||
# изменившиеся правила.
|
|
||||||
LAST_CONFIG_AFTER=""
|
|
||||||
for i in $(seq 1 10); do
|
|
||||||
LAST_CONFIG_AFTER="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
|
|
||||||
[ -n "$LAST_CONFIG_AFTER" ] && [ "$LAST_CONFIG_AFTER" != "$LAST_CONFIG_BEFORE" ] && break
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -z "$LAST_CONFIG_AFTER" ] || [ "$LAST_CONFIG_AFTER" = "$LAST_CONFIG_BEFORE" ]; then
|
|
||||||
echo "ОШИБКА: reload Prometheus не подтверждён — lastConfigTime не изменился ($LAST_CONFIG_BEFORE)."
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
echo "Prometheus: конфиг и правила проверены, reload подтверждён ($LAST_CONFIG_BEFORE -> $LAST_CONFIG_AFTER)."
|
sleep 3
|
||||||
else
|
done
|
||||||
echo "ОШИБКА: конфиг/правила Prometheus не проходят promtool (проверка конфига, правил или их юнит-тестов — смотри вывод выше) — reload НЕ выполнен, работающий Prometheus остаётся на прежнем конфиге."
|
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Grafana: датасорсы применяются только при старте (#3467) ────
|
|
||||||
# Каталог provisioning смонтирован целиком, новый datasources.yml
|
|
||||||
# контейнер видит сразу. Дашборды провайдер пересканирует сам
|
|
||||||
# (updateIntervalSeconds: 30), а датасорсы — нет: на стенде
|
|
||||||
# grafana:11.5.1 изменённый url не применился и через 75 с, POST
|
|
||||||
# на ручку ниже применил сразу (замер в PR #3475). Без этого шага
|
|
||||||
# правка датасорса лежала бы на диске без эффекта при зелёном деплое.
|
|
||||||
#
|
|
||||||
# Пароль раскрывается ВНУТРИ контейнера: в argv хоста и в лог деплоя
|
|
||||||
# он не попадает. wget отдаёт rc≠0 на 401/5xx; текст ответа сверяем
|
|
||||||
# отдельно, чтобы «200 не от той ручки» не сошло за успех.
|
|
||||||
GRAFANA_RELOAD="$(docker exec gendesign-grafana sh -c \
|
|
||||||
'wget -q -O- --post-data="" --header="Authorization: Basic $(printf "%s:%s" "${GF_SECURITY_ADMIN_USER:-admin}" "${GF_SECURITY_ADMIN_PASSWORD}" | base64 -w0)" http://localhost:3000/api/admin/provisioning/datasources/reload' \
|
|
||||||
2>&1)" || GRAFANA_RELOAD="rc=$? $GRAFANA_RELOAD"
|
|
||||||
case "$GRAFANA_RELOAD" in
|
|
||||||
*"Datasources config reloaded"*)
|
|
||||||
echo "Grafana: датасорсы перечитаны."
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "ОШИБКА: Grafana не перечитала датасорсы ($GRAFANA_RELOAD) — правка provisioning/datasources осталась бы без эффекта."
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
|
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
|
||||||
agent-apps:
|
agent-apps:
|
||||||
|
|
@ -606,37 +419,11 @@ jobs:
|
||||||
docker compose -p gendesign-metrics-agent \
|
docker compose -p gendesign-metrics-agent \
|
||||||
-f docker-compose.metrics-agent.yml up -d
|
-f docker-compose.metrics-agent.yml up -d
|
||||||
|
|
||||||
# Конфиг Alloy — бинд-маунт ОДНОГО файла, а `git reset --hard` выше пишет
|
|
||||||
# его новым инодом: `up -d` изменения не видит, контейнер держит старый.
|
|
||||||
METRICS_ROLE=apps \
|
|
||||||
METRICS_ALLOY_CONFIG=alloy-apps.alloy \
|
|
||||||
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
|
||||||
docker compose -p gendesign-metrics-agent \
|
|
||||||
-f docker-compose.metrics-agent.yml up -d --force-recreate alloy
|
|
||||||
|
|
||||||
sleep 10
|
sleep 10
|
||||||
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||||
docker compose -p gendesign-metrics-agent \
|
docker compose -p gendesign-metrics-agent \
|
||||||
-f docker-compose.metrics-agent.yml ps
|
-f docker-compose.metrics-agent.yml ps
|
||||||
|
|
||||||
[ "$(stat -c %i ops/metrics/alloy/alloy-apps.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|
|
||||||
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
|
|
||||||
|
|
||||||
# ── postgres-экспортёры: queries.yml — бинд-маунт ОДНОГО файла (#3486) ──
|
|
||||||
# PG_EXPORTER_EXTEND_QUERY_PATH читается только при старте, а
|
|
||||||
# `git reset --hard` пишет правку новым инодом: без пересоздания
|
|
||||||
# экспортёр продолжает отдавать старые запросы при зелёном деплое.
|
|
||||||
# Пересоздаём только при расхождении инода. Под гейтом профиля:
|
|
||||||
# `up` с явным именем сервиса включает его профиль сам.
|
|
||||||
if [ -n "$EXPORTER_PROFILE" ]; then
|
|
||||||
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
|
||||||
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
|
|
||||||
postgres-exporter-gendesign ops/metrics/postgres/queries.yml /etc/pg-queries.yml
|
|
||||||
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
|
||||||
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
|
|
||||||
postgres-exporter-tradein ops/metrics/postgres/queries.yml /etc/pg-queries.yml
|
|
||||||
fi
|
|
||||||
|
|
||||||
agent-infra:
|
agent-infra:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: server
|
needs: server
|
||||||
|
|
@ -691,25 +478,7 @@ jobs:
|
||||||
docker compose -p gendesign-metrics-agent \
|
docker compose -p gendesign-metrics-agent \
|
||||||
-f docker-compose.metrics-agent.yml up -d
|
-f docker-compose.metrics-agent.yml up -d
|
||||||
|
|
||||||
# Конфиг Alloy — бинд-маунт ОДНОГО файла, а `git reset --hard` (джоба server)
|
|
||||||
# пишет его новым инодом: `up -d` изменения не видит, контейнер держит старый.
|
|
||||||
METRICS_ROLE=infra \
|
|
||||||
METRICS_ALLOY_CONFIG=alloy-infra.alloy \
|
|
||||||
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
|
||||||
docker compose -p gendesign-metrics-agent \
|
|
||||||
-f docker-compose.metrics-agent.yml up -d --force-recreate alloy
|
|
||||||
|
|
||||||
sleep 10
|
sleep 10
|
||||||
METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||||
docker compose -p gendesign-metrics-agent \
|
docker compose -p gendesign-metrics-agent \
|
||||||
-f docker-compose.metrics-agent.yml ps
|
-f docker-compose.metrics-agent.yml ps
|
||||||
|
|
||||||
[ "$(stat -c %i ops/metrics/alloy/alloy-infra.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|
|
||||||
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
|
|
||||||
|
|
||||||
# queries.yml экспортёра инфраструктурной БД — см. agent-apps (#3486).
|
|
||||||
if [ -n "$EXPORTER_PROFILE" ]; then
|
|
||||||
METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
|
||||||
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
|
|
||||||
postgres-exporter-infra ops/metrics/postgres/queries.yml /etc/pg-queries.yml
|
|
||||||
fi
|
|
||||||
|
|
|
||||||
|
|
@ -511,37 +511,12 @@ jobs:
|
||||||
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
|
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
|
||||||
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
|
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
|
||||||
# см. frontend/Dockerfile комментарий у соответствующих ARG).
|
# см. frontend/Dockerfile комментарий у соответствующих ARG).
|
||||||
# NEXT_PUBLIC_YM_ID — счётчик Яндекс.Метрики публичного контура.
|
|
||||||
# Живёт на корпоративном аккаунте info@meraocenka.ru, там же, где
|
|
||||||
# Вебмастер: счётчик и права на сайт на одном аккаунте связываются
|
|
||||||
# автоматически, на разных — только через письмо-подтверждение
|
|
||||||
# владельцу счётчика. Прежний номер 112563104 был заведён на личном
|
|
||||||
# аккаунте подрядчика и на сайт не попал ни разу, данных не потерял.
|
|
||||||
# NEXT_PUBLIC_YANDEX_VERIFICATION — токен подтверждения прав в
|
|
||||||
# Яндекс.Вебмастере, отдаётся мета-тегом (mera-public/layout.tsx).
|
|
||||||
# Токен привязан к аккаунту: у другого аккаунта он будет свой.
|
|
||||||
# Удалять его после подтверждения НЕЛЬЗЯ — Вебмастер перепроверяет
|
|
||||||
# права и снимает их, если тег исчез.
|
|
||||||
# Литералы, а не secret'ы: оба значения и так уходят в разметку
|
|
||||||
# каждой страницы, прятать нечего, а secret'ом их не проверить
|
|
||||||
# глазами в диффе.
|
|
||||||
# NEXT_PUBLIC_GA_ID/GOOGLE_VERIFICATION — ПОКА ПУСТЫЕ: ресурс GA4 и
|
|
||||||
# права в Search Console ещё не заведены. Пустая строка =
|
|
||||||
# соответствующий тег НЕ рендерится вообще (контракт фронта, см.
|
|
||||||
# тот же Dockerfile-комментарий).
|
|
||||||
# ЛЮБОЕ из этих значений вписывать литералом СЮДА И в retry-блок
|
|
||||||
# ниже — оба обязательны, иначе ретрай без кеша уедет без счётчика.
|
|
||||||
# Требует пересборки образа: build-time bake, не runtime-правка.
|
|
||||||
build-args: |
|
build-args: |
|
||||||
NEXT_PUBLIC_BASE_PATH=/trade-in
|
NEXT_PUBLIC_BASE_PATH=/trade-in
|
||||||
NEXT_PUBLIC_API_BASE_URL=/trade-in
|
NEXT_PUBLIC_API_BASE_URL=/trade-in
|
||||||
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
|
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
|
||||||
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
||||||
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
||||||
NEXT_PUBLIC_YM_ID=112672927
|
|
||||||
NEXT_PUBLIC_GA_ID=
|
|
||||||
NEXT_PUBLIC_YANDEX_VERIFICATION=82cf8932658461d1
|
|
||||||
NEXT_PUBLIC_GOOGLE_VERIFICATION=
|
|
||||||
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
|
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
|
||||||
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
||||||
tags: |
|
tags: |
|
||||||
|
|
@ -565,10 +540,6 @@ jobs:
|
||||||
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
|
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
|
||||||
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
|
||||||
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
|
||||||
NEXT_PUBLIC_YM_ID=112672927
|
|
||||||
NEXT_PUBLIC_GA_ID=
|
|
||||||
NEXT_PUBLIC_YANDEX_VERIFICATION=82cf8932658461d1
|
|
||||||
NEXT_PUBLIC_GOOGLE_VERIFICATION=
|
|
||||||
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.IMAGE_FRONTEND }}:latest
|
${{ env.IMAGE_FRONTEND }}:latest
|
||||||
|
|
@ -1150,31 +1121,7 @@ jobs:
|
||||||
# tradein-mvp/backend/** включает app/tgbot_main.py), никакого
|
# tradein-mvp/backend/** включает app/tgbot_main.py), никакого
|
||||||
# in-flight state вроде scrape_runs → пересоздаётся безусловно вместе
|
# in-flight state вроде scrape_runs → пересоздаётся безусловно вместе
|
||||||
# с browser/backend/frontend, отдельного graceful-drain не требует.
|
# с browser/backend/frontend, отдельного graceful-drain не требует.
|
||||||
#
|
SERVICES="browser backend frontend tgbot"
|
||||||
# ── FRONTEND ЗДЕСЬ НЕТ. ЭТО И ЕСТЬ ЛЕЧЕНИЕ #3274 ─────────────────
|
|
||||||
# Публичный лендинг лежал 30–90 с на КАЖДОМ деплое. Причина не в
|
|
||||||
# том, что подмена контейнера медленная — она занимает полсекунды.
|
|
||||||
# Причина в том, что `docker compose up -d` со СПИСКОМ сервисов
|
|
||||||
# работает в две фазы: сначала create (старый контейнер каждого
|
|
||||||
# сервиса останавливается и УДАЛЯЕТСЯ — иначе занято container_name),
|
|
||||||
# и только потом start, в порядке зависимостей и с ожиданием их
|
|
||||||
# условий. Между фазами фронта уже нет, а нового ещё нет.
|
|
||||||
#
|
|
||||||
# Замер на проде (docker inspect, 10.09, оба деплоя МЕРЫ):
|
|
||||||
# пачка сервисов: tradein-backend создан 15:01:40 → запущен
|
|
||||||
# 15:02:10 = 30 с (и 503 на лендинге в
|
|
||||||
# 15:01:46/15:01:52/15:02:06 — ровно окно);
|
|
||||||
# ОДИН сервис: tradein-frontend создан 16:42:17.5 → запущен
|
|
||||||
# 16:42:18.0 = 0,5 с, 503 в логе нет вообще.
|
|
||||||
# Тот же двухфазный порядок воспроизведён на стенде по меткам
|
|
||||||
# .Created/.StartedAt: соседи создаются сразу, стартуют через 41 с.
|
|
||||||
#
|
|
||||||
# Поэтому фронт пересоздаётся ОТДЕЛЬНОЙ командой ниже, после этой
|
|
||||||
# пачки: в его графе один сервис, фазы create и start идут подряд.
|
|
||||||
# Остаток (~0,5 с) добирает ретрай подключения в Caddy — см.
|
|
||||||
# снипет (tradein_frontend_retry) в caddy/sites/apps.caddy.
|
|
||||||
# Гейт на обе половины: scripts/check-frontend-swap-window.py.
|
|
||||||
SERVICES="browser backend tgbot"
|
|
||||||
SCRAPER_STOP_TS=""
|
SCRAPER_STOP_TS=""
|
||||||
scraper_stale=""
|
scraper_stale=""
|
||||||
if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then
|
if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then
|
||||||
|
|
@ -1270,21 +1217,6 @@ jobs:
|
||||||
" || echo "WARNING: startup-reap query failed — orphaned runs (if any) fall back to the 6h zombie reaper"
|
" || echo "WARNING: startup-reap query failed — orphaned runs (if any) fall back to the 6h zombie reaper"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# (4b) Фронт — ОТДЕЛЬНОЙ командой, один сервис в графе (#3274).
|
|
||||||
# Обоснование и прод-замеры — у SERVICES выше. Здесь важен ПОРЯДОК:
|
|
||||||
# эта команда идёт ПОСЛЕ пачки (backend уже поднят — новый SSR сразу
|
|
||||||
# ходит в новый бэкенд) и ДО `caddy reload` ниже, чтобы reload, как
|
|
||||||
# и раньше, оставался последним касанием прокси.
|
|
||||||
#
|
|
||||||
# ЗАЧЕМ ЖДАТЬ ПОСЛЕ КОМАНДЫ. `up -d` возвращает управление, когда
|
|
||||||
# контейнер ЗАПУЩЕН, а не когда Next начал слушать (на проде между
|
|
||||||
# ними ~0,1 с, см. journald «Ready in 110ms», но это не гарантия).
|
|
||||||
# Полноценная проверка фронта — health-check ниже по файлу, он же
|
|
||||||
# валит деплой при неудаче; здесь только короткая пауза, чтобы
|
|
||||||
# ретрай Caddy (2 с) не пришёлся на ещё не слушающий порт.
|
|
||||||
docker compose -p gendesign-tradein $COMPOSE_FILES up -d --no-deps frontend
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# (5) `docker restart tradein-backend` БОЛЬШЕ НЕ НУЖЕН (issue #2216).
|
# (5) `docker restart tradein-backend` БОЛЬШЕ НЕ НУЖЕН (issue #2216).
|
||||||
# История (PR #493 / deploy 1156): backend раньше поднимался ПЕРЕД
|
# История (PR #493 / deploy 1156): backend раньше поднимался ПЕРЕД
|
||||||
# миграциями, его lifespan-hook (ensure_fdw_user_mapping) падал с
|
# миграциями, его lifespan-hook (ensure_fdw_user_mapping) падал с
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ on:
|
||||||
# исполнял бы старую версию — молча и без единого сигнала.
|
# исполнял бы старую версию — молча и без единого сигнала.
|
||||||
# Глоб, а не точечный список (#2203): класс бага — «любой ops-скрипт,
|
# Глоб, а не точечный список (#2203): класс бага — «любой ops-скрипт,
|
||||||
# запускаемый по cron с VM», не только docker-prune.sh. Сейчас сюда попадают
|
# запускаемый по cron с VM», не только docker-prune.sh. Сейчас сюда попадают
|
||||||
# backup.sh, restore-drill.sh, restore.sh — точечное
|
# backup.sh, restore-drill.sh, restore.sh, uptime-healthcheck.sh — точечное
|
||||||
# перечисление пришлось бы дополнять при каждом новом скрипте, и про это
|
# перечисление пришлось бы дополнять при каждом новом скрипте, и про это
|
||||||
# снова забыли бы (см. как этот самый комментарий выше был точечным про
|
# снова забыли бы (см. как этот самый комментарий выше был точечным про
|
||||||
# docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком.
|
# docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком.
|
||||||
|
|
@ -121,98 +121,37 @@ jobs:
|
||||||
infra: ${{ steps.filter.outputs.infra }}
|
infra: ${{ steps.filter.outputs.infra }}
|
||||||
# #2916: правка ТОЛЬКО конфига прокси. `infra` для этого не годится — он
|
# #2916: правка ТОЛЬКО конфига прокси. `infra` для этого не годится — он
|
||||||
# включает и compose, и сам workflow, где полный деплой обязателен.
|
# включает и compose, и сам workflow, где полный деплой обязателен.
|
||||||
caddy_only: ${{ steps.filter.outputs.caddy_only }}
|
# `github.event_name == 'push'` первым множителем НАМЕРЕННО: на
|
||||||
|
# workflow_dispatch у paths-filter нет диффа, и любой его ответ не должен
|
||||||
|
# уметь отключить сборку — ручной прогон обязан оставаться полным.
|
||||||
|
caddy_only: ${{ github.event_name == 'push' && steps.filter.outputs.caddy == 'true' && steps.filter.outputs.non_caddy == 'false' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dorny/paths-filter@v3
|
||||||
# ── #3448: список изменённых файлов считаем САМИ ─────────────────────────
|
|
||||||
#
|
|
||||||
# ЧТО БЫЛО. Быстрый путь «правка только прокси» (#2916) не отработал НИ
|
|
||||||
# РАЗУ. Причина — НЕ пустой `event.before`: эта гипотеза опровергнута
|
|
||||||
# логом задачи 29244 (run 10881, мерж 84920e6c) — `before` там валиден,
|
|
||||||
# 204e2e09…, и `git diff` вернул ровно один файл. Причина в семантике
|
|
||||||
# самого фильтра: dorny/paths-filter склеивает шаблоны ОДНОГО фильтра
|
|
||||||
# через `some`, то есть ИЛИ (src/filter.ts: `patterns.some(aPredicate)`,
|
|
||||||
# predicate-quantifier по умолчанию `some`). Список
|
|
||||||
# non_caddy: ['**', '!Caddyfile', '!caddy/**']
|
|
||||||
# читается не как «всё, КРОМЕ caddy», а как «подходит под `**` ИЛИ не
|
|
||||||
# Caddyfile ИЛИ не caddy/**». `**` матчит всё, поэтому non_caddy был true
|
|
||||||
# ВСЕГДА и caddy_only — false всегда. В логе это видно дословно:
|
|
||||||
# ##[group]Filter non_caddy = true
|
|
||||||
# Matching files:
|
|
||||||
# caddy/sites/apps.caddy [modified]
|
|
||||||
# Исключённый файл сам себя и «исключил». deploy-caddy при этом
|
|
||||||
# пропускался, а Forgejo рисует пропущенную джобу зелёной — сигнала не
|
|
||||||
# было ни одного.
|
|
||||||
#
|
|
||||||
# ПОЧЕМУ ШЕЛЛ, А НЕ ЗАПЛАТКА К ФИЛЬТРАМ. Разность множеств тут нужна одна
|
|
||||||
# («все изменения лежат под caddy»), и выражать её действием, у которого
|
|
||||||
# ИЛИ по умолчанию, — значит снова повесить решение на незаметное
|
|
||||||
# умолчание: `predicate-quantifier: every` действует на ВЕСЬ блок и
|
|
||||||
# сломал бы backend/frontend/infra. Плюс два требования #3448: решение
|
|
||||||
# обязано быть ВИДНО в логе (иначе «сработало» и «просто не совпало»
|
|
||||||
# неотличимы), и оно не должно молча зависеть от того, что платформа
|
|
||||||
# кладёт в `before`.
|
|
||||||
#
|
|
||||||
# FAIL-SAFE. База не разрешилась (ручной запуск, пустой/нулевой `before`,
|
|
||||||
# коммита нет на сервере) → считаем изменённым ВЕСЬ репозиторий: лишний
|
|
||||||
# полный деплой безопаснее пропущенного. Фолбэка на `HEAD^..HEAD` тут
|
|
||||||
# намеренно нет: у мерж-коммита он дал бы верный ответ, а у push'а из
|
|
||||||
# нескольких коммитов — молча урезанный, и быстрый путь включился бы
|
|
||||||
# там, где приехал бэкенд.
|
|
||||||
- name: Определить изменённые файлы (#3448)
|
|
||||||
id: filter
|
id: filter
|
||||||
env:
|
with:
|
||||||
BEFORE: ${{ github.event.before }}
|
filters: |
|
||||||
EVENT: ${{ github.event_name }}
|
backend:
|
||||||
run: |
|
- 'backend/**'
|
||||||
set -eu
|
- 'data/sql/**'
|
||||||
NULL_SHA=0000000000000000000000000000000000000000
|
frontend:
|
||||||
BASE=""
|
- 'frontend/**'
|
||||||
if [ "$EVENT" = "push" ] && [ -n "${BEFORE:-}" ] && [ "$BEFORE" != "$NULL_SHA" ]; then
|
infra:
|
||||||
git cat-file -e "${BEFORE}^{commit}" 2>/dev/null \
|
- 'docker-compose.prod.yml'
|
||||||
|| git fetch --depth=1 --no-tags origin "$BEFORE" >/dev/null 2>&1 \
|
- 'Caddyfile'
|
||||||
|| true
|
- 'caddy/**'
|
||||||
if git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
|
- '.forgejo/workflows/deploy.yml'
|
||||||
BASE="$BEFORE"
|
# Пара фильтров для «правка ТОЛЬКО прокси» (#2916). Одного `caddy`
|
||||||
else
|
# мало: он true и когда вместе с конфигом приехал бэкенд — тогда
|
||||||
echo "::warning::коммит $BEFORE недоступен в клоне — деплой будет полным"
|
# нужен обычный полный деплой. `non_caddy` матчит ВСЁ остальное,
|
||||||
fi
|
# и быстрый путь включается лишь когда он false.
|
||||||
fi
|
caddy:
|
||||||
|
- 'Caddyfile'
|
||||||
if [ -n "$BASE" ]; then
|
- 'caddy/**'
|
||||||
FILES=$(git -c core.quotePath=false diff --no-renames --name-only "$BASE" HEAD)
|
non_caddy:
|
||||||
N=$(printf '%s\n' "$FILES" | grep -c . || true)
|
- '**'
|
||||||
echo "База: $BASE → $(git rev-parse HEAD); изменённых файлов: $N"
|
- '!Caddyfile'
|
||||||
printf '%s\n' "$FILES" | sed 's/^/ /'
|
- '!caddy/**'
|
||||||
else
|
|
||||||
FILES=$(git -c core.quotePath=false ls-files)
|
|
||||||
N=$(printf '%s\n' "$FILES" | grep -c . || true)
|
|
||||||
echo "База не определена (event=$EVENT, before='${BEFORE:-}') — считаем изменённым весь репозиторий ($N файлов), деплой полный"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Те же наборы путей, что были в фильтрах до #3448.
|
|
||||||
CADDY_RE='^(Caddyfile$|caddy/)'
|
|
||||||
has() { printf '%s\n' "$FILES" | grep -qE "$1"; }
|
|
||||||
|
|
||||||
backend=false; frontend=false; infra=false; caddy_only=false
|
|
||||||
has '^(backend/|data/sql/)' && backend=true
|
|
||||||
has '^frontend/' && frontend=true
|
|
||||||
has '^(docker-compose\.prod\.yml$|Caddyfile$|caddy/|\.forgejo/workflows/deploy\.yml$)' && infra=true
|
|
||||||
# Быстрый путь: изменения ЕСТЬ и НИ ОДНО из них не лежит вне caddy.
|
|
||||||
# Проверка `N -gt 0` обязательна: пустой список иначе прошёл бы как
|
|
||||||
# «всё под caddy» и отключил бы сборку на ровном месте.
|
|
||||||
if [ "$N" -gt 0 ] && ! printf '%s\n' "$FILES" | grep -vE "$CADDY_RE" | grep -q .; then
|
|
||||||
caddy_only=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Флаги: backend=$backend frontend=$frontend infra=$infra caddy_only=$caddy_only"
|
|
||||||
{
|
|
||||||
echo "backend=$backend"
|
|
||||||
echo "frontend=$frontend"
|
|
||||||
echo "infra=$infra"
|
|
||||||
echo "caddy_only=$caddy_only"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
build-backend:
|
build-backend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -1058,17 +997,12 @@ jobs:
|
||||||
docker compose -p gendesign -f docker-compose.prod.yml up -d \
|
docker compose -p gendesign -f docker-compose.prod.yml up -d \
|
||||||
--force-recreate --no-deps $WORKER_SERVICES
|
--force-recreate --no-deps $WORKER_SERVICES
|
||||||
|
|
||||||
# Caddy: пересоздание ТОЛЬКО когда без него правка не доедет (#3443).
|
# Caddy: force-recreate чтобы подхватить изменения в Caddyfile
|
||||||
# Здесь стоял безусловный `up -d --force-recreate --no-deps caddy` —
|
# И в особенности новые volume mounts из docker-compose.prod.yml
|
||||||
# то есть КАЖДЫЙ полный деплой сносил единственный процесс, слушающий
|
# (`reload` не пересоздаёт container, поэтому новые binds не появляются —
|
||||||
# 80/443, и все домены хоста отдавали `code=000` (замер 05.09: 67 с).
|
# был случай 2026-05-17 с PR #268 preview/ — потребовался manual SSH fix).
|
||||||
# Довод той правки (17.05, 11e78d73 — «иначе новые volume mounts не
|
docker compose -p gendesign -f docker-compose.prod.yml up -d \
|
||||||
# появляются») не подтвердился: `up -d` БЕЗ флага пересоздаёт
|
--force-recreate --no-deps caddy
|
||||||
# контейнер сам, как только меняется описание сервиса или образ.
|
|
||||||
# Разбор и проверки — в шапке ops/caddy-apply.sh; там же сверка
|
|
||||||
# пофайловых bind-маунтов (Caddyfile + 4 сниппета держат инод) и
|
|
||||||
# `caddy validate` до применения.
|
|
||||||
sh ops/caddy-apply.sh
|
|
||||||
|
|
||||||
# Forwarder: force-recreate чтобы новый image / новые env подхватывались.
|
# Forwarder: force-recreate чтобы новый image / новые env подхватывались.
|
||||||
# Без --force-recreate обычный `up -d` НЕ recreate'ит при image rebuild
|
# Без --force-recreate обычный `up -d` НЕ recreate'ит при image rebuild
|
||||||
|
|
@ -1295,13 +1229,14 @@ jobs:
|
||||||
# Публичный периметр МЕРЫ живёт в этом файле и будет меняться часто: новая
|
# Публичный периметр МЕРЫ живёт в этом файле и будет меняться часто: новая
|
||||||
# страница = новая строка allowlist'а.
|
# страница = новая строка allowlist'а.
|
||||||
#
|
#
|
||||||
# ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Опечатка в конфиге на
|
# ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Полный деплой
|
||||||
# пересоздании уводит контейнер в crash-loop и роняет ВСЕ домены сразу, а
|
# осознанно пересоздаёт контейнер (комментарий в ci.yml: `reload` отказался бы
|
||||||
# `caddy reload` её просто не принимает: job краснеет, домены продолжают
|
# принять битый конфиг и оставил бы работать старый — на общем деплое это
|
||||||
# обслуживаться прежним конфигом. С #3443 ровно тот же порядок действует и на
|
# скрыло бы поломку). Здесь наоборот: правится ТОЛЬКО конфиг, и отказ
|
||||||
# полном деплое — оба пути зовут ops/caddy-apply.sh, который сперва проверяет
|
# применить битый — ровно то, что нужно. `caddy reload` возвращает ненулевой
|
||||||
# конфиг одноразовым контейнером и пересоздаёт Caddy, только если правка иначе
|
# код → job краснеет, а домены продолжают обслуживаться старым конфигом.
|
||||||
# не доедет (пофайловый bind-маунт держит инод).
|
# Альтернатива (`--force-recreate`) на опечатке уводит контейнер в crash-loop
|
||||||
|
# и роняет ВСЕ домены сразу.
|
||||||
#
|
#
|
||||||
# Гейт `caddy validate` на PR (#2913) остаётся первой линией; этот шаг —
|
# Гейт `caddy validate` на PR (#2913) остаётся первой линией; этот шаг —
|
||||||
# вторая, уже против боевого файла после `git reset`.
|
# вторая, уже против боевого файла после `git reset`.
|
||||||
|
|
@ -1347,73 +1282,14 @@ jobs:
|
||||||
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
|
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
|
||||||
script: |
|
script: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# #3448: ТОТ ЖЕ ЛОК, что берёт полный деплой (см. job `deploy` выше).
|
|
||||||
# Эта джоба делает `git reset --hard` в /opt/gendesign, то есть правит
|
|
||||||
# прод-дерево — ровно то, что полный деплой сериализует локом. Пока
|
|
||||||
# быстрый путь был мёртв, столкнуться было нечему; теперь есть.
|
|
||||||
exec 9>/var/lock/gendesign-docker-deploy.lock
|
|
||||||
if flock -n 9; then
|
|
||||||
echo "→ докер-лок свободен, взят сразу"
|
|
||||||
else
|
|
||||||
echo "→ докер-лок занят соседним деплоем, жду (до 900с)…"
|
|
||||||
lock_wait_started=$(date +%s)
|
|
||||||
if ! flock -w 900 9; then
|
|
||||||
echo "ERROR: не дождался лока докер-деплоя за 900с."
|
|
||||||
echo " Кто держит: ssh на хост, затем fuser -v /var/lock/gendesign-docker-deploy.lock"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "→ докер-лок получен через $(( $(date +%s) - lock_wait_started ))с ожидания"
|
|
||||||
fi
|
|
||||||
cd /opt/gendesign
|
cd /opt/gendesign
|
||||||
git fetch origin main
|
git fetch origin main
|
||||||
|
|
||||||
# ── #3448: быстрый путь законен, только если прод отстаёт РОВНО на
|
|
||||||
# конфиг прокси ────────────────────────────────────────────────────
|
|
||||||
#
|
|
||||||
# Джоба `changes` считает дифф between-push (before→HEAD) и не знает,
|
|
||||||
# что доехало до прода. Пока caddy_only был мёртв, любой push шёл
|
|
||||||
# полным деплоем и гард свежести :latest (#2950, job `deploy`)
|
|
||||||
# прикрывал прод по умолчанию. Оживший быстрый путь этот гард
|
|
||||||
# обходит: при caddy_only=true джоба `deploy` пропускается целиком.
|
|
||||||
#
|
|
||||||
# Сценарий отказа: push A правит бэкенд, билды ~6 мин, `deploy` в
|
|
||||||
# очереди; через 2 мин push B правит только caddy/. Forgejo на
|
|
||||||
# 10.0.3 отменяет ещё не стартовавший `deploy` предыдущего прогона
|
|
||||||
# ДАЖЕ при cancel-in-progress: false (наблюдение 21.08.2026 10:35:13,
|
|
||||||
# см. шапку scripts/check-latest-image-revision.sh). Дифф A..B — один
|
|
||||||
# caddy-файл, быстрый путь включается, `compose pull` + `up -d` не
|
|
||||||
# делает никто: прод крутит старый образ при зелёной голове main.
|
|
||||||
#
|
|
||||||
# Единственный источник правды о том, что реально на проде, — HEAD
|
|
||||||
# прод-дерева (у Trade-In для этого заведён отдельный маркер
|
|
||||||
# /opt/gendesign/.tradein-deployed-sha, см. deploy-tradein.yml:150;
|
|
||||||
# у ПТИЦЫ маркера нет, но git reset ниже делает HEAD эквивалентом).
|
|
||||||
# Проверка стоит ДО reset намеренно: при отказе прод-HEAD остаётся
|
|
||||||
# честным для следующего прогона.
|
|
||||||
#
|
|
||||||
# ЧЕГО ЭТА ПРОВЕРКА НЕ ЛОВИТ: `deploy` прогона A, упавшую ПОСЛЕ
|
|
||||||
# `git reset --hard` (например на миграции). Тогда прод-HEAD уже
|
|
||||||
# равен A, а контейнеры старые, и caddy-only push пройдёт быстрым
|
|
||||||
# путём. Это остаётся за настоящим маркером «что задеплоено».
|
|
||||||
PROD_HEAD=$(git rev-parse HEAD)
|
|
||||||
OUTSIDE=$(git -c core.quotePath=false diff --name-only "$PROD_HEAD" origin/main | grep -vE '^(Caddyfile$|caddy/)' || true)
|
|
||||||
if [ -n "$OUTSIDE" ]; then
|
|
||||||
echo "::error::прод отстаёт не только по конфигу прокси — быстрый путь запрещён:"
|
|
||||||
printf '%s\n' "$OUTSIDE" | sed 's/^/ /'
|
|
||||||
echo "Запусти полный деплой через workflow_dispatch."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
git reset --hard origin/main
|
git reset --hard origin/main
|
||||||
# #3443: тот же скрипт, что и в полном деплое. Голый `exec caddy
|
# Конфиг примонтирован read-only с хоста, пересборка не нужна —
|
||||||
# reload` здесь был ВЕРЕН только для каталогов (caddy/sites/**,
|
# контейнер читает тот же файл, что только что обновил git.
|
||||||
# caddy/local/**). Caddyfile и четыре сниппета смонтированы
|
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
|
||||||
# ПОФАЙЛОВО, а `git reset --hard` выше пишет новый инод — контейнер
|
caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||||
# остаётся на прежнем, и reload перечитывает СТАРЫЙ текст. Отказ
|
echo "✓ конфиг прокси перезагружен без пересборки и без миграций"
|
||||||
# беззвучный: джоба зелёная, конфиг на диске новый, прокси работает
|
|
||||||
# по старому. Скрипт сверяет, что именно видит контейнер, и
|
|
||||||
# пересоздаёт его только в этом случае.
|
|
||||||
sh ops/caddy-apply.sh
|
|
||||||
echo "✓ быстрый путь завершён: без пересборки образов и без миграций"
|
|
||||||
|
|
||||||
# ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ──────────────────
|
# ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ──────────────────
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -35,35 +35,7 @@ concurrency:
|
||||||
jobs:
|
jobs:
|
||||||
smoke:
|
smoke:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# 11.09.2026: было 5 минут — теперь мало. В скрипте появились пауза между
|
timeout-minutes: 5
|
||||||
# проверками (2 c) и повтор запроса, если ответа не пришло вовсе: обычный
|
|
||||||
# прогон вырос с ~89 c до ~175 c, а ХУДШИЙ случай — гораздо больше, потому
|
|
||||||
# что каждая неотвечающая проверка стоит до 3×15 c таймаута плюс паузы
|
|
||||||
# (~53 c против обычных ~2 c).
|
|
||||||
#
|
|
||||||
# 12.09.2026: 10 минут — тоже мало, и мало ровно в том сценарии, ради
|
|
||||||
# которого повтор писался. АРИФМЕТИКА ХУДШЕГО СЛУЧАЯ. Одна неотвечающая
|
|
||||||
# проверка сетевого класса = 3×15 c таймаута + 2 c и 4 c пауз ретрая + 2 c
|
|
||||||
# паузы между проверками = 53 c. Прод не отвечает целиком (DNS не
|
|
||||||
# резолвится, вход лежит) — мертвы ВСЕ проверки: 43 × 53 = 2279 c ≈ 38 мин.
|
|
||||||
# Откуда 43 (замер 12.09, зелёный прогон против прода — 43 PASS за 167 c):
|
|
||||||
# 42 обычные проверки + отдельная загрузка HTML лэндинга; 43-я, производный
|
|
||||||
# layout-чанк, при мёртвом ответе не запрашивается вовсе — запросов ровно
|
|
||||||
# столько же.
|
|
||||||
# В 10 минут помещалось ~8 мёртвых проверок из 43, дальше job убивали ДО
|
|
||||||
# печати FAIL-строк и итога — то есть лог терялся при полном отказе прода.
|
|
||||||
#
|
|
||||||
# Правка «повторяем только сетевой класс» (12.09) худший случай НЕ
|
|
||||||
# уменьшает: 15-секундный таймаут как раз сетевой (rc=28) и повторяется
|
|
||||||
# по-прежнему. Она удешевляет ДРУГОЙ сценарий — протухший/чужой сертификат
|
|
||||||
# (rc=60): отказ приходит сразу и без повторов. Замер 12.09 на
|
|
||||||
# expired.badssl.com, одна проверка при SMOKE_PAUSE=0 — 23 c на прежней
|
|
||||||
# голове (3 попытки + 6 c пауз) против <1 c теперь.
|
|
||||||
#
|
|
||||||
# 40 минут = 38 мин худшего случая + запас на чекаут и разброс сети.
|
|
||||||
# Цена промаха несимметрична: занятый раннер стоит дёшево (прогон daily +
|
|
||||||
# on-push), потерянный лог при полном отказе прода — дорого.
|
|
||||||
timeout-minutes: 40
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
|
|
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -109,10 +109,3 @@ ops/metrics/alertmanager/alertmanager.yml
|
||||||
# и Prometheus не видел ни одного приёмника (#3155). Производный файл убирает
|
# и Prometheus не видел ни одного приёмника (#3155). Производный файл убирает
|
||||||
# сам зазор — правится только там же, где принимается решение о профиле.
|
# сам зазор — правится только там же, где принимается решение о профиле.
|
||||||
ops/metrics/prometheus/alertmanager_targets.gen.yml
|
ops/metrics/prometheus/alertmanager_targets.gen.yml
|
||||||
|
|
||||||
# Временные рабочие копии-worktree вида _wt-<тема>/ живут рядом с репозиторием
|
|
||||||
# и в него попадать не должны. 09.09 копия _wt-mskcol попала в индекс одним
|
|
||||||
# файлом сборщика, и три следующих фикса ушли в дубликат мимо канонического
|
|
||||||
# tradein-mvp/scripts/local-avito-msk/collect.py — дефект нашёлся только при
|
|
||||||
# сверке page size.
|
|
||||||
_wt-*/
|
|
||||||
|
|
|
||||||
31
Caddyfile
31
Caddyfile
|
|
@ -50,27 +50,30 @@
|
||||||
# ничего не меняется. В окне: на Selectel CADDY_SITES=apps, на Beget=infra.
|
# ничего не меняется. В окне: на Selectel CADDY_SITES=apps, на Beget=infra.
|
||||||
import caddy/sites/{$CADDY_SITES:*}.caddy
|
import caddy/sites/{$CADDY_SITES:*}.caddy
|
||||||
|
|
||||||
# Plain HTTP by IP. /health остаётся публичным (liveness). Всё остальное —
|
# Plain HTTP by IP — closed by same auth gate (prevent bypass via direct IP / SSH tunnel).
|
||||||
# РЕДИРЕКТ на канонический HTTPS, а не проксирование под basic_auth.
|
# Caddy issues no TLS here (no hostname). /health remains public.
|
||||||
#
|
|
||||||
# ЗДЕСЬ СТОЯЛ auth-гейт с проксированием приложения — «закрыть обход через
|
|
||||||
# голый IP тем же гейтом». Замысел верный, исполнение — дыра: Basic-challenge
|
|
||||||
# на plain HTTP означает, что браузер отправит пароль пилота ОТКРЫТЫМ ТЕКСТОМ
|
|
||||||
# любому, кто слушает канал (аудит 02.09.2026: curl http://<IP>/api/v1/me →
|
|
||||||
# 401 + Www-Authenticate: Basic realm="GenDesign Pilot"). Редирект строже
|
|
||||||
# гейта: по HTTP не отдаётся ни контент, ни сам запрос пароля, обход через
|
|
||||||
# IP закрыт тем, что отвечать нечему. Потребителей у IP:80 нет: все
|
|
||||||
# deploy-смоки ходят docker exec → localhost внутри контейнеров (проверено
|
|
||||||
# grep-ом по .forgejo/workflows и ops/ 02.09.2026).
|
|
||||||
:80 {
|
:80 {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
route {
|
route {
|
||||||
# /health — public, без auth (liveness probe).
|
# /health — public, без auth (GHA deploy smoke check, liveness probe).
|
||||||
handle /health {
|
handle /health {
|
||||||
reverse_proxy backend:8000
|
reverse_proxy backend:8000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Auth gate (same snippet as gendsgn.ru).
|
||||||
|
import caddy/users.caddy.snippet
|
||||||
|
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy backend:8000 {
|
||||||
|
header_up X-Authenticated-User {http.auth.user.id}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handle {
|
handle {
|
||||||
redir https://gendsgn.ru{uri} permanent
|
reverse_proxy frontend:3000 {
|
||||||
|
header_up X-Authenticated-User {http.auth.user.id}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1910,19 +1910,17 @@ def trigger_ekburg_permits(
|
||||||
return {"task_id": result.id, "scope": scope, "queued_at": "now"}
|
return {"task_id": result.id, "scope": scope, "queued_at": "now"}
|
||||||
|
|
||||||
|
|
||||||
# Текст отказа guard'а блокировки DOM.РФ (#2443, #2445 D1). Зонды 20.08, 27.08 и
|
# WAF cooldown guard message (#2443 — DOM.РФ hard-banned this VPS's IP 2026-05-24
|
||||||
# 01.09: наш.дом.рф за StormWall отдаёт серверу «Доступ заблокирован [403]», таймера
|
# после серии failed catalog SSR extras-сессий). Beat schedule для catalog-object
|
||||||
# у этой блокировки нет. Beat-записи catalog-object и catalog-flat scrape'ов
|
# и catalog-flat scrape'ов ОТКЛЮЧЕН по этой же причине (см. beat_schedule.py) —
|
||||||
# выключены по той же причине (см. beat_schedule.py); оба ad-hoc эндпоинта ниже
|
# оба ad-hoc admin-эндпоинта ниже бьют по ТОМУ ЖЕ /сервисы/* BrowserSession
|
||||||
# ходят тем же /сервисы/* BrowserSession path family. Оператор решает по этому
|
# path family, поэтому без явного оператор-override могут углубить бан (#2445 D1).
|
||||||
# тексту, ставить ли флаг, поэтому он называет реальное условие (#3307).
|
_WAF_COOLDOWN_GUARD_MSG = (
|
||||||
_DOMRF_BLOCK_GUARD_MSG = (
|
"Ad-hoc catalog-scrape заблокирован guard'ом: DOM.РФ WAF hard-ban этого VPS IP "
|
||||||
"Ad-hoc catalog-scrape заблокирован guard'ом: наш.дом.рф за StormWall отдаёт "
|
"2026-05-24 (issue #2443), beat schedule для этого таска отключён по той же "
|
||||||
"этому серверу «Доступ заблокирован [403]» (зонды 20.08-01.09, issue #2443), "
|
"причине. Повторный ad-hoc запуск может углубить бан. Если ты осознанно "
|
||||||
"beat schedule для этого таска выключен по той же причине. Ожидание блокировку "
|
"принимаешь этот риск (WAF cooldown прошёл, targeted smoke-test и т.п.) — "
|
||||||
"не снимает, запуск отсюда даст только отказы. Передавай "
|
"передай i_understand_waf_risk=true в теле запроса."
|
||||||
"i_understand_waf_risk=true, только когда сбор идёт через прокси и kn-прогон "
|
|
||||||
"принят по числу строк (#3307)."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1954,20 +1952,20 @@ def trigger_kn_catalog_objects(
|
||||||
"""Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
|
"""Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
|
||||||
ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
|
ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
|
||||||
|
|
||||||
Beat-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py).
|
Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint — для ad-hoc
|
||||||
Этот endpoint — для ad-hoc запуска (smoke-тест или повторный pass для
|
запуска (smoke-тест после деплоя или повторный pass для свежесозданных
|
||||||
свежесозданных объектов).
|
объектов до next beat fire).
|
||||||
|
|
||||||
- max_objects=None → дефолтный лимит таска (300).
|
- max_objects=None → дефолтный лимит таска (300).
|
||||||
- max_objects=3 → smoke-тест.
|
- max_objects=3 → smoke-тест.
|
||||||
- force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд.
|
- force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд.
|
||||||
|
|
||||||
Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||||
beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc
|
beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
|
||||||
запуск без явного подтверждения оператора запрещён (условие — #3307).
|
re-trigger без явного подтверждения оператора запрещён.
|
||||||
"""
|
"""
|
||||||
if not payload.i_understand_waf_risk:
|
if not payload.i_understand_waf_risk:
|
||||||
raise HTTPException(status_code=400, detail=_DOMRF_BLOCK_GUARD_MSG)
|
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
|
||||||
|
|
||||||
from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects
|
from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects
|
||||||
|
|
||||||
|
|
@ -2024,12 +2022,12 @@ def trigger_kn_catalog_flats(
|
||||||
- max_flats=3 → smoke-тест.
|
- max_flats=3 → smoke-тест.
|
||||||
- force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
|
- force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
|
||||||
|
|
||||||
Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||||
same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же
|
same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
|
||||||
блокировкой StormWall (условие — #3307).
|
того же WAF-бана.
|
||||||
"""
|
"""
|
||||||
if not payload.i_understand_waf_risk:
|
if not payload.i_understand_waf_risk:
|
||||||
raise HTTPException(status_code=400, detail=_DOMRF_BLOCK_GUARD_MSG)
|
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
|
||||||
|
|
||||||
from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats
|
from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.observability.metrics import REPORTS_EXPORTED
|
|
||||||
from app.schemas.parcel import (
|
from app.schemas.parcel import (
|
||||||
AnalysisRunDetail,
|
AnalysisRunDetail,
|
||||||
AnalysisRunListResponse,
|
AnalysisRunListResponse,
|
||||||
|
|
@ -166,74 +165,6 @@ _GEO_PRICE_RADIUS_M: float = 3000.0 # 3 км — городской радиу
|
||||||
_GEO_PRICE_MIN_LOTS: int = 10
|
_GEO_PRICE_MIN_LOTS: int = 10
|
||||||
_GEO_PRICE_MIN_COMPLEXES: int = 2
|
_GEO_PRICE_MIN_COMPLEXES: int = 2
|
||||||
|
|
||||||
_GEO_RADIUS_PRICE_SQL = text("""
|
|
||||||
-- #1964 physflat-дедуп: objective_lots раздут ~2.91×
|
|
||||||
-- (мульти lot_id на физлот) → n/вес медианы/гейт n≥10
|
|
||||||
-- были по пере-листингам. Дедупим INLINE через DISTINCT ON
|
|
||||||
-- (physflat-ключ, последний снапшот), scope протолкнут В
|
|
||||||
-- CTE через проекты ближних ЖК. НЕ через
|
|
||||||
-- v_objective_lots_latest: view материализует ВСЮ таблицу
|
|
||||||
-- (qual/join не проходят ниже DISTINCT ON) → seq-scan+sort
|
|
||||||
-- 1.76M (~6.4 s на request-path analyze_parcel). Дедуп до price-фильтра:
|
|
||||||
-- цена объективна по физлоту (последний снапшот).
|
|
||||||
-- #3583: дедуп по проекту в LATERAL + premise_kind='квартира' → Index Only Scan
|
|
||||||
-- objective_lots_physflat_covering_v2_idx без сортировки. Общий DISTINCT ON по
|
|
||||||
-- всем снапшотам проектов шёл external merge на диск: прод-EXPLAIN 17.09, 16
|
|
||||||
-- проектов, 690 мс против 62 мс. Апартаменты на проде 17.09 есть у 6 проектов из
|
|
||||||
-- complex_sources, у всех complex без координат → в радиус не попадают, фильтр
|
|
||||||
-- медиану не меняет (замер на 570 участках: 0 отличий).
|
|
||||||
WITH nearby_cx AS (
|
|
||||||
-- #3583: complex → проект Объектива из complex_sources (source='objective',
|
|
||||||
-- 1:1), а НЕ из objective_lots.complex_id. Тот проставлен один раз миграцией 76,
|
|
||||||
-- а еженедельный 70_parse_objective_raw.py UPSERT'ом по objective_lot_id
|
|
||||||
-- переписывает project_name и не трогает complex_id → под id ближнего ЖК
|
|
||||||
-- лежат лоты чужих (прод 17.09: 236 354 из 303 677 строк). Тот же дефект,
|
|
||||||
-- что #2962 в competitors.py, и та же сверка имени: связь fuzzy, у «ЖК VEER
|
|
||||||
-- PARK» стоит 'Clever Park' в 11.7 км, у «ЖК Графит» — 'Гранит'.
|
|
||||||
SELECT c.id, cs.source_id AS project_name
|
|
||||||
FROM complexes c
|
|
||||||
JOIN complex_sources cs
|
|
||||||
ON cs.complex_id = c.id
|
|
||||||
AND cs.source = 'objective'
|
|
||||||
CROSS JOIN LATERAL (
|
|
||||||
SELECT regexp_replace(lower(c.canonical_name), '[^0-9a-zа-яё]', '', 'g') AS cx_key,
|
|
||||||
regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key
|
|
||||||
) k
|
|
||||||
WHERE c.latitude IS NOT NULL
|
|
||||||
AND c.longitude IS NOT NULL
|
|
||||||
AND ST_DWithin(
|
|
||||||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
|
|
||||||
ST_SetSRID(
|
|
||||||
ST_MakePoint(CAST(:lon AS float), CAST(:lat AS float)), 4326
|
|
||||||
)::geography,
|
|
||||||
CAST(:radius_m AS float)
|
|
||||||
)
|
|
||||||
AND ( k.cx_key LIKE '%' || k.project_key || '%'
|
|
||||||
OR k.project_key LIKE '%' || k.cx_key || '%')
|
|
||||||
),
|
|
||||||
latest AS (
|
|
||||||
SELECT l.price_per_m2_rub, nc.id AS complex_id
|
|
||||||
FROM nearby_cx nc
|
|
||||||
CROSS JOIN LATERAL (
|
|
||||||
SELECT DISTINCT ON (ol.corpus_name, ol.section, ol.floor, ol.lot_number)
|
|
||||||
ol.price_per_m2_rub
|
|
||||||
FROM objective_lots ol
|
|
||||||
WHERE ol.project_name = nc.project_name
|
|
||||||
AND ol.premise_kind = 'квартира'
|
|
||||||
ORDER BY ol.corpus_name, ol.section, ol.floor, ol.lot_number,
|
|
||||||
ol.snapshot_date DESC, ol.id DESC
|
|
||||||
) l
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
percentile_cont(0.5) WITHIN GROUP (
|
|
||||||
ORDER BY price_per_m2_rub
|
|
||||||
) AS median,
|
|
||||||
count(*) AS n,
|
|
||||||
count(DISTINCT complex_id) AS n_complexes
|
|
||||||
FROM latest
|
|
||||||
WHERE price_per_m2_rub IS NOT NULL
|
|
||||||
""")
|
|
||||||
|
|
||||||
# #1960 «Медиана рынка»: минимум сделок квартальной росреестровской MV
|
# #1960 «Медиана рынка»: минимум сделок квартальной росреестровской MV
|
||||||
# (mv_quarter_price_per_m2.deals_count, окно 24 мес), чтобы её медиана вообще
|
# (mv_quarter_price_per_m2.deals_count, окно 24 мес), чтобы её медиана вообще
|
||||||
# могла служить последним fallback'ом для карточки district.median_price_per_m2.
|
# могла служить последним fallback'ом для карточки district.median_price_per_m2.
|
||||||
|
|
@ -1681,11 +1612,6 @@ def export_parcel_forecast(
|
||||||
if run is None:
|
if run is None:
|
||||||
raise HTTPException(status_code=404, detail="прогноз ещё не посчитан")
|
raise HTTPException(status_code=404, detail="прогноз ещё не посчитан")
|
||||||
|
|
||||||
# #3471: считаем выгрузку здесь, а не в каждой format-ветке ниже — рано
|
|
||||||
# (до самого рендера), зато один раз на весь запрос и без риска разъехаться
|
|
||||||
# с новой веткой формата, если её когда-нибудь добавят.
|
|
||||||
REPORTS_EXPORTED.labels(format=format).inc()
|
|
||||||
|
|
||||||
# tg — INLINE сниппет (не файл): краткая сводка для копипаста в Telegram, без attachment.
|
# tg — INLINE сниппет (не файл): краткая сводка для копипаста в Telegram, без attachment.
|
||||||
if format == "tg":
|
if format == "tg":
|
||||||
return Response(
|
return Response(
|
||||||
|
|
@ -3630,7 +3556,7 @@ def analyze_parcel(
|
||||||
# радиусе вокруг центроида участка (ST_DWithin). Закрывает пробел district_reference:
|
# радиусе вокруг центроида участка (ST_DWithin). Закрывает пробел district_reference:
|
||||||
# только 4 из 9 админ-районов ЕКБ матчатся с Objective по имени, остальные 5 без неё
|
# только 4 из 9 админ-районов ЕКБ матчатся с Objective по имени, остальные 5 без неё
|
||||||
# проваливались в class_norm. У objective_lots нет geom — коорды берём через
|
# проваливались в class_norm. У objective_lots нет geom — коорды берём через
|
||||||
# complexes (latitude/longitude), проект — через complex_sources (#3583, как #2962).
|
# complexes (latitude/longitude), join по ol.complex_id = c.id (паттерн competitors.py).
|
||||||
geo_radius_price: dict[str, Any]
|
geo_radius_price: dict[str, Any]
|
||||||
# Вырожденная геометрия → центроид свалился на хардкод-центр ЕКБ. Гео-радиусная
|
# Вырожденная геометрия → центроид свалился на хардкод-центр ЕКБ. Гео-радиусная
|
||||||
# медиана тогда = «3км вокруг центра города», но выдаётся за калиброванную рыночную
|
# медиана тогда = «3км вокруг центра города», но выдаётся за калиброванную рыночную
|
||||||
|
|
@ -3641,7 +3567,60 @@ def analyze_parcel(
|
||||||
with db.begin_nested():
|
with db.begin_nested():
|
||||||
grp_row = (
|
grp_row = (
|
||||||
db.execute(
|
db.execute(
|
||||||
_GEO_RADIUS_PRICE_SQL,
|
text("""
|
||||||
|
-- #1964 physflat-дедуп: objective_lots раздут ~2.91×
|
||||||
|
-- (мульти lot_id на физлот) → n/вес медианы/гейт n≥10
|
||||||
|
-- были по пере-листингам. Дедупим INLINE через DISTINCT ON
|
||||||
|
-- (physflat-ключ, последний снапшот), scope протолкнут В
|
||||||
|
-- CTE через complex_id ближних ЖК. НЕ через
|
||||||
|
-- v_objective_lots_latest: view материализует ВСЮ таблицу
|
||||||
|
-- (qual/join не проходят ниже DISTINCT ON) → seq-scan+sort
|
||||||
|
-- 1.76M (~6.4 s на request-path analyze_parcel). Inline:
|
||||||
|
-- geo-index по complexes → Nested Loop bitmap
|
||||||
|
-- objective_lots_complex_idx по ~186 ЖК → ~120 ms
|
||||||
|
-- (прод-EXPLAIN deep-review #1964). Дедуп до price-фильтра:
|
||||||
|
-- цена объективна по физлоту (последний снапшот).
|
||||||
|
WITH nearby_cx AS (
|
||||||
|
SELECT c.id
|
||||||
|
FROM complexes c
|
||||||
|
WHERE c.latitude IS NOT NULL
|
||||||
|
AND c.longitude IS NOT NULL
|
||||||
|
AND ST_DWithin(
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_MakePoint(c.longitude, c.latitude),
|
||||||
|
4326
|
||||||
|
)::geography,
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_MakePoint(
|
||||||
|
CAST(:lon AS float),
|
||||||
|
CAST(:lat AS float)
|
||||||
|
), 4326
|
||||||
|
)::geography,
|
||||||
|
CAST(:radius_m AS float)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
latest AS (
|
||||||
|
SELECT DISTINCT ON (
|
||||||
|
ol.project_name, ol.corpus_name, ol.section,
|
||||||
|
ol.floor, ol.lot_number
|
||||||
|
)
|
||||||
|
ol.price_per_m2_rub,
|
||||||
|
ol.complex_id
|
||||||
|
FROM objective_lots ol
|
||||||
|
WHERE ol.complex_id IN (SELECT id FROM nearby_cx)
|
||||||
|
ORDER BY ol.project_name, ol.corpus_name, ol.section,
|
||||||
|
ol.floor, ol.lot_number,
|
||||||
|
ol.snapshot_date DESC, ol.id DESC
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
percentile_cont(0.5) WITHIN GROUP (
|
||||||
|
ORDER BY price_per_m2_rub
|
||||||
|
) AS median,
|
||||||
|
count(*) AS n,
|
||||||
|
count(DISTINCT complex_id) AS n_complexes
|
||||||
|
FROM latest
|
||||||
|
WHERE price_per_m2_rub IS NOT NULL
|
||||||
|
"""),
|
||||||
{
|
{
|
||||||
"lon": centroid_lon,
|
"lon": centroid_lon,
|
||||||
"lat": centroid_lat,
|
"lat": centroid_lat,
|
||||||
|
|
@ -4932,7 +4911,6 @@ async def get_parcel_best_layouts_pdf(
|
||||||
today = _dt.date.today().strftime("%Y-%m-%d")
|
today = _dt.date.today().strftime("%Y-%m-%d")
|
||||||
cad_safe = cad_num.replace(":", "-")
|
cad_safe = cad_num.replace(":", "-")
|
||||||
filename = f"tz-layout-{cad_safe}-{today}.pdf"
|
filename = f"tz-layout-{cad_safe}-{today}.pdf"
|
||||||
REPORTS_EXPORTED.labels(format="best_layouts_pdf").inc()
|
|
||||||
return Response(
|
return Response(
|
||||||
content=pdf_bytes,
|
content=pdf_bytes,
|
||||||
media_type="application/pdf",
|
media_type="application/pdf",
|
||||||
|
|
|
||||||
|
|
@ -100,19 +100,6 @@ BUILD_INFO.labels(
|
||||||
release=os.getenv("SENTRY_RELEASE") or os.getenv("IMAGE_TAG") or "unknown",
|
release=os.getenv("SENTRY_RELEASE") or os.getenv("IMAGE_TAG") or "unknown",
|
||||||
).set(1)
|
).set(1)
|
||||||
|
|
||||||
# ═══ ПРОДУКТОВЫЕ СЧЁТЧИКИ (#3471) ═══════════════════════════════════════════
|
|
||||||
#
|
|
||||||
# `format` — фиксированный литерал из сигнатуры эндпоинта (Literal["md", "json",
|
|
||||||
# "tg", "docx", "pptx", "pdf"] в `export_parcel_forecast` + одно статичное
|
|
||||||
# значение "best_layouts_pdf" из ТЗ-на-проектирование), НЕ произвольная строка —
|
|
||||||
# кардинальность ограничена набором форматов экспорта, а не количеством
|
|
||||||
# участков/пользователей.
|
|
||||||
REPORTS_EXPORTED = Counter(
|
|
||||||
"sitefinder_reports_exported_total",
|
|
||||||
"Экспортов отчётов по участку (§22-форсайт, ТЗ на проектирование), по формату",
|
|
||||||
labelnames=("format",),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def route_label(scope: Scope) -> str:
|
def route_label(scope: Scope) -> str:
|
||||||
"""Шаблон маршрута из ASGI-scope, либо ``__unmatched__``.
|
"""Шаблон маршрута из ASGI-scope, либо ``__unmatched__``.
|
||||||
|
|
|
||||||
|
|
@ -627,7 +627,7 @@ class LayoutDataQuality(BaseModel):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
objects_with_velocity_data: int # число комплексов с velocity-данными
|
objects_with_velocity_data: int # число комплексов с velocity-данными
|
||||||
objects_total_in_radius: int # число комплексов после exclude/filter (знаменатель coverage)
|
objects_total_in_radius: int # число комплексов (сгруппированных obj_id)
|
||||||
raw_objects_total: int # сырое число obj_id в радиусе (до группировки)
|
raw_objects_total: int # сырое число obj_id в радиусе (до группировки)
|
||||||
# Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по
|
# Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по
|
||||||
# нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id —
|
# нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id —
|
||||||
|
|
|
||||||
|
|
@ -587,6 +587,19 @@ class NSPDBulkClient:
|
||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
async def get_territorial_zones_in_bbox(
|
||||||
|
self,
|
||||||
|
bbox: tuple[float, float, float, float],
|
||||||
|
*,
|
||||||
|
grid_n: int = 7,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Grid-walk WMS GetFeatureInfo для layer 875838 (ПЗЗ территориальные зоны).
|
||||||
|
|
||||||
|
Returns: list of feature dicts с полями id, geometry, properties.
|
||||||
|
Дедуплицирует по feature id.
|
||||||
|
"""
|
||||||
|
return await self.get_features_in_bbox_grid(875838, bbox, grid_n=grid_n)
|
||||||
|
|
||||||
# ── 4. list_objects_in_building ───────────────────────────────────────────
|
# ── 4. list_objects_in_building ───────────────────────────────────────────
|
||||||
# Q3 deferred — метод реализован, но не вызывается в bulk_harvest_quarter MVP.
|
# Q3 deferred — метод реализован, но не вызывается в bulk_harvest_quarter MVP.
|
||||||
# Готов для per-building помещения/парковка фазы.
|
# Готов для per-building помещения/парковка фазы.
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ Resumable: phase_state в cadastre_jobs показывает прогресс.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
@ -259,8 +260,10 @@ async def harvest_quarter(
|
||||||
update_progress(done_progress)
|
update_progress(done_progress)
|
||||||
|
|
||||||
# ── Phase 4: quarter stats + auto-heal geom из snapshot ─────────────────
|
# ── Phase 4: quarter stats + auto-heal geom из snapshot ─────────────────
|
||||||
# Bug #1583: кварталы с broken/NULL geom дают quarter_bbox_3857() == None →
|
# Bug #1583: auto-heal geom выполняем ДО Phase 2.5 (territorial_zones). Иначе
|
||||||
# grid-walk следующего прогона молча пропускается. Чиним geom из snapshot.
|
# кварталы с broken/NULL geom дают quarter_bbox_3857() == None → ПЗЗ молча
|
||||||
|
# пропускаются, а на следующем harvest квартал отсекается skip_fresh_hours.
|
||||||
|
# Чиним geom здесь → Phase 2.5 ниже получит валидный bbox в этом же прогоне.
|
||||||
stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS]
|
stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS]
|
||||||
if stats_features:
|
if stats_features:
|
||||||
upsert_quarter_stats(db, quarter, stats_features[0])
|
upsert_quarter_stats(db, quarter, stats_features[0])
|
||||||
|
|
@ -275,6 +278,25 @@ async def harvest_quarter(
|
||||||
logger.warning("harvest_quarter: geom auto-heal failed for %s: %s", quarter, e)
|
logger.warning("harvest_quarter: geom auto-heal failed for %s: %s", quarter, e)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# ── Phase 2.5: grid-walk для territorial_zones (ПЗЗ, layer 875838) ────────
|
||||||
|
# Выполняем после основного grid-walk (Phase 2-3) И после Phase 4 geom
|
||||||
|
# auto-heal (см. Bug #1583) — так broken-geom кварталы, починенные выше,
|
||||||
|
# получают валидный bbox и ПЗЗ собираются в том же прогоне. Требует bbox квартала.
|
||||||
|
quarter_bbox = quarter_bbox_3857(db, quarter)
|
||||||
|
if quarter_bbox is not None:
|
||||||
|
update_progress({"phase": "territorial_zones_started", "quarter": quarter})
|
||||||
|
try:
|
||||||
|
tz_features = await client.get_territorial_zones_in_bbox(quarter_bbox)
|
||||||
|
tz_count = _save_territorial_zones(db, quarter, tz_features)
|
||||||
|
logger.info(
|
||||||
|
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
|
||||||
|
)
|
||||||
|
except (NspdBulkWafError, NspdBulkRateLimitError):
|
||||||
|
# #2464-A: см. выше — бан пробрасываем, а не превращаем в «слой пуст».
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
|
||||||
|
|
||||||
# Issue #252: финальный phase_state несёт АГРЕГИРОВАННЫЙ harvest_meta по всем
|
# Issue #252: финальный phase_state несёт АГРЕГИРОВАННЫЙ harvest_meta по всем
|
||||||
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
|
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
|
||||||
# per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в
|
# per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в
|
||||||
|
|
@ -657,7 +679,7 @@ def upsert_features(
|
||||||
cat = feature.category_id
|
cat = feature.category_id
|
||||||
# Per-feature SAVEPOINT (backend.md SAVEPOINT rule): один битый feature (edge-case
|
# Per-feature SAVEPOINT (backend.md SAVEPOINT rule): один битый feature (edge-case
|
||||||
# GeoJSON / NOT NULL violation / type mismatch) НЕ должен ронять весь snapshot-tx
|
# GeoJSON / NOT NULL violation / type mismatch) НЕ должен ронять весь snapshot-tx
|
||||||
# квартала. Зеркалит _grid_walk_category:
|
# квартала. Зеркалит _grid_walk_category (~368) и _save_territorial_zones (~1234):
|
||||||
# begin_nested() rollback'ит только этот feature, остальные сохраняются. Счётчик
|
# begin_nested() rollback'ит только этот feature, остальные сохраняются. Счётчик
|
||||||
# инкрементим ТОЛЬКО при успехе (внутри блока) → counts остаются точными.
|
# инкрементим ТОЛЬКО при успехе (внутри блока) → counts остаются точными.
|
||||||
try:
|
try:
|
||||||
|
|
@ -1454,6 +1476,114 @@ def upsert_quarter_stats(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── cad_territorial_zones upsert ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _save_territorial_zones(db: Session, quarter_cad: str, features: list[dict]) -> int:
|
||||||
|
"""UPSERT territorial_zones features в cad_territorial_zones по zone_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: SQLAlchemy session.
|
||||||
|
quarter_cad: кадастровый номер квартала (3 сегмента).
|
||||||
|
features: list of raw feature dicts от get_features_in_bbox_grid.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Количество успешно upserted строк.
|
||||||
|
"""
|
||||||
|
inserted = 0
|
||||||
|
for f in features:
|
||||||
|
props: dict = f.get("properties") or {}
|
||||||
|
geom = f.get("geometry")
|
||||||
|
geom_geojson: str | None = json.dumps(geom) if geom else None
|
||||||
|
|
||||||
|
# zone_id — NSPD feature id или стабильный fallback на основе md5 от properties.
|
||||||
|
# md5 гарантирует идемпотентность между runs (счётчик inserted сбрасывается).
|
||||||
|
_raw_id = props.get("id") or props.get("zone_id") or f.get("id")
|
||||||
|
if _raw_id:
|
||||||
|
zone_id = str(_raw_id)
|
||||||
|
else:
|
||||||
|
_props_hash = hashlib.md5(
|
||||||
|
json.dumps(props, sort_keys=True).encode("utf-8")
|
||||||
|
).hexdigest()[:12]
|
||||||
|
zone_id = f"{quarter_cad}_{_props_hash}"
|
||||||
|
zone_code = (
|
||||||
|
props.get("zone_code") or props.get("zone_index") or props.get("reg_numb_border")
|
||||||
|
)
|
||||||
|
zone_name = props.get("zone_name") or props.get("zone_type_name") or props.get("type_zone")
|
||||||
|
permitted_use = props.get("permitted_use") or props.get("vri")
|
||||||
|
|
||||||
|
# cad_territorial_zones.geom — geography(MultiPolygon, 4326).
|
||||||
|
# Polygon допустим (ST_Multi обернёт в SQL), но Point/LineString →
|
||||||
|
# geography-INSERT fail → SAVEPOINT откат → строка дропается молча.
|
||||||
|
# Зеркалит фильтр upsert_zouit (~1196).
|
||||||
|
geom_type = geom.get("type") if isinstance(geom, dict) else None
|
||||||
|
if geom_type not in ("Polygon", "MultiPolygon"):
|
||||||
|
if geom_type:
|
||||||
|
logger.info(
|
||||||
|
"_save_territorial_zones: zone_id=%s geom type=%s (не Polygon/MultiPolygon)"
|
||||||
|
" — geom=NULL",
|
||||||
|
zone_id,
|
||||||
|
geom_type,
|
||||||
|
)
|
||||||
|
geom_geojson = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# begin_nested() требует активной outer-транзакции для SAVEPOINT.
|
||||||
|
# SQLAlchemy Session (autobegin=True) автоматически начинает tx при первом
|
||||||
|
# db.execute() в этом loop — outer tx гарантирована.
|
||||||
|
with db.begin_nested():
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO cad_territorial_zones
|
||||||
|
(quarter_cad, zone_id, zone_code, zone_name,
|
||||||
|
permitted_use, raw_props, geom)
|
||||||
|
VALUES (
|
||||||
|
CAST(:quarter_cad AS text),
|
||||||
|
CAST(:zone_id AS text),
|
||||||
|
CAST(:zone_code AS text),
|
||||||
|
CAST(:zone_name AS text),
|
||||||
|
CAST(:permitted_use AS text),
|
||||||
|
CAST(:raw_props AS jsonb),
|
||||||
|
CASE WHEN CAST(:geom AS text) IS NOT NULL
|
||||||
|
THEN ST_Multi(
|
||||||
|
ST_Transform(
|
||||||
|
ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom AS text)), 3857),
|
||||||
|
4326
|
||||||
|
)
|
||||||
|
)::geography
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
)
|
||||||
|
ON CONFLICT (zone_id) DO UPDATE SET
|
||||||
|
zone_code = EXCLUDED.zone_code,
|
||||||
|
zone_name = EXCLUDED.zone_name,
|
||||||
|
permitted_use = EXCLUDED.permitted_use,
|
||||||
|
raw_props = EXCLUDED.raw_props,
|
||||||
|
geom = EXCLUDED.geom,
|
||||||
|
fetched_at = NOW()
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"quarter_cad": quarter_cad,
|
||||||
|
"zone_id": zone_id,
|
||||||
|
"zone_code": zone_code,
|
||||||
|
"zone_name": zone_name,
|
||||||
|
"permitted_use": permitted_use,
|
||||||
|
"raw_props": json.dumps(props, ensure_ascii=False),
|
||||||
|
"geom": geom_geojson,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"_save_territorial_zones: upsert failed zone_id=%s quarter=%s: %s",
|
||||||
|
zone_id,
|
||||||
|
quarter_cad,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
# ── Утилиты ──────────────────────────────────────────────────────────────────
|
# ── Утилиты ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,15 +30,11 @@ _EXCERPT_RADIUS = 100 # символов вокруг первого кад-н
|
||||||
# 66:41:NNNNNNN:NN — регион:район:квартал:номер. Квартал 6 или 7 цифр.
|
# 66:41:NNNNNNN:NN — регион:район:квартал:номер. Квартал 6 или 7 цифр.
|
||||||
_RE_CAD_NUM = re.compile(r"\b(66:\d{2}:\d{6,7}:\d+)\b")
|
_RE_CAD_NUM = re.compile(r"\b(66:\d{2}:\d{6,7}:\d+)\b")
|
||||||
|
|
||||||
# Номер акта: «№ 1413», «№ 509-ПП». Суффикс необязателен (#2982): прежний шаблон
|
# Паттерн номера акта: «№ NNN от DD.MM.YYYY» или «№ NNN-ПП».
|
||||||
# требовал областной (-ПП/-ПА/…), а у постановлений Администрации Екатеринбурга
|
_RE_ACT_NUMBER = re.compile(
|
||||||
# его нет — на проде номер не извлёкся ни у одной из 27 строк. Хвост (?!…) не
|
r"№\s*(\d[\d\s]*[\-–—]?\s*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
|
||||||
# даёт обрезать чужой номер: из «№ 60/1» не берётся «60», из «№ 12-ПППП» — «12».
|
re.UNICODE | re.IGNORECASE,
|
||||||
_ACT_NUM = r"(\d+(?:\s*[-–]\s*[А-Я]{1,3})?)(?![\w/\-–])"
|
)
|
||||||
_RE_ACT_NUMBER = re.compile(rf"№\s*{_ACT_NUM}")
|
|
||||||
# Номер того же акта, что и выбранная дата: «от DD.MM.YYYY № N» либо «№ N от DD.MM.YYYY».
|
|
||||||
_RE_ACT_NUMBER_AFTER_DATE = re.compile(rf"\s*№\s*{_ACT_NUM}")
|
|
||||||
_RE_ACT_NUMBER_BEFORE_DATE = re.compile(rf"№\s*{_ACT_NUM}\s*$")
|
|
||||||
_RE_ACT_DATE = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})")
|
_RE_ACT_DATE = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})")
|
||||||
|
|
||||||
# Слова, по которым дата опознаётся как дата САМОГО акта-основания, а не
|
# Слова, по которым дата опознаётся как дата САМОГО акта-основания, а не
|
||||||
|
|
@ -237,9 +233,7 @@ def extract_izyatie_records(
|
||||||
normalized = _normalize_ocr_text(ocr_text)
|
normalized = _normalize_ocr_text(ocr_text)
|
||||||
|
|
||||||
# Реквизиты акта из заголовка или текста.
|
# Реквизиты акта из заголовка или текста.
|
||||||
act_number = _extract_act_number(doc_title) or _extract_act_number(
|
act_number = _extract_act_number(doc_title) or _extract_act_number(normalized)
|
||||||
normalized, require_act_context=True
|
|
||||||
)
|
|
||||||
act_date = _extract_act_date(doc_title) or _extract_act_date(
|
act_date = _extract_act_date(doc_title) or _extract_act_date(
|
||||||
normalized, require_act_context=True
|
normalized, require_act_context=True
|
||||||
)
|
)
|
||||||
|
|
@ -295,26 +289,9 @@ def extract_izyatie_records(
|
||||||
# ── Вспомогательные функции ────────────────────────────────────────────────────
|
# ── Вспомогательные функции ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _extract_act_number(text: str, *, require_act_context: bool = False) -> str | None:
|
def _extract_act_number(text: str) -> str | None:
|
||||||
"""Извлекает номер акта.
|
"""Извлекает номер акта из текста (первое вхождение «№ NNN-ПП»)."""
|
||||||
|
m = _RE_ACT_NUMBER.search(text)
|
||||||
Без require_act_context (заголовок) — первое «№ N» в тексте.
|
|
||||||
|
|
||||||
С require_act_context (тело документа, #2982) — номер ТОГО ЖЕ акта, чью дату
|
|
||||||
выбирает `_extract_act_date`: «№» вплотную после даты, иначе вплотную перед
|
|
||||||
ней. Первое «№» в теле — это «Решение Думы № 60/1» (Генплан) или «Приказ
|
|
||||||
Министерства № 746-П», а не основание изъятия. Номера рядом с датой нет —
|
|
||||||
None, а не номер соседнего документа.
|
|
||||||
"""
|
|
||||||
if not require_act_context:
|
|
||||||
m = _RE_ACT_NUMBER.search(text)
|
|
||||||
else:
|
|
||||||
date_m = _act_date_match(text, require_act_context=True)
|
|
||||||
if date_m is None:
|
|
||||||
return None
|
|
||||||
m = _RE_ACT_NUMBER_AFTER_DATE.match(text, date_m.end()) or (
|
|
||||||
_RE_ACT_NUMBER_BEFORE_DATE.search(text, max(0, date_m.start() - 30), date_m.start())
|
|
||||||
)
|
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
return re.sub(r"\s+", "", m.group(1))
|
return re.sub(r"\s+", "", m.group(1))
|
||||||
|
|
@ -326,17 +303,6 @@ def _act_context_matches(text: str, pos: int) -> bool:
|
||||||
return all(word in ctx for word in _ACT_CONTEXT_WORDS)
|
return all(word in ctx for word in _ACT_CONTEXT_WORDS)
|
||||||
|
|
||||||
|
|
||||||
def _act_date_match(text: str, *, require_act_context: bool) -> re.Match[str] | None:
|
|
||||||
"""Первое «от DD.MM.YYYY» с допустимой датой (и контекстом постановления, если нужен)."""
|
|
||||||
for m in _RE_ACT_DATE.finditer(text):
|
|
||||||
if require_act_context and not _act_context_matches(text, m.start()):
|
|
||||||
continue
|
|
||||||
d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
||||||
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
|
|
||||||
return m
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | None:
|
def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | None:
|
||||||
"""Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE.
|
"""Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE.
|
||||||
|
|
||||||
|
|
@ -349,10 +315,18 @@ def _extract_act_date(text: str, *, require_act_context: bool = False) -> str |
|
||||||
даты честнее, чем дата чужого документа — по ней нельзя ни отфильтровать
|
даты честнее, чем дата чужого документа — по ней нельзя ни отфильтровать
|
||||||
актуальные изъятия, ни сверить срок.
|
актуальные изъятия, ни сверить срок.
|
||||||
"""
|
"""
|
||||||
m = _act_date_match(text, require_act_context=require_act_context)
|
for m in _RE_ACT_DATE.finditer(text):
|
||||||
if m is None:
|
if require_act_context and not _act_context_matches(text, m.start()):
|
||||||
return None
|
continue
|
||||||
return f"{m.group(3)}-{m.group(2)}-{m.group(1)}"
|
day, month, year = m.group(1), m.group(2), m.group(3)
|
||||||
|
try:
|
||||||
|
# Валидируем диапазоны.
|
||||||
|
d, mo, y = int(day), int(month), int(year)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
|
||||||
|
return f"{y:04d}-{mo:02d}-{d:02d}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _extract_purpose(text: str) -> str | None:
|
def _extract_purpose(text: str) -> str | None:
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,8 @@ _RE_CAD_NUM = re.compile(r"\b(\d{2}:\d{2}:\d{6,7}:\d+)\b")
|
||||||
|
|
||||||
# Номер постановления: 509-ПП, 1234-ПА, 55-ПП, NNN-РП и т.п.
|
# Номер постановления: 509-ПП, 1234-ПА, 55-ПП, NNN-РП и т.п.
|
||||||
# Паттерн намеренно широкий — постановления РФ имеют разные суффиксы.
|
# Паттерн намеренно широкий — постановления РФ имеют разные суффиксы.
|
||||||
# Суффикс необязателен (#2982): у муниципальных актов и приказов его нет («№ 1413»),
|
|
||||||
# и прежний шаблон пропускал собственный номер акта в шапке, забирая следующий
|
|
||||||
# «№ …-ПП» — номер чужого, цитируемого постановления. Хвост (?!…) отбрасывает
|
|
||||||
# номер с суффиксом не из списка целиком, а не обрезает его: «№ 218-ФЗ»,
|
|
||||||
# «№ 746-П» и «№ 60/1» не дают ни «218», ни «746», ни «60».
|
|
||||||
_RE_ACT_NUMBER = re.compile(
|
_RE_ACT_NUMBER = re.compile(
|
||||||
r"№\s*(\d+(?:[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))?)(?![\w/\-–—])",
|
r"№\s*(\d+[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))\b",
|
||||||
re.UNICODE,
|
re.UNICODE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1109,6 +1109,9 @@ def get_best_layouts(
|
||||||
id_row_dicts: list[dict[str, Any]] = [dict(r) for r in id_rows]
|
id_row_dicts: list[dict[str, Any]] = [dict(r) for r in id_rows]
|
||||||
all_obj_ids: list[int] = [int(r["obj_id"]) for r in id_row_dicts]
|
all_obj_ids: list[int] = [int(r["obj_id"]) for r in id_row_dicts]
|
||||||
raw_objects_total_in_radius = len(all_obj_ids)
|
raw_objects_total_in_radius = len(all_obj_ids)
|
||||||
|
# Число комплексов ДО exclude/filter — знаменатель для empty-ответа (совместимо
|
||||||
|
# со старой семантикой: пустой ответ отражал полный радиус, а не post-filter 0).
|
||||||
|
groups_total_pre_filter = len(group_radius_objects(id_row_dicts))
|
||||||
|
|
||||||
# Применить exclude / filter из request (и к obj_ids, и к строкам для группировки)
|
# Применить exclude / filter из request (и к obj_ids, и к строкам для группировки)
|
||||||
exclude_set = set(request.exclude_competitor_obj_ids)
|
exclude_set = set(request.exclude_competitor_obj_ids)
|
||||||
|
|
@ -1129,16 +1132,13 @@ def get_best_layouts(
|
||||||
all_obj_ids = [oid for oid in all_obj_ids if _keep(oid)]
|
all_obj_ids = [oid for oid in all_obj_ids if _keep(oid)]
|
||||||
|
|
||||||
# Группируем отфильтрованные obj_id в комплексы — знаменатель coverage.
|
# Группируем отфильтрованные obj_id в комплексы — знаменатель coverage.
|
||||||
# objects_total_in_radius во ВСЕХ ветках = len(complex_groups) (после фильтра):
|
|
||||||
# UI и PDF печатают его как N в «покрытие P% (Y из N комплексов)», а P считается
|
|
||||||
# по отфильтрованным (#2464). Число до фильтра — в raw_objects_total.
|
|
||||||
complex_groups = group_radius_objects(id_row_dicts)
|
complex_groups = group_radius_objects(id_row_dicts)
|
||||||
|
|
||||||
if not all_obj_ids:
|
if not all_obj_ids:
|
||||||
return _empty_response(
|
return _empty_response(
|
||||||
radius_km=request.radius_km,
|
radius_km=request.radius_km,
|
||||||
time_window=request.time_window,
|
time_window=request.time_window,
|
||||||
objects_total_in_radius=len(complex_groups),
|
objects_total_in_radius=groups_total_pre_filter,
|
||||||
raw_objects_total=raw_objects_total_in_radius,
|
raw_objects_total=raw_objects_total_in_radius,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -344,26 +344,21 @@ _COMPETITORS_SQL = text("""
|
||||||
-- важна — velocity питает forecast). Спатиальный join дёшев, т.к.
|
-- важна — velocity питает forecast). Спатиальный join дёшев, т.к.
|
||||||
-- complexes мал (~1.5k строк с geom) — НЕ потому что latest_obj
|
-- complexes мал (~1.5k строк с geom) — НЕ потому что latest_obj
|
||||||
-- пре-фильтрован (радиус участка применяется ниже, в финальном SELECT).
|
-- пре-фильтрован (радиус участка применяется ниже, в финальном SELECT).
|
||||||
--
|
|
||||||
-- #2962: complex → проект Объектива берём из complex_sources (source=
|
|
||||||
-- 'objective', строго 1:1), а НЕ из objective_lots.complex_id. Тот проставлен
|
|
||||||
-- один раз миграцией 76 на загрузке 10.05, а еженедельный
|
|
||||||
-- 70_parse_objective_raw.py UPSERT'ом по objective_lot_id переписывает
|
|
||||||
-- project_name и не трогает complex_id → под одним complex_id лежат чужие ЖК
|
|
||||||
-- (прод 17.09: 236 354 из 303 677 строк, у «ЖК Мичуринский» 129 проектов).
|
|
||||||
SELECT DISTINCT ON (o.obj_id)
|
SELECT DISTINCT ON (o.obj_id)
|
||||||
o.obj_id,
|
o.obj_id,
|
||||||
cs.source_id AS project_name,
|
c.id AS complex_id
|
||||||
-- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и
|
|
||||||
-- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин»,
|
|
||||||
-- «Парковый» ⊂ «Парковый квартал»)
|
|
||||||
( k.comm_key LIKE '%' || k.project_key || '%'
|
|
||||||
OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok
|
|
||||||
FROM latest_obj o
|
FROM latest_obj o
|
||||||
JOIN complexes c
|
JOIN complexes c
|
||||||
ON c.latitude IS NOT NULL
|
ON c.latitude IS NOT NULL
|
||||||
AND c.longitude IS NOT NULL
|
AND c.longitude IS NOT NULL
|
||||||
AND c.canonical_name IS NOT NULL
|
AND c.canonical_name IS NOT NULL
|
||||||
|
-- #968: только complexes, у которых ЕСТЬ objective velocity-данные →
|
||||||
|
-- «ближайший» выбирается среди data-bearing. Иначе ближайший complex
|
||||||
|
-- без objective_lots съедал бы матч (prod: +37 наивно → +181 так).
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM objective_lots ol
|
||||||
|
WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL
|
||||||
|
)
|
||||||
AND ST_DWithin(
|
AND ST_DWithin(
|
||||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
|
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
|
||||||
|
|
@ -374,20 +369,6 @@ _COMPETITORS_SQL = text("""
|
||||||
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
|
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
|
||||||
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
|
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
|
||||||
)
|
)
|
||||||
JOIN complex_sources cs
|
|
||||||
ON cs.complex_id = c.id
|
|
||||||
AND cs.source = 'objective'
|
|
||||||
-- #968: только complexes, у которых ЕСТЬ objective-данные →
|
|
||||||
-- «ближайший» выбирается среди data-bearing. Иначе ближайший complex
|
|
||||||
-- без objective_lots съедал бы матч (prod: +37 наивно → +181 так).
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM objective_lots ol
|
|
||||||
WHERE ol.project_name = cs.source_id
|
|
||||||
)
|
|
||||||
CROSS JOIN LATERAL (
|
|
||||||
SELECT regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key,
|
|
||||||
regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key
|
|
||||||
) k
|
|
||||||
WHERE o.comm_name IS NOT NULL
|
WHERE o.comm_name IS NOT NULL
|
||||||
AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё → spatial-only
|
AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё → spatial-only
|
||||||
AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping)
|
AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping)
|
||||||
|
|
@ -395,13 +376,7 @@ _COMPETITORS_SQL = text("""
|
||||||
ST_Distance(
|
ST_Distance(
|
||||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
|
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
|
||||||
) ASC,
|
) ASC
|
||||||
-- #2962: 1:1 complex → objective-проект держится только на данных
|
|
||||||
-- (UNIQUE(source, source_id) второй проект у complex не запрещает).
|
|
||||||
-- При двух проектах берём сверенный по имени, иначе DISTINCT ON
|
|
||||||
-- брал любой, сверка его отвергала, и верный терялся.
|
|
||||||
name_ok DESC,
|
|
||||||
cs.source_id
|
|
||||||
),
|
),
|
||||||
mapped AS (
|
mapped AS (
|
||||||
-- PRIMARY (unchanged): 148 явных маппингов — velocity для них byte-identical.
|
-- PRIMARY (unchanged): 148 явных маппингов — velocity для них byte-identical.
|
||||||
|
|
@ -409,18 +384,15 @@ _COMPETITORS_SQL = text("""
|
||||||
cm.objective_complex_name
|
cm.objective_complex_name
|
||||||
FROM objective_complex_mapping cm
|
FROM objective_complex_mapping cm
|
||||||
UNION
|
UNION
|
||||||
-- FALLBACK (gap-fill only): ровно один project_name ближайшего complex'а
|
-- FALLBACK (gap-fill only): все project_name ОДНОГО ближайшего complex'а
|
||||||
-- (корпуса у Объектива — corpus_name внутри проекта, не отдельные имена).
|
-- (у комплекса может быть несколько корпус-project_name → velocity
|
||||||
|
-- легитимно суммируется по ним, но НЕ по нескольким комплексам).
|
||||||
SELECT nc.obj_id,
|
SELECT nc.obj_id,
|
||||||
nc.project_name AS objective_complex_name
|
ol.project_name AS objective_complex_name
|
||||||
FROM nearest_cx nc
|
FROM nearest_cx nc
|
||||||
-- #2962: связь complex → проект в complex_sources почти вся fuzzy и не
|
JOIN objective_lots ol
|
||||||
-- проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит» —
|
ON ol.complex_id = nc.complex_id
|
||||||
-- 'Гранит'), поэтому имя проекта сверяем с именем объекта ДОМ.РФ ещё раз
|
AND ol.project_name IS NOT NULL
|
||||||
-- (name_ok в nearest_cx). Фильтр стоит ПОСЛЕ DISTINCT ON: внутри join
|
|
||||||
-- планировщик считал regexp на всех парах объект × проект (383k) раньше
|
|
||||||
-- гео-фильтра — 3 с против 0.4 с.
|
|
||||||
WHERE nc.name_ok
|
|
||||||
),
|
),
|
||||||
velocity AS (
|
velocity AS (
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -633,11 +605,9 @@ _SOLD_COUNT_SQL = text("""
|
||||||
# fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает
|
# fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает
|
||||||
# avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots:
|
# avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots:
|
||||||
# PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name
|
# PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name
|
||||||
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_sources
|
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_id →
|
||||||
# (source='objective', 1:1) → objective_lots по project_name (тот же DISTINCT ON
|
# objective_lots по complex_id (тот же DISTINCT ON ближайший complex, что и
|
||||||
# ближайший complex, что и velocity gap-fill — обязан совпадать, чтобы цена и
|
# velocity gap-fill — обязан совпадать, чтобы цена и velocity были про ОДИН ЖК).
|
||||||
# velocity были про ОДИН ЖК). НЕ по objective_lots.complex_id: он устарел и под
|
|
||||||
# ним лежат чужие ЖК (#2962, см. _COMPETITORS_SQL nearest_cx).
|
|
||||||
# obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ
|
# obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ
|
||||||
# mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен.
|
# mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен.
|
||||||
_OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
_OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
||||||
|
|
@ -654,17 +624,16 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
||||||
nearest_cx AS (
|
nearest_cx AS (
|
||||||
SELECT DISTINCT ON (o.obj_id)
|
SELECT DISTINCT ON (o.obj_id)
|
||||||
o.obj_id,
|
o.obj_id,
|
||||||
cs.source_id AS project_name,
|
c.id AS complex_id
|
||||||
-- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и
|
|
||||||
-- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин»,
|
|
||||||
-- «Парковый» ⊂ «Парковый квартал»)
|
|
||||||
( k.comm_key LIKE '%' || k.project_key || '%'
|
|
||||||
OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok
|
|
||||||
FROM domrf_kn_objects o
|
FROM domrf_kn_objects o
|
||||||
JOIN complexes c
|
JOIN complexes c
|
||||||
ON c.latitude IS NOT NULL
|
ON c.latitude IS NOT NULL
|
||||||
AND c.longitude IS NOT NULL
|
AND c.longitude IS NOT NULL
|
||||||
AND c.canonical_name IS NOT NULL
|
AND c.canonical_name IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM objective_lots ol
|
||||||
|
WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL
|
||||||
|
)
|
||||||
AND ST_DWithin(
|
AND ST_DWithin(
|
||||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
|
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
|
||||||
|
|
@ -675,17 +644,6 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
||||||
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
|
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
|
||||||
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
|
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
|
||||||
)
|
)
|
||||||
JOIN complex_sources cs
|
|
||||||
ON cs.complex_id = c.id
|
|
||||||
AND cs.source = 'objective'
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM objective_lots ol
|
|
||||||
WHERE ol.project_name = cs.source_id
|
|
||||||
)
|
|
||||||
CROSS JOIN LATERAL (
|
|
||||||
SELECT regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key,
|
|
||||||
regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key
|
|
||||||
) k
|
|
||||||
WHERE o.obj_id = ANY(:obj_ids)
|
WHERE o.obj_id = ANY(:obj_ids)
|
||||||
AND o.latitude IS NOT NULL
|
AND o.latitude IS NOT NULL
|
||||||
AND o.longitude IS NOT NULL
|
AND o.longitude IS NOT NULL
|
||||||
|
|
@ -696,13 +654,7 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
||||||
ST_Distance(
|
ST_Distance(
|
||||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
|
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
|
||||||
) ASC,
|
) ASC
|
||||||
-- #2962: 1:1 complex → objective-проект держится только на данных
|
|
||||||
-- (UNIQUE(source, source_id) второй проект у complex не запрещает).
|
|
||||||
-- При двух проектах берём сверенный по имени, иначе DISTINCT ON
|
|
||||||
-- брал любой, сверка его отвергала, и верный терялся.
|
|
||||||
name_ok DESC,
|
|
||||||
cs.source_id
|
|
||||||
),
|
),
|
||||||
gapfill_price AS (
|
gapfill_price AS (
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -710,10 +662,8 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
||||||
ol.price_per_m2_rub AS price_per_m2_rub
|
ol.price_per_m2_rub AS price_per_m2_rub
|
||||||
FROM nearest_cx nc
|
FROM nearest_cx nc
|
||||||
JOIN objective_lots ol
|
JOIN objective_lots ol
|
||||||
ON ol.project_name = nc.project_name
|
ON ol.complex_id = nc.complex_id
|
||||||
AND ol.price_per_m2_rub IS NOT NULL
|
AND ol.price_per_m2_rub IS NOT NULL
|
||||||
-- та же сверка имени, что в _COMPETITORS_SQL mapped (#2962)
|
|
||||||
WHERE nc.name_ok
|
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
p.obj_id,
|
p.obj_id,
|
||||||
|
|
|
||||||
|
|
@ -246,19 +246,22 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) ->
|
||||||
reserve_unit = 'МВт',
|
reserve_unit = 'МВт',
|
||||||
installed_capacity_mva = :installed,
|
installed_capacity_mva = :installed,
|
||||||
district = :district,
|
district = :district,
|
||||||
-- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки:
|
-- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки.
|
||||||
-- load_index — категориальная колонка, её заполняет
|
-- load_index — категориальная колонка
|
||||||
-- rosseti_wfs_loader._map_load_index. Историю см. в
|
-- ('open'|'limited'|'closed'|NULL, см.
|
||||||
-- git log этого файла.
|
-- data/sql/180_connection_capacity.sql:35), её
|
||||||
--
|
-- заполняет rosseti_wfs_loader._map_load_index.
|
||||||
-- ВАЖНО: в этом комментарии НЕЛЬЗЯ упоминать
|
-- Раньше тут стоял COALESCE(load_index,
|
||||||
-- бинд-параметры в синтаксисе «двоеточие + имя».
|
-- CAST(:load_pct AS text)) — при пустой ячейке
|
||||||
-- SQLAlchemy text() парсит бинды и внутри
|
-- в колонку легло бы число строкой ("72.5"),
|
||||||
-- SQL-комментариев: упоминание снятого параметра
|
-- а фронтовый classifyLoadIndex такое значение
|
||||||
-- «(двоеточие)load_pct» в тексте комментария
|
-- отбрасывает в null («неизвестно»), и в
|
||||||
-- сделало его ОБЯЗАТЕЛЬНЫМ, все 71 UPDATE падали
|
-- power_summary.by_load_index появился бы
|
||||||
-- с 18.08 по 02.09, а per-row except глотал это
|
-- бакет с именем "72.5".
|
||||||
-- как «битую строку» — задача оставалась зелёной.
|
-- Сегодня не стреляло только потому, что у всех
|
||||||
|
-- 3416 строк load_index уже заполнен
|
||||||
|
-- (open 2741 / limited 346 / closed 329, NULL 0)
|
||||||
|
-- и COALESCE не проваливался.
|
||||||
capacity_source = 'eesk_35_220',
|
capacity_source = 'eesk_35_220',
|
||||||
reserve_asof = :asof
|
reserve_asof = :asof
|
||||||
WHERE sc_name_norm = :name_norm
|
WHERE sc_name_norm = :name_norm
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,12 @@
|
||||||
"""Загрузчик OSM POI из Overpass API для site-finder.
|
"""Загрузчик OSM POI из Overpass API для site-finder.
|
||||||
|
|
||||||
Запускается раз в неделю через Celery beat (регион по умолчанию — ЕКБ, см. DEFAULT_REGION).
|
Запускается раз в неделю через Celery beat. Поддерживает фильтр
|
||||||
Поддерживает фильтр "не старше 2 лет" (требование Максима) — last_osm_edit_date.
|
"не старше 2 лет" (требование Максима) — last_osm_edit_date.
|
||||||
|
|
||||||
Параметризован регионом (REGION_BBOX) — sync_poi_to_db(region=...) может грузить любой
|
|
||||||
зарегистрированный bbox, не только ЕКБ; большие bbox автоматически режутся на тайлы
|
|
||||||
(_bbox_tiles), чтобы не упереться в лимиты одного Overpass-запроса.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -24,31 +19,6 @@ logger = logging.getLogger(__name__)
|
||||||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||||
EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east)
|
EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east)
|
||||||
|
|
||||||
# Прямоугольники продуктовых ядер по региону — источник загрузки POI больше не зашит
|
|
||||||
# в одну константу (было: только ЕКБ, блок «что рядом» молчал для остальных регионов).
|
|
||||||
# Site Finder — независимая половина монорепо со своим окружением/БД и НЕ импортирует
|
|
||||||
# tradein-mvp (у того свой реестр `app/services/regions.py`), поэтому bbox для Москвы
|
|
||||||
# продублирован явно, а не через кросс-импорт. Значение — bbox_product_core региона 77
|
|
||||||
# (tradein-mvp/backend/app/services/regions.py REGIONS[77]), пересчитанное в тот же
|
|
||||||
# (south, west, north, east) порядок, что EKB_BBOX выше. "ekb" остаётся значением по
|
|
||||||
# умолчанию ВЕЗДЕ (sync_poi_to_db / fetch_overpass) — существующее weekly-расписание
|
|
||||||
# (tasks.poi_sync.sync_osm_poi_ekb) не передаёт region и не должно молча сменить город.
|
|
||||||
REGION_BBOX: dict[str, tuple[float, float, float, float]] = {
|
|
||||||
"ekb": EKB_BBOX,
|
|
||||||
"msk": (55.55, 37.30, 55.95, 37.90), # (south, west, north, east)
|
|
||||||
}
|
|
||||||
DEFAULT_REGION = "ekb"
|
|
||||||
|
|
||||||
# Максимальный размер стороны ОДНОГО Overpass-запроса в градусах. У ЕКБ обе стороны
|
|
||||||
# bbox — ровно 0.25° (проверенный на практике размер: per-category запрос укладывается
|
|
||||||
# в timeout:30 без 504). Для региона с большей стороной bbox запрос режется на грид
|
|
||||||
# тайлов такого же порядка вместо одного большого — иначе на плотном городе (Москва на
|
|
||||||
# порядок плотнее ЕКБ по числу POI) Overpass либо отдаёт 504, либо (хуже) частично
|
|
||||||
# посчитанный ответ без явной ошибки, и загрузка молча обрежется. Для ЕКБ (0.25×0.25)
|
|
||||||
# тайлинг даёт РОВНО один тайл, совпадающий с EKB_BBOX бит-в-бит — поведение дефолтного
|
|
||||||
# региона не меняется.
|
|
||||||
MAX_TILE_SIDE_DEG = 0.25
|
|
||||||
|
|
||||||
# Маппинг набора OSM-тегов (все теги в кортеже должны совпасть — AND) → нормализованная
|
# Маппинг набора OSM-тегов (все теги в кортеже должны совпасть — AND) → нормализованная
|
||||||
# category. Каждая запись — один per-category Overpass-запрос (см. _build_overpass_query);
|
# category. Каждая запись — один per-category Overpass-запрос (см. _build_overpass_query);
|
||||||
# несколько записей с ОДИНАКОВЫМ значением category (как у metro_stop ниже) — это "ИЛИ" на
|
# несколько записей с ОДИНАКОВЫМ значением category (как у metro_stop ниже) — это "ИЛИ" на
|
||||||
|
|
@ -84,119 +54,17 @@ OSM_CATEGORIES: dict[tuple[tuple[str, str], ...], str] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _bbox_tiles(
|
def _build_overpass_query(tag_filters: tuple[tuple[str, str], ...]) -> str:
|
||||||
bbox: tuple[float, float, float, float], max_side_deg: float = MAX_TILE_SIDE_DEG
|
"""Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND).
|
||||||
) -> list[tuple[float, float, float, float]]:
|
|
||||||
"""Разбить bbox на равномерный грид тайлов со стороной ≤ max_side_deg.
|
|
||||||
|
|
||||||
(south, west, north, east) → список тайлов того же формата. Для bbox, у которого обе
|
|
||||||
стороны уже ≤ max_side_deg (текущий EKB_BBOX: 0.25×0.25), возвращает список ровно из
|
|
||||||
ОДНОГО тайла, идентичного входному bbox — тайлинг не меняет поведение для ЕКБ.
|
|
||||||
"""
|
|
||||||
south, west, north, east = bbox
|
|
||||||
rows = max(1, math.ceil(round((north - south) / max_side_deg, 6)))
|
|
||||||
cols = max(1, math.ceil(round((east - west) / max_side_deg, 6)))
|
|
||||||
lat_step = (north - south) / rows
|
|
||||||
lon_step = (east - west) / cols
|
|
||||||
tiles = []
|
|
||||||
for r in range(rows):
|
|
||||||
for c in range(cols):
|
|
||||||
tiles.append(
|
|
||||||
(
|
|
||||||
south + r * lat_step,
|
|
||||||
west + c * lon_step,
|
|
||||||
south + (r + 1) * lat_step,
|
|
||||||
west + (c + 1) * lon_step,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return tiles
|
|
||||||
|
|
||||||
|
|
||||||
def _split_bbox_quadrants(
|
|
||||||
bbox: tuple[float, float, float, float],
|
|
||||||
) -> list[tuple[float, float, float, float]]:
|
|
||||||
"""Разбить bbox на 4 равные четверти (2×2) — используется адаптивным ретраем
|
|
||||||
_fetch_category, когда сам тайл всё равно оказался слишком тяжёлым для Overpass."""
|
|
||||||
south, west, north, east = bbox
|
|
||||||
mid_lat = (south + north) / 2
|
|
||||||
mid_lon = (west + east) / 2
|
|
||||||
return [
|
|
||||||
(south, west, mid_lat, mid_lon),
|
|
||||||
(south, mid_lon, mid_lat, east),
|
|
||||||
(mid_lat, west, north, mid_lon),
|
|
||||||
(mid_lat, mid_lon, north, east),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# Живой замер 2026-09-13: uniform-тайл 0.25×0.25 (размер ЕКБ) для category=bus_stop в
|
|
||||||
# Москве отдал 504 Gateway Timeout ОДНИМ тайлом (12 181 bus_stop во всём продуктовом
|
|
||||||
# ядре — уже больше, чем ВСЕ 14 категорий ЕКБ вместе, 4 850). Единый "правильный" размер
|
|
||||||
# тайла под все 14 категорий Москвы заранее не подобрать — плотность по городу сильно
|
|
||||||
# неравномерна (плотный центр / разреженная периферия), а у Overpass нет заголовка с
|
|
||||||
# "это частичный ответ" — единственный надёжный сигнал перегруза — HTTP-ошибка/таймаут.
|
|
||||||
# Поэтому вместо фиксированного маленького тайла — АДАПТИВНОЕ дробление: тайл, на
|
|
||||||
# котором per-category запрос дважды падает, дробится на 4 четверти и каждая
|
|
||||||
# перезапрашивается рекурсивно (до RECURSIVE_SPLIT_MAX_DEPTH). Для ЕКБ recursion
|
|
||||||
# НИКОГДА не срабатывает (единственный тайл исторически всегда отвечал 200) — поведение
|
|
||||||
# дефолтного региона не меняется.
|
|
||||||
RECURSIVE_SPLIT_MAX_DEPTH = 3
|
|
||||||
|
|
||||||
# Дробить тайл имеет смысл ТОЛЬКО когда сервер отказал из-за тяжести запроса:
|
|
||||||
# 504/429/503 и таймаут чтения — «я не успел посчитать», четверть посчитается.
|
|
||||||
# Отказ на уровне транспорта (connection refused / network unreachable) про размер
|
|
||||||
# запроса не говорит ВООБЩЕ: хост нас не принимает, и дробление превращает один
|
|
||||||
# отказ в 4, 16, 64 повторных стука. Живой случай 15.09.2026: загрузка Москвы
|
|
||||||
# поймала блокировку overpass-api.de по IP и за три минуты выдала 58 отказов на
|
|
||||||
# 4 успеха — ровно этот механизм.
|
|
||||||
_SPLIT_WORTHY_STATUS = frozenset({429, 503, 504})
|
|
||||||
|
|
||||||
# Подряд идущие транспортные отказы = хост нас не принимает. Продолжать прогон
|
|
||||||
# бессмысленно и вредно (углубляем блокировку), поэтому после порога — стоп всего
|
|
||||||
# прогона с явной ошибкой, а не тихий пропуск категорий.
|
|
||||||
_MAX_CONSECUTIVE_TRANSPORT_ERRORS = 5
|
|
||||||
|
|
||||||
|
|
||||||
class OverpassUnreachableError(RuntimeError):
|
|
||||||
"""Overpass отказывает на уровне соединения подряд — прогон остановлен."""
|
|
||||||
|
|
||||||
|
|
||||||
class _RunState:
|
|
||||||
"""Счётчик подряд идущих транспортных отказов в рамках одного fetch_overpass."""
|
|
||||||
|
|
||||||
__slots__ = ("consecutive_transport_errors",)
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.consecutive_transport_errors = 0
|
|
||||||
|
|
||||||
|
|
||||||
def _is_overload(exc: Exception) -> bool:
|
|
||||||
"""True, если отказ говорит «запрос слишком тяжёлый» (есть смысл дробить)."""
|
|
||||||
if isinstance(exc, httpx.TimeoutException):
|
|
||||||
return True
|
|
||||||
if isinstance(exc, httpx.HTTPStatusError):
|
|
||||||
return exc.response.status_code in _SPLIT_WORTHY_STATUS
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _is_transport_error(exc: Exception) -> bool:
|
|
||||||
"""True для отказа на уровне соединения (хост не принимает), не про размер запроса."""
|
|
||||||
return isinstance(exc, httpx.TransportError) and not isinstance(exc, httpx.TimeoutException)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_overpass_query(
|
|
||||||
tag_filters: tuple[tuple[str, str], ...], bbox: tuple[float, float, float, float]
|
|
||||||
) -> str:
|
|
||||||
"""Запрос для ОДНОЙ комбинации tag=value (обычно один тег, иногда несколько — все AND)
|
|
||||||
в ОДНОМ тайле bbox (south, west, north, east) — см. _bbox_tiles.
|
|
||||||
|
|
||||||
Раньше делали один большой запрос на все 14 категорий — Overpass возвращал
|
Раньше делали один большой запрос на все 14 категорий — Overpass возвращал
|
||||||
504 Gateway Timeout (запрос слишком тяжёлый). Сплит на per-category даёт
|
504 Gateway Timeout (запрос слишком тяжёлый). Сплит на per-category даёт
|
||||||
быстрые запросы вместо одного 60+ секундного.
|
быстрые запросы вместо одного 60+ секундного.
|
||||||
"""
|
"""
|
||||||
south, west, north, east = bbox
|
south, west, north, east = EKB_BBOX
|
||||||
bbox_str = f"({south},{west},{north},{east})"
|
bbox = f"({south},{west},{north},{east})"
|
||||||
filt = "".join(f'["{k}"="{v}"]' for k, v in tag_filters)
|
filt = "".join(f'["{k}"="{v}"]' for k, v in tag_filters)
|
||||||
return f"[out:json][timeout:30];(node{filt}{bbox_str};way{filt}{bbox_str};);out center meta;"
|
return f"[out:json][timeout:30];(node{filt}{bbox};way{filt}{bbox};);out center meta;"
|
||||||
|
|
||||||
|
|
||||||
def _classify(tags: dict[str, str]) -> str | None:
|
def _classify(tags: dict[str, str]) -> str | None:
|
||||||
|
|
@ -212,117 +80,55 @@ def _tag_filters_desc(tag_filters: tuple[tuple[str, str], ...]) -> str:
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_category(
|
async def _fetch_category(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient, tag_filters: tuple[tuple[str, str], ...], category: str
|
||||||
tag_filters: tuple[tuple[str, str], ...],
|
|
||||||
category: str,
|
|
||||||
bbox: tuple[float, float, float, float],
|
|
||||||
state: _RunState,
|
|
||||||
depth: int = 0,
|
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Один per-category Overpass-запрос (для ОДНОГО тайла bbox) с ОДНИМ повтором при
|
"""Один per-category Overpass-запрос с ОДНИМ повтором при транзиентной ошибке.
|
||||||
транзиентной ошибке; если тайл падает оба раза — адаптивно дробится на 4 четверти
|
|
||||||
(см. RECURSIVE_SPLIT_MAX_DEPTH) и перезапрашивается рекурсивно, вместо того чтобы
|
|
||||||
тихо потерять весь тайл.
|
|
||||||
|
|
||||||
Fix (location-index rework, "не потерялись крупные категории"): раньше единственная
|
Fix (location-index rework, "не потерялись крупные категории"): раньше единственная
|
||||||
неудача (таймаут / 504) на всю неделю обнуляла категорию целиком (следующая попытка —
|
неудача (таймаут / 504) на всю неделю обнуляла категорию целиком (следующая попытка —
|
||||||
только на следующем weekly run). Один retry с паузой снимает большую часть транзиентных
|
только на следующем weekly run). Один retry с паузой снимает большую часть транзиентных
|
||||||
сбоев без риска зациклиться (Overpass rate-limit — max 2 concurrent, поэтому не более
|
сбоев без риска зациклиться (Overpass rate-limit — max 2 concurrent, поэтому не более
|
||||||
2 попыток на тайл ДО дробления).
|
2 попыток на категорию).
|
||||||
"""
|
"""
|
||||||
tag_desc = _tag_filters_desc(tag_filters)
|
tag_desc = _tag_filters_desc(tag_filters)
|
||||||
query = _build_overpass_query(tag_filters, bbox)
|
query = _build_overpass_query(tag_filters)
|
||||||
for attempt in (1, 2):
|
for attempt in (1, 2):
|
||||||
try:
|
try:
|
||||||
r = await client.post(OVERPASS_URL, data={"data": query})
|
r = await client.post(OVERPASS_URL, data={"data": query})
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
elements: list[dict] = r.json().get("elements", [])
|
elements: list[dict] = r.json().get("elements", [])
|
||||||
logger.info(
|
logger.info(
|
||||||
"Overpass: %s (%s) bbox=%s depth=%d → %d [attempt %d]",
|
"Overpass: %s (%s) → %d [attempt %d]", tag_desc, category, len(elements), attempt
|
||||||
tag_desc,
|
|
||||||
category,
|
|
||||||
bbox,
|
|
||||||
depth,
|
|
||||||
len(elements),
|
|
||||||
attempt,
|
|
||||||
)
|
)
|
||||||
# Привязываем category именно к тому per-category запросу, под который
|
# Привязываем category именно к тому per-category запросу, под который
|
||||||
# элемент реально пришёл. Элемент с двумя целевыми тегами (например
|
# элемент реально пришёл. Элемент с двумя целевыми тегами (например
|
||||||
# amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия
|
# amenity=pharmacy + shop=supermarket) приходит дважды — каждая копия
|
||||||
# несёт свою category. Иначе _classify по dict-порядку молча терял бы
|
# несёт свою category. Иначе _classify по dict-порядку молча терял бы
|
||||||
# вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372.
|
# вторую категорию при UPSERT по UNIQUE(osm_type, osm_id, category). См. #1372.
|
||||||
state.consecutive_transport_errors = 0
|
|
||||||
for el in elements:
|
for el in elements:
|
||||||
el["_gd_category"] = category
|
el["_gd_category"] = category
|
||||||
return elements
|
return elements
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if _is_transport_error(e):
|
|
||||||
state.consecutive_transport_errors += 1
|
|
||||||
if state.consecutive_transport_errors >= _MAX_CONSECUTIVE_TRANSPORT_ERRORS:
|
|
||||||
raise OverpassUnreachableError(
|
|
||||||
f"Overpass отказывает на уровне соединения "
|
|
||||||
f"{state.consecutive_transport_errors} раз подряд ({e}) — прогон "
|
|
||||||
f"остановлен, чтобы не стучаться в блокирующий хост"
|
|
||||||
) from e
|
|
||||||
logger.warning(
|
|
||||||
"Overpass transport error for %s bbox=%s (подряд %d) — тайл пропущен "
|
|
||||||
"без дробления: %s",
|
|
||||||
tag_desc,
|
|
||||||
bbox,
|
|
||||||
state.consecutive_transport_errors,
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
state.consecutive_transport_errors = 0
|
|
||||||
if attempt == 1:
|
if attempt == 1:
|
||||||
logger.warning("Overpass failed for %s (attempt 1, retrying): %s", tag_desc, e)
|
logger.warning("Overpass failed for %s (attempt 1, retrying): %s", tag_desc, e)
|
||||||
await asyncio.sleep(3.0)
|
await asyncio.sleep(3.0)
|
||||||
continue
|
continue
|
||||||
if _is_overload(e) and depth < RECURSIVE_SPLIT_MAX_DEPTH:
|
|
||||||
logger.warning(
|
|
||||||
"Overpass failed for %s bbox=%s twice — splitting into 4 quadrants "
|
|
||||||
"(depth %d→%d) instead of dropping the tile: %s",
|
|
||||||
tag_desc,
|
|
||||||
bbox,
|
|
||||||
depth,
|
|
||||||
depth + 1,
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
combined: list[dict] = []
|
|
||||||
for quadrant in _split_bbox_quadrants(bbox):
|
|
||||||
combined.extend(
|
|
||||||
await _fetch_category(
|
|
||||||
client, tag_filters, category, quadrant, state, depth + 1
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
return combined
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Overpass failed for %s bbox=%s at max split depth %d — tile skipped this run: %s",
|
"Overpass failed for %s after retry — category skipped this run: %s", tag_desc, e
|
||||||
tag_desc,
|
|
||||||
bbox,
|
|
||||||
depth,
|
|
||||||
e,
|
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def fetch_overpass(region: str = DEFAULT_REGION) -> list[dict]:
|
async def fetch_overpass() -> list[dict]:
|
||||||
"""Запросить Overpass API per category × per tile, вернуть combined список elements.
|
"""Запросить Overpass API per category, вернуть combined список elements.
|
||||||
|
|
||||||
Делаем отдельные запросы вместо одного гигантского — большой запрос
|
Делаем отдельные запросы вместо одного гигантского — большой запрос
|
||||||
отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage
|
отдаёт 504 Gateway Timeout. Между запросами sleep 1с (Overpass usage
|
||||||
policy: max 2 concurrent, лучше 1 req/s). bbox региона режется на тайлы
|
policy: max 2 concurrent, лучше 1 req/s).
|
||||||
≤ MAX_TILE_SIDE_DEG (_bbox_tiles) — для "ekb" это ровно один тайл (без
|
|
||||||
изменения поведения), для регионов с большим bbox (напр. "msk") — несколько,
|
|
||||||
чтобы не поймать 504 или тихо обрезанный ответ на плотном городе.
|
|
||||||
|
|
||||||
Overpass блокирует default `python-httpx/*` User-Agent (406) — поэтому
|
Overpass блокирует default `python-httpx/*` User-Agent (406) — поэтому
|
||||||
явный UA с контактом проекта.
|
явный UA с контактом проекта.
|
||||||
"""
|
"""
|
||||||
bbox = REGION_BBOX[region]
|
|
||||||
tiles = _bbox_tiles(bbox)
|
|
||||||
state = _RunState()
|
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": "GenDesign-SiteFinder/1.0 (+https://gendsgn.ru)",
|
"User-Agent": "GenDesign-SiteFinder/1.0 (+https://gendsgn.ru)",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
|
|
@ -330,34 +136,24 @@ async def fetch_overpass(region: str = DEFAULT_REGION) -> list[dict]:
|
||||||
all_elements: list[dict] = []
|
all_elements: list[dict] = []
|
||||||
async with httpx.AsyncClient(timeout=60, headers=headers) as client:
|
async with httpx.AsyncClient(timeout=60, headers=headers) as client:
|
||||||
for tag_filters, category in OSM_CATEGORIES.items():
|
for tag_filters, category in OSM_CATEGORIES.items():
|
||||||
for tile in tiles:
|
elements = await _fetch_category(client, tag_filters, category)
|
||||||
elements = await _fetch_category(client, tag_filters, category, tile, state)
|
all_elements.extend(elements)
|
||||||
all_elements.extend(elements)
|
await asyncio.sleep(1.0)
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Overpass region=%s: total %d elements across %d category-queries × %d tiles",
|
"Overpass: total %d elements across %d category-queries",
|
||||||
region,
|
|
||||||
len(all_elements),
|
len(all_elements),
|
||||||
len(OSM_CATEGORIES),
|
len(OSM_CATEGORIES),
|
||||||
len(tiles),
|
|
||||||
)
|
)
|
||||||
return all_elements
|
return all_elements
|
||||||
|
|
||||||
|
|
||||||
def sync_poi_to_db(region: str = DEFAULT_REGION) -> dict[str, int]:
|
def sync_poi_to_db() -> dict[str, int]:
|
||||||
"""Синхронизирует POI из Overpass в osm_poi_ekb для одного региона.
|
"""Синхронизирует POI из Overpass в osm_poi_ekb.
|
||||||
|
|
||||||
region — ключ REGION_BBOX ("ekb" по умолчанию, сохраняет старое поведение
|
|
||||||
weekly-расписания). Имя таблицы osm_poi_ekb — историческое (изначально
|
|
||||||
ЕКБ-only); таблица читается ещё в двух местах вне Site Finder (FDW-таблица
|
|
||||||
gendesign_osm_poi_ekb + локальное зеркало osm_poi_ekb_local в tradein), поэтому
|
|
||||||
НЕ переименована: переименование потянуло бы миграции в обеих половинах
|
|
||||||
монорепо (FDW-объект + зеркало + их индексы) ради косметики.
|
|
||||||
|
|
||||||
UPSERT по UNIQUE(osm_type, osm_id, category).
|
UPSERT по UNIQUE(osm_type, osm_id, category).
|
||||||
Returns: counters {fetched, inserted, updated, skipped_old}.
|
Returns: counters {fetched, inserted, updated, skipped_old}.
|
||||||
"""
|
"""
|
||||||
elements = asyncio.run(fetch_overpass(region))
|
elements = asyncio.run(fetch_overpass())
|
||||||
# 730 дней ≈ 2 года: избегаем ValueError 29 февраля (year-2 не високосный → нет 29.02).
|
# 730 дней ≈ 2 года: избегаем ValueError 29 февраля (year-2 не високосный → нет 29.02).
|
||||||
# Точность ±1 день несущественна для фильтра "не старше 2 лет" (требование Максима). См. #1232.
|
# Точность ±1 день несущественна для фильтра "не старше 2 лет" (требование Максима). См. #1232.
|
||||||
two_years_ago = date.today() - timedelta(days=730)
|
two_years_ago = date.today() - timedelta(days=730)
|
||||||
|
|
|
||||||
|
|
@ -329,7 +329,7 @@ def build_beat_schedule() -> dict:
|
||||||
# вывел PKK6-эндпоинт), таблица pzz_zones_ekb пуста (0 rows на prod) — задача ни
|
# вывел PKK6-эндпоинт), таблица pzz_zones_ekb пуста (0 rows на prod) — задача ни
|
||||||
# разу не наполнила её успешно. ПЗЗ-данные пришли в систему ИНЫМ путём:
|
# разу не наполнила её успешно. ПЗЗ-данные пришли в систему ИНЫМ путём:
|
||||||
# zone_regulation_cache (#1059, beat zone-regulation-refresh-monthly) + NSPD
|
# zone_regulation_cache (#1059, beat zone-regulation-refresh-monthly) + NSPD
|
||||||
# territorial_zones в nspd_quarter_dumps (nspd_sync, layer 875838). Активный beat
|
# territorial_zones dumps (Phase 2.5 bulk_harvest, layer 875838). Активный beat
|
||||||
# каждый месяц дёргал deprecated PKK6 → broad except в pzz_sync.py логировал
|
# каждый месяц дёргал deprecated PKK6 → broad except в pzz_sync.py логировал
|
||||||
# ошибку как error → рекуррентил GlitchTip BACKEND-1B.
|
# ошибку как error → рекуррентил GlitchTip BACKEND-1B.
|
||||||
#
|
#
|
||||||
|
|
@ -346,13 +346,11 @@ def build_beat_schedule() -> dict:
|
||||||
# Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта
|
# Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта
|
||||||
# за ~5 недель полного обновления.
|
# за ~5 недель полного обновления.
|
||||||
#
|
#
|
||||||
# DISABLED с 2026-05-24. Исходная причина «hard-ban, cooldown 24-48h» оказалась
|
# DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed
|
||||||
# догадкой: kn-прогоны 29-33 (03.06-28.06) шли успешно уже после 24.05. Зонды
|
# extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession
|
||||||
# 20.08, 27.08 и 01.09 (#2443, #3307): наш.дом.рф стоит за StormWall (куки
|
# + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 МСК) насыпет
|
||||||
# spid/spjs/spsc), IP Poincare получает «Доступ заблокирован [403]» — это
|
# 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после
|
||||||
# блокировка, ожидание её не снимает. Каталог не собирался с 19.05.
|
# cooldown 24-48h (проверить через targeted test).
|
||||||
# Включать только после решения по прокси и принятого по числу строк
|
|
||||||
# kn-прогона (#3307). Выключенность держит test_beat_schedule_domrf_catalog.py.
|
|
||||||
# schedule["scrape-kn-catalog-objects-weekly"] = {
|
# schedule["scrape-kn-catalog-objects-weekly"] = {
|
||||||
# "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
|
# "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
|
||||||
# "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК
|
# "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК
|
||||||
|
|
@ -364,11 +362,11 @@ def build_beat_schedule() -> dict:
|
||||||
# (цена/статус/отделка/потолки/дата обновления) + plan-изображения квартир из
|
# (цена/статус/отделка/потолки/дата обновления) + plan-изображения квартир из
|
||||||
# SSR-страниц каталога. Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL.
|
# SSR-страниц каталога. Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL.
|
||||||
#
|
#
|
||||||
# DISABLED (как и object-level выше): 1) та же блокировка StormWall на IP
|
# DISABLED (как и object-level выше): 1) тот же DOM.РФ WAF hard-ban на VPS IP —
|
||||||
# Poincare — каталог-квартиры используют тот же BrowserSession + /сервисы/* paths.
|
# каталог-квартиры используют тот же BrowserSession + /сервисы/* paths.
|
||||||
# 2) Sequencing: пока #2442 Task 1 (elemId → catalog_url_hash) не задеплоен и
|
# 2) Sequencing: пока #2442 Task 1 (elemId → catalog_url_hash) не задеплоен и
|
||||||
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
|
# свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
|
||||||
# Включать после прокси и принятого kn-прогона с hash (#3307), не по таймеру.
|
# Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом).
|
||||||
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
|
# Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
|
||||||
# напр. четверг 04:00 МСК.
|
# напр. четверг 04:00 МСК.
|
||||||
# schedule["scrape-kn-catalog-flats-weekly"] = {
|
# schedule["scrape-kn-catalog-flats-weekly"] = {
|
||||||
|
|
|
||||||
|
|
@ -110,25 +110,6 @@ _UPSERT_NO_ACT_SQL = text(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Прежний разбор того же участка из ТОГО ЖЕ документа, но с другим номером акта (#2982).
|
|
||||||
#
|
|
||||||
# act_number входит в ключ конфликта, поэтому, когда разбор номера меняется, новая
|
|
||||||
# строка с ним не конфликтует со старой и ложится рядом. Так и было бы при починке
|
|
||||||
# регекса номера: 27 строк на проде записаны с act_number IS NULL, следующий прогон
|
|
||||||
# добавил бы 27 таких же с номером, а reservation_lookup показал бы каждое изъятие
|
|
||||||
# дважды. Один документ = один акт (номер разбирается один раз на весь PDF), значит
|
|
||||||
# строка с тем же (cad_num, doc_url) и другим номером — устаревший разбор той же
|
|
||||||
# записи, а не второе основание. Участок в двух РАЗНЫХ документах не затрагивается.
|
|
||||||
_DELETE_STALE_PARSE_SQL = text(
|
|
||||||
"""
|
|
||||||
DELETE FROM land_reservation
|
|
||||||
WHERE cad_num = CAST(:cad_num AS text)
|
|
||||||
AND doc_url = CAST(:doc_url AS text)
|
|
||||||
AND act_number IS DISTINCT FROM CAST(:act_number AS text)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
|
def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
|
||||||
"""UPSERT записей в land_reservation. Возвращает число успешно обработанных строк."""
|
"""UPSERT записей в land_reservation. Возвращает число успешно обработанных строк."""
|
||||||
count = 0
|
count = 0
|
||||||
|
|
@ -136,7 +117,6 @@ def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
|
||||||
upsert_sql = _UPSERT_WITH_ACT_SQL if row.get("act_number") else _UPSERT_NO_ACT_SQL
|
upsert_sql = _UPSERT_WITH_ACT_SQL if row.get("act_number") else _UPSERT_NO_ACT_SQL
|
||||||
try:
|
try:
|
||||||
with db.begin_nested(): # SAVEPOINT per-row
|
with db.begin_nested(): # SAVEPOINT per-row
|
||||||
db.execute(_DELETE_STALE_PARSE_SQL, row)
|
|
||||||
db.execute(upsert_sql, row)
|
db.execute(upsert_sql, row)
|
||||||
count += 1
|
count += 1
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,8 @@ Sequencing dependency (важно): эта задача НЕ дублирует
|
||||||
1. #2442 Task 1 (elemId → catalog_url_hash) задеплоен, И
|
1. #2442 Task 1 (elemId → catalog_url_hash) задеплоен, И
|
||||||
2. прошёл свежий kn-sweep, наполнивший catalog_url_hash на новых snapshot'ах.
|
2. прошёл свежий kn-sweep, наполнивший catalog_url_hash на новых snapshot'ах.
|
||||||
До этого SELECT вернёт 0 строк (см. выше). Beat-entry намеренно оставлен
|
До этого SELECT вернёт 0 строк (см. выше). Beat-entry намеренно оставлен
|
||||||
ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level): наш.дом.рф за
|
ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level) — WAF-cooldown на
|
||||||
StormWall отдаёт «Доступ заблокирован [403]» (#2443), ожидание это не снимает;
|
VPS IP; включается вручную после проверки targeted-тестом.
|
||||||
включать после прокси и принятого kn-прогона (#3307).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -117,7 +116,7 @@ def scrape_kn_catalog_flats(
|
||||||
|
|
||||||
Concurrency:
|
Concurrency:
|
||||||
No Redis lock — consistent с sibling tasks (scrape_kn_catalog_objects и т.д.).
|
No Redis lock — consistent с sibling tasks (scrape_kn_catalog_objects и т.д.).
|
||||||
Beat отключён (блокировка StormWall, #2443); при ручном/ad-hoc запуске пересечение крайне
|
Beat отключён (WAF cooldown); при ручном/ad-hoc запуске пересечение крайне
|
||||||
маловероятно. Если случится:
|
маловероятно. Если случится:
|
||||||
- UPDATE идемпотентен (COALESCE в upsert_catalog_data, WHERE ods_id).
|
- UPDATE идемпотентен (COALESCE в upsert_catalog_data, WHERE ods_id).
|
||||||
- Max risk: 2x WAF-нагрузка на DOM.РФ для того же batch.
|
- Max risk: 2x WAF-нагрузка на DOM.РФ для того же batch.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ Selector logic:
|
||||||
- DATE(catalog_scraped_at) = CURRENT_DATE — уже сегодня обновлён, пропускаем
|
- DATE(catalog_scraped_at) = CURRENT_DATE — уже сегодня обновлён, пропускаем
|
||||||
- force=True — игнорирует фильтр, загружает все объекты последнего snapshot
|
- force=True — игнорирует фильтр, загружает все объекты последнего snapshot
|
||||||
|
|
||||||
Beat-запись (вторник 04:00 МСК) выключена в beat_schedule.py: блокировка StormWall (#2443).
|
Beat schedule: вторник 04:00 UTC (в beat_schedule.py).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -17,22 +17,12 @@ Analyze-тесты с ПОЗИЦИОННЫМ DB-моком (``_make_db_for_analy
|
||||||
(``test_analyze_zoning_regulation.py``), переопределяют этот же target своим
|
(``test_analyze_zoning_regulation.py``), переопределяют этот же target своим
|
||||||
per-test ``patch`` — он применяется ПОВЕРХ авто-фикстуры (вложенный mock-scope), так
|
per-test ``patch`` — он применяется ПОВЕРХ авто-фикстуры (вложенный mock-scope), так
|
||||||
что их ожидаемые значения резолвера сохраняются.
|
что их ожидаемые значения резолвера сохраняются.
|
||||||
|
|
||||||
Perf-fix (2026-09-12): в конце ``analyze_parcel`` — безусловный best-effort
|
|
||||||
``forecast_site_finder_report.delay(...)`` (§22-форсайт enqueue, см. app/api/v1/parcels.py).
|
|
||||||
В песочнице тестов Celery-брокер (Redis) недоступен → ``.delay()`` синхронно ждёт
|
|
||||||
kombu-реконнект с растущим backoff (~69с) ДО того как try/except его проглотит —
|
|
||||||
эта пауза оказалась внутри КАЖДОГО теста, который дергает ``POST /analyze`` и не
|
|
||||||
мокал форсайт-таску. Авто-фикстура ниже глушит ``.delay`` в no-op-мок для ВСЕХ
|
|
||||||
тестов каталога (как и с резолвером выше) — тесты самого enqueue
|
|
||||||
(``test_parcels_forecast.py``, ``test_run_history_and_response_contract.py``)
|
|
||||||
переопределяют тот же target своим per-test ``patch`` поверх авто-фикстуры.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -47,34 +37,3 @@ def _stub_zone_regulation_resolver() -> Iterator[None]:
|
||||||
"""
|
"""
|
||||||
with patch("app.api.v1.parcels.get_or_fetch_zone_regulation", return_value=None):
|
with patch("app.api.v1.parcels.get_or_fetch_zone_regulation", return_value=None):
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _stub_forecast_enqueue() -> Iterator[None]:
|
|
||||||
"""No-op форсайт-enqueue по умолчанию (без реального Celery/Redis round-trip).
|
|
||||||
|
|
||||||
``.delay(...)`` в проде — fire-and-forget (best-effort, обёрнут в try/except в
|
|
||||||
``analyze_parcel``), тестам сам форсайт не нужен, а живой брокер в CI/локальной
|
|
||||||
песочнице недоступен и держит запрос ~69с на реконнект-backoff.
|
|
||||||
"""
|
|
||||||
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", MagicMock()):
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _fast_inline_fetch_wait(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""Схлопнуть inline-ожидание NSPD-фетча (#93 graceful fallback) до миллисекунд.
|
|
||||||
|
|
||||||
В ``analyze_parcel`` ветка «участка нет в БД» ждёт появления геометрии циклом
|
|
||||||
``sleep(_INLINE_FETCH_POLL_INTERVAL_S)`` до ``_INLINE_FETCH_WAIT_S`` (15с прод-
|
|
||||||
значение). В тестах фетч замокан и геометрия не появится никогда → каждый такой
|
|
||||||
тест честно спал 16с (``test_market_price_invalid_cad_returns_404``,
|
|
||||||
``test_recent_permits_invalid_cad_no_regression``).
|
|
||||||
|
|
||||||
Оставляем цикл РАБОЧИМ (несколько итераций по 10мс), а не выключаем его нулём:
|
|
||||||
тесты, проверяющие сам fast-path «строка появилась на N-м опросе», продолжают
|
|
||||||
видеть опросы. Тесты с собственным ``patch`` того же имени (напр.
|
|
||||||
``test_run_history_and_response_contract.py``) переопределяют это поверх.
|
|
||||||
"""
|
|
||||||
monkeypatch.setattr("app.api.v1.parcels._INLINE_FETCH_WAIT_S", 0.05)
|
|
||||||
monkeypatch.setattr("app.api.v1.parcels._INLINE_FETCH_POLL_INTERVAL_S", 0.01)
|
|
||||||
|
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
"""Гео-радиусная цена участка берёт лоты проектов ближних ЖК, а не всё под complex_id (#3583).
|
|
||||||
|
|
||||||
`objective_lots.complex_id` проставлен один раз миграцией 76, а еженедельный
|
|
||||||
`70_parse_objective_raw.py` UPSERT'ом по objective_lot_id переписывает project_name и
|
|
||||||
не трогает complex_id: под id ближнего ЖК лежат лоты чужих. Тот же дефект, что #2962
|
|
||||||
в competitors.py.
|
|
||||||
|
|
||||||
Тест герметичный и прогоняет НАСТОЯЩИЙ `_GEO_RADIUS_PRICE_SQL`: временные таблицы
|
|
||||||
затеняют боевые в пределах сессии. Нужен Postgres с PostGIS (ST_DWithin по geography).
|
|
||||||
В CI он есть, и там тест не пропускается: без PostGIS падает с настоящей причиной.
|
|
||||||
Пропуск разрешён только вне CI и объявлен в skip_allowlist.txt.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy import create_engine, text
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
|
|
||||||
def _dsn() -> str:
|
|
||||||
raw = os.environ.get("TEST_DATABASE_URL") or os.environ["DATABASE_URL"]
|
|
||||||
return (
|
|
||||||
raw
|
|
||||||
if raw.startswith("postgresql+")
|
|
||||||
else raw.replace("postgresql://", "postgresql+psycopg://")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _postgis_reachable() -> tuple[bool, str]:
|
|
||||||
try:
|
|
||||||
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
|
|
||||||
with eng.connect() as c:
|
|
||||||
if c.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'")).first():
|
|
||||||
return True, ""
|
|
||||||
return False, "нет расширения postgis"
|
|
||||||
except Exception as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
|
|
||||||
|
|
||||||
_DB_OK, _DB_ERR = _postgis_reachable()
|
|
||||||
_IN_CI = bool(os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI"))
|
|
||||||
pytestmark = pytest.mark.skipif(
|
|
||||||
not _DB_OK and not _IN_CI, reason=f"Postgres/PostGIS недоступен: {_DB_ERR}"
|
|
||||||
)
|
|
||||||
|
|
||||||
_SCHEMA = [
|
|
||||||
"""CREATE TEMP TABLE complexes (
|
|
||||||
id bigint, canonical_name text, latitude double precision,
|
|
||||||
longitude double precision) ON COMMIT DROP""",
|
|
||||||
"""CREATE TEMP TABLE complex_sources (
|
|
||||||
complex_id bigint, source text, source_id text) ON COMMIT DROP""",
|
|
||||||
"""CREATE TEMP TABLE objective_lots (
|
|
||||||
id bigint, project_name text, corpus_name text, section text, floor int,
|
|
||||||
lot_number text, snapshot_date date, premise_kind text, complex_id bigint,
|
|
||||||
price_per_m2_rub numeric) ON COMMIT DROP""",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Участок в (56.840, 60.600), радиус 3 км. Малахит — в ~10 км, вне радиуса.
|
|
||||||
_DATA = [
|
|
||||||
"""INSERT INTO complexes VALUES
|
|
||||||
(10, 'ЖК Мичуринский', 56.841, 60.601),
|
|
||||||
(20, 'ЖК VEER PARK', 56.845, 60.605),
|
|
||||||
(30, 'СтудияПарк', 56.835, 60.595),
|
|
||||||
(40, 'ЖК Малахит', 56.930, 60.600)""",
|
|
||||||
# 20 → 'Clever Park': неверная fuzzy-связь, как на проде (complexes.id=1493).
|
|
||||||
"""INSERT INTO complex_sources VALUES
|
|
||||||
(10, 'objective', 'Мичуринский'),
|
|
||||||
(20, 'objective', 'Clever Park'),
|
|
||||||
(30, 'objective', 'Студия Парк'),
|
|
||||||
(40, 'objective', 'Малахит')""",
|
|
||||||
# Под complex_id=10 лежат свой лот и три лота чужого «Малахита» с устаревшим
|
|
||||||
# complex_id; у новых лотов complex_id NULL. Лот «Мичуринский/1/1/3/3» в двух
|
|
||||||
# снапшотах: в медиану идёт последний (120 тыс.), а не старый (50 тыс.).
|
|
||||||
"""INSERT INTO objective_lots VALUES
|
|
||||||
(1, 'Мичуринский', '1', '1', 1, '1', '2026-05-10', 'квартира', 10, 100000),
|
|
||||||
(2, 'Мичуринский', '1', '1', 2, '2', '2026-09-15', 'квартира', NULL, 110000),
|
|
||||||
(3, 'Мичуринский', '1', '1', 3, '3', '2026-08-01', 'квартира', NULL, 50000),
|
|
||||||
(4, 'Мичуринский', '1', '1', 3, '3', '2026-09-15', 'квартира', NULL, 120000),
|
|
||||||
(5, 'Малахит', '1', '1', 1, '1', '2026-05-10', 'квартира', 10, 300000),
|
|
||||||
(6, 'Малахит', '1', '1', 2, '2', '2026-05-10', 'квартира', 10, 300000),
|
|
||||||
(7, 'Малахит', '1', '1', 3, '3', '2026-05-10', 'квартира', 10, 300000),
|
|
||||||
(8, 'Clever Park', '1', '1', 1, '1', '2026-09-15', 'квартира', NULL, 500000),
|
|
||||||
(9, 'Clever Park', '1', '1', 2, '2', '2026-09-15', 'квартира', NULL, 500000),
|
|
||||||
(10, 'Clever Park', '1', '1', 3, '3', '2026-09-15', 'квартира', NULL, 500000),
|
|
||||||
(11, 'Студия Парк', '1', '1', 1, '1', '2026-09-15', 'квартира', NULL, 90000),
|
|
||||||
(12, 'Студия Парк', '1', '1', 2, '2', '2026-09-15', 'квартира', NULL, 95000)""",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def row() -> dict[str, float]:
|
|
||||||
from app.api.v1.parcels import _GEO_PRICE_RADIUS_M, _GEO_RADIUS_PRICE_SQL
|
|
||||||
|
|
||||||
session = sessionmaker(bind=create_engine(_dsn()))()
|
|
||||||
try:
|
|
||||||
for stmt in _SCHEMA + _DATA:
|
|
||||||
session.execute(text(stmt))
|
|
||||||
r = (
|
|
||||||
session.execute(
|
|
||||||
_GEO_RADIUS_PRICE_SQL,
|
|
||||||
{"lon": 60.600, "lat": 56.840, "radius_m": _GEO_PRICE_RADIUS_M},
|
|
||||||
)
|
|
||||||
.mappings()
|
|
||||||
.one()
|
|
||||||
)
|
|
||||||
return {k: float(v) for k, v in r.items()}
|
|
||||||
finally:
|
|
||||||
session.rollback()
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_median_counts_own_projects_of_nearby_complexes(row) -> None:
|
|
||||||
"""Лоты 90/95 тыс. («Студия Парк») и 100/110/120 тыс. («Мичуринский») → медиана 100 тыс.
|
|
||||||
|
|
||||||
По устаревшему complex_id было бы 100 + три «Малахита» по 300 тыс. → 300 тыс.;
|
|
||||||
без сверки имени добавились бы три лота «Clever Park» по 500 тыс. → 115 тыс.
|
|
||||||
"""
|
|
||||||
assert row == {"median": 100000.0, "n": 5.0, "n_complexes": 2.0}
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
"""Тесты guard'а блокировки DOM.РФ (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах.
|
"""Тесты для WAF cooldown guard (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах.
|
||||||
|
|
||||||
POST /api/v1/admin/scrape/kn-catalog-objects и /kn-catalog-flats бьют по тому же
|
POST /api/v1/admin/scrape/kn-catalog-objects и /kn-catalog-flats бьют по тому же
|
||||||
/сервисы/* BrowserSession path family, что закрыт StormWall (#2443). Beat schedule
|
/сервисы/* BrowserSession path family, что вызвал DOM.РФ WAF hard-ban 2026-05-24
|
||||||
для обоих тасков отключён по этой причине — эти эндпоинты должны отказывать в
|
(#2443). Beat schedule для обоих тасков отключён по этой причине — эти эндпоинты
|
||||||
запуске БЕЗ явного i_understand_waf_risk=true. Текст отказа — то, по чему оператор
|
должны отказывать в запуске БЕЗ явного i_understand_waf_risk=true, чтобы случайный
|
||||||
решает, ставить ли флаг, поэтому он обязан называть реальное условие (#3307), а не
|
smoke-тест/re-trigger не углубил бан.
|
||||||
ожидание таймера: зонды 20.08-01.09 показали, что ждать нечего.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -20,15 +19,6 @@ OBJECTS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-objects"
|
||||||
FLATS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-flats"
|
FLATS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-flats"
|
||||||
|
|
||||||
|
|
||||||
def _assert_refusal_names_real_condition(detail: str) -> None:
|
|
||||||
assert "2443" in detail
|
|
||||||
assert "i_understand_waf_risk" in detail
|
|
||||||
# Условие снятия — прокси и принятый kn-прогон (#3307). Обещание «cooldown
|
|
||||||
# прошёл» подсказывало оператору обойти блокировку, которую ожидание не снимает.
|
|
||||||
assert "3307" in detail
|
|
||||||
assert "cooldown" not in detail.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_apply_async_result(task_id: str = "fake-task-id") -> MagicMock:
|
def _mock_apply_async_result(task_id: str = "fake-task-id") -> MagicMock:
|
||||||
result = MagicMock()
|
result = MagicMock()
|
||||||
result.id = task_id
|
result.id = task_id
|
||||||
|
|
@ -43,7 +33,9 @@ def test_kn_catalog_objects_refuses_without_override() -> None:
|
||||||
resp = TestClient(app).post(OBJECTS_ENDPOINT, json={"region_code": 66})
|
resp = TestClient(app).post(OBJECTS_ENDPOINT, json={"region_code": 66})
|
||||||
|
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
_assert_refusal_names_real_condition(resp.json()["detail"])
|
detail = resp.json()["detail"]
|
||||||
|
assert "2443" in detail
|
||||||
|
assert "i_understand_waf_risk" in detail
|
||||||
mock_apply_async.assert_not_called()
|
mock_apply_async.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -76,7 +68,9 @@ def test_kn_catalog_flats_refuses_without_override() -> None:
|
||||||
resp = TestClient(app).post(FLATS_ENDPOINT, json={"region_code": 66})
|
resp = TestClient(app).post(FLATS_ENDPOINT, json={"region_code": 66})
|
||||||
|
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
_assert_refusal_names_real_condition(resp.json()["detail"])
|
detail = resp.json()["detail"]
|
||||||
|
assert "2443" in detail
|
||||||
|
assert "i_understand_waf_risk" in detail
|
||||||
mock_apply_async.assert_not_called()
|
mock_apply_async.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -264,10 +264,8 @@ def test_exclude_competitor_obj_ids_filter() -> None:
|
||||||
# После исключения obj_id=20 список пуст → пустой ответ
|
# После исключения obj_id=20 список пуст → пустой ответ
|
||||||
assert body["top_layouts"] == []
|
assert body["top_layouts"] == []
|
||||||
assert body["data_quality"]["confidence"] == "low"
|
assert body["data_quality"]["confidence"] == "low"
|
||||||
# objects_total_in_radius — знаменатель coverage, после исключения 0 (#2464);
|
# objects_total_in_radius = 1 (до исключения)
|
||||||
# число obj_id в радиусе до исключения — raw_objects_total
|
assert body["data_quality"]["objects_total_in_radius"] == 1
|
||||||
assert body["data_quality"]["objects_total_in_radius"] == 0
|
|
||||||
assert body["data_quality"]["raw_objects_total"] == 1
|
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Оповещения уходят в тему форума, а не в общую (#2203/#3078/#3164).
|
"""Оповещения уходят в тему «алерты», а не в общую (#2203/#3078).
|
||||||
|
|
||||||
Прод-факт, 27.08. Канал доставки включили, `notify()` заработал — и алерты
|
Прод-факт, 27.08. Канал доставки включили, `notify()` заработал — и алерты
|
||||||
посыпались в ОБЩУЮ тему форума (в чат переговорки), а не в «алерты». Причина: в
|
посыпались в ОБЩУЮ тему форума (в чат переговорки), а не в «алерты». Причина: в
|
||||||
|
|
@ -16,7 +16,9 @@
|
||||||
реально ушло бы в сеть.
|
реально ушло бы в сеть.
|
||||||
|
|
||||||
Функция извлекается из файла построчно, а не копируется в тест: копия разошлась
|
Функция извлекается из файла построчно, а не копируется в тест: копия разошлась
|
||||||
бы с оригиналом на первой же правке.
|
бы с оригиналом на первой же правке. Сорсить файл целиком нельзя — у
|
||||||
|
`uptime-healthcheck.sh` нет guard'а по `BASH_SOURCE`, и сорсинг запустил бы
|
||||||
|
настоящие сетевые проверки.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -32,6 +34,7 @@ import pytest
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
SENDERS = {
|
SENDERS = {
|
||||||
"lib-backup.sh": REPO_ROOT / "ops" / "lib-backup.sh",
|
"lib-backup.sh": REPO_ROOT / "ops" / "lib-backup.sh",
|
||||||
|
"uptime-healthcheck.sh": REPO_ROOT / "ops" / "uptime-healthcheck.sh",
|
||||||
}
|
}
|
||||||
|
|
||||||
FAKE_CURL = '#!/usr/bin/env bash\nprintf "%s\\n" "$@" >> "$ARGS_DUMP"\nexit 0\n'
|
FAKE_CURL = '#!/usr/bin/env bash\nprintf "%s\\n" "$@" >> "$ARGS_DUMP"\nexit 0\n'
|
||||||
|
|
@ -102,7 +105,7 @@ def _run_notify(tmp_path: Path, script: Path, *, topic: str | None) -> list[str]
|
||||||
f'ARGS_DUMP="{_posix(dump)}"',
|
f'ARGS_DUMP="{_posix(dump)}"',
|
||||||
'TELEGRAM_BOT_TOKEN="123:FAKE"',
|
'TELEGRAM_BOT_TOKEN="123:FAKE"',
|
||||||
'TELEGRAM_CHAT_ID="-1004443088679"',
|
'TELEGRAM_CHAT_ID="-1004443088679"',
|
||||||
f'BACKUP_ENV_FILE="{_posix(tmp_path / "missing.env")}"',
|
f"BACKUP_ENV_FILE=\"{_posix(tmp_path / 'missing.env')}\"",
|
||||||
]
|
]
|
||||||
if topic is not None:
|
if topic is not None:
|
||||||
prefix.append(f'TELEGRAM_TOPIC_ID="{topic}"')
|
prefix.append(f'TELEGRAM_TOPIC_ID="{topic}"')
|
||||||
|
|
@ -150,73 +153,3 @@ def test_adres_i_tekst_na_meste(tmp_path: Path, name: str) -> None:
|
||||||
args = _run_notify(tmp_path, SENDERS[name], topic="158")
|
args = _run_notify(tmp_path, SENDERS[name], topic="158")
|
||||||
assert "chat_id=-1004443088679" in args, f"{name}: потерялся chat_id"
|
assert "chat_id=-1004443088679" in args, f"{name}: потерялся chat_id"
|
||||||
assert any("тестовое сообщение" in a for a in args), f"{name}: потерялся текст"
|
assert any("тестовое сообщение" in a for a in args), f"{name}: потерялся текст"
|
||||||
|
|
||||||
|
|
||||||
# #3164. Решение владельца в #3163: инфраструктура — в тему «Metrics» (245),
|
|
||||||
# клиентские инциденты — в «алерты» (158). Бэкапы — инфраструктура. Тема живёт
|
|
||||||
# только в env-файле на хосте, поэтому образец — единственное место в репозитории,
|
|
||||||
# откуда следующий, кто заводит хост, узнает правильный адрес.
|
|
||||||
INFRA_TOPIC = "245"
|
|
||||||
BACKUP_ENV_EXAMPLES = [
|
|
||||||
"gendesign-backup.default.example",
|
|
||||||
"gendesign-backup-couchdb.default.example",
|
|
||||||
"gendesign-backup-forgejo.default.example",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("example", BACKUP_ENV_EXAMPLES)
|
|
||||||
def test_host_po_obraztsu_shlyot_bekapy_v_metriki(tmp_path: Path, example: str) -> None:
|
|
||||||
"""Хост, заведённый по образцу, шлёт алерт бэкапа в «Metrics», а не мимо.
|
|
||||||
|
|
||||||
Исполняется настоящий сторож `check-backup-staleness.sh` с отсутствующим
|
|
||||||
маркером, а env-файл — образец, заполненный так, как он велит человеку:
|
|
||||||
раскомментированы строки `TELEGRAM_*`, подставлены токен и чат.
|
|
||||||
|
|
||||||
Ловит и второе: тема обязана лежать В ТОМ ЖЕ файле, что токен и чат.
|
|
||||||
`notify()` читает `BACKUP_ENV_FILE`, только если токена или чата нет в
|
|
||||||
окружении, — тема из другого файла молча не подхватится.
|
|
||||||
"""
|
|
||||||
text = (REPO_ROOT / "ops" / example).read_text(encoding="utf-8")
|
|
||||||
filled = re.sub(r"(?m)^#(TELEGRAM_\w+=)", r"\1", text)
|
|
||||||
filled = re.sub(r"(?m)^TELEGRAM_BOT_TOKEN=.*$", "TELEGRAM_BOT_TOKEN=123:FAKE", filled)
|
|
||||||
filled = re.sub(r"(?m)^TELEGRAM_CHAT_ID=.*$", "TELEGRAM_CHAT_ID=-1004443088679", filled)
|
|
||||||
env_file = tmp_path / "backup.env"
|
|
||||||
env_file.write_text(filled, encoding="utf-8", newline="\n")
|
|
||||||
|
|
||||||
bin_dir = tmp_path / "bin"
|
|
||||||
bin_dir.mkdir()
|
|
||||||
fake = bin_dir / "curl"
|
|
||||||
fake.write_text(FAKE_CURL, encoding="utf-8", newline="\n")
|
|
||||||
fake.chmod(0o755)
|
|
||||||
dump = tmp_path / "args.txt"
|
|
||||||
|
|
||||||
# Пустые TELEGRAM_* префиксом: переменные из окружения разработчика иначе
|
|
||||||
# перебили бы файл, и тест проверял бы не образец.
|
|
||||||
prefix = [
|
|
||||||
f'PATH="{_posix(bin_dir)}:$PATH"',
|
|
||||||
f'ARGS_DUMP="{_posix(dump)}"',
|
|
||||||
'TELEGRAM_BOT_TOKEN="" TELEGRAM_CHAT_ID="" TELEGRAM_TOPIC_ID=""',
|
|
||||||
f'BACKUP_ENV_FILE="{_posix(env_file)}"',
|
|
||||||
f'BACKUP_STALENESS_STATE_FILE="{_posix(tmp_path / "state")}"',
|
|
||||||
]
|
|
||||||
script = REPO_ROOT / "ops" / "check-backup-staleness.sh"
|
|
||||||
proc = subprocess.run(
|
|
||||||
[
|
|
||||||
"bash",
|
|
||||||
"-c",
|
|
||||||
" ".join(prefix)
|
|
||||||
+ f' bash "{_posix(script)}" "{_posix(tmp_path / "no-sentinel")}" 26 "topic probe"',
|
|
||||||
],
|
|
||||||
cwd=str(REPO_ROOT),
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=30,
|
|
||||||
env=dict(os.environ),
|
|
||||||
)
|
|
||||||
args = dump.read_text(encoding="utf-8").splitlines() if dump.exists() else []
|
|
||||||
assert args, f"{example}: curl не был вызван.\n{proc.stdout}\n{proc.stderr}"
|
|
||||||
threads = [a for a in args if a.startswith("message_thread_id=")]
|
|
||||||
assert threads == [f"message_thread_id={INFRA_TOPIC}"], (
|
|
||||||
f"{example}: алерт бэкапа адресован {threads or 'в общую тему'}, "
|
|
||||||
f"а по #3163 инфраструктура идёт в «Metrics» ({INFRA_TOPIC})"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,7 @@ Poincare, 40 подключений к ЗАКРЕПЛЁННОМУ (#3093) 149.15
|
||||||
|
|
||||||
Как это ломало алерты:
|
Как это ломало алерты:
|
||||||
|
|
||||||
* `ops/uptime-healthcheck.sh` (удалён в #3164: ни на одном хосте не был
|
* `ops/uptime-healthcheck.sh` — ОДИН `curl`, дальше `|| log WARN`. Каждый
|
||||||
запущен, доступность сайтов сторожат uptime-мониторы GlitchTip) — ОДИН
|
|
||||||
`curl`, дальше `|| log WARN`. Каждый
|
|
||||||
отказ терял уведомление целиком. Watchdog, который не может дозваться, —
|
отказ терял уведомление целиком. Watchdog, который не может дозваться, —
|
||||||
худший вид самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс,
|
худший вид самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс,
|
||||||
что о них не сообщат. При этом сам файл ниже повторяет свои HTTP-ПРОВЕРКИ
|
что о них не сообщат. При этом сам файл ниже повторяет свои HTTP-ПРОВЕРКИ
|
||||||
|
|
@ -53,6 +51,7 @@ import pytest
|
||||||
# backend/tests/ops/<этот файл> → корень репозитория
|
# backend/tests/ops/<этот файл> → корень репозитория
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
UPTIME = "ops/uptime-healthcheck.sh"
|
||||||
LIB_BACKUP = "ops/lib-backup.sh"
|
LIB_BACKUP = "ops/lib-backup.sh"
|
||||||
|
|
||||||
# См. подробное обоснование shutil.which в
|
# См. подробное обоснование shutil.which в
|
||||||
|
|
@ -66,7 +65,8 @@ if BASH is None: # pragma: no cover - окружение без bash не за
|
||||||
def _extract_function(script_rel: str, name: str = "notify") -> str:
|
def _extract_function(script_rel: str, name: str = "notify") -> str:
|
||||||
"""Достаёт тело одной функции из скрипта — не весь файл.
|
"""Достаёт тело одной функции из скрипта — не весь файл.
|
||||||
|
|
||||||
Весь скрипт source'ить нельзя: lib-backup.sh рассчитан на вызов из backup.sh.
|
Весь скрипт source'ить нельзя: uptime-healthcheck.sh ниже функций реально
|
||||||
|
ходит по прод-URL, а lib-backup.sh рассчитан на вызов из backup.sh.
|
||||||
|
|
||||||
Сопоставление точное (`name() {`), иначе `notify` поймал бы
|
Сопоставление точное (`name() {`), иначе `notify` поймал бы
|
||||||
`notify_fallback_mail` — соседнюю функцию в том же файле.
|
`notify_fallback_mail` — соседнюю функцию в том же файле.
|
||||||
|
|
@ -140,7 +140,7 @@ def _run_notify(script_rel: str, fail_times: int) -> tuple[int, str, str]:
|
||||||
return calls, out, err
|
return calls, out, err
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("script_rel", [LIB_BACKUP])
|
@pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP])
|
||||||
def test_transient_failure_is_retried_not_lost(script_rel: str) -> None:
|
def test_transient_failure_is_retried_not_lost(script_rel: str) -> None:
|
||||||
"""Один отказ — алерт всё равно доставляется со второй попытки.
|
"""Один отказ — алерт всё равно доставляется со второй попытки.
|
||||||
|
|
||||||
|
|
@ -153,7 +153,7 @@ def test_transient_failure_is_retried_not_lost(script_rel: str) -> None:
|
||||||
assert "НЕ ДОСТАВЛЕН" not in err, f"{script_rel}: доставленный алерт помечен потерянным"
|
assert "НЕ ДОСТАВЛЕН" not in err, f"{script_rel}: доставленный алерт помечен потерянным"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("script_rel", [LIB_BACKUP])
|
@pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP])
|
||||||
def test_gives_up_after_three_attempts(script_rel: str) -> None:
|
def test_gives_up_after_three_attempts(script_rel: str) -> None:
|
||||||
"""Повторы ограничены: три попытки, а не бесконечный цикл.
|
"""Повторы ограничены: три попытки, а не бесконечный цикл.
|
||||||
|
|
||||||
|
|
@ -164,6 +164,12 @@ def test_gives_up_after_three_attempts(script_rel: str) -> None:
|
||||||
assert calls == 3, f"{script_rel}: ожидалось ровно 3 попытки, а curl позван {calls} раз"
|
assert calls == 3, f"{script_rel}: ожидалось ровно 3 попытки, а curl позван {calls} раз"
|
||||||
|
|
||||||
|
|
||||||
|
def test_uptime_reports_undelivered_alert_loudly() -> None:
|
||||||
|
"""Когда все три попытки провалились — это видно в логе, а не молча."""
|
||||||
|
_calls, _out, err = _run_notify(UPTIME, fail_times=99)
|
||||||
|
assert "НЕ ДОСТАВЛЕН" in err, f"недоставленный алерт должен логироваться громко.\n{err}"
|
||||||
|
|
||||||
|
|
||||||
def test_backup_does_not_burn_fallback_on_a_single_timeout() -> None:
|
def test_backup_does_not_burn_fallback_on_a_single_timeout() -> None:
|
||||||
"""Транзиентный таймаут не должен трогать запасной канал.
|
"""Транзиентный таймаут не должен трогать запасной канал.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import logging
|
|
||||||
import socket
|
|
||||||
import sys
|
import sys
|
||||||
import threading
|
|
||||||
from http.server import ThreadingHTTPServer
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
|
|
@ -183,48 +179,3 @@ def test_protuhshiy_token_ne_prinimaetsya(app: ModuleType, monkeypatch: pytest.M
|
||||||
code, _ = app.do_ack(token)
|
code, _ = app.do_ack(token)
|
||||||
assert code == 404
|
assert code == 404
|
||||||
assert app.sent == []
|
assert app.sent == []
|
||||||
|
|
||||||
|
|
||||||
# ── Секрет из query не попадает в лог (#3576) ────────────────────────────────
|
|
||||||
# GlitchTip шлёт секрет резервного вебхука только в `?secret=`, а http.server
|
|
||||||
# печатает строку запроса целиком — и в строке доступа, и в тексте ошибки
|
|
||||||
# разбора. Значение ниже выдуманное: проверяется, что его нет ни в одной записи.
|
|
||||||
|
|
||||||
_LEAK = "leak-probe-3576-VALUE"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("request_line", "masked", "lines_with_mask"),
|
|
||||||
[
|
|
||||||
# Строка доступа (log_request), путь резервного вебхука GlitchTip.
|
|
||||||
(f"POST /glitchtip?secret={_LEAK} HTTP/1.1", "/glitchtip?secret=***", 1),
|
|
||||||
# Имя с префиксом и соседний параметр — остальная строка цела.
|
|
||||||
(f"GET /ack/x?a=1&access_token={_LEAK}&b=2 HTTP/1.1", "?a=1&access_token=***&b=2", 1),
|
|
||||||
# Ошибка разбора (log_error): stdlib кладёт строку запроса в текст ошибки,
|
|
||||||
# затем та же строка идёт в строку доступа с кодом 400 — обе записи.
|
|
||||||
(f"POST /glitchtip?secret={_LEAK} junk HTTP/1.1", "/glitchtip?secret=***", 2),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_sekret_iz_query_ne_popadaet_v_log(
|
|
||||||
app: ModuleType,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
request_line: str,
|
|
||||||
masked: str,
|
|
||||||
lines_with_mask: int,
|
|
||||||
) -> None:
|
|
||||||
caplog.set_level(logging.INFO, logger="alert-ack")
|
|
||||||
srv = ThreadingHTTPServer(("127.0.0.1", 0), app.Handler)
|
|
||||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
||||||
try:
|
|
||||||
with socket.create_connection(srv.server_address, timeout=5) as sock:
|
|
||||||
sock.sendall(f"{request_line}\r\nHost: x\r\nContent-Length: 0\r\n\r\n".encode())
|
|
||||||
sock.shutdown(socket.SHUT_WR)
|
|
||||||
while sock.recv(4096): # до закрытия: к этому моменту запись лога уже сделана
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
srv.shutdown()
|
|
||||||
srv.server_close()
|
|
||||||
|
|
||||||
messages = [r.getMessage() for r in caplog.records if r.name == "alert-ack"]
|
|
||||||
assert not [m for m in messages if _LEAK in m], f"значение секрета в логе: {messages}"
|
|
||||||
assert sum(masked in m for m in messages) == lines_with_mask, messages
|
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,8 @@
|
||||||
переменной — и инфраструктурная тема «метрики» оставалась пустой, а весь трафик,
|
переменной — и инфраструктурная тема «метрики» оставалась пустой, а весь трафик,
|
||||||
и клиентский, и инфраструктурный, копился в теме «алерты». Владелец решил
|
и клиентский, и инфраструктурный, копился в теме «алерты». Владелец решил
|
||||||
развести: инфраструктура — в «метрики», клиентские инциденты — в «алерты».
|
развести: инфраструктура — в «метрики», клиентские инциденты — в «алерты».
|
||||||
Тест ниже закрепляет именно это для единственного оставшегося прямого
|
Тесты ниже закрепляют именно это: `telegram`/`telegram-heartbeat` получают
|
||||||
получателя — `telegram` (Watchdog с 17.09 больше не шлёт в Telegram вовсе,
|
ИНФРАСТРУКТУРНУЮ тему, а не общую.
|
||||||
у него теперь внешний webhook-приёмник `watchdog-ping`, см. шаблон).
|
|
||||||
|
|
||||||
Тесты рендерят шаблон обоими способами и разбирают результат как YAML —
|
Тесты рендерят шаблон обоими способами и разбирают результат как YAML —
|
||||||
проверяется фактический конфиг, а не наличие нужных слов в тексте.
|
проверяется фактический конфиг, а не наличие нужных слов в тексте.
|
||||||
|
|
@ -44,56 +43,9 @@ WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
||||||
|
|
||||||
INFRA_TOPIC_LINE = " message_thread_id: 245"
|
INFRA_TOPIC_LINE = " message_thread_id: 245"
|
||||||
|
|
||||||
# Заглушки для переменных, которые деплой может подставить. Значение
|
|
||||||
# METRICS_WATCHDOG_PING_BLOCK по умолчанию пустое — это реальный дефолт
|
|
||||||
# деплоя, когда секрет не заведён (#3589), а не тестовое упрощение.
|
|
||||||
_DUMMY_VALUES = {
|
|
||||||
"METRICS_TELEGRAM_BOT_TOKEN": "123:ABC",
|
|
||||||
"METRICS_TELEGRAM_CHAT_ID": "-100123",
|
|
||||||
"METRICS_TELEGRAM_ONCALL": "",
|
|
||||||
"METRICS_WATCHDOG_PING_BLOCK": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
def _render(infra_topic_line: str) -> dict:
|
||||||
def _envsubst_allowlist() -> set[str]:
|
"""Повторяет подстановку деплоя и разбирает результат как YAML.
|
||||||
"""Реальный список переменных, которые деплой передаёт в envsubst.
|
|
||||||
|
|
||||||
Не хардкодим копию списка — #3589 случился именно так: шаблон завёл
|
|
||||||
`${METRICS_WATCHDOG_PING_URL}`, а список envsubst в деплое не пополнили,
|
|
||||||
и тест этого не заметил, потому что сам подставлял значение мимо деплоя.
|
|
||||||
"""
|
|
||||||
assert WORKFLOW.is_file(), f"нет {WORKFLOW} — воркфлоу переехал, гейт ослеп"
|
|
||||||
text = WORKFLOW.read_text(encoding="utf-8")
|
|
||||||
m = re.search(r"envsubst '([^']+)'", text)
|
|
||||||
assert m, "не нашёл вызов envsubst в деплое"
|
|
||||||
return set(re.findall(r"\$\{(\w+)\}", m.group(1)))
|
|
||||||
|
|
||||||
|
|
||||||
def _template_placeholders() -> set[str]:
|
|
||||||
text = TMPL.read_text(encoding="utf-8")
|
|
||||||
return set(re.findall(r"\$\{(\w+)\}", text))
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_template_placeholder_is_in_envsubst_allowlist() -> None:
|
|
||||||
"""Регресс #3589: переменная шаблона обязана быть в allow-list envsubst.
|
|
||||||
|
|
||||||
Тогда в шаблоне появился `${METRICS_WATCHDOG_PING_URL}`, а список
|
|
||||||
envsubst в деплое не пополнили. envsubst подставляет ТОЛЬКО
|
|
||||||
перечисленные переменные — забытая долетает до `amtool check-config`
|
|
||||||
литералом плейсхолдера и валит проверку (`unsupported scheme ""`), то
|
|
||||||
есть роняет ВЕСЬ Alertmanager, а не только Watchdog.
|
|
||||||
"""
|
|
||||||
missing = _template_placeholders() - _envsubst_allowlist()
|
|
||||||
assert not missing, f"эти переменные шаблона деплой не подставляет: {missing}"
|
|
||||||
|
|
||||||
|
|
||||||
def _render(infra_topic_line: str, watchdog_ping_block: str | None = None) -> dict:
|
|
||||||
"""Повторяет ТОЧНО ТУ ЖЕ подстановку, что делает деплой, и разбирает YAML.
|
|
||||||
|
|
||||||
Подставляются только переменные из реального allow-list envsubst деплоя
|
|
||||||
(`_envsubst_allowlist`) — не весь известный тесту набор. Так регресс
|
|
||||||
#3589 (переменная в шаблоне, забытая в allow-list) ловится именно здесь:
|
|
||||||
`assert "${" not in rendered` ниже упадёт, если что-то не подставилось.
|
|
||||||
|
|
||||||
Строка темы в шаблоне ровно одна — инфраструктурная (#3163). Тема
|
Строка темы в шаблоне ровно одна — инфраструктурная (#3163). Тема
|
||||||
клиентских инцидентов сюда не подставляется вовсе: маршрут
|
клиентских инцидентов сюда не подставляется вовсе: маршрут
|
||||||
|
|
@ -103,18 +55,11 @@ def _render(infra_topic_line: str, watchdog_ping_block: str | None = None) -> di
|
||||||
"""
|
"""
|
||||||
assert TMPL.is_file(), f"нет {TMPL} — шаблон переехал, гейт ослеп"
|
assert TMPL.is_file(), f"нет {TMPL} — шаблон переехал, гейт ослеп"
|
||||||
text = TMPL.read_text(encoding="utf-8")
|
text = TMPL.read_text(encoding="utf-8")
|
||||||
|
rendered = (
|
||||||
values = dict(_DUMMY_VALUES)
|
text.replace("${METRICS_TELEGRAM_BOT_TOKEN}", "123:ABC")
|
||||||
values["METRICS_TELEGRAM_INFRA_TOPIC_LINE"] = infra_topic_line
|
.replace("${METRICS_TELEGRAM_CHAT_ID}", "-100123")
|
||||||
if watchdog_ping_block is not None:
|
.replace("${METRICS_TELEGRAM_INFRA_TOPIC_LINE}", infra_topic_line)
|
||||||
values["METRICS_WATCHDOG_PING_BLOCK"] = watchdog_ping_block
|
)
|
||||||
|
|
||||||
allowlist = _envsubst_allowlist()
|
|
||||||
rendered = text
|
|
||||||
for name, value in values.items():
|
|
||||||
if name in allowlist:
|
|
||||||
rendered = rendered.replace("${" + name + "}", value)
|
|
||||||
|
|
||||||
assert "${" not in rendered, (
|
assert "${" not in rendered, (
|
||||||
"в отрендеренном конфиге остался литерал плейсхолдера — "
|
"в отрендеренном конфиге остался литерал плейсхолдера — "
|
||||||
"значит в шаблоне появилась подстановка, о которой тест не знает"
|
"значит в шаблоне появилась подстановка, о которой тест не знает"
|
||||||
|
|
@ -122,37 +67,6 @@ def _render(infra_topic_line: str, watchdog_ping_block: str | None = None) -> di
|
||||||
return yaml.safe_load(rendered)
|
return yaml.safe_load(rendered)
|
||||||
|
|
||||||
|
|
||||||
def test_watchdog_receiver_without_secret_has_no_configs_and_parses() -> None:
|
|
||||||
"""Секрет не заведён (реальное состояние прода сейчас) — конфиг всё равно жив.
|
|
||||||
|
|
||||||
`watchdog-ping` остаётся без единого `*_configs` — валидный receiver,
|
|
||||||
Alertmanager его просто пропускает. Деградация корректна: Watchdog никуда
|
|
||||||
не пингует, но остальной алертинг (`telegram`, `telegram-clients`) цел.
|
|
||||||
"""
|
|
||||||
cfg = _render(INFRA_TOPIC_LINE, watchdog_ping_block="")
|
|
||||||
receivers = {r["name"]: r for r in cfg["receivers"]}
|
|
||||||
assert "watchdog-ping" in receivers, "receiver watchdog-ping пропал из конфига"
|
|
||||||
watchdog = receivers["watchdog-ping"]
|
|
||||||
assert "webhook_configs" not in watchdog, "пустой секрет не должен оставлять webhook_configs"
|
|
||||||
assert "telegram" in receivers and "telegram-clients" in receivers, (
|
|
||||||
"остальной алертинг не должен пострадать из-за пустого watchdog-секрета"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_watchdog_receiver_with_secret_gets_webhook() -> None:
|
|
||||||
"""Секрет задан — Watchdog реально пингует внешний deadman-приёмник."""
|
|
||||||
block = (
|
|
||||||
" webhook_configs:\n"
|
|
||||||
' - url: "https://hc-ping.com/dummy"\n'
|
|
||||||
" send_resolved: false"
|
|
||||||
)
|
|
||||||
cfg = _render(INFRA_TOPIC_LINE, watchdog_ping_block=block)
|
|
||||||
receivers = {r["name"]: r for r in cfg["receivers"]}
|
|
||||||
hooks = receivers["watchdog-ping"].get("webhook_configs") or []
|
|
||||||
assert hooks and hooks[0].get("url") == "https://hc-ping.com/dummy"
|
|
||||||
assert hooks[0].get("send_resolved") is False
|
|
||||||
|
|
||||||
|
|
||||||
def _telegram_configs(cfg: dict) -> list[dict]:
|
def _telegram_configs(cfg: dict) -> list[dict]:
|
||||||
out = []
|
out = []
|
||||||
for r in cfg.get("receivers", []):
|
for r in cfg.get("receivers", []):
|
||||||
|
|
@ -162,16 +76,16 @@ def _telegram_configs(cfg: dict) -> list[dict]:
|
||||||
|
|
||||||
|
|
||||||
def test_topic_lands_in_every_telegram_receiver() -> None:
|
def test_topic_lands_in_every_telegram_receiver() -> None:
|
||||||
"""Единственный прямой получатель адресует ИНФРАСТРУКТУРНУЮ тему, а не клиентскую (#3163).
|
"""Оба прямых получателя адресуют ИНФРАСТРУКТУРНУЮ тему, а не клиентскую (#3163).
|
||||||
|
|
||||||
До разделения тем `telegram` и `telegram-heartbeat` брали топик из одной
|
Получателей два — `telegram` и `telegram-heartbeat`. До разделения тем оба
|
||||||
переменной с клиентскими инцидентами, и тема «метрики» (245) оставалась
|
брали топик из одной переменной с клиентскими инцидентами, и тема «метрики»
|
||||||
пустой. С 17.09 (устранение шума Watchdog) прямой Telegram-получатель
|
(245) оставалась пустой. Если heartbeat уйдёт не в ту тему, «мониторинг жив»
|
||||||
остался один — `telegram`; Watchdog теперь пингует внешний
|
будет капать мимо, и это заметят не сразу — сюда же попадёт и весь
|
||||||
deadman-приёмник вебхуком (`watchdog-ping`, без topic вовсе — не Telegram).
|
инфраструктурный шум.
|
||||||
"""
|
"""
|
||||||
cfgs = _telegram_configs(_render(INFRA_TOPIC_LINE))
|
cfgs = _telegram_configs(_render(INFRA_TOPIC_LINE))
|
||||||
assert len(cfgs) >= 1, f"ожидался хотя бы один получатель telegram, найдено {len(cfgs)}"
|
assert len(cfgs) >= 2, f"ожидалось минимум два получателя telegram, найдено {len(cfgs)}"
|
||||||
for c in cfgs:
|
for c in cfgs:
|
||||||
assert c.get("message_thread_id") == 245, f"инфраструктурный топик не проставлен: {c}"
|
assert c.get("message_thread_id") == 245, f"инфраструктурный топик не проставлен: {c}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ def test_klientskiy_marshrut_idyot_v_servis_knopki() -> None:
|
||||||
"""
|
"""
|
||||||
text = TEMPLATE.read_text(encoding="utf-8")
|
text = TEMPLATE.read_text(encoding="utf-8")
|
||||||
block = text[text.index("- name: telegram-clients") :]
|
block = text[text.index("- name: telegram-clients") :]
|
||||||
block = block[: block.index("- name: watchdog-ping")]
|
block = block[: block.index("- name: telegram-heartbeat")]
|
||||||
assert "webhook_configs" in block, "клиентский приёмник не переключён на сервис"
|
assert "webhook_configs" in block, "клиентский приёмник не переключён на сервис"
|
||||||
assert "alert-ack:8080/alertmanager" in block, "вебхук указывает не на сервис кнопки"
|
assert "alert-ack:8080/alertmanager" in block, "вебхук указывает не на сервис кнопки"
|
||||||
# У прочих приёмников прямой путь сохранён.
|
# У прочих приёмников прямой путь сохранён.
|
||||||
|
|
|
||||||
|
|
@ -1,463 +0,0 @@
|
||||||
"""Полный деплой не пересоздаёт Caddy без нужды (#3443).
|
|
||||||
|
|
||||||
ЧТО СЛУЧИЛОСЬ. Каждый полный деплой ПТИЦЫ делал `up -d --force-recreate
|
|
||||||
--no-deps caddy`, то есть сносил единственный процесс, слушающий 80/443.
|
|
||||||
Замер 05.09 (#3274): 67 с `code=000` на ВСЕХ доменах хоста — gendsgn.ru,
|
|
||||||
meraocenka.ru и зеркала. Не 502/503: принимающего процесса нет вовсе, поэтому
|
|
||||||
заглушка окна деплоя бессильна по построению — её отдаёт тот же Caddy.
|
|
||||||
|
|
||||||
ЧТО УСТАНОВЛЕНО. Безусловный флаг появился 17.05 (11e78d73) ради нового
|
|
||||||
bind-маунта `./preview`, который «не появлялся в running container». Довод
|
|
||||||
неверен: `docker compose up -d` БЕЗ `--force-recreate` пересоздаёт контейнер
|
|
||||||
сам, как только меняется описание сервиса или образ (проверено на живом демоне
|
|
||||||
docker 28.4). Единственное, чего compose не видит, — СОДЕРЖИМОЕ пофайлового
|
|
||||||
bind-маунта: `git reset --hard` пишет новый инод, контейнер держит прежний, и
|
|
||||||
`caddy reload` перечитывает старый текст. У Caddy так смонтированы Caddyfile и
|
|
||||||
четыре сниппета; каталоги (caddy/sites, caddy/local, preview) этим не страдают.
|
|
||||||
|
|
||||||
ЗАЧЕМ ЭТОТ ФАЙЛ. У правки нет отрицательного признака: вернуть `--force-recreate`
|
|
||||||
«на всякий случай» — одна строка, все деплои останутся зелёными, а окно в минуту
|
|
||||||
увидит только тот, кто в этот момент держал непрерывную пробу. Проверки ниже
|
|
||||||
ИСПОЛНЯЮТ ops/caddy-apply.sh с подставным `docker` и смотрят на СОВЕРШЁННЫЕ
|
|
||||||
действия (пересоздал / перезагрузил / не тронул), а не на текст скрипта.
|
|
||||||
Отдельно проверяется проводка в deploy.yml — что оба пути деплоя зовут именно
|
|
||||||
его.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
# backend/tests/ops/<этот файл> → корень репозитория
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
||||||
SCRIPT = REPO_ROOT / "ops" / "caddy-apply.sh"
|
|
||||||
WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows"
|
|
||||||
DEPLOY = WORKFLOWS / "deploy.yml"
|
|
||||||
|
|
||||||
CID = "caddy-cid-0001"
|
|
||||||
|
|
||||||
# Маунты Caddy ровно как на проде (`docker inspect gendesign-caddy-1`, 12.09):
|
|
||||||
# пять ПОФАЙЛОВЫХ bind-маунтов и три каталога. Тома (caddy_data и соседи) в
|
|
||||||
# сверку не входят — их фильтрует `{{if eq .Type "bind"}}`.
|
|
||||||
FILE_MOUNTS = {
|
|
||||||
"Caddyfile": "/etc/caddy/Caddyfile",
|
|
||||||
"caddy/users.caddy.snippet": "/etc/caddy/caddy/users.caddy.snippet",
|
|
||||||
"caddy/metrics-ui.caddy.snippet": "/etc/caddy/caddy/metrics-ui.caddy.snippet",
|
|
||||||
"caddy/metrics-ingest.caddy.snippet": "/etc/caddy/caddy/metrics-ingest.caddy.snippet",
|
|
||||||
"caddy/deploy-window.caddy.snippet": "/etc/caddy/caddy/deploy-window.caddy.snippet",
|
|
||||||
}
|
|
||||||
DIR_MOUNTS = {
|
|
||||||
"caddy/sites": "/etc/caddy/caddy/sites",
|
|
||||||
"caddy/local": "/etc/caddy/caddy/local",
|
|
||||||
"preview": "/srv/preview",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Подставной `docker`. Пишет каждый вызов в $FAKE_LOG и отвечает по сценарию:
|
|
||||||
# run — одноразовый `caddy validate`, код из $FAKE_VALIDATE_RC;
|
|
||||||
# inspect — список маунтов из $FAKE_MOUNTS, код из $FAKE_INSPECT_RC;
|
|
||||||
# exec — sha256sum ФАЙЛА, КОТОРЫЙ ВИДИТ КОНТЕЙНЕР ($FAKE_VIEW/<slug>);
|
|
||||||
# compose … ps — текущий id контейнера из $FAKE_CID_FILE;
|
|
||||||
# compose … up — при $FAKE_UP_RECREATES=1 подменяет id (compose пересоздал сам).
|
|
||||||
FAKE_DOCKER = r"""#!/bin/bash
|
|
||||||
printf '%s\n' "$*" >> "$FAKE_LOG"
|
|
||||||
cmd="$1"; shift
|
|
||||||
case "$cmd" in
|
|
||||||
run) exit "${FAKE_VALIDATE_RC:-0}" ;;
|
|
||||||
inspect)
|
|
||||||
if [ "${FAKE_INSPECT_RC:-0}" != "0" ]; then
|
|
||||||
echo "Error: No such object" >&2
|
|
||||||
exit "$FAKE_INSPECT_RC"
|
|
||||||
fi
|
|
||||||
cat "$FAKE_MOUNTS"
|
|
||||||
;;
|
|
||||||
exec)
|
|
||||||
dst="$3"
|
|
||||||
view="$FAKE_VIEW/$(printf '%s' "$dst" | tr '/' '_')"
|
|
||||||
[ -f "$view" ] || exit 1
|
|
||||||
sha256sum "$view"
|
|
||||||
;;
|
|
||||||
compose)
|
|
||||||
case " $* " in
|
|
||||||
*" ps "*) cat "$FAKE_CID_FILE" ;;
|
|
||||||
*--force-recreate*) echo "recreated caddy (forced)" ;;
|
|
||||||
*" up "*)
|
|
||||||
if [ "${FAKE_UP_RECREATES:-0}" = "1" ]; then
|
|
||||||
printf 'caddy-cid-NEW\n' > "$FAKE_CID_FILE"
|
|
||||||
echo "Container gendesign-caddy-1 Started"
|
|
||||||
else
|
|
||||||
echo "Container gendesign-caddy-1 Running"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*) echo "(compose $*)" ;;
|
|
||||||
esac
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
exit 0
|
|
||||||
"""
|
|
||||||
|
|
||||||
# macOS несёт shasum вместо sha256sum; на раннере (ubuntu) и на проде утилита
|
|
||||||
# настоящая. Шим ставится только при её отсутствии — иначе гейт не запускался бы
|
|
||||||
# локально вовсе.
|
|
||||||
SHA_SHIM = '#!/bin/sh\nexec shasum -a 256 "$@"\n'
|
|
||||||
|
|
||||||
|
|
||||||
def _write_exec(path: Path, text: str) -> None:
|
|
||||||
path.write_text(text, encoding="utf-8")
|
|
||||||
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def prod_tree(tmp_path: Path) -> Path:
|
|
||||||
"""Копия боевого дерева: скрипт + конфиги + «взгляд контейнера»."""
|
|
||||||
tree = tmp_path / "opt" / "gendesign"
|
|
||||||
(tree / "ops").mkdir(parents=True)
|
|
||||||
shutil.copy(SCRIPT, tree / "ops" / SCRIPT.name)
|
|
||||||
for rel in [*FILE_MOUNTS, "caddy/sites/apps.caddy", "caddy/local/.gitignore"]:
|
|
||||||
path = tree / rel
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(f"# {rel} версия НОВАЯ\n", encoding="utf-8")
|
|
||||||
(tree / "preview").mkdir()
|
|
||||||
|
|
||||||
view = tmp_path / "view"
|
|
||||||
view.mkdir()
|
|
||||||
# По умолчанию контейнер видит ровно то же, что лежит на диске.
|
|
||||||
for rel, dst in FILE_MOUNTS.items():
|
|
||||||
(view / dst.replace("/", "_")).write_text(
|
|
||||||
(tree / rel).read_text(encoding="utf-8"), encoding="utf-8"
|
|
||||||
)
|
|
||||||
|
|
||||||
mounts = tmp_path / "mounts"
|
|
||||||
mounts.write_text(
|
|
||||||
"".join(f"{tree / rel}|{dst}\n" for rel, dst in {**FILE_MOUNTS, **DIR_MOUNTS}.items()),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
bin_dir = tmp_path / "bin"
|
|
||||||
bin_dir.mkdir()
|
|
||||||
_write_exec(bin_dir / "docker", FAKE_DOCKER)
|
|
||||||
if shutil.which("sha256sum") is None:
|
|
||||||
_write_exec(bin_dir / "sha256sum", SHA_SHIM)
|
|
||||||
(tmp_path / "cid").write_text(CID + "\n", encoding="utf-8")
|
|
||||||
(tmp_path / "log").write_text("", encoding="utf-8")
|
|
||||||
return tree
|
|
||||||
|
|
||||||
|
|
||||||
def _run(tree: Path, **env_extra: str) -> tuple[int, str, list[str]]:
|
|
||||||
root = tree.parent.parent
|
|
||||||
env = {
|
|
||||||
"PATH": f"{root / 'bin'}:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
||||||
"FAKE_LOG": str(root / "log"),
|
|
||||||
"FAKE_CID_FILE": str(root / "cid"),
|
|
||||||
"FAKE_MOUNTS": str(root / "mounts"),
|
|
||||||
"FAKE_VIEW": str(root / "view"),
|
|
||||||
**env_extra,
|
|
||||||
}
|
|
||||||
proc = subprocess.run(
|
|
||||||
["sh", str(tree / "ops" / "caddy-apply.sh")],
|
|
||||||
cwd=str(tree),
|
|
||||||
env=env,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
calls = [c for c in (root / "log").read_text(encoding="utf-8").splitlines() if c]
|
|
||||||
return proc.returncode, proc.stdout + proc.stderr, calls
|
|
||||||
|
|
||||||
|
|
||||||
def _stale(tree: Path, dst: str) -> None:
|
|
||||||
"""Контейнер остался на старом иноде этого маунта."""
|
|
||||||
view = tree.parent.parent / "view" / dst.replace("/", "_")
|
|
||||||
view.write_text("# версия СТАРАЯ\n", encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _recreated(calls: list[str]) -> bool:
|
|
||||||
return any("--force-recreate" in c for c in calls)
|
|
||||||
|
|
||||||
|
|
||||||
def _reloaded(calls: list[str]) -> bool:
|
|
||||||
return any("caddy reload" in c for c in calls)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Что скрипт делает на самом деле ──────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_nothing_changed_reloads_without_recreate(prod_tree: Path) -> None:
|
|
||||||
"""Обычный полный деплой (конфиг прокси не трогали): reload, без окна."""
|
|
||||||
rc, out, calls = _run(prod_tree)
|
|
||||||
assert rc == 0, out
|
|
||||||
assert not _recreated(calls), (
|
|
||||||
f"Caddy пересоздан, хотя ничего не изменилось: {calls}. "
|
|
||||||
"Это и есть #3443: 67 с code=000 на всех доменах при каждом деплое."
|
|
||||||
)
|
|
||||||
assert _reloaded(calls), f"конфиг не применён вовсе: {calls}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_changed_file_mount_forces_recreate(prod_tree: Path) -> None:
|
|
||||||
"""Caddyfile правлен: reload перечитал бы старый инод — нужен recreate."""
|
|
||||||
_stale(prod_tree, "/etc/caddy/Caddyfile")
|
|
||||||
rc, out, calls = _run(prod_tree)
|
|
||||||
assert rc == 0, out
|
|
||||||
assert _recreated(calls), (
|
|
||||||
f"пересоздания нет: {calls}. Пофайловый bind-маунт держит инод — правка "
|
|
||||||
"Caddyfile не доехала бы до контейнера, а деплой ушёл бы зелёным."
|
|
||||||
)
|
|
||||||
assert "/etc/caddy/Caddyfile" in out, f"решение не названо в логе:\n{out}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("dst", sorted(set(FILE_MOUNTS.values()) - {"/etc/caddy/Caddyfile"}))
|
|
||||||
def test_changed_snippet_forces_recreate(prod_tree: Path, dst: str) -> None:
|
|
||||||
"""Каждый из четырёх сниппетов — тот же класс, не только Caddyfile."""
|
|
||||||
_stale(prod_tree, dst)
|
|
||||||
_, _out, calls = _run(prod_tree)
|
|
||||||
assert _recreated(calls), f"{dst}: правка сниппета не доехала бы: {calls}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_directory_mount_change_does_not_recreate(prod_tree: Path) -> None:
|
|
||||||
"""caddy/sites/apps.caddy — самый частый случай; каталог инод не держит.
|
|
||||||
|
|
||||||
Если сюда приползёт пересоздание «за компанию», окно недоступности вернётся
|
|
||||||
ровно на тех правках, ради которых заведён быстрый путь #2916.
|
|
||||||
"""
|
|
||||||
(prod_tree / "caddy" / "sites" / "apps.caddy").write_text("# новый блок\n", encoding="utf-8")
|
|
||||||
rc, out, calls = _run(prod_tree)
|
|
||||||
assert rc == 0, out
|
|
||||||
assert not _recreated(calls), f"правка в КАТАЛОГЕ вызвала пересоздание: {calls}"
|
|
||||||
assert _reloaded(calls), f"правка в каталоге не применена: {calls}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_broken_config_touches_nothing(prod_tree: Path) -> None:
|
|
||||||
"""Битый конфиг: ни up, ни reload, ни пересоздания — прокси не тронут.
|
|
||||||
|
|
||||||
Иначе опечатка в Caddyfile уводит контейнер в crash-loop и роняет все
|
|
||||||
домены сразу (ровно то, чем опасен `--force-recreate` вслепую).
|
|
||||||
"""
|
|
||||||
rc, out, calls = _run(prod_tree, FAKE_VALIDATE_RC="1")
|
|
||||||
assert rc != 0, f"скрипт не упал на битом конфиге:\n{out}"
|
|
||||||
assert not _recreated(calls), f"битый конфиг поехал в пересоздание: {calls}"
|
|
||||||
assert not _reloaded(calls), f"битый конфиг поехал в reload: {calls}"
|
|
||||||
assert not any(" up " in f" {c} " for c in calls), f"был `up` при битом конфиге: {calls}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_compose_recreate_is_not_doubled(prod_tree: Path) -> None:
|
|
||||||
"""compose пересоздал сам (сменилось описание сервиса/образ) — хватит.
|
|
||||||
|
|
||||||
Второй `--force-recreate` поверх — ещё одно окно недоступности на ровном
|
|
||||||
месте, а новый контейнер и так читает свежие файлы.
|
|
||||||
"""
|
|
||||||
rc, out, calls = _run(prod_tree, FAKE_UP_RECREATES="1")
|
|
||||||
assert rc == 0, out
|
|
||||||
assert not _recreated(calls), f"пересоздание сделано дважды: {calls}"
|
|
||||||
assert not _reloaded(calls), f"reload поверх свежего контейнера: {calls}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unreadable_container_view_falls_back_to_recreate(prod_tree: Path) -> None:
|
|
||||||
"""Сверка не отработала (контейнер не отвечает) → прежнее поведение.
|
|
||||||
|
|
||||||
Fail-safe направлен в сторону пересоздания: лучше окно в секунды, чем
|
|
||||||
беззвучно не применённая правка конфига прокси.
|
|
||||||
"""
|
|
||||||
(prod_tree.parent.parent / "view" / "_etc_caddy_Caddyfile").unlink()
|
|
||||||
_, out, calls = _run(prod_tree)
|
|
||||||
assert _recreated(calls), f"непрочитанный маунт сочли доехавшим: {calls}\n{out}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unreadable_mount_list_falls_back_to_recreate(prod_tree: Path) -> None:
|
|
||||||
"""`docker inspect` не ответил → пересоздать, а не «расхождений нет».
|
|
||||||
|
|
||||||
Статус `$(docker inspect … | while …)` — это статус `while`, то есть всегда
|
|
||||||
0, а `pipefail` в POSIX-sh не существует. Провал команды давал бы пустой
|
|
||||||
список маунтов, ветку «всё доехало» и зелёную строку «окна недоступности
|
|
||||||
нет» при прокси, работающем по СТАРОМУ конфигу — тот самый беззвучный отказ,
|
|
||||||
ради которого написан скрипт.
|
|
||||||
"""
|
|
||||||
rc, out, calls = _run(prod_tree, FAKE_INSPECT_RC="1")
|
|
||||||
assert rc == 0, out
|
|
||||||
assert _recreated(calls), f"непрочитанный список маунтов сочли «всё доехало»: {calls}\n{out}"
|
|
||||||
assert not _reloaded(calls), f"reload вместо пересоздания: {calls}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_file_mounts_is_not_silence(prod_tree: Path) -> None:
|
|
||||||
"""Ноль пофайловых маунтов — не «сверка прошла», а «сверять было нечем».
|
|
||||||
|
|
||||||
Так выглядит, например, перевод Caddyfile на именованный том: фильтр
|
|
||||||
`{{if eq .Type "bind"}}` перестаёт что-либо отбирать, и сверка становится
|
|
||||||
тавтологически успешной.
|
|
||||||
"""
|
|
||||||
root = prod_tree.parent.parent
|
|
||||||
(root / "mounts").write_text(
|
|
||||||
"".join(f"{prod_tree / rel}|{dst}\n" for rel, dst in DIR_MOUNTS.items()), encoding="utf-8"
|
|
||||||
)
|
|
||||||
rc, out, calls = _run(prod_tree)
|
|
||||||
assert rc == 0, out
|
|
||||||
assert _recreated(calls), f"пустая сверка сочтена успешной: {calls}\n{out}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_host_file_is_not_skipped_as_a_directory(prod_tree: Path) -> None:
|
|
||||||
"""Файла на хосте нет — это расхождение, а не «нечего сверять».
|
|
||||||
|
|
||||||
Пропуск по `[ -f "$src" ] || continue` склеивает два разных случая: каталог
|
|
||||||
(пропустить верно — инод он не держит) и исчезнувший/нечитаемый файл, для
|
|
||||||
которого в контейнере как раз живёт старый инод со старым текстом. Файл
|
|
||||||
удаляется после проверки конфига (в тесте она подставная) — проверяется
|
|
||||||
именно ветка сверки.
|
|
||||||
"""
|
|
||||||
(prod_tree / "Caddyfile").unlink()
|
|
||||||
_, out, calls = _run(prod_tree)
|
|
||||||
assert _recreated(calls), f"исчезнувший файл сочли доехавшим: {calls}\n{out}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_log_says_how_many_mounts_were_compared(prod_tree: Path) -> None:
|
|
||||||
"""В логе должно быть ЧИСЛО сверенных файлов, а не только вердикт.
|
|
||||||
|
|
||||||
«Сверили пять» и «сверили ноль» обязаны различаться: иначе строка
|
|
||||||
«перезагружен без пересоздания» одинаково означает и проверку, и её
|
|
||||||
отсутствие.
|
|
||||||
"""
|
|
||||||
_, out, _ = _run(prod_tree)
|
|
||||||
assert re.search(r"сверено пофайловых маунтов[^\n]*: 5", out), (
|
|
||||||
f"скрипт не печатает число сверенных маунтов (их пять):\n{out}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validation_precedes_any_action(prod_tree: Path) -> None:
|
|
||||||
"""Проверка конфига идёт ПЕРВЫМ вызовом, до любого изменения состояния."""
|
|
||||||
_, out, calls = _run(prod_tree)
|
|
||||||
assert calls, f"скрипт не сделал ни одного вызова docker:\n{out}"
|
|
||||||
assert calls[0].startswith("run "), f"первым идёт не проверка конфига: {calls}"
|
|
||||||
assert "caddy validate" in calls[0], f"первый вызов — не validate: {calls[0]}"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Проводка: оба пути деплоя зовут именно этот скрипт ───────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _ssh_script(job: str) -> str:
|
|
||||||
spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8"))
|
|
||||||
steps = [s for s in spec["jobs"][job]["steps"] if "ssh-action" in str(s.get("uses"))]
|
|
||||||
assert len(steps) == 1, f"в job `{job}` нет ровно одного ssh-шага — гейт #3443 ослеп"
|
|
||||||
script = steps[0]["with"]["script"]
|
|
||||||
assert script.strip(), f"ssh-скрипт job `{job}` пуст"
|
|
||||||
return script
|
|
||||||
|
|
||||||
|
|
||||||
def _commands(script: str) -> str:
|
|
||||||
"""Только команды: комментарии выкинуты, продолжения строк склеены.
|
|
||||||
|
|
||||||
Комментарии — потому что разбор дефекта живёт в тех же файлах и содержит
|
|
||||||
его формулировку дословно: гейт по голому тексту краснел бы от объяснения,
|
|
||||||
а не от кода. Склейка `\\` — потому что `--force-recreate` и имя сервиса
|
|
||||||
легко оказываются на РАЗНЫХ физических строках, и построчный поиск такую
|
|
||||||
запись не увидел бы (зелено по построению).
|
|
||||||
"""
|
|
||||||
kept = [ln for ln in script.splitlines() if not ln.lstrip().startswith("#")]
|
|
||||||
return re.sub(r"\\\n\s*", " ", "\n".join(kept))
|
|
||||||
|
|
||||||
|
|
||||||
def _forced_caddy_recreates(commands: str) -> list[str]:
|
|
||||||
"""Строки, которые пересоздают именно сервис caddy."""
|
|
||||||
return [
|
|
||||||
ln
|
|
||||||
for ln in commands.splitlines()
|
|
||||||
if "--force-recreate" in ln and re.search(r"\bcaddy\b", ln)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_script_exists_and_is_the_one_under_test() -> None:
|
|
||||||
"""Признак непустоты: без скрипта проверки выше проходили бы вхолостую."""
|
|
||||||
assert SCRIPT.is_file(), f"нет {SCRIPT} — проводка ниже проверяла бы пустоту"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("job", ["deploy", "deploy-caddy"])
|
|
||||||
def test_deploy_applies_caddy_config_through_the_script(job: str) -> None:
|
|
||||||
assert "ops/caddy-apply.sh" in _commands(_ssh_script(job)), (
|
|
||||||
f"job `{job}` не зовёт ops/caddy-apply.sh — конфиг прокси применяется "
|
|
||||||
"мимо разбора #3443 (или безусловным пересозданием, или reload'ом, "
|
|
||||||
"который на пофайловом маунте читает старый инод)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_full_deploy_has_no_unconditional_caddy_recreate() -> None:
|
|
||||||
"""Главный инвариант: в полном деплое нет безусловного пересоздания Caddy.
|
|
||||||
|
|
||||||
Возврат одной строки `up -d --force-recreate --no-deps caddy` в job `deploy`
|
|
||||||
возвращает 67-секундное окно `code=000` на всех доменах — и не краснит
|
|
||||||
ничего: деплой остаётся зелёным, а увидеть отказ может только непрерывная
|
|
||||||
проба, запущенная ровно в эту минуту.
|
|
||||||
"""
|
|
||||||
bad = _forced_caddy_recreates(_commands(_ssh_script("deploy")))
|
|
||||||
assert not bad, (
|
|
||||||
"в полный деплой вернулось безусловное пересоздание Caddy:\n "
|
|
||||||
+ "\n ".join(bad)
|
|
||||||
+ "\nПересоздание обязано быть УСЛОВНЫМ — см. ops/caddy-apply.sh: compose "
|
|
||||||
"сам пересоздаёт контейнер при смене описания сервиса или образа, а "
|
|
||||||
"вручную это нужно только когда до контейнера не доехал пофайловый "
|
|
||||||
"bind-маунт (#3443)."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_failures_are_not_swallowed() -> None:
|
|
||||||
"""Ни применение конфига, ни сам reload не гасятся `|| true`.
|
|
||||||
|
|
||||||
Строка `caddy reload … || true` в репозитории уже живёт
|
|
||||||
(deploy-tradein.yml), то есть это не гипотеза: с ней отказ применения
|
|
||||||
перестаёт краснеть, и «конфиг доехал» становится неотличимо от «команда
|
|
||||||
упала, а мы продолжили». Проверяется и вызов скрипта из обеих джоб, и
|
|
||||||
строка reload внутри самого скрипта.
|
|
||||||
"""
|
|
||||||
swallow = re.compile(r"\|\|\s*(true|:)\s*$")
|
|
||||||
offenders = []
|
|
||||||
for where, text in [
|
|
||||||
("deploy", _commands(_ssh_script("deploy"))),
|
|
||||||
("deploy-caddy", _commands(_ssh_script("deploy-caddy"))),
|
|
||||||
(SCRIPT.name, SCRIPT.read_text(encoding="utf-8")),
|
|
||||||
]:
|
|
||||||
for line in text.splitlines():
|
|
||||||
code = line.split("#", 1)[0] if not line.lstrip().startswith("#") else ""
|
|
||||||
if ("caddy-apply.sh" in code or "caddy reload" in code) and swallow.search(code):
|
|
||||||
offenders.append(f"{where}: {line.strip()}")
|
|
||||||
assert not offenders, "отказ применения конфига проглочен:\n " + "\n ".join(offenders)
|
|
||||||
|
|
||||||
|
|
||||||
def test_gate_runs_on_changes_to_the_script_itself() -> None:
|
|
||||||
"""CI-фильтр обязан пускать backend-тесты на правку ops/**.
|
|
||||||
|
|
||||||
Все содержательные регрессии живут в ops/caddy-apply.sh: проверки выше его
|
|
||||||
ИСПОЛНЯЮТ. Без `ops/**` в фильтре PR, правящий только скрипт, даёт
|
|
||||||
backend=false — джоба пропускается, гейт не исполняется, и «пересоздавать
|
|
||||||
всегда» уезжает в main зелёным. Тот же класс, что #2950/#3448/#3467.
|
|
||||||
"""
|
|
||||||
spec = yaml.safe_load((WORKFLOWS / "ci.yml").read_text(encoding="utf-8"))
|
|
||||||
steps = [
|
|
||||||
s
|
|
||||||
for job in spec["jobs"].values()
|
|
||||||
for s in job.get("steps") or []
|
|
||||||
if str(s.get("uses", "")).startswith("dorny/paths-filter")
|
|
||||||
]
|
|
||||||
assert steps, "в ci.yml не найден paths-filter — проверка прошла бы вхолостую"
|
|
||||||
patterns = [p for s in steps for p in yaml.safe_load(s["with"]["filters"]).get("backend") or []]
|
|
||||||
assert "ops/**" in patterns, (
|
|
||||||
f"фильтр backend не покрывает ops/** (сейчас: {patterns}) — гейт #3443 не "
|
|
||||||
"побежит на правке ops/caddy-apply.sh, то есть ровно на той правке, от "
|
|
||||||
"которой стережёт"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_gate_would_notice_the_regression() -> None:
|
|
||||||
"""Сам гейт обязан краснеть на возвращённом дефекте — проверка на себя.
|
|
||||||
|
|
||||||
Без этого «не нашли force-recreate» неотличимо от «искали не там»: маска
|
|
||||||
поиска, промахнувшаяся мимо строки, выглядит зелёной ровно так же.
|
|
||||||
"""
|
|
||||||
regressed = _commands(
|
|
||||||
" # безусловное пересоздание caddy вернулось сюда\n"
|
|
||||||
" docker compose -p gendesign -f docker-compose.prod.yml up -d \\\n"
|
|
||||||
" --force-recreate --no-deps caddy\n"
|
|
||||||
)
|
|
||||||
assert _forced_caddy_recreates(regressed), (
|
|
||||||
"маска поиска не видит дословно ту строку, ради которой заведён гейт"
|
|
||||||
)
|
|
||||||
|
|
@ -1,460 +0,0 @@
|
||||||
"""Гейт: быстрый путь «правка только прокси» действительно включается (#3448).
|
|
||||||
|
|
||||||
ЧТО СЛУЧИЛОСЬ. Быстрый путь #2916 (`caddy_only` → джоба `deploy-caddy` с
|
|
||||||
`caddy reload` вместо пересоздания контейнеров) не отработал ни разу за всё
|
|
||||||
время жизни. Проверено на мерже 84920e6c, где в диффе ровно один файл
|
|
||||||
`caddy/sites/apps.caddy`: контейнеры пересозданы, в логе Caddy
|
|
||||||
`serving initial configuration` — холодный старт, а не reload.
|
|
||||||
|
|
||||||
ПРИЧИНА — НЕ пустой `github.event.before` (рабочая гипотеза #3448 опровергнута
|
|
||||||
логом задачи 29244: `before` = 204e2e09…, `git diff` вернул ровно один файл).
|
|
||||||
Причина в том, что dorny/paths-filter склеивает шаблоны одного фильтра через
|
|
||||||
`some`, то есть ИЛИ (src/filter.ts: `patterns.some(aPredicate)`; параметр
|
|
||||||
`predicate-quantifier` по умолчанию `some`). Список
|
|
||||||
|
|
||||||
non_caddy: ['**', '!Caddyfile', '!caddy/**']
|
|
||||||
|
|
||||||
значит «подходит под `**` ИЛИ не Caddyfile ИЛИ не caddy/**», а `**` матчит всё —
|
|
||||||
non_caddy был true ВСЕГДА. В логе это видно дословно:
|
|
||||||
|
|
||||||
##[group]Filter non_caddy = true
|
|
||||||
Matching files:
|
|
||||||
caddy/sites/apps.caddy [modified]
|
|
||||||
|
|
||||||
исключённый файл сам себя и «исключил».
|
|
||||||
|
|
||||||
ЗАЧЕМ ЭТОТ ФАЙЛ. У самой правки нет отрицательного признака: пропущенную джобу
|
|
||||||
Forgejo рисует зелёной, поэтому «зелёный deploy-caddy» одинаково выглядит и
|
|
||||||
когда быстрый путь сработал, и когда его вообще не было. Проверки ниже
|
|
||||||
ИСПОЛНЯЮТ шаг определения файлов из deploy.yml на настоящем временном
|
|
||||||
репозитории (включая мерж-коммит — ровно случай #3448) и смотрят на значения
|
|
||||||
флагов, а не на текст воркфлоу. Так же исполняется и прод-сторож из джобы
|
|
||||||
`deploy-caddy`: быстрый путь пропускает джобу `deploy` целиком, а вместе с ней
|
|
||||||
и гард свежести :latest (#2950), поэтому перезагружать прокси можно, только
|
|
||||||
если прод отстаёт РОВНО на конфиг прокси. Регресс к исключающим шаблонам
|
|
||||||
paths-filter ловит отдельная проверка в конце.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
# backend/tests/ops/<этот файл> → корень репозитория
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
||||||
WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows"
|
|
||||||
DEPLOY = WORKFLOWS / "deploy.yml"
|
|
||||||
|
|
||||||
NULL_SHA = "0" * 40
|
|
||||||
|
|
||||||
# Файлы, которые лежат в тестовом репозитории до правки. Набор подобран так,
|
|
||||||
# чтобы фолбэк «база не определена» мог отличить полный деплой от пустого.
|
|
||||||
BASE_FILES = (
|
|
||||||
"caddy/sites/apps.caddy",
|
|
||||||
"Caddyfile",
|
|
||||||
"backend/app/main.py",
|
|
||||||
"frontend/src/page.tsx",
|
|
||||||
"data/sql/001.sql",
|
|
||||||
"docker-compose.prod.yml",
|
|
||||||
"README.md",
|
|
||||||
)
|
|
||||||
|
|
||||||
GIT_ENV = {
|
|
||||||
"GIT_AUTHOR_NAME": "t",
|
|
||||||
"GIT_AUTHOR_EMAIL": "t@example.com",
|
|
||||||
"GIT_COMMITTER_NAME": "t",
|
|
||||||
"GIT_COMMITTER_EMAIL": "t@example.com",
|
|
||||||
"GIT_CONFIG_GLOBAL": "/dev/null",
|
|
||||||
"GIT_CONFIG_SYSTEM": "/dev/null",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Эти кейсы гоняют `bash -c <скрипт>` во временном git-репозитории (POSIX shell +
|
|
||||||
# /dev/null + жёсткий PATH=/usr/bin:/bin:/usr/local/bin). На Windows дочерний
|
|
||||||
# процесс не стартует (нет POSIX shell на этом PATH); покрытие обеспечивается в
|
|
||||||
# CI на Linux. Кейсы, которые читают только YAML/строки без subprocess, не
|
|
||||||
# помечены и обязаны выполняться локально.
|
|
||||||
posix_only = pytest.mark.skipif(
|
|
||||||
sys.platform == "win32",
|
|
||||||
reason="запускает bash-скрипт дочерним процессом; POSIX-only, покрытие в CI (Linux)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_script() -> str:
|
|
||||||
"""Тело шага, который считает изменённые файлы в job `changes`."""
|
|
||||||
spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8"))
|
|
||||||
job = spec["jobs"]["changes"]
|
|
||||||
steps = [s for s in job["steps"] if s.get("id") == "filter"]
|
|
||||||
assert len(steps) == 1, (
|
|
||||||
"в job `changes` нет ровно одного шага с `id: filter` — определение "
|
|
||||||
"изменённых файлов переехало, гейт #3448 ослеп"
|
|
||||||
)
|
|
||||||
step = steps[0]
|
|
||||||
assert "run" in step, (
|
|
||||||
f"шаг `filter` не считает файлы сам, а делегирует их {step.get('uses')!r}. "
|
|
||||||
"Именно так и возник #3448: у dorny/paths-filter шаблоны одного фильтра "
|
|
||||||
"склеиваются через ИЛИ, поэтому `non_caddy: ['**', '!caddy/**']` был true "
|
|
||||||
"ВСЕГДА и быстрый путь не включался ни разу."
|
|
||||||
)
|
|
||||||
assert step["run"].strip(), "шаг `filter` пуст"
|
|
||||||
return step["run"]
|
|
||||||
|
|
||||||
|
|
||||||
def _git(repo: Path, *args: str) -> None:
|
|
||||||
subprocess.run(
|
|
||||||
["git", "-C", str(repo), *args], check=True, env=dict(GIT_ENV), capture_output=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _sha(repo: Path) -> str:
|
|
||||||
return subprocess.run(
|
|
||||||
["git", "-C", str(repo), "rev-parse", "HEAD"],
|
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
env=dict(GIT_ENV),
|
|
||||||
).stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _commit(repo: Path, files: tuple[str, ...], msg: str = "c") -> None:
|
|
||||||
for name in files:
|
|
||||||
path = repo / name
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text("changed\n", encoding="utf-8")
|
|
||||||
_git(repo, "add", "-A")
|
|
||||||
_git(repo, "commit", "-qm", msg, *([] if files else ["--allow-empty"]))
|
|
||||||
|
|
||||||
|
|
||||||
def _base_repo(tmp_path: Path) -> tuple[Path, str]:
|
|
||||||
"""Репозиторий с одним базовым коммитом; возвращает его sha — это `before`."""
|
|
||||||
repo = tmp_path / "repo"
|
|
||||||
repo.mkdir(parents=True)
|
|
||||||
_git(repo, "init", "-q", "-b", "main")
|
|
||||||
for name in BASE_FILES:
|
|
||||||
path = repo / name
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text("base\n", encoding="utf-8")
|
|
||||||
_git(repo, "add", "-A")
|
|
||||||
_git(repo, "commit", "-qm", "base")
|
|
||||||
return repo, _sha(repo)
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_commit(repo: Path, changed: tuple[str, ...]) -> None:
|
|
||||||
"""Ветка с правкой и мерж `--no-ff` обратно в main.
|
|
||||||
|
|
||||||
Мерж, а не обычный коммит, — потому что #3448 наблюдался именно на мерже
|
|
||||||
PR'а: у мерж-коммита две родительские линии, и любой разбор диффа обязан
|
|
||||||
работать на этой форме. Что `before` нельзя заменить на `HEAD^`, стережёт
|
|
||||||
отдельная проверка — test_multi_commit_push_is_not_truncated: на ОДНОМ
|
|
||||||
мерж-коммите `HEAD^..HEAD` даёт верный ответ и такую подмену не ловит.
|
|
||||||
"""
|
|
||||||
_git(repo, "checkout", "-q", "-b", "feature")
|
|
||||||
_commit(repo, changed, "feature")
|
|
||||||
_git(repo, "checkout", "-q", "main")
|
|
||||||
_git(repo, "merge", "-q", "--no-ff", "-m", "merge feature", "feature")
|
|
||||||
|
|
||||||
|
|
||||||
def _exec(repo: Path, before: str, event: str = "push") -> tuple[dict[str, str], str]:
|
|
||||||
out_file = repo.parent / "outputs"
|
|
||||||
out_file.touch()
|
|
||||||
env = {
|
|
||||||
"PATH": "/usr/bin:/bin:/usr/local/bin",
|
|
||||||
"BEFORE": before,
|
|
||||||
"EVENT": event,
|
|
||||||
"GITHUB_OUTPUT": str(out_file),
|
|
||||||
**GIT_ENV,
|
|
||||||
}
|
|
||||||
proc = subprocess.run(
|
|
||||||
["bash", "-c", _detect_script()],
|
|
||||||
cwd=repo,
|
|
||||||
env=env,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
assert proc.returncode == 0, f"шаг упал:\n{proc.stdout}\n{proc.stderr}"
|
|
||||||
outputs = dict(
|
|
||||||
line.split("=", 1)
|
|
||||||
for line in out_file.read_text(encoding="utf-8").splitlines()
|
|
||||||
if "=" in line
|
|
||||||
)
|
|
||||||
return outputs, proc.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def _run(
|
|
||||||
tmp_path: Path, changed: tuple[str, ...], *, before: str | None = None, event: str = "push"
|
|
||||||
) -> tuple[dict[str, str], str]:
|
|
||||||
repo, base_sha = _base_repo(tmp_path)
|
|
||||||
_merge_commit(repo, changed)
|
|
||||||
return _exec(repo, base_sha if before is None else before, event)
|
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_merge_with_only_caddy_file_takes_the_fast_path(tmp_path: Path) -> None:
|
|
||||||
"""Случай #3448 дословно: мерж, в диффе один файл под caddy/."""
|
|
||||||
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy",))
|
|
||||||
assert outputs["caddy_only"] == "true", (
|
|
||||||
f"быстрый путь не включился на правке ТОЛЬКО прокси: {outputs}. "
|
|
||||||
"Ровно это и есть #3448: deploy-caddy пропускается, идёт полный деплой "
|
|
||||||
"с пересозданием контейнеров, а Forgejo рисует пропуск зелёным."
|
|
||||||
)
|
|
||||||
assert outputs["backend"] == "false"
|
|
||||||
assert outputs["frontend"] == "false"
|
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_caddy_plus_backend_is_a_full_deploy(tmp_path: Path) -> None:
|
|
||||||
"""Обратное направление: быстрый путь НЕ должен красть обычный деплой."""
|
|
||||||
outputs, _ = _run(tmp_path, ("caddy/sites/apps.caddy", "backend/app/main.py"))
|
|
||||||
assert outputs["caddy_only"] == "false", (
|
|
||||||
f"быстрый путь включился, хотя вместе с конфигом приехал бэкенд: {outputs}. "
|
|
||||||
"Так прод остался бы на старом образе при зелёном деплое."
|
|
||||||
)
|
|
||||||
assert outputs["backend"] == "true"
|
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_missing_base_falls_back_to_full_deploy(tmp_path: Path) -> None:
|
|
||||||
"""База не разрешилась → полный деплой, а не пустой список.
|
|
||||||
|
|
||||||
Пустой список изменений — это `caddy_only` без единого caddy-файла и
|
|
||||||
отключённая сборка: отказ, который выглядит как успешный быстрый путь.
|
|
||||||
"""
|
|
||||||
for before, event in ((NULL_SHA, "push"), ("", "push"), (None, "workflow_dispatch")):
|
|
||||||
outputs, log = _run(
|
|
||||||
tmp_path / f"case-{event}-{before!r}",
|
|
||||||
("caddy/sites/apps.caddy",),
|
|
||||||
before=before,
|
|
||||||
event=event,
|
|
||||||
)
|
|
||||||
assert outputs["caddy_only"] == "false", f"before={before!r} event={event}: {outputs}"
|
|
||||||
assert outputs["backend"] == "true", f"before={before!r} event={event}: {outputs}"
|
|
||||||
assert outputs["frontend"] == "true", f"before={before!r} event={event}: {outputs}"
|
|
||||||
assert outputs["infra"] == "true", f"before={before!r} event={event}: {outputs}"
|
|
||||||
assert "деплой полный" in log
|
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_decision_is_visible_in_the_log(tmp_path: Path) -> None:
|
|
||||||
"""Решение печатается: и список файлов, и итоговые флаги.
|
|
||||||
|
|
||||||
Без этого «сработало» и «просто не совпало» неотличимы — единственным
|
|
||||||
свидетелем остаётся метка Created у контейнера на проде.
|
|
||||||
"""
|
|
||||||
_, log = _run(tmp_path, ("caddy/sites/apps.caddy",))
|
|
||||||
assert "caddy/sites/apps.caddy" in log, f"шаг не печатает список файлов:\n{log}"
|
|
||||||
assert "caddy_only=true" in log and "backend=false" in log, (
|
|
||||||
f"шаг не печатает итоговые флаги:\n{log}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Быстрый путь на самом проде: джоба deploy-caddy ──────────────────────────
|
|
||||||
#
|
|
||||||
# Пока caddy_only был мёртв, каждый push шёл полным деплоем, и гард свежести
|
|
||||||
# :latest (#2950, job `deploy`) прикрывал прод по умолчанию. Оживший быстрый
|
|
||||||
# путь его обходит: при caddy_only=true джоба `deploy` пропускается целиком.
|
|
||||||
# Дифф between-push (before→HEAD) не знает, что реально доехало до прода:
|
|
||||||
# отменённая очередью `deploy` предыдущего прогона оставляет прод на старом
|
|
||||||
# образе, а следующий caddy-only push честно видит «изменился один caddy-файл».
|
|
||||||
|
|
||||||
|
|
||||||
def _caddy_deploy_script() -> str:
|
|
||||||
spec = yaml.safe_load(DEPLOY.read_text(encoding="utf-8"))
|
|
||||||
steps = [s for s in spec["jobs"]["deploy-caddy"]["steps"] if "ssh-action" in str(s.get("uses"))]
|
|
||||||
assert len(steps) == 1, "в deploy-caddy нет ровно одного ssh-шага — гейт #3448 ослеп"
|
|
||||||
return steps[0]["with"]["script"]
|
|
||||||
|
|
||||||
|
|
||||||
def _prod_lag_guard() -> str:
|
|
||||||
"""Кусок ssh-скрипта от вычисления PROD_HEAD до `git reset --hard`."""
|
|
||||||
script = _caddy_deploy_script()
|
|
||||||
assert "PROD_HEAD=" in script, (
|
|
||||||
"джоба deploy-caddy не сверяет отставание прода: быстрый путь перезагрузит "
|
|
||||||
"прокси и уйдёт зелёным, оставив прод на старом образе (#3448)"
|
|
||||||
)
|
|
||||||
# Ищем КОМАНДУ, а не подстроку: `git reset --hard` упоминается выше в
|
|
||||||
# комментариях, и поиск по тексту нашёл бы объяснение вместо кода.
|
|
||||||
reset_cmd = re.search(r"(?m)^\s*git reset --hard", script)
|
|
||||||
assert reset_cmd, "в deploy-caddy пропал `git reset --hard` — гейт опирается на него"
|
|
||||||
start, reset = script.index("PROD_HEAD="), reset_cmd.start()
|
|
||||||
assert start < reset, (
|
|
||||||
"проверка отставания прода стоит ПОСЛЕ `git reset --hard` — при отказе "
|
|
||||||
"прод-HEAD уже переписан, и следующий прогон снова уйдёт быстрым путём"
|
|
||||||
)
|
|
||||||
return "set -euo pipefail\n" + script[start:reset]
|
|
||||||
|
|
||||||
|
|
||||||
def _prod_repo(tmp_path: Path, ahead: tuple[str, ...]) -> Path:
|
|
||||||
"""Прод-дерево на базовом коммите, origin/main — на `ahead` впереди."""
|
|
||||||
repo, base_sha = _base_repo(tmp_path)
|
|
||||||
_commit(repo, ahead, "ahead")
|
|
||||||
_git(repo, "update-ref", "refs/remotes/origin/main", "HEAD")
|
|
||||||
_git(repo, "reset", "--hard", "-q", base_sha)
|
|
||||||
return repo
|
|
||||||
|
|
||||||
|
|
||||||
def _run_guard(repo: Path) -> subprocess.CompletedProcess:
|
|
||||||
return subprocess.run(
|
|
||||||
["bash", "-c", _prod_lag_guard()],
|
|
||||||
cwd=repo,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
env={"PATH": "/usr/bin:/bin:/usr/local/bin", **GIT_ENV},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_fast_path_allowed_when_prod_lags_only_by_proxy_config(tmp_path: Path) -> None:
|
|
||||||
proc = _run_guard(_prod_repo(tmp_path, ("caddy/sites/apps.caddy",)))
|
|
||||||
assert proc.returncode == 0, f"законный быстрый путь заблокирован:\n{proc.stdout}{proc.stderr}"
|
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_fast_path_refuses_when_prod_lags_by_code(tmp_path: Path) -> None:
|
|
||||||
"""Прод отстаёт не только по конфигу прокси → перезагрузка прокси запрещена."""
|
|
||||||
proc = _run_guard(_prod_repo(tmp_path, ("backend/app/main.py", "caddy/sites/apps.caddy")))
|
|
||||||
assert proc.returncode != 0, (
|
|
||||||
"быстрый путь разрешён, хотя прод отстаёт по коду бэкенда: перезагрузка "
|
|
||||||
f"прокси подменила бы выкатку, деплой ушёл бы зелёным.\n{proc.stdout}"
|
|
||||||
)
|
|
||||||
assert "backend/app/main.py" in proc.stdout, (
|
|
||||||
f"отказ не называет файлы, из-за которых он произошёл:\n{proc.stdout}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_fast_path_takes_the_same_host_lock() -> None:
|
|
||||||
"""deploy-caddy правит прод-дерево — значит берёт тот же лок, что `deploy`.
|
|
||||||
|
|
||||||
Проверка текстовая, как в test_2950: исполнить flock-секцию в тесте нельзя,
|
|
||||||
а её пропажа не даёт ни одного сигнала до совпадения окон двух деплоев.
|
|
||||||
"""
|
|
||||||
script = _caddy_deploy_script()
|
|
||||||
assert "exec 9>/var/lock/gendesign-docker-deploy.lock" in script, (
|
|
||||||
"deploy-caddy делает `git reset --hard` в /opt/gendesign в обход лока, "
|
|
||||||
"которым полный деплой сериализует работу с прод-деревом (#2950)"
|
|
||||||
)
|
|
||||||
assert "flock -w 900 9" in script, "лок открывается, но не захватывается"
|
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"changed", [("caddy-extra/x.txt",), ("Caddyfile.bak",), ("docs/caddy.md",)]
|
|
||||||
)
|
|
||||||
def test_paths_that_merely_start_with_caddy_are_not_the_fast_path(
|
|
||||||
tmp_path: Path, changed: tuple[str, ...]
|
|
||||||
) -> None:
|
|
||||||
"""`caddy-extra/…` и `Caddyfile.bak` — НЕ конфиг прокси.
|
|
||||||
|
|
||||||
Граница шаблона — единственное, что отделяет быстрый путь от тихого
|
|
||||||
пропуска полного деплоя: `^(Caddyfile|caddy)` вместо `^(Caddyfile$|caddy/)`
|
|
||||||
отправил бы эти правки перезагружать прокси вместо выкатки.
|
|
||||||
"""
|
|
||||||
outputs, _ = _run(tmp_path, changed)
|
|
||||||
assert outputs["caddy_only"] == "false", f"{changed}: {outputs}"
|
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_empty_diff_is_not_the_fast_path(tmp_path: Path) -> None:
|
|
||||||
"""Пустой дифф (`before` == HEAD, пустой мерж) — не «всё под caddy».
|
|
||||||
|
|
||||||
Без проверки «файлов больше нуля» пустой список формально удовлетворяет
|
|
||||||
«ни один файл не лежит вне caddy»: сборка отключается, деплой подменяется
|
|
||||||
перезагрузкой прокси — отказ, выглядящий как успешный быстрый путь.
|
|
||||||
"""
|
|
||||||
outputs, log = _run(tmp_path, ())
|
|
||||||
assert outputs["caddy_only"] == "false", f"пустой дифф ушёл в быстрый путь: {outputs}"
|
|
||||||
assert "изменённых файлов: 0" in log
|
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_data_sql_counts_as_backend(tmp_path: Path) -> None:
|
|
||||||
"""`data/sql/**` собирает backend-образ: миграции едут в нём."""
|
|
||||||
outputs, _ = _run(tmp_path, ("data/sql/002.sql",))
|
|
||||||
assert outputs["backend"] == "true", outputs
|
|
||||||
assert outputs["caddy_only"] == "false", outputs
|
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_non_ascii_path_is_classified(tmp_path: Path) -> None:
|
|
||||||
"""Кириллица в пути не должна прятать файл от классификации.
|
|
||||||
|
|
||||||
`git diff --name-only` при `core.quotePath=true` (умолчание) отдаёт
|
|
||||||
не-ASCII пути закавыченными и с \\NNN-экранированием — `^backend/`
|
|
||||||
такую строку не матчит. Старый paths-filter брал `--name-status -z`, где
|
|
||||||
квотирования нет; при переходе на свой diff это единственное место, где
|
|
||||||
поведение могло разойтись. В дереве такие пути уже живут (docs/).
|
|
||||||
"""
|
|
||||||
outputs, log = _run(tmp_path, ("backend/модуль.py",))
|
|
||||||
assert outputs["backend"] == "true", f"кириллический путь потерян: {outputs}\n{log}"
|
|
||||||
|
|
||||||
|
|
||||||
@posix_only
|
|
||||||
def test_multi_commit_push_is_not_truncated(tmp_path: Path) -> None:
|
|
||||||
"""Push из нескольких коммитов разбирается целиком, а не по последнему.
|
|
||||||
|
|
||||||
Ровно та подмена, которую соблазнительно сделать «чтобы не зависеть от
|
|
||||||
before»: `HEAD^..HEAD`. На одном мерж-коммите она даёт верный ответ и
|
|
||||||
выглядит рабочей, а здесь — молча теряет бэкенд из первого коммита и
|
|
||||||
включает быстрый путь, то есть пропускает выкатку кода.
|
|
||||||
"""
|
|
||||||
repo, base_sha = _base_repo(tmp_path)
|
|
||||||
_commit(repo, ("backend/app/main.py",), "backend")
|
|
||||||
_commit(repo, ("caddy/sites/apps.caddy",), "caddy")
|
|
||||||
outputs, log = _exec(repo, base_sha)
|
|
||||||
assert outputs["backend"] == "true", f"первый коммит push'а потерян: {outputs}\n{log}"
|
|
||||||
assert outputs["caddy_only"] == "false", outputs
|
|
||||||
|
|
||||||
|
|
||||||
def _paths_filter_steps() -> list[tuple[Path, str, dict]]:
|
|
||||||
"""Все шаги dorny/paths-filter во всех воркфлоу (включая .yaml)."""
|
|
||||||
found = []
|
|
||||||
for path in sorted(WORKFLOWS.glob("*.y*ml")):
|
|
||||||
spec = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
||||||
for job_name, job in (spec.get("jobs") or {}).items():
|
|
||||||
for step in job.get("steps") or []:
|
|
||||||
if str(step.get("uses", "")).startswith("dorny/paths-filter"):
|
|
||||||
found.append((path, job_name, step))
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def test_exclusion_gate_has_something_to_check() -> None:
|
|
||||||
"""Признак непустоты: проверка ниже обязана что-то находить.
|
|
||||||
|
|
||||||
Переименуют действие, разнесут воркфлоу по .yaml, уедут шаги — и гейт
|
|
||||||
пройдёт при нулевом охвате, молча (ровно то, от чего страхуется ci.yml:190).
|
|
||||||
"""
|
|
||||||
steps = _paths_filter_steps()
|
|
||||||
assert steps, (
|
|
||||||
"не найдено ни одного шага dorny/paths-filter — проверка исключающих "
|
|
||||||
"шаблонов прошла бы впустую, перепроверь маску поиска"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"path,job_name,step",
|
|
||||||
_paths_filter_steps(),
|
|
||||||
ids=[f"{p.name}:{j}" for p, j, _ in _paths_filter_steps()],
|
|
||||||
)
|
|
||||||
def test_no_paths_filter_relies_on_exclusion_patterns(
|
|
||||||
path: Path, job_name: str, step: dict
|
|
||||||
) -> None:
|
|
||||||
"""Ни один paths-filter в репозитории не вычитает пути через `!`.
|
|
||||||
|
|
||||||
Класс бага, а не единственный его случай: при `predicate-quantifier: some`
|
|
||||||
(умолчание) шаблоны фильтра склеиваются через ИЛИ, и `!` ничего не вычитает.
|
|
||||||
"""
|
|
||||||
with_ = step.get("with") or {}
|
|
||||||
if with_.get("predicate-quantifier") == "every":
|
|
||||||
pytest.skip("predicate-quantifier: every — шаблоны склеиваются через И")
|
|
||||||
filters = yaml.safe_load(with_.get("filters") or "") or {}
|
|
||||||
assert filters, f"{path.name}: job {job_name} — у paths-filter пустой блок filters"
|
|
||||||
for filter_name, patterns in filters.items():
|
|
||||||
bad = [p for p in (patterns or []) if isinstance(p, str) and p.startswith("!")]
|
|
||||||
assert not bad, (
|
|
||||||
f"{path.name}: job {job_name}, фильтр {filter_name!r} вычитает пути "
|
|
||||||
f"шаблонами {bad} — при `some` (умолчание) они склеиваются через ИЛИ "
|
|
||||||
"и фильтр становится true ВСЕГДА. Так #2916 не сработал ни разу (#3448)."
|
|
||||||
)
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
"""Правки Prometheus-конфига/правил обязаны доезжать до работающего процесса.
|
|
||||||
|
|
||||||
ЧТО СЛУЧИЛОСЬ НА ПРОДЕ. `GET /api/v1/status/runtimeinfo` внутри
|
|
||||||
`gendesign-prometheus` 12.09 отдавал `lastConfigTime`, совпадающий со
|
|
||||||
`startTime` контейнера, — конфиг и правила не перечитывались 16 суток.
|
|
||||||
Деплой при этом был зелёный: `docker compose up -d` не пересоздаёт
|
|
||||||
контейнер из-за изменения содержимого бинд-маунта (он сравнивает только
|
|
||||||
описание сервиса), а `--web.enable-lifecycle` был включён в
|
|
||||||
docker-compose.metrics.yml, но эндпоинт `/-/reload` никто не вызывал.
|
|
||||||
|
|
||||||
Тот же класс бага, что уже пойман и починен для Caddy (`caddy reload`)
|
|
||||||
и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py)
|
|
||||||
в этом же workflow. `POST /-/reload` лечит правила — они подключены
|
|
||||||
КАТАЛОГОМ. prometheus.yml же — бинд-маунт ОДНОГО файла: reload перечитывает
|
|
||||||
за тем же путём СТАРЫЙ инод (prom/prometheus:v3.1.0, 17.09: файл подменён,
|
|
||||||
reload rc=0, в /api/v1/status/config прежний job). Поэтому при расхождении
|
|
||||||
инода контейнер пересоздаётся (ops/metrics/recreate-stale-mount.sh, его
|
|
||||||
поведение — test_metrics_single_file_mounts.py), а promtool проверяет файлы С
|
|
||||||
ДИСКА: `docker exec` в работающий контейнер проверял бы старый инод.
|
|
||||||
|
|
||||||
Проверяется здесь: (1) валидация promtool ЕСТЬ и идёт по файлам с диска,
|
|
||||||
(2) reload и пересоздание — ТОЛЬКО после успешной валидации, (3) шаг обязан
|
|
||||||
упасть, если reload не подтверждён сменой lastConfigTime, (4) датасорсы
|
|
||||||
Grafana перечитываются, и отказ этого шага роняет деплой.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
||||||
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
|
||||||
CHECK_CONFIG = "promtool check config /etc/prometheus/prometheus.yml"
|
|
||||||
|
|
||||||
|
|
||||||
def _text() -> str:
|
|
||||||
return WORKFLOW.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _guard_start(text: str) -> int:
|
|
||||||
"""Начало `if <проверка promtool>; then` — ближайший `if` перед check config."""
|
|
||||||
guard = text.rfind("\n if ", 0, text.index(CHECK_CONFIG))
|
|
||||||
assert guard != -1, "проверка promtool не стоит под `if` — reload ничем не защищён"
|
|
||||||
return guard
|
|
||||||
|
|
||||||
|
|
||||||
def test_promtool_checks_config_and_rules() -> None:
|
|
||||||
"""promtool обязан проверять и конфиг, и правила — не только один файл."""
|
|
||||||
text = _text()
|
|
||||||
assert "promtool check config /etc/prometheus/prometheus.yml" in text, (
|
|
||||||
"нет проверки конфига promtool'ом — битый prometheus.yml долетит до reload"
|
|
||||||
)
|
|
||||||
assert "promtool check rules" in text, (
|
|
||||||
"нет проверки правил promtool'ом — битое правило долетит до reload"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_reload_endpoint_is_called() -> None:
|
|
||||||
"""Сам reload обязан вызываться — иначе валидация ничего не решает."""
|
|
||||||
text = _text()
|
|
||||||
assert "localhost:9090/-/reload" in text, (
|
|
||||||
"нет вызова POST /-/reload — конфиг/правила проверяются, но в силу не вступают "
|
|
||||||
"(#3467: lastConfigTime не менялся 16 суток при зелёном деплое)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_reload_happens_after_validation_not_before() -> None:
|
|
||||||
"""Reload обязан идти ПОСЛЕ promtool, а не до/вместо него."""
|
|
||||||
text = _text()
|
|
||||||
check_pos = text.index("promtool check config /etc/prometheus/prometheus.yml")
|
|
||||||
reload_pos = text.index("localhost:9090/-/reload")
|
|
||||||
assert check_pos < reload_pos, (
|
|
||||||
"reload стоит раньше проверки конфига — битый конфиг мог бы применяться вслепую"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_reload_is_guarded_by_the_promtool_check() -> None:
|
|
||||||
"""Reload обязан быть ВНУТРИ `if promtool ...; then`, а не безусловным."""
|
|
||||||
text = _text()
|
|
||||||
guard_start = _guard_start(text)
|
|
||||||
else_pos = text.index("else", guard_start)
|
|
||||||
reload_pos = text.index("localhost:9090/-/reload")
|
|
||||||
assert guard_start < reload_pos < else_pos, (
|
|
||||||
"вызов reload лежит вне ветки успешной проверки promtool — "
|
|
||||||
"битый конфиг всё равно приведёт к reload, либо reload вообще не защищён проверкой"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_failed_validation_skips_reload_and_fails_the_step() -> None:
|
|
||||||
"""При провале promtool — reload НЕ вызывается, и шаг падает (exit 1)."""
|
|
||||||
text = _text()
|
|
||||||
guard_start = _guard_start(text)
|
|
||||||
else_pos = text.index("else", guard_start)
|
|
||||||
fi_pos = text.index("fi", else_pos)
|
|
||||||
else_branch = text[else_pos:fi_pos]
|
|
||||||
assert "localhost:9090/-/reload" not in else_branch, (
|
|
||||||
"reload вызывается даже в ветке провалившейся проверки"
|
|
||||||
)
|
|
||||||
assert "exit 1" in else_branch, (
|
|
||||||
"провал promtool не роняет шаг — деплой останется зелёным при битом конфиге"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_acceptance_checks_last_config_time_actually_changed() -> None:
|
|
||||||
"""Приёмка обязана сверять `lastConfigTime` до/после, а не доверять коду ответа reload.
|
|
||||||
|
|
||||||
`wget` на POST /-/reload может отрапортовать успех, даже если Prometheus
|
|
||||||
молча остался на старом конфиге (например, если бинарь внутри образа не
|
|
||||||
поддерживает --post-data так, как ожидалось) — единственное надёжное
|
|
||||||
подтверждение реального перечитывания конфига это смена таймстемпа.
|
|
||||||
"""
|
|
||||||
text = _text()
|
|
||||||
assert text.count("lastConfigTime") >= 2, (
|
|
||||||
"нет сравнения lastConfigTime до/после — reload не проверяется по факту"
|
|
||||||
)
|
|
||||||
assert "LAST_CONFIG_BEFORE" in text and "LAST_CONFIG_AFTER" in text, (
|
|
||||||
"нет явного до/после сравнения таймстемпа последней перезагрузки конфига"
|
|
||||||
)
|
|
||||||
verify_start = text.index("LAST_CONFIG_AFTER")
|
|
||||||
verify_block_end = text.index("Prometheus: конфиг и правила проверены", verify_start)
|
|
||||||
verify_block = text[verify_start:verify_block_end]
|
|
||||||
assert "exit 1" in verify_block, (
|
|
||||||
"если lastConfigTime не изменился, шаг обязан падать, а не считаться успешным"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_promtool_checks_files_from_disk_not_the_running_container() -> None:
|
|
||||||
"""Проверяется то, что сейчас применится, а не то, что держит контейнер.
|
|
||||||
|
|
||||||
prometheus.yml смонтирован одним файлом: `docker exec … promtool check config`
|
|
||||||
в работающем контейнере читает СТАРЫЙ инод — битая правка прошла бы проверку
|
|
||||||
и уехала бы в пересоздание.
|
|
||||||
"""
|
|
||||||
text = _text()
|
|
||||||
guard = text[_guard_start(text) : text.index(CHECK_CONFIG)]
|
|
||||||
assert "docker run --rm" in guard, "promtool запускается не одноразовым контейнером"
|
|
||||||
assert "/opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro" in guard, (
|
|
||||||
"одноразовый promtool смотрит не на файлы с диска по боевым путям"
|
|
||||||
)
|
|
||||||
assert not re.search(r"docker exec gendesign-prometheus[^\n]*promtool", text), (
|
|
||||||
"promtool всё ещё проверяет работающий контейнер — то есть старый инод"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_stale_prometheus_yml_is_recreated_between_check_and_reload() -> None:
|
|
||||||
"""Пересоздание по иноду — после проверки promtool и до reload."""
|
|
||||||
text = _text()
|
|
||||||
check = text.index("promtool check config /etc/prometheus/prometheus.yml")
|
|
||||||
recreate = re.search(
|
|
||||||
r"recreate-stale-mount\.sh \\\s+gendesign-metrics docker-compose\.metrics\.yml \\\s+"
|
|
||||||
r"prometheus ops/metrics/prometheus/prometheus\.yml /etc/prometheus/prometheus\.yml",
|
|
||||||
text,
|
|
||||||
)
|
|
||||||
assert recreate, "prometheus.yml не сверяется по иноду — правка конфига не доедет"
|
|
||||||
else_pos = text.index("else", _guard_start(text))
|
|
||||||
assert check < recreate.start() < text.index("localhost:9090/-/reload") < else_pos, (
|
|
||||||
"пересоздание Prometheus стоит не между успешной проверкой и reload"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_grafana_datasources_are_reloaded_and_failure_is_red() -> None:
|
|
||||||
"""Датасорсы Grafana применяются только при старте — их надо перечитать явно.
|
|
||||||
|
|
||||||
Замер в PR #3475 (grafana:11.5.1): изменённый url не применился и через 75 с,
|
|
||||||
POST /api/admin/provisioning/datasources/reload применил сразу. Ответ ручки в
|
|
||||||
v11.5.1 — «Datasources config reloaded» (pkg/api/admin_provisioning.go).
|
|
||||||
"""
|
|
||||||
text = _text()
|
|
||||||
call = text.find("http://localhost:3000/api/admin/provisioning/datasources/reload")
|
|
||||||
assert call != -1, "деплой не перечитывает датасорсы Grafana"
|
|
||||||
block = re.search(r'case "\$GRAFANA_RELOAD" in(.+?)\besac\b', text[call:], re.S)
|
|
||||||
assert block, "результат перезагрузки датасорсов не разбирается"
|
|
||||||
ok, _, failed = block.group(1).partition(";;")
|
|
||||||
assert "Datasources config reloaded" in ok, "успех не сверяется по тексту ответа ручки"
|
|
||||||
assert "exit 1" in failed and "exit 1" not in ok, "отказ перезагрузки не роняет деплой"
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
"""Гейт: `$value` в тексте алерта — это та величина, которую текст называет.
|
|
||||||
|
|
||||||
Найдено 12.09.2026 по боевым сообщениям в Telegram:
|
|
||||||
|
|
||||||
«apps / tradein-browser: 2.684e+11% от mem_limit. Дальше OOM-kill.»
|
|
||||||
«apps / listings: доля HOT 75.21%.» (при пороге срабатывания «доля < 20%»)
|
|
||||||
|
|
||||||
Причина у обоих одна и она про ФОРМУ выражения, а не про условие. В PromQL
|
|
||||||
`A and B` возвращает ЗНАЧЕНИЯ ЛЕВОЙ части, отфильтрованные правой. Значит в
|
|
||||||
`$value` попадает A, а не то отношение, ради которого правило написано:
|
|
||||||
|
|
||||||
- ContainerNearMemoryLimit: слева стоял `container_spec_memory_limit_bytes`,
|
|
||||||
и в сообщение уходил ЛИМИТ В БАЙТАХ, отрендеренный `humanizePercentage`
|
|
||||||
(2 684 354 560 → «2.684e+11%»). Условие при этом срабатывало верно.
|
|
||||||
- PostgresLowHotUpdateRatio: слева стоял `rate(tup_upd[6h])` — АПДЕЙТОВ В
|
|
||||||
СЕКУНДУ. Это опаснее: 0.7521 превращалось в «75.21%», число попадало в
|
|
||||||
правдоподобный диапазон и противоречило собственному порогу («доля < 20%»),
|
|
||||||
но выглядело настоящим.
|
|
||||||
|
|
||||||
Отсюда инвариант, который здесь и проверяется: **если описание рендерит
|
|
||||||
`$value` как долю (`humanizePercentage`), выражение обязано возвращать долю** —
|
|
||||||
то есть его ЛЕВАЯ часть (до первого `and`/`unless`) обязана содержать деление.
|
|
||||||
Отсев побочных условий переносится внутрь знаменателя (`X / (Y > 0)`), а не в
|
|
||||||
`and` слева.
|
|
||||||
|
|
||||||
Гейт намеренно не пытается «понять» PromQL целиком: он ловит ровно ту форму,
|
|
||||||
которая уже дважды уехала в прод, и не мешает правилам, печатающим абсолютные
|
|
||||||
величины без humanize (PostgresDeadTuplesHigh, PostgresIdleInTransaction).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
_RULES_DIR = Path(__file__).resolve().parents[3] / "ops" / "metrics" / "prometheus" / "rules"
|
|
||||||
|
|
||||||
|
|
||||||
def _alerts() -> list[tuple[str, str, str, dict]]:
|
|
||||||
"""(файл, имя алерта, expr, annotations) по всем файлам правил."""
|
|
||||||
out: list[tuple[str, str, str, dict]] = []
|
|
||||||
for path in sorted(_RULES_DIR.glob("*.yml")):
|
|
||||||
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
||||||
for group in doc.get("groups", []):
|
|
||||||
for rule in group.get("rules", []):
|
|
||||||
if "alert" in rule:
|
|
||||||
out.append(
|
|
||||||
(
|
|
||||||
path.name,
|
|
||||||
rule["alert"],
|
|
||||||
rule.get("expr", ""),
|
|
||||||
rule.get("annotations") or {},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _left_of_and(expr: str) -> str:
|
|
||||||
"""Часть выражения ДО первого бинарного `and`/`unless` — её значения и видит $value."""
|
|
||||||
parts = re.split(r"\band\b|\bunless\b", expr, maxsplit=1)
|
|
||||||
return parts[0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_rules_dir_is_found() -> None:
|
|
||||||
assert _RULES_DIR.is_dir(), f"нет каталога правил: {_RULES_DIR}"
|
|
||||||
assert _alerts(), "правила не распарсились — гейт был бы зелёным впустую"
|
|
||||||
|
|
||||||
|
|
||||||
def test_percentage_annotations_come_from_a_ratio() -> None:
|
|
||||||
"""Текст обещает долю → выражение обязано её и возвращать."""
|
|
||||||
broken: list[str] = []
|
|
||||||
for fname, name, expr, ann in _alerts():
|
|
||||||
text = " ".join(str(v) for v in ann.values())
|
|
||||||
if "humanizePercentage" not in text:
|
|
||||||
continue
|
|
||||||
left = _left_of_and(expr)
|
|
||||||
if "/" not in left:
|
|
||||||
broken.append(
|
|
||||||
f"{fname}::{name}: описание печатает $value как долю, но левая часть "
|
|
||||||
f"выражения (её и видит $value) деления не содержит: {' '.join(left.split())!r}"
|
|
||||||
)
|
|
||||||
assert not broken, "\n".join(broken)
|
|
||||||
|
|
||||||
|
|
||||||
def test_known_two_rules_are_fixed() -> None:
|
|
||||||
"""Именные проверки для двух правил, которые уже соврали в проде."""
|
|
||||||
by_name = {name: (expr, ann) for _, name, expr, ann in _alerts()}
|
|
||||||
|
|
||||||
expr, ann = by_name["ContainerNearMemoryLimit"]
|
|
||||||
flat = " ".join(expr.split())
|
|
||||||
assert flat.startswith("container_memory_working_set_bytes"), (
|
|
||||||
"слева должно стоять потребление, иначе в Telegram уедет лимит в байтах: " + flat
|
|
||||||
)
|
|
||||||
assert "(container_spec_memory_limit_bytes" in flat and "> 0)" in flat, (
|
|
||||||
"отсев нулевого лимита должен стоять В ЗНАМЕНАТЕЛЕ, а не в `and` слева: " + flat
|
|
||||||
)
|
|
||||||
assert "humanizePercentage" in " ".join(ann.values())
|
|
||||||
|
|
||||||
expr, ann = by_name["PostgresLowHotUpdateRatio"]
|
|
||||||
flat = " ".join(expr.split())
|
|
||||||
assert flat.startswith("rate(pg_table_write_amplification_tup_hot_upd"), (
|
|
||||||
"слева должен стоять числитель доли HOT, иначе печатается rate(tup_upd): " + flat
|
|
||||||
)
|
|
||||||
assert "/ (rate(pg_table_write_amplification_tup_upd[6h]) > 0.5)" in flat, (
|
|
||||||
"гейт по объёму апдейтов должен жить в знаменателе — он же защищает от 0/0: " + flat
|
|
||||||
)
|
|
||||||
|
|
@ -1,310 +0,0 @@
|
||||||
"""Правка пофайлово смонтированного конфига метрик обязана доезжать до контейнера.
|
|
||||||
|
|
||||||
ЧТО НЕ ТАК. `git reset --hard` пишет изменившийся файл НОВЫМ инодом, а bind-маунт
|
|
||||||
одного файла держит прежний. `up -d` этого не видит (сравнивает описание сервиса),
|
|
||||||
перезагрузка по API тоже: на prom/prometheus:v3.1.0 (17.09) prometheus.yml подменён,
|
|
||||||
POST /-/reload → rc=0, lastConfigTime новый, а /api/v1/status/config отдаёт прежний
|
|
||||||
job. Деплой зелёный, конфиг старый — у отказа нет отрицательного признака (#3467).
|
|
||||||
Так смонтированы prometheus.yml и loki-config.yml на сервере и queries.yml у трёх
|
|
||||||
postgres-экспортёров (#3486).
|
|
||||||
|
|
||||||
ЧТО ПРОВЕРЯЕТСЯ.
|
|
||||||
1. ops/metrics/recreate-stale-mount.sh ИСПОЛНЯЕТСЯ с подставным `docker`: смотрим
|
|
||||||
на совершённые действия (пересоздал / не тронул / упал), а не на текст.
|
|
||||||
2. Гейт по compose-файлам метрик: у КАЖДОГО пофайлового маунта есть путь доезда в
|
|
||||||
той джобе деплоя, что поднимает сервис. Список маунтов берётся ИЗ compose:
|
|
||||||
добавят новый — тест покраснеет, пока путь не появится.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
||||||
SCRIPT = REPO_ROOT / "ops" / "metrics" / "recreate-stale-mount.sh"
|
|
||||||
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
|
||||||
|
|
||||||
SRC = "ops/metrics/loki/loki-config.yml"
|
|
||||||
DST = "/etc/loki/loki-config.yml"
|
|
||||||
|
|
||||||
# Подставной `docker`. Каждый вызов пишется в $FAKE_LOG.
|
|
||||||
# compose … ps -q — id контейнера из $FAKE_CID_FILE (пусто = не запущен);
|
|
||||||
# код из $FAKE_PS_RC;
|
|
||||||
# compose … up … — «пересоздание»: новый id cid-new, и контейнер видит
|
|
||||||
# инод $FAKE_NEW_VIEW (пусто — не видит ничего);
|
|
||||||
# exec ID stat -c %i … — инод, который видит контейнер ID ($FAKE_VIEW/ID).
|
|
||||||
FAKE_DOCKER = r"""#!/bin/bash
|
|
||||||
printf '%s\n' "$*" >> "$FAKE_LOG"
|
|
||||||
cmd="$1"; shift
|
|
||||||
case "$cmd" in
|
|
||||||
exec)
|
|
||||||
view="$FAKE_VIEW/$1"
|
|
||||||
[ -f "$view" ] || exit 1
|
|
||||||
cat "$view"
|
|
||||||
;;
|
|
||||||
compose)
|
|
||||||
case " $* " in
|
|
||||||
*" ps "*)
|
|
||||||
[ "${FAKE_PS_RC:-0}" = "0" ] || { echo "compose error" >&2; exit "$FAKE_PS_RC"; }
|
|
||||||
cat "$FAKE_CID_FILE"
|
|
||||||
;;
|
|
||||||
*" up "*)
|
|
||||||
printf 'cid-new\n' > "$FAKE_CID_FILE"
|
|
||||||
[ -z "${FAKE_NEW_VIEW:-}" ] || printf '%s\n' "$FAKE_NEW_VIEW" > "$FAKE_VIEW/cid-new"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
exit 0
|
|
||||||
"""
|
|
||||||
|
|
||||||
# На раннере и на хостах stat — GNU/busybox (`-c %i`); на macOS — BSD (`-f %i`).
|
|
||||||
# Шим ставится только там, где `-c` не понимают, иначе гейт не запускался бы локально.
|
|
||||||
STAT_SHIM = (
|
|
||||||
'#!/bin/sh\n[ "$1" = "-c" ] && [ "$2" = "%i" ] && exec /usr/bin/stat -f %i "$3"\n'
|
|
||||||
'exec /usr/bin/stat "$@"\n'
|
|
||||||
)
|
|
||||||
NO_SLEEP = "#!/bin/sh\nexit 0\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _write_exec(path: Path, text: str) -> None:
|
|
||||||
path.write_text(text, encoding="utf-8")
|
|
||||||
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
||||||
|
|
||||||
|
|
||||||
def _gnu_stat() -> bool:
|
|
||||||
return subprocess.run(["stat", "-c", "%i", "/"], capture_output=True).returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def host(tmp_path: Path) -> Path:
|
|
||||||
"""Боевое дерево: скрипт + конфиг; контейнер cid-old видит ТЕКУЩИЙ инод."""
|
|
||||||
tree = tmp_path / "opt" / "gendesign"
|
|
||||||
(tree / "ops" / "metrics" / "loki").mkdir(parents=True)
|
|
||||||
shutil.copy(SCRIPT, tree / "ops" / "metrics" / SCRIPT.name)
|
|
||||||
(tree / SRC).write_text("auth_enabled: false\n", encoding="utf-8")
|
|
||||||
|
|
||||||
bin_dir = tmp_path / "bin"
|
|
||||||
bin_dir.mkdir()
|
|
||||||
_write_exec(bin_dir / "docker", FAKE_DOCKER)
|
|
||||||
_write_exec(bin_dir / "sleep", NO_SLEEP)
|
|
||||||
if not _gnu_stat():
|
|
||||||
_write_exec(bin_dir / "stat", STAT_SHIM)
|
|
||||||
|
|
||||||
view = tmp_path / "view"
|
|
||||||
view.mkdir()
|
|
||||||
(view / "cid-old").write_text(f"{_ino(tree)}\n", encoding="utf-8")
|
|
||||||
(tmp_path / "cid").write_text("cid-old\n", encoding="utf-8")
|
|
||||||
(tmp_path / "log").write_text("", encoding="utf-8")
|
|
||||||
return tree
|
|
||||||
|
|
||||||
|
|
||||||
def _ino(tree: Path) -> int:
|
|
||||||
return os.stat(tree / SRC).st_ino
|
|
||||||
|
|
||||||
|
|
||||||
def _stale(tree: Path) -> None:
|
|
||||||
"""Контейнер держит прежний инод — как после `git reset --hard`."""
|
|
||||||
(tree.parent.parent / "view" / "cid-old").write_text(f"{_ino(tree) + 1}\n", encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _run(tree: Path, **env_extra: str) -> tuple[int, str, list[str]]:
|
|
||||||
root = tree.parent.parent
|
|
||||||
env = {
|
|
||||||
"PATH": f"{root / 'bin'}:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
||||||
"FAKE_LOG": str(root / "log"),
|
|
||||||
"FAKE_CID_FILE": str(root / "cid"),
|
|
||||||
"FAKE_VIEW": str(root / "view"),
|
|
||||||
**env_extra,
|
|
||||||
}
|
|
||||||
proc = subprocess.run(
|
|
||||||
[
|
|
||||||
"sh",
|
|
||||||
str(tree / "ops" / "metrics" / SCRIPT.name),
|
|
||||||
"gendesign-metrics",
|
|
||||||
"docker-compose.metrics.yml",
|
|
||||||
"loki",
|
|
||||||
SRC,
|
|
||||||
DST,
|
|
||||||
],
|
|
||||||
cwd="/", # скрипт обязан сам встать в корень дерева
|
|
||||||
env=env,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
calls = [c for c in (root / "log").read_text(encoding="utf-8").splitlines() if c]
|
|
||||||
return proc.returncode, proc.stdout + proc.stderr, calls
|
|
||||||
|
|
||||||
|
|
||||||
def _recreates(calls: list[str]) -> list[str]:
|
|
||||||
return [c for c in calls if "--force-recreate" in c]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Что скрипт делает на самом деле ──────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_fresh_inode_touches_nothing(host: Path) -> None:
|
|
||||||
"""Контейнер видит текущий файл — пересоздавать нельзя: это рвёт работу зря."""
|
|
||||||
rc, out, calls = _run(host)
|
|
||||||
assert rc == 0, out
|
|
||||||
assert not _recreates(calls), f"пересоздан без расхождения инода: {calls}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_stale_inode_recreates_that_service_and_confirms(host: Path) -> None:
|
|
||||||
"""Старый инод → пересоздать ИМЕННО этот сервис и убедиться, что доехало."""
|
|
||||||
_stale(host)
|
|
||||||
rc, out, calls = _run(host, FAKE_NEW_VIEW=str(_ino(host)))
|
|
||||||
assert rc == 0, out
|
|
||||||
recreates = _recreates(calls)
|
|
||||||
assert len(recreates) == 1, f"ожидалось одно пересоздание, было: {calls}"
|
|
||||||
assert recreates[0].split()[-1] == "loki", f"пересоздан не тот сервис: {recreates[0]}"
|
|
||||||
assert "--no-deps" in recreates[0], f"пересоздание тянет зависимости: {recreates[0]}"
|
|
||||||
assert "-p gendesign-metrics -f docker-compose.metrics.yml" in recreates[0], recreates[0]
|
|
||||||
assert any(c.startswith("exec cid-new ") for c in calls), (
|
|
||||||
f"после пересоздания инод НОВОГО контейнера не сверен: {calls}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_still_stale_after_recreate_fails_the_deploy(host: Path) -> None:
|
|
||||||
"""Пересоздание не помогло — красный деплой, а не зелёный со старым конфигом."""
|
|
||||||
_stale(host)
|
|
||||||
rc, out, _calls = _run(host, FAKE_NEW_VIEW=str(_ino(host) + 1))
|
|
||||||
assert rc != 0, f"контейнер всё ещё на старом иноде, а скрипт вышел с 0:\n{out}"
|
|
||||||
assert SRC in out, f"в логе не назван файл, который не доехал:\n{out}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unreadable_container_view_counts_as_stale(host: Path) -> None:
|
|
||||||
"""Инод в контейнере не прочитался — это расхождение, а не «доехало»."""
|
|
||||||
(host.parent.parent / "view" / "cid-old").unlink()
|
|
||||||
rc, out, calls = _run(host, FAKE_NEW_VIEW=str(_ino(host)))
|
|
||||||
assert rc == 0, out
|
|
||||||
assert _recreates(calls), f"непрочитанный инод сочтён доехавшим: {calls}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_not_running_container_is_left_alone(host: Path) -> None:
|
|
||||||
"""Контейнера нет — старый инод держать некому; поднимать сервис тут нельзя."""
|
|
||||||
(host.parent.parent / "cid").write_text("", encoding="utf-8")
|
|
||||||
rc, out, calls = _run(host)
|
|
||||||
assert rc == 0, out
|
|
||||||
assert not _recreates(calls), f"поднят незапущенный сервис: {calls}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_compose_failure_is_not_read_as_not_running(host: Path) -> None:
|
|
||||||
"""Упавший `compose ps` — не «контейнер не запущен»: иначе зелёный выход вслепую."""
|
|
||||||
_stale(host)
|
|
||||||
rc, out, calls = _run(host, FAKE_PS_RC="1")
|
|
||||||
assert rc != 0, f"ошибка compose проглочена, скрипт вышел с 0:\n{out}"
|
|
||||||
assert not _recreates(calls), calls
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_host_file_is_not_success(host: Path) -> None:
|
|
||||||
"""Файла на хосте нет — сверять не с чем; молча выйти с 0 нельзя."""
|
|
||||||
(host / SRC).unlink()
|
|
||||||
rc, out, _calls = _run(host)
|
|
||||||
assert rc != 0, f"отсутствующий файл сочтён доехавшим:\n{out}"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Гейт: у каждого пофайлового маунта есть путь доезда ──────────────────────
|
|
||||||
|
|
||||||
# Производные файлы: их рендерит сам деплой, в git их нет.
|
|
||||||
# alertmanager.yml — rm + рендер, пересоздание по ALERTMANAGER_RERENDERED;
|
|
||||||
# alertmanager_targets.gen.yml — усечение на месте (`: >`), инод сохраняется.
|
|
||||||
RENDERED = {
|
|
||||||
"ops/metrics/alertmanager/alertmanager.yml",
|
|
||||||
"ops/metrics/prometheus/alertmanager_targets.gen.yml",
|
|
||||||
}
|
|
||||||
# Источник маунта: ./путь[:${VAR:?…}]:/куда[:ro]. `${…}` внутри источника — файл,
|
|
||||||
# выбираемый ролью (alloy-apps.alloy / alloy-infra.alloy).
|
|
||||||
MOUNT = re.compile(r"^\./(?P<src>(?:\$\{[^}]*\}|[^:])+):(?P<dst>/[^:]+)")
|
|
||||||
COMPOSE_FILES = {
|
|
||||||
"docker-compose.metrics.yml": "gendesign-metrics",
|
|
||||||
"docker-compose.metrics-agent.yml": "gendesign-metrics-agent",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _jobs_for(compose_name: str, profiles: list[str]) -> list[str]:
|
|
||||||
"""Джобы деплоя, поднимающие сервис: сервер — одна; агенты — по профилю."""
|
|
||||||
if compose_name == "docker-compose.metrics.yml":
|
|
||||||
return ["server"]
|
|
||||||
return [f"agent-{p}" for p in profiles] or ["agent-apps", "agent-infra"]
|
|
||||||
|
|
||||||
|
|
||||||
def _commands(job: str) -> str:
|
|
||||||
"""ssh-скрипт джобы: комментарии выкинуты, продолжения строк склеены.
|
|
||||||
|
|
||||||
Комментарии — потому что разбор дефекта содержит его формулировку дословно.
|
|
||||||
"""
|
|
||||||
spec = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
|
|
||||||
steps = [s for s in spec["jobs"][job]["steps"] if "ssh-action" in str(s.get("uses"))]
|
|
||||||
assert len(steps) == 1, f"в джобе {job} не ровно один ssh-шаг — гейт ослеп"
|
|
||||||
kept = [ln for ln in steps[0]["with"]["script"].splitlines() if not ln.lstrip().startswith("#")]
|
|
||||||
return re.sub(r"\\\n\s*", " ", "\n".join(kept))
|
|
||||||
|
|
||||||
|
|
||||||
def _file_mounts(compose_name: str) -> list[tuple[str, str, str, list[str]]]:
|
|
||||||
spec = yaml.safe_load((REPO_ROOT / compose_name).read_text(encoding="utf-8"))
|
|
||||||
found = []
|
|
||||||
for service, body in spec["services"].items():
|
|
||||||
for vol in body.get("volumes") or []:
|
|
||||||
m = MOUNT.match(vol) if isinstance(vol, str) else None
|
|
||||||
if not m or (REPO_ROOT / m["src"]).is_dir() or m["src"] in RENDERED:
|
|
||||||
continue
|
|
||||||
found.append((service, m["src"], m["dst"], body.get("profiles") or []))
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def _arrival(commands: str, project: str, compose_name: str, service: str, src: str, dst: str):
|
|
||||||
script_call = re.search(
|
|
||||||
rf"recreate-stale-mount\.sh\s+{re.escape(project)}\s+{re.escape(compose_name)}"
|
|
||||||
rf"\s+{re.escape(service)}\s+{re.escape(src)}\s+{re.escape(dst)}[^\n]*",
|
|
||||||
commands,
|
|
||||||
)
|
|
||||||
forced = re.search(
|
|
||||||
rf"--force-recreate(?:\s+--no-deps)?\s+{re.escape(service)}(?![\w-])[^\n]*", commands
|
|
||||||
)
|
|
||||||
return script_call or forced
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("compose_name", sorted(COMPOSE_FILES))
|
|
||||||
def test_every_single_file_mount_has_a_way_to_arrive(compose_name: str) -> None:
|
|
||||||
project = COMPOSE_FILES[compose_name]
|
|
||||||
mounts = _file_mounts(compose_name)
|
|
||||||
assert mounts, f"в {compose_name} не найдено ни одного пофайлового маунта — маска ослепла"
|
|
||||||
missing = []
|
|
||||||
for service, src, dst, profiles in mounts:
|
|
||||||
for job in _jobs_for(compose_name, profiles):
|
|
||||||
hit = _arrival(_commands(job), project, compose_name, service, src, dst)
|
|
||||||
if not hit:
|
|
||||||
missing.append(f"{job}: {service} {src} → {dst}")
|
|
||||||
elif re.search(r"\|\|\s*(true|:)\s*$", hit.group(0)):
|
|
||||||
missing.append(f"{job}: {service} — отказ проглочен: {hit.group(0).strip()}")
|
|
||||||
assert not missing, (
|
|
||||||
"пофайловые маунты без пути доезда: правка файла ляжет на диск, контейнер "
|
|
||||||
"останется на старом иноде, деплой зелёный:\n " + "\n ".join(missing)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_gate_sees_the_known_mounts() -> None:
|
|
||||||
"""Проверка на себя: маска обязана находить маунты, ради которых гейт заведён."""
|
|
||||||
server = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics.yml")}
|
|
||||||
assert ("prometheus", "ops/metrics/prometheus/prometheus.yml") in server, server
|
|
||||||
assert ("loki", "ops/metrics/loki/loki-config.yml") in server, server
|
|
||||||
agent = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics-agent.yml")}
|
|
||||||
for exporter in ("gendesign", "tradein", "infra"):
|
|
||||||
assert (f"postgres-exporter-{exporter}", "ops/metrics/postgres/queries.yml") in agent, agent
|
|
||||||
|
|
||||||
|
|
||||||
def test_gate_would_notice_a_mount_without_arrival() -> None:
|
|
||||||
"""Сервис без пути доезда гейт обязан НЕ найти — иначе он зелёный по построению."""
|
|
||||||
commands = _commands("server")
|
|
||||||
assert not _arrival(
|
|
||||||
commands, "gendesign-metrics", "docker-compose.metrics.yml", "nosuch", SRC, DST
|
|
||||||
), "гейт нашёл путь доезда у несуществующего сервиса — маска слишком широкая"
|
|
||||||
198
backend/tests/services/cadastre/test_bulk_harvest_territorial.py
Normal file
198
backend/tests/services/cadastre/test_bulk_harvest_territorial.py
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
"""Тесты для _save_territorial_zones (bulk_harvest.py) — mock-based.
|
||||||
|
|
||||||
|
Проверяет:
|
||||||
|
- Успешный UPSERT 3 features → 3 строки вставлены
|
||||||
|
- Повторный вызов → ON CONFLICT обновляет, не дублирует
|
||||||
|
- Feature без geometry → строка вставлена с geom=NULL, без краша
|
||||||
|
- Feature без zone_id → синтетический fallback zone_id используется
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from app.services.cadastre.bulk_harvest import _save_territorial_zones
|
||||||
|
|
||||||
|
|
||||||
|
def _make_feature(
|
||||||
|
feature_id: Any = "zone_1",
|
||||||
|
zone_code: str = "Ж-1",
|
||||||
|
zone_name: str = "Жилая смешанная",
|
||||||
|
permitted_use: str = "ИЖС",
|
||||||
|
has_geometry: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Создать raw feature dict в формате get_features_in_bbox_grid."""
|
||||||
|
geom = (
|
||||||
|
{
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [
|
||||||
|
[
|
||||||
|
[6090000.0, 7590000.0],
|
||||||
|
[6090100.0, 7590000.0],
|
||||||
|
[6090100.0, 7590100.0],
|
||||||
|
[6090000.0, 7590100.0],
|
||||||
|
[6090000.0, 7590000.0],
|
||||||
|
]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if has_geometry
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": feature_id,
|
||||||
|
"geometry": geom,
|
||||||
|
"properties": {
|
||||||
|
"zone_code": zone_code,
|
||||||
|
"zone_name": zone_name,
|
||||||
|
"permitted_use": permitted_use,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db_mock() -> MagicMock:
|
||||||
|
"""Mock SQLAlchemy Session с begin_nested() savepoint support."""
|
||||||
|
db = MagicMock()
|
||||||
|
# begin_nested() используется как context manager
|
||||||
|
savepoint_ctx = MagicMock()
|
||||||
|
savepoint_ctx.__enter__ = MagicMock(return_value=savepoint_ctx)
|
||||||
|
savepoint_ctx.__exit__ = MagicMock(return_value=False)
|
||||||
|
db.begin_nested.return_value = savepoint_ctx
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveTerritorialZones:
|
||||||
|
"""Тесты для _save_territorial_zones."""
|
||||||
|
|
||||||
|
def test_three_features_inserted(self) -> None:
|
||||||
|
"""3 features → returned count == 3, execute вызван 3 раза."""
|
||||||
|
db = _make_db_mock()
|
||||||
|
features = [
|
||||||
|
_make_feature("z1", "Ж-1"),
|
||||||
|
_make_feature("z2", "ОД-1"),
|
||||||
|
_make_feature("z3", "П-1"),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = _save_territorial_zones(db, "66:41:0204016", features)
|
||||||
|
|
||||||
|
assert result == 3
|
||||||
|
assert db.execute.call_count == 3
|
||||||
|
db.commit.assert_called_once()
|
||||||
|
|
||||||
|
def test_empty_features_list(self) -> None:
|
||||||
|
"""Пустой список → 0 inserted, commit всё равно вызван."""
|
||||||
|
db = _make_db_mock()
|
||||||
|
|
||||||
|
result = _save_territorial_zones(db, "66:41:0204016", [])
|
||||||
|
|
||||||
|
assert result == 0
|
||||||
|
db.execute.assert_not_called()
|
||||||
|
db.commit.assert_called_once()
|
||||||
|
|
||||||
|
def test_feature_without_geometry_no_crash(self) -> None:
|
||||||
|
"""Feature без geometry → geom=NULL, строка вставлена без краша."""
|
||||||
|
db = _make_db_mock()
|
||||||
|
features = [_make_feature("zone_no_geom", has_geometry=False)]
|
||||||
|
|
||||||
|
result = _save_territorial_zones(db, "66:41:0204016", features)
|
||||||
|
|
||||||
|
assert result == 1
|
||||||
|
# Проверяем что geom параметр передан как None
|
||||||
|
call_kwargs: dict = db.execute.call_args[0][1]
|
||||||
|
assert call_kwargs["geom"] is None
|
||||||
|
|
||||||
|
def test_feature_without_zone_id_uses_fallback(self) -> None:
|
||||||
|
"""Feature без id → md5-based fallback zone_id (stable между runs)."""
|
||||||
|
db = _make_db_mock()
|
||||||
|
features = [
|
||||||
|
{
|
||||||
|
"id": None,
|
||||||
|
"geometry": None,
|
||||||
|
"properties": {"zone_code": "Ж-2"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = _save_territorial_zones(db, "66:41:0204016", features)
|
||||||
|
|
||||||
|
assert result == 1
|
||||||
|
call_kwargs = db.execute.call_args[0][1]
|
||||||
|
zone_id: str = call_kwargs["zone_id"]
|
||||||
|
# fallback zone_id содержит quarter_cad и стабильный hash (12 hex chars)
|
||||||
|
assert zone_id.startswith("66:41:0204016_")
|
||||||
|
suffix = zone_id.split("_", 3)[-1]
|
||||||
|
assert len(suffix) == 12
|
||||||
|
assert all(c in "0123456789abcdef" for c in suffix)
|
||||||
|
|
||||||
|
# Второй вызов с теми же данными → тот же zone_id (идемпотентность)
|
||||||
|
db2 = _make_db_mock()
|
||||||
|
_save_territorial_zones(db2, "66:41:0204016", features)
|
||||||
|
call_kwargs2 = db2.execute.call_args[0][1]
|
||||||
|
assert call_kwargs2["zone_id"] == zone_id
|
||||||
|
|
||||||
|
def test_zone_id_from_props_id(self) -> None:
|
||||||
|
"""Если feature.id=None, но props['id'] есть — используется props['id']."""
|
||||||
|
db = _make_db_mock()
|
||||||
|
features = [
|
||||||
|
{
|
||||||
|
"id": None,
|
||||||
|
"geometry": None,
|
||||||
|
"properties": {"id": "props_id_42", "zone_code": "Ж-3"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = _save_territorial_zones(db, "66:41:0204016", features)
|
||||||
|
|
||||||
|
assert result == 1
|
||||||
|
call_kwargs = db.execute.call_args[0][1]
|
||||||
|
assert call_kwargs["zone_id"] == "props_id_42"
|
||||||
|
|
||||||
|
def test_execute_error_logged_not_raised(self) -> None:
|
||||||
|
"""Exception в execute → строка не вставлена, warning залогирован, не re-raise."""
|
||||||
|
db = _make_db_mock()
|
||||||
|
db.execute.side_effect = RuntimeError("DB error")
|
||||||
|
features = [_make_feature("z_err")]
|
||||||
|
|
||||||
|
# Не должен бросить исключение
|
||||||
|
result = _save_territorial_zones(db, "66:41:0204016", features)
|
||||||
|
|
||||||
|
assert result == 0
|
||||||
|
db.commit.assert_called_once()
|
||||||
|
|
||||||
|
def test_savepoint_used_per_row(self) -> None:
|
||||||
|
"""begin_nested() вызывается для каждой строки (SAVEPOINT паттерн)."""
|
||||||
|
db = _make_db_mock()
|
||||||
|
features = [_make_feature(f"z{i}") for i in range(3)]
|
||||||
|
|
||||||
|
_save_territorial_zones(db, "66:41:0204016", features)
|
||||||
|
|
||||||
|
assert db.begin_nested.call_count == 3
|
||||||
|
|
||||||
|
def test_quarter_cad_param_passed(self) -> None:
|
||||||
|
"""quarter_cad правильно передаётся в SQL параметры."""
|
||||||
|
db = _make_db_mock()
|
||||||
|
features = [_make_feature("zone_check")]
|
||||||
|
|
||||||
|
_save_territorial_zones(db, "66:41:9999999", features)
|
||||||
|
|
||||||
|
call_kwargs = db.execute.call_args[0][1]
|
||||||
|
assert call_kwargs["quarter_cad"] == "66:41:9999999"
|
||||||
|
|
||||||
|
def test_raw_props_serialized(self) -> None:
|
||||||
|
"""raw_props — JSON строка из properties dict."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
db = _make_db_mock()
|
||||||
|
features = [
|
||||||
|
{
|
||||||
|
"id": "z_props",
|
||||||
|
"geometry": None,
|
||||||
|
"properties": {"zone_code": "ОД-2", "extra": "value"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
_save_territorial_zones(db, "66:41:0204016", features)
|
||||||
|
|
||||||
|
call_kwargs = db.execute.call_args[0][1]
|
||||||
|
raw = json.loads(call_kwargs["raw_props"])
|
||||||
|
assert raw["zone_code"] == "ОД-2"
|
||||||
|
assert raw["extra"] == "value"
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
"""act_number извлекается у постановлений Администрации Екатеринбурга (#2982).
|
|
||||||
|
|
||||||
Регекс номера требовал суффикс областных актов (`-ПП/-ПА/-РП/-ПГ/-ГП/-МО`). У
|
|
||||||
постановлений Администрации города его нет, поэтому на проде `act_number` пуст у
|
|
||||||
всех 27 строк `land_reservation` (замер 17.09.2026: 27 строк, заполнено 0).
|
|
||||||
|
|
||||||
Фрагменты — дословный OCR пяти прод-документов из test_2464_act_date_citation
|
|
||||||
(распознаны `ocr_pdf_text` в прод-контейнере). Ожидаемые номера — из самих PDF:
|
|
||||||
1413, 259, 2687, 863, 1504. Помехи в тех же текстах — «Решение Думы № 60/1»
|
|
||||||
(Генплан) и «Приказ Министерства № 746-П»: их номер браться не должен.
|
|
||||||
|
|
||||||
Тест зовёт `extract_izyatie_records` — путь загрузчика — и краснеет на origin/main
|
|
||||||
неверным значением (None вместо номера), а не ошибкой сигнатуры.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.services.scrapers.izyatie_ocr import extract_izyatie_records
|
|
||||||
from app.services.scrapers.page_reservation_parser import extract_reservations
|
|
||||||
from tests.services.scrapers.test_2464_act_date_citation import (
|
|
||||||
ДОК_ПЕРЕМЕШАННЫЙ,
|
|
||||||
ДОК_ПЕРЕМЕШАННЫЙ_2,
|
|
||||||
ДОК_ПРЯМОЙ,
|
|
||||||
ДОК_РАЗВЯЗКА,
|
|
||||||
ДОК_ЭНЕРГОСТРОИТЕЛЕЙ,
|
|
||||||
ЗАГОЛОВОК,
|
|
||||||
КАД,
|
|
||||||
)
|
|
||||||
|
|
||||||
ПЯТЬ_ДОКУМЕНТОВ = [
|
|
||||||
pytest.param(ДОК_РАЗВЯЗКА, "1413", "2022-05-27", id="развязка"),
|
|
||||||
pytest.param(ДОК_ЭНЕРГОСТРОИТЕЛЕЙ, "259", "2020-02-12", id="энергостроителей"),
|
|
||||||
pytest.param(ДОК_ПЕРЕМЕШАННЫЙ, "2687", "2015-09-29", id="павлодарская"),
|
|
||||||
pytest.param(ДОК_ПЕРЕМЕШАННЫЙ_2, "863", "2019-04-19", id="иркутская"),
|
|
||||||
pytest.param(ДОК_ПРЯМОЙ, "1504", "2019-06-24", id="татищева"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _запись(текст: str) -> dict:
|
|
||||||
записи = extract_izyatie_records(f"{текст}\n{КАД}", ЗАГОЛОВОК, "http://x/y")
|
|
||||||
assert записи, "кад-номер не разобран — тест смотрел бы не туда"
|
|
||||||
return записи[0]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("текст", "номер", "дата"), ПЯТЬ_ДОКУМЕНТОВ)
|
|
||||||
def test_act_number_of_ekb_administration_resolution(текст: str, номер: str, дата: str) -> None:
|
|
||||||
"""Головной: номер постановления без суффикса извлекается, и это номер того же акта,
|
|
||||||
чья дата стоит в act_date, — а не Решения Думы или приказа Министерства."""
|
|
||||||
запись = _запись(текст)
|
|
||||||
assert запись["act_number"] == номер
|
|
||||||
assert запись["act_date"] == дата
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("текст", "номер", "_дата"), ПЯТЬ_ДОКУМЕНТОВ)
|
|
||||||
def test_page_parser_takes_the_same_numbers(текст: str, номер: str, _дата: str) -> None:
|
|
||||||
"""Второе место того же дефекта — page_reservation_parser (pravo.gov66).
|
|
||||||
|
|
||||||
Там номер — первое «№» в тексте. В двух документах первыми идут «№ 60/1» и
|
|
||||||
«№ 746-П»: их нельзя ни взять, ни обрезать до «60»/«746».
|
|
||||||
"""
|
|
||||||
assert extract_reservations(f"{текст}\n{КАД}")[0].act_number == номер
|
|
||||||
|
|
||||||
|
|
||||||
def test_citations_alone_give_no_number() -> None:
|
|
||||||
"""Контроль: без постановления — None, а не номер Генплана или приказа."""
|
|
||||||
текст = (
|
|
||||||
"Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 «Об утверждении»;\n"
|
|
||||||
"Приказ Министерства строительства и развития инфраструктуры Свердловской "
|
|
||||||
"области от 30.12.2021 № 746-П"
|
|
||||||
)
|
|
||||||
assert _запись(текст)["act_number"] is None
|
|
||||||
assert extract_reservations(f"{текст}\n{КАД}")[0].act_number is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_number_belongs_to_the_act_whose_date_is_taken() -> None:
|
|
||||||
"""Контроль связки: номер берётся у того же акта, что и дата, а не первое «№».
|
|
||||||
|
|
||||||
Тот же документ «развязка», но у приказа Министерства номер без суффикса.
|
|
||||||
Первое подходящее «№» в тексте — «746», дата при этом у постановления № 1413:
|
|
||||||
в строке оказалась бы пара реквизитов от двух разных документов.
|
|
||||||
"""
|
|
||||||
текст = ДОК_РАЗВЯЗКА.replace("№ 746-П", "№ 746")
|
|
||||||
запись = _запись(текст)
|
|
||||||
assert (запись["act_date"], запись["act_number"]) == ("2022-05-27", "1413")
|
|
||||||
|
|
||||||
|
|
||||||
def test_page_parser_prefers_own_number_over_cited_regional_act() -> None:
|
|
||||||
"""Контроль порядка: собственный номер в шапке без суффикса не пропускается ради
|
|
||||||
следующего «№ …-ПП» — номера цитируемого постановления Правительства."""
|
|
||||||
текст = (
|
|
||||||
"ПРИКАЗ\nот 12.03.2024 № 1234\nОб изъятии земельных участков\n"
|
|
||||||
"В соответствии с постановлением Правительства Свердловской области "
|
|
||||||
f"от 26.02.2020 № 100-ПП\n{КАД}"
|
|
||||||
)
|
|
||||||
assert extract_reservations(текст)[0].act_number == "1234"
|
|
||||||
|
|
||||||
|
|
||||||
def test_regional_suffix_still_extracted() -> None:
|
|
||||||
"""Контроль от переусердствования: областной номер с суффиксом не потерян."""
|
|
||||||
assert _запись("Постановление № 509-ПП от 12.03.2024")["act_number"] == "509-ПП"
|
|
||||||
assert _запись("Постановление Правительства от 12.03.2024 № 509-ПП")["act_number"] == "509-ПП"
|
|
||||||
|
|
@ -1,246 +0,0 @@
|
||||||
"""Gap-fill конкурентов берёт скорость и цену своего ЖК, а не всех ЖК под complex_id (#2962).
|
|
||||||
|
|
||||||
`objective_lots.complex_id` проставлен один раз миграцией 76, а еженедельный
|
|
||||||
`70_parse_objective_raw.py` UPSERT'ом по objective_lot_id переписывает project_name и
|
|
||||||
не трогает complex_id. Прод 17.09.2026: из 303 677 строк с complex_id у 236 354
|
|
||||||
проект чужой; у 185 gap-fill конкурентов своих лотов 23 %, скорость завышена в
|
|
||||||
медиане в 39 раз.
|
|
||||||
|
|
||||||
Тест герметичный и прогоняет НАСТОЯЩИЕ `_COMPETITORS_SQL` и
|
|
||||||
`_OBJECTIVE_PRICE_FALLBACK_SQL`: временные таблицы затеняют боевые в пределах сессии.
|
|
||||||
Нужен Postgres с PostGIS (ST_DWithin по geography). В CI он есть
|
|
||||||
(.forgejo/workflows/ci.yml, образ postgis/postgis), и там тест не пропускается
|
|
||||||
никогда: без PostGIS он падает с настоящей причиной. Пропуск разрешён только вне CI,
|
|
||||||
на машине без базы, и объявлен в skip_allowlist.txt.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy import create_engine, text
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
|
|
||||||
def _dsn() -> str:
|
|
||||||
raw = os.environ.get("TEST_DATABASE_URL") or os.environ["DATABASE_URL"]
|
|
||||||
return (
|
|
||||||
raw
|
|
||||||
if raw.startswith("postgresql+")
|
|
||||||
else raw.replace("postgresql://", "postgresql+psycopg://")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _postgis_reachable() -> tuple[bool, str]:
|
|
||||||
try:
|
|
||||||
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
|
|
||||||
with eng.connect() as c:
|
|
||||||
if c.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'")).first():
|
|
||||||
return True, ""
|
|
||||||
return False, "нет расширения postgis"
|
|
||||||
except Exception as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
|
|
||||||
|
|
||||||
_DB_OK, _DB_ERR = _postgis_reachable()
|
|
||||||
_IN_CI = bool(os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI"))
|
|
||||||
pytestmark = pytest.mark.skipif(
|
|
||||||
not _DB_OK and not _IN_CI, reason=f"Postgres/PostGIS недоступен: {_DB_ERR}"
|
|
||||||
)
|
|
||||||
|
|
||||||
_SCHEMA = [
|
|
||||||
"""CREATE TEMP TABLE domrf_kn_objects (
|
|
||||||
obj_id bigint, comm_name text, dev_name text, obj_class text,
|
|
||||||
obj_class_fallback text, latitude double precision, longitude double precision,
|
|
||||||
flat_count int, site_status text, snapshot_date date) ON COMMIT DROP""",
|
|
||||||
"""CREATE TEMP TABLE objective_complex_mapping (
|
|
||||||
domrf_obj_id bigint, objective_complex_name text) ON COMMIT DROP""",
|
|
||||||
"""CREATE TEMP TABLE complexes (
|
|
||||||
id bigint, canonical_name text, latitude double precision,
|
|
||||||
longitude double precision) ON COMMIT DROP""",
|
|
||||||
"""CREATE TEMP TABLE complex_sources (
|
|
||||||
complex_id bigint, source text, source_id text) ON COMMIT DROP""",
|
|
||||||
"""CREATE TEMP TABLE objective_lots (
|
|
||||||
project_name text, complex_id bigint, price_per_m2_rub numeric) ON COMMIT DROP""",
|
|
||||||
"""CREATE TEMP TABLE objective_corpus_room_month (
|
|
||||||
project_name text, report_month date, deals_total_count int) ON COMMIT DROP""",
|
|
||||||
]
|
|
||||||
|
|
||||||
MICH, VEER, STUDIO, EXPLICIT, TATLIN, PARK, GROVE, PINES = 1, 2, 3, 4, 5, 6, 7, 8
|
|
||||||
|
|
||||||
_DATA = [
|
|
||||||
# obj_id, comm_name, …, latitude, longitude — объекты дальше 200 м друг от друга
|
|
||||||
"""INSERT INTO domrf_kn_objects VALUES
|
|
||||||
(1, 'ЖК Мичуринский', NULL, NULL, NULL, 56.840, 60.600, 500, 'Строящиеся', CURRENT_DATE),
|
|
||||||
(2, 'ЖК VEER PARK', NULL, NULL, NULL, 56.850, 60.620, 500, 'Строящиеся', CURRENT_DATE),
|
|
||||||
(3, 'Жилой комплекс "СтудияПарк"', NULL, NULL, NULL, 56.830, 60.580, 500,
|
|
||||||
'Строящиеся', CURRENT_DATE),
|
|
||||||
(4, 'ЖК Явный', NULL, NULL, NULL, 56.845, 60.640, 500, 'Строящиеся', CURRENT_DATE),
|
|
||||||
(5, 'Квартал "Татлин"', NULL, NULL, NULL, 56.835, 60.610, 500, 'Строящиеся',
|
|
||||||
CURRENT_DATE),
|
|
||||||
(6, 'Парковый', NULL, NULL, NULL, 56.855, 60.590, 500, 'Строящиеся', CURRENT_DATE),
|
|
||||||
(7, 'ЖК Роща Парк', NULL, NULL, NULL, 56.825, 60.630, 500, 'Строящиеся', CURRENT_DATE),
|
|
||||||
(8, 'ЖК Сосны', NULL, NULL, NULL, 56.860, 60.650, 500, 'Строящиеся', CURRENT_DATE)""",
|
|
||||||
"INSERT INTO objective_complex_mapping VALUES (4, 'Явный')",
|
|
||||||
"""INSERT INTO complexes VALUES
|
|
||||||
(10, 'ЖК Мичуринский', 56.840, 60.600),
|
|
||||||
(20, 'ЖК VEER PARK', 56.850, 60.620),
|
|
||||||
(30, 'СтудияПарк', 56.830, 60.580),
|
|
||||||
(50, 'Квартал "Татлин"', 56.835, 60.610),
|
|
||||||
(60, 'Парковый', 56.855, 60.590),
|
|
||||||
(70, 'Роща', 56.825, 60.630),
|
|
||||||
(71, 'Роща Парк', 56.8259, 60.630),
|
|
||||||
(80, 'ЖК Сосны', 56.860, 60.650)""",
|
|
||||||
# 20 → 'Clever Park': неверная fuzzy-связь, как на проде (complexes.id=1493).
|
|
||||||
# 70 → 'Роща': проект без лотов, complex ближе «Роща Парк» (71, в 100 м).
|
|
||||||
# 80 → два objective-проекта: UNIQUE(source, source_id) этого не запрещает.
|
|
||||||
"""INSERT INTO complex_sources VALUES
|
|
||||||
(10, 'objective', 'Мичуринский'),
|
|
||||||
(20, 'objective', 'Clever Park'),
|
|
||||||
(30, 'objective', 'Студия Парк'),
|
|
||||||
(50, 'objective', 'Квартал Татлин'),
|
|
||||||
(60, 'objective', 'Парковый квартал'),
|
|
||||||
(70, 'objective', 'Роща'),
|
|
||||||
(71, 'objective', 'Роща Парк'),
|
|
||||||
(80, 'objective', 'Клён'),
|
|
||||||
(80, 'objective', 'Сосны')""",
|
|
||||||
# Под complex_id=10 лежит и свой лот, и три лота чужого «Малахита» с устаревшим
|
|
||||||
# complex_id; два своих лота новые — complex_id у них NULL.
|
|
||||||
"""INSERT INTO objective_lots VALUES
|
|
||||||
('Мичуринский', 10, 100000), ('Мичуринский', NULL, 110000),
|
|
||||||
('Мичуринский', NULL, 120000),
|
|
||||||
('Малахит', 10, 300000), ('Малахит', 10, 300000), ('Малахит', 10, 300000),
|
|
||||||
('Clever Park', NULL, 150000),
|
|
||||||
('Студия Парк', NULL, 90000),
|
|
||||||
('Явный', NULL, 200000),
|
|
||||||
('Квартал Татлин', NULL, 95000),
|
|
||||||
('Парковый квартал', NULL, 130000),
|
|
||||||
('Роща Парк', NULL, 105000),
|
|
||||||
('Клён', NULL, 250000),
|
|
||||||
('Сосны', NULL, 115000)""",
|
|
||||||
"""INSERT INTO objective_corpus_room_month VALUES
|
|
||||||
('Мичуринский', date_trunc('month', now())::date, 30),
|
|
||||||
('Малахит', date_trunc('month', now())::date, 300),
|
|
||||||
('Clever Park', date_trunc('month', now())::date, 90),
|
|
||||||
('Студия Парк', date_trunc('month', now())::date, 15),
|
|
||||||
('Явный', date_trunc('month', now())::date, 60),
|
|
||||||
('Квартал Татлин', date_trunc('month', now())::date, 18),
|
|
||||||
('Парковый квартал', date_trunc('month', now())::date, 12),
|
|
||||||
('Роща Парк', date_trunc('month', now())::date, 21),
|
|
||||||
('Клён', date_trunc('month', now())::date, 240),
|
|
||||||
('Сосны', date_trunc('month', now())::date, 27)""",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def result() -> tuple[dict[int, float], dict[int, float]]:
|
|
||||||
"""(velocity по obj_id, медианная цена objective-fallback по obj_id)."""
|
|
||||||
from app.services.site_finder.competitors import (
|
|
||||||
_COMPETITORS_SQL,
|
|
||||||
_OBJECTIVE_PRICE_FALLBACK_SQL,
|
|
||||||
_VELOCITY_MATCH_RADIUS_M,
|
|
||||||
)
|
|
||||||
|
|
||||||
session = sessionmaker(bind=create_engine(_dsn()))()
|
|
||||||
try:
|
|
||||||
for stmt in _SCHEMA + _DATA:
|
|
||||||
session.execute(text(stmt))
|
|
||||||
vel_rows = session.execute(
|
|
||||||
_COMPETITORS_SQL,
|
|
||||||
{
|
|
||||||
"center_lon": 60.6,
|
|
||||||
"center_lat": 56.84,
|
|
||||||
"radius_m": 5000.0,
|
|
||||||
"time_window_months": 3.0,
|
|
||||||
"window_interval": "3 months",
|
|
||||||
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
|
|
||||||
},
|
|
||||||
).mappings()
|
|
||||||
velocity = {int(r["obj_id"]): float(r["velocity_per_month"]) for r in vel_rows}
|
|
||||||
price_rows = session.execute(
|
|
||||||
_OBJECTIVE_PRICE_FALLBACK_SQL,
|
|
||||||
{
|
|
||||||
"obj_ids": [MICH, VEER, STUDIO, EXPLICIT, TATLIN, PARK, GROVE, PINES],
|
|
||||||
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
|
|
||||||
},
|
|
||||||
).mappings()
|
|
||||||
price = {int(r["obj_id"]): float(r["median_price_per_m2"]) for r in price_rows}
|
|
||||||
return velocity, price
|
|
||||||
finally:
|
|
||||||
session.rollback()
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_gapfill_velocity_counts_only_own_project(result) -> None:
|
|
||||||
"""30 сделок своего ЖК за 3 месяца = 10/мес; с чужим «Малахитом» было бы 110."""
|
|
||||||
velocity, _ = result
|
|
||||||
assert velocity[MICH] == pytest.approx(10.0), velocity
|
|
||||||
|
|
||||||
|
|
||||||
def test_gapfill_price_is_median_of_own_lots(result) -> None:
|
|
||||||
"""Медиана своих лотов 100/110/120 тыс. = 110 тыс.; по complex_id было бы 300 тыс."""
|
|
||||||
_, price = result
|
|
||||||
assert price[MICH] == pytest.approx(110000.0), price
|
|
||||||
|
|
||||||
|
|
||||||
def test_wrong_fuzzy_link_gives_no_numbers(result) -> None:
|
|
||||||
"""«ЖК VEER PARK» связан в complex_sources с 'Clever Park' — чужие цифры не берём."""
|
|
||||||
velocity, price = result
|
|
||||||
assert velocity[VEER] == 0.0, velocity
|
|
||||||
assert VEER not in price, price
|
|
||||||
|
|
||||||
|
|
||||||
def test_space_difference_still_matches(result) -> None:
|
|
||||||
"""«СтудияПарк» и «Студия Парк» — один ЖК: 15 сделок / 3 = 5, цена 90 тыс."""
|
|
||||||
velocity, price = result
|
|
||||||
assert velocity[STUDIO] == pytest.approx(5.0), velocity
|
|
||||||
assert price[STUDIO] == pytest.approx(90000.0), price
|
|
||||||
|
|
||||||
|
|
||||||
def test_explicit_mapping_unchanged(result) -> None:
|
|
||||||
"""Явный маппинг правка не трогает: 60 / 3 = 20, цена 200 тыс."""
|
|
||||||
velocity, price = result
|
|
||||||
assert velocity[EXPLICIT] == pytest.approx(20.0), velocity
|
|
||||||
assert price[EXPLICIT] == pytest.approx(200000.0), price
|
|
||||||
|
|
||||||
|
|
||||||
def test_quotes_inside_name_still_match(result) -> None:
|
|
||||||
"""«Квартал "Татлин"» и «Квартал Татлин»: кавычки посреди имени сверку не ломают.
|
|
||||||
|
|
||||||
Убрать только пробелы мало: «квартал"татлин"» и «кварталтатлин» не входят друг в
|
|
||||||
друга. 18 сделок / 3 = 6, цена 95 тыс.
|
|
||||||
"""
|
|
||||||
velocity, price = result
|
|
||||||
assert velocity[TATLIN] == pytest.approx(6.0), velocity
|
|
||||||
assert price[TATLIN] == pytest.approx(95000.0), price
|
|
||||||
|
|
||||||
|
|
||||||
def test_project_name_longer_than_object_name_matches(result) -> None:
|
|
||||||
"""Объект «Парковый», проект «Парковый квартал»: имя сверяется в обе стороны. 12 / 3 = 4."""
|
|
||||||
velocity, price = result
|
|
||||||
assert velocity[PARK] == pytest.approx(4.0), velocity
|
|
||||||
assert price[PARK] == pytest.approx(130000.0), price
|
|
||||||
|
|
||||||
|
|
||||||
def test_nearest_complex_without_lots_does_not_eat_the_match(result) -> None:
|
|
||||||
"""#968: ближайший complex «Роща» без лотов не забирает объект у «Роща Парк» в 100 м.
|
|
||||||
|
|
||||||
Иначе у «ЖК Роща Парк» не было бы ни скорости, ни цены. 21 / 3 = 7, цена 105 тыс.
|
|
||||||
"""
|
|
||||||
velocity, price = result
|
|
||||||
assert velocity[GROVE] == pytest.approx(7.0), velocity
|
|
||||||
assert price[GROVE] == pytest.approx(105000.0), price
|
|
||||||
|
|
||||||
|
|
||||||
def test_complex_with_two_projects_takes_the_matching_one(result) -> None:
|
|
||||||
"""У complex два objective-проекта, «Клён» и «Сосны»: берём сверенный по имени.
|
|
||||||
|
|
||||||
Без этого DISTINCT ON брал любой из двух (здесь — «Клён»), сверка его отвергала,
|
|
||||||
и «Сосны» терялись. 27 / 3 = 9, цена 115 тыс.
|
|
||||||
"""
|
|
||||||
velocity, price = result
|
|
||||||
assert velocity[PINES] == pytest.approx(9.0), velocity
|
|
||||||
assert price[PINES] == pytest.approx(115000.0), price
|
|
||||||
|
|
@ -433,43 +433,7 @@ def test_exclude_competitor_obj_ids() -> None:
|
||||||
resp = get_best_layouts(db, CAD_NUM, req)
|
resp = get_best_layouts(db, CAD_NUM, req)
|
||||||
|
|
||||||
assert resp.top_layouts == []
|
assert resp.top_layouts == []
|
||||||
# после исключения комплексов 0 (знаменатель coverage), в радиусе было 1 obj_id
|
assert resp.data_quality.objects_total_in_radius == 1
|
||||||
assert resp.data_quality.objects_total_in_radius == 0
|
|
||||||
assert resp.data_quality.raw_objects_total == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("filter_ids", "vel_obj_ids", "expected_total", "expected_with_data"),
|
|
||||||
[
|
|
||||||
([99], None, 0, 0), # фильтр не оставил никого → ранний пустой ответ
|
|
||||||
([20], None, 1, 0), # остался 1 комплекс без velocity → пустой ответ
|
|
||||||
([20], [20], 1, 1), # остался 1 комплекс с velocity → штатная ветка
|
|
||||||
],
|
|
||||||
ids=["all-filtered", "no-velocity", "normal"],
|
|
||||||
)
|
|
||||||
def test_objects_total_is_coverage_denominator_in_every_branch(
|
|
||||||
filter_ids: list[int],
|
|
||||||
vel_obj_ids: list[int] | None,
|
|
||||||
expected_total: int,
|
|
||||||
expected_with_data: int,
|
|
||||||
) -> None:
|
|
||||||
"""#2464 (best_layouts:1113): objects_total_in_radius значит одно во всех ветках.
|
|
||||||
|
|
||||||
UI и PDF печатают «покрытие P% (Y из N комплексов)», значит N обязан быть
|
|
||||||
знаменателем P, а P считается по комплексам ПОСЛЕ filter. Раньше ранний пустой
|
|
||||||
ответ отдавал число ДО фильтра (2), две другие ветки — после.
|
|
||||||
"""
|
|
||||||
id_rows = [_obj_id_row(20), _obj_id_row(30)]
|
|
||||||
vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=vel_obj_ids)] if vel_obj_ids else []
|
|
||||||
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
|
|
||||||
resp = get_best_layouts(db, CAD_NUM, _request(filter_competitor_obj_ids=filter_ids))
|
|
||||||
|
|
||||||
dq = resp.data_quality
|
|
||||||
assert dq.objects_total_in_radius == expected_total
|
|
||||||
assert dq.objects_with_velocity_data == expected_with_data
|
|
||||||
assert dq.raw_objects_total == 2 # число до фильтра — отдельным полем
|
|
||||||
expected_pct = expected_with_data / expected_total * 100.0 if expected_total else 0.0
|
|
||||||
assert dq.velocity_coverage_pct == pytest.approx(expected_pct)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Тест: total_sold_in_window совпадает с deals_window ──────────────────────
|
# ── Тест: total_sold_in_window совпадает с deals_window ──────────────────────
|
||||||
|
|
|
||||||
|
|
@ -489,7 +489,7 @@ async def test_harvest_quarter_does_not_early_exit_on_shared_phase_done() -> Non
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
# Симулируем shared phase_state с phase=done от ДРУГОГО quarter.
|
# Симулируем shared phase_state с phase=done от ДРУГОГО quarter.
|
||||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||||
# grid-walk фаза корректно пропускается (тест про
|
# grid-walk + territorial_zones фазы корректно пропускаются (тест про
|
||||||
# snapshot/idempotency, не про geometry). Тот же dict возвращается на ВСЕ
|
# snapshot/idempotency, не про geometry). Тот же dict возвращается на ВСЕ
|
||||||
# db.execute().mappings().first() в этом тесте.
|
# db.execute().mappings().first() в этом тесте.
|
||||||
db.execute = MagicMock(
|
db.execute = MagicMock(
|
||||||
|
|
@ -554,7 +554,7 @@ async def test_harvest_quarter_calls_upsert_features() -> None:
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
# phase_state = None → начинаем с нуля
|
# phase_state = None → начинаем с нуля
|
||||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||||
# grid-walk фаза пропускается (тесты про snapshot /
|
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||||
# db.execute().mappings().first() вызовы.
|
# db.execute().mappings().first() вызовы.
|
||||||
db.execute = MagicMock(
|
db.execute = MagicMock(
|
||||||
|
|
@ -759,7 +759,7 @@ async def test_harvest_quarter_calls_per_cat_probe_for_zouit_when_meta_nonzero()
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||||
# grid-walk фаза пропускается (тесты про snapshot /
|
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||||
# db.execute().mappings().first() вызовы.
|
# db.execute().mappings().first() вызовы.
|
||||||
db.execute = MagicMock(
|
db.execute = MagicMock(
|
||||||
|
|
@ -834,7 +834,7 @@ async def test_harvest_quarter_skips_per_cat_probe_when_meta_zero() -> None:
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||||
# grid-walk фаза пропускается (тесты про snapshot /
|
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||||
# db.execute().mappings().first() вызовы.
|
# db.execute().mappings().first() вызовы.
|
||||||
db.execute = MagicMock(
|
db.execute = MagicMock(
|
||||||
|
|
@ -904,7 +904,7 @@ async def test_harvest_quarter_per_cat_probe_enk_called_when_meta_nonzero() -> N
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||||
# grid-walk фаза пропускается (тесты про snapshot /
|
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||||
# db.execute().mappings().first() вызовы.
|
# db.execute().mappings().first() вызовы.
|
||||||
db.execute = MagicMock(
|
db.execute = MagicMock(
|
||||||
|
|
@ -1223,7 +1223,7 @@ async def test_harvest_quarter_geom_heal_failure_does_not_propagate() -> None:
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||||
# grid-walk фаза пропускается (тесты про snapshot /
|
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||||
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||||
# db.execute().mappings().first() вызовы.
|
# db.execute().mappings().first() вызовы.
|
||||||
db.execute = MagicMock(
|
db.execute = MagicMock(
|
||||||
|
|
@ -1518,6 +1518,7 @@ async def test_harvest_quarter_records_failed_layer_in_phase_state() -> None:
|
||||||
db = _mock_db_grid_bbox()
|
db = _mock_db_grid_bbox()
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
client.search_by_quarter = AsyncMock(return_value=snapshot)
|
client.search_by_quarter = AsyncMock(return_value=snapshot)
|
||||||
|
client.get_territorial_zones_in_bbox = AsyncMock(return_value=[])
|
||||||
|
|
||||||
progress_states: list[dict[str, Any]] = []
|
progress_states: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
|
@ -1556,42 +1557,6 @@ async def test_harvest_quarter_records_failed_layer_in_phase_state() -> None:
|
||||||
assert progress_states[-1]["phase"] == "done"
|
assert progress_states[-1]["phase"] == "done"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_harvest_quarter_makes_no_territorial_zones_request_2985() -> None:
|
|
||||||
"""#2985: Phase 2.5 удалена. Квартал без overflow с валидным bbox стоит ровно один
|
|
||||||
запрос к НСПД (search_by_quarter) — отдельного grid-walk за ПЗЗ больше нет."""
|
|
||||||
from app.services.cadastre.bulk_harvest import harvest_quarter
|
|
||||||
|
|
||||||
snapshot = QuarterSnapshot(
|
|
||||||
quarter="66:41:0303161",
|
|
||||||
fetched_at="2026-05-15T10:00:00+00:00",
|
|
||||||
features=[_make_parcel_feature()],
|
|
||||||
meta_counts={},
|
|
||||||
)
|
|
||||||
client = AsyncMock()
|
|
||||||
client.search_by_quarter = AsyncMock(return_value=snapshot)
|
|
||||||
progress_states: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert,
|
|
||||||
patch("app.services.cadastre.bulk_harvest.quarter_bbox_3857") as mock_bbox,
|
|
||||||
):
|
|
||||||
mock_bbox.return_value = (6735845.0, 8329000.0, 6736595.0, 8329750.0)
|
|
||||||
mock_upsert.return_value = dict.fromkeys(
|
|
||||||
("parcels", "buildings", "constructions", "oncs", "enks", "zouit", "skipped"), 0
|
|
||||||
)
|
|
||||||
await harvest_quarter(
|
|
||||||
db=_mock_db_grid_bbox(),
|
|
||||||
client=client,
|
|
||||||
quarter="66:41:0303161",
|
|
||||||
job_id=1,
|
|
||||||
update_progress=progress_states.append,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert [c[0] for c in client.mock_calls] == ["search_by_quarter"]
|
|
||||||
assert [s["phase"] for s in progress_states] == ["snapshot_started", "snapshot_done", "done"]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Issue #200: geom backfill для участков с geom IS NULL ─────────────────────
|
# ── Issue #200: geom backfill для участков с geom IS NULL ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,29 +144,6 @@ tests/sql/test_2464_area_bin_unknown.py::test_lot_without_area_is_not_counted_as
|
||||||
tests/sql/test_2464_area_bin_unknown.py::test_unknown_area_gets_its_own_bucket
|
tests/sql/test_2464_area_bin_unknown.py::test_unknown_area_gets_its_own_bucket
|
||||||
tests/sql/test_2464_area_bin_unknown.py::test_total_lots_preserved
|
tests/sql/test_2464_area_bin_unknown.py::test_total_lots_preserved
|
||||||
tests/sql/test_2464_area_bin_unknown.py::test_normal_buckets_unchanged
|
tests/sql/test_2464_area_bin_unknown.py::test_normal_buckets_unchanged
|
||||||
|
|
||||||
# ── #2962: мост gap-fill конкурентов (complex_sources → project_name) ─────────
|
|
||||||
# Нужен Postgres С PostGIS: тесты затеняют боевые таблицы временными и гоняют
|
|
||||||
# НАСТОЯЩИЕ _COMPETITORS_SQL и _OBJECTIVE_PRICE_FALLBACK_SQL, а там ST_DWithin по
|
|
||||||
# geography. В CI ИДУТ (ci.yml поднимает postgis/postgis:16-3.4) и пропуститься
|
|
||||||
# там НЕ МОГУТ: при CI=true skipif выключен, без PostGIS тест падает. Записи нужны
|
|
||||||
# только для машины без базы.
|
|
||||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_gapfill_velocity_counts_only_own_project
|
|
||||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_gapfill_price_is_median_of_own_lots
|
|
||||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_wrong_fuzzy_link_gives_no_numbers
|
|
||||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_space_difference_still_matches
|
|
||||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_explicit_mapping_unchanged
|
|
||||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_quotes_inside_name_still_match
|
|
||||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_project_name_longer_than_object_name_matches
|
|
||||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_nearest_complex_without_lots_does_not_eat_the_match
|
|
||||||
tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_complex_with_two_projects_takes_the_matching_one
|
|
||||||
|
|
||||||
# ── #3583: гео-радиусная цена участка (complex_sources → project_name) ────────
|
|
||||||
# Тот же случай, что #2962: гоняет НАСТОЯЩИЙ parcels._GEO_RADIUS_PRICE_SQL на
|
|
||||||
# временных таблицах, нужен PostGIS. В CI идёт и пропуститься не может (CI=true
|
|
||||||
# выключает skipif). Запись только для машины без базы.
|
|
||||||
tests/api/v1/test_3583_geo_radius_price_project_bridge.py::test_median_counts_own_projects_of_nearby_complexes
|
|
||||||
|
|
||||||
# ── #2464: backfill act_date (миграция 191) ──────────────────────────────────
|
# ── #2464: backfill act_date (миграция 191) ──────────────────────────────────
|
||||||
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме
|
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме
|
||||||
# (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции
|
# (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции
|
||||||
|
|
@ -199,29 +176,3 @@ tests/sql/test_2998_rosreestr_partition_horizon.py::test_schema_01_alone_is_red_
|
||||||
tests/sql/test_2998_rosreestr_partition_horizon.py::test_migration_193_is_idempotent_and_closes_the_gap
|
tests/sql/test_2998_rosreestr_partition_horizon.py::test_migration_193_is_idempotent_and_closes_the_gap
|
||||||
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_for_every_publishable_quarter
|
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_for_every_publishable_quarter
|
||||||
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_one_quarter_ahead
|
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_one_quarter_ahead
|
||||||
|
|
||||||
# ── POSIX-only: тесты запускают дочерний bash-скрипт ───────────────────────────
|
|
||||||
# На Windows нет POSIX shell на PATH, которым эти тесты исполняют /bin/bash -c
|
|
||||||
# <скрипт> или сам shell-скрипт напрямую (шебанг #!/usr/bin/env bash) — процесс
|
|
||||||
# не стартует. В CI ЭТИ ТЕСТЫ ИДУТ (ubuntu-раннер). Записи нужны только для
|
|
||||||
# локального прогона на Windows; чисто-логические проверки в тех же файлах
|
|
||||||
# (читают YAML/строки без subprocess) в этот список НЕ входят и обязаны
|
|
||||||
# исполняться локально.
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_merge_with_only_caddy_file_takes_the_fast_path
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_caddy_plus_backend_is_a_full_deploy
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_missing_base_falls_back_to_full_deploy
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_decision_is_visible_in_the_log
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_fast_path_allowed_when_prod_lags_only_by_proxy_config
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_fast_path_refuses_when_prod_lags_by_code
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_paths_that_merely_start_with_caddy_are_not_the_fast_path
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_empty_diff_is_not_the_fast_path
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_data_sql_counts_as_backend
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_non_ascii_path_is_classified
|
|
||||||
tests/ops/test_3448_caddy_only_detection.py::test_multi_commit_push_is_not_truncated
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_fresh_latest_passes
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_newer_revision_passes
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_stale_latest_fails_after_timeout
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_revision_from_other_component_does_not_cover
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_missing_label_fails
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_registry_error_fails
|
|
||||||
tests/test_2950_latest_image_revision_gate.py::test_usage_errors_are_distinct
|
|
||||||
|
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
"""Починка разбора номера не удваивает land_reservation (#2982).
|
|
||||||
|
|
||||||
act_number входит в ключ `UNIQUE NULLS NOT DISTINCT (cad_num, act_number)`. На
|
|
||||||
проде 27 строк записаны с `act_number IS NULL`. Когда номер начинает извлекаться,
|
|
||||||
строка (участок, «1413») с (участок, NULL) не конфликтует — без очистки прогон
|
|
||||||
кладёт вторую строку рядом, и reservation_lookup показывает изъятие дважды.
|
|
||||||
|
|
||||||
Проверяется `_upsert_records` загрузчика целиком на временной таблице той же
|
|
||||||
формы: боевые данные не читаются и не меняются.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy import create_engine, text
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from app.workers.tasks.izyatie_ocr_ingest import _upsert_records
|
|
||||||
|
|
||||||
|
|
||||||
def _dsn() -> str:
|
|
||||||
raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get(
|
|
||||||
"DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign"
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
raw
|
|
||||||
if raw.startswith("postgresql+")
|
|
||||||
else raw.replace("postgresql://", "postgresql+psycopg://")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _db_reachable() -> tuple[bool, str]:
|
|
||||||
try:
|
|
||||||
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
|
|
||||||
with eng.connect() as c:
|
|
||||||
c.execute(text("SELECT 1"))
|
|
||||||
return True, ""
|
|
||||||
except Exception as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
|
|
||||||
|
|
||||||
_DB_OK, _DB_ERR = _db_reachable()
|
|
||||||
pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}")
|
|
||||||
|
|
||||||
_TABLE = """
|
|
||||||
CREATE TEMP TABLE land_reservation (
|
|
||||||
id bigserial PRIMARY KEY,
|
|
||||||
cad_num text NOT NULL,
|
|
||||||
reservation_kind text NOT NULL,
|
|
||||||
basis_act text NOT NULL,
|
|
||||||
act_number text,
|
|
||||||
act_date date,
|
|
||||||
purpose text,
|
|
||||||
doc_url text,
|
|
||||||
source text NOT NULL DEFAULT 'page_pdf',
|
|
||||||
is_active boolean NOT NULL DEFAULT true,
|
|
||||||
raw_excerpt text,
|
|
||||||
fetched_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT uq_t UNIQUE NULLS NOT DISTINCT (cad_num, act_number)
|
|
||||||
) ON COMMIT DROP;
|
|
||||||
"""
|
|
||||||
|
|
||||||
КАД = "66:41:0303004:22"
|
|
||||||
ДОК = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/89adb28a3677e7df933e2d9ce0f205c8"
|
|
||||||
|
|
||||||
|
|
||||||
def _row(cad: str = КАД, act: str | None = "1413", url: str = ДОК) -> dict:
|
|
||||||
return {
|
|
||||||
"cad_num": cad,
|
|
||||||
"reservation_kind": "изъятие",
|
|
||||||
"basis_act": "Сообщение о планируемом изъятии",
|
|
||||||
"act_number": act,
|
|
||||||
"act_date": "2022-05-27",
|
|
||||||
"purpose": None,
|
|
||||||
"doc_url": url,
|
|
||||||
"source": "izyatie_ekb_ocr",
|
|
||||||
"raw_excerpt": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def db():
|
|
||||||
engine = create_engine(_dsn())
|
|
||||||
session = sessionmaker(bind=engine)()
|
|
||||||
try:
|
|
||||||
session.execute(text(_TABLE))
|
|
||||||
n = session.execute(text("SELECT count(*) FROM land_reservation")).scalar()
|
|
||||||
assert n == 0, f"запрос попал НЕ во временную таблицу ({n} строк)"
|
|
||||||
yield session
|
|
||||||
finally:
|
|
||||||
session.rollback()
|
|
||||||
session.close()
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
def _rows(db) -> list[tuple[str, str | None, str]]:
|
|
||||||
return [
|
|
||||||
(r[0], r[1], r[2])
|
|
||||||
for r in db.execute(
|
|
||||||
text("SELECT cad_num, act_number, doc_url FROM land_reservation ORDER BY 1, 2, 3")
|
|
||||||
).all()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_numbered_parse_replaces_numberless_row_of_same_document(db) -> None:
|
|
||||||
"""Головной: прод-состояние (номер NULL) + прогон с номером → одна строка с номером."""
|
|
||||||
_upsert_records(db, [_row(act=None)])
|
|
||||||
assert _rows(db) == [(КАД, None, ДОК)]
|
|
||||||
|
|
||||||
assert _upsert_records(db, [_row(act="1413")]) == 1
|
|
||||||
assert _rows(db) == [(КАД, "1413", ДОК)], "строка без номера осталась рядом с новой"
|
|
||||||
|
|
||||||
_upsert_records(db, [_row(act="1413")])
|
|
||||||
assert _rows(db) == [(КАД, "1413", ДОК)], "повторный прогон не идемпотентен"
|
|
||||||
|
|
||||||
|
|
||||||
def test_same_parcel_in_another_document_is_kept(db) -> None:
|
|
||||||
"""Контроль ширины: участок в ДРУГОМ документе — другое основание, его не трогаем."""
|
|
||||||
другой = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/9b9d9a998f578db56315bb816fc2ebf5"
|
|
||||||
_upsert_records(db, [_row(act="259", url=другой)])
|
|
||||||
_upsert_records(db, [_row(act=None)])
|
|
||||||
_upsert_records(db, [_row(act="1413")])
|
|
||||||
assert _rows(db) == [(КАД, "1413", ДОК), (КАД, "259", другой)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_other_parcels_of_same_document_are_kept(db) -> None:
|
|
||||||
"""Контроль: чистка идёт по участку, а не по всему документу."""
|
|
||||||
_upsert_records(db, [_row(cad="66:41:0303004:23", act=None)])
|
|
||||||
_upsert_records(db, [_row(act="1413")])
|
|
||||||
assert _rows(db) == [("66:41:0303004:22", "1413", ДОК), ("66:41:0303004:23", None, ДОК)]
|
|
||||||
|
|
@ -15,20 +15,10 @@ from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
# Каждый тест в файле запускает scripts/check-latest-image-revision.sh (POSIX shell,
|
|
||||||
# shebang #!/usr/bin/env bash) дочерним процессом. На Windows нет POSIX shell на PATH,
|
|
||||||
# чтобы это исполнить, — покрытие обеспечивается в CI на Linux.
|
|
||||||
pytestmark = pytest.mark.skipif(
|
|
||||||
sys.platform == "win32",
|
|
||||||
reason="запускает bash-скрипт (check-latest-image-revision.sh) дочерним процессом; "
|
|
||||||
"POSIX-only, покрытие в CI (Linux)",
|
|
||||||
)
|
|
||||||
|
|
||||||
КОРЕНЬ = Path(__file__).resolve().parents[2]
|
КОРЕНЬ = Path(__file__).resolve().parents[2]
|
||||||
СКРИПТ = КОРЕНЬ / "scripts" / "check-latest-image-revision.sh"
|
СКРИПТ = КОРЕНЬ / "scripts" / "check-latest-image-revision.sh"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
"""Продуктовый счётчик экспорта отчётов (#3471): выгрузок §22-форсайта / ТЗ.
|
|
||||||
|
|
||||||
Мера кардинальности та же, что в `test_metrics.py`: единственный лейбл —
|
|
||||||
`format`, фиксированный литерал из `Literal[...]` сигнатуры эндпоинта
|
|
||||||
(`export_parcel_forecast`) плюс одно статичное значение `best_layouts_pdf`
|
|
||||||
(ТЗ на проектирование) — не кадастровый номер и не идентификатор пользователя.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from prometheus_client import REGISTRY, generate_latest
|
|
||||||
|
|
||||||
from app.observability import metrics as m
|
|
||||||
|
|
||||||
|
|
||||||
def test_reports_exported_counter_has_bounded_format_label() -> None:
|
|
||||||
assert tuple(m.REPORTS_EXPORTED._labelnames) == ("format",) # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
|
|
||||||
def test_reports_exported_counter_increments_per_format() -> None:
|
|
||||||
def _value(fmt: str) -> float:
|
|
||||||
return (
|
|
||||||
REGISTRY.get_sample_value("sitefinder_reports_exported_total", {"format": fmt}) or 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
before_pdf = _value("pdf")
|
|
||||||
before_layouts = _value("best_layouts_pdf")
|
|
||||||
|
|
||||||
m.REPORTS_EXPORTED.labels(format="pdf").inc()
|
|
||||||
m.REPORTS_EXPORTED.labels(format="best_layouts_pdf").inc()
|
|
||||||
|
|
||||||
assert _value("pdf") - before_pdf == 1.0
|
|
||||||
assert _value("best_layouts_pdf") - before_layouts == 1.0
|
|
||||||
|
|
||||||
body = generate_latest(REGISTRY).decode()
|
|
||||||
assert "sitefinder_reports_exported_total" in body
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
"""Бинды SQL-стейтментов ЕЭСК-лоадера обязаны совпадать с передаваемыми params.
|
|
||||||
|
|
||||||
ПОЧЕМУ ЭТОТ ТЕСТ СУЩЕСТВУЕТ. 18.08-02.09.2026 все 71 UPDATE резервов ПС
|
|
||||||
падали, а задача оставалась зелёной: упоминание СНЯТОГО параметра load_pct
|
|
||||||
в SQL-КОММЕНТАРИИ (в синтаксисе «двоеточие+имя») SQLAlchemy text() распарсил
|
|
||||||
как обязательный бинд, params его не содержал, каждая строка падала на
|
|
||||||
compile, а per-row except глотал это как «битую строку». ЕЭСК-резервы не
|
|
||||||
обновлялись две недели, freshness этого не видел (задача done).
|
|
||||||
|
|
||||||
Тест ловит МЕХАНИЗМ: собирает все text()-стейтменты модуля и сверяет их
|
|
||||||
бинд-имена с ключами словаря params из того же контекста. Никакой БД не
|
|
||||||
нужно — дефект живёт на этапе компиляции стейтмента.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
LOADER = (
|
|
||||||
Path(__file__).resolve().parents[1]
|
|
||||||
/ "app"
|
|
||||||
/ "services"
|
|
||||||
/ "site_finder"
|
|
||||||
/ "eesk_reserve_loader.py"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_update_sql() -> str:
|
|
||||||
"""Достаёт текст UPDATE power_supply_centers из исходника лоадера."""
|
|
||||||
src = LOADER.read_text(encoding="utf-8")
|
|
||||||
m = re.search(r'text\("""\s*(UPDATE power_supply_centers.*?)"""\)', src, re.S)
|
|
||||||
assert m, "UPDATE power_supply_centers не найден в лоадере"
|
|
||||||
return m.group(1)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_params_keys() -> set[str]:
|
|
||||||
"""Ключи словаря params, который передаётся в этот execute."""
|
|
||||||
src = LOADER.read_text(encoding="utf-8")
|
|
||||||
m = re.search(r"params = \{(.*?)\}", src, re.S)
|
|
||||||
assert m, "словарь params не найден"
|
|
||||||
return set(re.findall(r'"([a-z_]+)":', m.group(1)))
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_binds_are_subset_of_params() -> None:
|
|
||||||
"""Каждый бинд стейтмента обязан приходить из params — иначе UPDATE падает
|
|
||||||
на КАЖДОЙ строке, и per-row SAVEPOINT-except прячет это под «битую строку»."""
|
|
||||||
stmt = text(_extract_update_sql())
|
|
||||||
binds = {p.key for p in stmt._bindparams.values()}
|
|
||||||
params = _extract_params_keys()
|
|
||||||
missing = binds - params
|
|
||||||
assert not missing, (
|
|
||||||
f"стейтмент требует биндов {sorted(missing)}, которых нет в params — "
|
|
||||||
"все строки батча упадут молча. Частая причина: упоминание "
|
|
||||||
"«двоеточие+имя» в SQL-комментарии (text() парсит бинды и там)."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_comment_does_not_reintroduce_phantom_bind() -> None:
|
|
||||||
"""Регресс 18.08: снятый параметр упомянули в комментарии в живом
|
|
||||||
синтаксисе бинда. Проверяем ЗНАЧЕНИЕМ: компиляция с ровно теми params,
|
|
||||||
что собирает лоадер, не требует ничего лишнего."""
|
|
||||||
stmt = text(_extract_update_sql())
|
|
||||||
have = _extract_params_keys()
|
|
||||||
for bp in stmt._bindparams.values():
|
|
||||||
assert bp.key in have, f"фантомный бинд из комментария: {bp.key!r}"
|
|
||||||
|
|
@ -1,257 +0,0 @@
|
||||||
"""Unit tests для poi_loader — региональный bbox + адаптивное дробление Overpass-тайлов.
|
|
||||||
|
|
||||||
Mock-based / pure — БЕЗ живых походов в Overpass и без БД (правило: реальные запросы
|
|
||||||
к Overpass в тестах недопустимы). Покрывает:
|
|
||||||
- REGION_BBOX / DEFAULT_REGION — дефолт остаётся "ekb", не меняется молча.
|
|
||||||
- _bbox_tiles — для ЕКБ ровно один тайл, идентичный EKB_BBOX; для Москвы — несколько
|
|
||||||
тайлов, покрывающих исходный bbox без дыр/нахлёста (по площади).
|
|
||||||
- _build_overpass_query — bbox теперь параметр, а не глобальная константа.
|
|
||||||
- _fetch_category — retry (было и раньше) + НОВОЕ: адаптивное дробление тайла на 4
|
|
||||||
четверти при устойчивом провале (вместо тихой потери тайла), с остановкой на
|
|
||||||
RECURSIVE_SPLIT_MAX_DEPTH (без бесконечной рекурсии).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.services.site_finder.poi_loader import (
|
|
||||||
_MAX_CONSECUTIVE_TRANSPORT_ERRORS,
|
|
||||||
DEFAULT_REGION,
|
|
||||||
EKB_BBOX,
|
|
||||||
RECURSIVE_SPLIT_MAX_DEPTH,
|
|
||||||
REGION_BBOX,
|
|
||||||
OverpassUnreachableError,
|
|
||||||
_bbox_tiles,
|
|
||||||
_build_overpass_query,
|
|
||||||
_fetch_category,
|
|
||||||
_RunState,
|
|
||||||
_split_bbox_quadrants,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeResponse:
|
|
||||||
"""Ответ Overpass. ok=False отдаёт 504 — «запрос слишком тяжёлый», единственный
|
|
||||||
класс отказа, ради которого тайл вообще осмысленно дробить."""
|
|
||||||
|
|
||||||
def __init__(self, ok: bool, elements: list[dict] | None = None) -> None:
|
|
||||||
self._ok = ok
|
|
||||||
self._elements = elements or []
|
|
||||||
|
|
||||||
def raise_for_status(self) -> None:
|
|
||||||
if not self._ok:
|
|
||||||
raise httpx.HTTPStatusError(
|
|
||||||
"simulated Overpass overload",
|
|
||||||
request=httpx.Request("POST", "https://overpass-api.de/api/interpreter"),
|
|
||||||
response=httpx.Response(504),
|
|
||||||
)
|
|
||||||
|
|
||||||
def json(self) -> dict:
|
|
||||||
return {"elements": self._elements}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def instant_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""Подменяет asyncio.sleep внутри poi_loader на no-op — тесты дробления тайлов иначе
|
|
||||||
реально спали бы минуты (retry-пауза 3с + 1с между каждой из 4 четвертей на каждом
|
|
||||||
уровне рекурсии)."""
|
|
||||||
|
|
||||||
async def _instant(_seconds: float) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
monkeypatch.setattr("app.services.site_finder.poi_loader.asyncio.sleep", _instant)
|
|
||||||
|
|
||||||
|
|
||||||
# ── REGION_BBOX / дефолт ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_region_is_ekb_unchanged() -> None:
|
|
||||||
assert DEFAULT_REGION == "ekb"
|
|
||||||
assert REGION_BBOX["ekb"] == EKB_BBOX
|
|
||||||
|
|
||||||
|
|
||||||
def test_region_bbox_has_msk_product_core() -> None:
|
|
||||||
assert "msk" in REGION_BBOX
|
|
||||||
south, west, north, east = REGION_BBOX["msk"]
|
|
||||||
assert south < north
|
|
||||||
assert west < east
|
|
||||||
|
|
||||||
|
|
||||||
# ── _bbox_tiles ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_bbox_tiles_ekb_is_single_tile_identical_to_ekb_bbox() -> None:
|
|
||||||
"""Дефолтный регион не должен молча поменять поведение — один тайл, байт-в-байт EKB_BBOX."""
|
|
||||||
tiles = _bbox_tiles(EKB_BBOX)
|
|
||||||
assert tiles == [EKB_BBOX]
|
|
||||||
|
|
||||||
|
|
||||||
def test_bbox_tiles_msk_splits_into_multiple_tiles_without_gaps() -> None:
|
|
||||||
bbox = REGION_BBOX["msk"]
|
|
||||||
tiles = _bbox_tiles(bbox)
|
|
||||||
assert len(tiles) > 1
|
|
||||||
south, west, north, east = bbox
|
|
||||||
total_area = (north - south) * (east - west)
|
|
||||||
tiles_area = sum((t[2] - t[0]) * (t[3] - t[1]) for t in tiles)
|
|
||||||
assert tiles_area == pytest.approx(total_area, rel=1e-9)
|
|
||||||
|
|
||||||
|
|
||||||
# ── _split_bbox_quadrants ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_split_bbox_quadrants_covers_original_area() -> None:
|
|
||||||
bbox = (55.55, 37.30, 55.95, 37.90)
|
|
||||||
quads = _split_bbox_quadrants(bbox)
|
|
||||||
assert len(quads) == 4
|
|
||||||
south, west, north, east = bbox
|
|
||||||
total_area = (north - south) * (east - west)
|
|
||||||
quads_area = sum((q[2] - q[0]) * (q[3] - q[1]) for q in quads)
|
|
||||||
assert quads_area == pytest.approx(total_area, rel=1e-9)
|
|
||||||
|
|
||||||
|
|
||||||
# ── _build_overpass_query ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_overpass_query_uses_given_bbox_not_global_constant() -> None:
|
|
||||||
q = _build_overpass_query((("amenity", "pharmacy"),), (1.0, 2.0, 3.0, 4.0))
|
|
||||||
assert "(1.0,2.0,3.0,4.0)" in q
|
|
||||||
assert '["amenity"="pharmacy"]' in q
|
|
||||||
|
|
||||||
|
|
||||||
# ── _fetch_category: retry (существующее поведение) ──────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
async def test_fetch_category_retries_then_succeeds(instant_sleep: None) -> None:
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
async def fake_post(_url: str, data: dict) -> _FakeResponse:
|
|
||||||
calls["n"] += 1
|
|
||||||
if calls["n"] == 1:
|
|
||||||
return _FakeResponse(ok=False)
|
|
||||||
return _FakeResponse(ok=True, elements=[{"type": "node", "id": 1, "lat": 1, "lon": 2}])
|
|
||||||
|
|
||||||
client = SimpleNamespace(post=fake_post)
|
|
||||||
result = await _fetch_category(
|
|
||||||
client, (("amenity", "pharmacy"),), "pharmacy", (0, 0, 1, 1), _RunState()
|
|
||||||
)
|
|
||||||
assert calls["n"] == 2
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["_gd_category"] == "pharmacy"
|
|
||||||
|
|
||||||
|
|
||||||
# ── _fetch_category: адаптивное дробление (НОВОЕ) ─────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
async def test_fetch_category_splits_into_quadrants_on_persistent_failure(
|
|
||||||
instant_sleep: None,
|
|
||||||
) -> None:
|
|
||||||
"""Тайл, где оба attempt проваливаются, дробится на 4 четверти вместо потери данных."""
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
async def fake_post(_url: str, data: dict) -> _FakeResponse:
|
|
||||||
calls["n"] += 1
|
|
||||||
query = data["data"]
|
|
||||||
if "(0.0,0.0,1.0,1.0)" in query: # верхнеуровневый тайл всегда 504
|
|
||||||
return _FakeResponse(ok=False)
|
|
||||||
return _FakeResponse(
|
|
||||||
ok=True, elements=[{"type": "node", "id": calls["n"], "lat": 0.1, "lon": 0.1}]
|
|
||||||
)
|
|
||||||
|
|
||||||
client = SimpleNamespace(post=fake_post)
|
|
||||||
result = await _fetch_category(
|
|
||||||
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), _RunState()
|
|
||||||
)
|
|
||||||
# верхний тайл: 2 неудачных attempt, затем 4 успешных запроса по четвертям
|
|
||||||
assert calls["n"] == 2 + 4
|
|
||||||
assert len(result) == 4
|
|
||||||
|
|
||||||
|
|
||||||
async def test_fetch_category_gives_up_at_max_depth_without_infinite_recursion(
|
|
||||||
instant_sleep: None,
|
|
||||||
) -> None:
|
|
||||||
"""Тайл, падающий на ЛЮБОМ размере, останавливает дробление на RECURSIVE_SPLIT_MAX_DEPTH
|
|
||||||
и возвращает пустой список — не зацикливается и не падает."""
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
async def fake_post(_url: str, data: dict) -> _FakeResponse:
|
|
||||||
calls["n"] += 1
|
|
||||||
assert data # параметр используется — сигнатура должна совпадать с client.post
|
|
||||||
return _FakeResponse(ok=False)
|
|
||||||
|
|
||||||
client = SimpleNamespace(post=fake_post)
|
|
||||||
result = await _fetch_category(
|
|
||||||
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), _RunState()
|
|
||||||
)
|
|
||||||
assert result == []
|
|
||||||
# sum_{d=0}^{max_depth} 4^d узлов, каждый по 2 attempt — рекурсия конечна
|
|
||||||
expected_nodes = sum(4**d for d in range(RECURSIVE_SPLIT_MAX_DEPTH + 1))
|
|
||||||
assert calls["n"] == expected_nodes * 2
|
|
||||||
|
|
||||||
|
|
||||||
# ── отказ транспорта: НЕ дробим и не стучимся дальше ──────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
async def test_transport_error_does_not_split_the_tile(instant_sleep: None) -> None:
|
|
||||||
"""Connection refused говорит «хост нас не принимает», а не «запрос тяжёлый».
|
|
||||||
|
|
||||||
Дробление тут превращает один отказ в 4, 16, 64 повторных стука — ровно это
|
|
||||||
случилось 15.09.2026 на загрузке Москвы (58 отказов на 4 успеха за три минуты).
|
|
||||||
"""
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
async def fake_post(_url: str, data: dict) -> _FakeResponse:
|
|
||||||
calls["n"] += 1
|
|
||||||
assert data
|
|
||||||
raise httpx.ConnectError("[Errno 101] Network is unreachable")
|
|
||||||
|
|
||||||
client = SimpleNamespace(post=fake_post)
|
|
||||||
result = await _fetch_category(
|
|
||||||
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), _RunState()
|
|
||||||
)
|
|
||||||
assert result == []
|
|
||||||
assert calls["n"] == 1 # ни retry, ни четвертей
|
|
||||||
|
|
||||||
|
|
||||||
async def test_consecutive_transport_errors_abort_the_run(instant_sleep: None) -> None:
|
|
||||||
"""Порог подряд идущих транспортных отказов останавливает ВЕСЬ прогон явной ошибкой."""
|
|
||||||
|
|
||||||
async def fake_post(_url: str, data: dict) -> _FakeResponse:
|
|
||||||
assert data
|
|
||||||
raise httpx.ConnectError("connection refused")
|
|
||||||
|
|
||||||
client = SimpleNamespace(post=fake_post)
|
|
||||||
state = _RunState()
|
|
||||||
for _ in range(_MAX_CONSECUTIVE_TRANSPORT_ERRORS - 1):
|
|
||||||
assert (
|
|
||||||
await _fetch_category(
|
|
||||||
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), state
|
|
||||||
)
|
|
||||||
== []
|
|
||||||
)
|
|
||||||
with pytest.raises(OverpassUnreachableError):
|
|
||||||
await _fetch_category(
|
|
||||||
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), state
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_successful_tile_resets_transport_error_streak(instant_sleep: None) -> None:
|
|
||||||
"""Одиночные сетевые икоты вперемешку с успехами не должны копиться до аварии."""
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
async def fake_post(_url: str, data: dict) -> _FakeResponse:
|
|
||||||
calls["n"] += 1
|
|
||||||
assert data
|
|
||||||
if calls["n"] % 2:
|
|
||||||
raise httpx.ConnectError("hiccup")
|
|
||||||
return _FakeResponse(ok=True, elements=[{"type": "node", "id": calls["n"]}])
|
|
||||||
|
|
||||||
client = SimpleNamespace(post=fake_post)
|
|
||||||
state = _RunState()
|
|
||||||
for _ in range(_MAX_CONSECUTIVE_TRANSPORT_ERRORS * 2):
|
|
||||||
await _fetch_category(
|
|
||||||
client, (("amenity", "pharmacy"),), "pharmacy", (0.0, 0.0, 1.0, 1.0), state
|
|
||||||
)
|
|
||||||
assert state.consecutive_transport_errors < _MAX_CONSECUTIVE_TRANSPORT_ERRORS
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
"""#2443: каталожные DOM.РФ-скраперы не должны попасть в beat, пока стоит блокировка.
|
|
||||||
|
|
||||||
С 01.09 наш.дом.рф за StormWall отдаёт IP Poincare «Доступ заблокирован [403]».
|
|
||||||
Решение «выключено до прокси» раньше жило только в комментарии с обещанием
|
|
||||||
«cooldown 24-48h» и месяцами читалось как таймер. Здесь оно проверяется по
|
|
||||||
значению: включить запись = осознанно поправить этот тест после #3307.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
_BLOCKED_TASKS = (
|
|
||||||
"tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
|
|
||||||
"tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_domrf_catalog_scrapers_not_scheduled_while_stormwall_blocks() -> None:
|
|
||||||
from app.workers import beat_schedule
|
|
||||||
|
|
||||||
with patch.object(beat_schedule, "_build_beat_schedule_from_db", return_value={}):
|
|
||||||
schedule = beat_schedule.build_beat_schedule()
|
|
||||||
|
|
||||||
# Контроль: статическая часть расписания реально построилась, иначе
|
|
||||||
# отсутствие задач ниже было бы зелёным по построению.
|
|
||||||
assert schedule["refresh-quarter-price-index"]["task"] == (
|
|
||||||
"tasks.refresh_quarter_price_index.refresh_quarter_price_index_chain"
|
|
||||||
)
|
|
||||||
|
|
||||||
# По task, а не по ключу: переименованный ключ не должен обойти проверку.
|
|
||||||
scheduled = {
|
|
||||||
name: entry["task"] for name, entry in schedule.items() if entry["task"] in _BLOCKED_TASKS
|
|
||||||
}
|
|
||||||
assert scheduled == {}, (
|
|
||||||
f"каталог DOM.РФ включён в beat: {scheduled}. IP Poincare заблокирован "
|
|
||||||
"StormWall (#2443); включать только после прокси и принятого kn-прогона (#3307)"
|
|
||||||
)
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
|
||||||
# caddy/deploy-window.caddy.snippet — ответ на окно деплоя (#3274)
|
|
||||||
#
|
|
||||||
# Импортируется ВНУТРЬ `handle_errors 502 503 504 { ... }` (см. apps.caddy):
|
|
||||||
# сам по себе снипет ничего не перехватывает, он только решает, ЧТО отдать,
|
|
||||||
# когда апстрим не отвечает.
|
|
||||||
#
|
|
||||||
# ЧТО ЭТО ЛЕЧИТ, А ЧТО НЕТ. Каждый деплой tradein-frontend/tradein-backend
|
|
||||||
# оставляет окно 30–90 с, в котором контейнера просто нет: 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
|
|
||||||
}
|
|
||||||
|
|
@ -42,40 +42,6 @@
|
||||||
# код.
|
# код.
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
# ── (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.
|
# Caddy config.
|
||||||
#
|
#
|
||||||
# - gendsgn.ru — main production site, auto-TLS via Let's Encrypt.
|
# - gendsgn.ru — main production site, auto-TLS via Let's Encrypt.
|
||||||
|
|
@ -133,35 +99,6 @@ gendsgn.ru {
|
||||||
format json
|
format json
|
||||||
}
|
}
|
||||||
|
|
||||||
# #3471: копия access-лога на stdout. За 3 часа наблюдения в
|
|
||||||
# gendesign-caddy-1 не было НИ ОДНОЙ строки http.log.access — только ACME/
|
|
||||||
# TLS/warn от reverse_proxy, статусы/латентность/RPS прокси не видны в
|
|
||||||
# Loki вообще. Alloy на этом хосте уже собирает stdout контейнеров через
|
|
||||||
# journald (`loki.source.journal "host"`,
|
|
||||||
# ops/metrics/alloy/alloy-apps.alloy) — второй bind-монт не нужен.
|
|
||||||
#
|
|
||||||
# Секреты в query (`?secret=`, `?token=` и т.п., см. инцидент #3154) режет
|
|
||||||
# УЖЕ РАБОТАЮЩИЙ `loki.process.scrub_credentials` (#3354, тот же список
|
|
||||||
# имён параметров, что в app/core/log_scrub.py) — он стоит на пути ЛЮБОГО
|
|
||||||
# journal-лога, включая этот, поэтому второй слой скраба здесь не заводим.
|
|
||||||
#
|
|
||||||
# `log_skip` — общий для ВСЕХ логгеров сайта флаг на запрос (Caddy не даёт
|
|
||||||
# скипать выборочно по конкретному логгеру), поэтому /health и статика
|
|
||||||
# Next пропадают заодно и из файлового gendsgn.ru.log выше, не только из
|
|
||||||
# копии на stdout. Это осознанный побочный эффект, не только экономия:
|
|
||||||
# /health — 32% строк gendsgn.ru.log в измеренном сегменте (12.09.2026,
|
|
||||||
# 240 из 742 строк за ~4ч, аптайм-монитор раз в минуту) без диагностической
|
|
||||||
# ценности, и именно он гонит файловый лог через 50MiB roll_size так часто.
|
|
||||||
log access_stdout {
|
|
||||||
output stdout
|
|
||||||
format json
|
|
||||||
}
|
|
||||||
|
|
||||||
@noisy_access_log {
|
|
||||||
path /health /_next/static/*
|
|
||||||
}
|
|
||||||
log_skip @noisy_access_log
|
|
||||||
|
|
||||||
route {
|
route {
|
||||||
# `/metrics` наружу не отдаётся — ни бэкендом, ни фронтом (#3078).
|
# `/metrics` наружу не отдаётся — ни бэкендом, ни фронтом (#3078).
|
||||||
# Сегодня он и так недостижим: бэкенду «Птицы» ниже уходят только
|
# Сегодня он и так недостижим: бэкенду «Птицы» ниже уходят только
|
||||||
|
|
@ -184,13 +121,10 @@ gendsgn.ru {
|
||||||
}
|
}
|
||||||
|
|
||||||
# Static HTML mockups для review (audit alternatives).
|
# Static HTML mockups для review (audit alternatives).
|
||||||
# Public access — без auth (по запросу 2026-05-17) — это намеренно и
|
# Public access — без auth (по запросу 2026-05-17).
|
||||||
# менять НЕ надо. Но каталог не должен листингом отдавать список
|
|
||||||
# файлов случайному посетителю: без `browse` file_server продолжает
|
|
||||||
# отдавать сами файлы по прямой ссылке, просто без index-листинга.
|
|
||||||
handle_path /preview/* {
|
handle_path /preview/* {
|
||||||
root * /srv/preview
|
root * /srv/preview
|
||||||
file_server
|
file_server browse
|
||||||
}
|
}
|
||||||
|
|
||||||
# Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру
|
# Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру
|
||||||
|
|
@ -203,7 +137,6 @@ gendsgn.ru {
|
||||||
@uipreview path /trade-in/ui-preview/* /trade-in/_next/static/*
|
@uipreview path /trade-in/ui-preview/* /trade-in/_next/static/*
|
||||||
handle @uipreview {
|
handle @uipreview {
|
||||||
reverse_proxy tradein-frontend:3000 {
|
reverse_proxy tradein-frontend:3000 {
|
||||||
import tradein_frontend_retry
|
|
||||||
# #2558 review: тот же периметр-scrub, что и у /trade-in/api/* и
|
# #2558 review: тот же периметр-scrub, что и у /trade-in/api/* и
|
||||||
# @tradein ниже — этот блок тоже теперь ДО basic_auth, клиент
|
# @tradein ниже — этот блок тоже теперь ДО basic_auth, клиент
|
||||||
# мог бы прислать свой X-Authenticated-User. Сейчас инертно
|
# мог бы прислать свой X-Authenticated-User. Сейчас инертно
|
||||||
|
|
@ -307,7 +240,6 @@ gendsgn.ru {
|
||||||
handle @tradein {
|
handle @tradein {
|
||||||
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
|
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
|
||||||
reverse_proxy tradein-frontend:3000 {
|
reverse_proxy tradein-frontend:3000 {
|
||||||
import tradein_frontend_retry
|
|
||||||
# См. комментарий над /trade-in/api/* выше — та же логика (явное
|
# См. комментарий над /trade-in/api/* выше — та же логика (явное
|
||||||
# удаление вместо Set с пустым {http.auth.user.id}), и по той же
|
# удаление вместо Set с пустым {http.auth.user.id}), и по той же
|
||||||
# причине здесь больше нет инжекта X-Internal-Auth-Secret
|
# причине здесь больше нет инжекта X-Internal-Auth-Secret
|
||||||
|
|
@ -335,32 +267,6 @@ gendsgn.ru {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Окно деплоя (#3274) — ТОЛЬКО для /trade-in. Каждый мерж в tradein
|
|
||||||
# оставляет 30–90 с, в которые контейнера нет и посетитель видит голый 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 {
|
www.gendsgn.ru {
|
||||||
|
|
@ -403,58 +309,10 @@ www.gendsgn.ru {
|
||||||
meraocenka.ru {
|
meraocenka.ru {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
# #3471 (security-headers follow-up): публичный B2C-домен без базовых
|
|
||||||
# security-заголовков. Позиция директивы в файле не важна — Caddyfile
|
|
||||||
# применяет директивы в фиксированном порядке (`header` идёт раньше
|
|
||||||
# `reverse_proxy`), не по строке в исходнике.
|
|
||||||
#
|
|
||||||
# Проверено: ни tradein-mvp/frontend (обслуживает этот домен, отдаёт
|
|
||||||
# `/trade-in/mera-public*`), ни его next.config.ts/middleware не
|
|
||||||
# выставляют ни один из этих заголовков — дублирования нет.
|
|
||||||
header {
|
|
||||||
# 180 суток (15552000s). НЕ ставить includeSubDomains и НЕ ставить
|
|
||||||
# preload — оба флага практически необратимы (браузеры кэшируют
|
|
||||||
# preload-список надолго, includeSubDomains ломает любой будущий
|
|
||||||
# HTTP-поддомен), а не все поддомены meraocenka.ru гарантированно
|
|
||||||
# под нашим контролем. Если это когда-нибудь понадобится — отдельное
|
|
||||||
# осознанное решение, не «улучшение по умолчанию».
|
|
||||||
Strict-Transport-Security "max-age=15552000"
|
|
||||||
# Запрет встраивания во фрейм: X-Frame-Options — для старых
|
|
||||||
# клиентов, которые не понимают CSP; frame-ancestors — современный
|
|
||||||
# механизм. Оба вместе, не один вместо другого.
|
|
||||||
X-Frame-Options "DENY"
|
|
||||||
Content-Security-Policy "frame-ancestors 'none'"
|
|
||||||
X-Content-Type-Options "nosniff"
|
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
|
||||||
# Не даём стеку светиться в ответе. Полноценный CSP
|
|
||||||
# (default-src/script-src/...) сюда намеренно НЕ входит — отдельная
|
|
||||||
# работа с риском сломать фронт, здесь только периметр вокруг фрейма.
|
|
||||||
-Server
|
|
||||||
-X-Powered-By
|
|
||||||
}
|
|
||||||
|
|
||||||
log {
|
log {
|
||||||
output file /var/log/caddy/meraocenka.ru.log
|
output file /var/log/caddy/meraocenka.ru.log
|
||||||
}
|
}
|
||||||
|
|
||||||
# #3471: та же копия access-лога на stdout, что у gendsgn.ru — см.
|
|
||||||
# развёрнутый комментарий там (Alloy/journald, scrub_credentials #3354,
|
|
||||||
# log_skip общий на все логгеры сайта). `/trade-in/_next/static/*` — 5.1%
|
|
||||||
# строк meraocenka.ru.log в измерении 12.09.2026 (3782 из 73810 за
|
|
||||||
# ~17.6 суток без ротации, roll_size здесь вообще не настроен) — статика
|
|
||||||
# Next не может быть источником 5xx бэкенда. `/health` на этом домене не
|
|
||||||
# проксируется (allowlist ниже отдаёт по нему 404), но матчер добавлен для
|
|
||||||
# единообразия с gendsgn.ru — вреда от него ноль.
|
|
||||||
log access_stdout {
|
|
||||||
output stdout
|
|
||||||
format json
|
|
||||||
}
|
|
||||||
|
|
||||||
@noisy_access_log {
|
|
||||||
path /health /trade-in/_next/static/*
|
|
||||||
}
|
|
||||||
log_skip @noisy_access_log
|
|
||||||
|
|
||||||
# `/metrics` наружу не отдаётся (#3078). Здесь действует белый список и
|
# `/metrics` наружу не отдаётся (#3078). Здесь действует белый список и
|
||||||
# финальный `handle { respond 404 }`, так что путь и без этой строки не
|
# финальный `handle { respond 404 }`, так что путь и без этой строки не
|
||||||
# проходит, — но у бэкенда «Меры» он ОТКРЫТ без авторизации ради агента
|
# проходит, — но у бэкенда «Меры» он ОТКРЫТ без авторизации ради агента
|
||||||
|
|
@ -471,7 +329,6 @@ meraocenka.ru {
|
||||||
handle / {
|
handle / {
|
||||||
rewrite * /trade-in/mera-public
|
rewrite * /trade-in/mera-public
|
||||||
reverse_proxy tradein-frontend:3000 {
|
reverse_proxy tradein-frontend:3000 {
|
||||||
import tradein_frontend_retry
|
|
||||||
# Тот же периметр-скраб, что у @uipreview (:87) и @tradein ниже.
|
# Тот же периметр-скраб, что у @uipreview (:87) и @tradein ниже.
|
||||||
# Этот блок вообще не под basic_auth, поэтому анонимный клиент
|
# Этот блок вообще не под basic_auth, поэтому анонимный клиент
|
||||||
# тем более может прислать свой X-Authenticated-User. Сейчас
|
# тем более может прислать свой X-Authenticated-User. Сейчас
|
||||||
|
|
@ -511,18 +368,10 @@ meraocenka.ru {
|
||||||
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
|
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
|
||||||
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
|
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
|
||||||
# «/», и запрос закольцуется.
|
# «/», и запрос закольцуется.
|
||||||
# /sitemap.xml дописан той же строкой, а не отдельным handle (как
|
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business
|
||||||
# robots.txt ниже): Next отдаёт его по /trade-in/mera-public/sitemap.xml
|
|
||||||
# (src/app/mera-public/sitemap.ts) — ровно в форме, которую уже собирает
|
|
||||||
# rewrite `/trade-in/mera-public{path}` ниже. robots.txt так не смог бы:
|
|
||||||
# Next отдаёт его КОРНЕВЫМ /trade-in/robots.txt (конвенция Next —
|
|
||||||
# robots.txt существует только в корне app/, не под mera-public/), эта
|
|
||||||
# форма под `{path}` не попадает — отсюда отдельный handle у него.
|
|
||||||
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /articles/skolko-stoit-metr-v-ekaterinburge /articles/pochemu-kvartira-ne-prodaetsya /docs /business /sitemap.xml
|
|
||||||
handle @meraPages {
|
handle @meraPages {
|
||||||
rewrite * /trade-in/mera-public{path}
|
rewrite * /trade-in/mera-public{path}
|
||||||
reverse_proxy tradein-frontend:3000 {
|
reverse_proxy tradein-frontend:3000 {
|
||||||
import tradein_frontend_retry
|
|
||||||
header_up -X-Authenticated-User
|
header_up -X-Authenticated-User
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -533,28 +382,9 @@ meraocenka.ru {
|
||||||
# голый 404 (так было и до этого PR, с момента #2615). Заодно это
|
# голый 404 (так было и до этого PR, с момента #2615). Заодно это
|
||||||
# замыкает цепочку для длинных адресов со слэшем: они приходят на короткий
|
# замыкает цепочку для длинных адресов со слэшем: они приходят на короткий
|
||||||
# со слэшем и здесь нормализуются.
|
# со слэшем и здесь нормализуются.
|
||||||
# БАГ, найденный замером на живом проде 10.09.2026:
|
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs|business)/$
|
||||||
# https://meraocenka.ru/articles/?utm_source=vc -> Location:
|
|
||||||
# https://meraocenka.ru/articles — query терялся, потому что
|
|
||||||
# `redir * /{re.shortslash.1}` собирает цель заново из regex-захвата
|
|
||||||
# пути и не переносит исходную query-строку. Вред тихий: статьи
|
|
||||||
# публикуются с UTM-метками, а мессенджеры и автолинкификаторы дописывают
|
|
||||||
# слэш к скопированной ссылке — то есть именно трафик по опубликованному
|
|
||||||
# посту терял атрибуцию, и 301 при этом честно отрабатывал (просто не
|
|
||||||
# туда), ошибки никто не видел.
|
|
||||||
#
|
|
||||||
# Тот же приём, что у @meraLongPages/@meraV3Gone ниже и выше: `uri
|
|
||||||
# strip_suffix /` снимает ровно завершающий слэш (сам путь уже сужен
|
|
||||||
# матчером до канонического короткого вида), `redir * {uri} permanent`
|
|
||||||
# переносит query как есть. Обёртка `route` обязательна по той же
|
|
||||||
# причине, что у соседей — внутри `handle` порядок директив определяет
|
|
||||||
# Caddy, и без неё `redir` выполнился бы раньше `uri`.
|
|
||||||
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|articles/skolko-stoit-metr-v-ekaterinburge|articles/pochemu-kvartira-ne-prodaetsya|docs|business)/$
|
|
||||||
handle @meraShortSlash {
|
handle @meraShortSlash {
|
||||||
route {
|
redir * /{re.shortslash.1} permanent
|
||||||
uri strip_suffix /
|
|
||||||
redir * {uri} permanent
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# `/v3` — АДРЕС ПРЕВЬЮ, КОТОРОГО БОЛЬШЕ НЕТ. С 29.08.2026 выбранный вариант
|
# `/v3` — АДРЕС ПРЕВЬЮ, КОТОРОГО БОЛЬШЕ НЕТ. С 29.08.2026 выбранный вариант
|
||||||
|
|
@ -637,7 +467,7 @@ meraocenka.ru {
|
||||||
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
|
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
|
||||||
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
|
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
|
||||||
# (бесконечный цикл; поймано на локальном стенде).
|
# (бесконечный цикл; поймано на локальном стенде).
|
||||||
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/articles/skolko-stoit-metr-v-ekaterinburge /trade-in/mera-public/articles/skolko-stoit-metr-v-ekaterinburge/ /trade-in/mera-public/articles/pochemu-kvartira-ne-prodaetsya /trade-in/mera-public/articles/pochemu-kvartira-ne-prodaetsya/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/
|
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/
|
||||||
handle @meraLongPages {
|
handle @meraLongPages {
|
||||||
route {
|
route {
|
||||||
uri strip_prefix /trade-in/mera-public
|
uri strip_prefix /trade-in/mera-public
|
||||||
|
|
@ -689,7 +519,6 @@ meraocenka.ru {
|
||||||
respond @foreignRouteChunk 404
|
respond @foreignRouteChunk 404
|
||||||
|
|
||||||
reverse_proxy tradein-frontend:3000 {
|
reverse_proxy tradein-frontend:3000 {
|
||||||
import tradein_frontend_retry
|
|
||||||
header_up -X-Authenticated-User
|
header_up -X-Authenticated-User
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -700,53 +529,6 @@ meraocenka.ru {
|
||||||
handle /favicon.ico {
|
handle /favicon.ico {
|
||||||
rewrite * /trade-in/favicon.ico
|
rewrite * /trade-in/favicon.ico
|
||||||
reverse_proxy tradein-frontend:3000 {
|
reverse_proxy tradein-frontend:3000 {
|
||||||
import tradein_frontend_retry
|
|
||||||
header_up -X-Authenticated-User
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# robots.txt — ОТДЕЛЬНЫЙ handle, а не строка в @meraPages выше: у Next
|
|
||||||
# robots.txt — конвенция корня app/ (src/app/robots.ts), а не
|
|
||||||
# mera-public/, поэтому rewrite `/trade-in/mera-public{path}` его не
|
|
||||||
# обслужит — нужен свой rewrite на /trade-in/robots.txt. По образцу
|
|
||||||
# favicon.ico прямо над этим блоком, включая тот же периметр-скраб
|
|
||||||
# заголовка.
|
|
||||||
handle /robots.txt {
|
|
||||||
rewrite * /trade-in/robots.txt
|
|
||||||
reverse_proxy tradein-frontend:3000 {
|
|
||||||
import tradein_frontend_retry
|
|
||||||
header_up -X-Authenticated-User
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# og-mera.png — картинка превью ссылок (og:image). Её просят НЕ браузеры
|
|
||||||
# посетителей, а обходчики мессенджеров и соцсетей (Telegram, VK,
|
|
||||||
# WhatsApp), причём анонимно и без Referer: если путь не открыт, ссылка на
|
|
||||||
# сайт разворачивается без картинки, и понять это по логам приложения
|
|
||||||
# нельзя — запрос просто не доходит.
|
|
||||||
#
|
|
||||||
# Отдельный handle по той же причине, что у robots.txt: файл лежит в
|
|
||||||
# `public/` фронта и раздаётся Next'ом по basePath-корню
|
|
||||||
# (/trade-in/og-mera.png), а не под /trade-in/mera-public{path}, куда
|
|
||||||
# рерайтит @meraPages. Адрес намеренно короткий: он попадает в разметку
|
|
||||||
# каждой публичной страницы и расходится по чужим кэшам, так что менять
|
|
||||||
# его потом дорого.
|
|
||||||
handle /og-mera.png {
|
|
||||||
rewrite * /trade-in/og-mera.png
|
|
||||||
reverse_proxy tradein-frontend:3000 {
|
|
||||||
import tradein_frontend_retry
|
|
||||||
header_up -X-Authenticated-User
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# logo-mera.png — логотип организации из микроразметки (Organization.logo).
|
|
||||||
# Отдельный файл от og-mera.png выше: превью ссылки широкое, а логотип
|
|
||||||
# поисковик обрезает близко к квадрату. Свой handle по той же причине —
|
|
||||||
# файл лежит в `public/` и раздаётся по basePath-корню.
|
|
||||||
handle /logo-mera.png {
|
|
||||||
rewrite * /trade-in/logo-mera.png
|
|
||||||
reverse_proxy tradein-frontend:3000 {
|
|
||||||
import tradein_frontend_retry
|
|
||||||
header_up -X-Authenticated-User
|
header_up -X-Authenticated-User
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -789,19 +571,6 @@ meraocenka.ru {
|
||||||
handle {
|
handle {
|
||||||
respond 404
|
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.
|
# Домены-спутники МЕРА → 301 на канонический meraocenka.ru.
|
||||||
|
|
|
||||||
|
|
@ -56,28 +56,6 @@ errors.gendsgn.ru {
|
||||||
git.gendsgn.ru {
|
git.gendsgn.ru {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
# Генерация архивов закрыта — она съедала диск (16.09.2026).
|
|
||||||
#
|
|
||||||
# Forgejo на КАЖДЫЙ GET /<owner>/<repo>/archive/<ref>.{zip,tar.gz,bundle}
|
|
||||||
# собирает снапшот репозитория заново (20-30 МБ, несколько секунд CPU) и
|
|
||||||
# кладёт результат в кэш repo-archive. Штатная уборка archive_cleanup
|
|
||||||
# работает, но раз в сутки — а внешний обходчик (73 из 78 обращений за 6ч
|
|
||||||
# с одного адреса 216.73.216.17) дёргал архив для каждого коммита и держал
|
|
||||||
# генерацию на ~100 файлах в час. Кэш вырос до 55 ГБ при диске 145 ГБ и ел
|
|
||||||
# 2 ГБ/ч: до нуля оставалось ~12 часов.
|
|
||||||
#
|
|
||||||
# Ограничение скорости тут не лечит: даже один запрос в минуту — это
|
|
||||||
# 30 МБ/мин новых файлов при суточной уборке. Нужен именно отказ.
|
|
||||||
# На `git clone`, `git fetch` и работу CI это НЕ влияет: смарт-протокол
|
|
||||||
# ходит по /<owner>/<repo>/info/refs и /git-upload-pack, другие пути.
|
|
||||||
# Цена: анонимная кнопка «скачать архив» в вебе больше не работает —
|
|
||||||
# сознательный размен, вернуть можно снятием этих двух правил.
|
|
||||||
@repo_archive path_regexp repo_archive ^/[^/]+/[^/]+/archive/.+$
|
|
||||||
respond @repo_archive "Архивы отключены: используйте git clone" 403
|
|
||||||
|
|
||||||
@api_archive path_regexp api_archive ^/api/v1/repos/[^/]+/[^/]+/archive/.+$
|
|
||||||
respond @api_archive "Архивы отключены: используйте git clone" 403
|
|
||||||
|
|
||||||
reverse_proxy forgejo:3000
|
reverse_proxy forgejo:3000
|
||||||
|
|
||||||
log {
|
log {
|
||||||
|
|
@ -127,44 +105,11 @@ metrics.gendsgn.ru {
|
||||||
# угадавший, — ложная отметка «принято» в чате, где сразу видно, что её
|
# угадавший, — ложная отметка «принято» в чате, где сразу видно, что её
|
||||||
# поставил не человек. Прав в системе токен не даёт никаких.
|
# поставил не человек. Прав в системе токен не даёт никаких.
|
||||||
#
|
#
|
||||||
# Сервис отвечает только на /ack/*, /glitchtip и /healthz; всё прочее — 404.
|
# Сервис отвечает только на /ack/* и /healthz; всё прочее — 404.
|
||||||
handle /ack/* {
|
handle /ack/* {
|
||||||
reverse_proxy alert-ack:8080
|
reverse_proxy alert-ack:8080
|
||||||
}
|
}
|
||||||
|
|
||||||
# Резервный приёмник алертов GlitchTip (#3471). Основной получатель —
|
|
||||||
# продуктовый бэкенд на Selectel, то есть тот самый сервис, за которым эти
|
|
||||||
# алерты и следят: пока он лежит, его собственные ошибки доставлять некому.
|
|
||||||
# Этот путь живёт у другого провайдера и с чистой сетью до Telegram, поэтому
|
|
||||||
# переживает падение Selectel целиком.
|
|
||||||
#
|
|
||||||
# Секрет — в значении query-параметра, а не в пути: путь сам по себе не
|
|
||||||
# секрет, и его попадание в access-лог безопасно. Само значение вырезает
|
|
||||||
# scrub_credentials в Alloy до записи в Loki (#3154).
|
|
||||||
handle /glitchtip* {
|
|
||||||
reverse_proxy alert-ack:8080
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ретранслятор Bot API продукта (#3471). Путь от Selectel до
|
|
||||||
# api.telegram.org теряет примерно каждый четвёртый короткий запрос, тот же
|
|
||||||
# замер с Beget в те же минуты — чистый. Поэтому продуктовый бот ходит в
|
|
||||||
# Telegram отсюда, а не напрямую.
|
|
||||||
#
|
|
||||||
# Путь содержит токен бота (/tg-relay/bot<TOKEN>/<method>) — он не должен
|
|
||||||
# осесть ни в файловом логе сайта, ни в stdout-копии (#3154), поэтому
|
|
||||||
# запрос помечен log_skip.
|
|
||||||
@tg_relay path /tg-relay/*
|
|
||||||
log_skip @tg_relay
|
|
||||||
|
|
||||||
handle_path /tg-relay/* {
|
|
||||||
reverse_proxy tg-relay:8080 {
|
|
||||||
# getUpdates — long-poll до ~40с, дефолтный таймаут ответа короче.
|
|
||||||
transport http {
|
|
||||||
response_header_timeout 80s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Вход ОДИН — собственный вход Grafana (#3078). Внешний basic_auth снят по
|
# Вход ОДИН — собственный вход Grafana (#3078). Внешний basic_auth снят по
|
||||||
# решению владельца: два запроса пароля подряд мешали работе, а Grafana имеет
|
# решению владельца: два запроса пароля подряд мешали работе, а Grafana имеет
|
||||||
# собственную аутентификацию с ролями и `GF_USERS_ALLOW_SIGN_UP=false`.
|
# собственную аутентификацию с ролями и `GF_USERS_ALLOW_SIGN_UP=false`.
|
||||||
|
|
|
||||||
|
|
@ -231,77 +231,6 @@ services:
|
||||||
mem_limit: 128m
|
mem_limit: 128m
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
|
|
||||||
# ── redis-exporter: здоровье общего Redis (только на Poincare) ───────────────
|
|
||||||
# #3471. Redis — один инстанс на три потребителя: db0 celery-брокер Site
|
|
||||||
# Finder, db1 SearchCache trade-in, db2 glitchtip (см. комментарий у сервиса
|
|
||||||
# `redis` в docker-compose.prod.yml). Один `redis_up` покрывает риск для всех
|
|
||||||
# трёх разом — до этой правки Redis не измерялся вообще, переполнение
|
|
||||||
# брокера и обычная недоступность снаружи выглядели одинаково — тишиной.
|
|
||||||
#
|
|
||||||
# Адрес — через alias `gendesign-redis`, который `redis` регистрирует на
|
|
||||||
# сети `shared` (см. #2709 в docker-compose.prod.yml) — джойнить ещё и
|
|
||||||
# `product` не нужно, тем же путём уже идёт postgres-exporter-tradein.
|
|
||||||
#
|
|
||||||
# Пароль — из окружения, не хардкод: сегодня на Redis нет requirepass (нет
|
|
||||||
# переменной ни в docker-compose.prod.yml, ни здесь), но если он появится,
|
|
||||||
# значение подставляется через METRICS_REDIS_PASSWORD в /opt/gendesign/.env
|
|
||||||
# на хосте, а не в этот файл.
|
|
||||||
redis-exporter:
|
|
||||||
image: oliver006/redis_exporter:v1.65.0
|
|
||||||
container_name: gendesign-redis-exporter
|
|
||||||
restart: unless-stopped
|
|
||||||
profiles: ["apps"]
|
|
||||||
environment:
|
|
||||||
REDIS_ADDR: ${METRICS_REDIS_ADDR:-redis://gendesign-redis:6379}
|
|
||||||
REDIS_PASSWORD: ${METRICS_REDIS_PASSWORD:-}
|
|
||||||
expose:
|
|
||||||
- "9121"
|
|
||||||
networks:
|
|
||||||
- shared
|
|
||||||
mem_limit: 64m
|
|
||||||
logging: *default-logging
|
|
||||||
|
|
||||||
# ── celery-exporter: очередь Site Finder (только на Poincare) ────────────────
|
|
||||||
# #3471. Слепая зона: глубина очереди, число живых воркеров и счётчик
|
|
||||||
# неуспешных задач нигде не измерялись — залипший воркер и переполненная
|
|
||||||
# очередь снаружи неотличимы от тишины.
|
|
||||||
#
|
|
||||||
# ПОЧЕМУ ОТДЕЛЬНЫЙ ОБРАЗ, А НЕ redis-exporter --check-keys. check-keys дал
|
|
||||||
# бы LLEN дефолтной очереди "celery" (в backend/app/workers/celery_app.py
|
|
||||||
# НЕТ task_routes — все таски идут в один дефолтный queue, имя буквально
|
|
||||||
# "celery") без нового образа вообще. Но он НЕ умеет считать живых
|
|
||||||
# воркеров и неуспешные таски — то есть закрыл бы только треть минимума
|
|
||||||
# из задачи. celery-exporter слушает событийную шину Celery через тот же
|
|
||||||
# брокер и даёт все три метрики разом, поэтому выбран он, а не комбинация
|
|
||||||
# check-keys + что-то ещё для остальных двух чисел.
|
|
||||||
#
|
|
||||||
# ТЕГ И ФЛАГИ СВЕРЕНЫ С ИСХОДНИКОМ ОБРАЗА, а не с памятью (#3493). Прежняя
|
|
||||||
# запись `0.13.0` + `--queue` не существовала вовсе: такого тега нет на Docker
|
|
||||||
# Hub (`pull` → `not found`), а в `src/cli.py` версии 0.12.2 опция зовётся
|
|
||||||
# `--queues` (список через запятую), `--queue` click отвергает при старте.
|
|
||||||
# Под `set -e` упавший `pull` обрывал ВСЮ джобу `agent-apps` деплоя метрик:
|
|
||||||
# с 12.09 до этой правки на Poincare не доезжало ничего из агента — ни этот
|
|
||||||
# экспортер, ни redis-exporter, ни новый конфиг Alloy, а NoActiveCeleryWorkers
|
|
||||||
# горел по ветке absent() при живом воркере.
|
|
||||||
#
|
|
||||||
# Имена метрик 0.12.2 (src/exporter.py, metric_prefix="celery_"):
|
|
||||||
# celery_worker_up{hostname}, celery_queue_length{queue_name},
|
|
||||||
# celery_task_failed_total — совпадают с ops/metrics/prometheus/rules/infra.yml.
|
|
||||||
celery-exporter:
|
|
||||||
image: danihodovic/celery-exporter:0.12.2
|
|
||||||
container_name: gendesign-celery-exporter
|
|
||||||
restart: unless-stopped
|
|
||||||
profiles: ["apps"]
|
|
||||||
command:
|
|
||||||
- "--broker-url=${METRICS_CELERY_BROKER_URL:-redis://gendesign-redis:6379/0}"
|
|
||||||
- "--queues=celery"
|
|
||||||
expose:
|
|
||||||
- "9808"
|
|
||||||
networks:
|
|
||||||
- shared
|
|
||||||
mem_limit: 128m
|
|
||||||
logging: *default-logging
|
|
||||||
|
|
||||||
# ── postgres-exporter: инфраструктурная БД (только на Beget) ─────────────────
|
# ── postgres-exporter: инфраструктурная БД (только на Beget) ─────────────────
|
||||||
# forgejo + glitchtip. Нужен и сам по себе, и как страховка: рост базы glitchtip
|
# forgejo + glitchtip. Нужен и сам по себе, и как страховка: рост базы glitchtip
|
||||||
# ничем не ограничен — политики ретенции у GlitchTip нет вообще.
|
# ничем не ограничен — политики ретенции у GlitchTip нет вообще.
|
||||||
|
|
|
||||||
|
|
@ -196,12 +196,6 @@ services:
|
||||||
# Внешний адрес попадает в кнопку. Пустой — сообщение уйдёт без кнопки,
|
# Внешний адрес попадает в кнопку. Пустой — сообщение уйдёт без кнопки,
|
||||||
# но уйдёт: алерт важнее подтверждения.
|
# но уйдёт: алерт важнее подтверждения.
|
||||||
ALERT_ACK_PUBLIC_URL: ${ALERT_ACK_PUBLIC_URL:-https://metrics.gendsgn.ru}
|
ALERT_ACK_PUBLIC_URL: ${ALERT_ACK_PUBLIC_URL:-https://metrics.gendsgn.ru}
|
||||||
# Резервный получатель GlitchTip-алертов (#3471, POST /glitchtip) — второй
|
|
||||||
# получатель наряду с основным вебхуком в продуктовый бэкенд на Selectel.
|
|
||||||
# Секрет СВОЙ, не общий с продуктовым TRADEIN_INTERNAL_AUTH_SECRET: разные
|
|
||||||
# хосты/домены безопасности. Пусто — эндпоинт отвечает 503, остальной
|
|
||||||
# функционал сервиса не затронут.
|
|
||||||
ALERT_ACK_GLITCHTIP_SECRET: ${ALERT_ACK_GLITCHTIP_SECRET:-}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./ops/metrics/alert-ack/app.py:/app/app.py:ro
|
- ./ops/metrics/alert-ack/app.py:/app/app.py:ro
|
||||||
expose:
|
expose:
|
||||||
|
|
@ -216,74 +210,7 @@ services:
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
# ── Ретранслятор Bot API продукта через Beget (#3471) ───────────────────────
|
|
||||||
#
|
|
||||||
# Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` из контейнера
|
|
||||||
# `tradein-tgbot` на Selectel — 9 успешных из 12, три ConnectTimeout. TCP-443
|
|
||||||
# до адреса, резолвящегося на Selectel — 5/6. Тот же TCP-443 до адреса,
|
|
||||||
# резолвящегося на Beget — 8/8. За сутки 508 строк `network error` в логе
|
|
||||||
# бота, за 30 дней 92 обрыва итерации poll loop. Путь до Telegram с Selectel
|
|
||||||
# лоссовый, с Beget чистый — Alertmanager (тот же чат, живёт рядом) шлёт без
|
|
||||||
# проблем. Продуктовые sendMessage/copyMessage/getUpdates идут сюда вместо
|
|
||||||
# прямого пути; выключается пустым TELEGRAM_RELAY_BASE_URL на стороне
|
|
||||||
# продукта — это и есть откат.
|
|
||||||
#
|
|
||||||
# НЕ рядом с продуктом: смысл ретранслятора именно в том, что он живёт там,
|
|
||||||
# откуда путь до Telegram чистый, а не там, откуда он лоссовый.
|
|
||||||
#
|
|
||||||
# Образ без сборки и без зависимостей (только stdlib) — тот же принцип, что у
|
|
||||||
# alert-ack: сервис обязан подниматься даже когда сломано всё остальное.
|
|
||||||
tg-relay:
|
|
||||||
image: python:3.12-slim
|
|
||||||
container_name: gendesign-tg-relay
|
|
||||||
# #3471 (PR #3487 инцидент): без profiles сервис поднимался ВСЕГДА, а при
|
|
||||||
# пустом TG_RELAY_SECRET делает SystemExit — то есть уходит в бесконечный
|
|
||||||
# Restarting сразу после деплоя. Профиль включает deploy-metrics.yml, и
|
|
||||||
# только когда секрет реально задан (см. PROFILES там).
|
|
||||||
profiles: ["relay"]
|
|
||||||
restart: unless-stopped
|
|
||||||
user: "65534:65534"
|
|
||||||
command: ["python", "-u", "/app/app.py"]
|
|
||||||
env_file:
|
|
||||||
- path: ./backend/.env.runtime
|
|
||||||
required: false
|
|
||||||
- path: ./backend/.env
|
|
||||||
required: false
|
|
||||||
environment:
|
|
||||||
# Общий секрет с продуктовым клиентом (TELEGRAM_RELAY_SECRET на стороне
|
|
||||||
# tradein-backend/tradein-tgbot) — домен публичный, без секрета отказ.
|
|
||||||
TG_RELAY_SECRET: ${TG_RELAY_SECRET:-}
|
|
||||||
volumes:
|
|
||||||
- ./ops/metrics/tg-relay/app.py:/app/app.py:ro
|
|
||||||
expose:
|
|
||||||
- "8080"
|
|
||||||
networks:
|
|
||||||
- shared
|
|
||||||
mem_limit: 128m
|
|
||||||
logging: *default-logging
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://localhost:8080/healthz',timeout=5)"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
# ── Grafana: витрина ─────────────────────────────────────────────────────────
|
# ── Grafana: витрина ─────────────────────────────────────────────────────────
|
||||||
# Grafana здесь ТОЛЬКО рисует — не решает, что считать инцидентом и куда его
|
|
||||||
# слать. Тревоги живут в Prometheus (правила) и Alertmanager (маршрутизация,
|
|
||||||
# Telegram); это единственный путь доставки (#3158).
|
|
||||||
#
|
|
||||||
# Встроенный Alerting выключен ЯВНО, а не просто «не настроен». Проверка на
|
|
||||||
# живом API 12.09.2026 нашла: 0 правил, единственный контакт-поинт —
|
|
||||||
# стоковый grafana-default-email на example@email.com, GF_SMTP_* не заданы.
|
|
||||||
# То есть кнопка «New alert rule» в интерфейсе есть и работает, а результат
|
|
||||||
# молча уходит в никуда — ровно та ситуация, из-за которой никто не проверяет
|
|
||||||
# второй, настоящий путь. Дублирующий движок на том же датасорсе Prometheus
|
|
||||||
# надёжности всё равно не прибавляет (общая точка отказа), только даёт второе
|
|
||||||
# место, где правило может быть заведено и забыто.
|
|
||||||
#
|
|
||||||
# Если это когда-нибудь понадобится включить обратно — сначала подключить
|
|
||||||
# реальный SMTP или другой contact point и завести хотя бы одно тестовое
|
|
||||||
# правило руками, иначе вернётся тот же капкан.
|
|
||||||
grafana:
|
grafana:
|
||||||
image: grafana/grafana:11.5.1
|
image: grafana/grafana:11.5.1
|
||||||
container_name: gendesign-grafana
|
container_name: gendesign-grafana
|
||||||
|
|
@ -303,12 +230,6 @@ services:
|
||||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-}
|
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-}
|
||||||
GF_SERVER_ROOT_URL: https://metrics.gendsgn.ru/
|
GF_SERVER_ROOT_URL: https://metrics.gendsgn.ru/
|
||||||
GF_SERVER_SERVE_FROM_SUB_PATH: "false"
|
GF_SERVER_SERVE_FROM_SUB_PATH: "false"
|
||||||
# Единственный официальный переключатель Grafana Alerting в 11.x — секция
|
|
||||||
# [unified_alerting], легаси-[alerting] удалён из Grafana ещё в 9.0 и в
|
|
||||||
# 11.5 в конфиге отсутствует (сверено с grafana.com/docs/grafana/v11.5/
|
|
||||||
# setup-grafana/configure-grafana/#unified_alerting). false здесь убирает
|
|
||||||
# раздел Alerting из UI и глушит движок правил целиком — см. #3158 выше.
|
|
||||||
GF_UNIFIED_ALERTING_ENABLED: "false"
|
|
||||||
# Телеметрия наружу — выключена. Отдельный хост, отдельный провайдер, и не
|
# Телеметрия наружу — выключена. Отдельный хост, отдельный провайдер, и не
|
||||||
# хочется, чтобы наблюдатель сам ходил в интернет без нужды.
|
# хочется, чтобы наблюдатель сам ходил в интернет без нужды.
|
||||||
GF_ANALYTICS_REPORTING_ENABLED: "false"
|
GF_ANALYTICS_REPORTING_ENABLED: "false"
|
||||||
|
|
|
||||||
|
|
@ -778,9 +778,6 @@ services:
|
||||||
# роняя ВСЕ сайты хоста, а не только metrics.gendsgn.ru.
|
# роняя ВСЕ сайты хоста, а не только metrics.gendsgn.ru.
|
||||||
- ./caddy/metrics-ingest.caddy.snippet:/etc/caddy/caddy/metrics-ingest.caddy.snippet:ro
|
- ./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
|
- ./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). Каталог
|
# Untracked локальные site-блоки (см. import в конце Caddyfile). Каталог
|
||||||
# держится в git через caddy/local/.gitignore — иначе docker создал бы
|
# держится в git через caddy/local/.gitignore — иначе docker создал бы
|
||||||
# отсутствующий bind-source сам, root-owned пустышкой.
|
# отсутствующий bind-source сам, root-owned пустышкой.
|
||||||
|
|
|
||||||
|
|
@ -1919,17 +1919,17 @@ export interface paths {
|
||||||
* @description Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
|
* @description Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff,
|
||||||
* ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
|
* ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__).
|
||||||
*
|
*
|
||||||
* Beat-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py).
|
* Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint — для ad-hoc
|
||||||
* Этот endpoint — для ad-hoc запуска (smoke-тест или повторный pass для
|
* запуска (smoke-тест после деплоя или повторный pass для свежесозданных
|
||||||
* свежесозданных объектов).
|
* объектов до next beat fire).
|
||||||
*
|
*
|
||||||
* - max_objects=None → дефолтный лимит таска (300).
|
* - max_objects=None → дефолтный лимит таска (300).
|
||||||
* - max_objects=3 → smoke-тест.
|
* - max_objects=3 → smoke-тест.
|
||||||
* - force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд.
|
* - force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд.
|
||||||
*
|
*
|
||||||
* Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
* WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||||
* beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc
|
* beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
|
||||||
* запуск без явного подтверждения оператора запрещён (условие — #3307).
|
* re-trigger без явного подтверждения оператора запрещён.
|
||||||
*/
|
*/
|
||||||
post: operations["trigger_kn_catalog_objects_api_v1_admin_scrape_kn_catalog_objects_post"];
|
post: operations["trigger_kn_catalog_objects_api_v1_admin_scrape_kn_catalog_objects_post"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
|
|
@ -1960,9 +1960,9 @@ export interface paths {
|
||||||
* - max_flats=3 → smoke-тест.
|
* - max_flats=3 → smoke-тест.
|
||||||
* - force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
|
* - force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
|
||||||
*
|
*
|
||||||
* Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
* WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||||
* same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же
|
* same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
|
||||||
* блокировкой StormWall (условие — #3307).
|
* того же WAF-бана.
|
||||||
*/
|
*/
|
||||||
post: operations["trigger_kn_catalog_flats_api_v1_admin_scrape_kn_catalog_flats_post"];
|
post: operations["trigger_kn_catalog_flats_api_v1_admin_scrape_kn_catalog_flats_post"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
|
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
# Применить текущий конфиг прокси к работающему Caddy (#3443).
|
|
||||||
#
|
|
||||||
# ЗАЧЕМ. Полный деплой ПТИЦЫ пересоздавал сам Caddy БЕЗУСЛОВНО
|
|
||||||
# (`up -d --force-recreate --no-deps caddy`), а вместе с контейнером исчезал
|
|
||||||
# единственный процесс, слушающий 80/443. Замер 05.09 (#3274): 67 с `code=000`
|
|
||||||
# на ВСЕХ доменах хоста — gendsgn.ru, meraocenka.ru и зеркала, включая
|
|
||||||
# публичный лендинг МЕРЫ. Это не 502/503: принимающего процесса нет вовсе,
|
|
||||||
# поэтому заглушка окна деплоя (caddy/sites/deploy-window.caddy.snippet) здесь
|
|
||||||
# бессильна по построению — её отдаёт тот же Caddy.
|
|
||||||
#
|
|
||||||
# ЧТО НА САМОМ ДЕЛЕ ТРЕБУЕТ ПЕРЕСОЗДАНИЯ. Безусловный флаг появился 17.05
|
|
||||||
# (11e78d73) ради нового bind-маунта `./preview` из docker-compose.prod.yml,
|
|
||||||
# который «не появлялся в running container». Довод неверен: `docker compose
|
|
||||||
# up -d` БЕЗ `--force-recreate` пересоздаёт контейнер сам, как только меняется
|
|
||||||
# описание сервиса или образ. Проверено на живом демоне (docker 28.4):
|
|
||||||
# добавлен volume → `Container … Starting/Started`, id контейнера новый;
|
|
||||||
# тег указан на др. образ → id новый;
|
|
||||||
# не менялось ничего → `Container … Running`, id тот же.
|
|
||||||
#
|
|
||||||
# Остаётся ровно один класс изменений, которого compose не видит: СОДЕРЖИМОЕ
|
|
||||||
# пофайлового bind-маунта. `git reset --hard` не правит файл на месте, а пишет
|
|
||||||
# новый инод; контейнер держит примонтированным прежний и продолжает читать
|
|
||||||
# его — `caddy reload` перечитает ровно тот же старый инод. Тот же механизм уже
|
|
||||||
# ловили на Alertmanager (27.08, deploy-metrics.yml) и на Alloy (#3380). У Caddy
|
|
||||||
# так смонтированы пять путей: Caddyfile и четыре сниппета. Каталоги
|
|
||||||
# (caddy/sites, caddy/local, preview) этим не страдают — правка внутри каталога
|
|
||||||
# видна контейнеру сразу, поэтому самый частый случай (caddy/sites/apps.caddy)
|
|
||||||
# пересоздания НЕ требует.
|
|
||||||
#
|
|
||||||
# ОТСЮДА ПОРЯДОК: проверить конфиг → `up -d` без `--force-recreate` → если
|
|
||||||
# контейнер остался тем же, сверить, видит ли он текущее содержимое пофайловых
|
|
||||||
# маунтов → пересоздать ТОЛЬКО при расхождении, иначе `caddy reload`, который
|
|
||||||
# не рвёт соединения.
|
|
||||||
#
|
|
||||||
# ГРАНИЦА. Сверка по СОДЕРЖИМОМУ, а не по иноду: файл, переписанный тем же
|
|
||||||
# текстом, пересоздания не требует. Не прочиталось (контейнер не запущен, в
|
|
||||||
# образе нет sha256sum) — считаем расхождением: fail-safe в сторону прежнего
|
|
||||||
# поведения, то есть пересоздания.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
# Оба вызывающих (job `deploy` и job `deploy-caddy` в .forgejo/workflows/deploy.yml)
|
|
||||||
# работают в /opt/gendesign, но не зависеть от cwd дешевле, чем это помнить.
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
COMPOSE="docker compose -p gendesign -f docker-compose.prod.yml"
|
|
||||||
|
|
||||||
caddy_cid() { $COMPOSE ps -aq caddy 2>/dev/null | tail -n1; }
|
|
||||||
|
|
||||||
# ── 1. Проверка ДО применения ────────────────────────────────────────────────
|
|
||||||
# Одноразовый контейнер читает файлы С ХОСТА — то есть ровно то, что поедет в
|
|
||||||
# работающий Caddy. `exec caddy validate` здесь не годится: он проверил бы
|
|
||||||
# старый инод, то есть предыдущую версию конфига. Образ и парсер те же, что на
|
|
||||||
# PR-гейте (ci.yml «Guard: Caddyfile синтаксически валиден»).
|
|
||||||
#
|
|
||||||
# ГРАНИЦА ЭТОЙ ПРОВЕРКИ. Она обрывает применение до того, как конфиг попадёт в
|
|
||||||
# работающий Caddy, — но только на быстром пути. В полном деплое ВЫШЕ по
|
|
||||||
# скрипту (deploy.yml, `up -d $UP_SERVICES`) уже прошёл общий подъём всех
|
|
||||||
# сервисов, и если правка одновременно ломает Caddyfile И меняет блок caddy в
|
|
||||||
# docker-compose.prod.yml, контейнер пересоздастся там — с непроверенным
|
|
||||||
# конфигом и раньше этой строки. Первая линия против этого — гейт на PR (#2913).
|
|
||||||
echo "→ проверяю конфиг прокси одноразовым контейнером…"
|
|
||||||
if ! docker run --rm -v "$PWD:/work:ro" -w /work caddy:2 \
|
|
||||||
caddy validate --config /work/Caddyfile --adapter caddyfile; then
|
|
||||||
echo "ОШИБКА: конфиг прокси не применён — проверка не пройдена ЛИБО не удалось"
|
|
||||||
echo " запустить проверочный контейнер (нет образа caddy:2, занят демон,"
|
|
||||||
echo " недоступен реестр). Причина — в выводе выше, не гадать по этой строке."
|
|
||||||
echo " Работающий Caddy не тронут, домены живы."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 2. Описание сервиса и образ ──────────────────────────────────────────────
|
|
||||||
before=$(caddy_cid)
|
|
||||||
$COMPOSE up -d --no-deps caddy
|
|
||||||
after=$(caddy_cid)
|
|
||||||
if [ -z "$after" ]; then
|
|
||||||
echo 'ОШИБКА: после `up -d` контейнера caddy нет — смотри вывод compose выше.'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ "$before" != "$after" ]; then
|
|
||||||
echo "✓ Caddy пересоздан compose'ом: изменилось описание сервиса или образ (${before:-нет}→${after})."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 3. Доехало ли содержимое пофайловых маунтов ──────────────────────────────
|
|
||||||
# Список маунтов читается ОТДЕЛЬНОЙ командой, а не в конвейере с циклом: в
|
|
||||||
# `$(docker inspect … | while …)` статус подстановки — это статус `while`, то
|
|
||||||
# есть всегда 0 (`pipefail` в POSIX-sh нет вовсе). Провал `docker inspect`
|
|
||||||
# давал бы пустой список → «расхождений нет» → `caddy reload` → зелёная джоба с
|
|
||||||
# надписью «окна недоступности нет», а прокси работал бы по СТАРОМУ конфигу.
|
|
||||||
# Это ровно тот беззвучный отказ, ради которого написан весь скрипт.
|
|
||||||
mounts=$(docker inspect "$after" \
|
|
||||||
--format '{{range .Mounts}}{{if eq .Type "bind"}}{{.Source}}|{{.Destination}}{{println}}{{end}}{{end}}') \
|
|
||||||
|| mounts=''
|
|
||||||
|
|
||||||
if [ -z "$mounts" ]; then
|
|
||||||
echo "WARNING: список маунтов Caddy не прочитан (docker inspect молчит или упал) —"
|
|
||||||
echo " сверить нечем, считаю расхождением: fail-safe в прежнее поведение."
|
|
||||||
verdicts=""
|
|
||||||
stale="(маунты не прочитаны)"
|
|
||||||
else
|
|
||||||
verdicts=$(printf '%s\n' "$mounts" | while IFS='|' read -r src dst; do
|
|
||||||
[ -n "${dst:-}" ] || continue
|
|
||||||
# Каталог инод не держит — пропускаем. Именно `-d`, а не `-f`:
|
|
||||||
# отсутствующий/нечитаемый ФАЙЛ — не повод молча пропустить, в
|
|
||||||
# контейнере в этот момент живёт старый инод со старым текстом.
|
|
||||||
if [ -d "$src" ]; then continue; fi
|
|
||||||
host_sum=$(sha256sum "$src" 2>/dev/null | cut -d' ' -f1)
|
|
||||||
seen_sum=$(docker exec "$after" sha256sum "$dst" 2>/dev/null | cut -d' ' -f1)
|
|
||||||
if [ -n "$host_sum" ] && [ "$host_sum" = "${seen_sum:-НЕ_ПРОЧИТАНО}" ]; then
|
|
||||||
echo "OK $dst"
|
|
||||||
else
|
|
||||||
echo "STALE $dst"
|
|
||||||
fi
|
|
||||||
done)
|
|
||||||
# «Сверили пять файлов» и «сверили ноль» обязаны различаться в логе — иначе
|
|
||||||
# зелёная строка ниже одинаково означает и проверку, и её отсутствие.
|
|
||||||
checked=$(printf '%s\n' "$verdicts" | grep -c . || true)
|
|
||||||
echo "→ сверено пофайловых маунтов с тем, что видит контейнер: $checked"
|
|
||||||
stale=$(printf '%s\n' "$verdicts" | sed -n 's/^STALE //p' | tr '\n' ' ')
|
|
||||||
if [ "$checked" -eq 0 ]; then
|
|
||||||
echo "WARNING: ни одного пофайлового bind-маунта не найдено — у Caddy их пять"
|
|
||||||
echo " (Caddyfile + 4 сниппета). Считаю расхождением: fail-safe."
|
|
||||||
stale="(пофайловых маунтов не найдено)"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$stale" ]; then
|
|
||||||
echo "→ до контейнера НЕ доехали пофайловые маунты: $stale"
|
|
||||||
echo " (bind-маунт файла держит инод: reload перечитал бы старую версию — нужен recreate)"
|
|
||||||
$COMPOSE up -d --force-recreate --no-deps caddy
|
|
||||||
echo "✓ Caddy пересоздан — иначе правка осталась бы неприменённой."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 4. Всё доехало — перезагрузка без разрыва соединений ─────────────────────
|
|
||||||
$COMPOSE exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
|
|
||||||
echo "✓ конфиг прокси перезагружен без пересоздания контейнера — окна недоступности нет."
|
|
||||||
|
|
@ -10,8 +10,9 @@
|
||||||
# script says so loudly instead of the silence that let backups break for
|
# script says so loudly instead of the silence that let backups break for
|
||||||
# weeks undetected before (#71).
|
# weeks undetected before (#71).
|
||||||
#
|
#
|
||||||
# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), so an
|
# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), same
|
||||||
# hourly cron doesn't spam Telegram once a backup is already known to be stale.
|
# discipline as ops/uptime-healthcheck.sh, so an hourly cron doesn't spam
|
||||||
|
# Telegram once a backup is already known to be stale.
|
||||||
#
|
#
|
||||||
# Usage (cron — one line per sentinel, run more often than the backup itself
|
# Usage (cron — one line per sentinel, run more often than the backup itself
|
||||||
# so a stale state is caught promptly; hourly is a reasonable default for a
|
# so a stale state is caught promptly; hourly is a reasonable default for a
|
||||||
|
|
@ -26,10 +27,9 @@
|
||||||
# /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" \
|
# /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" \
|
||||||
# >> /var/log/gendesign-backup-staleness.log 2>&1
|
# >> /var/log/gendesign-backup-staleness.log 2>&1
|
||||||
#
|
#
|
||||||
# Alert channel: notify() in ops/lib-backup.sh — creds in
|
# Alert channel: reuses the Telegram bot from ops/uptime-healthcheck.sh (see
|
||||||
# ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}, TELEGRAM_BOT_TOKEN/
|
# notify() in ops/lib-backup.sh) — creds in /etc/default/gendesign-backup,
|
||||||
# TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID (245 «Metrics», #3164). Without the first
|
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID. Without them, logs only.
|
||||||
# two, logs only.
|
|
||||||
#
|
#
|
||||||
# Exit code: 0 = fresh, 1 = stale or sentinel missing (so this can ALSO be
|
# Exit code: 0 = fresh, 1 = stale or sentinel missing (so this can ALSO be
|
||||||
# used as a plain healthcheck by anything that just wants the exit code).
|
# used as a plain healthcheck by anything that just wants the exit code).
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,7 @@
|
||||||
15 4 * * * FORGEJO_BACKUP_ENV_FILE=/opt/gendesign/secrets/forgejo-backup.env bash /opt/gendesign/ops/backup-forgejo.sh >> /opt/gendesign/logs/backup-forgejo.log 2>&1
|
15 4 * * * FORGEJO_BACKUP_ENV_FILE=/opt/gendesign/secrets/forgejo-backup.env bash /opt/gendesign/ops/backup-forgejo.sh >> /opt/gendesign/logs/backup-forgejo.log 2>&1
|
||||||
|
|
||||||
# Сторож пропущенных прогонов для forgejo-бэкапа. Порог 26ч.
|
# Сторож пропущенных прогонов для forgejo-бэкапа. Порог 26ч.
|
||||||
# Канал оповещения сторожей и бэкапа волта — /opt/gendesign/secrets/backup-notify.env
|
0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
|
||||||
# (TELEGRAM_BOT_TOKEN/CHAT_ID/TOPIC_ID, тема 245 «Metrics», #3164). Без
|
|
||||||
# BACKUP_ENV_FILE notify() ищет /etc/default/gendesign-backup, где канала на
|
|
||||||
# этом хосте нет, — алерты молча не уходят никуда.
|
|
||||||
0 * * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
|
|
||||||
|
|
||||||
# ── Бэкап волта Obsidian (CouchDB) ──────────────────────────────────────────
|
# ── Бэкап волта Obsidian (CouchDB) ──────────────────────────────────────────
|
||||||
# Волт остаётся на этом хосте вместе с Forgejo и GlitchTip, поэтому запись
|
# Волт остаётся на этом хосте вместе с Forgejo и GlitchTip, поэтому запись
|
||||||
|
|
@ -37,11 +33,11 @@
|
||||||
# file-level копия работающего узла может оказаться рассогласованной. Время —
|
# file-level копия работающего узла может оказаться рассогласованной. Время —
|
||||||
# 04:45, между forgejo-бэкапом (04:15) и недельной уборкой докера (04:00 вс),
|
# 04:45, между forgejo-бэкапом (04:15) и недельной уборкой докера (04:00 вс),
|
||||||
# чтобы три задачи не дрались за диск и сеть одновременно.
|
# чтобы три задачи не дрались за диск и сеть одновременно.
|
||||||
45 4 * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1
|
45 4 * * * bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1
|
||||||
|
|
||||||
# Сторож пропущенных прогонов для бэкапа волта. Порог 26ч — как у остальных:
|
# Сторож пропущенных прогонов для бэкапа волта. Порог 26ч — как у остальных:
|
||||||
# сутки плюс запас на сдвиг прогона.
|
# сутки плюс запас на сдвиг прогона.
|
||||||
0 * * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
|
0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1
|
||||||
|
|
||||||
# ── Уборка докера ───────────────────────────────────────────────────────────
|
# ── Уборка докера ───────────────────────────────────────────────────────────
|
||||||
# Остаётся здесь, потому что здесь остаются CI-раннеры (#2881) — именно их
|
# Остаётся здесь, потому что здесь остаются CI-раннеры (#2881) — именно их
|
||||||
|
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
# Source-of-truth copy of the Forgejo compose file.
|
|
||||||
#
|
|
||||||
# Forgejo is NOT part of the automated deploy pipeline (deploy.yml only
|
|
||||||
# manages /opt/gendesign via `git reset --hard origin/main` on the main and
|
|
||||||
# obsidian stacks). Forgejo lives separately at /home/gendesign/forgejo on
|
|
||||||
# the VM and is a plain directory there — NOT a git checkout — so changes
|
|
||||||
# here do not auto-apply. Sync manually:
|
|
||||||
#
|
|
||||||
# scp ops/forgejo/docker-compose.yml gendesign:/home/gendesign/forgejo/docker-compose.yml
|
|
||||||
# ssh gendesign "cd /home/gendesign/forgejo && docker compose up -d --force-recreate forgejo"
|
|
||||||
#
|
|
||||||
# `up -d --force-recreate` (not `restart`) is required: Forgejo generates
|
|
||||||
# app.ini from the FORGEJO__* env vars via /usr/local/bin/environment-to-ini
|
|
||||||
# at container start, and `restart` does not re-read `environment:` from a
|
|
||||||
# changed compose file (see docker-compose pitfall #1 in devops CLAUDE.md).
|
|
||||||
#
|
|
||||||
# Config keys verified 2026-09-16 against the running image
|
|
||||||
# (codeberg.org/forgejo/forgejo:10, Forgejo 10.0.3+gitea-1.22.0) by
|
|
||||||
# extracting Go struct tags from the binary (`strings` on
|
|
||||||
# /app/gitea/gitea) and by a dry-run of environment-to-ini in a scratch
|
|
||||||
# dir inside the container (no prod files touched). Do not re-derive these
|
|
||||||
# from memory — a wrong key is silently ignored (empty section) and
|
|
||||||
# creates a false sense of safety.
|
|
||||||
#
|
|
||||||
# Dotted section names ([cron.archive_cleanup]) must be encoded as
|
|
||||||
# `_0x2E_` in the env var per the container's own
|
|
||||||
# `environment-to-ini --help` (confirmed empirically, see PR description).
|
|
||||||
services:
|
|
||||||
forgejo:
|
|
||||||
image: codeberg.org/forgejo/forgejo:10
|
|
||||||
container_name: forgejo
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
USER_UID: 1000
|
|
||||||
USER_GID: 1000
|
|
||||||
FORGEJO__database__DB_TYPE: postgres
|
|
||||||
FORGEJO__database__HOST: infra-postgres:5432
|
|
||||||
FORGEJO__database__NAME: forgejo
|
|
||||||
FORGEJO__database__USER: forgejo
|
|
||||||
FORGEJO__database__PASSWD: ${FORGEJO_DB_PASS}
|
|
||||||
FORGEJO__server__DOMAIN: git.gendsgn.ru
|
|
||||||
FORGEJO__server__ROOT_URL: https://git.gendsgn.ru/
|
|
||||||
FORGEJO__server__SSH_PORT: 2222
|
|
||||||
FORGEJO__server__SSH_LISTEN_PORT: 22
|
|
||||||
FORGEJO__server__START_SSH_SERVER: "false"
|
|
||||||
FORGEJO__service__DISABLE_REGISTRATION: "true"
|
|
||||||
FORGEJO__service__REQUIRE_SIGNIN_VIEW: "false"
|
|
||||||
FORGEJO__actions__ENABLED: "true"
|
|
||||||
FORGEJO__actions__DEFAULT_ACTIONS_URL: "github"
|
|
||||||
# Actions Log/artifact retention — was unset (Forgejo defaults), which
|
|
||||||
# let CI run logs/artifacts accumulate indefinitely. Artifact
|
|
||||||
# retention checked against .forgejo/workflows + .github/workflows on
|
|
||||||
# 2026-09-16: no workflow uploads/downloads artifacts today, so 14d
|
|
||||||
# cannot break a cross-job dependency. Revisit this comment if a
|
|
||||||
# workflow starts using actions/upload-artifact.
|
|
||||||
FORGEJO__actions__LOG_RETENTION_DAYS: "30"
|
|
||||||
FORGEJO__actions__ARTIFACT_RETENTION_DAYS: "14"
|
|
||||||
# Repo-archive cache cleanup — was entirely absent (no
|
|
||||||
# [cron.archive_cleanup] section), so it ran on Forgejo's own default
|
|
||||||
# schedule (once every 24h, deleting archives older than 24h). That
|
|
||||||
# let an external crawler hitting /<owner>/<repo>/archive/<ref>
|
|
||||||
# balloon the cache to ~48GB/145GB disk before the daily sweep caught
|
|
||||||
# up (see PR #3534, which closed the path in Caddy as the primary
|
|
||||||
# fix). This is the second line of defense if that Caddy rule is ever
|
|
||||||
# removed: run hourly, evict anything older than 1h.
|
|
||||||
FORGEJO__CRON_0x2E_ARCHIVE_CLEANUP__ENABLED: "true"
|
|
||||||
FORGEJO__CRON_0x2E_ARCHIVE_CLEANUP__RUN_AT_START: "true"
|
|
||||||
FORGEJO__CRON_0x2E_ARCHIVE_CLEANUP__SCHEDULE: "@every 1h"
|
|
||||||
FORGEJO__CRON_0x2E_ARCHIVE_CLEANUP__OLDER_THAN: "1h"
|
|
||||||
FORGEJO__security__INSTALL_LOCK: "true"
|
|
||||||
volumes:
|
|
||||||
- ./data/forgejo:/data
|
|
||||||
# Container log cap. Measured 2026-09-17: this container's json log had
|
|
||||||
# grown to 3.35 GiB in 23 days (~150 MB/day) with no rotation at all,
|
|
||||||
# the single largest log on the host by two orders of magnitude. Docker's
|
|
||||||
# json-file driver never rotates unless told to, and there is no
|
|
||||||
# /etc/docker/daemon.json on this VM to set a global default. 50m x 3
|
|
||||||
# caps this container at 150 MB; recreating it also drops the old
|
|
||||||
# unbounded file.
|
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "50m"
|
|
||||||
max-file: "3"
|
|
||||||
ports:
|
|
||||||
- "2222:22"
|
|
||||||
networks:
|
|
||||||
- gendesign_default
|
|
||||||
|
|
||||||
networks:
|
|
||||||
gendesign_default:
|
|
||||||
external: true
|
|
||||||
name: gendesign_default
|
|
||||||
|
|
@ -51,10 +51,5 @@ COUCHDB_S3_SECRET_KEY=
|
||||||
# Переопределяй только если этому бэкапу нужен ОТДЕЛЬНЫЙ адресат:
|
# Переопределяй только если этому бэкапу нужен ОТДЕЛЬНЫЙ адресат:
|
||||||
#TELEGRAM_BOT_TOKEN=
|
#TELEGRAM_BOT_TOKEN=
|
||||||
#TELEGRAM_CHAT_ID=
|
#TELEGRAM_CHAT_ID=
|
||||||
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
|
|
||||||
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
|
|
||||||
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
|
|
||||||
# чат: notify() читает env-файл, только если их нет в окружении.
|
|
||||||
#TELEGRAM_TOPIC_ID=245
|
|
||||||
# Либо укажи другой файл, откуда notify() возьмёт настройки канала:
|
# Либо укажи другой файл, откуда notify() возьмёт настройки канала:
|
||||||
#BACKUP_ENV_FILE=/etc/default/gendesign-backup
|
#BACKUP_ENV_FILE=/etc/default/gendesign-backup
|
||||||
|
|
|
||||||
|
|
@ -27,17 +27,6 @@
|
||||||
#FORGEJO_S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY_ONCE_CREATED
|
#FORGEJO_S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY_ONCE_CREATED
|
||||||
#FORGEJO_S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY_ONCE_CREATED
|
#FORGEJO_S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY_ONCE_CREATED
|
||||||
|
|
||||||
# --- оповещения (Telegram) ---
|
|
||||||
# backup-forgejo.sh читает этот файл сам, поэтому канал задаётся здесь: без
|
|
||||||
# токена и чата notify() ищет их в /etc/default/gendesign-backup.
|
|
||||||
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
|
|
||||||
#TELEGRAM_CHAT_ID=123456789
|
|
||||||
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
|
|
||||||
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
|
|
||||||
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
|
|
||||||
# чат: notify() читает env-файл, только если их нет в окружении.
|
|
||||||
#TELEGRAM_TOPIC_ID=245
|
|
||||||
|
|
||||||
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
||||||
#FORGEJO_S3_PREFIX=forgejo/ # key prefix inside the bucket; must match
|
#FORGEJO_S3_PREFIX=forgejo/ # key prefix inside the bucket; must match
|
||||||
# the policy's Resource path exactly
|
# the policy's Resource path exactly
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,13 @@
|
||||||
#
|
#
|
||||||
# Missed-run alerting (#2203): ops/check-backup-staleness.sh (separate cron
|
# Missed-run alerting (#2203): ops/check-backup-staleness.sh (separate cron
|
||||||
# entry, see its header) alerts when a backup's sentinel goes stale. It reads
|
# entry, see its header) alerts when a backup's sentinel goes stale. It reads
|
||||||
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID from THIS file.
|
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID from THIS file (not from
|
||||||
# Without the first two set, ops/check-backup-staleness.sh still logs, just
|
# /etc/default/gendesign-uptime — deliberately a separate config so backup
|
||||||
|
# alerting doesn't depend on the uptime watchdog's env file existing).
|
||||||
|
# SAME variable names as ops/gendesign-uptime.default.example — point both
|
||||||
|
# files at the same bot/chat if you want one Telegram destination for
|
||||||
|
# everything, or use different bots/chats if you'd rather split the noise.
|
||||||
|
# Without these two set, ops/check-backup-staleness.sh still logs, just
|
||||||
# doesn't send a Telegram alert.
|
# doesn't send a Telegram alert.
|
||||||
|
|
||||||
# --- S3 off-site upload (Selectel S3-compatible). All four REQUIRED (#3085) —
|
# --- S3 off-site upload (Selectel S3-compatible). All four REQUIRED (#3085) —
|
||||||
|
|
@ -36,14 +41,9 @@
|
||||||
#S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY
|
#S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY
|
||||||
#S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY
|
#S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY
|
||||||
|
|
||||||
# --- missed-run alerting (Telegram) ---
|
# --- missed-run alerting (Telegram, shared bot with uptime watchdog) ---
|
||||||
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
|
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
|
||||||
#TELEGRAM_CHAT_ID=123456789
|
#TELEGRAM_CHAT_ID=123456789
|
||||||
# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в
|
|
||||||
# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без
|
|
||||||
# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и
|
|
||||||
# чат: notify() читает env-файл, только если их нет в окружении.
|
|
||||||
#TELEGRAM_TOPIC_ID=245
|
|
||||||
|
|
||||||
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
||||||
#BACKUP_ALLOW_LOCAL_ONLY=1 # explicit escape hatch: run without S3 creds and
|
#BACKUP_ALLOW_LOCAL_ONLY=1 # explicit escape hatch: run without S3 creds and
|
||||||
|
|
|
||||||
28
ops/gendesign-uptime.default.example
Normal file
28
ops/gendesign-uptime.default.example
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Environment file for ops/uptime-healthcheck.sh (external uptime watchdog, #75).
|
||||||
|
#
|
||||||
|
# Install as a ROOT-OWNED, chmod-600 file that is NOT in git, on whatever host
|
||||||
|
# runs the cron (ideally a host OTHER than the prod VM, so it survives a full
|
||||||
|
# VPS outage):
|
||||||
|
# sudo cp ops/gendesign-uptime.default.example /etc/default/gendesign-uptime
|
||||||
|
# sudo chmod 600 /etc/default/gendesign-uptime
|
||||||
|
# sudo $EDITOR /etc/default/gendesign-uptime # fill in real Telegram creds
|
||||||
|
#
|
||||||
|
# uptime-healthcheck.sh sources this file if present. With NO Telegram vars set,
|
||||||
|
# it still logs up/down but sends no alert (useful for a dry run first).
|
||||||
|
#
|
||||||
|
# Get a bot token from @BotFather; get your chat_id by messaging the bot then
|
||||||
|
# GET https://api.telegram.org/bot<TOKEN>/getUpdates and reading message.chat.id.
|
||||||
|
|
||||||
|
# --- Telegram alerting (both required to enable alerts) ---
|
||||||
|
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
|
||||||
|
#TELEGRAM_CHAT_ID=123456789
|
||||||
|
|
||||||
|
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
||||||
|
#BASE_URL=https://gendsgn.ru
|
||||||
|
#STATE_FILE=/var/tmp/gendesign-uptime-state
|
||||||
|
#CURL_TIMEOUT=15
|
||||||
|
#RETRIES=2
|
||||||
|
#RETRY_SLEEP=5
|
||||||
|
# Custom check list (newline-separated "label|path|expected_status"):
|
||||||
|
#CHECKS="health|/health|200
|
||||||
|
#market-pulse|/api/v1/analytics/market-pulse|200"
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
# SOURCED, not executed directly — no shebang execution of its own. Inherits
|
# SOURCED, not executed directly — no shebang execution of its own. Inherits
|
||||||
# the caller's `set -euo pipefail`. Keep this dependency-free: bash builtins +
|
# the caller's `set -euo pipefail`. Keep this dependency-free: bash builtins +
|
||||||
# coreutils (date, stat, mkdir, grep, awk, mktemp) + curl (only used by
|
# coreutils (date, stat, mkdir, grep, awk, mktemp) + curl (only used by
|
||||||
# notify() when Telegram vars are actually set).
|
# notify() when Telegram vars are actually set — curl is already a hard
|
||||||
|
# requirement of ops/uptime-healthcheck.sh on the same box, so this adds no
|
||||||
|
# new dependency).
|
||||||
#
|
#
|
||||||
# Load with (script computes its own dir so this works regardless of cron's
|
# Load with (script computes its own dir so this works regardless of cron's
|
||||||
# CWD or which repo subdir the caller lives in):
|
# CWD or which repo subdir the caller lives in):
|
||||||
|
|
@ -16,8 +18,14 @@
|
||||||
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
|
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
|
||||||
|
|
||||||
# --- notify ------------------------------------------------------------
|
# --- notify ------------------------------------------------------------
|
||||||
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID / TELEGRAM_TOPIC_ID from the env
|
# Reuses the SAME Telegram channel/bot as ops/uptime-healthcheck.sh (#75) —
|
||||||
# (or ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}). Topic: #3164.
|
# this is NOT a second alerting system, just the same TELEGRAM_BOT_TOKEN /
|
||||||
|
# TELEGRAM_CHAT_ID variable names read from a DIFFERENT env file
|
||||||
|
# (/etc/default/gendesign-backup, not /etc/default/gendesign-uptime) so
|
||||||
|
# backup alerting doesn't depend on the uptime watchdog's config being
|
||||||
|
# present, and vice versa. Point both files at the same bot/chat if you want
|
||||||
|
# one Telegram destination for everything — that's an ops choice, not this
|
||||||
|
# script's concern.
|
||||||
#
|
#
|
||||||
# No-op (logs only) when unset — this is the extension point: to wire a
|
# No-op (logs only) when unset — this is the extension point: to wire a
|
||||||
# different channel later, edit ONLY this function; every caller in this repo
|
# different channel later, edit ONLY this function; every caller in this repo
|
||||||
|
|
@ -145,9 +153,12 @@ sentinel_age_hours() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- transition-tracked alert state --------------------------------------
|
# --- transition-tracked alert state --------------------------------------
|
||||||
# A flat "<label> <status>" file, one line per label, so repeated runs alert
|
# Same idiom as ops/uptime-healthcheck.sh's prev_status()/set_status(): a
|
||||||
|
# flat "<label> <status>" file, one line per label, so repeated runs alert
|
||||||
# only on a STATE TRANSITION (fresh->stale, stale->fresh) instead of every
|
# only on a STATE TRANSITION (fresh->stale, stale->fresh) instead of every
|
||||||
# single run — avoids Telegram spam from an hourly staleness-check cron.
|
# single run — avoids Telegram spam from an hourly staleness-check cron.
|
||||||
|
# Intentionally a separate, independent implementation (not shared code with
|
||||||
|
# uptime-healthcheck.sh) — that script is out of scope for this change.
|
||||||
backup_prev_status() {
|
backup_prev_status() {
|
||||||
local state_file="$1" label="$2" v
|
local state_file="$1" label="$2" v
|
||||||
[[ -f "$state_file" ]] || { echo "unknown"; return; }
|
[[ -f "$state_file" ]] || { echo "unknown"; return; }
|
||||||
|
|
|
||||||
|
|
@ -27,28 +27,6 @@
|
||||||
METRICS_TELEGRAM_ONCALL кого звать поимённо (необязательна)
|
METRICS_TELEGRAM_ONCALL кого звать поимённо (необязательна)
|
||||||
ALERT_ACK_PUBLIC_URL внешний адрес сервиса, попадает в кнопку
|
ALERT_ACK_PUBLIC_URL внешний адрес сервиса, попадает в кнопку
|
||||||
ALERT_ACK_TTL_MIN сколько минут живёт токен (по умолчанию 1440)
|
ALERT_ACK_TTL_MIN сколько минут живёт токен (по умолчанию 1440)
|
||||||
ALERT_ACK_GLITCHTIP_SECRET секрет резервного вебхука GlitchTip (#3471,
|
|
||||||
см. POST /glitchtip ниже); пусто — 503
|
|
||||||
|
|
||||||
РЕЗЕРВНЫЙ КАНАЛ GLITCHTIP (#3471). Все три alert-правила GlitchTip (backend,
|
|
||||||
frontend, Trade-In) шлют основной вебхук в продуктовый бэкенд на Selectel —
|
|
||||||
тот самый хост, за которым они следят. Если там упал сам бэкенд или Caddy,
|
|
||||||
алерт об этом теряется именно тогда, когда нужнее всего. `POST /glitchtip`
|
|
||||||
— второй получатель того же алерта, зарегистрированный в GlitchTip отдельной
|
|
||||||
строкой; живёт на ЭТОМ (инфраструктурном, Beget) хосте и не зависит от
|
|
||||||
здоровья продукта. Формат тела — тот же Slack-совместимый payload, что и у
|
|
||||||
продуктового приёмника (`tradein-mvp/backend/app/api/v1/glitchtip.py`):
|
|
||||||
``{"text": str, "attachments": [{"title","title_link","text","color",
|
|
||||||
"fields":[{"title","value"}]}]}``, GlitchTip заголовков не шлёт вовсе —
|
|
||||||
аутентификация только через секрет в query (``?secret=``) или в заголовке
|
|
||||||
``X-GlitchTip-Secret`` (тот же выбор, что там же и по той же причине: заголовок
|
|
||||||
не течёт в access-log, query остаётся, т.к. сам GlitchTip 6.1.6 заголовков не
|
|
||||||
добавляет). Секрет намеренно СВОЙ (``ALERT_ACK_GLITCHTIP_SECRET``), а не общий
|
|
||||||
с продуктовым ``TRADEIN_INTERNAL_AUTH_SECRET`` — секреты разных хостов/доменов
|
|
||||||
безопасности компрометировать вместе незачем. Сообщение уходит в ту же тему
|
|
||||||
клиентских инцидентов (``METRICS_TELEGRAM_CHAT_ID``/``_TOPIC_ID``), что и
|
|
||||||
Alertmanager-алерты через alert-ack, с явной пометкой «резервный канал», чтобы
|
|
||||||
не спутать с основным путём.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -57,7 +35,6 @@ import html
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
@ -74,19 +51,8 @@ TOPIC_ID = os.environ.get("METRICS_TELEGRAM_TOPIC_ID", "")
|
||||||
ONCALL = os.environ.get("METRICS_TELEGRAM_ONCALL", "")
|
ONCALL = os.environ.get("METRICS_TELEGRAM_ONCALL", "")
|
||||||
PUBLIC_URL = os.environ.get("ALERT_ACK_PUBLIC_URL", "").rstrip("/")
|
PUBLIC_URL = os.environ.get("ALERT_ACK_PUBLIC_URL", "").rstrip("/")
|
||||||
TTL_SEC = int(os.environ.get("ALERT_ACK_TTL_MIN", "1440")) * 60
|
TTL_SEC = int(os.environ.get("ALERT_ACK_TTL_MIN", "1440")) * 60
|
||||||
GLITCHTIP_SECRET = os.environ.get("ALERT_ACK_GLITCHTIP_SECRET", "")
|
|
||||||
API = "https://api.telegram.org/bot{}/{}"
|
API = "https://api.telegram.org/bot{}/{}"
|
||||||
|
|
||||||
# Значения секретных query-параметров в логе — `***` (#3576). GlitchTip шлёт
|
|
||||||
# секрет только в `?secret=`, а строка запроса целиком уходит в access-log. То
|
|
||||||
# же выражение, что у бэкенда МЕРЫ (tradein-mvp/backend/app/core/log_scrub.py,
|
|
||||||
# #3154) и у Alloy (#3354); импортировать нельзя — сервис без зависимостей.
|
|
||||||
_SENSITIVE_QUERY = re.compile(
|
|
||||||
r"([?&][\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=)"
|
|
||||||
r"[^&\s\"'<>]+",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# token -> {"message_id": int, "title": str, "created": float, "acked_by": str|None}
|
# token -> {"message_id": int, "title": str, "created": float, "acked_by": str|None}
|
||||||
_PENDING: dict[str, dict] = {}
|
_PENDING: dict[str, dict] = {}
|
||||||
_LOCK = threading.Lock()
|
_LOCK = threading.Lock()
|
||||||
|
|
@ -187,79 +153,6 @@ def _send_alert(payload: dict) -> None:
|
||||||
_tg("sendMessage", msg)
|
_tg("sendMessage", msg)
|
||||||
|
|
||||||
|
|
||||||
_GLITCHTIP_TEXT_LIMIT = 3500 # запас под баннер+имя проекта до лимита Telegram 4096
|
|
||||||
|
|
||||||
|
|
||||||
def _verify_glitchtip_secret(provided: str) -> bool:
|
|
||||||
"""Constant-time сравнение — длина/префикс секрета не утекают через время
|
|
||||||
ответа (тот же приём, что у продуктового приёмника, см. docstring модуля)."""
|
|
||||||
return bool(GLITCHTIP_SECRET) and secrets.compare_digest(provided or "", GLITCHTIP_SECRET)
|
|
||||||
|
|
||||||
|
|
||||||
def _glitchtip_field(attachment: dict, label: str) -> str | None:
|
|
||||||
for field in attachment.get("fields") or []:
|
|
||||||
if not isinstance(field, dict):
|
|
||||||
continue
|
|
||||||
if str(field.get("title", "")).strip().lower() == label.lower():
|
|
||||||
value = field.get("value")
|
|
||||||
return str(value) if value is not None else None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _render_glitchtip(payload: dict) -> str:
|
|
||||||
"""Собрать текст сообщения из Slack-совместимого payload GlitchTip.
|
|
||||||
|
|
||||||
Максимально терпимо к форме тела: GlitchTip шлёт ОДИНАКОВУЮ структуру для
|
|
||||||
issue- и uptime-алертов, но поля внутри attachments опциональны, а тестовое
|
|
||||||
сообщение из UI GlitchTip может не иметь attachments вовсе. Ничего в теле
|
|
||||||
не считаем обязательным — падать сервису на резервном канале нельзя.
|
|
||||||
"""
|
|
||||||
lines = ["⚠️ РЕЗЕРВНЫЙ КАНАЛ (GlitchTip → alert-ack)"]
|
|
||||||
lines.append("Основной путь через продуктовый бэкенд мог быть недоступен.")
|
|
||||||
lines.append("")
|
|
||||||
text = payload.get("text")
|
|
||||||
lines.append(html.escape(str(text)) if text else "GlitchTip alert")
|
|
||||||
|
|
||||||
attachments = payload.get("attachments")
|
|
||||||
for attachment in attachments if isinstance(attachments, list) else []:
|
|
||||||
if not isinstance(attachment, dict):
|
|
||||||
continue
|
|
||||||
block: list[str] = []
|
|
||||||
project = _glitchtip_field(attachment, "Project")
|
|
||||||
if project:
|
|
||||||
block.append(f"Проект: {html.escape(project)}")
|
|
||||||
if attachment.get("title"):
|
|
||||||
block.append(html.escape(str(attachment["title"])))
|
|
||||||
if attachment.get("text"):
|
|
||||||
block.append(html.escape(str(attachment["text"])))
|
|
||||||
if attachment.get("title_link"):
|
|
||||||
block.append(f"Ссылка: {html.escape(str(attachment['title_link']))}")
|
|
||||||
if block:
|
|
||||||
lines.append("")
|
|
||||||
lines.extend(block)
|
|
||||||
|
|
||||||
out = "\n".join(lines)
|
|
||||||
if len(out) > _GLITCHTIP_TEXT_LIMIT:
|
|
||||||
out = out[:_GLITCHTIP_TEXT_LIMIT] + "\n… (обрезано)"
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _send_glitchtip_alert(payload: dict) -> None:
|
|
||||||
"""Переслать вебхук GlitchTip в ту же тему клиентских инцидентов, что и
|
|
||||||
Alertmanager через этот сервис. Без кнопки подтверждения — это не
|
|
||||||
firing/resolved инцидент с состоянием, а разовое уведомление резервного
|
|
||||||
канала."""
|
|
||||||
msg = {
|
|
||||||
"chat_id": CHAT_ID,
|
|
||||||
"text": _render_glitchtip(payload),
|
|
||||||
"parse_mode": "HTML",
|
|
||||||
"disable_web_page_preview": "true",
|
|
||||||
}
|
|
||||||
if TOPIC_ID:
|
|
||||||
msg["message_thread_id"] = TOPIC_ID
|
|
||||||
_tg("sendMessage", msg)
|
|
||||||
|
|
||||||
|
|
||||||
_PAGE = (
|
_PAGE = (
|
||||||
"<!doctype html><meta charset=utf-8>"
|
"<!doctype html><meta charset=utf-8>"
|
||||||
"<title>{t}</title>"
|
"<title>{t}</title>"
|
||||||
|
|
@ -330,9 +223,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
protocol_version = "HTTP/1.1"
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
def log_message(self, fmt: str, *args) -> None: # noqa: A003 — подпись из stdlib
|
def log_message(self, fmt: str, *args) -> None: # noqa: A003 — подпись из stdlib
|
||||||
# Сюда сходятся и строка доступа (log_request), и ошибки разбора запроса
|
log.info("%s %s", self.address_string(), fmt % args)
|
||||||
# (log_error: «Bad request syntax ('POST /glitchtip?secret=…')») — маскируем здесь.
|
|
||||||
log.info("%s %s", self.address_string(), _SENSITIVE_QUERY.sub(r"\1***", fmt % args))
|
|
||||||
|
|
||||||
def _reply(self, code: int, body: bytes, ctype: str = "text/html; charset=utf-8") -> None:
|
def _reply(self, code: int, body: bytes, ctype: str = "text/html; charset=utf-8") -> None:
|
||||||
self.send_response(code)
|
self.send_response(code)
|
||||||
|
|
@ -352,24 +243,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._reply(code, page.encode())
|
self._reply(code, page.encode())
|
||||||
|
|
||||||
def do_POST(self) -> None: # noqa: N802 — имя из stdlib
|
def do_POST(self) -> None: # noqa: N802 — имя из stdlib
|
||||||
# Тело читается ДО любой развилки и ветки отказа. protocol_version =
|
if self.path != "/alertmanager":
|
||||||
# HTTP/1.1, то есть соединение переиспользуется, а Caddy перед нами
|
|
||||||
# держит пул к апстриму. Ответить 401/404/503, не вычитав тело, значит
|
|
||||||
# оставить его в сокете — и следующий запрос по тому же соединению
|
|
||||||
# начнётся с чужих байт. Проверено на проде 12.09.2026: неавторизованный
|
|
||||||
# зонд на /glitchtip, а следом законный алерт получил
|
|
||||||
# 501 Unsupported method ('{"text":"probe"}POST'). То есть один
|
|
||||||
# отказ ронял следующий НАСТОЯЩИЙ алерт — ровно то, ради чего этот
|
|
||||||
# резервный канал и заводился.
|
|
||||||
parsed = urllib.parse.urlsplit(self.path)
|
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
|
||||||
raw = self.rfile.read(length) if length else b"{}"
|
|
||||||
if parsed.path == "/glitchtip":
|
|
||||||
self._handle_glitchtip(parsed, raw)
|
|
||||||
return
|
|
||||||
if parsed.path != "/alertmanager":
|
|
||||||
self._reply(404, b"not found", "text/plain; charset=utf-8")
|
self._reply(404, b"not found", "text/plain; charset=utf-8")
|
||||||
return
|
return
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
raw = self.rfile.read(length) if length else b"{}"
|
||||||
try:
|
try:
|
||||||
payload = json.loads(raw.decode() or "{}")
|
payload = json.loads(raw.decode() or "{}")
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
|
|
@ -383,36 +261,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._reply(200, b"accepted", "text/plain; charset=utf-8")
|
self._reply(200, b"accepted", "text/plain; charset=utf-8")
|
||||||
threading.Thread(target=_send_alert, args=(payload,), daemon=True).start()
|
threading.Thread(target=_send_alert, args=(payload,), daemon=True).start()
|
||||||
|
|
||||||
def _handle_glitchtip(self, parsed: urllib.parse.SplitResult, raw: bytes) -> None:
|
|
||||||
"""POST /glitchtip — резервный получатель GlitchTip-алертов (#3471).
|
|
||||||
|
|
||||||
Секрет — из заголовка ``X-GlitchTip-Secret`` (предпочтительно, не течёт
|
|
||||||
в access-log) либо из query ``?secret=`` (fallback: GlitchTip 6.1.6
|
|
||||||
заголовков не шлёт вовсе). Несконфигурированный секрет → 503, а не
|
|
||||||
тихий приём без проверки. Неразобранное/нестандартное тело НЕ роняет
|
|
||||||
запрос — это резервный канал, теряться на кривом JSON ему нельзя.
|
|
||||||
"""
|
|
||||||
if not GLITCHTIP_SECRET:
|
|
||||||
self._reply(503, b"glitchtip webhook not configured", "text/plain; charset=utf-8")
|
|
||||||
return
|
|
||||||
|
|
||||||
header_secret = self.headers.get("X-GlitchTip-Secret", "")
|
|
||||||
query_secret = urllib.parse.parse_qs(parsed.query).get("secret", [""])[0]
|
|
||||||
if not _verify_glitchtip_secret(header_secret or query_secret):
|
|
||||||
log.warning("glitchtip webhook: invalid or missing secret")
|
|
||||||
self._reply(401, b"invalid or missing secret", "text/plain; charset=utf-8")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = json.loads(raw.decode() or "{}")
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
payload = {"text": raw.decode(errors="replace")}
|
|
||||||
except Exception: # noqa: BLE001 — резервный канал не роняем на кривом теле
|
|
||||||
payload = {"text": raw.decode(errors="replace")}
|
|
||||||
|
|
||||||
self._reply(200, b"accepted", "text/plain; charset=utf-8")
|
|
||||||
threading.Thread(target=_send_glitchtip_alert, args=(payload,), daemon=True).start()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
missing = [n for n, v in (("BOT_TOKEN", BOT_TOKEN), ("CHAT_ID", CHAT_ID)) if not v]
|
missing = [n for n, v in (("BOT_TOKEN", BOT_TOKEN), ("CHAT_ID", CHAT_ID)) if not v]
|
||||||
|
|
@ -420,11 +268,6 @@ def main() -> None:
|
||||||
raise SystemExit(f"не заданы обязательные переменные: {', '.join(missing)}")
|
raise SystemExit(f"не заданы обязательные переменные: {', '.join(missing)}")
|
||||||
if not PUBLIC_URL:
|
if not PUBLIC_URL:
|
||||||
log.warning("ALERT_ACK_PUBLIC_URL пуст — сообщения уйдут БЕЗ кнопки подтверждения")
|
log.warning("ALERT_ACK_PUBLIC_URL пуст — сообщения уйдут БЕЗ кнопки подтверждения")
|
||||||
if not GLITCHTIP_SECRET:
|
|
||||||
log.warning(
|
|
||||||
"ALERT_ACK_GLITCHTIP_SECRET пуст — резервный канал GlitchTip (#3471) "
|
|
||||||
"отключён, POST /glitchtip будет отвечать 503"
|
|
||||||
)
|
|
||||||
port = int(os.environ.get("ALERT_ACK_PORT", "8080"))
|
port = int(os.environ.get("ALERT_ACK_PORT", "8080"))
|
||||||
log.info("alert-ack слушает :%d, тема=%s, дежурный=%s", port, TOPIC_ID or "—", ONCALL or "—")
|
log.info("alert-ack слушает :%d, тема=%s, дежурный=%s", port, TOPIC_ID or "—", ONCALL or "—")
|
||||||
ThreadingHTTPServer(("", port), Handler).serve_forever()
|
ThreadingHTTPServer(("", port), Handler).serve_forever()
|
||||||
|
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
"""Тесты для резервного канала GlitchTip → alert-ack (#3471).
|
|
||||||
|
|
||||||
Продукт (tradein-backend на Selectel) — САМ объект наблюдения GlitchTip. Если
|
|
||||||
он лежит, основной вебхук-получатель лежит вместе с ним, и алерт об этом не
|
|
||||||
доходит именно тогда, когда нужнее всего. `POST /glitchtip` — второй
|
|
||||||
получатель на ДРУГОМ хосте (Beget, рядом с этим сервисом), не зависящий от
|
|
||||||
здоровья продукта.
|
|
||||||
|
|
||||||
Проверяем на уровне функций, а не полного HTTP-транспорта (тот же приём, что
|
|
||||||
`ops/glitchtip-auth-forwarder/test_forwarder.py`): `BaseHTTPRequestHandler`
|
|
||||||
неудобно поднимать без реального сокета, а бизнес-логика — секрет, рендер,
|
|
||||||
отправка — целиком вынесена в чистые функции модуля.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
|
||||||
|
|
||||||
# Env — ДО импорта app.py: BOT_TOKEN/CHAT_ID читаются на уровне модуля.
|
|
||||||
os.environ.setdefault("METRICS_TELEGRAM_BOT_TOKEN", "test-bot-token")
|
|
||||||
os.environ.setdefault("METRICS_TELEGRAM_CHAT_ID", "-1001234567890")
|
|
||||||
os.environ.setdefault("METRICS_TELEGRAM_TOPIC_ID", "158")
|
|
||||||
os.environ["ALERT_ACK_GLITCHTIP_SECRET"] = "correct-secret"
|
|
||||||
|
|
||||||
import app as alert_ack # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_glitchtip_secret_accepts_correct_value() -> None:
|
|
||||||
assert alert_ack._verify_glitchtip_secret("correct-secret") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_glitchtip_secret_rejects_wrong_value() -> None:
|
|
||||||
"""Неверный секрет — отказ."""
|
|
||||||
assert alert_ack._verify_glitchtip_secret("wrong-secret") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_glitchtip_secret_rejects_empty_value() -> None:
|
|
||||||
assert alert_ack._verify_glitchtip_secret("") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_glitchtip_secret_fails_closed_when_unconfigured(monkeypatch) -> None:
|
|
||||||
"""Пустой ALERT_ACK_GLITCHTIP_SECRET — отказ всем, а не тихий fail-open."""
|
|
||||||
monkeypatch.setattr(alert_ack, "GLITCHTIP_SECRET", "")
|
|
||||||
assert alert_ack._verify_glitchtip_secret("correct-secret") is False
|
|
||||||
assert alert_ack._verify_glitchtip_secret("") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_glitchtip_full_payload_marks_fallback_channel() -> None:
|
|
||||||
payload = {
|
|
||||||
"text": "GlitchTip Alert: Something broke",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"title": "TypeError: boom",
|
|
||||||
"title_link": "https://errors.gendsgn.ru/issue/1",
|
|
||||||
"text": "подробности ошибки",
|
|
||||||
"color": "#ff0000",
|
|
||||||
"fields": [{"title": "Project", "value": "tradein-backend"}],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
text = alert_ack._render_glitchtip(payload)
|
|
||||||
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
|
|
||||||
assert "Проект: tradein-backend" in text
|
|
||||||
assert "TypeError: boom" in text
|
|
||||||
assert "https://errors.gendsgn.ru/issue/1" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_glitchtip_survives_payload_without_attachments() -> None:
|
|
||||||
"""Тело без attachments не роняет сервис — только текст."""
|
|
||||||
text = alert_ack._render_glitchtip({"text": "просто текст без вложений"})
|
|
||||||
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
|
|
||||||
assert "просто текст без вложений" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_glitchtip_survives_empty_payload() -> None:
|
|
||||||
"""Пустой словарь (нет ни text, ни attachments) — тоже не должен падать."""
|
|
||||||
text = alert_ack._render_glitchtip({})
|
|
||||||
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
|
|
||||||
assert "GlitchTip alert" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_glitchtip_survives_malformed_attachments() -> None:
|
|
||||||
"""attachments/fields неожиданной формы (не список, не словарь, битые
|
|
||||||
типы) — резервный канал не должен падать на кривом теле."""
|
|
||||||
payload = {
|
|
||||||
"text": "test",
|
|
||||||
"attachments": [
|
|
||||||
"not-a-dict",
|
|
||||||
{"fields": "not-a-list"},
|
|
||||||
{"fields": [{"title": "Project"}]}, # value отсутствует
|
|
||||||
None,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
text = alert_ack._render_glitchtip(payload)
|
|
||||||
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_send_glitchtip_alert_forwards_via_tg(monkeypatch) -> None:
|
|
||||||
"""Верный секрет уже проверен вызывающей стороной (do_POST) — здесь
|
|
||||||
проверяем, что отрендеренное сообщение реально уходит в тот же chat/topic,
|
|
||||||
что и Alertmanager-алерты этого сервиса, без кнопки подтверждения."""
|
|
||||||
calls = []
|
|
||||||
monkeypatch.setattr(alert_ack, "_tg", lambda method, payload: calls.append((method, payload)))
|
|
||||||
|
|
||||||
alert_ack._send_glitchtip_alert({"text": "boom", "attachments": []})
|
|
||||||
|
|
||||||
assert len(calls) == 1
|
|
||||||
method, sent = calls[0]
|
|
||||||
assert method == "sendMessage"
|
|
||||||
assert sent["chat_id"] == alert_ack.CHAT_ID
|
|
||||||
assert sent["message_thread_id"] == alert_ack.TOPIC_ID
|
|
||||||
assert "reply_markup" not in sent
|
|
||||||
assert "boom" in sent["text"]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Keep-alive: отказ не должен ронять СЛЕДУЮЩИЙ запрос ──────────────────────
|
|
||||||
# Эти четыре теста — единственные, что поднимают настоящий сокет. Дефект,
|
|
||||||
# который они стерегут, живёт именно в транспорте и на уровне функций невидим:
|
|
||||||
# ветка отказа отвечала, не вычитав тело запроса, а `protocol_version` здесь
|
|
||||||
# HTTP/1.1, то есть соединение переиспользуется (и Caddy перед сервисом держит
|
|
||||||
# пул к апстриму). Непрочитанное тело оставалось в сокете, и следующий запрос
|
|
||||||
# по тому же соединению начинался с чужих байт.
|
|
||||||
#
|
|
||||||
# Поймано на проде 12.09.2026: зонд без секрета получил 401, а следующий —
|
|
||||||
# уже с верным секретом — вернул 501 Unsupported method ('{"text":"probe"}POST').
|
|
||||||
# То есть один отказ съедал следующий НАСТОЯЩИЙ алерт.
|
|
||||||
|
|
||||||
|
|
||||||
def _serve_in_background(monkeypatch):
|
|
||||||
"""Поднимает Handler на эфемерном порту, глушит отправку в Telegram."""
|
|
||||||
import threading as _threading
|
|
||||||
from http.server import ThreadingHTTPServer
|
|
||||||
|
|
||||||
sent: list[dict] = []
|
|
||||||
monkeypatch.setattr(alert_ack, "_send_glitchtip_alert", sent.append)
|
|
||||||
monkeypatch.setattr(alert_ack, "_send_alert", sent.append)
|
|
||||||
|
|
||||||
srv = ThreadingHTTPServer(("127.0.0.1", 0), alert_ack.Handler)
|
|
||||||
thread = _threading.Thread(target=srv.serve_forever, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
return srv, sent
|
|
||||||
|
|
||||||
|
|
||||||
def _raw_post(sock, path: str, body: bytes, headers: str = "") -> str:
|
|
||||||
"""Шлёт POST по уже открытому сокету и возвращает статусную строку."""
|
|
||||||
req = (
|
|
||||||
f"POST {path} HTTP/1.1\r\n"
|
|
||||||
f"Host: localhost\r\n"
|
|
||||||
f"Content-Type: application/json\r\n"
|
|
||||||
f"Content-Length: {len(body)}\r\n"
|
|
||||||
f"{headers}"
|
|
||||||
f"\r\n"
|
|
||||||
).encode() + body
|
|
||||||
sock.sendall(req)
|
|
||||||
# Читаем ровно заголовки: тела короткие, Content-Length всегда проставлен.
|
|
||||||
buf = b""
|
|
||||||
while b"\r\n\r\n" not in buf:
|
|
||||||
chunk = sock.recv(4096)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
buf += chunk
|
|
||||||
head, _, rest = buf.partition(b"\r\n\r\n")
|
|
||||||
length = 0
|
|
||||||
for line in head.split(b"\r\n")[1:]:
|
|
||||||
if line.lower().startswith(b"content-length:"):
|
|
||||||
length = int(line.split(b":")[1])
|
|
||||||
while len(rest) < length:
|
|
||||||
rest += sock.recv(4096)
|
|
||||||
return head.split(b"\r\n")[0].decode()
|
|
||||||
|
|
||||||
|
|
||||||
def _pair_on_one_connection(monkeypatch, first_headers: str, first_path: str = "/glitchtip"):
|
|
||||||
import socket
|
|
||||||
|
|
||||||
srv, sent = _serve_in_background(monkeypatch)
|
|
||||||
try:
|
|
||||||
sock = socket.create_connection(srv.server_address, timeout=5)
|
|
||||||
try:
|
|
||||||
first = _raw_post(sock, first_path, b'{"text":"probe"}', first_headers)
|
|
||||||
second = _raw_post(
|
|
||||||
sock,
|
|
||||||
"/glitchtip",
|
|
||||||
b'{"text":"real alert"}',
|
|
||||||
"X-GlitchTip-Secret: correct-secret\r\n",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
sock.close()
|
|
||||||
finally:
|
|
||||||
srv.shutdown()
|
|
||||||
srv.server_close()
|
|
||||||
return first, second, sent
|
|
||||||
|
|
||||||
|
|
||||||
def test_rejected_request_does_not_break_next_one_on_same_connection(monkeypatch) -> None:
|
|
||||||
"""401 без секрета, следом законный алерт по ТОМУ ЖЕ соединению — 200."""
|
|
||||||
first, second, sent = _pair_on_one_connection(monkeypatch, "")
|
|
||||||
assert "401" in first
|
|
||||||
assert "200" in second, f"второй запрос испорчен первым: {second}"
|
|
||||||
assert sent == [{"text": "real alert"}]
|
|
||||||
|
|
||||||
|
|
||||||
def test_unconfigured_secret_does_not_break_next_request(monkeypatch) -> None:
|
|
||||||
"""503 при пустом секрете тоже обязан вычитать тело."""
|
|
||||||
import socket
|
|
||||||
|
|
||||||
monkeypatch.setattr(alert_ack, "GLITCHTIP_SECRET", "")
|
|
||||||
srv, _sent = _serve_in_background(monkeypatch)
|
|
||||||
try:
|
|
||||||
sock = socket.create_connection(srv.server_address, timeout=5)
|
|
||||||
try:
|
|
||||||
first = _raw_post(sock, "/glitchtip", b'{"text":"probe"}')
|
|
||||||
second = _raw_post(sock, "/glitchtip", b'{"text":"again"}')
|
|
||||||
finally:
|
|
||||||
sock.close()
|
|
||||||
finally:
|
|
||||||
srv.shutdown()
|
|
||||||
srv.server_close()
|
|
||||||
assert "503" in first
|
|
||||||
assert "503" in second, f"второй запрос испорчен первым: {second}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_path_does_not_break_next_request(monkeypatch) -> None:
|
|
||||||
"""404 на чужом пути — та же ветка раннего ответа, то же требование."""
|
|
||||||
first, second, sent = _pair_on_one_connection(monkeypatch, "", first_path="/nope")
|
|
||||||
assert "404" in first
|
|
||||||
assert "200" in second, f"второй запрос испорчен первым: {second}"
|
|
||||||
assert sent == [{"text": "real alert"}]
|
|
||||||
|
|
||||||
|
|
||||||
def test_bad_json_on_alertmanager_does_not_break_next_request(monkeypatch) -> None:
|
|
||||||
"""400 на неразобранном теле /alertmanager — тело уже вычитано, связь цела."""
|
|
||||||
import socket
|
|
||||||
|
|
||||||
srv, sent = _serve_in_background(monkeypatch)
|
|
||||||
try:
|
|
||||||
sock = socket.create_connection(srv.server_address, timeout=5)
|
|
||||||
try:
|
|
||||||
first = _raw_post(sock, "/alertmanager", b"{not json")
|
|
||||||
second = _raw_post(
|
|
||||||
sock,
|
|
||||||
"/glitchtip",
|
|
||||||
b'{"text":"real alert"}',
|
|
||||||
"X-GlitchTip-Secret: correct-secret\r\n",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
sock.close()
|
|
||||||
finally:
|
|
||||||
srv.shutdown()
|
|
||||||
srv.server_close()
|
|
||||||
assert "400" in first
|
|
||||||
assert "200" in second, f"второй запрос испорчен первым: {second}"
|
|
||||||
assert sent == [{"text": "real alert"}]
|
|
||||||
|
|
@ -15,11 +15,11 @@
|
||||||
# клиентские инциденты МЕРЫ) и тему «метрики» (245, инфраструктурный шум —
|
# клиентские инциденты МЕРЫ) и тему «метрики» (245, инфраструктурный шум —
|
||||||
# диск, память, просевший экспортер). Инфраструктурный шум и клиентский
|
# диск, память, просевший экспортер). Инфраструктурный шум и клиентский
|
||||||
# инцидент не равны по срочности, а смешанные в одной теме они обучают
|
# инцидент не равны по срочности, а смешанные в одной теме они обучают
|
||||||
# пролистывать обе. Поэтому `telegram` ниже адресует ИНФРАСТРУКТУРНУЮ тему
|
# пролистывать обе. Поэтому `telegram` и `telegram-heartbeat` ниже адресуют
|
||||||
# (`METRICS_TELEGRAM_INFRA_TOPIC_LINE`). Получателя `telegram-clients` в этом
|
# ИНФРАСТРУКТУРНУЮ тему (`METRICS_TELEGRAM_INFRA_TOPIC_LINE`). Получателя
|
||||||
# списке нет: он не шлёт в Telegram напрямую, а вебхуком уходит в alert-ack, и
|
# `telegram-clients` в этом списке нет: он не шлёт в Telegram напрямую, а
|
||||||
# тему адресует сам, своей переменной METRICS_TELEGRAM_TOPIC_ID. `watchdog-ping`
|
# вебхуком уходит в alert-ack, и тему адресует сам, своей переменной
|
||||||
# тоже не шлёт в Telegram вовсе — см. комментарий у его маршрута ниже.
|
# METRICS_TELEGRAM_TOPIC_ID.
|
||||||
|
|
||||||
global:
|
global:
|
||||||
resolve_timeout: 5m
|
resolve_timeout: 5m
|
||||||
|
|
@ -35,35 +35,14 @@ route:
|
||||||
repeat_interval: 6h
|
repeat_interval: 6h
|
||||||
|
|
||||||
routes:
|
routes:
|
||||||
# Watchdog — «сторож сторожа», горит ВСЕГДА по построению (`vector(1)`,
|
# Watchdog не должен смешиваться с настоящими алертами и не должен молчать:
|
||||||
# см. infra.yml). Раньше уходил в Telegram раз в 12ч — 14 сообщений в
|
# это «сторож сторожа», он горит всегда и подтверждает, что канал доставки жив.
|
||||||
# неделю ни о чём, и именно они приучили пролистывать инфра-тему: 17.09
|
- receiver: telegram-heartbeat
|
||||||
# настоящий DiskWillFillIn24h утонул между Watchdog и вечно горящим
|
|
||||||
# NoActiveCeleryWorkers, диск дошёл до 84% незамеченным.
|
|
||||||
#
|
|
||||||
# ПОЧЕМУ ВНЕШНИЙ DEADMAN-ПРИЁМНИК, А НЕ «РЕЖЕ» И НЕ ОТДЕЛЬНАЯ ТЕМА.
|
|
||||||
# Увеличенный интервал по-прежнему кладёт человеку регулярное сообщение —
|
|
||||||
# просто реже, и его тоже рано или поздно начнут пролистывать. Отдельная
|
|
||||||
# техническая тема — это ещё один chat_id/topic_id и ещё один канал,
|
|
||||||
# за которым НАДО СПЕЦИАЛЬНО следить, то есть тот же человеческий цикл,
|
|
||||||
# сдвинутый в другое место. Внешний deadman-приёмник (healthchecks.io и
|
|
||||||
# аналоги) устроен наоборот: Alertmanager молча шлёт HTTP-пинг на каждый
|
|
||||||
# Watchdog, и пока пинги идут — сервис МОЛЧИТ. Он заговорит (email/свой
|
|
||||||
# alert) только когда пинг ПЕРЕСТАНЕТ приходить, то есть ровно когда
|
|
||||||
# канал доставки умер, — это и есть смысл «сторожа сторожа», без единого
|
|
||||||
# штатного сообщения человеку. Полностью выключать эту проверку нельзя —
|
|
||||||
# remove бы всей ветки Watchdog это и сделал.
|
|
||||||
#
|
|
||||||
# METRICS_WATCHDOG_PING_URL пока НЕ заведён на хосте (нужен аккаунт
|
|
||||||
# healthchecks.io/аналога) — до тех пор webhook будет молча падать по
|
|
||||||
# DNS/сети, Alertmanager это тихо ретраит; человека это не касается ни
|
|
||||||
# раньше, ни теперь.
|
|
||||||
- receiver: watchdog-ping
|
|
||||||
matchers:
|
matchers:
|
||||||
- alertname = "Watchdog"
|
- alertname = "Watchdog"
|
||||||
group_wait: 0s
|
group_wait: 0s
|
||||||
group_interval: 5m
|
group_interval: 12h
|
||||||
repeat_interval: 5m
|
repeat_interval: 12h
|
||||||
|
|
||||||
# Клиентский инцидент. host="apps" — это продуктовая машина: если на ней
|
# Клиентский инцидент. host="apps" — это продуктовая машина: если на ней
|
||||||
# критично, значит МЕРА и Site Finder недоступны людям, а не «где-то в
|
# критично, значит МЕРА и Site Finder недоступны людям, а не «где-то в
|
||||||
|
|
@ -84,20 +63,6 @@ route:
|
||||||
group_wait: 10s
|
group_wait: 10s
|
||||||
repeat_interval: 30m
|
repeat_interval: 30m
|
||||||
|
|
||||||
# Эскалация по длительности (AlertFiringTooLong, prometheus/rules/infra.yml)
|
|
||||||
# — сигнал о том, что какую-то другую тревогу не заметили или на неё
|
|
||||||
# забили дольше 6 часов. Она НЕ про клиентский инцидент, но обязана быть
|
|
||||||
# заметнее обычной инфраструктуры, поэтому уходит в ту же тему, где
|
|
||||||
# владелец бывает чаще, а не смешивается с общим потоком severity=critical
|
|
||||||
# ниже. Матчим по имени, а не по host="apps": исходная тревога может
|
|
||||||
# быть про любой хост, и врать в лейбле не стоит (см. инвариант host
|
|
||||||
# у alert:app в infra.yml).
|
|
||||||
- receiver: telegram-clients
|
|
||||||
matchers:
|
|
||||||
- alertname = "AlertFiringTooLong"
|
|
||||||
group_wait: 10s
|
|
||||||
repeat_interval: 30m
|
|
||||||
|
|
||||||
# Прочее критичное — инфраструктура, клиенты пока не затронуты.
|
# Прочее критичное — инфраструктура, клиенты пока не затронуты.
|
||||||
- receiver: telegram
|
- receiver: telegram
|
||||||
matchers:
|
matchers:
|
||||||
|
|
@ -106,16 +71,9 @@ route:
|
||||||
repeat_interval: 3h
|
repeat_interval: 3h
|
||||||
|
|
||||||
inhibit_rules:
|
inhibit_rules:
|
||||||
# Если хост целиком недоступен, не сыпать отдельно про каждый его
|
# Если хост целиком недоступен, не сыпать отдельно про каждый его сервис.
|
||||||
# warning-сервис. `target_matchers` НАМЕРЕННО ограничен одним warning
|
|
||||||
# (#3471): раньше сюда попадал и critical, и падение node-exporter молча
|
|
||||||
# гасило заодно PostgresLongTransactionCritical и все critical cAdvisor-
|
|
||||||
# алерты того же хоста — самое важное сообщение исчезало вместе с шумом,
|
|
||||||
# который оно должно было подавить. Warning того же хоста подавлять по-
|
|
||||||
# прежнему стоит (диск/память/своп неотличимы от «нет данных»), а critical
|
|
||||||
# обязан пережить это подавление и дойти до дежурного отдельно.
|
|
||||||
- source_matchers: [alertname = "HostAgentDown"]
|
- source_matchers: [alertname = "HostAgentDown"]
|
||||||
target_matchers: [severity = "warning"]
|
target_matchers: [severity =~ "warning|critical"]
|
||||||
equal: ["host"]
|
equal: ["host"]
|
||||||
|
|
||||||
receivers:
|
receivers:
|
||||||
|
|
@ -152,17 +110,14 @@ ${METRICS_TELEGRAM_INFRA_TOPIC_LINE}
|
||||||
- url: "http://alert-ack:8080/alertmanager"
|
- url: "http://alert-ack:8080/alertmanager"
|
||||||
send_resolved: true
|
send_resolved: true
|
||||||
|
|
||||||
# Внешний deadman-приёмник вместо Telegram — см. комментарий у маршрута
|
- name: telegram-heartbeat
|
||||||
# Watchdog выше. Блок целиком (не значение) подставляется деплоем в
|
telegram_configs:
|
||||||
# METRICS_WATCHDOG_PING_BLOCK — тот же приём, что у
|
- bot_token: "${METRICS_TELEGRAM_BOT_TOKEN}"
|
||||||
# METRICS_TELEGRAM_INFRA_TOPIC_LINE, и по той же причине: envsubst не умеет
|
chat_id: ${METRICS_TELEGRAM_CHAT_ID}
|
||||||
# условий. Секрет ещё не заведён на хосте — деплой в этом случае подставит
|
${METRICS_TELEGRAM_INFRA_TOPIC_LINE}
|
||||||
# ПУСТУЮ строку, и receiver останется без единого `*_configs`. Это валидный
|
api_url: "https://api.telegram.org"
|
||||||
# Alertmanager-конфиг: приёмник без конфигов просто молча отбрасывает
|
parse_mode: HTML
|
||||||
# уведомление, амtool его пропускает. Одинарная подстановка ЗНАЧЕНИЯ url
|
send_resolved: false
|
||||||
# (переменная-URL напрямую внутри готового ключа `url:`) сюда не годится:
|
message: |
|
||||||
# непустой ключ с пустым значением или литералом плейсхолдера амtool валит
|
⚪ <b>Мониторинг жив</b> — сторож отчитался, канал доставки работает.
|
||||||
# целиком (`unsupported scheme ""`), а с этим — весь Alertmanager, не
|
Если это сообщение перестало приходить дважды подряд, замолчал сам мониторинг.
|
||||||
# только Watchdog.
|
|
||||||
- name: watchdog-ping
|
|
||||||
${METRICS_WATCHDOG_PING_BLOCK}
|
|
||||||
|
|
|
||||||
|
|
@ -119,27 +119,6 @@ prometheus.scrape "postgres" {
|
||||||
scrape_interval = "60s"
|
scrape_interval = "60s"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ REDIS И ОЧЕРЕДЬ CELERY (#3471) ═════════════════════════════════════════════
|
|
||||||
// До этой правки ни одной серии redis_* / celery_* в Prometheus не было: глубина
|
|
||||||
// очереди, число живых воркеров и потеря соединения с брокером были невидимы —
|
|
||||||
// переполнение очереди и залипший воркер снаружи выглядели одинаково, тишиной.
|
|
||||||
|
|
||||||
prometheus.scrape "redis" {
|
|
||||||
targets = [
|
|
||||||
{ __address__ = "gendesign-redis-exporter:9121", job = "redis" },
|
|
||||||
]
|
|
||||||
forward_to = [prometheus.remote_write.central.receiver]
|
|
||||||
scrape_interval = "30s"
|
|
||||||
}
|
|
||||||
|
|
||||||
prometheus.scrape "celery" {
|
|
||||||
targets = [
|
|
||||||
{ __address__ = "gendesign-celery-exporter:9808", job = "celery" },
|
|
||||||
]
|
|
||||||
forward_to = [prometheus.remote_write.central.receiver]
|
|
||||||
scrape_interval = "30s"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══ МЕТРИКИ ПРИЛОЖЕНИЙ ════════════════════════════════════════════════════════
|
// ═══ МЕТРИКИ ПРИЛОЖЕНИЙ ════════════════════════════════════════════════════════
|
||||||
// Эндпоинты появляются в части 3. До этого скрейп просто отдаёт `up 0` — и это
|
// Эндпоинты появляются в части 3. До этого скрейп просто отдаёт `up 0` — и это
|
||||||
// правильно: цель видна как недоступная, а не отсутствует молча.
|
// правильно: цель видна как недоступная, а не отсутствует молча.
|
||||||
|
|
@ -193,26 +172,6 @@ loki.process "scrub_credentials" {
|
||||||
expression = "://[^:@/ ]+:([^@ ]+)@"
|
expression = "://[^:@/ ]+:([^@ ]+)@"
|
||||||
replace = "***"
|
replace = "***"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Секреты в query-строке (#3354) ─────────────────────────────────────────
|
|
||||||
// Прод-факт (#3154): uvicorn пишет в access-log ПОЛНЫЙ путь с query, и туда
|
|
||||||
// уезжал `?secret=<64 hex>` вебхука GlitchTip. Приложение чистит это у себя
|
|
||||||
// (tradein-mvp/backend/app/core/log_scrub.py), здесь — второй слой на случай
|
|
||||||
// строки, пришедшей мимо фильтра: другой процесс, sidecar, будущий логгер.
|
|
||||||
//
|
|
||||||
// Множество имён держим ОДИНАКОВЫМ с log_scrub.py. Префикс `[\w.-]*` перед
|
|
||||||
// альтернацией — ради суффиксных имён (`client_secret`, `refresh_token`,
|
|
||||||
// `webhook_secret`); значение обрывается на `&`, пробеле или кавычке, потому
|
|
||||||
// что access-строка uvicorn обрамляет запрос кавычками.
|
|
||||||
//
|
|
||||||
// Группа захвата стоит на ЗНАЧЕНИИ, а не на имени параметра: Alloy заменяет
|
|
||||||
// содержимое групп, а не весь совпавший фрагмент (ровно как у DSN выше — там
|
|
||||||
// группа на пароле, поэтому пользователь и хост уцелевают). Обернуть группой
|
|
||||||
// `?secret=` значило бы затереть имя и оставить сам секрет.
|
|
||||||
stage.replace {
|
|
||||||
expression = "(?i)[?&][\\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=([^&\\s\"'<>]+)"
|
|
||||||
replace = "***"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loki.relabel "journal" {
|
loki.relabel "journal" {
|
||||||
|
|
|
||||||
|
|
@ -57,13 +57,9 @@ prometheus.scrape "cadvisor" {
|
||||||
prometheus.relabel "cadvisor_trim" {
|
prometheus.relabel "cadvisor_trim" {
|
||||||
forward_to = [prometheus.remote_write.central.receiver]
|
forward_to = [prometheus.remote_write.central.receiver]
|
||||||
|
|
||||||
// `up`/`scrape_samples_scraped` — служебные ряды самого скрейпа, не
|
|
||||||
// container_*-метрики. Без явного допуска этот keep-фильтр резал их вместе
|
|
||||||
// с прочим шумом, и у cAdvisor как job'а не было своей серии `up` вообще —
|
|
||||||
// его смерть выглядела так же, как «ничего не изменилось» (#3471).
|
|
||||||
rule {
|
rule {
|
||||||
source_labels = ["__name__"]
|
source_labels = ["__name__"]
|
||||||
regex = "up|scrape_samples_scraped|container_(memory_(usage_bytes|working_set_bytes|rss)|cpu_(usage_seconds_total|cfs_throttled_seconds_total)|network_(receive|transmit)_bytes_total|fs_(usage|limit)_bytes|last_seen|spec_memory_limit_bytes|start_time_seconds|processes)"
|
regex = "container_(memory_(usage_bytes|working_set_bytes|rss)|cpu_(usage_seconds_total|cfs_throttled_seconds_total)|network_(receive|transmit)_bytes_total|fs_(usage|limit)_bytes|last_seen|spec_memory_limit_bytes|start_time_seconds|processes)"
|
||||||
action = "keep"
|
action = "keep"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,15 +70,9 @@ prometheus.relabel "cadvisor_trim" {
|
||||||
//
|
//
|
||||||
// NB: на пустые панели это правило НЕ влияло. Причина была в cAdvisor 0.52 на
|
// NB: на пустые панели это правило НЕ влияло. Причина была в cAdvisor 0.52 на
|
||||||
// Docker 29 — до сюда доезжал ровно один ряд, корневой. Лечится версией 0.55.1.
|
// Docker 29 — до сюда доезжал ровно один ряд, корневой. Лечится версией 0.55.1.
|
||||||
//
|
|
||||||
// `up`/`scrape_samples_scraped` лейбла `name` не несут вовсе (они не про
|
|
||||||
// конкретный контейнер, а про сам скрейп) — фильтр по нему вырезал бы и их.
|
|
||||||
// Поэтому здесь смотрим на пару (__name__, name): для служебных рядов
|
|
||||||
// достаточно самого __name__, для контейнерных метрик по-прежнему обязателен
|
|
||||||
// непустой name.
|
|
||||||
rule {
|
rule {
|
||||||
source_labels = ["__name__", "name"]
|
source_labels = ["name"]
|
||||||
regex = "up;.*|scrape_samples_scraped;.*|container_[^;]*;.+"
|
regex = ".+"
|
||||||
action = "keep"
|
action = "keep"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -141,18 +131,6 @@ loki.process "scrub_credentials" {
|
||||||
expression = "://[^:@/ ]+:([^@ ]+)@"
|
expression = "://[^:@/ ]+:([^@ ]+)@"
|
||||||
replace = "***"
|
replace = "***"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Секреты в query-строке (#3354) ─────────────────────────────────────────
|
|
||||||
// То же выражение, что в alloy-apps.alloy, и по той же причине — подробности
|
|
||||||
// там. Здесь оно нужно не меньше: на инфра-хосте живут Forgejo и GlitchTip,
|
|
||||||
// у обоих есть ручки с `?token=` в адресе, и их логи идут в тот же Loki.
|
|
||||||
//
|
|
||||||
// Группа захвата — на ЗНАЧЕНИИ: Alloy заменяет содержимое групп, а не весь
|
|
||||||
// совпавший фрагмент (как у DSN выше, где группа на пароле).
|
|
||||||
stage.replace {
|
|
||||||
expression = "(?i)[?&][\\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=([^&\\s\"'<>]+)"
|
|
||||||
replace = "***"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loki.relabel "journal" {
|
loki.relabel "journal" {
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@
|
||||||
{
|
{
|
||||||
"type": "timeseries",
|
"type": "timeseries",
|
||||||
"title": "Запросы по классам ответов",
|
"title": "Запросы по классам ответов",
|
||||||
"description": "Классы, а не отдельные коды: форма графика важнее точного номера. Всплеск 4xx без 5xx — обычно сканер или сломанный клиент; всплеск 5xx — наша ошибка. Линии НЕ стекируются: высота красной линии — это и есть число пятисоток, а не сумма со всем, что под ней.",
|
"description": "Классы, а не отдельные коды: форма графика важнее точного номера. Всплеск 4xx без 5xx — обычно сканер или сломанный клиент; всплеск 5xx — наша ошибка.",
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 },
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 },
|
||||||
"targets": [
|
"targets": [
|
||||||
|
|
@ -117,7 +117,7 @@
|
||||||
{ "refId": "D", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=~\"5..\"}[5m]))", "legendFormat": "{{app}} · 5xx" }
|
{ "refId": "D", "expr": "sum by (app) (rate(http_requests_total{app=~\"$app\", status=~\"5..\"}[5m]))", "legendFormat": "{{app}} · 5xx" }
|
||||||
],
|
],
|
||||||
"fieldConfig": {
|
"fieldConfig": {
|
||||||
"defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 } },
|
"defaults": { "unit": "reqps", "min": 0, "custom": { "fillOpacity": 25, "stacking": { "mode": "normal" }, "showPoints": "never", "lineWidth": 1 } },
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{ "matcher": { "id": "byRegexp", "options": ".*2xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] },
|
{ "matcher": { "id": "byRegexp", "options": ".*2xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] },
|
||||||
{ "matcher": { "id": "byRegexp", "options": ".*3xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } } ] },
|
{ "matcher": { "id": "byRegexp", "options": ".*3xx.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } } ] },
|
||||||
|
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
{
|
|
||||||
"uid": "gendesign-product",
|
|
||||||
"title": "Продуктовые метрики",
|
|
||||||
"description": "Числа бизнеса, а не процесса: сколько людей реально что-то сделали в «Мере» и «Птице» (#3471). Источник — те же счётчики Prometheus, что и на дашборде «Приложения», только считают не HTTP-статусы, а продуктовые события (оценка, лид, отчёт, вход, обращение в поддержку). Часовое окно нарочно грубое: при текущем трафике (сотни событий в сутки) минутные всплески — шум, а не сигнал.",
|
|
||||||
"tags": ["gendesign", "product"],
|
|
||||||
"timezone": "browser",
|
|
||||||
"editable": false,
|
|
||||||
"schemaVersion": 39,
|
|
||||||
"refresh": "5m",
|
|
||||||
"time": { "from": "now-24h", "to": "now" },
|
|
||||||
"panels": [
|
|
||||||
{ "type": "row", "title": "Мера — воронка продукта", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } },
|
|
||||||
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Оценок в час, по исходу",
|
|
||||||
"description": "insufficient_data — не ошибка: аналогов рядом с адресом не нашлось, расчёт прошёл штатно. Тревожиться стоит, если эта линия начинает расти быстрее ok — значит покрытие рынка проседает, а не то, что сломался расчёт.",
|
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 1 },
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"refId": "A",
|
|
||||||
"expr": "sum by (outcome) (increase(mera_estimates_total[1h]))",
|
|
||||||
"legendFormat": "{{outcome}}"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"unit": "short",
|
|
||||||
"min": 0,
|
|
||||||
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
|
||||||
},
|
|
||||||
"overrides": [
|
|
||||||
{ "matcher": { "id": "byName", "options": "insufficient_data" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] },
|
|
||||||
{ "matcher": { "id": "byName", "options": "ok" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Подсказки адреса в час, нашёлся ли результат",
|
|
||||||
"description": "found=no — человек напечатал адрес, а автокомплит ничего не предложил: либо адреса нет в базе, либо он вне зоны покрытия. Устойчивый рост этой линии — повод расширять покрытие, а не баг одного запроса.",
|
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 1 },
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"refId": "A",
|
|
||||||
"expr": "sum by (found) (increase(mera_address_suggestions_total[1h]))",
|
|
||||||
"legendFormat": "найден: {{found}}"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"unit": "short",
|
|
||||||
"min": 0,
|
|
||||||
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
|
||||||
},
|
|
||||||
"overrides": [
|
|
||||||
{ "matcher": { "id": "byRegexp", "options": ".*no.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Лиды и скачанные отчёты в час",
|
|
||||||
"description": "Лид — заявка с телефоном после оценки, отчёт — скачанный PDF по оценке. Оба редкие: сверяйте с недельным окном (кнопка времени вверху), часовой провал сам по себе ни о чём не говорит при таком трафике.",
|
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 9 },
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "increase(mera_leads_total[1h])", "legendFormat": "лиды" },
|
|
||||||
{ "refId": "B", "expr": "increase(mera_reports_exported_total[1h])", "legendFormat": "отчёты" }
|
|
||||||
],
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"unit": "short",
|
|
||||||
"min": 0,
|
|
||||||
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
|
||||||
},
|
|
||||||
"overrides": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Входы в час, по исходу",
|
|
||||||
"description": "Устойчивый рост failed при ровном success — либо перебор паролей, либо сломался клиент (истёкшая сессия, старый билд фронта). При подозрении на перебор смотрите заодно панель «Отказы авторизации и лимитера» на дашборде «Приложения» — там 401/429 по HTTP.",
|
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 9 },
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"refId": "A",
|
|
||||||
"expr": "sum by (result) (increase(mera_logins_total[1h]))",
|
|
||||||
"legendFormat": "{{result}}"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"unit": "short",
|
|
||||||
"min": 0,
|
|
||||||
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
|
||||||
},
|
|
||||||
"overrides": [
|
|
||||||
{ "matcher": { "id": "byName", "options": "failed" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] },
|
|
||||||
{ "matcher": { "id": "byName", "options": "success" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Обращения в поддержку в час, по каналу",
|
|
||||||
"description": "anon — обращения с экрана входа, без логина (типично «не могу войти»), web — уже залогиненные пользователи. Всплеск anon без роста web — обычно означает баг именно в форме/процессе входа, а не общий рост нагрузки на поддержку.",
|
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 17 },
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"refId": "A",
|
|
||||||
"expr": "sum by (channel) (increase(mera_support_messages_total[1h]))",
|
|
||||||
"legendFormat": "{{channel}}"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"unit": "short",
|
|
||||||
"min": 0,
|
|
||||||
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
|
||||||
},
|
|
||||||
"overrides": [
|
|
||||||
{ "matcher": { "id": "byName", "options": "anon" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Конверсия оценка → заявка, скользящие 7 суток",
|
|
||||||
"description": "Доля успешных оценок (outcome=ok), после которых оставили заявку (#1971): лиды за 7 суток / оценки ok за 7 суток. Знаменатель — только ok, потому что форма заявки показывается только при посчитанной оценке (insufficient_data формы не видит). Счёт по событиям, а не по людям: одна заявка после трёх пересчётов того же адреса даёт 33 %. Пусто вместо 0 % — за 7 суток не было ни одной успешной оценки.",
|
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 17 },
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"refId": "A",
|
|
||||||
"expr": "sum(increase(mera_leads_total[7d])) / sum(increase(mera_estimates_total{outcome=\"ok\"}[7d]))",
|
|
||||||
"legendFormat": "заявки / оценки"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"unit": "percentunit",
|
|
||||||
"min": 0,
|
|
||||||
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
|
||||||
},
|
|
||||||
"overrides": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
{ "type": "row", "title": "Птица — экспорт отчётов", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } },
|
|
||||||
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Экспортов отчётов по участку в час, по формату",
|
|
||||||
"description": "Формат — то, что реально скачали: md/json/docx/pptx/pdf/tg-сводка §22-форсайта плюс best_layouts_pdf (ТЗ на проектирование). Провал всех форматов разом при живом трафике на дашборде «Приложения» значит, что сломан сам экспорт, а не рендер одного конкретного формата.",
|
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
||||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 26 },
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"refId": "A",
|
|
||||||
"expr": "sum by (format) (increase(sitefinder_reports_exported_total[1h]))",
|
|
||||||
"legendFormat": "{{format}}"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"unit": "short",
|
|
||||||
"min": 0,
|
|
||||||
"custom": { "fillOpacity": 8, "stacking": { "mode": "none" }, "showPoints": "never", "lineWidth": 2 }
|
|
||||||
},
|
|
||||||
"overrides": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
# Эта папка сознательно пустая
|
|
||||||
|
|
||||||
Grafana умеет провижинить contact points, notification policies и alert rules
|
|
||||||
файлами отсюда (`/etc/grafana/provisioning/alerting`). Не клади их сюда.
|
|
||||||
|
|
||||||
Решение (#3158, 12.09.2026): единственный путь доставки тревог — Prometheus
|
|
||||||
(правила) + Alertmanager (маршрутизация, Telegram). Grafana только рисует.
|
|
||||||
Встроенный Alerting выключен явно (`GF_UNIFIED_ALERTING_ENABLED: "false"` в
|
|
||||||
`docker-compose.metrics.yml`, секция `grafana`) — при живом API 12.09.2026
|
|
||||||
единственным контакт-поинтом был стоковый `grafana-default-email` на
|
|
||||||
`example@email.com`, `GF_SMTP_*` не задан, правил ноль. Файл сюда работать не
|
|
||||||
заставит: движок alerting выключен на уровне сервиса, провижининг в эту папку
|
|
||||||
Grafana просто не читает.
|
|
||||||
|
|
||||||
Если понадобится включить обратно — сначала пересмотреть само решение в
|
|
||||||
`docker-compose.metrics.yml`, а не просто добавить файл в эту папку.
|
|
||||||
|
|
@ -44,104 +44,44 @@ groups:
|
||||||
summary: "С продуктового хоста 15 минут не приходят метрики"
|
summary: "С продуктового хоста 15 минут не приходят метрики"
|
||||||
description: "Либо лёг агент на Poincare, либо оборван канал до metrics.gendsgn.ru, либо приёмник не принимает remote-write."
|
description: "Либо лёг агент на Poincare, либо оборван канал до metrics.gendsgn.ru, либо приёмник не принимает remote-write."
|
||||||
|
|
||||||
# cAdvisor как job не публикует `up` естественным образом — до фикса
|
|
||||||
# keep-фильтра в alloy-infra.alloy/alloy-apps.alloy (#3471) эта серия
|
|
||||||
# вырезалась тем же правилом, что чистит container_* от мусорных
|
|
||||||
# лейблов. Итог — смерть cAdvisor молча гасила ContainerRestartLoop и
|
|
||||||
# ContainerNearMemoryLimit: обе метрики просто переставали поступать, а
|
|
||||||
# выглядело это как «событий не было».
|
|
||||||
- alert: CadvisorDown
|
|
||||||
expr: up{job="cadvisor"} == 0 or absent(up{job="cadvisor"})
|
|
||||||
for: 5m
|
|
||||||
labels:
|
|
||||||
severity: critical
|
|
||||||
annotations:
|
|
||||||
summary: "cAdvisor не отвечает"
|
|
||||||
description: "{{ if $labels.host }}{{ $labels.host }}: {{ end }}job=\"cadvisor\" вернул up=0 либо серия пропала целиком. Без неё контейнерные алерты этого хоста молчат вне зависимости от реального состояния контейнеров."
|
|
||||||
|
|
||||||
# ── Хост ────────────────────────────────────────────────────────────────────
|
# ── Хост ────────────────────────────────────────────────────────────────────
|
||||||
- name: host
|
- name: host
|
||||||
interval: 60s
|
interval: 60s
|
||||||
rules:
|
rules:
|
||||||
# Диск. Инцидент 16.09: кэш архивов Forgejo ел 2 ГБ/ч на диске 145 ГБ
|
# Диск. На Beget уже был случай, когда занято 79 % и никто не смотрел;
|
||||||
# (host=infra). Проценты на дисках разного размера значат разное —
|
# порог 85 % даёт запас на реакцию, а не сообщает о свершившемся факте.
|
||||||
# host=apps держит /-раздел ~910 ГБ, те же 85 % там это ещё ~135 ГБ
|
|
||||||
# запаса, а на infra (145 ГБ) — почти ничего. Порог переведён в
|
|
||||||
# АБСОЛЮТНЫЕ ГБ свободного места: он одинаково осмыслен на любом диске,
|
|
||||||
# потому что напрямую отвечает на вопрос «сколько времени есть до нуля
|
|
||||||
# при текущей скорости утечки», а не «какая доля занята».
|
|
||||||
#
|
|
||||||
# `mountpoint="/"` — намеренное сужение с прежнего «все fstype кроме
|
|
||||||
# tmpfs/overlay»: /boot и /boot/efi по обоим хостам меньше 1 ГБ
|
|
||||||
# целиком, с порогом 30/15 ГБ они бы горели ПОСТОЯННО (проверено живыми
|
|
||||||
# метриками 16.09 — infra:/boot/efi 98 МБ, apps:/boot 553 МБ). Данные
|
|
||||||
# приложения и Postgres лежат на "/", туда и целится алерт.
|
|
||||||
- alert: DiskSpaceLow
|
- alert: DiskSpaceLow
|
||||||
expr: |
|
expr: |
|
||||||
node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}
|
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
|
||||||
/ 1073741824 < 30
|
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.85
|
||||||
for: 15m
|
for: 15m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Свободно на диске меньше 30 ГБ"
|
summary: "Диск занят больше 85 %"
|
||||||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: свободно {{ printf \"%.1f\" $value }} ГБ."
|
description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}."
|
||||||
|
|
||||||
- alert: DiskSpaceCritical
|
- alert: DiskSpaceCritical
|
||||||
expr: |
|
expr: |
|
||||||
node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}
|
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
|
||||||
/ 1073741824 < 15
|
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.93
|
||||||
for: 5m
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: critical
|
severity: critical
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Свободно на диске меньше 15 ГБ"
|
summary: "Диск почти кончился"
|
||||||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: свободно {{ printf \"%.1f\" $value }} ГБ. Postgres при заполнении диска останавливается."
|
description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}. Postgres при заполнении диска останавливается."
|
||||||
|
|
||||||
# Скорость, а не уровень. Ловит именно ту утечку, что была 16.09: пока
|
# Прогноз важнее порога: он ловит утечку до того, как она упрётся в стену.
|
||||||
# DiskSpaceLow/Critical ещё не сработали (места вагон), но оно тает
|
|
||||||
# быстрее ~1 ГБ/ч устойчиво — это уже течь, а не органический рост.
|
|
||||||
# `deriv()` — линейная регрессия по 15-минутному окну (не мгновенная
|
|
||||||
# разница двух точек, ту дёргает шум). Знак минус спереди и деление
|
|
||||||
# переносят результат из "Б/с, отрицательное при убыли" в "ГБ/ч,
|
|
||||||
# положительное когда тает" — чтобы $value в тексте читался нормально
|
|
||||||
# (не "-2.1 ГБ/ч"). `for: 30m` поверх 15-минутного окна регрессии
|
|
||||||
# требует НЕПРЕРЫВНОЙ убыли около часа, чтобы не будить на разовый
|
|
||||||
# скачок (бэкап, ротация логов).
|
|
||||||
- alert: DiskSpaceDepletingFast
|
|
||||||
expr: |
|
|
||||||
-deriv(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}[15m])
|
|
||||||
* 3600 / 1073741824 > 1
|
|
||||||
for: 30m
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
annotations:
|
|
||||||
summary: "Диск пустеет быстрее ГБ в час"
|
|
||||||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: свободное место убывает на {{ printf \"%.1f\" $value }} ГБ/час устойчиво последние ~30 минут — независимо от того, сколько места осталось сейчас."
|
|
||||||
|
|
||||||
# Прогноз важнее порога: он ловит утечку до того, как она упрётся в
|
|
||||||
# стену — но роль другая, чем у DiskSpaceDepletingFast выше. Тот ловит
|
|
||||||
# БЫСТРУЮ (>1 ГБ/ч) утечку рано, за счёт короткого 15-минутного окна.
|
|
||||||
# Этот ловит УМЕРЕННУЮ утечку (может быть медленнее 1 ГБ/ч), которую
|
|
||||||
# короткое окно не поймает, но которая всё равно приведёт к нулю в
|
|
||||||
# течение суток при текущем уровне занятости — окно 6h усредняет шум
|
|
||||||
# ценой более позднего срабатывания. На одной и той же быстрой утечке
|
|
||||||
# оба правила могут сработать (сначала это, следом то) — это
|
|
||||||
# ЗАДУМАННАЯ эскалация двумя разными сигналами (скорость сейчас →
|
|
||||||
# подтверждённый тренд на сутки), а не дублирующее письмо: тексты и
|
|
||||||
# время срабатывания разные. DiskSpaceLow/Critical выше добавляют
|
|
||||||
# третий, независимый от скорости сигнал — «места мало» само по себе,
|
|
||||||
# даже если утечки нет и ничего не тает быстро.
|
|
||||||
- alert: DiskWillFillIn24h
|
- alert: DiskWillFillIn24h
|
||||||
expr: |
|
expr: |
|
||||||
(0 - predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}[6h], 24*3600))
|
predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[6h], 24*3600) < 0
|
||||||
/ 1073741824 > 0
|
|
||||||
for: 30m
|
for: 30m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "По текущему темпу диск кончится за сутки"
|
summary: "По текущему темпу диск кончится за сутки"
|
||||||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: по экстраполяции последних 6 часов через сутки не хватит ~{{ printf \"%.1f\" $value }} ГБ."
|
description: "{{ $labels.host }} {{ $labels.mountpoint }}: экстраполяция по последним 6 часам."
|
||||||
|
|
||||||
- alert: MemoryPressure
|
- alert: MemoryPressure
|
||||||
expr: |
|
expr: |
|
||||||
|
|
@ -185,225 +125,17 @@ groups:
|
||||||
description: "{{ $labels.host }} / {{ $labels.name }}: больше трёх стартов за полчаса."
|
description: "{{ $labels.host }} / {{ $labels.name }}: больше трёх стартов за полчаса."
|
||||||
|
|
||||||
# Подошёл к своему mem_limit — следующий шаг OOM-kill.
|
# Подошёл к своему mem_limit — следующий шаг OOM-kill.
|
||||||
#
|
|
||||||
# ФОРМА ВЫРАЖЕНИЯ ВАЖНА, а не только условие. `A and B` возвращает ЗНАЧЕНИЯ
|
|
||||||
# ЛЕВОЙ части, отфильтрованные правой, — то есть в `$value` попадает именно
|
|
||||||
# A. Прежняя запись (`limit > 0 and working_set/limit > 0.90`) слала в
|
|
||||||
# Telegram лимит В БАЙТАХ, отрендеренный как процент: боевое сообщение
|
|
||||||
# 12.09 — «2.684e+11% от mem_limit» при limit = 2 684 354 560 Б. Условие
|
|
||||||
# при этом срабатывало верно, врал только текст. Поэтому отношение стоит
|
|
||||||
# СЛЕВА, а отсев нулевого лимита убран внутрь знаменателя: `(X > 0)`
|
|
||||||
# выбрасывает серии без лимита ДО деления.
|
|
||||||
#
|
|
||||||
# ВТОРОЕ УСЛОВИЕ — АНОНИМНАЯ ПАМЯТЬ (#3493). working_set = usage − inactive_file,
|
|
||||||
# то есть в нём остаётся АКТИВНЫЙ кэш страниц, а его ядро вытесняет само и
|
|
||||||
# OOM из-за него не наступает. У контейнера с базой ночные сканы поднимают
|
|
||||||
# активный кэш к потолку каждую ночь: tradein-postgres за 14 суток дал 5
|
|
||||||
# эпизодов (все 01:09–03:44 UTC) при анонимной памяти не выше 11.4 % лимита и
|
|
||||||
# oom_kill = 0 — текст «дальше OOM-kill» был ложью. Калибровка этим самым
|
|
||||||
# выражением по истории 14 суток: tradein-postgres 5 → 0, tradein-browser
|
|
||||||
# 2 → 2 (06.09 и 12.09, rss до 95 % — настоящие), остальные 0 → 0.
|
|
||||||
# `and` без `on()`: у working_set и rss один и тот же набор меток cAdvisor,
|
|
||||||
# совпадение проверено на проде (913 из 915 серий); слева по-прежнему доля.
|
|
||||||
- alert: ContainerNearMemoryLimit
|
- alert: ContainerNearMemoryLimit
|
||||||
expr: |
|
expr: |
|
||||||
container_memory_working_set_bytes{name!=""}
|
container_spec_memory_limit_bytes{name!=""} > 0
|
||||||
/ (container_spec_memory_limit_bytes{name!=""} > 0) > 0.90
|
and container_memory_working_set_bytes{name!=""}
|
||||||
and
|
/ container_spec_memory_limit_bytes{name!=""} > 0.90
|
||||||
(container_memory_rss{name!=""}
|
|
||||||
/ (container_spec_memory_limit_bytes{name!=""} > 0) > 0.50)
|
|
||||||
for: 15m
|
for: 15m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Контейнер у своего потолка памяти"
|
summary: "Контейнер у своего потолка памяти"
|
||||||
description: "{{ $labels.host }} / {{ $labels.name }}: {{ $value | humanizePercentage }} от mem_limit, и больше половины лимита — анонимная память процессов, которую ядро не вытеснит как кэш. Дальше OOM-kill."
|
description: "{{ $labels.host }} / {{ $labels.name }}: {{ $value | humanizePercentage }} от mem_limit. Дальше OOM-kill."
|
||||||
|
|
||||||
# tradein-tgbot и tradein-scraper не HTTP-сервисы — у них нет `up{}`
|
|
||||||
# вообще, поэтому крэш-без-рестарта или удаление контейнера иначе не
|
|
||||||
# поймать. `absent()` на каждое имя отдельно (не одним regex-селектором):
|
|
||||||
# regex-селектор с несколькими сериями считается «пустым» только когда
|
|
||||||
# ПРОПАЛИ ОБЕ — если жив хотя бы один из двух контейнеров, absent() по
|
|
||||||
# общему selector'у молчит и не заметит пропажу второго.
|
|
||||||
#
|
|
||||||
# ВАЖНО, чего это правило НЕ ловит: container_last_seen обновляется, пока
|
|
||||||
# Docker видит контейнер живым, — зависший, но не упавший процесс
|
|
||||||
# (внутренний цикл встал, контейнер по-прежнему числится running) эту
|
|
||||||
# метрику не тронет. Слепая зона «живой процесс с застрявшим циклом»
|
|
||||||
# остаётся открытой: подходящей метрики для неё сейчас нет.
|
|
||||||
- alert: TradeInBackgroundContainerMissing
|
|
||||||
expr: |
|
|
||||||
absent(container_last_seen{name="tradein-tgbot"})
|
|
||||||
or absent(container_last_seen{name="tradein-scraper"})
|
|
||||||
for: 5m
|
|
||||||
labels:
|
|
||||||
severity: critical
|
|
||||||
annotations:
|
|
||||||
summary: "Фоновый контейнер Меры пропал из cAdvisor"
|
|
||||||
description: "{{ $labels.name }}: серия container_last_seen исчезла — контейнер, судя по всему, не работает и не перезапускается."
|
|
||||||
|
|
||||||
# ── Приложение ──────────────────────────────────────────────────────────────
|
|
||||||
# `job="app"` — job из alloy-apps.alloy, лейбл `app` различает продукты
|
|
||||||
# (sitefinder / mera). Метрики отдаёт `MetricsMiddleware`
|
|
||||||
# (`backend/app/observability/metrics.py` у «Птицы»,
|
|
||||||
# `tradein-mvp/backend/app/observability/metrics.py` у «Меры») — счётчик
|
|
||||||
# `http_requests_total{method,route,status}` и гистограмма
|
|
||||||
# `http_request_duration_seconds{method,route}`. До этой группы доля 5xx и
|
|
||||||
# задержка были видны только постфактум в GlitchTip, без порога срабатывания
|
|
||||||
# (#3471).
|
|
||||||
#
|
|
||||||
# severity: critical + host: apps здесь ОБЯЗАТЕЛЬНЫ содержательно, не для
|
|
||||||
# красоты: именно эта пара матчится маршрутом telegram-clients в
|
|
||||||
# alertmanager.yml.tmpl — тот зовёт дежурного и напоминает каждые 30 минут.
|
|
||||||
# `host` выставлен статически: `sum by (app)` вырезает его из результата
|
|
||||||
# запроса, а job="app" в принципе существует только на продуктовом хосте.
|
|
||||||
- name: app
|
|
||||||
interval: 60s
|
|
||||||
rules:
|
|
||||||
# Бэкенд «Меры» лежит или завис (#2214). До этого правила такой отказ не
|
|
||||||
# давал ни одного сигнала вне Poincare. GlitchTip-монитор 6 смотрит на
|
|
||||||
# лэндинг meraocenka.ru, а его отдаёт tradein-frontend из пререндер-кэша
|
|
||||||
# (x-nextjs-cache: HIT) — при мёртвом бэкенде там по-прежнему 200.
|
|
||||||
# Монитор 2 (gendsgn.ru/health) — это бэкенд «Птицы». Два правила ниже
|
|
||||||
# считают метрики, которые отдаёт сам бэкенд: мёртвый не отдаёт, и они
|
|
||||||
# молчат по построению.
|
|
||||||
#
|
|
||||||
# Скрейп идёт с Poincare (Alloy), но решение и доставка — здесь, на Beget.
|
|
||||||
# Зависший event loop даёт таймаут скрейпа (20s), удалённый контейнер —
|
|
||||||
# ошибку DNS; в обоих случаях up=0. Смерть всего хоста ловят
|
|
||||||
# HostAgentDown/RemoteWriteStalled; `absent()` — на случай, когда цель
|
|
||||||
# тихо пропала из alloy-apps.alloy.
|
|
||||||
#
|
|
||||||
# for: 5m — по истории up{app="mera"} 26.08–17.09 (61 857 точек): 140
|
|
||||||
# провалов, все короткие (окна деплоя), самый длинный — 4 нулевые точки
|
|
||||||
# подряд, ~2 минуты до восстановления. 5 минут — запас в 2.5 раза.
|
|
||||||
#
|
|
||||||
# Чего правило НЕ видит: публичный путь до бэкенда (DNS, TLS, Caddy).
|
|
||||||
# Для лэндинга его проверяет монитор 6; монитора на публичную ручку
|
|
||||||
# бэкенда (meraocenka.ru/trade-in/api/public/mera/stats) пока нет — он
|
|
||||||
# заводится в UI GlitchTip, а не кодом.
|
|
||||||
- alert: MeraBackendDown
|
|
||||||
expr: |
|
|
||||||
up{job="app", app="mera"} == 0
|
|
||||||
or absent(up{job="app", app="mera"})
|
|
||||||
for: 5m
|
|
||||||
labels:
|
|
||||||
severity: critical
|
|
||||||
host: apps
|
|
||||||
annotations:
|
|
||||||
summary: "Бэкенд «Меры» не отвечает"
|
|
||||||
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
|
|
||||||
|
|
||||||
# Гейт по RPS внутри знаменателя — тот же приём, что у
|
|
||||||
# PostgresLowHotUpdateRatio: делит только там, где трафик уже есть,
|
|
||||||
# иначе один упавший запрос при нулевой нагрузке даёт 100% и будит
|
|
||||||
# дежурного зря.
|
|
||||||
- alert: AppHighErrorRate
|
|
||||||
expr: |
|
|
||||||
sum by (app) (rate(http_requests_total{job="app", status=~"5.."}[5m]))
|
|
||||||
/ (sum by (app) (rate(http_requests_total{job="app"}[5m])) > 0.1) > 0.05
|
|
||||||
for: 5m
|
|
||||||
labels:
|
|
||||||
severity: critical
|
|
||||||
host: apps
|
|
||||||
annotations:
|
|
||||||
summary: "Доля 5xx выше 5%"
|
|
||||||
description: "{{ $labels.app }}: {{ $value | humanizePercentage }} ответов 5xx за последние 5 минут при RPS выше 0.1."
|
|
||||||
|
|
||||||
# Порог 5s — заведомо выше рабочего профиля обоих продуктов (у «Меры»
|
|
||||||
# типичный расчёт 90мс, у «Птицы» верхняя граница гистограммы — 60с под
|
|
||||||
# тяжёлую геометрию, но это единичные хвостовые запросы, не p95).
|
|
||||||
# Калибровка по реальному трафику — отдельная задача, не эта.
|
|
||||||
- alert: AppHighLatencyP95
|
|
||||||
expr: |
|
|
||||||
histogram_quantile(0.95, sum by (le, app) (rate(http_request_duration_seconds_bucket{job="app"}[10m]))) > 5
|
|
||||||
for: 10m
|
|
||||||
labels:
|
|
||||||
severity: critical
|
|
||||||
host: apps
|
|
||||||
annotations:
|
|
||||||
summary: "p95 задержки ответа выше 5 секунд"
|
|
||||||
description: "{{ $labels.app }}: p95 за 10 минут — {{ $value | humanizeDuration }}."
|
|
||||||
|
|
||||||
# ── Redis и очередь Celery (#3471) ───────────────────────────────────────────
|
|
||||||
# Слепая зона: до этих правил ни redis_*, ни celery_* не собирались вовсе.
|
|
||||||
# Redis — общий инстанс на три потребителя (celery-брокер Site Finder, кэш
|
|
||||||
# trade-in, glitchtip — см. docker-compose.prod.yml), поэтому его смерть
|
|
||||||
# клиентская, отсюда severity: critical без явного host: apps — серия
|
|
||||||
# приходит только с продуктового alloy (alloy-apps.alloy), host в неё
|
|
||||||
# проставляется через external_labels уже на месте.
|
|
||||||
#
|
|
||||||
# Имена метрик celery_queue_length / celery_worker_up / celery_task_failed_total
|
|
||||||
# сверены с исходником celery-exporter 0.12.2 (src/exporter.py) — см.
|
|
||||||
# комментарий у сервиса в docker-compose.metrics-agent.yml (#3493).
|
|
||||||
- name: redis-celery
|
|
||||||
interval: 60s
|
|
||||||
rules:
|
|
||||||
- alert: RedisDown
|
|
||||||
expr: up{job="redis"} == 0 or redis_up == 0
|
|
||||||
for: 5m
|
|
||||||
labels:
|
|
||||||
severity: critical
|
|
||||||
annotations:
|
|
||||||
summary: "Redis недоступен"
|
|
||||||
description: "redis_exporter не может достучаться до Redis (или сам процесс лёг). Разом теряют связь celery-брокер Site Finder, SearchCache trade-in и glitchtip."
|
|
||||||
|
|
||||||
# ДВЕ РАЗНЫЕ НОВОСТИ — ДВА ПРАВИЛА (#3493). Прежнее
|
|
||||||
# `count(celery_worker_up == 1) == 0 or absent(celery_worker_up)` смешивало
|
|
||||||
# «воркер мёртв» с «экспортёр не поднят» под текстом первой: с 12.09 по 17.09
|
|
||||||
# оно горело без перерыва по ветке absent() при живом воркере (экспортёр не
|
|
||||||
# стартовал), повторяясь каждые ~3 часа. А первая ветка не сработала бы
|
|
||||||
# НИКОГДА: `count()` от пустого вектора возвращает пустой вектор, а не 0,
|
|
||||||
# поэтому `== 0` сравнивать не с чем — мёртвый воркер она бы пропустила.
|
|
||||||
#
|
|
||||||
# Здесь — только когда экспортёр ЖИВ (`up == 1`), но живых воркеров нет:
|
|
||||||
# либо все серии в 0, либо серий нет (экспортёр вычищает отвалившихся).
|
|
||||||
# Отсутствие самого экспортёра — QueueExporterDown ниже, со своим текстом.
|
|
||||||
- alert: NoActiveCeleryWorkers
|
|
||||||
expr: |
|
|
||||||
(sum(celery_worker_up) == 0 or absent(celery_worker_up))
|
|
||||||
and on() (up{job="celery"} == 1)
|
|
||||||
for: 5m
|
|
||||||
labels:
|
|
||||||
severity: critical
|
|
||||||
annotations:
|
|
||||||
summary: "Ни одного живого воркера Celery"
|
|
||||||
description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
|
|
||||||
|
|
||||||
# Экспортёр очереди не отдаёт метрики. `absent(up{job="X"})` переносит
|
|
||||||
# job в метки результата, поэтому текст знает, какой из двух.
|
|
||||||
- alert: QueueExporterDown
|
|
||||||
expr: |
|
|
||||||
up{job=~"redis|celery"} == 0
|
|
||||||
or absent(up{job="redis"})
|
|
||||||
or absent(up{job="celery"})
|
|
||||||
for: 10m
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
annotations:
|
|
||||||
summary: "Метрики очереди не собираются"
|
|
||||||
description: "{{ $labels.job }}-экспортёр на Poincare не отдаёт метрики (up=0 или серии нет вовсе). Это авария наблюдаемости, а не продукта: Redis и воркеры могут быть живы — проверь `docker ps`. Пока она горит, RedisDown и NoActiveCeleryWorkers молчат по построению."
|
|
||||||
|
|
||||||
# Порог 150 ПРЕДВАРИТЕЛЬНЫЙ: реальных данных по глубине очереди нет (до
|
|
||||||
# этой правки метрика не собиралась). beat_schedule.py на момент правки
|
|
||||||
# содержит 44 periodic-задачи с разным временем срабатывания — даже
|
|
||||||
# маловероятный залп всех разом даёт кратно меньше 150. Порог взят с
|
|
||||||
# запасом сознательно и требует пересмотра через неделю наблюдений по
|
|
||||||
# факту `celery_queue_length`.
|
|
||||||
#
|
|
||||||
# `delta(...) >= 0` — очередь не УМЕНЬШАЕТСЯ за 15 минут (тот же приём,
|
|
||||||
# что и "растёт и не разгребается" в тексте задачи): просто высокое
|
|
||||||
# значение без этого условия поймало бы и здоровый кратковременный всплеск.
|
|
||||||
- alert: CeleryQueueGrowing
|
|
||||||
expr: |
|
|
||||||
celery_queue_length{queue_name="celery"} > 150
|
|
||||||
and delta(celery_queue_length{queue_name="celery"}[15m]) >= 0
|
|
||||||
for: 15m
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
annotations:
|
|
||||||
summary: "Очередь Celery растёт и не разгребается"
|
|
||||||
description: "В очереди {{ $value }} задач, за 15 минут меньше не стало. Похоже на залипший воркер или устойчивый рост нагрузки."
|
|
||||||
|
|
||||||
# ── Postgres ────────────────────────────────────────────────────────────────
|
# ── Postgres ────────────────────────────────────────────────────────────────
|
||||||
- name: postgres
|
- name: postgres
|
||||||
|
|
@ -440,20 +172,12 @@ groups:
|
||||||
|
|
||||||
# Раздутие. Не мгновенный сигнал, а тренд — но именно его отсутствие
|
# Раздутие. Не мгновенный сигнал, а тренд — но именно его отсутствие
|
||||||
# позволило 91 день не замечать 198 апдейтов на строку.
|
# позволило 91 день не замечать 198 апдейтов на строку.
|
||||||
#
|
|
||||||
# Та же ловушка `A and B`, что и у ContainerNearMemoryLimit, и здесь она
|
|
||||||
# опаснее: в `$value` попадал `rate(tup_upd[6h])` — АПДЕЙТОВ В СЕКУНДУ, а
|
|
||||||
# текст называл это долей HOT. Боевое сообщение 12.09 — «доля HOT 75.21%»
|
|
||||||
# при пороге срабатывания «доля < 20%»: число само себе противоречило и
|
|
||||||
# выглядело правдоподобно, поэтому никто не заметил (замер 12.09 по той же
|
|
||||||
# таблице listings: rate(tup_upd[6h]) = 0.0411 → сообщение сказало бы
|
|
||||||
# «4.11%», настоящая доля HOT = 0.00%). Гейт по объёму апдейтов
|
|
||||||
# (> 0.5/с — «трафик есть, значит вопрос осмыслен») перенесён внутрь
|
|
||||||
# знаменателя: там он и фильтрует серии, и защищает от деления на ноль.
|
|
||||||
- alert: PostgresLowHotUpdateRatio
|
- alert: PostgresLowHotUpdateRatio
|
||||||
expr: |
|
expr: |
|
||||||
|
rate(pg_table_write_amplification_tup_upd[6h]) > 0.5
|
||||||
|
and
|
||||||
rate(pg_table_write_amplification_tup_hot_upd[6h])
|
rate(pg_table_write_amplification_tup_hot_upd[6h])
|
||||||
/ (rate(pg_table_write_amplification_tup_upd[6h]) > 0.5) < 0.2
|
/ rate(pg_table_write_amplification_tup_upd[6h]) < 0.2
|
||||||
for: 6h
|
for: 6h
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
|
|
@ -461,11 +185,6 @@ groups:
|
||||||
summary: "Обновления идут мимо HOT"
|
summary: "Обновления идут мимо HOT"
|
||||||
description: "{{ $labels.host }} / {{ $labels.table }}: доля HOT {{ $value | humanizePercentage }}. Каждый такой апдейт переписывает строку во все индексы и заново тостит длинные поля — так набегает раздутие."
|
description: "{{ $labels.host }} / {{ $labels.table }}: доля HOT {{ $value | humanizePercentage }}. Каждый такой апдейт переписывает строку во все индексы и заново тостит длинные поля — так набегает раздутие."
|
||||||
|
|
||||||
# Третье правило того же семейства `A and B` — и единственное, где текст
|
|
||||||
# верен: `$value` тут печатается без humanize, а слева стоит ровно то, что
|
|
||||||
# описание и называет («N мёртвых»). Совпадение, а не заслуга формы: если
|
|
||||||
# когда-нибудь захочется печатать здесь ДОЛЮ, отношение придётся вынести
|
|
||||||
# влево, как в двух правилах выше.
|
|
||||||
- alert: PostgresDeadTuplesHigh
|
- alert: PostgresDeadTuplesHigh
|
||||||
expr: |
|
expr: |
|
||||||
pg_table_write_amplification_dead_tup > 1000000
|
pg_table_write_amplification_dead_tup > 1000000
|
||||||
|
|
@ -487,44 +206,3 @@ groups:
|
||||||
annotations:
|
annotations:
|
||||||
summary: "WAL пишется быстрее 100 МБ/час"
|
summary: "WAL пишется быстрее 100 МБ/час"
|
||||||
description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanize1024 }}B/с. Стоит сверить с реальной пользовательской нагрузкой — расхождение означает лишние записи."
|
description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanize1024 }}B/с. Стоит сверить с реальной пользовательской нагрузкой — расхождение означает лишние записи."
|
||||||
|
|
||||||
# ── Эскалация ──────────────────────────────────────────────────────────────
|
|
||||||
# Симптом 17.09: DiskWillFillIn24h пришёл вовремя и утонул между Watchdog
|
|
||||||
# (10080 интервалов firing за 7 суток — горит всегда по построению) и
|
|
||||||
# NoActiveCeleryWorkers (6229 интервалов) в общей ленте; диск дошёл до 84%
|
|
||||||
# незамеченным. Alertmanager сам по длительности не эскалирует — это
|
|
||||||
# правило Prometheus поверх служебной метрики ALERTS_FOR_STATE (unix-время
|
|
||||||
# входа тревоги в pending/firing, см. Robust Perception "The
|
|
||||||
# ALERTS_FOR_STATE metric").
|
|
||||||
- name: escalation
|
|
||||||
interval: 60s
|
|
||||||
rules:
|
|
||||||
# ИСКЛЮЧЕНИЯ В `alertname!~` ОБЯЗАТЕЛЬНЫ, а не для порядка:
|
|
||||||
# - Watchdog горит всегда по построению (`vector(1)` выше) — без
|
|
||||||
# исключения это правило унаследовало бы его вечный firing и стало
|
|
||||||
# ВТОРЫМ таким сигналом, то есть тем самым шумом, который лечим.
|
|
||||||
# - Сама AlertFiringTooLong — иначе, однажды сработав, она бы никогда
|
|
||||||
# не погасла: собственная ALERTS_FOR_STATE тоже старше порога, и
|
|
||||||
# правило продлевало бы себя бесконечно.
|
|
||||||
# Что это НЕ значит: если NoActiveCeleryWorkers (её чинит параллельная
|
|
||||||
# правка, здесь не трогаем) продолжит гореть дольше 6 часов, эта
|
|
||||||
# тревога сработает сразу после мержа — это ожидаемо и верно: она
|
|
||||||
# огонь реального незакрытого инцидента, а не вечная по построению.
|
|
||||||
#
|
|
||||||
# `label_replace(..., "stuck_alertname", "$1", "alertname", "(.+)")`
|
|
||||||
# ОБЯЗАТЕЛЕН, а не косметика: `alertname` — зарезервированный лейбл,
|
|
||||||
# Prometheus молча перезаписывает его именем ЭТОГО правила
|
|
||||||
# (AlertFiringTooLong) на выходе, каким бы ни было значение в expr.
|
|
||||||
# Без копии в `stuck_alertname` текст сообщения называл бы саму себя
|
|
||||||
# виновником, а не исходную тревогу.
|
|
||||||
- alert: AlertFiringTooLong
|
|
||||||
expr: |
|
|
||||||
label_replace(
|
|
||||||
(time() - ALERTS_FOR_STATE{alertname!~"Watchdog|AlertFiringTooLong"}) > 6*3600,
|
|
||||||
"stuck_alertname", "$1", "alertname", "(.+)"
|
|
||||||
)
|
|
||||||
labels:
|
|
||||||
severity: critical
|
|
||||||
annotations:
|
|
||||||
summary: "Тревога держится дольше 6 часов"
|
|
||||||
description: "{{ $labels.stuck_alertname }}{{ if $labels.host }} ({{ $labels.host }}){{ end }} непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
|
||||||
|
|
|
||||||
|
|
@ -1,254 +0,0 @@
|
||||||
# Юнит-тесты правил `promtool test rules` (#3493). Исполняются деплоем метрик
|
|
||||||
# перед reload Prometheus (.forgejo/workflows/deploy-metrics.yml): упавший тест
|
|
||||||
# оставляет работающий Prometheus на прежних правилах.
|
|
||||||
#
|
|
||||||
# Каждый случай — ровно тот, что уже случился на проде, а не придуманный.
|
|
||||||
rule_files:
|
|
||||||
- ../rules/infra.yml
|
|
||||||
|
|
||||||
evaluation_interval: 1m
|
|
||||||
|
|
||||||
tests:
|
|
||||||
# tradein-postgres, ночь 17.09: рабочий набор 98 % от лимита, но почти весь —
|
|
||||||
# кэш страниц; анонимная память ~1 %. OOM не грозит — тревоги быть не должно.
|
|
||||||
- interval: 1m
|
|
||||||
input_series:
|
|
||||||
- series: 'container_memory_working_set_bytes{host="apps",name="tradein-postgres"}'
|
|
||||||
values: '2940x40'
|
|
||||||
- series: 'container_memory_rss{host="apps",name="tradein-postgres"}'
|
|
||||||
values: '40x40'
|
|
||||||
- series: 'container_spec_memory_limit_bytes{host="apps",name="tradein-postgres"}'
|
|
||||||
values: '3000x40'
|
|
||||||
alert_rule_test:
|
|
||||||
- eval_time: 30m
|
|
||||||
alertname: ContainerNearMemoryLimit
|
|
||||||
exp_alerts: []
|
|
||||||
|
|
||||||
# tradein-browser, 12.09: у потолка, и это анонимная память незакрытых
|
|
||||||
# инстансов браузера. Тревога обязана прийти, и в тексте — доля, а не байты.
|
|
||||||
- interval: 1m
|
|
||||||
input_series:
|
|
||||||
- series: 'container_memory_working_set_bytes{host="apps",name="tradein-browser"}'
|
|
||||||
values: '1960x40'
|
|
||||||
- series: 'container_memory_rss{host="apps",name="tradein-browser"}'
|
|
||||||
values: '1900x40'
|
|
||||||
- series: 'container_spec_memory_limit_bytes{host="apps",name="tradein-browser"}'
|
|
||||||
values: '2000x40'
|
|
||||||
alert_rule_test:
|
|
||||||
- eval_time: 30m
|
|
||||||
alertname: ContainerNearMemoryLimit
|
|
||||||
exp_alerts:
|
|
||||||
- exp_labels:
|
|
||||||
severity: warning
|
|
||||||
host: apps
|
|
||||||
name: tradein-browser
|
|
||||||
exp_annotations:
|
|
||||||
summary: "Контейнер у своего потолка памяти"
|
|
||||||
description: "apps / tradein-browser: 98% от mem_limit, и больше половины лимита — анонимная память процессов, которую ядро не вытеснит как кэш. Дальше OOM-kill."
|
|
||||||
|
|
||||||
# 12.09–17.09: экспортёр Celery не поднят, серий нет вовсе, воркер при этом жив.
|
|
||||||
# Должна гореть QueueExporterDown про celery — и НЕ должна NoActiveCeleryWorkers.
|
|
||||||
- interval: 1m
|
|
||||||
input_series:
|
|
||||||
- series: 'up{job="redis",host="apps"}'
|
|
||||||
values: '1x40'
|
|
||||||
alert_rule_test:
|
|
||||||
- eval_time: 30m
|
|
||||||
alertname: NoActiveCeleryWorkers
|
|
||||||
exp_alerts: []
|
|
||||||
- eval_time: 30m
|
|
||||||
alertname: QueueExporterDown
|
|
||||||
exp_alerts:
|
|
||||||
- exp_labels:
|
|
||||||
severity: warning
|
|
||||||
job: celery
|
|
||||||
exp_annotations:
|
|
||||||
summary: "Метрики очереди не собираются"
|
|
||||||
description: "celery-экспортёр на Poincare не отдаёт метрики (up=0 или серии нет вовсе). Это авария наблюдаемости, а не продукта: Redis и воркеры могут быть живы — проверь `docker ps`. Пока она горит, RedisDown и NoActiveCeleryWorkers молчат по построению."
|
|
||||||
|
|
||||||
# Нормальная работа: экспортёр жив, воркер шлёт heartbeat — тишина.
|
|
||||||
- interval: 1m
|
|
||||||
input_series:
|
|
||||||
- series: 'up{job="celery",host="apps"}'
|
|
||||||
values: '1x40'
|
|
||||||
- series: 'up{job="redis",host="apps"}'
|
|
||||||
values: '1x40'
|
|
||||||
- series: 'celery_worker_up{hostname="celery@worker",host="apps"}'
|
|
||||||
values: '1x40'
|
|
||||||
alert_rule_test:
|
|
||||||
- eval_time: 30m
|
|
||||||
alertname: NoActiveCeleryWorkers
|
|
||||||
exp_alerts: []
|
|
||||||
- eval_time: 30m
|
|
||||||
alertname: QueueExporterDown
|
|
||||||
exp_alerts: []
|
|
||||||
|
|
||||||
# Воркер умер: экспортёр жив, серия воркера в 0. Прежнее правило
|
|
||||||
# (`count(x == 1) == 0`) этот случай пропускало — count() от пустого вектора пуст.
|
|
||||||
- interval: 1m
|
|
||||||
input_series:
|
|
||||||
- series: 'up{job="celery",host="apps"}'
|
|
||||||
values: '1x40'
|
|
||||||
- series: 'celery_worker_up{hostname="celery@worker",host="apps"}'
|
|
||||||
values: '0x40'
|
|
||||||
alert_rule_test:
|
|
||||||
- eval_time: 30m
|
|
||||||
alertname: NoActiveCeleryWorkers
|
|
||||||
exp_alerts:
|
|
||||||
- exp_labels:
|
|
||||||
severity: critical
|
|
||||||
exp_annotations:
|
|
||||||
summary: "Ни одного живого воркера Celery"
|
|
||||||
description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
|
|
||||||
|
|
||||||
# Воркер умер давно: экспортёр вычистил его серию, осталось только `up`.
|
|
||||||
- interval: 1m
|
|
||||||
input_series:
|
|
||||||
- series: 'up{job="celery",host="apps"}'
|
|
||||||
values: '1x40'
|
|
||||||
alert_rule_test:
|
|
||||||
- eval_time: 30m
|
|
||||||
alertname: NoActiveCeleryWorkers
|
|
||||||
exp_alerts:
|
|
||||||
- exp_labels:
|
|
||||||
severity: critical
|
|
||||||
exp_annotations:
|
|
||||||
summary: "Ни одного живого воркера Celery"
|
|
||||||
description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
|
|
||||||
|
|
||||||
# #2214: tradein-backend умер, хост и агент живы — агент шлёт up=0.
|
|
||||||
# До MeraBackendDown этот случай не давал ни одного сигнала вне Poincare.
|
|
||||||
- interval: 30s
|
|
||||||
input_series:
|
|
||||||
- series: 'up{job="app",app="mera",instance="tradein-backend:8000",host="apps"}'
|
|
||||||
values: '1x9 0x40'
|
|
||||||
alert_rule_test:
|
|
||||||
- eval_time: 15m
|
|
||||||
alertname: MeraBackendDown
|
|
||||||
exp_alerts:
|
|
||||||
- exp_labels:
|
|
||||||
severity: critical
|
|
||||||
host: apps
|
|
||||||
job: app
|
|
||||||
app: mera
|
|
||||||
instance: tradein-backend:8000
|
|
||||||
exp_annotations:
|
|
||||||
summary: "Бэкенд «Меры» не отвечает"
|
|
||||||
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
|
|
||||||
|
|
||||||
# Окно деплоя: самый длинный провал за 26.08–17.09 — 4 нулевые точки подряд
|
|
||||||
# (~2 минуты). Тревоги быть не должно. Заодно упавший бэкенд «Птицы» не должен
|
|
||||||
# поднимать тревогу про «Меру».
|
|
||||||
- interval: 30s
|
|
||||||
input_series:
|
|
||||||
- series: 'up{job="app",app="mera",instance="tradein-backend:8000",host="apps"}'
|
|
||||||
values: '1x19 0 0 0 0 1x40'
|
|
||||||
- series: 'up{job="app",app="sitefinder",instance="gendesign-backend-1:8000",host="apps"}'
|
|
||||||
values: '0x64'
|
|
||||||
alert_rule_test:
|
|
||||||
- eval_time: 11m
|
|
||||||
alertname: MeraBackendDown
|
|
||||||
exp_alerts: []
|
|
||||||
- eval_time: 30m
|
|
||||||
alertname: MeraBackendDown
|
|
||||||
exp_alerts: []
|
|
||||||
|
|
||||||
# Цель пропала из скрейпа (агент жив, «Птица» снимается), серии «Меры» нет.
|
|
||||||
- interval: 30s
|
|
||||||
input_series:
|
|
||||||
- series: 'up{job="app",app="sitefinder",instance="gendesign-backend-1:8000",host="apps"}'
|
|
||||||
values: '1x40'
|
|
||||||
alert_rule_test:
|
|
||||||
- eval_time: 10m
|
|
||||||
alertname: MeraBackendDown
|
|
||||||
exp_alerts:
|
|
||||||
- exp_labels:
|
|
||||||
severity: critical
|
|
||||||
host: apps
|
|
||||||
job: app
|
|
||||||
app: mera
|
|
||||||
exp_annotations:
|
|
||||||
summary: "Бэкенд «Меры» не отвечает"
|
|
||||||
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
|
|
||||||
|
|
||||||
# Эскалация по длительности. `promtool test rules` держит одну общую шкалу
|
|
||||||
# времени и TSDB на весь файл — к 6.5 часам к этому моменту «зависшими»
|
|
||||||
# (input series предыдущих сценариев кончились, но absent()-условия по ним
|
|
||||||
# продолжают гореть) оказываются и другие тестовые тревоги файла, не только
|
|
||||||
# NoActiveCeleryWorkers из этого блока. Список ниже — ровно то, что
|
|
||||||
# реально вернул promtool (проверено запуском, не придумано): 8 тревог,
|
|
||||||
# держащихся дольше 6 часов. ГЛАВНАЯ ПРОВЕРКА в этом списке — то, чего в
|
|
||||||
# нём НЕТ: ни Watchdog (горит вечно с t=0 точно так же, но исключён
|
|
||||||
# матчером), ни сама AlertFiringTooLong (иначе была бы там на восьмое
|
|
||||||
# место и продлевала бы себя бесконечно). В 3 часа — рано, эскалации
|
|
||||||
# ещё быть не должно вовсе.
|
|
||||||
- interval: 1m
|
|
||||||
input_series:
|
|
||||||
- series: 'up{job="celery",host="apps"}'
|
|
||||||
values: '1x420'
|
|
||||||
alert_rule_test:
|
|
||||||
- eval_time: 3h
|
|
||||||
alertname: AlertFiringTooLong
|
|
||||||
exp_alerts: []
|
|
||||||
- eval_time: 6h30m
|
|
||||||
alertname: AlertFiringTooLong
|
|
||||||
exp_alerts:
|
|
||||||
- exp_labels:
|
|
||||||
severity: critical
|
|
||||||
stuck_alertname: MeraBackendDown
|
|
||||||
host: apps
|
|
||||||
job: app
|
|
||||||
app: mera
|
|
||||||
exp_annotations:
|
|
||||||
summary: "Тревога держится дольше 6 часов"
|
|
||||||
description: "MeraBackendDown (apps) непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
|
||||||
- exp_labels:
|
|
||||||
severity: critical
|
|
||||||
stuck_alertname: HostAgentDown
|
|
||||||
host: apps
|
|
||||||
job: node
|
|
||||||
exp_annotations:
|
|
||||||
summary: "Тревога держится дольше 6 часов"
|
|
||||||
description: "HostAgentDown (apps) непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
|
||||||
- exp_labels:
|
|
||||||
severity: critical
|
|
||||||
stuck_alertname: RemoteWriteStalled
|
|
||||||
host: apps
|
|
||||||
job: node
|
|
||||||
exp_annotations:
|
|
||||||
summary: "Тревога держится дольше 6 часов"
|
|
||||||
description: "RemoteWriteStalled (apps) непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
|
||||||
- exp_labels:
|
|
||||||
severity: critical
|
|
||||||
stuck_alertname: CadvisorDown
|
|
||||||
job: cadvisor
|
|
||||||
exp_annotations:
|
|
||||||
summary: "Тревога держится дольше 6 часов"
|
|
||||||
description: "CadvisorDown непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
|
||||||
- exp_labels:
|
|
||||||
severity: critical
|
|
||||||
stuck_alertname: QueueExporterDown
|
|
||||||
job: redis
|
|
||||||
exp_annotations:
|
|
||||||
summary: "Тревога держится дольше 6 часов"
|
|
||||||
description: "QueueExporterDown непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
|
||||||
- exp_labels:
|
|
||||||
severity: critical
|
|
||||||
stuck_alertname: TradeInBackgroundContainerMissing
|
|
||||||
name: tradein-scraper
|
|
||||||
exp_annotations:
|
|
||||||
summary: "Тревога держится дольше 6 часов"
|
|
||||||
description: "TradeInBackgroundContainerMissing непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
|
||||||
- exp_labels:
|
|
||||||
severity: critical
|
|
||||||
stuck_alertname: TradeInBackgroundContainerMissing
|
|
||||||
name: tradein-tgbot
|
|
||||||
exp_annotations:
|
|
||||||
summary: "Тревога держится дольше 6 часов"
|
|
||||||
description: "TradeInBackgroundContainerMissing непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
|
||||||
- exp_labels:
|
|
||||||
severity: critical
|
|
||||||
stuck_alertname: NoActiveCeleryWorkers
|
|
||||||
exp_annotations:
|
|
||||||
summary: "Тревога держится дольше 6 часов"
|
|
||||||
description: "NoActiveCeleryWorkers непрерывно firing больше 6 часов — похоже, её не заметили или на неё забили."
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
# Пересоздать контейнер, если он читает СТАРЫЙ инод пофайлового bind-маунта
|
|
||||||
# (#3467, #3486).
|
|
||||||
#
|
|
||||||
# sh ops/metrics/recreate-stale-mount.sh ПРОЕКТ COMPOSE-ФАЙЛ СЕРВИС ФАЙЛ-НА-ХОСТЕ ПУТЬ-В-КОНТЕЙНЕРЕ
|
|
||||||
#
|
|
||||||
# ЗАЧЕМ. `git reset --hard` пишет изменившийся файл НОВЫМ инодом, а bind-маунт
|
|
||||||
# одного файла держит прежний: контейнер продолжает читать старый текст, `up -d`
|
|
||||||
# этого не видит (сравнивает описание сервиса, а не содержимое маунта), и
|
|
||||||
# перезагрузка по API тоже — процесс перечитывает путь, за которым в контейнере
|
|
||||||
# всё тот же старый инод. Проверено на prom/prometheus:v3.1.0 (17.09): файл
|
|
||||||
# подменён, POST /-/reload → rc=0, а в /api/v1/status/config остался прежний
|
|
||||||
# job. Лечит только пересоздание контейнера — его и делаем, но лишь при
|
|
||||||
# расхождении, чтобы не рвать работу на каждом деплое.
|
|
||||||
#
|
|
||||||
# Та же ловушка уже ловилась на Alertmanager (27.08), Alloy (#3380) и Caddy
|
|
||||||
# (#3443, ops/caddy-apply.sh). Контейнер не запущен — старого инода держать
|
|
||||||
# некому (при старте маунт берётся с хоста заново), выходим с 0. Сервис
|
|
||||||
# выключенного профиля сюда передавать нельзя: `up` с явным именем включает
|
|
||||||
# профиль и поднял бы сервис без окружения — гейт по профилю у вызывающего.
|
|
||||||
# Сверка не прочиталась — считаем расхождением. После пересоздания сверяем
|
|
||||||
# снова: не сошлось — exit 1, а не зелёный деплой со старым конфигом.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
if [ "$#" -ne 5 ]; then
|
|
||||||
echo "usage: $0 PROJECT COMPOSE_FILE SERVICE HOST_FILE CONTAINER_FILE" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
project=$1 compose_file=$2 service=$3 src=$4 dst=$5
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/../.."
|
|
||||||
|
|
||||||
# Ошибку `ps` НЕ глушим: пустой ответ от упавшего compose читался бы как
|
|
||||||
# «контейнер не запущен» — и зелёный выход со старым конфигом.
|
|
||||||
cid() { docker compose -p "$project" -f "$compose_file" ps -q "$service"; }
|
|
||||||
seen() { docker exec "$1" stat -c %i "$dst" 2>/dev/null || true; }
|
|
||||||
|
|
||||||
id=$(cid)
|
|
||||||
if [ -z "$id" ]; then
|
|
||||||
echo "$service: контейнер не запущен — старый инод $src держать некому."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
want=$(stat -c %i "$src")
|
|
||||||
if [ "$(seen "$id")" = "$want" ]; then
|
|
||||||
echo "$service: $src доехал до контейнера (инод $want)."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$service: контейнер не видит текущий $src (инод на хосте $want) — пересоздаю."
|
|
||||||
docker compose -p "$project" -f "$compose_file" up -d --no-deps --force-recreate "$service"
|
|
||||||
|
|
||||||
i=0
|
|
||||||
while [ "$i" -lt 15 ]; do
|
|
||||||
id=$(cid)
|
|
||||||
if [ -n "$id" ] && [ "$(seen "$id")" = "$want" ]; then
|
|
||||||
echo "$service: пересоздан, $src доехал (инод $want)."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
i=$((i + 1))
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "::error::$service: после пересоздания контейнер всё ещё не видит $src (инод $want)"
|
|
||||||
exit 1
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Ретранслятор Bot API продукта через инфраструктурный хост Beget (#3471).
|
|
||||||
|
|
||||||
ЗАЧЕМ. Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` из контейнера
|
|
||||||
`tradein-tgbot` на Selectel — 9 успешных из 12, три `ConnectTimeout`. TCP на 443
|
|
||||||
до адреса, резолвящегося на Selectel (149.154.167.220) — 5 из 6. Тот же TCP до
|
|
||||||
адреса, резолвящегося на Beget (149.154.166.110) — 8 из 8. За сутки в логе бота
|
|
||||||
508 строк `network error`, за 30 дней 92 обрыва итерации poll loop. Путь до
|
|
||||||
Telegram с Selectel лоссовый, с Beget чистый: Alertmanager (живёт на Beget)
|
|
||||||
пишет в тот же чат без проблем, а бот поддержки на Selectel часть отправок
|
|
||||||
теряет. Решение — не чинить сеть Selectel (вне контроля), а дать продуктовым
|
|
||||||
сервисам обходной путь через хост, с которого Telegram отвечает надёжно.
|
|
||||||
|
|
||||||
ЧТО ПРОКСИРУЕТСЯ. Метод Bot API целиком — путь `/bot<TOKEN>/<method>`, тело,
|
|
||||||
query. Не только отправка: `getUpdates` (long-poll) страдает от потерь ровно
|
|
||||||
так же, как `sendMessage`, и это тот же HTTP-путь через тот же испорченный
|
|
||||||
транзит.
|
|
||||||
|
|
||||||
ТОКЕН НЕ ЛОГИРУЕТСЯ. Он приходит в пути запроса. `log_request` переопределён
|
|
||||||
ПОЛНОСТЬЮ (не вызывает `super()`): дефолт stdlib кладёт в лог `requestline`
|
|
||||||
целиком, включая токен. Здесь путь редактируется до записи в лог.
|
|
||||||
|
|
||||||
АУТЕНТИФИКАЦИЯ. Общий секрет в заголовке `X-Relay-Secret`, по образцу общего
|
|
||||||
секрета `X-Internal-Auth-Secret` в этом же стеке (`app/core/config.py`,
|
|
||||||
`tradein_internal_auth_secret`) — сравнение строкой (не сравнение таймингов:
|
|
||||||
секрет не является паролем пользователя, ценность атаки по времени здесь
|
|
||||||
исчезающе мала при секрете длиной от 32 байт, а stdlib `hmac` — лишняя
|
|
||||||
зависимость ради stdlib-only сервиса). Домен публичный, без секрета — отказ,
|
|
||||||
а не тихий приём.
|
|
||||||
|
|
||||||
БЕЗ ЗАВИСИМОСТЕЙ. Только стандартная библиотека — тот же принцип, что у
|
|
||||||
`ops/metrics/alert-ack/app.py`: сервис обязан подниматься, даже когда всё
|
|
||||||
остальное сломано, и не тащить установку пакетов.
|
|
||||||
|
|
||||||
ОТКАЗ РЕТРАНСЛЯТОРА НЕ ДОЛЖЕН РОНЯТЬ БОТА. Это реализовано НЕ здесь, а на
|
|
||||||
стороне клиента (`tradein-mvp/backend/app/services/tgbot/client.py`): при
|
|
||||||
транспортном отказе похода на ретранслятор клиент делает одну попытку
|
|
||||||
напрямую к `api.telegram.org`. Здесь достаточно не быть точкой отказа хуже
|
|
||||||
прямого пути: таймаут до апстрима подобран так, чтобы не обрубать long-poll
|
|
||||||
`getUpdates` раньше, чем это сделал бы сам Telegram.
|
|
||||||
|
|
||||||
Переменные окружения:
|
|
||||||
TG_RELAY_SECRET обязательна — общий секрет, сверяется с
|
|
||||||
заголовком X-Relay-Secret
|
|
||||||
TG_RELAY_PORT порт (по умолчанию 8080)
|
|
||||||
TG_RELAY_UPSTREAM_TIMEOUT_S таймаут запроса к api.telegram.org в секундах
|
|
||||||
(по умолчанию 75 — с запасом над самым долгим
|
|
||||||
long-poll getUpdates, который шлёт клиент:
|
|
||||||
timeout=30 + 10с запаса на стороне httpx = 40с)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
||||||
log = logging.getLogger("tg-relay")
|
|
||||||
|
|
||||||
RELAY_SECRET = os.environ.get("TG_RELAY_SECRET", "")
|
|
||||||
UPSTREAM = "https://api.telegram.org"
|
|
||||||
UPSTREAM_TIMEOUT_S = float(os.environ.get("TG_RELAY_UPSTREAM_TIMEOUT_S", "75"))
|
|
||||||
SECRET_HEADER = "X-Relay-Secret"
|
|
||||||
|
|
||||||
|
|
||||||
def redact_path(path: str) -> str:
|
|
||||||
"""Прячет токен из `/bot<TOKEN>/method[?query]` для логов и ошибок.
|
|
||||||
|
|
||||||
Вынесена в модульную функцию (не метод), чтобы быть проверяемой напрямую
|
|
||||||
без поднятия HTTP-сервера.
|
|
||||||
"""
|
|
||||||
if not path.startswith("/bot"):
|
|
||||||
return path
|
|
||||||
rest = path[len("/bot") :]
|
|
||||||
_token_part, sep, tail = rest.partition("/")
|
|
||||||
if not sep:
|
|
||||||
# Ни `/method`, ни query — токен без хвоста (или токен+query без slash).
|
|
||||||
return "/bot<REDACTED>"
|
|
||||||
tail = tail.split("?", 1)[0]
|
|
||||||
return f"/bot<REDACTED>/{tail}" if tail else "/bot<REDACTED>"
|
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
protocol_version = "HTTP/1.1"
|
|
||||||
|
|
||||||
def log_message(self, fmt: str, *args) -> None: # noqa: A003 — сигнатура из stdlib
|
|
||||||
log.info("%s", fmt % args)
|
|
||||||
|
|
||||||
def log_request(self, code="-", size="-") -> None: # noqa: A003 — сигнатура из stdlib
|
|
||||||
# ПОЛНОСТЬЮ заменяет реализацию BaseHTTPRequestHandler (не вызывает
|
|
||||||
# super()): та кладёт в лог self.requestline целиком, а он для Bot API
|
|
||||||
# содержит токен в пути.
|
|
||||||
log.info('%s "%s %s" %s', self.address_string(), self.command, redact_path(self.path), code)
|
|
||||||
|
|
||||||
def _reply(self, code: int, body: bytes, ctype: str = "application/json") -> None:
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", ctype)
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.end_headers()
|
|
||||||
if body:
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def _authorized(self) -> bool:
|
|
||||||
return bool(RELAY_SECRET) and self.headers.get(SECRET_HEADER) == RELAY_SECRET
|
|
||||||
|
|
||||||
def _proxy(self) -> None:
|
|
||||||
if self.path == "/healthz":
|
|
||||||
self._reply(200, b"ok", "text/plain; charset=utf-8")
|
|
||||||
return
|
|
||||||
if not self._authorized():
|
|
||||||
self._reply(401, b'{"ok":false,"description":"unauthorized"}')
|
|
||||||
return
|
|
||||||
if not self.path.startswith("/bot"):
|
|
||||||
self._reply(404, b'{"ok":false,"description":"not found"}')
|
|
||||||
return
|
|
||||||
|
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
|
||||||
body = self.rfile.read(length) if length else None
|
|
||||||
req = urllib.request.Request(
|
|
||||||
UPSTREAM + self.path,
|
|
||||||
data=body,
|
|
||||||
method=self.command,
|
|
||||||
headers={"Content-Type": self.headers.get("Content-Type") or "application/json"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
# Таймаут ЯВНО шире любого long-poll getUpdates клиента — иначе
|
|
||||||
# ретранслятор обрубит соединение раньше площадки и превратит
|
|
||||||
# штатный long-poll в вечный network error, то есть станет хуже
|
|
||||||
# прямого пути, а не лучше.
|
|
||||||
with urllib.request.urlopen(req, timeout=UPSTREAM_TIMEOUT_S) as resp:
|
|
||||||
out = resp.read()
|
|
||||||
self._reply(resp.status, out, resp.headers.get("Content-Type") or "application/json")
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
# Telegram ответил ошибкой (4xx/5xx) — это НЕ отказ ретранслятора,
|
|
||||||
# передаём как есть, клиент сам решает, ретраить или нет.
|
|
||||||
out = exc.read()
|
|
||||||
ctype = exc.headers.get("Content-Type") if exc.headers else None
|
|
||||||
self._reply(exc.code, out, ctype or "application/json")
|
|
||||||
except Exception as exc: # noqa: BLE001 — любой отказ апстрима не должен уронить сервис
|
|
||||||
log.warning("upstream недоступен (%s): %s", redact_path(self.path), type(exc).__name__)
|
|
||||||
self._reply(502, b'{"ok":false,"description":"relay upstream unreachable"}')
|
|
||||||
|
|
||||||
def do_GET(self) -> None: # noqa: N802 — имя из stdlib
|
|
||||||
self._proxy()
|
|
||||||
|
|
||||||
def do_POST(self) -> None: # noqa: N802 — имя из stdlib
|
|
||||||
self._proxy()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
if not RELAY_SECRET:
|
|
||||||
raise SystemExit("не задан TG_RELAY_SECRET — домен публичный, отказ на старте")
|
|
||||||
port = int(os.environ.get("TG_RELAY_PORT", "8080"))
|
|
||||||
log.info("tg-relay слушает :%d, upstream_timeout=%.0fs", port, UPSTREAM_TIMEOUT_S)
|
|
||||||
ThreadingHTTPServer(("", port), Handler).serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
"""Тесты для tg-relay — ретранслятора Bot API продукта через Beget (#3471).
|
|
||||||
|
|
||||||
Гоняют реальный `ThreadingHTTPServer` на localhost (эфемерный порт), апстрим
|
|
||||||
`urllib.request.urlopen` подменяется моком — реальный api.telegram.org НЕ
|
|
||||||
дёргаем никогда.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import http.client
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import threading
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import app as relay
|
|
||||||
|
|
||||||
|
|
||||||
def _fake_upstream_response(status: int = 200, body: bytes = b'{"ok": true, "result": []}'):
|
|
||||||
resp = mock.MagicMock()
|
|
||||||
resp.status = status
|
|
||||||
resp.read.return_value = body
|
|
||||||
resp.headers.get.return_value = "application/json"
|
|
||||||
resp.__enter__.return_value = resp
|
|
||||||
resp.__exit__.return_value = False
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def secret(monkeypatch):
|
|
||||||
monkeypatch.setattr(relay, "RELAY_SECRET", "test-secret-value")
|
|
||||||
return "test-secret-value"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def server(secret):
|
|
||||||
httpd = relay.ThreadingHTTPServer(("127.0.0.1", 0), relay.Handler)
|
|
||||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
try:
|
|
||||||
yield httpd
|
|
||||||
finally:
|
|
||||||
httpd.shutdown()
|
|
||||||
thread.join(timeout=5)
|
|
||||||
|
|
||||||
|
|
||||||
def _request(server, path, headers=None, method="GET", body=None):
|
|
||||||
conn = http.client.HTTPConnection(*server.server_address, timeout=5)
|
|
||||||
try:
|
|
||||||
conn.request(method, path, body=body, headers=headers or {})
|
|
||||||
resp = conn.getresponse()
|
|
||||||
return resp.status, resp.read()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_redact_path_hides_token_from_method_path():
|
|
||||||
assert relay.redact_path("/bot123456:ABC-DEF/sendMessage") == "/bot<REDACTED>/sendMessage"
|
|
||||||
|
|
||||||
|
|
||||||
def test_redact_path_hides_bare_token():
|
|
||||||
assert relay.redact_path("/bot123456:ABC-DEF/") == "/bot<REDACTED>"
|
|
||||||
assert relay.redact_path("/bot123456:ABC-DEF") == "/bot<REDACTED>"
|
|
||||||
|
|
||||||
|
|
||||||
def test_redact_path_leaves_non_bot_paths_untouched():
|
|
||||||
assert relay.redact_path("/healthz") == "/healthz"
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_secret_rejected_without_touching_upstream(server):
|
|
||||||
with mock.patch.object(relay.urllib.request, "urlopen") as mocked:
|
|
||||||
status, _body = _request(server, "/bot123:TOK/getMe")
|
|
||||||
assert status == 401
|
|
||||||
mocked.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_wrong_secret_rejected_without_touching_upstream(server):
|
|
||||||
with mock.patch.object(relay.urllib.request, "urlopen") as mocked:
|
|
||||||
status, _body = _request(server, "/bot123:TOK/getMe", headers={"X-Relay-Secret": "wrong"})
|
|
||||||
assert status == 401
|
|
||||||
mocked.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_valid_secret_passes_method_path_and_body_unmodified(server, secret):
|
|
||||||
with mock.patch.object(
|
|
||||||
relay.urllib.request, "urlopen", return_value=_fake_upstream_response()
|
|
||||||
) as mocked:
|
|
||||||
status, body = _request(
|
|
||||||
server,
|
|
||||||
"/bot123:TOK/sendMessage",
|
|
||||||
headers={"X-Relay-Secret": secret, "Content-Type": "application/json"},
|
|
||||||
method="POST",
|
|
||||||
body=b'{"chat_id": 1, "text": "hi"}',
|
|
||||||
)
|
|
||||||
|
|
||||||
assert status == 200
|
|
||||||
assert json.loads(body) == {"ok": True, "result": []}
|
|
||||||
|
|
||||||
sent_request = mocked.call_args[0][0]
|
|
||||||
assert sent_request.full_url == "https://api.telegram.org/bot123:TOK/sendMessage"
|
|
||||||
assert sent_request.data == b'{"chat_id": 1, "text": "hi"}'
|
|
||||||
assert sent_request.get_method() == "POST"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_updates_uses_upstream_timeout_wider_than_longest_client_poll(server, secret):
|
|
||||||
"""Клиент шлёт getUpdates(timeout=30) → httpx ждёт ответ 40с (30 + запас
|
|
||||||
10с). Апстрим-таймаут ретранслятора обязан быть шире, иначе он обрубит
|
|
||||||
long-poll раньше площадки и превратит штатный цикл в вечный network error."""
|
|
||||||
with mock.patch.object(
|
|
||||||
relay.urllib.request, "urlopen", return_value=_fake_upstream_response()
|
|
||||||
) as mocked:
|
|
||||||
_request(
|
|
||||||
server,
|
|
||||||
"/bot123:TOK/getUpdates",
|
|
||||||
headers={"X-Relay-Secret": secret},
|
|
||||||
method="POST",
|
|
||||||
body=b'{"offset": 1, "timeout": 30}',
|
|
||||||
)
|
|
||||||
|
|
||||||
_req, kwargs = mocked.call_args
|
|
||||||
assert kwargs["timeout"] >= 40
|
|
||||||
|
|
||||||
|
|
||||||
def test_token_never_appears_in_logs(server, secret, caplog):
|
|
||||||
token = "999888777:VerySecretTokenValue"
|
|
||||||
with caplog.at_level(logging.INFO, logger="tg-relay"):
|
|
||||||
with mock.patch.object(
|
|
||||||
relay.urllib.request, "urlopen", return_value=_fake_upstream_response()
|
|
||||||
):
|
|
||||||
_request(server, f"/bot{token}/getMe", headers={"X-Relay-Secret": secret})
|
|
||||||
|
|
||||||
for record in caplog.records:
|
|
||||||
assert token not in record.getMessage()
|
|
||||||
|
|
||||||
|
|
||||||
def test_unauthorized_attempt_does_not_leak_token_either(server, caplog):
|
|
||||||
token = "999888777:VerySecretTokenValue"
|
|
||||||
with caplog.at_level(logging.INFO, logger="tg-relay"):
|
|
||||||
_request(server, f"/bot{token}/getMe")
|
|
||||||
|
|
||||||
for record in caplog.records:
|
|
||||||
assert token not in record.getMessage()
|
|
||||||
|
|
@ -216,7 +216,7 @@ log "11. Закрепление рабочего адреса api.telegram.org"
|
||||||
#
|
#
|
||||||
# Контейнерам это даёт tradein-mvp/docker-compose.selectel.yml (extra_hosts для
|
# Контейнерам это даёт tradein-mvp/docker-compose.selectel.yml (extra_hosts для
|
||||||
# tgbot и backend). Здесь — для ХОСТОВЫХ скриптов, которым compose не помогает:
|
# tgbot и backend). Здесь — для ХОСТОВЫХ скриптов, которым compose не помогает:
|
||||||
# ops/lib-backup.sh (уведомления о бэкапах).
|
# ops/lib-backup.sh (уведомления о бэкапах) и ops/uptime-healthcheck.sh.
|
||||||
# Без этого они молча перестают слать алерты — а это ровно тот канал, которым
|
# Без этого они молча перестают слать алерты — а это ровно тот канал, которым
|
||||||
# мы узнали бы о любой другой поломке.
|
# мы узнали бы о любой другой поломке.
|
||||||
TELEGRAM_API_IP="${TELEGRAM_API_IP:-149.154.167.220}"
|
TELEGRAM_API_IP="${TELEGRAM_API_IP:-149.154.167.220}"
|
||||||
|
|
|
||||||
171
ops/uptime-healthcheck.sh
Executable file
171
ops/uptime-healthcheck.sh
Executable file
|
|
@ -0,0 +1,171 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# External uptime watchdog for gendesign (#75 B6-1, lightweight fallback).
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS alongside Uptime Kuma (docker-compose.uptime.yml): Kuma runs
|
||||||
|
# ON the prod VM, so if the whole VM dies it can't alert. This script is meant to
|
||||||
|
# run from cron on a DIFFERENT host (your laptop, a tiny free-tier box, Beget
|
||||||
|
# shared-host cron) and hit the PUBLIC URLs over the internet — last-resort
|
||||||
|
# "весь хост лёг" detection. Kuma covers rich per-endpoint/SSL/latency monitoring;
|
||||||
|
# this covers the case Kuma structurally can't.
|
||||||
|
#
|
||||||
|
# Self-contained: only needs `curl` + `bash`. No docker, no repo checkout.
|
||||||
|
# State (last-known status per check) lives in a file so we alert on TRANSITIONS
|
||||||
|
# (up→down, down→up) — not every run — to avoid Telegram spam.
|
||||||
|
#
|
||||||
|
# Usage (cron — note `bash`, not a bare path, so a missing +x bit can't break it):
|
||||||
|
# * * * * * bash /path/to/uptime-healthcheck.sh >> /var/log/gendesign-uptime.log 2>&1
|
||||||
|
#
|
||||||
|
# Telegram alerting — set these in an env file (NOT in git, chmod 600):
|
||||||
|
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||||
|
# TELEGRAM_CHAT_ID=123456789
|
||||||
|
# Default env path: /etc/default/gendesign-uptime (override via UPTIME_ENV_FILE).
|
||||||
|
# A redacted template lives at ops/gendesign-uptime.default.example.
|
||||||
|
# Without a token set, the script still logs up/down but sends no alert.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- config (env-overridable) ---
|
||||||
|
UPTIME_ENV_FILE="${UPTIME_ENV_FILE:-/etc/default/gendesign-uptime}"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
[[ -f "$UPTIME_ENV_FILE" ]] && source "$UPTIME_ENV_FILE"
|
||||||
|
|
||||||
|
BASE_URL="${BASE_URL:-https://gendsgn.ru}"
|
||||||
|
STATE_FILE="${STATE_FILE:-/var/tmp/gendesign-uptime-state}"
|
||||||
|
CURL_TIMEOUT="${CURL_TIMEOUT:-15}" # seconds per request (connect+read)
|
||||||
|
RETRIES="${RETRIES:-2}" # extra attempts before declaring DOWN
|
||||||
|
RETRY_SLEEP="${RETRY_SLEEP:-5}" # seconds between attempts
|
||||||
|
TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
|
||||||
|
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}"
|
||||||
|
|
||||||
|
# Checks to probe. Format per line: "<label>|<path>|<expected_http_status>".
|
||||||
|
# Override the whole list via CHECKS env (same newline-separated format).
|
||||||
|
#
|
||||||
|
# DEFAULT = /health ONLY. WHY: this watchdog is UNAUTHENTICATED, and in the
|
||||||
|
# Caddyfile only /health (and /preview/*) are public — ALL /api/* sits behind
|
||||||
|
# the Basic-Auth gate (import caddy/users.caddy.snippet) and returns 401 to an
|
||||||
|
# anonymous client. So the issue's /api/v1/analytics/market-pulse and
|
||||||
|
# /parcels/{cad}/analyze monitors CANNOT be probed anonymously — they belong in
|
||||||
|
# Uptime Kuma, which can attach the Basic-Auth header (see README "Kuma monitors"
|
||||||
|
# in docker-compose.uptime.yml). Probing them here would false-alarm forever.
|
||||||
|
CHECKS="${CHECKS:-health|/health|200}"
|
||||||
|
|
||||||
|
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
|
||||||
|
|
||||||
|
# --- telegram (no-op if token/chat unset) ---
|
||||||
|
notify() {
|
||||||
|
local text="$1"
|
||||||
|
if [[ -z "$TELEGRAM_BOT_TOKEN" || -z "$TELEGRAM_CHAT_ID" ]]; then
|
||||||
|
log "NOTIFY (telegram disabled — no token/chat): $text"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# #3059: путь до Telegram теряет соединения. Замер 26.08 с Poincare — 40
|
||||||
|
# подключений к ЗАКРЕПЛЁННОМУ (#3093) 149.154.167.220: 3 отказа (7.5%), все
|
||||||
|
# таймаутом на установке соединения; успешные при этом стабильны (0.14-0.17 с).
|
||||||
|
# Три остальных дата-центра Telegram с Selectel недостижимы вовсе, так что
|
||||||
|
# запасного адреса нет — потери на единственном рабочем неустранимы сетью.
|
||||||
|
#
|
||||||
|
# Раньше здесь был ОДИН curl, и `|| log WARN` означал, что каждый такой отказ
|
||||||
|
# ТЕРЯЕТ алерт целиком: уведомление о падении прода не приходит, остаётся
|
||||||
|
# строка в логе, который читают уже после аварии. Watchdog, который сам себя
|
||||||
|
# не может дозваться, — худший вид самоскрывающейся поломки: чем хуже дела,
|
||||||
|
# тем вероятнее, что о них не сообщат.
|
||||||
|
#
|
||||||
|
# Цикл, а не `curl --retry`: ниже в этом же файле проверки уже повторяются
|
||||||
|
# ровно такой конструкцией (см. `for attempt in $(seq 1 ...)`), и семантика
|
||||||
|
# `--max-time` при ретраях curl зависит от версии. Здесь таймаут заведомо
|
||||||
|
# применяется к КАЖДОЙ попытке.
|
||||||
|
#
|
||||||
|
# Дубль вместо потери — осознанный размен: sendMessage не идемпотентен, но
|
||||||
|
# замер показал, что отказы происходят на СТАДИИ ПОДКЛЮЧЕНИЯ, до отправки
|
||||||
|
# запроса, так что повтор почти никогда не дублирует уже доставленное
|
||||||
|
# сообщение. А продублированный алерт безвреден, пропущенный — нет.
|
||||||
|
local attempt
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if curl -fsS --max-time "$CURL_TIMEOUT" \
|
||||||
|
-X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||||
|
-d "chat_id=${TELEGRAM_CHAT_ID}" \
|
||||||
|
${TELEGRAM_TOPIC_ID:+-d "message_thread_id=${TELEGRAM_TOPIC_ID}"} \
|
||||||
|
-d "disable_web_page_preview=true" \
|
||||||
|
--data-urlencode "text=${text}" \
|
||||||
|
>/dev/null 2>&1; then
|
||||||
|
[[ "$attempt" -gt 1 ]] && log "telegram sendMessage: доставлено с попытки ${attempt}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
[[ "$attempt" -lt 3 ]] && sleep "${NOTIFY_RETRY_DELAY:-2}"
|
||||||
|
done
|
||||||
|
log "WARN: telegram sendMessage failed — 3 попытки подряд, алерт НЕ ДОСТАВЛЕН"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- state helpers (last status per check) ---
|
||||||
|
prev_status() {
|
||||||
|
local label="$1"
|
||||||
|
[[ -f "$STATE_FILE" ]] || { echo "unknown"; return; }
|
||||||
|
# Line format: "<label> <status>". grep the latest for this label.
|
||||||
|
local v
|
||||||
|
v="$(grep -E "^${label} " "$STATE_FILE" 2>/dev/null | tail -1 | awk '{print $2}')"
|
||||||
|
echo "${v:-unknown}"
|
||||||
|
}
|
||||||
|
|
||||||
|
set_status() {
|
||||||
|
local label="$1" status="$2" tmp
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
# Drop any prior line for this label, then append the fresh one.
|
||||||
|
if [[ -f "$STATE_FILE" ]]; then
|
||||||
|
grep -vE "^${label} " "$STATE_FILE" > "$tmp" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo "${label} ${status}" >> "$tmp"
|
||||||
|
mv "$tmp" "$STATE_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- probe one URL with retries; echoes "up" or "down code=NNN" ---
|
||||||
|
probe() {
|
||||||
|
local url="$1" expect="$2" attempt code
|
||||||
|
# NB: NO `-f` here. `-w %{http_code}` always prints exactly a 3-digit status
|
||||||
|
# (or 000 on connect/timeout failure), so we judge by the code ourselves and
|
||||||
|
# never need a `|| echo` fallback (which previously concatenated → "401000").
|
||||||
|
for attempt in $(seq 1 "$((RETRIES + 1))"); do
|
||||||
|
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time "$CURL_TIMEOUT" "$url" 2>/dev/null)"
|
||||||
|
code="${code:-000}"
|
||||||
|
if [[ "$code" == "$expect" ]]; then
|
||||||
|
echo "up"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
[[ "$attempt" -le "$RETRIES" ]] && sleep "$RETRY_SLEEP"
|
||||||
|
done
|
||||||
|
# Report the last code seen for the alert body.
|
||||||
|
echo "down code=${code}"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- run ---
|
||||||
|
overall_rc=0
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
label="${line%%|*}"
|
||||||
|
rest="${line#*|}"
|
||||||
|
path="${rest%%|*}"
|
||||||
|
expect="${rest##*|}"
|
||||||
|
url="${BASE_URL}${path}"
|
||||||
|
|
||||||
|
result="$(probe "$url" "$expect")"
|
||||||
|
now="up"
|
||||||
|
[[ "$result" == up ]] || now="down"
|
||||||
|
prev="$(prev_status "$label")"
|
||||||
|
|
||||||
|
if [[ "$now" == "up" ]]; then
|
||||||
|
log "OK $label ($url)"
|
||||||
|
if [[ "$prev" == "down" ]]; then
|
||||||
|
notify "✅ RECOVERED: gendesign $label is back UP ($url)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
overall_rc=1
|
||||||
|
log "DOWN $label ($url) — $result"
|
||||||
|
# Alert on transition into down (or first-ever observation that is down).
|
||||||
|
if [[ "$prev" != "down" ]]; then
|
||||||
|
notify "🔴 DOWN: gendesign $label — ${url} (${result#down }). $(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
set_status "$label" "$now"
|
||||||
|
done <<< "$CHECKS"
|
||||||
|
|
||||||
|
exit "$overall_rc"
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
#!/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())
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
# Проба окна недоступности при деплое (#3274).
|
|
||||||
#
|
|
||||||
# ЗАЧЕМ. Статус джобы деплоя зелёный независимо от того, видел ли посетитель
|
|
||||||
# ошибку: подмена контейнера происходит ВНУТРИ успешного прогона. Единственный
|
|
||||||
# честный замер — непрерывный опрос публичного адреса во время деплоя и подсчёт
|
|
||||||
# САМОЙ ДЛИННОЙ СЕРИИ подряд идущих не-200. Приёмка #3274 сформулирована именно
|
|
||||||
# так (комментарий от 05.09): «серия не-2xx/000 на / в окне мержа < 2 с».
|
|
||||||
#
|
|
||||||
# ГДЕ ЗАПУСКАТЬ — НА САМОМ ХОСТЕ (ssh poincare), не с ноутбука. Боевой периметр
|
|
||||||
# отбивает частые серии с одного внешнего адреса: замер 11.09 дал 30–50 %
|
|
||||||
# `000` с внешнего IP при интервале 0 и ноль при 2 с, а с самого хоста — 0/10
|
|
||||||
# без пауз. Иначе защита периметра читается как простой сервиса.
|
|
||||||
#
|
|
||||||
# ПОЧЕМУ 000 СЧИТАЕТСЯ ОТДЕЛЬНО. curl отдаёт `000`, когда HTTP-ответа не было
|
|
||||||
# вообще (TCP/TLS не встал, таймаут). Это может быть и простой (Caddy
|
|
||||||
# пересоздан), и отбой периметра — сваливать его в одну кучу с 502/503 нельзя,
|
|
||||||
# иначе своя же защита попадёт в числа простоя.
|
|
||||||
#
|
|
||||||
# Запуск:
|
|
||||||
# scripts/probe-deploy-window.sh [URL] [ИНТЕРВАЛ_С] [ДЛИТЕЛЬНОСТЬ_С] [ФАЙЛ]
|
|
||||||
# scripts/probe-deploy-window.sh --summary <ФАЙЛ> # пересчитать сводку
|
|
||||||
# scripts/probe-deploy-window.sh --selftest # проверка счётчика серий
|
|
||||||
#
|
|
||||||
# Пример (окно деплоя, 10 минут с шагом 200 мс):
|
|
||||||
# ssh poincare 'nohup /opt/gendesign/scripts/probe-deploy-window.sh \
|
|
||||||
# https://meraocenka.ru/ 0.2 600 /tmp/probe-mera.tsv >/dev/null 2>&1 &'
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
# Сводка по TSV (epoch<TAB>ISO-время<TAB>код<TAB>секунды): сколько чего, и главное — самая
|
|
||||||
# длинная серия подряд идущих не-200 в СЕКУНДАХ (по меткам времени самих
|
|
||||||
# образцов, а не «число образцов × интервал»: curl с таймаутом растягивает шаг,
|
|
||||||
# и умножение занизило бы реальную длину окна).
|
|
||||||
summarize() {
|
|
||||||
awk -F'\t' '
|
|
||||||
# Серия закрывается первым 200. Длина в секундах считается ТОЛЬКО в END:
|
|
||||||
# средний шаг известен лишь после прохода, а внутри цикла он ещё 0 — из-за
|
|
||||||
# этого первая версия занижала окно ровно на один шаг (поймал --selftest).
|
|
||||||
{ total++; code[$3]++
|
|
||||||
if ($3 == "200") {
|
|
||||||
if (streak > max_n) { max_n = streak; max_first = first_bad_ts; max_last = last_bad_ts; max_at = first_bad_at }
|
|
||||||
streak = 0
|
|
||||||
} else {
|
|
||||||
if (!streak) { first_bad_ts = $1; first_bad_at = $2 }
|
|
||||||
streak++; last_bad_ts = $1
|
|
||||||
}
|
|
||||||
if (prev_ts && $1 - prev_ts < 60) { gaps += $1 - prev_ts; gapn++ }
|
|
||||||
prev_ts = $1
|
|
||||||
}
|
|
||||||
END {
|
|
||||||
step = (gapn ? gaps / gapn : 0)
|
|
||||||
if (streak > max_n) { max_n = streak; max_first = first_bad_ts; max_last = last_bad_ts; max_at = first_bad_at }
|
|
||||||
printf "образцов: %d, шаг ~%.2f с\n", total, step
|
|
||||||
for (c in code) printf " %s: %d\n", c, code[c]
|
|
||||||
printf "максимальная серия не-200: %d образцов ≈ %.1f с (начало %s)\n", \
|
|
||||||
max_n, (max_n ? max_last - max_first + step : 0), (max_at ? max_at : "-")
|
|
||||||
}
|
|
||||||
' "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ "${1:-}" = "--summary" ]; then
|
|
||||||
summarize "$2"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${1:-}" = "--selftest" ]; then
|
|
||||||
tmp=$(mktemp)
|
|
||||||
# 10 образцов с шагом 1 с: три подряд не-200 (2-я…4-я секунды) → серия ≈ 3 с.
|
|
||||||
printf '1000\tT0\t200\t0.01\n1001\tT1\t502\t0.01\n1002\tT2\t000\t5.00\n1003\tT3\t503\t0.01\n1004\tT4\t200\t0.01\n1005\tT5\t200\t0.01\n1006\tT6\t502\t0.01\n1007\tT7\t200\t0.01\n' >"$tmp"
|
|
||||||
out=$(summarize "$tmp")
|
|
||||||
rm -f "$tmp"
|
|
||||||
echo "$out"
|
|
||||||
echo "$out" | grep -q "максимальная серия не-200: 3 образцов ≈ 3.0 с" || {
|
|
||||||
echo "SELFTEST FAILED: серия посчитана неверно" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
echo "$out" | grep -q " 000: 1" || { echo "SELFTEST FAILED: 000 не выделен отдельно" >&2; exit 1; }
|
|
||||||
echo "SELFTEST OK"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
URL=${1:-https://meraocenka.ru/}
|
|
||||||
INTERVAL=${2:-1}
|
|
||||||
DURATION=${3:-900}
|
|
||||||
OUT=${4:-/tmp/probe-deploy-window.tsv}
|
|
||||||
|
|
||||||
: >"$OUT"
|
|
||||||
echo "проба: $URL каждые ${INTERVAL}с в течение ${DURATION}с → $OUT"
|
|
||||||
end=$(( $(date +%s) + DURATION ))
|
|
||||||
while [ "$(date +%s)" -lt "$end" ]; do
|
|
||||||
# --max-time 5: зависший запрос не должен растягивать шаг пробы на минуты.
|
|
||||||
# -o /dev/null: тело не нужно, важны код и время.
|
|
||||||
line=$(curl -sS -o /dev/null --max-time 5 -w '%{http_code}\t%{time_total}' "$URL" 2>/dev/null || echo "000 0")
|
|
||||||
printf '%s\t%s\t%s\n' "$(date +%s.%N)" "$(date -u +%H:%M:%SZ)" "$line" >>"$OUT"
|
|
||||||
sleep "$INTERVAL"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "── сводка ─────────────────────────────────────────"
|
|
||||||
summarize "$OUT"
|
|
||||||
|
|
@ -38,137 +38,12 @@ BASE_MERA="${SMOKE_MERA_BASE:-https://meraocenka.ru}"
|
||||||
BASE_MAIN="${SMOKE_MAIN_BASE:-https://gendsgn.ru}"
|
BASE_MAIN="${SMOKE_MAIN_BASE:-https://gendsgn.ru}"
|
||||||
|
|
||||||
fail=0
|
fail=0
|
||||||
noresp=0
|
|
||||||
|
|
||||||
# --- ТЕМП ЗАПРОСОВ И ПОВТОРЫ (11.09.2026) --------------------------------
|
|
||||||
#
|
|
||||||
# ЧТО СЛУЧИЛОСЬ. Прогон на голове main (a659b187) покраснел на двух последних
|
|
||||||
# проверках списка с кодом `000`. `000` у curl — это НЕ «пришёл неверный код»,
|
|
||||||
# а «ответа не было вовсе» (таймаут/обрыв). Периметр при этом был цел: те же
|
|
||||||
# пути, запрошенные вручную, отдавали 503 и 405 от приложения.
|
|
||||||
#
|
|
||||||
# ЗАМЕР (внешний IP, https://gendsgn.ru/trade-in/api/v1/me):
|
|
||||||
# серия без пауз — 5 обрывов из 12
|
|
||||||
# серия с паузой 0.5 c — 6 обрывов из 12
|
|
||||||
# серия с паузой 2 c — 0 обрывов из 8
|
|
||||||
# с самого хоста прода — 0 обрывов из 10 (все 401)
|
|
||||||
# То есть приложение отвечает, а частую серию запросов с одного внешнего
|
|
||||||
# адреса отбивает ВХОД (на хосте активен fail2ban). Смоук шлёт ~43 запроса
|
|
||||||
# подряд без пауз и попадает ровно под этот эффект — и падают именно
|
|
||||||
# ПОСЛЕДНИЕ проверки, потому что к концу серии счётчик уже набран.
|
|
||||||
#
|
|
||||||
# ОТСЮДА ДВА МЕХАНИЗМА, И НИ ОДИН НЕ ТРОГАЕТ САМИ ОЖИДАНИЯ:
|
|
||||||
# 1. Повтор ТОЛЬКО там, где ответа не было вовсе. Ответ с «не тем» кодом —
|
|
||||||
# это результат проверки, он не повторяется никогда: иначе ретрай
|
|
||||||
# маскировал бы настоящий регресс периметра, ради которого всё написано.
|
|
||||||
# 2. Пауза между проверками, чтобы серия не выглядела флудом.
|
|
||||||
#
|
|
||||||
# ПОЧЕМУ ПАУЗА 2 c, А НЕ МЕНЬШЕ. Это наименьшая величина, у которой есть
|
|
||||||
# замер: 0.5 c измеренно НЕ помогает (6 обрывов из 12 — не лучше, чем без
|
|
||||||
# пауз), 2 c даёт ноль обрывов, промежуточные значения никто не мерил, и
|
|
||||||
# взять их означало бы выдумать число. Цена ИЗМЕРЕНА, а не оценена: прогон
|
|
||||||
# целиком занимал 89 c и стал занимать 160 c (оба замера 11.09 с локальной
|
|
||||||
# машины, 43 проверки, все зелёные). Под это в воркфлоу периметра поднят
|
|
||||||
# timeout-minutes: неотвечающая проверка сетевого класса стоит 3×15 c таймаута
|
|
||||||
# + 2 c и 4 c пауз ретрая + 2 c паузы между проверками = 53 c, а худший случай
|
|
||||||
# (прод не отвечает вовсе — мертвы все 43) = 43 × 53 ≈ 2279 c ≈ 38 мин.
|
|
||||||
# Ни 5, ни 10 минут на него не хватало: job убивали ДО печати FAIL-строк и
|
|
||||||
# итога, то есть ровно там, где лог нужнее всего.
|
|
||||||
# Обе величины переопределяются из окружения — для отладки локально
|
|
||||||
# (`SMOKE_PAUSE=0 bash scripts/smoke-mera-perimeter.sh` даёт прежний темп).
|
|
||||||
SMOKE_PAUSE="${SMOKE_PAUSE:-2}"
|
|
||||||
SMOKE_ATTEMPTS="${SMOKE_ATTEMPTS:-3}"
|
|
||||||
|
|
||||||
# curl_try: запрос с повтором, если ответа не пришло ВООБЩЕ, и с паузой после.
|
|
||||||
#
|
|
||||||
# Признак «ответа не было» берём у самого curl — код возврата из СЕТЕВОГО
|
|
||||||
# класса (перечислен в константе NETWORK_RC ниже). Он строго эквивалентен
|
|
||||||
# `%{http_code}` = 000, но доступен ВСЕМ проверкам, включая те, которые
|
|
||||||
# http_code вообще не запрашивают: до этой правки обрыв в
|
|
||||||
# check_redirect_location читался как «Location не тот», а обрыв при загрузке
|
|
||||||
# лэндинга — как «сам лэндинг сломан». Один сторож в общей обёртке чинит все
|
|
||||||
# места сразу, а не только те две проверки, что покраснели.
|
|
||||||
#
|
|
||||||
# СЕТЕВОЙ КЛАСС — И ТОЛЬКО ОН. Коды живут в константе, а не в тексте
|
|
||||||
# комментария, чтобы описание и поведение не разъехались:
|
|
||||||
# 6 — DNS не разрешился
|
|
||||||
# 7 — соединение отвергнуто (вход лежит; REJECT у fail2ban выглядит так же)
|
|
||||||
# 28 — таймаут
|
|
||||||
# 35 — обрыв на TLS-хендшейке
|
|
||||||
# 52 — сервер закрыл соединение, не ответив
|
|
||||||
# 56 — обрыв при приёме ответа
|
|
||||||
# Общее у них ровно одно: ответа не получено, и повтор имеет шанс помочь —
|
|
||||||
# тот самый эффект входа, ради которого повтор и заведён.
|
|
||||||
#
|
|
||||||
# ОСТАЛЬНЫЕ КОДЫ НЕ ПОВТОРЯЕМ И НЕ ЗОВЁМ «ответа нет». Протухший, чужой или
|
|
||||||
# самоподписанный сертификат даёт rc=60 (замер 12.09: expired.badssl.com,
|
|
||||||
# self-signed.badssl.com, wrong.host.badssl.com — все три). Это ИЗМЕРЕННЫЙ
|
|
||||||
# отказ периметра, а не потерянный запрос: см. проверку 5b в шапке — без
|
|
||||||
# site-блока Caddy не выпускает сертификат, и клиент видит обрыв TLS вместо
|
|
||||||
# редиректа, ради этого проверка и написана. Отказ детерминирован: три попытки
|
|
||||||
# дадут тот же rc, потратив 6 c пауз, а результат уехал бы в колонку «не
|
|
||||||
# измеряли» — то есть регресс спрятался бы ровно там, где его надо показать.
|
|
||||||
NETWORK_RC=" 6 7 28 35 52 56 "
|
|
||||||
|
|
||||||
# Ответ, пришедший с «неправильным» кодом, для curl — успех (rc=0), повтора не
|
|
||||||
# будет; проверка отработает ровно так же, как до правки.
|
|
||||||
curl_try() {
|
|
||||||
local attempt=1 rc out
|
|
||||||
while :; do
|
|
||||||
out=$(curl "$@" 2>/dev/null)
|
|
||||||
rc=$?
|
|
||||||
{ [ "$rc" -eq 0 ] || [ "$attempt" -ge "$SMOKE_ATTEMPTS" ]; } && break
|
|
||||||
# Код вне сетевого класса — повторять нечего, отдаём rc наверх (см. NETWORK_RC).
|
|
||||||
case "$NETWORK_RC" in *" $rc "*) ;; *) break ;; esac
|
|
||||||
# Пауза растёт: 2 c, затем 4 c — ниже 2 c смысла нет (см. замер выше).
|
|
||||||
echo " RETRY: ответа нет (curl rc=$rc), попытка $((attempt + 1)) из $SMOKE_ATTEMPTS через $((attempt * 2)) c: $*" >&2
|
|
||||||
sleep "$((attempt * 2))"
|
|
||||||
attempt=$((attempt + 1))
|
|
||||||
done
|
|
||||||
[ "$SMOKE_PAUSE" = "0" ] || sleep "$SMOKE_PAUSE"
|
|
||||||
printf '%s' "$out"
|
|
||||||
return "$rc"
|
|
||||||
}
|
|
||||||
|
|
||||||
# curl_failed: красная строка, когда ответа не удалось получить у самого curl.
|
|
||||||
#
|
|
||||||
# За одним «rc != 0» прячутся ДВА разных диагноза, и путать их нельзя:
|
|
||||||
# - сетевой класс → «ответа нет»: цел ли периметр, мы не знаем, проверка НЕ
|
|
||||||
# измерилась (плюс счётчик noresp и отдельная строка в итоге);
|
|
||||||
# - всё остальное, прежде всего cert-класс (rc=60) → обычный FAIL: отказ
|
|
||||||
# ИЗМЕРЕН, это регресс периметра, искать надо конфиг, а не флап входа.
|
|
||||||
#
|
|
||||||
# rc печатается в ОБЕИХ ветках: строка RETRY при SMOKE_ATTEMPTS=1 не выводится
|
|
||||||
# вовсе, и без rc читатель красного лога не отличит «домена нет» (6) от
|
|
||||||
# «сертификат протух» (60) — а это диагнозы из разных отделов.
|
|
||||||
curl_failed() {
|
|
||||||
local desc="$1" url="$2" rc="$3" reason
|
|
||||||
fail=1
|
|
||||||
case "$NETWORK_RC" in
|
|
||||||
*" $rc "*)
|
|
||||||
echo "FAIL (ответа нет): $desc ($url — вход не отдал ответ после $SMOKE_ATTEMPTS попыток, curl rc=$rc;" \
|
|
||||||
"это НЕ измеренный код ответа, периметр этой проверкой НЕ проверен — повторите URL вручную)"
|
|
||||||
noresp=$((noresp + 1))
|
|
||||||
return
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
case "$rc" in
|
|
||||||
60|51|83) reason="TLS-сертификат отвергнут" ;;
|
|
||||||
58|77) reason="проблема с клиентским сертификатом/CA" ;;
|
|
||||||
*) reason="curl не выполнил запрос" ;;
|
|
||||||
esac
|
|
||||||
echo "FAIL: $desc ($url -> $reason (curl rc=$rc); ответ измерен как отказ, это не потерянный" \
|
|
||||||
"запрос — повтора не было, код вне сетевого класса «ответа нет»)"
|
|
||||||
}
|
|
||||||
|
|
||||||
check() {
|
check() {
|
||||||
local desc="$1" url="$2" expected="$3"
|
local desc="$1" url="$2" expected="$3"
|
||||||
local code rc
|
local code
|
||||||
code=$(curl_try -s -o /dev/null -w '%{http_code}' --max-time 15 "$url")
|
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$url" 2>/dev/null)
|
||||||
rc=$?
|
if [ "$code" = "$expected" ]; then
|
||||||
if [ "$rc" -ne 0 ]; then
|
|
||||||
curl_failed "$desc" "$url" "$rc"
|
|
||||||
elif [ "$code" = "$expected" ]; then
|
|
||||||
echo "PASS: $desc ($url -> $code)"
|
echo "PASS: $desc ($url -> $code)"
|
||||||
else
|
else
|
||||||
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
|
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
|
||||||
|
|
@ -176,122 +51,15 @@ check() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# check_any: PASS, если код ответа — ЛЮБОЙ из перечисленных.
|
|
||||||
#
|
|
||||||
# Нужен там, где проверяемое свойство — «этого адреса наружу нет», а каким
|
|
||||||
# именно отказом это выражено, зависит от того, какой рубеж ответил первым.
|
|
||||||
# На gendsgn.ru таких рубежа два: пилотный basic_auth (401) стоит выше
|
|
||||||
# allowlist'а site-блока (404), и какой сработает — вопрос порядка директив, а
|
|
||||||
# не безопасности. Фиксировать один конкретный код значило бы ронять смоук при
|
|
||||||
# снятии пилотного гейта, то есть при изменении, которое к предмету проверки
|
|
||||||
# отношения не имеет.
|
|
||||||
check_any() {
|
|
||||||
local desc="$1" url="$2"
|
|
||||||
shift 2
|
|
||||||
local code rc expected="$*"
|
|
||||||
code=$(curl_try -s -o /dev/null -w '%{http_code}' --max-time 15 "$url")
|
|
||||||
rc=$?
|
|
||||||
if [ "$rc" -ne 0 ]; then
|
|
||||||
curl_failed "$desc" "$url" "$rc"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
for want in "$@"; do
|
|
||||||
if [ "$code" = "$want" ]; then
|
|
||||||
echo "PASS: $desc ($url -> $code)"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected one of: $expected)"
|
|
||||||
fail=1
|
|
||||||
}
|
|
||||||
|
|
||||||
check_post() {
|
check_post() {
|
||||||
local desc="$1" url="$2" body="$3" expected="$4" reject="${5:-}"
|
local desc="$1" url="$2" body="$3" expected="$4"
|
||||||
local out code head_and_body rc
|
local code
|
||||||
# -i: заголовки попадают в вывод вместе с телом — по ним отличаем ответ
|
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
|
||||||
# приложения от заглушки Caddy (см. $reject у вызова payments/notify).
|
-X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null)
|
||||||
out=$(curl_try -s -i -w '\n%{http_code}' --max-time 15 \
|
if [ "$code" = "$expected" ]; then
|
||||||
-X POST -H 'Content-Type: application/json' -d "$body" "$url")
|
|
||||||
rc=$?
|
|
||||||
if [ "$rc" -ne 0 ]; then
|
|
||||||
curl_failed "$desc" "$url" "$rc"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
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)"
|
echo "PASS: $desc ($url -> $code)"
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# check_redirect_location: сверяет буквальный заголовок Location редиректа,
|
|
||||||
# а не только код ответа — нужен там, где регресс не меняет код (301
|
|
||||||
# остаётся 301), а меняет ТОЛЬКО цель (баг 10.09.2026 у @meraShortSlash в
|
|
||||||
# apps.caddy: query терялась молча, код ответа был зелёным всю дорогу).
|
|
||||||
# curl без -L (редирект не проходим), -D - дампит заголовки в stdout.
|
|
||||||
#
|
|
||||||
# ФОРМА LOCATION НЕ ФИКСИРУЕТСЯ. Caddy отдаёт цель редиректа так, как её
|
|
||||||
# собрала директива: для `redir * {uri}` это относительный путь
|
|
||||||
# (`/articles?utm_source=vc`), а для веток с явным хостом — абсолютный URL
|
|
||||||
# (`https://meraocenka.ru/articles?utm_source=vc`). Обе формы валидны по
|
|
||||||
# RFC 7231, браузер разрешает относительную сам, и переписывание одной ветки
|
|
||||||
# конфига на другую — не регресс, ради которого стоит ронять смоук. Поэтому
|
|
||||||
# сверяется ХВОСТ: путь с query, с какого бы префикса Location ни начинался.
|
|
||||||
# Ровно это и есть предмет проверки — что query дожила до цели.
|
|
||||||
check_redirect_location() {
|
|
||||||
local desc="$1" url="$2" expected_suffix="$3"
|
|
||||||
local location headers rc
|
|
||||||
# Заголовки сначала забираем целиком, и только потом разбираем: при обрыве
|
|
||||||
# соединения grep по пустому выводу дал бы «Location: <none>» — красную
|
|
||||||
# строку про подмену цели редиректа там, где ответа не было вовсе.
|
|
||||||
headers=$(curl_try -s -o /dev/null -D - --max-time 15 "$url")
|
|
||||||
rc=$?
|
|
||||||
if [ "$rc" -ne 0 ]; then
|
|
||||||
curl_failed "$desc" "$url" "$rc"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
location=$(printf '%s' "$headers" \
|
|
||||||
| grep -i '^location:' | tr -d '\r' | sed 's/^[Ll]ocation: *//')
|
|
||||||
case "$location" in
|
|
||||||
"$expected_suffix"|*"$expected_suffix")
|
|
||||||
echo "PASS: $desc ($url -> Location: $location)"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "FAIL: $desc ($url -> got Location '${location:-<none>}', expected ending with '$expected_suffix')"
|
|
||||||
fail=1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# check_content_type: 200 И заявленный Content-Type начинается с ожидаемого.
|
|
||||||
#
|
|
||||||
# Заведён под og:image. Для картинки превью «200» само по себе ничего не
|
|
||||||
# доказывает: перепутанный rewrite приводит к 200 с HTML-страницей фронта, и
|
|
||||||
# обходчик мессенджера её молча отбросит — ссылка развернётся без картинки, а
|
|
||||||
# смоук останется зелёным. Проверять надо ровно то, ради чего адрес открыт.
|
|
||||||
check_content_type() {
|
|
||||||
local desc="$1" url="$2" expected_prefix="$3"
|
|
||||||
local out code ctype rc
|
|
||||||
out=$(curl_try -s -o /dev/null -D - -w '%{http_code}' --max-time 15 "$url")
|
|
||||||
rc=$?
|
|
||||||
if [ "$rc" -ne 0 ]; then
|
|
||||||
curl_failed "$desc" "$url" "$rc"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
code=${out##*$'
|
|
||||||
'}
|
|
||||||
ctype=$(printf '%s' "$out" | grep -i '^content-type:' | tr -d '
|
|
||||||
' | sed 's/^[Cc]ontent-[Tt]ype: *//' | head -1)
|
|
||||||
if [ "$code" = "200" ] && [ "${ctype#"$expected_prefix"}" != "$ctype" ]; then
|
|
||||||
echo "PASS: $desc ($url -> $code, $ctype)"
|
|
||||||
else
|
else
|
||||||
echo "FAIL: $desc ($url -> got '${code:-<no response>}' / '${ctype:-<no content-type>}', expected 200 + $expected_prefix)"
|
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
|
||||||
fail=1
|
fail=1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
@ -327,22 +95,6 @@ check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200
|
||||||
# человека на форму входа; отвалится handle — вернётся 404 вместо неё.
|
# человека на форму входа; отвалится handle — вернётся 404 вместо неё.
|
||||||
check "meraocenka.ru/business — public 200" "$BASE_MERA/business" 200
|
check "meraocenka.ru/business — public 200" "$BASE_MERA/business" 200
|
||||||
|
|
||||||
# 1d3. robots.txt / sitemap.xml — первое, что запрашивает поисковый краулер
|
|
||||||
# при индексации. robots.txt отдаётся ОТДЕЛЬНЫМ handle (Next-конвенция:
|
|
||||||
# файл в корне app/, а не под mera-public/), sitemap.xml — той же строкой
|
|
||||||
# что и остальные @meraPages (см. комментарии в apps.caddy). Отвалится
|
|
||||||
# любой из handle — сайт не проиндексируется вовсе.
|
|
||||||
check "meraocenka.ru/robots.txt — public 200" "$BASE_MERA/robots.txt" 200
|
|
||||||
check "meraocenka.ru/sitemap.xml — public 200" "$BASE_MERA/sitemap.xml" 200
|
|
||||||
|
|
||||||
# 1d4. og:image — картинка превью ссылок. Её просят обходчики мессенджеров и
|
|
||||||
# соцсетей, анонимно и без Referer; отвалится handle — ссылки на сайт
|
|
||||||
# начнут разворачиваться без картинки, и заметить это по логам
|
|
||||||
# приложения нельзя (запрос туда просто не доходит). Content-Type
|
|
||||||
# проверяется вместе с кодом: 200 с HTML вместо PNG выглядел бы так же.
|
|
||||||
check_content_type "meraocenka.ru/og-mera.png — public 200 + image/png" "$BASE_MERA/og-mera.png" "image/png"
|
|
||||||
check_content_type "meraocenka.ru/logo-mera.png — public 200 + image/png" "$BASE_MERA/logo-mera.png" "image/png"
|
|
||||||
|
|
||||||
# 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один
|
# 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один
|
||||||
# канонический адрес, а старые ссылки и закладки продолжают работать.
|
# канонический адрес, а старые ссылки и закладки продолжают работать.
|
||||||
# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер
|
# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер
|
||||||
|
|
@ -351,16 +103,6 @@ check_content_type "meraocenka.ru/logo-mera.png — public 200 + image/png" "$BA
|
||||||
check "meraocenka.ru длинный корень — 301 на /" "$BASE_MERA/trade-in/mera-public" 301
|
check "meraocenka.ru длинный корень — 301 на /" "$BASE_MERA/trade-in/mera-public" 301
|
||||||
check "meraocenka.ru длинная оферта — 301 на /oferta" "$BASE_MERA/trade-in/mera-public/oferta" 301
|
check "meraocenka.ru длинная оферта — 301 на /oferta" "$BASE_MERA/trade-in/mera-public/oferta" 301
|
||||||
|
|
||||||
# 1f. Регресс-тест на баг 10.09.2026 (замер на живом проде): редирект со
|
|
||||||
# слэшем терял query-строку — статьи публикуются с UTM-метками, а
|
|
||||||
# мессенджеры и автолинкификаторы дописывают слэш к скопированной ссылке,
|
|
||||||
# то есть именно этот трафик терял атрибуцию молча (код ответа
|
|
||||||
# оставался 301, поэтому предыдущая версия смоука проблему не ловила).
|
|
||||||
# Проверяем сам факт 301 (уже покрыт проверками check выше по коду) И
|
|
||||||
# буквальный Location — только вторая половина ловит регресс.
|
|
||||||
check_redirect_location "meraocenka.ru/articles/?utm_source=vc — query сохраняется на редиректе" \
|
|
||||||
"$BASE_MERA/articles/?utm_source=vc" "/articles?utm_source=vc"
|
|
||||||
|
|
||||||
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
|
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
|
||||||
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
|
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
|
||||||
|
|
||||||
|
|
@ -389,13 +131,8 @@ check "meraocenka.ru/_next/image — must 404 (не открываем опти
|
||||||
# (замер на проде 02.09.2026: 9 байт).
|
# (замер на проде 02.09.2026: 9 байт).
|
||||||
check_caddy_404() {
|
check_caddy_404() {
|
||||||
local desc="$1" url="$2"
|
local desc="$1" url="$2"
|
||||||
local out code size rc
|
local out code size
|
||||||
out=$(curl_try -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 15 "$url")
|
out=$(curl -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 15 "$url" 2>/dev/null)
|
||||||
rc=$?
|
|
||||||
if [ "$rc" -ne 0 ]; then
|
|
||||||
curl_failed "$desc" "$url" "$rc"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
code=${out%% *}
|
code=${out%% *}
|
||||||
size=${out##* }
|
size=${out##* }
|
||||||
if [ "$code" = "404" ] && [ "$size" = "0" ]; then
|
if [ "$code" = "404" ] && [ "$size" = "0" ]; then
|
||||||
|
|
@ -416,23 +153,15 @@ check_caddy_404 "meraocenka.ru — чанки /team не раздаются" \
|
||||||
# Обратная сторона того же матчера: статика САМОГО лэндинга обязана остаться
|
# Обратная сторона того же матчера: статика САМОГО лэндинга обязана остаться
|
||||||
# живой. Без этой строки «починка» вида «404 на весь chunks/app/» выглядела бы
|
# живой. Без этой строки «починка» вида «404 на весь chunks/app/» выглядела бы
|
||||||
# успешной, а публичный сайт молча остался бы без JS.
|
# успешной, а публичный сайт молча остался бы без JS.
|
||||||
layout_html=$(curl_try -s --max-time 15 "$BASE_MERA/")
|
layout_chunk=$(curl -s --max-time 15 "$BASE_MERA/" 2>/dev/null \
|
||||||
layout_rc=$?
|
| grep -o '/trade-in/_next/static/chunks/app/layout-[^"]*\.js' | head -1)
|
||||||
if [ "$layout_rc" -ne 0 ]; then
|
if [ -z "$layout_chunk" ]; then
|
||||||
# Обрыв на загрузке лэндинга раньше попадал в ветку «не нашёл чанк» и читался
|
# Пустая строка вместо пути дала бы запрос к корню и зелёную проверку ни о
|
||||||
# как «сам лэндинг сломан» — диагноз, которого никто не измерял.
|
# чём — поэтому это FAIL, а не «пропустим».
|
||||||
curl_failed "HTML лэндинга (ищем в нём layout-чанк)" "$BASE_MERA/" "$layout_rc"
|
echo "FAIL: не нашёл layout-чанк в HTML лэндинга (сам лэндинг сломан?)"
|
||||||
|
fail=1
|
||||||
else
|
else
|
||||||
layout_chunk=$(printf '%s' "$layout_html" \
|
check "meraocenka.ru — корневой layout-чанк лэндинга жив (200)" "$BASE_MERA$layout_chunk" 200
|
||||||
| grep -o '/trade-in/_next/static/chunks/app/layout-[^"]*\.js' | head -1)
|
|
||||||
if [ -z "$layout_chunk" ]; then
|
|
||||||
# Пустая строка вместо пути дала бы запрос к корню и зелёную проверку ни о
|
|
||||||
# чём — поэтому это FAIL, а не «пропустим».
|
|
||||||
echo "FAIL: не нашёл layout-чанк в HTML лэндинга (сам лэндинг сломан?)"
|
|
||||||
fail=1
|
|
||||||
else
|
|
||||||
check "meraocenka.ru — корневой layout-чанк лэндинга жив (200)" "$BASE_MERA$layout_chunk" 200
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/*
|
# 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/*
|
||||||
|
|
@ -472,43 +201,8 @@ check_post "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/ap
|
||||||
# Гейт данных переехал на API — там и проверяем, иначе тест зелёный при
|
# Гейт данных переехал на API — там и проверяем, иначе тест зелёный при
|
||||||
# открытом наружу бэкенде.
|
# открытом наружу бэкенде.
|
||||||
check "trade-in /api/v1/me — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/me" 401
|
check "trade-in /api/v1/me — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/me" 401
|
||||||
# Путь именно `/api/v1/trade-in/history`: роутер trade_in подключён в main.py с
|
check "trade-in /api/v1/history — 401 anonymous (чужие оценки)" "$BASE_MAIN/trade-in/api/v1/history" 401
|
||||||
# префиксом `/api/v1/trade-in`, а сама ручка объявлена как `@router.get("/history")`.
|
check "trade-in /api/v1/admin/* — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/admin/users" 401
|
||||||
# До 05.09.2026 здесь стоял несуществующий `/api/v1/history` — проверка была
|
|
||||||
# зелёной только потому, что guard отвечал 401 на ЛЮБОЙ путь; после #3352
|
|
||||||
# несуществующий путь даёт 404, и проверка честно покраснела.
|
|
||||||
check "trade-in /api/v1/trade-in/history — 401 anonymous (чужие оценки)" "$BASE_MAIN/trade-in/api/v1/trade-in/history" 401
|
|
||||||
# #3360: admin-префикс анониму отвечает 404, а НЕ 401. Периметр здесь не срезан
|
|
||||||
# (Caddy-блок /trade-in/api/* стоит выше basic_auth-снипета, и срезать нельзя —
|
|
||||||
# admin-UI зовёт эти же пути из браузера), поэтому существование ручки прячет сам
|
|
||||||
# guard. Пара путей взята намеренно разная: `/proxies` — РЕАЛЬНЫЙ роут
|
|
||||||
# (app/api/v1/admin.py), `/users` — несуществующий. Одинаковый код на обоих и
|
|
||||||
# означает, что перебором имён admin-API снаружи ничего не узнать; 401 на первом
|
|
||||||
# = регресс маскировки (app/core/rbac.py::_unauthenticated).
|
|
||||||
check "trade-in /api/v1/admin/proxies — 404 anonymous (существующая ручка скрыта)" \
|
|
||||||
"$BASE_MAIN/trade-in/api/v1/admin/proxies" 404
|
|
||||||
check "trade-in /api/v1/admin/users — 404 anonymous (несуществующая — тот же ответ)" \
|
|
||||||
"$BASE_MAIN/trade-in/api/v1/admin/users" 404
|
|
||||||
|
|
||||||
# 3b. sitemap.xml публичного лэндинга МЕРЫ не должен просачиваться через
|
|
||||||
# закрытый B2B-контур gendsgn.ru — корень gendsgn.ru принадлежит Site
|
|
||||||
# Finder, у него нет своего route на /sitemap.xml под МЕРУ, allowlist-by-
|
|
||||||
# default этого site-блока (как и у meraocenka.ru) отдаёт 404 на всё
|
|
||||||
# незаявленное.
|
|
||||||
#
|
|
||||||
# ДВА ДОПУСТИМЫХ КОДА, А НЕ ОДИН. Первая редакция этой проверки ждала 404 и
|
|
||||||
# упала на первом же прогоне против прода: корень gendsgn.ru закрыт
|
|
||||||
# пилотным basic_auth, и гейт отвечает 401 РАНЬШЕ, чем запрос доходит до
|
|
||||||
# allowlist'а. Оба ответа означают ровно проверяемое — карты сайта МЕРЫ на
|
|
||||||
# B2B-домене нет; какой именно рубеж сработал первым, к предмету проверки
|
|
||||||
# отношения не имеет, а вот снятие пилотного гейта (оно запланировано)
|
|
||||||
# переключит 401 на 404 и уронило бы жёсткое ожидание на пустом месте.
|
|
||||||
# Красная строка здесь — только 200: он означал бы, что sitemap реально
|
|
||||||
# отдаётся из закрытого контура.
|
|
||||||
#
|
|
||||||
# Если у Site Finder когда-нибудь появится СВОЙ /sitemap.xml, ожидание
|
|
||||||
# придётся пересмотреть на «200 с другим содержимым».
|
|
||||||
check_any "gendsgn.ru/sitemap.xml — must NOT serve MERA sitemap (закрытый контур)" "$BASE_MAIN/sitemap.xml" 401 404
|
|
||||||
|
|
||||||
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (auth gate стоит ДО роутинга
|
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (auth gate стоит ДО роутинга
|
||||||
# в FastAPI — конкретный путь неважен, любой /api/v1/admin/* перехватывается
|
# в FastAPI — конкретный путь неважен, любой /api/v1/admin/* перехватывается
|
||||||
|
|
@ -557,30 +251,14 @@ check "meraocenka.ru payments/checkout — must 404 (Caddy не проксиру
|
||||||
# маршрут существует (не 404 — старый образ) И что приём платежей выключен
|
# маршрут существует (не 404 — старый образ) И что приём платежей выключен
|
||||||
# (`payments_enabled=False`). 200 здесь означал бы, что флаг включили, не тронув
|
# (`payments_enabled=False`). 200 здесь означал бы, что флаг включили, не тронув
|
||||||
# этот смоук. GET → 405 закрепляет, что путь принимает только POST.
|
# этот смоук. 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 (маршрут есть, приём выключен)" \
|
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)" \
|
check "trade-in payments/notify — 405 на GET (только POST)" \
|
||||||
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 405
|
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 405
|
||||||
check "trade-in payments/checkout — 401 anonymous (не публичный по построению)" \
|
check "trade-in payments/checkout — 401 anonymous (не публичный по построению)" \
|
||||||
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401
|
"$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401
|
||||||
|
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
if [ "$noresp" -gt 0 ]; then
|
|
||||||
# Отдельная строка в итоге, а не только у самой проверки: читатель красного
|
|
||||||
# лога должен сразу видеть, что часть проверок НЕ ИЗМЕРИЛАСЬ, и не искать
|
|
||||||
# регресс периметра там, где ответа просто не было.
|
|
||||||
echo "БЕЗ ОТВЕТА: $noresp проверок не получили ответа даже после $SMOKE_ATTEMPTS попыток."
|
|
||||||
echo " Это не измеренный код ответа. Повторите эти URL вручную (и учтите, что"
|
|
||||||
echo " вход отбивает частые серии запросов с одного адреса — см. шапку скрипта)."
|
|
||||||
fi
|
|
||||||
if [ "$fail" -eq 0 ]; then
|
if [ "$fail" -eq 0 ]; then
|
||||||
echo "ALL CHECKS PASSED"
|
echo "ALL CHECKS PASSED"
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.v1.geocode import SuggestResponse, effective_region_code, suggest_addresses
|
from app.api.v1.geocode import SuggestResponse, suggest_addresses
|
||||||
from app.api.v1.trade_in import coverage_probe, estimate
|
from app.api.v1.trade_in import coverage_probe, estimate
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
|
@ -222,9 +222,6 @@ class PublicSuggestInput(BaseModel):
|
||||||
q: str = Field(min_length=2, max_length=200)
|
q: str = Field(min_length=2, max_length=200)
|
||||||
limit: int = Field(default=8, ge=1, le=10)
|
limit: int = Field(default=8, ge=1, le=10)
|
||||||
city_hint: str | None = Field(default=None, max_length=100)
|
city_hint: str | None = Field(default=None, max_length=100)
|
||||||
# #3051: явный регион с фронта (если он его когда-нибудь пришлёт) —
|
|
||||||
# приоритетнее вывода из city_hint, см. effective_region_code.
|
|
||||||
region_code: int | None = Field(default=None)
|
|
||||||
|
|
||||||
|
|
||||||
def _fold(text: str) -> str:
|
def _fold(text: str) -> str:
|
||||||
|
|
@ -329,7 +326,6 @@ async def public_suggest(
|
||||||
limit=payload.limit,
|
limit=payload.limit,
|
||||||
db=db,
|
db=db,
|
||||||
city_hint=payload.city_hint,
|
city_hint=payload.city_hint,
|
||||||
region_code=effective_region_code(payload.region_code, payload.city_hint),
|
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
_suggest_slots.release()
|
_suggest_slots.release()
|
||||||
|
|
@ -483,15 +479,9 @@ class ShowcaseStats(BaseModel):
|
||||||
|
|
||||||
Без этих чисел «20 отличных строк» неотличимо от «столько и было»:
|
Без этих чисел «20 отличных строк» неотличимо от «столько и было»:
|
||||||
посетитель не может отличить выборку из работы оценщика от её лучшего
|
посетитель не может отличить выборку из работы оценщика от её лучшего
|
||||||
хвоста. `eligible` — сколько строк прогон СОБРАЛ (данных хватило),
|
хвоста. `eligible` минус `written` — сколько годных строк не поместилось
|
||||||
`written` — сколько из них показано; `rejection_rule` — по какому правилу
|
в витрину; `rejection_rule` — по какому правилу отсеяно остальное,
|
||||||
отобраны показанные, записанное ТЕМ прогоном, который их посчитал.
|
записанное ТЕМ прогоном, который эти строки посчитал.
|
||||||
|
|
||||||
`eligible` минус `written` — НЕ «столько не поместилось»: с 2026-09-12
|
|
||||||
витрина показывает полосу расхождения −5 %..+20 %, и в разницу входят
|
|
||||||
строки, отсеянные полосой. Что это именно отбор, а не вся сверка, говорит
|
|
||||||
`rejection_rule` — поэтому счётчики и правило показываются вместе, одной
|
|
||||||
подписью, а не порознь.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
considered: int
|
considered: int
|
||||||
|
|
@ -560,7 +550,7 @@ def public_showcase(
|
||||||
нечего, и это ровно то, что фронт должен увидеть вместо выдуманных строк.
|
нечего, и это ровно то, что фронт должен увидеть вместо выдуманных строк.
|
||||||
|
|
||||||
Вместе со строками едет `stats` — сколько сделок рассмотрено, сколько
|
Вместе со строками едет `stats` — сколько сделок рассмотрено, сколько
|
||||||
строк прогон собрал и по какому правилу из них отобраны показанные. Числа
|
годных строк не поместилось и по какому правилу отсеяно остальное. Числа
|
||||||
считает пересчёт; без них витрина не имеет права подписаться честно.
|
считает пересчёт; без них витрина не имеет права подписаться честно.
|
||||||
"""
|
"""
|
||||||
run = db.execute(_SHOWCASE_RUN_SQL).mappings().first()
|
run = db.execute(_SHOWCASE_RUN_SQL).mappings().first()
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ from pydantic import BaseModel, Field, field_validator
|
||||||
# убрал legacy app.services.scheduler.scheduler_loop fallback) — тот же orchestrator,
|
# убрал legacy app.services.scheduler.scheduler_loop fallback) — тот же orchestrator,
|
||||||
# что и debug-роуты этого файла.
|
# что и debug-роуты этого файла.
|
||||||
from scraper_kit.base import save_listings
|
from scraper_kit.base import save_listings
|
||||||
|
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||||
from scraper_kit.orchestration.pipeline import (
|
from scraper_kit.orchestration.pipeline import (
|
||||||
DEFAULT_REGION_CODE,
|
DEFAULT_REGION_CODE,
|
||||||
run_avito_city_sweep,
|
run_avito_city_sweep,
|
||||||
|
|
@ -48,7 +49,6 @@ from scraper_kit.orchestration.pipeline import (
|
||||||
run_yandex_city_sweep,
|
run_yandex_city_sweep,
|
||||||
run_yandex_full_load,
|
run_yandex_full_load,
|
||||||
)
|
)
|
||||||
from scraper_kit.providers._base import build_browser_fetcher
|
|
||||||
from scraper_kit.providers.avito.detail import fetch_detail, save_detail_enrichment
|
from scraper_kit.providers.avito.detail import fetch_detail, save_detail_enrichment
|
||||||
from scraper_kit.providers.avito.houses import fetch_house_catalog, save_house_catalog_enrichment
|
from scraper_kit.providers.avito.houses import fetch_house_catalog, save_house_catalog_enrichment
|
||||||
from scraper_kit.providers.avito.imv import (
|
from scraper_kit.providers.avito.imv import (
|
||||||
|
|
@ -63,7 +63,6 @@ from scraper_kit.providers.yandex.detail import YandexDetailScraper
|
||||||
from scraper_kit.providers.yandex.newbuilding import YandexNewbuildingScraper
|
from scraper_kit.providers.yandex.newbuilding import YandexNewbuildingScraper
|
||||||
from scraper_kit.providers.yandex.serp import YandexRealtyScraper
|
from scraper_kit.providers.yandex.serp import YandexRealtyScraper
|
||||||
from scraper_kit.providers.yandex.valuation import YandexValuationScraper
|
from scraper_kit.providers.yandex.valuation import YandexValuationScraper
|
||||||
from scraper_kit.proxy_errors import NoProxyAvailableError
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -269,7 +268,6 @@ async def geocode_missing(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
target: Literal["listings", "deals"] = "listings",
|
target: Literal["listings", "deals"] = "listings",
|
||||||
region_code: int = 66,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
|
"""Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
|
||||||
|
|
||||||
|
|
@ -281,10 +279,6 @@ async def geocode_missing(
|
||||||
geocode_tried_at: после КАЖДОЙ попытки (успех/провал) ставим NOW(). Failed-
|
geocode_tried_at: после КАЖДОЙ попытки (успех/провал) ставим NOW(). Failed-
|
||||||
адреса не выбираются повторно 7 дней → cron-loop завершается, не зацикливается.
|
адреса не выбираются повторно 7 дней → cron-loop завершается, не зацикливается.
|
||||||
geom обновляется автоматически триггером.
|
geom обновляется автоматически триггером.
|
||||||
|
|
||||||
region_code (дефолт 66, #3051) — фильтрует обе таблицы (`listings`/`deals`
|
|
||||||
несут колонку) и прокидывается в `known_city_hint`/`geocode`. Дефолт 66 —
|
|
||||||
прежнее поведение без изменений (cron не меняется, follow-up ниже).
|
|
||||||
"""
|
"""
|
||||||
# Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса.
|
# Доп. фильтр для listings — у Avito встречаются плейсхолдер-адреса.
|
||||||
extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else ""
|
extra_filter = "AND address NOT LIKE '%(Avito)%'" if target == "listings" else ""
|
||||||
|
|
@ -297,14 +291,13 @@ async def geocode_missing(
|
||||||
WHERE lat IS NULL
|
WHERE lat IS NULL
|
||||||
AND COALESCE(address, '') != ''
|
AND COALESCE(address, '') != ''
|
||||||
{extra_filter}
|
{extra_filter}
|
||||||
AND region_code = CAST(:region_code AS int)
|
|
||||||
AND (geocode_tried_at IS NULL
|
AND (geocode_tried_at IS NULL
|
||||||
OR geocode_tried_at < NOW() - interval '7 days')
|
OR geocode_tried_at < NOW() - interval '7 days')
|
||||||
ORDER BY geocode_tried_at NULLS FIRST
|
ORDER BY geocode_tried_at NULLS FIRST
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"limit": limit, "region_code": region_code},
|
{"limit": limit},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
|
|
@ -339,8 +332,8 @@ async def geocode_missing(
|
||||||
# хинт закрывает EKB-локальные тиры и уезжает префиксом в запрос
|
# хинт закрывает EKB-локальные тиры и уезжает префиксом в запрос
|
||||||
# провайдеру, т.е. вреднее отсутствия хинта. Общий хелпер, тот же, что у
|
# провайдеру, т.е. вреднее отсутствия хинта. Общий хелпер, тот же, что у
|
||||||
# scripts/geocode_deals_nominatim.py и tasks/geocode_missing.py.
|
# scripts/geocode_deals_nominatim.py и tasks/geocode_missing.py.
|
||||||
city = known_city_hint(row.get("city"), region_code)
|
city = known_city_hint(row.get("city"))
|
||||||
result = await geocode(clean, db, city_hint=city, region_code=region_code)
|
result = await geocode(clean, db, city_hint=city)
|
||||||
if result is None:
|
if result is None:
|
||||||
# Помечаем что пробовали — иначе ретрай на каждом cron.
|
# Помечаем что пробовали — иначе ретрай на каждом cron.
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -369,12 +362,10 @@ async def geocode_missing(
|
||||||
WHERE lat IS NULL
|
WHERE lat IS NULL
|
||||||
AND COALESCE(address, '') != ''
|
AND COALESCE(address, '') != ''
|
||||||
{extra_filter}
|
{extra_filter}
|
||||||
AND region_code = CAST(:region_code AS int)
|
|
||||||
AND (geocode_tried_at IS NULL
|
AND (geocode_tried_at IS NULL
|
||||||
OR geocode_tried_at < NOW() - interval '7 days')
|
OR geocode_tried_at < NOW() - interval '7 days')
|
||||||
"""
|
"""
|
||||||
),
|
)
|
||||||
{"region_code": region_code},
|
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -517,12 +508,8 @@ async def cian_auto_login(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# #3410: логин идёт через узел пула, как debug-карточка DomClick ниже. `/login`
|
async with BrowserFetcher(
|
||||||
# сайдкара принимает proxy в теле, `BrowserFetcher.login` кладёт туда узел аренды.
|
source="cian", endpoint=settings.browser_http_endpoint
|
||||||
# До этого (#3197) провайдер сюда не передавали, и логин шёл с env-узла сайдкара —
|
|
||||||
# на проде это выключенный узел 9, camoufox `InvalidIP`, ручка не работала вовсе.
|
|
||||||
async with build_browser_fetcher(
|
|
||||||
RealScraperConfig(), "cian", proxy_provider=_kit_proxy_provider()
|
|
||||||
) as fetcher:
|
) as fetcher:
|
||||||
raw_cookies = await fetcher.login(
|
raw_cookies = await fetcher.login(
|
||||||
url=settings.cian_login_url,
|
url=settings.cian_login_url,
|
||||||
|
|
@ -535,13 +522,6 @@ async def cian_auto_login(
|
||||||
pre_click_selectors=settings.cian_login_pre_click_selectors,
|
pre_click_selectors=settings.cian_login_pre_click_selectors,
|
||||||
wait_ms=settings.cian_login_wait_ms,
|
wait_ms=settings.cian_login_wait_ms,
|
||||||
)
|
)
|
||||||
except NoProxyAvailableError as exc:
|
|
||||||
# Пул пуст для cian (прод, #2616): на мёртвый env-узел не идём, говорим прямо.
|
|
||||||
logger.warning("cian auto-login: пул прокси пуст для cian — логин не запускался")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Нет свободного узла прокси для cian в пуле — логин не запускался",
|
|
||||||
) from exc
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("cian auto-login failed: %s", type(exc).__name__)
|
logger.error("cian auto-login failed: %s", type(exc).__name__)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -719,12 +699,7 @@ async def debug_domclick_detail_fetch(
|
||||||
# построен и verified live именно 2026-07-04 (815КБ __SSR_STATE__ через свежий IP).
|
# построен и verified live именно 2026-07-04 (815КБ __SSR_STATE__ через свежий IP).
|
||||||
# Заменяет прежний source="cian" (docstring domclick/detail.py — устаревшая
|
# Заменяет прежний source="cian" (docstring domclick/detail.py — устаревшая
|
||||||
# рекомендация от 2026-06-27, до появления выделенного пула).
|
# рекомендация от 2026-06-27, до появления выделенного пула).
|
||||||
# #3197 (хвост): фабрика вместо прямой конструкции — она и есть то место, где
|
async with BrowserFetcher(source="domclick", endpoint=settings.browser_http_endpoint) as bf:
|
||||||
# proxy_provider/use_pool/environment попадают в тело POST /fetch. Без них узел
|
|
||||||
# выбирал сайдкар из своего env, а не пул с provider_affinity='domclick'.
|
|
||||||
async with build_browser_fetcher(
|
|
||||||
RealScraperConfig(), "domclick", proxy_provider=_kit_proxy_provider()
|
|
||||||
) as bf:
|
|
||||||
try:
|
try:
|
||||||
enrichment = await domclick_fetch_detail(
|
enrichment = await domclick_fetch_detail(
|
||||||
body.card_url, browser_fetcher=bf, cookies=cookies
|
body.card_url, browser_fetcher=bf, cookies=cookies
|
||||||
|
|
@ -1131,10 +1106,6 @@ async def scrape_avito_imv(
|
||||||
has_balcony=has_balcony,
|
has_balcony=has_balcony,
|
||||||
has_loggia=has_loggia,
|
has_loggia=has_loggia,
|
||||||
config=RealScraperConfig(),
|
config=RealScraperConfig(),
|
||||||
# #3386: без provider'а curl_proxy_url считает use_pool=False (флаг AND
|
|
||||||
# provider is not None) → env-прокси SCRAPER_PROXY_URL, мёртвый узел (#2613).
|
|
||||||
# _kit_proxy_provider() отдаёт None при выключенных флагах — ship-dark цел.
|
|
||||||
proxy_provider=_kit_proxy_provider(),
|
|
||||||
)
|
)
|
||||||
except IMVAddressNotFoundError as e:
|
except IMVAddressNotFoundError as e:
|
||||||
# Ожидаемое клиентское условие (адрес не в базе Avito), НЕ сбой — logger.warning
|
# Ожидаемое клиентское условие (адрес не в базе Avito), НЕ сбой — logger.warning
|
||||||
|
|
@ -1257,11 +1228,8 @@ async def start_avito_city_sweep(
|
||||||
request_delay_sec=payload.request_delay_sec,
|
request_delay_sec=payload.request_delay_sec,
|
||||||
enrich_imv=payload.enrich_imv,
|
enrich_imv=payload.enrich_imv,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
logger.exception("city-sweep background task run_id=%d crashed", run_id)
|
logger.exception("city-sweep background task run_id=%d crashed", run_id)
|
||||||
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
|
|
||||||
# до финализатора пайплайна прогон висел 'running' до zombie.
|
|
||||||
runs_mod.mark_crashed(sweep_db, run_id, exc)
|
|
||||||
finally:
|
finally:
|
||||||
sweep_db.close()
|
sweep_db.close()
|
||||||
|
|
||||||
|
|
@ -1352,11 +1320,8 @@ async def start_cian_city_sweep(
|
||||||
detail_top_n=payload.detail_top_n,
|
detail_top_n=payload.detail_top_n,
|
||||||
enrich_houses=payload.enrich_houses,
|
enrich_houses=payload.enrich_houses,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
logger.exception("cian-sweep background task run_id=%d crashed", run_id)
|
logger.exception("cian-sweep background task run_id=%d crashed", run_id)
|
||||||
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
|
|
||||||
# до финализатора пайплайна прогон висел 'running' до zombie.
|
|
||||||
runs_mod.mark_crashed(sweep_db, run_id, exc)
|
|
||||||
finally:
|
finally:
|
||||||
sweep_db.close()
|
sweep_db.close()
|
||||||
|
|
||||||
|
|
@ -1498,11 +1463,8 @@ async def start_cian_full_load(
|
||||||
resume_run_id=payload.resume_run_id,
|
resume_run_id=payload.resume_run_id,
|
||||||
secondary_only=payload.secondary_only,
|
secondary_only=payload.secondary_only,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
logger.exception("cian-full-load background task run_id=%d crashed", run_id)
|
logger.exception("cian-full-load background task run_id=%d crashed", run_id)
|
||||||
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
|
|
||||||
# до финализатора пайплайна прогон висел 'running' до zombie.
|
|
||||||
runs_mod.mark_crashed(task_db, run_id, exc)
|
|
||||||
finally:
|
finally:
|
||||||
task_db.close()
|
task_db.close()
|
||||||
|
|
||||||
|
|
@ -1604,11 +1566,8 @@ async def start_yandex_full_load(
|
||||||
concurrency=payload.concurrency,
|
concurrency=payload.concurrency,
|
||||||
resume_run_id=payload.resume_run_id,
|
resume_run_id=payload.resume_run_id,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
logger.exception("yandex-full-load background task run_id=%d crashed", run_id)
|
logger.exception("yandex-full-load background task run_id=%d crashed", run_id)
|
||||||
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
|
|
||||||
# до финализатора пайплайна прогон висел 'running' до zombie.
|
|
||||||
runs_mod.mark_crashed(task_db, run_id, exc)
|
|
||||||
finally:
|
finally:
|
||||||
task_db.close()
|
task_db.close()
|
||||||
|
|
||||||
|
|
@ -1675,11 +1634,8 @@ async def start_yandex_city_sweep(
|
||||||
request_delay_sec=payload.request_delay_sec,
|
request_delay_sec=payload.request_delay_sec,
|
||||||
enrich_address=payload.enrich_address,
|
enrich_address=payload.enrich_address,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
logger.exception("yandex-sweep background task run_id=%d crashed", run_id)
|
logger.exception("yandex-sweep background task run_id=%d crashed", run_id)
|
||||||
# #1940: ручной запуск идёт мимо scheduler._dispatch — без этого упавший
|
|
||||||
# до финализатора пайплайна прогон висел 'running' до zombie.
|
|
||||||
runs_mod.mark_crashed(sweep_db, run_id, exc)
|
|
||||||
finally:
|
finally:
|
||||||
sweep_db.close()
|
sweep_db.close()
|
||||||
|
|
||||||
|
|
@ -1988,10 +1944,7 @@ async def scrape_yandex_newbuilding(
|
||||||
"""
|
"""
|
||||||
# fetch_jk использует внутренний BrowserFetcher, httpx-клиент BaseScraper не нужен.
|
# fetch_jk использует внутренний BrowserFetcher, httpx-клиент BaseScraper не нужен.
|
||||||
# config= обязателен (#2322 fix) — иначе BrowserFetcher строится без endpoint=.
|
# config= обязателен (#2322 fix) — иначе BrowserFetcher строится без endpoint=.
|
||||||
# proxy_provider (#3197): без него сайдкар уходит на свой env-узел мимо пула.
|
scraper = YandexNewbuildingScraper(config=RealScraperConfig())
|
||||||
scraper = YandexNewbuildingScraper(
|
|
||||||
config=RealScraperConfig(), proxy_provider=_kit_proxy_provider()
|
|
||||||
)
|
|
||||||
result = await scraper.fetch_jk(jk_slug=slug, jk_id=id, city=city)
|
result = await scraper.fetch_jk(jk_slug=slug, jk_id=id, city=city)
|
||||||
if result is None:
|
if result is None:
|
||||||
raise HTTPException(404, f"Could not parse Yandex JK: {slug}-{id} in {city}")
|
raise HTTPException(404, f"Could not parse Yandex JK: {slug}-{id} in {city}")
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,6 @@ from app.core.password import (
|
||||||
verify_slots_saturated,
|
verify_slots_saturated,
|
||||||
)
|
)
|
||||||
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
||||||
from app.observability.metrics import LOGINS
|
|
||||||
from app.services.auth_session import create_session, get_user_by_username, revoke_session
|
from app.services.auth_session import create_session, get_user_by_username, revoke_session
|
||||||
from app.services.identity_store import AccessState, get_identity_db
|
from app.services.identity_store import AccessState, get_identity_db
|
||||||
from app.services.user_events import schedule_event
|
from app.services.user_events import schedule_event
|
||||||
|
|
@ -325,7 +324,6 @@ async def _reject_invalid_credentials(
|
||||||
fails = _USERNAME_FAIL_LIMITER.record(username)
|
fails = _USERNAME_FAIL_LIMITER.record(username)
|
||||||
delay_s = _throttle_delay_s(fails)
|
delay_s = _throttle_delay_s(fails)
|
||||||
|
|
||||||
LOGINS.labels(result="failed").inc()
|
|
||||||
schedule_event(
|
schedule_event(
|
||||||
event_type="login_failed",
|
event_type="login_failed",
|
||||||
username=username,
|
username=username,
|
||||||
|
|
@ -454,7 +452,6 @@ async def login(
|
||||||
path="/",
|
path="/",
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGINS.labels(result="success").inc()
|
|
||||||
schedule_event(
|
schedule_event(
|
||||||
event_type="login_success",
|
event_type="login_success",
|
||||||
username=user["username"],
|
username=user["username"],
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
|
|
@ -10,34 +11,15 @@ from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.db import get_db, run_db_thread
|
from app.core.db import get_db
|
||||||
from app.observability.metrics import ADDRESS_SUGGESTIONS
|
|
||||||
from app.services.estimator import _lookup_house_facts
|
from app.services.estimator import _lookup_house_facts
|
||||||
from app.services.geocoder import GeocodeResult, geocode, reverse_geocode, suggest
|
from app.services.geocoder import GeocodeResult, geocode, reverse_geocode, suggest
|
||||||
from app.services.regions import DEFAULT_REGION_CODE, region_by_city
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def effective_region_code(region_code: int | None, city_hint: str | None) -> int:
|
|
||||||
"""Регион для геокодера (#3051): явный `region_code` > вывод из `city_hint` > 66.
|
|
||||||
|
|
||||||
До этого хелпера `/suggest` всегда уходил в геокодер с регионом 66
|
|
||||||
по умолчанию — московский `city_hint` («Москва») молча получал
|
|
||||||
свердловский bbox-констрейнт и терял подсказки. Реестр `app.services.regions`
|
|
||||||
уже знает, каким городам какой регион соответствует (REGIONS[77].cities
|
|
||||||
содержит «москва») — используем его вместо повторного захардкоженного списка.
|
|
||||||
"""
|
|
||||||
if region_code is not None:
|
|
||||||
return region_code
|
|
||||||
region = region_by_city(city_hint)
|
|
||||||
if region is not None:
|
|
||||||
return region.code
|
|
||||||
return DEFAULT_REGION_CODE
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/lookup", response_model=GeocodeResult)
|
@router.get("/lookup", response_model=GeocodeResult)
|
||||||
async def lookup(
|
async def lookup(
|
||||||
address: Annotated[str, Query(min_length=3, max_length=500)],
|
address: Annotated[str, Query(min_length=3, max_length=500)],
|
||||||
|
|
@ -99,21 +81,9 @@ async def suggest_addresses(
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
] = None,
|
] = None,
|
||||||
region_code: Annotated[
|
|
||||||
int | None,
|
|
||||||
Query(
|
|
||||||
description=(
|
|
||||||
"Регион покрытия (#3051). None (дефолт) — выводится из `city_hint` "
|
|
||||||
"через реестр регионов, иначе 66 (Свердловская область, прежнее "
|
|
||||||
"поведение). 77 — Москва: без него DaData и Nominatim получают "
|
|
||||||
"свердловский hard-констрейнт и молча возвращают ПУСТО на "
|
|
||||||
"московском адресе."
|
|
||||||
),
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
) -> SuggestResponse:
|
) -> SuggestResponse:
|
||||||
"""Автокомплит адресов в регионе `region_code` (дефолт 66 — Свердловская область;
|
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
|
||||||
ЕКБ — основной трафик, остаётся быстрым fast-path).
|
остаётся быстрым fast-path).
|
||||||
|
|
||||||
Используется в EstimateForm для подсказок пока пользователь печатает.
|
Используется в EstimateForm для подсказок пока пользователь печатает.
|
||||||
Bounded viewbox — генеральный по всей области (см. geocoder.OBLAST66_VIEWBOX),
|
Bounded viewbox — генеральный по всей области (см. geocoder.OBLAST66_VIEWBOX),
|
||||||
|
|
@ -123,17 +93,8 @@ async def suggest_addresses(
|
||||||
/api/v1/geocode/suggest?q=Малышева
|
/api/v1/geocode/suggest?q=Малышева
|
||||||
/api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет
|
/api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет
|
||||||
/api/v1/geocode/suggest?q=Ленина+1&city_hint=Нижний+Тагил
|
/api/v1/geocode/suggest?q=Ленина+1&city_hint=Нижний+Тагил
|
||||||
/api/v1/geocode/suggest?q=Тверская+6®ion_code=77 # Москва
|
|
||||||
"""
|
"""
|
||||||
resolved_region_code = effective_region_code(region_code, city_hint)
|
items = await suggest(q, db=db, limit=limit, city_hint=city_hint)
|
||||||
try:
|
|
||||||
items = await suggest(
|
|
||||||
q, db=db, limit=limit, city_hint=city_hint, region_code=resolved_region_code
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
# Регион вне реестра покрытия — 422, а не 500: это ошибка ввода клиента.
|
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
||||||
ADDRESS_SUGGESTIONS.labels(found="yes" if items else "no").inc()
|
|
||||||
return SuggestResponse(
|
return SuggestResponse(
|
||||||
items=[
|
items=[
|
||||||
SuggestItem(
|
SuggestItem(
|
||||||
|
|
@ -277,9 +238,9 @@ async def house_facts(
|
||||||
"""
|
"""
|
||||||
target_house_id: int | None = None
|
target_house_id: int | None = None
|
||||||
if fias_id is not None:
|
if fias_id is not None:
|
||||||
target_house_id = await run_db_thread(_resolve_house_id_by_fias, db, fias_id)
|
target_house_id = await asyncio.to_thread(_resolve_house_id_by_fias, db, fias_id)
|
||||||
|
|
||||||
facts = await run_db_thread(
|
facts = await asyncio.to_thread(
|
||||||
_lookup_house_facts,
|
_lookup_house_facts,
|
||||||
db,
|
db,
|
||||||
target_house_id=target_house_id,
|
target_house_id=target_house_id,
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,9 @@ _send_uptime_generic``) в итоге идут через ОДНУ И ТУ ЖЕ
|
||||||
1) тело запроса для issue и uptime алертов структурно ОДИНАКОВОЕ —
|
1) тело запроса для issue и uptime алертов структурно ОДИНАКОВОЕ —
|
||||||
``{"text": str, "attachments": [{"title","title_link","text","color",
|
``{"text": str, "attachments": [{"title","title_link","text","color",
|
||||||
"fields",...}]}`` — просто у uptime пустые/отсутствующие ``fields``/``color``;
|
"fields",...}]}`` — просто у uptime пустые/отсутствующие ``fields``/``color``;
|
||||||
2) единственный канал для аутентификации от САМОГО GlitchTip — сам URL (как и
|
2) единственный канал для аутентификации — сам URL (как и Slack-вебхуки).
|
||||||
у Slack-вебхуков): заголовок GlitchTip-сторона не добавляет. Поэтому
|
Секрет ОБЯЗАН ехать query-параметром, HTTP-заголовок здесь поставить
|
||||||
хендлер принимает секрет и из заголовка ``X-GlitchTip-Secret``
|
нечем (GlitchTip-сторона его не добавляет).
|
||||||
(предпочтительно — не течёт в access-log, #3154), и из query-параметра
|
|
||||||
``?secret=`` как fallback для текущего отправителя.
|
|
||||||
|
|
||||||
Переиспользуем существующий ``TRADEIN_INTERNAL_AUTH_SECRET`` (#2213
|
Переиспользуем существующий ``TRADEIN_INTERNAL_AUTH_SECRET`` (#2213
|
||||||
defense-in-depth, см. ``app.core.rbac``) вместо нового секрета — тот же
|
defense-in-depth, см. ``app.core.rbac``) вместо нового секрета — тот же
|
||||||
|
|
@ -43,22 +41,17 @@ regex/prefix-веток в ``rbac_guard``). Сам путь — не секре
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||||
from starlette.background import BackgroundTask
|
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.tgbot.client import TelegramError
|
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||||
from app.services.tgbot.shared import get_telegram_client
|
|
||||||
from app.tasks.glitchtip_alert_retry import retry_forward_alert
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -75,14 +68,6 @@ _TRUNCATE_SUFFIX = "\n… (обрезано)"
|
||||||
# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты.
|
# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты.
|
||||||
_INTERACTIVE_SEND_TIMEOUT_S = 8.0
|
_INTERACTIVE_SEND_TIMEOUT_S = 8.0
|
||||||
_INTERACTIVE_SEND_MAX_RETRIES = 1
|
_INTERACTIVE_SEND_MAX_RETRIES = 1
|
||||||
# Потолок на ВСЮ синхронную попытку (#3157). Таймаут выше — на один HTTP-запрос,
|
|
||||||
# а попыток больше одной: ретранслятор, при его отказе прямой путь, пауза, повтор
|
|
||||||
# (#3471). 16.09.2026 16:42 UTC отказ шёл медленно (8 с relay + 5 с напрямую +
|
|
||||||
# 2 с паузы), GlitchTip на 10-й секунде оборвал соединение (Caddy: status=0),
|
|
||||||
# а хендлер досчитал себе 200 уже в пустоту — uvicorn ответ разорванному
|
|
||||||
# клиенту молча выбрасывает вместе со строкой access-log. Отправитель и
|
|
||||||
# приёмник расходились в исходе. Запас 3 с от 10 с GlitchTip — на Caddy и тело.
|
|
||||||
_INTERACTIVE_SEND_DEADLINE_S = 7.0
|
|
||||||
|
|
||||||
|
|
||||||
class GlitchTipAttachment(BaseModel):
|
class GlitchTipAttachment(BaseModel):
|
||||||
|
|
@ -190,94 +175,45 @@ def _alerts_configured() -> bool:
|
||||||
|
|
||||||
def _verify_secret(provided: str) -> None:
|
def _verify_secret(provided: str) -> None:
|
||||||
expected = settings.tradein_internal_auth_secret
|
expected = settings.tradein_internal_auth_secret
|
||||||
# constant-time: длина/префикс секрета не утекают через время ответа.
|
|
||||||
if not secrets.compare_digest(provided or "", expected):
|
if not secrets.compare_digest(provided or "", expected):
|
||||||
logger.warning("glitchtip webhook: invalid or missing secret")
|
logger.warning("glitchtip webhook: invalid or missing secret query param")
|
||||||
raise HTTPException(status_code=401, detail="invalid or missing secret")
|
raise HTTPException(status_code=401, detail="invalid or missing secret")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ops/glitchtip-webhook", response_model=None)
|
@router.post("/ops/glitchtip-webhook")
|
||||||
async def glitchtip_webhook(
|
async def glitchtip_webhook(
|
||||||
request: Request,
|
request: Request,
|
||||||
secret: Annotated[str, Query()] = "",
|
secret: Annotated[str, Query()] = "",
|
||||||
header_secret: Annotated[str, Header(alias="X-GlitchTip-Secret")] = "",
|
) -> dict[str, str]:
|
||||||
) -> dict[str, str] | JSONResponse:
|
|
||||||
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
|
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
|
||||||
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
|
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
|
||||||
— ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля).
|
— ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля).
|
||||||
|
|
||||||
Отказ синхронной попытки (#3471) отвечает 502 как и раньше (#3456 — честный
|
|
||||||
сигнал отправителю), но ставит доставку в фон
|
|
||||||
(``app.tasks.glitchtip_alert_retry.retry_forward_alert`` через
|
|
||||||
``starlette.background.BackgroundTask`` на самом ответе) — GlitchTip вебхуки
|
|
||||||
не ретраит (#3157), без этого текст алерта терялся бы безвозвратно.
|
|
||||||
``BackgroundTask`` привязан НАПРЯМУЮ к возвращаемому ``JSONResponse``, а не
|
|
||||||
к ``BackgroundTasks``-зависимости: FastAPI прикрепляет задачи из
|
|
||||||
``BackgroundTasks`` только к ответу, который вернул сам хендлер, а `raise
|
|
||||||
HTTPException` строит ОТДЕЛЬНЫЙ ответ в exception-мидлваре — задачи,
|
|
||||||
поставленные до `raise`, в реальности молча терялись бы вместе с ним (это
|
|
||||||
воспроизведено тестом, не гипотеза).
|
|
||||||
|
|
||||||
Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) — этот
|
Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) — этот
|
||||||
хендлер сам делает единственную проверку секрета.
|
хендлер сам делает единственную проверку (``secret`` query-параметр).
|
||||||
|
|
||||||
Секрет принимается ИЗ ЗАГОЛОВКА ``X-GlitchTip-Secret``, а query-параметр
|
|
||||||
``?secret=`` остаётся fallback'ом (#3154). Заголовок предпочтителен потому,
|
|
||||||
что query едет в access-log и оттуда в Loki открытым текстом; query оставлен,
|
|
||||||
т.к. САМ GlitchTip 6.1.6 заголовков не шлёт вовсе (``send_webhook()`` —
|
|
||||||
``session.post(url, json=...)`` без headers, см. docstring модуля), и убрать
|
|
||||||
query можно только когда заголовок начнёт подставлять кто-то перед нами
|
|
||||||
(Caddy ``header_up`` на маршруте вебхука) либо после смены отправителя.
|
|
||||||
"""
|
"""
|
||||||
if not _alerts_configured():
|
if not _alerts_configured():
|
||||||
raise HTTPException(status_code=503, detail="glitchtip alerts webhook not configured")
|
raise HTTPException(status_code=503, detail="glitchtip alerts webhook not configured")
|
||||||
|
|
||||||
_verify_secret(header_secret or secret)
|
_verify_secret(secret)
|
||||||
|
|
||||||
raw_body = await request.body()
|
raw_body = await request.body()
|
||||||
received_at = datetime.now(UTC)
|
received_at = datetime.now(UTC)
|
||||||
text = _build_message(raw_body, received_at)
|
text = _build_message(raw_body, received_at)
|
||||||
|
|
||||||
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос
|
client = TelegramClient(settings.telegram_bot_token)
|
||||||
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
|
|
||||||
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
|
|
||||||
client = get_telegram_client()
|
|
||||||
try:
|
try:
|
||||||
async with asyncio.timeout(_INTERACTIVE_SEND_DEADLINE_S):
|
await client.send_message(
|
||||||
await client.send_message(
|
chat_id=settings.telegram_alerts_chat_id,
|
||||||
chat_id=settings.telegram_alerts_chat_id,
|
text=text,
|
||||||
text=text,
|
message_thread_id=settings.telegram_alerts_topic_id or None,
|
||||||
message_thread_id=settings.telegram_alerts_topic_id or None,
|
# review H1-style бюджет (см. support.py) — синхронный HTTP-путь не
|
||||||
# review H1-style бюджет (см. support.py) — синхронный HTTP-путь не
|
# может легально висеть воркерные минуты ретраев.
|
||||||
# может легально висеть воркерные минуты ретраев.
|
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
||||||
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
|
||||||
)
|
|
||||||
except (TelegramError, TimeoutError):
|
|
||||||
# Ловим общий предок, а не `TelegramApiError`: недоступность Telegram —
|
|
||||||
# тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не
|
|
||||||
# 500 из необработанного исключения (#3456). `TimeoutError` — наш потолок
|
|
||||||
# `_INTERACTIVE_SEND_DEADLINE_S`, путь тот же (#3157). 502 ОСТАЁТСЯ — это честный
|
|
||||||
# сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого
|
|
||||||
# текст алерта пропал бы бесследно, поэтому доставку ставим в фон
|
|
||||||
# (#3471, см. app.tasks.glitchtip_alert_retry).
|
|
||||||
#
|
|
||||||
# `raise HTTPException` здесь НЕ подходит: FastAPI прикрепляет
|
|
||||||
# background-задачи только к ответу, который вернул сам хендлер, а
|
|
||||||
# исключение строит СВОЙ отдельный JSONResponse в exception-мидлваре —
|
|
||||||
# задача, поставленная до `raise`, никогда бы не выполнилась. Поэтому
|
|
||||||
# 502 собран и возвращён вручную, с задачей на этом же объекте ответа.
|
|
||||||
logger.exception("glitchtip webhook: не удалось переслать алерт в Telegram")
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=502,
|
|
||||||
content={"detail": "failed to forward alert to telegram"},
|
|
||||||
background=BackgroundTask(
|
|
||||||
retry_forward_alert,
|
|
||||||
client,
|
|
||||||
chat_id=settings.telegram_alerts_chat_id,
|
|
||||||
text=text,
|
|
||||||
message_thread_id=settings.telegram_alerts_topic_id or None,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
except TelegramApiError:
|
||||||
|
logger.exception("glitchtip webhook: не удалось переслать алерт в Telegram")
|
||||||
|
raise HTTPException(status_code=502, detail="failed to forward alert to telegram") from None
|
||||||
|
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,9 @@
|
||||||
|
|
||||||
POST /api/v1/trade-in/lead — контактная заявка с результата оценки:
|
POST /api/v1/trade-in/lead — контактная заявка с результата оценки:
|
||||||
телефон + явное согласие на обработку персональных данных. Persist в
|
телефон + явное согласие на обработку персональных данных. Persist в
|
||||||
trade_in_leads.
|
trade_in_leads. Notification (Telegram/email) — вне scope: нет существующей
|
||||||
|
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
|
||||||
Уведомление ответственному (#1971): после ответа клиенту (BackgroundTasks) лид
|
persist + log; `notified_at` в таблице зарезервирован под будущую доставку.
|
||||||
уходит сообщением в support-топик Telegram — тот же бот и топик, что у веб-чата
|
|
||||||
поддержки (`app.api.v1.support`). До этого заявки только ложились в таблицу, и
|
|
||||||
никто о них не узнавал. Успешная отправка проставляет `notified_at`; отказ
|
|
||||||
Telegram не трогает ни ответ, ни лид — остаётся лог и пустой `notified_at` как
|
|
||||||
видимый след недоставки. Телефона в сообщении НЕТ: копия в Telegram не стирается
|
|
||||||
механизмом удаления ПДн (`data_erasure.py`), поэтому туда идут только id —
|
|
||||||
телефон оператор берёт из trade_in_leads.
|
|
||||||
|
|
||||||
IDOR-фикс (security-audit): `estimate_id` раньше только проверялся на
|
IDOR-фикс (security-audit): `estimate_id` раньше только проверялся на
|
||||||
СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца — любой
|
СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца — любой
|
||||||
|
|
@ -32,17 +25,14 @@ from datetime import UTC, datetime, timedelta
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.v1.support import _bot_configured
|
|
||||||
from app.api.v1.trade_in import _assert_estimate_access
|
from app.api.v1.trade_in import _assert_estimate_access
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import SessionLocal, get_db
|
from app.core.db import get_db
|
||||||
from app.observability.metrics import LEADS
|
|
||||||
from app.services.tgbot.shared import get_telegram_client
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -63,19 +53,12 @@ _PHONE_MAX_DIGITS = 15
|
||||||
# только в audit-лог (#2497 TODO, теперь закрыт).
|
# только в audit-лог (#2497 TODO, теперь закрыт).
|
||||||
#
|
#
|
||||||
# Значение = дата утверждения политики (PRIVACY_APPROVAL в frontend/src/app/
|
# Значение = дата утверждения политики (PRIVACY_APPROVAL в frontend/src/app/
|
||||||
# mera-public/content.ts: «приказом директора № 2 от 10 сентября 2026 г.» →
|
# mera-public/content.ts: «приказом директора № 1 от 13 августа 2026 г.» →
|
||||||
# "2026-09-10"), а не дата этого коммита — версия обязана указывать на редакцию
|
# "2026-08-13"), а не дата этого коммита — версия обязана указывать на редакцию
|
||||||
# ДОКУМЕНТА, на который согласие фактически ссылается (чекбокс теперь линкует
|
# ДОКУМЕНТА, на который согласие фактически ссылается (чекбокс теперь линкует
|
||||||
# именно на /mera-public/privacy). test_consent_text_frontend_sync.py проверяет
|
# именно на /mera-public/privacy). test_consent_text_frontend_sync.py проверяет
|
||||||
# это соответствие автоматически, так что рассинхронизация здесь падает в CI.
|
# это соответствие автоматически, так что рассинхронизация здесь падает в CI.
|
||||||
#
|
_CONSENT_POLICY_VERSION = "2026-08-13"
|
||||||
# Редакция № 2 от 10.09.2026: в политику добавлен раздел 9 «Файлы cookie и
|
|
||||||
# веб-аналитика» (на публичный сайт поставлены Яндекс.Метрика и GA4), прежний
|
|
||||||
# раздел «Контакты» стал десятым. Согласия, собранные до этой даты, ссылаются
|
|
||||||
# на редакцию № 1 и остаются с версией "2026-08-13" — в этом и смысл хранить
|
|
||||||
# версию per-row: снимок согласия должен указывать на тот документ, который
|
|
||||||
# человек видел, а не на текущий.
|
|
||||||
_CONSENT_POLICY_VERSION = "2026-09-10"
|
|
||||||
|
|
||||||
# Снимок точного текста согласия, который видит пользователь при отправке лида
|
# Снимок точного текста согласия, который видит пользователь при отправке лида
|
||||||
# (ПЛОСКИЙ текст — без разметки ссылки на политику, которая в LeadForm.tsx рядом
|
# (ПЛОСКИЙ текст — без разметки ссылки на политику, которая в LeadForm.tsx рядом
|
||||||
|
|
@ -108,49 +91,11 @@ class TradeInLeadInput(BaseModel):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
async def _notify_new_lead(lead_id: str, estimate_id: UUID | None, username: str | None) -> None:
|
|
||||||
"""Сообщает о заявке в support-топик и отмечает `notified_at` (#1971).
|
|
||||||
|
|
||||||
Идёт после ответа клиенту, поэтому ретраи клиента — штатные воркерные, без
|
|
||||||
интерактивного бюджета (как `app.tasks.glitchtip_alert_retry`). Сессия своя:
|
|
||||||
сессия запроса к этому моменту уже закрыта. Любой отказ — только в лог.
|
|
||||||
"""
|
|
||||||
message = (
|
|
||||||
"Новая заявка на трейд-ин\n"
|
|
||||||
f"id: {lead_id}\n"
|
|
||||||
f"пользователь: {username or '—'}\n"
|
|
||||||
f"оценка: {estimate_id or 'без привязки'}\n"
|
|
||||||
"Телефон — в trade_in_leads по id."
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await get_telegram_client().send_message(
|
|
||||||
chat_id=settings.telegram_support_chat_id,
|
|
||||||
text=message,
|
|
||||||
message_thread_id=settings.telegram_support_topic_id or None,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("trade_in_lead: уведомление не доставлено id=%s", lead_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
db.execute(
|
|
||||||
text("UPDATE trade_in_leads SET notified_at = now() WHERE id = CAST(:id AS uuid)"),
|
|
||||||
{"id": lead_id},
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
except Exception:
|
|
||||||
logger.exception("trade_in_lead: уведомление ушло, notified_at не записан id=%s", lead_id)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/lead")
|
@router.post("/lead")
|
||||||
async def create_trade_in_lead(
|
async def create_trade_in_lead(
|
||||||
payload: TradeInLeadInput,
|
payload: TradeInLeadInput,
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Сохраняет лид (телефон + согласие) в trade_in_leads."""
|
"""Сохраняет лид (телефон + согласие) в trade_in_leads."""
|
||||||
|
|
@ -221,7 +166,6 @@ async def create_trade_in_lead(
|
||||||
)
|
)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
LEADS.inc()
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"trade_in_lead saved id=%s estimate_id=%s source=%s ip=%s policy=%s",
|
"trade_in_lead saved id=%s estimate_id=%s source=%s ip=%s policy=%s",
|
||||||
|
|
@ -232,15 +176,6 @@ async def create_trade_in_lead(
|
||||||
_CONSENT_POLICY_VERSION,
|
_CONSENT_POLICY_VERSION,
|
||||||
)
|
)
|
||||||
|
|
||||||
if _bot_configured():
|
|
||||||
background_tasks.add_task(
|
|
||||||
_notify_new_lead, row["id"], payload.estimate_id, x_authenticated_user
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"trade_in_lead: бот не настроен, уведомление о id=%s не отправлено", row["id"]
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"created_at": row["created_at"].isoformat(),
|
"created_at": row["created_at"].isoformat(),
|
||||||
|
|
|
||||||
|
|
@ -187,52 +187,12 @@ _ABANDONABLE_STATUSES = frozenset({"NEW", "FORM_SHOWED"})
|
||||||
# списания требуют, чтобы человек намеренно оплатил обе формы.
|
# списания требуют, чтобы человек намеренно оплатил обе формы.
|
||||||
_ABANDONED_AFTER_MINUTES = 30
|
_ABANDONED_AFTER_MINUTES = 30
|
||||||
|
|
||||||
# Статус для строки, чей Init отвалился: своего кода вроде INIT_FAILED в
|
|
||||||
# CHECK миграции 233 нет, а заводить его ради этого случая значило бы менять
|
|
||||||
# схему ради ярлыка — «почему» и так лежит в error_code/error_message. Берём
|
|
||||||
# терминальный DEADLINE_EXPIRED, которым checkout уже помечает попытки, из
|
|
||||||
# которых платёж не выйдет сам. Обязательное свойство ровно одно: статус ВНЕ
|
|
||||||
# предиката 279 (= вне _REUSABLE_STATUSES), иначе мёртвая строка продолжит
|
|
||||||
# держать пару (estimate_id, product_code). Это стережёт
|
|
||||||
# tests/test_payments_router.py::test_init_failed_status_is_terminal.
|
|
||||||
_INIT_FAILED_STATUS = "DEADLINE_EXPIRED"
|
|
||||||
|
|
||||||
_ORDER_ID_PREFIX = "mera-"
|
_ORDER_ID_PREFIX = "mera-"
|
||||||
# 32 байта энтропии (43 символа base64url) — перебор capability-ссылки
|
# 32 байта энтропии (43 символа base64url) — перебор capability-ссылки
|
||||||
# неосуществим, а сама ссылка остаётся кликабельной в мессенджере.
|
# неосуществим, а сама ссылка остаётся кликабельной в мессенджере.
|
||||||
_REPORT_TOKEN_BYTES = 32
|
_REPORT_TOKEN_BYTES = 32
|
||||||
|
|
||||||
|
|
||||||
def _mark_init_failed(db: Session, order_id: str, *, code: str, message: str) -> None:
|
|
||||||
"""Переводит строку провалившегося Init в терминальный статус + «почему».
|
|
||||||
|
|
||||||
Общая для ВСЕХ исходов, после которых ссылки у строки не будет: отказ банка
|
|
||||||
и Success:true без PaymentURL. Статус NEW тут оставлять нельзя — см.
|
|
||||||
комментарий к `_INIT_FAILED_STATUS`: строка без `payment_url` невидима для
|
|
||||||
`_find_live_payment`, но видима предикату UNIQUE миграции 279, и следующий
|
|
||||||
checkout получил бы ложный 409 на все `_ABANDONED_AFTER_MINUTES`.
|
|
||||||
"""
|
|
||||||
db.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
UPDATE payments
|
|
||||||
SET status = :status,
|
|
||||||
error_code = :code,
|
|
||||||
error_message = :message,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE order_id = :order_id
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"status": _INIT_FAILED_STATUS,
|
|
||||||
"code": code[:64],
|
|
||||||
"message": message[:500],
|
|
||||||
"order_id": order_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _require_enabled() -> None:
|
def _require_enabled() -> None:
|
||||||
"""Kill-switch контура. 503, а не 404: путь существует, приём оплаты выключен."""
|
"""Kill-switch контура. 503, а не 404: путь существует, приём оплаты выключен."""
|
||||||
if not settings.payments_enabled:
|
if not settings.payments_enabled:
|
||||||
|
|
@ -429,17 +389,21 @@ def checkout(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except TBankApiError as exc:
|
except TBankApiError as exc:
|
||||||
# Запись остаётся в БД с текстом ошибки — иначе факт попытки (и заказа,
|
# Запись остаётся в БД со статусом NEW и текстом ошибки — иначе факт
|
||||||
# который банк мог принять до обрыва) не остался бы нигде. Холда здесь
|
# попытки (и возможного холда, если обрыв случился после приёма запроса
|
||||||
# быть не может: Init только заводит заказ и отдаёт ссылку на форму, а
|
# банком) не остался бы нигде. Слепой повтор Init по тому же order_id
|
||||||
# авторизация суммы происходит, когда покупатель платит по форме — её
|
|
||||||
# ему не выдавали. Слепой повтор Init по тому же order_id всё равно
|
|
||||||
# запрещён (см. докстринг init_payment) — это работа реконсиляции.
|
# запрещён (см. докстринг init_payment) — это работа реконсиляции.
|
||||||
#
|
db.execute(
|
||||||
# Статус — терминальный (см. `_mark_init_failed`); если банк всё же
|
text(
|
||||||
# пришлёт по этой строке нотификацию, статус-машина notify доведёт её до
|
"""
|
||||||
# конца (терминальный статус не блокирует CONFIRMED).
|
UPDATE payments
|
||||||
_mark_init_failed(db, order_id, code=exc.error_code, message=str(exc))
|
SET error_code = :code, error_message = :message, updated_at = NOW()
|
||||||
|
WHERE order_id = :order_id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"code": exc.error_code[:64], "message": str(exc)[:500], "order_id": order_id},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
logger.warning("checkout: Init отклонён банком, order_id=%s: %s", order_id, exc)
|
logger.warning("checkout: Init отклонён банком, order_id=%s: %s", order_id, exc)
|
||||||
raise HTTPException(status_code=502, detail="payment provider error") from exc
|
raise HTTPException(status_code=502, detail="payment provider error") from exc
|
||||||
|
|
||||||
|
|
@ -447,15 +411,7 @@ def checkout(
|
||||||
tbank_payment_id = init.get("PaymentId")
|
tbank_payment_id = init.get("PaymentId")
|
||||||
if not isinstance(payment_url, str) or not payment_url:
|
if not isinstance(payment_url, str) or not payment_url:
|
||||||
# Success:true без PaymentURL — контракт банка нарушен; выдумывать
|
# Success:true без PaymentURL — контракт банка нарушен; выдумывать
|
||||||
# ссылку нечем. Замок тот же, что и у отказа выше, и даже вернее: банк
|
# ссылку нечем.
|
||||||
# заказ ПРИНЯЛ, а ссылки у строки уже не будет — оставить её в NEW
|
|
||||||
# значит отдать следующему checkout ложный 409 на 30 минут.
|
|
||||||
_mark_init_failed(
|
|
||||||
db,
|
|
||||||
order_id,
|
|
||||||
code="no_payment_url",
|
|
||||||
message=f"Init: Success без PaymentURL (Status={init.get('Status')!r})",
|
|
||||||
)
|
|
||||||
logger.error("checkout: Init без PaymentURL, order_id=%s", order_id)
|
logger.error("checkout: Init без PaymentURL, order_id=%s", order_id)
|
||||||
raise HTTPException(status_code=502, detail="payment provider returned no payment url")
|
raise HTTPException(status_code=502, detail="payment provider returned no payment url")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ module docstring (честно про то, что не всегда разре
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
@ -22,7 +23,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.db import get_db, run_db_thread
|
from app.core.db import get_db
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -66,7 +67,7 @@ async def erase_person_data_endpoint(
|
||||||
|
|
||||||
from app.services.data_erasure import erase_person_data
|
from app.services.data_erasure import erase_person_data
|
||||||
|
|
||||||
counters = await run_db_thread(
|
counters = await asyncio.to_thread(
|
||||||
erase_person_data,
|
erase_person_data,
|
||||||
db,
|
db,
|
||||||
username=payload.username,
|
username=payload.username,
|
||||||
|
|
|
||||||
|
|
@ -62,23 +62,18 @@ import hashlib
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
||||||
from app.observability.metrics import SUPPORT_MESSAGES
|
|
||||||
from app.services.tgbot import web_support_storage as storage
|
from app.services.tgbot import web_support_storage as storage
|
||||||
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
|
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
|
||||||
from app.services.tgbot.client import TelegramError
|
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||||
from app.services.tgbot.shared import get_telegram_client
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -127,58 +122,10 @@ _INTERACTIVE_SEND_TIMEOUT_S = 5.0
|
||||||
_INTERACTIVE_SEND_MAX_RETRIES = 3
|
_INTERACTIVE_SEND_MAX_RETRIES = 3
|
||||||
_INTERACTIVE_SEND_MAX_BACKOFF_S = 1.0
|
_INTERACTIVE_SEND_MAX_BACKOFF_S = 1.0
|
||||||
|
|
||||||
# Счётчик ОТКАЗОВ отправки — отдельный от бюджетов выше (#tgsupport-fail-cooldown).
|
|
||||||
#
|
|
||||||
# Зачем вообще второй счётчик. `_send_limiter`/`_anon_ip_limiter` расходуются
|
|
||||||
# ТОЛЬКО на успехе (review L3, non-destructive peek выше) — это верно для
|
|
||||||
# «не наказывать за чужую аварию», но имеет обратную сторону: пока Telegram
|
|
||||||
# недоступен, лимита нет ВООБЩЕ. Каждый повтор пользователя при этом стоит до
|
|
||||||
# `1 + _INTERACTIVE_SEND_MAX_RETRIES` = 4 попыток к api.telegram.org и не
|
|
||||||
# расходует ни один бюджет. Двух-трёх вкладок с авто-ретраем хватает, чтобы
|
|
||||||
# выесть лимиты группы ровно в тот момент, когда канал и так еле жив.
|
|
||||||
#
|
|
||||||
# Отсюда — дешёвый gate ПЕРЕД походом в Telegram, на своих ключах (тех же, что
|
|
||||||
# у основных лимитеров: username / anon-thread-key и client IP).
|
|
||||||
#
|
|
||||||
# N=5. Отказ одной отправки — не событие: по замеру #tgsupport-retry доля отказов
|
|
||||||
# на попытку 15-38%, но после 4 попыток до пользователя доходит ~0.8-2% отказов.
|
|
||||||
# Пять подряд на живом канале — вероятность порядка 1e-10, то есть cooldown
|
|
||||||
# физически не может сработать на «просто не повезло»; он срабатывает только на
|
|
||||||
# настоящей недоступности. Плюс `reset()` на успехе: считаем именно ПОДРЯД, одна
|
|
||||||
# успешная отправка стирает историю.
|
|
||||||
#
|
|
||||||
# 30с окна (оно же длительность cooldown: блок держится, пока самый старый из
|
|
||||||
# N отказов не выпадет из окна). Верхняя граница осмысленности — реальная
|
|
||||||
# недоступность Telegram по тому же замеру длится минутами, так что 30с заведомо
|
|
||||||
# короче и пользователя после восстановления канала не наказывают. Нижняя —
|
|
||||||
# один отказавший интерактивный запрос сам по себе занимает до 23с
|
|
||||||
# (4 попытки × 5с + 3 паузы × 1с), cooldown короче этого просто не имел бы смысла.
|
|
||||||
_SEND_FAILURE_LIMIT = 5
|
|
||||||
_SEND_FAILURE_WINDOW_S = 30.0
|
|
||||||
_send_failure_limiter = SlidingWindowLimiter(
|
|
||||||
limit=_SEND_FAILURE_LIMIT, window_s=_SEND_FAILURE_WINDOW_S
|
|
||||||
)
|
|
||||||
# Отдельный экземпляр для IP-ключей — ровно как `_anon_ip_limiter` отдельный от
|
|
||||||
# `_send_limiter`: ключи разных пространств (IP vs thread-key) в одном словаре
|
|
||||||
# смешивать нельзя.
|
|
||||||
_anon_ip_failure_limiter = SlidingWindowLimiter(
|
|
||||||
limit=_SEND_FAILURE_LIMIT, window_s=_SEND_FAILURE_WINDOW_S
|
|
||||||
)
|
|
||||||
|
|
||||||
_SEND_UNAVAILABLE_DETAIL = "Telegram сейчас недоступен. Попробуйте через полминуты."
|
|
||||||
|
|
||||||
# #tgsupport-web review M5: без LIMIT каждое монтирование виджета на старом
|
# #tgsupport-web review M5: без LIMIT каждое монтирование виджета на старом
|
||||||
# треде отдавало бы ВЕСЬ лог переписки. См. `web_support_storage.list_messages`.
|
# треде отдавало бы ВЕСЬ лог переписки. См. `web_support_storage.list_messages`.
|
||||||
_LIST_MESSAGES_LIMIT = 200
|
_LIST_MESSAGES_LIMIT = 200
|
||||||
|
|
||||||
# Идемпотентность отправки (#3471 retry-storm) — см. `_resolve_idempotency_key`.
|
|
||||||
_IDEMPOTENCY_HEADER = "idempotency-key"
|
|
||||||
# Форма клиентского ключа — как у anon-токена (`_ANON_TOKEN_RE`): произвольная
|
|
||||||
# opaque-строка клиента, без пробелов/спецсимволов, которые попали бы в SQL-параметр
|
|
||||||
# как есть. Не матчится — считаем заголовок отсутствующим и уходим на fallback,
|
|
||||||
# а не пытаемся его "починить" (тот же принцип, что у `_read_anon_token`).
|
|
||||||
_CLIENT_IDEMPOTENCY_KEY_RE = re.compile(r"^[A-Za-z0-9_.-]{8,128}\Z")
|
|
||||||
|
|
||||||
|
|
||||||
def _require_username(request: Request) -> str:
|
def _require_username(request: Request) -> str:
|
||||||
"""Достаёт X-Authenticated-User. rbac_guard (app/main.py) уже гарантирует его
|
"""Достаёт X-Authenticated-User. rbac_guard (app/main.py) уже гарантирует его
|
||||||
|
|
@ -218,18 +165,6 @@ class SupportMessageOut(BaseModel):
|
||||||
text_body: str
|
text_body: str
|
||||||
operator_tg_id: int | None = None
|
operator_tg_id: int | None = None
|
||||||
created_at: str
|
created_at: str
|
||||||
# Дошло ли сообщение до БД. `True` по умолчанию — все существующие пути
|
|
||||||
# (`GET /support/messages`, успешный POST) строят модель из storage-строки и
|
|
||||||
# ничего про флаг не знают, контракт для них не меняется.
|
|
||||||
#
|
|
||||||
# `False` ставит ТОЛЬКО `_unpersisted_message_out`: доставлено оператору, но
|
|
||||||
# не записано. Флаг нужен потому, что без него деградация неотличима от
|
|
||||||
# тишины: фронт выбрасывает тело POST и рендерит транскрипт исключительно из
|
|
||||||
# `GET /support/messages` (`useSupportChat.ts`), где этого сообщения нет —
|
|
||||||
# поле ввода очищается, сообщение не появляется, ошибки нет. Пользователь
|
|
||||||
# решает, что не отправилось, и шлёт снова — ровно тот дубль в топике,
|
|
||||||
# против которого вся эта ветка и сделана.
|
|
||||||
persisted: bool = True
|
|
||||||
|
|
||||||
@field_validator("created_at", mode="before")
|
@field_validator("created_at", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -255,150 +190,8 @@ def _format_mirror_text(username: str, message_text: str) -> str:
|
||||||
return f"[С САЙТА] {username}:\n{message_text}"
|
return f"[С САЙТА] {username}:\n{message_text}"
|
||||||
|
|
||||||
|
|
||||||
def _too_many_failures_error(retry_after: float) -> HTTPException:
|
|
||||||
"""429 вместо похода в Telegram — канал только что отказал N раз подряд."""
|
|
||||||
return HTTPException(
|
|
||||||
status_code=429,
|
|
||||||
detail=_SEND_UNAVAILABLE_DETAIL,
|
|
||||||
headers={"Retry-After": str(int(retry_after) + 1)},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _unpersisted_message_out(text_body: str) -> SupportMessageOut:
|
|
||||||
"""Синтетический ответ для случая «в топик доставлено, а в БД не записано».
|
|
||||||
|
|
||||||
Почему ответ вообще УСПЕШНЫЙ. Сообщение оператору реально доставлено —
|
|
||||||
отдать на это ошибку значит соврать: пользователь повторит, и в топике
|
|
||||||
окажется дубль (плюс второе предупреждение оператору). Успех здесь честнее
|
|
||||||
отказа, но он неполный, и об этом клиенту надо сказать явно.
|
|
||||||
|
|
||||||
Что сообщает `persisted=False`: «доставлено, но ответить тебе через чат не
|
|
||||||
смогут; повторять не надо». Именно флаг — контракт для клиента, а НЕ `id=0`:
|
|
||||||
по идентификатору клиент отличить деградацию не обязан и не будет.
|
|
||||||
|
|
||||||
`id` при этом всё равно нужен модели, и 0 — сознательный сентинел, а не
|
|
||||||
выдумка: реальные id в `web_support_messages` начинаются с 1 (serial),
|
|
||||||
поэтому 0 ни с чем не столкнётся, а `GET /support/messages` принимает
|
|
||||||
`since>=0`. Даже если фронт когда-нибудь начнёт курсорить по возвращённому
|
|
||||||
id (сейчас он перечитывает тред целиком с `since=0`, `useSupportChat.ts`),
|
|
||||||
нулевой курсор не перепрыгнет ни одного реального сообщения: занижение
|
|
||||||
курсора безопасно, завышение — нет.
|
|
||||||
"""
|
|
||||||
return SupportMessageOut(
|
|
||||||
id=0,
|
|
||||||
direction="in",
|
|
||||||
text_body=text_body,
|
|
||||||
operator_tg_id=None,
|
|
||||||
created_at=datetime.now(UTC),
|
|
||||||
persisted=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _warn_operator_message_not_recorded(*, label: str, topic_message_id: int | None) -> None:
|
|
||||||
"""Реплаем к только что доставленному зеркалу предупреждает оператора, что
|
|
||||||
ответ на ЭТО сообщение не смаршрутизируется: треда в БД нет, на реплай к
|
|
||||||
осиротевшему зеркалу `bridge._handle_group_reply` напишет только WARNING, а
|
|
||||||
клиент не увидит ничего. Без предупреждения оператор отвечает в пустоту и
|
|
||||||
считает, что помог.
|
|
||||||
|
|
||||||
Текст обращения сюда НЕ дублируется (ПДн) — оператор видит его в сообщении,
|
|
||||||
к которому это реплай.
|
|
||||||
|
|
||||||
Формулировка учитывает, что клиенту ответили успехом (`persisted=False`,
|
|
||||||
«принято, повторять не надо»): рассчитывать на «клиент напишет снова»
|
|
||||||
оператору нельзя, поднимать тред придётся иначе.
|
|
||||||
|
|
||||||
Любой отказ гасится логом: основное сообщение УЖЕ доставлено, превращать
|
|
||||||
неудачу служебного уведомления в 500 поверх успеха нельзя.
|
|
||||||
"""
|
|
||||||
text = (
|
|
||||||
f"ВНИМАНИЕ · {label}: сообщение доставлено в топик, но НЕ записано в базу "
|
|
||||||
"(сбой БД). Треда у этого обращения нет — ответить клиенту через бота "
|
|
||||||
"НЕЛЬЗЯ: реплай на это сообщение никуда не уйдёт. Повтора тоже не ждите, "
|
|
||||||
"клиенту показано, что сообщение принято и отправлять его снова не нужно. "
|
|
||||||
"Если в обращении есть контакт — свяжитесь напрямую; иначе передайте "
|
|
||||||
"дежурному и сообщите об отказе БД."
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
client = get_telegram_client()
|
|
||||||
await client.send_message(
|
|
||||||
chat_id=settings.telegram_support_chat_id,
|
|
||||||
text=text,
|
|
||||||
message_thread_id=settings.telegram_support_topic_id or None,
|
|
||||||
# reply_to может отсутствовать (sendMessage не вернул message_id) —
|
|
||||||
# тогда уведомление уходит отдельным сообщением в топик: хуже, чем
|
|
||||||
# реплай, но несравнимо лучше тишины.
|
|
||||||
reply_to_message_id=topic_message_id,
|
|
||||||
# Тот же узкий интерактивный бюджет (review H1): клиент ждёт ответа
|
|
||||||
# ручки, а не доставки служебного уведомления.
|
|
||||||
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
|
||||||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
|
||||||
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
# Шире `TelegramError` намеренно: это best-effort хвост уже успешного
|
|
||||||
# запроса, любой отказ здесь обязан остаться в логе, а не у клиента.
|
|
||||||
logger.exception(
|
|
||||||
"web support: не удалось предупредить оператора о несохранённом сообщении (%s)",
|
|
||||||
label,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _rollback_quietly(db: Session) -> None:
|
|
||||||
"""Откат после `SQLAlchemyError`. Сам rollback на мёртвом соединении тоже
|
|
||||||
может бросить, а мы уже решили отдать клиенту успех — `get_db` в любом
|
|
||||||
случае закроет сессию в `finally`."""
|
|
||||||
try:
|
|
||||||
db.rollback()
|
|
||||||
except SQLAlchemyError:
|
|
||||||
logger.warning("web support: rollback после сбоя БД тоже не удался", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_idempotency_key(request: Request, *, identity_key: str, text: str) -> str:
|
|
||||||
"""Ключ идемпотентности inbound-отправки (#3471, миграция 301).
|
|
||||||
|
|
||||||
Почему заголовок + fallback, а не что-то одно. Явный `Idempotency-Key` —
|
|
||||||
ЕДИНСТВЕННЫЙ надёжный путь: клиент генерирует ключ ОДИН раз на "намерение
|
|
||||||
отправить" и переиспользует его на любом ретрае (fetch retry / переотправка
|
|
||||||
после таймаута) независимо от того, что именно менялось в UI между
|
|
||||||
попытками (см. `useSendSupportMessage` во фронтенде — реальный трафик
|
|
||||||
обязан идти этим путём). Fallback без заголовка — детерминированный
|
|
||||||
отпечаток sha256(identity, текст, минутное окно) — существует ТОЛЬКО для
|
|
||||||
клиентов, которые заголовок не прислали (п.5 требования — старое поведение
|
|
||||||
не должно сломаться), и у него два честных изъяна, оба снимаются самим
|
|
||||||
фактом использования заголовка:
|
|
||||||
1. два РАЗНЫХ по смыслу сообщения с одинаковым текстом от одного и того
|
|
||||||
же человека в течение одной минуты ("да" и ещё раз "да", "+", "ок")
|
|
||||||
СХЛОПНУТСЯ в одно — второе будет молча проглочено, клиент получит id
|
|
||||||
первого, оператор не увидит второе сообщение вообще;
|
|
||||||
2. окно — не скользящие 60 секунд, а округление `time.time() // 60` вниз:
|
|
||||||
фактический срок дедупликации случаен от 0 до 60с в зависимости от
|
|
||||||
момента внутри минуты, и часть настоящих повторов (ретрай ровно на
|
|
||||||
границе окна) эту защиту не получит.
|
|
||||||
Оба пункта перестают быть важны, когда клиент реально шлёт заголовок
|
|
||||||
(см. `useSendSupportMessage`).
|
|
||||||
|
|
||||||
Хранит и потенциально логирует (см. вызовы в этом файле) только САМ ключ —
|
|
||||||
он либо непрозрачный клиентский токен, либо хэш. Текст сообщения сюда
|
|
||||||
попадает ТОЛЬКО как вход в sha256, в открытом виде не сохраняется и не
|
|
||||||
возвращается — жёсткое правило проекта "текст не в логах" (ПДн) при этом
|
|
||||||
не нарушается, даже если бы этот ключ где-то залогировали.
|
|
||||||
|
|
||||||
Префиксы `client:`/`auto:` разводят два namespace'а ключей (защита от
|
|
||||||
случайного совпадения клиентского токена с fallback-хэшем) — дёшево и не
|
|
||||||
требует отдельной колонки.
|
|
||||||
"""
|
|
||||||
header = (request.headers.get(_IDEMPOTENCY_HEADER) or "").strip()
|
|
||||||
if header and _CLIENT_IDEMPOTENCY_KEY_RE.match(header):
|
|
||||||
return f"client:{header}"
|
|
||||||
window = int(time.time() // 60)
|
|
||||||
fingerprint = f"{identity_key}|{text}|{window}"
|
|
||||||
return "auto:" + hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/support/messages", response_model=SupportMessageOut)
|
@router.post("/support/messages", response_model=SupportMessageOut)
|
||||||
async def send_support_message(
|
async def send_support_message(
|
||||||
request: Request,
|
|
||||||
payload: SupportMessageInput,
|
payload: SupportMessageInput,
|
||||||
username: Annotated[str, Depends(_require_username)],
|
username: Annotated[str, Depends(_require_username)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
|
@ -426,47 +219,7 @@ async def send_support_message(
|
||||||
headers={"Retry-After": str(int(retry_after) + 1)},
|
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cooldown по ОТКАЗАМ (см. `_SEND_FAILURE_LIMIT`): канал только что отказал
|
client = TelegramClient(settings.telegram_bot_token)
|
||||||
# N раз подряд — не тратим на этот запрос ещё четыре попытки к Telegram.
|
|
||||||
cooldown = _send_failure_limiter.retry_after(username)
|
|
||||||
if cooldown is not None:
|
|
||||||
raise _too_many_failures_error(cooldown)
|
|
||||||
|
|
||||||
# Идемпотентность (#3471). Что именно закрывает этот pre-check — важно не
|
|
||||||
# переоценить: строка в БД (и её idempotency_key) появляется ТОЛЬКО ПОСЛЕ
|
|
||||||
# успешной доставки в Telegram (H1 ниже), поэтому потерю самого запроса на
|
|
||||||
# плече Selectel -> api.telegram.org этот механизм НЕ дедуплицирует — если
|
|
||||||
# `send_message` не удался, ключ нигде не записан, и повтор клиента после
|
|
||||||
# неудачи это законная первая попытка. Реально закрывается другой, тоже
|
|
||||||
# частый случай: доставка УЖЕ состоялась (Telegram принял, строка
|
|
||||||
# закоммичена), но ответ до клиента не дошёл (обрыв на обратном пути,
|
|
||||||
# клиентский таймаут) или пользователь кликнул "отправить" второй раз по
|
|
||||||
# той же ещё не отрисовавшейся отправке — тогда pre-check находит уже
|
|
||||||
# записанное сообщение по ключу и отдаёт его же, не уходя в Telegram снова.
|
|
||||||
# Тред может ещё не существовать (это первая попытка этого ключа) — тогда
|
|
||||||
# сравнивать не с чем, идём в Telegram как обычно; сам факт "нет треда"
|
|
||||||
# здесь безопасен, т.к. тред создаётся ТОЛЬКО в этой же ручке (см. H1) —
|
|
||||||
# если бы сообщение под этим ключом уже было записано, тред уже был бы.
|
|
||||||
idempotency_key = _resolve_idempotency_key(request, identity_key=username, text=payload.text)
|
|
||||||
existing_thread_id = storage.find_thread_id(db, username)
|
|
||||||
if existing_thread_id is not None:
|
|
||||||
existing = storage.find_inbound_by_idempotency_key(
|
|
||||||
db, thread_id=existing_thread_id, idempotency_key=idempotency_key
|
|
||||||
)
|
|
||||||
if existing is not None:
|
|
||||||
logger.info(
|
|
||||||
"web support: repeat send (idempotency key already recorded) "
|
|
||||||
"username=%s thread_id=%d message_id=%s",
|
|
||||||
username,
|
|
||||||
existing_thread_id,
|
|
||||||
existing["id"],
|
|
||||||
)
|
|
||||||
return SupportMessageOut(**existing)
|
|
||||||
|
|
||||||
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос
|
|
||||||
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
|
|
||||||
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
|
|
||||||
client = get_telegram_client()
|
|
||||||
try:
|
try:
|
||||||
mirrored = await client.send_message(
|
mirrored = await client.send_message(
|
||||||
chat_id=settings.telegram_support_chat_id,
|
chat_id=settings.telegram_support_chat_id,
|
||||||
|
|
@ -477,22 +230,16 @@ async def send_support_message(
|
||||||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||||
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
|
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
|
||||||
)
|
)
|
||||||
except TelegramError:
|
except TelegramApiError:
|
||||||
# НЕ логируем payload.text (переписка — ПДн) и НЕ логируем токен (его в
|
# НЕ логируем payload.text (переписка — ПДн) и НЕ логируем токен (его в
|
||||||
# TelegramApiError и не бывает — см. client.py docstring про redaction).
|
# TelegramApiError и не бывает — см. client.py docstring про redaction).
|
||||||
# Предок, а не `TelegramApiError`: при таймауте до Telegram пользователь
|
|
||||||
# должен увидеть тот же «сервис недоступен», а не 500 (#3456).
|
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"web support: не удалось отправить зеркало в топик (username=%s)", username
|
"web support: не удалось отправить зеркало в топик (username=%s)", username
|
||||||
)
|
)
|
||||||
_send_failure_limiter.record(username)
|
|
||||||
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
|
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
|
||||||
|
|
||||||
# Отправка удалась — теперь и только теперь расходуем rate-limit бюджет.
|
# Отправка удалась — теперь и только теперь расходуем rate-limit бюджет.
|
||||||
_send_limiter.record(username)
|
_send_limiter.record(username)
|
||||||
SUPPORT_MESSAGES.labels(channel="web").inc()
|
|
||||||
# Канал жив — счётчик отказов считает именно ПОДРЯД идущие отказы.
|
|
||||||
_send_failure_limiter.reset(username)
|
|
||||||
|
|
||||||
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
|
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
|
||||||
if topic_message_id is None:
|
if topic_message_id is None:
|
||||||
|
|
@ -506,35 +253,15 @@ async def send_support_message(
|
||||||
)
|
)
|
||||||
|
|
||||||
# review H1: БД-операция ПОСЛЕ успешной отправки — см. docstring модуля.
|
# review H1: БД-операция ПОСЛЕ успешной отправки — см. docstring модуля.
|
||||||
#
|
thread_id = storage.get_or_create_thread(db, username)
|
||||||
# Оборотная сторона этого порядка: отказ БД здесь означает, что сообщение
|
row = storage.record_inbound(
|
||||||
# оператору УЖЕ доставлено. Отдавать на это 500 (как было) — худший из
|
db,
|
||||||
# вариантов: клиент видит ошибку, шлёт повторно, в топике дубль, а на
|
thread_id=thread_id,
|
||||||
# осиротевшее зеркало оператор отвечает в пустоту. Поэтому отвечаем успехом
|
text_body=payload.text,
|
||||||
# (доставка правда состоялась) и отдельным сообщением предупреждаем
|
topic_message_id=topic_message_id,
|
||||||
# оператора, что отвечать на это зеркало бесполезно.
|
support_chat_id=settings.telegram_support_chat_id,
|
||||||
try:
|
)
|
||||||
thread_id = storage.get_or_create_thread(db, username)
|
db.commit()
|
||||||
row = storage.record_inbound(
|
|
||||||
db,
|
|
||||||
thread_id=thread_id,
|
|
||||||
text_body=payload.text,
|
|
||||||
topic_message_id=topic_message_id,
|
|
||||||
support_chat_id=settings.telegram_support_chat_id,
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
except SQLAlchemyError:
|
|
||||||
# Ни текста обращения (ПДн), ни токена в логе — только кто и что сломалось.
|
|
||||||
logger.exception(
|
|
||||||
"web support: сообщение доставлено в топик, но не записано в БД (username=%s)",
|
|
||||||
username,
|
|
||||||
)
|
|
||||||
_rollback_quietly(db)
|
|
||||||
await _warn_operator_message_not_recorded(
|
|
||||||
label=f"[С САЙТА] {username}", topic_message_id=topic_message_id
|
|
||||||
)
|
|
||||||
return _unpersisted_message_out(payload.text)
|
|
||||||
|
|
||||||
logger.info("web support: message sent username=%s thread_id=%d", username, thread_id)
|
logger.info("web support: message sent username=%s thread_id=%d", username, thread_id)
|
||||||
return SupportMessageOut(**row)
|
return SupportMessageOut(**row)
|
||||||
|
|
@ -681,46 +408,8 @@ async def send_anon_support_message(
|
||||||
headers={"Retry-After": str(int(retry_after) + 1)},
|
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cooldown по ОТКАЗАМ (см. `_SEND_FAILURE_LIMIT`) — оба ключа, как и у
|
|
||||||
# бюджетов выше: per-token ловит одну вкладку с авто-ретраем, per-IP — ту же
|
|
||||||
# петлю после сброса куки.
|
|
||||||
for cooldown in (
|
|
||||||
_send_failure_limiter.retry_after(thread_key),
|
|
||||||
_anon_ip_failure_limiter.retry_after(ip),
|
|
||||||
):
|
|
||||||
if cooldown is not None:
|
|
||||||
raise _too_many_failures_error(cooldown)
|
|
||||||
|
|
||||||
display_id = _anon_display_id(token)
|
display_id = _anon_display_id(token)
|
||||||
|
client = TelegramClient(settings.telegram_bot_token)
|
||||||
# Идемпотентность (#3471) — см. развёрнутый комментарий в авторизованной
|
|
||||||
# ветке. Ограничение специфично для анонимной ветки: если предыдущая
|
|
||||||
# попытка сама провалилась ДО выдачи куки (`is_new_token=True` тогда и
|
|
||||||
# сейчас), identity_key каждый раз новый (случайный токен) и pre-check
|
|
||||||
# структурно не может найти прошлую попытку — это тот же класс проблемы,
|
|
||||||
# что и потеря куки в сети, вне scope этой правки.
|
|
||||||
idempotency_key = _resolve_idempotency_key(request, identity_key=thread_key, text=payload.text)
|
|
||||||
existing_thread_id = storage.find_thread_id(db, thread_key)
|
|
||||||
if existing_thread_id is not None:
|
|
||||||
existing = storage.find_inbound_by_idempotency_key(
|
|
||||||
db, thread_id=existing_thread_id, idempotency_key=idempotency_key
|
|
||||||
)
|
|
||||||
if existing is not None:
|
|
||||||
logger.info(
|
|
||||||
"web support (anon): repeat send (idempotency key already recorded) "
|
|
||||||
"%s thread_id=%d message_id=%s",
|
|
||||||
display_id,
|
|
||||||
existing_thread_id,
|
|
||||||
existing["id"],
|
|
||||||
)
|
|
||||||
if is_new_token:
|
|
||||||
_set_anon_cookie(response, token)
|
|
||||||
return SupportMessageOut(**existing)
|
|
||||||
|
|
||||||
# Общий клиент приложения (#tg-connection-resilience): на каждый запрос
|
|
||||||
# свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк
|
|
||||||
# до api.telegram.org перед каждой отправкой. Живёт в lifespan.
|
|
||||||
client = get_telegram_client()
|
|
||||||
try:
|
try:
|
||||||
mirrored = await client.send_message(
|
mirrored = await client.send_message(
|
||||||
chat_id=settings.telegram_support_chat_id,
|
chat_id=settings.telegram_support_chat_id,
|
||||||
|
|
@ -730,22 +419,15 @@ async def send_anon_support_message(
|
||||||
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||||
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
|
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
|
||||||
)
|
)
|
||||||
except TelegramError:
|
except TelegramApiError:
|
||||||
# Ни текст сообщения (ПДн), ни токен (bearer треда) в лог не попадают.
|
# Ни текст сообщения (ПДн), ни токен (bearer треда) в лог не попадают.
|
||||||
# Про предок вместо `TelegramApiError` — см. комментарий в парной ручке.
|
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"web support (anon): не удалось отправить зеркало в топик (%s)", display_id
|
"web support (anon): не удалось отправить зеркало в топик (%s)", display_id
|
||||||
)
|
)
|
||||||
_send_failure_limiter.record(thread_key)
|
|
||||||
_anon_ip_failure_limiter.record(ip)
|
|
||||||
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
|
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
|
||||||
|
|
||||||
_send_limiter.record(thread_key)
|
_send_limiter.record(thread_key)
|
||||||
_anon_ip_limiter.record(ip)
|
_anon_ip_limiter.record(ip)
|
||||||
SUPPORT_MESSAGES.labels(channel="anon").inc()
|
|
||||||
# Канал жив — счётчики отказов считают именно ПОДРЯД идущие отказы.
|
|
||||||
_send_failure_limiter.reset(thread_key)
|
|
||||||
_anon_ip_failure_limiter.reset(ip)
|
|
||||||
|
|
||||||
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
|
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
|
||||||
if topic_message_id is None:
|
if topic_message_id is None:
|
||||||
|
|
@ -755,37 +437,15 @@ async def send_anon_support_message(
|
||||||
display_id,
|
display_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Отказ БД после успешной отправки — см. развёрнутый комментарий в парной
|
thread_id = storage.get_or_create_thread(db, thread_key)
|
||||||
# (авторизованной) ручке: сообщение оператору уже доставлено, 500 тут создаёт
|
row = storage.record_inbound(
|
||||||
# дубли в топике и «осиротевшее» зеркало, на которое оператор отвечает зря.
|
db,
|
||||||
try:
|
thread_id=thread_id,
|
||||||
thread_id = storage.get_or_create_thread(db, thread_key)
|
text_body=payload.text,
|
||||||
row = storage.record_inbound(
|
topic_message_id=topic_message_id,
|
||||||
db,
|
support_chat_id=settings.telegram_support_chat_id,
|
||||||
thread_id=thread_id,
|
)
|
||||||
text_body=payload.text,
|
db.commit()
|
||||||
topic_message_id=topic_message_id,
|
|
||||||
support_chat_id=settings.telegram_support_chat_id,
|
|
||||||
idempotency_key=idempotency_key,
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
except SQLAlchemyError:
|
|
||||||
# Ни текста обращения (ПДн), ни токена (bearer треда) в логе — только
|
|
||||||
# несекретный ярлык треда.
|
|
||||||
logger.exception(
|
|
||||||
"web support (anon): сообщение доставлено в топик, но не записано в БД (%s)",
|
|
||||||
display_id,
|
|
||||||
)
|
|
||||||
_rollback_quietly(db)
|
|
||||||
await _warn_operator_message_not_recorded(
|
|
||||||
label=f"[С САЙТА · БЕЗ ВХОДА] {display_id}", topic_message_id=topic_message_id
|
|
||||||
)
|
|
||||||
# Куку ставим ВСЁ РАВНО: тред в БД не создан, но идентичность посетителя
|
|
||||||
# обязана пережить этот сбой — иначе следующее сообщение (когда база
|
|
||||||
# поднимется) заведёт ВТОРОЙ тред, и переписка разъедется на два.
|
|
||||||
if is_new_token:
|
|
||||||
_set_anon_cookie(response, token)
|
|
||||||
return _unpersisted_message_out(payload.text)
|
|
||||||
|
|
||||||
if is_new_token:
|
if is_new_token:
|
||||||
_set_anon_cookie(response, token)
|
_set_anon_cookie(response, token)
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ from app.core.anon_session import get_or_create_anon_session_id
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
||||||
from app.observability.metrics import ESTIMATES, REPORTS_EXPORTED
|
|
||||||
from app.schemas.trade_in import (
|
from app.schemas.trade_in import (
|
||||||
AggregatedEstimate,
|
AggregatedEstimate,
|
||||||
AnalogLot,
|
AnalogLot,
|
||||||
|
|
@ -31,7 +30,6 @@ from app.schemas.trade_in import (
|
||||||
CoverageProbeInput,
|
CoverageProbeInput,
|
||||||
CoverageProbeResponse,
|
CoverageProbeResponse,
|
||||||
DkpCorridor,
|
DkpCorridor,
|
||||||
ExposureWindow,
|
|
||||||
HouseAnalyticsKpi,
|
HouseAnalyticsKpi,
|
||||||
HouseAnalyticsResponse,
|
HouseAnalyticsResponse,
|
||||||
HouseInfoForEstimate,
|
HouseInfoForEstimate,
|
||||||
|
|
@ -52,7 +50,6 @@ from app.schemas.trade_in import (
|
||||||
TradeInEstimateInput,
|
TradeInEstimateInput,
|
||||||
)
|
)
|
||||||
from app.services import account_quota
|
from app.services import account_quota
|
||||||
from app.services import regions as regions_mod
|
|
||||||
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
|
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
|
||||||
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
|
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
|
||||||
from app.services.user_events import schedule_event
|
from app.services.user_events import schedule_event
|
||||||
|
|
@ -81,10 +78,8 @@ _estimate_limiter = SlidingWindowLimiter(
|
||||||
# внешние тиры; пила одновременных оценок выедает пул и тормозит весь /api/v1/*.
|
# внешние тиры; пила одновременных оценок выедает пул и тормозит весь /api/v1/*.
|
||||||
# Образец — public/mera.py::_suggest_slots (4 слота на секундное автодополнение).
|
# Образец — public/mera.py::_suggest_slots (4 слота на секундное автодополнение).
|
||||||
#
|
#
|
||||||
# 4 слота: вместе с 4 слотами suggest — 8 одновременно удерживаемых соединений.
|
# 4 слота: вместе с 4 слотами suggest — 8 одновременно удерживаемых соединений
|
||||||
# Пул под это заведомо шире: 5+15=20 на процесс, и потолок пула держится не
|
# из 15 возможных, остаток пула остаётся прочим ручкам. Ожидание слота 5с ≈ две
|
||||||
# меньше СУММЫ объявленных потолков одновременности, включая 8 фоновых догрузок
|
|
||||||
# (core/db.py + tests/test_3408_pool_ceiling.py, #3408). Ожидание слота 5с ≈ две
|
|
||||||
# длительности оценки: если за это время слот не освободился, очередь глубока и
|
# длительности оценки: если за это время слот не освободился, очередь глубока и
|
||||||
# честный ответ — быстрый 429 с Retry-After, а не растущая очередь (очередь под
|
# честный ответ — быстрый 429 с Retry-After, а не растущая очередь (очередь под
|
||||||
# нагрузкой — те же занятые соединения плюс таймаут у клиента; mera.py:117-127).
|
# нагрузкой — те же занятые соединения плюс таймаут у клиента; mera.py:117-127).
|
||||||
|
|
@ -391,7 +386,6 @@ async def _try_revive_dead_estimate(
|
||||||
),
|
),
|
||||||
{"orig": str(estimate_id), "temp": str(temp_id)},
|
{"orig": str(estimate_id), "temp": str(temp_id)},
|
||||||
)
|
)
|
||||||
w = result.exposure_window # #2898: окно целиком или NULL целиком
|
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
|
|
@ -420,10 +414,6 @@ async def _try_revive_dead_estimate(
|
||||||
ratio_basis = :ratio_basis,
|
ratio_basis = :ratio_basis,
|
||||||
relaxations = CAST(:relaxations_json AS jsonb),
|
relaxations = CAST(:relaxations_json AS jsonb),
|
||||||
reliability = :reliability,
|
reliability = :reliability,
|
||||||
est_days_p25 = :est_days_p25,
|
|
||||||
est_days_p50 = :est_days_p50,
|
|
||||||
est_days_p75 = :est_days_p75,
|
|
||||||
est_days_n = :est_days_n,
|
|
||||||
revival_completed_at = NOW()
|
revival_completed_at = NOW()
|
||||||
WHERE id = CAST(:id AS uuid)
|
WHERE id = CAST(:id AS uuid)
|
||||||
"""
|
"""
|
||||||
|
|
@ -458,10 +448,6 @@ async def _try_revive_dead_estimate(
|
||||||
"ratio_basis": result.ratio_basis,
|
"ratio_basis": result.ratio_basis,
|
||||||
"relaxations_json": json.dumps(result.relaxations, ensure_ascii=False),
|
"relaxations_json": json.dumps(result.relaxations, ensure_ascii=False),
|
||||||
"reliability": result.reliability,
|
"reliability": result.reliability,
|
||||||
"est_days_p25": w.p25_days if w else None,
|
|
||||||
"est_days_p50": w.p50_days if w else None,
|
|
||||||
"est_days_p75": w.p75_days if w else None,
|
|
||||||
"est_days_n": w.n if w else None,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -571,13 +557,6 @@ async def estimate(
|
||||||
# #3082: слот возвращаем сразу после дорогой части — инкремент квоты и
|
# #3082: слот возвращаем сразу после дорогой части — инкремент квоты и
|
||||||
# сериализация ответа ниже дёшевы и слот держать не должны.
|
# сериализация ответа ниже дёшевы и слот держать не должны.
|
||||||
_estimate_slots.release()
|
_estimate_slots.release()
|
||||||
|
|
||||||
# #3471: считаем оценку "успешно посчитанной" здесь — до квоты и до 429
|
|
||||||
# ниже, потому что расчёт (дорогая часть) уже прошёл. insufficient_data —
|
|
||||||
# тоже успех расчёта: медиана не нашлась не потому что что-то сломалось, а
|
|
||||||
# потому что аналогов не было, это отдельный, а не ошибочный исход.
|
|
||||||
ESTIMATES.labels(outcome="insufficient_data" if result.insufficient_data else "ok").inc()
|
|
||||||
|
|
||||||
# #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise
|
# #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise
|
||||||
# выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение
|
# выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение
|
||||||
# тут: при гонке двух /estimate на used=lim-1 второй получит False.
|
# тут: при гонке двух /estimate на used=lim-1 второй получит False.
|
||||||
|
|
@ -688,8 +667,7 @@ def load_estimate(
|
||||||
expected_sold_price, expected_sold_range_low,
|
expected_sold_price, expected_sold_range_low,
|
||||||
expected_sold_range_high, expected_sold_per_m2,
|
expected_sold_range_high, expected_sold_per_m2,
|
||||||
asking_to_sold_ratio, ratio_basis, created_by, created_at,
|
asking_to_sold_ratio, ratio_basis, created_by, created_at,
|
||||||
relaxations, reliability,
|
relaxations, reliability
|
||||||
est_days_p25, est_days_p50, est_days_p75, est_days_n
|
|
||||||
FROM trade_in_estimates
|
FROM trade_in_estimates
|
||||||
WHERE id = CAST(:id AS uuid)
|
WHERE id = CAST(:id AS uuid)
|
||||||
AND {ESTIMATE_READABLE_SQL}
|
AND {ESTIMATE_READABLE_SQL}
|
||||||
|
|
@ -729,7 +707,6 @@ def load_estimate(
|
||||||
_qc_geo_to_precision,
|
_qc_geo_to_precision,
|
||||||
_resolve_target_city,
|
_resolve_target_city,
|
||||||
_source_counts,
|
_source_counts,
|
||||||
analog_tier_from_explanation,
|
|
||||||
rehydrate_search_radius_m,
|
rehydrate_search_radius_m,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -786,22 +763,8 @@ def load_estimate(
|
||||||
# когда raw payload.address начнёт персиститься — follow-up). Для ЕКБ ок; reorder
|
# когда raw payload.address начнёт персиститься — follow-up). Для ЕКБ ок; reorder
|
||||||
# ниже безвреден (оба source city-stripped для не-ЕКБ, оба екб для ЕКБ).
|
# ниже безвреден (оба source city-stripped для не-ЕКБ, оба екб для ЕКБ).
|
||||||
target_city = _resolve_target_city(row.address) or _resolve_target_city(row.canonical_address)
|
target_city = _resolve_target_city(row.address) or _resolve_target_city(row.canonical_address)
|
||||||
# #3051 PR-A: тот же region_code-скоуп, что и POST /estimate — гарантирует
|
|
||||||
# «регион не резолвится → DEFAULT_REGION_CODE (66)», не NULL (NULL в SQL
|
|
||||||
# обнулил бы фильтр). row.lat/row.lon персистятся с estimate-time.
|
|
||||||
target_region = (
|
|
||||||
regions_mod.region_for_point(row.lat, row.lon)
|
|
||||||
if row.lat is not None and row.lon is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
target_region_code = target_region.code if target_region else regions_mod.DEFAULT_REGION_CODE
|
|
||||||
dkp_raw = _fetch_dkp_corridor(
|
dkp_raw = _fetch_dkp_corridor(
|
||||||
db,
|
db, address=row.address, rooms=row.rooms, area=area_f, city=target_city
|
||||||
address=row.address,
|
|
||||||
rooms=row.rooms,
|
|
||||||
area=area_f,
|
|
||||||
city=target_city,
|
|
||||||
region_code=target_region_code,
|
|
||||||
)
|
)
|
||||||
dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None
|
dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None
|
||||||
|
|
||||||
|
|
@ -813,16 +776,7 @@ def load_estimate(
|
||||||
recommended_price=int(imv_raw["recommended_price"]),
|
recommended_price=int(imv_raw["recommended_price"]),
|
||||||
lower_price=int(imv_raw["lower_price"]) if imv_raw.get("lower_price") else None,
|
lower_price=int(imv_raw["lower_price"]) if imv_raw.get("lower_price") else None,
|
||||||
higher_price=int(imv_raw["higher_price"]) if imv_raw.get("higher_price") else None,
|
higher_price=int(imv_raw["higher_price"]) if imv_raw.get("higher_price") else None,
|
||||||
# #3323: `is not None` (0 — самый тонкий рынок, не «неизвестно») + thin_market
|
market_count=int(imv_raw["market_count"]) if imv_raw.get("market_count") else None,
|
||||||
# считаем тем же порогом, что POST-путь в estimator, иначе одна и та же
|
|
||||||
# оценка при переоткрытии по ссылке / в PDF теряла флаг тонкого рынка.
|
|
||||||
market_count=(
|
|
||||||
int(imv_raw["market_count"]) if imv_raw.get("market_count") is not None else None
|
|
||||||
),
|
|
||||||
thin_market=(
|
|
||||||
imv_raw.get("market_count") is not None
|
|
||||||
and int(imv_raw["market_count"]) < settings.avito_imv_thin_market_threshold
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if imv_raw is not None and imv_raw.get("recommended_price")
|
if imv_raw is not None and imv_raw.get("recommended_price")
|
||||||
else None
|
else None
|
||||||
|
|
@ -854,19 +808,6 @@ def load_estimate(
|
||||||
# миграции 267) деградирует в None — «позиции не знаем», — а не роняет
|
# миграции 267) деградирует в None — «позиции не знаем», — а не роняет
|
||||||
# ответ AttributeError'ом.
|
# ответ AttributeError'ом.
|
||||||
market_percentile=getattr(row, "market_percentile", None),
|
market_percentile=getattr(row, "market_percentile", None),
|
||||||
# #2898: окно из колонок, а не пересчёт — по ссылке то же окно, что при оценке.
|
|
||||||
# getattr: строка без колонок (in-memory double) — окна нет, а не AttributeError.
|
|
||||||
exposure_window=(
|
|
||||||
ExposureWindow(
|
|
||||||
p25_days=row.est_days_p25,
|
|
||||||
p50_days=row.est_days_p50,
|
|
||||||
p75_days=row.est_days_p75,
|
|
||||||
n=row.est_days_n,
|
|
||||||
)
|
|
||||||
if getattr(row, "est_days_n", None) is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
est_days_on_market=getattr(row, "est_days_p50", None),
|
|
||||||
period_months=12,
|
period_months=12,
|
||||||
analogs=analogs,
|
analogs=analogs,
|
||||||
actual_deals=actual_deals,
|
actual_deals=actual_deals,
|
||||||
|
|
@ -921,10 +862,6 @@ def load_estimate(
|
||||||
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
|
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
|
||||||
# сюда дефолт значило бы выдать догадку за то, что просил пользователь.
|
# сюда дефолт значило бы выдать догадку за то, что просил пользователь.
|
||||||
search_radius_m=search_radius_m,
|
search_radius_m=search_radius_m,
|
||||||
# #3466: якорный тир (same_building/micro_radius) восстанавливается из подписи
|
|
||||||
# якорного блока в confidence_explanation; радиусный — null, колонки нет.
|
|
||||||
# Без него ссылка ?id= теряла признак «коридор в цену не вошёл» (Tier A).
|
|
||||||
analog_tier=analog_tier_from_explanation(row.confidence_explanation),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1050,7 +987,6 @@ def estimate_pdf(
|
||||||
brand_obj = _resolve_brand(owner_brand_slug, db)
|
brand_obj = _resolve_brand(owner_brand_slug, db)
|
||||||
pdf_bytes = generate_trade_in_pdf(estimate, input_snapshot, brand=brand_obj)
|
pdf_bytes = generate_trade_in_pdf(estimate, input_snapshot, brand=brand_obj)
|
||||||
filename = f"trade-in-{brand_obj.slug}-{estimate_id}.pdf"
|
filename = f"trade-in-{brand_obj.slug}-{estimate_id}.pdf"
|
||||||
REPORTS_EXPORTED.inc()
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"PDF generated estimate_id=%s brand=%s size=%d",
|
"PDF generated estimate_id=%s brand=%s size=%d",
|
||||||
estimate_id,
|
estimate_id,
|
||||||
|
|
@ -2225,11 +2161,8 @@ def get_street_deals(
|
||||||
"""ДКП-сделки Росреестра по улице целевого адреса.
|
"""ДКП-сделки Росреестра по улице целевого адреса.
|
||||||
|
|
||||||
Open dataset Росреестра агрегирует адреса до улицы (без номера дома).
|
Open dataset Росреестра агрегирует адреса до улицы (без номера дома).
|
||||||
Поэтому это per-street view, не per-house. До квартир-аналогов выборку
|
Поэтому это per-street view, не per-house. Фильтр по rooms + area
|
||||||
сужает полоса площади ±area_tolerance; комнатность клиента в фильтр НЕ
|
сужает выборку до квартир-аналогов.
|
||||||
входит — `deals.rooms` не комнатность, а синтетика из той же площади
|
|
||||||
(#3256, см. estimator._fetch_dkp_corridor). `rooms` остаётся параметром
|
|
||||||
ручки: он описывает запрос и попадает в лог, но не в WHERE.
|
|
||||||
|
|
||||||
После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована
|
После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована
|
||||||
в import-rosreestr.sh).
|
в import-rosreestr.sh).
|
||||||
|
|
@ -2239,7 +2172,6 @@ def get_street_deals(
|
||||||
_percentile,
|
_percentile,
|
||||||
_resolve_target_city,
|
_resolve_target_city,
|
||||||
extract_street_name,
|
extract_street_name,
|
||||||
region_code_for_address,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
now = datetime.now(tz=UTC)
|
now = datetime.now(tz=UTC)
|
||||||
|
|
@ -2283,17 +2215,6 @@ def get_street_deals(
|
||||||
target_city = _resolve_target_city(address)
|
target_city = _resolve_target_city(address)
|
||||||
city_filter = "AND LOWER(city) = CAST(:target_city AS text)" if target_city else ""
|
city_filter = "AND LOWER(city) = CAST(:target_city AS text)" if target_city else ""
|
||||||
|
|
||||||
# #dkp-corridor-scope (2026-09-12): region_code — обязательный фильтр, в отличие
|
|
||||||
# от city_filter выше (который применяется, только если _resolve_target_city
|
|
||||||
# узнал город обл.66). extract_street_name после фикса московского порядка
|
|
||||||
# ("Название улица") стал возвращать имя улицы и для Москвы, а
|
|
||||||
# _resolve_target_city знает ТОЛЬКО города обл.66 → для Москвы target_city=None,
|
|
||||||
# city_filter пуст, и без region_code одноимённая улица другого региона
|
|
||||||
# подмешалась бы в выборку (прод-замер: "Ясная" — 168 сделок в 66 и 80 в 77,
|
|
||||||
# "Советская" — 1202 и 17). region_code заполнен у всех deals (66→108 623,
|
|
||||||
# 77→212 937, NULL нет) — фильтр не отрезает ни одной существующей строки.
|
|
||||||
region_code = region_code_for_address(address)
|
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -2305,11 +2226,7 @@ def get_street_deals(
|
||||||
AND address ILIKE :street_pattern
|
AND address ILIKE :street_pattern
|
||||||
AND address ~* :street_regex
|
AND address ~* :street_regex
|
||||||
{city_filter}
|
{city_filter}
|
||||||
AND region_code = CAST(:region_code AS integer)
|
AND rooms = CAST(:rooms AS integer)
|
||||||
-- #3256: фильтра по rooms нет — deals.rooms синтезирована из площади
|
|
||||||
-- (тот же CASE 30/44/62/85, что area_bucket), т.е. это был второй
|
|
||||||
-- ступенчатый фильтр по площади поверх полосы ±15% ниже. Развёрнуто —
|
|
||||||
-- в комментарии estimator._fetch_dkp_corridor.
|
|
||||||
AND area_m2 BETWEEN :area_min AND :area_max
|
AND area_m2 BETWEEN :area_min AND :area_max
|
||||||
AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
|
AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
|
||||||
AND price_rub > 0
|
AND price_rub > 0
|
||||||
|
|
@ -2320,7 +2237,7 @@ def get_street_deals(
|
||||||
"street_pattern": "%" + street_name + "%",
|
"street_pattern": "%" + street_name + "%",
|
||||||
"street_regex": r"\m" + street_name + r"\M",
|
"street_regex": r"\m" + street_name + r"\M",
|
||||||
"target_city": target_city.lower() if target_city else None,
|
"target_city": target_city.lower() if target_city else None,
|
||||||
"region_code": region_code,
|
"rooms": rooms,
|
||||||
"area_min": area_min,
|
"area_min": area_min,
|
||||||
"area_max": area_max,
|
"area_max": area_max,
|
||||||
"period_months": period_months,
|
"period_months": period_months,
|
||||||
|
|
@ -2331,16 +2248,12 @@ def get_street_deals(
|
||||||
)
|
)
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
# #3256: лог называет ТОТ ключ, которым искали. Комнатность клиента в
|
|
||||||
# выборку не входит (deals.rooms — синтетика из площади), поэтому она
|
|
||||||
# печатается как контекст запроса, а не как параметр фильтра.
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"street-deals: no rows found street=%r area=%.1f±%.0f%% "
|
"street-deals: no rows found street=%r rooms=%d area=%.1f±%.0f%%",
|
||||||
"(ключ по комнатам не применяется, #3256; комнатность клиента=%d)",
|
|
||||||
street_name,
|
street_name,
|
||||||
|
rooms,
|
||||||
area_m2,
|
area_m2,
|
||||||
area_tolerance * 100,
|
area_tolerance * 100,
|
||||||
rooms,
|
|
||||||
)
|
)
|
||||||
return StreetDealsResponse(
|
return StreetDealsResponse(
|
||||||
street=street_name,
|
street=street_name,
|
||||||
|
|
@ -2368,14 +2281,12 @@ def get_street_deals(
|
||||||
top10 = [_deal_to_analog(dict(r)) for r in rows[:10]]
|
top10 = [_deal_to_analog(dict(r)) for r in rows[:10]]
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"street-deals: street=%r area=%.1f±%.0f%% count=%d median_ppm2=%.0f "
|
"street-deals: street=%r rooms=%d area=%.1f count=%d median_ppm2=%.0f",
|
||||||
"(ключ по комнатам не применяется, #3256; комнатность клиента=%d)",
|
|
||||||
street_name,
|
street_name,
|
||||||
|
rooms,
|
||||||
area_m2,
|
area_m2,
|
||||||
area_tolerance * 100,
|
|
||||||
count,
|
count,
|
||||||
median_ppm2,
|
median_ppm2,
|
||||||
rooms,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return StreetDealsResponse(
|
return StreetDealsResponse(
|
||||||
|
|
@ -2540,12 +2451,8 @@ def get_sales_vs_listings(
|
||||||
"""Pairs (ДКП-сделка, listing) для улицы целевого адреса (PR K / #564).
|
"""Pairs (ДКП-сделка, listing) для улицы целевого адреса (PR K / #564).
|
||||||
|
|
||||||
Для каждой ДКП-сделки Росреестра в окне `period_months` пытаемся найти
|
Для каждой ДКП-сделки Росреестра в окне `period_months` пытаемся найти
|
||||||
matching listing на той же улице с близкой area_m2 / listing_date в окне
|
matching listing на той же улице с такими же rooms / близкой area_m2 /
|
||||||
[deal_date - window_days, deal_date + 30d grace]. Комнатность в ключе стоит
|
listing_date в окне [deal_date - window_days, deal_date + 30d grace].
|
||||||
ТОЛЬКО на стороне объявлений (`l.rooms = p_rooms`, комнатность клиента): у
|
|
||||||
сделок Росреестра `rooms` — синтетика из площади, предикат по ней снят
|
|
||||||
миграцией 300 (#3451/#3256). Поэтому `deal_rooms` в паре может не совпадать
|
|
||||||
с запрошенным `rooms`.
|
|
||||||
|
|
||||||
Возвращаем LEFT JOIN: сделки без listing match сохраняются (listing_* = None),
|
Возвращаем LEFT JOIN: сделки без listing match сохраняются (listing_* = None),
|
||||||
чтобы вычислить linkage_rate.
|
чтобы вычислить linkage_rate.
|
||||||
|
|
@ -2555,12 +2462,7 @@ def get_sales_vs_listings(
|
||||||
|
|
||||||
Per-street view: Росреестр open dataset агрегирует адреса до улицы.
|
Per-street view: Росреестр open dataset агрегирует адреса до улицы.
|
||||||
"""
|
"""
|
||||||
from app.services.estimator import (
|
from app.services.estimator import _percentile, _resolve_target_city, extract_street_name
|
||||||
_percentile,
|
|
||||||
_resolve_target_city,
|
|
||||||
extract_street_name,
|
|
||||||
region_code_for_address,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _empty(reason_street: str | None = None) -> SalesVsListingsResponse:
|
def _empty(reason_street: str | None = None) -> SalesVsListingsResponse:
|
||||||
return SalesVsListingsResponse(
|
return SalesVsListingsResponse(
|
||||||
|
|
@ -2590,36 +2492,16 @@ def get_sales_vs_listings(
|
||||||
# известная H1) → фильтр не применяется на TVF-стороне (см. миграцию 205).
|
# известная H1) → фильтр не применяется на TVF-стороне (см. миграцию 205).
|
||||||
target_city = _resolve_target_city(address)
|
target_city = _resolve_target_city(address)
|
||||||
|
|
||||||
# #dkp-corridor-scope (2026-09-12): region_code — фильтр ДОПОЛНИТЕЛЬНО к
|
|
||||||
# target_city выше, нужен по той же причине, что и в /street-deals: для
|
|
||||||
# Москвы _resolve_target_city (словарь городов ТОЛЬКО обл.66) возвращает
|
|
||||||
# None → target_city-фильтр внутри TVF (миграция 205) не срабатывает ни
|
|
||||||
# для deals, ни для listings, и одноимённая улица другого региона
|
|
||||||
# подмешивается в пары (см. estimator.region_code_for_address).
|
|
||||||
#
|
|
||||||
# TVF street_sales_vs_listings() (миграция 205) параметра региона не
|
|
||||||
# знает — сама TVF не трогается (это отдельная миграция, вне текущего
|
|
||||||
# фикса), фильтр применён СНАРУЖИ: оборачиваем вызов в JOIN на deals по
|
|
||||||
# deal_id и фильтруем region_code ТОЛЬКО на стороне сделок. Сторона
|
|
||||||
# listings внутри TVF остаётся НЕ отфильтрованной по региону — это
|
|
||||||
# известный узкий компромисс, не побочный эффект: у listings нет
|
|
||||||
# общего для всех источников поля региона (city заполнен частично, см.
|
|
||||||
# комментарий 205 выше), а сам JOIN внутри TVF уже требует совпадения
|
|
||||||
# street_pattern + rooms + area + date proximity, что резко сужает шанс
|
|
||||||
# чужого региона на listing-стороне. Полный фикс (передать region_code
|
|
||||||
# внутрь TVF седьмым/восьмым параметром) — отдельная миграция.
|
|
||||||
region_code = region_code_for_address(address)
|
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
sv.deal_id, sv.deal_date, sv.deal_price_rub, sv.deal_price_per_m2,
|
deal_id, deal_date, deal_price_rub, deal_price_per_m2,
|
||||||
sv.deal_area_m2, sv.deal_rooms, sv.deal_floor, sv.deal_address,
|
deal_area_m2, deal_rooms, deal_floor, deal_address,
|
||||||
sv.listing_id, sv.listing_source, sv.listing_source_url,
|
listing_id, listing_source, listing_source_url,
|
||||||
sv.listing_date, sv.listing_price_rub, sv.listing_price_per_m2,
|
listing_date, listing_price_rub, listing_price_per_m2,
|
||||||
sv.listing_area_m2, sv.days_listing_to_deal, sv.discount_pct
|
listing_area_m2, days_listing_to_deal, discount_pct
|
||||||
FROM street_sales_vs_listings(
|
FROM street_sales_vs_listings(
|
||||||
CAST(:street_pattern AS text),
|
CAST(:street_pattern AS text),
|
||||||
CAST(:area_m2 AS numeric),
|
CAST(:area_m2 AS numeric),
|
||||||
|
|
@ -2628,9 +2510,7 @@ def get_sales_vs_listings(
|
||||||
CAST(:area_tolerance AS numeric),
|
CAST(:area_tolerance AS numeric),
|
||||||
CAST(:period_months AS integer),
|
CAST(:period_months AS integer),
|
||||||
CAST(:target_city AS text)
|
CAST(:target_city AS text)
|
||||||
) sv
|
)
|
||||||
JOIN deals d ON d.id = sv.deal_id
|
|
||||||
WHERE d.region_code = CAST(:region_code AS integer)
|
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
|
|
@ -2641,7 +2521,6 @@ def get_sales_vs_listings(
|
||||||
"area_tolerance": area_tolerance,
|
"area_tolerance": area_tolerance,
|
||||||
"period_months": period_months,
|
"period_months": period_months,
|
||||||
"target_city": target_city,
|
"target_city": target_city,
|
||||||
"region_code": region_code,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
|
|
@ -2873,247 +2752,15 @@ COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Ураль
|
||||||
COVERAGE_GREEN_MIN_N = 8
|
COVERAGE_GREEN_MIN_N = 8
|
||||||
COVERAGE_YELLOW_MIN_N = 12
|
COVERAGE_YELLOW_MIN_N = 12
|
||||||
|
|
||||||
# Московская область (регион 50) добавлена 2026-09-16 — все 22 города сразу в
|
|
||||||
# жёлтый тир, той же метрикой, которой выбирали тир Москве. Замер: 120 проб на
|
|
||||||
# город, радиус 8 км от центра, когорта = is_active + точные комнаты + площадь
|
|
||||||
# +-15% + scraped_at > now()-14d + ST_DWithin 1000 м. Медиана когорты по
|
|
||||||
# городам области 17-51, доля проб с когортой >= 12 равна 0.63-0.92 (Видное
|
|
||||||
# 51/0.83, Балашиха 40/0.88, Железнодорожный 39/0.92, Люберцы 37/0.87,
|
|
||||||
# Электросталь 35/0.83, Химки 30/0.74, Домодедово 30/0.79, Ногинск 29/0.85,
|
|
||||||
# Реутов 28/0.82, Долгопрудный 27/0.72, Красногорск 27/0.77, Одинцово 27/0.78,
|
|
||||||
# Подольск 26/0.83, Сергиев Посад 25/0.79, Королёв 24/0.79, Мытищи 23/0.73,
|
|
||||||
# Орехово-Зуево 22/0.73, Серпухов 22/0.66, Раменское 21/0.68,
|
|
||||||
# Наро-Фоминск 20/0.73, Щёлково 19/0.73, Коломна 17/0.63). Контроль той же
|
|
||||||
# метрикой: Москва (уже жёлтая) — медиана 14, доля 0.57; Нижний Тагил (тоже
|
|
||||||
# жёлтый) — медиана 11, доля 0.473. То есть КАЖДЫЙ город области плотнее уже
|
|
||||||
# принятого жёлтого эталона (Москвы) — зелёный тир не рассматривался, порог
|
|
||||||
# 12 достаточен и единообразен. Порядок — по убыванию медианы когорты.
|
|
||||||
COVERAGE_MO_CITIES = (
|
|
||||||
"Видное",
|
|
||||||
"Балашиха",
|
|
||||||
"Железнодорожный",
|
|
||||||
"Люберцы",
|
|
||||||
"Электросталь",
|
|
||||||
"Химки",
|
|
||||||
"Домодедово",
|
|
||||||
"Ногинск",
|
|
||||||
"Реутов",
|
|
||||||
"Долгопрудный",
|
|
||||||
"Красногорск",
|
|
||||||
"Одинцово",
|
|
||||||
"Подольск",
|
|
||||||
"Сергиев Посад",
|
|
||||||
"Королёв",
|
|
||||||
"Мытищи",
|
|
||||||
"Орехово-Зуево",
|
|
||||||
"Серпухов",
|
|
||||||
"Раменское",
|
|
||||||
"Наро-Фоминск",
|
|
||||||
"Щёлково",
|
|
||||||
"Коломна",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Москва добавлена 10.09.2026 — ОТДЕЛЬНОЙ константой, а не в
|
|
||||||
# COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше —
|
|
||||||
# это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется
|
|
||||||
# тестом test_offered_cities_match_coverage_cities), а Москвы в том дропдауне
|
|
||||||
# нет и быть не должно — там выбирают город Свердловской области. Порог при
|
|
||||||
# этом нужен, и живёт он здесь, в том же _COVERAGE_CITY_THRESHOLDS.
|
|
||||||
#
|
|
||||||
# Тир — ЖЁЛТЫЙ (12), не зелёный. Замер на проде 10.09.2026: 200 случайных
|
|
||||||
# московских объявлений, когорта считалась тем же предикатом, что сама проба
|
|
||||||
# (см. SQL в coverage_probe ниже) — медиана когорты 14, p25 = 8, доля точек с
|
|
||||||
# когортой >= 12 равна 0.57, с когортой >= 8 равна 0.77. Контроли той же
|
|
||||||
# метрикой: Екатеринбург (зелёный, порог 8) — медиана 37, доля >= 12 равна
|
|
||||||
# 0.865; Нижний Тагил (жёлтый, порог 12) — медиана 11, доля >= 12 равна 0.473.
|
|
||||||
# То есть по плотности когорты Москва втрое реже зелёного эталона и стоит
|
|
||||||
# вплотную к жёлтому: с порогом 8 «ok» получали бы 77% адресов, но за этим «ok»
|
|
||||||
# стояла бы заметно менее надёжная оценка. Плюс модельная сторона для Москвы
|
|
||||||
# ещё не откалибрована (время правится свердловским рядом СберИндекса, полоса
|
|
||||||
# цен одна на весь город), так что 43% адресов честнее показать как thin.
|
|
||||||
COVERAGE_MOSCOW_MIN_N = COVERAGE_YELLOW_MIN_N
|
|
||||||
|
|
||||||
# Ключ центроида и display-имя города — РАЗНЫЕ вещи, и здесь это видно яснее
|
|
||||||
# всего: у Москвы 67 центроидов (сетка, см. `_MOSCOW_GRID_DEG` ниже), а наружу,
|
|
||||||
# в поле `city` ответа, обязано уходить одно имя «Москва» и один порог. Иначе
|
|
||||||
# повторится ровно тот баг, что был с Берёзовским: человек получает в ответе
|
|
||||||
# название района вместо своего города.
|
|
||||||
COVERAGE_MOSCOW_DISPLAY = "Москва"
|
|
||||||
|
|
||||||
# СЕТКА МОСКОВСКИХ ЦЕНТРОИДОВ (11.09.2026).
|
|
||||||
#
|
|
||||||
# Откуда координаты. Это не справочник районов и не ручной подбор: точки
|
|
||||||
# получены кластеризацией НАШЕГО ЖЕ корпуса объявлений средствами PostGIS —
|
|
||||||
# `ST_ClusterKMeans(ST_Transform(geom, 32637), K) OVER ()` по 35 552 активным
|
|
||||||
# вторичным объявлениям region_code=77 (geom заполнена у 100%), центроид
|
|
||||||
# кластера = `ST_Centroid(ST_Collect(...))` в UTM 37N, округление до 4 знаков.
|
|
||||||
# Поэтому точки стоят там, где реально живут объявления, а не там, где на карте
|
|
||||||
# нарисован центр района. Подписи в ключах — человеческие ярлыки для читаемости
|
|
||||||
# (ближайший район/поселение), на резолв они не влияют никак.
|
|
||||||
#
|
|
||||||
# Почему точек 67. Кластеризация дала K=56: порог качества был «меньше 2%
|
|
||||||
# московских объявлений уходит к подмосковному центроиду», свип по K на тех же
|
|
||||||
# данных — 24 → 6.06%, 30 → 4.02%, 36 → 3.67%, 42 → 3.46%, 48 → 2.23%,
|
|
||||||
# 54 → 2.12%, 56 → 1.79%, 60 → 1.64%, 75 → 0.84%, 90 → 0.45%, и K=56 — первое
|
|
||||||
# значение, берущее 2%. Из этих 56 один кластер выброшен как артефакт геокода,
|
|
||||||
# и 12 точек добавлены вручную по замеру 11.09.2026 (жилые районы, целиком
|
|
||||||
# проигрывавшие конкурс подмосковной точке): 56 − 1 + 12 = 67.
|
|
||||||
#
|
|
||||||
# Выброшенный призрак. «Москва (Восточный, эксклав)» 56.0087/37.7960: n=1,
|
|
||||||
# отрыв от остальной сетки 17.9 км. Адрес объявления — «ВАО, р-н Восточный, 4»,
|
|
||||||
# а настоящий район Восточный лежит на 55.81/37.85: геокодер промахнулся
|
|
||||||
# на 22 км. Точка стояла в 3.06 км от Пушкино и раздавала ему имя «Москва».
|
|
||||||
# Взамен добавлена «Москва (Восточный)» по фактическому центру девяти
|
|
||||||
# объявлений района. Проверены ВСЕ кластеры с n<60 либо изоляцией >12 км
|
|
||||||
# (9 штук, адреса подняты с прода) — артефакт ровно один. Второй кандидат,
|
|
||||||
# «Москва (юг ТАО)» 55.2643/37.1033 (n=1, адрес без улицы и дома =
|
|
||||||
# settlement-fallback геокодера), ОСТАВЛЕН намеренно: точка стоит внутри ТАО,
|
|
||||||
# при радиусе 8 км не достаёт ни до одного города области, а снос стоил бы
|
|
||||||
# покрытия анклава.
|
|
||||||
#
|
|
||||||
# Где проходит граница с областью. Двумя механизмами сразу, и нужны оба.
|
|
||||||
# (1) Конкурс ближайшего центроида: 32 подмосковные точки
|
|
||||||
# (`_COVERAGE_NEGATIVE_CENTROIDS_DEG` ниже) участвуют в поиске ближайшего, но
|
|
||||||
# порога не имеют, поэтому адрес, для которого выиграл подмосковный центр,
|
|
||||||
# получает «город не определён». (2) Радиус, который теперь СВОЙСТВО ЦЕНТРОИДА
|
|
||||||
# (`_CENTROID_RADIUS_KM` ниже), а не одна константа на всю страну: у московских
|
|
||||||
# точек он 8 км, у свердловских остались прежние 25.
|
|
||||||
#
|
|
||||||
# Почему радиус пришлось сделать свойством точки. 25 км были рассчитаны на ОДИН
|
|
||||||
# центроид в центре города — круг вокруг Кремля. У плотной сетки круги
|
|
||||||
# СКЛАДЫВАЮТСЯ, и объединение 67 кругов по 25 км — это уже не «Москва с
|
|
||||||
# запасом», а пятно, накрывающее половину области. Наро-Фоминск, Кубинка и Чехов
|
|
||||||
# резолвились как «Москва» именно так: до ближайшей точки сетки им 9.83, 17.18
|
|
||||||
# и 22.25 км (расстояния посчитаны _haversine_km этого же модуля, сфера
|
|
||||||
# R=6371 — замер на эллипсоиде PostGIS даёт на 0.1-0.5 км больше), то есть
|
|
||||||
# внутрь общего круга они попадали, а собственной
|
|
||||||
# отрицательной точки у них нет. Прежнее утверждение в этом месте — будто
|
|
||||||
# радиус 25 км «больше не ограничение, запас четырёхкратный» — было ложным:
|
|
||||||
# ограничение не исчезло, оно поменяло знак. Раньше радиус резал покрытие
|
|
||||||
# изнутри (адрес дальше 25 км от Кремля терял город), теперь протекал наружу.
|
|
||||||
#
|
|
||||||
# Откуда 8 км. Свип по радиусу на итоговой сетке, тот же корпус (35 552
|
|
||||||
# активных вторичных объявления, region_code=77): R = 5, 6, 7 и 8 дают
|
|
||||||
# ОДИНАКОВЫЙ результат — 0.00% объявлений вне радиуса, 29/29 московских
|
|
||||||
# контрольных адресов резолвятся в «Москву», 32/32 областных дают «не
|
|
||||||
# определён». Верхний край плато жёсткий: при R=10 внутрь входит Наро-Фоминск
|
|
||||||
# (9.83 км — город БЕЗ отрицательной точки), при R=12 — ещё Голицыно и
|
|
||||||
# Лыткарино. Берём верхний край: 8 км — это 3.5 км запаса на адреса вне
|
|
||||||
# корпуса (худший московский контроль — Рублёво, 4.44 км до сетки) и всё ещё
|
|
||||||
# на 1.83 км ниже потолка 9.83. Брать меньше нечем оправдать: по корпусу
|
|
||||||
# выигрыша нет, а запас на дырки в сетке тает.
|
|
||||||
#
|
|
||||||
# Остаточная цена. 48 московских объявлений из 35 552 = 0.14% (на прежней
|
|
||||||
# конфигурации — 636 = 1.79%) всё ещё выигрываются подмосковным центром:
|
|
||||||
# Молжаниновский 15, Реутов 8, Подольск 7, Долгопрудный 7, Мытищи 7,
|
|
||||||
# Красногорск 3, Королёв 1. Крупнейший кусок снимается 13-й точкой
|
|
||||||
# «Молжаниновский» 55.9480/37.3478 (0.14% → 0.09%) — она в замере посчитана,
|
|
||||||
# но в список не внесена, чтобы конфигурация здесь совпадала с той, на которой
|
|
||||||
# прогнаны оба контрольных набора. Одно объявление не берётся ничем:
|
|
||||||
# 56.0087/37.7960, тот самый кривой геокод, — лечится перегеокодированием
|
|
||||||
# строки адреса, а не сеткой.
|
|
||||||
#
|
|
||||||
# Не перегенерируйте кластеризацию перед правкой: ST_ClusterKMeans без сида
|
|
||||||
# недетерминирован, разброс между прогонами ±0.2 п.п. Список ниже перепроверен
|
|
||||||
# отдельным прогоном именно в том виде, в каком он здесь записан.
|
|
||||||
_MOSCOW_GRID_DEG: dict[str, tuple[float, float]] = {
|
|
||||||
"Москва (Рогово, ТАО)": (55.2146, 37.0695),
|
|
||||||
"Москва (юг ТАО)": (55.2643, 37.1033),
|
|
||||||
"Москва (Вороновское, ТАО)": (55.3159, 37.1759),
|
|
||||||
"Москва (Кленовское, ТАО)": (55.3343, 37.3299),
|
|
||||||
"Москва (ТАО у Подольска)": (55.3569, 37.4010),
|
|
||||||
"Москва (Шишкин Лес, ТАО)": (55.4202, 37.1687),
|
|
||||||
"Москва (Щапово, ТАО)": (55.4212, 37.3979),
|
|
||||||
"Москва (Новофёдоровское, ТАО)": (55.4307, 36.8675),
|
|
||||||
"Москва (Троицк, юг)": (55.4568, 37.2867),
|
|
||||||
"Москва (Остафьево, НАО)": (55.4760, 37.5249),
|
|
||||||
"Москва (Киевский, ТАО)": (55.4884, 36.9199),
|
|
||||||
"Москва (Троицк)": (55.4953, 37.3150),
|
|
||||||
"Москва (Щербинка, НАО)": (55.5046, 37.5578),
|
|
||||||
"Москва (Ватутинки, НАО)": (55.5187, 37.3605),
|
|
||||||
"Москва (Первомайское, ТАО)": (55.5405, 37.1599),
|
|
||||||
"Москва (Коммунарка, НАО)": (55.5474, 37.4927),
|
|
||||||
"Москва (Филимонковское, НАО)": (55.5568, 37.3188),
|
|
||||||
"Москва (Северное Бутово)": (55.5615, 37.5697),
|
|
||||||
"Москва (Саларьево, НАО)": (55.5862, 37.4568),
|
|
||||||
"Москва (Марушкино, НАО)": (55.5933, 37.1658),
|
|
||||||
"Москва (Бирюлёво Западное)": (55.5945, 37.6447),
|
|
||||||
"Москва (Внуковское, НАО)": (55.6030, 37.3796),
|
|
||||||
"Москва (Ясенево)": (55.6138, 37.5535),
|
|
||||||
"Москва (Зябликово)": (55.6225, 37.7303),
|
|
||||||
"Москва (Ново-Переделкино)": (55.6317, 37.3215),
|
|
||||||
"Москва (Нагорный)": (55.6425, 37.6245),
|
|
||||||
"Москва (Солнцево)": (55.6455, 37.3966),
|
|
||||||
"Москва (Тропарёво-Никулино)": (55.6576, 37.4684),
|
|
||||||
"Москва (Люблино)": (55.6696, 37.7461),
|
|
||||||
"Москва (Обручевский)": (55.6711, 37.5432),
|
|
||||||
"Москва (Даниловский)": (55.6921, 37.6397),
|
|
||||||
"Москва (Некрасовка)": (55.7033, 37.9093),
|
|
||||||
"Москва (Раменки)": (55.7048, 37.4870),
|
|
||||||
"Москва (Кузьминки)": (55.7143, 37.7723),
|
|
||||||
"Москва (Кунцево)": (55.7281, 37.4239),
|
|
||||||
"Москва (Якиманка)": (55.7290, 37.6055),
|
|
||||||
"Москва (Таганский)": (55.7370, 37.6845),
|
|
||||||
"Москва (Пресненский)": (55.7486, 37.5282),
|
|
||||||
"Москва (Новогиреево)": (55.7545, 37.8107),
|
|
||||||
"Москва (Тверской)": (55.7655, 37.6038),
|
|
||||||
"Москва (Хорошёво-Мнёвники)": (55.7657, 37.4668),
|
|
||||||
"Москва (Преображенское)": (55.7886, 37.7045),
|
|
||||||
"Москва (Хорошёвский)": (55.7951, 37.5373),
|
|
||||||
"Москва (Строгино)": (55.7982, 37.3859),
|
|
||||||
"Москва (Северное Измайлово)": (55.8071, 37.7881),
|
|
||||||
"Москва (Останкинский)": (55.8086, 37.6174),
|
|
||||||
"Москва (Покровское-Стрешнево)": (55.8203, 37.4470),
|
|
||||||
"Москва (Митино)": (55.8467, 37.3877),
|
|
||||||
"Москва (Отрадное)": (55.8558, 37.5796),
|
|
||||||
"Москва (Ховрино)": (55.8581, 37.4924),
|
|
||||||
"Москва (Бабушкинский)": (55.8633, 37.6740),
|
|
||||||
"Москва (Лианозово)": (55.8944, 37.5622),
|
|
||||||
"Москва (Куркино — Молжаниново)": (55.9169, 37.3837),
|
|
||||||
"Москва (Зеленоград, юг)": (55.9793, 37.1620),
|
|
||||||
"Москва (Зеленоград, север)": (55.9958, 37.2147),
|
|
||||||
# Кластер «Москва (Восточный, эксклав)» (56.0087, 37.7960) ВЫБРОШЕН
|
|
||||||
# 11.09.2026 как артефакт геокода — см. «Выброшенный призрак» выше.
|
|
||||||
#
|
|
||||||
# Ниже — 12 точек, добавленных тем же замером вручную: жилые районы
|
|
||||||
# Москвы, которые целиком проигрывали конкурс ближайшего подмосковной
|
|
||||||
# точке. В комментарии — сколько объявлений возвращает точка и кому они
|
|
||||||
# уходили (прод-корпус, is_active AND region_code=77).
|
|
||||||
"Москва (Восточный)": (55.8194, 37.8740), # +9, уходили к Балашихе
|
|
||||||
"Москва (Левобережный)": (55.8713, 37.4592), # +91, к Химкам
|
|
||||||
"Москва (Орехово-Борисово Южное)": (55.5986, 37.7233), # +80, к Развилке
|
|
||||||
"Москва (Северный)": (55.9278, 37.5411), # +79, к Долгопрудному
|
|
||||||
"Москва (Митино-запад)": (55.8410, 37.3507), # +78, к Красногорску
|
|
||||||
"Москва (Ивановское)": (55.7717, 37.8326), # +65, к Реутову
|
|
||||||
"Москва (Жулебино)": (55.6885, 37.8521), # +62, к Люберцам
|
|
||||||
"Москва (Можайский-запад)": (55.7008, 37.3936), # +50, к Немчиновке
|
|
||||||
"Москва (Новокосино)": (55.7385, 37.8576), # +44, к Реутову
|
|
||||||
"Москва (Капотня)": (55.6341, 37.7981), # +29, к Дзержинскому
|
|
||||||
"Москва (Куркино)": (55.8895, 37.3980), # +20, к Химкам
|
|
||||||
"Москва (Некрасовка-восток)": (55.6838, 37.9216), # +13, к Люберцам
|
|
||||||
}
|
|
||||||
|
|
||||||
# Все ключи сетки — один display «Москва» и один порог. Кортеж выводится из
|
|
||||||
# самого словаря, чтобы список ключей физически не мог разойтись с сеткой.
|
|
||||||
COVERAGE_MOSCOW_CENTROID_KEYS = tuple(_MOSCOW_GRID_DEG)
|
|
||||||
|
|
||||||
|
|
||||||
def _fold_city(name: str) -> str:
|
def _fold_city(name: str) -> str:
|
||||||
"""ёЁ→еЕ + casefold — та же normalization-идиома, что для адресов (см. #1774)."""
|
"""ёЁ→еЕ + casefold — та же normalization-идиома, что для адресов (см. #1774)."""
|
||||||
return name.strip().translate(str.maketrans("ёЁ", "ее")).casefold()
|
return name.strip().translate(str.maketrans("ёЁ", "ее")).casefold()
|
||||||
|
|
||||||
|
|
||||||
# Ключ — folded ИМЯ ЦЕНТРОИДА, значение — (display-имя города, порог). Для
|
|
||||||
# свердловских городов ключ и display совпадают, для московских центроидов —
|
|
||||||
# нет (все 67 ключей сетки дают «Москва»).
|
|
||||||
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
|
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
|
||||||
**{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES},
|
**{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES},
|
||||||
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
|
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
|
||||||
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_MO_CITIES},
|
|
||||||
**{
|
|
||||||
_fold_city(k): (COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_MIN_N)
|
|
||||||
for k in COVERAGE_MOSCOW_CENTROID_KEYS
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Повторная проверка ручки #2894 (2026-08): город раньше резолвился модой
|
# Повторная проверка ручки #2894 (2026-08): город раньше резолвился модой
|
||||||
|
|
@ -3136,15 +2783,7 @@ _COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
|
||||||
# статичного справочника из 8 географических центров населённых пунктов —
|
# статичного справочника из 8 географических центров населённых пунктов —
|
||||||
# оверинжиниринг; координаты (WGS84, общедоступные центры НП) живут здесь же,
|
# оверинжиниринг; координаты (WGS84, общедоступные центры НП) живут здесь же,
|
||||||
# рядом с порогами, которые они резолвят.
|
# рядом с порогами, которые они резолвят.
|
||||||
# Радиусы матчинга. Радиус — СВОЙСТВО ЦЕНТРОИДА (таблица `_CENTROID_RADIUS_KM`
|
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дальше — город не определён (not_covered)
|
||||||
# ниже, применяется в `_resolve_coverage_city`), потому что 25 км осмысленны
|
|
||||||
# ровно для конфигурации «один центроид на город», а у Москвы центроидов 67 и
|
|
||||||
# их круги складываются — разбор в комментарии над `_MOSCOW_GRID_DEG`.
|
|
||||||
# Свердловские точки радиуса не меняли: у каждой из них один центр на город,
|
|
||||||
# складываться нечему, а замер, которым выбраны эти 25 км, к сетке отношения
|
|
||||||
# не имеет.
|
|
||||||
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дефолт: один центроид на город, дальше — not_covered
|
|
||||||
COVERAGE_MOSCOW_MATCH_RADIUS_KM = 8.0 # сетка из 67 точек; верхний край плато 5..8, потолок 9.83
|
|
||||||
|
|
||||||
_CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
_CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
||||||
"Екатеринбург": (56.8389, 60.6057),
|
"Екатеринбург": (56.8389, 60.6057),
|
||||||
|
|
@ -3156,117 +2795,8 @@ _CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
||||||
"Первоуральск": (56.9083, 59.9483),
|
"Первоуральск": (56.9083, 59.9483),
|
||||||
"Ревда": (56.7986, 59.9298),
|
"Ревда": (56.7986, 59.9298),
|
||||||
"Серов": (59.6047, 60.5772),
|
"Серов": (59.6047, 60.5772),
|
||||||
# Московская область (регион 50), 22 города — добавлены 2026-09-16 вместе с
|
|
||||||
# COVERAGE_MO_CITIES (см. обоснование порога там же). Координаты для 12 из
|
|
||||||
# 22 перенесены как есть из `_COVERAGE_NEGATIVE_CENTROIDS_DEG` (эти города
|
|
||||||
# раньше были ТОЛЬКО отрицательными точками московской сетки — конкурс
|
|
||||||
# ближайшего центроида без порога, теперь у них есть и собственный порог).
|
|
||||||
# Оставшиеся 10 взяты из `MOSCOW_OBLAST_CITY_ANCHORS` в
|
|
||||||
# packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py — те же
|
|
||||||
# координаты, что использует скрейпер (резолв Nominatim).
|
|
||||||
"Видное": (55.5519, 37.7133),
|
|
||||||
"Балашиха": (55.7969, 37.9386),
|
|
||||||
"Железнодорожный": (55.7444, 38.0128),
|
|
||||||
"Люберцы": (55.6767, 37.8931),
|
|
||||||
"Электросталь": (55.7845, 38.4449),
|
|
||||||
"Химки": (55.8894, 37.4450),
|
|
||||||
"Домодедово": (55.4368, 37.7681),
|
|
||||||
"Ногинск": (55.8554, 38.4412),
|
|
||||||
"Реутов": (55.7614, 37.8564),
|
|
||||||
"Долгопрудный": (55.9386, 37.5100),
|
|
||||||
"Красногорск": (55.8317, 37.3300),
|
|
||||||
"Одинцово": (55.6789, 37.2639),
|
|
||||||
"Подольск": (55.4312, 37.5450),
|
|
||||||
"Сергиев Посад": (56.3153, 38.1360),
|
|
||||||
"Королёв": (55.9142, 37.8256),
|
|
||||||
"Мытищи": (55.9116, 37.7308),
|
|
||||||
"Орехово-Зуево": (55.8062, 38.9810),
|
|
||||||
"Серпухов": (54.9155, 37.4196),
|
|
||||||
"Раменское": (55.5709, 38.2282),
|
|
||||||
"Наро-Фоминск": (55.3843, 36.7235),
|
|
||||||
"Щёлково": (55.9206, 37.9916),
|
|
||||||
"Коломна": (55.0939, 38.7670),
|
|
||||||
# Москва — сетка из 67 центроидов, одно display-имя и один порог на все.
|
|
||||||
# Как сетка получена, почему точек именно столько, где проходит граница
|
|
||||||
# с областью и какова остаточная цена — см. большой комментарий над
|
|
||||||
# `_MOSCOW_GRID_DEG`. Здесь только подстановка: координаты и ключи живут
|
|
||||||
# в одном месте, чтобы их нельзя было рассинхронизировать.
|
|
||||||
**_MOSCOW_GRID_DEG,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ОТРИЦАТЕЛЬНЫЕ центроиды: подмосковные города и посёлки, попадающие внутрь
|
|
||||||
# радиуса московских точек. Список 11.09.2026 перепроверен на итоговой
|
|
||||||
# конфигурации (67 точек сетки, радиус 8 км): все 32 точки живы — ни одну
|
|
||||||
# не перехватывает московский центроид, в самой точке выигрывает она сама,
|
|
||||||
# и ответ остаётся «город не определён». Внутрь 8 км от какой-нибудь точки
|
|
||||||
# сетки попадают 13 из них (Химки, Реутов, Люберцы, Котельники, Дзержинский,
|
|
||||||
# Красногорск, Долгопрудный, Мытищи, Видное, Одинцово, Подольск, Апрелевка,
|
|
||||||
# Селятино) — каждая выигрывает собственным центром. Ближайшая пара после
|
|
||||||
# добавления новых точек: «Митино-запад» в 1.6 км от Красногорска. Участвуют
|
|
||||||
# в конкурсе ближайшего центроида наравне с городами из
|
|
||||||
# `_CITY_CENTROIDS_DEG`, но НАМЕРЕННО отсутствуют в `_COVERAGE_CITY_THRESHOLDS`:
|
|
||||||
# центроид без порога = «город не определён», тот же ответ, что для точки
|
|
||||||
# в чистом поле. Обоснование и цена — в комментарии «ГРАНИЦА С ОБЛАСТЬЮ» выше.
|
|
||||||
#
|
|
||||||
# Координаты — WGS84, общедоступные центры НП; список закрытый и расширяется
|
|
||||||
# только по замеру (точка вне радиуса всех московских бесполезна, точка внутри
|
|
||||||
# Москвы сожгла бы ещё часть покрытия). Ключи здесь и в `_CITY_CENTROIDS_DEG`
|
|
||||||
# не должны пересекаться — это проверяется тестом.
|
|
||||||
#
|
|
||||||
# Города области, которые НЕ попадают ни в один московский радиус, здесь и не
|
|
||||||
# нужны: Наро-Фоминск (9.83 км до сетки), Кубинка (17.18), Чехов (22.25) отдают
|
|
||||||
# «город не определён» просто потому, что вне 8 км. При прежних 25 км все трое
|
|
||||||
# резолвились как «Москва» — это и был дефект, ради которого радиус переехал
|
|
||||||
# в свойство центроида.
|
|
||||||
_COVERAGE_NEGATIVE_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
|
||||||
"Андреевка": (55.9772, 37.1100),
|
|
||||||
"Менделеево": (56.0333, 37.2333),
|
|
||||||
"Сходня": (55.9500, 37.3000),
|
|
||||||
"Чёрная Грязь": (55.9667, 37.3167),
|
|
||||||
"Лунёво": (56.0000, 37.3500),
|
|
||||||
"Поварово": (56.0667, 37.0667),
|
|
||||||
"Дедовск": (55.8672, 37.1200),
|
|
||||||
"Нахабино": (55.8500, 37.1833),
|
|
||||||
"Апрелевка": (55.5500, 37.0700),
|
|
||||||
"Немчиновка": (55.7050, 37.3450),
|
|
||||||
"Лобня": (56.0100, 37.4750),
|
|
||||||
"Котельники": (55.6553, 37.8619),
|
|
||||||
"Дзержинский": (55.6294, 37.8500),
|
|
||||||
"Развилка": (55.5842, 37.7392),
|
|
||||||
"Климовск": (55.3667, 37.5333),
|
|
||||||
"Истра": (55.9167, 36.8667),
|
|
||||||
"Селятино": (55.5081, 36.9825),
|
|
||||||
"Томилино": (55.6528, 37.9472),
|
|
||||||
"Некрасовский": (56.0500, 37.5500),
|
|
||||||
"Барвиха": (55.7333, 37.2333),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Радиус матчинга по центроиду: ключ — folded имя центроида, значение — км.
|
|
||||||
# Чего здесь нет — берёт `COVERAGE_CITY_MATCH_RADIUS_KM` (25 км), то есть все
|
|
||||||
# свердловские точки. Таблица выводится из самих словарей координат, чтобы
|
|
||||||
# радиус физически не мог разойтись со списком точек.
|
|
||||||
#
|
|
||||||
# Почему у отрицательных точек радиус бесконечный. Отрицательный центроид
|
|
||||||
# ничего не «накрывает»: он влияет на ответ только там, где он ближе всех
|
|
||||||
# (в своей ячейке Вороного), а выигравший центроид без порога даёт «город не
|
|
||||||
# определён» на ЛЮБОМ расстоянии — обе ветки `_resolve_coverage_city` (вышли
|
|
||||||
# за радиус / нет порога) возвращают один и тот же ответ. Поэтому конечное
|
|
||||||
# число здесь было бы декорацией: поведение оно не меняет, но читалось бы как
|
|
||||||
# «дальше отрицательная точка перестаёт действовать», чего не происходит.
|
|
||||||
# math.inf говорит правду — границу отрицательной точки задаёт геометрия
|
|
||||||
# соседей, а не радиус, и попытка «подрезать» её числом ничего не вернёт
|
|
||||||
# Москве, зато спрячет этот факт от следующего читателя.
|
|
||||||
_CENTROID_RADIUS_KM: dict[str, float] = {
|
|
||||||
**{_fold_city(k): COVERAGE_MOSCOW_MATCH_RADIUS_KM for k in COVERAGE_MOSCOW_CENTROID_KEYS},
|
|
||||||
**{_fold_city(k): math.inf for k in _COVERAGE_NEGATIVE_CENTROIDS_DEG},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _centroid_radius_km(centroid_key: str) -> float:
|
|
||||||
"""Радиус матчинга КОНКРЕТНОГО центроида в км (см. `_CENTROID_RADIUS_KM`)."""
|
|
||||||
return _CENTROID_RADIUS_KM.get(_fold_city(centroid_key), COVERAGE_CITY_MATCH_RADIUS_KM)
|
|
||||||
|
|
||||||
|
|
||||||
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||||
"""Расстояние по большому кругу (км), радиус Земли 6371 км."""
|
"""Расстояние по большому кругу (км), радиус Земли 6371 км."""
|
||||||
|
|
@ -3282,49 +2812,23 @@ def _resolve_coverage_city(lat: float, lon: float) -> tuple[str, int, bool]:
|
||||||
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия — ПО КООРДИНАТАМ.
|
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия — ПО КООРДИНАТАМ.
|
||||||
|
|
||||||
Город = ближайший центроид из `_CITY_CENTROIDS_DEG`, если расстояние до него
|
Город = ближайший центроид из `_CITY_CENTROIDS_DEG`, если расстояние до него
|
||||||
в пределах СОБСТВЕННОГО радиуса этого центроида (`_centroid_radius_km`:
|
< `COVERAGE_CITY_MATCH_RADIUS_KM`; иначе город не определён. Детерминированно
|
||||||
25 км у свердловских городов, 8 км у точек московской сетки — почему так,
|
|
||||||
см. комментарий над `_MOSCOW_GRID_DEG`); иначе город не определён.
|
|
||||||
Детерминированно
|
|
||||||
и без участия клиента — см. комментарий над `_CITY_CENTROIDS_DEG` про то,
|
и без участия клиента — см. комментарий над `_CITY_CENTROIDS_DEG` про то,
|
||||||
почему `listings.city` (мода когорты) и `city_hint` (клиентский вход) сюда
|
почему `listings.city` (мода когорты) и `city_hint` (клиентский вход) сюда
|
||||||
больше НЕ допускаются в качестве источника истины.
|
больше НЕ допускаются в качестве источника истины.
|
||||||
|
|
||||||
В конкурсе ближайшего участвуют И отрицательные центроиды
|
|
||||||
(`_COVERAGE_NEGATIVE_CENTROIDS_DEG`) — подмосковные точки без порога.
|
|
||||||
Выигравший центроид без порога означает «город не определён»: так граница
|
|
||||||
Москвы и области проходит по конкурсу центров, а не по кругу радиуса
|
|
||||||
(см. «ГРАНИЦА С ОБЛАСТЬЮ» над `_CITY_CENTROIDS_DEG`).
|
|
||||||
|
|
||||||
Возвращается display-имя из `_COVERAGE_CITY_THRESHOLDS`, а НЕ ключ
|
|
||||||
центроида: у Москвы 67 центроидов на один город, и наружу все они обязаны
|
|
||||||
отдавать «Москва» с одним порогом (см. `COVERAGE_MOSCOW_CENTROID_KEYS`).
|
|
||||||
"""
|
"""
|
||||||
nearest_city: str | None = None
|
nearest_city: str | None = None
|
||||||
nearest_km = math.inf
|
nearest_km = math.inf
|
||||||
for city, (clat, clon) in (
|
for city, (clat, clon) in _CITY_CENTROIDS_DEG.items():
|
||||||
*_CITY_CENTROIDS_DEG.items(),
|
|
||||||
*_COVERAGE_NEGATIVE_CENTROIDS_DEG.items(),
|
|
||||||
):
|
|
||||||
distance_km = _haversine_km(lat, lon, clat, clon)
|
distance_km = _haversine_km(lat, lon, clat, clon)
|
||||||
if distance_km < nearest_km:
|
if distance_km < nearest_km:
|
||||||
nearest_km = distance_km
|
nearest_km = distance_km
|
||||||
nearest_city = city
|
nearest_city = city
|
||||||
|
|
||||||
# Радиус берётся у ПОБЕДИТЕЛЯ конкурса, а не общий: круги плотной сетки
|
if nearest_city is None or nearest_km > COVERAGE_CITY_MATCH_RADIUS_KM:
|
||||||
# складываются, и один глобальный радиус либо режет Свердловскую область,
|
|
||||||
# либо протекает из Москвы в область (см. `_CENTROID_RADIUS_KM`).
|
|
||||||
if nearest_city is None or nearest_km > _centroid_radius_km(nearest_city):
|
|
||||||
return "", 0, False
|
return "", 0, False
|
||||||
|
|
||||||
# .get(), а не индексирование: ближайшим мог оказаться отрицательный
|
display, threshold = _COVERAGE_CITY_THRESHOLDS[_fold_city(nearest_city)]
|
||||||
# центроид (или новый центроид, для которого забыли завести порог) — это
|
|
||||||
# «город не определён», а не KeyError и 500 на живом адресе.
|
|
||||||
entry = _COVERAGE_CITY_THRESHOLDS.get(_fold_city(nearest_city))
|
|
||||||
if entry is None:
|
|
||||||
return "", 0, False
|
|
||||||
|
|
||||||
display, threshold = entry
|
|
||||||
return display, threshold, True
|
return display, threshold, True
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,6 @@ from sqlalchemy.exc import ArgumentError
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import DB_CONNECT_ARGS
|
|
||||||
|
|
||||||
|
|
||||||
class AuthDatabaseNotConfiguredError(RuntimeError):
|
class AuthDatabaseNotConfiguredError(RuntimeError):
|
||||||
|
|
@ -102,18 +101,6 @@ def _build() -> tuple[Engine, sessionmaker[Session]]:
|
||||||
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
|
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
|
||||||
# и сырые psycopg-подключения мимо движков — это отдельный класс.
|
# и сырые psycopg-подключения мимо движков — это отдельный класс.
|
||||||
hide_parameters=True,
|
hide_parameters=True,
|
||||||
# #3463. Те же потолки, что у продуктового движка, — ОДНОЙ константой на оба:
|
|
||||||
# потолок на одном движке и мина на втором это не починка, а половина.
|
|
||||||
# Этот движок живёт на ГОРЯЧЕМ пути: `core/rbac.py` резолвит session-cookie
|
|
||||||
# в middleware, синхронно на event loop'е, на КАЖДОМ запросе с cookie
|
|
||||||
# (на проде IDENTITY_STORE=auth во всех трёх сервисах образа — сверено 12.09,
|
|
||||||
# `printenv` в контейнерах). Без потолка `ACCESS EXCLUSIVE` на `auth.sessions`
|
|
||||||
# вешает не четыре слота `/estimate`, а весь uvicorn-воркер (он один, без
|
|
||||||
# --workers) — включая `/health`.
|
|
||||||
# Срабатывание потолка безопасно: вызов в rbac.py уже под `except Exception`
|
|
||||||
# с фолбэком на заголовочную аутентификацию, то есть отмена запроса даёт тот
|
|
||||||
# же путь, что и любой другой сбой реестра, а не 500.
|
|
||||||
connect_args=DB_CONNECT_ARGS,
|
|
||||||
)
|
)
|
||||||
except (ArgumentError, ValueError):
|
except (ArgumentError, ValueError):
|
||||||
# ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до
|
# ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,6 @@ _AUTH_DB_DEFAULT_USER = "auth_app"
|
||||||
# никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк).
|
# никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк).
|
||||||
LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней
|
LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней
|
||||||
|
|
||||||
# #1795 шаг 1 / #3452: минимум ДКП-сделок, с которого коридор держит цену — soft-кламп
|
|
||||||
# headline сверху и radius-floor снизу (estimator.py). Живёт здесь, а не в estimator.py:
|
|
||||||
# тот же порог читает схема (DkpCorridor.advisory_only), а schemas остаётся import-light.
|
|
||||||
# Бывшее поле Settings estimate_corridor_clamp_min_n (#2380), на проде не переопределялось.
|
|
||||||
CORRIDOR_CLAMP_MIN_N = 10
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
@ -384,25 +378,36 @@ class Settings(BaseSettings):
|
||||||
dadata_api_token: str | None = None
|
dadata_api_token: str | None = None
|
||||||
dadata_api_secret: str | None = None
|
dadata_api_secret: str | None = None
|
||||||
|
|
||||||
# #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на
|
# ── #651: IMV / Yandex blend (killer accuracy fix) ──────────────────────
|
||||||
# данные ЭТОГО адреса, а не на среднее по городу.
|
# Радиусная медиана ₽/м² системно недооценивает премиум/видовые квартиры
|
||||||
# Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного
|
# (нет class/segment/IMV-коррекции → premium ~2x underestimate, case 50М vs
|
||||||
# объявления. Число приходило из ДКП-коридора Росреестра, а тот при тонкой
|
# факт ~100М). Если внешний якорь (Avito IMV recommended_price из
|
||||||
# улице (n < DKP_CORRIDOR_CITY_WIDE_MIN_N = 3) расширяется до всего города и
|
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
|
||||||
# помечает себя scope="city_wide". Проверено на проде: у Янтарной ОДНА сделка,
|
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
|
||||||
# по Красногорску 4770 с медианой 200 676 ₽/м² — пользователю показали среднюю
|
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
|
||||||
# цену города как оценку его квартиры. Доля таких ответов 3.8% по ЕКБ, 4.8% по
|
# При отсутствии IMV/Yandex no-op (медиана не меняется).
|
||||||
# 77/50, то есть дефект продукта, а не регионального покрытия.
|
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
|
||||||
# Флаг делает две вещи, обе откатываются одним переключателем:
|
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
|
||||||
# 1) общегородской коридор больше не может стать headline (уличный — может:
|
|
||||||
# Нижний Тагил с 12 сделками по улице это настоящий сигнал, ради него
|
|
||||||
# #oblast-D и делали, иначе туда протекал екатеринбургский asking вшестеро
|
|
||||||
# выше);
|
|
||||||
# 2) если headline так и не появился, ответ уходит честной карточкой
|
|
||||||
# «недостаточно данных», а не нулями.
|
|
||||||
# False ⇒ прежнее поведение целиком, без релиза.
|
|
||||||
estimate_require_local_evidence: bool = True
|
|
||||||
|
|
||||||
|
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ──────────
|
||||||
|
# Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка,
|
||||||
|
# комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
|
||||||
|
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
|
||||||
|
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
|
||||||
|
# и tighter FSD-диапазон.
|
||||||
|
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
|
||||||
|
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
|
||||||
|
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
|
||||||
|
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
|
||||||
|
# #680-WB within-building heterogeneity refine: floor-similarity Gaussian по
|
||||||
|
# ОТНОСИТЕЛЬНОЙ вертикальной позиции (floor/total_floors). Прижимает якорь к
|
||||||
|
# комплам с похожим этажом — мелкокомнатный/нижний юнит во флагман-доме больше
|
||||||
|
# не наследует цену видового топ-этажа. 0.0 → выключено (точно старое поведение).
|
||||||
|
# Откалибровано на 55 golden (offline): σ_f=0.25 даёт лучший medAPE без потери
|
||||||
|
# покрытия; Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%.
|
||||||
|
estimate_sb_floor_sigma: float = 0.25
|
||||||
|
estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol)
|
||||||
|
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD
|
||||||
# ── #1966: honest calibrated prediction-interval для expected_sold range ─────
|
# ── #1966: honest calibrated prediction-interval для expected_sold range ─────
|
||||||
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
|
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
|
||||||
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
|
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
|
||||||
|
|
@ -412,24 +417,6 @@ class Settings(BaseSettings):
|
||||||
# (проверено: 80.0% coverage на тех же 2366).
|
# (проверено: 80.0% coverage на тех же 2366).
|
||||||
estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366)
|
estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366)
|
||||||
estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366)
|
estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366)
|
||||||
# ── региональные множители PI (замер 2026-09-16) ──────────────────────────
|
|
||||||
# Скаляры выше сняты ТОЛЬКО на Екатеринбурге (обл.66, #1966) и молча
|
|
||||||
# применялись ко всем регионам — прогон `--engine full --sample 2000
|
|
||||||
# --since 2025-06-01 --spread scattered --seed 42 --resolve-house-id
|
|
||||||
# --pi-report` (гедоническая коррекция #2002 уже выключена в main) показал,
|
|
||||||
# что достигнутое покрытие «80%-й» вилки уезжает по региону:
|
|
||||||
# регион n p10 p50 p90 факт. покрытие
|
|
||||||
# 77 1969 0.448 1.043 1.440 71.2%
|
|
||||||
# 50 1632 0.703 1.117 1.500 74.8%
|
|
||||||
# 66 1360 0.649 1.143 1.584 66.2%
|
|
||||||
# Воспроизведено на независимой выборке (seed 777): 77 → 0.453/1.040/1.455,
|
|
||||||
# 50 → 0.701/1.115/1.514 — числа устойчивы, это не шум сэмпла.
|
|
||||||
# ВАЖНО: p50 (медиана actual_sold/expected_sold) > 1 во ВСЕХ трёх регионах —
|
|
||||||
# точка expected_sold систематически занижена на 4-14%. Эта правка чинит
|
|
||||||
# только ШИРИНУ/покрытие вилки по региону, а НЕ смещение точки. Калибровка
|
|
||||||
# asking→sold на более коротком (свежем) окне — отдельная незакрытая задача.
|
|
||||||
estimate_pi_low_mult_by_region: dict[int, float] = {77: 0.448, 50: 0.703, 66: 0.649}
|
|
||||||
estimate_pi_high_mult_by_region: dict[int, float] = {77: 1.440, 50: 1.500, 66: 1.584}
|
|
||||||
# ── #2002: hedonic year+area correction на точку expected_sold ─────────────
|
# ── #2002: hedonic year+area correction на точку expected_sold ─────────────
|
||||||
# Диагноз: estimator систематически промахивается по эре дома + размеру —
|
# Диагноз: estimator систематически промахивается по эре дома + размеру —
|
||||||
# недооценивает новостройки, плохо держит крупные лоты. Held-out fit (n=2366
|
# недооценивает новостройки, плохо держит крупные лоты. Held-out fit (n=2366
|
||||||
|
|
@ -443,27 +430,7 @@ class Settings(BaseSettings):
|
||||||
# эконом/комфорт/премиум лучше, элит без изменений (no harm).
|
# эконом/комфорт/премиум лучше, элит без изменений (no harm).
|
||||||
# После фактора заново применяется le_asking-кламп (expected_sold ≤ asking).
|
# После фактора заново применяется le_asking-кламп (expected_sold ≤ asking).
|
||||||
# OFF ⇒ точно старое поведение expected_sold.
|
# OFF ⇒ точно старое поведение expected_sold.
|
||||||
# ВЫКЛЮЧЕНА 2026-09-16. Замер на проде показал, что коррекция превратилась в
|
estimate_hedonic_correction_enabled: bool = True
|
||||||
# чистое занижение цены: она двигает точку вниз и НЕ покупает за это точности.
|
|
||||||
# Контролируемый эксперимент — одна и та же выборка (--engine full --sample 2000
|
|
||||||
# --since 2025-06-01 --spread scattered --seed 42 --resolve-house-id), меняется
|
|
||||||
# ровно эта переменная:
|
|
||||||
# регион ВКЛ bias/MAPE/покрытие ВЫКЛ bias/MAPE/покрытие
|
|
||||||
# 77 Москва -9.1 / 20.5 / 69.0 -4.1 / 19.7 / 71.2
|
|
||||||
# 50 область -12.1 / 19.4 / 75.4 -10.4 / 19.2 / 74.8
|
|
||||||
# 66 обл. целиком -17.1 / 23.0 / 66.7 -12.4 / 23.2 / 66.2
|
|
||||||
# 66 только ЕКБ -17.4 / 21.2 / 69.8 -14.2 / 21.3 / 69.9
|
|
||||||
# Последняя строка решающая: ЕКБ — ровно та выборка, на которой коэффициенты
|
|
||||||
# перефитили 30.08.2026 (#3248, n=1269). Даже там выключение снимает 3.2 пункта
|
|
||||||
# занижения при неизменных MAPE и покрытии. Механика понятна из самих
|
|
||||||
# коэффициентов: b_larea=0, поэтому фактор = exp(-0.0140 + 0.0769*(year-2000)/20
|
|
||||||
# - 0.0745*[floor==1]), и для дома 1980 года это 0.913, то есть -8.7%. Жилой фонд
|
|
||||||
# в выборке преимущественно старый → фактор systematically < 1 → минус к цене.
|
|
||||||
# Коэффициенты фитили на остатке log(actual_sold/expected_sold) ПРИ ТОЙ базе,
|
|
||||||
# какая была тогда; база с тех пор уехала (area-бакетный ratio #2620, коэффициент
|
|
||||||
# asking->sold пересчитан по регионам 15.09), а хедонику не пересчитывали.
|
|
||||||
# ВКЛЮЧАТЬ ОБРАТНО только вместе с перефитом и замером всех четырёх строк выше.
|
|
||||||
estimate_hedonic_correction_enabled: bool = False
|
|
||||||
# #3248 (перефит 2026-08-30, n=1269 из свежей прод-фикстуры ЕКБ 1600 сделок).
|
# #3248 (перефит 2026-08-30, n=1269 из свежей прод-фикстуры ЕКБ 1600 сделок).
|
||||||
#
|
#
|
||||||
# Прежние значения (b0=0.6146, year=0.1220, larea=-0.1603, first=-0.1248) зафичены
|
# Прежние значения (b0=0.6146, year=0.1220, larea=-0.1603, first=-0.1248) зафичены
|
||||||
|
|
@ -497,13 +464,128 @@ class Settings(BaseSettings):
|
||||||
estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
|
estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
|
||||||
estimate_hedonic_factor_min: float = 0.75
|
estimate_hedonic_factor_min: float = 0.75
|
||||||
estimate_hedonic_factor_max: float = 1.30
|
estimate_hedonic_factor_max: float = 1.30
|
||||||
|
# ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────
|
||||||
|
# Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП
|
||||||
|
# (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%.
|
||||||
|
# Каждый флаг в no-op/OFF положении восстанавливает ТОЧНО старое поведение.
|
||||||
|
#
|
||||||
|
# Шаг 1 — soft-кламп headline к коридору ДКП-сделок Росреестра. Когда
|
||||||
|
# median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A"
|
||||||
|
# (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
|
||||||
|
# к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
|
||||||
|
estimate_corridor_clamp_min_n: int = 10
|
||||||
|
# cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
|
||||||
|
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
|
||||||
|
# (tier-C гейт ×1.5 ловит явную контаминацию выше).
|
||||||
|
estimate_corridor_clamp_slack: float = 0.40
|
||||||
|
# Нижний floor для radius-пути: симметрично corridor-clamp сверху, но снизу.
|
||||||
|
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
|
||||||
|
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
|
||||||
|
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
|
||||||
|
# (избегаем ложных подъёмов) и ловит явный undershoot.
|
||||||
|
# ENV: ESTIMATE_RADIUS_FLOOR_FACTOR.
|
||||||
|
estimate_radius_floor_factor: float = 0.8
|
||||||
|
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
|
||||||
|
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
|
||||||
|
# Диагноз: в high-price tier asking->sold ratio > 1.0 (product artefact, не реальные
|
||||||
|
# сделки выше прайса) -> expected_sold = headline x ratio > headline.
|
||||||
|
# При флаге True: если ratio > 1.0 — клампаем до 1.0 и логируем. Применяется
|
||||||
|
# к point И range (expected_sold_low/high/price) консистентно.
|
||||||
|
# False -> старое поведение без clamp (backward-compat).
|
||||||
|
# ENV: ESTIMATE_EXPECTED_SOLD_LE_ASKING.
|
||||||
|
estimate_expected_sold_le_asking: bool = Field(
|
||||||
|
default=True, validation_alias="ESTIMATE_EXPECTED_SOLD_LE_ASKING"
|
||||||
|
)
|
||||||
|
# Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor:
|
||||||
|
# при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k
|
||||||
|
# (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх).
|
||||||
|
# mad_k_small >= estimate_sb_mad_k → no-op (старое поведение).
|
||||||
|
estimate_sb_mad_k_small_n: float = 2.5
|
||||||
|
estimate_sb_small_n_threshold: int = 10
|
||||||
|
# Шаг 3 — гейт Tier C: micro-radius anchor (НЕ тот же дом) с
|
||||||
|
# anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану.
|
||||||
|
# Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение).
|
||||||
|
estimate_anchor_tier_c_corridor_mult: float = 1.5
|
||||||
|
# Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < threshold
|
||||||
|
# k уменьшается с 1.5 до tukey_k_small. threshold=0 → выключено (старое поведение).
|
||||||
|
estimate_outlier_small_n_threshold: int = 15
|
||||||
|
estimate_outlier_tukey_k_small: float = 1.0
|
||||||
|
# #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же
|
||||||
|
# доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где
|
||||||
|
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
|
||||||
|
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
|
||||||
|
# ratio — не затрагиваются.
|
||||||
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
|
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
|
||||||
|
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
|
||||||
|
|
||||||
|
# ── #audit-1: anchor low-confidence gate ─────────────────────────────────
|
||||||
|
# Якорь с низкой уверенностью (confidence="low" ИЛИ n < min_n И FSD > max_fsd)
|
||||||
|
# НЕ заменяет headline — fallback на radius-median. Дефолты подобраны так, что
|
||||||
|
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
|
||||||
|
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
|
||||||
|
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
|
||||||
|
estimate_sb_gate_min_n: int = 3
|
||||||
|
estimate_sb_gate_max_fsd: float = 0.20
|
||||||
|
|
||||||
# ── #audit-3: price_trend freshness filter ────────────────────────────────
|
# ── #audit-3: price_trend freshness filter ────────────────────────────────
|
||||||
# Исключать items старше N месяцев из price_trend (house_placement_history).
|
# Исключать items старше N месяцев из price_trend (house_placement_history).
|
||||||
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
|
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
|
||||||
estimate_price_trend_max_age_months: int = 6
|
estimate_price_trend_max_age_months: int = 6
|
||||||
|
|
||||||
|
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
|
||||||
|
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
|
||||||
|
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
|
||||||
|
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
|
||||||
|
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
|
||||||
|
# аналога → headline выглядит достоверным при нулевой реальной базе.
|
||||||
|
|
||||||
|
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
|
||||||
|
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
|
||||||
|
# человеком. Research: элит/премиум-премия unit-level и под-доверена (зависит
|
||||||
|
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
|
||||||
|
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
|
||||||
|
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
|
||||||
|
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
|
||||||
|
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
|
||||||
|
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
|
||||||
|
# (премия за отделку/вид/класс — unit-level, отсутствует в данных сделок).
|
||||||
|
estimate_manual_review_elite_ppm2: int = 250000
|
||||||
|
|
||||||
|
# ── #1871 P2: radius-tier (source, source_id) dedup ───────────────────────
|
||||||
|
# Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address
|
||||||
|
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
|
||||||
|
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
|
||||||
|
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
|
||||||
|
# (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире
|
||||||
|
# (freshest scraped_at на (source, source_id|source_url|ctid)).
|
||||||
|
|
||||||
|
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
|
||||||
|
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
|
||||||
|
# Один физический лот кросс-постится на avito+cian+domklik (разные source, разные
|
||||||
|
# source_id) → radius-дедуп его НЕ схлопывает → он считается несколько раз →
|
||||||
|
# раздувает n_analogs И cv (→ шире коридор), может смещать медиану. Прод-аудит
|
||||||
|
# #2087: лот 80м²/265000₽/м² = N1+Домклик+Циан (×3); «14 аналогов» → ~6-7 уникальных.
|
||||||
|
# True схлопывает дубли по ФИЗИЧЕСКОМУ ключу до подсчёта n_analogs/median/cv:
|
||||||
|
# building (building_cadastral_number | нормализованный address)
|
||||||
|
# + floor + area_bucket (round(area_m2), ~±0.5 м²)
|
||||||
|
# + price_bucket (round(price_rub / 100000), ~±0.5% @21М / ~±2% @2.5М).
|
||||||
|
# Из группы остаётся ОДИН представитель (свежайший scraped_at), НЕ суммируем;
|
||||||
|
# n_analogs/median/cv/source_counts/sources_used считаются по физическим лотам
|
||||||
|
# («лот считается один раз»).
|
||||||
|
#
|
||||||
|
# Бэктест #1966 (400 ДКП, radius-путь, full spine, OFF vs ON): MAPE 13.89% →
|
||||||
|
# 13.89%, coverage 83.33% → 83.33%, bias −3.83% → −3.83%, median width 0.743 →
|
||||||
|
# 0.743, median cv 0.0988 → 0.0988; avg n_analogs 27.64 → 27.57. Дедуп отработал
|
||||||
|
# 107× на 335 оценках, но снимает лишь identical-price кросс-посты (дубли имеют
|
||||||
|
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
|
||||||
|
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
|
||||||
|
# физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор —
|
||||||
|
# post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs
|
||||||
|
# ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%, median width/cv
|
||||||
|
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
|
||||||
|
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
|
||||||
|
estimate_dedup_analogs_enabled: bool = True
|
||||||
|
|
||||||
# ── #2012: kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring ──
|
# ── #2012: kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring ──
|
||||||
# Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
|
# Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
|
||||||
# estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
|
# estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
|
||||||
|
|
@ -572,16 +654,6 @@ class Settings(BaseSettings):
|
||||||
# ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED.
|
# ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED.
|
||||||
estimate_unknown_attr_penalty_enabled: bool = False
|
estimate_unknown_attr_penalty_enabled: bool = False
|
||||||
estimate_unknown_attr_penalty_min_n: int = 5
|
estimate_unknown_attr_penalty_min_n: int = 5
|
||||||
# #3234: Tier H в _fetch_analogs («тот же класс дома»). Порог отдачи и окно
|
|
||||||
# этажности были литералами; вынесены, чтобы бэктест мог сравнить варианты
|
|
||||||
# через env без правки кода. Дефолты = прежнее поведение байт-в-байт.
|
|
||||||
# Прод, Loki 30 сут (12.09): 61 из 86 попыток Tier H уходят в Tier W, в 31 из
|
|
||||||
# них в своей полосе 0 комплов. Менять дефолт — только по MAPE бэктеста.
|
|
||||||
# Ручки временные: после решения по бэктесту (#3234, шаг 4) вернуть их в
|
|
||||||
# константы движка, как #1970 сделал с остальными неменявшимися estimate_*.
|
|
||||||
# ENV: ESTIMATE_TIER_H_MIN_COMPS, ESTIMATE_TIER_H_FLOORS_TOL.
|
|
||||||
estimate_tier_h_min_comps: int = 5
|
|
||||||
estimate_tier_h_floors_tol: float = 0.30 # total_floors × (1 ± tol)
|
|
||||||
#
|
#
|
||||||
# is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая
|
# is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая
|
||||||
# корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard
|
# корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard
|
||||||
|
|
@ -596,6 +668,20 @@ class Settings(BaseSettings):
|
||||||
# ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED.
|
# ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED.
|
||||||
estimate_is_apartments_filter_enabled: bool = False
|
estimate_is_apartments_filter_enabled: bool = False
|
||||||
|
|
||||||
|
# ── #1871 P2: split-дома wide-corridor disclosure (default ON, порог 1.2) ──
|
||||||
|
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
|
||||||
|
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
|
||||||
|
# растягивается через несколько ценовых режимов → коридор range_low/high
|
||||||
|
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A +
|
||||||
|
# corridor_pct > threshold → понижаем confidence на ступень и дописываем
|
||||||
|
# disclosure в explanation. НЕ трогает point/median/range.
|
||||||
|
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
|
||||||
|
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
|
||||||
|
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
|
||||||
|
# Genuine split-дома из аудита = 148-170% (1.48-1.70) → 1.2 ловит только
|
||||||
|
# экстремальный хвост (>p99), не трогая нормальную оценочную неопределённость.
|
||||||
|
estimate_wide_corridor_threshold: float = 1.2
|
||||||
|
|
||||||
# ── Mera-audit fix-1: Cian valuation sanity bounds ────────────────────────
|
# ── Mera-audit fix-1: Cian valuation sanity bounds ────────────────────────
|
||||||
# API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999).
|
# API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999).
|
||||||
# sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется).
|
# sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется).
|
||||||
|
|
@ -620,7 +706,26 @@ class Settings(BaseSettings):
|
||||||
# datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER).
|
# datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER).
|
||||||
avito_imv_use_browser_fetcher: bool = False
|
avito_imv_use_browser_fetcher: bool = False
|
||||||
|
|
||||||
# ── #764: квартальный индекс цены (пороги — константы QUARTER_* в estimator.py) ──
|
# ── #764: per-cadastral-quarter price index correction ───────────────────
|
||||||
|
# Gap-correction: квартальный индекс применяется ТОЛЬКО в pure-radius пути
|
||||||
|
# (когда same-building anchor и IMV-blend не сработали). Корректирует РАЗРЫВ
|
||||||
|
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
|
||||||
|
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
|
||||||
|
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
|
||||||
|
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
|
||||||
|
estimate_quarter_index_min_n_deals: int = 10
|
||||||
|
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
|
||||||
|
# аналоги уже несут локацию квартала → skip (location in median).
|
||||||
|
estimate_quarter_match_skip_ratio: float = 0.6
|
||||||
|
# Bimodal/nominal guard (backtest 2026-05-31): структурно неоднородные кварталы
|
||||||
|
# дают индекс > 2.0 при малой выборке → no-op чтобы избежать регрессию.
|
||||||
|
estimate_quarter_index_max_for_small_n: float = 2.0
|
||||||
|
estimate_quarter_index_small_n_threshold: int = 50
|
||||||
|
# Sanity-clamp на factor = target_index / avg_analog_index (#859).
|
||||||
|
# Belt-and-suspenders против патологичных FDW-данных. Нормальные квартальные
|
||||||
|
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
|
||||||
|
estimate_quarter_index_factor_min: float = 0.6
|
||||||
|
estimate_quarter_index_factor_max: float = 1.8
|
||||||
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
|
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
|
||||||
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
|
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
|
||||||
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
|
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
|
||||||
|
|
@ -799,27 +904,6 @@ class Settings(BaseSettings):
|
||||||
# ENV: ASKING_RATIO_PPM2_MAX.
|
# ENV: ASKING_RATIO_PPM2_MAX.
|
||||||
asking_ratio_ppm2_max: int = 1_200_000
|
asking_ratio_ppm2_max: int = 1_200_000
|
||||||
|
|
||||||
# Time-adjust для SOLD-стороны asking→sold ratio (rollback-флаг, без релиза).
|
|
||||||
# ПРОБЛЕМА: числитель ratio (deals, trailing 12 months) — сырые цены сделок,
|
|
||||||
# знаменатель (listings, LISTINGS_FRESH_DAYS) — сегодняшний рынок. На растущем
|
|
||||||
# рынке медиана сделок системно отстаёт от медианы объявлений, т.е. ratio занижен
|
|
||||||
# ровно на дрейф рынка за половину окна. Прод-замер 2026-09-16 (окно сделок
|
|
||||||
# 2025-10..2026-04, ряд real_estate_deals своего региона, clamp 0.7/1.6):
|
|
||||||
# регион 77 n=68 731 sold_median 323 529 → 360 330 (×1.1137, factor 1.077..1.147)
|
|
||||||
# регион 50 n=35 264 sold_median 162 303 → 179 314 (×1.1048, factor 1.065..1.138)
|
|
||||||
# регион 66 n=33 907 sold_median 94 655 → 99 059 (×1.0465, factor 1.032..1.062)
|
|
||||||
# Ожидаемые ratio после первого пересчёта: 0.729→0.811 (77), 0.811→0.896 (50),
|
|
||||||
# 0.728→0.762 (66) — все ниже 1.0, клампа expected_sold ≤ asking не задевают.
|
|
||||||
# NB: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его
|
|
||||||
# bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод
|
|
||||||
# обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025.
|
|
||||||
# При True каждая сделка домножается на sber-фактор приведения (тот же
|
|
||||||
# ряд/карту, что estimator._SBER_REGION_SERIES, см. app.tasks.asking_to_sold_ratio)
|
|
||||||
# к последнему доступному месяцу серии перед расчётом медианы. False — байт-в-байт
|
|
||||||
# прежнее поведение (для быстрого отката без деплоя, сравнения до/после).
|
|
||||||
# ENV: ASKING_RATIO_TIME_ADJUST_ENABLED.
|
|
||||||
asking_ratio_time_adjust_enabled: bool = True
|
|
||||||
|
|
||||||
# SSRF-защита для admin scrape endpoints (#756).
|
# SSRF-защита для admin scrape endpoints (#756).
|
||||||
# Список хостов которым разрешено передавать абсолютные URL в параметрах *_url.
|
# Список хостов которым разрешено передавать абсолютные URL в параметрах *_url.
|
||||||
# Относительные пути (без netloc) проходят без проверки — хост подставляется
|
# Относительные пути (без netloc) проходят без проверки — хост подставляется
|
||||||
|
|
@ -923,14 +1007,6 @@ class Settings(BaseSettings):
|
||||||
# ENV: YANDEX_PROXY_MAX_ROTATIONS.
|
# ENV: YANDEX_PROXY_MAX_ROTATIONS.
|
||||||
yandex_proxy_max_rotations: int = 4
|
yandex_proxy_max_rotations: int = 4
|
||||||
|
|
||||||
# ── Порог полноты detail-страницы Яндекса (#3191) ────────────────────────
|
|
||||||
# Полная карточка оффера — 3-5 МБ; недорендеренная приходит с HTTP 200, валидным
|
|
||||||
# HTML и БЕЗ блока контактов (наблюдалось 1,8 МБ). Размер — второй признак к
|
|
||||||
# структурному (scraper_kit...yandex.detail.DETAIL_CONTACTS_MARKERS); любой из двух
|
|
||||||
# даёт отказ, объявление остаётся в очереди (detail_enriched_at не проставляется).
|
|
||||||
# ENV: YANDEX_DETAIL_MIN_HTML_BYTES.
|
|
||||||
yandex_detail_min_html_bytes: int = 1_000_000
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def yandex_proxy_url(self) -> str | None:
|
def yandex_proxy_url(self) -> str | None:
|
||||||
"""Прокси для Yandex-скраперов (#2616 шаг 2: = scraper_proxy_url)."""
|
"""Прокси для Yandex-скраперов (#2616 шаг 2: = scraper_proxy_url)."""
|
||||||
|
|
@ -1032,6 +1108,14 @@ class Settings(BaseSettings):
|
||||||
# False = старое поведение (без фильтра). ENV: AVITO_SERP_EKB_ONLY.
|
# False = старое поведение (без фильтра). ENV: AVITO_SERP_EKB_ONLY.
|
||||||
avito_serp_ekb_only: bool = Field(default=True, validation_alias="AVITO_SERP_EKB_ONLY")
|
avito_serp_ekb_only: bool = Field(default=True, validation_alias="AVITO_SERP_EKB_ONLY")
|
||||||
|
|
||||||
|
# ── Yandex SERP cookies (#801/T4) ───────────────────────────────────────
|
||||||
|
# Путь к JSON-файлу с cookies браузера (формат: [{name, value, ...}, ...]).
|
||||||
|
# Если задан и файл существует — cookies передаются в curl_cffi-сессию при
|
||||||
|
# Yandex SERP-запросах; снижает вероятность captcha на datacenter IP.
|
||||||
|
# Пусто / файл не найден = запросы без cookies (не падаем, только warning).
|
||||||
|
# ENV: YANDEX_COOKIES_FILE.
|
||||||
|
yandex_cookies_file: str | None = None
|
||||||
|
|
||||||
# ── #639: Cian browser auto-login (Variant B) ────────────────────────────
|
# ── #639: Cian browser auto-login (Variant B) ────────────────────────────
|
||||||
# Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый
|
# Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый
|
||||||
# (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable.
|
# (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable.
|
||||||
|
|
@ -1051,19 +1135,25 @@ class Settings(BaseSettings):
|
||||||
cian_login_success_cookie: str = "DMIR_AUTH"
|
cian_login_success_cookie: str = "DMIR_AUTH"
|
||||||
cian_login_wait_ms: int = 4000
|
cian_login_wait_ms: int = 4000
|
||||||
|
|
||||||
# detail_backfill через curl_cffi вместо браузера. ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
|
# detail_backfill через curl_cffi+backconnect (mproxy) вместо браузера/auv.
|
||||||
# False (дефолт) = BrowserFetcher (camoufox дожидается QRATOR PoW, #3045/#3046).
|
# SERP (full_load/city_sweep) и detail_backfill делят один прокси-аккаунт auv
|
||||||
# True = curl_cffi через settings.scraper_proxy_url.
|
# (~5 параллельных коннектов); browser-фетч в backfill открывает десятки коннектов
|
||||||
|
# → cap превышается → HTTP 500 / краши. Backconnect (mproxy, авто-ротация,
|
||||||
|
# 1 коннект/запрос) развязывает прокси-аккаунты.
|
||||||
|
# True (дефолт) = curl_cffi через settings.scraper_proxy_url (backconnect mproxy).
|
||||||
|
# False = старое browser-поведение (BrowserFetcher/auv, как scraper_fetch_mode).
|
||||||
|
# ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
|
||||||
#
|
#
|
||||||
# Дефолт False (#3045): Авито за QRATOR отдаёт JS proof-of-work челлендж, который
|
# ⚠️ Обоснование выше УСТАРЕЛО для Авито (замер 2026-08-21). Авито за QRATOR
|
||||||
# curl_cffi не решает в принципе — замер 2026-08-21, прогоны 4348/4394/4508: 3-4
|
# отдаёт JS proof-of-work челлендж, который curl_cffi не решает: прогоны
|
||||||
# обогащённых из 46-53 попыток (~6%) против ~74% на браузере. Старое обоснование
|
# 4348/4394/4508 — 3-4 обогащённых из 46-53 попыток (~6%) против ~74% на
|
||||||
# curl (браузер превышал cap прокси-аккаунта auv) снято: браузер сериализован
|
# браузерном пути. Опасение «browser превышает cap прокси-аккаунта auv» снято:
|
||||||
# BROWSER_CONCURRENCY=1. Пока дефолт был True, браузер включался только
|
# браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect.
|
||||||
# environment'ом сервиса scraper, а tradein-backend (ручка админки
|
# Прод переведён на браузер через docker-compose.prod.yml (environment
|
||||||
# scrape_avito_detail_backfill исполняет добор в своём процессе) шёл curl'ом.
|
# перекрывает env_file). Дефолт оставлен True, чтобы не менять поведение
|
||||||
|
# других окружений вслепую.
|
||||||
avito_detail_backfill_use_curl: bool = Field(
|
avito_detail_backfill_use_curl: bool = Field(
|
||||||
default=False, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
|
default=True, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
|
||||||
)
|
)
|
||||||
|
|
||||||
# #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший
|
# #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший
|
||||||
|
|
@ -1089,8 +1179,7 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
# #3283g: ротация exit-IP НА САМ БАН площадки, а не только по счётчику попыток.
|
# #3283g: ротация exit-IP НА САМ БАН площадки, а не только по счётчику попыток.
|
||||||
# Бан привязан к IP (замерено вживую: rotate_proxy() лечит забаненный узел за
|
# Бан привязан к IP (замерено вживую: rotate_proxy() лечит забаненный узел за
|
||||||
# секунды, clear_source_bans гасит бан в scrape_proxy_source_bans — строка живёт
|
# секунды, clear_source_bans снимает запись из scrape_proxy_source_bans), но
|
||||||
# до purge, #3404), но
|
|
||||||
# #3251/#3212 запрещают сбрасывать browser-context на КАЖДЫЙ блок -- сброс без
|
# #3251/#3212 запрещают сбрасывать browser-context на КАЖДЫЙ блок -- сброс без
|
||||||
# смены IP выбрасывает пройденный QRATOR-PoW и запускает самоподдерживающийся
|
# смены IP выбрасывает пройденный QRATOR-PoW и запускает самоподдерживающийся
|
||||||
# каскад блоков на том же адресе. rotate_on_ban МЕНЯЕТ IP вместе со сбросом,
|
# каскад блоков на том же адресе. rotate_on_ban МЕНЯЕТ IP вместе со сбросом,
|
||||||
|
|
@ -1189,69 +1278,6 @@ class Settings(BaseSettings):
|
||||||
telegram_alerts_chat_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_CHAT_ID")
|
telegram_alerts_chat_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_CHAT_ID")
|
||||||
telegram_alerts_topic_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_TOPIC_ID")
|
telegram_alerts_topic_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_TOPIC_ID")
|
||||||
|
|
||||||
# Общий (не per-тему) лимит частоты отправки в ОДНУ группу — Telegram
|
|
||||||
# считает ~20 сообщений/минуту на группу суммарно по всем её темам (#3471:
|
|
||||||
# всплеск GlitchTip-алертов + поток поддержки в ту же группу давали 429 и
|
|
||||||
# потерю сообщений). `TELEGRAM_SUPPORT_CHAT_ID`/`TELEGRAM_ALERTS_CHAT_ID` на
|
|
||||||
# проде равны (одна группа, темы разные) — обе половины делят один
|
|
||||||
# площадочный бюджет.
|
|
||||||
#
|
|
||||||
# РАЗДЕЛЕНО ПО РОЛЯМ (review H2, #3471), а не одна общая константа: лимитер
|
|
||||||
# живёт in-memory В ЭКЗЕМПЛЯРЕ `TelegramClient`, а в эту группу пишут ДВА
|
|
||||||
# независимых процесса — API под uvicorn (`app/services/tgbot/shared.py`,
|
|
||||||
# интерактивные ручки + GlitchTip-вебхук) и контейнер бота (`app/tgbot_main.py`,
|
|
||||||
# long-polling воркер). У них НЕТ общего счётчика (это отдельная задача —
|
|
||||||
# Redis-based распределённый лимитер), поэтому если каждому дать по 18,
|
|
||||||
# сумма (2×18=36) УДВОИТ площадочный лимit и 429 вернётся ровно там же.
|
|
||||||
# Бюджет делится статически так, чтобы СУММА была заметно НИЖЕ ~20: у API
|
|
||||||
# больше — там же интерактивные ответы клиентам, у бота меньше — там же
|
|
||||||
# обычно только зеркалирование/уведомления, которые могут подождать дольше
|
|
||||||
# (см. `TelegramGroupRateLimiter.acquire` про `max_wait=None` для фона).
|
|
||||||
# 0/отрицательное значение выключает лимитер для соответствующего процесса.
|
|
||||||
#
|
|
||||||
# ЧЕСТНО ПРО ОГРАНИЧЕНИЕ ЭТОГО ДИЗАЙНА (review, #3471):
|
|
||||||
# `telegram_group_rate_limit_api_per_minute` — ОДИН общий бюджет на ВСЕ
|
|
||||||
# отправки процесса API в эту группу, а туда
|
|
||||||
# пишут И зеркала веб-чата поддержки (`app/api/v1/support.py`), И
|
|
||||||
# GlitchTip-алерты (`app/api/v1/glitchtip.py`) — обе ручки идут через один
|
|
||||||
# и тот же `get_telegram_client()` (см. `app/services/tgbot/shared.py`).
|
|
||||||
# Приоритета между ними НЕТ: кто первый встал в очередь `TelegramGroupRateLimiter`,
|
|
||||||
# тот и получил слот. Оба пути передают узкий `timeout` (5с у support, 8с у
|
|
||||||
# glitchtip) — он же становится потолком ожидания слота (см.
|
|
||||||
# `TelegramClient._request`, review H1). Значит при всплеске алертов (пачка
|
|
||||||
# ошибок прода бьёт в вебхук залпом) реально возможен сценарий: бюджет
|
|
||||||
# 12/мин исчерпан алертами → следующая отправка живого клиента в веб-чате
|
|
||||||
# ждёт до 5с и получает `TelegramRateLimitedError` → 502 клиенту поддержки.
|
|
||||||
# То есть при достаточно большом всплеске алертов веб-чат ДЕЙСТВИТЕЛЬНО
|
|
||||||
# может временно вставать. Разделить бюджет по ИСТОЧНИКУ (не по процессу) —
|
|
||||||
# отдельная задача: нужен свой `TelegramGroupRateLimiter` на алерты с явно
|
|
||||||
# малой квотой и/или приоритет для support-трафика; здесь НЕ сделано
|
|
||||||
# (вне бюджета этой правки).
|
|
||||||
telegram_group_rate_limit_api_per_minute: int = Field(
|
|
||||||
default=12, validation_alias="TELEGRAM_GROUP_RATE_LIMIT_API_PER_MINUTE"
|
|
||||||
)
|
|
||||||
telegram_group_rate_limit_bot_per_minute: int = Field(
|
|
||||||
default=6, validation_alias="TELEGRAM_GROUP_RATE_LIMIT_BOT_PER_MINUTE"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Ретранслятор Bot API через Beget (#3471) ─────────────────────────────
|
|
||||||
# Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` с Selectel — 9
|
|
||||||
# успешных из 12 (три ConnectTimeout), TCP-443 до адреса Selectel — 5/6, TCP-443
|
|
||||||
# до адреса Beget — 8/8; за сутки 508 строк `network error` в логе бота, за 30
|
|
||||||
# дней 92 обрыва итерации poll loop. Путь до Telegram с Selectel лоссовый, с
|
|
||||||
# Beget чистый (Alertmanager там же шлёт без проблем) — поэтому продуктовый
|
|
||||||
# трафик Bot API идёт через маленький HTTP-ретранслятор на Beget
|
|
||||||
# (`ops/metrics/tg-relay`), а не напрямую.
|
|
||||||
#
|
|
||||||
# Пусто (дефолт) = прежнее поведение, прямой путь к api.telegram.org — это и
|
|
||||||
# есть механизм отката, если ретранслятор сам подведёт. При заданном адресе
|
|
||||||
# клиент (`app/services/tgbot/client.py`) всё равно делает одну попытку
|
|
||||||
# напрямую при транспортном отказе похода на ретранслятор — хуже прямого
|
|
||||||
# пути быть не должно ни при каких условиях.
|
|
||||||
# ENV: TELEGRAM_RELAY_BASE_URL, TELEGRAM_RELAY_SECRET.
|
|
||||||
telegram_relay_base_url: str = Field(default="", validation_alias="TELEGRAM_RELAY_BASE_URL")
|
|
||||||
telegram_relay_secret: str = Field(default="", validation_alias="TELEGRAM_RELAY_SECRET")
|
|
||||||
|
|
||||||
# ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ──────────
|
# ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ──────────
|
||||||
# См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит
|
# См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит
|
||||||
# роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch.
|
# роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch.
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,14 @@
|
||||||
import asyncio
|
from collections.abc import Generator
|
||||||
import logging
|
|
||||||
from collections.abc import Callable, Generator
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# #3463. Потолок ОДНОГО statement'а, секунды·1000. Ставится на КОННЕКТЕ (libpq
|
|
||||||
# `options`), а не в питоновской обёртке: обёртка (`run_db_thread` ниже) при
|
|
||||||
# отмене обязана ДОЖДАТЬСЯ потока, иначе поток остаётся сиротой в общей
|
|
||||||
# `Session` — ровно то, ради чего писался #3449. Значит верхняя граница ожидания
|
|
||||||
# = длительность самого запроса, и задать её может только сервер.
|
|
||||||
#
|
|
||||||
# 30 с выбраны так, чтобы потолок НИКОГДА не стал биндящим ограничением для
|
|
||||||
# честной работы, но остался конечным:
|
|
||||||
# * самый длинный ОБЪЯВЛЕННЫЙ бюджет на `/estimate` — 20 с (`estimate_avito_imv_timeout_s`,
|
|
||||||
# config.py:852); дальше 12 с геокод, 8 с Yandex/Cian/house_meta. 30 с = 1.5× от максимума;
|
|
||||||
# * ОСНОВНАЯ опора по планировщику — `scrape_runs` (длительности целых прогонов, они
|
|
||||||
# не вытесняются): самая долгая ЧИСТО-БД задача за 14 суток — listing_source_snapshot,
|
|
||||||
# 9.7 с ЦЕЛИКОМ (и у неё сверх того свой `SET LOCAL statement_timeout = 900000`,
|
|
||||||
# который перекрывает это значение — гейт tests/test_3463_db_timeouts.py);
|
|
||||||
# * самый длинный set-based statement ЧЕРЕЗ движок из замеренных — матч ГАР→houses
|
|
||||||
# (`services/gar_flats_loader._MATCH_SQL`): 2.07 с с городским фильтром и 6.46 с без
|
|
||||||
# него (`city_filter=None`, флаг CLI). Запас ~3×, и это СЧИТАЮЩИЙ запрос, а не ждущий.
|
|
||||||
#
|
|
||||||
# `pg_stat_statements` опорой по планировщику НЕ является: при `max = 5000` он вытесняет
|
|
||||||
# редкие записи (проверено 12.09 — `dealloc` вырос на единицу за десять минут, и из топа
|
|
||||||
# пропали ВСЕ записи с `calls = 1`, включая `REFRESH MATERIALIZED VIEW` 30.85 с и KNN
|
|
||||||
# `cadastral_geo_match` 2.45 с). Суточная задача до следующих суток там не доживает, так
|
|
||||||
# что «самый долгий запрос 4.27 с» верно только для ВЫСОКОЧАСТОТНЫХ запросов.
|
|
||||||
_STATEMENT_TIMEOUT_MS = 30_000
|
|
||||||
|
|
||||||
# Ожидание БЛОКИРОВКИ — заведомо меньше: ждать лок дольше секунд смысла нет, лучше
|
|
||||||
# деградировать. 5 с — та же величина, что у миграций проекта
|
|
||||||
# (`SET LOCAL lock_timeout = '5s'` в data/sql/250,251,260,272,277…), снизу ограничена
|
|
||||||
# deadlock_timeout (на проде 1 с — сверено 12.09). Именно этот потолок закрывает
|
|
||||||
# сценарий #3463: под `ACCESS EXCLUSIVE` на `geocode_cache` запрос ЖДЁТ лок, а не
|
|
||||||
# считает, — statement_timeout тут только страховка от «считает вечно».
|
|
||||||
_LOCK_TIMEOUT_MS = 5_000
|
|
||||||
|
|
||||||
# idle_in_transaction_session_timeout НАМЕРЕННО не трогаем: тем же движком живёт tgbot,
|
|
||||||
# и `services/tgbot/bridge.py` держит транзакцию открытой ПОВЕРХ long-poll Telegram
|
|
||||||
# (замер на проде 12.09, 3 пробы с шагом 7 с: одна и та же сессия, запрос
|
|
||||||
# `SELECT value FROM tg_support_state …`, возраст транзакции циклически растёт до ~29 с).
|
|
||||||
# Сессионный потолок на простой в транзакции ронял бы long-poll КАЖДЫЙ цикл —
|
|
||||||
# гарантированно, а не в редком случае.
|
|
||||||
DB_CONNECT_ARGS = {
|
|
||||||
"options": f"-c statement_timeout={_STATEMENT_TIMEOUT_MS} -c lock_timeout={_LOCK_TIMEOUT_MS}"
|
|
||||||
}
|
|
||||||
|
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
settings.database_url,
|
settings.database_url,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
future=True,
|
future=True,
|
||||||
# #3463. Накрывает ВСЕ три сервиса образа (backend / scraper / tgbot — один и тот
|
|
||||||
# же `app.core.db`, см. docker-compose.prod.yml) и обе стороны: продуктовый путь
|
|
||||||
# `/estimate` и задачи планировщика. Миграции идут мимо (psql из
|
|
||||||
# .forgejo/workflows/deploy-tradein.yml, не этот движок) — их DDL под своим
|
|
||||||
# `SET LOCAL lock_timeout` и потолком не ограничен.
|
|
||||||
connect_args=DB_CONNECT_ARGS,
|
|
||||||
# #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError —
|
# #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError —
|
||||||
# через них в GlitchTip уезжали ключ шифрования кук и сами куки
|
# через них в GlitchTip уезжали ключ шифрования кук и сами куки
|
||||||
# (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все
|
# (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все
|
||||||
|
|
@ -70,41 +16,6 @@ engine = create_engine(
|
||||||
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
|
# НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением)
|
||||||
# и сырые psycopg-подключения мимо движков — это отдельный класс.
|
# и сырые psycopg-подключения мимо движков — это отдельный класс.
|
||||||
hide_parameters=True,
|
hide_parameters=True,
|
||||||
# #3408 п.1. Потолок пула обязан быть НЕ МЕНЬШЕ суммы потолков одновременности,
|
|
||||||
# которые сам же процесс и объявляет: 4 оценки (`api/v1/trade_in`
|
|
||||||
# `_ESTIMATE_CONCURRENCY`) + 4 подсказки (`api/public/mera` `_SUGGEST_CONCURRENCY`)
|
|
||||||
# + 8 фоновых догрузок (`services/estimator` `_MAX_DEFERRED_REFRESH_TASKS`) = 16.
|
|
||||||
# Дефолт SQLAlchemy 5+10=15 меньше этой суммы, то есть исчерпать пул можно
|
|
||||||
# штатной работой, не абузом. Гейт — tests/test_3408_pool_ceiling.py.
|
|
||||||
#
|
|
||||||
# Инвариант «пул >= суммы объявленных потолков» — это ПОЛ, а не гарантия:
|
|
||||||
# сумма считает по одному коннекту на объявленную единицу работы, а коннект
|
|
||||||
# держит и всё остальное — любая ручка с `Depends(get_db)` тоже. Глобального
|
|
||||||
# обработчика `sqlalchemy.exc.TimeoutError` в app/main.py нет, поэтому на
|
|
||||||
# исчерпанном пуле соседние ручки отдают 500 (после ожидания `pool_timeout`),
|
|
||||||
# а не медленный 200.
|
|
||||||
#
|
|
||||||
# pool_size оставлен дефолтным (5): это ПОСТОЯННО открытые коннекты, а в покое
|
|
||||||
# прод держит 5-6 (замер 11.09). Растёт только overflow — коннекты пика,
|
|
||||||
# которые пул закрывает сам. Потолок процесса: 5 + 15 = 20; воркер один
|
|
||||||
# (docker-compose.prod.yml, uvicorn без --workers), Postgres max_connections=100.
|
|
||||||
max_overflow=15,
|
|
||||||
# #3408 п.2. Дефолтные 30 с ожидания коннекта длиннее ЛЮБОГО бюджета внешнего
|
|
||||||
# источника в эстиматоре: 8 с (Yandex / Cian / house_meta), 12 с (geocode),
|
|
||||||
# 20 с (Avito IMV — `estimate_avito_imv_timeout_s`, config.py:852). Сам чекаут
|
|
||||||
# прервать `asyncio.wait_for` не может: он занимает поток `asyncio.to_thread`
|
|
||||||
# целиком, а пул потоков конечен (min(32, cpu+4)) — исчерпанный пул коннектов
|
|
||||||
# так превращается в исчерпанный пул потоков. 5 с короче самого КОРОТКОГО
|
|
||||||
# бюджета: занятый пул деградирует ОДИН источник, а не весь запрос.
|
|
||||||
#
|
|
||||||
# Отдельный коммит в конце ветки намеренно (ревью PR #3444): это единственная
|
|
||||||
# правка, которая меняет режим отказа с «медленно» на «быстро с ошибкой», и
|
|
||||||
# едет она во ВСЕ сервисы образа — backend, scraper, tgbot
|
|
||||||
# (tradein-mvp/docker-compose.prod.yml). За 29 ч логов исчерпания пула не было
|
|
||||||
# ни разу, то есть новое значение на проде пока не на чем проверить.
|
|
||||||
# ТРИГГЕР ОТКАТА на 30 с: любое `QueuePool limit ... timed out` в логах
|
|
||||||
# бэкенда ЛИБО рост failed+zombie в `scrape_runs` после деплоя.
|
|
||||||
pool_timeout=5,
|
|
||||||
)
|
)
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
@ -119,39 +30,3 @@ def get_db() -> Generator[Session, None, None]:
|
||||||
yield db
|
yield db
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
async def run_db_thread[T](fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
|
||||||
"""`asyncio.to_thread(fn, ...)`, который при отмене ДОЖИДАЕТСЯ своего потока (#3449).
|
|
||||||
|
|
||||||
Для синхронной работы по сессии, которая шагу НЕ принадлежит — она общая со
|
|
||||||
всем остальным запросом (`Depends(get_db)`). Поток отменить нельзя: у
|
|
||||||
`asyncio.to_thread` отменяется только ожидание со стороны loop'а. Корутина
|
|
||||||
умирает по бюджету источника (`estimator._with_budget` = `asyncio.wait_for`,
|
|
||||||
геокодер — 12 с), а поток продолжает работать с ТОЙ ЖЕ `Session`, пока
|
|
||||||
вызывающий уже идёт дальше по коду — следующий источник,
|
|
||||||
`_fetch_anchor_comps`, `_persist_estimate_and_commit`. Два потока в одной
|
|
||||||
`Session` дают «another operation is in progress» / `InvalidRequestError` на
|
|
||||||
СЛЕДУЮЩЕМ шаге: у источников такую ошибку глушит `except` вокруг вызова, у
|
|
||||||
персиста оценки не глушит ничего — 500 и потерянная оценка клиента.
|
|
||||||
|
|
||||||
Поэтому отмена пробрасывается ПОСЛЕ того, как поток отпустил сессию. Цена —
|
|
||||||
бюджет источника переезжает на длину ОДНОГО шага БД (чекаут ≤ `pool_timeout`
|
|
||||||
плюс сам запрос), а не на длину фетча, ради которой бюджет и заведён.
|
|
||||||
|
|
||||||
Только защита от сироты: транзакцию шаг НЕ завершает (в середине геокодинга
|
|
||||||
commit зафиксировал бы частичное состояние оценки). Кому нужен ещё и возврат
|
|
||||||
коннекта в пул перед внешним HTTP — `estimator._db_step`, он поверх этого.
|
|
||||||
|
|
||||||
Гейт — tests/test_3449_geocoder_cancel_orphan.py.
|
|
||||||
"""
|
|
||||||
step = asyncio.ensure_future(asyncio.to_thread(fn, *args, **kwargs))
|
|
||||||
try:
|
|
||||||
return await asyncio.shield(step)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
await asyncio.wait([step])
|
|
||||||
if not step.cancelled() and step.exception() is not None:
|
|
||||||
# Результата уже никто не ждёт: без явного чтения asyncio напечатает
|
|
||||||
# «Task exception was never retrieved» вообще без контекста.
|
|
||||||
logger.warning("шаг БД упал уже после отмены: %s", step.exception())
|
|
||||||
raise
|
|
||||||
|
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
"""Секреты из query-строки не попадают в лог процесса (#3154).
|
|
||||||
|
|
||||||
Прод-факт: uvicorn пишет в access-log ПОЛНЫЙ путь вместе с query, а лог уезжает
|
|
||||||
в Loki (ретенция 30 суток, доступ по входу в Grafana):
|
|
||||||
|
|
||||||
INFO: 172.18.0.3:60322 - "POST /api/v1/trade-in/ops/glitchtip-webhook
|
|
||||||
?secret=<64 hex> HTTP/1.1" 200 OK
|
|
||||||
|
|
||||||
Скруббер в Alloy (#3115) это не ловит: там одно выражение под форму
|
|
||||||
``scheme://user:pass@host`` (DSN postgres_exporter, #3114). Чиним в СВОЁМ
|
|
||||||
процессе — тогда секрета нет и в `docker logs`, до отправки куда-либо.
|
|
||||||
|
|
||||||
Фильтр вешается на логгер (`logging.Filter`), а не на форматтер: uvicorn.access
|
|
||||||
кладёт путь в ``record.args``, до форматирования он уже там. Поэтому берём
|
|
||||||
``record.getMessage()`` и, если что-то замаскировали, подменяем msg/args.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
|
|
||||||
# Имена параметров, значение которых маскируем: имя ОКАНЧИВАЕТСЯ на чувствительное
|
|
||||||
# слово, поэтому перед альтернацией допускаем префикс (`client_secret`,
|
|
||||||
# `refresh_token`, `webhook_secret`). Значение — до следующего `&`, пробела или
|
|
||||||
# кавычки (access-строка uvicorn обрамляет запрос кавычками).
|
|
||||||
_SENSITIVE_QUERY = re.compile(
|
|
||||||
r"([?&][\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=)"
|
|
||||||
r"[^&\s\"'<>]+",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def scrub_query_secrets(text: str) -> str:
|
|
||||||
"""Заменяет значения чувствительных query-параметров на ``***``.
|
|
||||||
|
|
||||||
Имя параметра и остальная строка сохраняются — иначе access-лог перестал бы
|
|
||||||
годиться для диагностики.
|
|
||||||
"""
|
|
||||||
return _SENSITIVE_QUERY.sub(r"\1***", text)
|
|
||||||
|
|
||||||
|
|
||||||
class QuerySecretFilter(logging.Filter):
|
|
||||||
"""Маскирует секреты в query-строке ЛЮБОЙ записи логгера, к которому привязан.
|
|
||||||
|
|
||||||
Скрабит `record.msg` и КАЖДЫЙ элемент `record.args` по отдельности — НЕ схлопывает
|
|
||||||
их в единую строку через `record.getMessage()` с последующим `args = ()`.
|
|
||||||
|
|
||||||
Прод-баг (#3471): схлопывание ломало `uvicorn.access` — там `record.args` это
|
|
||||||
структурный 5-tuple `(client_addr, method, full_path, http_version, status_code)`,
|
|
||||||
который `uvicorn.logging.AccessFormatter.formatMessage()` распаковывает напрямую
|
|
||||||
(`a, b, c, d, e = record.args`), в обход `record.getMessage()`. Как только фильтр
|
|
||||||
находил секрет (например `?secret=` в webhook-пути) и обнулял `args`, форматтер падал
|
|
||||||
с `ValueError: not enough values to unpack (expected 5, got 0)` — сама попытка
|
|
||||||
заскрабить секрет ломала запись лога целиком (`--- Logging error ---` в докер-логах).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
|
||||||
args = record.args
|
|
||||||
if isinstance(args, tuple) and args:
|
|
||||||
# Есть позиционные args — скрабим КАЖДЫЙ элемент отдельно, arity не трогаем.
|
|
||||||
# `record.msg` (шаблон вида `"%s ..."`) не трогаем вовсе: в реальных вызовах
|
|
||||||
# этого кодбейза секрет+query-контекст лежат САМОДОСТАТОЧНО внутри одного
|
|
||||||
# аргумента (например body_preview в app/services/dadata.py), а не расщеплены
|
|
||||||
# между текстом шаблона и голым значением — трогать msg тут не нужно и опасно
|
|
||||||
# (шаблонный `%s` сам по себе мог бы ложно совпасть с чувствительным именем
|
|
||||||
# параметра прямо перед ним).
|
|
||||||
scrubbed_args = tuple(
|
|
||||||
scrub_query_secrets(a) if isinstance(a, str) else a for a in args
|
|
||||||
)
|
|
||||||
if scrubbed_args != args:
|
|
||||||
record.args = scrubbed_args
|
|
||||||
elif isinstance(record.msg, str):
|
|
||||||
# Args нет — вся запись уже готовым текстом в msg (f-string и т.п.).
|
|
||||||
scrubbed_msg = scrub_query_secrets(record.msg)
|
|
||||||
if scrubbed_msg != record.msg:
|
|
||||||
record.msg = scrubbed_msg
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# ── Секреты в произвольном тексте (тело ответа внешнего API) — #3471 ──────────
|
|
||||||
#
|
|
||||||
# DaData на HTTP 403 возвращает диагностику вида:
|
|
||||||
# "Feature 'CLEAN' disabled for token '<действующий 40-символьный токен>'. See ..."
|
|
||||||
# body_preview из этого текста уходит в WARNING/ERROR лог (app/services/dadata.py) →
|
|
||||||
# docker logs → потенциально breadcrumb к любой последующей ошибке в GlitchTip.
|
|
||||||
# Маскируем ДО логирования. Не завязываемся на конкретную формулировку вендора —
|
|
||||||
# она может измениться (см. generic-слой ниже).
|
|
||||||
|
|
||||||
_TOKEN_QUOTED = re.compile(r"(\btoken\s*['\"])([^'\"]+)(['\"])", re.IGNORECASE)
|
|
||||||
# Любая hex/base64-подобная последовательность 24+ символов — ловит секрет независимо
|
|
||||||
# от контекста (Authorization/X-Secret значения, если когда-нибудь попадут в текст как есть).
|
|
||||||
_LONG_SECRET_LIKE = re.compile(r"[A-Za-z0-9+/_-]{24,}")
|
|
||||||
|
|
||||||
|
|
||||||
def _mask_value(value: str) -> str:
|
|
||||||
"""`8d4e…(40)` — первые 4 символа + длина в скобках, остальное скрыто."""
|
|
||||||
if len(value) <= 4:
|
|
||||||
return "***"
|
|
||||||
return f"{value[:4]}…({len(value)})"
|
|
||||||
|
|
||||||
|
|
||||||
def scrub_body_secrets(text: str | None) -> str:
|
|
||||||
"""Маскирует токены/секреты в произвольном тексте (тело ответа внешнего API и т.п.).
|
|
||||||
|
|
||||||
Двухслойно: 1) явный ``token '<value>'`` (текущая формулировка DaData на 403),
|
|
||||||
2) generic — любая hex/base64-подобная последовательность 24+ символов, чтобы защита
|
|
||||||
не зависела от того, как вендор сформулирует сообщение завтра.
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return text or ""
|
|
||||||
|
|
||||||
def _replace_quoted(m: re.Match[str]) -> str:
|
|
||||||
return f"{m.group(1)}{_mask_value(m.group(2))}{m.group(3)}"
|
|
||||||
|
|
||||||
scrubbed = _TOKEN_QUOTED.sub(_replace_quoted, text)
|
|
||||||
scrubbed = _LONG_SECRET_LIKE.sub(lambda m: _mask_value(m.group(0)), scrubbed)
|
|
||||||
return scrubbed
|
|
||||||
|
|
||||||
|
|
||||||
def install_query_secret_filter(*logger_names: str) -> None:
|
|
||||||
"""Вешает фильтр на access-лог uvicorn И на обработчики корневого логгера.
|
|
||||||
|
|
||||||
Двумя местами, потому что uvicorn в своём log-config ставит `uvicorn.access`
|
|
||||||
собственный handler с ``propagate = False`` — до корневого его записи не
|
|
||||||
доходят. А фильтр на handler'ах корня закрывает всё остальное приложение
|
|
||||||
(записи дочерних логгеров фильтры родителя не проходят, фильтры handler'а —
|
|
||||||
проходят).
|
|
||||||
|
|
||||||
Идемпотентно: повторный вызов не наплодит дублей.
|
|
||||||
"""
|
|
||||||
targets: list[logging.Logger | logging.Handler] = [
|
|
||||||
logging.getLogger(name) for name in logger_names or ("uvicorn.access",)
|
|
||||||
]
|
|
||||||
targets.extend(logging.getLogger().handlers)
|
|
||||||
for target in targets:
|
|
||||||
if not any(isinstance(f, QuerySecretFilter) for f in target.filters):
|
|
||||||
target.addFilter(QuerySecretFilter())
|
|
||||||
|
|
@ -161,18 +161,6 @@ class SlidingWindowLimiter:
|
||||||
del self._hits[k]
|
del self._hits[k]
|
||||||
return len(bucket)
|
return len(bucket)
|
||||||
|
|
||||||
def reset(self, key: str) -> None:
|
|
||||||
"""Обнуляет окно под *key*.
|
|
||||||
|
|
||||||
Нужен вызывающим, которые считают не «сколько сделано», а «сколько ПОДРЯД»
|
|
||||||
— например счётчик отказов отправки (`support.py:_send_failure_limiter`):
|
|
||||||
успешная отправка означает, что канал жив, и предыдущие отказы больше не
|
|
||||||
должны приближать cooldown. Для лимитеров-бюджетов (`_send_limiter`,
|
|
||||||
`_anon_ip_limiter`, `_notify_limiter`) не используется — там обнуление
|
|
||||||
по запросу было бы дырой в самом лимите.
|
|
||||||
"""
|
|
||||||
self._hits.pop(key, None)
|
|
||||||
|
|
||||||
def check(self, key: str) -> float | None:
|
def check(self, key: str) -> float | None:
|
||||||
"""Комбинированная проверка+регистрация (peek+record за один вызов) —
|
"""Комбинированная проверка+регистрация (peek+record за один вызов) —
|
||||||
для вызывающих, которым не нужно различать "попытка"/"успех" (см.
|
для вызывающих, которым не нужно различать "попытка"/"успех" (см.
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ from typing import Any
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse, Response
|
||||||
from starlette.routing import Match
|
|
||||||
|
|
||||||
from app.core.auth import get_role, is_path_allowed
|
from app.core.auth import get_role, is_path_allowed
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
@ -182,90 +181,6 @@ def _db_role_path_allowed(role: str, path: str) -> bool:
|
||||||
return any(_db_glob_match(p, path) for p in paths)
|
return any(_db_glob_match(p, path) for p in paths)
|
||||||
|
|
||||||
|
|
||||||
def _path_is_routed(request: Request) -> bool:
|
|
||||||
"""Есть ли у пути хоть один маршрут в роутере приложения.
|
|
||||||
|
|
||||||
#3324: guard — HTTP-middleware, он отрабатывает ДО роутинга, поэтому раньше
|
|
||||||
отвечал 401 и на пути, которых в приложении нет вовсе. Анониму этого хватало,
|
|
||||||
чтобы бесплатно разведать периметр: мусор под «интересным» префиксом
|
|
||||||
(``/api/public/whatever``) давал 401 с rbac-текстом, а мусор под публичным
|
|
||||||
префиксом — 404 роутера. Выключенная/закрытая ручка отличалась от
|
|
||||||
несуществующей. Несуществующий путь обязан отвечать одинаково независимо от
|
|
||||||
префикса, поэтому такие запросы пропускаются дальше — 404 отдаёт роутер, тот
|
|
||||||
же самый, что и на любой другой мусор.
|
|
||||||
|
|
||||||
ЧТО ИМЕННО РАЗМЕНЯНО (это НЕ «ослабления нет»). Раньше аноним получал 401 на
|
|
||||||
ЛЮБОЙ non-public путь — то есть оракул был ПРЕФИКСНЫЙ: он говорил «префикс
|
|
||||||
закрыт», но перечислить по нему таблицу маршрутов было нельзя. Теперь 401 =
|
|
||||||
«такой маршрут есть», 404 = «нет», и это уже оракул СУЩЕСТВОВАНИЯ маршрута:
|
|
||||||
перебором аноним восстанавливает список всех ручек приложения, включая имена
|
|
||||||
под ``/api/v1/admin/*``. Доступа это не даёт (закрытая ручка по-прежнему
|
|
||||||
отвечает 401/403), но карту периметра — даёт.
|
|
||||||
|
|
||||||
Почему размен принят. Точечно: 404 на несуществующее — норма HTTP, а
|
|
||||||
подобранное ИМЯ ручки без креденшелов бесполезно; исчезает же реальный
|
|
||||||
признак «этот префикс что-то охраняет». Это защита в глубину, и её глубина
|
|
||||||
здесь честно меньше, чем была.
|
|
||||||
|
|
||||||
⚠️ Периметр admin-путей снаружи НЕ срезан — проверено по конфигу, а не по
|
|
||||||
предположению: ``caddy/sites/apps.caddy`` → блок ``handle /trade-in/api/*``
|
|
||||||
делает ``uri strip_prefix /trade-in`` + ``reverse_proxy tradein-backend:8000``
|
|
||||||
и стоит ЦЕЛИКОМ ВЫШЕ ``import caddy/users.caddy.snippet`` (basic_auth), т.е.
|
|
||||||
у trade-in своя авторизация и внешнего barrier'а нет. Значит внешний
|
|
||||||
``https://gendsgn.ru/trade-in/api/v1/admin/...`` доходит до этого guard'а
|
|
||||||
анонимно, и перебор имён admin-ручек выполним снаружи, не только изнутри
|
|
||||||
docker-сети. Хочется убрать — резать надо в Caddy (отдельный issue), guard
|
|
||||||
этого не сделает: он про роли, а не про сетевой периметр.
|
|
||||||
|
|
||||||
``Match.NONE`` по ВСЕМ маршрутам значит, что выполнять нечего — хендлера, до
|
|
||||||
которого можно было бы дотянуться, не существует. ``Match.PARTIAL`` (путь
|
|
||||||
есть, метод другой) считается маршрутом и идёт в guard как раньше — там путь
|
|
||||||
реально существует, скрывать нечего.
|
|
||||||
|
|
||||||
Трейлинг-слэш был вторым каналом того же оракула в обход guard'а:
|
|
||||||
``/api/v1/me/`` не матчит ни один маршрут (``Match.NONE``) → guard пропускает
|
|
||||||
→ Starlette-роутер отвечал 307 на ``/api/v1/me``, то есть «маршрут есть»
|
|
||||||
сообщал редирект, а не 401. Закрыто в ``app/main.py``:
|
|
||||||
``FastAPI(redirect_slashes=False)`` — теперь такой путь даёт тот же 404, что
|
|
||||||
и любой другой мусор.
|
|
||||||
|
|
||||||
Неизвестное приложение (``scope["app"]`` не выставлен) — ведём себя как
|
|
||||||
раньше, то есть отдаём запрос в guard.
|
|
||||||
"""
|
|
||||||
router = getattr(request.scope.get("app"), "router", None)
|
|
||||||
if router is None:
|
|
||||||
return True
|
|
||||||
return any(route.matches(request.scope)[0] != Match.NONE for route in router.routes)
|
|
||||||
|
|
||||||
|
|
||||||
def _unauthenticated(path: str, detail: str) -> JSONResponse:
|
|
||||||
"""Отказ анониму: 401 везде, но на admin-префиксе — 404 роутера.
|
|
||||||
|
|
||||||
#3360: периметр admin-путей снаружи НЕ срезан (``caddy/sites/apps.caddy``,
|
|
||||||
блок ``handle /trade-in/api/*`` стоит выше ``import
|
|
||||||
caddy/users.caddy.snippet``), и срезать его нельзя — admin-UI кабинета зовёт
|
|
||||||
``/api/v1/admin/*`` ИЗ БРАУЗЕРА (tradein-mvp/frontend/src/app/scrapers/**,
|
|
||||||
components/scrapers/**, lib/admin-audit-api.ts). Значит внешний аноним
|
|
||||||
доходит сюда, а после #3324 (несуществующий путь → 404 роутера) 401 на
|
|
||||||
существующей ручке работал оракулом: перебором имён восстанавливался список
|
|
||||||
admin-API. Отвечаем ТЕМ ЖЕ, что роутер отдаёт на несуществующий путь, —
|
|
||||||
существующая и несуществующая admin-ручки анониму неразличимы.
|
|
||||||
|
|
||||||
Скрываем ровно от НЕаутентифицированного. Аутентифицированный не-admin
|
|
||||||
по-прежнему получает 403 «admin only»: он уже прошёл идентификацию, прятать
|
|
||||||
от него наличие ручки незачем, а 404 вместо 403 маскировал бы отладку.
|
|
||||||
|
|
||||||
Тело — литерал ``{"detail": "Not Found"}``: это ответ дефолтного
|
|
||||||
http_exception_handler FastAPI, тот же, что придёт из роутера. Тест
|
|
||||||
сравнивает два ЖИВЫХ ответа между собой, а не с этой константой, — если
|
|
||||||
фреймворк сменит формулировку, покраснеет он, а не прод.
|
|
||||||
"""
|
|
||||||
if _ADMIN_API_RE.match(path):
|
|
||||||
logger.info("RBAC: anonymous probe of admin path %s — cloaked as 404", path)
|
|
||||||
return JSONResponse(status_code=404, content={"detail": "Not Found"})
|
|
||||||
return JSONResponse(status_code=401, content={"detail": detail})
|
|
||||||
|
|
||||||
|
|
||||||
def _propagate_authenticated_user(request: Request, username: str) -> None:
|
def _propagate_authenticated_user(request: Request, username: str) -> None:
|
||||||
"""Инжектит ``X-Authenticated-User`` в ASGI scope — ПЕРЕЗАПИСЫВАЯ, а не
|
"""Инжектит ``X-Authenticated-User`` в ASGI scope — ПЕРЕЗАПИСЫВАЯ, а не
|
||||||
только добавляя при отсутствии, — чтобы ``RateLimitMiddleware``/
|
только добавляя при отсутствии, — чтобы ``RateLimitMiddleware``/
|
||||||
|
|
@ -324,11 +239,6 @@ async def rbac_guard(
|
||||||
if path in _PUBLIC_PATHS or path.startswith(_PUBLIC_PATH_PREFIXES):
|
if path in _PUBLIC_PATHS or path.startswith(_PUBLIC_PATH_PREFIXES):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
# #3324: путь, которого нет в роутере, отвечает как любой несуществующий
|
|
||||||
# путь (404 роутера) — иначе 401 работает оракулом существования ручки.
|
|
||||||
if not _path_is_routed(request):
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
role: str | None = None
|
role: str | None = None
|
||||||
from_session = False
|
from_session = False
|
||||||
|
|
@ -368,12 +278,18 @@ async def rbac_guard(
|
||||||
# auth_mode == "db_only" — легаси trusted-header путь ПОЛНОСТЬЮ
|
# auth_mode == "db_only" — легаси trusted-header путь ПОЛНОСТЬЮ
|
||||||
# отключён, даже если валидный X-Authenticated-User присутствует.
|
# отключён, даже если валидный X-Authenticated-User присутствует.
|
||||||
if settings.auth_mode != "dual":
|
if settings.auth_mode != "dual":
|
||||||
return _unauthenticated(path, "valid session required")
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "valid session required"},
|
||||||
|
)
|
||||||
|
|
||||||
# ---- legacy trusted-header path — BIT-FOR-BIT как было до #2552 ----
|
# ---- legacy trusted-header path — BIT-FOR-BIT как было до #2552 ----
|
||||||
username = request.headers.get("X-Authenticated-User")
|
username = request.headers.get("X-Authenticated-User")
|
||||||
if not username:
|
if not username:
|
||||||
return _unauthenticated(path, "no authenticated user (valid session required)")
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "no authenticated user (valid session required)"},
|
||||||
|
)
|
||||||
|
|
||||||
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
|
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
|
||||||
# ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
|
# ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
|
||||||
|
|
@ -389,7 +305,10 @@ async def rbac_guard(
|
||||||
username,
|
username,
|
||||||
path,
|
path,
|
||||||
)
|
)
|
||||||
return _unauthenticated(path, "invalid or missing internal auth secret")
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "invalid or missing internal auth secret"},
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
role = get_role(username)
|
role = get_role(username)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ from sentry_sdk.integrations.httpx import HttpxIntegration
|
||||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||||
from sentry_sdk.integrations.starlette import StarletteIntegration
|
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||||
from sentry_sdk.types import Event, Hint
|
|
||||||
|
|
||||||
from app.api.public import mera as public_mera
|
from app.api.public import mera as public_mera
|
||||||
from app.api.v1 import (
|
from app.api.v1 import (
|
||||||
|
|
@ -45,13 +44,11 @@ from app.core.config import settings
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
from app.core.fdw import ensure_fdw_user_mapping
|
from app.core.fdw import ensure_fdw_user_mapping
|
||||||
from app.core.http_errors import install_validation_error_handler
|
from app.core.http_errors import install_validation_error_handler
|
||||||
from app.core.log_scrub import install_query_secret_filter
|
|
||||||
from app.core.ratelimit import RateLimitMiddleware
|
from app.core.ratelimit import RateLimitMiddleware
|
||||||
from app.core.rbac import rbac_guard
|
from app.core.rbac import rbac_guard
|
||||||
from app.core.request_audit import RequestAuditMiddleware
|
from app.core.request_audit import RequestAuditMiddleware
|
||||||
from app.observability import metrics as app_metrics
|
from app.observability import metrics as app_metrics
|
||||||
from app.observability.sentry_scrub import scrub_pii_event
|
from app.observability.sentry_scrub import scrub_pii_event
|
||||||
from app.services.tgbot.shared import close_telegram_client, init_telegram_client
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -67,11 +64,6 @@ logging.basicConfig(
|
||||||
# закрытие, что уже стоит в tgbot_main.py (см. его комментарий), нужно и здесь.
|
# закрытие, что уже стоит в tgbot_main.py (см. его комментарий), нужно и здесь.
|
||||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
|
||||||
# #3154: uvicorn access-log печатает полный путь С QUERY, а лог уезжает в Loki —
|
|
||||||
# так секрет вебхука GlitchTip (`?secret=…`) оказался в хранилище открытым.
|
|
||||||
# Маскируем значения чувствительных query-параметров ДО записи строки.
|
|
||||||
install_query_secret_filter()
|
|
||||||
|
|
||||||
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
|
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
|
||||||
# DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP.
|
# DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP.
|
||||||
# Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs),
|
# Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs),
|
||||||
|
|
@ -80,43 +72,31 @@ install_query_secret_filter()
|
||||||
# frontend), отдельного broker нет → мониторить нечего.
|
# frontend), отдельного broker нет → мониторить нечего.
|
||||||
if settings.glitchtip_dsn:
|
if settings.glitchtip_dsn:
|
||||||
from app.observability.sentry_scrub import (
|
from app.observability.sentry_scrub import (
|
||||||
drop_payments_disabled_event,
|
|
||||||
redact_telegram_bot_token,
|
redact_telegram_bot_token,
|
||||||
scrub_payment_request_body,
|
scrub_payment_request_body,
|
||||||
scrub_public_address,
|
scrub_public_address,
|
||||||
stabilize_retry_error_fingerprint,
|
stabilize_retry_error_fingerprint,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _before_send(event: Event, hint: Hint) -> Event | None:
|
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
|
||||||
"""Композиция payments-disabled drop + платёжный body-wipe + PII-scrub +
|
"""Композиция платёжный body-wipe + PII-scrub + Telegram bot-токен redaction +
|
||||||
Telegram bot-токен redaction + RetryError fingerprint-стабилизация
|
RetryError fingerprint-стабилизация (#tgsupport-web, PR-D2, glitchtip-noise) —
|
||||||
(#tgsupport-web, PR-D2, glitchtip-noise, #3471) — см.
|
см. app/tgbot_main.py._before_send (идентичная композиция без последнего шага,
|
||||||
app/tgbot_main.py._before_send (идентичная композиция без последнего
|
тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже держит
|
||||||
шага, тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже
|
TelegramClient в стек-фреймах при ошибке sendMessage, а
|
||||||
держит TelegramClient в стек-фреймах при ошибке sendMessage, а
|
|
||||||
include_local_variables=False ниже — первый рубеж защиты.
|
include_local_variables=False ниже — первый рубеж защиты.
|
||||||
|
|
||||||
#3471: payments-disabled drop идёт ПЕРВЫМ шагом — это единственный
|
PR-D2: платёжный body-wipe идёт ПЕРВЫМ шагом, а не заменяет остальные —
|
||||||
процесс из трёх entrypoint'ов, который реально держит ASGI-роут
|
режет `request.data` целиком только для `/payments/*`, остальные пути
|
||||||
`/api/v1/payments/*`, поэтому именно здесь события возникают; ранний
|
(extra/contexts/traceback) по-прежнему проходят ключ-based scrub и
|
||||||
return None экономит остальную композицию на заведомо отбрасываемом
|
token-redaction. Тот же обработчик передан ОБОИМ каналам ниже
|
||||||
событии.
|
(before_send и before_send_transaction) — вчерашний баг в Птице закрыл
|
||||||
|
только error-канал, transaction-канал остался вообще без обработчика.
|
||||||
PR-D2: платёжный body-wipe идёт следующим шагом, а не заменяет
|
|
||||||
остальные — режет `request.data` целиком только для `/payments/*`,
|
|
||||||
остальные пути (extra/contexts/traceback) по-прежнему проходят
|
|
||||||
ключ-based scrub и token-redaction. Тот же обработчик передан ОБОИМ
|
|
||||||
каналам ниже (before_send и before_send_transaction) — вчерашний баг в
|
|
||||||
Птице закрыл только error-канал, transaction-канал остался вообще без
|
|
||||||
обработчика.
|
|
||||||
|
|
||||||
RetryError-стабилизация — этот процесс обслуживает /api/v1/geocode/*
|
RetryError-стабилизация — этот процесс обслуживает /api/v1/geocode/*
|
||||||
(suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см.
|
(suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см.
|
||||||
sentry_scrub.stabilize_retry_error_fingerprint."""
|
sentry_scrub.stabilize_retry_error_fingerprint."""
|
||||||
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
|
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
|
||||||
if dropped is None:
|
|
||||||
return None
|
|
||||||
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
|
|
||||||
if scrubbed is None:
|
if scrubbed is None:
|
||||||
return None
|
return None
|
||||||
# Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а
|
# Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а
|
||||||
|
|
@ -230,19 +210,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
# in the tradein-scraper container (`python -m app.scheduler_main`, kit scheduler).
|
# in the tradein-scraper container (`python -m app.scheduler_main`, kit scheduler).
|
||||||
# Prod backend has always run with SCHEDULER_ENABLE=false (see docker-compose.prod.yml);
|
# Prod backend has always run with SCHEDULER_ENABLE=false (see docker-compose.prod.yml);
|
||||||
# this API process never actually launched scheduler_loop() in production.
|
# this API process never actually launched scheduler_loop() in production.
|
||||||
|
yield
|
||||||
# Общий на приложение Telegram-клиент (#tg-connection-resilience): ручки
|
|
||||||
# support/glitchtip раньше создавали его на КАЖДЫЙ запрос, то есть каждое
|
|
||||||
# зеркалирование сообщения начиналось с полного TCP+TLS-хендшейка до
|
|
||||||
# api.telegram.org. Один пул keep-alive на процесс, закрываем на shutdown.
|
|
||||||
# Без токена не создаём: ручки в этом случае и так отвечают 503.
|
|
||||||
if settings.telegram_bot_token:
|
|
||||||
init_telegram_client()
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
await close_telegram_client()
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
|
@ -250,16 +218,6 @@ app = FastAPI(
|
||||||
description="Оценка вторичного жилья (выкупная стоимость) — копия trade-in feature из gendesign", # noqa: E501
|
description="Оценка вторичного жилья (выкупная стоимость) — копия trade-in feature из gendesign", # noqa: E501
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
# #3324: трейлинг-слэш обходил rbac_guard как канал разведки периметра.
|
|
||||||
# `/api/v1/me/` не матчит ни один маршрут → guard пропускает (см.
|
|
||||||
# rbac._path_is_routed) → роутер отвечал 307 на `/api/v1/me`, т.е. «маршрут
|
|
||||||
# существует» сообщал редирект вместо 401. Выключение проверено на предмет
|
|
||||||
# поломок: ни один route не объявлен с трейлинг-слэшем (нет `@router.get("/")`
|
|
||||||
# и пустых путей), ни один из 173 вызовов `api/v1` во фронте
|
|
||||||
# (tradein-mvp/frontend/src) не заканчивается слэшем, mount/StaticFiles нет.
|
|
||||||
# Deny-правила на слэш тоже не зависят от редиректа — они переведены на
|
|
||||||
# глоб-форму специально ради этого (см. auth_session.DB_ROLE_PATHS).
|
|
||||||
redirect_slashes=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue