From b90872b5d74d90fb28e2e5fbaf29fa17557e579c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 27 Aug 2026 21:50:51 +0300 Subject: [PATCH] =?UTF-8?q?feat(ops/metrics):=20=D0=B8=D0=BD=D1=84=D1=80?= =?UTF-8?q?=D0=B0-=D0=B0=D0=BB=D0=B5=D1=80=D1=82=D1=8B=20=D1=83=D0=B5?= =?UTF-8?q?=D0=B7=D0=B6=D0=B0=D1=8E=D1=82=20=D0=B2=20=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=D1=83=20=C2=AB=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA=D0=B8=C2=BB?= =?UTF-8?q?,=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D1=8B=20=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20=D0=B2=20=C2=AB=D0=B0?= =?UTF-8?q?=D0=BB=D0=B5=D1=80=D1=82=D0=B0=D1=85=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Форумная группа имеет три темы, но тема «метрики» была пуста: оба прямых получателя 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 --- .forgejo/workflows/deploy-metrics.yml | 59 ++++++++++--- backend/tests/ops/test_3078_alert_topic.py | 84 +++++++++++++++---- docker-compose.metrics.yml | 4 + .../alertmanager/alertmanager.yml.tmpl | 14 +++- 4 files changed, 129 insertions(+), 32 deletions(-) diff --git a/.forgejo/workflows/deploy-metrics.yml b/.forgejo/workflows/deploy-metrics.yml index 637c4d5a..e197618d 100644 --- a/.forgejo/workflows/deploy-metrics.yml +++ b/.forgejo/workflows/deploy-metrics.yml @@ -88,9 +88,10 @@ jobs: METRICS_TELEGRAM_BOT_TOKEN: ${{ secrets.METRICS_TELEGRAM_BOT_TOKEN }} METRICS_TELEGRAM_CHAT_ID: ${{ secrets.METRICS_TELEGRAM_CHAT_ID }} METRICS_TELEGRAM_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_TOPIC_ID }} + METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }} METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }} with: - envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_ONCALL + envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }} username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }} key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }} @@ -138,17 +139,51 @@ jobs: PROFILES="alerts" mkdir -p ops/metrics/alertmanager - # Топик форумной группы (#3078). Необязателен: без него алерты - # уходят в общую тему. Подставляем ЦЕЛОЙ СТРОКОЙ, а не значением, - # потому что envsubst не умеет условий — при пустом - # METRICS_TELEGRAM_TOPIC_ID в конфиг попал бы `message_thread_id:` - # без значения, и Alertmanager не стартовал бы вовсе. + # Тема КЛИЕНТСКИХ инцидентов задаётся НЕ здесь. Их отправляет + # сервис alert-ack, читая METRICS_TELEGRAM_TOPIC_ID из своего + # окружения (docker-compose.metrics.yml): маршрут + # telegram-clients уходит вебхуком, а не в Telegram напрямую, + # поэтому в конфиг Alertmanager эта тема не попадает вовсе. + # Печатаем её только затем, чтобы по логу деплоя было видно, + # куда пойдут инциденты. if [ -n "${METRICS_TELEGRAM_TOPIC_ID:-}" ]; then - METRICS_TELEGRAM_TOPIC_LINE=" message_thread_id: ${METRICS_TELEGRAM_TOPIC_ID}" - echo "Алерты: адресуются в топик ${METRICS_TELEGRAM_TOPIC_ID}." + echo "Клиентские инциденты: тема ${METRICS_TELEGRAM_TOPIC_ID}, адресует alert-ack." else - METRICS_TELEGRAM_TOPIC_LINE="" - echo "Алерты: топик не задан — уйдут в общую тему чата." + echo "::warning title=Тема клиентских инцидентов не задана::METRICS_TELEGRAM_TOPIC_ID пуст — alert-ack отправит инцидент в общую тему чата, где его не читают." + fi + + # Инфраструктурная тема (#3163): telegram/telegram-heartbeat + # адресуют СЮДА, отдельно от темы клиентских инцидентов — иначе + # инфраструктурный шум (диск, память, просевший экспортер) и + # клиентский инцидент смешиваются в одной ленте и приучают + # пролистывать обе. + # + # Подставляем ЦЕЛОЙ СТРОКОЙ, а не значением, потому что envsubst + # не умеет условий: при пустом топике в конфиг попал бы + # `message_thread_id:` без значения, и Alertmanager не стартовал + # бы вовсе — то есть алертинг исчез бы целиком, а не «ушёл не в + # ту тему». + # + # Откат обязателен: пока METRICS_TELEGRAM_INFRA_TOPIC_ID не + # прописана на хосте, берём METRICS_TELEGRAM_TOPIC_ID — тогда + # поведение остаётся ровно прежним, а не ломается молча. + if [ -n "${METRICS_TELEGRAM_INFRA_TOPIC_ID:-}" ]; then + INFRA_TOPIC_ID="${METRICS_TELEGRAM_INFRA_TOPIC_ID}" + INFRA_TOPIC_IS_FALLBACK=0 + else + INFRA_TOPIC_ID="${METRICS_TELEGRAM_TOPIC_ID:-}" + INFRA_TOPIC_IS_FALLBACK=1 + fi + if [ -n "${INFRA_TOPIC_ID}" ]; then + METRICS_TELEGRAM_INFRA_TOPIC_LINE=" message_thread_id: ${INFRA_TOPIC_ID}" + if [ "${INFRA_TOPIC_IS_FALLBACK}" = "1" ]; then + echo "Инфраструктура: METRICS_TELEGRAM_INFRA_TOPIC_ID не задана — откат на METRICS_TELEGRAM_TOPIC_ID, топик ${INFRA_TOPIC_ID}." + else + echo "Инфраструктура: адресуются в топик ${INFRA_TOPIC_ID}." + fi + else + METRICS_TELEGRAM_INFRA_TOPIC_LINE="" + echo "Инфраструктура: топик не задан — уйдут в общую тему чата." fi if [ -n "${METRICS_TELEGRAM_ONCALL:-}" ]; then @@ -169,9 +204,9 @@ jobs: METRICS_TELEGRAM_BOT_TOKEN="$METRICS_TELEGRAM_BOT_TOKEN" \ METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \ - METRICS_TELEGRAM_TOPIC_LINE="$METRICS_TELEGRAM_TOPIC_LINE" \ + METRICS_TELEGRAM_INFRA_TOPIC_LINE="$METRICS_TELEGRAM_INFRA_TOPIC_LINE" \ METRICS_TELEGRAM_ONCALL="${METRICS_TELEGRAM_ONCALL:-}" \ - envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_TOPIC_LINE} ${METRICS_TELEGRAM_ONCALL}' \ + envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_INFRA_TOPIC_LINE} ${METRICS_TELEGRAM_ONCALL}' \ < ops/metrics/alertmanager/alertmanager.yml.tmpl \ > ops/metrics/alertmanager/alertmanager.yml chmod 600 ops/metrics/alertmanager/alertmanager.yml diff --git a/backend/tests/ops/test_3078_alert_topic.py b/backend/tests/ops/test_3078_alert_topic.py index 67bf2766..07b4597e 100644 --- a/backend/tests/ops/test_3078_alert_topic.py +++ b/backend/tests/ops/test_3078_alert_topic.py @@ -16,6 +16,14 @@ алертинга: контейнер просто не поднимется. Поэтому деплой формирует либо всю строку с отступом, либо пустую. +ПОЧЕМУ ДВЕ ТЕМЫ (#3163), А НЕ ОДНА. До этого тикета оба прямых получателя +(`telegram`, `telegram-heartbeat`) и клиентские инциденты брали топик из ОДНОЙ +переменной — и инфраструктурная тема «метрики» оставалась пустой, а весь трафик, +и клиентский, и инфраструктурный, копился в теме «алерты». Владелец решил +развести: инфраструктура — в «метрики», клиентские инциденты — в «алерты». +Тесты ниже закрепляют именно это: `telegram`/`telegram-heartbeat` получают +ИНФРАСТРУКТУРНУЮ тему, а не общую. + Тесты рендерят шаблон обоими способами и разбирают результат как YAML — проверяется фактический конфиг, а не наличие нужных слов в тексте. """ @@ -33,17 +41,28 @@ 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" +INFRA_TOPIC_LINE = " message_thread_id: 245" -def _render(topic_line: str) -> dict: - """Повторяет подстановку деплоя и разбирает результат как YAML.""" +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_TOPIC_LINE}", topic_line) + .replace("${METRICS_TELEGRAM_INFRA_TOPIC_LINE}", infra_topic_line) + ) + assert "${" not in rendered, ( + "в отрендеренном конфиге остался литерал плейсхолдера — " + "значит в шаблоне появилась подстановка, о которой тест не знает" ) return yaml.safe_load(rendered) @@ -57,19 +76,22 @@ def _telegram_configs(cfg: dict) -> list[dict]: def test_topic_lands_in_every_telegram_receiver() -> None: - """Топик проставляется во ВСЕХ получателях, а не только в основном. + """Оба прямых получателя адресуют ИНФРАСТРУКТУРНУЮ тему, а не клиентскую (#3163). - Получателей два — `telegram` и `telegram-heartbeat`. Если heartbeat уйдёт - в общую тему, «мониторинг жив» будет капать мимо, и это заметят не сразу. + Получателей два — `telegram` и `telegram-heartbeat`. До разделения тем оба + брали топик из одной переменной с клиентскими инцидентами, и тема «метрики» + (245) оставалась пустой. Если heartbeat уйдёт не в ту тему, «мониторинг жив» + будет капать мимо, и это заметят не сразу — сюда же попадёт и весь + инфраструктурный шум. """ - cfgs = _telegram_configs(_render(TOPIC_LINE)) + 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") == 42, f"топик не проставлен: {c}" + assert c.get("message_thread_id") == 245, f"инфраструктурный топик не проставлен: {c}" -def test_without_topic_field_is_absent_not_empty() -> None: - """Без топика поля нет вовсе — не пустое значение. +def test_without_infra_topic_field_is_absent_not_empty() -> None: + """Без инфраструктурной темы поля нет вовсе — не пустое значение. Ядро регресса: `message_thread_id:` без значения уронил бы Alertmanager, то есть выключил бы алертинг целиком, а не «просто отправил бы в общую тему». @@ -81,28 +103,54 @@ def test_without_topic_field_is_absent_not_empty() -> None: def test_deploy_computes_whole_line_and_passes_it_to_envsubst() -> None: - """Деплой формирует строку целиком и объявляет переменную в envsubst. + """Деплой формирует строку темы целиком и объявляет её в envsubst. `envsubst` подставляет ТОЛЬКО перечисленные ему переменные. Забыть добавить новую в список — значит оставить в готовом конфиге литерал - `${METRICS_TELEGRAM_TOPIC_LINE}`, на котором Alertmanager не стартует. + `${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_TOPIC_ID" in text, ( + "деплой не читает переменную клиентского топика — она нужна как откат" + ) + assert "METRICS_TELEGRAM_INFRA_TOPIC_ID" in text, ( + "деплой не читает переменную инфраструктурного топика" + ) assert re.search( - r"METRICS_TELEGRAM_TOPIC_LINE=\"\s+message_thread_id: \$\{METRICS_TELEGRAM_TOPIC_ID\}\"", + 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_TOPIC_LINE}" in envsubst.group(1), ( - "переменная топика не объявлена в 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. diff --git a/docker-compose.metrics.yml b/docker-compose.metrics.yml index 723a4f03..856deede 100644 --- a/docker-compose.metrics.yml +++ b/docker-compose.metrics.yml @@ -187,6 +187,10 @@ services: environment: METRICS_TELEGRAM_BOT_TOKEN: ${METRICS_TELEGRAM_BOT_TOKEN:-} METRICS_TELEGRAM_CHAT_ID: ${METRICS_TELEGRAM_CHAT_ID:-} + # 158 — тема клиентских инцидентов (#3163). Инфраструктурная тема (245) + # теперь отдельная и живёт в конфиге Alertmanager + # (ops/metrics/alertmanager/alertmanager.yml.tmpl), не здесь: alert-ack + # получает только клиентские инциденты и остаётся на этой переменной. METRICS_TELEGRAM_TOPIC_ID: ${METRICS_TELEGRAM_TOPIC_ID:-} METRICS_TELEGRAM_ONCALL: ${METRICS_TELEGRAM_ONCALL:-} # Внешний адрес попадает в кнопку. Пустой — сообщение уйдёт без кнопки, diff --git a/ops/metrics/alertmanager/alertmanager.yml.tmpl b/ops/metrics/alertmanager/alertmanager.yml.tmpl index 2c80f354..2cf58177 100644 --- a/ops/metrics/alertmanager/alertmanager.yml.tmpl +++ b/ops/metrics/alertmanager/alertmanager.yml.tmpl @@ -10,6 +10,16 @@ # Telegram с Beget работает — проверено серией замеров 25.08: TLS-рукопожатие # 18 из 20, getMe 4 из 4. Ломается только длинный long-poll (30 с), а отправка # сообщения — короткий запрос. +# +# ПОЧЕМУ ДВЕ ТЕМЫ, А НЕ ОДНА (#3163). Форум держит тему «алерты» (158, +# клиентские инциденты МЕРЫ) и тему «метрики» (245, инфраструктурный шум — +# диск, память, просевший экспортер). Инфраструктурный шум и клиентский +# инцидент не равны по срочности, а смешанные в одной теме они обучают +# пролистывать обе. Поэтому `telegram` и `telegram-heartbeat` ниже адресуют +# ИНФРАСТРУКТУРНУЮ тему (`METRICS_TELEGRAM_INFRA_TOPIC_LINE`). Получателя +# `telegram-clients` в этом списке нет: он не шлёт в Telegram напрямую, а +# вебхуком уходит в alert-ack, и тему адресует сам, своей переменной +# METRICS_TELEGRAM_TOPIC_ID. global: resolve_timeout: 5m @@ -71,7 +81,7 @@ receivers: telegram_configs: - bot_token: "${METRICS_TELEGRAM_BOT_TOKEN}" chat_id: ${METRICS_TELEGRAM_CHAT_ID} -${METRICS_TELEGRAM_TOPIC_LINE} +${METRICS_TELEGRAM_INFRA_TOPIC_LINE} api_url: "https://api.telegram.org" parse_mode: HTML send_resolved: true @@ -104,7 +114,7 @@ ${METRICS_TELEGRAM_TOPIC_LINE} telegram_configs: - bot_token: "${METRICS_TELEGRAM_BOT_TOKEN}" chat_id: ${METRICS_TELEGRAM_CHAT_ID} -${METRICS_TELEGRAM_TOPIC_LINE} +${METRICS_TELEGRAM_INFRA_TOPIC_LINE} api_url: "https://api.telegram.org" parse_mode: HTML send_resolved: false -- 2.45.3