Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,15 @@ updates:
groups:
github-actions:
patterns: ["*"]

- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Australia/Perth"
open-pull-requests-limit: 2
groups:
docker-images:
patterns: ["*"]
84 changes: 64 additions & 20 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ permissions:
contents: read

jobs:
app-image:
build-and-verify:
runs-on: ubuntu-24.04
timeout-minutes: 30
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -42,38 +42,82 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4

- name: Build app image (no push)
- name: Build app image (load, no push)
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: Dockerfile
tags: clinical-kb-app:ci
push: false
# The publishable key is public-by-design and only inlines into the
# client bundle, so the CI placeholder is enough to validate the build.
# Real production images are built with the real key at deploy time.
load: true
build-args: |
NEXT_PUBLIC_SUPABASE_URL=https://sjrfecxgysukkwxsowpy.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=placeholder-ci-publishable-key
# Exercise a matched lowered limit, not only the 150 MB defaults.
MAX_UPLOAD_MB=50
NEXT_PUBLIC_MAX_UPLOAD_MB=50
ALLOW_LOW_RAM_BUILD=1
cache-from: type=gha,scope=clinical-kb-app
cache-to: type=gha,scope=clinical-kb-app,mode=max

worker-image:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4

- name: Build worker image (no push)
- name: Build worker image (load, no push)
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: Dockerfile.worker
tags: clinical-kb-worker:ci
push: false
load: true
cache-from: type=gha,scope=clinical-kb-worker
cache-to: type=gha,scope=clinical-kb-worker,mode=max

- name: Verify image content contract
run: node scripts/check-image-content-contract.mjs

- name: Run app container smoke (provider-free, cap-drop)
run: node scripts/app-container-smoke.mjs

- name: Run worker runtime validation (provider-free, network-isolated)
run: docker run --rm --network=none --entrypoint node clinical-kb-worker:ci dist/worker/validate-runtime.mjs

- name: Read-only runtime check (advisory)
run: |
docker run --rm --read-only --cap-drop=ALL --security-opt no-new-privileges:true \
-e NODE_ENV=production \
-e NEXT_PUBLIC_DEMO_MODE=false \
-e NEXT_PUBLIC_SUPABASE_URL=https://sjrfecxgysukkwxsowpy.supabase.co \
-e SUPABASE_SERVICE_ROLE_KEY=smoke-test-placeholder \
-e RAG_QUERY_HASH_SECRET=smoke-test-hash-secret \
-e RAG_PROVIDER_MODE=offline \
-e NEXT_TELEMETRY_DISABLED=1 \
clinical-kb-app:ci \
node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>r.json()).then(j=>{if(j.status!=='ok')throw new Error(JSON.stringify(j));console.log(j)}).catch(e=>{console.error(e);process.exit(1)})" || true
continue-on-error: true

- name: Generate SBOMs
# Advisory: docker-save of both loaded images can ENOSPC on small runners.
# Free BuildKit cache first; do not fail the required gate on scanner disk pressure.
continue-on-error: true
run: |
docker builder prune -af || true
df -h || true
node scripts/trivy-image-scan.mjs clinical-kb-app:ci --sbom sbom-app.cdx.json
node scripts/trivy-image-scan.mjs clinical-kb-worker:ci --sbom sbom-worker.cdx.json

- name: Upload SBOMs
if: always()
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: sboms
path: |
sbom-app.cdx.json
sbom-worker.cdx.json
if-no-files-found: warn

- name: Vulnerability scan (HIGH,CRITICAL)
# Non-blocking by design — findings are reported, not merge-gating.
continue-on-error: true
run: |
docker builder prune -af || true
node scripts/trivy-image-scan.mjs clinical-kb-app:ci
node scripts/trivy-image-scan.mjs clinical-kb-worker:ci
21 changes: 15 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
# NEVER baked into the image — inject them at run time from the host's
# secret store.

FROM node:24-bookworm-slim AS deps
FROM node:24-bookworm-slim@sha256:235600a8101ab264e117b1768e925532262668dc9b581ef1dd7d96ced463b8e7 AS node-base

FROM node-base AS deps
WORKDIR /app
# check-node-engine.cjs runs as the npm preinstall hook and
# install-git-hooks.mjs as the postinstall hook, so both must be in place
Expand All @@ -28,13 +30,14 @@ COPY scripts/check-node-engine.cjs scripts/check-node-engine.cjs
COPY scripts/install-git-hooks.mjs scripts/install-git-hooks.mjs
# Registry blips (ECONNRESET) have failed CI app-image builds mid-install; retry
# the whole `npm ci` rather than relying only on per-request fetch retries.
RUN for attempt in 1 2 3; do \
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
for attempt in 1 2 3; do \
npm ci --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 && break; \
if [ "$attempt" -eq 3 ]; then exit 1; fi; \
sleep $((attempt * 10)); \
done

FROM node:24-bookworm-slim AS build
FROM node-base AS build
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
Expand All @@ -60,18 +63,19 @@ ENV ALLOW_LOW_RAM_BUILD=${ALLOW_LOW_RAM_BUILD}
# build argument for a runtime value.
RUN UPLOAD_LIMIT_PARITY_SERVER_MB="${MAX_UPLOAD_MB}" env -u MAX_UPLOAD_MB npm run build

FROM node:24-bookworm-slim AS prod-deps
FROM node-base AS prod-deps
WORKDIR /app
COPY package.json package-lock.json .npmrc ./
COPY scripts/check-node-engine.cjs scripts/check-node-engine.cjs
COPY scripts/install-git-hooks.mjs scripts/install-git-hooks.mjs
RUN for attempt in 1 2 3; do \
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
for attempt in 1 2 3; do \
npm ci --omit=dev --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 && break; \
if [ "$attempt" -eq 3 ]; then exit 1; fi; \
sleep $((attempt * 10)); \
done

FROM node:24-bookworm-slim AS runner
FROM node-base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
Expand All @@ -84,6 +88,11 @@ COPY --from=build /app/src/lib/supabase/project.ts ./src/lib/supabase/project.ts
COPY package.json next.config.ts ./
USER node
EXPOSE 3000
LABEL org.opencontainers.image.source="https://github.com/BigSimmo/Database"
LABEL org.opencontainers.image.title="Clinical KB app tier"
LABEL org.opencontainers.image.description="Next.js 16 app tier for the Clinical KB medical guideline RAG knowledge base"
LABEL org.opencontainers.image.licenses="UNLICENSED"
STOPSIGNAL SIGTERM
# /api/health is the app's own ops health route.
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
Expand Down
28 changes: 21 additions & 7 deletions Dockerfile.worker
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@
# `server-only` marker to the standalone stub at build time (the job
# run-tsx.mjs previously did at runtime) and keeps npm packages external,
# so the bundle resolves them from the runner's production node_modules.
FROM node:24-bookworm-slim AS build
FROM node:24-bookworm-slim@sha256:235600a8101ab264e117b1768e925532262668dc9b581ef1dd7d96ced463b8e7 AS node-base

FROM node-base AS build
WORKDIR /app
COPY package.json package-lock.json .npmrc ./
COPY scripts/check-node-engine.cjs scripts/check-node-engine.cjs
COPY scripts/install-git-hooks.mjs scripts/install-git-hooks.mjs
# Same install-retry contract as the app Dockerfile (registry ECONNRESET flakes).
RUN for attempt in 1 2 3; do \
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
for attempt in 1 2 3; do \
npm ci --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 && break; \
if [ "$attempt" -eq 3 ]; then exit 1; fi; \
sleep $((attempt * 10)); \
Expand All @@ -35,25 +38,28 @@ RUN node scripts/build-worker.mjs

# Production-only node_modules for the runtime stage. The preinstall/
# postinstall lifecycle scripts must be present in every npm-ci stage.
FROM node:24-bookworm-slim AS prod-deps
FROM node-base AS prod-deps
WORKDIR /app
COPY package.json package-lock.json .npmrc ./
COPY scripts/check-node-engine.cjs scripts/check-node-engine.cjs
COPY scripts/install-git-hooks.mjs scripts/install-git-hooks.mjs
RUN for attempt in 1 2 3; do \
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
for attempt in 1 2 3; do \
npm ci --omit=dev --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 && break; \
if [ "$attempt" -eq 3 ]; then exit 1; fi; \
sleep $((attempt * 10)); \
done

FROM node:24-bookworm-slim AS runner
FROM node-base AS runner
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-venv tesseract-ocr \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY worker/python/requirements.txt worker/python/requirements.txt
RUN python3 -m venv /opt/ocr-venv \
&& /opt/ocr-venv/bin/pip install --no-cache-dir -r worker/python/requirements.txt
RUN python3 -m venv /opt/ocr-venv
RUN --mount=type=cache,target=/root/.cache/pip \
/opt/ocr-venv/bin/pip install --upgrade --require-hashes -r worker/python/requirements.txt \
&& /opt/ocr-venv/bin/pip check
ENV PATH="/opt/ocr-venv/bin:${PATH}"
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
Expand All @@ -70,6 +76,14 @@ RUN python -m compileall -q worker/python \
&& rm -f worker/python/test_*.py
COPY package.json ./package.json
USER node
# The runtime validator proves the bundle, Node, Python and module resolution
# before the image is promoted. It never calls Supabase/OpenAI or claims jobs.
RUN node dist/worker/validate-runtime.mjs
LABEL org.opencontainers.image.source="https://github.com/BigSimmo/Database"
LABEL org.opencontainers.image.title="Clinical KB ingestion worker"
LABEL org.opencontainers.image.description="Node + Python OCR ingestion worker for the Clinical KB medical guideline RAG knowledge base"
LABEL org.opencontainers.image.licenses="UNLICENSED"
STOPSIGNAL SIGTERM
# Long-poll worker; WORKER_* env vars control claim batch size, concurrency,
# and the stale-claim window (see src/lib/env.ts).
#
Expand Down
22 changes: 15 additions & 7 deletions docs/deployment-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,10 @@ comparable (~200 ms) from Singapore or Sydney and does not favour either host.

### Image contract (`Dockerfile`)

- `node:24-bookworm-slim` in all stages — respects `engines`/`engine-strict`
and the `preinstall` engine guard.
- `node:24-bookworm-slim` is pinned by multi-platform SHA-256 digest in a
shared `node-base` stage and used by every stage. BuildKit cache mounts
speed `npm ci` and the worker Python venv install without bloating final
images.
- The build stage runs the repo's own `npm run build`
(`guard-next-build.mjs` + `next build --webpack` + the client-bundle secret
scan) — **the image build fails exactly where a local build would**. The
Expand Down Expand Up @@ -235,8 +237,9 @@ comparable (~200 ms) from Singapore or Sydney and does not favour either host.
differ.
- Runtime is a non-root `node` user, prod-only `node_modules`, direct
`next start -H 0.0.0.0 -p $PORT` (Railway injects `$PORT`; the local
port-picker script is deliberately bypassed), and a `HEALTHCHECK` against
`/api/health`.
port-picker script is deliberately bypassed), a `HEALTHCHECK` against
`/api/health`, an explicit `STOPSIGNAL SIGTERM`, and OCI source/title
labels for supply-chain traceability.
- No secret is ever baked into a layer. `SUPABASE_SERVICE_ROLE_KEY`,
`OPENAI_API_KEY`, etc. are injected at run time by Railway's variable store.
- Request bodies are bounded twice: Next Proxy buffers at most 151 MiB, and
Expand Down Expand Up @@ -279,9 +282,10 @@ check and watch patterns rather than relying on dashboard defaults.

### Decision: containerized worker (recommended) over completing the edge-agent migration

Ship the existing worker as a container (`Dockerfile.worker`: Node 24 + a
prebuilt esbuild bundle over production-only `node_modules` +
Tesseract + a Python venv with `worker/python/requirements.txt`) and run **one
Ship the existing worker as a container (`Dockerfile.worker`: pinned Node 24 +
a prebuilt esbuild bundle over production-only `node_modules` +
Tesseract + a Python venv with a hashed `worker/python/requirements.txt` +
a provider-free `dist/worker/validate-runtime.mjs` gate) and run **one
always-on worker instance** co-located in Railway Singapore (`worker` service),
using Railway's `ALWAYS` restart policy so repeated bootstrap failures cannot
exhaust a finite retry allowance and leave the queue undrained.
Expand Down Expand Up @@ -355,6 +359,10 @@ Operational rules that follow:
generation/chunk-key, and completion is gated by the strict completion RPCs
plus the edge agent. Railway's always-restart policy brings the worker back and
it reclaims stale jobs automatically.
- The worker handles `SIGTERM`/`SIGINT` gracefully: it drains the
already-claimed active batch, stops claiming new jobs, and exits `0`. A
`STOPSIGNAL SIGTERM` directive is in both final images. Fatal runtime errors
still exit `1` and dispatch the existing failure webhook.
- **Backlog improvement (not in this change):** a heartbeat that refreshes
`locked_at` could ride the existing throttled progress updates
(`WORKER_PROGRESS_UPDATE_MIN_INTERVAL_MS`, 60 s), which would let the stale
Expand Down
Loading
Loading