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
+37
-29
@@ -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
|
||||
|
||||
+155
-56
@@ -64,6 +64,7 @@ TLS is everywhere, without any exception in Core:
|
||||
| `/etc/ekklesia/object-storage.env` | The S3 key pair | `object-storage` |
|
||||
| `/etc/ekklesia/tunnel.env` | `TUNNEL_TOKEN` | `tunnel` |
|
||||
| `/etc/ekklesia/tls/` | The CA and server certificates and keys | mounted file by file |
|
||||
| `/var/lib/ekklesia/modules/` | Installed modules ([Installed modules](#installed-modules)); not a secret | `api`, `migrate`, read-only |
|
||||
|
||||
The secret files are owned by root with mode 600, one per service, so no container receives another
|
||||
service's credentials. None is committed, and `deploy/.env` is ignored by Git. `EKKLESIA_ENV_DIR`
|
||||
@@ -94,10 +95,19 @@ and `EKKLESIA_TLS_DIR` in `deploy/.env` move the directories.
|
||||
|
||||
`EKKLESIA_IDENTITY_BASE_URL` and `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` are `https://` and your
|
||||
Ekklesia hostname. `S3_DELIVERY_ENDPOINT` is `https://` and your storage hostname.
|
||||
5. **Configure the proxy:** copy `Caddyfile.example` to `Caddyfile`, and set `EKKLESIA_HOSTNAME`,
|
||||
5. **Create module storage**, empty, even if you install no module yet:
|
||||
|
||||
```sh
|
||||
sudo install -d -o root -g root -m 755 /var/lib/ekklesia/modules
|
||||
```
|
||||
|
||||
`api` and `migrate` refuse to start without it: Compose never creates it for you, so a mistyped
|
||||
path cannot quietly become an empty directory. `EKKLESIA_MODULES_HOST_DIR` in `deploy/.env`
|
||||
moves it.
|
||||
6. **Configure the proxy:** copy `Caddyfile.example` to `Caddyfile`, and set `EKKLESIA_HOSTNAME`,
|
||||
`EKKLESIA_STORAGE_HOSTNAME`, and `EKKLESIA_ACME_EMAIL` in `deploy/.env`. Both hostnames must
|
||||
resolve to this host with ports 80 and 443 reachable for HTTP-01, or see [DNS-01](#dns-01).
|
||||
6. **Start the data services, then migrate explicitly:**
|
||||
7. **Start the data services, then migrate explicitly:**
|
||||
|
||||
```sh
|
||||
docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait postgres object-storage
|
||||
@@ -107,8 +117,8 @@ and `EKKLESIA_TLS_DIR` in `deploy/.env` move the directories.
|
||||
|
||||
Nothing ever applies migrations automatically: the API never does, and `migrate` runs only when
|
||||
you run it.
|
||||
7. **Start everything:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
|
||||
8. **Check it:** `curl https://<hostname>/api/v1/health/ready` answers `{"status":"ready"}`, and
|
||||
8. **Start everything:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
|
||||
9. **Check it:** `curl https://<hostname>/api/v1/health/ready` answers `{"status":"ready"}`, and
|
||||
`docker compose run --rm migrate node core/dist/cli/doctor.js` reports `READY` (with warnings
|
||||
for anything optional you left unconfigured, such as email).
|
||||
|
||||
@@ -123,25 +133,93 @@ is created already verified, so they can sign in before any email relay exists
|
||||
([ADR 0035](../docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md)). To supply the
|
||||
password from a secret manager instead, pipe it on standard input and use `-T` instead of `-it`.
|
||||
|
||||
### Installed modules
|
||||
|
||||
The image contains no modules
|
||||
([ADR 0048](../docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)).
|
||||
Installed modules live on the host, in module storage: `/var/lib/ekklesia/modules`, or
|
||||
`EKKLESIA_MODULES_HOST_DIR`. Compose mounts it **read-only** at `/var/lib/ekklesia/modules` in `api`
|
||||
and `migrate`, which is where `EKKLESIA_MODULES_DIR` points. Ekklesia never writes there.
|
||||
Installing, updating, or removing a module never needs a new image, only a restart.
|
||||
|
||||
**What belongs there.** One directory per module, each a self-contained installed module: its
|
||||
`package.json`, `ekklesia.module.json`, built artifacts, migrations, and the production
|
||||
dependencies its server code imports, in its own `node_modules`
|
||||
([Module development](../docs/module-development/README.md#an-installed-module-is-self-contained)).
|
||||
Copying a module's `dist/` alone is not enough. Files are owned by root and readable by everyone
|
||||
(directories 755, files 644), so the API's unprivileged user can read them and never change them.
|
||||
|
||||
**Nothing else belongs there.** Every non-hidden directory must be a valid module, or the API does
|
||||
not start. That includes `lost+found`, so if module storage is a file system of its own, mount it
|
||||
one level up and point `EKKLESIA_MODULES_HOST_DIR` at a directory inside it. Hidden entries are
|
||||
ignored, which is what makes staging possible.
|
||||
|
||||
**Changes take effect only at a restart.** A running API never notices module storage changing, and
|
||||
never loads or unloads a module while it runs. Change module storage only while the API is stopped:
|
||||
it reads browser artifacts from disk as it serves them, so files changed under a running API would
|
||||
serve one version's browser code beside another's server code.
|
||||
|
||||
To **install or update** a module, with `<id>` its module id:
|
||||
|
||||
```sh
|
||||
docker compose stop api
|
||||
# Stage under a hidden name, which discovery ignores, then rename it into place in one step.
|
||||
sudo cp -R ./<id> /var/lib/ekklesia/modules/.incoming-<id>
|
||||
sudo chown -R root:root /var/lib/ekklesia/modules/.incoming-<id>
|
||||
sudo chmod -R a+rX,go-w /var/lib/ekklesia/modules/.incoming-<id>
|
||||
sudo mv /var/lib/ekklesia/modules/<id> /var/lib/ekklesia/modules/.previous-<id> # update only
|
||||
sudo mv /var/lib/ekklesia/modules/.incoming-<id> /var/lib/ekklesia/modules/<id>
|
||||
docker compose run --rm migrate node core/dist/cli/validate-modules.js
|
||||
docker compose run --rm migrate # plan: review it
|
||||
docker compose run --rm migrate node core/dist/cli/apply-migrations.js # only when the plan is right
|
||||
docker compose up -d --wait api
|
||||
```
|
||||
|
||||
Back up before an update that brings migrations: migrations are forward-only. A newly installed
|
||||
module is enabled unless an administrator disabled it before; set its configuration and grants as
|
||||
its documentation says. Keep `.previous-<id>` until the update has proven itself, then delete it.
|
||||
|
||||
To **remove** a module, stop the API, delete (or move away) its directory, and start the API again.
|
||||
Remove first any module that *requires* it, or the API refuses to start. Removing a module's code
|
||||
deletes nothing else. Its tables and rows, its buckets and objects, its enablement, configuration,
|
||||
Dashboard placement, and grants all stay. Its applied migration history stays too, and the
|
||||
migration plan and Doctor report it as `retained` history for a module that is not installed. That
|
||||
is information, not a problem, and it blocks no other migration. To stop a module without removing
|
||||
it, disable it instead (`set-module-enabled.js --disable`, [Operator commands](#operator-commands)).
|
||||
There is no purge: deleting a module's data is a separate operation that does not exist yet.
|
||||
|
||||
To **restore** a removed module, install it again as above, at the same version or a newer one. It
|
||||
finds its data, its settings, and its history where it left them. Its history is strict again: a
|
||||
package that no longer contains a migration that was applied, or contains one with different
|
||||
contents, is refused by the migration plan, so an older version cannot be installed over newer data.
|
||||
|
||||
Module storage is installation state: back it up with the database and the buckets.
|
||||
|
||||
### Upgrading
|
||||
|
||||
1. **Pull or build the new image**, and change `EKKLESIA_IMAGE` in `deploy/.env`. Keep the old tag.
|
||||
Installed modules stay where they are, in module storage.
|
||||
2. **Back up** ([Backups](#backups)).
|
||||
3. **Plan, review, then apply:** `docker compose run --rm migrate` with the new image, then
|
||||
3. **Check the installed modules against the new platform:**
|
||||
`docker compose run --rm migrate node core/dist/cli/validate-modules.js`. A module whose
|
||||
`compatibility.platform` range excludes the new version fails here, and would stop the API from
|
||||
starting: update it first, or stay on the old image.
|
||||
4. **Plan, review, then apply:** `docker compose run --rm migrate` with the new image, then
|
||||
`docker compose run --rm migrate node core/dist/cli/apply-migrations.js`.
|
||||
4. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
|
||||
5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
|
||||
|
||||
### Rolling back
|
||||
|
||||
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
|
||||
the upgrade applied no migrations (the plan in step 3 showed none pending). Migrations are
|
||||
the upgrade applied no migrations (the plan in step 4 showed none pending). Migrations are
|
||||
forward-only. If any were applied, restore the backup taken in step 2 before starting the previous
|
||||
image, because it was not built for the newer schema.
|
||||
image, because it was not built for the newer schema. Module storage is not part of the image, so a
|
||||
rollback does not change installed modules.
|
||||
|
||||
### Backups
|
||||
|
||||
Slice 6 adds the scheduled backup job and the restore procedure (ADR 0044, Backups). Until then,
|
||||
take a `pg_dump` and a copy of every bucket before each upgrade by hand.
|
||||
take a `pg_dump`, a copy of every bucket, and a copy of module storage before each upgrade by hand.
|
||||
|
||||
### Split Dashboard and API origins
|
||||
|
||||
@@ -309,9 +387,13 @@ deployment:
|
||||
|
||||
- `pnpm deploy:config` validates the base, base + proxy, and base + proxy + tunnel with the example
|
||||
env files. CI runs it in `pnpm ci:verify`.
|
||||
- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the whole deployment end to end, against a
|
||||
platform-only image and a disposable PostgreSQL and SeaweedFS that exist only for that run. It
|
||||
follows the first boot above and checks the following through the proxy:
|
||||
- `bash scripts/check-image.sh` builds the image and proves it is generic: no `/opt/ekklesia/modules`,
|
||||
no module template, no `ekklesia.module.json` outside installed dependencies, an empty root-owned
|
||||
`/var/lib/ekklesia/modules`, and `EKKLESIA_MODULES_DIR` set. It then builds again from a checkout
|
||||
with a populated `modules/` directory and requires an identical runtime file system. CI runs it.
|
||||
- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the whole deployment end to end, against the
|
||||
generic image, empty module storage, and a disposable PostgreSQL and SeaweedFS that exist only for
|
||||
that run. It follows the first boot above and checks the following through the proxy:
|
||||
- readiness and the Dashboard;
|
||||
- a signed object URL fetched through the storage hostname, and the same image loaded twice over
|
||||
HTTP/2, the second time as a conditional request that must be a clean `304`;
|
||||
@@ -324,12 +406,23 @@ deployment:
|
||||
- Doctor.
|
||||
|
||||
`EKKLESIA_SMOKE_TOPOLOGY=split` runs the [split topology](#split-dashboard-and-api-origins)
|
||||
instead: an image with the repository's module template installed as a test-only module, every
|
||||
hostname's served and refused paths, exact CORS for the Dashboard's origin and none for others,
|
||||
and then a real headless Chromium (pinned by digest, driven over the DevTools protocol) that signs
|
||||
in on the Dashboard's hostname, checks the session cookie lives only on the API's, mounts the
|
||||
module's UI from the API's origin and sees its own credentialed requests succeed, mounts its display
|
||||
on the API's hostname with its service worker, and signs out.
|
||||
instead, on the **same generic image**, and also proves [Installed modules](#installed-modules)
|
||||
end to end:
|
||||
- the image boots with no module;
|
||||
- the repository's module template, packaged as a self-contained test module
|
||||
(`scripts/lib/test-module-package.Dockerfile`, test-only and not a package format), is installed
|
||||
into module storage, its migration is planned and explicitly applied, the API restarts, and the
|
||||
module reads data it owns;
|
||||
- every hostname's served and refused paths, exact CORS for the Dashboard's origin and none for
|
||||
others;
|
||||
- a real headless Chromium (pinned by digest, driven over the DevTools protocol) signs in on the
|
||||
Dashboard's hostname, checks the session cookie lives only on the API's, mounts the module's UI
|
||||
from the API's origin and sees its own credentialed requests succeed, mounts its display on the
|
||||
API's hostname with its service worker, and signs out;
|
||||
- the module is removed and the API restarted: Core still runs, the module's routes are gone,
|
||||
migration planning and applying succeed and report its retained history, and its data is intact;
|
||||
- the module is restored and the API restarted: its history validates strictly and it returns its
|
||||
previous data.
|
||||
|
||||
CI runs both topologies on every change.
|
||||
- **Test-only:** the smoke test runs Caddy with its internal CA (`local_certs`), because a test run
|
||||
@@ -350,40 +443,37 @@ is no second image and no second build.
|
||||
|
||||
### What it builds
|
||||
|
||||
The build context is an **already-assembled workspace**: this repository, plus zero or more module
|
||||
packages already present in `modules/<id>`, plus a `pnpm-lock.yaml` describing exactly that set.
|
||||
The Dockerfile clones nothing and chooses no modules. It builds what it is given. With no
|
||||
`modules/` directory, it builds the platform alone; CI builds that case on every change.
|
||||
The build context is **this repository**, and the image is the same for every installation
|
||||
([ADR 0048](../docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)). It
|
||||
contains **zero feature modules**: installed modules live in module storage outside it
|
||||
([Installed modules](#installed-modules)). No input is specific to an installation: no module list,
|
||||
no lockfile of its own, no branding, no configuration.
|
||||
|
||||
Assembling the context (checking out the platform at a release tag, each module at a pinned tag or
|
||||
commit, and the instance's own lockfile) belongs to the installation's instance composition, not to
|
||||
this file.
|
||||
|
||||
> **Being replaced.** [ADR 0048](../docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)
|
||||
> supersedes this composition model. The image will contain no modules and ignore `modules/` in its
|
||||
> build context. Installed modules will live in installation-owned storage
|
||||
> (`EKKLESIA_MODULES_DIR`, `/var/lib/ekklesia/modules` in the image), mounted read-only. Until that
|
||||
> lands, the image behaves exactly as described here. Do not start a new per-installation assembly.
|
||||
A `modules/` directory in the checkout never reaches the image. `.dockerignore` keeps it out of the
|
||||
build context, and the build refuses a context that still contains one. The module template is a
|
||||
workspace package, so it is built and validated with everything else, but neither its files nor its
|
||||
dependencies reach the runtime image. `scripts/check-image.sh` proves all of this on every change
|
||||
([Testing this deployment](#testing-this-deployment)).
|
||||
|
||||
| Stage | Does |
|
||||
| --- | --- |
|
||||
| `base` | `node:24.18.0-trixie-slim`, pinned by its multi-architecture index digest. Debian, so glibc, because module dependencies such as sharp ship glibc prebuilt binaries. Every stage starts here. |
|
||||
| `toolchain` | Enables pnpm through Corepack, at exactly the root `packageManager` version. |
|
||||
| `manifests` | Collects the lockfile, workspace configuration, `.npmrc`, and every workspace `package.json` at its repository path. |
|
||||
| `build` | `pnpm install --frozen-lockfile`, then `pnpm boundaries`, `pnpm modules:validate`, `pnpm migrations:validate`, and `pnpm build`, against exactly the modules in the context. Never uses host `node_modules`: `.dockerignore` excludes them. |
|
||||
| `prod-deps` | The same lockfile with `--prod`: production dependencies only. |
|
||||
| `runtime` | Production dependencies plus built outputs, owned by root and run as the unprivileged `node` user (uid 1000). No pnpm, no compiler, no curl. |
|
||||
| `manifests` | Collects the lockfile, workspace configuration, `.npmrc`, and the `package.json` of every platform package (not the module template's) at its repository path. |
|
||||
| `build` | Refuses a context containing `modules/`, then `pnpm install --frozen-lockfile`, `pnpm boundaries`, `pnpm modules:validate`, `pnpm migrations:validate`, and `pnpm build`. Never uses host `node_modules`: `.dockerignore` excludes them. |
|
||||
| `prod-deps` | The same lockfile with `--prod`, for the platform packages only: production dependencies only. |
|
||||
| `runtime` | Production dependencies plus built outputs, owned by root and run as the unprivileged `node` user (uid 1000), and an empty, root-owned `/var/lib/ekklesia/modules`. No pnpm, no compiler, no curl. |
|
||||
|
||||
The runtime keeps the repository layout at `/opt/ekklesia`, because that layout is how the platform
|
||||
finds its files:
|
||||
|
||||
- the API takes the repository root as three directories above `apps/api/dist/`, and reads
|
||||
`package.json`, `packages/module-sdk/package.json`, and `modules/` beneath it;
|
||||
`package.json` and `packages/module-sdk/package.json` beneath it;
|
||||
- every CLI takes its **working directory** as the root and also reads Core's migrations in
|
||||
`core/drizzle/`, so commands must run from `/opt/ekklesia`, the image's `WORKDIR`;
|
||||
- the Dashboard and display host files are read from `surfaces/shell/dist/browser/`;
|
||||
- each module directory is copied whole, minus `node_modules`, because each manifest names its
|
||||
own entrypoints, UI artifacts, and migrations directory.
|
||||
- installed modules are read from `EKKLESIA_MODULES_DIR`, `/var/lib/ekklesia/modules`, never from
|
||||
under `/opt/ekklesia`. With nothing mounted there, the image runs with no modules.
|
||||
|
||||
### Building
|
||||
|
||||
@@ -405,30 +495,37 @@ set `DOCKER_BUILDKIT=1` if your Docker CLI has no buildx plugin.
|
||||
No secret is a build argument, and none enters any layer. `.env` files are excluded from the build
|
||||
context entirely, and no registry credentials are needed.
|
||||
|
||||
**A module missing a production dependency fails the build, not the API.** The runtime receives
|
||||
production dependencies only, so a module that imports a package at run time but declares it only
|
||||
as a development or peer dependency would build and then fail at startup. `pnpm boundaries` runs
|
||||
in the build stage and rejects that module first
|
||||
(`runtime-imports-are-production-dependencies`, see
|
||||
[Repository boundaries](../docs/architecture/repository-boundaries.md)). The same applies to every
|
||||
other boundary rule: a module the platform's own CI would reject cannot be built into an image.
|
||||
### The runtime an installed module targets
|
||||
|
||||
**A platform-only image does not exercise native module dependencies.** With no modules in the
|
||||
context, nothing like sharp is installed, so the CI image build proves nothing about them. An
|
||||
instance build that includes such a module must smoke-test the built image itself: start it,
|
||||
confirm `/api/v1/health/ready` answers `200`, and exercise the module's native code path (for
|
||||
example, process one image).
|
||||
An installed module runs on the image's Node.js, inside the API process, from its own directory
|
||||
([ADR 0048](../docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)):
|
||||
|
||||
- **Node.js 24.18.0 on Debian 13, so glibc**, for the architecture the image was built for. A native
|
||||
dependency such as sharp must be built or downloaded for exactly that.
|
||||
- **Its own dependencies only.** Node resolves a module's imports from the module's directory, and
|
||||
the platform's `node_modules` is never on that path. A module whose server code imports something
|
||||
its directory does not contain stops the API from starting, with a sanitized
|
||||
`MODULE_RUNTIME_IMPORT_FAILED`.
|
||||
- **The platform's exact Drizzle version, for now.** Core runs a module's queries through its own
|
||||
Drizzle instance over tables the module defined with its own copy, so the two copies must be
|
||||
interchangeable. That is a constraint of the current implementation, not a permanent contract.
|
||||
|
||||
The repository's boundary checks (`pnpm boundaries`) prove only the source modules in a workspace.
|
||||
An installed module's conformance is its own CI's job, so check a module before installing it:
|
||||
`validate-modules.js` ([Operator commands](#operator-commands)) validates what can be checked on an
|
||||
installed directory, and the API's startup refuses anything it cannot load.
|
||||
|
||||
### Configuration
|
||||
|
||||
Configuration comes **only from the container environment**, never from a file inside the image.
|
||||
The image sets exactly three variables itself:
|
||||
The image sets exactly four variables itself:
|
||||
|
||||
| Variable | Image value | Why |
|
||||
| --- | --- | --- |
|
||||
| `NODE_ENV` | `production` | Stated rather than relied on. Anything other than `development` already validates as production. |
|
||||
| `EKKLESIA_API_HOST` | `0.0.0.0` | Reachable from other containers. Outside a container the platform default stays `127.0.0.1`. |
|
||||
| `EKKLESIA_API_PORT` | `3000` | The platform default, made explicit; `EXPOSE 3000`. |
|
||||
| `EKKLESIA_MODULES_DIR` | `/var/lib/ekklesia/modules` | Where installed modules are read from (ADR 0048). Outside a container, unset means the checkout's own `modules/`. Compose states the same value beside the mount. |
|
||||
|
||||
Everything else is the ordinary host configuration in
|
||||
[API § Required configuration](../docs/development/api.md#required-configuration), supplied by the
|
||||
@@ -449,8 +546,8 @@ Never publish the API's port to the host or the Internet. Only the proxy publish
|
||||
- **Read-only:** the image was verified under exactly these flags. The API reached `healthy`, and
|
||||
the migration and Doctor commands ran. Nothing wrote to `/tmp`, which stays mounted as headroom
|
||||
for module code that needs scratch space. The installation, `/home/node` included, is not
|
||||
writable, so anything that needs a writable path other than `/tmp` is a defect to report, not to
|
||||
work around.
|
||||
writable, and neither is module storage, which is mounted read-only. Anything that needs a
|
||||
writable path other than `/tmp` is a defect to report, not to work around.
|
||||
|
||||
### Health
|
||||
|
||||
@@ -488,14 +585,16 @@ reports direct mode for an API that trusts its proxy. The running API's own valu
|
||||
| Apply migrations | `docker compose run --rm migrate node core/dist/cli/apply-migrations.js` |
|
||||
| Doctor (read-only report) | `docker compose run --rm migrate node core/dist/cli/doctor.js` |
|
||||
| Create the initial owner (interactive) | `docker compose run --rm -it migrate node core/dist/cli/create-initial-owner.js --email owner@example.org` |
|
||||
| Validate the installed modules (read-only) | `docker compose run --rm migrate node core/dist/cli/validate-modules.js` |
|
||||
| Enable or disable a module | `docker compose run --rm migrate node core/dist/cli/set-module-enabled.js --enable --module <id>` (or `--disable`) |
|
||||
|
||||
The plan, apply, Doctor, and initial-owner commands are exercised by the smoke test; module
|
||||
enablement is not.
|
||||
Every command reads installed modules from the same `EKKLESIA_MODULES_DIR` the API does. The plan,
|
||||
apply, Doctor, and initial-owner commands are exercised by the smoke test, and so are installing,
|
||||
removing, and restoring a module; module validation and enablement are not.
|
||||
|
||||
### Size
|
||||
|
||||
The platform-only image is about 378 MB: the Debian slim Node base (about 230 MB), production
|
||||
The image is about 378 MB, and it contains no module: the Debian slim Node base (about 230 MB), production
|
||||
dependencies (about 141 MB), and built outputs (about 6 MB). A rebuild that reuses a warm pnpm store
|
||||
cache can come out about 21 MB larger. When esbuild's install script runs, it hard-links its
|
||||
`bin/esbuild` to the native binary it ships beside it; when pnpm restores esbuild from the store's
|
||||
|
||||
@@ -32,8 +32,20 @@ x-ekklesia-app: &ekklesia-app
|
||||
# without any storage TLS exception. The database CA travels separately, as PEM text in
|
||||
# EKKLESIA_DATABASE_CA, and is the only CA the database connection trusts.
|
||||
NODE_EXTRA_CA_CERTS: /run/ekklesia/tls/ca.crt
|
||||
# Installed modules (ADR 0048). The image sets the same value; stating it beside the mount below
|
||||
# keeps the variable and the mount target from ever drifting apart.
|
||||
EKKLESIA_MODULES_DIR: /var/lib/ekklesia/modules
|
||||
volumes:
|
||||
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/ca.crt:/run/ekklesia/tls/ca.crt:ro
|
||||
# Installation-owned module storage, outside the image (ADR 0048). Read-only: Ekklesia never
|
||||
# writes installed code. The host directory must already exist; a missing one stops the
|
||||
# container from starting instead of Compose quietly creating an empty, root-owned directory.
|
||||
- type: bind
|
||||
source: ${EKKLESIA_MODULES_HOST_DIR:-/var/lib/ekklesia/modules}
|
||||
target: /var/lib/ekklesia/modules
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
networks:
|
||||
data: {}
|
||||
storage: {}
|
||||
|
||||
Vendored
+6
@@ -15,6 +15,12 @@ EKKLESIA_IMAGE=
|
||||
EKKLESIA_ENV_DIR=/etc/ekklesia
|
||||
EKKLESIA_TLS_DIR=/etc/ekklesia/tls
|
||||
|
||||
# Installed modules (ADR 0048): the host directory mounted read-only into `api` and `migrate` at
|
||||
# /var/lib/ekklesia/modules. It must already exist; Compose never creates it. Create it once, owned
|
||||
# by root, before the first `up`:
|
||||
# sudo install -d -o root -g root -m 755 /var/lib/ekklesia/modules
|
||||
# EKKLESIA_MODULES_HOST_DIR=/var/lib/ekklesia/modules
|
||||
|
||||
# --- compose.proxy.yaml ---------------------------------------------------------------------------
|
||||
# The Ekklesia hostname and the storage hostname (signed object URLs). In the default same-origin
|
||||
# topology (Caddyfile.example) EKKLESIA_HOSTNAME serves the Dashboard, the API, and displays.
|
||||
|
||||
Vendored
+8
-3
@@ -6,9 +6,14 @@
|
||||
# Never commit the filled-in file. Generate secrets with scripts/gen-secrets.sh, and the database CA
|
||||
# line with scripts/init-tls.sh.
|
||||
#
|
||||
# The image already sets NODE_ENV=production, EKKLESIA_API_HOST=0.0.0.0, and EKKLESIA_API_PORT=3000.
|
||||
# Compose sets NODE_EXTRA_CA_CERTS. The proxy overlay sets the three EKKLESIA_CLIENT_ADDRESS_*
|
||||
# variables. None of those belong here.
|
||||
# The image already sets NODE_ENV=production, EKKLESIA_API_HOST=0.0.0.0, EKKLESIA_API_PORT=3000, and
|
||||
# EKKLESIA_MODULES_DIR=/var/lib/ekklesia/modules. Compose sets NODE_EXTRA_CA_CERTS and mounts module
|
||||
# storage at that path. The proxy overlay sets the three EKKLESIA_CLIENT_ADDRESS_* variables. None of
|
||||
# those belong here.
|
||||
#
|
||||
# Installed modules (ADR 0048) are read from EKKLESIA_MODULES_DIR, which must name the mounted
|
||||
# directory. Move the host directory with EKKLESIA_MODULES_HOST_DIR in deploy/.env instead.
|
||||
# EKKLESIA_MODULES_DIR=/var/lib/ekklesia/modules
|
||||
#
|
||||
# Values that are part of the reference topology are filled in; everything installation-specific is
|
||||
# blank. Full reference: docs/development/api.md#required-configuration.
|
||||
|
||||
Reference in New Issue
Block a user