Merge pull request 'feat(observability): клиентский инцидент зовёт дежурного поимённо + чинит сломанное продолжение команды (#3078)' (#3128) from feat/3078-oncall-mention into main
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / deploy-caddy (push) Blocked by required conditions
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
Deploy Metrics / server (push) Successful in 21s
Deploy Metrics / agent-infra (push) Successful in 24s
Deploy Metrics / agent-apps (push) Successful in 25s
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / deploy-caddy (push) Blocked by required conditions
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
Deploy Metrics / server (push) Successful in 21s
Deploy Metrics / agent-infra (push) Successful in 24s
Deploy Metrics / agent-apps (push) Successful in 25s
This commit is contained in:
commit
f0b03112fc
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_BOT_TOKEN: ${{ secrets.METRICS_TELEGRAM_BOT_TOKEN }}
|
||||||
METRICS_TELEGRAM_CHAT_ID: ${{ secrets.METRICS_TELEGRAM_CHAT_ID }}
|
METRICS_TELEGRAM_CHAT_ID: ${{ secrets.METRICS_TELEGRAM_CHAT_ID }}
|
||||||
METRICS_TELEGRAM_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_TOPIC_ID }}
|
METRICS_TELEGRAM_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_TOPIC_ID }}
|
||||||
|
METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }}
|
||||||
with:
|
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 }}
|
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
|
||||||
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
|
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
|
||||||
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
|
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
|
||||||
|
|
@ -150,15 +151,27 @@ jobs:
|
||||||
echo "Алерты: топик не задан — уйдут в общую тему чата."
|
echo "Алерты: топик не задан — уйдут в общую тему чата."
|
||||||
fi
|
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_BOT_TOKEN="$METRICS_TELEGRAM_BOT_TOKEN" \
|
||||||
METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \
|
METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \
|
||||||
METRICS_TELEGRAM_TOPIC_LINE="$METRICS_TELEGRAM_TOPIC_LINE" \
|
METRICS_TELEGRAM_TOPIC_LINE="$METRICS_TELEGRAM_TOPIC_LINE" \
|
||||||
# rm перед записью обязателен: после chown ниже файл принадлежит
|
METRICS_TELEGRAM_ONCALL="${METRICS_TELEGRAM_ONCALL:-}" \
|
||||||
# 65534 с правами 600, и на СЛЕДУЮЩЕМ деплое `>` в него уже не
|
envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_TOPIC_LINE} ${METRICS_TELEGRAM_ONCALL}' \
|
||||||
# запишет. Каталог принадлежит деплой-пользователю, поэтому
|
|
||||||
# пересоздать файл он может, а перезаписать — нет.
|
|
||||||
rm -f ops/metrics/alertmanager/alertmanager.yml
|
|
||||||
envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_TOPIC_LINE}' \
|
|
||||||
< ops/metrics/alertmanager/alertmanager.yml.tmpl \
|
< ops/metrics/alertmanager/alertmanager.yml.tmpl \
|
||||||
> ops/metrics/alertmanager/alertmanager.yml
|
> ops/metrics/alertmanager/alertmanager.yml
|
||||||
chmod 600 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
|
group_interval: 12h
|
||||||
repeat_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
|
- receiver: telegram
|
||||||
matchers:
|
matchers:
|
||||||
- severity = "critical"
|
- severity = "critical"
|
||||||
|
|
@ -63,6 +82,30 @@ ${METRICS_TELEGRAM_TOPIC_LINE}
|
||||||
{{ if .Annotations.description }}{{ .Annotations.description }}{{ end }}
|
{{ if .Annotations.description }}{{ .Annotations.description }}{{ end }}
|
||||||
{{ 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
|
- name: telegram-heartbeat
|
||||||
telegram_configs:
|
telegram_configs:
|
||||||
- bot_token: "${METRICS_TELEGRAM_BOT_TOKEN}"
|
- bot_token: "${METRICS_TELEGRAM_BOT_TOKEN}"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue