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"]