gendesign/backend/tests/ops/test_3078_alert_topic.py
bot-backend b90872b5d7
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
feat(ops/metrics): инфра-алерты уезжают в тему «метрики», клиенты остаются в «алертах»
Форумная группа имеет три темы, но тема «метрики» была пуста: оба прямых
получателя 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

162 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Алерты можно адресовать в топик форумной группы (#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_falls_back_to_client_topic_when_unset() -> None:
"""Без METRICS_TELEGRAM_INFRA_TOPIC_ID — откат на METRICS_TELEGRAM_TOPIC_ID (#3163).
Разделение тем не должно ломать деплой у тех, кто ещё не прописал новую
переменную на хосте: пока её нет, поведение обязано остаться ровно
прежним (топик из METRICS_TELEGRAM_TOPIC_ID), а не откатиться в общую тему
чата или сломать рендер конфига.
"""
text = WORKFLOW.read_text(encoding="utf-8")
assert re.search(
r'if \[ -n "\$\{METRICS_TELEGRAM_INFRA_TOPIC_ID:-\}" \]; then'
r'[\s\S]*?INFRA_TOPIC_ID="\$\{METRICS_TELEGRAM_INFRA_TOPIC_ID\}"'
r'[\s\S]*?else'
r'[\s\S]*?INFRA_TOPIC_ID="\$\{METRICS_TELEGRAM_TOPIC_ID:-\}"',
text,
), "нет отката на METRICS_TELEGRAM_TOPIC_ID при незаданной METRICS_TELEGRAM_INFRA_TOPIC_ID"
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 перед подъёмом стека"
)