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
+62 -1
View File
@@ -6,6 +6,9 @@
// node deploy-smoke-client.mjs edge an ordinary, untrusted peer of the proxy
// node deploy-smoke-client.mjs tunnel the Cloudflare Tunnel connector's fixed address
// node deploy-smoke-client.mjs session the initial owner signing in and out through the proxy
// node deploy-smoke-client.mjs module the test module `example` as the initial owner sees it:
// SMOKE_MODULE_EXPECT is "absent", or a JSON array of the
// exact values its records route must return (ADR 0048)
//
// Either topology (ADR 0045): SMOKE_HOSTNAME is the API's hostname, and SMOKE_DASHBOARD_HOSTNAME,
// when set, the Dashboard's. Unset means the same-origin topology, where both are one hostname. Every
@@ -443,14 +446,72 @@ async function sessionChecks() {
);
}
/** Signs the initial owner in, through the proxy, and returns the session cookie. */
async function ownerCookie() {
const signIn = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
body: JSON.stringify({
email: required("SMOKE_OWNER_EMAIL"),
password: required("SMOKE_OWNER_PASSWORD"),
}),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
await signIn.arrayBuffer();
check(signIn.status === 200, "the initial owner signs in");
return (signIn.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
}
/**
* The installed-module checks (ADR 0048): whether `example` is in the module inventory, and what
* its own route returns. Absent, it is in no inventory and its routes are not mounted at all;
* present, its records route returns exactly the expected values, which proves its tables, its
* dependencies, and the platform's database facade all work from module storage.
*/
async function moduleChecks() {
const expectation = required("SMOKE_MODULE_EXPECT");
const cookie = await ownerCookie();
const headers = { cookie, origin: dashboardOrigin };
const inventory = await fetch(`${apiOrigin}/api/v1/module-ui`, { headers });
const inventoryBody = await inventory.json();
const installed =
inventory.status === 200 && inventoryBody.modules.some((module) => module.id === "example");
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
if (expectation === "absent") {
check(inventory.status === 200 && !installed, "the module inventory has no `example` module");
check(
records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}',
"the absent module's routes are not mounted",
);
} else {
const expected = JSON.parse(expectation);
check(installed, "the module inventory lists the `example` module");
const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null;
check(
JSON.stringify(values) === JSON.stringify(expected),
`the module's records route returns ${JSON.stringify(expected)}`,
);
}
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
body: "{}",
headers: { ...headers, "content-type": "application/json" },
method: "POST",
});
await signOut.arrayBuffer();
}
if (mode === "edge") {
await edgeChecks();
} else if (mode === "tunnel") {
await tunnelChecks();
} else if (mode === "session") {
await sessionChecks();
} else if (mode === "module") {
await moduleChecks();
} else {
throw new Error('Mode must be "edge", "tunnel", or "session".');
throw new Error('Mode must be "edge", "tunnel", "session", or "module".');
}
if (failures > 0) {
@@ -0,0 +1,50 @@
# TEST-ONLY. Builds the repository's module template as one self-contained installed-module directory
# for scripts/smoke-deploy.sh, which installs it into a deployment's module storage (ADR 0048). It is
# not a module package format and defines none: the archive format, signing, and transport of real
# module packages are deferred. No image is produced; the build's only output is the directory:
#
# DOCKER_BUILDKIT=1 docker build --file scripts/lib/test-module-package.Dockerfile \
# --output type=local,dest=<directory> .
#
# writes <directory>/example: the module's package.json, manifest, built artifacts, migrations, and a
# node_modules holding exactly its production dependencies, including its own copies of the Module
# SDK, the contracts, and Drizzle. Nothing in it resolves to anything outside it, because the
# platform lends an installed module nothing.
#
# The same pinned Node.js base as deploy/Dockerfile, so the package targets the runtime it is
# installed into. Drizzle comes from the platform's own lockfile, which is what keeps it the exact
# version the platform uses: a constraint of the current implementation (ADR 0048), not a contract.
FROM node:24.18.0-trixie-slim@sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573 AS build
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
PNPM_HOME=/pnpm \
TURBO_TELEMETRY_DISABLED=1 \
DO_NOT_TRACK=1
WORKDIR /opt/source
COPY package.json ./
RUN corepack enable && corepack install
COPY . .
RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \
pnpm install --frozen-lockfile --store-dir /pnpm/store
RUN pnpm exec turbo run build --filter=@ekklesia/module-template
# `--legacy` deploys from this workspace without switching it to injected workspace packages; the
# Module SDK and every other workspace dependency are still copied into the package, built.
RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \
set -eu; \
pnpm --filter @ekklesia/module-template deploy --prod --legacy --store-dir /pnpm/store \
/package/example; \
rm -f /package/example/README.md; \
# pnpm's hoisted self-reference points back into this workspace; an installed module has none.
rm -f /package/example/node_modules/.pnpm/node_modules/@ekklesia/module-template; \
escaping=""; \
for link in $(find /package/example -type l); do \
case "$(realpath -m "$link")" in \
/package/example/*) ;; \
*) escaping="$escaping $link" ;; \
esac; \
done; \
if [ -n "$escaping" ]; then echo "Links escape the package:$escaping" >&2; exit 1; fi; \
chmod -R a+rX,go-w /package/example; \
cd /package/example && node -e 'import("./dist/server/index.js").then((m) => { if (m.default.id !== "example") process.exit(1); })'
FROM scratch AS package
COPY --from=build /package/ /