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
+72 -2
View File
@@ -26,7 +26,10 @@ application's. A module consumes those; it never redefines or bypasses them.
## Required package shape
Each immediate child of `modules/` must be an ESM workspace package with a unique package name, a
A module is developed as an ESM workspace package in a checkout's `modules/` directory, and it is
installed, self-contained, into an installation's module storage
([An installed module is self-contained](#an-installed-module-is-self-contained)). In a checkout,
each immediate child of `modules/` must be an ESM workspace package with a unique package name, a
strict semantic version, and the module layer marker:
```text
@@ -66,6 +69,72 @@ module with no migration history omits `entrypoints.migrations` and needs no pla
The package version must match the manifest and runtime-definition versions. Internal package
dependencies use `workspace:` ranges and supported public package entrypoints.
### An installed module is self-contained
Ekklesia's image contains no modules
([ADR 0048](../architecture/decisions/0048-generic-image-and-installed-module-storage.md)). An
installation keeps installed modules in its own module storage, the directory `EKKLESIA_MODULES_DIR`
names (`/var/lib/ekklesia/modules` in the production image, the checkout's `modules/` when it is
unset). Discovery reads it only when a process starts. Nothing is hot loaded, and an installed module
is read-only to the platform.
An installed module directory is **self-contained for runtime use**. Everything the platform reads
or loads from it, and everything those files import at run time, resolves inside it:
- `package.json`, with no `workspace:` range needed at run time, and `ekklesia.module.json`;
- every built artifact the manifest declares: `dist/server`, `dist/schema`, jobs, and the UI,
display, and contribution bundles;
- the `drizzle/` migrations directory, with its journal and `_ekklesia.json`;
- a `node_modules` holding the production dependencies of your server, schema, and jobs code,
including your own copies of `@ekklesia/module-sdk`, `@ekklesia/contracts`, and `drizzle-orm`.
**Copying `dist/` is not an installed module.** Your compiled server code imports
`@ekklesia/module-sdk` and `drizzle-orm` by name, and Node resolves those only from your module's
own directory upwards. The platform's `node_modules` is never on that path, and Ekklesia adds it
through no link, `NODE_PATH`, loader hook, or alias. A module whose imports do not resolve stops the
API from starting with a sanitized `MODULE_RUNTIME_IMPORT_FAILED`, so test the directory you ship,
not your workspace.
**Native dependencies** must match the platform image's runtime: Node.js 24.18.0 on Debian 13
(glibc), on the image's CPU architecture.
**Drizzle, for now.** You define tables with your own copy of Drizzle, and Core runs your queries
through the platform's Drizzle instance (see [Database access](#database-access)). Objects from two
copies interoperate only when the copies are interchangeable, so the current implementation requires
**exactly the Drizzle version the platform uses**: the one `@ekklesia/module-sdk` depends on. This is
a constraint of today's implementation, not a permanent contract.
Browser artifacts are already self-contained bundles, so nothing changes for them.
From a workspace, `pnpm --filter <your package> deploy --prod --legacy <directory>` produces such a
directory; remove the self-reference pnpm leaves at
`node_modules/.pnpm/node_modules/<your package>`, which points back into the workspace. The
repository's own smoke test packages the module template this way
(`scripts/lib/test-module-package.Dockerfile`). That is a test fixture, not a package format: the
format modules are delivered in, how they are signed, and how they reach an installation are not
decided yet.
The repository's boundary checks (`pnpm boundaries`) prove only source modules in a workspace. An
installed package normally has no source, so the platform cannot check it the same way. Your
module's own CI is where its boundary rules, its production dependencies, and its behaviour inside
Ekklesia are proven ([ADR 0047](../architecture/decisions/0047-module-ci-harness.md)).
### Removing a module keeps its data
Removing your module's directory from module storage is not deleting its data:
- your tables and rows, buckets and objects stay;
- the platform keeps its enablement decision, your configuration, its Dashboard placement, and
permission grants;
- your applied migration history stays, and the migration plan reports it as retained history for
a module that is not installed.
Reinstalled with the same module id, your module finds all of it again. Its history is validated
strictly again, so a reinstalled package must still contain every migration that was ever applied,
unchanged. Never delete or edit a released migration, and never ship an older version over newer
data. Disabling is different from removing: a disabled module stays installed and is simply not
loaded. Deleting a module's data (purge) does not exist yet.
## Canonical manifest
`ekklesia.module.json` is inert and authoritative, so Core can validate every installed module
@@ -1447,7 +1516,8 @@ pnpm migrations:plan
pnpm verify
```
`pnpm modules:validate` builds the workspace and then atomically validates every installed module.
`pnpm modules:validate` builds the workspace and then atomically validates every installed module,
from the checkout's `modules/` or from `EKKLESIA_MODULES_DIR` when it is set.
It rejects malformed manifests, duplicate IDs or package names, version mismatches, incompatible
platform or SDK ranges, missing required modules or capabilities, incompatible installed
dependencies, cycles, and missing, escaping, or non-JavaScript server or schema entrypoints.