From 5b7ef161e34260b54f08f9e5bb7eaba5a18b0d17 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 26 Aug 2026 14:10:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(observability):=20=D0=B0=D0=BB=D0=B5=D1=80?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D1=83=D1=8E=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=B2=20=D1=82=D0=BE=D0=BF=D0=B8=D0=BA=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D1=83=D0=BC=D0=BD=D0=BE=D0=B9=20=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BF=D1=8B=20(#3078)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бот, которым шлются тревоги, — тот же, что пересылает сообщения поддержки, а его чат форумный. Без 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. --- .forgejo/workflows/deploy-metrics.yml | 31 ++++- backend/tests/ops/test_3078_alert_topic.py | 114 ++++++++++++++++++ .../alertmanager/alertmanager.yml.tmpl | 2 + 3 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 backend/tests/ops/test_3078_alert_topic.py diff --git a/.forgejo/workflows/deploy-metrics.yml b/.forgejo/workflows/deploy-metrics.yml index ad9c453c..51764969 100644 --- a/.forgejo/workflows/deploy-metrics.yml +++ b/.forgejo/workflows/deploy-metrics.yml @@ -120,13 +120,40 @@ jobs: if [ -n "${METRICS_TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${METRICS_TELEGRAM_CHAT_ID:-}" ]; then PROFILES="alerts" mkdir -p ops/metrics/alertmanager + + # Топик форумной группы (#3078). Необязателен: без него алерты + # уходят в общую тему. Подставляем ЦЕЛОЙ СТРОКОЙ, а не значением, + # потому что envsubst не умеет условий — при пустом + # METRICS_TELEGRAM_TOPIC_ID в конфиг попал бы `message_thread_id:` + # без значения, и Alertmanager не стартовал бы вовсе. + if [ -n "${METRICS_TELEGRAM_TOPIC_ID:-}" ]; then + METRICS_TELEGRAM_TOPIC_LINE=" message_thread_id: ${METRICS_TELEGRAM_TOPIC_ID}" + echo "Алерты: адресуются в топик ${METRICS_TELEGRAM_TOPIC_ID}." + else + METRICS_TELEGRAM_TOPIC_LINE="" + echo "Алерты: топик не задан — уйдут в общую тему чата." + fi + METRICS_TELEGRAM_BOT_TOKEN="$METRICS_TELEGRAM_BOT_TOKEN" \ METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \ - envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID}' \ + METRICS_TELEGRAM_TOPIC_LINE="$METRICS_TELEGRAM_TOPIC_LINE" \ + envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_TOPIC_LINE}' \ < ops/metrics/alertmanager/alertmanager.yml.tmpl \ > ops/metrics/alertmanager/alertmanager.yml chmod 600 ops/metrics/alertmanager/alertmanager.yml - echo "Алерты: канал задан, Alertmanager поднимается." + + # Проверяем ДО подъёма, как и Caddyfile ниже. Битый конфиг + # Alertmanager не «деградирует» — контейнер не стартует вовсе, и + # алертинг молча исчезает целиком. amtool берём из того же образа, + # что и сам Alertmanager, иначе проверяли бы не ту версию схемы. + if ! docker run --rm \ + -v "$PWD/ops/metrics/alertmanager/alertmanager.yml:/tmp/am.yml:ro" \ + --entrypoint amtool "$(grep -oE 'prom/alertmanager:[^ ]+' docker-compose.metrics.yml | head -1)" \ + check-config /tmp/am.yml; then + echo "ОШИБКА: конфиг Alertmanager не проходит проверку — стек не поднимаем." + exit 1 + fi + echo "Алерты: канал задан, конфиг проверен, Alertmanager поднимается." else echo "::warning title=Алерты выключены::METRICS_TELEGRAM_BOT_TOKEN/CHAT_ID не заданы. Метрики и логи собираются, но при срабатывании правила НИКТО не будет уведомлён. Канал доставки — открытый вопрос #3078." fi diff --git a/backend/tests/ops/test_3078_alert_topic.py b/backend/tests/ops/test_3078_alert_topic.py new file mode 100644 index 00000000..67bf2766 --- /dev/null +++ b/backend/tests/ops/test_3078_alert_topic.py @@ -0,0 +1,114 @@ +"""Алерты можно адресовать в топик форумной группы (#3078). + +Зачем. Бот, которым шлются тревоги, — тот же, что пересылает сообщения +поддержки, а его чат форумный. Без `message_thread_id` Alertmanager кладёт +тревоги в общую тему, вперемешку с клиентской перепиской. + +Поле поддерживается: проверено `amtool check-config` на том же образе, что +поднимается в проде (`prom/alertmanager:v0.28.0`) — конфиг с +`message_thread_id: 42` принимается. Схема Alertmanager строгая и неизвестные +поля отвергает, так что успешная проверка означает именно поддержку поля. + +Почему подставляется ЦЕЛАЯ СТРОКА, а не значение. `envsubst` не умеет условий. +Если бы в шаблоне стояло `message_thread_id: ${METRICS_TELEGRAM_TOPIC_ID}`, то +при незаданном топике в конфиг попало бы `message_thread_id:` без значения — +и Alertmanager не стартовал бы вовсе. А это не деградация, а полное исчезновение +алертинга: контейнер просто не поднимется. Поэтому деплой формирует либо всю +строку с отступом, либо пустую. + +Тесты рендерят шаблон обоими способами и разбирают результат как YAML — +проверяется фактический конфиг, а не наличие нужных слов в тексте. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +yaml = pytest.importorskip("yaml", reason="PyYAML нужен для разбора конфига") + +REPO_ROOT = Path(__file__).resolve().parents[3] +TMPL = REPO_ROOT / "ops" / "metrics" / "alertmanager" / "alertmanager.yml.tmpl" +WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml" + +TOPIC_LINE = " message_thread_id: 42" + + +def _render(topic_line: str) -> dict: + """Повторяет подстановку деплоя и разбирает результат как YAML.""" + assert TMPL.is_file(), f"нет {TMPL} — шаблон переехал, гейт ослеп" + text = TMPL.read_text(encoding="utf-8") + rendered = ( + text.replace("${METRICS_TELEGRAM_BOT_TOKEN}", "123:ABC") + .replace("${METRICS_TELEGRAM_CHAT_ID}", "-100123") + .replace("${METRICS_TELEGRAM_TOPIC_LINE}", topic_line) + ) + return yaml.safe_load(rendered) + + +def _telegram_configs(cfg: dict) -> list[dict]: + out = [] + for r in cfg.get("receivers", []): + out.extend(r.get("telegram_configs", []) or []) + assert out, "в конфиге не нашлось ни одного telegram_configs" + return out + + +def test_topic_lands_in_every_telegram_receiver() -> None: + """Топик проставляется во ВСЕХ получателях, а не только в основном. + + Получателей два — `telegram` и `telegram-heartbeat`. Если heartbeat уйдёт + в общую тему, «мониторинг жив» будет капать мимо, и это заметят не сразу. + """ + cfgs = _telegram_configs(_render(TOPIC_LINE)) + assert len(cfgs) >= 2, f"ожидалось минимум два получателя telegram, найдено {len(cfgs)}" + for c in cfgs: + assert c.get("message_thread_id") == 42, f"топик не проставлен: {c}" + + +def test_without_topic_field_is_absent_not_empty() -> None: + """Без топика поля нет вовсе — не пустое значение. + + Ядро регресса: `message_thread_id:` без значения уронил бы Alertmanager, + то есть выключил бы алертинг целиком, а не «просто отправил бы в общую тему». + """ + cfgs = _telegram_configs(_render("")) + for c in cfgs: + assert "message_thread_id" not in c, f"поле осталось при незаданном топике: {c}" + assert c.get("chat_id") == -100123, "chat_id пострадал при пустой подстановке" + + +def test_deploy_computes_whole_line_and_passes_it_to_envsubst() -> None: + """Деплой формирует строку целиком и объявляет переменную в envsubst. + + `envsubst` подставляет ТОЛЬКО перечисленные ему переменные. Забыть добавить + новую в список — значит оставить в готовом конфиге литерал + `${METRICS_TELEGRAM_TOPIC_LINE}`, на котором Alertmanager не стартует. + """ + assert WORKFLOW.is_file(), f"нет {WORKFLOW} — воркфлоу переехал, гейт ослеп" + text = WORKFLOW.read_text(encoding="utf-8") + + assert "METRICS_TELEGRAM_TOPIC_ID" in text, "деплой не читает переменную топика" + assert re.search( + r"METRICS_TELEGRAM_TOPIC_LINE=\"\s+message_thread_id: \$\{METRICS_TELEGRAM_TOPIC_ID\}\"", + text, + ), "строка топика собирается не целиком — при пустом значении конфиг сломается" + + envsubst = re.search(r"envsubst '([^']+)'", text) + assert envsubst, "не нашёл вызов envsubst" + assert "${METRICS_TELEGRAM_TOPIC_LINE}" in envsubst.group(1), ( + "переменная топика не объявлена в envsubst — в конфиг попадёт литерал плейсхолдера" + ) + + +def test_config_is_validated_before_stack_comes_up() -> None: + """Конфиг проверяется до подъёма — как Caddyfile. + + Битый Alertmanager не деградирует, а не стартует: алертинг исчезает молча. + """ + text = WORKFLOW.read_text(encoding="utf-8") + assert "amtool" in text and "check-config" in text, ( + "нет проверки конфига Alertmanager перед подъёмом стека" + ) diff --git a/ops/metrics/alertmanager/alertmanager.yml.tmpl b/ops/metrics/alertmanager/alertmanager.yml.tmpl index 4e807845..e7539309 100644 --- a/ops/metrics/alertmanager/alertmanager.yml.tmpl +++ b/ops/metrics/alertmanager/alertmanager.yml.tmpl @@ -52,6 +52,7 @@ receivers: telegram_configs: - bot_token: "${METRICS_TELEGRAM_BOT_TOKEN}" chat_id: ${METRICS_TELEGRAM_CHAT_ID} +${METRICS_TELEGRAM_TOPIC_LINE} api_url: "https://api.telegram.org" parse_mode: HTML send_resolved: true @@ -66,6 +67,7 @@ receivers: telegram_configs: - bot_token: "${METRICS_TELEGRAM_BOT_TOKEN}" chat_id: ${METRICS_TELEGRAM_CHAT_ID} +${METRICS_TELEGRAM_TOPIC_LINE} api_url: "https://api.telegram.org" parse_mode: HTML send_resolved: false -- 2.45.3