From 053a5fb75cdc6995d1d6a76da2a57231456d4f5a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 27 Aug 2026 13:56:00 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(observability):=20=D0=BA=D0=BD=D0=BE?= =?UTF-8?q?=D0=BF=D0=BA=D0=B0=20=C2=AB=D0=9F=D1=80=D0=B8=D0=BD=D1=8F=D0=BB?= =?UTF-8?q?=20=D0=B2=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=83=C2=BB=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D0=BC=20=D0=B8=D0=BD=D1=86=D0=B8=D0=B4=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=BC=20(#3078)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alertmanager инлайн-клавиатуру не поддерживает, а без кнопки нет обратной связи «человек увидел и взял в работу»: 27.08 продукты лежали 10 часов, и вопрос «а кто-нибудь это читает» было не к кому адресовать. ГДЕ ЖИВЁТ. Рядом с Alertmanager, на инфраструктурной машине. У бота МЕРЫ уже есть приём обновлений, и повесить обработку туда было бы дешевле, но он работает на продуктовом хосте: при падении продукта кнопка оказалась бы мёртвой ровно тогда, когда нужна. ССЫЛКА, А НЕ CALLBACK. Callback требует читателя обновлений бота. Бот один, и его обновления уже читает МЕРА — второй читатель получил бы 409 Conflict и отобрал бы сообщения у поддержки. БЕЗ ПАРОЛЯ НА /ack/*, ОСОЗНАННО. Кнопку жмут ночью с телефона, когда лежит прод; требование пароля даст ноль нажатий. Защита — 128-битный токен под конкретное сообщение, живущий сутки; максимум, чего добьётся угадавший, — ложная отметка в чате, где сразу видно, что её поставил не человек. ТОЛЬКО КЛИЕНТСКИЙ МАРШРУТ идёт через сервис. Прочие алерты сохраняют прямой путь в Telegram: чем меньше звеньев, тем надёжнее. Если сервис лёг, Alertmanager повторяет доставку и переуведомляет каждые 30 минут — алерт задерживается, но не теряется. Дублировать вторым прямым каналом не стали: шум в канале тревог опаснее задержки. Девять тестов дёргают настоящие функции, подменяя один шов — вызов Bot API. Важнейший: при отказе отправки с клавиатурой сообщение уходит БЕЗ неё — алерт важнее кнопки. --- backend/tests/ops/test_3078_alert_ack.py | 181 ++++++++++++ backend/tests/ops/test_3078_oncall_route.py | 30 +- caddy/sites/infra.caddy | 16 + docker-compose.metrics.yml | 49 ++++ ops/metrics/alert-ack/app.py | 277 ++++++++++++++++++ .../alertmanager/alertmanager.yml.tmpl | 34 +-- 6 files changed, 564 insertions(+), 23 deletions(-) create mode 100644 backend/tests/ops/test_3078_alert_ack.py create mode 100644 ops/metrics/alert-ack/app.py diff --git a/backend/tests/ops/test_3078_alert_ack.py b/backend/tests/ops/test_3078_alert_ack.py new file mode 100644 index 00000000..70568523 --- /dev/null +++ b/backend/tests/ops/test_3078_alert_ack.py @@ -0,0 +1,181 @@ +"""Кнопка подтверждения инцидента: что реально уходит в Telegram (#3078). + +ЗАЧЕМ СЕРВИС ВООБЩЕ. Alertmanager пишет в Telegram сам, но инлайн-клавиатуру его +интеграция не поддерживает. Без кнопки нет обратной связи «человек увидел и взял +в работу» — 27.08 продукты лежали 10 часов, и вопрос «а кто-нибудь это читает» +было не к кому адресовать. + +ЧТО СТОРОЖАТ ТЕСТЫ. Сервис маленький, но в нём три места, где ошибка не видна +глазами и проявится только в аварию — то есть тогда, когда проверять уже поздно: + +1. Кнопка не должна теряться, но и не должна ронять сообщение. Если прицепить + клавиатуру не удалось, алерт обязан уйти БЕЗ неё: сообщение важнее кнопки. +2. Повторное нажатие не должно слать второй «принято» — ссылка живёт сутки, по + ней кликнут дважды, и дубль в теме выглядит как второй человек. +3. Токен должен быть неугадываемым и одноразовым: эндпоинт публичный. + +Тесты дёргают настоящие функции модуля, подменяя ровно один шов — вызов Bot API. +Сеть не трогается, а всё, что ушло бы в неё, записывается и проверяется. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +APP = REPO_ROOT / "ops" / "metrics" / "alert-ack" / "app.py" + +FIRING = { + "status": "firing", + "commonLabels": {"alertname": "HostAgentDown", "host": "apps", "severity": "critical"}, + "alerts": [ + { + "labels": {"alertname": "HostAgentDown"}, + "annotations": { + "summary": "Агент метрик не отвечает", + "description": "15 минут тишины", + }, + } + ], +} +RESOLVED = {**FIRING, "status": "resolved"} + + +@pytest.fixture +def app(monkeypatch: pytest.MonkeyPatch) -> ModuleType: + """Загрузить сервис с предсказуемым окружением и подменённым Bot API.""" + assert APP.is_file(), f"нет {APP} — сервис переехал, тест ослеп" + monkeypatch.setenv("METRICS_TELEGRAM_BOT_TOKEN", "123:FAKE") + monkeypatch.setenv("METRICS_TELEGRAM_CHAT_ID", "-1004443088679") + monkeypatch.setenv("METRICS_TELEGRAM_TOPIC_ID", "158") + monkeypatch.setenv("METRICS_TELEGRAM_ONCALL", "@leks361") + monkeypatch.setenv("ALERT_ACK_PUBLIC_URL", "https://metrics.gendsgn.ru") + + spec = importlib.util.spec_from_file_location("alert_ack_under_test", APP) + assert spec and spec.loader + mod = importlib.util.module_from_spec(spec) + sys.modules["alert_ack_under_test"] = mod + spec.loader.exec_module(mod) + + sent: list[tuple[str, dict]] = [] + + def fake_tg(method: str, payload: dict) -> dict: + sent.append((method, payload)) + return {"ok": True, "result": {"message_id": 1000 + len(sent)}} + + mod._tg = fake_tg # noqa: SLF001 — единственный шов, ради него тест и существует + mod.sent = sent + mod._PENDING.clear() # noqa: SLF001 + return mod + + +def test_gorjaschiy_incident_uhodit_s_knopkoy(app: ModuleType) -> None: + """У горящего инцидента есть кнопка со ссылкой и живой токен.""" + app._send_alert(FIRING) # noqa: SLF001 + method, payload = app.sent[0] + assert method == "sendMessage" + assert "reply_markup" in payload, "кнопка не прицеплена" + assert "/ack/" in payload["reply_markup"], "в кнопке нет ссылки подтверждения" + assert len(app._PENDING) == 1, "токен не сохранён — нажатие будет некуда деть" # noqa: SLF001 + + +def test_soobschenie_adresovano_v_temu_i_zovyot_dezhurnogo(app: ModuleType) -> None: + """Тема форума и упоминание — обе вещи, которые молча теряются.""" + app._send_alert(FIRING) # noqa: SLF001 + _, payload = app.sent[0] + assert payload.get("message_thread_id") == "158", "уйдёт в общую тему форума" + assert "@leks361" in payload["text"], "дежурного не позвали" + assert "КЛИЕНТЫ ЗАТРОНУТЫ" in payload["text"] + + +def test_vosstanovlenie_bez_knopki(app: ModuleType) -> None: + """У «восстановлено» подтверждать нечего — кнопки быть не должно.""" + app._send_alert(RESOLVED) # noqa: SLF001 + _, payload = app.sent[0] + assert "reply_markup" not in payload + assert not app._PENDING, "токен выдан там, где кнопки нет" # noqa: SLF001 + + +def test_esli_knopka_ne_prikrepilas_soobschenie_vsyo_ravno_uhodit( + app: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """Отказ Bot API на сообщении с клавиатурой → повтор без неё. + + Самый важный из тестов: алерт важнее кнопки. Молчание вместо сообщения — + ровно тот отказ, ради устранения которого весь стек и заводится. + """ + calls: list[dict] = [] + + def flaky(method: str, payload: dict): + calls.append(payload) + # Первая попытка (с клавиатурой) не удалась, вторая — без неё. + return None if "reply_markup" in payload else {"ok": True, "result": {"message_id": 7}} + + monkeypatch.setattr(app, "_tg", flaky) + app._send_alert(FIRING) # noqa: SLF001 + assert len(calls) == 2, "не было повтора без кнопки — алерт потерян" + assert "reply_markup" not in calls[1] + assert calls[1]["text"] == calls[0]["text"], "во второй попытке потерялся текст" + + +def test_nazhatie_otvechaet_v_temu_i_snimaet_knopku(app: ModuleType) -> None: + """Нажатие: ответ в тему + снятие клавиатуры у исходного сообщения.""" + app._send_alert(FIRING) # noqa: SLF001 + token = next(iter(app._PENDING)) # noqa: SLF001 + app.sent.clear() + + code, page = app.do_ack(token) + assert code == 200 + assert "Принято в работу" in page + + methods = [m for m, _ in app.sent] + assert methods == ["sendMessage", "editMessageReplyMarkup"], methods + reply = app.sent[0][1] + assert reply.get("message_thread_id") == "158", "подтверждение уйдёт не в ту тему" + assert reply.get("reply_to_message_id"), "подтверждение не привязано к сообщению инцидента" + assert "@leks361" in reply["text"], "не видно, кто именно принял" + + +def test_povtornoe_nazhatie_ne_shlyot_vtoroy_raz(app: ModuleType) -> None: + """Идемпотентность: ссылка живёт сутки, по ней кликнут дважды.""" + app._send_alert(FIRING) # noqa: SLF001 + token = next(iter(app._PENDING)) # noqa: SLF001 + app.do_ack(token) + app.sent.clear() + + code, page = app.do_ack(token) + assert code == 200 + assert "Уже подтверждено" in page + assert app.sent == [], "второе нажатие отправило дубль в чат" + + +def test_neizvestnyy_token_nichego_ne_rasskazyvaet(app: ModuleType) -> None: + """Публичный эндпоинт: перебор не должен получать подсказок и ничего не шлёт.""" + code, page = app.do_ack("нет-такого-токена") + assert code == 404 + assert app.sent == [], "неизвестный токен что-то отправил в чат" + assert "недействительна" in page + + +def test_token_dostatochno_dlinnyy(app: ModuleType) -> None: + """Ссылка защищена только неугадываемостью — длина токена и есть защита.""" + app._send_alert(FIRING) # noqa: SLF001 + token = next(iter(app._PENDING)) # noqa: SLF001 + assert len(token) >= 20, f"слишком короткий токен: {len(token)} символов" + + +def test_protuhshiy_token_ne_prinimaetsya(app: ModuleType, monkeypatch: pytest.MonkeyPatch) -> None: + """По истечении срока ссылка мертва — иначе она копится вечно.""" + app._send_alert(FIRING) # noqa: SLF001 + token = next(iter(app._PENDING)) # noqa: SLF001 + app._PENDING[token]["created"] -= app.TTL_SEC + 1 # noqa: SLF001 + app.sent.clear() + + code, _ = app.do_ack(token) + assert code == 404 + assert app.sent == [] diff --git a/backend/tests/ops/test_3078_oncall_route.py b/backend/tests/ops/test_3078_oncall_route.py index b678499d..22dfa50d 100644 --- a/backend/tests/ops/test_3078_oncall_route.py +++ b/backend/tests/ops/test_3078_oncall_route.py @@ -91,10 +91,34 @@ def test_marshrut_klientov_ranshe_obschego_critical() -> None: def test_upominanie_iz_peremennoy_a_ne_zashito() -> None: - """Дежурный задаётся переменной: он меняется, а конфиг в git — нет.""" + """Дежурный задаётся переменной: он меняется, а конфиг в git — нет. + + Упоминание переехало из шаблона Alertmanager в сервис alert-ack вместе с + самим сообщением (Alertmanager не умеет инлайн-клавиатуру, поэтому + клиентский маршрут теперь идёт вебхуком). Проверка та же по смыслу — + аккаунт не должен быть зашит в репозиторий, — но смотрит туда, где текст + сообщения формируется сейчас. + """ + service = (REPO_ROOT / "ops" / "metrics" / "alert-ack" / "app.py").read_text(encoding="utf-8") + assert "METRICS_TELEGRAM_ONCALL" in service, "упоминание дежурного не параметризовано" + assert "@leks361" not in service, "конкретный аккаунт зашит в репозиторий" + assert "@leks361" not in TEMPLATE.read_text(encoding="utf-8"), "аккаунт зашит в шаблон" + + +def test_klientskiy_marshrut_idyot_v_servis_knopki() -> None: + """Клиентский приёмник — вебхук в alert-ack, а не прямой Telegram. + + Прямой канал остаётся у ПРОЧИХ маршрутов: чем меньше звеньев у алерта, тем + он надёжнее, и терять это для инфраструктурных сообщений незачем. Кнопка + нужна там, где требуется отметка «взял в работу». + """ text = TEMPLATE.read_text(encoding="utf-8") - assert "${METRICS_TELEGRAM_ONCALL}" in text, "упоминание дежурного не параметризовано" - assert "@leks361" not in text, "конкретный аккаунт зашит в репозиторий" + block = text[text.index("- name: telegram-clients") :] + block = block[: block.index("- name: telegram-heartbeat")] + assert "webhook_configs" in block, "клиентский приёмник не переключён на сервис" + assert "alert-ack:8080/alertmanager" in block, "вебхук указывает не на сервис кнопки" + # У прочих приёмников прямой путь сохранён. + assert "telegram_configs" in text, "прямой канал пропал у остальных маршрутов" def test_peremennaya_dezhurnogo_dohodit_do_shablona() -> None: diff --git a/caddy/sites/infra.caddy b/caddy/sites/infra.caddy index e9a51b3b..15788ff9 100644 --- a/caddy/sites/infra.caddy +++ b/caddy/sites/infra.caddy @@ -94,6 +94,22 @@ metrics.gendsgn.ru { reverse_proxy loki:3100 } + # Кнопка «Принял в работу» под клиентским инцидентом (#3078). + # + # БЕЗ АВТОРИЗАЦИИ НАМЕРЕННО. Кнопку жмут с телефона, ночью, когда лежит + # прод. Требовать в этот момент ввод пароля — значит получить ноль нажатий + # и вернуться к вопросу «а кто-нибудь вообще это читает». + # + # Защита — неугадываемость и одноразовость: путь содержит 128-битный токен, + # выданный ровно под это сообщение и живущий сутки. Худшее, чего добьётся + # угадавший, — ложная отметка «принято» в чате, где сразу видно, что её + # поставил не человек. Прав в системе токен не даёт никаких. + # + # Сервис отвечает только на /ack/* и /healthz; всё прочее — 404. + handle /ack/* { + reverse_proxy alert-ack:8080 + } + # Вход ОДИН — собственный вход Grafana (#3078). Внешний basic_auth снят по # решению владельца: два запроса пароля подряд мешали работе, а Grafana имеет # собственную аутентификацию с ролями и `GF_USERS_ALLOW_SIGN_UP=false`. diff --git a/docker-compose.metrics.yml b/docker-compose.metrics.yml index ef52d524..e7658647 100644 --- a/docker-compose.metrics.yml +++ b/docker-compose.metrics.yml @@ -154,6 +154,55 @@ services: timeout: 10s retries: 5 + # ── Приёмник алертов с кнопкой подтверждения ──────────────────────────────── + # + # Alertmanager умеет писать в Telegram сам, но инлайн-клавиатуру его интеграция + # не поддерживает — а без кнопки нет обратной связи «человек увидел и взял в + # работу». Клиентские инциденты (severity=critical на продуктовом хосте) идут + # вебхуком сюда, всё остальное — прежним прямым путём: чем меньше звеньев у + # алерта, тем он надёжнее. + # + # Живёт РЯДОМ С ALERTMANAGER, на инфраструктурной машине. У бота МЕРЫ уже есть + # приём обновлений, и повесить обработку туда было бы дешевле, но он работает + # на продуктовом хосте: при падении продукта кнопка оказалась бы мёртвой ровно + # тогда, когда нужна. + # + # Образ без сборки и без зависимостей (только stdlib): сервис обязан + # подниматься даже когда сломано всё остальное. + alert-ack: + image: python:3.12-slim + container_name: gendesign-alert-ack + restart: unless-stopped + user: "65534:65534" + profiles: ["alerts"] + command: ["python", "-u", "/app/app.py"] + env_file: + - path: ./backend/.env.runtime + required: false + - path: ./backend/.env + required: false + environment: + METRICS_TELEGRAM_BOT_TOKEN: ${METRICS_TELEGRAM_BOT_TOKEN:-} + METRICS_TELEGRAM_CHAT_ID: ${METRICS_TELEGRAM_CHAT_ID:-} + METRICS_TELEGRAM_TOPIC_ID: ${METRICS_TELEGRAM_TOPIC_ID:-} + METRICS_TELEGRAM_ONCALL: ${METRICS_TELEGRAM_ONCALL:-} + # Внешний адрес попадает в кнопку. Пустой — сообщение уйдёт без кнопки, + # но уйдёт: алерт важнее подтверждения. + ALERT_ACK_PUBLIC_URL: ${ALERT_ACK_PUBLIC_URL:-https://metrics.gendsgn.ru} + volumes: + - ./ops/metrics/alert-ack/app.py:/app/app.py:ro + expose: + - "8080" + networks: + - shared + mem_limit: 128m + logging: *default-logging + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://localhost:8080/healthz',timeout=5)"] + interval: 30s + timeout: 10s + retries: 5 + # ── Grafana: витрина ───────────────────────────────────────────────────────── grafana: image: grafana/grafana:11.5.1 diff --git a/ops/metrics/alert-ack/app.py b/ops/metrics/alert-ack/app.py new file mode 100644 index 00000000..323f077d --- /dev/null +++ b/ops/metrics/alert-ack/app.py @@ -0,0 +1,277 @@ +#!/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) +""" + +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 +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"✅ Восстановлено · {html.escape(title)}", title + + lines = [f"🚨 КЛИЕНТЫ ЗАТРОНУТЫ · {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) + + +_PAGE = ( + "" + "{t}" + "" + "

{h}

{p}

" +) + + +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"✅ Принято в работу · {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 + if self.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 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 пуст — сообщения уйдут БЕЗ кнопки подтверждения") + 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() diff --git a/ops/metrics/alertmanager/alertmanager.yml.tmpl b/ops/metrics/alertmanager/alertmanager.yml.tmpl index 1920d6a1..2c80f354 100644 --- a/ops/metrics/alertmanager/alertmanager.yml.tmpl +++ b/ops/metrics/alertmanager/alertmanager.yml.tmpl @@ -82,29 +82,23 @@ ${METRICS_TELEGRAM_TOPIC_LINE} {{ if .Annotations.description }}{{ .Annotations.description }}{{ end }} {{ end }} - # Клиентский инцидент: зовём дежурного поимённо. + # Клиентский инцидент уходит В СЕРВИС, а не напрямую в Telegram. # - # Упоминание берётся из ${METRICS_TELEGRAM_ONCALL} и НЕ зашито в репозиторий: - # дежурный меняется, а конфиг в git — нет. Пустая переменная = обычное - # сообщение без тега, то есть поведение не ломается, если её забыли задать. + # Alertmanager инлайн-клавиатуру не умеет, а без кнопки нет обратной связи + # «человек увидел и взял в работу». Сервис alert-ack (тот же хост, рядом) + # формирует сообщение с упоминанием дежурного и кнопкой подтверждения, а по + # нажатию отвечает в ту же тему «принято, кто, во сколько». + # + # ЧТО ПРОИСХОДИТ, ЕСЛИ СЕРВИС ЛЁГ. Alertmanager считает доставку неудачной и + # повторяет; плюс сам инцидент остаётся активным и переуведомляется каждые + # 30 минут (repeat_interval маршрута выше). То есть алерт задерживается, но + # не теряется. Это сознательный размен: дублировать то же сообщение вторым, + # прямым каналом означало бы два уведомления на каждый инцидент, а шум в + # канале тревог опаснее, чем задержка. - name: telegram-clients - telegram_configs: - - bot_token: "${METRICS_TELEGRAM_BOT_TOKEN}" - chat_id: ${METRICS_TELEGRAM_CHAT_ID} -${METRICS_TELEGRAM_TOPIC_LINE} - api_url: "https://api.telegram.org" - parse_mode: HTML + webhook_configs: + - url: "http://alert-ack:8080/alertmanager" send_resolved: true - message: | - {{ if eq .Status "firing" }}🚨 КЛИЕНТЫ ЗАТРОНУТЫ{{ else }}✅ Восстановлено{{ end }} · {{ .CommonLabels.alertname }} - {{ if eq .Status "firing" }}${METRICS_TELEGRAM_ONCALL} — нужна реакция.{{ end }} - {{ range .Alerts }} - {{ .Annotations.summary }} - {{ if .Annotations.description }}{{ .Annotations.description }}{{ end }} - {{ end }} - {{ if eq .Status "firing" }} - Напоминание придёт снова через 30 минут, пока инцидент не погашен. - {{ end }} - name: telegram-heartbeat telegram_configs: From 4e8daff675d19701d14405699034295d4a97dac8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 27 Aug 2026 14:32:34 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(tests):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B4=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D0=B8=D0=B2?= =?UTF-8?q?=D1=8B=20noqa=20=D0=BD=D0=B0=20=D0=BD=D0=B5=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=B8=D0=B2=D0=BD=D0=BE=D0=B5=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SLF001` (обращение к приватному члену) в конфиге ruff не включён, поэтому `# noqa: SLF001` — подавление того, что и так не проверяется. Ruff ловит это правилом RUF100 и валит проверку. Тест намеренно лезет в приватные `_tg`, `_PENDING`, `_send_alert`: подмена единственного шва до сети — и есть смысл этих тестов. Пояснение, которое стояло после директивы, сохранено обычным комментарием. --- backend/tests/ops/test_3078_alert_ack.py | 34 ++++++++++++------------ 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/backend/tests/ops/test_3078_alert_ack.py b/backend/tests/ops/test_3078_alert_ack.py index 70568523..58f3d471 100644 --- a/backend/tests/ops/test_3078_alert_ack.py +++ b/backend/tests/ops/test_3078_alert_ack.py @@ -68,25 +68,25 @@ def app(monkeypatch: pytest.MonkeyPatch) -> ModuleType: sent.append((method, payload)) return {"ok": True, "result": {"message_id": 1000 + len(sent)}} - mod._tg = fake_tg # noqa: SLF001 — единственный шов, ради него тест и существует + mod._tg = fake_tg # единственный шов, ради него тест и существует mod.sent = sent - mod._PENDING.clear() # noqa: SLF001 + mod._PENDING.clear() return mod def test_gorjaschiy_incident_uhodit_s_knopkoy(app: ModuleType) -> None: """У горящего инцидента есть кнопка со ссылкой и живой токен.""" - app._send_alert(FIRING) # noqa: SLF001 + app._send_alert(FIRING) method, payload = app.sent[0] assert method == "sendMessage" assert "reply_markup" in payload, "кнопка не прицеплена" assert "/ack/" in payload["reply_markup"], "в кнопке нет ссылки подтверждения" - assert len(app._PENDING) == 1, "токен не сохранён — нажатие будет некуда деть" # noqa: SLF001 + assert len(app._PENDING) == 1, "токен не сохранён — нажатие будет некуда деть" def test_soobschenie_adresovano_v_temu_i_zovyot_dezhurnogo(app: ModuleType) -> None: """Тема форума и упоминание — обе вещи, которые молча теряются.""" - app._send_alert(FIRING) # noqa: SLF001 + app._send_alert(FIRING) _, payload = app.sent[0] assert payload.get("message_thread_id") == "158", "уйдёт в общую тему форума" assert "@leks361" in payload["text"], "дежурного не позвали" @@ -95,10 +95,10 @@ def test_soobschenie_adresovano_v_temu_i_zovyot_dezhurnogo(app: ModuleType) -> N def test_vosstanovlenie_bez_knopki(app: ModuleType) -> None: """У «восстановлено» подтверждать нечего — кнопки быть не должно.""" - app._send_alert(RESOLVED) # noqa: SLF001 + app._send_alert(RESOLVED) _, payload = app.sent[0] assert "reply_markup" not in payload - assert not app._PENDING, "токен выдан там, где кнопки нет" # noqa: SLF001 + assert not app._PENDING, "токен выдан там, где кнопки нет" def test_esli_knopka_ne_prikrepilas_soobschenie_vsyo_ravno_uhodit( @@ -117,7 +117,7 @@ def test_esli_knopka_ne_prikrepilas_soobschenie_vsyo_ravno_uhodit( return None if "reply_markup" in payload else {"ok": True, "result": {"message_id": 7}} monkeypatch.setattr(app, "_tg", flaky) - app._send_alert(FIRING) # noqa: SLF001 + app._send_alert(FIRING) assert len(calls) == 2, "не было повтора без кнопки — алерт потерян" assert "reply_markup" not in calls[1] assert calls[1]["text"] == calls[0]["text"], "во второй попытке потерялся текст" @@ -125,8 +125,8 @@ def test_esli_knopka_ne_prikrepilas_soobschenie_vsyo_ravno_uhodit( def test_nazhatie_otvechaet_v_temu_i_snimaet_knopku(app: ModuleType) -> None: """Нажатие: ответ в тему + снятие клавиатуры у исходного сообщения.""" - app._send_alert(FIRING) # noqa: SLF001 - token = next(iter(app._PENDING)) # noqa: SLF001 + app._send_alert(FIRING) + token = next(iter(app._PENDING)) app.sent.clear() code, page = app.do_ack(token) @@ -143,8 +143,8 @@ def test_nazhatie_otvechaet_v_temu_i_snimaet_knopku(app: ModuleType) -> None: def test_povtornoe_nazhatie_ne_shlyot_vtoroy_raz(app: ModuleType) -> None: """Идемпотентность: ссылка живёт сутки, по ней кликнут дважды.""" - app._send_alert(FIRING) # noqa: SLF001 - token = next(iter(app._PENDING)) # noqa: SLF001 + app._send_alert(FIRING) + token = next(iter(app._PENDING)) app.do_ack(token) app.sent.clear() @@ -164,16 +164,16 @@ def test_neizvestnyy_token_nichego_ne_rasskazyvaet(app: ModuleType) -> None: def test_token_dostatochno_dlinnyy(app: ModuleType) -> None: """Ссылка защищена только неугадываемостью — длина токена и есть защита.""" - app._send_alert(FIRING) # noqa: SLF001 - token = next(iter(app._PENDING)) # noqa: SLF001 + app._send_alert(FIRING) + token = next(iter(app._PENDING)) assert len(token) >= 20, f"слишком короткий токен: {len(token)} символов" def test_protuhshiy_token_ne_prinimaetsya(app: ModuleType, monkeypatch: pytest.MonkeyPatch) -> None: """По истечении срока ссылка мертва — иначе она копится вечно.""" - app._send_alert(FIRING) # noqa: SLF001 - token = next(iter(app._PENDING)) # noqa: SLF001 - app._PENDING[token]["created"] -= app.TTL_SEC + 1 # noqa: SLF001 + app._send_alert(FIRING) + token = next(iter(app._PENDING)) + app._PENDING[token]["created"] -= app.TTL_SEC + 1 app.sent.clear() code, _ = app.do_ack(token)