From 08d5c118d32a323eff0354206213131ed8fa82cd Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 20:47:12 +0200 Subject: [PATCH] Become a nox mesh module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mesh builds all three images from this repository and a commit (novox/hq ADR 0069) — the Drone pipeline that built outside Docker and copied its output in retires with the predecessor. One module carries the API and every client instance: eef and filip are the same built client under two names, which is a second route contribution, not a second module (ADR 0015 — an application is one repository). The clients drop nginx and its bash startup script: the node the API already needs serves the static build too (client-server/serve.mjs — files, the SPA fallback, env-config.js from the environment), so the whole module stands on one declared base. What the machine serves does not move: the same four public names, the same four machine ports. MongoDB, the bucket and every credential arrive as mesh grants instead of hand-set environment; SUPER_ADMIN_KEY stops being a default written in code and becomes a minted secret. --- admin-client/Dockerfile | 43 +++++----- client-server/serve.mjs | 80 ++++++++++++++++++ client/Dockerfile | 49 +++++------ module.json | 181 ++++++++++++++++++++++++++++++++++++++++ server/Dockerfile | 33 ++++++-- 5 files changed, 330 insertions(+), 56 deletions(-) create mode 100644 client-server/serve.mjs create mode 100644 module.json diff --git a/admin-client/Dockerfile b/admin-client/Dockerfile index d139a5b..0762776 100644 --- a/admin-client/Dockerfile +++ b/admin-client/Dockerfile @@ -1,26 +1,21 @@ -FROM nginx:1.15.2-alpine +# The photos admin client, built by the mesh from this repository (novox/hq ADR 0069). The same +# shape as client/Dockerfile — see the notes there; only the source directory differs. +# +# The build context is the repository root; every COPY says so. +ARG NODE_BASE -# Nginx config -RUN rm -rf /etc/nginx/conf.d -COPY ./conf /etc/nginx +FROM ${NODE_BASE} AS build +WORKDIR /build +COPY admin-client/package.json admin-client/package-lock.json ./ +RUN npm ci +COPY admin-client/public ./public +COPY admin-client/src ./src +COPY admin-client/.env ./.env +RUN npm run build -# Static build -COPY ./build /usr/share/nginx/html/ - -# Default port exposure -EXPOSE 80 - -# Copy .env file and shell script to container -WORKDIR /usr/share/nginx/html - -COPY scripts/env.sh . -COPY .env . - -# Add bash -RUN apk add --no-cache bash - -# Make our shell script executable -RUN chmod +x env.sh - -# Start Nginx server -CMD ["/bin/bash", "-c", "/usr/share/nginx/html/env.sh && nginx -g \"daemon off;\""] \ No newline at end of file +FROM ${NODE_BASE} +ENV NODE_ENV=production +COPY client-server/serve.mjs /serve.mjs +COPY --from=build /build/build /site/ +COPY admin-client/.env /site/.env +CMD ["node", "/serve.mjs"] diff --git a/client-server/serve.mjs b/client-server/serve.mjs new file mode 100644 index 0000000..1a8b07d --- /dev/null +++ b/client-server/serve.mjs @@ -0,0 +1,80 @@ +// The client sites' server: static files, an SPA fallback, and env-config.js from the +// environment. Node built-ins only — the runtime base already carries node for the API, so a +// second web server (and the shell its startup script needed) would be two more moving parts to +// serve files the first one serves fine. +// +// What the nginx arrangement this replaces did, it does: every unknown path without an extension +// falls back to index.html (client-side routing), nothing is cached (the site is tiny and a stale +// index.html after a deploy is the only caching bug this app has ever had), and env-config.js is +// regenerated from the container's environment at start — which is how one built image serves +// under any API URL. +import { createServer } from "node:http"; +import { readFileSync, writeFileSync, createReadStream, statSync } from "node:fs"; +import { join, resolve, extname } from "node:path"; + +const root = resolve(process.env.HTML_ROOT ?? "/site"); +const port = Number(process.env.PORT ?? 80); + +// .env names the variables the site reads; the environment overrides their values. Written once +// at start, exactly as the env.sh this replaces did. +const pairs = []; +for (const line of readFileSync(join(root, ".env"), "utf8").split("\n")) { + const at = line.indexOf("="); + if (at < 1) continue; + const name = line.slice(0, at).trim(); + pairs.push(` ${name}: ${JSON.stringify(process.env[name] ?? line.slice(at + 1).trim())},`); +} +writeFileSync(join(root, "env-config.js"), `window._env_ = {\n${pairs.join("\n")}\n}\n`); + +const types = { + ".html": "text/html; charset=utf-8", + ".js": "application/javascript", + ".mjs": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".map": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".txt": "text/plain; charset=utf-8", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", +}; + +createServer((request, response) => { + const asked = decodeURIComponent(new URL(request.url, "http://x").pathname); + // resolve() collapses any ".." before the prefix check, so a path cannot escape the root. + let path = resolve(join(root, asked)); + if (!path.startsWith(root)) { + response.writeHead(403).end(); + return; + } + let served; + try { + served = statSync(path); + if (served.isDirectory()) { + path = join(path, "index.html"); + served = statSync(path); + } + } catch { + // Not a file: a client-side route, answered by the app itself — unless it asked for a file + // by extension, where index.html would be a wrong answer dressed as a right one. + if (extname(asked) !== "") { + response.writeHead(404, { "Content-Type": "text/plain" }).end("not found\n"); + return; + } + path = join(root, "index.html"); + served = statSync(path); + } + response.writeHead(200, { + "Content-Type": types[extname(path)] ?? "application/octet-stream", + "Content-Length": served.size, + "Cache-Control": "no-cache", + }); + createReadStream(path).pipe(response); +}).listen(port, () => console.log(`serving ${root} on :${port}`)); diff --git a/client/Dockerfile b/client/Dockerfile index d139a5b..0364bb1 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -1,26 +1,27 @@ -FROM nginx:1.15.2-alpine +# The photos client, built by the mesh from this repository (novox/hq ADR 0069): the react build +# happens here rather than in a CI step that copied its output in. +# +# One base, named rather than pinned (novox/hq issue 044), declared in module.json's `build.on`: +# the site is compiled and served by the same node the API already needs. Serving is +# client-server/serve.mjs — static files, the SPA fallback, and env-config.js regenerated from the +# container's environment at start, which is how one built image serves under any API URL. The +# nginx-and-bash arrangement this replaces did the same with two more moving parts. +# +# The build context is the repository root; every COPY says so. +ARG NODE_BASE -# Nginx config -RUN rm -rf /etc/nginx/conf.d -COPY ./conf /etc/nginx +FROM ${NODE_BASE} AS build +WORKDIR /build +COPY client/package.json client/package-lock.json ./ +RUN npm ci +COPY client/public ./public +COPY client/src ./src +COPY client/.env ./.env +RUN npm run build -# Static build -COPY ./build /usr/share/nginx/html/ - -# Default port exposure -EXPOSE 80 - -# Copy .env file and shell script to container -WORKDIR /usr/share/nginx/html - -COPY scripts/env.sh . -COPY .env . - -# Add bash -RUN apk add --no-cache bash - -# Make our shell script executable -RUN chmod +x env.sh - -# Start Nginx server -CMD ["/bin/bash", "-c", "/usr/share/nginx/html/env.sh && nginx -g \"daemon off;\""] \ No newline at end of file +FROM ${NODE_BASE} +ENV NODE_ENV=production +COPY client-server/serve.mjs /serve.mjs +COPY --from=build /build/build /site/ +COPY client/.env /site/.env +CMD ["node", "/serve.mjs"] diff --git a/module.json b/module.json new file mode 100644 index 0000000..e5a6442 --- /dev/null +++ b/module.json @@ -0,0 +1,181 @@ +{ + "module": "photos", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "s3-bucket", + "mongodb-database", + "route" + ], + "contributes": { + "s3-bucket": { + "bucket": "photos" + }, + "mongodb-database": { + "name": "photos" + }, + "route": { + "api": { + "label": "photos-api", + "port": 9102 + }, + "admin": { + "label": "photos", + "port": 8102 + }, + "eef": { + "label": "eef", + "port": 8104 + }, + "filip": { + "label": "filip", + "port": 8103 + } + } + }, + "binds": { + "s3-bucket": "/var/lib/photos/store.json", + "mongodb-database": "/var/lib/photos/database.json", + "route": "/var/lib/photos/route.json" + }, + "secrets": { + "s3-bucket": "/var/lib/photos/store.secret", + "mongodb-database": "/var/lib/photos/database.secret" + }, + "own-secrets": { + "admin-key": "/var/lib/photos/admin-key.secret" + }, + "listens": [ + { + "port": 9102, + "protocol": "tcp", + "from": "mesh", + "why": "the photos API over http; photos-api.novox.be is a route grant, and the proxy reaches it here. The machine side of the 9102:9000 mapping, named because three of this module's containers share the container-side port 80 and only the machine side tells them apart" + }, + { + "port": 8102, + "protocol": "tcp", + "from": "mesh", + "why": "the admin client site over http; the public name photos.novox.be is a route grant, and the proxy reaches it here" + }, + { + "port": 8103, + "protocol": "tcp", + "from": "mesh", + "why": "the filip client site over http; the public name filip.novox.be is a route grant, and the proxy reaches it here" + }, + { + "port": 8104, + "protocol": "tcp", + "from": "mesh", + "why": "the eef client site over http; the public name eef.novox.be is a route grant, and the proxy reaches it here" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "path": "/var/lib/photos", + "mode": "0700" + }, + { + "id": "server-env", + "type": "file", + "path": "/var/lib/photos/server.env", + "mode": "0600", + "content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=admin\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_BUCKET=photos\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\nMINIO_USE_SSL=false\nSUPER_ADMIN_KEY=${secret:admin-key}\n" + }, + { + "id": "client-env", + "type": "file", + "path": "/var/lib/photos/client.env", + "mode": "0644", + "content": "REACT_APP_API_URL=https://photos-api.novox.be\n" + }, + { + "id": "net", + "type": "network", + "name": "photos" + }, + { + "id": "server", + "type": "container", + "name": "photos-server", + "artifact": "server", + "network": "photos", + "env-file": [ + "/var/lib/photos/server.env" + ], + "ports": [ + "9102:9000" + ], + "secrets-in-environment": "the application's own code reads MONGO_URL, MINIO_SECRET and SUPER_ADMIN_KEY from the environment (server/src/config.js); converting is this repository's change, tracked but not blocking the mesh conversion" + }, + { + "id": "admin", + "type": "container", + "name": "photos-admin", + "artifact": "admin-client", + "network": "photos", + "env-file": [ + "/var/lib/photos/client.env" + ], + "ports": [ + "8102:80" + ] + }, + { + "id": "eef", + "type": "container", + "name": "photos-eef", + "artifact": "client", + "network": "photos", + "env-file": [ + "/var/lib/photos/client.env" + ], + "ports": [ + "8104:80" + ] + }, + { + "id": "filip", + "type": "container", + "name": "photos-filip", + "artifact": "client", + "network": "photos", + "env-file": [ + "/var/lib/photos/client.env" + ], + "ports": [ + "8103:80" + ] + } + ], + "build": { + "on": [ + { + "arg": "NODE_BASE", + "image": "node@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9" + } + ], + "artifacts": [ + { + "name": "server", + "kind": "image", + "from": "server/Dockerfile" + }, + { + "name": "client", + "kind": "image", + "from": "client/Dockerfile" + }, + { + "name": "admin-client", + "kind": "image", + "from": "admin-client/Dockerfile" + } + ] + } +} diff --git a/server/Dockerfile b/server/Dockerfile index 49dfb10..cc6a3ec 100755 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,10 +1,27 @@ -FROM node:21.2.0 +# The photos API, built by the mesh from this repository (novox/hq ADR 0069): the build happens +# here rather than in a CI step that copied its output in — an image the mesh can rebuild from a +# commit is one whose contents that commit fully determines. +# +# The base is named rather than pinned (novox/hq issue 044): declared in module.json's `build.on`, +# so the copy the mesh holds answers it. One base for both stages — the runtime stage installs +# production dependencies itself (sharp is a native module rollup cannot bundle), so it needs npm +# exactly as the build stage does. +# +# The build context is the repository root; every COPY says so. +ARG NODE_BASE -# Create app directory -WORKDIR /usr/src/photos-server.novox.be +FROM ${NODE_BASE} AS build +WORKDIR /build +COPY server/package.json server/package-lock.json ./ +RUN npm ci +COPY server/rollup.config.mjs ./ +COPY server/src ./src +RUN npm run build -# Copy build output -COPY ./dist ./ -COPY ./node_modules ./node_modules - -CMD ["node", "server.cjs", "--enable-source-maps"] +FROM ${NODE_BASE} +ENV NODE_ENV=production +WORKDIR /app +COPY server/package.json server/package-lock.json ./ +RUN npm ci --omit=dev +COPY --from=build /build/dist ./dist +CMD ["node", "dist/server.cjs", "--enable-source-maps"]