diff --git a/README.md b/README.md index 68e4fc4..d37515e 100644 --- a/README.md +++ b/README.md @@ -155,21 +155,23 @@ Dev-пользователи (`api/seed/dev/users.yaml`, создаются `mak ### Что вы увидите в логе при запуске -Старт занимает около секунды; в логе один трейсбэк — это ожидаемо в dev, приложение при этом работает: - -- Трейсбэк SMB: `Ошибка при регистрации сессии SMB ... Failed to connect to 'localhost:445': Connection refused`. - Настройки `OPENBAO__SMB__*` — заглушки, SMB в Срезе 0 не используется; игнорируйте. Пока на `localhost:445` ничего - не слушает, отказ приходит сразу (старт не задерживается); если порт отвечает, но молчит, ожидание ограничено - тайм-аутом `smbclient` (60 с). +Старт занимает около секунды; трейсбэков в логе нет. SMB (горячие папки ИС Сканирование из шаблона) необязателен и по +умолчанию выключен: в `.env.example` `OPENBAO__SMB__SMB_HOST` пуст, сессия SMB не регистрируется (сообщение об этом — +только на уровне DEBUG). Если `SMB_HOST` задан, регистрация выполняется в отдельном потоке (цикл событий не +блокируется; старт ждёт её завершения не дольше тайм-аута `smbclient`, 60 с); ошибка подключения логируется трейсбэком +`Ошибка при регистрации сессии SMB`, приложение продолжает работу. Строки `Бакет проверен`, `Подключение к NATS установлено` и `Подписка NATS активна. subject=recognition.result, queue=arm-core` означают, что S3 и NATS из compose подключены (MinIO из compose слушает `http`, поэтому в `.env` задано `OPENBAO__MINIO__MINIO_SECURE=false`). -Недоступность SMB, S3 или NATS при старте приложение не роняет и не задерживает: ошибка логируется, состояние NATS -видно в `/healthcheck`. К NATS ядро подключается в фоне и повторяет попытки само (`nats=down`, пока связи нет; warning -в логе — на первую ошибку и дальше не чаще раза в минуту); после разрыва nats-py переподключается и восстанавливает -подписку. +Недоступность S3 или NATS при старте приложение не роняет и не задерживает (недоступный SMB, если он настроен, — не +роняет): ошибка логируется, состояние NATS видно в `/healthcheck`. К NATS ядро подключается в фоне и повторяет попытки +само (`nats=down`, пока связи нет; warning в логе — на первую ошибку и дальше не чаще раза в минуту); после разрыва +nats-py переподключается и восстанавливает подписку. + +При остановке ресурсы закрываются по шагам: фоновые задачи обмена → NATS → S3 → SMB → пул соединений БД. Ошибка +одного шага логируется (`Ошибка при остановке приложения. step=...`) и не отменяет следующие. Предупреждение «Включён dev-эмитент токенов … только для разработки» появляется не при старте, а при первом запросе к защищённому эндпоинту (`/api/v1/me`): проверяющий токены создаётся лениво (штатно, см. «Безопасность @@ -207,10 +209,10 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб | `OPENBAO__NATS__NATS_URL` | — (обязательно) | адрес NATS (`nats://localhost:4222`) | | `OPENBAO__NATS__NATS_SEED` | пусто | nkey seed; пусто — подключение без учётных данных (compose, тесты). Задаётся вместе с `NATS_JWT` или не задаётся вовсе | | `OPENBAO__NATS__NATS_JWT` | пусто | JWT NATS; см. `NATS_SEED` | -| `OPENBAO__SMB__SMB_HOST` | — (обязательно) | заглушка SMB (в Срезе 0 не используется, блок обязателен) | -| `OPENBAO__SMB__SMB_PATH` | — (обязательно) | заглушка SMB | -| `OPENBAO__SMB__SMB_LOGIN` | — (обязательно) | заглушка SMB | -| `OPENBAO__SMB__SMB_PASSWORD` | — (обязательно) | заглушка SMB | +| `OPENBAO__SMB__SMB_HOST` | пусто | SMB из шаблона, необязателен (ядру в Срезе 0 не нужен). Пусто — сессия SMB не регистрируется; задан — регистрация при старте (в отдельном потоке) и сброс кэша соединений при остановке | +| `OPENBAO__SMB__SMB_PATH` | пусто | путь SMB (в Срезе 0 не используется) | +| `OPENBAO__SMB__SMB_LOGIN` | пусто | логин SMB | +| `OPENBAO__SMB__SMB_PASSWORD` | пусто | пароль SMB | | `NATS_ENABLED` | `false` | подключение к NATS и обмен через `recognition_job`: фоновые outbox-публикатор и вахтёр сроков ответа (период 30 с, `processing`/`published` с прошедшим `reply_deadline` → `timeout`); без него фоновых задач нет, `/healthcheck` показывает `nats=off` | | `OUTBOX_POLL_SECONDS` | `5` | период опроса outbox (`queued` → публикация → `published`) | | `ACK_TIMEOUT_SECONDS` | `30` | ожидание ack сервиса распознавания до повторной публикации | diff --git a/api/.env.example b/api/.env.example index 952c90f..2bd9534 100644 --- a/api/.env.example +++ b/api/.env.example @@ -19,11 +19,11 @@ 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 +# SMB необязателен (ядру в Срезе 0 не нужен): пустой SMB_HOST — сессия SMB не регистрируется +OPENBAO__SMB__SMB_HOST= +OPENBAO__SMB__SMB_PATH= +OPENBAO__SMB__SMB_LOGIN= +OPENBAO__SMB__SMB_PASSWORD= # Аутентификация. Dev-эмитент работает только при AUTH_DEV_ISSUER=true И DEBUG=true. AUTH_DEV_ISSUER=true diff --git a/api/.env.test b/api/.env.test index 0d3b09f..623e56d 100644 --- a/api/.env.test +++ b/api/.env.test @@ -17,10 +17,11 @@ OPENBAO__NATS__NATS_URL=nats://localhost:4222 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 +# SMB необязателен: пустой SMB_HOST — сессия SMB не регистрируется +OPENBAO__SMB__SMB_HOST= +OPENBAO__SMB__SMB_PATH= +OPENBAO__SMB__SMB_LOGIN= +OPENBAO__SMB__SMB_PASSWORD= AUTH_DEV_ISSUER=true AUTH_DEV_SECRET=test-only-secret-not-for-production-0123456789 diff --git a/api/src/app.py b/api/src/app.py index 2c78acf..623a0a2 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -2,17 +2,21 @@ Что здесь есть: настройки, подключение S3 (MinIO; `MINIO_SECURE` — https или http), NATS (`src/core/nats.py`: core NATS без JetStream, подключение в фоне при NATS_ENABLED=true независимо от DEBUG, подписка `recognition.result` в queue -group `arm-core`), SMB (горячие папки ИС Сканирование), middleware (gzip, proxy headers, CORS; при DEBUG=False пакет -РАИСА лишь условно импортируется, middleware не подключается), healthcheck (uptime, version, nats: ok|down|off), раздача -собранного SPA. +group `arm-core`), SMB (горячие папки ИС Сканирование; необязателен — при пустом `SMB_HOST` не подключается), +middleware (gzip, proxy headers, CORS; при DEBUG=False пакет РАИСА лишь условно импортируется, middleware не +подключается), healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA. Недоступность S3 или NATS при старте не роняет и не задерживает приложение: ошибка логируется, healthcheck показывает состояние. При NATS_ENABLED=true lifespan запускает фоновые задачи обмена `recognition_job` — outbox-публикатор и вахтёр сроков (`src/services/recognition_exchange.py`, design D-6) — и останавливает их до закрытия NATS. Сообщения `recognition.result` (ack и результаты сервисов) обрабатывает inbox `handle_message` того же модуля с фабрикой сессий приложения и `NoopMapper` (маппинг результата в доменные таблицы — Срез 2). +Остановка: фоновые задачи обмена → NATS → S3 → SMB → пул соединений БД; каждый шаг независим — ошибка одного +логируется и не отменяет остальные. """ +import asyncio import os +from collections.abc import Awaitable, Callable from contextlib import asynccontextmanager from datetime import UTC, datetime from functools import partial @@ -33,7 +37,7 @@ from src.core.clients import S3Storage from src.core.config import get_settings from src.core.exception_handlers import register_exception_handlers from src.core.nats import MessageHandler, NatsConnection -from src.db.db import async_session_maker +from src.db.db import async_session_maker, engine from src.services.recognition_exchange import ExchangeTasks, NoopMapper, handle_message settings = get_settings() @@ -118,18 +122,57 @@ def _start_exchange(nats: NatsConnection) -> ExchangeTasks: return ExchangeTasks.start(async_session_maker, nats, settings) +def _smb_configured() -> bool: + return bool(settings.OPENBAO.SMB.SMB_HOST) + + +async def _register_smb() -> None: + """Сессия SMB (шаблон) — только если задан `SMB_HOST`; вызов блокирующий, поэтому в отдельном потоке.""" + smb = settings.OPENBAO.SMB + if not _smb_configured(): + logger.debug("SMB не настроен (OPENBAO__SMB__SMB_HOST пуст): сессия SMB не регистрируется") + return + try: + await asyncio.to_thread(smbclient.register_session, smb.SMB_HOST, smb.SMB_LOGIN, smb.SMB_PASSWORD) + except Exception: + logger.exception("Ошибка при регистрации сессии SMB") + + +async def _reset_smb() -> None: + """Закрывает кэш соединений SMB (блокирующий вызов — в отдельном потоке); без SMB ничего не делает.""" + if _smb_configured(): + await asyncio.to_thread(smbclient.reset_connection_cache) + + +async def _cleanup_step(name: str, action: Callable[[], Awaitable[object]]) -> None: + """Один шаг остановки: исключение логируется и не мешает следующим шагам.""" + try: + await action() + except Exception: + logger.exception("Ошибка при остановке приложения. step={step}", step=name) + + +async def _shutdown(app: FastAPI) -> None: + """Остановка по шагам: задачи обмена (до NATS: публикатор не пишет в закрытое соединение) → NATS → S3 → SMB → + пул соединений БД (последним: задачи обмена и inbox к этому моменту остановлены).""" + exchange, nats, storage = app.state.exchange, app.state.nats, app.state.storage + if exchange is not None: + await _cleanup_step("exchange", exchange.stop) + if nats is not None: + await _cleanup_step("nats", nats.stop) + if storage: + await _cleanup_step("s3", storage.client.close_session) + await _cleanup_step("smb", _reset_smb) + await _cleanup_step("db", engine.dispose) + + @asynccontextmanager async def lifespan(app: FastAPI): app.state.storage = None app.state.nats = None app.state.exchange = None - try: - smbclient.register_session( - settings.OPENBAO.SMB.SMB_HOST, settings.OPENBAO.SMB.SMB_LOGIN, settings.OPENBAO.SMB.SMB_PASSWORD - ) - except Exception: - logger.exception("Ошибка при регистрации сессии SMB") + await _register_smb() try: app.state.storage = await _connect_s3() @@ -142,13 +185,7 @@ async def lifespan(app: FastAPI): yield finally: logger.info("Приложение закрывается") - smbclient.reset_connection_cache() - if app.state.exchange is not None: # до закрытия NATS: публикатор не должен писать в закрытое соединение - await app.state.exchange.stop() - if app.state.nats is not None: - await app.state.nats.stop() - if app.state.storage: - await app.state.storage.client.close_session() + await _shutdown(app) app = FastAPI(title="АРМ КА", version=VERSION, root_path=settings.ROOT_PATH, lifespan=lifespan) diff --git a/api/src/core/config.py b/api/src/core/config.py index 52604b7..360182b 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -22,17 +22,20 @@ class NATSSettings(BaseSettings): class SMBSettings(BaseSettings): - SMB_HOST: str - SMB_PATH: str - SMB_LOGIN: str - SMB_PASSWORD: str + """SMB (горячие папки ИС Сканирование, шаблон). Необязательны: ядру в Срезе 0 SMB не нужен; пустой `SMB_HOST` — + сессия SMB не регистрируется.""" + + SMB_HOST: str = "" + SMB_PATH: str = "" + SMB_LOGIN: str = "" + SMB_PASSWORD: str = "" class OpenBAOSettings(BaseSettings): DATABASE: DBSettings MINIO: MinioSettings NATS: NATSSettings - SMB: SMBSettings + SMB: SMBSettings = SMBSettings() # все поля необязательны (см. SMBSettings) class ServerSettings(BaseSettings): diff --git a/api/tests/test_app.py b/api/tests/test_app.py index 671001b..3060c60 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -5,12 +5,14 @@ import asyncio import importlib +import threading from http import HTTPStatus from types import SimpleNamespace from typing import Any import httpx import pytest +from loguru import logger from src.api.router import router from src.core.nats import NatsConnection @@ -94,6 +96,27 @@ def _no_smb(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(app_module.smbclient, "reset_connection_cache", lambda: None) +class _FakeEngine: + """Подмена движка БД приложения: lifespan вызывает `dispose()` последним шагом остановки (финальное ревью M-3).""" + + def __init__(self, events: list[str] | None = None) -> None: + self.disposed = 0 + self.events = events + + async def dispose(self) -> None: + self.disposed += 1 + if self.events is not None: + self.events.append("db") + + +@pytest.fixture(autouse=True) +def fake_engine(monkeypatch: pytest.MonkeyPatch) -> _FakeEngine: + """Lifespan в быстрых тестах не трогает пул реального движка БД.""" + engine = _FakeEngine() + monkeypatch.setattr(app_module, "engine", engine) + return engine + + @pytest.fixture(autouse=True) def exchange_loops(monkeypatch: pytest.MonkeyPatch) -> list[str]: """Циклы публикатора и вахтёра подменены ожиданием: быстрые тесты lifespan не ходят в БД.""" @@ -300,3 +323,113 @@ async def test_lifespan_without_nats_starts_no_exchange_tasks( await asyncio.sleep(0) assert exchange_loops == [] assert asyncio.all_tasks() - tasks_before == set() + + +# --- SMB необязателен, остановка по независимым шагам (финальное ревью I-3, T4.5, M-3) ------------------------------- + + +async def _unavailable_s3() -> None: + return None + + +def test_env_files_leave_smb_off(): + """В `.env.test` (и, значит, в тестах) SMB выключен: пустой SMB_HOST.""" + assert app_module.settings.OPENBAO.SMB.SMB_HOST == "" + + +async def test_lifespan_skips_smb_when_host_empty(monkeypatch: pytest.MonkeyPatch, fake_engine: _FakeEngine): + def forbidden(*args: Any, **kwargs: Any) -> None: + raise AssertionError("SMB не настроен — вызова быть не должно") + + records: list[Any] = [] + handler_id = logger.add(lambda message: records.append(message.record), level="DEBUG") + monkeypatch.setattr(app_module.settings.OPENBAO.SMB, "SMB_HOST", "") + monkeypatch.setattr(app_module.smbclient, "register_session", forbidden) + monkeypatch.setattr(app_module.smbclient, "reset_connection_cache", forbidden) + monkeypatch.setattr(app_module, "_connect_s3", _unavailable_s3) + monkeypatch.setattr(app_module, "_create_nats", lambda: None) + try: + async with app_module.lifespan(app_module.app): + pass + finally: + logger.remove(handler_id) + + assert [r for r in records if "SMB не настроен" in r["message"] and r["level"].name == "DEBUG"] + assert not [r for r in records if r["level"].name == "ERROR"] + assert fake_engine.disposed == 1 + + +async def test_lifespan_registers_smb_in_worker_thread_when_configured(monkeypatch: pytest.MonkeyPatch): + """Задан SMB_HOST: `register_session` и `reset_connection_cache` — вне потока цикла событий (`to_thread`).""" + loop_thread = threading.get_ident() + calls: list[tuple[str, Any, bool]] = [] + + def register_session(*args: Any, **kwargs: Any) -> None: + calls.append(("register", args, threading.get_ident() != loop_thread)) + + def reset_connection_cache() -> None: + calls.append(("reset", (), threading.get_ident() != loop_thread)) + + smb = app_module.settings.OPENBAO.SMB + monkeypatch.setattr(smb, "SMB_HOST", "smb.local") + monkeypatch.setattr(smb, "SMB_LOGIN", "user") + monkeypatch.setattr(smb, "SMB_PASSWORD", "password") + monkeypatch.setattr(app_module.smbclient, "register_session", register_session) + monkeypatch.setattr(app_module.smbclient, "reset_connection_cache", reset_connection_cache) + monkeypatch.setattr(app_module, "_connect_s3", _unavailable_s3) + monkeypatch.setattr(app_module, "_create_nats", lambda: None) + + async with app_module.lifespan(app_module.app): + assert calls == [("register", ("smb.local", "user", "password"), True)] + + assert calls[-1] == ("reset", (), True) + + +class _FailingNats(_FakeNats): + def __init__(self, events: list[str]) -> None: + super().__init__() + self.events = events + + async def stop(self) -> None: + self.events.append("nats") + raise RuntimeError("подставная ошибка остановки NATS") + + +class _FailingS3Client: + def __init__(self, events: list[str]) -> None: + self.events = events + + async def close_session(self) -> None: + self.events.append("s3") + raise RuntimeError("подставная ошибка закрытия S3") + + +async def test_shutdown_steps_are_independent_and_engine_disposed_last( + monkeypatch: pytest.MonkeyPatch, exchange_loops: list[str] +): + """Ошибки `nats.stop()` и закрытия S3 не отменяют сброс SMB и `engine.dispose()`; порядок шагов сохраняется.""" + events: list[str] = [] + monkeypatch.setattr(app_module, "engine", _FakeEngine(events)) + monkeypatch.setattr(app_module.settings.OPENBAO.SMB, "SMB_HOST", "smb.local") + monkeypatch.setattr(app_module.smbclient, "register_session", lambda *args, **kwargs: None) + monkeypatch.setattr(app_module.smbclient, "reset_connection_cache", lambda: events.append("smb")) + storage = SimpleNamespace(client=_FailingS3Client(events)) + + async def connect_s3() -> SimpleNamespace: + return storage + + monkeypatch.setattr(app_module, "_connect_s3", connect_s3) + monkeypatch.setattr(app_module, "_create_nats", lambda: _FailingNats(events)) + records: list[Any] = [] + handler_id = logger.add(lambda message: records.append(message.record), level="ERROR") + try: + async with app_module.lifespan(app_module.app): + exchange = app_module.app.state.exchange + assert exchange is not None + finally: + logger.remove(handler_id) + + assert all(task.done() for task in exchange.tasks) + assert events == ["nats", "s3", "smb", "db"] + failed_steps = sorted(r["extra"].get("step") for r in records) + assert failed_steps == ["nats", "s3"] diff --git a/api/tests/test_config.py b/api/tests/test_config.py index 22f3f05..907f606 100644 --- a/api/tests/test_config.py +++ b/api/tests/test_config.py @@ -27,10 +27,11 @@ def test_env_test_is_read(clean_env: pytest.MonkeyPatch) -> None: assert s.AUTH_DEV_ISSUER is True assert s.NATS_ENABLED is False assert s.OPENBAO.MINIO.MINIO_SECURE is False + assert s.OPENBAO.SMB.SMB_HOST == "" # SMB выключен: в логе запуска нет трейсбэка SMB def test_defaults(clean_env: pytest.MonkeyPatch) -> None: - """Значения по умолчанию классов настроек: env-файла нет, заданы только обязательные OPENBAO__*.""" + """Значения по умолчанию классов настроек: env-файла нет, заданы только обязательные OPENBAO__* (SMB не нужен).""" required = { "OPENBAO__DATABASE__DATABASE_URL": "postgresql+asyncpg://u:p@localhost:5432/d", "OPENBAO__MINIO__MINIO_URL": "localhost:9000", @@ -38,10 +39,6 @@ def test_defaults(clean_env: pytest.MonkeyPatch) -> None: "OPENBAO__MINIO__MINIO_ADMIN_PASSWORD": "password", "OPENBAO__MINIO__MINIO_BUCKET": "bucket", "OPENBAO__NATS__NATS_URL": "nats://localhost:4222", - "OPENBAO__SMB__SMB_HOST": "host", - "OPENBAO__SMB__SMB_PATH": "path", - "OPENBAO__SMB__SMB_LOGIN": "login", - "OPENBAO__SMB__SMB_PASSWORD": "password", } for key, value in required.items(): clean_env.setenv(key, value) @@ -59,6 +56,8 @@ def test_defaults(clean_env: pytest.MonkeyPatch) -> None: assert s.OPENBAO.NATS.NATS_SEED == "" assert s.OPENBAO.NATS.NATS_JWT == "" assert s.OPENBAO.MINIO.MINIO_SECURE is True + smb = s.OPENBAO.SMB + assert (smb.SMB_HOST, smb.SMB_PATH, smb.SMB_LOGIN, smb.SMB_PASSWORD) == ("", "", "", "") def test_empty_nats_seed_and_jwt_allowed(clean_env: pytest.MonkeyPatch) -> None: @@ -67,3 +66,4 @@ def test_empty_nats_seed_and_jwt_allowed(clean_env: pytest.MonkeyPatch) -> None: assert s.OPENBAO.NATS.NATS_SEED == "" assert s.OPENBAO.NATS.NATS_JWT == "" assert s.NATS_ENABLED is True + assert s.OPENBAO.SMB.SMB_HOST == ""