gendesign/backend/tests/ops/test_3078_exporter_profiles.py
bot-backend beafe6925b
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
fix(observability): агент не падает из-за переменной чужой роли (#3078)
Джоба 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-переменная проверяется в деплое; профили не
захардкожены. Фальсификация: на исходных файлах краснеют все три.
2026-08-26 12:45:31 +03:00

89 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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