diff --git a/.forgejo/workflows/deploy-metrics.yml b/.forgejo/workflows/deploy-metrics.yml index 6d30ecb9..5b77f2ad 100644 --- a/.forgejo/workflows/deploy-metrics.yml +++ b/.forgejo/workflows/deploy-metrics.yml @@ -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 diff --git a/backend/tests/ops/test_3467_prometheus_reload.py b/backend/tests/ops/test_3467_prometheus_reload.py index d27e5673..554cbb22 100644 --- a/backend/tests/ops/test_3467_prometheus_reload.py +++ b/backend/tests/ops/test_3467_prometheus_reload.py @@ -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, "отказ перезагрузки не роняет деплой" diff --git a/backend/tests/ops/test_metrics_single_file_mounts.py b/backend/tests/ops/test_metrics_single_file_mounts.py new file mode 100644 index 00000000..0cd8fb3f --- /dev/null +++ b/backend/tests/ops/test_metrics_single_file_mounts.py @@ -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(?:\$\{[^}]*\}|[^:])+):(?P/[^:]+)") +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 + ), "гейт нашёл путь доезда у несуществующего сервиса — маска слишком широкая" diff --git a/ops/metrics/recreate-stale-mount.sh b/ops/metrics/recreate-stale-mount.sh new file mode 100755 index 00000000..c27c840f --- /dev/null +++ b/ops/metrics/recreate-stale-mount.sh @@ -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