From 0ad8858d6fdad6499e14f238cd78a00bc0bedeb5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 18:35:39 +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(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тот же источник настроек, что в arm-core#44: приоритет обычный env -> OPENBAO__APP__* -> .env -> умолчание; неизвестное имя — ошибка при старте; ключ APP не ломает OpenBAOSettings. Режим smb из OPENBAO__APP__ включает проверку обязательных OPENBAO__SMB__*. 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 | 39 +++++- api/src/core/settings_sources.py | 50 ++++++++ api/tests/unit/test_config_openbao_app.py | 139 ++++++++++++++++++++++ 5 files changed, 233 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 81ebbaf..6970222 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,7 @@ pydantic-валидацией; битый файл (код не из 4–5 ци | Переменная | По умолчанию | Назначение | |---|---|---| +| `OPENBAO__APP__<ИМЯ>` | — | **контур платформы:** туда передаются только секреты OpenBao (`OPENBAO__*`), обычные переменные окружения задать нельзя. Любая настройка верхнего уровня из этой таблицы (`SCAN_HOTFOLDER_MODE`, `NATS_ENABLED`, `DEBUG`, `SCAN_*`, …) может прийти как `OPENBAO__APP__<ИМЯ>`, например `OPENBAO__APP__SCAN_HOTFOLDER_MODE=smb` (путь шары — по-прежнему секрет `OPENBAO__SMB__SMB_PATH`, `SCAN_HOTFOLDER_PATH` в контуре не нужен). Значения разбираются как из обычного env. Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию. Неизвестное имя — ошибка конфигурации при старте; `OPENBAO__APP__OPENBAO__…` не допускается. Читается только из окружения процесса (в `.env` не действует). Реализация — `api/src/core/settings_sources.py` (тот же файл в arm-core и arm-tabular-parser) | | `OPENBAO__MINIO__MINIO_URL` | — (обязательна) | адрес MinIO `host:port`, без схемы | | `OPENBAO__MINIO__MINIO_ADMIN_LOGIN` | — (обязательна) | логин MinIO | | `OPENBAO__MINIO__MINIO_ADMIN_PASSWORD` | — (обязательна) | пароль MinIO | diff --git a/api/.env.example b/api/.env.example index 3c3641e..10d57bb 100644 --- a/api/.env.example +++ b/api/.env.example @@ -3,6 +3,13 @@ # это только dev-умолчания. Файл окружения можно выбрать переменной ENV_FILE (по умолчанию .env, путь от каталога api/); # переменные окружения приоритетнее файла. Тестовый набор — .env.test (его подключает pytest.ini). +# Контур платформы: обычные переменные окружения там задать нельзя, передаются только секреты OpenBao (OPENBAO__*). +# Любая настройка верхнего уровня ниже (не вложенные OPENBAO__<ГРУППА>__*) может прийти как OPENBAO__APP__<ИМЯ>, например: +# OPENBAO__APP__SCAN_HOTFOLDER_MODE=smb +# OPENBAO__APP__NATS_ENABLED=true +# Приоритет: обычный env -> OPENBAO__APP__* -> .env -> значение по умолчанию. Неизвестное имя — ошибка при старте. +# В .env-файле OPENBAO__APP__* не читается (только процесс окружения). + DEBUG=true # --- MinIO (S3): единственный бакет, в нём лежат входные файлы, raw/{job_id}.json и results/ --- diff --git a/api/src/core/config.py b/api/src/core/config.py index 100a8ea..d30dbce 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -2,17 +2,22 @@ Вложенные модели — тоже BaseSettings (как в шаблоне): имена полей в ENV регистронезависимы только для них. Секреты платформы приходят вложенными переменными `OPENBAO__MINIO__*`, `OPENBAO__NATS__*`, `OPENBAO__SMB__*`; -прикладные параметры — обычными именами верхнего уровня (`SCAN_HOTFOLDER_MODE`, `SCAN_POLL_SECONDS`, ...). +прикладные параметры — обычными именами верхнего уровня (`SCAN_HOTFOLDER_MODE`, `SCAN_POLL_SECONDS`, ...), а там, где +обычные переменные окружения задать нельзя (контур платформы), — как `OPENBAO__APP__<ИМЯ>` +(`src/core/settings_sources.py`; +приоритет: обычный env -> `OPENBAO__APP__*` -> `.env` -> умолчание; неизвестное имя — ошибка при старте). Файл окружения выбирается переменной `ENV_FILE` (по умолчанию `.env`, путь считается от текущего каталога — `api/`); переменные окружения приоритетнее файла. Настройки читаются при первом вызове `get_settings()`, не при импорте. """ import os from functools import lru_cache -from typing import Literal +from typing import Any, Literal from pydantic import NonNegativeFloat, PositiveFloat, PositiveInt, model_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict + +from src.core.settings_sources import OpenBaoAppSettingsSource class MinioSettings(BaseSettings): @@ -44,6 +49,16 @@ class OpenBAOSettings(BaseSettings): NATS: NATSSettings SMB: SMBSettings = SMBSettings() # весь блок необязателен: в режиме local не требуется + @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" @@ -86,6 +101,24 @@ class Settings(BaseSettings): nested_model_default_partial_update=False, ) + @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` -> значение по умолчанию.""" + return ( + init_settings, + env_settings, + OpenBaoAppSettingsSource(settings_cls), + dotenv_settings, + file_secret_settings, + ) + @model_validator(mode="after") def _check_hotfolder(self) -> "Settings": if self.SCAN_HOTFOLDER_MODE == "local" and not self.SCAN_HOTFOLDER_PATH.strip(): 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..cf34665 --- /dev/null +++ b/api/tests/unit/test_config_openbao_app.py @@ -0,0 +1,139 @@ +"""Несекретные настройки из OpenBao: `OPENBAO__APP__<ИМЯ>` (issue #11). + +Приоритет: обычный 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", +} +SMB_ENV = { + "OPENBAO__SMB__SMB_HOST": "smb.example", + "OPENBAO__SMB__SMB_PATH": "share/dir", + "OPENBAO__SMB__SMB_LOGIN": "user", + "OPENBAO__SMB__SMB_PASSWORD": "secret", +} +FLAT_KEYS = { + "DEBUG", + "NATS_ENABLED", + "SCAN_HOTFOLDER_MODE", + "SCAN_HOTFOLDER_PATH", + "SCAN_POLL_SECONDS", + "PUBLISH_RETRIES", +} +RETRIES = 7 +POLL = 2.5 +PORT = 9000 + + +@pytest.fixture +def env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> pytest.MonkeyPatch: + """Чистое окружение с обязательными OPENBAO__* и горячей папкой local.""" + 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) + monkeypatch.setenv("SCAN_HOTFOLDER_PATH", str(tmp_path)) + 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__NATS_ENABLED", "false") + env.setenv("OPENBAO__APP__DEBUG", "true") + s = _settings() + assert s.NATS_ENABLED is False + 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().SCAN_HOTFOLDER_MODE == "local" + + +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("NATS_ENABLED=true\nPUBLISH_RETRIES=5\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 == 5 # noqa: PLR2004 - значение из .env + + +def test_types_parsed_like_plain_env(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__APP__PUBLISH_RETRIES", str(RETRIES)) + env.setenv("OPENBAO__APP__SCAN_POLL_SECONDS", str(POLL)) + env.setenv("OPENBAO__APP__NATS_ENABLED", "0") + s = _settings() + assert s.PUBLISH_RETRIES == RETRIES + assert s.SCAN_POLL_SECONDS == POLL + assert s.NATS_ENABLED is False + + +def test_invalid_value_rejected(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__APP__PUBLISH_RETRIES", "0") + with pytest.raises(ValidationError, match="PUBLISH_RETRIES"): + _settings() + + +def test_unknown_name_is_config_error(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__APP__SCAN_HOTFOLDER_MOD", "smb") + with pytest.raises(ValueError, match="SCAN_HOTFOLDER_MOD"): + _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 + + +def test_smb_mode_from_app_applies_and_requires_smb_secrets(env: pytest.MonkeyPatch) -> None: + """Режим из OPENBAO__APP__ включает проверку «при smb обязательны OPENBAO__SMB__*» и работает с ними.""" + env.setenv("OPENBAO__APP__SCAN_HOTFOLDER_MODE", "smb") + with pytest.raises(ValidationError, match="OPENBAO__SMB"): + _settings() + for key, value in SMB_ENV.items(): + env.setenv(key, value) + s = _settings() + assert s.SCAN_HOTFOLDER_MODE == "smb" + assert s.OPENBAO.SMB.SMB_PATH == "share/dir"