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