From b8d2e74951431ebaa8681fc1af249e4d793e3c5d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 16:24:59 +0300 Subject: [PATCH] =?UTF-8?q?fix(ops):=20=D0=BD=D0=B5=20=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA=20=D0=BF=D1=83=D1=81=D1=82?= =?UTF-8?q?=D1=8B=D0=BC/=D0=BD=D0=B5=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=BC=20watchdog-=D1=81=D0=B5=D0=BA=D1=80?= =?UTF-8?q?=D0=B5=D1=82=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit amtool check-config падал на "unsupported scheme \"\" for URL": шаблон заводил url: "${METRICS_WATCHDOG_PING_URL}" напрямую, а envsubst в deploy-metrics.yml вызывается с явным allow-list переменных, в который эту новую переменную не добавили — литерал плейсхолдера (или пустая строка, если бы добавили без блока) доезжал до amtool и валил проверку целиком, то есть ронял ВЕСЬ Alertmanager, а не только Watchdog. Тот же приём, что уже применён для темы форума: подставляется ЦЕЛЫЙ БЛОК (webhook_configs целиком), а не голое значение url. Собирается в deploy-metrics.yml через METRICS_WATCHDOG_PING_BLOCK — если секрет задан, блок с url и send_resolved; если пуст, пустая строка и ::warning в лог. Пустой блок оставляет receiver watchdog-ping без единого *_configs — это валидный конфиг Alertmanager (приёмник молча ничего не шлёт), а не деградация всего алертинга. METRICS_WATCHDOG_PING_URL добавлен в env/envs appleboy/ssh-action и в allow-list envsubst. _render() в test_3078_alert_topic.py переписан так, чтобы подставлять РОВНО тот набор переменных, что реально в allow-list деплоя (вытащен регексом из самого воркфлоу), а не свой отдельный список — так и воспроизвёлся бы этот регресс автоматически. Добавлен явный тест test_every_template_placeholder_is_in_envsubst_allowlist на этот инвариант и два теста на обе ветки watchdog-секрета (пуст / задан), проверенные amtool check-config на prom/alertmanager:v0.28.0 вручную для обоих рендеров. --- .forgejo/workflows/deploy-metrics.yml | 27 +++++- backend/tests/ops/test_3078_alert_topic.py | 96 +++++++++++++++++-- .../alertmanager/alertmanager.yml.tmpl | 19 ++-- 3 files changed, 125 insertions(+), 17 deletions(-) diff --git a/.forgejo/workflows/deploy-metrics.yml b/.forgejo/workflows/deploy-metrics.yml index 01407644..474d38d9 100644 --- a/.forgejo/workflows/deploy-metrics.yml +++ b/.forgejo/workflows/deploy-metrics.yml @@ -91,12 +91,16 @@ jobs: METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }} METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }} ALERT_ACK_GLITCHTIP_SECRET: ${{ secrets.ALERT_ACK_GLITCHTIP_SECRET }} + # #3589: URL внешнего deadman-приёмника Watchdog (healthchecks.io и + # аналоги). Пусто — watchdog-ping остаётся без конфигов, см. блок + # METRICS_WATCHDOG_PING_BLOCK ниже. + METRICS_WATCHDOG_PING_URL: ${{ secrets.METRICS_WATCHDOG_PING_URL }} # #3471: секрет ретранслятора Telegram Bot API (tg-relay). Пусто — # профиль relay не включаем (см. PROFILES ниже), а не падаем в # рестарт-луп: контейнер сам делает SystemExit на пустом секрете. TG_RELAY_SECRET: ${{ secrets.TG_RELAY_SECRET }} with: - envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL,ALERT_ACK_GLITCHTIP_SECRET,TG_RELAY_SECRET + envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL,ALERT_ACK_GLITCHTIP_SECRET,TG_RELAY_SECRET,METRICS_WATCHDOG_PING_URL 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 }} @@ -193,6 +197,24 @@ jobs: echo "Инфраструктура: тема ${INFRA_TOPIC_ID} по умолчанию (METRICS_TELEGRAM_INFRA_TOPIC_ID не задана)." fi + # Watchdog-пинг во внешний deadman-приёмник (healthchecks.io и + # аналоги) — заменяет регулярные сообщения в Telegram, см. + # комментарий у watchdog-ping в alertmanager.yml.tmpl. Блок + # целиком, а не значение — та же причина, что у + # METRICS_TELEGRAM_INFRA_TOPIC_LINE: пустой `url:` в + # receiver'е не деградирует, а валит amtool check-config + # целиком, то есть роняет ВЕСЬ алертинг из-за одного + # необязательного получателя. Секрет пока не заведён — + # деградация корректна: receiver остаётся без `*_configs` и + # молча ничего никуда не шлёт, amtool это пропускает. + if [ -n "${METRICS_WATCHDOG_PING_URL:-}" ]; then + METRICS_WATCHDOG_PING_BLOCK=$(printf ' webhook_configs:\n - url: "%s"\n send_resolved: false' "${METRICS_WATCHDOG_PING_URL}") + echo "Watchdog: внешний deadman-пинг настроен." + else + METRICS_WATCHDOG_PING_BLOCK="" + echo "::warning title=Watchdog без deadman-пинга::METRICS_WATCHDOG_PING_URL пуст — сторож мониторинга никуда не сообщает о своей живости. Заведи аккаунт healthchecks.io (или аналог) и секрет, иначе обрыв канала доставки не заметит никто (#3589)." + fi + # Резервный приёмник GlitchTip (#3471) отвечает 503 на любой # запрос, пока секрет пуст: тихо принимать чужие алерты настежь # хуже, чем не принимать вовсе. Молчаливого отказа тут быть не @@ -221,7 +243,8 @@ jobs: METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \ 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_INFRA_TOPIC_LINE} ${METRICS_TELEGRAM_ONCALL}' \ + METRICS_WATCHDOG_PING_BLOCK="$METRICS_WATCHDOG_PING_BLOCK" \ + envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_INFRA_TOPIC_LINE} ${METRICS_TELEGRAM_ONCALL} ${METRICS_WATCHDOG_PING_BLOCK}' \ < 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 4e0a58d3..0daa4f5a 100644 --- a/backend/tests/ops/test_3078_alert_topic.py +++ b/backend/tests/ops/test_3078_alert_topic.py @@ -44,9 +44,56 @@ WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml" INFRA_TOPIC_LINE = " message_thread_id: 245" +# Заглушки для переменных, которые деплой может подставить. Значение +# METRICS_WATCHDOG_PING_BLOCK по умолчанию пустое — это реальный дефолт +# деплоя, когда секрет не заведён (#3589), а не тестовое упрощение. +_DUMMY_VALUES = { + "METRICS_TELEGRAM_BOT_TOKEN": "123:ABC", + "METRICS_TELEGRAM_CHAT_ID": "-100123", + "METRICS_TELEGRAM_ONCALL": "", + "METRICS_WATCHDOG_PING_BLOCK": "", +} -def _render(infra_topic_line: str) -> dict: - """Повторяет подстановку деплоя и разбирает результат как YAML. + +def _envsubst_allowlist() -> set[str]: + """Реальный список переменных, которые деплой передаёт в envsubst. + + Не хардкодим копию списка — #3589 случился именно так: шаблон завёл + `${METRICS_WATCHDOG_PING_URL}`, а список envsubst в деплое не пополнили, + и тест этого не заметил, потому что сам подставлял значение мимо деплоя. + """ + assert WORKFLOW.is_file(), f"нет {WORKFLOW} — воркфлоу переехал, гейт ослеп" + text = WORKFLOW.read_text(encoding="utf-8") + m = re.search(r"envsubst '([^']+)'", text) + assert m, "не нашёл вызов envsubst в деплое" + return set(re.findall(r"\$\{(\w+)\}", m.group(1))) + + +def _template_placeholders() -> set[str]: + text = TMPL.read_text(encoding="utf-8") + return set(re.findall(r"\$\{(\w+)\}", text)) + + +def test_every_template_placeholder_is_in_envsubst_allowlist() -> None: + """Регресс #3589: переменная шаблона обязана быть в allow-list envsubst. + + Тогда в шаблоне появился `${METRICS_WATCHDOG_PING_URL}`, а список + envsubst в деплое не пополнили. envsubst подставляет ТОЛЬКО + перечисленные переменные — забытая долетает до `amtool check-config` + литералом плейсхолдера и валит проверку (`unsupported scheme ""`), то + есть роняет ВЕСЬ Alertmanager, а не только Watchdog. + """ + missing = _template_placeholders() - _envsubst_allowlist() + assert not missing, f"эти переменные шаблона деплой не подставляет: {missing}" + + +def _render(infra_topic_line: str, watchdog_ping_block: str | None = None) -> dict: + """Повторяет ТОЧНО ТУ ЖЕ подстановку, что делает деплой, и разбирает YAML. + + Подставляются только переменные из реального allow-list envsubst деплоя + (`_envsubst_allowlist`) — не весь известный тесту набор. Так регресс + #3589 (переменная в шаблоне, забытая в allow-list) ловится именно здесь: + `assert "${" not in rendered` ниже упадёт, если что-то не подставилось. Строка темы в шаблоне ровно одна — инфраструктурная (#3163). Тема клиентских инцидентов сюда не подставляется вовсе: маршрут @@ -56,12 +103,18 @@ def _render(infra_topic_line: str) -> dict: """ 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) - .replace("${METRICS_WATCHDOG_PING_URL}", "https://hc-ping.com/dummy") - ) + + values = dict(_DUMMY_VALUES) + values["METRICS_TELEGRAM_INFRA_TOPIC_LINE"] = infra_topic_line + if watchdog_ping_block is not None: + values["METRICS_WATCHDOG_PING_BLOCK"] = watchdog_ping_block + + allowlist = _envsubst_allowlist() + rendered = text + for name, value in values.items(): + if name in allowlist: + rendered = rendered.replace("${" + name + "}", value) + assert "${" not in rendered, ( "в отрендеренном конфиге остался литерал плейсхолдера — " "значит в шаблоне появилась подстановка, о которой тест не знает" @@ -69,6 +122,33 @@ def _render(infra_topic_line: str) -> dict: return yaml.safe_load(rendered) +def test_watchdog_receiver_without_secret_has_no_configs_and_parses() -> None: + """Секрет не заведён (реальное состояние прода сейчас) — конфиг всё равно жив. + + `watchdog-ping` остаётся без единого `*_configs` — валидный receiver, + Alertmanager его просто пропускает. Деградация корректна: Watchdog никуда + не пингует, но остальной алертинг (`telegram`, `telegram-clients`) цел. + """ + cfg = _render(INFRA_TOPIC_LINE, watchdog_ping_block="") + receivers = {r["name"]: r for r in cfg["receivers"]} + assert "watchdog-ping" in receivers, "receiver watchdog-ping пропал из конфига" + watchdog = receivers["watchdog-ping"] + assert "webhook_configs" not in watchdog, "пустой секрет не должен оставлять webhook_configs" + assert "telegram" in receivers and "telegram-clients" in receivers, ( + "остальной алертинг не должен пострадать из-за пустого watchdog-секрета" + ) + + +def test_watchdog_receiver_with_secret_gets_webhook() -> None: + """Секрет задан — Watchdog реально пингует внешний deadman-приёмник.""" + block = ' webhook_configs:\n - url: "https://hc-ping.com/dummy"\n send_resolved: false' + cfg = _render(INFRA_TOPIC_LINE, watchdog_ping_block=block) + receivers = {r["name"]: r for r in cfg["receivers"]} + hooks = receivers["watchdog-ping"].get("webhook_configs") or [] + assert hooks and hooks[0].get("url") == "https://hc-ping.com/dummy" + assert hooks[0].get("send_resolved") is False + + def _telegram_configs(cfg: dict) -> list[dict]: out = [] for r in cfg.get("receivers", []): diff --git a/ops/metrics/alertmanager/alertmanager.yml.tmpl b/ops/metrics/alertmanager/alertmanager.yml.tmpl index 037f0acc..a95c80b6 100644 --- a/ops/metrics/alertmanager/alertmanager.yml.tmpl +++ b/ops/metrics/alertmanager/alertmanager.yml.tmpl @@ -153,11 +153,16 @@ ${METRICS_TELEGRAM_INFRA_TOPIC_LINE} send_resolved: true # Внешний deadman-приёмник вместо Telegram — см. комментарий у маршрута - # Watchdog выше. URL — HTTPS-пинг вида healthchecks.io/ping/, - # значение живёт в METRICS_WATCHDOG_PING_URL на хосте (как и остальные - # METRICS_* секреты, в git не попадает). `send_resolved: false`: сервису - # не нужно resolved-событие, важен сам факт регулярного POST. + # Watchdog выше. Блок целиком (не значение) подставляется деплоем в + # METRICS_WATCHDOG_PING_BLOCK — тот же приём, что у + # METRICS_TELEGRAM_INFRA_TOPIC_LINE, и по той же причине: envsubst не умеет + # условий. Секрет ещё не заведён на хосте — деплой в этом случае подставит + # ПУСТУЮ строку, и receiver останется без единого `*_configs`. Это валидный + # Alertmanager-конфиг: приёмник без конфигов просто молча отбрасывает + # уведомление, амtool его пропускает. Одинарная подстановка ЗНАЧЕНИЯ url + # (переменная-URL напрямую внутри готового ключа `url:`) сюда не годится: + # непустой ключ с пустым значением или литералом плейсхолдера амtool валит + # целиком (`unsupported scheme ""`), а с этим — весь Alertmanager, не + # только Watchdog. - name: watchdog-ping - webhook_configs: - - url: "${METRICS_WATCHDOG_PING_URL}" - send_resolved: false +${METRICS_WATCHDOG_PING_BLOCK}