diff --git a/.forgejo/workflows/deploy-metrics.yml b/.forgejo/workflows/deploy-metrics.yml index 2b00d3bb..ad9c453c 100644 --- a/.forgejo/workflows/deploy-metrics.yml +++ b/.forgejo/workflows/deploy-metrics.yml @@ -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 diff --git a/backend/tests/ops/test_3078_exporter_profiles.py b/backend/tests/ops/test_3078_exporter_profiles.py new file mode 100644 index 00000000..318fa0bd --- /dev/null +++ b/backend/tests/ops/test_3078_exporter_profiles.py @@ -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" + ) diff --git a/docker-compose.metrics-agent.yml b/docker-compose.metrics-agent.yml index e079e638..4ce49308 100644 --- a/docker-compose.metrics-agent.yml +++ b/docker-compose.metrics-agent.yml @@ -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 diff --git a/scripts/setup-metrics-exporter-dsn.sh b/scripts/setup-metrics-exporter-dsn.sh index ff618cab..a29adaf5 100644 --- a/scripts/setup-metrics-exporter-dsn.sh +++ b/scripts/setup-metrics-exporter-dsn.sh @@ -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