From 6febb36afd44ea871eba094ffd6459d89cc3f358 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 13:55:53 +0300 Subject: [PATCH] =?UTF-8?q?fix(ops):=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D1=87=D0=B8=D1=82=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=20Prometheus,=20=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D0=B4=D1=91=D1=82=20=D0=B5=D0=B3=D0=BE=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=B8=D1=81=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lastConfigTime у gendesign-prometheus совпадал со startTime контейнера 16 суток: docker compose up -d не пересоздаёт контейнер из-за изменения содержимого бинд-маунта (сравнивается только описание сервиса), а --web.enable-lifecycle был включён, но /-/reload никто не вызывал. Любая правка ops/metrics/prometheus/** доезжала до диска и молча не вступала в силу до случайного рестарта, при зелёном деплое. Добавлен шаг по образцу уже работающей проверки Caddyfile в этом же workflow: promtool check config + promtool check rules внутри контейнера, reload только при успешной проверке, приёмка через сравнение lastConfigTime до/после (обновляется на каждый успешный reload, поэтому надёжно ловит и несостоявшийся вызов). Провал promtool теперь роняет шаг и не трогает работающий Prometheus. Alertmanager уже чинился отдельно (--force-recreate, #3078/#3136) — reload для него намеренно не помогает из-за переиспользуемого инода, это не regressed. Loki (/etc/loki/loki-config.yml), Grafana (provisioning) и Alloy (config.alloy) в том же деплое лежат на дисковых бинд-маунтах без reload — чинить их этим PR не стал, см. summary задачи. Refs #3467, #3471 --- .forgejo/workflows/deploy-metrics.yml | 45 +++++++ .../tests/ops/test_3467_prometheus_reload.py | 112 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 backend/tests/ops/test_3467_prometheus_reload.py diff --git a/.forgejo/workflows/deploy-metrics.yml b/.forgejo/workflows/deploy-metrics.yml index 559784aa..90db7731 100644 --- a/.forgejo/workflows/deploy-metrics.yml +++ b/.forgejo/workflows/deploy-metrics.yml @@ -344,6 +344,51 @@ jobs: done docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps + # ── Prometheus: конфиг/правила лежат на диске, `up -d` их не + # перечитывает ──────────────────────────────────────────────── + # Тот же класс бага, что у Caddyfile и alertmanager.yml выше: + # docker compose сравнивает описание сервиса, а НЕ содержимое + # бинд-маунта, поэтому уже работающий контейнер продолжает жить + # со старым конфигом сколько угодно — на проде дошло до 16 суток + # незамеченными (#3467): lastConfigTime совпадал со startTime + # контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**. + # + # У Prometheus, в отличие от Alertmanager (см. комментарий выше), + # /-/reload переоткрывает файлы ПО ПУТИ заново, поэтому новый инод + # после `git reset --hard` подхватывается без пересоздания + # контейнера. --web.enable-lifecycle уже включён в compose ради + # этого шага (см. docker-compose.metrics.yml) — просто раньше + # никто не звал сам reload. + # + # promtool проверяет ОБА файла ДО reload: битый конфиг не должен + # положить работающий Prometheus молчаливым откатом на дефолты. + if docker exec gendesign-prometheus promtool check config /etc/prometheus/prometheus.yml \ + && docker exec gendesign-prometheus sh -c 'promtool check rules /etc/prometheus/rules/*.yml'; then + LAST_CONFIG_BEFORE="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')" + + docker exec gendesign-prometheus wget -q -O /dev/null --post-data='' http://localhost:9090/-/reload + + # lastConfigTime обновляется на КАЖДЫЙ успешный reload, даже + # если содержимое конфига не поменялось — значит сравнение + # "было/стало" надёжно ловит и несостоявшийся reload, и + # изменившиеся правила. + LAST_CONFIG_AFTER="" + for i in $(seq 1 10); do + LAST_CONFIG_AFTER="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')" + [ -n "$LAST_CONFIG_AFTER" ] && [ "$LAST_CONFIG_AFTER" != "$LAST_CONFIG_BEFORE" ] && break + sleep 1 + done + + if [ -z "$LAST_CONFIG_AFTER" ] || [ "$LAST_CONFIG_AFTER" = "$LAST_CONFIG_BEFORE" ]; then + echo "ОШИБКА: reload Prometheus не подтверждён — lastConfigTime не изменился ($LAST_CONFIG_BEFORE)." + exit 1 + fi + echo "Prometheus: конфиг и правила проверены, reload подтверждён ($LAST_CONFIG_BEFORE -> $LAST_CONFIG_AFTER)." + else + echo "ОШИБКА: конфиг/правила Prometheus не проходят promtool — reload НЕ выполнен, работающий Prometheus остаётся на прежнем конфиге." + exit 1 + fi + # ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════ agent-apps: runs-on: ubuntu-latest diff --git a/backend/tests/ops/test_3467_prometheus_reload.py b/backend/tests/ops/test_3467_prometheus_reload.py new file mode 100644 index 00000000..d27e5673 --- /dev/null +++ b/backend/tests/ops/test_3467_prometheus_reload.py @@ -0,0 +1,112 @@ +"""Правки 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 — только для Prometheus починка не пересоздание +контейнера, а именно `POST /-/reload`: он переоткрывает файлы конфига по +пути заново, так что новый инод после `git reset --hard` подхватывается +без даунтайма. + +Проверяется здесь: (1) валидация promtool ЕСТЬ, (2) reload вызывается +ТОЛЬКО после успешной валидации, (3) шаг обязан упасть, если reload не +подтверждён сменой lastConfigTime. +""" + +from __future__ import annotations + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] +WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml" + + +def _text() -> str: + return WORKFLOW.read_text(encoding="utf-8") + + +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 = text.index("if docker exec gendesign-prometheus promtool check config") + 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 = text.index("if docker exec gendesign-prometheus promtool check config") + 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 не изменился, шаг обязан падать, а не считаться успешным" + )