diff --git a/.forgejo/workflows/deploy-metrics.yml b/.forgejo/workflows/deploy-metrics.yml
index ce23046c..214cdc3f 100644
--- a/.forgejo/workflows/deploy-metrics.yml
+++ b/.forgejo/workflows/deploy-metrics.yml
@@ -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
diff --git a/backend/tests/ops/test_3078_oncall_route.py b/backend/tests/ops/test_3078_oncall_route.py
new file mode 100644
index 00000000..b678499d
--- /dev/null
+++ b/backend/tests/ops/test_3078_oncall_route.py
@@ -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}"
diff --git a/ops/metrics/alertmanager/alertmanager.yml.tmpl b/ops/metrics/alertmanager/alertmanager.yml.tmpl
index e7539309..1920d6a1 100644
--- a/ops/metrics/alertmanager/alertmanager.yml.tmpl
+++ b/ops/metrics/alertmanager/alertmanager.yml.tmpl
@@ -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" }}🚨 КЛИЕНТЫ ЗАТРОНУТЫ{{ else }}✅ Восстановлено{{ 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}"