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

Merged
aep merged 11 commits from feat/80-nats-env-config into main 2026-10-13 12:55:27 +03:00
5 changed files with 263 additions and 0 deletions
Showing only changes of commit da9b9f66cf - Show all commits

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-12

View File

@ -0,0 +1,79 @@
## Context
Решение владельца 12.10.2026: «топики можно выставлять через секреты и jwt токен тоже, т.к. у нас будут различные наборы
топиков и разные jwt для прод и тест окружения». Выбран подход 1 из трёх обсуждённых (12.10.2026):
1. **Выбран:** три ключа в группе `OPENBAO__NATS__`, одинаковые в трёх сервисах.
2. `OPENBAO__APP__*` (`settings_sources.py`) — отвергнут: разносит JWT и subject'ы, на которые он даёт права, по разным
группам.
3. Один префикс `NATS_SUBJECT_PREFIX` — отвергнут: работает, только если тестовые subject'ы платформы отличаются от
боевых префиксом.
Образец — `RSHB/armka_file_worker` (проверен в контуре): `NATSSettings` с `NATS_URL`, `NATS_JWT: SecretStr`,
`NATS_SEED: SecretStr`, `NATS_COMMAND_SUBJECT`, `NATS_EVENT_SUBJECT`; `Settings(hide_input_in_errors=True)`.
| Ключ | Умолчание | arm-core | tabular-parser | scan-gateway |
|---|---|---|---|---|
| `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT` | `files.tabular_uploaded` | публикует | подписан (queue `tabular-parser`) | — |
| `OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT` | `files.img_uploaded` | публикует | — | подписан (queue `scan-gateway`) |
| `OPENBAO__NATS__NATS_RESULT_SUBJECT` | `files.tabular_parsed` | подписан (queue `arm-core`) | публикует | публикует |
## Goals / Non-Goals
Goals: subject'ы и учётные данные NATS задаются секретами окружения; разъехавшиеся настройки видны в `/healthcheck`;
тесты не делят subject'ы со стендом. Non-goals: см. proposal.
## Decisions
1. **Ключи одинаковые во всех трёх сервисах, проверяются все три.** На окружение — один набор секретов, который
платформа кладёт всем сервисам; сервис использует только свои subject'ы, но валидирует все три, чтобы ошибка набора
проявилась в любом сервисе одинаково.
2. **Валидация при старте.** Имя subject'а — непустое, без пробельных символов, без шаблонов `*` и `>`, без пустых
токенов (`a..b`, ведущая или завершающая точка). Три имени попарно различны: совпадение запроса и ответа — сервис
получает собственные ответы; совпадение двух запросов — каждый запрос получают оба сервиса. Ошибка — сервис не
стартует (ошибка конфигурации, как у `DB_SCHEMA`).
3. **Константы `contract/subjects.py` — только умолчания.** Код публикации и подписок получает имена из настроек;
функция выбора subject'а по `pipeline` принимает настройки (форма — на усмотрение плана сервиса).
4. **Секреты.** `NATS_JWT` и `NATS_SEED` — `SecretStr`; в колбэки nkeys значение передаётся через
`get_secret_value()`; `Settings` с `hide_input_in_errors=True`, чтобы ошибка валидации конфигурации не печатала
входные значения.
5. **`/healthcheck`.** Новый объект `subjects` с subject'ами, которые использует сервис: arm-core —
`tabular_request`, `image_request`, `result`; tabular-parser — `tabular_request`, `result`; scan-gateway —
`image_request`, `result`. Отдаётся всегда, в том числе при `nats = off | down` (это конфигурация, не состояние).
Решение владельца 12.10.2026: «пусть будет в хэлзчеке».
6. **Лог при старте.** Сервис пишет свои subject'ы одной строкой при старте (существующая строка «Подписка NATS
активна» сохраняется).
7. **Тесты (OQ-83).** Интеграционные тесты `tabular-parser` и `scan-gateway`, где обе стороны обмена — в самом тесте,
задают уникальные subject'ы через настройки и не пересекаются с контейнерами стенда. e2e ядра (`make e2e`) остаётся
на subject'ах по умолчанию: он гоняет настоящий контейнер `tabular-parser` стенда, который слушает умолчания, а
конфликт там — в общей queue group `arm-core` на subject'е ответа; требование останавливать ядро стенда сохраняется
(R-7). Интеграционные тесты NATS ядра уже работают на уникальных subject'ах.
## Risks / Trade-offs
- Разъехавшиеся настройки ядра и сервиса — тишина на шине: ядро переведёт задания в `dlq` по `NO_ACK`. Смягчение:
`subjects` в `/healthcheck` и лог при старте; в `03-architecture.md` — требование задавать набор всем трём сервисам.
- JWT окружения без прав на заданные subject'ы — ошибка прав NATS при подписке/публикации (в логе сервиса); смягчение
— перечень секретов окружения в `03-architecture.md` и `contour-transfer-guide.md`.
- `/healthcheck` публичен и теперь раскрывает имена subject'ов. Имена не секретны (без JWT ими не воспользоваться),
JWT и seed в ответ не попадают.
## Decisions made without discussion
1. 12.10.2026 — **R-1:** имена ключей `NATS_TABULAR_REQUEST_SUBJECT`, `NATS_IMAGE_REQUEST_SUBJECT`,
`NATS_RESULT_SUBJECT` (по образцу FileWorker — префикс `NATS_`, суффикс `_SUBJECT`), а не `NATS_COMMAND_SUBJECT` /
`NATS_EVENT_SUBJECT` FileWorker: у нас два subject'а запроса. Имена показаны владельцу в дизайне и приняты вместе
с подходом. Цена ошибки: низкая — переименование ключа до выката в контур.
2. 12.10.2026 — **R-2:** `schema_version` не меняется: тела сообщений те же, subject'ы — окружение (как R-2 в
`nats-files-subjects`). Цена ошибки: низкая.
3. 12.10.2026 — **R-3:** проверяются все три subject'а в каждом сервисе, включая неиспользуемые им (решение 1).
Цена ошибки: низкая — лишний отказ старта при кривом наборе, который всё равно сломал бы обмен.
4. 12.10.2026 — **R-4:** `subjects` в `/healthcheck` отдаётся и при `nats = off`. Цена ошибки: низкая.
5. 12.10.2026 — **R-5:** пароли MinIO/SMB в `SecretStr` не переводятся — вне запроса владельца; `hide_input_in_errors`
всё равно скрывает их в ошибках валидации. Цена ошибки: пароли видны в `repr(settings)` до отдельной задачи.
6. 12.10.2026 — **R-6:** маршрут — OpenSpec change `nats-env-config` в каждом из четырёх репозиториев, один план SDD на
все четыре (больше пяти задач — SDD-контроллер). Цена ошибки: низкая, процессная.
7. 12.10.2026 — **R-7:** e2e ядра не переводится на свои subject'ы (решение 7): ему нужен контейнер `tabular-parser`
стенда на умолчаниях, а развести ядро теста и ядро стенда может только своя queue group, которая в этом change
не настраивается. OQ-83 для `make e2e` остаётся открытым. Цена ошибки: перед `make e2e` по-прежнему останавливать
ядро стенда.

View File

@ -0,0 +1,60 @@
## Why
Прод и тест будут работать с разными наборами subject'ов NATS и разными JWT. Платформа РАИСА передаёт в под только
переменные `OPENBAO__*`, поэтому окружение должно настраиваться только секретами OpenBao, без пересборки и релиза
сервисов. Сейчас JWT и seed уже приходят как `OPENBAO__NATS__NATS_JWT` / `NATS_SEED`, а subject'ы зашиты константами
`api/src/contract/subjects.py` трёх сервисов (решение R-4 в #74) и уже раз менялись (`recognition.*` → `files.*`,
08.10.2026). Решение владельца 12.10.2026: subject'ы и JWT задаются по окружению секретами группы `OPENBAO__NATS__` —
так устроен `RSHB/armka_file_worker`, проверенный в контуре (`NATS_COMMAND_SUBJECT`, `NATS_EVENT_SUBJECT` рядом с
`NATS_JWT`).
Срез: вне плана срезов `09-delivery-plan.md` — подготовка к контуру (инфраструктура NATS платформы). Issue
RSHB/arm-ka-docs#80.
## What Changes
- Три настройки окружения, одинаковые ключи во всех трёх сервисах, умолчания — текущие имена:
`OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT` (`files.tabular_uploaded`),
`OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT` (`files.img_uploaded`),
`OPENBAO__NATS__NATS_RESULT_SUBJECT` (`files.tabular_parsed`).
- Контракт: subject'ы — конфигурация окружения; у ядра и сервисов одного окружения значения MUST совпадать; имена
проверяются при старте (непустые, без пробелов и шаблонов `*`/`>`, три разных).
- `NATS_JWT` / `NATS_SEED` — секреты: значения не попадают в `repr`, логи и тексты ошибок валидации конфигурации.
- Каждый сервис пишет свои subject'ы в лог при старте и отдаёт их в `/healthcheck` (объект `subjects`).
- Интеграционные тесты `tabular-parser` и `scan-gateway` берут собственные subject'ы через настройки и не делят их с
контейнерами стенда (OQ-83 в части этих тестов; e2e ядра — без изменений, design R-7).
- Документы: `context/03-architecture.md` (NATS платформы, перечень секретов), `context/07-open-questions.md` (OQ-83),
`dev/README.md`, `dev/contour-transfer-guide.md`, `dev/qa-guide.md`, `dev/frontend-api-guide.md` (ответ
`/healthcheck`, если описан), `dev/.env.example`.
Читают и пишут контракт: `arm-core` (публикует запросы в оба subject'а запроса, подписан на subject ответа),
`tabular-parser` (подписан на subject запроса `tabular`, публикует в subject ответа), `scan-gateway` (подписан на
subject запроса `image`, публикует в subject ответа). Дочерние change'и `nats-env-config`: RSHB/arm-core#106,
RSHB/arm-tabular-parser#31, RSHB/arm-scan-gateway#32.
## Non-goals
- `schema_version` контракта не меняется: тела сообщений, `Nats-Msg-Id`, файл результата — те же.
- Queue group'ы (`arm-core`, `tabular-parser`, `scan-gateway`) остаются константами.
- Механизм аутентификации NATS не меняется (nkeys seed + JWT, оба пусты — без учётных данных).
- Пароли MinIO и SMB в `SecretStr` не переводятся.
- Subject'ы не настраиваются через `OPENBAO__APP__*`: это группа `NATS`, а не настройки верхнего уровня.
- DBML не поднимается; заметки v5.17 про subject'ы — follow-up RSHB/arm-ka-docs#79 (R-3).
- Отдельный NATS для тестов не поднимается.
## Capabilities
### New Capabilities
### Modified Capabilities
- `recognition-contract`: subject'ы запроса и ответа — настройки окружения с умолчаниями `files.*`.
## Impact
- Сервисы: `arm-core`, `tabular-parser`, `scan-gateway` — `core/config.py` (`NATSSettings`), `contract/subjects.py`,
`core/nats.py`, публикация и подписки, `/healthcheck`, тесты, `.env.example`, спеки, README.
- Выкат обратно совместим: без новых ключей всё работает на `files.*`. Для теста платформа кладёт свои `NATS_JWT`,
`NATS_SEED` и три subject'а одинаково всем трём сервисам; JWT окружения должен давать права publish/subscribe на эти
subject'ы.
- Ядро вычисляет subject при каждой публикации по `pipeline`, поэтому после смены настроек повторная публикация
заданий в работе уходит в новый subject; `recognition_job.request_subject` записывает фактический subject.

View File

@ -0,0 +1,96 @@
## ADDED Requirements
### Requirement: Subject'ы NATS — настройки окружения
Subject'ы обмена SHALL задаваться секретами группы `OPENBAO__NATS__` с одинаковыми ключами во всех трёх сервисах:
`NATS_TABULAR_REQUEST_SUBJECT` (по умолчанию `files.tabular_uploaded`), `NATS_IMAGE_REQUEST_SUBJECT` (по умолчанию
`files.img_uploaded`), `NATS_RESULT_SUBJECT` (по умолчанию `files.tabular_parsed`). У ядра и сервисов одного окружения
значения MUST совпадать. Каждый сервис SHALL при старте проверять все три имени: непустое, без пробельных символов, без
шаблонов `*` и `>`, без пустых токенов, три имени попарно различны; при нарушении сервис MUST NOT стартовать.
Учётные данные NATS (`OPENBAO__NATS__NATS_JWT`, `OPENBAO__NATS__NATS_SEED`) SHALL задаваться по окружению теми же
секретами, и их значения MUST NOT попадать в логи, `repr` настроек и тексты ошибок валидации конфигурации. Каждый
сервис SHALL писать используемые subject'ы в лог при старте и отдавать их в `/healthcheck` объектом `subjects`
(ключи `tabular_request`, `image_request`, `result` — только те, что использует сервис) независимо от состояния NATS.
Queue group'ы и `schema_version` от настроек subject'ов не зависят.
#### Scenario: Умолчания
- **WHEN** сервис стартует без ключей `OPENBAO__NATS__NATS_*_SUBJECT`
- **THEN** обмен идёт по `files.tabular_uploaded`, `files.img_uploaded`, `files.tabular_parsed`
#### Scenario: Набор тестового окружения
- **WHEN** всем трём сервисам заданы одинаковые `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT`,
`NATS_IMAGE_REQUEST_SUBJECT`, `NATS_RESULT_SUBJECT`, `NATS_JWT`, `NATS_SEED` тестового окружения
- **THEN** ядро публикует запросы и получает ack и результаты только по этим subject'ам; subject'ы по умолчанию не
используются, `recognition_job.request_subject` содержит subject тестового окружения
#### Scenario: Недопустимый набор
- **WHEN** `NATS_RESULT_SUBJECT` совпадает с одним из subject'ов запроса, или имя содержит `*`, `>` или пробел
- **THEN** сервис не стартует с ошибкой конфигурации, называющей ключ
#### Scenario: Разъехавшиеся настройки видны
- **WHEN** у ядра и сервиса заданы разные subject'ы ответа
- **THEN** `/healthcheck.subjects.result` ядра и сервиса различаются; JWT и seed в ответе `/healthcheck` отсутствуют
#### Scenario: Ошибка конфигурации не раскрывает JWT
- **WHEN** сервис не стартует из-за ошибки валидации настроек при заданных `NATS_JWT` и `NATS_SEED`
- **THEN** текст ошибки и лог не содержат значений JWT и seed
## MODIFIED Requirements
### Requirement: Сообщение запроса распознавания
Тело сообщения на subject запроса `tabular` или `image` (настройки окружения `NATS_TABULAR_REQUEST_SUBJECT` /
`NATS_IMAGE_REQUEST_SUBJECT`, по умолчанию `files.tabular_uploaded` / `files.img_uploaded`) SHALL соответствовать
`docs/contracts/recognition-request.schema.json` v1.0: обязательные поля `schema_version` (`"1.0"`), `job_id` (uuid =
`recognition_job.id`), `file_id` (uuid = `stored_file.id`), `pipeline` (`tabular` | `image`), `file_type`
(`XLS` | `XLSX` | `XLSB` | `PDF` | `PNG` | `JPG`), `original_name`, `s3 {bucket, key}` исходника в роли `uploads`,
`reply_deadline` (date-time, UTC), `attempt` (≥ 1). Subject SHALL соответствовать `pipeline`.
#### Scenario: Публикация запроса ядром
- **WHEN** ядро переводит `recognition_job` из `queued` в `published`
- **THEN** в subject, соответствующий `recognition_job.pipeline`, публикуется сообщение, проходящее схему запроса, с
заголовком `Nats-Msg-Id` = `<job_id>:<attempt>`
#### Scenario: Сообщение не по схеме
- **WHEN** сервис получает запрос, не проходящий схему (нет `s3.key`, неизвестный `file_type`), но из тела извлекаются
корректные `job_id` и `file_id`
- **THEN** сервис публикует результат со `status = error`, `error.code = SCHEMA_MISMATCH` **без предшествующего ack
`processing`** и не падает; `attempt` для `Nats-Msg-Id` результата берётся из тела запроса, при его отсутствии
реализации расходятся (`tabular-parser` берёт `attempt` из заголовка `Nats-Msg-Id` запроса или не отвечает,
`scan-gateway` подставляет 1) — см. OQ-71
#### Scenario: Нечитаемый запрос
- **WHEN** сервис получает сообщение, из которого нельзя извлечь `job_id` (не JSON, не объект, `job_id` отсутствует или
не uuid), а значит нельзя собрать валидный конверт
- **THEN** сервис ответа не публикует: запрос записывается в лог и отбрасывается; ядро не получит ack, повторит
публикацию `OUTBOX_MAX_REPUBLISH` раз и переведёт задание в `dlq` (`error_code = NO_ACK`)
#### Scenario: Невалидный повтор запроса, уже находящегося в работе
- **WHEN** сервис получает запрос, не проходящий схему, но с `job_id`, который уже в работе или обработан этим процессом
- **THEN** конверт `SCHEMA_MISMATCH` не публикуется (его `Nats-Msg-Id` `result:<job_id>:<attempt>` совпал бы с настоящим
результатом, и ядро отбросило бы настоящий как дубликат); запрос записывается в лог и отбрасывается
#### Scenario: Примеры контракта — иллюстрации
- **WHEN** запрос берётся из `docs/contracts/examples/request-*.json` (в `request-image.json` `reply_deadline` лежит в
прошлом)
- **THEN** он проходит схему: схема проверяет для `reply_deadline` лишь формат `date-time`, но не сравнивает срок с
текущим временем; пример — иллюстрация формы сообщения, а не готовое к исполнению задание; проверка срока — дело
сервиса (`scan-gateway` отвечает `error TIMEOUT`, если `reply_deadline` истёк до отправки файла в ИС Сканирование)
### Requirement: Сообщение результата распознавания
Тело сообщения на subject ответа (настройка окружения `NATS_RESULT_SUBJECT`, по умолчанию `files.tabular_parsed`)
SHALL соответствовать `docs/contracts/recognition-result-message.schema.json`
v1.0: обязательные поля `schema_version` (`"1.0"`), `job_id`, `file_id`, `status` (`processing` | `done` | `needs_check` |
`error`), `service {name, version}`; при `status ∈ {done, needs_check}` обязательны `result_s3 {bucket, key}` и
`result_schema_version`; при `status = error` обязателен `error {code, message}`; опциональны `raw_s3_key`
(только `scan-gateway`) и `metrics`. Поле `group_schema` в конверт NOT включается — источник `recognition_job.group_schema`
— файл результата.
#### Scenario: Успешный результат
- **WHEN** сервис завершил обработку файла
- **THEN** файл результата записан в S3 по ключу `results/{file_id}/{job_id}/result.json`, а затем опубликован конверт
со `status = done | needs_check`, `result_s3` указывает на этот файл, заголовок `Nats-Msg-Id` = `result:<job_id>:<attempt>`
#### Scenario: Ошибка обработки
- **WHEN** сервис не смог обработать файл (нечитаем, таймаут внешней системы, тип не поддерживается)
- **THEN** публикуется конверт со `status = error` и `error.code` из перечня схемы (`UNSUPPORTED_FORMAT`, `UNREADABLE`,
`TIMEOUT`, `SCHEMA_MISMATCH`, `NOT_IMPLEMENTED`, `FILE_READ_ERROR`, `INVALID_FORMAT`, `INVALID_DOCUMENT`,
`RECOGNITION_FAILED`, `UNKNOWN_ERROR`); `result_s3` может отсутствовать

View File

@ -0,0 +1,26 @@
## 1. Контракт (docs)
- [ ] 1.1 Дельта спеки `recognition-contract` (новое требование «Subject'ы NATS — настройки окружения», два изменённых);
Purpose спеки при архивации: subject'ы — настройки с умолчаниями `files.*`
- [ ] 1.2 `contracts/recognition-request.schema.json`, `contracts/recognition-result-message.schema.json`: в описаниях
subject'ов — «настройка окружения, по умолчанию …» (только `description`, `schema_version` не меняется; копии в
сервисах обновляются побайтно)
## 2. Сервисы (дочерние change'и `nats-env-config`)
- [ ] 2.1 RSHB/arm-core#106: `NATSSettings` (3 subject'а, `SecretStr`, валидация), `hide_input_in_errors`, публикация и
подписка по настройкам, `/healthcheck.subjects`, лог при старте, unit-тесты (e2e — на умолчаниях, design R-7), `.env.example`, спека `recognition-job-exchange`, README
- [ ] 2.2 RSHB/arm-tabular-parser#31: то же для подписки `tabular_request` и публикации `result`, спека
`tabular-recognition-service`
- [ ] 2.3 RSHB/arm-scan-gateway#32: то же для подписки `image_request` и публикации `result`, спека
`image-recognition-gateway`
## 3. Документы контекста
- [ ] 3.1 `context/03-architecture.md`: subject'ы — настройки окружения (таблица потоков, NATS платформы), перечень
секретов `OPENBAO__NATS__*` по сервисам, требование одинакового набора для всех трёх сервисов
- [ ] 3.2 `dev/README.md` (таблица переменных, `/healthcheck`, интеграционные тесты), `dev/.env.example`,
`dev/contour-transfer-guide.md` (секреты окружения), `dev/qa-guide.md` и `dev/frontend-api-guide.md` (ответ
`/healthcheck`, если описан), `repo-templates/*.CLAUDE.md` при упоминании констант subject'ов
- [ ] 3.3 `context/07-open-questions.md`: OQ-83 — интеграционные тесты `tabular-parser`/`scan-gateway` на своих
subject'ах; для e2e ядра вопрос остаётся (общая queue group `arm-core`, design R-7)