8155ff456b
Containerize the two non-desktop apps for the booth appliance. The desktop app stays on its own tag-only release.yml. - apps/server/Dockerfile: multi-stage node:22-alpine. `pnpm deploy --legacy --prod` (NOT prune — the monorepo native better-sqlite3 won't resolve under a root prune) yields a self-contained bundle; build stage adds node-gyp toolchain, runtime adds libstdc++; non-root, healthcheck. Migrates the mounted DB on boot via a drizzle-kit- free runtime migrator (packages/db/scripts/migrate-runtime.mjs) — drizzle-kit is a devDep, pruned from prod. - apps/server/src/static-spa.ts: Fastify serves the built React SPA (one container serves API + UI). GET-only fallback to index.html, excludes /api + /health so it never shadows the backend; a no-op in dev (no dist). Registered last in server.ts. - apps/vision/Dockerfile: uv base, --extra alpr, model weights PRE-WARMED into the image as the runtime user so fast_alpr boots offline (0 downloads at runtime). Engine env- selected (VISION_RECOGNIZER stub|fast_alpr). - Branch-aware: docker-compose.yml (base) + .dev.yml (build local, stub, ports) + .prod.yml (pull pinned, fast_alpr, vision internal, restart always); REGISTRY/TAG from env so a branch deploy pulls that branch's image. - .gitea/workflows/build-images.yml: on push to dev/main, run the full turbo build+lint+ test gate, then buildx push both images to git.infra.msai.al/mca/parking_solution with branch + branch-<sha> tags (registry cache; optional Komodo webhook behind KOMODO_ENABLED). - .dockerignore excludes **/parking.sqlite* so the signed ledger is NEVER baked. Verified locally (Docker 29): server image migrates + serves API+SPA (/health 200, / + /booth HTML, /api/nope JSON 404, no sqlite outside /data); vision image boots fast_alpr with 0 runtime downloads; compose stack healthy with server→vision over the private network. Wiki: new container-deployment.md; vision-service-packaging open Qs resolved; index + log. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
6.4 KiB
6.4 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | |||||
|---|---|---|---|---|---|---|---|---|---|
| decision |
|
2026-06-22 | 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).
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>;mainbuilds:main+:main-<sha>. The moving branch tag is the deploy pointer; the branch-SHA tag is the immutable record. Same forparking-vision. - 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.
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. - 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".
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).