From be67828cbb1ef03448edf454bd35ac0f873ca2bf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 18:39:31 +0400 Subject: [PATCH] =?UTF-8?q?feat(config):=20=D0=BD=D0=B5=D1=81=D0=B5=D0=BA?= =?UTF-8?q?=D1=80=D0=B5=D1=82=D0=BD=D1=8B=D0=B5=20=D0=BD=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20=D0=B8=D0=B7=20OpenBao=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20OPENBAO=5F=5FAPP=5F=5F<=D0=98?= =?UTF-8?q?=D0=9C=D0=AF>=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тот же источник настроек, что в arm-core#44: приоритет обычный env -> OPENBAO__APP__* -> .env -> умолчание; неизвестное имя — ошибка при старте; ключ APP не ломает OpenBAOSettings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3 --- README.md | 1 + api/.env.example | 7 ++ api/src/core/config.py | 36 ++++++- api/src/core/settings_sources.py | 50 ++++++++++ api/tests/unit/test_config_openbao_app.py | 111 ++++++++++++++++++++++ 5 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 api/src/core/settings_sources.py create mode 100644 api/tests/unit/test_config_openbao_app.py diff --git a/README.md b/README.md index 52e96c7..33eada4 100644 --- a/README.md +++ b/README.md @@ -442,6 +442,7 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli <файл> [--file-type XL | Переменная | По умолчанию | Назначение | |---|---|---| +| `OPENBAO__APP__<ИМЯ>` | — | **контур платформы:** туда передаются только секреты OpenBao (`OPENBAO__*`), обычные переменные окружения задать нельзя. Любая настройка верхнего уровня из этой таблицы (`PARSE_TIMEOUT_SECONDS`, `PARSE_MAX_BYTES`, `NATS_ENABLED`, `DEBUG`, …) может прийти как `OPENBAO__APP__<ИМЯ>`, например `OPENBAO__APP__PARSE_TIMEOUT_SECONDS=300`. Значения разбираются как из обычного env. Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию. Неизвестное имя — ошибка конфигурации при старте; `OPENBAO__APP__OPENBAO__…` не допускается. Читается только из окружения процесса (в `.env` не действует). Реализация — `api/src/core/settings_sources.py` (тот же файл в arm-core и arm-scan-gateway) | | `OPENBAO__MINIO__MINIO_URL` | — (обязательна) | адрес MinIO без схемы, `host:port` | | `OPENBAO__MINIO__MINIO_ADMIN_LOGIN` | — (обязательна) | логин S3 | | `OPENBAO__MINIO__MINIO_ADMIN_PASSWORD` | — (обязательна) | пароль S3 | diff --git a/api/.env.example b/api/.env.example index 2f5738d..106f24d 100644 --- a/api/.env.example +++ b/api/.env.example @@ -2,6 +2,13 @@ # Порты и учётные данные — стандартные для docs/dev/docker-compose.yml (`make up`: NATS 4222, MinIO 9000). # Сервис без БД: видит только NATS и S3. +# Контур платформы: обычные переменные окружения там задать нельзя, передаются только секреты OpenBao (OPENBAO__*). +# Любая настройка верхнего уровня ниже (не вложенные OPENBAO__<ГРУППА>__*) может прийти как OPENBAO__APP__<ИМЯ>, например: +# OPENBAO__APP__PARSE_TIMEOUT_SECONDS=300 +# OPENBAO__APP__NATS_ENABLED=true +# Приоритет: обычный env -> OPENBAO__APP__* -> .env -> значение по умолчанию. Неизвестное имя — ошибка при старте. +# В .env-файле OPENBAO__APP__* не читается (только процесс окружения). + DEBUG=true # Dev-запуск слушает только loopback. По умолчанию приложения (без этих строк) — 0.0.0.0:8000, как в контейнере платформы. diff --git a/api/src/core/config.py b/api/src/core/config.py index 25fdb11..326608f 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -1,8 +1,10 @@ import os -from typing import Literal +from typing import Any, Literal -from pydantic import NonNegativeInt, PositiveInt -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import NonNegativeInt, PositiveInt, model_validator +from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict + +from src.core.settings_sources import OpenBaoAppSettingsSource class MinioSettings(BaseSettings): @@ -23,6 +25,16 @@ class OpenBAOSettings(BaseSettings): MINIO: MinioSettings NATS: NATSSettings + @model_validator(mode="before") + @classmethod + def _drop_app(cls, data: Any) -> Any: + """`OPENBAO__APP__<ИМЯ>` — настройки верхнего уровня `Settings` (читает `OpenBaoAppSettingsSource`), не группа + секретов. Вложенный разбор `__` кладёт их сюда ключом `APP`, а `extra=forbid` отверг бы его; убирается только + этот ключ — опечатка в имени группы (`OPENBAO__MINO__...`) по-прежнему ошибка.""" + if isinstance(data, dict): + return {key: value for key, value in data.items() if str(key).upper() != "APP"} + return data + class ServerSettings(BaseSettings): HOST: str = "0.0.0.0" @@ -53,6 +65,24 @@ class Settings(BaseSettings): PARSE_MAX_BYTES: PositiveInt = 50 * 1024 * 1024 PARSE_TIMEOUT_SECONDS: PositiveInt = 120 + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + """Обычный env -> `OPENBAO__APP__*` -> `.env` -> значение по умолчанию (см. `settings_sources.py`).""" + return ( + init_settings, + env_settings, + OpenBaoAppSettingsSource(settings_cls), + dotenv_settings, + file_secret_settings, + ) + model_config = SettingsConfigDict( extra="ignore", env_file=os.environ.get("ENV_FILE", ".env"), diff --git a/api/src/core/settings_sources.py b/api/src/core/settings_sources.py new file mode 100644 index 0000000..ce57bb1 --- /dev/null +++ b/api/src/core/settings_sources.py @@ -0,0 +1,50 @@ +"""Источник настроек `OPENBAO__APP__<ИМЯ>`: несекретные настройки верхнего уровня из OpenBao. + +Платформа передаёт в под только переменные `OPENBAO__*`, обычные переменные окружения задать нельзя. Поэтому любая +настройка верхнего уровня `Settings` может прийти как `OPENBAO__APP__<ИМЯ>` +(например `OPENBAO__APP__NATS_ENABLED=true`). +Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию. + +Файл одинаковый в arm-core, arm-scan-gateway, arm-tabular-parser (менять во всех трёх). +""" + +from typing import Any + +from pydantic_settings import BaseSettings, EnvSettingsSource + +APP_PREFIX = "OPENBAO__APP__" +# Группа секретов не настраивается через APP: секреты — только `OPENBAO__<ГРУППА>__<ИМЯ>` +RESERVED_FIELD = "OPENBAO" + + +class OpenBaoAppSettingsSource(EnvSettingsSource): + """Читает `OPENBAO__APP__<ИМЯ>` как обычный env: те же разбор типов, `NoDecode`, вложенность `__`, регистр. + + Только процесс окружения (не `.env`): так платформа и передаёт значения. Неизвестное имя — `ValueError`. + """ + + def __init__(self, settings_cls: type[BaseSettings]) -> None: + super().__init__( + settings_cls, + env_prefix=APP_PREFIX, + env_nested_delimiter=settings_cls.model_config.get("env_nested_delimiter"), + ) + + def __call__(self) -> dict[str, Any]: + self._check_names() + return super().__call__() + + def _check_names(self) -> None: + known = {name.lower() for name in self.settings_cls.model_fields} - {RESERVED_FIELD.lower()} + delimiter = self.env_nested_delimiter or "__" + prefix = self.env_prefix.lower() + unknown = sorted( + env_name + for env_name in self.env_vars + if env_name.startswith(prefix) and env_name[len(prefix) :].split(delimiter, 1)[0] not in known + ) + if unknown: + raise ValueError( + f"Неизвестная настройка в {APP_PREFIX}*: {', '.join(name.upper() for name in unknown)}. " + "Допустимы имена полей верхнего уровня Settings (кроме OPENBAO)." + ) diff --git a/api/tests/unit/test_config_openbao_app.py b/api/tests/unit/test_config_openbao_app.py new file mode 100644 index 0000000..34c22f2 --- /dev/null +++ b/api/tests/unit/test_config_openbao_app.py @@ -0,0 +1,111 @@ +"""Несекретные настройки из OpenBao: `OPENBAO__APP__<ИМЯ>` (issue #22). + +Приоритет: обычный env -> `OPENBAO__APP__*` -> `.env` -> значение по умолчанию. +""" + +import os +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from src.core.config import Settings + +REQUIRED_ENV = { + "OPENBAO__MINIO__MINIO_URL": "localhost:9000", + "OPENBAO__MINIO__MINIO_ADMIN_LOGIN": "login", + "OPENBAO__MINIO__MINIO_ADMIN_PASSWORD": "password", + "OPENBAO__MINIO__MINIO_BUCKET": "bucket", + "OPENBAO__NATS__NATS_URL": "nats://localhost:4222", +} +FLAT_KEYS = {"DEBUG", "NATS_ENABLED", "PARSE_TIMEOUT_SECONDS", "PARSE_MAX_BYTES", "PUBLISH_RETRIES"} +TIMEOUT = 300 +PORT = 9000 +DOTENV_RETRIES = 5 + + +@pytest.fixture +def env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch: + """Чистое окружение с обязательными OPENBAO__*; остальное из процесса убрано.""" + for key in list(os.environ): + if key.startswith(("OPENBAO__", "SERVER__")) or key in FLAT_KEYS: + monkeypatch.delenv(key) + for key, value in REQUIRED_ENV.items(): + monkeypatch.setenv(key, value) + return monkeypatch + + +def _settings(env_file: Path | None = None) -> Settings: + return Settings(_env_file=env_file) # type: ignore[call-arg] + + +def test_app_value_applied(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__APP__PARSE_TIMEOUT_SECONDS", str(TIMEOUT)) + env.setenv("OPENBAO__APP__DEBUG", "true") + s = _settings() + assert s.PARSE_TIMEOUT_SECONDS == TIMEOUT + assert s.DEBUG is True + assert s.OPENBAO.NATS.NATS_URL == "nats://localhost:4222" + + +def test_default_without_app(env: pytest.MonkeyPatch) -> None: + assert _settings().PARSE_TIMEOUT_SECONDS == 120 # noqa: PLR2004 - значение по умолчанию + + +def test_plain_env_overrides_app(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__APP__NATS_ENABLED", "false") + env.setenv("NATS_ENABLED", "true") + assert _settings().NATS_ENABLED is True + + +def test_app_overrides_dotenv(env: pytest.MonkeyPatch, tmp_path: Path) -> None: + dotenv = tmp_path / ".env" + dotenv.write_text(f"NATS_ENABLED=true\nPUBLISH_RETRIES={DOTENV_RETRIES}\n", encoding="utf-8") + env.setenv("OPENBAO__APP__NATS_ENABLED", "false") + s = _settings(dotenv) + assert s.NATS_ENABLED is False # OPENBAO__APP__ сильнее .env + assert s.PUBLISH_RETRIES == DOTENV_RETRIES + + +def test_types_parsed_like_plain_env(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__APP__NATS_ENABLED", "0") + env.setenv("OPENBAO__APP__PARSE_MAX_BYTES", "1048576") + s = _settings() + assert s.NATS_ENABLED is False + assert s.PARSE_MAX_BYTES == 1048576 # noqa: PLR2004 + + +def test_invalid_value_rejected(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__APP__PARSE_TIMEOUT_SECONDS", "0") + with pytest.raises(ValidationError, match="PARSE_TIMEOUT_SECONDS"): + _settings() + + +def test_unknown_name_is_config_error(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__APP__PARSE_TIMEOUT_SECONDZ", "300") + with pytest.raises(ValueError, match="PARSE_TIMEOUT_SECONDZ"): + _settings() + + +def test_openbao_group_not_settable_via_app(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__APP__OPENBAO__NATS__NATS_URL", "nats://x:4222") + with pytest.raises(ValueError, match="OPENBAO"): + _settings() + + +def test_app_key_does_not_break_openbao_validation(env: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Ключ APP не попадает в OpenBAOSettings (там extra=forbid) — ни из env, ни из env-файла.""" + dotenv = tmp_path / ".env" + dotenv.write_text("OPENBAO__APP__NATS_ENABLED=false\n", encoding="utf-8") + env.setenv("OPENBAO__APP__DEBUG", "true") + s = _settings(dotenv) + assert s.DEBUG is True + assert not hasattr(s.OPENBAO, "APP") + + +def test_nested_group_still_works(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__MINIO__MINIO_SECURE", "false") + env.setenv("OPENBAO__APP__SERVER__PORT", str(PORT)) + s = _settings() + assert s.OPENBAO.MINIO.MINIO_SECURE is False + assert s.server.PORT == PORT