Merge pull request 'Метрики: правки prometheus.yml, конфига Loki, датасорсов Grafana и запросов postgres-экспортёров вступают в силу после деплоя' (#3557) from fix/metrics-deploy-reload into main
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Successful in 17s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy Metrics / server (push) Successful in 44s
Deploy Metrics / agent-infra (push) Successful in 28s
Deploy Metrics / agent-apps (push) Successful in 31s
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / perimeter-smoke (push) Blocked by required conditions
Deploy / deploy-status (push) Blocked by required conditions
Deploy / changes (push) Successful in 17s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy Metrics / server (push) Successful in 44s
Deploy Metrics / agent-infra (push) Successful in 28s
Deploy Metrics / agent-apps (push) Successful in 31s
This commit is contained in:
commit
1812a1ed18
5 changed files with 541 additions and 38 deletions
|
|
@ -233,6 +233,10 @@ jobs:
|
||||||
# уезжает в main зелёной. Ровно то, что осуждает комментарий выше.
|
# уезжает в main зелёной. Ровно то, что осуждает комментарий выше.
|
||||||
- '.forgejo/workflows/deploy-metrics.yml'
|
- '.forgejo/workflows/deploy-metrics.yml'
|
||||||
- 'docker-compose.metrics.yml'
|
- 'docker-compose.metrics.yml'
|
||||||
|
# #3486: гейт test_metrics_single_file_mounts.py читает и
|
||||||
|
# compose агентов — правка ТОЛЬКО его (новый пофайловый маунт у
|
||||||
|
# экспортёра) без этой строки прошла бы мимо гейта.
|
||||||
|
- 'docker-compose.metrics-agent.yml'
|
||||||
# #3443: тот же класс, третий раз. Гейт
|
# #3443: тот же класс, третий раз. Гейт
|
||||||
# backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает
|
# backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает
|
||||||
# ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть
|
# ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть
|
||||||
|
|
|
||||||
|
|
@ -388,6 +388,15 @@ jobs:
|
||||||
echo "Alertmanager: контейнер пересоздан — иначе читал бы конфиг по старому иноду."
|
echo "Alertmanager: контейнер пересоздан — иначе читал бы конфиг по старому иноду."
|
||||||
fi
|
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: СНАЧАЛА проверить, потом применять ──────────────────
|
||||||
# На этом хосте тот же Caddy обслуживает git., errors. и obsidian.
|
# На этом хосте тот же Caddy обслуживает git., errors. и obsidian.
|
||||||
# Синтаксическая ошибка в infra.caddy положила бы их все, включая
|
# Синтаксическая ошибка в infra.caddy положила бы их все, включая
|
||||||
|
|
@ -406,15 +415,6 @@ jobs:
|
||||||
fi
|
fi
|
||||||
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` их не
|
# ── Prometheus: конфиг/правила лежат на диске, `up -d` их не
|
||||||
# перечитывает ────────────────────────────────────────────────
|
# перечитывает ────────────────────────────────────────────────
|
||||||
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше:
|
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше:
|
||||||
|
|
@ -424,27 +424,41 @@ jobs:
|
||||||
# незамеченными (#3467): lastConfigTime совпадал со startTime
|
# незамеченными (#3467): lastConfigTime совпадал со startTime
|
||||||
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
|
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
|
||||||
#
|
#
|
||||||
# У Prometheus, в отличие от Alertmanager (см. комментарий выше),
|
# /-/reload перечитывает правила: они подключены КАТАЛОГОМ, и новый
|
||||||
# /-/reload переоткрывает файлы ПО ПУТИ заново, поэтому новый инод
|
# файл контейнер видит сразу. А prometheus.yml — бинд-маунт ОДНОГО
|
||||||
# после `git reset --hard` подхватывается без пересоздания
|
# файла: после `git reset --hard` за путём в контейнере остаётся
|
||||||
# контейнера. --web.enable-lifecycle уже включён в compose ради
|
# СТАРЫЙ инод, и reload честно перечитывает старый текст с rc=0 и
|
||||||
# этого шага (см. docker-compose.metrics.yml) — просто раньше
|
# новым lastConfigTime (проверено на prom/prometheus:v3.1.0, 17.09).
|
||||||
# никто не звал сам reload.
|
# Поэтому при расхождении инода контейнер пересоздаётся (#3467).
|
||||||
|
# --web.enable-lifecycle включён в compose ради этого шага.
|
||||||
#
|
#
|
||||||
# promtool проверяет ОБА файла ДО reload: битый конфиг не должен
|
# promtool проверяет конфиг, правила и их юнит-тесты (#3493) ДО
|
||||||
# положить работающий Prometheus молчаливым откатом на дефолты.
|
# любого применения, и именно ФАЙЛЫ С ДИСКА — одноразовым
|
||||||
#
|
# контейнером того же образа. `docker exec` в работающий проверял
|
||||||
# Плюс юнит-тесты правил (#3493): синтаксически верное правило может
|
# бы тот самый старый инод, а не то, что сейчас применится.
|
||||||
# врать по смыслу — `count(x == 1) == 0` от пустого вектора не
|
# Синтаксически верное правило может врать по смыслу —
|
||||||
# срабатывает никогда, а под текстом «воркер мёртв» горел не поднятый
|
# `count(x == 1) == 0` от пустого вектора не срабатывает никогда, —
|
||||||
# экспортёр. Каталог tests/ в контейнер не смонтирован, поэтому
|
# отсюда test rules.
|
||||||
# одноразовый контейнер ТОГО ЖЕ образа поверх файлов с диска.
|
if docker run --rm --entrypoint sh \
|
||||||
if docker exec gendesign-prometheus promtool check config /etc/prometheus/prometheus.yml \
|
-v /opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro \
|
||||||
&& docker exec gendesign-prometheus sh -c 'promtool check rules /etc/prometheus/rules/*.yml' \
|
"$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \
|
||||||
&& docker run --rm --entrypoint promtool \
|
-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
|
||||||
-v /opt/gendesign/ops/metrics/prometheus:/work:ro \
|
COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \
|
||||||
"$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \
|
gendesign-metrics docker-compose.metrics.yml \
|
||||||
test rules /work/tests/infra_test.yml; then
|
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":"[^"]*"')"
|
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
|
docker exec gendesign-prometheus wget -q -O /dev/null --post-data='' http://localhost:9090/-/reload
|
||||||
|
|
@ -470,6 +484,30 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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:
|
agent-apps:
|
||||||
runs-on: ubuntu-latest
|
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)" ] \
|
[ "$(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; }
|
|| { 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:
|
agent-infra:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: server
|
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)" ] \
|
[ "$(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; }
|
|| { 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`)
|
Тот же класс бага, что уже пойман и починен для Caddy (`caddy reload`)
|
||||||
и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py)
|
и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py)
|
||||||
в этом же workflow — только для Prometheus починка не пересоздание
|
в этом же workflow. `POST /-/reload` лечит правила — они подключены
|
||||||
контейнера, а именно `POST /-/reload`: он переоткрывает файлы конфига по
|
КАТАЛОГОМ. prometheus.yml же — бинд-маунт ОДНОГО файла: reload перечитывает
|
||||||
пути заново, так что новый инод после `git reset --hard` подхватывается
|
за тем же путём СТАРЫЙ инод (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 вызывается
|
Проверяется здесь: (1) валидация promtool ЕСТЬ и идёт по файлам с диска,
|
||||||
ТОЛЬКО после успешной валидации, (3) шаг обязан упасть, если reload не
|
(2) reload и пересоздание — ТОЛЬКО после успешной валидации, (3) шаг обязан
|
||||||
подтверждён сменой lastConfigTime.
|
упасть, если reload не подтверждён сменой lastConfigTime, (4) датасорсы
|
||||||
|
Grafana перечитываются, и отказ этого шага роняет деплой.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
||||||
|
CHECK_CONFIG = "promtool check config /etc/prometheus/prometheus.yml"
|
||||||
|
|
||||||
|
|
||||||
def _text() -> str:
|
def _text() -> str:
|
||||||
return WORKFLOW.read_text(encoding="utf-8")
|
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:
|
def test_promtool_checks_config_and_rules() -> None:
|
||||||
"""promtool обязан проверять и конфиг, и правила — не только один файл."""
|
"""promtool обязан проверять и конфиг, и правила — не только один файл."""
|
||||||
text = _text()
|
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:
|
def test_reload_is_guarded_by_the_promtool_check() -> None:
|
||||||
"""Reload обязан быть ВНУТРИ `if promtool ...; then`, а не безусловным."""
|
"""Reload обязан быть ВНУТРИ `if promtool ...; then`, а не безусловным."""
|
||||||
text = _text()
|
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)
|
else_pos = text.index("else", guard_start)
|
||||||
reload_pos = text.index("localhost:9090/-/reload")
|
reload_pos = text.index("localhost:9090/-/reload")
|
||||||
assert guard_start < reload_pos < else_pos, (
|
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:
|
def test_failed_validation_skips_reload_and_fails_the_step() -> None:
|
||||||
"""При провале promtool — reload НЕ вызывается, и шаг падает (exit 1)."""
|
"""При провале promtool — reload НЕ вызывается, и шаг падает (exit 1)."""
|
||||||
text = _text()
|
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)
|
else_pos = text.index("else", guard_start)
|
||||||
fi_pos = text.index("fi", else_pos)
|
fi_pos = text.index("fi", else_pos)
|
||||||
else_branch = text[else_pos:fi_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, (
|
assert "exit 1" in verify_block, (
|
||||||
"если lastConfigTime не изменился, шаг обязан падать, а не считаться успешным"
|
"если 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