"""Алерты можно адресовать в топик форумной группы (#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 перед подъёмом стека" )