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
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:
co-authored by
Claude Opus 5.5
parent
4d28a82937
commit
e36b70d8d4
@@ -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/ /
|
||||
Reference in New Issue
Block a user