diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47a8e9251..f6ad8ee86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,6 +126,40 @@ jobs: exit 1 fi + collab-image: + name: Collab relay image + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: "22" + + # publish-container.yml only builds the web Dockerfile, so nothing in CI + # ever built the relay image and v2.5.0 shipped one that exited at startup + # on module resolution (GeoLibre#1866). A build alone would not have caught + # it either -- the missing dependency only surfaces when the container + # runs, so this job starts it and talks to it. + - name: Build the collaboration relay image + run: docker build -f workers/collab-node/Dockerfile -t geolibre-collab:ci . + + - name: Start the relay container + run: docker run -d --name collab -p 8787:8787 geolibre-collab:ci + + - name: Smoke-test the running relay + run: node workers/collab-node/scripts/smoke.mjs http://127.0.0.1:8787 + + # The container logs carry the module-resolution error that a failed smoke + # test only reports as "never answered GET /health". + - name: Show the container logs + if: ${{ always() }} + run: docker logs collab + checks: name: Build and test runs-on: ubuntu-22.04 diff --git a/workers/collab-node/Dockerfile b/workers/collab-node/Dockerfile index 8278a6338..2ef835cf3 100644 --- a/workers/collab-node/Dockerfile +++ b/workers/collab-node/Dockerfile @@ -8,14 +8,45 @@ COPY packages/collab-core packages/collab-core COPY workers/collab-node workers/collab-node RUN npm run build -w geolibre-collab-node +# Assemble the runtime tree under /runtime so the final stage is one COPY of a +# layout that is known to resolve. esbuild bundles everything except `ws`, and +# `ws` does not always land in /app/node_modules: the root lockfile carries two +# versions of it (8.21.0 hoisted for the app's transitive deps, ^8.21.3 for this +# worker), so npm installs this workspace's copy at the nested +# workers/collab-node/node_modules. Copying only /app/node_modules dropped it and +# the container exited at startup with ERR_MODULE_NOT_FOUND (GeoLibre#1866), so +# take the nested tree too, guarded because it disappears whenever a dependency +# bump lets npm hoist `ws` to the root instead. packages/collab-core comes along +# for a weaker reason: esbuild inlines it, so the running server never imports it +# and it is not what broke the image. It is copied so that the symlink npm leaves +# at node_modules/@geolibre/collab-core points at something, since a dangling +# entry in node_modules trips anything that walks the tree in the container. The +# reinstall drops esbuild and the other dev dependencies now that the bundle is +# built, which is what keeps the copied tree small. +RUN npm ci --omit=dev --workspace geolibre-collab-node --workspace @geolibre/collab-core \ + && mkdir -p /runtime/workers/collab-node \ + && cp -a node_modules /runtime/node_modules \ + && cp -a packages /runtime/packages \ + && cp -a workers/collab-node/package.json workers/collab-node/dist /runtime/workers/collab-node/ \ + && if [ -d workers/collab-node/node_modules ]; then \ + cp -a workers/collab-node/node_modules /runtime/workers/collab-node/node_modules; \ + fi + +# Load the staged bundle from the path it will actually sit at, so a layout +# regression fails the build here instead of at container start. Importing it is +# side-effect-free: server.js only listens when it is process.argv[1]. The +# `test` is the weaker half -- nothing at runtime resolves that symlink, it just +# asserts the tree was copied whole. +RUN node --input-type=module \ + -e "await import('/runtime/workers/collab-node/dist/server.js');" \ + && test -e /runtime/node_modules/@geolibre/collab-core + FROM node:22-bookworm-slim ENV NODE_ENV=production PORT=8787 COLLAB_DB_PATH=/data/collab.sqlite WORKDIR /app # --chown on the COPY rather than a recursive chown afterwards: chowning /app # rewrites every file, node_modules included, into a second copy in a new layer. -COPY --from=build --chown=node:node /app/node_modules ./node_modules -COPY --from=build --chown=node:node /app/workers/collab-node/package.json ./workers/collab-node/package.json -COPY --from=build --chown=node:node /app/workers/collab-node/dist ./workers/collab-node/dist +COPY --from=build --chown=node:node /runtime ./ # /data is created and owned here so a named volume mounted over it inherits the # ownership; Docker only copies image ownership onto a volume when the path # already exists, otherwise the mountpoint lands root-owned. diff --git a/workers/collab-node/README.md b/workers/collab-node/README.md index 3032576e7..54d5d5989 100644 --- a/workers/collab-node/README.md +++ b/workers/collab-node/README.md @@ -23,6 +23,22 @@ Endpoints are `POST /sessions`, `GET /sessions/:id/ws`, and `GET /health`. Persist the directory containing `COLLAB_DB_PATH`, and terminate TLS at the ingress so browsers can connect with `wss://`. +## Checking a deployment + +`scripts/smoke.mjs` exercises the three endpoints against a running relay — +health, session creation, and a WebSocket join round-trip — and needs nothing +beyond Node: + +```bash +node workers/collab-node/scripts/smoke.mjs http://127.0.0.1:8787 +``` + +CI runs it against the freshly built image. The Dockerfile already imports the +staged bundle during `docker build`, so a missing `ws` or a broken +`@geolibre/collab-core` target fails there; this script covers the rest, which +only a running container shows: that the process starts and stays up, and that +it actually serves HTTP and completes a WebSocket upgrade. + ## Volume ownership The container runs as the unprivileged `node` user, and the image creates diff --git a/workers/collab-node/scripts/smoke.mjs b/workers/collab-node/scripts/smoke.mjs new file mode 100644 index 000000000..551e8e8b3 --- /dev/null +++ b/workers/collab-node/scripts/smoke.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +// Smoke-test a *running* GeoLibre collaboration relay: health, session +// creation, and a WebSocket join round-trip. +// +// The relay's worst failure mode is an image that builds clean and then exits at +// startup because the runtime stage is missing a dependency the bundle imports +// (GeoLibre#1866). No unit test or type-check sees that, and neither does +// `docker build` -- only starting the container does. So CI builds the image, +// runs it, and points this script at it. It talks plain HTTP plus the global +// WebSocket, so it needs nothing installed beyond Node itself and can be aimed +// at any deployed relay: +// +// node workers/collab-node/scripts/smoke.mjs http://127.0.0.1:8787 + +const baseUrl = (process.argv[2] ?? "http://127.0.0.1:8787").replace(/\/+$/, ""); +// Generous: on a cold CI runner the container has to start Node and open the +// SQLite database before it listens. +const STARTUP_TIMEOUT_MS = 60_000; +const WS_TIMEOUT_MS = 15_000; +const PROBE_TIMEOUT_MS = 5_000; + +function fail(message, detail) { + console.error(`FAIL: ${message}`); + if (detail !== undefined) console.error(detail); + process.exit(1); +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitForHealth() { + const deadline = Date.now() + STARTUP_TIMEOUT_MS; + let lastError = "no response"; + while (Date.now() < deadline) { + try { + // Bounded, because a container that accepts the connection and then never + // answers falls back on undici's own header/body timeouts, which are + // minutes long: the job would stall well past the startup budget instead + // of failing at it. Capped at whatever is left of that budget so a probe + // started near the deadline cannot overrun it either. + const signal = AbortSignal.timeout(Math.min(PROBE_TIMEOUT_MS, deadline - Date.now())); + const response = await fetch(`${baseUrl}/health`, { signal }); + // The same signal still covers this: aborting after the headers arrive + // errors the body stream rather than leaving the read hanging. + const body = await response.json(); + if (response.ok && body?.ok) return body; + lastError = `HTTP ${response.status} ${JSON.stringify(body)}`; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await sleep(500); + } + fail(`the relay never answered GET /health at ${baseUrl}`, lastError); +} + +async function createSession() { + const response = await fetch(`${baseUrl}/sessions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + // Bounded for the same reason as the health probe: the relay has answered + // by now, so anything slower than this is a hang, not a slow start. + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + const body = await response.json().catch(() => null); + if (!response.ok || !body?.sessionId || !body?.hostToken) + fail( + "POST /sessions did not return a session", + `HTTP ${response.status} ${JSON.stringify(body)}`, + ); + return body; +} + +// The join round-trip is the part that actually exercises `ws`: the relay only +// reaches WebSocketServer.handleUpgrade here, so a missing or broken copy of it +// shows up as a failed upgrade rather than a passing health check. +function join(session) { + const wsUrl = `${baseUrl.replace(/^http/, "ws")}/sessions/${session.sessionId}/ws`; + return new Promise((resolve) => { + const socket = new WebSocket(wsUrl); + const timer = setTimeout(() => { + socket.close(); + fail(`no welcome frame within ${WS_TIMEOUT_MS}ms of joining ${wsUrl}`); + }, WS_TIMEOUT_MS); + + socket.addEventListener("open", () => { + socket.send( + JSON.stringify({ + type: "join", + clientId: "smoke-test", + displayName: "Smoke test", + color: "#2563eb", + hostToken: session.hostToken, + }), + ); + }); + socket.addEventListener("message", (event) => { + clearTimeout(timer); + let message; + try { + message = JSON.parse(String(event.data)); + } catch { + fail("the relay sent a frame that is not JSON", String(event.data).slice(0, 200)); + } + if (message.type !== "welcome") + fail( + `expected a welcome frame, got "${message.type}"`, + JSON.stringify(message).slice(0, 200), + ); + if (message.role !== "host") + fail(`the host token did not claim the host role (got "${message.role}")`); + socket.close(); + resolve(message); + }); + socket.addEventListener("error", () => { + clearTimeout(timer); + fail(`the WebSocket upgrade to ${wsUrl} failed`); + }); + }); +} + +const health = await waitForHealth(); +console.log(`ok GET /health -> ${JSON.stringify(health)}`); +const session = await createSession(); +console.log(`ok POST /sessions -> ${session.sessionId} (${session.mode})`); +const welcome = await join(session); +console.log( + `ok WebSocket join -> welcome as ${welcome.role}, ${welcome.participants.length} participant(s)`, +); +console.log(`PASS: the relay at ${baseUrl} is serving`);