Merge pull request 'feat(config): несекретные настройки из OpenBao через OPENBAO__APP__<ИМЯ> (#22)' (#23) from feat/22-openbao-app-settings into main
Reviewed-on: #23
This commit is contained in:
commit
a4221e0b6e
@ -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 |
|
||||
|
||||
@ -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, как в контейнере платформы.
|
||||
|
||||
@ -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"),
|
||||
|
||||
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)."
|
||||
)
|
||||
111
api/tests/unit/test_config_openbao_app.py
Normal file
111
api/tests/unit/test_config_openbao_app.py
Normal file
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user