From 6f120c6605ae5258ae2fd24e829382463831f691 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 27 Aug 2026 13:05:32 +0300 Subject: [PATCH] =?UTF-8?q?feat(observability):=20=D0=BA=D0=BB=D0=B8=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D1=81=D0=BA=D0=B8=D0=B9=20=D0=B8=D0=BD=D1=86=D0=B8?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D1=82=20=D0=B7=D0=BE=D0=B2=D1=91=D1=82=20?= =?UTF-8?q?=D0=B4=D0=B5=D0=B6=D1=83=D1=80=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D0=BC=D1=91=D0=BD=D0=BD=D0=BE=20+=20=D1=87=D0=B8?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=20=D1=81=D0=BB=D0=BE=D0=BC=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B4=D0=BE=D0=BB=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D1=8B?= =?UTF-8?q?=20(#3078)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Две вещи, вторая — исправление собственной ошибки из #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, а не только к месту ожога. --- .forgejo/workflows/deploy-metrics.yml | 27 +++- backend/tests/ops/test_3078_oncall_route.py | 123 ++++++++++++++++++ .../alertmanager/alertmanager.yml.tmpl | 45 ++++++- 3 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 backend/tests/ops/test_3078_oncall_route.py 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}" -- 2.45.3