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