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_UUIDVendorService.get_token_by_account_id/get_token_by_context_key→DEV_MS_TOKENContextManager.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). ГолыйENVguard не читает: его выставляет сторонний тулинг, и случайное значение давало бы ложный отказ. Текст отказа перечисляет допустимые хосты, свой 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.runservices/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-urlsrc/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) → подписанная cookierep2_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 это не заменяет, он сокращает цикл до этого момента с часов до секунд.