NATS: subject'ы и JWT по окружению из OpenBao (OPENBAO__NATS__*) (#106) #107

Merged
aep merged 8 commits from feat/106-nats-env-config into main 2026-10-13 12:55:17 +03:00
7 changed files with 159 additions and 23 deletions
Showing only changes of commit f0c1073918 - Show all commits

View File

@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "arm-ka/recognition-request/v1",
"title": "Запрос распознавания файла (arm-core → tabular-parser / scan-gateway), контракт v1.0",
"description": "Тело сообщения на subject files.tabular_uploaded или files.img_uploaded; subject должен соответствовать полю pipeline (tabular → files.tabular_uploaded, image → files.img_uploaded). Заголовок Nats-Msg-Id = <job_id>:<attempt> (не поле тела). Один файл (stored_file) → один job.",
"description": "Тело сообщения на subject запроса; subject — настройка окружения и должен соответствовать полю pipeline (tabular → NATS_TABULAR_REQUEST_SUBJECT, по умолчанию files.tabular_uploaded; image → NATS_IMAGE_REQUEST_SUBJECT, по умолчанию files.img_uploaded). Заголовок Nats-Msg-Id = <job_id>:<attempt> (не поле тела). Один файл (stored_file) → один job.",
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.",
"type": "object",
"required": ["schema_version", "job_id", "file_id", "pipeline", "file_type", "original_name", "s3", "reply_deadline", "attempt"],

View File

@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "arm-ka/recognition-result-message/v1",
"title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0",
"description": "Тело сообщения на subject files.tabular_parsed. Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:<job_id>:<attempt> — для status = processing; result:<job_id>:<attempt> — для done, needs_check и error. Форма запроса <job_id>:<attempt> описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).",
"description": "Тело сообщения на subject ответа (настройка окружения NATS_RESULT_SUBJECT, по умолчанию files.tabular_parsed). Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:<job_id>:<attempt> — для status = processing; result:<job_id>:<attempt> — для done, needs_check и error. Форма запроса <job_id>:<attempt> описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).",
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.",
"type": "object",
"required": ["schema_version", "job_id", "file_id", "status", "service"],

View File

@ -22,10 +22,12 @@ REQUEST_SUBJECTS: Mapping[str, str] = {
}
def request_subject(pipeline: str) -> str:
"""Subject запроса по pipeline: `tabular` → `files.tabular_uploaded`, `image` → `files.img_uploaded`."""
def request_subject(pipeline: str, subjects: Mapping[str, str] = REQUEST_SUBJECTS) -> str:
"""Subject запроса по pipeline. `subjects` — карта `pipeline → subject` окружения
(`NATSSettings.request_subjects()`); по умолчанию — имена контракта: `tabular` → `files.tabular_uploaded`,
`image` → `files.img_uploaded`."""
try:
return REQUEST_SUBJECTS[pipeline]
return subjects[pipeline]
except KeyError:
raise ValueError(f"неизвестный pipeline {pipeline!r}: допустимы {', '.join(get_args(Pipeline))}") from None

View File

@ -3,19 +3,27 @@ import os
import re
from typing import Annotated, Any
from pydantic import NonNegativeInt, PositiveInt, field_validator, model_validator
from pydantic import NonNegativeInt, PositiveInt, SecretStr, ValidationInfo, field_validator, model_validator
from pydantic_settings import BaseSettings, NoDecode, PydanticBaseSettingsSource, SettingsConfigDict
from src.contract.subjects import IMAGE_REQUEST_SUBJECT, RESULT_SUBJECT, TABULAR_REQUEST_SUBJECT
from src.core.settings_sources import OpenBaoAppSettingsSource
DB_SCHEMA_RE = re.compile(r"[a-z_][a-z0-9_]{0,62}")
# Subject NATS без шаблонов: непустые токены через точку, без пробельных символов, `*` и `>` (docs nats-env-config)
NATS_SUBJECT_RE = re.compile(r"[^\s.*>]+(?:\.[^\s.*>]+)*")
NATS_SUBJECT_FIELDS = ("NATS_TABULAR_REQUEST_SUBJECT", "NATS_IMAGE_REQUEST_SUBJECT", "NATS_RESULT_SUBJECT")
class DBSettings(BaseSettings):
model_config = SettingsConfigDict(hide_input_in_errors=True)
DATABASE_URL: str
class MinioSettings(BaseSettings):
model_config = SettingsConfigDict(hide_input_in_errors=True)
MINIO_URL: str
MINIO_ADMIN_LOGIN: str
MINIO_ADMIN_PASSWORD: str
@ -24,15 +32,57 @@ class MinioSettings(BaseSettings):
class NATSSettings(BaseSettings):
"""Подключение и subject'ы NATS — секреты окружения `OPENBAO__NATS__*` (прод и тест — свои наборы).
Пустые `NATS_SEED` и `NATS_JWT` — подключение без nkeys (локальная разработка, тесты). Subject'ы по умолчанию —
имена контракта v1.0; у ядра и сервисов одного окружения они обязаны совпадать (docs, change nats-env-config).
"""
model_config = SettingsConfigDict(hide_input_in_errors=True)
NATS_URL: str
NATS_SEED: str = "" # пусто — подключение к NATS без nkeys (локальная разработка, тесты)
NATS_JWT: str = ""
NATS_SEED: SecretStr = SecretStr("") # пусто — подключение к NATS без nkeys (локальная разработка, тесты)
NATS_JWT: SecretStr = SecretStr("")
NATS_TABULAR_REQUEST_SUBJECT: str = TABULAR_REQUEST_SUBJECT
NATS_IMAGE_REQUEST_SUBJECT: str = IMAGE_REQUEST_SUBJECT
NATS_RESULT_SUBJECT: str = RESULT_SUBJECT
@field_validator(*NATS_SUBJECT_FIELDS)
@classmethod
def _check_subject(cls, value: str, info: ValidationInfo) -> str:
"""Конкретный subject без шаблонов: иначе подписка молча слушала бы чужой трафик или ничего."""
if not NATS_SUBJECT_RE.fullmatch(value):
raise ValueError(
f"{info.field_name}={value!r}: нужен subject NATS из непустых токенов через точку, "
"без пробелов, '*' и '>'"
)
return value
@model_validator(mode="after")
def _check_distinct_subjects(self) -> "NATSSettings":
"""Три subject'а попарно различны: совпадение запроса и ответа — сервис получает свои ответы, двух запросов —
каждый запрос получают оба сервиса."""
seen: dict[str, str] = {}
for field in NATS_SUBJECT_FIELDS:
subject = getattr(self, field)
if subject in seen:
raise ValueError(
f"{seen[subject]} и {field} совпадают ({subject!r}): subject'ы NATS должны различаться"
)
seen[subject] = field
return self
def request_subjects(self) -> dict[str, str]:
"""Subject запроса по `pipeline` (`tabular`, `image`) — аргумент `request_subject` контракта."""
return {"tabular": self.NATS_TABULAR_REQUEST_SUBJECT, "image": self.NATS_IMAGE_REQUEST_SUBJECT}
class SMBSettings(BaseSettings):
"""SMB (горячие папки ИС Сканирование, шаблон). Необязательны: ядру в Срезе 0 SMB не нужен; пустой `SMB_HOST` —
сессия SMB не регистрируется."""
model_config = SettingsConfigDict(hide_input_in_errors=True)
SMB_HOST: str = ""
SMB_PATH: str = ""
SMB_LOGIN: str = ""
@ -40,6 +90,8 @@ class SMBSettings(BaseSettings):
class OpenBAOSettings(BaseSettings):
model_config = SettingsConfigDict(hide_input_in_errors=True)
DATABASE: DBSettings
MINIO: MinioSettings
NATS: NATSSettings
@ -57,6 +109,8 @@ class OpenBAOSettings(BaseSettings):
class ServerSettings(BaseSettings):
model_config = SettingsConfigDict(hide_input_in_errors=True)
HOST: str = "0.0.0.0"
PORT: int = 8000
@ -162,6 +216,7 @@ class Settings(BaseSettings):
env_file=os.environ.get("ENV_FILE", ".env"),
env_nested_delimiter="__",
nested_model_default_partial_update=False,
hide_input_in_errors=True,
)

View File

@ -66,8 +66,8 @@ def build_nats_auth(settings: NATSSettings) -> dict[str, Any]:
Пустые `NATS_SEED` и `NATS_JWT` — подключение без учётных данных (docker compose, тесты): `{}`. Заданы оба —
колбэки nkeys/JWT, как в шаблоне. Задан только один или seed не читается — `NatsConfigError`.
"""
seed = settings.NATS_SEED
jwt = settings.NATS_JWT
seed = settings.NATS_SEED.get_secret_value()
jwt = settings.NATS_JWT.get_secret_value()
if not seed and not jwt:
return {}
if not seed or not jwt:

View File

@ -36,15 +36,7 @@ def test_env_test_is_read(clean_env: pytest.MonkeyPatch) -> None:
def test_defaults(clean_env: pytest.MonkeyPatch) -> None:
"""Значения по умолчанию классов настроек: env-файла нет, заданы только обязательные OPENBAO__* (SMB не нужен)."""
required = {
"OPENBAO__DATABASE__DATABASE_URL": "postgresql+asyncpg://u:p@localhost:5432/d",
"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",
}
for key, value in required.items():
for key, value in REQUIRED_ENV.items():
clean_env.setenv(key, value)
s = Settings(_env_file=None) # type: ignore[call-arg]
assert s.DEBUG is False
@ -58,8 +50,8 @@ def test_defaults(clean_env: pytest.MonkeyPatch) -> None:
assert s.NATS_ENABLED is False
assert (s.OUTBOX_POLL_SECONDS, s.ACK_TIMEOUT_SECONDS, s.OUTBOX_MAX_REPUBLISH) == (5, 30, 3)
assert (s.UPLOAD_MAX_BYTES, s.MAPPING_POLL_SECONDS, s.MAPPING_MAX_ATTEMPTS) == (50 * 1024 * 1024, 5, 3)
assert s.OPENBAO.NATS.NATS_SEED == ""
assert s.OPENBAO.NATS.NATS_JWT == ""
assert s.OPENBAO.NATS.NATS_SEED.get_secret_value() == ""
assert s.OPENBAO.NATS.NATS_JWT.get_secret_value() == ""
assert s.OPENBAO.MINIO.MINIO_SECURE is True
assert (s.server.HOST, s.server.PORT) == ("0.0.0.0", 8000) # noqa: S104 - по умолчанию, как в контейнере
smb = s.OPENBAO.SMB
@ -69,8 +61,8 @@ def test_defaults(clean_env: pytest.MonkeyPatch) -> None:
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.OPENBAO.NATS.NATS_SEED.get_secret_value() == ""
assert s.OPENBAO.NATS.NATS_JWT.get_secret_value() == ""
assert s.NATS_ENABLED is True
assert s.OPENBAO.SMB.SMB_HOST == ""
assert (s.server.HOST, s.server.PORT) == ("127.0.0.1", 8000) # dev-запуск — только loopback (M-11)
@ -155,3 +147,84 @@ def test_cors_origins_from_env(clean_env: pytest.MonkeyPatch, raw: str, expected
clean_env.setenv("CORS_ORIGINS", raw)
s = Settings(_env_file=API_DIR / ".env.test") # type: ignore[call-arg]
assert s.CORS_ORIGINS == expected
@pytest.fixture
def settings_from_env(clean_env: pytest.MonkeyPatch):
def make(**env: str | None) -> Settings:
for key, value in {**REQUIRED_ENV, **env}.items():
if value is None:
clean_env.delenv(key, raising=False)
else:
clean_env.setenv(key, value)
return Settings(_env_file=None) # type: ignore[call-arg]
return make
JWT = "eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.secret-payload.sig"
SEED = "SUACSSL3UAHUDXKFSNVUZRF5UHPMWZ6BFDTJ7M6USDXIEDNPPQYYYCU3VY"
def test_nats_subjects_default_to_contract_names(settings_from_env) -> None:
nats = settings_from_env().OPENBAO.NATS
assert nats.NATS_TABULAR_REQUEST_SUBJECT == "files.tabular_uploaded"
assert nats.NATS_IMAGE_REQUEST_SUBJECT == "files.img_uploaded"
assert nats.NATS_RESULT_SUBJECT == "files.tabular_parsed"
assert nats.request_subjects() == {"tabular": "files.tabular_uploaded", "image": "files.img_uploaded"}
def test_nats_subjects_from_openbao(settings_from_env) -> None:
nats = settings_from_env(
OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT="test.files.tabular_uploaded",
OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT="test.files.img_uploaded",
OPENBAO__NATS__NATS_RESULT_SUBJECT="test.files.tabular_parsed",
).OPENBAO.NATS
assert nats.request_subjects() == {"tabular": "test.files.tabular_uploaded", "image": "test.files.img_uploaded"}
assert nats.NATS_RESULT_SUBJECT == "test.files.tabular_parsed"
@pytest.mark.parametrize(
"value",
["", "files.*", "files.>", ">", "files..tabular", ".files", "files.", "files tabular", "files.tabular_uploaded "],
)
def test_invalid_subject_rejected(settings_from_env, value: str) -> None:
with pytest.raises(ValidationError, match="NATS_RESULT_SUBJECT"):
settings_from_env(OPENBAO__NATS__NATS_RESULT_SUBJECT=value)
def test_equal_subjects_rejected(settings_from_env) -> None:
with pytest.raises(ValidationError, match="NATS_TABULAR_REQUEST_SUBJECT и NATS_RESULT_SUBJECT совпадают"):
settings_from_env(OPENBAO__NATS__NATS_RESULT_SUBJECT="files.tabular_uploaded")
def test_nats_secrets_hidden_in_repr(settings_from_env) -> None:
settings = settings_from_env(OPENBAO__NATS__NATS_JWT=JWT, OPENBAO__NATS__NATS_SEED=SEED)
assert settings.OPENBAO.NATS.NATS_JWT.get_secret_value() == JWT
assert JWT not in repr(settings) and SEED not in repr(settings)
assert JWT not in str(settings) and SEED not in str(settings)
MINIO_KEYS = ("MINIO_URL", "MINIO_ADMIN_LOGIN", "MINIO_ADMIN_PASSWORD", "MINIO_BUCKET", "MINIO_SECURE")
@pytest.mark.parametrize(
"missing",
[
("OPENBAO__NATS__NATS_URL",), # нет поля в группе NATS: в ошибке — вход группы NATS (с JWT)
tuple(f"OPENBAO__MINIO__{key}" for key in MINIO_KEYS), # нет группы MINIO: в ошибке — вход всего OPENBAO
],
)
def test_validation_error_hides_nats_secrets(settings_from_env, missing: tuple[str, ...]) -> None:
"""Ошибка «нет поля» печатает вход всей группы — JWT и seed туда попадать не должны (Review Focus 1).
Маркеры короткие: pydantic обрезает `input_value` в тексте ошибки (около 50 символов по середине), и полный
JWT там не виден даже без скрытия — проверка на полном значении прошла бы вхолостую.
"""
jwt_mark, seed_mark = "JWTMARK", "SEEDMARK"
with pytest.raises(ValidationError) as exc_info:
settings_from_env(
OPENBAO__NATS__NATS_JWT=jwt_mark, OPENBAO__NATS__NATS_SEED=seed_mark, **dict.fromkeys(missing)
)
assert jwt_mark not in str(exc_info.value)
assert seed_mark not in str(exc_info.value)

View File

@ -626,6 +626,12 @@ def test_request_subject_by_pipeline() -> None:
assert NATS_MSG_ID_HEADER == "Nats-Msg-Id"
def test_request_subject_uses_environment_map() -> None:
subjects = {"tabular": "test.files.tabular_uploaded", "image": "test.files.img_uploaded"}
assert request_subject("tabular", subjects) == "test.files.tabular_uploaded"
assert request_subject("image", subjects) == "test.files.img_uploaded"
def test_request_subject_rejects_unknown_pipeline() -> None:
with pytest.raises(ValueError, match="pipeline"):
request_subject("ocr")