gendesign/backend/tests/ops/test_3078_oncall_route.py
bot-backend 053a5fb75c
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Failing after 1m12s
CI / openapi-codegen-check (pull_request) Successful in 1m55s
feat(observability): кнопка «Принял в работу» под клиентским инцидентом (#3078)
Alertmanager инлайн-клавиатуру не поддерживает, а без кнопки нет обратной
связи «человек увидел и взял в работу»: 27.08 продукты лежали 10 часов, и
вопрос «а кто-нибудь это читает» было не к кому адресовать.

ГДЕ ЖИВЁТ. Рядом с Alertmanager, на инфраструктурной машине. У бота МЕРЫ
уже есть приём обновлений, и повесить обработку туда было бы дешевле, но
он работает на продуктовом хосте: при падении продукта кнопка оказалась бы
мёртвой ровно тогда, когда нужна.

ССЫЛКА, А НЕ CALLBACK. Callback требует читателя обновлений бота. Бот один,
и его обновления уже читает МЕРА — второй читатель получил бы 409 Conflict
и отобрал бы сообщения у поддержки.

БЕЗ ПАРОЛЯ НА /ack/*, ОСОЗНАННО. Кнопку жмут ночью с телефона, когда лежит
прод; требование пароля даст ноль нажатий. Защита — 128-битный токен под
конкретное сообщение, живущий сутки; максимум, чего добьётся угадавший, —
ложная отметка в чате, где сразу видно, что её поставил не человек.

ТОЛЬКО КЛИЕНТСКИЙ МАРШРУТ идёт через сервис. Прочие алерты сохраняют прямой
путь в Telegram: чем меньше звеньев, тем надёжнее. Если сервис лёг,
Alertmanager повторяет доставку и переуведомляет каждые 30 минут — алерт
задерживается, но не теряется. Дублировать вторым прямым каналом не стали:
шум в канале тревог опаснее задержки.

Девять тестов дёргают настоящие функции, подменяя один шов — вызов Bot API.
Важнейший: при отказе отправки с клавиатурой сообщение уходит БЕЗ неё —
алерт важнее кнопки.
2026-08-27 13:56:00 +03:00

147 lines
9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

r"""Клиентский инцидент зовёт дежурного поимённо — и шелл в workflow не разваливается (#3078).
ДВА ИНВАРИАНТА, и второй появился из-за собственной ошибки.
1. Маршрут для клиентских инцидентов. 27.08 продукты лежали 10 часов, и в канале
«диск занят на 86 %» и «клиенты не могут открыть сайт» выглядели одинаково.
Отдельный приёмник `telegram-clients` с упоминанием дежурного и коротким
`repeat_interval` — это и есть разница между «шумит» и «зовёт».
2. Комментарий не должен стоять между строками продолжения команды. В #3127 блок
`rm -f` вместе с комментарием встал МЕЖДУ строками, каждая из которых
заканчивалась обратным слешем:
VAR1="..." \
VAR2="..." \
# комментарий <- строки склеиваются, дальше всё уходит в комментарий
rm -f ...
envsubst ... > конфиг <- НИКОГДА не выполнялся
Синтаксически это корректный шелл, поэтому `bash -n` такое не ловит, а глазами
в диффе не видно: строки выглядят как отдельные. Результат — конфиг Alertmanager
молча перестаёт рендериться, то есть алертинг исчезает целиком при зелёном
деплое. Проверка структурная, потому что никакой линтер этого за нас не сделает.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[3]
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
TEMPLATE = REPO_ROOT / "ops" / "metrics" / "alertmanager" / "alertmanager.yml.tmpl"
def _shell_blocks() -> list[str]:
"""Все shell-скрипты из workflow: и `run:`, и `script:` у ssh-action."""
doc = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
blocks: list[str] = []
for job in (doc.get("jobs") or {}).values():
for step in job.get("steps") or []:
if isinstance(step.get("run"), str):
blocks.append(step["run"])
with_ = step.get("with") or {}
if isinstance(with_.get("script"), str):
blocks.append(with_["script"])
assert blocks, "в workflow не нашлось ни одного shell-блока — тест ослеп"
return blocks
def test_kommentariy_ne_stoit_vnutri_prodolzheniya_komandy() -> None:
"""После строки с продолжением не может идти комментарий.
Ровно эта ошибка увела вызов envsubst в комментарий и оставила Alertmanager
без конфига. Проверка применяется ко ВСЕМ shell-блокам workflow, а не только
к тому месту, где обожглись.
"""
for block in _shell_blocks():
lines = block.split("\n")
for i, line in enumerate(lines[:-1]):
if not line.rstrip().endswith("\\"):
continue
nxt = lines[i + 1].strip()
assert not nxt.startswith("#"), (
"комментарий внутри продолжения команды — всё, что ниже, "
f"уедет в комментарий:\n {line.strip()}\n {nxt}"
)
def test_priyomnik_klientskih_incidentov_est() -> None:
"""В шаблоне есть отдельный приёмник и маршрут на него."""
text = TEMPLATE.read_text(encoding="utf-8")
assert "- name: telegram-clients" in text, "пропал приёмник клиентских инцидентов"
assert "receiver: telegram-clients" in text, "на приёмник никто не маршрутизирует"
def test_marshrut_klientov_ranshe_obschego_critical() -> None:
"""Клиентский маршрут должен стоять ВЫШЕ общего `severity=critical`.
Alertmanager берёт первый подошедший маршрут. Если общий окажется выше,
клиентские инциденты уйдут в него и упоминание не сработает — отказ тихий:
сообщения приходят, просто без тега.
"""
text = TEMPLATE.read_text(encoding="utf-8")
i_clients = text.index("receiver: telegram-clients")
i_generic = text.index("# Прочее критичное")
assert i_clients < i_generic, "общий critical перехватит клиентские инциденты раньше"
def test_upominanie_iz_peremennoy_a_ne_zashito() -> None:
"""Дежурный задаётся переменной: он меняется, а конфиг в 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")
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:
"""Переменная реально прокидывается и подставляется, а не объявлена вхолостую.
Три звена, и каждое рвётся молча: секрет → env шага → список envsubst. Без
последнего `${METRICS_TELEGRAM_ONCALL}` останется в конфиге буквально.
"""
wf = WORKFLOW.read_text(encoding="utf-8")
assert "METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }}" in wf
m = re.search(r"envs:\s*(\S+)", wf)
assert m and "METRICS_TELEGRAM_ONCALL" in m.group(1), "переменная не форвардится в ssh-шаг"
m2 = re.search(r"envsubst '([^']+)'", wf)
assert m2 and "${METRICS_TELEGRAM_ONCALL}" in m2.group(1), "переменной нет в списке envsubst"
@pytest.mark.parametrize("marker", ['severity = "critical"', 'host = "apps"'])
def test_klientskiy_marshrut_suzhen_po_oboim_priznakam(marker: str) -> None:
"""Маршрут ловит именно критичное НА ПРОДУКТОВОМ хосте.
Без `host = "apps"` дежурного звали бы на каждую инфраструктурную мелочь, и
тег быстро перестал бы что-либо значить.
"""
text = TEMPLATE.read_text(encoding="utf-8")
block = text[text.index("receiver: telegram-clients") : text.index("# Прочее критичное")]
assert marker in block, f"в клиентском маршруте нет условия {marker}"