Andrey Epifancev 0ad8858d6f feat(config): несекретные настройки из OpenBao через OPENBAO__APP__<ИМЯ> (#11)
Тот же источник настроек, что в arm-core#44: приоритет обычный env -> OPENBAO__APP__* -> .env -> умолчание;
неизвестное имя — ошибка при старте; ключ APP не ломает OpenBAOSettings. Режим smb из OPENBAO__APP__
включает проверку обязательных OPENBAO__SMB__*.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
2026-10-07 18:35:39 +04:00

145 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Настройки сервиса (pydantic-settings).
Вложенные модели — тоже BaseSettings (как в шаблоне): имена полей в ENV регистронезависимы только для них.
Секреты платформы приходят вложенными переменными `OPENBAO__MINIO__*`, `OPENBAO__NATS__*`, `OPENBAO__SMB__*`;
прикладные параметры — обычными именами верхнего уровня (`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 Any, Literal
from pydantic import NonNegativeFloat, PositiveFloat, PositiveInt, model_validator
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
from src.core.settings_sources import OpenBaoAppSettingsSource
class MinioSettings(BaseSettings):
MINIO_URL: str
MINIO_ADMIN_LOGIN: str
MINIO_ADMIN_PASSWORD: str
MINIO_BUCKET: str
MINIO_SECURE: bool = True # https (платформа); false — http (MinIO из docker compose)
class NATSSettings(BaseSettings):
NATS_URL: str
NATS_SEED: str = "" # пусто — подключение к NATS без nkeys/JWT (локальная разработка, тесты)
NATS_JWT: str = ""
class SMBSettings(BaseSettings):
"""SMB-шара горячих папок ИС Сканирование. Нужна только при `SCAN_HOTFOLDER_MODE=smb` (там все поля обязательны,
проверяет `Settings`); в режиме local весь блок можно не задавать."""
SMB_HOST: str = ""
SMB_PATH: str = ""
SMB_LOGIN: str = ""
SMB_PASSWORD: str = ""
class OpenBAOSettings(BaseSettings):
MINIO: MinioSettings
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"
PORT: int = 8000
class Settings(BaseSettings):
OPENBAO: OpenBAOSettings
DEBUG: bool = False
server: ServerSettings = ServerSettings()
ROOT_PATH: str = "/"
VERSION: str = "1.0.0"
SERVICE_NAME: str = "scan-gateway" # service.name в конверте recognition.result
# NATS: подписка на recognition.image.request и публикация. Выключено — воркер не запускается (dev без NATS)
NATS_ENABLED: bool = True
PUBLISH_RETRIES: PositiveInt = 3 # повторы публикации результата/ack после первой попытки (всего до 4)
DONE_CACHE_SIZE: PositiveInt = 1000 # размер LRU обработанных job_id (дедупликация повторов)
# Горячие папки ИС Сканирование: local — обычный каталог (dev, тесты), smb — SMB-шара платформы
SCAN_HOTFOLDER_MODE: Literal["local", "smb"] = "local"
SCAN_HOTFOLDER_PATH: str = "" # корень горячей папки в режиме local (каталог должен существовать)
SCAN_INPUT_DIR: str = "Input"
SCAN_OUTPUT_DIR: str = "Output"
SCAN_TMP_DIR: str = "Tmp" # служебный каталог временных файлов записи (рядом с Input, на той же ФС/шаре)
SCAN_POLL_SECONDS: PositiveFloat = 5 # период опроса «Исходящих»
# набор *.json ответа готов, когда имена и размеры не менялись столько секунд; 0 — готов сразу (тесты)
SCAN_SETTLE_SECONDS: NonNegativeFloat = 2
SCAN_TIMEOUT_SECONDS: PositiveFloat = 300 # потолок ожидания ответа ИС (дополнительно к reply_deadline запроса)
SCAN_MAX_INFLIGHT: PositiveInt = 4 # одновременно обрабатываемых заданий
# Очистка горячей папки после прочтения набора ответа (D-2): любое удаление, включая .part в Tmp, только при true.
# На SMB-шаре контура права на удаление не подтверждены — по умолчанию выключено
SCAN_CLEANUP: bool = False
SCAN_RULES_DIR: str = "rules" # справочники нормализатора (yaml), путь от текущего каталога (api/)
model_config = SettingsConfigDict(
extra="ignore",
env_file_encoding="utf-8",
env_nested_delimiter="__",
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():
raise ValueError("SCAN_HOTFOLDER_MODE=local требует SCAN_HOTFOLDER_PATH (корень горячей папки)")
if self.SCAN_HOTFOLDER_MODE == "smb":
missing = [name for name, value in self.OPENBAO.SMB.model_dump().items() if not value.strip()]
if missing:
raise ValueError(
"SCAN_HOTFOLDER_MODE=smb требует OPENBAO__SMB__SMB_HOST/SMB_PATH/SMB_LOGIN/SMB_PASSWORD; "
f"не заданы: {', '.join(missing)}"
)
if self.SCAN_SETTLE_SECONDS >= self.SCAN_TIMEOUT_SECONDS:
raise ValueError(
f"SCAN_SETTLE_SECONDS ({self.SCAN_SETTLE_SECONDS}) должен быть меньше SCAN_TIMEOUT_SECONDS "
f"({self.SCAN_TIMEOUT_SECONDS}): иначе набор ответа не успеет настояться и каждое задание завершится "
"TIMEOUT"
)
return self
@lru_cache
def get_settings() -> Settings:
return Settings(_env_file=os.environ.get("ENV_FILE", ".env"))