fix(observability): агент не падает из-за переменной чужой роли (#3078)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m55s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Successful in 17m25s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m55s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Successful in 17m25s
Джоба agent-apps упала целиком:
error while interpolating services.postgres-exporter-infra.environment.
DATA_SOURCE_NAME: required variable INFRA_EXPORTER_DSN is missing a value
INFRA_EXPORTER_DSN нужен экспортеру с profiles: ["infra"], который на
продуктовом хосте не поднимается вовсе. Но compose интерполирует ВЕСЬ файл
до фильтрации по профилям, поэтому `${VAR:?}` роняет команду из-за чужой
переменной. Вместе с агентом не поднялись alloy, node-exporter и cadvisor,
которым никакой DSN не нужен. Симметрично упал бы и инфраструктурный агент -
на двух продуктовых переменных.
Второй дефект в той же цепочке: GENDESIGN_EXPORTER_DSN тоже отсутствовал.
setup-metrics-exporter-dsn.sh читает только runtime-файл окружения бэкенда, а
DATABASE_URL и TRADEIN_DATABASE_URL живут в основном. Значит подстановка
всегда была пустой, add_key печатал "нечем заполнить" и выходил с кодом 0 -
мягкий пропуск встречался с жёстким требованием compose.
Стало:
- compose: `:-` вместо `:?` у трёх DSN. Интерполяция больше не может упасть.
- deploy-metrics.yml: профиль экспортеров включается, только если нужные ЭТОЙ
роли DSN заполнены; иначе ::warning и агент поднимается без экспортера.
Громкость не убрана, а перенесена туда, где роль известна. Тот же приём, что
уже применён к Alertmanager в джобе server.
- setup-metrics-exporter-dsn.sh: читает оба файла окружения (базовый, затем
runtime - он перекрывает). Пишет по-прежнему только в runtime, лишних копий
пароля не заводит.
Почему `:-` не ослабление: пустой DATA_SOURCE_NAME поднял бы экспортер,
который молча не отдаёт метрик, - ровно тот тихий отказ, ради которого весь
стек и заводится. Поэтому пустой DSN теперь означает "профиль не включаем",
а не "поднимаем пустым".
Известное следствие, отмеченное в коде: INFRA_EXPORTER_DSN не собирает никто -
скрипт знает только про GENDESIGN_/TRADEIN_ и работает на продуктовом хосте.
Пока это так, инфраструктурный агент будет честно предупреждать, что метрик
Postgres инфры нет, вместо того чтобы падать целиком.
Тесты (3) структурные, проверяют оба конца инварианта: обязательности не
вернулись в compose; каждая DSN-переменная проверяется в деплое; профили не
захардкожены. Фальсификация: на исходных файлах краснеют все три.
This commit is contained in:
parent
1da2eee142
commit
beafe6925b
4 changed files with 159 additions and 9 deletions
|
|
@ -212,20 +212,39 @@ jobs:
|
||||||
|
|
||||||
set -a; . backend/.env.runtime; set +a
|
set -a; . backend/.env.runtime; set +a
|
||||||
|
|
||||||
|
# Профиль экспортеров БД включаем, только если DSN реально собрались.
|
||||||
|
# Раньше их обязательность стояла в compose (`${VAR:?}`), но compose
|
||||||
|
# интерполирует ВЕСЬ файл до фильтрации по профилям — и продуктовый
|
||||||
|
# агент падал на INFRA_EXPORTER_DSN, переменной сервиса, который тут
|
||||||
|
# не поднимается вовсе. Проверка переехала сюда, где роль известна.
|
||||||
|
#
|
||||||
|
# Не падаем, а предупреждаем: alloy / node-exporter / cadvisor и сбор
|
||||||
|
# логов не должны отваливаться из-за одного ненастроенного экспортера.
|
||||||
|
# Тот же приём, что у Alertmanager в джобе server выше.
|
||||||
|
EXPORTER_PROFILE=""
|
||||||
|
missing_dsn=""
|
||||||
|
[ -n "${GENDESIGN_EXPORTER_DSN:-}" ] || missing_dsn="$missing_dsn GENDESIGN_EXPORTER_DSN"
|
||||||
|
[ -n "${TRADEIN_EXPORTER_DSN:-}" ] || missing_dsn="$missing_dsn TRADEIN_EXPORTER_DSN"
|
||||||
|
if [ -z "$missing_dsn" ]; then
|
||||||
|
EXPORTER_PROFILE="apps"
|
||||||
|
else
|
||||||
|
echo "::warning title=Метрики БД не собираются::не заполнены:$missing_dsn. Хостовые метрики и логи поедут, метрик Postgres не будет. Проверь, что scripts/setup-metrics-exporter-dsn.sh нашёл DATABASE_URL/TRADEIN_DATABASE_URL."
|
||||||
|
fi
|
||||||
|
|
||||||
METRICS_ROLE=apps \
|
METRICS_ROLE=apps \
|
||||||
METRICS_ALLOY_CONFIG=alloy-apps.alloy \
|
METRICS_ALLOY_CONFIG=alloy-apps.alloy \
|
||||||
COMPOSE_PROFILES=apps \
|
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||||
docker compose -p gendesign-metrics-agent \
|
docker compose -p gendesign-metrics-agent \
|
||||||
-f docker-compose.metrics-agent.yml pull --quiet
|
-f docker-compose.metrics-agent.yml pull --quiet
|
||||||
|
|
||||||
METRICS_ROLE=apps \
|
METRICS_ROLE=apps \
|
||||||
METRICS_ALLOY_CONFIG=alloy-apps.alloy \
|
METRICS_ALLOY_CONFIG=alloy-apps.alloy \
|
||||||
COMPOSE_PROFILES=apps \
|
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||||
docker compose -p gendesign-metrics-agent \
|
docker compose -p gendesign-metrics-agent \
|
||||||
-f docker-compose.metrics-agent.yml up -d
|
-f docker-compose.metrics-agent.yml up -d
|
||||||
|
|
||||||
sleep 10
|
sleep 10
|
||||||
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES=apps \
|
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||||
docker compose -p gendesign-metrics-agent \
|
docker compose -p gendesign-metrics-agent \
|
||||||
-f docker-compose.metrics-agent.yml ps
|
-f docker-compose.metrics-agent.yml ps
|
||||||
|
|
||||||
|
|
@ -253,19 +272,37 @@ jobs:
|
||||||
set -a; . backend/.env.runtime; set +a
|
set -a; . backend/.env.runtime; set +a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Симметрично продуктовому агенту: профиль экспортера включаем,
|
||||||
|
# только если DSN есть. Без этого гарда экспортер поднялся бы с
|
||||||
|
# ПУСТЫМ DATA_SOURCE_NAME (в compose теперь `:-`, а не `:?`) и
|
||||||
|
# молча не отдавал бы метрик — ровно тот тихий отказ, ради которого
|
||||||
|
# весь стек и заводится.
|
||||||
|
#
|
||||||
|
# NB: INFRA_EXPORTER_DSN сейчас не собирает никто —
|
||||||
|
# scripts/setup-metrics-exporter-dsn.sh знает только про
|
||||||
|
# GENDESIGN_/TRADEIN_ и работает на продуктовом хосте. Пока это так,
|
||||||
|
# ветка ниже всегда даёт предупреждение, и это честно: метрик
|
||||||
|
# инфраструктурной БД действительно нет.
|
||||||
|
EXPORTER_PROFILE=""
|
||||||
|
if [ -n "${INFRA_EXPORTER_DSN:-}" ]; then
|
||||||
|
EXPORTER_PROFILE="infra"
|
||||||
|
else
|
||||||
|
echo "::warning title=Метрики инфраструктурной БД не собираются::INFRA_EXPORTER_DSN не задан. Хостовые метрики и логи поедут, метрик Postgres инфры не будет."
|
||||||
|
fi
|
||||||
|
|
||||||
METRICS_ROLE=infra \
|
METRICS_ROLE=infra \
|
||||||
METRICS_ALLOY_CONFIG=alloy-infra.alloy \
|
METRICS_ALLOY_CONFIG=alloy-infra.alloy \
|
||||||
COMPOSE_PROFILES=infra \
|
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||||
docker compose -p gendesign-metrics-agent \
|
docker compose -p gendesign-metrics-agent \
|
||||||
-f docker-compose.metrics-agent.yml pull --quiet
|
-f docker-compose.metrics-agent.yml pull --quiet
|
||||||
|
|
||||||
METRICS_ROLE=infra \
|
METRICS_ROLE=infra \
|
||||||
METRICS_ALLOY_CONFIG=alloy-infra.alloy \
|
METRICS_ALLOY_CONFIG=alloy-infra.alloy \
|
||||||
COMPOSE_PROFILES=infra \
|
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||||
docker compose -p gendesign-metrics-agent \
|
docker compose -p gendesign-metrics-agent \
|
||||||
-f docker-compose.metrics-agent.yml up -d
|
-f docker-compose.metrics-agent.yml up -d
|
||||||
|
|
||||||
sleep 10
|
sleep 10
|
||||||
METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES=infra \
|
METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||||
docker compose -p gendesign-metrics-agent \
|
docker compose -p gendesign-metrics-agent \
|
||||||
-f docker-compose.metrics-agent.yml ps
|
-f docker-compose.metrics-agent.yml ps
|
||||||
|
|
|
||||||
89
backend/tests/ops/test_3078_exporter_profiles.py
Normal file
89
backend/tests/ops/test_3078_exporter_profiles.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""Regression: обязательная переменная чужой роли не роняет агента (#3078).
|
||||||
|
|
||||||
|
Что произошло. `docker-compose.metrics-agent.yml` объявлял DSN экспортеров через
|
||||||
|
`${VAR:?...}` — «обязательна, иначе ошибка». Экспортеры при этом разложены по
|
||||||
|
профилям: два продуктовых в `apps`, инфраструктурный в `infra`.
|
||||||
|
|
||||||
|
Compose интерполирует **весь файл до фильтрации по профилям**. Поэтому на
|
||||||
|
продуктовом хосте (`COMPOSE_PROFILES=apps`) команда падала на переменной
|
||||||
|
сервиса, который там не поднимается вовсе:
|
||||||
|
|
||||||
|
error while interpolating services.postgres-exporter-infra.environment.
|
||||||
|
DATA_SOURCE_NAME: required variable INFRA_EXPORTER_DSN is missing a value
|
||||||
|
|
||||||
|
Джоба `agent-apps` падала целиком — вместе с alloy, node-exporter и cadvisor,
|
||||||
|
которым никакой DSN не нужен. Симметрично упал бы и инфраструктурный агент, на
|
||||||
|
двух продуктовых переменных.
|
||||||
|
|
||||||
|
Фикс — `:-` в compose (интерполяция больше не может упасть) плюс проверка в
|
||||||
|
`deploy-metrics.yml`, где роль ИЗВЕСТНА: профиль экспортеров включается, только
|
||||||
|
если нужные этому хосту DSN реально заполнены, иначе `::warning` и агент всё
|
||||||
|
равно поднимается без экспортера.
|
||||||
|
|
||||||
|
Почему это не «ослабление»: пустой `DATA_SOURCE_NAME` поднял бы экспортер,
|
||||||
|
который молча не отдаёт метрик, — тот самый тихий отказ, ради которого весь
|
||||||
|
стек и заводится. Громкость не убрана, а перенесена туда, где известно, какая
|
||||||
|
переменная нужна.
|
||||||
|
|
||||||
|
Тесты ниже структурные: проверяют оба конца инварианта — что обязательности не
|
||||||
|
вернулись в compose и что каждый профиль-гейт реально стоит в деплое.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# backend/tests/ops/<этот файл> → корень репозитория
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
AGENT_COMPOSE = REPO_ROOT / "docker-compose.metrics-agent.yml"
|
||||||
|
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_required_var_syntax_on_profile_gated_services() -> None:
|
||||||
|
"""Ни один DSN экспортера не объявлен обязательным через `:?`.
|
||||||
|
|
||||||
|
Ядро регресса: `:?` у сервиса под профилем роняет ЛЮБУЮ compose-команду на
|
||||||
|
хосте другой роли, потому что интерполяция идёт до фильтрации.
|
||||||
|
"""
|
||||||
|
assert AGENT_COMPOSE.is_file(), f"нет {AGENT_COMPOSE} — файл переехал, гейт ослеп"
|
||||||
|
text = AGENT_COMPOSE.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
offenders = re.findall(r"\$\{([A-Z_]*EXPORTER_DSN):\?", text)
|
||||||
|
assert not offenders, (
|
||||||
|
f"обязательные переменные вернулись: {offenders}. "
|
||||||
|
"Compose интерполирует весь файл до профилей — это уронит агента чужой роли."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_exporter_dsn_is_referenced_by_a_profile_gate_in_deploy() -> None:
|
||||||
|
"""Каждая DSN-переменная из compose проверяется в деплое перед включением профиля.
|
||||||
|
|
||||||
|
Обратный конец инварианта: раз обязательность убрали из compose, она обязана
|
||||||
|
быть в деплое — иначе экспортер поднимется с пустым DSN и замолчит.
|
||||||
|
"""
|
||||||
|
assert WORKFLOW.is_file(), f"нет {WORKFLOW} — воркфлоу переехал, гейт ослеп"
|
||||||
|
compose_text = AGENT_COMPOSE.read_text(encoding="utf-8")
|
||||||
|
wf_text = WORKFLOW.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
dsn_vars = set(re.findall(r"\$\{([A-Z_]*EXPORTER_DSN)[:\-}]", compose_text))
|
||||||
|
assert dsn_vars, "не нашёл ни одной DSN-переменной — изменился синтаксис compose"
|
||||||
|
|
||||||
|
for var in sorted(dsn_vars):
|
||||||
|
assert var in wf_text, (
|
||||||
|
f"{var} используется в compose, но нигде не проверяется в deploy-metrics.yml — "
|
||||||
|
"экспортер поднимется с пустым DATA_SOURCE_NAME и молча не отдаст метрик"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_profiles_are_computed_not_hardcoded() -> None:
|
||||||
|
"""`COMPOSE_PROFILES` берётся из вычисленной переменной, а не зашит строкой.
|
||||||
|
|
||||||
|
Захардкоженный `COMPOSE_PROFILES=apps` включает экспортеры безусловно и
|
||||||
|
обходит проверку DSN выше.
|
||||||
|
"""
|
||||||
|
wf_text = WORKFLOW.read_text(encoding="utf-8")
|
||||||
|
hardcoded = re.findall(r"COMPOSE_PROFILES=(apps|infra)\b", wf_text)
|
||||||
|
assert not hardcoded, (
|
||||||
|
f"жёстко заданные профили: {hardcoded} — они обходят проверку заполненности DSN"
|
||||||
|
)
|
||||||
|
|
@ -148,7 +148,13 @@ services:
|
||||||
- path: ./backend/.env.runtime
|
- path: ./backend/.env.runtime
|
||||||
required: false
|
required: false
|
||||||
environment:
|
environment:
|
||||||
DATA_SOURCE_NAME: ${GENDESIGN_EXPORTER_DSN:?nyzhen v backend/.env.runtime}
|
# `:-`, а НЕ `:?`. Compose интерполирует ВЕСЬ файл до фильтрации по
|
||||||
|
# профилям, поэтому `:?` роняет команду из-за переменной сервиса, который
|
||||||
|
# на этом хосте вообще не поднимается: продуктовый агент падал на
|
||||||
|
# INFRA_EXPORTER_DSN (профиль infra), инфраструктурный упал бы на двух
|
||||||
|
# продуктовых. Громкость не потеряна — перенесена в deploy-metrics.yml,
|
||||||
|
# где роль ИЗВЕСТНА и проверяется ровно тот DSN, который нужен здесь.
|
||||||
|
DATA_SOURCE_NAME: ${GENDESIGN_EXPORTER_DSN:-}
|
||||||
PG_EXPORTER_EXTEND_QUERY_PATH: /etc/pg-queries.yml
|
PG_EXPORTER_EXTEND_QUERY_PATH: /etc/pg-queries.yml
|
||||||
volumes:
|
volumes:
|
||||||
- ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro
|
- ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro
|
||||||
|
|
@ -170,7 +176,8 @@ services:
|
||||||
- path: ./backend/.env.runtime
|
- path: ./backend/.env.runtime
|
||||||
required: false
|
required: false
|
||||||
environment:
|
environment:
|
||||||
DATA_SOURCE_NAME: ${TRADEIN_EXPORTER_DSN:?nyzhen v backend/.env.runtime}
|
# `:-`, а не `:?` — см. пояснение у экспортера Птицы выше.
|
||||||
|
DATA_SOURCE_NAME: ${TRADEIN_EXPORTER_DSN:-}
|
||||||
PG_EXPORTER_EXTEND_QUERY_PATH: /etc/pg-queries.yml
|
PG_EXPORTER_EXTEND_QUERY_PATH: /etc/pg-queries.yml
|
||||||
volumes:
|
volumes:
|
||||||
- ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro
|
- ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro
|
||||||
|
|
@ -193,7 +200,8 @@ services:
|
||||||
- path: ./backend/.env.runtime
|
- path: ./backend/.env.runtime
|
||||||
required: false
|
required: false
|
||||||
environment:
|
environment:
|
||||||
DATA_SOURCE_NAME: ${INFRA_EXPORTER_DSN:?nyzhen v backend/.env.runtime}
|
# `:-`, а не `:?` — см. пояснение у экспортера Птицы выше.
|
||||||
|
DATA_SOURCE_NAME: ${INFRA_EXPORTER_DSN:-}
|
||||||
PG_EXPORTER_EXTEND_QUERY_PATH: /etc/pg-queries.yml
|
PG_EXPORTER_EXTEND_QUERY_PATH: /etc/pg-queries.yml
|
||||||
volumes:
|
volumes:
|
||||||
- ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro
|
- ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,23 @@ if [ ! -f "$ENVF" ]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Источники DSN лежат НЕ здесь. `DATABASE_URL` и `TRADEIN_DATABASE_URL` живут в
|
||||||
|
# основном файле окружения бэкенда, а $ENVF (runtime) хранит только то, что
|
||||||
|
# дописывается на хосте: SENTRY_RELEASE, METRICS_* и т. п. Скрипт читал лишь
|
||||||
|
# runtime, поэтому подстановка `${DATABASE_URL:-}` всегда была пустой, add_key
|
||||||
|
# молча писал «нечем заполнить» и выходил с кодом 0 — а compose затем падал на
|
||||||
|
# обязательной переменной. Мягкий пропуск встречался с жёстким требованием.
|
||||||
|
#
|
||||||
|
# Читаем оба, в порядке приоритета: сначала базовый, потом runtime (он должен
|
||||||
|
# перекрывать). Пишем по-прежнему ТОЛЬКО в runtime — лишних копий пароля не
|
||||||
|
# заводим.
|
||||||
|
BASE_ENVF=/opt/gendesign/backend/.env
|
||||||
|
|
||||||
set -a
|
set -a
|
||||||
|
if [ -f "$BASE_ENVF" ]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. "$BASE_ENVF"
|
||||||
|
fi
|
||||||
# shellcheck source=/dev/null
|
# shellcheck source=/dev/null
|
||||||
. "$ENVF"
|
. "$ENVF"
|
||||||
set +a
|
set +a
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue