Хвосты #3467, не попавшие в main вместе с #3476: - prometheus.yml смонтирован одним файлом: после git reset --hard reload перечитывал СТАРЫЙ инод с rc=0 и новым lastConfigTime (стенд prom/prometheus:v3.1.0, 17.09). Комментарий в деплое утверждал обратное. Теперь promtool проверяет файлы С ДИСКА одноразовым контейнером, а при расхождении инода контейнер пересоздаётся до reload. - loki-config.yml — тот же пофайловый маунт, перезагрузки у Loki нет: пересоздание при расхождении инода. - Датасорсы Grafana применяются только при старте: POST /api/admin/provisioning/datasources/reload, отказ роняет деплой. Общий шаг — ops/metrics/recreate-stale-mount.sh: пересоздаёт только при расхождении инода и перепроверяет после; тесты исполняют его с подставным docker. Гейт берёт пофайловые маунты из docker-compose.metrics.yml. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 lines
10 KiB
Python
176 lines
10 KiB
Python
"""Правки 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, "отказ перезагрузки не роняет деплой"
|