fix(observability): агент не падает из-за переменной чужой роли + DSN экспортеров собираются (#3078) #3106
4 changed files with 159 additions and 9 deletions
|
|
@ -212,20 +212,39 @@ jobs:
|
|||
|
||||
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_ALLOY_CONFIG=alloy-apps.alloy \
|
||||
COMPOSE_PROFILES=apps \
|
||||
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||
docker compose -p gendesign-metrics-agent \
|
||||
-f docker-compose.metrics-agent.yml pull --quiet
|
||||
|
||||
METRICS_ROLE=apps \
|
||||
METRICS_ALLOY_CONFIG=alloy-apps.alloy \
|
||||
COMPOSE_PROFILES=apps \
|
||||
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||
docker compose -p gendesign-metrics-agent \
|
||||
-f docker-compose.metrics-agent.yml up -d
|
||||
|
||||
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 \
|
||||
-f docker-compose.metrics-agent.yml ps
|
||||
|
||||
|
|
@ -253,19 +272,37 @@ jobs:
|
|||
set -a; . backend/.env.runtime; set +a
|
||||
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_ALLOY_CONFIG=alloy-infra.alloy \
|
||||
COMPOSE_PROFILES=infra \
|
||||
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||
docker compose -p gendesign-metrics-agent \
|
||||
-f docker-compose.metrics-agent.yml pull --quiet
|
||||
|
||||
METRICS_ROLE=infra \
|
||||
METRICS_ALLOY_CONFIG=alloy-infra.alloy \
|
||||
COMPOSE_PROFILES=infra \
|
||||
COMPOSE_PROFILES="$EXPORTER_PROFILE" \
|
||||
docker compose -p gendesign-metrics-agent \
|
||||
-f docker-compose.metrics-agent.yml up -d
|
||||
|
||||
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 \
|
||||
-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
|
||||
required: false
|
||||
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
|
||||
volumes:
|
||||
- ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro
|
||||
|
|
@ -170,7 +176,8 @@ services:
|
|||
- path: ./backend/.env.runtime
|
||||
required: false
|
||||
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
|
||||
volumes:
|
||||
- ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro
|
||||
|
|
@ -193,7 +200,8 @@ services:
|
|||
- path: ./backend/.env.runtime
|
||||
required: false
|
||||
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
|
||||
volumes:
|
||||
- ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro
|
||||
|
|
|
|||
|
|
@ -16,7 +16,23 @@ if [ ! -f "$ENVF" ]; then
|
|||
exit 1
|
||||
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
|
||||
if [ -f "$BASE_ENVF" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
. "$BASE_ENVF"
|
||||
fi
|
||||
# shellcheck source=/dev/null
|
||||
. "$ENVF"
|
||||
set +a
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue