"""Алерты можно адресовать в топик форумной группы (#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 не стартовал бы вовсе. А это не деградация, а полное исчезновение алертинга: контейнер просто не поднимется. Поэтому деплой формирует либо всю строку с отступом, либо пустую. ПОЧЕМУ ДВЕ ТЕМЫ (#3163), А НЕ ОДНА. До этого тикета оба прямых получателя (`telegram`, `telegram-heartbeat`) и клиентские инциденты брали топик из ОДНОЙ переменной — и инфраструктурная тема «метрики» оставалась пустой, а весь трафик, и клиентский, и инфраструктурный, копился в теме «алерты». Владелец решил развести: инфраструктура — в «метрики», клиентские инциденты — в «алерты». Тесты ниже закрепляют именно это: `telegram`/`telegram-heartbeat` получают ИНФРАСТРУКТУРНУЮ тему, а не общую. Тесты рендерят шаблон обоими способами и разбирают результат как 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" INFRA_TOPIC_LINE = " message_thread_id: 245" def _render(infra_topic_line: str) -> dict: """Повторяет подстановку деплоя и разбирает результат как YAML. Строка темы в шаблоне ровно одна — инфраструктурная (#3163). Тема клиентских инцидентов сюда не подставляется вовсе: маршрут `telegram-clients` уходит вебхуком в alert-ack, и тему адресует уже он, своей переменной окружения. Держать здесь второй параметр было бы враньём — он ни на что не влиял бы, а тест выглядел бы строже, чем он есть. """ 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_INFRA_TOPIC_LINE}", infra_topic_line) ) assert "${" not in rendered, ( "в отрендеренном конфиге остался литерал плейсхолдера — " "значит в шаблоне появилась подстановка, о которой тест не знает" ) 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: """Оба прямых получателя адресуют ИНФРАСТРУКТУРНУЮ тему, а не клиентскую (#3163). Получателей два — `telegram` и `telegram-heartbeat`. До разделения тем оба брали топик из одной переменной с клиентскими инцидентами, и тема «метрики» (245) оставалась пустой. Если heartbeat уйдёт не в ту тему, «мониторинг жив» будет капать мимо, и это заметят не сразу — сюда же попадёт и весь инфраструктурный шум. """ cfgs = _telegram_configs(_render(INFRA_TOPIC_LINE)) assert len(cfgs) >= 2, f"ожидалось минимум два получателя telegram, найдено {len(cfgs)}" for c in cfgs: assert c.get("message_thread_id") == 245, f"инфраструктурный топик не проставлен: {c}" def test_without_infra_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_INFRA_TOPIC_LINE}`, на котором Alertmanager не стартует вовсе (#3163) — та же ловушка, из-за которой изначально завели этот файл для клиентской строки в #3078. """ assert WORKFLOW.is_file(), f"нет {WORKFLOW} — воркфлоу переехал, гейт ослеп" text = WORKFLOW.read_text(encoding="utf-8") assert "METRICS_TELEGRAM_TOPIC_ID" in text, ( "деплой не читает переменную клиентского топика — она нужна как откат" ) assert "METRICS_TELEGRAM_INFRA_TOPIC_ID" in text, ( "деплой не читает переменную инфраструктурного топика" ) assert re.search( r"METRICS_TELEGRAM_INFRA_TOPIC_LINE=\"\s+message_thread_id: \$\{INFRA_TOPIC_ID\}\"", text, ), "инфраструктурная строка собирается не целиком — при пустом значении конфиг сломается" envsubst = re.search(r"envsubst '([^']+)'", text) assert envsubst, "не нашёл вызов envsubst" assert "${METRICS_TELEGRAM_INFRA_TOPIC_LINE}" in envsubst.group(1), ( "переменная инфраструктурного топика не объявлена в envsubst — " "в конфиг попадёт литерал плейсхолдера" ) def test_infra_topic_has_working_default_and_never_falls_back_to_client_topic() -> None: """Тема по умолчанию задана в самом деплое и НЕ откатывается на клиентскую (#3163). Первая половина: без значения по умолчанию разделение тем зависело бы от ручного шага «завести секрет». Этот шаг уже отказал — 27.08 два прогона подряд отработали зелёными, а тема «метрики» осталась пустой. Вторая половина важнее: откат на METRICS_TELEGRAM_TOPIC_ID запрещён явно. Он возвращал ровно то состояние, ради ухода от которого всё затевалось — весь инфраструктурный поток в теме клиентских инцидентов, — и сообщал об этом строкой в логе прогона, которую никто не читает. """ text = WORKFLOW.read_text(encoding="utf-8") assert re.search( r'INFRA_TOPIC_ID="\$\{METRICS_TELEGRAM_INFRA_TOPIC_ID:-\d+\}"', text, ), "у инфраструктурной темы нет значения по умолчанию — разделение зависит от ручного шага" assert not re.search( r'INFRA_TOPIC_ID="\$\{METRICS_TELEGRAM_TOPIC_ID', text, ), "вернулся откат на тему клиентских инцидентов — это и есть исходный дефект" 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 перед подъёмом стека" )