77 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
635ad2df35 |
chore(forgejo): track compose file with retention/cleanup settings
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 12s
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 2m38s
CI / backend-tests (pull_request) Successful in 6m32s
app.ini on prod has no [cron.archive_cleanup] section and no retention keys under [actions] — everything ran on Forgejo defaults (archive cache swept once/24h, no cap on Actions log/artifact age). An external crawler hitting per-commit archive URLs grew repo-archive cache to ~48GB/145GB before the daily sweep caught up; Caddy now blocks that path (#3534), but this is the second line of defense if that rule is ever lifted. Forgejo isn't part of the automated deploy pipeline and its compose file only existed on the host (not a git checkout), so there was nowhere for this config to survive a redeploy or even be reviewed. Adds a tracked copy at ops/forgejo/docker-compose.yml, alongside the existing ops/backup.sh and ops/restore.sh host scripts, with: - [cron.archive_cleanup]: ENABLED/RUN_AT_START=true, SCHEDULE=@every 1h, OLDER_THAN=1h — hourly sweep instead of daily, 1h grace window. - [actions] LOG_RETENTION_DAYS=30, ARTIFACT_RETENTION_DAYS=14 — checked against .forgejo/workflows and .github/workflows first: no workflow uploads/downloads artifacts today, so nothing to break. Key names verified against the running image (Forgejo 10.0.3) by extracting Go struct ini-tags from the binary and dry-running environment-to-ini in a container scratch dir — not from memory, since a wrong key is silently dropped. Not applied to prod. Needs manual scp + `up -d --force-recreate forgejo` per the file's header comment — see PR description. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG |
||
| 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> |
|||
| 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 (
|
|||
| bbf70a3283 |
Merge pull request 'Продуктовые счётчики и дашборд воронки' (#3491) from feat/3471-product-metrics-dashboard into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 15s
Deploy / deploy-caddy (push) Has been skipped
Deploy Metrics / server (push) Successful in 17s
Deploy / build-frontend (push) Has been skipped
Deploy Metrics / agent-apps (push) Failing after 18s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (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
|
|||
| ede6fd5974 |
Merge pull request 'Продуктовый Telegram-трафик уходит через ретранслятор на Beget' (#3487) from feat/3471-telegram-relay-beget into main
Some checks failed
Deploy Trade-In / test (push) Successful in 6m53s
Deploy Trade-In / build-backend (push) Has been cancelled
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 Infra Host / sync-infra-host (push) Successful in 7s
Deploy / changes (push) Successful in 13s
Deploy Trade-In / changes (push) Successful in 18s
Deploy Metrics / server (push) Successful in 25s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-worker (push) Successful in 45s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-frontend (push) Successful in 49s
Deploy / build-backend (push) Successful in 51s
Deploy Metrics / agent-apps (push) Failing after 15s
Deploy Metrics / agent-infra (push) Successful in 25s
Deploy / deploy (push) Successful in 1m13s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 1m46s
|
|||
| d74121d74c | Merge pull request 'Отказ в alert-ack перестаёт съедать следующий алерт' (#3490) from fix/3471-alert-ack-drain-body into main | |||
| 694bf13d3d | Merge pull request 'Очередь задач и Redis становятся видимыми' (#3489) from feat/3471-celery-redis-metrics into main | |||
|
|
4d3e273405 |
fix(ops): alert-ack вычитывает тело запроса до любой ветки отказа
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 / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Соединение переиспользуется (protocol_version = HTTP/1.1), и Caddy перед
сервисом держит пул к апстриму. Ответ 401/404/503 без чтения тела оставлял
его в сокете, и следующий запрос по тому же соединению начинался с чужих
байт.
Поймано на проде: зонд без секрета получил 401, а следующий запрос — уже с
верным секретом — вернул 501 Unsupported method ('{"text":"probe"}POST').
То есть один отказ съедал следующий НАСТОЯЩИЙ алерт, ровно в том канале,
который заводился как резервный.
Тело теперь читается один раз в начале do_POST и передаётся вниз. Четыре
теста поднимают настоящий сокет и шлют пару запросов по одному соединению —
на прежнем коде три из них падают с той же строкой 501.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
|
||
|
|
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
|
||
|
|
9a93e575cc | Merge remote-tracking branch 'forgejo/main' into feat/3471-telegram-relay-beget | ||
|
|
62a560387c |
feat(ops): измеряем очередь Celery и Redis, до сих пор слепая зона
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / 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 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
Prometheus не видел ни одной серии celery_*/redis_* — переполнение очереди Site Finder и залипший воркер снаружи выглядели одинаково, тишиной (issue #3471). Добавлено (только на продуктовом хосте, профиль apps): - redis-exporter (oliver006/redis_exporter) — здоровье общего Redis (db0 celery-брокер Site Finder, db1 SearchCache trade-in, db2 glitchtip), адрес через alias gendesign-redis на сети shared, без нового сетевого доступа. - celery-exporter (danihodovic/celery-exporter) — глубина очереди, число живых воркеров, счётчик неуспешных задач. Выбран вместо redis-exporter --check-keys, потому что дефолтная очередь "celery" дала бы только глубину, но не воркеров и не failures. - Скрейп обоих в alloy-apps.alloy. - Алерты в infra.yml: RedisDown, NoActiveCeleryWorkers, CeleryQueueGrowing (порог 150 предварительный — реальных данных по глубине очереди ещё нет, пересмотр через неделю наблюдений). Имена метрик celery-exporter (celery_queue_length, celery_worker_up, celery_task_failed_total) — по документации проекта, без прогона на реальном брокере; сверить после первого деплоя, см. комментарий у сервиса. promtool check rules — 23 правила, SUCCESS. Refs #3471 |
||
|
|
e0564d12fe |
feat(tg): продуктовый Bot API трафик уходит через ретранслятор на Beget
Замер 12.09.2026, оба хоста в одни и те же минуты: getMe из tradein-tgbot на Selectel — 9 успешных из 12, три ConnectTimeout; TCP-443 до адреса, резолвящегося на Selectel (149.154.167.220) — 5 из 6; TCP-443 до адреса, резолвящегося на Beget (149.154.166.110) — 8 из 8. За сутки в логе бота 508 строк network error, за 30 дней 92 обрыва итерации poll loop. Значит: путь до Telegram с Selectel лоссовый, с Beget чистый — Alertmanager (живёт на Beget) шлёт в тот же чат без проблем, а бот поддержки на Selectel часть отправок теряет. Добавлен ops/metrics/tg-relay — stdlib-only HTTP-сервис (тот же принцип, что у alert-ack: без зависимостей, поднимается даже когда всё остальное сломано), проксирует Bot API целиком (метод, путь, тело — sendMessage, copyMessage, getUpdates) на api.telegram.org. Токен из пути не логируется: log_request переопределён полностью, путь редактируется до записи в лог. Аутентификация — общий секрет в X-Relay-Secret, по образцу X-Internal-Auth-Secret из этого же стека. Клиент (tgbot/client.py) при транспортном отказе похода на ретранслятор делает одну попытку напрямую к api.telegram.org — хуже прямого пути быть не должно ни при каких условиях. Пустой TELEGRAM_RELAY_BASE_URL — прежнее поведение без изменений, это и есть механизм отката. Refs #3471 |
||
| 01960b03be | Merge pull request 'Резервный канал для алертов GlitchTip: приём на alert-ack, другой хост и другой провайдер' (#3482) from feat/3471-glitchtip-fallback-alert-ack into main | |||
| 1254294ac3 | Merge pull request 'Alertmanager объявлен единственным путём доставки: встроенный алертинг Grafana выключен явно' (#3481) from chore/3158-grafana-alerting-off into main | |||
| 5d4d17a5e1 | Merge pull request 'Тревоги уровня приложения, cAdvisor и пропавшие фоновые контейнеры; critical переживает подавление' (#3477) from feat/3471-app-alerts-coverage into main | |||
|
|
423842ae36 |
feat(ops): резервный получатель GlitchTip-алертов в alert-ack
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Все три alert-правила GlitchTip (backend, frontend, Trade-In) сейчас шлют единственный вебхук в продуктовый бэкенд на Selectel — тот самый хост, за которым они следят. Если там упал backend или Caddy, ошибки приложения задерживаются или пропадают именно тогда, когда нужнее всего. Добавлен POST /glitchtip в alert-ack (живёт на инфраструктурном хосте Beget, не зависит от здоровья продукта): второй получатель того же Slack-совместимого payload, аутентификация секретом в заголовке X-GlitchTip-Secret или query ?secret= (тот же подход, что у tradein-mvp/backend/app/api/v1/glitchtip.py). Сообщение уходит в существующую тему клиентских инцидентов с явной пометкой «резервный канал». Секрет свой (ALERT_ACK_GLITCHTIP_SECRET), не переиспользует продуктовый TRADEIN_INTERNAL_AUTH_SECRET. Refs #3471 |
||
|
|
93451fae08 |
chore(ops): выключить встроенный Grafana Alerting, единственный путь — Alertmanager
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
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 / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Проверка живого API Grafana 12.09.2026: правил алертинга ноль, контакт-поинт единственный и стоковый — grafana-default-email на example@email.com, GF_SMTP_* не заданы. Интерфейс выглядит настроенным, кнопка "New alert rule" работает, а результат молча уходит в никуда — ровно тот случай, из-за которого никто не проверяет второй, настоящий путь доставки. Дублирующий движок на том же датасорсе Prometheus надёжности не добавляет (общая точка отказа), а поддержку удваивает. Решение: Alertmanager — единственный путь доставки тревог, Grafana только рисует. GF_UNIFIED_ALERTING_ENABLED: "false" в docker-compose.metrics.yml (секция grafana) — единственная официальная секция [unified_alerting] в Grafana 11.x, легаси-[alerting] удалён из Grafana ещё в 9.0 (сверено с grafana.com/docs/ grafana/v11.5/setup-grafana/configure-grafana/#unified_alerting). Разом убирает Alerting из UI и глушит движок правил, так что искать и вычищать стоковый контакт-поинт отдельно не требуется. ops/metrics/grafana/provisioning/alerting/README.md — явная отметка для следующего человека: провижинить contact points/rules в эту папку не нужно, Grafana её при выключенном unified alerting не читает. Closes #3158, refs #3471 |
||
|
|
655652ae1c |
feat(ops): алерты уровня приложения и три слепые зоны мониторинга
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
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 / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
- AppHighErrorRate / AppHighLatencyP95 (job="app", severity=critical,
host="apps") — доля 5xx и p95 задержки по http_requests_total /
http_request_duration_seconds_bucket теперь ловятся Prometheus'ом, а не
только постфактум в GlitchTip. Пара critical+apps обязательна для
маршрута telegram-clients в alertmanager.yml.tmpl.
- Inhibit по HostAgentDown больше не гасит critical того же хоста —
target_matchers сужен до severity="warning" (падение node-exporter
раньше молча забирало с собой PostgresLongTransactionCritical и
critical-алерты cAdvisor).
- cAdvisor: keep-фильтр в alloy-infra.alloy резал у него `up` наравне с
container_*-мусором — job "cadvisor" не публиковал свою же серию `up`.
Пропущены up/scrape_samples_scraped, добавлен CadvisorDown.
- TradeInBackgroundContainerMissing по absent(container_last_seen) на
tradein-tgbot/tradein-scraper — эти контейнеры не HTTP-сервисы и в
up{} не участвуют вовсе; крэш без рестарта раньше не алертился.
Не закрыто: живой, но зависший процесс tgbot/scraper (container_last_seen
не про внутренний прогресс, а про то, что Docker видит контейнер running).
Refs #3471
|
||
|
|
412d78f357 |
fix(ops): панель классов ответов больше не стекируется — красная линия и есть число 5xx
All checks were successful
CI Trade-In / 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 / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 16s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
11.09 панель «Запросы по классам ответов» дала ложную тревогу: при stacking=normal верхняя, красная линия рисуется на высоте суммы всех классов, и её положение читается как объём пятисоток. Фактически за то окно их было шесть. Стек здесь ничего не даёт: суммарный трафик уже показан отдельной панелью «Запросов в минуту», а от этой нужна форма каждого класса по отдельности. Заливка снижена, линия утолщена — без стека 25% заливки перекрывают друг друга. Описание панели теперь прямо говорит, что линии независимы. Refs #3471 |
||
| 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>
|
|||
| ed71292f03 |
fix(metrics): маскировать секреты из query-строки в Alloy до отправки в Loki
All checks were successful
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 Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Скруббер #3115 знал одну форму — пароль в DSN (scheme://user:pass@host). Секрет в query-строке (`?secret=<64 hex>` вебхука GlitchTip, #3154) проходил насквозь и оседал в Loki на 30 суток ретенции. Приложение чистит это у себя (#3353), но фильтр стоит на логгере ОДНОГО процесса. Второй слой на сборщике закрывает всё, что придёт мимо: sidecar, чужой процесс, будущий логгер без фильтра. Множество имён параметров взято из log_scrub.py дословно, включая суффиксные client_secret/refresh_token. Группа захвата стоит на значении, а не на имени параметра: Alloy заменяет содержимое ГРУПП, а не весь совпавший фрагмент. Группа вокруг `?secret=` затёрла бы имя и оставила сам секрет — то есть ровно наоборот. Стадия добавлена и в alloy-infra.alloy: на инфра-хосте Forgejo и GlitchTip с их `?token=` в адресах пишут в тот же Loki, дефект там тот же. Closes #3354 |
|||
|
|
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 |
||
|
|
99125b8093 |
fix(ops/metrics): Prometheus не видел ни одного Alertmanager — цель file_sd осталась пустой
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 / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Профиль alerts включили, а файл целей `alertmanager_targets.yml` остался плейсхолдером `[]`. Снаружи всё зелёное: alertmanager и alert-ack Up/healthy, деплой зелёный, в логах ни одной ошибки — при этом `activeAlertmanagers: []` и 1568 уведомлений в `prometheus_notifications_dropped_total`. Горящий с 26.08 `Watchdog` не доехал никуда, как и HostAgentDown с RemoteWriteStalled. Причина в том, что включатель профиля и цель для Prometheus лежали в разных местах: профиль поднимает deploy-metrics.yml по наличию токена и чата, а файл целей правился руками. Разъезд не ловится ничем — `[]` штатен при выключенном профиле, поэтому ни валидация, ни healthcheck, ни лог на него не реагируют. Файл становится производным (`alertmanager_targets.gen.yml`, в .gitignore) и рендерится деплоем тем же условием, что включает профиль: цель при включённых алертах, `[]` при выключенных. Рендер идёт до `up` и пишет усечением на месте, поэтому инод сохраняется и работающий Prometheus подхватывает цель сам — та же ловушка одиночного бинд-маунта, что уже описана в этом workflow у Alertmanager. Пустой список пишется явно, а не удалением файла: несуществующий путь docker подменяет каталогом, и Prometheus не стартует вовсе. Closes #3155 |
||
|
|
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 |
||
| 4f77197f01 |
Merge pull request 'chore(ops): restore-дрель для базы tradein — она не покрывалась вовсе' (#3141) from chore/3xxx-restore-drill-tradein into main
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-backend (push) Has been skipped
Deploy / deploy (push) Successful in 1m2s
Deploy Infra Host / sync-infra-host (push) Successful in 6s
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 11s
|
|||
|
|
ec6181b7b3 |
chore(ops): restore-дрель для базы tradein — она не покрывалась вовсе
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 / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Дрель восстановления стоит в cron с #3085, но её строка берёт только `gendesign_*`. База tradein — клиентские оценки, листинги, дома, сделки — автоматически на восстановимость не проверялась ни разу: дампы снимались, уезжали в S3, и никто не знал, разворачиваются ли они. Сам `ops/restore-drill.sh` менять не пришлось — он с самого начала умеет оба проекта: находит globals по своей схеме имён (`tradein-globals-<ts>`), подставляет свой список таблиц для сверки. Не хватало ровно строки в cron. Прогнал на боевом дампе 27.08 перед тем, как добавлять (оповещение заглушено, чтобы не слать ложную тревогу): tradein-20260827-111102.sql.gz, 343 МБ → 58 c GRANT-ы применены, материализованные представления обновлены PostGIS 3.4.3, таблиц в public: 76 listings 109978 · listing_sources 106704 · deals 108623 houses 10400 · trade_in_estimates 1121 Сверка с боевой базой: houses, listings, deals, osm_poi сходятся точно; trade_in_estimates и user_events больше на проде ровно на строки, созданные ПОСЛЕ снятия дампа. То есть дамп верен. Еженедельно, а не раз в месяц: две минуты работы против месяца, в течение которого битый дамп остаётся незамеченным. Понедельник 03:00 — после дрели gendesign (02:15 первого числа) и до ночных бэкапов в 03:30. |
||
|
|
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. Важнейший: при отказе отправки с клавиатурой сообщение уходит БЕЗ неё — алерт важнее кнопки. |
||
| 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
|
|||
|
|
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, и сорсинг
запустил бы настоящие сетевые проверки.
|
||
|
|
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, а не только к месту ожога. |
||
|
|
efaab2efda |
chore(ops): адрес Poincare — 188.124.37.140 вместо заменённого 188.246.224.93 (#3110)
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 / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (pull_request) Successful in 17m16s
Selectel заменил адрес сервера в ночь на 27.08 по нашей заявке: старый 188.246.224.93 фильтровался российскими операторами и не открывался ни с домашнего интернета, ни с мобильного (#3110). Новый адрес фильтрации не имеет — проверено с машины владельца после переключения DNS. Замена уронила сервер на 10 часов: адрес на порте сменился, а в ОС остался прежний (разбор и восстановление — #3119). Здесь только то, что осталось в репозитории. Единственное функциональное вхождение — дефолт HOST_IP в ops/selectel-ci-access.sh: скрипт открывает раннеру доступ на прод-хост, и с прежним значением он молча настроил бы доступ на адрес, которого у нас больше нет. Остальные семь — тексты комментариев в Caddyfile-секциях, compose, bootstrap-скриптах и раннбуке крона; они не исполняются, но именно по ним сверяются при переезде, и разошедшийся адрес в них дороже, чем кажется. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
a72d39d74d |
fix(observability): cAdvisor 0.55.1 — 0.52 не видит контейнеры на Docker 29
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
#3108 не починил пустые панели по контейнерам, и правило relabel там было ни при чём. Настоящая причина глубже: Docker 29 на обоих хостах работает через containerd-snapshotter (Storage Driver = overlayfs), а cAdvisor 0.52 ищет метаданные слоя в легаси-хранилище: failed to identify the read-write layer ID for container "<id>" - open /rootfs/var/lib/docker/image/overlayfs/layerdb/mounts/<id>/mount-id: no such file or directory При снапшоттере этого каталога нет вовсе — в /var/lib/docker/image/ лежит только identity-cache.db, метаданные слоёв живут в containerd. Обработчик контейнера не создаётся, и наружу уходит ровно один ряд: корневой cgroup container_last_seen{id="/"}. Отсюда и «нодата» на панелях контейнеров при живых node/postgres/app метриках. 0.55.1 умеет читать containerd-снапшоттер. Проверено пробами с ПРОДОВЫМИ флагами на обоих хостах (одноразовые контейнеры, убраны за собой): Beget (Docker 29.4.1): 22 ряда, все с name=, ошибок rw-layer 0 Poincare (Docker 29.7.2): 23 ряда, все с name=, ошибок rw-layer 0 Примеры рядов — name="gendesign-alloy", name="gendesign-backend-1", name="gendesign-osrm-1", с лейблом image. То есть именно то, чего не хватало панелям. Заодно поправлен комментарий, который я же вписал в cadvisor_trim в #3108: он объяснял пустые панели трактовкой пустого regex, а это оказалось неверно. Правило корректно и остаётся (корневой ряд приходит с name="" и должен отсеиваться), но объяснение рядом с ним вводило в заблуждение. Почему тег .1, а не .0: в реестре нет ни v0.53.0, ни v0.54.0, ни v0.55.0 — только v0.54.1 и v0.55.1. Проверял по списку тегов, а не подбором. Проверки: yaml.safe_load compose — ok; alloy fmt обоих .alloy в grafana/alloy:v1.6.1 — exit 0. |
||
|
|
c6e15954ba |
fix(observability): контейнерные метрики терялись целиком + два хвоста стека
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 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
Три независимых дефекта, найденных на живом проде после подъёма стека метрик.
1. cAdvisor-метрики не доезжали ВООБЩЕ. В Prometheus ноль имён container_*
при 2034 именах всего, хотя cAdvisor отдаёт 880 рядов, scrape-таргет в
alloy health=up с последним скрейпом 10 мс назад, а remote_write рабочий
(node/postgres идут через него же и доезжают). Методом исключения — потери
в prometheus.relabel.cadvisor_trim, во втором правиле:
rule { source_labels = ["name"], regex = "", action = "drop" }
Замысел был выкинуть безымянные cgroup-ряды (id="/"). Но regex в Alloy
документированно дефолтится в (.*), и пустая строка неотличима от
незаданного значения — такой drop рискует выкидывать вообще всё, что и
наблюдалось. Заменено на однозначное keep regex=".+" — тот же замысел,
без зависимости от того, как трактуется пустой regex.
2. healthcheck alloy не мог пройти никогда: дёргал wget, которого в образе
grafana/alloy нет (как и curl, и nc). Контейнер вечно unhealthy при
полностью исправном alloy — ложная тревога, маскирующая настоящие сбои.
Заменено на сырой HTTP через bash /dev/tcp, без внешних утилит.
3. Prometheus раз в минуту писал "lookup alertmanager: no such host" и держал
up{job="alertmanager"}=0. Alertmanager намеренно за профилем alerts до
решения #3078 — дефект не в профиле, а в безусловной ссылке на сервис.
Оба места (alerting.alertmanagers и job_name: alertmanager) переведены на
file_sd_configs с файлом целей, по умолчанию пустым: целей нет — ошибок
тоже нет. Prometheus перечитывает file_sd на лету, поэтому включение
профиля сведётся к наполнению файла, без рестарта и правки конфига.
Файл целей смонтирован в сервис prometheus явным volume.
Проверено на живом хосте, не на глаз:
- alloy fmt обоих .alloy в одноразовом контейнере grafana/alloy:v1.6.1 - exit 0
- promtool check config в prom/prometheus:v3.1.0 - valid, 16 rules found
- механизм нового healthcheck выполнен внутри работающего gendesign-alloy:
первая строка ответа "HTTP/1.0 200 OK", grep матчится, RESULT=HEALTHY
- наличие bash/head/grep/printf в образе alloy подтверждено command -v
|
||
|
|
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 |
||
| afd881d6b1 |
Merge pull request 'feat(observability): стек метрик и логов — Prometheus, Loki, Grafana на Beget, агенты на обоих хостах' (#3099) from feat/observability-metrics-stack into main
Some checks failed
Deploy Infra Host / sync-infra-host (push) Failing after 4s
Deploy / changes (push) Successful in 8s
Deploy Metrics / server (push) Failing after 8s
Deploy Metrics / agent-apps (push) Has been skipped
Deploy Metrics / agent-infra (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-frontend (push) Successful in 34s
Deploy / build-worker (push) Successful in 36s
Deploy / build-backend (push) Successful in 37s
Deploy / deploy (push) Successful in 1m4s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Failing after 2m40s
|
|||
|
|
7b6832e90f |
feat(observability): дашборд баз данных — раздутие, горизонт vacuum, WAL
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
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 / openapi-codegen-check (pull_request) Has been skipped
Вторая часть #3078. Конфигурация экспортеров приехала первым коммитом, но смотреть на неё было негде: без витрины ряды есть, а ответа на вопрос нет. Панели подобраны по разборам постфактум, а не по списку «что обычно рисуют». Доля апдейтов мимо HOT — потому что у listings она была 0,43 % при 198 апдейтах на строку, и именно это дало 15 ГБ TOAST при 230 МБ живого содержимого (#2992/#2989), копившиеся 91 день. Возраст самой старой транзакции — потому что осиротевшие запросы висели 46 часов и держали горизонт видимости, из-за чего autovacuum не убирал мёртвые строки во всей базе (#2607). WAL за сутки — потому что 7,02 ГБ при четырёх пользовательских расчётах это диспропорция, заметная только на ряде. Размер баз — потому что у GlitchTip нет политики ретенции вовсе, и он растёт без ограничения. Размеры разложены на heap / индексы / TOAST: суммарный размер таблицы не объясняет ничего, а именно это разделение объяснило, куда ушли 19 ГБ. Отдельная панель «экспортер отвечает»: пустой график и упавшая база выглядят одинаково, и различать их должно что-то явное. Refs #3078 |
||
|
|
309d273f3f |
feat(observability): стек метрик и логов — Prometheus, Loki, Grafana, агенты на обоих хостах
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
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 / openapi-codegen-check (pull_request) Has been skipped
Метрик в проекте не было ни одной: ни экспортеров, ни /metrics в бэкендах, единственный канал наблюдения — journald, единственный сигнал об аварии — исключение в GlitchTip. Из-за этого целый класс отказов невидим в принципе: задача рапортует done, строк ноль, исключения нет. Так протухли данные на семь месяцев (#2998), 34 дня был мёртв house_imv_backfill (#2698), 8 суток писал ноль newbuilding_enrich (#2767), 91 день копилось раздутие listings (#2992). Grafana не заменяет GlitchTip: ошибки остаются там. Grafana OSS не принимает Sentry DSN ни одним компонентом, а скрубберы в before_send — требование 152-ФЗ. Здесь появляется другой класс данных: ряды и алерты по трендам. Наблюдатель поставлен у ДРУГОГО провайдера, чем наблюдаемое: серверная сторона на Beget, рядом с GlitchTip. Если ляжет Poincare, мониторинг должен об этом сказать, а не лечь вместе с ним. Транспорт push, а не pull: агент на Poincare шлёт remote_write и логи исходящим HTTPS, поэтому там не открывается ни одного входящего порта сверх 22/80/443. При обрыве канала Alloy копит в WAL и досылает — pull-скрейп в той же ситуации терял бы точки именно в аварии, ради которой мониторинг и нужен. Два контура доступа с разными учётками. Пароль приёмника по построению лежит открытым на продуктовом хосте, значит его компрометация неизбежна вместе с хостом; будь это учётка витрины, утёк бы и доступ к дашбордам. GlitchTip читается прямым SQL, а не Sentry-плагином: у плагина на 6.1.6 stats_v2 отдаёт 500 (баг GlitchTip #381), Events/Discover — 404 (#416), а в grafana/sentry-datasource слово glitchtip не встречается ни разу. Схема сверена на живой базе: колонка времени называется timestamp, а не received, и отдельной таблицы IssueIndex не существует — агрегаты лежат на самой issue_events_issue. Алерты за профилем alerts: канал доставки — открытый вопрос #3078, и стек не должен на нём стоять. Деплой предупреждает, что уведомлять пока некому. Каждая настройка, способная отказать молча, закрыта явно: ретенция Prometheus задана и по времени и по размеру, retention_enabled у компактора Loki (без него retention_period не работает вовсе), путь к журналу и запуск Alloy от root (иначе агент читает ноль записей без ошибки), проверка Caddy до перезагрузки (на этом хосте тот же Caddy держит git, errors и obsidian). Refs #3078 |
||
|
|
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. |
||
| 12e48a7783 |
fix(ops): страж повторной заливки перестал молча пропускаться (#3092)
All checks were successful
Deploy / build-backend (push) Has been skipped
Deploy Infra Host / sync-infra-host (push) Successful in 5s
Deploy / changes (push) Successful in 7s
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / deploy (push) Successful in 1m38s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 9s
|
|||
| 00d434f88d |
chore(ops): backup-couchdb.sh исполняемый, как остальные бэкапы (#3091)
All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 3s
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 8s
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / deploy (push) Successful in 1m32s
|
|||
| bffec49434 |
feat(ops): у волта Obsidian появился автоматический бэкап (#3090)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy Infra Host / sync-infra-host (push) Successful in 4s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / deploy (push) Successful in 1m35s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 9s
|
|||
| d99f733f41 |
fix(ops): восстановление не падает в хвосте из-за незаведённых ролей (#3089)
All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 4s
Deploy / build-backend (push) Has been skipped
Deploy / changes (push) Successful in 8s
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / deploy (push) Successful in 1m46s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 9s
|
|||
| 2feb0de446 |
fix(ops): бэкап без выгрузки в S3 падает, а не рапортует успех (#3086)
All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 2s
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 12s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 45s
Deploy / deploy (push) Successful in 1m45s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Successful in 2m35s
Deploy Trade-In / test (push) Successful in 3m53s
Deploy Trade-In / build-backend (push) Successful in 34s
Deploy Trade-In / deploy (push) Successful in 2m21s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
|
|||
| 536137d460 |
feat(ci): деплой проверяет, к тому ли хосту подключился (#3079)
All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 2s
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 12s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 52s
Deploy / build-worker (push) Successful in 52s
Deploy / build-frontend (push) Successful in 52s
Deploy Trade-In / build-browser (push) Successful in 41s
Deploy Obsidian / deploy-obsidian (push) Successful in 2m15s
Deploy / deploy (push) Successful in 1m53s
Deploy Trade-In / build-frontend (push) Successful in 2m44s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / test (push) Successful in 4m8s
Deploy Trade-In / build-backend (push) Successful in 47s
Deploy Trade-In / deploy (push) Successful in 1m42s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 9s
|
|||
| be2e07d9c3 |
feat(ops): лёгкий Postgres на Beget под forgejo и glitchtip (#3080)
All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 2s
Deploy / changes (push) Successful in 7s
Deploy / deploy-caddy (push) Has been skipped
Deploy / perimeter-smoke (push) Successful in 10s
Deploy / build-backend (push) Successful in 50s
Deploy / build-frontend (push) Successful in 51s
Deploy / build-worker (push) Successful in 53s
Deploy / deploy (push) Successful in 1m43s
Deploy / deploy-status (push) Successful in 1s
|
|||
| f52601de41 |
feat(ops): бэкапить конфигурацию Forgejo, а не только его содержимое (#3073)
All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 2s
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / perimeter-smoke (push) Successful in 9s
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Has been skipped
Deploy / deploy (push) Successful in 1m23s
Deploy / deploy-status (push) Successful in 1s
|
|||
| 0ba52e55db |
chore(ops): развести crontab по хостам под переезд (#3072)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-frontend (push) Successful in 41s
Deploy / build-backend (push) Successful in 43s
Deploy / build-worker (push) Successful in 45s
Deploy / deploy (push) Successful in 1m32s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 9s
|
|||
| 3a7fc2ff65 |
feat(ops): запасной канал алертов на случай недоступного Telegram (#3070)
All checks were successful
Deploy / build-backend (push) Has been skipped
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / deploy (push) Successful in 1m18s
Deploy / changes (push) Successful in 7s
Deploy / build-worker (push) Has been skipped
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 8s
|