feat: one generic image, with installed modules in installation-owned storage (ADR 0048)
CI / verify (pull_request) Failing after 47s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped

The production image now contains zero feature modules and is the same for every
installation. Installed modules live in module storage that one Core resolver
selects from EKKLESIA_MODULES_DIR: the checkout's modules/ when unset, an
absolute path that must exist when set (/var/lib/ekklesia/modules in the image).
A relative, blank, missing, or unreadable configured directory fails closed with
a sanitized error that never contains the value.

- Host configuration reads EKKLESIA_MODULES_DIR. The API, migration plan, apply
  and validate, module validation, module enablement, and Doctor all resolve it
  the same way; the test-only API override still takes precedence.
- Doctor receives Core's migrations directory explicitly instead of deriving it
  from the modules directory's parent.
- The migration runner retains and reports the applied history of a module that
  is not installed, and plans and applies Core's and present modules' migrations
  around it. Core's history and every installed module's history stay strict: a
  missing or changed applied migration, or a downgrade, still fails. No down
  migrations, no data deletion.
- deploy/Dockerfile copies no module and installs nothing for the module
  template; .dockerignore excludes modules/ and the build refuses a context that
  contains it; the runtime has an empty root-owned /var/lib/ekklesia/modules and
  sets EKKLESIA_MODULES_DIR.
- deploy/compose.yaml bind-mounts EKKLESIA_MODULES_HOST_DIR read-only into api
  and migrate, with create_host_path disabled.
- scripts/check-image.sh proves the image holds no modules and that a populated
  modules/ directory leaves its runtime file system identical; CI runs it.
- The split deploy smoke now uses the same generic image: it boots with no
  modules, installs a self-contained test module packaged from the template
  (test-only), migrates and restarts, removes it (Core runs, planning reports the
  retained history, data survives), and restores it with its data.

Docs: deploy README (module storage, install/update/remove/restore, the runtime
modules target), .env.example, API configuration, module development guide,
testing, workflow, security, and the architecture overview.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-07 08:38:14 -05:00
co-authored by Claude Opus 5.5
parent 4d28a82937
commit e36b70d8d4
50 changed files with 2275 additions and 224 deletions
+74
View File
@@ -26,6 +26,15 @@ interface ComposeService {
read_only?: boolean;
security_opt?: string[];
tmpfs?: string[];
volumes?: (string | ComposeVolume)[];
}
interface ComposeVolume {
bind?: { create_host_path?: boolean };
read_only?: boolean;
source?: string;
target?: string;
type?: string;
}
interface ComposeDocument {
@@ -234,3 +243,68 @@ describe("reference deployment env examples", () => {
expect(example).toContain("EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>=");
});
});
describe("installation-owned module storage (ADR 0048)", () => {
const modulesTarget = "/var/lib/ekklesia/modules";
function moduleMount(service: ComposeService | undefined): ComposeVolume | undefined {
return (service?.volumes ?? []).find(
(volume): volume is ComposeVolume =>
typeof volume === "object" && volume.target === modulesTarget,
);
}
it.each([
"api",
"migrate",
])("mounts host module storage read-only into %s, and never lets Compose create it", async (name) => {
const service = (await compose("compose.yaml")).services?.[name];
expect(moduleMount(service)).toEqual({
bind: { create_host_path: false },
read_only: true,
source: "${EKKLESIA_MODULES_HOST_DIR:-/var/lib/ekklesia/modules}",
target: modulesTarget,
type: "bind",
});
// The process reads exactly the directory the mount provides.
expect(service?.environment?.EKKLESIA_MODULES_DIR).toBe(modulesTarget);
// Read-only everywhere else too: the mount is the only module storage there is.
expect(service?.read_only).toBe(true);
});
it("mounts module storage into nothing but the API and the operator commands", async () => {
const services = (await compose("compose.yaml")).services ?? {};
for (const [name, service] of Object.entries(services)) {
if (name !== "api" && name !== "migrate") {
expect(moduleMount(service), name).toBeUndefined();
}
}
for (const overlay of ["compose.proxy.yaml", "compose.tunnel.yaml"]) {
for (const [name, service] of Object.entries((await compose(overlay)).services ?? {})) {
expect(moduleMount(service), `${overlay} ${name}`).toBeUndefined();
}
}
});
it("names the host-side directory with its own variable, never EKKLESIA_MODULES_DIR", async () => {
const composeEnvironment = await text("env/compose.env.example");
expect(composeEnvironment).toMatch(
/^# ?EKKLESIA_MODULES_HOST_DIR=\/var\/lib\/ekklesia\/modules$/mu,
);
expect(composeEnvironment).not.toMatch(/^#? ?EKKLESIA_MODULES_DIR=/mu);
// The API's own env file must not move the directory away from the mount.
expect(envAssignments(await text("env/ekklesia.env.example")).has("EKKLESIA_MODULES_DIR")).toBe(
false,
);
});
it("builds an image that holds no modules and points at empty module storage", async () => {
const dockerfile = await text("Dockerfile");
const ignore = await readFile(path.join(repositoryRoot, ".dockerignore"), "utf8");
expect(ignore).toMatch(/^\/modules$/mu);
expect(dockerfile).toMatch(/EKKLESIA_MODULES_DIR=\/var\/lib\/ekklesia\/modules/u);
expect(dockerfile).toContain("install -d -o root -g root -m 0755 /var/lib/ekklesia/modules");
// Nothing from a module or the module template is copied into, or installed for, the runtime.
expect(dockerfile).not.toMatch(/\/runtime\/modules|modules\/\*\/package\.json|templates\/\*/u);
});
});