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 — нет. Упоминание переехало из шаблона Alertmanager в сервис alert-ack вместе с самим сообщением (Alertmanager не умеет инлайн-клавиатуру, поэтому клиентский маршрут теперь идёт вебхуком). Проверка та же по смыслу — аккаунт не должен быть зашит в репозиторий, — но смотрит туда, где текст сообщения формируется сейчас. """ service = (REPO_ROOT / "ops" / "metrics" / "alert-ack" / "app.py").read_text(encoding="utf-8") assert "METRICS_TELEGRAM_ONCALL" in service, "упоминание дежурного не параметризовано" assert "@leks361" not in service, "конкретный аккаунт зашит в репозиторий" assert "@leks361" not in TEMPLATE.read_text(encoding="utf-8"), "аккаунт зашит в шаблон" def test_klientskiy_marshrut_idyot_v_servis_knopki() -> None: """Клиентский приёмник — вебхук в alert-ack, а не прямой Telegram. Прямой канал остаётся у ПРОЧИХ маршрутов: чем меньше звеньев у алерта, тем он надёжнее, и терять это для инфраструктурных сообщений незачем. Кнопка нужна там, где требуется отметка «взял в работу». """ text = TEMPLATE.read_text(encoding="utf-8") block = text[text.index("- name: telegram-clients") :] block = block[: block.index("- name: telegram-heartbeat")] assert "webhook_configs" in block, "клиентский приёмник не переключён на сервис" assert "alert-ack:8080/alertmanager" in block, "вебхук указывает не на сервис кнопки" # У прочих приёмников прямой путь сохранён. assert "telegram_configs" 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}"