Andrey Epifancev a3f33fbe40 docs(context): Срез 3a — раскладка горячих папок, §11.1 п. 6/9/10, OQ по редакциям и очистке, compose (#46)
- 03-architecture: scan-gateway — .part остаётся в Tmp, очистка под SCAN_CLEANUP, OcrNormalizer, эмулятор по sha256
- 05-domain-rules §11.1: п. 1 (двойники только для дат), п. 4 (повтор кода, пропуск строк с мусорным кодом),
  п. 6 (свой statement_lines.yaml с редакциями), п. 7 (string_value, скобки, тире), п. 9 (BACKEND_PARSING_ERROR на таблицу),
  новый п. 10 «Редакции форм»
- 07-open-questions: OQ-137 уточнён; OQ-12, OQ-139 дополнены; новые OQ-144…OQ-151
- 09-delivery-plan: Срез 3 разделён на 3a (scan-gateway) и 3b (arm-core)
- dev: SCAN_CLEANUP и SCAN_EMULATOR_DEFAULT в compose, комментарий про <uuid>.<ext> и пары по sha256; qa-guide §6.8

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 02:55:11 +04:00
..

Окружение разработки АРМ КА (docs/dev/)

Локальная инфраструктура workspace одной командой: PostgreSQL 16, NATS 2.10 без JetStream и MinIO с бакетом arm-ka. Профиль services добавляет к ней сервисы системы, собранные из соседних репозиториев workspace: arm-core, tabular-parser, scan-gateway и эмулятор ИС Сканирование scan-emulator (см. «Профиль services»). Без профиля поднимается только инфраструктура, а сервисы можно запускать из venv (см. «Запуск сервисов из venv»).

Для ручного тестирования стенда (установка ПО, запуск, чек-лист сценариев по ролям) — qa-guide.md. Разработчику фронта: как устроены вход и права arm-core, какие ручки когда дёргать, семантика 401/403/404 — frontend-auth-guide.md; контракт API консолидации — api-contract-slice-4.md. Перенос сервисов в контур банка (снимки без истории, что вырезается, скрипт подготовки, проверки в контуре) — contour-transfer-guide.md.

Предварительные требования

  • Docker и compose v2 (проверено на docker 28 / compose 2.34).
  • uv и Python 3.11 — для запуска сервисов из venv (версия Python — как на платформе).
  • Свободные порты хоста (по умолчанию доступны только с 127.0.0.1, см. BIND_ADDR): 5432 (PostgreSQL), 4222 и 8222 (NATS и его мониторинг), 9000 и 9001 (MinIO API и консоль); для профиля services ещё 8000 (arm-core), 8001 (tabular-parser), 8002 (scan-gateway). Занятые порты меняются в .env (см. «Типичные проблемы»).
  • Для профиля services: соседние каталоги ../../arm-core, ../../tabular-parser, ../../scan-gateway и ../../fixtures относительно docs/dev/ (в workspace и в зеркальном worktree .wt/<срез>/ это одни и те же соседи docs). Сборка образов ставит зависимости из PyPI (строки raisa_* отфильтрованы, Nexus не нужен).

Запуск инфраструктуры

Команды — из каталога docs/dev/:

cp .env.example .env        # .env в git не попадает
docker compose up -d        # вернётся сразу; healthy — примерно через 6 с

Ожидание готовности (для скриптов и Makefile):

docker compose up -d && docker compose up -d --wait postgres nats minio

Голый docker compose up -d --wait возвращает код 1: одноразовый minio-init (создаёт бакет и завершается с кодом 0) compose 2.34 считает сбоем (container arm-ka-minio-init-1 exited (0)). Поэтому --wait нужно давать со списком долгоживущих сервисов — как выше.

Проверка:

docker compose ps                                            # postgres, nats, minio — Up (healthy); minio-init — Exited (0)
docker compose exec postgres sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "select version()"'
curl -s localhost:8222/healthz                               # {"status":"ok"}
curl -s localhost:8222/varz | grep -A1 jetstream             # "jetstream": {} — JetStream не включён
docker compose run --rm --entrypoint sh minio-init -c 'mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null && mc ls local'
                                                             # [...]  0B arm-ka/

Консоль MinIO — http://localhost:9001 (по умолчанию minioadmin / minioadmin); S3 API — localhost:9000. Схема БД (dkb) создаётся миграциями arm-core, не compose.

Профиль services

Сервисы системы в контейнерах против той же инфраструктуры. Образы собираются из Dockerfile в корне соседних репозиториев (../../arm-core, ../../tabular-parser, ../../scan-gateway) и получают теги arm-core:dev, tabular-parser:dev, scan-gateway:dev. Настройки сервисов задаёт сам compose (переменные OPENBAO__* шаблона с адресами внутри сети compose — postgres:5432, nats:4222, minio:9000, DEBUG=true); второй блок .env и api/.env сервисов на контейнеры не влияют.

Команды — из каталога docs/dev/:

cp .env.example .env                                   # если ещё не сделано
docker compose --profile services up -d --build        # сборка образов и запуск; первая сборка — около минуты
docker compose --profile services up -d --wait arm-core tabular-parser scan-gateway scan-emulator   # ждать healthy
Сервис Порт хоста (.env) Порт в контейнере Что делает
arm-core 8000 (ARM_CORE_PORT) 8000 ядро: API, outbox/inbox recognition_job, подписка recognition.result
tabular-parser 8001 (TABULAR_PARSER_PORT) 8000 подписка recognition.tabular.request (queue group tabular-parser)
scan-gateway 8002 (SCAN_GATEWAY_PORT) 8000 подписка recognition.image.request (queue group scan-gateway), горячая папка — том hotfolder
scan-emulator — — эмулятор ИС Сканирование: следит за Input/ тома hotfolder, ответы — из ../../fixtures/ocr (только чтение)
arm-core-migrate — — одноразовый: alembic upgrade head и python -m scripts.seed --dev, затем arm-core стартует

Все три сервиса внутри контейнера слушают 8000 (значение по умолчанию SERVER__PORT); различаются только порты хоста. Сервис, запущенный из venv, по умолчанию тоже слушает 8000 на хосте — он конфликтует с контейнером arm-core (остановите контейнер или задайте SERVER__PORT в api/.env).

Порядок старта задан depends_on: arm-core-migrate ждёт healthy postgres; arm-core — postgres, nats, minio, успешного завершения minio-init и arm-core-migrate; tabular-parser и scan-gateway — nats, minio и minio-init. Одноразовые minio-init и arm-core-migrate выполняются при каждом up профиля (миграции и seed идемпотентны: повторный seed пишет 0 / 0 / N — без изменений) и остаются в ps -a как Exited (0).

Проверка (фактический вывод на чистых томах, 2026-10-04, сокращён):

docker compose --profile services ps -a
# arm-core           Up (healthy)      127.0.0.1:8000->8000/tcp
# arm-core-migrate   Exited (0)
# minio-init         Exited (0)
# scan-emulator      Up                8000/tcp            (healthcheck нет: процесс без HTTP)
# scan-gateway       Up (healthy)      127.0.0.1:8002->8000/tcp
# tabular-parser     Up (healthy)      127.0.0.1:8001->8000/tcp
curl -s localhost:8000/healthcheck   # {"uptime":"…","version":"1.0.0","nats":"ok"}
curl -s localhost:8001/healthcheck   # {"uptime":"…","version":"1.0.0","nats":"ok"}
curl -s localhost:8002/healthcheck   # {"uptime":"…","version":"1.0.0","nats":"ok","hotfolder":"ok"}
docker compose --profile services logs arm-core-migrate   # Running upgrade  -> 0001_dbml_v5_13 …; Seed применён …
docker compose --profile services logs scan-emulator      # … корень /data/hotfolder …; Фикстуры: /fixtures/ocr, фикстур: 2

/healthcheck всех трёх сервисов всегда отвечает 200, поэтому healthcheck контейнеров читает поля ответа: nats = ok (и hotfolder = ok у scan-gateway). Статус healthy означает, что сервис подключён к NATS.

Dev-токен arm-core (в compose включён dev-эмитент, AUTH_DEV_ISSUER=true, DEBUG=true; пользователи — из seed --dev, см. ../../arm-core/README.md):

curl -s -X POST localhost:8000/api/v1/dev/token -H 'Content-Type: application/json' -d '{"email":"ka_rf@armka.local"}'
curl -s localhost:8000/api/v1/me -H "Authorization: Bearer <JWT>"     # email, роли KA_RF, права

Образ arm-core содержит только бэкенд: фронт (web/) в Dockerfile не собирается. GET / отвечает 307 с редиректом на //, а тот — 404 (curl -L показывает 404 на http://127.0.0.1:8000//; редирект на // — особенность arm-core, вынесена в его follow-up). UI — npm run dev из ../../arm-core/web (см. README arm-core).

Ожидание готовности и коды возврата --wait (compose 2.34, проверено 2026-10-04):

Команда Код Комментарий
docker compose --profile services up -d --wait arm-core tabular-parser scan-gateway scan-emulator 0 рекомендуемая форма
docker compose --profile services up -d --wait scan-gateway scan-emulator 0 и на чистых томах, и на работающем окружении: minio-init поднимается как зависимость scan-gateway (service_completed_successfully) и его выход с кодом 0 сбоем не считается
docker compose --profile services up -d --wait (без списка) 0 все одноразовые контейнеры профиля — зависимости с service_completed_successfully
docker compose up -d --wait (без профиля и списка) 1 container arm-ka-minio-init-1 exited (0): без профиля у minio-init нет зависимых сервисов

Для скриптов указывайте долгоживущие сервисы явно — форма не зависит от состава профиля.

Пересборка после изменения кода сервиса: docker compose --profile services up -d --build — пересобирает образы и пересоздаёт все контейнеры, чей образ сменился. Образ arm-core:dev собирает только сервис arm-core, образ scan-gateway:dev — только scan-gateway; arm-core-migrate и scan-emulator используют готовый тег (pull_policy: never): две сборки одного тега давали разные ID образа, и каждый следующий up пересоздавал контейнеры. Поэтому arm-core-migrate или scan-emulator в одиночку (up -d scan-emulator) на машине без образа не стартуют — сначала соберите его (docker compose --profile services build scan-gateway) или поднимайте профиль целиком.

Форма up -d --build <сервис> пересоздаёт только сервис и его зависимости (проверено 2026-10-04 на образе, пересобранном с --no-cache):

Команда Что пересоздано Что осталось на старом образе
up -d --build arm-core arm-core и arm-core-migrate (он — зависимость arm-core, миграции нового кода выполняются) —
up -d --build scan-gateway только scan-gateway scan-emulator (не зависимость шлюза) — до следующего docker compose --profile services up -d, который его пересоздаёт

После правок кода scan-gateway, затрагивающих эмулятор, пересобирайте профиль целиком.

Остановка и сброс профиля — только с --profile services: без него compose не видит контейнеры профиля (см. «Сброс окружения»).

Проверка окружения (сквозной сценарий)

Ручная проверка обмена recognition_job через NATS на поднятом профиле services: ядро публикует задание, сервис отвечает ack и результатом, ядро меняет статус. UI и API загрузки файлов в Срезе 0 нет, поэтому файл кладётся в MinIO через mc, а строки legal_entity / s3_object / stored_file / recognition_job вставляются через psql. Проверено 2026-10-04 (все три сценария ниже — фактический вывод, время UTC).

Помощники (bash/zsh, из docs/dev/; uuidgen есть в macOS и util-linux):

dpsql() { docker compose exec -T postgres sh -c 'psql -X -q -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" "$@"' sh "$@"; }
dmc() { docker compose run --rm -v "$PWD/../../fixtures:/fixtures:ro" --entrypoint sh minio-init \
          -c 'mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null && mc "$@"' sh "$@"; }
new_job() {  # new_job <tabular|image> <file_type> <файл в fixtures> — кладёт файл в uploads/ и ставит задание queued
  F=$(uuidgen | tr A-Z a-z); JOB=$(uuidgen | tr A-Z a-z); O=$(uuidgen | tr A-Z a-z); N=$(basename "$3")
  dmc cp -q "/fixtures/$3" "local/arm-ka/uploads/$F/$JOB/$N" >/dev/null || return 1
  dpsql <<SQL
insert into dkb.legal_entity (id, inn, name, valid_from) values (gen_random_uuid(), '7700000001', 'Проверка окружения', now())
  on conflict (inn) do nothing;
insert into dkb.s3_object (id, bucket_role, bucket, key, created_at) values ('$O', 'uploads', 'arm-ka', 'uploads/$F/$JOB/$N', now());
insert into dkb.stored_file (id, legal_entity_id, file_type, original_name, s3_object_id, status, uploaded_at)
  values ('$F', (select id from dkb.legal_entity where inn = '7700000001'), '$2', '$N', '$O', 'UPLOADED', now());
insert into dkb.recognition_job (id, file_id, pipeline, request_subject, schema_version, status, requested_at)
  values ('$JOB', '$F', '$1', 'recognition.$1.request', '1.0', 'queued', now());
SQL
  echo "JOB=$JOB"
}
poll() {  # poll <секунд> — статус задания $JOB раз в секунду, до финального статуса
  for _ in $(seq "$1"); do
    s=$(dpsql -At -c "select status || ' ' || coalesce(error_code, '') || ' ' || coalesce(metrics->>'republish_count', '') from dkb.recognition_job where id = '$JOB'")
    [ "$s" != "$p" ] && echo "$(date -u +%T) $s" && p=$s
    case $s in error*|done*|needs_check*|timeout*|dlq*) p=; return;; esac
    sleep 1
  done; p=
}

Рецепт прогнан дословно на чистой БД (вставка legal_entity без конфликта по inn) и повторно при уже существующей строке (on conflict (inn) do nothing); оба пути проверены.

Ключ исходника — uploads/{file_id}/{job_id}/{имя} (../context/03-architecture.md, «Схема ключей S3»). Сообщения на шине можно смотреть параллельно: docker run --rm --network arm-ka_default natsio/nats-box:0.17.0 nats sub -s nats://nats:4222 'recognition.>' (печатает Nats-Msg-Id и тело каждого запроса, ack и результата).

1. tabular → NOT_IMPLEMENTED. Заглушка парсера Среза 0 отвечает ошибкой на любой файл:

new_job tabular XLSX "osv/schastl/2025-Q3/Оборотносальдовая ведомость по счету 60 за 3 квартал 2025 г..xlsx" && poll 30
# JOB=e585f47b-1b38-44a6-84c5-9432c7e6d194
# 02:12:15 queued
# 02:12:20 error NOT_IMPLEMENTED
dpsql -c "select status, error_code, error_message, published_at::time(3), started_at::time(3), finished_at::time(3) from dkb.recognition_job where id = '$JOB'"
#  error | NOT_IMPLEMENTED | разбор XLS/XLSX/XLSB реализуется в Срезе 2 | 02:12:18.98 | 02:12:19.021 | 02:12:19.024

published → processing → error проходит за десятки миллисекунд (в прогонах 14–51 мс), опросом его не поймать; последовательность видна по published_at < started_at < finished_at (started_at пишет ack), в логе arm-core («Запрос распознавания опубликован» → «Сервис подтвердил получение запроса (ack): задание в processing» → «Результат распознавания принят … error_code=NOT_IMPLEMENTED») и на шине: recognition.tabular.request (Nats-Msg-Id: <job_id>:1) → recognition.result ack:<job_id>:1 (status: processing) → result:<job_id>:1 (status: error). Публикатор опрашивает outbox раз в 5 с, поэтому от вставки до публикации проходит до 5 с.

2. image → эмулятор ИС → INVALID_DOCUMENT, сырой ответ в raw/:

new_job image PDF ocr/aep_5.PDF && poll 30
# JOB=46b0c90b-5a1b-4ac0-9e8a-0cdad80d857e
# 02:12:27 queued
# 02:12:29 processing
# 02:12:34 error INVALID_DOCUMENT
dmc ls local/arm-ka/raw/
# [2026-10-04 11:23:58 UTC]   600B STANDARD 0ea16933-4cc1-4b50-bda9-a97089a918c5.json
dmc cat "local/arm-ka/raw/$JOB.json"        # {"job_id": "<job_id>", "files": [{"name": "<job_id>.json", "response": {"file_uid": "<job_id>", "error": {"code": "INVALID_DOCUMENT", "message": "Не удалось определить тип документа."}, "data_ocr": {…}}, "parse_error": null}]}

Код INVALID_DOCUMENT здесь — настоящий ответ ИС из фикстуры: aep_5.json и aep_8_form4.json — ответы-ошибки ИС Сканирование («Не удалось определить тип документа.», пустой table_structure); у aep_3 и aep_6 есть успешные ответы (сценарий 2а). Эмулятор нашёл набор ответов (в логе scan-emulator: <job_id>_aep_5.PDF -> /data/hotfolder/Output/<job_id>; при отсутствии набора сообщение было бы «…: нет эталонного ответа в фикстурах»). Успешные ответы ИС есть (aep_3.json + aep_3_ofr.json, aep_6.json); шлюз склеивает все *.json каталога ответа и отвечает NOT_IMPLEMENTED (нормализация — Срез 3), ошибочные файлы набора — в metrics.warnings. Имя файла — aep_5.PDF (расширение в верхнем регистре; на macOS aep_5.pdf тоже находится, на Linux — нет). processing виден ~10 с: задержка эмулятора 2 с, затем до SCAN_POLL_SECONDS + SCAN_SETTLE_SECONDS (5 + 2 с) после первого опроса, увидевшего набор.

2а. image → успешный ответ ИС → NOT_IMPLEMENTED:

new_job image PDF ocr/aep_3.pdf && poll 60
# 11:22:07 queued
# 11:22:08 processing
# 11:22:18 error NOT_IMPLEMENTED
dpsql -c "select status, error_code, metrics from dkb.recognition_job where id = '$JOB'"
#  error | NOT_IMPLEMENTED | {"warnings": ["<job_id>_ofr.json: INVALID_DOCUMENT: Не удалось определить тип документа."]}
new_job image PDF ocr/aep_6.pdf && poll 60
# error NOT_IMPLEMENTED, metrics пусто

3. Сервис недоступен → три повторные публикации → dlq / NO_ACK:

docker compose stop tabular-parser            # имя сервиса указано явно — профиль не нужен
new_job tabular XLSX "osv/schastl/2025-Q3/Оборотносальдовая ведомость по счету 60 за 3 квартал 2025 г..xlsx" && poll 180
# 02:12:43 queued
# 02:12:44 published
# 02:13:14 published  1          ← republish_count: повтор через ACK_TIMEOUT_SECONDS = 30 с, тот же Nats-Msg-Id <job_id>:1
# 02:13:45 published  2
# 02:14:14 published  3
# 02:14:44 dlq NO_ACK 3          ← ещё через 30 с после третьего повтора (OUTBOX_MAX_REPUBLISH = 3)
dpsql -c "select status, error_code, error_message, metrics, request_msg_id from dkb.recognition_job where id = '$JOB'"
#  dlq | NO_ACK | Сервис распознавания не подтвердил получение запроса (ack) после 3 повторных публикаций | {"republish_count": 3} | <job_id>:1
docker compose --profile services up -d --wait tabular-parser     # вернуть сервис; rc=0, Healthy

От первой публикации до dlq — ~120 с (4 × 30 с). published_at при каждом повторе перезаписывается (время последней публикации), attempt остаётся 1, started_at и service_name пусты. Задание в dlq после возврата сервиса само не перезапускается (replay — вне Среза 0).

Что сквозная проверка не покрывает (Срез 0): успешные результаты (done/needs_check, регистрация файла results/… в s3_object) — заглушка парсера и нормализатор шлюза Среза 0 (NOT_IMPLEMENTED) дают только ошибки, хотя фикстуры ИС (aep_3, aep_6) успешные ответы содержат; stored_file.status остаётся UPLOADED, current_recognition_job_id не заполняется (маппинг результата — NoopMapper, Срез 2); у ошибок scan-gateway в recognition_job.metrics только warnings — по строке <имя файла>: <код>: <сообщение> на ошибочный файл ответа ИС, если такие есть (tabular-parser присылает duration_ms). Тестовые строки и объекты остаются в БД и MinIO; полная очистка — docker compose --profile services down -v (удаляет и общие тома postgres-data/minio-data всех worktree: проект compose arm-ka общий).

Переменные .env

Файл читают docker compose (из этого каталога) и, вторым блоком, сервисы при запуске из venv. Значения по умолчанию — только для dev.

Блок compose:

Переменная По умолчанию Для чего
BIND_ADDR 127.0.0.1 адрес привязки всех портов хоста; 0.0.0.0 — доступ с других машин (небезопасно, см. «Примечания»)
POSTGRES_DB armka имя БД
POSTGRES_USER armka пользователь БД
POSTGRES_PASSWORD armka пароль БД
POSTGRES_PORT 5432 порт PostgreSQL на хосте
NATS_PORT 4222 клиентский порт NATS на хосте
NATS_MONITOR_PORT 8222 порт мониторинга NATS (/varz, /healthz)
MINIO_ROOT_USER minioadmin администратор MinIO
MINIO_ROOT_PASSWORD minioadmin пароль администратора MinIO
MINIO_API_PORT 9000 порт S3 API на хосте
MINIO_CONSOLE_PORT 9001 порт консоли MinIO на хосте
MINIO_BUCKET arm-ka единственный бакет, создаётся minio-init
ARM_CORE_PORT 8000 порт хоста arm-core (профиль services; в контейнере 8000)
TABULAR_PARSER_PORT 8001 порт хоста tabular-parser (профиль services; в контейнере 8000)
SCAN_GATEWAY_PORT 8002 порт хоста scan-gateway (профиль services; в контейнере 8000)

Блок сервисов (запуск из venv против compose по localhost, формат шаблона команды):

Переменная По умолчанию Для чего
DEBUG true dev-режим: пакеты raisa_* импортируются условно
OPENBAO__DATABASE__DATABASE_URL postgresql+asyncpg://armka:armka@localhost:5432/armka подключение к PostgreSQL (asyncpg)
OPENBAO__MINIO__MINIO_URL localhost:9000 адрес MinIO без схемы (host:port, так ждёт miniopy-async)
OPENBAO__MINIO__MINIO_ADMIN_LOGIN minioadmin логин MinIO
OPENBAO__MINIO__MINIO_ADMIN_PASSWORD minioadmin пароль MinIO
OPENBAO__MINIO__MINIO_BUCKET arm-ka бакет
OPENBAO__NATS__NATS_URL nats://localhost:4222 адрес NATS
OPENBAO__NATS__NATS_SEED пусто nkey seed; пусто — подключение без nkeys
OPENBAO__NATS__NATS_JWT пусто JWT NATS; пусто — подключение без nkeys
OPENBAO__SMB__SMB_HOST localhost заглушка SMB (в Срезе 0 не используется); блок необязателен для arm-core и для scan-gateway в режиме SCAN_HOTFOLDER_MODE=local (по умолчанию); tabular-parser его не знает и отвергнет (см. ниже)
OPENBAO__SMB__SMB_PATH share заглушка SMB
OPENBAO__SMB__SMB_LOGIN user заглушка SMB
OPENBAO__SMB__SMB_PASSWORD password заглушка SMB
NATS_ENABLED true включает подключение arm-core к NATS; без него /healthcheck показывает nats=off

Два блока описывают одни и те же ресурсы: при смене порта, пароля, пользователя или бакета меняйте обе части.

Сброс окружения

docker compose --profile services down      # останавливает все контейнеры проекта; тома сохраняются
docker compose --profile services down -v   # полный сброс: тома postgres-data, minio-data и hotfolder (БД, бакет, горячая папка — с нуля)

Без --profile services команда down (с -v или без) не трогает контейнеры профиля: проверено — после docker compose down -v контейнеры arm-core/tabular-parser/scan-gateway/scan-emulator продолжают работать, том hotfolder и сеть arm-ka_default остаются (Network arm-ka_default Resource is still in use). Если профиль не поднимался, docker compose down [-v] достаточно.

Имя проекта compose фиксировано (name: arm-ka), поэтому тома общие для любого worktree (.wt/<срез>/docs/dev): сброс из одного worktree затрагивает все.

Запуск сервисов из venv без Nexus (черновик)

Черновик: запуск сервисов из venv в Срезе 0 не проверялся (сквозная проверка шла на профиле services); шаги ниже не прогонялись целиком. Сценарий spec dev-environment остаётся неподтверждённым — вопрос OQ-84, решение design #43. make venv и make test-integration есть во всех трёх сервисах.

Пакеты raisa_* из Nexus банка вне банка недоступны (D-10). Поэтому make venv ставит requirements.txt без строк raisa_* (фильтр grep -v '^raisa_'), код импортирует raisa_* условно (при DEBUG=true), а npm-пакеты raisa-* в web/package.json вынесены в optionalDependencies.

Шаги для сервиса (пути — относительно каталога его репозитория; в workspace docs/ лежит рядом, ../docs/dev/):

# 1. поднять инфраструктуру (из docs/dev/, см. выше)
# 2. из корня репозитория сервиса:
cp ../docs/dev/.env.example api/.env
make venv                                  # api/.venv: Python 3.11 + зависимости без raisa_*
cd api && .venv/bin/python run_server.py   # сервис подключается к PostgreSQL/NATS/MinIO по localhost
# 3. в другом терминале:
curl -s localhost:<порт сервиса>/healthcheck

Из venv каждый сервис слушает порт из своих настроек (SERVER__PORT, по умолчанию 8000 у всех трёх) — порты хоста 8001/8002 относятся только к контейнерам профиля services (см. таблицу портов выше). Два сервиса из venv одновременно или сервис из venv рядом с профилем services требуют разных SERVER__PORT. Для arm-core в ответе /healthcheck ожидаются version и nats = ok — при NATS_ENABLED=true (задан в .env.example; без него nats = off). Поля /healthcheck в контейнерах: tabular-parser — uptime, version, nats; scan-gateway — ещё hotfolder (шлюзу нужен SCAN_HOTFOLDER_PATH). Набор переменных сервисов для запуска из venv не проверялся (см. черновик выше и OQ-84). По чтению конфигов: scan-gateway и tabular-parser отвергают неизвестные ключи OPENBAO__*, а поле DATABASE есть только у arm-core. Поэтому в .env scan-gateway нужно удалить строки OPENBAO__DATABASE__* (SMB в режиме local допустим), а в .env tabular-parser — OPENBAO__DATABASE__* и OPENBAO__SMB__*; для MinIO по http нужен OPENBAO__MINIO__MINIO_SECURE=false (в .env.example его нет, по умолчанию true).

Интеграционные тесты сервисов против compose (черновик)

Черновик: состав целей и порядок проверок описаны в checks.md; открытые вопросы — OQ-83 (общий NATS у контейнеров и тестов) и OQ-84.

Общий набор целей (up, test, test-integration, lint, check, check-full) есть во всех сервисных репозиториях (arm-core, tabular-parser, scan-gateway); семантика, состав check-full, порядок перед PR и пропуск тестов без docs/fixtures описаны в checks.md; эталон рецептов общих целей — Makefile.common. Кратко: make check — быстрые проверки без Docker, make check-full — check + интеграционные тесты против этого compose + образ (для arm-core ещё сверка с DBML, миграции и web); выполняется перед созданием PR.

Интеграционные тесты требуют поднятой инфраструктуры: сначала docker compose up -d && docker compose up -d --wait postgres nats minio, затем make test-integration (или make check-full) в репозитории сервиса. Для scan-gateway эмулятор ИС Сканирование поднимается в процессе интеграционного теста; контейнер scan-emulator профиля services тестами не используется. Общее описание проверок — checks.md.

Не запускайте интеграционные тесты tabular-parser и scan-gateway при поднятом профиле services. Их сквозные тесты (api/tests/integration/test_roundtrip.py, у scan-gateway ещё test_nats_minio_integration.py) публикуют запросы в боевые recognition.tabular.request / recognition.image.request, а экземпляр сервиса из теста подписан в ту же queue group (tabular-parser / scan-gateway), что и контейнер. NATS отдаёт каждое сообщение группы одному подписчику — запрос теста может уйти контейнеру, и тест упадёт по таймауту ожидания ack (так прямо сказано в docstring теста scan-gateway). Риск подтверждён чтением кода тестов, тесты при поднятом профиле не запускались. Интеграционные тесты arm-core от контейнеров изолированы: временная БД на сессию (рабочая armka не используется) и уникальный subject результатов на тест. Перед тестами сервисов: docker compose --profile services stop tabular-parser scan-gateway scan-emulator; проверить, что чужих подписчиков нет: curl -s 'http://127.0.0.1:8222/subsz?subs=1' | grep -o '"subject": *"recognition[^"]*"' — строк recognition.tabular.request / recognition.image.request быть не должно (recognition.result — подписка контейнера arm-core, тестам arm-core не мешает).

Проверка контракта

Из корня репозитория docs: python scripts/validate_contracts.py — валидирует примеры в contracts/examples/ тремя схемами из contracts/ (3 схемы, 8 примеров; нужен только пакет jsonschema: формат date-time скрипт проверяет собственной функцией, поэтому rfc3339-validator не требуется). Или make venv && make check (make venv создаёт .venv и ставит jsonschema; check использует этот .venv и ещё запускает openspec validate), см. checks.md.

Примечания

  • Образы MinIO неофициальные. Официальные minio/minio и minio/mc недоступны (Docker Hub — denied, на quay.io нет нужных тегов), поэтому в compose стоят alpine/minio:RELEASE.2025-10-15T17-29-55Z и bitnamilegacy/minio-client:2025.7.21, а сервис minio запущен с user: "0:0" (иначе образ не пишет в свежий том) — только для dev. Решения: design.md, раздел «Decisions made without discussion», решения №16–17. Замена на официальные образы — две строки image: в docker-compose.yml (и удаление user: "0:0").
  • Безопасность. По умолчанию порты публикуются только на 127.0.0.1 (BIND_ADDR), снаружи машины они недоступны. Чтобы открыть их для других машин, задайте в .env BIND_ADDR=0.0.0.0 — но пароли по умолчанию (armka, minioadmin) годятся только для dev, поэтому сначала смените их (в обоих блоках .env) и не делайте этого в недоверенной сети.

Типичные проблемы

  • Порт занят (address already in use): измените *_PORT в .env и одновременно соответствующий порт в OPENBAO__* (DATABASE_URL, MINIO_URL, NATS_URL); перезапустите docker compose up -d. Для портов сервисов (ARM_CORE_PORT, TABULAR_PARSER_PORT, SCAN_GATEWAY_PORT) достаточно .env.
  • Сервис профиля не становится healthy: docker compose --profile services logs <сервис>. nats = down — NATS недоступен; hotfolder = down у scan-gateway — каталог SCAN_HOTFOLDER_PATH (том hotfolder) недоступен. arm-core не стартует, пока arm-core-migrate не завершился с кодом 0: docker compose --profile services logs arm-core-migrate.
  • No such image: arm-core:dev / scan-gateway:dev при запуске arm-core-migrate или scan-emulator по отдельности: образ ещё не собран — docker compose --profile services build (см. «Профиль services»).
  • minio-init завис или завершился с ошибкой: docker compose logs minio-init; чаще всего minio не стал healthy — docker compose logs minio. Повторный docker compose up -d идемпотентен.
  • Остаточные тома от прошлых запусков (старый пароль БД, бакет не тот): docker compose --profile services down -v и запуск заново (без --profile services контейнеры профиля и том hotfolder остаются, см. «Сброс окружения»).
  • up -d --wait возвращает код 1: см. раздел «Запуск инфраструктуры» — указывайте сервисы явно.