Merge pull request 'feat(observability): кнопка «Принял в работу» под клиентским инцидентом (#3078)' (#3136) from feat/3078-alert-ack-button into main
All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 5s
Deploy / changes (push) Successful in 8s
Deploy / deploy-caddy (push) Has been skipped
Deploy Metrics / server (push) Successful in 16s
Deploy Metrics / agent-apps (push) Successful in 24s
Deploy Metrics / agent-infra (push) Successful in 29s
Deploy / build-backend (push) Successful in 37s
Deploy / build-worker (push) Successful in 39s
Deploy / build-frontend (push) Successful in 39s
Deploy / deploy (push) Successful in 1m4s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 11s
All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 5s
Deploy / changes (push) Successful in 8s
Deploy / deploy-caddy (push) Has been skipped
Deploy Metrics / server (push) Successful in 16s
Deploy Metrics / agent-apps (push) Successful in 24s
Deploy Metrics / agent-infra (push) Successful in 29s
Deploy / build-backend (push) Successful in 37s
Deploy / build-worker (push) Successful in 39s
Deploy / build-frontend (push) Successful in 39s
Deploy / deploy (push) Successful in 1m4s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 11s
This commit is contained in:
commit
d0547ddb9a
6 changed files with 564 additions and 23 deletions
181
backend/tests/ops/test_3078_alert_ack.py
Normal file
181
backend/tests/ops/test_3078_alert_ack.py
Normal file
|
|
@ -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 # единственный шов, ради него тест и существует
|
||||||
|
mod.sent = sent
|
||||||
|
mod._PENDING.clear()
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def test_gorjaschiy_incident_uhodit_s_knopkoy(app: ModuleType) -> None:
|
||||||
|
"""У горящего инцидента есть кнопка со ссылкой и живой токен."""
|
||||||
|
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, "токен не сохранён — нажатие будет некуда деть"
|
||||||
|
|
||||||
|
|
||||||
|
def test_soobschenie_adresovano_v_temu_i_zovyot_dezhurnogo(app: ModuleType) -> None:
|
||||||
|
"""Тема форума и упоминание — обе вещи, которые молча теряются."""
|
||||||
|
app._send_alert(FIRING)
|
||||||
|
_, 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)
|
||||||
|
_, payload = app.sent[0]
|
||||||
|
assert "reply_markup" not in payload
|
||||||
|
assert not app._PENDING, "токен выдан там, где кнопки нет"
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
token = next(iter(app._PENDING))
|
||||||
|
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)
|
||||||
|
token = next(iter(app._PENDING))
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
token = next(iter(app._PENDING))
|
||||||
|
app._PENDING[token]["created"] -= app.TTL_SEC + 1
|
||||||
|
app.sent.clear()
|
||||||
|
|
||||||
|
code, _ = app.do_ack(token)
|
||||||
|
assert code == 404
|
||||||
|
assert app.sent == []
|
||||||
|
|
@ -91,10 +91,34 @@ def test_marshrut_klientov_ranshe_obschego_critical() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_upominanie_iz_peremennoy_a_ne_zashito() -> 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")
|
text = TEMPLATE.read_text(encoding="utf-8")
|
||||||
assert "${METRICS_TELEGRAM_ONCALL}" in text, "упоминание дежурного не параметризовано"
|
block = text[text.index("- name: telegram-clients") :]
|
||||||
assert "@leks361" not in text, "конкретный аккаунт зашит в репозиторий"
|
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:
|
def test_peremennaya_dezhurnogo_dohodit_do_shablona() -> None:
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,22 @@ metrics.gendsgn.ru {
|
||||||
reverse_proxy loki:3100
|
reverse_proxy loki:3100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Кнопка «Принял в работу» под клиентским инцидентом (#3078).
|
||||||
|
#
|
||||||
|
# БЕЗ АВТОРИЗАЦИИ НАМЕРЕННО. Кнопку жмут с телефона, ночью, когда лежит
|
||||||
|
# прод. Требовать в этот момент ввод пароля — значит получить ноль нажатий
|
||||||
|
# и вернуться к вопросу «а кто-нибудь вообще это читает».
|
||||||
|
#
|
||||||
|
# Защита — неугадываемость и одноразовость: путь содержит 128-битный токен,
|
||||||
|
# выданный ровно под это сообщение и живущий сутки. Худшее, чего добьётся
|
||||||
|
# угадавший, — ложная отметка «принято» в чате, где сразу видно, что её
|
||||||
|
# поставил не человек. Прав в системе токен не даёт никаких.
|
||||||
|
#
|
||||||
|
# Сервис отвечает только на /ack/* и /healthz; всё прочее — 404.
|
||||||
|
handle /ack/* {
|
||||||
|
reverse_proxy alert-ack:8080
|
||||||
|
}
|
||||||
|
|
||||||
# Вход ОДИН — собственный вход Grafana (#3078). Внешний basic_auth снят по
|
# Вход ОДИН — собственный вход Grafana (#3078). Внешний basic_auth снят по
|
||||||
# решению владельца: два запроса пароля подряд мешали работе, а Grafana имеет
|
# решению владельца: два запроса пароля подряд мешали работе, а Grafana имеет
|
||||||
# собственную аутентификацию с ролями и `GF_USERS_ALLOW_SIGN_UP=false`.
|
# собственную аутентификацию с ролями и `GF_USERS_ALLOW_SIGN_UP=false`.
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,55 @@ services:
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
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: витрина ─────────────────────────────────────────────────────────
|
||||||
grafana:
|
grafana:
|
||||||
image: grafana/grafana:11.5.1
|
image: grafana/grafana:11.5.1
|
||||||
|
|
|
||||||
277
ops/metrics/alert-ack/app.py
Normal file
277
ops/metrics/alert-ack/app.py
Normal file
|
|
@ -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"✅ <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)
|
||||||
|
|
||||||
|
|
||||||
|
_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
|
||||||
|
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()
|
||||||
|
|
@ -82,29 +82,23 @@ ${METRICS_TELEGRAM_TOPIC_LINE}
|
||||||
{{ if .Annotations.description }}{{ .Annotations.description }}{{ end }}
|
{{ if .Annotations.description }}{{ .Annotations.description }}{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
# Клиентский инцидент: зовём дежурного поимённо.
|
# Клиентский инцидент уходит В СЕРВИС, а не напрямую в Telegram.
|
||||||
#
|
#
|
||||||
# Упоминание берётся из ${METRICS_TELEGRAM_ONCALL} и НЕ зашито в репозиторий:
|
# Alertmanager инлайн-клавиатуру не умеет, а без кнопки нет обратной связи
|
||||||
# дежурный меняется, а конфиг в git — нет. Пустая переменная = обычное
|
# «человек увидел и взял в работу». Сервис alert-ack (тот же хост, рядом)
|
||||||
# сообщение без тега, то есть поведение не ломается, если её забыли задать.
|
# формирует сообщение с упоминанием дежурного и кнопкой подтверждения, а по
|
||||||
|
# нажатию отвечает в ту же тему «принято, кто, во сколько».
|
||||||
|
#
|
||||||
|
# ЧТО ПРОИСХОДИТ, ЕСЛИ СЕРВИС ЛЁГ. Alertmanager считает доставку неудачной и
|
||||||
|
# повторяет; плюс сам инцидент остаётся активным и переуведомляется каждые
|
||||||
|
# 30 минут (repeat_interval маршрута выше). То есть алерт задерживается, но
|
||||||
|
# не теряется. Это сознательный размен: дублировать то же сообщение вторым,
|
||||||
|
# прямым каналом означало бы два уведомления на каждый инцидент, а шум в
|
||||||
|
# канале тревог опаснее, чем задержка.
|
||||||
- name: telegram-clients
|
- name: telegram-clients
|
||||||
telegram_configs:
|
webhook_configs:
|
||||||
- bot_token: "${METRICS_TELEGRAM_BOT_TOKEN}"
|
- url: "http://alert-ack:8080/alertmanager"
|
||||||
chat_id: ${METRICS_TELEGRAM_CHAT_ID}
|
|
||||||
${METRICS_TELEGRAM_TOPIC_LINE}
|
|
||||||
api_url: "https://api.telegram.org"
|
|
||||||
parse_mode: HTML
|
|
||||||
send_resolved: true
|
send_resolved: true
|
||||||
message: |
|
|
||||||
{{ if eq .Status "firing" }}🚨 <b>КЛИЕНТЫ ЗАТРОНУТЫ</b>{{ else }}✅ <b>Восстановлено</b>{{ 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
|
- name: telegram-heartbeat
|
||||||
telegram_configs:
|
telegram_configs:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue