# shopdb-flask single-site container. # # One image, one site. Per ADR-004, each adopting facility runs its own # stack with its own DB, secrets, and enabled-plugin list. This image # bundles all 13 catalog plugins (computers, employees, geenforce, # knowledgebase, machines, measuringtools, network, notifications, # printedparts, printers, slides, usb, warranty); a site installs + enables # the ones it wants with `flask plugin install ` (or, declaratively, # `flask plugin apply-profile `). Per ADR-013 a future lean # build stages only the chosen plugin directories into this image. # # The frontend is built in a first stage and its dist output is copied into # the final image so Flask can serve the SPA (register_frontend_routes in # shopdb/__init__.py resolves /frontend/dist). # # Build: # docker build -t shopdb-flask . # Run (with .env): # docker run --env-file .env -p 5001:5001 shopdb-flask # # Note: this image does not migrate its own database on start. A plain # `docker run` expects the schema to already be current; run the same sequence # the compose `migrate` service does first: # flask db upgrade && ./scripts/bootstrap-plugins.sh && flask plugin upgrade-all # && flask seed permissions && flask seed settings && flask seed reference-data # The supported path is `docker compose up`, which runs that for you. # ---- Stage 1: build the Vue frontend ---- FROM node:20-slim AS frontendbuild WORKDIR /build # Optionally trust the site's corporate root CA so npm's TLS to the registry # validates behind an inspecting proxy. The .pem is gitignored and absent from a # clean checkout, so the copy is made optional by pairing it with a file that # always exists; the cert is installed only when actually provided. Without it # npm 10.x behind an inspecting proxy fails silently and leaves an incomplete # node_modules; a clean build with no proxy is unaffected. COPY corp-root-ca.pem* frontend/package.json /tmp/corpca/ # node:20-slim ships without the ca-certificates package (no update-ca-certificates # and no /usr/local/share/ca-certificates), so install it before trusting the cert. RUN if [ -f /tmp/corpca/corp-root-ca.pem ]; then \ apt-get update && apt-get install -y --no-install-recommends ca-certificates && \ rm -rf /var/lib/apt/lists/* && \ cp /tmp/corpca/corp-root-ca.pem /usr/local/share/ca-certificates/corp-root-ca.crt && \ update-ca-certificates; \ fi # Copy only the manifests first so `npm ci` caches on dependency changes. COPY frontend/package*.json ./frontend/ # Trust the corp CA for npm's TLS only when it was actually installed above. The # guard keeps NODE_EXTRA_CA_CERTS unset on a clean checkout (no cert), so Node # does not warn about a missing cert path; a proxy-inspected build still gets # the cert because the file is present. # Install the frontend dependencies. vite and the rest of the build toolchain # live under devDependencies, so include them explicitly (--include=dev). # Behind an inspecting proxy (Zscaler) each package fetch is slow (40-50s) and # npm's default concurrency piles up parallel sockets that the proxy resets # mid-stream, making npm abort with "Exit handler never called" and leaving an # incomplete node_modules with no vite. Serializing to a single socket # (maxsockets 1) lets every fetch complete; the retry loop and raised timeouts # ride out any remaining transient reset, and the final test hard-verifies the # toolchain landed so a truncated install fails loudly here rather than as a # confusing "vite: not found" three steps later. RUN if [ -f /usr/local/share/ca-certificates/corp-root-ca.crt ]; then \ export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-root-ca.crt; \ fi; \ cd frontend && \ npm config set fetch-retries 5 && \ npm config set fetch-retry-factor 2 && \ npm config set fetch-retry-mintimeout 20000 && \ npm config set fetch-retry-maxtimeout 120000 && \ npm config set fetch-timeout 600000 && \ npm config set maxsockets 1 && \ ( npm ci --include=dev --no-audit --no-fund \ || { echo "npm ci attempt 1 failed, retrying..."; sleep 5; npm ci --include=dev --no-audit --no-fund; } \ || { echo "npm ci attempt 2 failed, retrying..."; sleep 10; npm ci --include=dev --no-audit --no-fund; } ) && \ test -x node_modules/.bin/vite || { \ echo "ERROR: node_modules/.bin/vite missing after npm ci (proxy likely truncated the install)."; \ exit 1; \ } # The frontend keeps its repo-relative layout here, because npm prebuild runs # `node ../scripts/stage-frontend.mjs`, which stages each plugin's frontend/ into # the Vite tree and codegens routes.gen.js. That script resolves its repo root as # its own directory's parent, so it needs scripts/ and plugins/ as SIBLINGS of # frontend/. Building with frontend/ flattened to the stage root made `../scripts` # resolve to /scripts and the build failed on a missing module. COPY scripts/stage-frontend.mjs ./scripts/ COPY plugins/ ./plugins/ COPY frontend/ ./frontend/ RUN if [ -f /usr/local/share/ca-certificates/corp-root-ca.crt ]; then \ export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-root-ca.crt; \ fi; \ cd frontend && npm run build # Output lands in /build/frontend/dist (Vite default), copied into the final # stage below. # ---- Stage 2: Python application image ---- FROM python:3.14-slim AS base ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 WORKDIR /app RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential \ default-libmysqlclient-dev \ pkg-config \ curl \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt ./ # gunicorn and gevent are container-only: requirements.txt is a universal, # hash-checked lockfile that also serves the Windows/IIS install, where waitress # is the server and neither of these is used. gevent is the worker class (see # the CMD below) because the endpoint long-poll holds a connection per agent, # which a thread pool cannot absorb at fleet scale. RUN pip install --no-cache-dir -r requirements.txt \ && pip install --no-cache-dir gunicorn gevent COPY shopdb/ ./shopdb/ COPY plugins/ ./plugins/ COPY migrations/ ./migrations/ COPY scripts/ ./scripts/ COPY wsgi.py ./ # Make entrypoint and bootstrap scripts executable RUN chmod +x /app/scripts/docker-entrypoint.sh /app/scripts/bootstrap-plugins.sh # The two files under docs/ that the APP SERVES at runtime, not documentation. # shopdb/core/api/docs.py resolves both relative to the repo root, so without # them /api/docs renders the Redoc shell and then 404s fetching its own spec - # which is what every Docker deployment did. Copied by name rather than copying # docs/ wholesale: the rest of that tree is prose and has no business in a lean # runtime image. COPY docs/openapi.json docs/llms.txt ./docs/ # Fail the BUILD on a syntax error rather than the pod at runtime. Docker never # imports the application, so a broken .py sails through every layer and only # surfaces as a crash-looping container: observed, four restarts before anyone # noticed. compileall touches nothing at runtime and costs a couple of seconds. RUN python -m compileall -q shopdb plugins migrations wsgi.py # Built SPA from stage 1. Flask serves it via register_frontend_routes. COPY --from=frontendbuild /build/frontend/dist ./frontend/dist # Create instance/ IN THE IMAGE, owned by the app user, before the chown below. # Docker seeds an empty named volume from the image's content at the mountpoint, # ownership included. Without this the mountpoint is created root-owned 0755 and # the container, which runs as shopdb, cannot write plugins.json or any upload # into its own instance directory. RUN mkdir -p /app/instance RUN useradd --create-home --shell /bin/bash shopdb \ && chown -R shopdb:shopdb /app USER shopdb EXPOSE 5001 # Entrypoint waits for the database, then execs the CMD. Migrations, plugin # bootstrap and seeds are NOT run here - they belong to the `migrate` service # in the compose files so they happen exactly once per deployment rather than # on every container restart. ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"] # Probe the public health route, not the login endpoint. Probing POST # /api/auth/login logged a 400 every 30s and ran the credential path from a # health check. The route answers 200 even when the database is degraded, so the # body's status field is what decides health. Compose files that terminate TLS # override this with an https probe; plain http is correct for the pull and # airgap stacks, which inherit this unchanged. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD curl --silent http://localhost:5001/api/dashboard/health \ | grep -q '"status":"ok"' || exit 1 # Async workers, matching docker-compose.yml - see the reasoning there. A # compose file overrides this CMD, but an image run without one (a plain # `docker run`, a site's own orchestrator) would otherwise still get the # blocking sync default and the same starvation. CMD ["gunicorn", \ "--bind", "0.0.0.0:5001", \ "--worker-class", "gevent", \ "--workers", "4", \ "--worker-connections", "2000", \ "--timeout", "60", \ "--access-logfile", "-", \ "--access-logformat", "%(h)s %(l)s %(u)s %(t)s \"%(r)s\" %(s)s %(b)s %(D)sus \"%(f)s\" \"%(a)s\"", \ "--error-logfile", "-", \ "wsgi:app"]