feat: Settings читает ENV_FILE, поля auth/NATS/outbox, .env.example и .env.test

NATS_SEED/NATS_JWT необязательны (пусто — подключение без nkeys); в app.py
разбор seed защищён от пустого значения (подключение переедет в задаче 22).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-03 18:17:19 +04:00
parent 56ea8dce0c
commit dff1e050b9
5 changed files with 154 additions and 9 deletions

41
api/.env.example Normal file
View File

@ -0,0 +1,41 @@
# Пример локального окружения. Скопируйте в api/.env: cp .env.example .env
# Порты — стандартные для docs/dev/docker-compose.yml.
DEBUG=true
# PostgreSQL (драйвер asyncpg)
OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:5432/armka
# MinIO (адрес без схемы — так ждёт miniopy-async)
OPENBAO__MINIO__MINIO_URL=localhost:9000
OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin
OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=minioadmin
OPENBAO__MINIO__MINIO_BUCKET=arm-ka
# NATS без JetStream; пустые NATS_SEED/NATS_JWT — подключение без nkeys
OPENBAO__NATS__NATS_URL=nats://localhost:4222
OPENBAO__NATS__NATS_SEED=
OPENBAO__NATS__NATS_JWT=
# SMB — заглушки (в Срезе 0 не используется)
OPENBAO__SMB__SMB_HOST=localhost
OPENBAO__SMB__SMB_PATH=share
OPENBAO__SMB__SMB_LOGIN=user
OPENBAO__SMB__SMB_PASSWORD=password
# Аутентификация. Dev-эмитент работает только при AUTH_DEV_ISSUER=true И DEBUG=true.
AUTH_DEV_ISSUER=true
# ТОЛЬКО ДЛЯ РАЗРАБОТКИ: секрет HS256 dev-эмитента, в проде не использовать
AUTH_DEV_SECRET=dev-only-secret-do-not-use-in-production-0123456789
# Проверка токенов РАИСА (прод): JWKS по URL либо публичный ключ PEM
AUTH_JWKS_URL=
AUTH_PUBLIC_KEY=
AUTH_ISSUER=
AUTH_AUDIENCE=
AUTH_EMAIL_CLAIM=email
# Обмен с сервисами распознавания
NATS_ENABLED=true
OUTBOX_POLL_SECONDS=5
ACK_TIMEOUT_SECONDS=30
OUTBOX_MAX_REPUBLISH=3

34
api/.env.test Normal file
View File

@ -0,0 +1,34 @@
# Окружение тестов (pytest.ini: ENV_FILE=.env.test). Только тестовые значения, секретов здесь нет.
# Порты — временная тестовая инфраструктура: PostgreSQL 55432, NATS 54222, MinIO 59000.
DEBUG=true
OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:55432/armka
OPENBAO__MINIO__MINIO_URL=localhost:59000
OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin
OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=minioadmin
OPENBAO__MINIO__MINIO_BUCKET=arm-ka
OPENBAO__NATS__NATS_URL=nats://localhost:54222
OPENBAO__NATS__NATS_SEED=
OPENBAO__NATS__NATS_JWT=
OPENBAO__SMB__SMB_HOST=localhost
OPENBAO__SMB__SMB_PATH=share
OPENBAO__SMB__SMB_LOGIN=user
OPENBAO__SMB__SMB_PASSWORD=password
AUTH_DEV_ISSUER=true
AUTH_DEV_SECRET=test-only-secret-not-for-production-0123456789
AUTH_JWKS_URL=
AUTH_PUBLIC_KEY=
AUTH_ISSUER=
AUTH_AUDIENCE=
AUTH_EMAIL_CLAIM=email
# Интеграционные тесты включают NATS сами
NATS_ENABLED=false
OUTBOX_POLL_SECONDS=5
ACK_TIMEOUT_SECONDS=30
OUTBOX_MAX_REPUBLISH=3

View File

@ -36,7 +36,8 @@ UP_TIME = datetime.now(UTC)
# --- NATS: аутентификация по nkeys/JWT ---------------------------------------------------------
kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode("utf-8"))
# Пустой NATS_SEED — подключение без nkeys (разработка, тесты); подключение переедет в core/nats.py (задача 22)
kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode("utf-8")) if settings.OPENBAO.NATS.NATS_SEED else None
def jwt_cb():
@ -44,6 +45,7 @@ def jwt_cb():
def sig_cb(nonce):
assert kp is not None
if isinstance(nonce, str):
nonce = nonce.encode("utf-8")
return base64.b64encode(kp.sign(nonce))

View File

@ -1,4 +1,6 @@
from pydantic_settings import BaseSettings
import os
from pydantic_settings import BaseSettings, SettingsConfigDict
class DBSettings(BaseSettings):
@ -14,8 +16,8 @@ class MinioSettings(BaseSettings):
class NATSSettings(BaseSettings):
NATS_URL: str
NATS_SEED: str
NATS_JWT: str
NATS_SEED: str = "" # пусто — подключение к NATS без nkeys (локальная разработка, тесты)
NATS_JWT: str = ""
class SMBSettings(BaseSettings):
@ -38,17 +40,40 @@ class ServerSettings(BaseSettings):
class Settings(BaseSettings):
"""Настройки приложения.
Файл окружения выбирается переменной ``ENV_FILE`` (по умолчанию ``.env``; значение читается при импорте модуля,
относительный путь считается от текущего каталога — ``api/``). Тесты используют ``.env.test``.
"""
OPENBAO: OpenBAOSettings
DEBUG: bool = False
server: ServerSettings = ServerSettings()
ROOT_PATH: str = "/"
VERSION: str = "1.0.0"
class Config:
extra = "ignore"
env_file = ".env"
env_nested_delimiter = "__"
nested_model_default_partial_update = False
# Аутентификация: dev-эмитент (только для разработки; роутер подключается при AUTH_DEV_ISSUER и DEBUG одновременно)
AUTH_DEV_ISSUER: bool = False
AUTH_DEV_SECRET: str = ""
# Проверка токенов РАИСА: JWKS по URL либо статический публичный ключ (PEM)
AUTH_JWKS_URL: str = ""
AUTH_PUBLIC_KEY: str = ""
AUTH_ISSUER: str = ""
AUTH_AUDIENCE: str = ""
AUTH_EMAIL_CLAIM: str = "email" # claim токена с email пользователя (сопоставляется с app_user.email)
# NATS и outbox/inbox обмена recognition_job
NATS_ENABLED: bool = False
OUTBOX_POLL_SECONDS: int = 5 # период опроса outbox
ACK_TIMEOUT_SECONDS: int = 30 # ожидание ack сервиса распознавания до повторной публикации
OUTBOX_MAX_REPUBLISH: int = 3 # число повторных публикаций до перевода в dlq
model_config = SettingsConfigDict(
extra="ignore",
env_file=os.environ.get("ENV_FILE", ".env"),
env_nested_delimiter="__",
nested_model_default_partial_update=False,
)
def get_settings() -> Settings:

43
api/tests/test_config.py Normal file
View File

@ -0,0 +1,43 @@
"""Настройки: чтение ENV_FILE и значения по умолчанию (без БД и сети)."""
import os
from pathlib import Path
import pytest
from src.core.config import Settings
API_DIR = Path(__file__).resolve().parent.parent
@pytest.fixture
def clean_env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch:
"""Убирает из окружения процесса переменные, которые перебили бы значения из env-файла."""
for key in list(os.environ):
if key.startswith(("OPENBAO__", "AUTH_", "OUTBOX_", "ACK_")) or key in {"NATS_ENABLED", "DEBUG"}:
monkeypatch.delenv(key)
return monkeypatch
def test_env_test_is_read(clean_env: pytest.MonkeyPatch) -> None:
""".env.test читается: порты временной тестовой инфраструктуры."""
s = Settings(_env_file=API_DIR / ".env.test") # type: ignore[call-arg]
assert s.OPENBAO.DATABASE.DATABASE_URL.endswith("localhost:55432/armka")
assert s.OPENBAO.NATS.NATS_URL == "nats://localhost:54222"
assert s.AUTH_DEV_ISSUER is True
assert s.NATS_ENABLED is False
def test_defaults(clean_env: pytest.MonkeyPatch) -> None:
"""Значения по умолчанию: claim email и параметры outbox."""
s = Settings(_env_file=API_DIR / ".env.test") # type: ignore[call-arg]
assert s.AUTH_EMAIL_CLAIM == "email"
assert (s.OUTBOX_POLL_SECONDS, s.ACK_TIMEOUT_SECONDS, s.OUTBOX_MAX_REPUBLISH) == (5, 30, 3)
def test_empty_nats_seed_and_jwt_allowed(clean_env: pytest.MonkeyPatch) -> None:
"""Пустые NATS_SEED/NATS_JWT допустимы (подключение без nkeys)."""
s = Settings(_env_file=API_DIR / ".env.example") # type: ignore[call-arg]
assert s.OPENBAO.NATS.NATS_SEED == ""
assert s.OPENBAO.NATS.NATS_JWT == ""
assert s.NATS_ENABLED is True