gendesign/backend/tests/ops/test_3078_oncall_route.py
bot-backend 6f120c6605
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 17m26s
feat(observability): клиентский инцидент зовёт дежурного поимённо + чинит сломанное продолжение команды (#3078)
Две вещи, вторая — исправление собственной ошибки из #3127.

## Дежурного зовут по имени

27.08 продукты лежали 10 часов, и в канале «диск занят на 86 %» и «клиенты
не могут открыть сайт» выглядели одинаково. Появился отдельный маршрут:
severity=critical И host=apps, то есть критично на ПРОДУКТОВОЙ машине —
значит людям недоступна МЕРА и Site Finder, а не «где-то в инфраструктуре
тесно».

У такого сообщения другой текст (🚨 КЛИЕНТЫ ЗАТРОНУТЫ), упоминание дежурного
и repeat_interval 30 минут против 3 часов у прочего критичного: пока
инцидент не погашен, напоминание должно быть неудобным.

Сужение по host=apps существенно. Без него дежурного звали бы на каждую
инфраструктурную мелочь, и тег перестал бы что-либо значить за неделю.

Сам аккаунт в репозиторий не попадает — берётся из METRICS_TELEGRAM_ONCALL.
Дежурный меняется, конфиг в git — нет. Пустая переменная = сообщение без
тега, поведение не ломается.

## Починка: комментарий внутри продолжения команды

В #3127 блок rm -f вместе с комментарием встал МЕЖДУ строками, каждая из
которых заканчивалась обратным слешем. Строки склеиваются, и весь вызов
envsubst уехал в комментарий — конфиг Alertmanager перестал бы рендериться
вовсе, при полностью зелёном деплое.

Синтаксически это корректный шелл, bash -n такое не ловит, а в диффе не
видно: строки выглядят как отдельные. Поэтому проверка структурная и
применяется ко ВСЕМ shell-блокам workflow, а не только к месту ожога.
2026-08-27 13:05:32 +03:00

123 lines
7.2 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 — нет."""
text = TEMPLATE.read_text(encoding="utf-8")
assert "${METRICS_TELEGRAM_ONCALL}" in text, "упоминание дежурного не параметризовано"
assert "@leks361" not 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}"