feat(observability): клиентский инцидент зовёт дежурного поимённо + чинит сломанное продолжение команды (#3078)
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

Две вещи, вторая — исправление собственной ошибки из #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, а не только к месту ожога.
This commit is contained in:
bot-backend 2026-08-27 13:05:32 +03:00
parent 51c779c092
commit 6f120c6605
3 changed files with 187 additions and 8 deletions

View file

@ -88,8 +88,9 @@ jobs:
METRICS_TELEGRAM_BOT_TOKEN: ${{ secrets.METRICS_TELEGRAM_BOT_TOKEN }}
METRICS_TELEGRAM_CHAT_ID: ${{ secrets.METRICS_TELEGRAM_CHAT_ID }}
METRICS_TELEGRAM_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_TOPIC_ID }}
METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }}
with:
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_ONCALL
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
@ -150,15 +151,27 @@ jobs:
echo "Алерты: топик не задан — уйдут в общую тему чата."
fi
if [ -n "${METRICS_TELEGRAM_ONCALL:-}" ]; then
echo "Клиентские инциденты: зовём ${METRICS_TELEGRAM_ONCALL} поимённо."
else
echo "::warning title=Дежурный не задан::METRICS_TELEGRAM_ONCALL пуст — при клиентском инциденте сообщение придёт без упоминания и потеряется в общем потоке (#3078)."
fi
# rm перед записью обязателен: после chown ниже файл принадлежит 65534
# с правами 600, и на СЛЕДУЮЩЕМ деплое перенаправление в него уже не
# запишет. Каталог принадлежит деплой-пользователю, поэтому пересоздать
# файл он может, а перезаписать — нет.
#
# NB: rm обязан стоять ДО префикса переменных ниже. В #3127 он встал
# МЕЖДУ строками продолжения команды — и весь вызов envsubst уехал в
# комментарий, то есть конфиг переставал рендериться вовсе.
rm -f ops/metrics/alertmanager/alertmanager.yml
METRICS_TELEGRAM_BOT_TOKEN="$METRICS_TELEGRAM_BOT_TOKEN" \
METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \
METRICS_TELEGRAM_TOPIC_LINE="$METRICS_TELEGRAM_TOPIC_LINE" \
# rm перед записью обязателен: после chown ниже файл принадлежит
# 65534 с правами 600, и на СЛЕДУЮЩЕМ деплое `>` в него уже не
# запишет. Каталог принадлежит деплой-пользователю, поэтому
# пересоздать файл он может, а перезаписать — нет.
rm -f ops/metrics/alertmanager/alertmanager.yml
envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_TOPIC_LINE}' \
METRICS_TELEGRAM_ONCALL="${METRICS_TELEGRAM_ONCALL:-}" \
envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_TOPIC_LINE} ${METRICS_TELEGRAM_ONCALL}' \
< ops/metrics/alertmanager/alertmanager.yml.tmpl \
> ops/metrics/alertmanager/alertmanager.yml
chmod 600 ops/metrics/alertmanager/alertmanager.yml

View file

@ -0,0 +1,123 @@
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}"

View file

@ -34,7 +34,26 @@ route:
group_interval: 12h
repeat_interval: 12h
# Критичное — без задержки на группировку.
# Клиентский инцидент. host="apps" — это продуктовая машина: если на ней
# критично, значит МЕРА и Site Finder недоступны людям, а не «где-то в
# инфраструктуре тесно». Такое зовём поимённо и напоминаем часто.
#
# Почему отдельный маршрут, а не общий critical: 27.08 продукты лежали
# 10 часов, и разницы между «диск на 86 %» и «клиенты не могут открыть
# сайт» в канале не было никакой. Разный текст и разная настойчивость —
# это и есть разница.
#
# repeat_interval 30m против 3h у прочего критичного: пока инцидент не
# погашен, напоминание должно быть неудобным. Заглушить его — осознанное
# действие через Alertmanager, и оно же служит отметкой «принято».
- receiver: telegram-clients
matchers:
- severity = "critical"
- host = "apps"
group_wait: 10s
repeat_interval: 30m
# Прочее критичное — инфраструктура, клиенты пока не затронуты.
- receiver: telegram
matchers:
- severity = "critical"
@ -63,6 +82,30 @@ ${METRICS_TELEGRAM_TOPIC_LINE}
{{ if .Annotations.description }}{{ .Annotations.description }}{{ end }}
{{ end }}
# Клиентский инцидент: зовём дежурного поимённо.
#
# Упоминание берётся из ${METRICS_TELEGRAM_ONCALL} и НЕ зашито в репозиторий:
# дежурный меняется, а конфиг в git — нет. Пустая переменная = обычное
# сообщение без тега, то есть поведение не ломается, если её забыли задать.
- 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
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
telegram_configs:
- bot_token: "${METRICS_TELEGRAM_BOT_TOKEN}"