gendesign/backend/tests/ops/test_3078_alert_topic.py
bot-backend 192fdbfbf6
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / 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 / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m2s
CI / backend-tests (pull_request) Successful in 17m10s
fix(ops/metrics): тема «метрики» задаётся дефолтом, а не ручным заведением секрета
Разделение тем из #3163 зависело от шага «завести секрет руками». Шаг отказал
сразу же: 27.08 два прогона деплоя подряд отработали зелёными, напечатали
строку про откат — и тема «метрики» осталась пустой, а весь инфраструктурный
поток продолжил идти в тему клиентских инцидентов.

Номер темы форума секретом не является: в репозитории уже лежат домены, пути
на хостах, имена контейнеров и внешние адреса. Ставим 245 значением по
умолчанию прямо в деплое; переменная окружения по-прежнему перекрывает — переезд
темы или другой чат решается ею, без правки кода.

Откат на METRICS_TELEGRAM_TOPIC_ID убран намеренно и запрещён тестом. Он
возвращал ровно то состояние, ради ухода от которого всё затевалось, и
сообщал об этом строкой в логе прогона, которую никто не читает. Молчаливое
«почти правильно» хуже явной поломки.

Прогнано в обе стороны: с переменной — тема из окружения, без неё — 245.
backend/tests/ops — 86 passed.
2026-08-27 22:28:52 +03:00

167 lines
11 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_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 перед подъёмом стека"
)