gendesign/backend/tests/ops/test_3467_prometheus_reload.py
bot-backend 3aef5c3631
All checks were successful
CI Trade-In / changes (pull_request) Successful in 14s
CI / changes (pull_request) Successful in 15s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m51s
CI / backend-tests (pull_request) Successful in 18m17s
Гейт #3467 больше не слеп к || true и к маунту без :ro (ревью PR #3475)
Мутации ревьюера проходили гейт насквозь:

- к шагу `POST /-/reload` дописано `|| true` — все 7 тестов зелёные. Regex
  заканчивался на URL и хвост строки не смотрел; сегодня спасает `set -e`, но
  именно так («зелёная сводка ≠ зелёный выход») в этом репозитории уже ломали
  гейты. Добавлен отрицательный lookahead на `||` в той же строке;
- новый одиночный файловый маунт БЕЗ суффикса `:ro` — тест зелёный, потому что
  распознавание маунта требовало `:ro`. Права к типу маунта отношения не имеют:
  инод он держит одинаково. Требование снято.

Проверка promtool переехала ВЫШЕ `docker compose up -d`: прежнее место (между
подъёмом и перезагрузкой) не спасало от битого конфига, потому что `up -d`
пересоздаёт контейнер при смене образа. Новый тест сторожит и это.

Якоря границ серверной джобы и блока триггеров читаются через find с внятным
assert: при переименовании шага гейт скажет, что сломалось, а не даст ValueError.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 16:18:45 +05:00

213 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Деплой метрик обязан не только положить конфигурацию на диск, но и применить её.
ЧТО СЛУЧИЛОСЬ НА ПРОДЕ 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 останется без эффекта при зелёном деплое"
)