Commit graph

22 commits

Author SHA1 Message Date
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
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
cdf493f345 chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-08-21 12:01:52 +00:00
bot-backend
70f3c0a88a fix(ops): проверка трейлера дампа падала на grep — ведущие -- принимались за опции (#2203)
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 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 / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 17m7s
verify_dump_integrity() в ops/backup.sh и tradein-mvp/deploy/backup-tradein-db.sh
делала `grep -qF "$trailer"`, где $trailer = "-- PostgreSQL database dump
complete". Ведущие -- в аргументе grep трактует как конец опций/флаг, без
разделителя команда падает: `grep: unrecognized option '-- PostgreSQL...'`.

Проверка из-за этого ВСЕГДА возвращала "трейлера нет" — не потому что дамп
оборван, а потому что сама проверка не могла выполниться. Вызывающий код
удалял только что созданный валидный дамп и завершался с ошибкой; ретеншен
не успевал отработать (ранний exit) — свежие бэкапы не создавались никогда,
старые копии оставались молча.

Воспроизведено вручную на проде: bash
/opt/gendesign/tradein-mvp/deploy/backup-tradein-db.sh удалил свежий дамп с
сообщением "дамп оборван?".

Фикс: `grep -qF -- "$trailer"` — `--` явно завершает список опций grep.

Регрессионный тест (backend/tests/ops/test_2203_backup_trailer_grep_dashdash.py)
исполняет РЕАЛЬНУЮ verify_dump_integrity() из обоих скриптов на настоящем
gzip-потоке через gunzip|tail|grep — не читает исходник текстом. Проверено
локально: падает на добаговой версии с тем же "unrecognized option", зелен
на исправленной.
2026-08-20 23:38:45 +03:00
68d041022d fix(ci): ожидание докер-лока оставляет след в логе (#2950) (#2958)
Some checks are pending
Deploy / changes (push) Waiting to run
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 Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Successful in 50s
Deploy Trade-In / build-frontend (push) Successful in 2m58s
Deploy Trade-In / test (push) Successful in 3m53s
Deploy Trade-In / build-backend (push) Successful in 32s
Deploy Trade-In / deploy (push) Successful in 1m53s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 8s
2026-08-20 08:44:10 +00:00
feff8214f7 fix(ci): докер-секции прод-деплоев исключают друг друга через host-lock (#2950) (#2955)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Successful in 48s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 47s
Deploy / build-frontend (push) Successful in 49s
Deploy / build-worker (push) Successful in 49s
Deploy / deploy (push) Successful in 1m26s
Deploy / deploy-status (push) Successful in 3s
Deploy / perimeter-smoke (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Successful in 2m59s
Deploy Trade-In / test (push) Successful in 3m52s
Deploy Trade-In / build-backend (push) Successful in 37s
Deploy Trade-In / deploy (push) Successful in 2m22s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
2026-08-20 08:13:56 +00:00
2a01dea103 fix(ci): прод-деплои в одну группу concurrency — прун одного убивал pull другого (#2950) (#2952)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy Trade-In / changes (push) Successful in 10s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-worker (push) Successful in 51s
Deploy / build-backend (push) Successful in 52s
Deploy / build-frontend (push) Successful in 51s
Deploy Trade-In / build-browser (push) Successful in 32s
Deploy / deploy (push) Successful in 1m21s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Successful in 3m30s
Deploy Trade-In / test (push) Successful in 4m15s
Deploy Trade-In / build-backend (push) Successful in 31s
Deploy Trade-In / deploy (push) Successful in 2m6s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-08-20 07:47:48 +00:00