"""Правки Prometheus-конфига/правил обязаны доезжать до работающего процесса. ЧТО СЛУЧИЛОСЬ НА ПРОДЕ. `GET /api/v1/status/runtimeinfo` внутри `gendesign-prometheus` 12.09 отдавал `lastConfigTime`, совпадающий со `startTime` контейнера, — конфиг и правила не перечитывались 16 суток. Деплой при этом был зелёный: `docker compose up -d` не пересоздаёт контейнер из-за изменения содержимого бинд-маунта (он сравнивает только описание сервиса), а `--web.enable-lifecycle` был включён в docker-compose.metrics.yml, но эндпоинт `/-/reload` никто не вызывал. Тот же класс бага, что уже пойман и починен для Caddy (`caddy reload`) и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py) в этом же workflow. `POST /-/reload` лечит правила — они подключены КАТАЛОГОМ. prometheus.yml же — бинд-маунт ОДНОГО файла: reload перечитывает за тем же путём СТАРЫЙ инод (prom/prometheus:v3.1.0, 17.09: файл подменён, reload rc=0, в /api/v1/status/config прежний job). Поэтому при расхождении инода контейнер пересоздаётся (ops/metrics/recreate-stale-mount.sh, его поведение — test_metrics_single_file_mounts.py), а promtool проверяет файлы С ДИСКА: `docker exec` в работающий контейнер проверял бы старый инод. Проверяется здесь: (1) валидация promtool ЕСТЬ и идёт по файлам с диска, (2) reload и пересоздание — ТОЛЬКО после успешной валидации, (3) шаг обязан упасть, если reload не подтверждён сменой lastConfigTime, (4) датасорсы Grafana перечитываются, и отказ этого шага роняет деплой. """ from __future__ import annotations import re from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[3] WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml" CHECK_CONFIG = "promtool check config /etc/prometheus/prometheus.yml" def _text() -> str: return WORKFLOW.read_text(encoding="utf-8") def _guard_start(text: str) -> int: """Начало `if <проверка promtool>; then` — ближайший `if` перед check config.""" guard = text.rfind("\n if ", 0, text.index(CHECK_CONFIG)) assert guard != -1, "проверка promtool не стоит под `if` — reload ничем не защищён" return guard def test_promtool_checks_config_and_rules() -> None: """promtool обязан проверять и конфиг, и правила — не только один файл.""" text = _text() assert "promtool check config /etc/prometheus/prometheus.yml" in text, ( "нет проверки конфига promtool'ом — битый prometheus.yml долетит до reload" ) assert "promtool check rules" in text, ( "нет проверки правил promtool'ом — битое правило долетит до reload" ) def test_reload_endpoint_is_called() -> None: """Сам reload обязан вызываться — иначе валидация ничего не решает.""" text = _text() assert "localhost:9090/-/reload" in text, ( "нет вызова POST /-/reload — конфиг/правила проверяются, но в силу не вступают " "(#3467: lastConfigTime не менялся 16 суток при зелёном деплое)" ) def test_reload_happens_after_validation_not_before() -> None: """Reload обязан идти ПОСЛЕ promtool, а не до/вместо него.""" text = _text() check_pos = text.index("promtool check config /etc/prometheus/prometheus.yml") reload_pos = text.index("localhost:9090/-/reload") assert check_pos < reload_pos, ( "reload стоит раньше проверки конфига — битый конфиг мог бы применяться вслепую" ) def test_reload_is_guarded_by_the_promtool_check() -> None: """Reload обязан быть ВНУТРИ `if promtool ...; then`, а не безусловным.""" text = _text() guard_start = _guard_start(text) else_pos = text.index("else", guard_start) reload_pos = text.index("localhost:9090/-/reload") assert guard_start < reload_pos < else_pos, ( "вызов reload лежит вне ветки успешной проверки promtool — " "битый конфиг всё равно приведёт к reload, либо reload вообще не защищён проверкой" ) def test_failed_validation_skips_reload_and_fails_the_step() -> None: """При провале promtool — reload НЕ вызывается, и шаг падает (exit 1).""" text = _text() guard_start = _guard_start(text) else_pos = text.index("else", guard_start) fi_pos = text.index("fi", else_pos) else_branch = text[else_pos:fi_pos] assert "localhost:9090/-/reload" not in else_branch, ( "reload вызывается даже в ветке провалившейся проверки" ) assert "exit 1" in else_branch, ( "провал promtool не роняет шаг — деплой останется зелёным при битом конфиге" ) def test_acceptance_checks_last_config_time_actually_changed() -> None: """Приёмка обязана сверять `lastConfigTime` до/после, а не доверять коду ответа reload. `wget` на POST /-/reload может отрапортовать успех, даже если Prometheus молча остался на старом конфиге (например, если бинарь внутри образа не поддерживает --post-data так, как ожидалось) — единственное надёжное подтверждение реального перечитывания конфига это смена таймстемпа. """ text = _text() assert text.count("lastConfigTime") >= 2, ( "нет сравнения lastConfigTime до/после — reload не проверяется по факту" ) assert "LAST_CONFIG_BEFORE" in text and "LAST_CONFIG_AFTER" in text, ( "нет явного до/после сравнения таймстемпа последней перезагрузки конфига" ) verify_start = text.index("LAST_CONFIG_AFTER") verify_block_end = text.index("Prometheus: конфиг и правила проверены", verify_start) verify_block = text[verify_start:verify_block_end] assert "exit 1" in verify_block, ( "если lastConfigTime не изменился, шаг обязан падать, а не считаться успешным" ) def test_promtool_checks_files_from_disk_not_the_running_container() -> None: """Проверяется то, что сейчас применится, а не то, что держит контейнер. prometheus.yml смонтирован одним файлом: `docker exec … promtool check config` в работающем контейнере читает СТАРЫЙ инод — битая правка прошла бы проверку и уехала бы в пересоздание. """ text = _text() guard = text[_guard_start(text) : text.index(CHECK_CONFIG)] assert "docker run --rm" in guard, "promtool запускается не одноразовым контейнером" assert "/opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro" in guard, ( "одноразовый promtool смотрит не на файлы с диска по боевым путям" ) assert not re.search(r"docker exec gendesign-prometheus[^\n]*promtool", text), ( "promtool всё ещё проверяет работающий контейнер — то есть старый инод" ) def test_stale_prometheus_yml_is_recreated_between_check_and_reload() -> None: """Пересоздание по иноду — после проверки promtool и до reload.""" text = _text() check = text.index("promtool check config /etc/prometheus/prometheus.yml") recreate = re.search( r"recreate-stale-mount\.sh \\\s+gendesign-metrics docker-compose\.metrics\.yml \\\s+" r"prometheus ops/metrics/prometheus/prometheus\.yml /etc/prometheus/prometheus\.yml", text, ) assert recreate, "prometheus.yml не сверяется по иноду — правка конфига не доедет" else_pos = text.index("else", _guard_start(text)) assert check < recreate.start() < text.index("localhost:9090/-/reload") < else_pos, ( "пересоздание Prometheus стоит не между успешной проверкой и reload" ) def test_grafana_datasources_are_reloaded_and_failure_is_red() -> None: """Датасорсы Grafana применяются только при старте — их надо перечитать явно. Замер в PR #3475 (grafana:11.5.1): изменённый url не применился и через 75 с, POST /api/admin/provisioning/datasources/reload применил сразу. Ответ ручки в v11.5.1 — «Datasources config reloaded» (pkg/api/admin_provisioning.go). """ text = _text() call = text.find("http://localhost:3000/api/admin/provisioning/datasources/reload") assert call != -1, "деплой не перечитывает датасорсы Grafana" block = re.search(r'case "\$GRAFANA_RELOAD" in(.+?)\besac\b', text[call:], re.S) assert block, "результат перезагрузки датасорсов не разбирается" ok, _, failed = block.group(1).partition(";;") assert "Datasources config reloaded" in ok, "успех не сверяется по тексту ответа ручки" assert "exit 1" in failed and "exit 1" not in ok, "отказ перезагрузки не роняет деплой"