Commit graph

801 commits

Author SHA1 Message Date
af5eed19f9 Merge pull request 'Бэкапы: образцы настроек ведут алерты в тему «Metrics», неработавший сторож доступности удалён' (#3559) from fix/backup-notify-topic into main
All checks were successful
Deploy / build-backend (push) Successful in 3m27s
Deploy / build-worker (push) Successful in 5m9s
Deploy / build-frontend (push) Successful in 6m7s
Deploy Infra Host / sync-infra-host (push) Successful in 7s
Deploy / deploy (push) Successful in 1m42s
Deploy / deploy-status (push) Successful in 5s
Deploy / changes (push) Successful in 17s
Deploy / perimeter-smoke (push) Successful in 1m51s
Deploy / deploy-caddy (push) Has been skipped
2026-09-17 09:16:45 +00:00
d9a5bfe71f Merge pull request 'ПТИЦА: у изъятий на участке появляется номер постановления, а таблица не удваивается' (#3568) from fix/ptica-reservation-act-number into main
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Successful in 14s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
2026-09-17 09:16:07 +00:00
b2394b0a0f Merge pull request 'Каталог DOM.РФ в beat: вместо обещания «вернуть после cooldown» — факт о блокировке StormWall и тест, который не даст включить молча' (#3566) from fix/ptica-domrf-waf-probe into main
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / deploy-caddy (push) Blocked by required conditions
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
2026-09-17 09:15:47 +00:00
7cd15aab71 Отказ ручного сбора каталога DOM.РФ больше не советует ждать cooldown (#2443)
Ревью PR #3566: обещание таймера, убранное из beat, осталось в тексте
HTTP 400 эндпоинтов /admin/scrape/kn-catalog-objects и /kn-catalog-flats.
Отказ предлагал передать i_understand_waf_risk=true, «если WAF cooldown
прошёл», то есть подсказывал оператору обойти блокировку, которую ожидание
не снимает (зонды 20.08-01.09: StormWall, «Доступ заблокирован [403]»).

Теперь отказ называет StormWall и реальное условие: прокси и kn-прогон,
принятый по числу строк (#3307). Константа переименована в
_DOMRF_BLOCK_GUARD_MSG. Докстринги эндпоинтов и задач
scrape_kn_catalog_flats/objects больше не говорят про WAF cooldown и про
«вторник 04:00 UTC» у выключенной записи с расписанием в МСК.
api-types.ts перегенерирован как в CI-гейте openapi-codegen-check,
изменились только два докстринга.

Тесты отказа проверяют значение detail: #3307 есть, cooldown нет.
Поведение guard'а (400 без флага, задача не ставится) не менялось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:58:31 +05:00
7501fef5c3 fix(ptica): номер акта в land_reservation извлекается у постановлений Администрации ЕКБ (#2982)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m31s
CI / backend-tests (pull_request) Successful in 6m44s
Регекс номера требовал суффикс областных актов (-ПП/-ПА/-РП/-ПГ/-ГП/-МО),
а у постановлений Администрации Екатеринбурга его нет: на проде act_number
пуст у всех 27 строк (17.09.2026).

- izyatie_ocr: номер берётся у того же акта, чью дату выбирает
  _extract_act_date (вплотную после «от DD.MM.YYYY», иначе перед ней).
  Первое «№» в теле — «Решение Думы № 60/1» или «Приказ № 746-П».
- page_reservation_parser: суффикс необязателен; номер с суффиксом не из
  списка («218-ФЗ», «746-П», «60/1») отбрасывается целиком, а не обрезается.
- izyatie_ocr_ingest: перед записью удаляется прежний разбор того же
  участка из того же документа с другим номером. act_number в ключе
  конфликта, без этого прогон положил бы 27 строк с номером рядом с 27
  строками без номера.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:24:06 +05:00
b2ff92afbb beat: каталог DOM.РФ выключен из-за блокировки StormWall, а не «до cooldown» (#2443)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 17s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m45s
CI / backend-tests (pull_request) Successful in 6m59s
Комментарии у закомментированных записей scrape-kn-catalog-objects-weekly и
scrape-kn-catalog-flats-weekly обещали «возврат после cooldown 24-48h
(проверить через targeted test)». Тест проведён 20.08, 27.08 и 01.09:
кулдауна нет (kn-прогоны 29-33 шли в июне, уже после «бана» 24.05), с 01.09
наш.дом.рф за StormWall отдаёт IP Poincare «Доступ заблокирован [403]».
Комментарии теперь называют факт и условие включения (прокси и принятый
kn-прогон, #3307).

Решение больше не живёт только в комментарии: тест проверяет по значению
(по task, не по ключу), что ни одна из двух задач не попала в собранное
beat-расписание, и в тексте падения ведёт в #3307.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:09:44 +05:00
7218c2094c Бэкапы: образцы env ведут алерты в тему «Metrics», мёртвый uptime-сторож удалён (#3164)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 24s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 29s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m15s
CI / backend-tests (pull_request) Successful in 7m37s
Тема форума для уведомлений бэкапов задаётся только env-файлом на хосте, а в
образцах её не было вовсе. На проде она задана, но не та: 158 («алерты») в
/opt/gendesign/secrets/backup-notify.env и forgejo-backup.env на Beget и в
/etc/default/gendesign-backup на Poincare. По решению #3163 инфраструктура идёт
в 245 «Metrics». Значение на хостах этот коммит не меняет.

- ops/gendesign-backup*.default.example: строка #TELEGRAM_TOPIC_ID=245 с
  причиной и ловушкой: тема обязана лежать в одном файле с токеном и чатом,
  иначе notify() её не прочитает.
- ops/crontab-beget.cron сверен с живым crontab Beget: сторожа и бэкап волта
  получают BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env. Без него
  переустановка crontab из репозитория глушила бы алерты бэкапов на Beget.
- ops/uptime-healthcheck.sh и его образец удалены: скрипт не запущен ни на
  одном хосте (crontab, cron.d, таймеры), доступность сторожат uptime-мониторы
  GlitchTip на Beget (gendsgn.ru, /health, meraocenka.ru — раз в 60 с).

Тест исполняет настоящий check-backup-staleness.sh с образцом, заполненным
по инструкции, и проверяет адрес в вызове curl: message_thread_id=245.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:49:33 +05:00
ec85ea1861 fix(metrics): правка queries.yml доезжает до postgres-экспортёров (#3486)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 19s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m59s
CI / openapi-codegen-check (pull_request) Successful in 3m19s
CI / backend-tests (pull_request) Successful in 8m6s
queries.yml смонтирован трём экспортёрам одним файлом и читается только при
старте, а агентские джобы деплоя пересоздавали лишь alloy: правка ложилась на
диск новым инодом, экспортёры продолжали отдавать старые запросы при зелёном
деплое. Разрыв латентный — 17.09 иноды хоста и контейнеров совпадают
(Poincare 5112170, Beget 569352).

agent-apps и agent-infra после подъёма сверяют инод queries.yml у своих
экспортёров через ops/metrics/recreate-stale-mount.sh и пересоздают только при
расхождении, под гейтом профиля. Гейт пофайловых маунтов теперь читает и
docker-compose.metrics-agent.yml (сервис → джоба по профилю), а ci.yml
запускает backend-тесты на правку этого файла.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:43:48 +05:00
e5db8dd80f fix(metrics): правки prometheus.yml, loki-config.yml и датасорсов Grafana доезжают до работающих сервисов (#3467)
Хвосты #3467, не попавшие в main вместе с #3476:

- prometheus.yml смонтирован одним файлом: после git reset --hard reload
  перечитывал СТАРЫЙ инод с rc=0 и новым lastConfigTime (стенд
  prom/prometheus:v3.1.0, 17.09). Комментарий в деплое утверждал обратное.
  Теперь promtool проверяет файлы С ДИСКА одноразовым контейнером, а при
  расхождении инода контейнер пересоздаётся до reload.
- loki-config.yml — тот же пофайловый маунт, перезагрузки у Loki нет:
  пересоздание при расхождении инода.
- Датасорсы Grafana применяются только при старте: POST
  /api/admin/provisioning/datasources/reload, отказ роняет деплой.

Общий шаг — ops/metrics/recreate-stale-mount.sh: пересоздаёт только при
расхождении инода и перепроверяет после; тесты исполняют его с подставным
docker. Гейт берёт пофайловые маунты из docker-compose.metrics.yml.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:42:35 +05:00
dbf46228fb Дробить плитку Overpass только при перегрузке, а не при отказе сети (#3528)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m29s
Deploy / build-worker (push) Successful in 4m1s
Deploy / deploy (push) Successful in 1m28s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 1m44s
2026-09-15 07:33:56 +00:00
a83418799c Загрузчик точек интереса берёт регион параметром, а не только Екатеринбург (#3524)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m21s
Deploy / build-worker (push) Successful in 3m38s
Deploy / deploy (push) Successful in 1m23s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 1m42s
2026-09-15 07:05:21 +00:00
94d34e7dcb chore(tests): POSIX-only кейсы пропускаются на Windows и зарегистрированы в allowlist (#3520)
Some checks failed
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 40s
Deploy / build-worker (push) Successful in 39s
Deploy / deploy (push) Successful in 1m11s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Failing after 1m43s
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-09-13 11:17:13 +00:00
68495907d5 Merge pull request 'fix(deploy): Caddy пересоздаётся только когда правка иначе не доедет (#3443)' (#3506) from fix/3443-caddy-selfdowntime into main
All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 5s
Deploy / changes (push) Successful in 8s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-frontend (push) Successful in 39s
Deploy / build-backend (push) Successful in 41s
Deploy / build-worker (push) Successful in 40s
Deploy / deploy (push) Successful in 1m8s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 1m40s
2026-09-12 16:16:03 +00:00
d5f0557ca8 fix(deploy): сверка маунтов Caddy не может провалиться втихую (#3443, ревью)
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m8s
CI / openapi-codegen-check (pull_request) Successful in 2m3s
CI / backend-tests (pull_request) Successful in 17m40s
Две дыры из deep-ревью PR #3506 — обе про «отказ выглядит как успех».

M1. `stale=$(docker inspect … | while …)` под `set -eu` без `pipefail`
(в POSIX-sh его нет) отдаёт статус `while`, то есть всегда 0. Провал
`docker inspect` или пустой вывод давали пустой список → ветка «всё
доехало» → `caddy reload` → зелёная джоба с надписью «окна недоступности
нет» при прокси, работающем по СТАРОМУ конфигу. Ровно тот беззвучный
отказ, ради которого написан скрипт. Теперь список читается отдельной
командой, провал и пустой вывод считаются расхождением (fail-safe в
прежнее поведение), число сверенных файлов печатается — «сверили пять» и
«сверили ноль» в логе больше не выглядят одинаково. Ноль пофайловых
маунтов (например, если Caddyfile переведут на именованный том) — тоже
расхождение, а не тавтологически успешная сверка.

M2. В фильтре `backend` (ci.yml) не было `ops/**`, а все содержательные
регрессии живут в самом ops/caddy-apply.sh: гейт его ИСПОЛНЯЕТ. PR,
правящий только скрипт, давал backend=false — джоба пропускается, гейт не
исполняется, «пересоздавать всегда» уезжает в main зелёным. Тот же класс,
что уже осуждён комментариями рядом (#2950/#3448/#3467).

Мелочи оттуда же:
* `[ -d "$src" ] && continue` вместо `[ -f "$src" ] || continue` — пропуск
  по `-f` склеивал «это каталог» (пропустить верно) и «файла на хосте
  нет», для которого в контейнере как раз живёт старый инод;
* сообщение об отказе `caddy validate` больше не называет причиной
  битый конфиг, когда упасть мог и сам запуск проверочного контейнера;
* в комментарии к проверке записана её граница: в полном деплое общий
  `up -d $UP_SERVICES` (deploy.yml:959) поднимает и caddy за ~110 строк
  до вызова скрипта, поэтому правка, которая одновременно ломает Caddyfile
  и меняет блок caddy в compose, пересоздаст контейнер раньше проверки.

Гейт дорос с 16 до 22 проверок: `docker inspect` не ответил → пересоздание,
ноль пофайловых маунтов → пересоздание, исчезнувший файл на хосте →
пересоздание, число сверенных маунтов печатается, `caddy reload`/вызов
скрипта не проглочены `|| true`, ci.yml-фильтр покрывает ops/**.
Все 11 мутантов (7 новых + 4 прежних) краснеют, контроль зелёный.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 20:49:34 +05:00
9fedaa4582 fix(deploy): Caddy пересоздаётся только когда правка иначе не доедет (#3443)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m56s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Successful in 17m31s
Полный деплой ПТИЦЫ каждый раз делал `up -d --force-recreate --no-deps caddy`
и сносил единственный процесс, слушающий 80/443: замер 05.09 (#3274) — 67 с
`code=000` на ВСЕХ доменах хоста, включая публичный лендинг МЕРЫ. Заглушка
окна деплоя здесь бессильна по построению: её отдаёт тот же Caddy.

Что установлено, а не принято на веру:

* Безусловный флаг появился 17.05 (11e78d73) ради нового bind-маунта
  `./preview` — «иначе новые volume mounts не появляются». Довод неверен:
  `docker compose up -d` БЕЗ `--force-recreate` пересоздаёт контейнер сам при
  смене описания сервиса или образа. Проверено на живом демоне (docker 28.4):
  добавлен volume → новый id; тег переставлен на другой образ → новый id;
  не менялось ничего → `Container … Running`, id тот же.
* Compose не видит только одного: СОДЕРЖИМОГО пофайлового bind-маунта.
  `git reset --hard` пишет новый инод, контейнер держит прежний, и `caddy
  reload` перечитывает старый текст (тот же механизм — Alertmanager 27.08 и
  Alloy #3380). У Caddy так смонтированы пять путей: Caddyfile и четыре
  сниппета; каталоги (caddy/sites, caddy/local, preview) этим не страдают —
  самый частый случай, caddy/sites/apps.caddy, пересоздания НЕ требует.
* Отсюда же второй, беззвучный дефект: быстрый путь `deploy-caddy` делал голый
  `exec caddy reload` после `git reset --hard`, то есть правка Caddyfile или
  сниппета до контейнера не доезжала вовсе, а джоба уходила зелёной.

Оба пути деплоя теперь зовут ops/caddy-apply.sh: `caddy validate` одноразовым
контейнером по файлам С ХОСТА (битый конфиг не применяется и прокси не
трогает) → `up -d` без `--force-recreate` → если контейнер тот же, сверка
sha256 каждого пофайлового маунта с тем, что видит контейнер → пересоздание
ТОЛЬКО при расхождении, иначе `caddy reload` без разрыва соединений.
Не прочиталось — считаем расхождением: fail-safe в сторону прежнего поведения.

Гейт backend/tests/ops/test_3443_caddy_reload_not_recreate.py исполняет скрипт
с подставным `docker` и смотрит на совершённые действия, а не на его текст:
ничего не менялось → reload без пересоздания; правлен Caddyfile или любой из
сниппетов → пересоздание; правка в каталоге → без пересоздания; битый конфиг →
не тронуто ничего; compose пересоздал сам → второго пересоздания нет.
Отдельно — проводка в deploy.yml и запрет безусловного `--force-recreate` для
Caddy в полном деплое.

Приёмка (#3443) снимается ПОСЛЕ мержа, на живом деплое: непрерывная проба
`scripts/probe-deploy-window.sh` с хоста — максимальная серия `000` меньше 2 с
против нынешних 67 с.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 19:38:48 +05:00
98a582e242 perf(tests): убрать фиксированные ожидания из analyze-тестов Site Finder (#3502)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 39s
Deploy / build-worker (push) Successful in 40s
Deploy / deploy (push) Successful in 1m12s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 1m41s
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-09-12 14:09:23 +00:00
bbf70a3283 Merge pull request 'Продуктовые счётчики и дашборд воронки' (#3491) from feat/3471-product-metrics-dashboard into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 15s
Deploy Metrics / server (push) Successful in 17s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Metrics / agent-apps (push) Failing after 18s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Metrics / agent-infra (push) Successful in 30s
Deploy / build-backend (push) Successful in 2m22s
Deploy Trade-In / test (push) Has been cancelled
Deploy / build-worker (push) Successful in 3m31s
Deploy / deploy (push) Successful in 1m31s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 1m41s
2026-09-12 11:45:49 +00:00
8f10ded2a0 Merge pull request 'Быстрый путь «правка только прокси» наконец включается: считаем изменённые файлы сами (#3448)' (#3465) from fix/3448-caddy-only-detection into main
Some checks failed
Deploy / changes (push) Successful in 9s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 54s
Deploy / build-worker (push) Successful in 54s
Deploy / build-frontend (push) Successful in 54s
Deploy / deploy (push) Has been cancelled
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
2026-09-12 11:43:38 +00:00
bot-backend
690f1ef5d2 feat(metrics): продуктовые счётчики Prometheus для Меры и Птицы + дашборд
All checks were successful
CI / backend-tests (pull_request) Successful in 17m59s
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m18s
CI Trade-In / backend-tests (pull_request) Successful in 5m59s
Владелец попросил вывести продукт в Графану — до этого там были только
технические панели (запросы/латентность/память). Список счётчиков взят из
реально пишущихся событий, а не выдуман:

Мера (tradein-mvp/backend/app/observability/metrics.py):
- mera_estimates_total{outcome=ok|insufficient_data} — POST /estimate,
  зеркалит user_events.event_type=estimate_request (294 строки в БД),
  insufficient_data — не ошибка, а исход без аналогов.
- mera_address_suggestions_total{found=yes|no} — GET /geocode/suggest,
  своего user_events-события у ручки не было.
- mera_reports_exported_total (без лейблов) — GET /estimate/{id}/pdf.
- mera_leads_total (без лейблов) — POST /trade-in/lead.
- mera_support_messages_total{channel=web|anon} — POST /support/messages
  и /support/anon/messages, счётчик после успешной доставки в Telegram.
- mera_logins_total{result=success|failed} — рядом с user_events
  login_success/login_failed в auth.py (97/453 строк в БД).

Птица (backend/app/observability/metrics.py):
- sitefinder_reports_exported_total{format} — GET .../forecast/export
  (md/json/tg/docx/pptx/pdf) и POST .../best-layouts/pdf.

Метки везде — фиксированный литерал из места вызова (outcome/found/channel/
result/format), никогда username/адрес/estimate_id/кадастровый номер —
это ровно то, что взрывает кардинальность ряда у Prometheus.

Дашборд ops/metrics/grafana/dashboards/product.json ("Продуктовые метрики",
uid gendesign-product) — воронка Меры (оценки/подсказки/лиды/отчёты/входы/
поддержка) + экспорт форматов Птицы, часовые increase()-панели без
стекирования (на соседней панели оно уже давало ложную тревогу, PR #3474).
Provisioning тот же, что у apps.json — сканирует директорию, отдельного
конфига не нужно.

ops/metrics/alloy/alloy-apps.alloy проверен: у job "apps" нет relabel-
фильтра по __name__ (в отличие от cadvisor) — новые счётчики уходят в
remote_write как есть, правки не потребовалось.

Refs #3471
2026-09-12 14:22:33 +03:00
2e928c715b Гейт #3448: закрыть зелёные мутации, добавить признак непустоты, запускать на ci-tradein.yml
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 16s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m42s
CI / openapi-codegen-check (pull_request) Successful in 2m37s
CI / backend-tests (pull_request) Successful in 19m21s
Мутационный прогон нашёл пять зелёных мутаций — то есть мест, где логику можно
сломать, а гейт этого не заметит. Закрыты фикстурами, каждая краснеет ровно на
своей мутации:

* CADDY_RE → `^(Caddyfile|caddy)`: тогда `caddy-extra/**` и `Caddyfile.bak`
  дают caddy_only=true — тихий пропуск полного деплоя, против которого весь PR;
* снятие проверки «файлов больше нуля»: пустой дифф формально удовлетворяет
  «ни один файл не лежит вне caddy» и отключает сборку;
* выпадение `data/sql/**` из backend: миграции едут в backend-образе;
* подмена базы на `HEAD^..HEAD`: на ОДНОМ мерж-коммите даёт верный ответ и
  выглядит рабочей, а на push'е из нескольких коммитов теряет первый — фикстура
  «бэкенд-коммит + caddy-коммит» это ловит;
* потеря `core.quotePath=false`: кириллический путь под backend/ выпадает из
  классификации.

Плюс прод-сторож из deploy-caddy: его кусок (от PROD_HEAD до `git reset --hard`)
извлекается из ssh-скрипта и ИСПОЛНЯЕТСЯ на временном репозитории, где прод-дерево
отстаёт от origin/main — отдельно законный случай (отстал только конфиг прокси)
и отказной (отстал бэкенд). Проверяется и порядок: сторож обязан стоять ДО
`git reset`. Команда ищется регуляркой по началу строки, а не подстрокой:
`git reset --hard` упоминается выше в комментариях, и поиск по тексту находил
объяснение вместо кода.

Признак непустоты у проверки исключающих `!`-шаблонов: раньше она бы прошла при
нулевом охвате (переименуют действие, заведут .yaml) — теперь отдельно
утверждается, что хотя бы один шаг paths-filter найден, как это сделано в ci.yml
для shell-гейта. Маска расширена до *.y*ml, параметризация — по найденным шагам.

ci.yml: в фильтр `backend` добавлен `.forgejo/workflows/ci-tradein.yml` — там
тоже живёт paths-filter, и без этой строки правка с `!`-шаблоном не запустила бы
backend-tests, то есть гейт не побежал бы ровно на той правке, от которой стережёт.

Докстринг фикстуры с мержем переписан: он утверждал, что «дифф последнего
коммита» на мерж-коммите даёт пустой список (это верно для `git show`, а не для
`git diff HEAD^ HEAD`) — то есть обещал защиту, которой у этой фикстуры нет.
Теперь там сказано, что подмену базы стережёт отдельная проверка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 15:57:11 +05:00
bot-backend
6febb36afd fix(ops): деплой метрик перечитывает конфиг Prometheus, а не только кладёт его на диск
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m14s
CI / backend-tests (pull_request) Successful in 19m15s
lastConfigTime у gendesign-prometheus совпадал со startTime контейнера 16
суток: docker compose up -d не пересоздаёт контейнер из-за изменения
содержимого бинд-маунта (сравнивается только описание сервиса), а
--web.enable-lifecycle был включён, но /-/reload никто не вызывал. Любая
правка ops/metrics/prometheus/** доезжала до диска и молча не вступала в
силу до случайного рестарта, при зелёном деплое.

Добавлен шаг по образцу уже работающей проверки Caddyfile в этом же
workflow: promtool check config + promtool check rules внутри контейнера,
reload только при успешной проверке, приёмка через сравнение
lastConfigTime до/после (обновляется на каждый успешный reload, поэтому
надёжно ловит и несостоявшийся вызов). Провал promtool теперь роняет шаг
и не трогает работающий Prometheus.

Alertmanager уже чинился отдельно (--force-recreate, #3078/#3136) — reload
для него намеренно не помогает из-за переиспользуемого инода, это не
regressed. Loki (/etc/loki/loki-config.yml), Grafana (provisioning) и Alloy
(config.alloy) в том же деплое лежат на дисковых бинд-маунтах без reload —
чинить их этим PR не стал, см. summary задачи.

Refs #3467, #3471
2026-09-12 13:55:53 +03:00
33714e464e fix(alerts): в тексте тревоги печаталась не та величина, которую текст называет
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m30s
CI / backend-tests (pull_request) Successful in 17m50s
Боевые сообщения в Telegram 12.09:
  «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` —
    в сообщение уходил лимит в байтах (2 684 354 560), отрендеренный как
    процент. Замер 12.09: настоящее потребление того контейнера — 1.2 % лимита.
  - PostgresLowHotUpdateRatio: слева стоял `rate(tup_upd[6h])` — апдейтов в
    секунду. Это опаснее: 0.7521 превращалось в «75.21%», попадало в
    правдоподобный диапазон и противоречило собственному порогу, но выглядело
    настоящим числом. Замер 12.09 по listings: rate(tup_upd[6h]) = 0.0411 →
    сообщение сказало бы «4.11%», настоящая доля HOT = 0.00%.

Условия срабатывания в обоих случаях были ВЕРНЫ — врал только текст, поэтому
дефект и прожил незамеченным.

Правка: отношение вынесено влево, а побочное условие — внутрь знаменателя
(`X / (Y > 0)`), где оно и фильтрует серии, и защищает от деления на ноль.
Проверено на живом Prometheus (только чтение): новое выражение памяти отдаёт
доли 0.35–0.71 (топ — gendesign-infra-postgres 70.8 %), новое выражение HOT —
доли 0.00–1.00. `promtool check rules` — SUCCESS, 16 rules.

Третье правило того же семейства (PostgresDeadTuplesHigh) верно, но верно
случайно: печатаемая величина совпала с левым операндом. Помечено комментарием,
чтобы его не «причесали» по образцу двух других.

Гейт: backend/tests/ops/test_alert_value_is_the_described_quantity.py — если
описание рендерит `$value` как долю (`humanizePercentage`), левая часть
выражения обязана содержать деление. Фальсификация: вернул файл правил с
origin/main → красные test_percentage_annotations_come_from_a_ratio и
test_known_two_rules_are_fixed; с правкой — 3 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:27:02 +05:00
091137a0c5 Быстрый путь caddy_only: считаем изменённые файлы сами, без исключающих шаблонов (#3448)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 13s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m34s
CI / backend-tests (pull_request) Successful in 17m41s
Быстрый путь «правка ТОЛЬКО прокси» (#2916) не отработал ни разу: мерж
84920e6c, где в диффе один файл caddy/sites/apps.caddy, пересоздал весь стек
ПТИЦЫ (gendesign-caddy-1 Created=2026-09-11T20:40:14, в логе Caddy
"serving initial configuration" — холодный старт, а не reload).

ПРИЧИНА НЕ ТА, ЧТО В ГИПОТЕЗЕ. Гипотеза #3448 — пустой github.event.before у
мерж-коммита — опровергнута логом задачи 29244 (run 10881):

    Changes will be detected between 204e2e09de and main
    git diff --no-renames --name-status -z 204e2e09..refs/remotes/origin/main
    M caddy/sites/apps.caddy
    Detected 1 changed files

before валиден, коммит дотянут, дифф верный. Дальше в том же логе:

    ##[group]Filter non_caddy = true
    Matching files:
    caddy/sites/apps.caddy [modified]

Исключённый файл сам себя и «исключил». dorny/paths-filter склеивает шаблоны
одного фильтра через some, то есть ИЛИ (src/filter.ts: patterns.some(aPredicate),
predicate-quantifier по умолчанию some), поэтому

    non_caddy: ['**', '!Caddyfile', '!caddy/**']

читается как «подходит под ** ИЛИ не Caddyfile ИЛИ не caddy/**» — а ** матчит
всё. non_caddy был true ВСЕГДА, caddy_only — false всегда, deploy-caddy
пропускался. Сигнала не было ни одного: пропущенную джобу Forgejo рисует
зелёной, и «зелёный deploy-caddy» неотличим от невыполненного.

ЧТО СДЕЛАНО. Job changes считает список файлов сам: git diff по явным границам
(before → HEAD), флаги backend/frontend/infra/caddy_only выводятся из этого
списка. Заплатки к фильтрам не годятся: predicate-quantifier: every действует
на ВЕСЬ блок и сломал бы backend/frontend/infra, то есть фикс снова висел бы на
незаметном умолчании.

Шаг ПЕЧАТАЕТ и список файлов, и итоговые флаги — у правки должен быть
наблюдаемый признак, иначе «сработало» и «просто не совпало» выглядят одинаково.
База не разрешилась (ручной запуск, пустой/нулевой before, коммита нет в клоне)
→ изменённым считается весь репозиторий: лишний полный деплой безопаснее
пропущенного. Фолбэка на HEAD^..HEAD намеренно нет — у push'а из нескольких
коммитов он молча урезал бы список и включил быстрый путь там, где приехал бэкенд.

Гейт backend/tests/ops/test_3448_caddy_only_detection.py ИСПОЛНЯЕТ этот шаг на
временном репозитории с настоящим мерж-коммитом и проверяет значения флагов:
только caddy → caddy_only=true; caddy+backend → false; база не разрешилась →
полный деплой; решение видно в логе. Отдельная проверка ловит класс бага во всех
воркфлоу — исключающие шаблоны '!' в любом paths-filter без predicate-quantifier: every.

Приёмка на проде: следующий мерж с единственным файлом под caddy/** не меняет
docker inspect gendesign-caddy-1 --format '{{.Created}}', а в логе Caddy —
reload, а не "serving initial configuration".

Closes #3448

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:26:52 +05:00
6eb1441508 Merge branch 'main' into fix/eesk-bind-in-comment
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m24s
CI / backend-tests (pull_request) Successful in 17m40s
2026-09-09 21:47:07 +00:00
6a2d873c70 fix(rosseti): считать координату ключа в SQL тем же double, что в питоне
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 14s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m24s
CI / backend-tests (pull_request) Successful in 17m41s
Ревью PR #3329. round(ST_X(geom)::numeric * 100000) округляет по кратчайшему
десятичному представлению float8, а питон — по двоичному double: расхождение на
0.19% реальных координат (761 из 400000), напр. 64.423605 → питон 6442360
(6442360.499999999), numeric-путь 6442361. Каждое расхождение = вечный дубль ЦП,
который сам не зарастёт — миграция применяется один раз (_schema_migrations).
Теперь в SQL sign/floor/abs над float8 без каста в numeric: IEEE754 бит в бит
как math.floor в питоне.

test_coord_e5 брал 60.123455, где двоичное и десятичное округление совпадают —
защита, которая не защищает. Добавлено расходящееся значение 64.423605.

RAISE WARNING при rows_after > 700 заменён на RAISE EXCEPTION: warning не
останавливает прогон, файл помечался бы applied навсегда вместе с дублями.

Refs #3322
2026-09-02 14:51:47 +05:00
b35f444d50 fix(rosseti): стабильный external_id ЦП вместо сессионного fid GeoServer
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m22s
CI / backend-tests (pull_request) Successful in 17m46s
feature['id'] в WFS-ответе — сессионный fid, новый на каждый GetFeature.
ON CONFLICT (source, external_id) не срабатывал ни разу, каждый weekly-прогон
дописывал полный комплект ~488 фич: 4880 строк на 481 ЦП. Задуманный sha1-фолбэк
по атрибутам был мёртв — fid присутствует всегда.

Ключ теперь считается ТОЛЬКО по стабильным атрибутам (нормализованное имя, класс
напряжения, координаты в 1e-5 градуса), fid игнорируется. Координата квантуется
в целое, а не форматируется как float: ключ обязан совпадать байт-в-байт с
бэкфиллом в SQL. sha256 вместо sha1 — встроен в PG16, pgcrypto не нужен.

Починка разбора старые строки не убирает (ключи не совпадут, ON CONFLICT ничего
не перезапишет) → data/sql/99c_power_supply_centers_dedup.sql: пересчёт ключа
существующих строк + схлопывание копий (победитель — свежайший snapshot,
NULLS LAST явно), с печатью чисел до/после и идемпотентностью.

Refs #3322
2026-09-02 14:43:58 +05:00
a9de9bedf6 ЕЭСК-лоадер: бинд-параметр в SQL-комментарии ронял все 71 UPDATE с 18.08
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m1s
CI / backend-tests (pull_request) Successful in 17m30s
Комментарий #2464-B, объясняющий, почему из UPDATE убрали :load_pct, сам
содержал «CAST(:load_pct AS text)» — а SQLAlchemy text() парсит бинды и
внутри SQL-комментариев. Параметр стал обязательным, params его не содержит,
КАЖДАЯ строка батча падала на компиляции, per-row SAVEPOINT-except глотал
это как «битую строку», задача оставалась зелёной. Резервы ПС 35-220 не
обновлялись две недели, и никакой сторож этого не видел.

Найдено армейским аудитом 01-02.09 (линза ptica-workers), подтверждено
скептиком воспроизведением на проде.

Правка — переписан комментарий БЕЗ упоминания снятого параметра в живом
синтаксисе бинда, с предупреждением, почему это запрещено.

Сторож на МЕХАНИЗМ: тест собирает text()-стейтмент из исходника и сверяет
его бинд-имена с ключами params. БД не нужна — дефект живёт на компиляции.
Фальсификация: возврат «:load_pct» в комментарий даёт красное по значению
(«стейтмент требует биндов ['load_pct']»).
2026-09-02 11:53:10 +05:00
7e59c1e5b0 fix(#3194): hide_parameters=True на всех движках, include_local_variables=False у scheduler
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 7s
CI / openapi-codegen-check (pull_request) Successful in 3m10s
CI Trade-In / backend-tests (pull_request) Successful in 5m47s
CI / backend-tests (pull_request) Successful in 18m3s
Ключ шифрования кук и сами куки уезжали в GlitchTip: сервисы сессий передают
их bind-параметрами в pgp_sym_encrypt(:cookies_json, :key), а SQLAlchemy при
ошибке печатает ВСЕ параметры в тексте StatementError.

Правка на уровне движка (backend + tradein-mvp: db.py, auth_db.py,
alembic/env.py) кроет все сайты вызова разом, включая четвёртую копию в
scraper-kit и всё будущее.

scheduler_main.py был единственным из трёх sentry_sdk.init без
include_local_variables=False — процесс скрейпера, в кадрах лежат прокси-креды.

НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением) и
сырые psycopg-подключения мимо движков — отдельный класс.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:53:41 +05:00
84505b1e0b chore(site-finder): удалить мёртвый код — 14 неиспользуемых символов (#3235)
Some checks failed
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m30s
Deploy / build-worker (push) Successful in 3m39s
Deploy / deploy (push) Successful in 1m18s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Failing after 12s
2026-08-29 15:39:10 +00:00
bot-backend
25bebadca5 chore(deps): три места, где сборка не воспроизводится — лок-призрак, глоб и неприпинованный сайдкар
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m9s
CI / openapi-codegen-check (pull_request) Successful in 2m23s
CI / backend-tests (pull_request) Successful in 18m6s
Ничего не апгрейдит. Убирает три способа собрать образ не тем, что в репозитории.

1. tradein-mvp/backend/uv.lock — удалён (файл был untracked, в .gitignore
   с рождения). Мера — uv-workspace, сборка берёт КОРНЕВОЙ tradein-mvp/uv.lock
   (`context: ./tradein-mvp`, `COPY pyproject.toml uv.lock ./` + `uv sync
   --frozen`). Лок, созданный `uv lock` из backend/, не читает никто, и он тихо
   разошёлся с рабочим: на 2026-08-29 в нём pillow 12.2.0, starlette 1.0.1,
   python-multipart 0.0.29, pydantic-settings 2.14.1, weasyprint 68.1 — пять
   пакетов с открытыми advisory, которых в реальной сборке Меры нет вообще.
   Именно он подмешал пять фантомных строк в OSV-скан этой серии.
   Строка .gitignore остаётся (второй лок не нужен), но теперь с объяснением
   почему — иначе следующий читатель снимет ignore и закоммитит призрак.

2. backend/Dockerfile — `COPY pyproject.toml uv.lock* ./` + `if [ -f uv.lock ];
   then uv sync --frozen ...; else uv sync ...; fi` → без глоба и без фолбэка.
   Глоб + фолбэк означали: пропал лок — сборка не падает, а молча переключается
   на резолв «свежайшее из диапазонов pyproject». Образ собрался бы с версиями,
   которых никто не видел ни в одном PR. Теперь пропажа лока роняет COPY.

3. tradein-mvp/browser/Dockerfile — `pip install "camoufox[geoip]" aiohttp`
   без единого пина. У сайдкара нет лока вообще, так что любая пересборка (в том
   числе на несвязанном коммите) тянула свежайший camoufox, а с ним другой
   playwright — под который НЕ написан sed-патч coreBundle.js в том же файле.
   Запинено по факту прод-контейнера tradein-browser: camoufox 0.5.5,
   playwright 1.60.0, aiohttp 3.14.3. playwright явно, хотя и транзитивный
   (camoufox 0.5.5 → playwright<1.61): патч завязан на конкретную сборку драйвера.

   Там же переписан комментарий «Апгрейд playwright невозможен — camoufox 0.4.11
   pinned»: неверны обе половины. camoufox не был запинен ни на что, а 0.4.11
   в проде не стоит с неизвестно каких пор — контейнер сейчас несёт 0.5.5 и
   playwright 1.60.0.

Версии сняты с живого прод-контейнера, наличие на PyPI проверено.
2026-08-29 14:01:14 +03:00
bot-backend
e059e41f5f fix(deps/site-finder): 53 advisory в backend/uv.lock — точечный апгрейд восьми пакетов
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m9s
CI / backend-tests (pull_request) Successful in 17m38s
Прогон всех локов через OSV batch API (2026-08-29) показал: уязвимые версии есть
только в backend/uv.lock. Корневой лок «Меры» (tradein-mvp/uv.lock, из которого и
собирается её образ) чист полностью — ноль находок.

Было 8 пакетов / 53 advisory, стало 0. Проверено повторным прогоном OSV по
получившемуся локу.

  pillow            12.2.0 -> 12.3.0   26 advisory, среди них heap OOB write в
                                       Image.paste/crop и ImageFilter.RankFilter;
                                       Image.open у нас на внешнем входе (загрузка фото)
  starlette         1.0.0  -> 1.6.0    10 advisory, в т.ч. обход лимитов request.form()
  cryptography      47.0.0 -> 50.0.1   7 advisory; транзитив pdfminer-six
  urllib3           2.6.3  -> 2.7.0    4 advisory; обход защиты от decompression-bomb
  weasyprint        68.1   -> 69.0     CSS injection via presentational hints
  idna              3.13   -> 3.19     2 advisory
  mako              1.3.11 -> 1.4.1    path traversal в TemplateLookup (Windows-only,
                                       у нас Linux — берём попутно)
  pydantic-settings 2.14.0 -> 2.15.0   1 advisory

Апгрейд ТОЧЕЧНЫЙ (--upgrade-package на 8 имён), не общий --upgrade: спеки в
pyproject все вида ">=" без верхней границы, и общий апгрейд затянул бы мажоры
вроде redis 7->8 и numpy заодно. Диффом подтверждено: из 138 пакетов изменились
ровно эти 8, прямые спеки в pyproject.toml не тронуты.

Проверка совместимости локально: fastapi 0.136.1 поднимается на starlette 1.6.0,
TestClient отвечает. weasyprint импортировать на Windows нельзя (нет GTK), его
гоняет CI на Linux.

NB: fastapi.testclient на starlette 1.6 предупреждает, что связка с httpx
устарела в пользу httpx2 — это ворнинг, не отказ; отдельная задача.
2026-08-29 13:28:36 +03:00
bot-backend
192fdbfbf6 fix(ops/metrics): тема «метрики» задаётся дефолтом, а не ручным заведением секрета
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m2s
CI / backend-tests (pull_request) Successful in 17m10s
Разделение тем из #3163 зависело от шага «завести секрет руками». Шаг отказал
сразу же: 27.08 два прогона деплоя подряд отработали зелёными, напечатали
строку про откат — и тема «метрики» осталась пустой, а весь инфраструктурный
поток продолжил идти в тему клиентских инцидентов.

Номер темы форума секретом не является: в репозитории уже лежат домены, пути
на хостах, имена контейнеров и внешние адреса. Ставим 245 значением по
умолчанию прямо в деплое; переменная окружения по-прежнему перекрывает — переезд
темы или другой чат решается ею, без правки кода.

Откат на METRICS_TELEGRAM_TOPIC_ID убран намеренно и запрещён тестом. Он
возвращал ровно то состояние, ради ухода от которого всё затевалось, и
сообщал об этом строкой в логе прогона, которую никто не читает. Молчаливое
«почти правильно» хуже явной поломки.

Прогнано в обе стороны: с переменной — тема из окружения, без неё — 245.
backend/tests/ops — 86 passed.
2026-08-27 22:28:52 +03:00
bot-backend
b90872b5d7 feat(ops/metrics): инфра-алерты уезжают в тему «метрики», клиенты остаются в «алертах»
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m57s
CI / backend-tests (pull_request) Successful in 17m26s
Форумная группа имеет три темы, но тема «метрики» была пуста: оба прямых
получателя Alertmanager — telegram и telegram-heartbeat — брали топик из той
же переменной METRICS_TELEGRAM_TOPIC_ID, что и сервис alert-ack. Развести их
было нечем, и heartbeat вместе со всем инфраструктурным шумом падал в ленту
клиентских инцидентов.

Смешанные в одной теме, инфраструктура и клиентский инцидент не равны по
срочности и приучают пролистывать обе.

Вводится METRICS_TELEGRAM_INFRA_TOPIC_ID для прямых получателей Alertmanager.
alert-ack и вебхук GlitchTip остаются на прежней переменной, тема поддержки
не тронута. Пока новая переменная не задана, берётся старая — до этого момента
поведение ровно прежнее, а не сломанное.

Клиентская METRICS_TELEGRAM_TOPIC_LINE убрана целиком: после переезда обоих
получателей на инфраструктурную строку шаблон её не содержит, а деплой
продолжал бы её собирать и объявлять в envsubst. Тест, закрепляющий сборку
такой строки, зеленел бы вечно и мешал бы её убрать.

Проверено рендером, а не чтением: при заданной теме telegram и
telegram-heartbeat дают 245, telegram-clients уходит вебхуком без темы; при
незаданной — поля message_thread_id нет вовсе (пустое значение уронило бы
Alertmanager целиком). Логика отката прогнана во всех трёх состояниях
переменных. backend/tests/ops — 86 passed.

Closes #3163
2026-08-27 21:50:51 +03:00
bot-backend
de940c7534 feat(ops): off-box копия рантайм-конфига — зашифрованной, иначе никак
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Четвёртый пункт приёмки #2203. В дампах есть колонки под pgp_sym_encrypt, ключ
к ним лежит на самой машине и никуда не уезжает: потеря машины означает «дампы
есть, расшифровать нечем». Ради этой связки шифрование в базе и заводилось.

КУДА И ПОЧЕМУ ИМЕННО ТУДА. В тот же S3, отдельным префиксом env/, и только
зашифрованным. Разобранные варианты:

  - рядом с дампами открытым — нельзя: ключ рядом с шифротекстом обнуляет
    шифрование, одна утечка доступа к бакету отдаёт и то и другое;
  - на соседний хост по SSH — потребовало бы завести доверие между машинами,
    которого нет (проверено: Permission denied (publickey)), то есть РАСШИРИТЬ
    периметр ровно тогда, когда #3075 его сужает;
  - отдельный бакет с отдельными ключами — правильнее всего, но требует новых
    учётных данных.

Выбран единственный исполнимый без расширения доступа: шифротекст в S3,
парольная фраза — вне S3.

FAIL-CLOSED. Без фразы скрипт не выгружает файл открытым, а падает с явным
сообщением и алертом. Молчаливая выгрузка ключа в бакет с дампами хуже
отсутствия копии: создаёт ложное чувство защищённости.

Фраза уходит через дескриптор, а не аргументом — иначе видна в ps любому
пользователю машины. После шифрования файл проверяется обратной расшифровкой:
без этого можно годами возить нечитаемый мусор и узнать в тот момент, когда он
понадобился.

Прогон на хосте 27.08 (подставной исходник, боевой не трогался):
  без фразы → код 1, файлов создано 0
  с фразой  → «Зашифровано и проверено расшифровкой», 110 байт
  своей фразой читается, чужой — нет

ОДИН ШАГ ЗА ВЛАДЕЛЬЦЕМ, и он неустраним: фраза обязана жить там, где переживёт
смерть машины, иначе копия бесполезна — расшифровать будет нечем. Сгенерировать
её здесь и оставить на хосте нельзя по построению. Инструкция — в шапке скрипта.

Пять тестов сторожат ровно те свойства, ради которых всё сделано.
Прогон: 85 ops-тестов зелёные, ruff чист.

Refs #2203
2026-08-27 16:32:27 +03:00
bot-backend
b746134976 fix(ops/metrics): postgres-exporter печатал пароль БД в лог при каждой ошибке
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Successful in 17m34s
Версия v0.16.0 при КАЖДОЙ неудаче сбора печатала полный DSN вместе с паролем:

    msg="error scraping dsn" err="queryNamespaceMappings returned 1 errors"
    dsn="postgresql://<роль>:<ПАРОЛЬ>@<хост>:5432/<база>?sslmode=disable"

Строки уходят в Loki — около 210 в сутки с двух хостов, при ретенции 30 дней
это тысячи паролей в хранилище логов (#3114).

Проверял опытом, а не документацией. Стенд на скретч-контейнерах: чистый
постгрес, роль без прав, тот же queries.yml что на проде — то есть ровно та
ошибка, что случалась в бою.

    v0.16.0 → строк с паролем: 1
    v0.18.0 → строк с паролем: 0

ОБХОДНОЙ ПУТЬ ИЗ ISSUE НЕ РАБОТАЕТ, и это важнее самой правки. Предлагалось
передавать параметры через DATA_SOURCE_URI + DATA_SOURCE_USER + DATA_SOURCE_PASS
вместо единой строки — «тогда в лог попадать нечему». Проверил на том же
стенде: экспортер собирает DSN внутри и печатает его целиком точно так же,
1 строка с паролем. Реализация этого варианта была бы работой вхолостую при
полном ощущении, что дыра закрыта.

Паритет метрик проверен там же: все пять пользовательских запросов из
queries.yml отдаются обеими версиями одинаково (PG_EXPORTER_EXTEND_QUERY_PATH
в v0.18 работает), v0.18 добавляет три встроенные метрики и не теряет ни одной.

Два теста сторожат нижнюю границу версии на всех трёх экспортерах.
Прогон: 80 ops-тестов зелёные, ruff чист.

Refs #3114
2026-08-27 15:55:24 +03:00
bot-backend
5ea05cffa6 fix(ops/metrics): правки конфига Alertmanager молча не доезжали до контейнера
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m0s
CI / backend-tests (pull_request) Successful in 17m28s
Пойман на проде 27.08 сразу после мержа #3136. На диске лежал новый конфиг —
`webhook_configs` на сервис кнопки подтверждения, — `amtool check-config` его
одобрил, деплой зелёный. А контейнер продолжал слать алерты напрямую:

    на диске:     webhook_configs: url http://alert-ack:8080/alertmanager
    в контейнере: telegram_configs:

Конфиг подключён бинд-маунтом ФАЙЛА, а рендер делает `rm` и создаёт файл
заново — иначе не перезаписать: после chown он принадлежит 65534 с правами
600, а каталог принадлежит деплой-пользователю. `rm` + создание даёт НОВЫЙ
инод, тогда как открытый дескриптор внутри работающего контейнера продолжает
смотреть на прежний, уже удалённый. `up -d` контейнер не трогает: он
сравнивает описание сервиса, а содержимое бинд-маунта в сравнение не входит.

Отказ беззвучный — ни одного красного признака нигде. Значит и все прежние
правки маршрутизации применялись лишь тогда, когда контейнер пересоздавался
по совпадению.

Перезагрузка по SIGHUP/API не лечит: она перечитывает тот же открытый инод.
Лечит только пересоздание контейнера — его и добавляю, под флагом, который
выставляется ПОСЛЕ успешной проверки конфига. Порядок важен: при обратном
битый конфиг убивал бы работающий Alertmanager вместо того, чтобы оставить
прежний работать.

Прод уже приведён в соответствие вручную — контейнер пересоздан, маршрут
клиентских инцидентов теперь идёт через кнопку. Эта правка нужна, чтобы
следующая правка конфига доехала сама.

Три теста: пересоздание есть, оно закрыто проверкой флага (безусловное рвало
бы доставку на каждом деплое метрик), флаг выставляется после проверки.
Прогон: 78 ops-тестов зелёные, ruff чист.
2026-08-27 15:10:23 +03:00
bot-backend
4e8daff675 fix(tests): убрать директивы noqa на неактивное правило
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m8s
CI / backend-tests (pull_request) Successful in 17m35s
`SLF001` (обращение к приватному члену) в конфиге ruff не включён, поэтому
`# noqa: SLF001` — подавление того, что и так не проверяется. Ruff ловит это
правилом RUF100 и валит проверку.

Тест намеренно лезет в приватные `_tg`, `_PENDING`, `_send_alert`: подмена
единственного шва до сети — и есть смысл этих тестов. Пояснение, которое
стояло после директивы, сохранено обычным комментарием.
2026-08-27 14:32:34 +03:00
bot-backend
053a5fb75c feat(observability): кнопка «Принял в работу» под клиентским инцидентом (#3078)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Failing after 1m12s
CI / openapi-codegen-check (pull_request) Successful in 1m55s
Alertmanager инлайн-клавиатуру не поддерживает, а без кнопки нет обратной
связи «человек увидел и взял в работу»: 27.08 продукты лежали 10 часов, и
вопрос «а кто-нибудь это читает» было не к кому адресовать.

ГДЕ ЖИВЁТ. Рядом с Alertmanager, на инфраструктурной машине. У бота МЕРЫ
уже есть приём обновлений, и повесить обработку туда было бы дешевле, но
он работает на продуктовом хосте: при падении продукта кнопка оказалась бы
мёртвой ровно тогда, когда нужна.

ССЫЛКА, А НЕ CALLBACK. Callback требует читателя обновлений бота. Бот один,
и его обновления уже читает МЕРА — второй читатель получил бы 409 Conflict
и отобрал бы сообщения у поддержки.

БЕЗ ПАРОЛЯ НА /ack/*, ОСОЗНАННО. Кнопку жмут ночью с телефона, когда лежит
прод; требование пароля даст ноль нажатий. Защита — 128-битный токен под
конкретное сообщение, живущий сутки; максимум, чего добьётся угадавший, —
ложная отметка в чате, где сразу видно, что её поставил не человек.

ТОЛЬКО КЛИЕНТСКИЙ МАРШРУТ идёт через сервис. Прочие алерты сохраняют прямой
путь в Telegram: чем меньше звеньев, тем надёжнее. Если сервис лёг,
Alertmanager повторяет доставку и переуведомляет каждые 30 минут — алерт
задерживается, но не теряется. Дублировать вторым прямым каналом не стали:
шум в канале тревог опаснее задержки.

Девять тестов дёргают настоящие функции, подменяя один шов — вызов Bot API.
Важнейший: при отказе отправки с клавиатурой сообщение уходит БЕЗ неё —
алерт важнее кнопки.
2026-08-27 13:56:00 +03:00
0f09c47418 Merge pull request 'fix(ops): оповещения уходят в тему «алерты», а не в переговорку (#2203)' (#3129) from fix/2203-notify-topic into main
All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 9s
Deploy / changes (push) Successful in 14s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 44s
Deploy / build-worker (push) Successful in 39s
Deploy / deploy (push) Successful in 1m8s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 14s
2026-08-27 10:50:55 +00:00
bot-backend
6446a8cdf5 fix(tests): ruff E741 — однобуквенное имя переменной в разборе скрипта
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 17m21s
2026-08-27 13:16:26 +03:00
bot-backend
8945ea5d04 fix(ops): оповещения уходят в тему «алерты», а не в переговорку (#2203)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Failing after 1m6s
CI / openapi-codegen-check (pull_request) Successful in 1m55s
Канал включили — и алерты бэкапов посыпались в ОБЩУЮ тему форума. В форуме
Telegram адрес сообщения это пара «чат + тема»: без message_thread_id всё
попадает в General, причём без единой ошибки. sendMessage возвращает 200,
доставка «успешна», просто не туда.

Отказ того же класса, что и всё остальное сегодня: зелено везде, а человек,
которому адресован алерт, его не видит.

Оба отправителя (ops/lib-backup.sh и ops/uptime-healthcheck.sh) получили
условную подстановку ${TELEGRAM_TOPIC_ID:+-d "message_thread_id=..."}.
Условная намеренно: пустой message_thread_id= Telegram отвергает вместе со
всем сообщением, а молчащий алерт хуже алерта не в той теме. Нет переменной —
нет параметра, поведение прежнее бит в бит.

Тест ИСПОЛНЯЕТ настоящий notify(), извлечённый из файла построчно, подсовывая
подставной curl и проверяя, что реально ушло бы в сеть. Проверять подстроку в
файле бессмысленно: она может стоять в мёртвой ветке. Копировать функцию в
тест — тоже: копия разойдётся с оригиналом на первой правке. Сорсить файл
целиком нельзя: у uptime-healthcheck.sh нет guard'а по BASH_SOURCE, и сорсинг
запустил бы настоящие сетевые проверки.
2026-08-27 13:12:10 +03:00
bot-backend
6f120c6605 feat(observability): клиентский инцидент зовёт дежурного поимённо + чинит сломанное продолжение команды (#3078)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 17m26s
Две вещи, вторая — исправление собственной ошибки из #3127.

## Дежурного зовут по имени

27.08 продукты лежали 10 часов, и в канале «диск занят на 86 %» и «клиенты
не могут открыть сайт» выглядели одинаково. Появился отдельный маршрут:
severity=critical И host=apps, то есть критично на ПРОДУКТОВОЙ машине —
значит людям недоступна МЕРА и Site Finder, а не «где-то в инфраструктуре
тесно».

У такого сообщения другой текст (🚨 КЛИЕНТЫ ЗАТРОНУТЫ), упоминание дежурного
и repeat_interval 30 минут против 3 часов у прочего критичного: пока
инцидент не погашен, напоминание должно быть неудобным.

Сужение по host=apps существенно. Без него дежурного звали бы на каждую
инфраструктурную мелочь, и тег перестал бы что-либо значить за неделю.

Сам аккаунт в репозиторий не попадает — берётся из METRICS_TELEGRAM_ONCALL.
Дежурный меняется, конфиг в git — нет. Пустая переменная = сообщение без
тега, поведение не ломается.

## Починка: комментарий внутри продолжения команды

В #3127 блок rm -f вместе с комментарием встал МЕЖДУ строками, каждая из
которых заканчивалась обратным слешем. Строки склеиваются, и весь вызов
envsubst уехал в комментарий — конфиг Alertmanager перестал бы рендериться
вовсе, при полностью зелёном деплое.

Синтаксически это корректный шелл, bash -n такое не ловит, а в диффе не
видно: строки выглядят как отдельные. Поэтому проверка структурная и
применяется ко ВСЕМ shell-блокам workflow, а не только к месту ожога.
2026-08-27 13:05:32 +03:00
bot-backend
de56b8ae01 feat(ops): сторож расхождения «прод ↔ main» — молчаливый недоехавший деплой становится видимым (#3029)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m8s
CI / backend-tests (pull_request) Successful in 17m30s
27.08 прод сутки жил на позавчерашнем коммите, и это нашлось только руками.
Замена IP сервера (#3110) осиротила секрет DEPLOY_HOST: deploy.yml падал на
`dial tcp ***:***: i/o timeout`, при этом CI оставался зелёным, PR
продолжали мержиться, а deploy-infra.yml исправно обновлял Beget и создавал
впечатление, что всё в порядке.

Проверок, которые спрашивают не «прошёл ли прогон», а «доехал ли код», не
было ни одной. Этот сторож — ровно такая: ежечасно читает HEAD с прод-хоста
и сверяет с tip main.

Три исхода вместо двух. «Хост не ответил» (2) отделён от «на хосте не тот
код» (1) — это разные аварии с разной первой командой в разборе, и сегодня
погорели именно на их смешении: недоступность выглядела как обычный красный
прогон. Льготный период 30 минут гасит ложную тревогу на деплое, который
ещё в полёте: ежечасный сторож неизбежно попадёт в окно между мержем и
концом выката, а сторож, которого научились игнорировать, хуже отсутствующего.

Логика вынесена в scripts/check-deploy-drift.sh и не ходит по сети — SSH
живёт в workflow, где секреты. Благодаря этому тест ИСПОЛНЯЕТ настоящий
скрипт, а не пересказывает его: ошибка в самом bash видна только при запуске.

Read-only: один ssh и git rev-parse, ничего не деплоит.
2026-08-27 12:35:43 +03:00
bot-backend
c093eaafe5 fix(observability): пароли не уезжают в Loki — скруббер учётных данных (#3114)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (pull_request) Successful in 17m33s
postgres_exporter при неудачном скрейпе печатает полный DSN вместе с паролем.
Замер по Loki за сутки: 104 такие строки на инфра-хосте и 106 на продуктовом.
При ретенции 30 дней это порядка 6000 строк с паролями БД в хранилище, доступ
к которому даёт вход в Grafana - причём внешний basic_auth с витрины сегодня
же снят (#3113).

Проверено экспериментом, а не предположено. Напрашивалось передать пароль
отдельно от строки подключения (DATA_SOURCE_URI + DATA_SOURCE_USER +
DATA_SOURCE_PASS_FILE). Прогон на скретч-контейнерах с настоящим файлом
запросов показал: НЕ помогает - экспортер собирает строку сам и логирует её
целиком. Первые три попытки воспроизведения были неинформативны (контейнер
падал сразу; скрейпа не было; не подключён файл кастомных запросов) - утечка
воспроизводится только при неудачном скрейпе с нашим queries.yml.

Стало: ступень loki.process между источником журнала и loki.write, маскирует
пароль в любом URL вида scheme://user:pass@host. Пользователь и адрес
остаются - без них строка ошибки перестаёт годиться для диагностики. Ровно
одна группа захвата: Alloy заменяет содержимое групп, вторая затёрла бы имя
пользователя.

Это защита в глубину, а не замена причине. Конкретно эта ошибка уходит грантом
pg_monitor (запрос pg_wal_bytes в нашем queries.yml требует pg_ls_waldir) -
это боевая БД и остаётся за владельцем. Скруббер же ловит любой пароль в URL,
включая компоненты, о которых мы ещё не знаем.

Регулярка без экранирования намеренно: Alloy не принимает \s в строке
(unknown escape sequence), поэтому класс задан явным пробелом. Оба конфига
прогнаны через `alloy fmt` образом grafana/alloy:v1.6.1 - синтаксис ok.

Тесты (8) берут выражение ИЗ КОНФИГА и применяют к настоящей строке из прода:
пароль исчезает; пользователь и адрес остаются; обычные URL и почтовые адреса
не портятся; журнал направлен в скруббер, а не мимо него. Фальсификация: на
исходных конфигах краснеют все 8. tests/ops целиком - 43 passed.
2026-08-26 15:50:57 +03:00
bot-backend
5b7ef161e3 feat(observability): алерты адресуются в топик форумной группы (#3078)
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m2s
CI / backend-tests (pull_request) Successful in 17m24s
Бот, которым шлются тревоги, — тот же, что пересылает сообщения поддержки, а
его чат форумный. Без message_thread_id Alertmanager кладёт тревоги в общую
тему, вперемешку с клиентской перепиской.

Поле поддерживается: проверено amtool check-config на том же образе, что
поднимается в проде (prom/alertmanager:v0.28.0). Схема Alertmanager строгая и
неизвестные поля отвергает, так что успешная проверка означает именно
поддержку, а не молчаливое игнорирование.

Подставляется ЦЕЛАЯ СТРОКА, а не значение: envsubst не умеет условий, и при
шаблоне вида `message_thread_id: ${TOPIC_ID}` незаданный топик дал бы
`message_thread_id:` без значения. Это не деградация - Alertmanager с таким
конфигом не стартует вовсе, то есть алертинг исчезает целиком. Деплой
формирует либо всю строку с отступом, либо пустую.

Топик необязателен: без него поле отсутствует, алерты уходят в общую тему,
поведение прежнее.

Попутно добавлена проверка конфига через amtool ДО подъёма стека - по образцу
`caddy validate` ниже в этом же файле. amtool берётся из того же образа, что и
сам Alertmanager, иначе проверялась бы не та версия схемы. Битый конфиг теперь
роняет деплой громко, а не выключает алертинг тихо.

Тесты (4) рендерят шаблон обоими способами и разбирают результат как YAML -
проверяется фактический конфиг, а не наличие нужных слов в тексте. Отдельно
проверено, что переменная объявлена в списке envsubst: забыть её - значит
оставить в конфиге литерал плейсхолдера.

Фальсификация: на исходных файлах краснеют 3 из 4; проходит только тест,
фиксирующий сохранённое поведение при незаданном топике. tests/ops целиком -
35 passed.
2026-08-26 14:10:56 +03:00
bot-backend
beafe6925b fix(observability): агент не падает из-за переменной чужой роли (#3078)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m55s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Successful in 17m25s
Джоба agent-apps упала целиком:

    error while interpolating services.postgres-exporter-infra.environment.
    DATA_SOURCE_NAME: required variable INFRA_EXPORTER_DSN is missing a value

INFRA_EXPORTER_DSN нужен экспортеру с profiles: ["infra"], который на
продуктовом хосте не поднимается вовсе. Но compose интерполирует ВЕСЬ файл
до фильтрации по профилям, поэтому `${VAR:?}` роняет команду из-за чужой
переменной. Вместе с агентом не поднялись alloy, node-exporter и cadvisor,
которым никакой DSN не нужен. Симметрично упал бы и инфраструктурный агент -
на двух продуктовых переменных.

Второй дефект в той же цепочке: GENDESIGN_EXPORTER_DSN тоже отсутствовал.
setup-metrics-exporter-dsn.sh читает только runtime-файл окружения бэкенда, а
DATABASE_URL и TRADEIN_DATABASE_URL живут в основном. Значит подстановка
всегда была пустой, add_key печатал "нечем заполнить" и выходил с кодом 0 -
мягкий пропуск встречался с жёстким требованием compose.

Стало:
- compose: `:-` вместо `:?` у трёх DSN. Интерполяция больше не может упасть.
- deploy-metrics.yml: профиль экспортеров включается, только если нужные ЭТОЙ
  роли DSN заполнены; иначе ::warning и агент поднимается без экспортера.
  Громкость не убрана, а перенесена туда, где роль известна. Тот же приём, что
  уже применён к Alertmanager в джобе server.
- setup-metrics-exporter-dsn.sh: читает оба файла окружения (базовый, затем
  runtime - он перекрывает). Пишет по-прежнему только в runtime, лишних копий
  пароля не заводит.

Почему `:-` не ослабление: пустой DATA_SOURCE_NAME поднял бы экспортер,
который молча не отдаёт метрик, - ровно тот тихий отказ, ради которого весь
стек и заводится. Поэтому пустой DSN теперь означает "профиль не включаем",
а не "поднимаем пустым".

Известное следствие, отмеченное в коде: INFRA_EXPORTER_DSN не собирает никто -
скрипт знает только про GENDESIGN_/TRADEIN_ и работает на продуктовом хосте.
Пока это так, инфраструктурный агент будет честно предупреждать, что метрик
Postgres инфры нет, вместо того чтобы падать целиком.

Тесты (3) структурные, проверяют оба конца инварианта: обязательности не
вернулись в compose; каждая DSN-переменная проверяется в деплое; профили не
захардкожены. Фальсификация: на исходных файлах краснеют все три.
2026-08-26 12:45:31 +03:00
bot-backend
33bc7e4acf fix(observability): привилегированную роль спрашиваем у контейнера, а не угадываем (#3078)
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m2s
CI / backend-tests (pull_request) Successful in 17m21s
Стек наблюдаемости не поднялся ни на одном хосте после мержа #3099: джоба
server упала, agent-apps и agent-infra пропустились как зависимые.

Причина (задача 23657, 26.08 08:55):

    err:   ОШИБКА: не нашёл роль с правом CREATE ROLE в gendesign-infra-postgres

setup-metrics-grafana-role.sh искал привилегированную роль перебором трёх
имён - glitchtip, forgejo, postgres. Ни одно не совпадает ни с одним реальным
кластером проекта: infra-postgres -> infra, gendesign-postgres-1 -> gendesign,
tradein-postgres -> tradein. Комментарий над перебором сам предупреждал, что
"угадывать postgres неверно", и дальше шло угадывание.

Замер на живом контейнере 26.08 (read-only, ничего не создавалось):

    POSTGRES_USER изнутри контейнера: infra
    glitchtip - отказ, forgejo - отказ, postgres - отказ, infra - 1

Стало: имя берём из POSTGRES_USER самого контейнера - это та переменная,
которой роль и создана при initdb, то есть источник истины. Прежний список
оставлен ПОСЛЕ него запасным путём для кластера не из образа postgres.

Попутно - глоб в paths деплоя. Воркфлоу запускает ТРИ setup-скрипта, а в
триггере стоял только setup-metrics-secrets.sh: правка двух остальных не
заводила выкат, и на хосте молча оставалась старая версия. Тот же класс, что
#2203 закрыл глобом ops/*.sh. Добавлен тест, который сверяет запускаемые
скрипты с шаблонами paths - на исходном воркфлоу он краснеет, указывая на
setup-metrics-exporter-dsn.sh.

Тесты (6) исполняют РЕАЛЬНЫЙ скрипт с подставным docker и проверяют
фактический выбор роли, а не наличие правильных слов в комментарии.
Фальсификация: на исходном коде краснеют 3 из 5 ролевых тестов; проходят
только те два, что фиксируют сохранённое поведение (запасной перебор и
громкая ошибка при отсутствии привилегий). tests/ops целиком - 28 passed.
2026-08-26 12:08:37 +03:00
bot-backend
124cfb3d5d feat(observability): /metrics в обоих бэкендах — счётчики, задержка, дашборд
All checks were successful
CI / backend-tests (pull_request) Successful in 17m30s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m22s
CI Trade-In / backend-tests (pull_request) Successful in 4m51s
Третья часть #3078 и единственная, трогающая прод-код.

До неё числовых рядов у приложений не было вовсе: только логи и исключения в
GlitchTip. Класс отказов «отвечает, но медленно» и «отдаёт 401 потоком» в такой
картине невидим — исключения нет, строка в логе выглядит обычной, а продукт
при этом не работает.

Метка route — ШАБЛОН маршрута, а не путь запроса. Это несущее решение, а не
деталь: кадастровый номер или идентификатор заявки в метке даёт новый временной
ряд на каждую сущность, а ряд у Prometheus стоит памяти постоянно, а не в момент
запроса. Самый известный способ уронить мониторинг тем самым мониторингом.
Незаматченные пути (404, сканеры) сведены в одну метку, иначе тот же взрыв
устроит любой бот, перебирающий адреса. Оба свойства сторожатся тестами, а не
комментарием: тест бьёт тремя разными идентификаторами и требует ОДИН ряд.

Слой регистрируется последним и потому оказывается самым внешним. Изнутри
RBAC-гварда не видно ни отказов авторизации, ни времени, которое он тратит на
резолв сессии в БД auth, — а именно этот путь уже давал инцидент с блокирующим
I/O в middleware (#1202). Упавший исключением запрос считается как 500 в
finally: без этого он просто отсутствовал бы в счётчике, то есть ровно тогда,
когда метрики нужнее всего.

Путь публичен ВНУТРИ и закрыт СНАРУЖИ — это два разных периметра. Скрейп идёт
из docker-сети, где заголовка X-Authenticated-User нет ни у кого, поэтому
/metrics внесён в _PUBLIC_PATHS обоих бэкендов; иначе агент получал бы 401 и
метрик не было бы вовсе. Наружу путь не открывается ни через gendsgn.ru, ни
через meraocenka.ru, и вдобавок закрыт явным respond 404 в обоих site-блоках —
чтобы закрытость осталась решением, а не следствием текущего порядка директив.

Ограничитель частоты и аудит «Меры» не трогались: оба смотрят только на пути
под /api/, скрейп под них не попадает. Проверено тестом, а не чтением.

Прод-поведение не меняется ничем, кроме нового публичного пути: ни один
существующий обработчик, гвард или маршрут не тронут.

Refs #3078
2026-08-26 11:30:18 +03:00
bot-backend
c7df2732bd fix(ops): алерт не теряется от одного сетевого отказа (#3059)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m48s
CI / backend-tests (pull_request) Successful in 22m26s
Путь Selectel-Telegram теряет соединения. Замер 26.08 с Poincare, 40
подключений к закреплённому (#3093) 149.154.167.220:

    успешно 37 из 40, отказов 3 (7.5%) - все TimeoutError
    время успешных: min 0.14s, медиана 0.15s, max 0.17s

Отказы происходят на стадии ПОДКЛЮЧЕНИЯ - быстрые стабильные успехи на фоне
редких таймаутов. Три остальных дата-центра Telegram с Selectel недостижимы
вовсе, так что закрепление адреса потери убрать не может: запасного адреса
нет. Бот это переживает своими ретраями (106 таймаутов за сутки, 97 лечатся
первой же повторной попыткой), а вот алерты - нет.

uptime-healthcheck.sh: был один curl и `|| log WARN` - каждый отказ терял
уведомление целиком. Сторож, который не может дозваться, - худший вид
самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс, что о них
не сообщат. Ирония в том, что ниже в этом же файле HTTP-проверки уже
повторяются циклом: ретраили то, что измеряют, но не то, чем докладывают.

lib-backup.sh: тоже один curl, но с падением в почту (#3070). Алерт не
терялся, зато каждый транзиентный таймаут впустую сжигал последнее средство
вместо простого переподключения.

Стало: цикл из трёх попыток в обеих notify(). Не `curl --retry` - семантика
--max-time при ретраях зависит от версии curl, а цикл даёт таймаут на КАЖДУЮ
попытку и повторяет идиому, уже принятую в uptime-healthcheck.sh.

Дубль вместо потери - осознанный размен: sendMessage не идемпотентен, но
отказ случается ДО отправки запроса, так что повтор почти никогда не
продублирует доставленное. Лишний алерт безвреден, пропущенный - нет.

Тесты (backend/tests/ops/test_3059_alert_retry.py, 7 шт) исполняют РЕАЛЬНЫЕ
notify(), извлечённые из обоих скриптов, с подставным curl, отказывающим
заданное число раз, и считают фактическое число попыток.

Фальсификация: на исходных скриптах краснеют 6 из 7. Проходит только
test_backup_falls_back_to_mail_when_telegram_is_really_down - он фиксирует
сохранённое поведение, а не регресс.

Проверено: `bash -n` обоих скриптов (та же проверка, что в CI - shellcheck
там нет); конструкция `[[ ]] && cmd` в конце тела цикла безопасна под
`set -euo pipefail`, который стоит в uptime-healthcheck.sh:25 (проверено
исполнением, не рассуждением); tests/ops целиком - 22 passed.
2026-08-26 10:02:46 +03:00
f2945b7157 fix(ci): деплой падает громко, если :latest отстаёт от головы по компоненту (#2950) (#3023)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 15s
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 57s
Deploy / build-backend (push) Successful in 1m2s
Deploy / build-frontend (push) Successful in 1m8s
Deploy / build-worker (push) Successful in 1m9s
Deploy / deploy (push) Successful in 1m58s
Deploy / deploy-status (push) Successful in 3s
Deploy Trade-In / build-frontend (push) Successful in 3m14s
Deploy / perimeter-smoke (push) Successful in 14s
Deploy Trade-In / test (push) Successful in 4m12s
Deploy Trade-In / build-backend (push) Successful in 33s
Deploy Trade-In / deploy (push) Successful in 2m16s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 9s
2026-08-21 12:19:07 +00:00