All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 12s
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 / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Все три 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
414 lines
21 KiB
Python
414 lines
21 KiB
Python
#!/usr/bin/env python3
|
||
"""Приёмник алертов с кнопкой подтверждения (#3078).
|
||
|
||
ЗАЧЕМ ОТДЕЛЬНЫЙ СЕРВИС. Alertmanager умеет писать в Telegram сам, но инлайн-
|
||
клавиатуру его интеграция не поддерживает — а без кнопки нет обратной связи
|
||
«человек увидел и взял в работу». 27.08 продукты лежали 10 часов, и вопрос «а
|
||
кто-нибудь вообще это читает» было не к кому адресовать.
|
||
|
||
ПОЧЕМУ НА НАБЛЮДАТЕЛЕ, А НЕ У БОТА МЕРЫ. У `tradein-tgbot` уже есть приём
|
||
обновлений, и соблазн повесить обработку туда велик. Но он живёт на продуктовом
|
||
хосте: при падении продукта кнопка окажется мёртвой ровно в тот момент, ради
|
||
которого её и делают. Сервис живёт рядом с Alertmanager на инфраструктурной
|
||
машине и переживает смерть наблюдаемого.
|
||
|
||
ПОЧЕМУ ССЫЛКА, А НЕ CALLBACK. Callback-кнопка требует, чтобы кто-то читал
|
||
обновления бота. Бот один, и его обновления уже читает МЕРА — второй читатель
|
||
получил бы `409 Conflict` и отобрал бы сообщения у поддержки. URL-кнопка не
|
||
требует обновлений вовсе.
|
||
|
||
БЕЗ ЗАВИСИМОСТЕЙ. Только стандартная библиотека: сервис обязан подниматься даже
|
||
тогда, когда всё остальное сломано, и не тащить за собой установку пакетов.
|
||
|
||
Переменные окружения:
|
||
METRICS_TELEGRAM_BOT_TOKEN обязательна
|
||
METRICS_TELEGRAM_CHAT_ID обязательна
|
||
METRICS_TELEGRAM_TOPIC_ID тема форума (необязательна)
|
||
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
|
||
|
||
import html
|
||
import json
|
||
import logging
|
||
import os
|
||
import secrets
|
||
import threading
|
||
import time
|
||
import urllib.parse
|
||
import urllib.request
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
|
||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||
log = logging.getLogger("alert-ack")
|
||
|
||
BOT_TOKEN = os.environ.get("METRICS_TELEGRAM_BOT_TOKEN", "")
|
||
CHAT_ID = os.environ.get("METRICS_TELEGRAM_CHAT_ID", "")
|
||
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}
|
||
_PENDING: dict[str, dict] = {}
|
||
_LOCK = threading.Lock()
|
||
|
||
|
||
def _tg(method: str, payload: dict) -> dict | None:
|
||
"""Вызвать Bot API. Три попытки: путь до Telegram теряет соединения (#3059).
|
||
|
||
Отказы происходят на стадии подключения, до отправки запроса, поэтому повтор
|
||
почти никогда не дублирует уже доставленное. Потерянный алерт дороже дубля.
|
||
"""
|
||
data = urllib.parse.urlencode(payload).encode()
|
||
for attempt in (1, 2, 3):
|
||
try:
|
||
req = urllib.request.Request(API.format(BOT_TOKEN, method), data=data)
|
||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||
out = json.loads(resp.read().decode())
|
||
if attempt > 1:
|
||
log.info("%s: доставлено с попытки %d", method, attempt)
|
||
return out
|
||
except Exception as exc: # noqa: BLE001 — падать нельзя, это канал алертов
|
||
log.warning("%s: попытка %d не удалась: %s", method, attempt, exc)
|
||
if attempt < 3:
|
||
time.sleep(2)
|
||
log.error("%s: три попытки подряд неудачны — сообщение НЕ доставлено", method)
|
||
return None
|
||
|
||
|
||
def _purge() -> None:
|
||
"""Выбросить протухшие токены. Без этого словарь растёт до перезапуска."""
|
||
now = time.time()
|
||
with _LOCK:
|
||
dead = [t for t, v in _PENDING.items() if now - v["created"] > TTL_SEC]
|
||
for t in dead:
|
||
del _PENDING[t]
|
||
|
||
|
||
def _render(payload: dict) -> tuple[str, str]:
|
||
"""Собрать текст сообщения и краткий заголовок инцидента."""
|
||
status = payload.get("status", "firing")
|
||
alerts = payload.get("alerts") or []
|
||
common = payload.get("commonLabels") or {}
|
||
first = (alerts[0].get("labels") or {}).get("alertname") if alerts else "?"
|
||
name = common.get("alertname") or first
|
||
host = common.get("host", "")
|
||
title = f"{name}{' · ' + host if host else ''}"
|
||
|
||
if status == "resolved":
|
||
return f"✅ <b>Восстановлено</b> · {html.escape(title)}", title
|
||
|
||
lines = [f"🚨 <b>КЛИЕНТЫ ЗАТРОНУТЫ</b> · {html.escape(title)}"]
|
||
if ONCALL:
|
||
lines.append(f"{html.escape(ONCALL)} — нужна реакция.")
|
||
for a in alerts[:5]:
|
||
ann = a.get("annotations") or {}
|
||
if ann.get("summary"):
|
||
lines.append(html.escape(ann["summary"]))
|
||
if ann.get("description"):
|
||
lines.append(html.escape(ann["description"]))
|
||
return "\n".join(lines), title
|
||
|
||
|
||
def _send_alert(payload: dict) -> None:
|
||
text, title = _render(payload)
|
||
msg = {
|
||
"chat_id": CHAT_ID,
|
||
"text": text,
|
||
"parse_mode": "HTML",
|
||
"disable_web_page_preview": "true",
|
||
}
|
||
if TOPIC_ID:
|
||
msg["message_thread_id"] = TOPIC_ID
|
||
|
||
# Кнопка только у горящего инцидента: подтверждать нечего, когда всё прошло.
|
||
token = ""
|
||
if payload.get("status", "firing") == "firing" and PUBLIC_URL:
|
||
token = secrets.token_urlsafe(16)
|
||
msg["reply_markup"] = json.dumps(
|
||
{
|
||
"inline_keyboard": [
|
||
[{"text": "✅ Принял в работу", "url": f"{PUBLIC_URL}/ack/{token}"}]
|
||
]
|
||
}
|
||
)
|
||
|
||
out = _tg("sendMessage", msg)
|
||
if token and out and out.get("ok"):
|
||
with _LOCK:
|
||
_PENDING[token] = {
|
||
"message_id": out["result"]["message_id"],
|
||
"title": title,
|
||
"created": time.time(),
|
||
"acked_by": None,
|
||
}
|
||
elif token:
|
||
# Кнопку прицепить не вышло — сообщение важнее кнопки, шлём без неё.
|
||
msg.pop("reply_markup", 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 = (
|
||
"<!doctype html><meta charset=utf-8>"
|
||
"<title>{t}</title>"
|
||
"<body style='font:16px system-ui;margin:3rem;max-width:32rem'>"
|
||
"<h1 style='font-size:1.4rem'>{h}</h1><p>{p}</p></body>"
|
||
)
|
||
|
||
|
||
def do_ack(token: str) -> tuple[int, str]:
|
||
"""Обработать нажатие кнопки. Вынесено из HTTP-слоя, чтобы быть проверяемым.
|
||
|
||
Возвращает (код ответа, HTML-страница). Идемпотентно: повторное нажатие не
|
||
шлёт второе сообщение в чат — ссылка живёт сутки, и по ней могут кликнуть
|
||
дважды, а дубль «принято» в теме выглядит как второй человек.
|
||
"""
|
||
_purge()
|
||
with _LOCK:
|
||
entry = _PENDING.get(token)
|
||
|
||
if entry is None:
|
||
# Неизвестный или протухший токен. Не рассказываем, что именно: эндпоинт
|
||
# публичный, и перебор не должен получать подсказок.
|
||
return 404, _PAGE.format(
|
||
t="Не найдено",
|
||
h="Ссылка недействительна",
|
||
p="Возможно, инцидент уже подтверждён или ссылка устарела.",
|
||
)
|
||
|
||
if entry["acked_by"]:
|
||
return 200, _PAGE.format(
|
||
t="Уже подтверждено",
|
||
h="Уже подтверждено",
|
||
p=f"Инцидент «{html.escape(entry['title'])}» уже отмечен как принятый.",
|
||
)
|
||
|
||
who = ONCALL or "дежурный"
|
||
when = time.strftime("%H:%M", time.localtime())
|
||
with _LOCK:
|
||
entry["acked_by"] = who
|
||
|
||
reply = {
|
||
"chat_id": CHAT_ID,
|
||
"text": (
|
||
f"✅ <b>Принято в работу</b> · {html.escape(entry['title'])}\n"
|
||
f"{html.escape(who)}, {when}"
|
||
),
|
||
"parse_mode": "HTML",
|
||
"reply_to_message_id": entry["message_id"],
|
||
}
|
||
if TOPIC_ID:
|
||
reply["message_thread_id"] = TOPIC_ID
|
||
_tg("sendMessage", reply)
|
||
|
||
# Снимаем кнопку у исходного сообщения, чтобы её не жали повторно.
|
||
_tg(
|
||
"editMessageReplyMarkup",
|
||
{"chat_id": CHAT_ID, "message_id": entry["message_id"], "reply_markup": json.dumps({})},
|
||
)
|
||
|
||
return 200, _PAGE.format(
|
||
t="Принято",
|
||
h="Принято в работу",
|
||
p=f"Отметка отправлена в чат. Инцидент: «{html.escape(entry['title'])}».",
|
||
)
|
||
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
protocol_version = "HTTP/1.1"
|
||
|
||
def log_message(self, fmt: str, *args) -> None: # noqa: A003 — подпись из stdlib
|
||
log.info("%s %s", self.address_string(), fmt % args)
|
||
|
||
def _reply(self, code: int, body: bytes, ctype: str = "text/html; charset=utf-8") -> None:
|
||
self.send_response(code)
|
||
self.send_header("Content-Type", ctype)
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
def do_GET(self) -> None: # noqa: N802 — имя из stdlib
|
||
if self.path == "/healthz":
|
||
self._reply(200, b"ok", "text/plain; charset=utf-8")
|
||
return
|
||
if not self.path.startswith("/ack/"):
|
||
self._reply(404, b"not found", "text/plain; charset=utf-8")
|
||
return
|
||
code, page = do_ack(self.path[len("/ack/") :].split("?")[0])
|
||
self._reply(code, page.encode())
|
||
|
||
def do_POST(self) -> None: # noqa: N802 — имя из stdlib
|
||
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)
|
||
raw = self.rfile.read(length) if length else b"{}"
|
||
try:
|
||
payload = json.loads(raw.decode() or "{}")
|
||
except Exception: # noqa: BLE001
|
||
log.warning("вебхук: тело не разобрано, %d байт", len(raw))
|
||
self._reply(400, b"bad json", "text/plain; charset=utf-8")
|
||
return
|
||
|
||
# Отвечаем Alertmanager сразу: его таймаут короткий, а отправка в
|
||
# Telegram может занять до трёх попыток. Иначе он посчитает доставку
|
||
# неудачной и начнёт слать повторы поверх уже отправленного.
|
||
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]
|
||
if missing:
|
||
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()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|