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
+37 -29
View File
@@ -1,9 +1,10 @@
# Ekklesia's one production image (ADR 0044): the `api` service and every operator-run CLI, from one
# build. See deploy/README.md for how to build and run it.
# Ekklesia's one generic platform image (ADR 0044, ADR 0048): the `api` service and every operator-run
# CLI, from one build, for every installation. See deploy/README.md for how to build and run it.
#
# The build context is an already-assembled workspace: the platform, zero or more module packages in
# `modules/<id>`, and the `pnpm-lock.yaml` that describes exactly that set. This file clones nothing
# and chooses no modules. With an empty or absent `modules/`, it builds the platform alone.
# The build context is this repository. The image contains **zero feature modules**: installed
# modules live in installation-owned storage, mounted read-only at /var/lib/ekklesia/modules
# (EKKLESIA_MODULES_DIR) and discovered when a process starts. `.dockerignore` keeps a checkout's
# `modules/` out of the context, so a build is the same whether or not one is present.
#
# No `# syntax=` directive, deliberately: it would make BuildKit pull an unpinned frontend image.
# The built-in frontend supports everything below, including cache mounts.
@@ -28,9 +29,10 @@ COPY package.json ./
RUN corepack enable && corepack install
# ---------------------------------------------------------------------------------------------------
# manifests: the lockfile, workspace configuration, and every workspace package.json, at their
# repository paths. Dependency installation keys on these alone, so a source-only change reuses the
# installed layers.
# manifests: the lockfile, workspace configuration, and the package.json of every platform package
# the runtime needs, at their repository paths. Dependency installation keys on these alone, so a
# source-only change reuses the installed layers. The module template is a workspace package but not
# part of the platform, so neither its manifest nor its dependencies reach the runtime.
# ---------------------------------------------------------------------------------------------------
FROM base AS manifests
WORKDIR /context
@@ -38,9 +40,8 @@ COPY . .
RUN set -eu; \
mkdir /manifests; \
cp package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc /manifests/; \
for manifest in apps/*/package.json builders/*/package.json core/package.json \
modules/*/package.json packages/*/package.json surfaces/*/package.json \
templates/*/package.json; do \
for manifest in apps/*/package.json core/package.json packages/*/package.json \
surfaces/*/package.json; do \
if [ -f "$manifest" ]; then install -D -m 0644 "$manifest" "/manifests/$manifest"; fi; \
done
@@ -52,37 +53,38 @@ FROM toolchain AS build
# module UI builder's, which every module build invokes), and pnpm links a bin only when its target
# already exists. The store cache mount keeps a rebuild off the network all the same.
COPY . .
# A second line of defence behind `.dockerignore`: a context that still carries installed modules is
# refused rather than built into a platform image.
RUN if [ -e modules ]; then \
echo "The build context contains modules/. Installed modules never enter the platform image (ADR 0048)." >&2; \
exit 1; \
fi
RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \
pnpm install --frozen-lockfile --store-dir /pnpm/store
# The same gates `pnpm verify` runs for boundaries, modules, and migrations, against exactly the
# module set in this context. `pnpm boundaries` comes first and reads only sources: among its rules,
# a module that imports something at run time without declaring it a production dependency fails
# here, because the production-only install below would leave it unresolvable at API startup. Each
# The same gates `pnpm verify` runs for boundaries, modules, and migrations. With no modules in the
# context, module validation covers the empty set and migration validation covers Core's own. Each
# validate script builds first; the final build is a Turbo cache hit that keeps the intent explicit.
RUN pnpm boundaries && pnpm modules:validate && pnpm migrations:validate && pnpm build
# What the runtime resolves at startup, at the paths it resolves them from:
# - every workspace package's `dist/` (every package exports only from `dist/`), which includes
# - every platform package's `dist/` (every package exports only from `dist/`), which includes
# the Dashboard and display host assets under `surfaces/shell/dist/browser/`;
# - Core's migrations, `core/drizzle/` with its `meta/` journal;
# - each module directory whole, minus `node_modules`, because each manifest names its own
# entrypoints, UI artifacts, and migrations directory.
# `node_modules` comes from the production-only install below, never from this stage.
# - Core's migrations, `core/drizzle/` with its `meta/` journal.
# Never a module and never the module template. `node_modules` comes from the production-only
# install below, never from this stage.
RUN set -eu; \
mkdir -p /runtime/core /runtime/modules; \
mkdir -p /runtime/core; \
for package in apps/* core packages/* surfaces/*; do \
if [ -d "$package/dist" ]; then \
mkdir -p "/runtime/$package"; \
cp -a "$package/dist" "/runtime/$package/dist"; \
fi; \
done; \
cp -a core/drizzle /runtime/core/drizzle; \
if [ -d modules ]; then \
tar -C modules --exclude='node_modules' -cf - . | tar -C /runtime/modules -xf -; \
fi
cp -a core/drizzle /runtime/core/drizzle
# ---------------------------------------------------------------------------------------------------
# prod-deps: the same lockfile, production dependencies only. No compiler, bundler, or test runner
# reaches the runtime image.
# prod-deps: the same lockfile, production dependencies only, for the platform packages alone. No
# compiler, bundler, or test runner reaches the runtime image, and neither does anything only the
# module template depends on.
# ---------------------------------------------------------------------------------------------------
FROM toolchain AS prod-deps
COPY --from=manifests /manifests/ ./
@@ -109,13 +111,19 @@ LABEL org.opencontainers.image.title="Ekklesia" \
# the API still binds 127.0.0.1. `production` is stated rather than relied on as the default.
ENV NODE_ENV=production \
EKKLESIA_API_HOST=0.0.0.0 \
EKKLESIA_API_PORT=3000
EKKLESIA_API_PORT=3000 \
EKKLESIA_MODULES_DIR=/var/lib/ekklesia/modules
# The repository layout, preserved: the API finds the repository root three directories above
# `apps/api/dist/`, and every CLI uses its working directory as the root.
# `apps/api/dist/`, and every CLI uses its working directory as the root. Installed modules are not
# under it: they are read from EKKLESIA_MODULES_DIR.
WORKDIR /opt/ekklesia
COPY --from=prod-deps /opt/ekklesia/ ./
COPY --from=build /runtime/ ./
# The mount point for installation-owned module storage (ADR 0048), empty in the image. An image run
# with nothing mounted there has zero modules; the reference deployment mounts host storage
# read-only over it.
RUN install -d -o root -g root -m 0755 /var/lib/ekklesia/modules
# Files stay root-owned: the unprivileged runtime user can read the installation, never modify it.
USER node