feat(modules): mount the modules directory as a volume (phase 2, PR 9) #136

Merged
whitlocktech merged 1 commits from feature/module-compose-volume into edge 2026-08-11 05:58:20 +00:00
Member

Closes Phase 2. Last of the nine scaffolding PRs, and the one that makes the WordPress model real: modules live on a mount, never in the image, so an operator adds one to a pull-only deployment without building anything.

Docs half: RunicGateway/docs#134.

What changed

File Change
docker-compose.yml ./modules/app/modules, read-write, plus MODULES_DIR
Dockerfile creates /app/modules owned by node
.dockerignore excludes modules
.gitignore ignores modules/* except its README
modules/README.md new — what the directory is, how a module gets there, the restart
README.md env table + deploy notes
server/scripts/routeManifest.js comment only (see below)

No application code. 933 server + 160 client tests pass unchanged, routes.manifest.json stays at 230 routes, OpenAPI regenerates byte-identical.

A bind mount, not a named volume

§2.5 said "the same treatment uploads already gets", reaching for a named volume by analogy. The build settled it the other way: placing a module directory by hand is a supported install, and a named volume makes that a docker cp into a running container — the least discoverable mechanism Docker offers, for the one install path an operator without the admin panel has. A bind mount makes it tar -xf … -C ./modules, and makes the installed set something an operator can see. Read-write, because §2.5's install unpacks into it from inside the container.

Two silent failures this closes

The directory is tracked (via its README — the shape brand/ already uses). Docker recreates a missing bind-mount source as root:root, and the container runs as uid 1000. Delete modules/ from a checkout and the next install fails on a permission error that names no cause.

.dockerignore excludes it. COPY . . would otherwise bake whatever module the builder had checked out into every image — and Docker seeds a fresh named volume from image contents, so it could have surfaced on a production deployment that never installed it. This exclusion is what makes "never in the image" true rather than merely intended.

Verified against a running container

A compose file that parses proves nothing about ownership, and nothing about what the image contains — so this was checked on the real thing (-f docker-compose.dev.yml, project rgmodvol):

  • image carries an empty, node-owned /app/modules despite a module sitting in the build context
  • a module on the mount loads, mounts, runs onBoot, reaches started; moduleRoot is /app/modules/volsmoke
  • GET /api/v1/public/modules lists it; its API route answers
  • chunk serves from the entry's directory alone — the module's server/index.js and module.json are both 404 — with Cache-Control: no-cache and the right MIME; /modules/<unknown> is 404 application/json, not the SPA shell
  • the injected tag lands after core's bundle

§7.7 browser smoke, re-run against the containerised stack rather than a working tree — in Chrome, console open:

  • the page renders on first paint (no redirect home), inside core's PublicLayout, drawing React and the UI kit off window.__rg
  • its nav row is interleaved into core's public nav and marked active
  • enforced script-src 'self', zero CSP reports, zero console errors

Negative case: removing the directory by hand and restarting reconciles the row to startup_failed / stage require ("module directory not present on the volume") exactly as §2.4 says, and leaves core healthy — {"modules":[]}, no script injected, /api/health ok.

One unrelated correction

scripts/routeManifest.js enumerated the filesystem-conditional mounts it excludes and had never been told about /modules when PR 7 added it. Its filter is an allowlist, so the behaviour was always right and only the explanation was stale — comment and README prose only.


  • AI-assisted — Claude Code (Claude Opus 5)

🤖 Generated with Claude Code

https://claude.ai/code/session_018ocYxQWk3EhZe5gWRJXFU8

Closes **Phase 2**. Last of the nine scaffolding PRs, and the one that makes the WordPress model real: modules live on a mount, never in the image, so an operator adds one to a pull-only deployment without building anything. Docs half: RunicGateway/docs#134. ## What changed | File | Change | | --- | --- | | `docker-compose.yml` | `./modules` → `/app/modules`, read-write, plus `MODULES_DIR` | | `Dockerfile` | creates `/app/modules` owned by `node` | | `.dockerignore` | excludes `modules` | | `.gitignore` | ignores `modules/*` except its README | | `modules/README.md` | new — what the directory is, how a module gets there, the restart | | `README.md` | env table + deploy notes | | `server/scripts/routeManifest.js` | comment only (see below) | No application code. 933 server + 160 client tests pass **unchanged**, `routes.manifest.json` stays at 230 routes, OpenAPI regenerates byte-identical. ## A bind mount, not a named volume §2.5 said "the same treatment `uploads` already gets", reaching for a named volume by analogy. The build settled it the other way: **placing a module directory by hand is a supported install**, and a named volume makes that a `docker cp` into a running container — the least discoverable mechanism Docker offers, for the one install path an operator without the admin panel has. A bind mount makes it `tar -xf … -C ./modules`, and makes the installed set something an operator can *see*. Read-write, because §2.5's install unpacks into it from inside the container. ## Two silent failures this closes **The directory is tracked** (via its README — the shape `brand/` already uses). Docker recreates a *missing* bind-mount source as `root:root`, and the container runs as uid 1000. Delete `modules/` from a checkout and the next install fails on a permission error that names no cause. **`.dockerignore` excludes it.** `COPY . .` would otherwise bake whatever module the builder had checked out into every image — and Docker seeds a fresh named volume from image contents, so it could have surfaced on a production deployment that never installed it. This exclusion is what makes "never in the image" true rather than merely intended. ## Verified against a running container A compose file that parses proves nothing about ownership, and nothing about what the image contains — so this was checked on the real thing (`-f docker-compose.dev.yml`, project `rgmodvol`): - image carries an empty, **node-owned** `/app/modules` **despite a module sitting in the build context** - a module on the mount loads, mounts, runs `onBoot`, reaches `started`; `moduleRoot` is `/app/modules/volsmoke` - `GET /api/v1/public/modules` lists it; its API route answers - chunk serves from the **entry's directory alone** — the module's `server/index.js` and `module.json` are both `404` — with `Cache-Control: no-cache` and the right MIME; `/modules/<unknown>` is `404 application/json`, not the SPA shell - the injected tag lands **after** core's bundle §7.7 browser smoke, re-run against the containerised stack rather than a working tree — in Chrome, console open: - the page renders **on first paint** (no redirect home), inside core's `PublicLayout`, drawing React and the UI kit off `window.__rg` - its nav row is interleaved into core's public nav and marked active - enforced `script-src 'self'`, **zero CSP reports, zero console errors** Negative case: removing the directory by hand and restarting reconciles the row to `startup_failed` / stage `require` ("module directory not present on the volume") exactly as §2.4 says, and leaves core healthy — `{"modules":[]}`, no script injected, `/api/health` ok. ## One unrelated correction `scripts/routeManifest.js` enumerated the filesystem-conditional mounts it excludes and had never been told about `/modules` when PR 7 added it. Its filter is an allowlist, so the behaviour was always right and only the explanation was stale — comment and README prose only. --- - [x] AI-assisted — Claude Code (Claude Opus 5) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018ocYxQWk3EhZe5gWRJXFU8
wtclaude added 1 commit 2026-08-11 05:50:52 +00:00
feat(modules): mount the modules directory as a volume (phase 2, PR 9)
All checks were successful
PR Checks / bot-install (pull_request) Successful in 21s
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / server-tests (pull_request) Successful in 1m39s
a103e0ce10
Closes Phase 2. Modules live on a mount, never in the image — that is what
lets an operator add one to a pull-only deployment without building anything.

`./modules` is a bind mount rather than a named volume: placing a module
directory by hand is a supported install (MODULE_SYSTEM.md §2.5), and that has
to be doable from the host rather than through `docker cp`. Read-write, because
the admin panel's install/uninstall unpacks and removes directories there.

The directory is tracked via its README so it exists in the checkout with the
operator's own ownership — Docker recreates a missing bind-mount source as
root:root, which the container user could not then write. `.dockerignore`
excludes it so a module in the builder's working tree can never ship inside an
image.

Also corrects the route-manifest generator's list of filesystem-conditional
mounts, which never picked up `/modules` when PR 7 added it. Comment only; the
generator filters on an allowlist, so its behaviour was already right.

Verified against a real container, not just a parsed compose file: image
carries an empty node-owned /app/modules despite a module in the build context;
a module on the bind mount loads, mounts, replays and reaches `started`;
`/api/v1/public/modules` lists it; the chunk serves from the entry's directory
only (server source and module.json 404) with `no-cache`; the injected tag
follows core's bundle; and in Chrome the page renders on first paint inside
core's PublicLayout with its nav row interleaved into core's public nav, under
enforced `script-src 'self'` with zero CSP reports and no console errors.
Removing the directory by hand reconciles the row to `startup_failed`/`require`
and leaves core healthy with no injection.

933 server + 160 client tests pass, manifest unchanged at 230 routes, swagger
regenerates byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit b649345484 into edge 2026-08-11 05:58:20 +00:00
whitlocktech deleted branch feature/module-compose-volume 2026-08-11 05:58:21 +00:00
Sign in to join this conversation.
No description provided.