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
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:
parent
51c779c092
commit
6f120c6605
3 changed files with 187 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
123
backend/tests/ops/test_3078_oncall_route.py
Normal file
123
backend/tests/ops/test_3078_oncall_route.py
Normal 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}"
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue