Files
parking_solution/wiki/decisions/vision-service-packaging.md
T
julian 8155ff456b
CI / check (push) Successful in 35s
Build & push images / images (push) Failing after 17s
feat(deploy): Docker images for server (API+SPA) and vision + branch-aware build pipeline
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
2026-06-23 15:07:52 +02:00

7.1 KiB

type, tags, sources, updated, status
type tags sources updated status
decision
parking
decisions
vision
anpr
monorepo
packaging
2026-06-19 settled

Decision: the vision service lives in this monorepo (apps/vision/), wired into Turbo via a shim

Taken 2026-06-19, when planning how to implement the host-side [[opencv-anpr-service|vision service]] decided in vision-service. That decision settled WHAT (a separate localhost Python process) and the recognizer baseline (opencv-anpr-service); this one settles WHERE the source lives and how it joins the build.

Decision

  1. In THIS monorepo, at apps/vision/ — a Python/FastAPI service co-located with the Node backend, not a separate repository. One git history, atomic cross-cutting commits (the /analyze contract + the Node-side adapter change together), one wiki.
  2. Still a separate OS process — co-location is source-level only. It runs as its own process (uvicorn), called over localhost HTTP by the Node backend, with its own failure domain. Nothing about putting it in apps/vision/ weakens the runtime isolation vision-service requires.
  3. Wired into the Turbo task graph via a thin package.json shim. pnpm-workspace.yaml already globs apps/*, so an apps/vision/package.json auto-joins the workspace. Its scripts shell out to Python tooling, so the existing turbo run tasks cover it:
    • dev → uv run uvicorn app:app --reload (matches turbo.json dev: persistent, uncached)
    • lint → ruff check · test → pytest · typecheck → ruff/mypy
    • build → no-op or model-fetch (Python has no dist/**; the build task's outputs: ["dist/**"] simply won't match — fine). If models are fetched/cached at build, point outputs at the model dir. Python dependencies stay managed by uv + pyproject.toml (NOT pnpm) — the shim only exposes tasks, not deps.
  4. Node talks to it through an interface (VisionClient behind a port, the device-adapter-pattern style) so the recognizer/service is swappable without touching business logic — as opencv-anpr-service already specifies.

Why co-located beats a separate repo

  • Atomic changes. The service contract (POST /analyze shape) and its Node consumer evolve together; one repo = one PR, no two-repo version skew.
  • uv makes Python-in-monorepo painless — fast, lockfile-based, offline-friendly (fits offline-first); the appliance build pulls a pinned env.
  • Turbo still orchestrates it. The shim makes turbo run lint/test include the Python service as a first-class node — one command lints front, back, AND vision — even though Turbo can't build Python. Turbo orchestrates tasks, and a task can be a Python command.
  • One knowledge base. The wiki + CLAUDE.md already describe the whole system; a split repo fragments that.

Why this still honors the isolation decision

The "vision-service" decision is about runtime isolation (own process + failure domain) and license isolation (AGPL obligations don't reach the Node/React code because it is not linked — it's a separate program over HTTP). Neither depends on a separate repository. AGPL's reach is a linking/distribution-boundary question between programs, not a which-folder question. A Python service in apps/vision/ that Node calls over localhost is exactly as isolated, license-wise, as one in its own repo.

  • With the opencv-anpr-service MIT-end-to-end baseline, the AGPL pressure to split the repo out largely evaporates (pending the weight-provenance caveat). Co-location is the low-friction default.
  • If a true-AGPL model (Ultralytics YOLO) is later adopted, its weights live under apps/vision/ — still fine (separate process), and that dir is the natural place to document the license boundary + the [[standing-decisions|scoped exception]].

Rejected

  • Separate repo — strongest separation, but loses atomic contract changes and adds coordination overhead; justified only if a different team owns it or the AGPL concern becomes acute. Kept as the fallback if either happens.
  • Embed Python in the Node process (opencv4nodejs / a child-process module) — already rejected by vision-service (native-build pain, no process isolation, shares the app's failure + license surface). Unchanged.
  • A Python package under packages/ — packages/ is for shared JS libraries imported by other workspaces; the vision service is a deployable app, so apps/vision/ is the right bucket.

As-scaffolded (2026-06-19)

The skeleton is built and wired (no recognizer models yet):

  • apps/vision/ — pyproject.toml (+ uv.lock, uv-managed), the thin package.json shim, a per-package turbo.json (extends: ["//"], build outputs [] so the no-op build is warning- free), .gitignore (venv/caches/*.onnx/models/ out), README.
  • vision_service/: app.py (FastAPI GET /health + POST /analyze, raw octet-stream body so Node POSTs Snapshot.bytes directly; oversize→413, empty→400, recognizer-not-ready→503), settings.py (env VISION_*), schemas.py (the /analyze contract incl. a not-yet-populated vehicle field for Job 2), recognizer.py (a Recognizer Protocol + StubRecognizer and FastAlprRecognizer — the device-adapter-pattern applied to the model).
  • Light-core, heavy-optional: core deps boot in stub mode (no model download) so uv sync + tests work offline; the real stack is the alpr extra (uv sync --extra alpr → fast-alpr + onnxruntime). VISION_RECOGNIZER=fast_alpr switches it on.
  • Verified: turbo run lint|test|build includes @parking/vision (ruff/pytest/no-op via the shim) and stays green; uv run mypy strict-clean; uvicorn boots and serves /health (ready, stub-0) + /analyze (contract shape) live. pnpm workspace count 6→7.

Still to build (next, when vision work proceeds)

  • The Node-side VisionClient adapter (localhost HTTP) + per-camera opt-in wiring (the open item in opencv-anpr-service).
  • A Dockerfile/process unit for the appliance (its own image/process); model-weight fetch at deploy (the alpr extra), kept out of git (opencv-anpr-service check first).
  • Job 2 (vehicle attributes / fingerprint) — the vehicle field is scaffolded but unpopulated; fast-alpr is plate-only. Built later on the same ONNX runtime.

Open

  • uv vs. pip-tools/poetry for the Python env (leaning uv — speed + lockfile + offline).
  • Whether build should fetch/cache model weights (and set Turbo outputs to the model dir) or keep weights out of the build entirely (baked into the Docker image instead).
  • Container/runtime supervision on the appliance (systemd unit vs. compose) — deployment detail, defer to the install/hardening pass.

Resolved 2026-06-22 → container-deployment: the vision service now ships as the parking-vision Docker image (uv base, --extra alpr), model weights pre-warmed into the image layer at build (offline-first), and runs under docker-compose (base + per-env override).