# polyrouter production image (#22, spec §13): one container serving the SPA, # the dashboard API, and the inference proxy on one port. Multi-stage: the # build stage compiles the workspace and prunes dev deps; the runtime stage # preserves the monorepo layout (/app/packages/...) so the SPA dist lookup and # the bundled Drizzle migrations resolve unchanged. # # Two publishable targets share ONE build stage (add-semantic-dashboard 4.1): # * `runtime` — the BASELINE (default target). musl/alpine, ORT- and # model-free. `docker build .` builds exactly this; the # image-inspection neutrality assertion is its gate. # * `runtime-semantic` — the batteries-included `-semantic` variant, built with # `--target runtime-semantic`. glibc base (ORT prebuilts # are glibc-only), the exact-pinned onnxruntime-node, and # the reference embedding model baked in at BUILD time. # The baseline never sees the semantic stage: it is not a dependency of `runtime`, # so a default build skips it entirely and stays byte-for-byte the previous image. ARG ONNXRUNTIME_NODE_VERSION=1.27.0 # glibc base for the semantic variant — onnxruntime-node's prebuilt binaries do # not run on musl (Alpine); Debian slim keeps it lean while giving glibc. ARG SEMANTIC_BASE_IMAGE=node:24-bookworm-slim # ---- build ---- FROM node:24-alpine AS build WORKDIR /app # add-semantic-embedder: the ORT devDependency (tests only; pruned from the # runtime stage) must never fetch CUDA blobs during image builds. ENV ONNXRUNTIME_NODE_INSTALL=skip # Manifests first: the npm ci layer caches until a lockfile/manifest changes. COPY package.json package-lock.json turbo.json .npmrc ./ COPY packages/shared/package.json packages/shared/ COPY packages/data-plane/package.json packages/data-plane/ COPY packages/control-plane/package.json packages/control-plane/ COPY packages/frontend/package.json packages/frontend/ RUN npm ci COPY . . RUN npm run build && npm prune --omit=dev # ---- runtime-semantic (the -semantic variant; built via --target) ---- # Defined BEFORE the default `runtime` stage so it is never on the baseline's # dependency path. glibc base + the exact-pinned ORT + the pre-baked reference # model, with SEMANTIC_MODEL_PATH preset so L2 is available on first boot. FROM ${SEMANTIC_BASE_IMAGE} AS runtime-semantic ARG ONNXRUNTIME_NODE_VERSION ENV NODE_ENV=production WORKDIR /app COPY --from=build /app/package.json ./package.json COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/packages/shared/package.json packages/shared/package.json COPY --from=build /app/packages/shared/dist packages/shared/dist COPY --from=build /app/packages/data-plane/package.json packages/data-plane/package.json COPY --from=build /app/packages/data-plane/dist packages/data-plane/dist COPY --from=build /app/packages/control-plane/package.json packages/control-plane/package.json COPY --from=build /app/packages/control-plane/dist packages/control-plane/dist COPY --from=build /app/packages/frontend/package.json packages/frontend/package.json COPY --from=build /app/packages/frontend/dist packages/frontend/dist # Install the exact-pinned optional peer (CUDA postinstall disabled — CPU # prebuilts ship in the tarball) and bake the reference model, both at BUILD # time. curl is fetched only for the bake and purged; no runtime network reach. COPY scripts/bake-semantic-model.sh ./scripts/bake-semantic-model.sh RUN apt-get update \ && apt-get install -y --no-install-recommends curl ca-certificates \ && export ONNXRUNTIME_NODE_INSTALL=skip \ && npm install --no-save --omit=dev "onnxruntime-node@${ONNXRUNTIME_NODE_VERSION}" \ && sh scripts/bake-semantic-model.sh /app/models/reference \ && rm scripts/bake-semantic-model.sh \ && apt-get purge -y --auto-remove curl \ && rm -rf /var/lib/apt/lists/* \ && chown -R node:node /app/models # The baked bundle is the default; an operator-mounted path simply overrides it. ENV SEMANTIC_MODEL_PATH=/app/models/reference USER node EXPOSE 3001 # ONE probe contract for BOTH variants (fix-image-healthcheck-and-l2-hint): # exec-form Node probe (no shell layer, no wget/curl dependency), /api/health on # the PORT the server binds. This literal must stay BYTE-IDENTICAL to the # baseline stage's, and the 3001 fallback is paired with the registered PORT # default. /api/health is the REAL health route — the SPA fallback answers bare # /health with 200 HTML, which must never satisfy a probe. HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=5 \ CMD ["node", "-e", "const p=process.env.PORT||3001;require('http').get('http://127.0.0.1:'+p+'/api/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"] CMD ["node", "packages/control-plane/dist/main.js"] # ---- runtime (BASELINE — the default target; keep last & unchanged) ---- FROM node:24-alpine AS runtime ENV NODE_ENV=production WORKDIR /app COPY --from=build /app/package.json ./package.json COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/packages/shared/package.json packages/shared/package.json COPY --from=build /app/packages/shared/dist packages/shared/dist COPY --from=build /app/packages/data-plane/package.json packages/data-plane/package.json COPY --from=build /app/packages/data-plane/dist packages/data-plane/dist COPY --from=build /app/packages/control-plane/package.json packages/control-plane/package.json COPY --from=build /app/packages/control-plane/dist packages/control-plane/dist COPY --from=build /app/packages/frontend/package.json packages/frontend/package.json COPY --from=build /app/packages/frontend/dist packages/frontend/dist USER node EXPOSE 3001 # ONE probe contract for BOTH variants (fix-image-healthcheck-and-l2-hint): # exec-form Node probe (no shell layer, no wget/curl dependency), /api/health on # the PORT the server binds. This literal must stay BYTE-IDENTICAL to the # semantic stage's, and the 3001 fallback is paired with the registered PORT # default. /api/health is the REAL health route — the SPA fallback answers bare # /health with 200 HTML, which must never satisfy a probe. HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=5 \ CMD ["node", "-e", "const p=process.env.PORT||3001;require('http').get('http://127.0.0.1:'+p+'/api/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"] # Exec form: Node is PID 1 and receives SIGTERM directly, so Nest's shutdown # hooks run — the #12 stream drain, #11 writer flush, and #21 span flush. CMD ["node", "packages/control-plane/dist/main.js"]