Метрики: правки prometheus.yml, конфига Loki, датасорсов Grafana и запросов postgres-экспортёров вступают в силу после деплоя #3557
5 changed files with 541 additions and 38 deletions
|
|
@ -233,6 +233,10 @@ jobs:
|
|||
# уезжает в main зелёной. Ровно то, что осуждает комментарий выше.
|
||||
- '.forgejo/workflows/deploy-metrics.yml'
|
||||
- 'docker-compose.metrics.yml'
|
||||
# #3486: гейт test_metrics_single_file_mounts.py читает и
|
||||
# compose агентов — правка ТОЛЬКО его (новый пофайловый маунт у
|
||||
# экспортёра) без этой строки прошла бы мимо гейта.
|
||||
- 'docker-compose.metrics-agent.yml'
|
||||
# #3443: тот же класс, третий раз. Гейт
|
||||
# backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает
|
||||
# ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть
|
||||
|
|
|
|||
|
|
@ -388,6 +388,15 @@ jobs:
|
|||
echo "Alertmanager: контейнер пересоздан — иначе читал бы конфиг по старому иноду."
|
||||
fi
|
||||
|
||||
# ── Loki: конфиг — бинд-маунт ОДНОГО файла (#3467) ─────────────
|
||||
# Та же ловушка инода, что у Alertmanager выше, а перезагрузки
|
||||
# основного конфига у Loki нет вовсе: правка loki-config.yml ложилась
|
||||
# на диск и не вступала в силу до случайного пересоздания.
|
||||
# Пересоздаём только при расхождении инода.
|
||||
COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \
|
||||
gendesign-metrics docker-compose.metrics.yml \
|
||||
loki ops/metrics/loki/loki-config.yml /etc/loki/loki-config.yml
|
||||
|
||||
# ── Caddy: СНАЧАЛА проверить, потом применять ──────────────────
|
||||
# На этом хосте тот же Caddy обслуживает git., errors. и obsidian.
|
||||
# Синтаксическая ошибка в infra.caddy положила бы их все, включая
|
||||
|
|
@ -406,15 +415,6 @@ jobs:
|
|||
fi
|
||||
fi
|
||||
|
||||
# ── Приёмка ────────────────────────────────────────────────────
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
|
||||
|
||||
# ── Prometheus: конфиг/правила лежат на диске, `up -d` их не
|
||||
# перечитывает ────────────────────────────────────────────────
|
||||
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше:
|
||||
|
|
@ -424,27 +424,41 @@ jobs:
|
|||
# незамеченными (#3467): lastConfigTime совпадал со startTime
|
||||
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
|
||||
#
|
||||
# У Prometheus, в отличие от Alertmanager (см. комментарий выше),
|
||||
# /-/reload переоткрывает файлы ПО ПУТИ заново, поэтому новый инод
|
||||
# после `git reset --hard` подхватывается без пересоздания
|
||||
# контейнера. --web.enable-lifecycle уже включён в compose ради
|
||||
# этого шага (см. docker-compose.metrics.yml) — просто раньше
|
||||
# никто не звал сам reload.
|
||||
# /-/reload перечитывает правила: они подключены КАТАЛОГОМ, и новый
|
||||
# файл контейнер видит сразу. А prometheus.yml — бинд-маунт ОДНОГО
|
||||
# файла: после `git reset --hard` за путём в контейнере остаётся
|
||||
# СТАРЫЙ инод, и reload честно перечитывает старый текст с rc=0 и
|
||||
# новым lastConfigTime (проверено на prom/prometheus:v3.1.0, 17.09).
|
||||
# Поэтому при расхождении инода контейнер пересоздаётся (#3467).
|
||||
# --web.enable-lifecycle включён в compose ради этого шага.
|
||||
#
|
||||
# promtool проверяет ОБА файла ДО reload: битый конфиг не должен
|
||||
# положить работающий Prometheus молчаливым откатом на дефолты.
|
||||
#
|
||||
# Плюс юнит-тесты правил (#3493): синтаксически верное правило может
|
||||
# врать по смыслу — `count(x == 1) == 0` от пустого вектора не
|
||||
# срабатывает никогда, а под текстом «воркер мёртв» горел не поднятый
|
||||
# экспортёр. Каталог tests/ в контейнер не смонтирован, поэтому
|
||||
# одноразовый контейнер ТОГО ЖЕ образа поверх файлов с диска.
|
||||
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' \
|
||||
&& docker run --rm --entrypoint promtool \
|
||||
-v /opt/gendesign/ops/metrics/prometheus:/work:ro \
|
||||
# promtool проверяет конфиг, правила и их юнит-тесты (#3493) ДО
|
||||
# любого применения, и именно ФАЙЛЫ С ДИСКА — одноразовым
|
||||
# контейнером того же образа. `docker exec` в работающий проверял
|
||||
# бы тот самый старый инод, а не то, что сейчас применится.
|
||||
# Синтаксически верное правило может врать по смыслу —
|
||||
# `count(x == 1) == 0` от пустого вектора не срабатывает никогда, —
|
||||
# отсюда test rules.
|
||||
if docker run --rm --entrypoint sh \
|
||||
-v /opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro \
|
||||
"$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \
|
||||
test rules /work/tests/infra_test.yml; then
|
||||
-c 'promtool check config /etc/prometheus/prometheus.yml && promtool check rules /etc/prometheus/rules/*.yml && promtool test rules /etc/prometheus/tests/infra_test.yml'; then
|
||||
COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \
|
||||
gendesign-metrics docker-compose.metrics.yml \
|
||||
prometheus ops/metrics/prometheus/prometheus.yml /etc/prometheus/prometheus.yml
|
||||
|
||||
# ── Приёмка ────────────────────────────────────────────────
|
||||
# После возможного пересоздания выше — ждём оба процесса,
|
||||
# которые дальше перечитывают конфиг по HTTP.
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null \
|
||||
&& docker exec gendesign-grafana wget -q --spider http://localhost:3000/api/health 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
|
||||
|
||||
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
|
||||
|
|
@ -470,6 +484,30 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# ── Grafana: датасорсы применяются только при старте (#3467) ────
|
||||
# Каталог provisioning смонтирован целиком, новый datasources.yml
|
||||
# контейнер видит сразу. Дашборды провайдер пересканирует сам
|
||||
# (updateIntervalSeconds: 30), а датасорсы — нет: на стенде
|
||||
# grafana:11.5.1 изменённый url не применился и через 75 с, POST
|
||||
# на ручку ниже применил сразу (замер в PR #3475). Без этого шага
|
||||
# правка датасорса лежала бы на диске без эффекта при зелёном деплое.
|
||||
#
|
||||
# Пароль раскрывается ВНУТРИ контейнера: в argv хоста и в лог деплоя
|
||||
# он не попадает. wget отдаёт rc≠0 на 401/5xx; текст ответа сверяем
|
||||
# отдельно, чтобы «200 не от той ручки» не сошло за успех.
|
||||
GRAFANA_RELOAD="$(docker exec gendesign-grafana sh -c \
|
||||
'wget -q -O- --post-data="" --header="Authorization: Basic $(printf "%s:%s" "${GF_SECURITY_ADMIN_USER:-admin}" "${GF_SECURITY_ADMIN_PASSWORD}" | base64 -w0)" http://localhost:3000/api/admin/provisioning/datasources/reload' \
|
||||
2>&1)" || GRAFANA_RELOAD="rc=$? $GRAFANA_RELOAD"
|
||||
case "$GRAFANA_RELOAD" in
|
||||
*"Datasources config reloaded"*)
|
||||
echo "Grafana: датасорсы перечитаны."
|
||||
;;
|
||||
*)
|
||||
echo "ОШИБКА: Grafana не перечитала датасорсы ($GRAFANA_RELOAD) — правка provisioning/datasources осталась бы без эффекта."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
|
||||
agent-apps:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -561,6 +599,21 @@ jobs:
|
|||
[ "$(stat -c %i ops/metrics/alloy/alloy-apps.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|
||||
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
|
||||
|
||||
# ── postgres-экспортёры: queries.yml — бинд-маунт ОДНОГО файла (#3486) ──
|
||||
# PG_EXPORTER_EXTEND_QUERY_PATH читается только при старте, а
|
||||
# `git reset --hard` пишет правку новым инодом: без пересоздания
|
||||
# экспортёр продолжает отдавать старые запросы при зелёном деплое.
|
||||
# Пересоздаём только при расхождении инода. Под гейтом профиля:
|
||||
# `up` с явным именем сервиса включает его профиль сам.
|
||||
if [ -n "$EXPORTER_PROFILE" ]; then
|
||||
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
|
||||
postgres-exporter-gendesign ops/metrics/postgres/queries.yml /etc/pg-queries.yml
|
||||
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
|
||||
postgres-exporter-tradein ops/metrics/postgres/queries.yml /etc/pg-queries.yml
|
||||
fi
|
||||
|
||||
agent-infra:
|
||||
runs-on: ubuntu-latest
|
||||
needs: server
|
||||
|
|
@ -630,3 +683,10 @@ jobs:
|
|||
|
||||
[ "$(stat -c %i ops/metrics/alloy/alloy-infra.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|
||||
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
|
||||
|
||||
# queries.yml экспортёра инфраструктурной БД — см. agent-apps (#3486).
|
||||
if [ -n "$EXPORTER_PROFILE" ]; then
|
||||
METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||
sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \
|
||||
postgres-exporter-infra ops/metrics/postgres/queries.yml /etc/pg-queries.yml
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -10,28 +10,41 @@ docker-compose.metrics.yml, но эндпоинт `/-/reload` никто не в
|
|||
|
||||
Тот же класс бага, что уже пойман и починен для Caddy (`caddy reload`)
|
||||
и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py)
|
||||
в этом же workflow — только для Prometheus починка не пересоздание
|
||||
контейнера, а именно `POST /-/reload`: он переоткрывает файлы конфига по
|
||||
пути заново, так что новый инод после `git reset --hard` подхватывается
|
||||
без даунтайма.
|
||||
в этом же 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.
|
||||
Проверяется здесь: (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()
|
||||
|
|
@ -65,7 +78,7 @@ def test_reload_happens_after_validation_not_before() -> None:
|
|||
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")
|
||||
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, (
|
||||
|
|
@ -77,7 +90,7 @@ def test_reload_is_guarded_by_the_promtool_check() -> None:
|
|||
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")
|
||||
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]
|
||||
|
|
@ -110,3 +123,54 @@ def test_acceptance_checks_last_config_time_actually_changed() -> None:
|
|||
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, "отказ перезагрузки не роняет деплой"
|
||||
|
|
|
|||
310
backend/tests/ops/test_metrics_single_file_mounts.py
Normal file
310
backend/tests/ops/test_metrics_single_file_mounts.py
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
"""Правка пофайлово смонтированного конфига метрик обязана доезжать до контейнера.
|
||||
|
||||
ЧТО НЕ ТАК. `git reset --hard` пишет изменившийся файл НОВЫМ инодом, а bind-маунт
|
||||
одного файла держит прежний. `up -d` этого не видит (сравнивает описание сервиса),
|
||||
перезагрузка по API тоже: на prom/prometheus:v3.1.0 (17.09) prometheus.yml подменён,
|
||||
POST /-/reload → rc=0, lastConfigTime новый, а /api/v1/status/config отдаёт прежний
|
||||
job. Деплой зелёный, конфиг старый — у отказа нет отрицательного признака (#3467).
|
||||
Так смонтированы prometheus.yml и loki-config.yml на сервере и queries.yml у трёх
|
||||
postgres-экспортёров (#3486).
|
||||
|
||||
ЧТО ПРОВЕРЯЕТСЯ.
|
||||
1. ops/metrics/recreate-stale-mount.sh ИСПОЛНЯЕТСЯ с подставным `docker`: смотрим
|
||||
на совершённые действия (пересоздал / не тронул / упал), а не на текст.
|
||||
2. Гейт по compose-файлам метрик: у КАЖДОГО пофайлового маунта есть путь доезда в
|
||||
той джобе деплоя, что поднимает сервис. Список маунтов берётся ИЗ compose:
|
||||
добавят новый — тест покраснеет, пока путь не появится.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPT = REPO_ROOT / "ops" / "metrics" / "recreate-stale-mount.sh"
|
||||
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
||||
|
||||
SRC = "ops/metrics/loki/loki-config.yml"
|
||||
DST = "/etc/loki/loki-config.yml"
|
||||
|
||||
# Подставной `docker`. Каждый вызов пишется в $FAKE_LOG.
|
||||
# compose … ps -q — id контейнера из $FAKE_CID_FILE (пусто = не запущен);
|
||||
# код из $FAKE_PS_RC;
|
||||
# compose … up … — «пересоздание»: новый id cid-new, и контейнер видит
|
||||
# инод $FAKE_NEW_VIEW (пусто — не видит ничего);
|
||||
# exec ID stat -c %i … — инод, который видит контейнер ID ($FAKE_VIEW/ID).
|
||||
FAKE_DOCKER = r"""#!/bin/bash
|
||||
printf '%s\n' "$*" >> "$FAKE_LOG"
|
||||
cmd="$1"; shift
|
||||
case "$cmd" in
|
||||
exec)
|
||||
view="$FAKE_VIEW/$1"
|
||||
[ -f "$view" ] || exit 1
|
||||
cat "$view"
|
||||
;;
|
||||
compose)
|
||||
case " $* " in
|
||||
*" ps "*)
|
||||
[ "${FAKE_PS_RC:-0}" = "0" ] || { echo "compose error" >&2; exit "$FAKE_PS_RC"; }
|
||||
cat "$FAKE_CID_FILE"
|
||||
;;
|
||||
*" up "*)
|
||||
printf 'cid-new\n' > "$FAKE_CID_FILE"
|
||||
[ -z "${FAKE_NEW_VIEW:-}" ] || printf '%s\n' "$FAKE_NEW_VIEW" > "$FAKE_VIEW/cid-new"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
"""
|
||||
|
||||
# На раннере и на хостах stat — GNU/busybox (`-c %i`); на macOS — BSD (`-f %i`).
|
||||
# Шим ставится только там, где `-c` не понимают, иначе гейт не запускался бы локально.
|
||||
STAT_SHIM = (
|
||||
'#!/bin/sh\n[ "$1" = "-c" ] && [ "$2" = "%i" ] && exec /usr/bin/stat -f %i "$3"\n'
|
||||
'exec /usr/bin/stat "$@"\n'
|
||||
)
|
||||
NO_SLEEP = "#!/bin/sh\nexit 0\n"
|
||||
|
||||
|
||||
def _write_exec(path: Path, text: str) -> None:
|
||||
path.write_text(text, encoding="utf-8")
|
||||
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
|
||||
def _gnu_stat() -> bool:
|
||||
return subprocess.run(["stat", "-c", "%i", "/"], capture_output=True).returncode == 0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host(tmp_path: Path) -> Path:
|
||||
"""Боевое дерево: скрипт + конфиг; контейнер cid-old видит ТЕКУЩИЙ инод."""
|
||||
tree = tmp_path / "opt" / "gendesign"
|
||||
(tree / "ops" / "metrics" / "loki").mkdir(parents=True)
|
||||
shutil.copy(SCRIPT, tree / "ops" / "metrics" / SCRIPT.name)
|
||||
(tree / SRC).write_text("auth_enabled: false\n", encoding="utf-8")
|
||||
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
_write_exec(bin_dir / "docker", FAKE_DOCKER)
|
||||
_write_exec(bin_dir / "sleep", NO_SLEEP)
|
||||
if not _gnu_stat():
|
||||
_write_exec(bin_dir / "stat", STAT_SHIM)
|
||||
|
||||
view = tmp_path / "view"
|
||||
view.mkdir()
|
||||
(view / "cid-old").write_text(f"{_ino(tree)}\n", encoding="utf-8")
|
||||
(tmp_path / "cid").write_text("cid-old\n", encoding="utf-8")
|
||||
(tmp_path / "log").write_text("", encoding="utf-8")
|
||||
return tree
|
||||
|
||||
|
||||
def _ino(tree: Path) -> int:
|
||||
return os.stat(tree / SRC).st_ino
|
||||
|
||||
|
||||
def _stale(tree: Path) -> None:
|
||||
"""Контейнер держит прежний инод — как после `git reset --hard`."""
|
||||
(tree.parent.parent / "view" / "cid-old").write_text(f"{_ino(tree) + 1}\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _run(tree: Path, **env_extra: str) -> tuple[int, str, list[str]]:
|
||||
root = tree.parent.parent
|
||||
env = {
|
||||
"PATH": f"{root / 'bin'}:/usr/bin:/bin:/usr/sbin:/sbin",
|
||||
"FAKE_LOG": str(root / "log"),
|
||||
"FAKE_CID_FILE": str(root / "cid"),
|
||||
"FAKE_VIEW": str(root / "view"),
|
||||
**env_extra,
|
||||
}
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"sh",
|
||||
str(tree / "ops" / "metrics" / SCRIPT.name),
|
||||
"gendesign-metrics",
|
||||
"docker-compose.metrics.yml",
|
||||
"loki",
|
||||
SRC,
|
||||
DST,
|
||||
],
|
||||
cwd="/", # скрипт обязан сам встать в корень дерева
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
calls = [c for c in (root / "log").read_text(encoding="utf-8").splitlines() if c]
|
||||
return proc.returncode, proc.stdout + proc.stderr, calls
|
||||
|
||||
|
||||
def _recreates(calls: list[str]) -> list[str]:
|
||||
return [c for c in calls if "--force-recreate" in c]
|
||||
|
||||
|
||||
# ── Что скрипт делает на самом деле ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fresh_inode_touches_nothing(host: Path) -> None:
|
||||
"""Контейнер видит текущий файл — пересоздавать нельзя: это рвёт работу зря."""
|
||||
rc, out, calls = _run(host)
|
||||
assert rc == 0, out
|
||||
assert not _recreates(calls), f"пересоздан без расхождения инода: {calls}"
|
||||
|
||||
|
||||
def test_stale_inode_recreates_that_service_and_confirms(host: Path) -> None:
|
||||
"""Старый инод → пересоздать ИМЕННО этот сервис и убедиться, что доехало."""
|
||||
_stale(host)
|
||||
rc, out, calls = _run(host, FAKE_NEW_VIEW=str(_ino(host)))
|
||||
assert rc == 0, out
|
||||
recreates = _recreates(calls)
|
||||
assert len(recreates) == 1, f"ожидалось одно пересоздание, было: {calls}"
|
||||
assert recreates[0].split()[-1] == "loki", f"пересоздан не тот сервис: {recreates[0]}"
|
||||
assert "--no-deps" in recreates[0], f"пересоздание тянет зависимости: {recreates[0]}"
|
||||
assert "-p gendesign-metrics -f docker-compose.metrics.yml" in recreates[0], recreates[0]
|
||||
assert any(c.startswith("exec cid-new ") for c in calls), (
|
||||
f"после пересоздания инод НОВОГО контейнера не сверен: {calls}"
|
||||
)
|
||||
|
||||
|
||||
def test_still_stale_after_recreate_fails_the_deploy(host: Path) -> None:
|
||||
"""Пересоздание не помогло — красный деплой, а не зелёный со старым конфигом."""
|
||||
_stale(host)
|
||||
rc, out, _calls = _run(host, FAKE_NEW_VIEW=str(_ino(host) + 1))
|
||||
assert rc != 0, f"контейнер всё ещё на старом иноде, а скрипт вышел с 0:\n{out}"
|
||||
assert SRC in out, f"в логе не назван файл, который не доехал:\n{out}"
|
||||
|
||||
|
||||
def test_unreadable_container_view_counts_as_stale(host: Path) -> None:
|
||||
"""Инод в контейнере не прочитался — это расхождение, а не «доехало»."""
|
||||
(host.parent.parent / "view" / "cid-old").unlink()
|
||||
rc, out, calls = _run(host, FAKE_NEW_VIEW=str(_ino(host)))
|
||||
assert rc == 0, out
|
||||
assert _recreates(calls), f"непрочитанный инод сочтён доехавшим: {calls}"
|
||||
|
||||
|
||||
def test_not_running_container_is_left_alone(host: Path) -> None:
|
||||
"""Контейнера нет — старый инод держать некому; поднимать сервис тут нельзя."""
|
||||
(host.parent.parent / "cid").write_text("", encoding="utf-8")
|
||||
rc, out, calls = _run(host)
|
||||
assert rc == 0, out
|
||||
assert not _recreates(calls), f"поднят незапущенный сервис: {calls}"
|
||||
|
||||
|
||||
def test_compose_failure_is_not_read_as_not_running(host: Path) -> None:
|
||||
"""Упавший `compose ps` — не «контейнер не запущен»: иначе зелёный выход вслепую."""
|
||||
_stale(host)
|
||||
rc, out, calls = _run(host, FAKE_PS_RC="1")
|
||||
assert rc != 0, f"ошибка compose проглочена, скрипт вышел с 0:\n{out}"
|
||||
assert not _recreates(calls), calls
|
||||
|
||||
|
||||
def test_missing_host_file_is_not_success(host: Path) -> None:
|
||||
"""Файла на хосте нет — сверять не с чем; молча выйти с 0 нельзя."""
|
||||
(host / SRC).unlink()
|
||||
rc, out, _calls = _run(host)
|
||||
assert rc != 0, f"отсутствующий файл сочтён доехавшим:\n{out}"
|
||||
|
||||
|
||||
# ── Гейт: у каждого пофайлового маунта есть путь доезда ──────────────────────
|
||||
|
||||
# Производные файлы: их рендерит сам деплой, в git их нет.
|
||||
# alertmanager.yml — rm + рендер, пересоздание по ALERTMANAGER_RERENDERED;
|
||||
# alertmanager_targets.gen.yml — усечение на месте (`: >`), инод сохраняется.
|
||||
RENDERED = {
|
||||
"ops/metrics/alertmanager/alertmanager.yml",
|
||||
"ops/metrics/prometheus/alertmanager_targets.gen.yml",
|
||||
}
|
||||
# Источник маунта: ./путь[:${VAR:?…}]:/куда[:ro]. `${…}` внутри источника — файл,
|
||||
# выбираемый ролью (alloy-apps.alloy / alloy-infra.alloy).
|
||||
MOUNT = re.compile(r"^\./(?P<src>(?:\$\{[^}]*\}|[^:])+):(?P<dst>/[^:]+)")
|
||||
COMPOSE_FILES = {
|
||||
"docker-compose.metrics.yml": "gendesign-metrics",
|
||||
"docker-compose.metrics-agent.yml": "gendesign-metrics-agent",
|
||||
}
|
||||
|
||||
|
||||
def _jobs_for(compose_name: str, profiles: list[str]) -> list[str]:
|
||||
"""Джобы деплоя, поднимающие сервис: сервер — одна; агенты — по профилю."""
|
||||
if compose_name == "docker-compose.metrics.yml":
|
||||
return ["server"]
|
||||
return [f"agent-{p}" for p in profiles] or ["agent-apps", "agent-infra"]
|
||||
|
||||
|
||||
def _commands(job: str) -> str:
|
||||
"""ssh-скрипт джобы: комментарии выкинуты, продолжения строк склеены.
|
||||
|
||||
Комментарии — потому что разбор дефекта содержит его формулировку дословно.
|
||||
"""
|
||||
spec = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
|
||||
steps = [s for s in spec["jobs"][job]["steps"] if "ssh-action" in str(s.get("uses"))]
|
||||
assert len(steps) == 1, f"в джобе {job} не ровно один ssh-шаг — гейт ослеп"
|
||||
kept = [ln for ln in steps[0]["with"]["script"].splitlines() if not ln.lstrip().startswith("#")]
|
||||
return re.sub(r"\\\n\s*", " ", "\n".join(kept))
|
||||
|
||||
|
||||
def _file_mounts(compose_name: str) -> list[tuple[str, str, str, list[str]]]:
|
||||
spec = yaml.safe_load((REPO_ROOT / compose_name).read_text(encoding="utf-8"))
|
||||
found = []
|
||||
for service, body in spec["services"].items():
|
||||
for vol in body.get("volumes") or []:
|
||||
m = MOUNT.match(vol) if isinstance(vol, str) else None
|
||||
if not m or (REPO_ROOT / m["src"]).is_dir() or m["src"] in RENDERED:
|
||||
continue
|
||||
found.append((service, m["src"], m["dst"], body.get("profiles") or []))
|
||||
return found
|
||||
|
||||
|
||||
def _arrival(commands: str, project: str, compose_name: str, service: str, src: str, dst: str):
|
||||
script_call = re.search(
|
||||
rf"recreate-stale-mount\.sh\s+{re.escape(project)}\s+{re.escape(compose_name)}"
|
||||
rf"\s+{re.escape(service)}\s+{re.escape(src)}\s+{re.escape(dst)}[^\n]*",
|
||||
commands,
|
||||
)
|
||||
forced = re.search(
|
||||
rf"--force-recreate(?:\s+--no-deps)?\s+{re.escape(service)}(?![\w-])[^\n]*", commands
|
||||
)
|
||||
return script_call or forced
|
||||
|
||||
|
||||
@pytest.mark.parametrize("compose_name", sorted(COMPOSE_FILES))
|
||||
def test_every_single_file_mount_has_a_way_to_arrive(compose_name: str) -> None:
|
||||
project = COMPOSE_FILES[compose_name]
|
||||
mounts = _file_mounts(compose_name)
|
||||
assert mounts, f"в {compose_name} не найдено ни одного пофайлового маунта — маска ослепла"
|
||||
missing = []
|
||||
for service, src, dst, profiles in mounts:
|
||||
for job in _jobs_for(compose_name, profiles):
|
||||
hit = _arrival(_commands(job), project, compose_name, service, src, dst)
|
||||
if not hit:
|
||||
missing.append(f"{job}: {service} {src} → {dst}")
|
||||
elif re.search(r"\|\|\s*(true|:)\s*$", hit.group(0)):
|
||||
missing.append(f"{job}: {service} — отказ проглочен: {hit.group(0).strip()}")
|
||||
assert not missing, (
|
||||
"пофайловые маунты без пути доезда: правка файла ляжет на диск, контейнер "
|
||||
"останется на старом иноде, деплой зелёный:\n " + "\n ".join(missing)
|
||||
)
|
||||
|
||||
|
||||
def test_gate_sees_the_known_mounts() -> None:
|
||||
"""Проверка на себя: маска обязана находить маунты, ради которых гейт заведён."""
|
||||
server = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics.yml")}
|
||||
assert ("prometheus", "ops/metrics/prometheus/prometheus.yml") in server, server
|
||||
assert ("loki", "ops/metrics/loki/loki-config.yml") in server, server
|
||||
agent = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics-agent.yml")}
|
||||
for exporter in ("gendesign", "tradein", "infra"):
|
||||
assert (f"postgres-exporter-{exporter}", "ops/metrics/postgres/queries.yml") in agent, agent
|
||||
|
||||
|
||||
def test_gate_would_notice_a_mount_without_arrival() -> None:
|
||||
"""Сервис без пути доезда гейт обязан НЕ найти — иначе он зелёный по построению."""
|
||||
commands = _commands("server")
|
||||
assert not _arrival(
|
||||
commands, "gendesign-metrics", "docker-compose.metrics.yml", "nosuch", SRC, DST
|
||||
), "гейт нашёл путь доезда у несуществующего сервиса — маска слишком широкая"
|
||||
65
ops/metrics/recreate-stale-mount.sh
Executable file
65
ops/metrics/recreate-stale-mount.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
#!/bin/sh
|
||||
# Пересоздать контейнер, если он читает СТАРЫЙ инод пофайлового bind-маунта
|
||||
# (#3467, #3486).
|
||||
#
|
||||
# sh ops/metrics/recreate-stale-mount.sh ПРОЕКТ COMPOSE-ФАЙЛ СЕРВИС ФАЙЛ-НА-ХОСТЕ ПУТЬ-В-КОНТЕЙНЕРЕ
|
||||
#
|
||||
# ЗАЧЕМ. `git reset --hard` пишет изменившийся файл НОВЫМ инодом, а bind-маунт
|
||||
# одного файла держит прежний: контейнер продолжает читать старый текст, `up -d`
|
||||
# этого не видит (сравнивает описание сервиса, а не содержимое маунта), и
|
||||
# перезагрузка по API тоже — процесс перечитывает путь, за которым в контейнере
|
||||
# всё тот же старый инод. Проверено на prom/prometheus:v3.1.0 (17.09): файл
|
||||
# подменён, POST /-/reload → rc=0, а в /api/v1/status/config остался прежний
|
||||
# job. Лечит только пересоздание контейнера — его и делаем, но лишь при
|
||||
# расхождении, чтобы не рвать работу на каждом деплое.
|
||||
#
|
||||
# Та же ловушка уже ловилась на Alertmanager (27.08), Alloy (#3380) и Caddy
|
||||
# (#3443, ops/caddy-apply.sh). Контейнер не запущен — старого инода держать
|
||||
# некому (при старте маунт берётся с хоста заново), выходим с 0. Сервис
|
||||
# выключенного профиля сюда передавать нельзя: `up` с явным именем включает
|
||||
# профиль и поднял бы сервис без окружения — гейт по профилю у вызывающего.
|
||||
# Сверка не прочиталась — считаем расхождением. После пересоздания сверяем
|
||||
# снова: не сошлось — exit 1, а не зелёный деплой со старым конфигом.
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 5 ]; then
|
||||
echo "usage: $0 PROJECT COMPOSE_FILE SERVICE HOST_FILE CONTAINER_FILE" >&2
|
||||
exit 2
|
||||
fi
|
||||
project=$1 compose_file=$2 service=$3 src=$4 dst=$5
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
# Ошибку `ps` НЕ глушим: пустой ответ от упавшего compose читался бы как
|
||||
# «контейнер не запущен» — и зелёный выход со старым конфигом.
|
||||
cid() { docker compose -p "$project" -f "$compose_file" ps -q "$service"; }
|
||||
seen() { docker exec "$1" stat -c %i "$dst" 2>/dev/null || true; }
|
||||
|
||||
id=$(cid)
|
||||
if [ -z "$id" ]; then
|
||||
echo "$service: контейнер не запущен — старый инод $src держать некому."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
want=$(stat -c %i "$src")
|
||||
if [ "$(seen "$id")" = "$want" ]; then
|
||||
echo "$service: $src доехал до контейнера (инод $want)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "$service: контейнер не видит текущий $src (инод на хосте $want) — пересоздаю."
|
||||
docker compose -p "$project" -f "$compose_file" up -d --no-deps --force-recreate "$service"
|
||||
|
||||
i=0
|
||||
while [ "$i" -lt 15 ]; do
|
||||
id=$(cid)
|
||||
if [ -n "$id" ] && [ "$(seen "$id")" = "$want" ]; then
|
||||
echo "$service: пересоздан, $src доехал (инод $want)."
|
||||
exit 0
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "::error::$service: после пересоздания контейнер всё ещё не видит $src (инод $want)"
|
||||
exit 1
|
||||
Loading…
Add table
Reference in a new issue