Two unrelated leftover wiki edits from earlier sessions:
- NEW concepts/vision-service-hardening.md: the prioritised to-do list from the
2026-07-02 code + security reviews of apps/vision/ (DoS gaps, unauthenticated/
operator-writable model weights, 0.0.0.0 default bind). Cross-linked from
opencv-anpr-service.md ("consult before touching this service").
- container-deployment.md: note that a boot-time migration can be a DATA SEED
(e.g. an RBAC permission granted to the operator role via INSERT OR IGNORE),
and that a built-in-role grant does not auto-apply to a custom role.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
10 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | |||||
|---|---|---|---|---|---|---|---|---|---|
| decision |
|
2026-06-24 | settled |
Container deployment (Docker images for the non-desktop apps)
How the parking system's runtime apps are packaged as containers, tagged, and published. Settled 2026-06-22. Companion to vision-service-packaging (which scopes the vision service into the monorepo) and the desktop desktop-shell-tauri (a separate, tag-only bundle).
The build/tag/registry pipeline below is current. What changed (2026-06-27): the deploy mechanism is no longer "SSH in and run
booth.sh". At fleet scale that's superseded by fleet-deployment-komodo (Komodo Periphery over a NetBird mesh, driving these same compose files).scripts/booth.shis now a break-glass local fallback, not the primary deploy path.
Two images (the desktop app is NOT containerized)
parking-server— the Fastify API plus the built React SPA. One container serves both: Fastify servesapps/web/distvia@fastify/static(wired inapps/server/src/static-spa.ts), with an SPA fallback toindex.htmlfor client routing. This matches offline-first — the booth appliance is one box, not a web host + an API host.@fastify/webstatic serving is a no-op in dev (no build dir → the Vite dev server serves the UI), so local DX is unchanged.parking-vision— the Python/uv ANPR service (opencv-anpr-service). Ships WITH thealprextra (real fast-alpr/onnxruntime stack); the engine is env-selected (VISION_RECOGNIZER=stub|fast_alpr, defaultstubso it boots anywhere). Model weights are pre-warmed at build (best-effort) so the appliance's first scan needs no network.
The desktop app stays on its own tag-only release.yml (Tauri installers), not these images.
Branch-aware (the user's hard requirement)
- Image tags = branch + short SHA. A push to
devbuilds…/parking-server:dev+…/parking-server:dev-<sha>;stagebuilds:stage+:stage-<sha>;mainbuilds:main+:main-<sha>. The moving branch tag is the deploy pointer; the branch-SHA tag is the immutable record. Same forparking-vision. (Thestagetier — the staging booth — was added 2026-06-29; see fleet-deployment-komodo "Promotion tiers".) - Per-env compose. A base
docker-compose.yml+ overrides:docker-compose.dev.yml(build locally, expose ports,stubrecognizer) anddocker-compose.prod.yml(pull pinned images,restart: always,fast_alpr, vision kept internal).REGISTRY/TAGcome from env, so a deploy on a branch pulls that branch's image — the branch→environment mapping IS the override file.
Booth operator wrapper — scripts/booth.sh
So the on-site operator runs one command instead of the long docker compose -f … -f … --env-file …
line, scripts/booth.sh wraps the base + override + env-file. Prod by default (the booth is
prod); ENV=dev switches to the dev override.
./scripts/booth.sh up— start (detached).down/restart/status/logs [service]/pull/config/exec <svc> …as expected../scripts/booth.sh update— the "I know there are new images" path:compose pullthe moving branch tag, thenup -d --remove-orphans(recreates only services whose image digest moved; named volumes — the SQLite ledger — are preserved), thendocker image prune -fto reclaim the old layers. This is the routine update after adev/mainpush republishes the branch tag.- Env handling. Reads
.env(copy from.env.example:REGISTRY,TAG,JWT_SECRET,EVENT_SIGNING_KEY,COOKIE_SECURE=0,WS_ALLOWED_ORIGINS). Prod refuses to run without.env(no safeJWT_SECRETdefault —auth.tsrejects weak ones). Dev with no.envinjects the documented benign local secret soupworks out of the box. The base file makesJWT_SECRETshell-required (${JWT_SECRET:?}), so the env-file is mandatory for both — the script surfaces that early with a clear message rather than a raw compose interpolation error. - Safety:
downnever passes-v(deletingparking-datawould wipe the signed append-only-event-chain);help/unknown-command short-circuit before any Docker/.env requirement. The operator never typesJWT_SECRETon the CLI — it lives in.env(the user generates it withopenssl rand -hex 32).
Registry + CI
- Published to the house Gitea registry
git.infra.msai.al/mca/parking_solution/{parking-server, parking-vision}. Login viaREGISTRY_USERNAME/REGISTRY_PASSWORDsecrets. - New workflow
.gitea/workflows/build-images.yml(separate from the checks-onlyci.ymland the tag-onlyrelease.yml): on push todev/main, run the fullturbo build lint testfirst (don't ship a broken image), then buildx +docker/build-push-actionfor both images with branch+SHA tags and a registry build cache. An optional Komodo redeploy webhook is guarded behind aKOMODO_ENABLEDvar (mirrors the housetrm/processorpattern). The vision checks needuv(theastral-sh/setup-uvstep), same asci.yml.
Build specifics that bit us (record so they don't recur)
pnpm deploy --legacy --prod, NOTpnpm prune --prod. It's a pnpm/turbo monorepo; pruning at the root leavespackages/db/node_modulesempty, so the nativebetter-sqlite3binding can't resolve at runtime.pnpm deployproduces a self-contained, hoisted bundle (the workspace packages' builtdist+ their native deps) — a singleCOPY --from=build /deploy ./. pnpm 10 needs--legacy(orinject-workspace-packages).- Native modules: Alpine build stage needs
python3 make g++(node-gyp for better-sqlite3); runtime needslibstdc++.bcryptships alinux-x64/muslprebuild, so it works on Alpine as-is. pnpm prune/deploy refuse to run without a TTY unlessCI=true(orENV CI=true) is set in the build stage.- Migrations at boot, not at build. The DB lives on a mounted volume (
/data), so the entrypoint runs them against the live file via a drizzle-kit-free runtime migrator (packages/db/scripts/migrate-runtime.mjs, usingdrizzle-orm/.../migrator— drizzle-kit is a devDep, pruned from the prod bundle). Idempotent: a restart re-applies nothing. A migration is not only schema — it can also be a data seed (e.g. a new RBAC permission granted to theoperatorrole viaINSERT OR IGNORE, so a new operator capability reaches the booth on the next deploy). The whole@parking/dbpackage ships in the bundle (nofilesallowlist), so everydrizzle/*.sqlis present in the image. NB a permission seeded to the built-inoperatorrole does NOT auto-apply to a custom role — an admin toggles it in Setup → Roles. - JWT_SECRET must be a real value at deploy —
auth.tsrejects anything<32chars or matchingchange.?me|insecure|dev-only, so the dev compose default is a benign 32-char string, not a "dev-only…" placeholder (which would crash boot). - Vision model pre-warm must run AS the runtime user. fast-alpr's
open-image-modelscaches weights under$HOME/.cache/open-image-modelskeyed to$HOME— it ignoresHF_HOME/XDG_CACHE_HOME. A first attempt pre-warmed as root (/root/.cache), so the non-root runtime re-downloaded at boot (offline-first BROKEN). Fix: create thevisionuser first,USER vision, THEN runpython -c "from fast_alpr import ALPR; ALPR()"so weights land in/home/vision/.cache— exactly where the runtime reads. Verify the boot log shows NO "Downloading …onnx".
Web access — relative API + Caddy proxy (2026-06-23)
- The server-image SPA uses a RELATIVE
/apibase (no baked origin), so the UI works loaded from any hostname/IP. The Dockerfile emptiesVITE_API_BASEviaapps/web/.env.production.localbefore the web build — because Vite auto-loadsapps/web/.env.production, which setsVITE_API_BASE=http://127.0.0.1:3000for the Tauri desktop build only. Without the override the browser bundle baked127.0.0.1:3000and failed Same-Origin Policy from any other host. Do NOT bake the domain via a build var — relative means naming is controlled by hosts/DNS at deploy, never a rebuild. - A Caddy reverse proxy (prod override) publishes
:80→server:3000(server isexpose-only, internal);/api/wsupgrades pass through.Caddyfilebinds:80so it matches ANY host — booth IP, localhost, orparksystems.msai.al(pointed at the booth IP via hosts/DNS on-site). TLS later: swap:80for the real hostname + uncomment Caddy:443→ auto-HTTPS. WS_ALLOWED_ORIGINS(env) must list any REMOTE origin admins use (same-origin always passes).
Invariants (must hold)
- Never bake the live DB.
.dockerignoreexcludes**/parking.sqlite*(incl.-wal/-shm/.bak-*) —pnpm deploycopies the package dir's files ignoring.gitignore, so the.dockerignore(which gates the build CONTEXT) is what keeps the signed ledger out of the image. The DB is a host-volume asset (append-only-event-chain, threat-model). - SPA serving must not shadow the API — the fallback is GET-only and excludes
/api,/health; a missing/api/*still 404s as JSON, not the HTML shell. - Offline-first — both images boot + serve with no network (vision default
stub;fast_alprweights pre-warmed into the image layer). - Non-root runtime, minimal final image (deploy bundle only; build toolchain dropped).
Verified on hardware (2026-06-22)
Both images built + smoke-tested locally (Docker 29, buildx):
- server: build → run → entrypoint migrates
/data/parking.sqlite, SPA static serving enabled, server listens;/health200,/+/boothserve the SPA (text/html),/api/nope→ JSON 404; noparking.sqlite*anywhere outside/datain the image. - vision (1.8 GB,
--extra alpr): build pre-warms the YOLOv9 + CCT weights into the image (/home/vision/.cache); run asfast_alpr→ready:truewith 0 downloads at boot (offline- first confirmed);stubmode also boots clean. - compose (
docker-compose.yml+.dev.yml): both containers come up healthy and the server reaches the vision service over the private network (wget http://vision:8089/healthfrom the server container → 200).