From 423842ae36e1b4cb8494a58c7b0e9dd2dbb3132d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 14:01:30 +0300 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=D1=80=D0=B5=D0=B7=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=20GlitchTip-=D0=B0=D0=BB=D0=B5=D1=80?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D0=B2=20alert-ack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Все три alert-правила GlitchTip (backend, frontend, Trade-In) сейчас шлют единственный вебхук в продуктовый бэкенд на Selectel — тот самый хост, за которым они следят. Если там упал backend или Caddy, ошибки приложения задерживаются или пропадают именно тогда, когда нужнее всего. Добавлен POST /glitchtip в alert-ack (живёт на инфраструктурном хосте Beget, не зависит от здоровья продукта): второй получатель того же Slack-совместимого payload, аутентификация секретом в заголовке X-GlitchTip-Secret или query ?secret= (тот же подход, что у tradein-mvp/backend/app/api/v1/glitchtip.py). Сообщение уходит в существующую тему клиентских инцидентов с явной пометкой «резервный канал». Секрет свой (ALERT_ACK_GLITCHTIP_SECRET), не переиспользует продуктовый TRADEIN_INTERNAL_AUTH_SECRET. Refs #3471 --- docker-compose.metrics.yml | 6 ++ ops/metrics/alert-ack/app.py | 139 +++++++++++++++++++++++++++++- ops/metrics/alert-ack/test_app.py | 117 +++++++++++++++++++++++++ 3 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 ops/metrics/alert-ack/test_app.py diff --git a/docker-compose.metrics.yml b/docker-compose.metrics.yml index 856deede..4854780f 100644 --- a/docker-compose.metrics.yml +++ b/docker-compose.metrics.yml @@ -196,6 +196,12 @@ services: # Внешний адрес попадает в кнопку. Пустой — сообщение уйдёт без кнопки, # но уйдёт: алерт важнее подтверждения. ALERT_ACK_PUBLIC_URL: ${ALERT_ACK_PUBLIC_URL:-https://metrics.gendsgn.ru} + # Резервный получатель GlitchTip-алертов (#3471, POST /glitchtip) — второй + # получатель наряду с основным вебхуком в продуктовый бэкенд на Selectel. + # Секрет СВОЙ, не общий с продуктовым TRADEIN_INTERNAL_AUTH_SECRET: разные + # хосты/домены безопасности. Пусто — эндпоинт отвечает 503, остальной + # функционал сервиса не затронут. + ALERT_ACK_GLITCHTIP_SECRET: ${ALERT_ACK_GLITCHTIP_SECRET:-} volumes: - ./ops/metrics/alert-ack/app.py:/app/app.py:ro expose: diff --git a/ops/metrics/alert-ack/app.py b/ops/metrics/alert-ack/app.py index 323f077d..0ca1f2ea 100644 --- a/ops/metrics/alert-ack/app.py +++ b/ops/metrics/alert-ack/app.py @@ -27,6 +27,28 @@ METRICS_TELEGRAM_ONCALL кого звать поимённо (необязательна) ALERT_ACK_PUBLIC_URL внешний адрес сервиса, попадает в кнопку ALERT_ACK_TTL_MIN сколько минут живёт токен (по умолчанию 1440) + ALERT_ACK_GLITCHTIP_SECRET секрет резервного вебхука GlitchTip (#3471, + см. POST /glitchtip ниже); пусто — 503 + +РЕЗЕРВНЫЙ КАНАЛ GLITCHTIP (#3471). Все три alert-правила GlitchTip (backend, +frontend, Trade-In) шлют основной вебхук в продуктовый бэкенд на Selectel — +тот самый хост, за которым они следят. Если там упал сам бэкенд или Caddy, +алерт об этом теряется именно тогда, когда нужнее всего. `POST /glitchtip` +— второй получатель того же алерта, зарегистрированный в GlitchTip отдельной +строкой; живёт на ЭТОМ (инфраструктурном, Beget) хосте и не зависит от +здоровья продукта. Формат тела — тот же Slack-совместимый payload, что и у +продуктового приёмника (`tradein-mvp/backend/app/api/v1/glitchtip.py`): +``{"text": str, "attachments": [{"title","title_link","text","color", +"fields":[{"title","value"}]}]}``, GlitchTip заголовков не шлёт вовсе — +аутентификация только через секрет в query (``?secret=``) или в заголовке +``X-GlitchTip-Secret`` (тот же выбор, что там же и по той же причине: заголовок +не течёт в access-log, query остаётся, т.к. сам GlitchTip 6.1.6 заголовков не +добавляет). Секрет намеренно СВОЙ (``ALERT_ACK_GLITCHTIP_SECRET``), а не общий +с продуктовым ``TRADEIN_INTERNAL_AUTH_SECRET`` — секреты разных хостов/доменов +безопасности компрометировать вместе незачем. Сообщение уходит в ту же тему +клиентских инцидентов (``METRICS_TELEGRAM_CHAT_ID``/``_TOPIC_ID``), что и +Alertmanager-алерты через alert-ack, с явной пометкой «резервный канал», чтобы +не спутать с основным путём. """ from __future__ import annotations @@ -51,6 +73,7 @@ TOPIC_ID = os.environ.get("METRICS_TELEGRAM_TOPIC_ID", "") ONCALL = os.environ.get("METRICS_TELEGRAM_ONCALL", "") PUBLIC_URL = os.environ.get("ALERT_ACK_PUBLIC_URL", "").rstrip("/") TTL_SEC = int(os.environ.get("ALERT_ACK_TTL_MIN", "1440")) * 60 +GLITCHTIP_SECRET = os.environ.get("ALERT_ACK_GLITCHTIP_SECRET", "") API = "https://api.telegram.org/bot{}/{}" # token -> {"message_id": int, "title": str, "created": float, "acked_by": str|None} @@ -153,6 +176,79 @@ def _send_alert(payload: dict) -> None: _tg("sendMessage", msg) +_GLITCHTIP_TEXT_LIMIT = 3500 # запас под баннер+имя проекта до лимита Telegram 4096 + + +def _verify_glitchtip_secret(provided: str) -> bool: + """Constant-time сравнение — длина/префикс секрета не утекают через время + ответа (тот же приём, что у продуктового приёмника, см. docstring модуля).""" + return bool(GLITCHTIP_SECRET) and secrets.compare_digest(provided or "", GLITCHTIP_SECRET) + + +def _glitchtip_field(attachment: dict, label: str) -> str | None: + for field in attachment.get("fields") or []: + if not isinstance(field, dict): + continue + if str(field.get("title", "")).strip().lower() == label.lower(): + value = field.get("value") + return str(value) if value is not None else None + return None + + +def _render_glitchtip(payload: dict) -> str: + """Собрать текст сообщения из Slack-совместимого payload GlitchTip. + + Максимально терпимо к форме тела: GlitchTip шлёт ОДИНАКОВУЮ структуру для + issue- и uptime-алертов, но поля внутри attachments опциональны, а тестовое + сообщение из UI GlitchTip может не иметь attachments вовсе. Ничего в теле + не считаем обязательным — падать сервису на резервном канале нельзя. + """ + lines = ["⚠️ РЕЗЕРВНЫЙ КАНАЛ (GlitchTip → alert-ack)"] + lines.append("Основной путь через продуктовый бэкенд мог быть недоступен.") + lines.append("") + text = payload.get("text") + lines.append(html.escape(str(text)) if text else "GlitchTip alert") + + attachments = payload.get("attachments") + for attachment in attachments if isinstance(attachments, list) else []: + if not isinstance(attachment, dict): + continue + block: list[str] = [] + project = _glitchtip_field(attachment, "Project") + if project: + block.append(f"Проект: {html.escape(project)}") + if attachment.get("title"): + block.append(html.escape(str(attachment["title"]))) + if attachment.get("text"): + block.append(html.escape(str(attachment["text"]))) + if attachment.get("title_link"): + block.append(f"Ссылка: {html.escape(str(attachment['title_link']))}") + if block: + lines.append("") + lines.extend(block) + + out = "\n".join(lines) + if len(out) > _GLITCHTIP_TEXT_LIMIT: + out = out[:_GLITCHTIP_TEXT_LIMIT] + "\n… (обрезано)" + return out + + +def _send_glitchtip_alert(payload: dict) -> None: + """Переслать вебхук GlitchTip в ту же тему клиентских инцидентов, что и + Alertmanager через этот сервис. Без кнопки подтверждения — это не + firing/resolved инцидент с состоянием, а разовое уведомление резервного + канала.""" + msg = { + "chat_id": CHAT_ID, + "text": _render_glitchtip(payload), + "parse_mode": "HTML", + "disable_web_page_preview": "true", + } + if TOPIC_ID: + msg["message_thread_id"] = TOPIC_ID + _tg("sendMessage", msg) + + _PAGE = ( "" "{t}" @@ -243,7 +339,11 @@ class Handler(BaseHTTPRequestHandler): self._reply(code, page.encode()) def do_POST(self) -> None: # noqa: N802 — имя из stdlib - if self.path != "/alertmanager": + parsed = urllib.parse.urlsplit(self.path) + if parsed.path == "/glitchtip": + self._handle_glitchtip(parsed) + return + if parsed.path != "/alertmanager": self._reply(404, b"not found", "text/plain; charset=utf-8") return length = int(self.headers.get("Content-Length") or 0) @@ -261,6 +361,38 @@ class Handler(BaseHTTPRequestHandler): self._reply(200, b"accepted", "text/plain; charset=utf-8") threading.Thread(target=_send_alert, args=(payload,), daemon=True).start() + def _handle_glitchtip(self, parsed: urllib.parse.SplitResult) -> None: + """POST /glitchtip — резервный получатель GlitchTip-алертов (#3471). + + Секрет — из заголовка ``X-GlitchTip-Secret`` (предпочтительно, не течёт + в access-log) либо из query ``?secret=`` (fallback: GlitchTip 6.1.6 + заголовков не шлёт вовсе). Несконфигурированный секрет → 503, а не + тихий приём без проверки. Неразобранное/нестандартное тело НЕ роняет + запрос — это резервный канал, теряться на кривом JSON ему нельзя. + """ + if not GLITCHTIP_SECRET: + self._reply(503, b"glitchtip webhook not configured", "text/plain; charset=utf-8") + return + + header_secret = self.headers.get("X-GlitchTip-Secret", "") + query_secret = urllib.parse.parse_qs(parsed.query).get("secret", [""])[0] + if not _verify_glitchtip_secret(header_secret or query_secret): + log.warning("glitchtip webhook: invalid or missing secret") + self._reply(401, b"invalid or missing secret", "text/plain; charset=utf-8") + return + + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"{}" + try: + payload = json.loads(raw.decode() or "{}") + if not isinstance(payload, dict): + payload = {"text": raw.decode(errors="replace")} + except Exception: # noqa: BLE001 — резервный канал не роняем на кривом теле + payload = {"text": raw.decode(errors="replace")} + + self._reply(200, b"accepted", "text/plain; charset=utf-8") + threading.Thread(target=_send_glitchtip_alert, args=(payload,), daemon=True).start() + def main() -> None: missing = [n for n, v in (("BOT_TOKEN", BOT_TOKEN), ("CHAT_ID", CHAT_ID)) if not v] @@ -268,6 +400,11 @@ def main() -> None: raise SystemExit(f"не заданы обязательные переменные: {', '.join(missing)}") if not PUBLIC_URL: log.warning("ALERT_ACK_PUBLIC_URL пуст — сообщения уйдут БЕЗ кнопки подтверждения") + if not GLITCHTIP_SECRET: + log.warning( + "ALERT_ACK_GLITCHTIP_SECRET пуст — резервный канал GlitchTip (#3471) " + "отключён, POST /glitchtip будет отвечать 503" + ) port = int(os.environ.get("ALERT_ACK_PORT", "8080")) log.info("alert-ack слушает :%d, тема=%s, дежурный=%s", port, TOPIC_ID or "—", ONCALL or "—") ThreadingHTTPServer(("", port), Handler).serve_forever() diff --git a/ops/metrics/alert-ack/test_app.py b/ops/metrics/alert-ack/test_app.py new file mode 100644 index 00000000..019ff13c --- /dev/null +++ b/ops/metrics/alert-ack/test_app.py @@ -0,0 +1,117 @@ +"""Тесты для резервного канала GlitchTip → alert-ack (#3471). + +Продукт (tradein-backend на Selectel) — САМ объект наблюдения GlitchTip. Если +он лежит, основной вебхук-получатель лежит вместе с ним, и алерт об этом не +доходит именно тогда, когда нужнее всего. `POST /glitchtip` — второй +получатель на ДРУГОМ хосте (Beget, рядом с этим сервисом), не зависящий от +здоровья продукта. + +Проверяем на уровне функций, а не полного HTTP-транспорта (тот же приём, что +`ops/glitchtip-auth-forwarder/test_forwarder.py`): `BaseHTTPRequestHandler` +неудобно поднимать без реального сокета, а бизнес-логика — секрет, рендер, +отправка — целиком вынесена в чистые функции модуля. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +# Env — ДО импорта app.py: BOT_TOKEN/CHAT_ID читаются на уровне модуля. +os.environ.setdefault("METRICS_TELEGRAM_BOT_TOKEN", "test-bot-token") +os.environ.setdefault("METRICS_TELEGRAM_CHAT_ID", "-1001234567890") +os.environ.setdefault("METRICS_TELEGRAM_TOPIC_ID", "158") +os.environ["ALERT_ACK_GLITCHTIP_SECRET"] = "correct-secret" + +import app as alert_ack # noqa: E402 + + +def test_verify_glitchtip_secret_accepts_correct_value() -> None: + assert alert_ack._verify_glitchtip_secret("correct-secret") is True + + +def test_verify_glitchtip_secret_rejects_wrong_value() -> None: + """Неверный секрет — отказ.""" + assert alert_ack._verify_glitchtip_secret("wrong-secret") is False + + +def test_verify_glitchtip_secret_rejects_empty_value() -> None: + assert alert_ack._verify_glitchtip_secret("") is False + + +def test_verify_glitchtip_secret_fails_closed_when_unconfigured(monkeypatch) -> None: + """Пустой ALERT_ACK_GLITCHTIP_SECRET — отказ всем, а не тихий fail-open.""" + monkeypatch.setattr(alert_ack, "GLITCHTIP_SECRET", "") + assert alert_ack._verify_glitchtip_secret("correct-secret") is False + assert alert_ack._verify_glitchtip_secret("") is False + + +def test_render_glitchtip_full_payload_marks_fallback_channel() -> None: + payload = { + "text": "GlitchTip Alert: Something broke", + "attachments": [ + { + "title": "TypeError: boom", + "title_link": "https://errors.gendsgn.ru/issue/1", + "text": "подробности ошибки", + "color": "#ff0000", + "fields": [{"title": "Project", "value": "tradein-backend"}], + } + ], + } + text = alert_ack._render_glitchtip(payload) + assert "РЕЗЕРВНЫЙ КАНАЛ" in text + assert "Проект: tradein-backend" in text + assert "TypeError: boom" in text + assert "https://errors.gendsgn.ru/issue/1" in text + + +def test_render_glitchtip_survives_payload_without_attachments() -> None: + """Тело без attachments не роняет сервис — только текст.""" + text = alert_ack._render_glitchtip({"text": "просто текст без вложений"}) + assert "РЕЗЕРВНЫЙ КАНАЛ" in text + assert "просто текст без вложений" in text + + +def test_render_glitchtip_survives_empty_payload() -> None: + """Пустой словарь (нет ни text, ни attachments) — тоже не должен падать.""" + text = alert_ack._render_glitchtip({}) + assert "РЕЗЕРВНЫЙ КАНАЛ" in text + assert "GlitchTip alert" in text + + +def test_render_glitchtip_survives_malformed_attachments() -> None: + """attachments/fields неожиданной формы (не список, не словарь, битые + типы) — резервный канал не должен падать на кривом теле.""" + payload = { + "text": "test", + "attachments": [ + "not-a-dict", + {"fields": "not-a-list"}, + {"fields": [{"title": "Project"}]}, # value отсутствует + None, + ], + } + text = alert_ack._render_glitchtip(payload) + assert "РЕЗЕРВНЫЙ КАНАЛ" in text + + +def test_send_glitchtip_alert_forwards_via_tg(monkeypatch) -> None: + """Верный секрет уже проверен вызывающей стороной (do_POST) — здесь + проверяем, что отрендеренное сообщение реально уходит в тот же chat/topic, + что и Alertmanager-алерты этого сервиса, без кнопки подтверждения.""" + calls = [] + monkeypatch.setattr(alert_ack, "_tg", lambda method, payload: calls.append((method, payload))) + + alert_ack._send_glitchtip_alert({"text": "boom", "attachments": []}) + + assert len(calls) == 1 + method, sent = calls[0] + assert method == "sendMessage" + assert sent["chat_id"] == alert_ack.CHAT_ID + assert sent["message_thread_id"] == alert_ack.TOPIC_ID + assert "reply_markup" not in sent + assert "boom" in sent["text"]