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:
commit
536dd31536
@ -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_URL` | — (обязательна) | адрес MinIO `host:port`, без схемы |
|
||||||
| `OPENBAO__MINIO__MINIO_ADMIN_LOGIN` | — (обязательна) | логин MinIO |
|
| `OPENBAO__MINIO__MINIO_ADMIN_LOGIN` | — (обязательна) | логин MinIO |
|
||||||
| `OPENBAO__MINIO__MINIO_ADMIN_PASSWORD` | — (обязательна) | пароль MinIO |
|
| `OPENBAO__MINIO__MINIO_ADMIN_PASSWORD` | — (обязательна) | пароль MinIO |
|
||||||
|
|||||||
@ -3,6 +3,13 @@
|
|||||||
# это только dev-умолчания. Файл окружения можно выбрать переменной ENV_FILE (по умолчанию .env, путь от каталога api/);
|
# это только dev-умолчания. Файл окружения можно выбрать переменной ENV_FILE (по умолчанию .env, путь от каталога api/);
|
||||||
# переменные окружения приоритетнее файла. Тестовый набор — .env.test (его подключает pytest.ini).
|
# переменные окружения приоритетнее файла. Тестовый набор — .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
|
DEBUG=true
|
||||||
|
|
||||||
# --- MinIO (S3): единственный бакет, в нём лежат входные файлы, raw/{job_id}.json и results/ ---
|
# --- MinIO (S3): единственный бакет, в нём лежат входные файлы, raw/{job_id}.json и results/ ---
|
||||||
|
|||||||
@ -2,17 +2,22 @@
|
|||||||
|
|
||||||
Вложенные модели — тоже BaseSettings (как в шаблоне): имена полей в ENV регистронезависимы только для них.
|
Вложенные модели — тоже BaseSettings (как в шаблоне): имена полей в ENV регистронезависимы только для них.
|
||||||
Секреты платформы приходят вложенными переменными `OPENBAO__MINIO__*`, `OPENBAO__NATS__*`, `OPENBAO__SMB__*`;
|
Секреты платформы приходят вложенными переменными `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/`);
|
Файл окружения выбирается переменной `ENV_FILE` (по умолчанию `.env`, путь считается от текущего каталога — `api/`);
|
||||||
переменные окружения приоритетнее файла. Настройки читаются при первом вызове `get_settings()`, не при импорте.
|
переменные окружения приоритетнее файла. Настройки читаются при первом вызове `get_settings()`, не при импорте.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import NonNegativeFloat, PositiveFloat, PositiveInt, model_validator
|
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):
|
class MinioSettings(BaseSettings):
|
||||||
@ -44,6 +49,16 @@ class OpenBAOSettings(BaseSettings):
|
|||||||
NATS: NATSSettings
|
NATS: NATSSettings
|
||||||
SMB: SMBSettings = SMBSettings() # весь блок необязателен: в режиме local не требуется
|
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):
|
class ServerSettings(BaseSettings):
|
||||||
HOST: str = "0.0.0.0"
|
HOST: str = "0.0.0.0"
|
||||||
@ -86,6 +101,24 @@ class Settings(BaseSettings):
|
|||||||
nested_model_default_partial_update=False,
|
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")
|
@model_validator(mode="after")
|
||||||
def _check_hotfolder(self) -> "Settings":
|
def _check_hotfolder(self) -> "Settings":
|
||||||
if self.SCAN_HOTFOLDER_MODE == "local" and not self.SCAN_HOTFOLDER_PATH.strip():
|
if self.SCAN_HOTFOLDER_MODE == "local" and not self.SCAN_HOTFOLDER_PATH.strip():
|
||||||
|
|||||||
50
api/src/core/settings_sources.py
Normal file
50
api/src/core/settings_sources.py
Normal 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)."
|
||||||
|
)
|
||||||
139
api/tests/unit/test_config_openbao_app.py
Normal file
139
api/tests/unit/test_config_openbao_app.py
Normal 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"
|
||||||
Loading…
x
Reference in New Issue
Block a user