fix(observability): агент не падает из-за переменной чужой роли + DSN экспортеров собираются (#3078) #3106

Merged
lekss361 merged 1 commit from fix/3078-exporter-dsn-and-profiles into main 2026-08-26 09:50:42 +00:00
4 changed files with 159 additions and 9 deletions

View file

@ -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

View 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"
)

View file

@ -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

View file

@ -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