"""Деплой метрик обязан не только положить конфигурацию на диск, но и применить её. ЧТО СЛУЧИЛОСЬ НА ПРОДЕ 12.09 (#3467). #3464 поправил два правила в `ops/metrics/prometheus/rules/infra.yml`, «Deploy Metrics / server» на этом коммите — success, новый текст лежал и на диске инфраструктурного хоста, и ВНУТРИ контейнера: docker exec gendesign-prometheus grep -n container_memory_working_set_bytes \ /etc/prometheus/rules/infra.yml → строка 139, новая формулировка а API отдавал прежнее правило: GET /api/v1/rules → ContainerNearMemoryLimit: container_spec_memory_limit_bytes{name!=""} > 0 and ... ПОЧЕМУ. Правила подключены КАТАЛОГОМ, поэтому новый текст контейнер видит сразу, но Prometheus читает правила только при старте или по явной перезагрузке. Контейнер не перезапускался с 2026-08-27T18:12:24Z (16 суток), а деплой перезагружал один Caddy. То есть ЛЮБАЯ правка ops/metrics/prometheus/** с момента появления стека доезжала до диска и не вступала в силу до случайного пересоздания контейнера — при зелёном деплое и без единого отрицательного признака. Тот же класс, что #3448: объявлено ≠ исполняется. ЧТО ПРОВЕРЕНО НА СТЕНДЕ, а не взято из документации (prom/prometheus:v3.1.0, те же файлы, что на проде): правило переписано на диске, контейнер не тронут → API отдаёт СТАРОЕ promtool check config && check rules → rc=0, затем POST /-/reload → rc=0 после reload → API отдаёт НОВОЕ битое правило → проверка rc=1 (reload не делается) пустой каталог правил → `check config` ПРОХОДИТ («0 rule files found»), падает только `check rules` — потому в деплое стоят обе команды, а не одна Тест сторожит связку целиком: правки правил приезжают этим воркфлоу (триггер), перезагрузка в нём есть, она стоит ПОСЛЕ проверки конфигурации, а провалившаяся проверка обрывает деплой и не доходит до перезагрузки. Отдельно проверяется, что у каждого одиночного файлового маунта есть путь доезда: такой маунт держит инод, и перезагрузка по API его не лечит (см. test_3xxx_alertmanager_inode.py). """ 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" COMPOSE = REPO_ROOT / "docker-compose.metrics.yml" # Перезагрузка Prometheus: POST на ручку lifecycle из работающего контейнера. # # Хвост строки смотрим НАРОЧНО: `|| true` после команды глушит её отказ, и шаг # остаётся зелёным с неперечитанной конфигурацией — то есть ровно тот разрыв, # который тут и чинится, только теперь ещё и с «зелёной сводкой». Ревьюер # #3475 этой мутацией прошёл гейт насквозь. RELOAD = re.compile( r"docker exec gendesign-prometheus wget[^\n]*--post-data[^\n]*" r"http://localhost:9090/-/reload(?![^\n]*\|\|)" ) # Проверка конфигурации: обе команды promtool по файлам с диска. CHECK = re.compile( r"promtool check config \S*prometheus\.yml" r" && promtool check rules \S*rules/\*\.yml" ) # Ветка «проверка не прошла» — от `if ! ... promtool ...` до её `fi`. CHECK_FAILED_BRANCH = re.compile(r"if ! docker run[^\n]*promtool[^\n]*; then(.+?)\n\s*fi\n", re.S) # Одиночный файловый маунт в серверном стеке: ./ops/metrics/... : /путь/в/контейнере. # Суффикс прав (`:ro`) НЕ требуется: инод маунт держит одинаково что на чтение, # что на запись, а требование `:ro` делало гейт слепым к маунту без него. MOUNT = re.compile(r"-\s+\./(ops/metrics/[^\s:]+):(/[^\s:]+)") def _server_script() -> str: """Скрипт серверной джобы с ЗАКЛЕЕННЫМИ переносами строк. Команды в воркфлоу разбиты `\\` по ширине, и без склейки любой поиск «команда целиком» ловил бы только первую строку. """ text = WORKFLOW.read_text(encoding="utf-8") start = text.find("Поднять серверный стек") end = text.find("# ═══ АГЕНТЫ") assert start != -1 and end != -1, ( "в deploy-metrics.yml не найдены границы серверной джобы: шаг «Поднять " "серверный стек» или заголовок «АГЕНТЫ» переименовали — гейт смотрел бы " "не туда, поправь якоря здесь" ) return re.sub(r"\s*\\\n\s*", " ", text[start:end]) def test_rules_are_delivered_by_this_workflow() -> None: """Правки правил приезжают на хост именно этим деплоем. Без этого остальные проверки сторожили бы механизм, который никто не запускает: «объявлено» и «исполняется» разошлись бы уже на триггере. """ text = WORKFLOW.read_text(encoding="utf-8") start, end = text.find("paths:"), text.find("workflow_dispatch") assert start != -1 and end != -1, "в deploy-metrics.yml не найден блок триггеров paths:" assert '"ops/metrics/**"' in text[start:end], ( "деплой не запускается на правку ops/metrics/** — правила меняются мимо него" ) def test_deploy_reloads_prometheus() -> None: """Перезагрузка Prometheus в деплое есть. Её отсутствие и есть #3467: конфигурация на диске новая, работающий процесс на старой, деплой зелёный. """ assert RELOAD.search(_server_script()), ( "в серверной джобе нет POST /-/reload для Prometheus — правки правил " "лягут на диск и не вступят в силу, как это и было с #3464" ) def test_reload_comes_after_config_check() -> None: """Перезагрузка — только после проверки конфигурации, как у Caddy. Порядок здесь несущий: перезагрузка битого конфига оставляет Prometheus на прежнем — то есть ровно тот же тихий отказ, который чинится. """ script = _server_script() check = CHECK.search(script) reload_ = RELOAD.search(script) assert check, "в деплое нет проверки promtool (check config + check rules)" assert reload_, "в деплое нет перезагрузки Prometheus" assert check.start() < reload_.start(), ( "перезагрузка стоит РАНЬШЕ проверки конфигурации — битый конфиг молча " "оставит Prometheus на старой конфигурации при зелёном деплое" ) def test_config_check_precedes_any_application() -> None: """Проверка стоит ВЫШЕ `up -d`, а не между подъёмом и перезагрузкой. `up -d` пересоздаёт контейнер при смене образа: проверка ниже него не успевает спасти — битый конфиг уже применён стартом нового контейнера, и Prometheus не поднимется вовсе. """ script = _server_script() check = CHECK.search(script) assert check, "в деплое нет проверки promtool (check config + check rules)" assert check.start() < script.index("up -d --remove-orphans"), ( "проверка конфигурации стоит после `up -d` — контейнер успеет " "пересоздаться с битым конфигом раньше, чем деплой покраснеет" ) def test_failed_check_stops_deploy_without_reload() -> None: """Провалившаяся проверка обрывает деплой и НЕ перезагружает Prometheus.""" branch = CHECK_FAILED_BRANCH.search(_server_script()) assert branch, "проверка promtool не закрыта веткой обработки отказа" body = branch.group(1) assert "exit 1" in body, "при провале проверки деплой продолжается — отказ выглядел бы успехом" assert "/-/reload" not in body, "в ветке отказа делается перезагрузка" def test_lifecycle_flag_makes_reload_possible() -> None: """Ручка /-/reload работает только с --web.enable-lifecycle. Флаг и перезагрузка — одно целое: снимут флаг, и POST начнёт отвечать 405. """ assert "--web.enable-lifecycle" in COMPOSE.read_text(encoding="utf-8"), ( "у Prometheus снят --web.enable-lifecycle — перезагрузка из деплоя невозможна" ) def test_every_single_file_mount_has_a_way_to_arrive() -> None: """У каждого одиночного файлового маунта есть путь доезда до контейнера. Бинд-маунт ОДНОГО файла держит инод: `git reset --hard` пишет новый файл, а контейнер продолжает читать прежний — `up -d` этого не видит, перезагрузка по API тоже (перечитывается тот же инод). Лечит только пересоздание, и деплой обязан сверять инод по каждому такому файлу. Список берётся ИЗ compose, а не из воркфлоу: добавят новый файловый маунт — тест покраснеет, пока для него не появится сверка. Производные файлы (alertmanager.yml, alertmanager_targets.gen.yml) в git не лежат и потому сюда не попадают: их рендерит сам деплой и сам разбирается с инодом. """ script = _server_script() mounts = [ (host, container) for host, container in MOUNT.findall(COMPOSE.read_text(encoding="utf-8")) if (REPO_ROOT / host).is_file() ] assert len(mounts) == 3, ( f"ожидались три одиночных файловых маунта (prometheus.yml, loki-config.yml, " f"alert-ack/app.py), найдено: {mounts}" ) assert "stat -c %i" in script and '--force-recreate "$1"' in script, ( "в деплое нет сверки инода с пересозданием контейнера" ) for host, container in mounts: assert re.search(rf'"[^"\n]*{re.escape(host)} {re.escape(container)}[^"\n]*"', script), ( f"{host} не сверяется с {container}: правка этого файла ляжет на диск, " f"а контейнер останется на старом иноде — молча" ) def test_grafana_datasources_are_reloaded() -> None: """Датасорсы Grafana применяются ТОЛЬКО при старте — их надо перечитать явно. Замер на стенде (grafana/grafana:11.5.1, provisioning из этого репозитория): новый дашборд появился сам за 30 с (updateIntervalSeconds), а изменённый url датасорса не применился и через 75 с; POST на /api/admin/provisioning/datasources/reload применил его сразу. """ assert "/api/admin/provisioning/datasources/reload" in _server_script(), ( "деплой не перечитывает датасорсы Grafana — правка " "provisioning/datasources останется без эффекта при зелёном деплое" )