Skip to content

Dev UI harness — локальный запуск GUI-страниц rep2 без docker-стека и vendor-кабинета

Ветка: feat/dev-ui-harness. Проверено на payments-linking, doc-from-table, set-project, expenseitems (200 с реальным МС-токеном),edocsuz-sync/set-prefix (нужен заполненный env.dev, см. «Статус страниц»).

Зачем

Раньше, чтобы посмотреть GUI-страницу приложения (то, что встраивается в iframe МойСклад), нужно было: создать тестовое приложение в кабинете вендора, прописать iframe-URL, установить его на аккаунт, задеплоить ветку в docker-стек и открывать страницу через МойСклад. Каждый чих = деплой.

Harness запускает тот же NiceGUI-код локально: один контейнер postgres, реальный API МойСклад, итерация — секунды (uvicorn reload подхватывает правки).

Что подменяется (и только это)

Точка входа любой страницы: main(contextKey, appUid), где дальше VendorService.get_account_id(contextKey) → get_token_by_account_id(uuid) → ContextManager.init(context_key) — вот эти 4 вызова и уходили в vendor API (кабинет вендора). В gui_dev.py они заменены на env-значения:

  • VendorService.get_account_id → DEV_MS_ACCOUNT_UUID
  • VendorService.get_token_by_account_id / get_token_by_context_key → DEV_MS_TOKEN
  • ContextManager.init → локальный dict (uid, accountId, contextKey, appUid)

Всё остальное — настоящий прод-код: МойСклад API ходит с реальным токеном, правила/настройки лежат в локальном postgres (миграции те же).

Дополнительно (только локально, на прод не влияет): - POSTGRES_HOST/POSTGRES_PORT теперь читаются из env (src/database/register.py), дефолты db/5432 — прод-поведение прежнее; учётные данные в URL экранируются (quote_plus), чтобы пароль с @///: не ломал подключение. Имя БД (POSTGRES_DB) не экранируется намеренно: SQLAlchemy отдаёт url.database драйверу как есть, без percent-декодирования, поэтому quote превратил бы my db в другое имя (my%20db). Ограничения: ? в имени БД URL не переживёт, а POSTGRES_HOST не экранируется — IPv6 задавай в скобках ([::1]). - celery-задачи печатают [gui_dev] suppressed task: ... вместо постановки в очередь (брокера нет). Патчится Task.run, поэтому подавляется и синхронный вызов .run(), не только .delay(): UI-код, который ждёт результат от задачи, получит None. - logfire: send_to_logfire=False (токен не нужен). - DEV_MS_TOKEN/DEV_MS_ACCOUNT_UUID проверяются при старте: пустой токен или не-UUID пишутся в stdout как WARNING (без самого токена) — иначе причина 401 ищется в коде страницы. - env.dev читается самим gui_dev.py (load_dotenv) и дополнительно сорсится целями make dev-*; формат файла — обычный key=value без пробелов вокруг =.

Быстрый старт

# 1. env: скопировать шаблон и вписать токен+uuid тестового аккаунта МойСклад
cp env.dev.example env.dev
#   DEV_MS_TOKEN — токен аккаунта (Admin → Настройки → API)
#   DEV_MS_ACCOUNT_UUID — accountId того аккаунта (uuid в .../entity/... или admin)

# 2. БД (контейнер postgres:15, порт хоста 5433)
make dev-db            # останавливать: make dev-db-stop
#   контейнер создаётся с --rm: данные НЕ переживают dev-db-stop,
#   после остановки заново `make dev-migrate` + `make dev-seed`

# 3. Миграции (внутри контейнера БД не нужны — накат с хоста)
make dev-migrate

# 4. Seed: Account + App(9 шт) + Install + app-data строки (идемпотентно)
make dev-seed

# 5. UI (порт 8080, uvicorn reload включён)
make dev-ui            # останавливать: make dev-ui-stop

Открыть: http://localhost:8080/<app-name>?contextKey=dev&appUid=<app-uid> например http://localhost:8080/payments-linking?contextKey=dev&appUid=payments-linking.sorochinsky

contextKey может быть любым — он перехвачен. appUid — как в проде (<name>.sorochinsky), страница по нему выбирает правила.

Требования к окружению и защита от прода

Переменные (все — в env.dev, шаблон env.dev.example):

переменная зачем
DEV_MS_TOKEN токен тестового аккаунта МойСклад (страницы ходят в реальный API)
DEV_MS_ACCOUNT_UUID accountId того же аккаунта
POSTGRES_HOST / POSTGRES_PORT / POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB локальная dev-БД
POSTGRES_HOSTPORT порт контейнера make dev-db на хосте
SUBDOMAIN_NAME, DOMAIN_NAME, YAMAIL_PASS, OMF_EMAIL обязательны при импорте src/env.py
NICEGUI_STORAGE_SECRET секрет storage NiceGUI
LOGFIRE_TOKEN не нужен: harness конфигурирует logfire с send_to_logfire=False
DEV_ACCOUNT_UID (опц.) Account.uid в БД, по умолчанию devuser; без @ (см. ниже)
DEV_GATE_BASE_URL (опц.) хост стенда для полной ссылки-приглашения, напр. https://rep2-dev.progmachine.com

На проде harness не запускать. Он подменяет vendor-авторизацию и пишет в БД, поэтому запуск защищён явно:

  • цели make dev-* начинаются с DEV_GUARD: отказ, если APP_ENV/ENVIRONMENT назван и он не из набора dev/development/local/test/testing (= DEV_ENVS в src/dev_guard.py), если нет env.dev, или если POSTGRES_HOST из env.dev не локальный (осознанный обход — DEV_ALLOW_REMOTE_DB=1 make dev-...). Список хостов в Makefile совпадает с src/dev_guard.py, POSTGRES_HOST нормализуется так же (регистр, скобки IPv6, явный порт, оканчивающая точка). Слой Makefile строже python-guard'а ровно в одном месте: флаг --i-know-this-is-dev у цели указать негде, поэтому незнакомое имя окружения здесь всегда отказ, а не «не опознано, но подтверждено флагом»;
  • gui_dev.py — тоже fail-closed (src/dev_guard.py): REFUSING, если APP_ENV/ENVIRONMENT назван боевым (prod, production, stage, staging, preprod, live — и по префиксу, то есть prod2/staging2/ preprod-eu тоже), не назван вовсе (нужно dev/test/local) или если POSTGRES_HOST не локальный (обход — тот же DEV_ALLOW_REMOTE_DB=1). Голый ENV guard не читает: его выставляет сторонний тулинг, и случайное значение давало бы ложный отказ. Текст отказа перечисляет допустимые хосты, свой dev-хост добавляется переменной DEV_LOCAL_DB_HOSTS (через запятую или точку с запятой, без пробелов внутри элемента) — код править не нужно. Дополнительно gui_dev отказывается стартовать, если DEV_GATE_TOKEN задан, но gate не зарегистрировался (проверяет route + middleware в app): иначе рассинхронизация порядка импортов молча оставила бы стенд открытым;
  • dev_seed.py (ensure_dev_only()) требует названное dev/test-окружение и локальный POSTGRES_HOST; обход — флаг --i-know-this-is-dev или тот же DEV_ALLOW_REMOTE_DB=1, и ни один из них не снимает запрет для боевых имён окружения (включая боевые префиксы prod*/stage*/staging*/preprod*/ live*). Любое другое незнакомое имя с флагом считается dev — и это правило только про сид: флаг подтверждения есть лишь у dev_seed, у gui_dev (ensure_dev_harness_allowed()) его нет вовсе, поэтому незнакомое имя (например mystand) там отвергается, а make dev-ui/dev-migrate/dev-seed отказывают ещё раньше — на слое Makefile (DEV_GUARD), где флага указать негде. Наборы окружений, whitelist хостов и чтение имени окружения импортируются из src/dev_guard — политика одна на оба слоя, дубликатов списков в dev_seed больше нет.

POSTGRES_HOST перед проверкой нормализуется (нижний регистр, снимаются пробелы, оканчивающая точка localhost., скобки вокруг IPv6 [::1], явный порт host:5433) — одинаково в src/dev_guard.db_host() и в DEV_GUARD Makefile, чтобы слои не расходились (раньше POSTGRES_HOST=localhost. проходил python-guard, но отвергался Makefile-слоем). Форма с более чем одним : и не являющаяся IPv6-литералом (например host:5433:extra) отвергается явно обоими слоями, а не «просто не проходит whitelist»: добавить её в DEV_LOCAL_DB_HOSTS и обойти проверку не получится. Причина отказа — порт задаётся отдельной POSTGRES_PORT, поэтому лишний сегмент в хосте ничего не задаёт, а хост выходит нерабочим; признак IPv6-литерала в обоих слоях одинаков (непустое значение из символов 0123456789abcdef:.), поэтому IPv6-зона (fe80::1%eth0) тоже отвергается — осознанный fail-closed. Формы, которые не нормализуются — полная запись IPv6 (0:0:0:0:0:0:0:1), IPv4-mapped (::ffff:127.0.0.1), localhost.localdomain, ip6-localhost — считаются не локальными и дают отказ: это осознанный fail-closed, свой хост добавь через DEV_LOCAL_DB_HOSTS. 0.0.0.0 из списка локальных хостов убран: это wildcard «слушать на всех интерфейсах», а не локальный адрес.

Статус страниц (проверено curl, реальный МС-токен)

страница статус примечание
/payments-linking 200 полный рендер
/doc-from-table 200 фоновый запрос processingprocess падает без данных в МС — не влияет на UI
/set-project 200
/expenseitems 200
/edocsuz-sync 500 без UZ-кредов рендер дергает api.edocs.uz; нужны логин/пароль edocs в EdocsuzData (seed кладёт dev-заглушку)
/set-prefix 500 без токена рендер сразу читает organization из МС — нужен валидный DEV_MS_TOKEN
/salesreturn, /supply-by-demand, /loss-and-enter 404 main-страниц нет (только виджеты/popup) — не баг harness

Концепция данных (чего ждёт код)

Аккаунт в БД: uid=devuser (БЕЗ @ — remove_user_from_uid режет по последнему @, get_account_uid() из контекста должен вернуть ровно Account.uid). ContextManager отдаёт uid=user@devuser → get_account_uid()=devuser.

DEV_ACCOUNT_UID с @ (например devuser@devorg) запрещён: harness собирает uid=user@<DEV_ACCOUNT_UID>, а код читает часть после последнего @, поэтому такой аккаунт из БД искался бы по другому uid (devorg) — молчаливый рассинхрон. gui_dev.py в этом случае отказывается стартовать с REFUSING; значение должно совпадать с Account.uid от make dev-seed.

На каждый install создаются пустые app-data строки (PLData, DocFromTableData, SPrefixData, EIData, SPData, SRData; для edocs — EdocsuzData с dev-кредами): в проде их создаёт обработчик install-вебхука, которого в harness нет. AppDataNotFound на странице = данных нет → проверь, что seed отработал.

install.access_token — колонка VARCHAR(40): из полного токена лежат первые 40 символов. Код, читающий токен из БД (а не из env), получит укороченный — пока таких мест в GUI-пути не встретилось.

Файлы

  • services/backend/src/gui_dev.py — harness: env, патчи, роутеры, ui.run
  • services/backend/src/dev_seed.py — сид БД (идемпотентный)
  • services/backend/src/dev_guard.py — guard «это dev» (общий для harness и сида)
  • services/backend/src/dev_gate.py — gate: защита от посторонних (вкл. при DEV_GATE_TOKEN)
  • services/backend/src/tests/test_dev_seed_guard.py — guard'ы; test_dev_gate_middleware.py/test_dev_gate_safe_next.py — gate (http/WS/next)
  • env.dev.example → env.dev (в .gitignore, не коммитится)
  • Makefile: dev-db, dev-db-stop, dev-migrate, dev-seed, dev-ui, dev-ui-stop, dev-gate-url
  • src/database/register.py — POSTGRES_HOST/POSTGRES_PORT из env (единственное изменение прод-кода, обратно совместимо)

Публикация в интернете: rep2-dev.progmachine.com

Dev-UI доступен снаружи через общий edge-traefik (file-provider, /srv/hermes-tooling/traefik/traefik-data/config/dynamic.yml):

  • A-запись rep2-dev.progmachine.com → 5.35.104.39 (hoster.kz);
  • rep2-dev-router (Host → http://172.18.0.1:8080, passHostHeader: false, tls.domains обязательны — иначе file-provider отдаст self-signed fallback);
  • harness должен слушать 0.0.0.0 (сейчас так и есть: ui.run дефолт).

Защита от посторонних — gate внутри приложения (dev_gate.py, включается переменной DEV_GATE_TOKEN в env.dev; без неё — локальный режим без защиты):

  • вход по ссылке-приглашению https://rep2-dev.progmachine.com/___dev_gate/<токен> (токен = DEV_GATE_TOKEN из env.dev) → подписанная cookie rep2_dev_gate на 90 дней → редирект на нужную страницу; дальше ссылка не нужна. Ссылку печатает отдельная команда make dev-gate-url — в stdout harness она не попадает, потому что stdout стенда уходит в journal/systemd. make dev-gate-url печатает полный URL, если в env.dev задан DEV_GATE_BASE_URL (https://rep2-dev.progmachine.com — схема http(s) обязательна, значение без схемы/хоста игнорируется, см. env.dev.example); без него — относительный путь, хост нужно приклеить самому (из SUBDOMAIN_NAME/DOMAIN_NAME хост не собирается: там внутренний домен приложения, а не публичное имя стенда);
  • без cookie — 401, токен никогда не возвращается в ответах. Обхода через ?token=<токен> в query нет намеренно: query-строка целиком оседает в access-логах traefik/uvicorn, в Referer и в истории браузера. Для curl сначала получи cookie запросом на /___dev_gate/<токен>, дальше ходи с ней;
  • gate закрывает и WebSocket: NiceGUI работает поверх WS, поэтому middleware написан как чистый ASGI и на websocket-scope без валидной cookie отклоняет handshake (websocket.close), а не отдаёт страницу (проверено: WS без cookie → 403, с cookie → соединение принимается). Отказ идёт до websocket.accept, поэтому браузер на http-запросе без cookie получает 401, а NiceGUI-клиент продолжает переподключать WS в цикле — «соединение…» в UI это ожидаемое следствие отказа handshake, а не отдельная ошибка;
  • /healthcheck открыт осознанно — его дёргает мониторинг traefik; это единственное принятое исключение, других открытых путей нет;
  • cookie подписана (itsdangerous.TimestampSigner) и ставится с флагом secure только для https-схемы запроса, иначе на http://localhost браузер не сохранил бы её и gate блокировал бы сам себя;
  • токен входа идёт в path-сегменте, поэтому виден в access-логах edge-traefik/uvicorn (в логах самого harness его нет). При утечке логов перевыпусти DEV_GATE_TOKEN в env.dev и рестартуй harness — старые cookie становятся невалидными автоматически.

Ограничения / что НЕ проверяется harness-ом

  • vendor-протокол целиком (JWT-подпись contextKey, iframe postMessage, актуализация app) — финальный гейт всё равно прогонять на проде (см. ниже).
  • celery-воркеры и вебхуки МойСклад (постановка задач печатается в консоль).
  • Взаимодействие нескольких пользователей (storage in-memory).

Финальная проверка перед мерджем — как обычно: ветка в прод-стек + открыть страницу через реальный МойСклад (iframe). Harness это не заменяет, он сокращает цикл до этого момента с часов до секунд.