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-переменная проверяется в деплое; профили не
захардкожены. Фальсификация: на исходных файлах краснеют все три.
89 lines
5.5 KiB
Python
89 lines
5.5 KiB
Python
"""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"
|
||
)
|