Merge pull request 'feat(config): несекретные настройки из OpenBao через OPENBAO__APP__<ИМЯ> (#11)' (#12) from feat/11-openbao-app-settings into main

Reviewed-on: #12
This commit is contained in:
aep 2026-10-07 17:42:19 +03:00
commit 536dd31536
5 changed files with 233 additions and 3 deletions

View File

@ -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 |

View File

@ -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/ ---

View File

@ -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():

View File

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

View File

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