Метрики: правки prometheus.yml, конфига Loki, датасорсов Grafana и запросов postgres-экспортёров вступают в силу после деплоя #3557

Merged
bot-backend merged 2 commits from fix/metrics-deploy-reload into main 2026-09-17 09:15:20 +00:00
4 changed files with 511 additions and 38 deletions
Showing only changes of commit e5db8dd80f - Show all commits

View file

@ -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 \
"$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \
test rules /work/tests/infra_test.yml; then
# 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)" \
-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

View file

@ -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, "отказ перезагрузки не роняет деплой"

View file

@ -0,0 +1,306 @@
"""Правка пофайлово смонтированного конфига метрик обязана доезжать до контейнера.
ЧТО НЕ ТАК. `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",
}
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
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
), "гейт нашёл путь доезда у несуществующего сервиса — маска слишком широкая"

View 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