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
@@ -652,3 +652,23 @@ Still deliberately absent after Phase 8: a theme editor, a brand builder, or any
|
||||
module store or installer; a standalone host; signed write URLs, resumable uploads, or any body above
|
||||
the absolute ceiling; module-owned offline *content*; and every real feature module, Signage
|
||||
included. Nothing in this pass claims any of them exists.
|
||||
|
||||
[ADR 0048](decisions/0048-generic-image-and-installed-module-storage.md) separates the platform
|
||||
artifact from what an installation installs:
|
||||
|
||||
- **One generic image.** `deploy/Dockerfile` builds one image with zero feature modules, the same
|
||||
for every installation; a checkout's `modules/` never reaches it.
|
||||
- **Module storage.** Installed modules live in installation-owned storage that one Core resolver
|
||||
selects from `EKKLESIA_MODULES_DIR`. The API, every operator command, and Doctor use the same
|
||||
resolver, and Core's own migrations are always found under the platform, never beside the
|
||||
modules. A configured directory that is relative, blank, missing, or unreadable fails closed
|
||||
with a sanitized error.
|
||||
- **Self-contained modules.** An installed module is a self-contained directory with its own
|
||||
production dependencies; the platform lends it none. Discovery still happens only when a process
|
||||
starts, so module changes take effect at a controlled restart and nothing is hot loaded.
|
||||
- **Removing keeps data.** Removing a module's code keeps its data and stored state. The migration
|
||||
runner retains and reports the applied history of a module that is absent, instead of refusing
|
||||
to plan, while Core's history and every installed module's history stay strict, so a returning
|
||||
module that lost or changed an applied migration is still refused.
|
||||
- **Trust is unchanged.** Installed modules are still trusted code; module storage is mounted
|
||||
read-only, which keeps Ekklesia from modifying installed code but does not sandbox it.
|
||||
|
||||
@@ -60,6 +60,7 @@ No real values are shown here — configure per environment and never commit the
|
||||
| `EKKLESIA_DATABASE_CA` | Optional. PEM text of a private certificate authority to trust in addition to the system store, for a database using an internal CA. |
|
||||
| `EKKLESIA_API_HOST` | Optional. Listen address. Defaults to loopback (`127.0.0.1`). An explicit non-loopback address is permitted for future deployment. |
|
||||
| `EKKLESIA_API_PORT` | Optional. Listen port, `0`–`65535` (`0` requests an OS-assigned port). Defaults to `3000`. |
|
||||
| `EKKLESIA_MODULES_DIR` | Optional. The installed-modules directory, as an absolute path ([ADR 0048](../architecture/decisions/0048-generic-image-and-installed-module-storage.md)). Unset means the checkout's own `modules/`, where a missing directory is simply zero modules. Set, the directory must exist: a relative, blank, or unreadable value, or a missing directory, stops startup before any database connection with a sanitized `MODULES_DIRECTORY_INVALID` or `MODULES_DIRECTORY_UNREADABLE` that never contains the value. The production image sets `/var/lib/ekklesia/modules`. Every operator command and Doctor read the same value. |
|
||||
| `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. |
|
||||
| `EKKLESIA_IDENTITY_BASE_URL` | Exactly one canonical `http`/`https` origin — scheme, host, and non-default port only, with no path, trailing slash, query, fragment, credentials, or explicit default port. `https` is required in production; loopback and unspecified hosts are rejected in production. |
|
||||
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of canonical origins, validated identically to the base URL (no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. The base URL's own origin is always trusted as well and need not be listed ([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)). |
|
||||
|
||||
@@ -173,8 +173,9 @@ integration layer depends on authorization).
|
||||
exact same installed-module inventory every time it starts, in a fixed order, before anything
|
||||
else:
|
||||
|
||||
1. Resolve module-discovery configuration (the repository's `modules/` directory, and the
|
||||
platform/Module SDK versions from their own `package.json` files).
|
||||
1. Resolve module-discovery configuration: the installed-modules directory, chosen by Core's one
|
||||
resolver from `EKKLESIA_MODULES_DIR` (the repository's own `modules/` when it is unset, ADR
|
||||
0048), and the platform/Module SDK versions from the platform's own `package.json` files.
|
||||
2. Discover installed modules exactly once (`discoverModules`).
|
||||
3. Compose the platform permission registry from that exact discovered list
|
||||
(`composePlatformPermissionRegistry`).
|
||||
@@ -199,7 +200,8 @@ never falls back to `CORE_PERMISSION_REGISTRY` or any Core-only registry. The re
|
||||
resolved from this compiled/source file's own location (two directories up from
|
||||
`apps/api/dist/*.js` or `apps/api/src/*.ts`, which sit at identical depth), never `process.cwd()`
|
||||
— a package-scoped test run has `apps/api` as its working directory, which would otherwise point
|
||||
discovery at the wrong `modules/` directory.
|
||||
discovery at the wrong `modules/` directory. A configured `EKKLESIA_MODULES_DIR` that is not an
|
||||
absolute path to an existing directory fails at step 1, before anything else exists.
|
||||
|
||||
The resulting registry is a **static, immutable snapshot** taken once at startup. There is no hot
|
||||
installation, removal, or dynamic mutation of installed modules or their permissions while the
|
||||
|
||||
@@ -35,8 +35,10 @@ The lint, typecheck, and test commands run both repository-root checks and every
|
||||
package-level Turborepo task. New packages add their own `lint`, `typecheck`, and `test` scripts;
|
||||
the root verification command discovers them automatically.
|
||||
|
||||
`pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A
|
||||
missing or empty modules directory is valid and reports zero installed modules. Installed modules
|
||||
`pnpm modules:validate` builds the workspace, then scans the installed-modules directory without a
|
||||
central registry: the checkout's own `modules/`, or `EKKLESIA_MODULES_DIR` when it is set (ADR
|
||||
0048). A missing or empty `modules/` is valid and reports zero installed modules; a configured
|
||||
directory must exist. Installed modules
|
||||
must produce the compiled JavaScript entrypoints declared by their static manifests. Each module's
|
||||
declared browser contributions are listed with their target capability and range, and marked
|
||||
according to whether a compatible provider is installed — a contribution nobody provides is otherwise
|
||||
@@ -113,9 +115,12 @@ identity secret, not a module secret value.
|
||||
|
||||
Every check has a stable ID and one of four states: pass, warning, failure, or skipped. Independent
|
||||
checks continue after a failure; a failed prerequisite causes only its dependent checks to be
|
||||
skipped. Pending or approval-blocked migrations are warnings and are never applied. Invalid
|
||||
history, checksum disagreement, unknown applied migrations, or a failed configured connection are
|
||||
failures. If `DATABASE_URL` is missing, PostgreSQL is skipped and all static checks still run.
|
||||
skipped. Pending or approval-blocked migrations are warnings and are never applied. Applied
|
||||
history that belongs to a module which is not installed is reported as `retained` and is neither a
|
||||
warning nor a failure (ADR 0048). Invalid history, checksum disagreement, an applied migration that
|
||||
an installed module or Core no longer contains, or a failed configured connection are failures. An
|
||||
unusable configured `EKKLESIA_MODULES_DIR` fails module discovery. If `DATABASE_URL` is missing,
|
||||
PostgreSQL is skipped and all static checks still run.
|
||||
|
||||
`READY` means every check passed. `READY WITH WARNINGS` means no check failed but at least one check
|
||||
warned or was skipped. Both results exit zero. `NOT READY` means at least one check failed and exits
|
||||
|
||||
@@ -472,6 +472,15 @@ CI builds and smoke-tests end to end on every change against disposable services
|
||||
publishes, or runs it against a real installation. No `.env` has ever been committed; only
|
||||
`.env.example`, which the image build context excludes.
|
||||
|
||||
The image contains no modules
|
||||
([ADR 0048](../architecture/decisions/0048-generic-image-and-installed-module-storage.md)).
|
||||
Installed modules come from installation-owned module storage, which the reference deployment mounts
|
||||
**read-only** into `api` and `migrate`, so the Ekklesia process cannot modify installed code. That
|
||||
is an integrity property, not a sandbox: an installed module is still trusted code with the API's
|
||||
database role, exactly as above. The repository's boundary checks prove only source modules in a
|
||||
workspace, and an installed package usually ships none, so installing one means trusting that
|
||||
module's own CI and whoever placed it in module storage.
|
||||
|
||||
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
|
||||
|
||||
- `apps/api` defaults its listen address to loopback (`127.0.0.1`), so it is not reachable off-host
|
||||
|
||||
@@ -171,6 +171,16 @@ whole-batch DDL rollback, Drizzle breakpoint execution, real advisory-lock concu
|
||||
timeouts, lock release after failure, exact destructive approvals, and public-error sanitization.
|
||||
It does not make migrations automatic during Core startup.
|
||||
|
||||
It also proves ADR 0048's retained history for an uninstalled module end to end, with real
|
||||
discovery: Core's committed migrations (the newest one withheld at first) and the module template
|
||||
installed in a modules directory outside the repository are applied, the module writes a row, and
|
||||
its directory is removed. Planning then reports the module's history as retained, and the withheld
|
||||
Core migration applies with the module absent. The module's table and row survive. Reinstalled, the
|
||||
module's history validates strictly again with nothing pending, its row is intact, and a reinstalled
|
||||
package whose applied migration changed is refused. A history row for an absent module is retained
|
||||
and reported, never fatal, while an unknown row belonging to an installed module is still rejected
|
||||
without repair.
|
||||
|
||||
A dedicated set of scenarios exercises the committed Core identity migration against real
|
||||
PostgreSQL: read-only planning on a fresh database, applying it and confirming exactly the four
|
||||
expected `ekklesia_core_*` tables and their indexes exist, a genuine no-op on a second apply, Core
|
||||
@@ -554,6 +564,33 @@ the following pull-request status context to `main` branch protection:
|
||||
CI / migration-smoke (pull_request)
|
||||
```
|
||||
|
||||
## The generic platform image and the reference deployment
|
||||
|
||||
These need Docker and build images locally; nothing is pushed or published.
|
||||
|
||||
- `bash scripts/check-image.sh` builds the production image and proves it is the one generic
|
||||
platform artifact ADR 0048 requires. It checks that the image has:
|
||||
- no `/opt/ekklesia/modules`;
|
||||
- no module template;
|
||||
- no `ekklesia.module.json` outside installed dependencies;
|
||||
- an empty, root-owned `/var/lib/ekklesia/modules`;
|
||||
- `EKKLESIA_MODULES_DIR` set, in the image configuration and in a running process.
|
||||
|
||||
It then builds again from the same checkout with a populated `modules/` directory, and requires
|
||||
the same checks and an identical runtime file system. CI's `image-build` job runs it.
|
||||
- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the reference deployment end to end on the
|
||||
generic image with empty module storage. With `EKKLESIA_SMOKE_TOPOLOGY=split`, the same image also
|
||||
runs the installed-module lifecycle:
|
||||
- the module template, packaged as a self-contained test module
|
||||
(`scripts/lib/test-module-package.Dockerfile`, test-only), is installed into module storage;
|
||||
- its migration is planned and explicitly applied, and the API restarted;
|
||||
- a real browser mounts it;
|
||||
- it is removed and the API restarted, and Core still runs, migrations still plan and apply, and
|
||||
its data survives;
|
||||
- it is restored, with its data.
|
||||
|
||||
See [deploy/README.md](../../deploy/README.md#testing-this-deployment).
|
||||
|
||||
## Safe troubleshooting
|
||||
|
||||
Confirm the prerequisites without printing configuration:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user