Compare commits
26
Commits
v0.1.1
...
45d6506c10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45d6506c10 | ||
|
|
1119cc5fdf | ||
|
|
fbf8f6edac | ||
|
|
f122e910fc | ||
|
|
e36b70d8d4 | ||
|
|
4d28a82937 | ||
|
|
cefdde36a2 | ||
|
|
fc504eee0a | ||
|
|
5abc8be167 | ||
|
|
12dca3e8a4 | ||
|
|
0ec1755279 | ||
|
|
874deabea3 | ||
|
|
62951975da | ||
|
|
703d92436b | ||
|
|
43911a088d | ||
|
|
3a2f5d68f6 | ||
|
|
3827841205 | ||
|
|
2152eb036a | ||
|
|
e1ac1da416 | ||
|
|
dcbd7613ee | ||
|
|
dbb4648a35 | ||
|
|
f1d6501239 | ||
|
|
54ac6ab83f | ||
|
|
fa493365bd | ||
|
|
f50e8f4201 | ||
|
|
ff8a3e8097 |
@@ -2,6 +2,11 @@
|
|||||||
# `pnpm install --frozen-lockfile` and builds its own outputs, so nothing installed or built on the
|
# `pnpm install --frozen-lockfile` and builds its own outputs, so nothing installed or built on the
|
||||||
# host may enter the context, and no secret or environment file may enter any layer.
|
# host may enter the context, and no secret or environment file may enter any layer.
|
||||||
|
|
||||||
|
# Installed modules never enter the platform image (ADR 0048): an installation keeps them in its own
|
||||||
|
# module storage, mounted at run time. Excluding the checkout's `modules/` is what makes a build
|
||||||
|
# identical whether or not modules happen to be present in the source tree.
|
||||||
|
/modules
|
||||||
|
|
||||||
# Host-installed dependencies and build outputs: the image produces its own.
|
# Host-installed dependencies and build outputs: the image produces its own.
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/dist
|
**/dist
|
||||||
|
|||||||
@@ -10,6 +10,12 @@
|
|||||||
# The plaintext loopback DATABASE_URL below is therefore only usable with this set.
|
# The plaintext loopback DATABASE_URL below is therefore only usable with this set.
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Installed modules (ADR 0048). Unset, the checkout's own modules/ is used, as module development
|
||||||
|
# and the platform's tests expect. Set, it must be an absolute path to an existing directory;
|
||||||
|
# anything else stops the API and every module command with a sanitized error. The production
|
||||||
|
# image sets /var/lib/ekklesia/modules.
|
||||||
|
# EKKLESIA_MODULES_DIR=/absolute/path/to/modules
|
||||||
|
|
||||||
POSTGRES_DB=ekklesia
|
POSTGRES_DB=ekklesia
|
||||||
POSTGRES_USER=ekklesia
|
POSTGRES_USER=ekklesia
|
||||||
POSTGRES_PASSWORD=ekklesia-development-only
|
POSTGRES_PASSWORD=ekklesia-development-only
|
||||||
|
|||||||
+70
-29
@@ -1,18 +1,13 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
# IMPORTANT — the currently inventoried server is Gitea 1.24.2, which *ignores* several keys used
|
# The server runs Gitea 1.27.2 (docs/development/security.md, "What this server honours"):
|
||||||
# below. Per https://docs.gitea.com/1.24/usage/actions/comparison, its unsupported workflow syntax
|
|
||||||
# includes `permissions` / `jobs.<job_id>.permissions`, `concurrency`, and
|
|
||||||
# `jobs.<job_id>.timeout-minutes`. They are declared here deliberately, so the workflow is already
|
|
||||||
# correct on a supported release and on GitHub Actions, but on this server they are inert:
|
|
||||||
#
|
#
|
||||||
# * `permissions:` does NOT restrict the in-job `GITEA_TOKEN`. Job-token permission controls,
|
# * `concurrency:` is honoured: a newer push to a pull request cancels the run it supersedes, so a
|
||||||
# repository/owner defaults, and server-side maximum-permission clamping arrive in the 1.26
|
# cancelled run's own cleanup is never guaranteed (see the CI stack sweep in each stack job).
|
||||||
# feature line (https://docs.gitea.com/1.26/usage/actions/token-permissions). Until the server
|
# * `permissions:` is supported by 1.27 with Gitea's own scopes, but what the job token can do on
|
||||||
# is upgraded and configured, the token's authority is whatever the server grants — not what
|
# this server is not yet proven, so nothing here relies on it being read-only.
|
||||||
# this file requests.
|
# * `timeout-minutes:` is no longer listed as unsupported, but is not verified here either;
|
||||||
# * `concurrency:` and `timeout-minutes:` are NOT bounded-execution guarantees here. Cancellation,
|
# teardown relies on the stack sweep and the runner's own reaper.
|
||||||
# timeout, and teardown must be enforced by the trusted runner provisioner/reaper.
|
|
||||||
#
|
#
|
||||||
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action
|
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action
|
||||||
# itself. See docs/development/security.md.
|
# itself. See docs/development/security.md.
|
||||||
@@ -63,6 +58,9 @@ jobs:
|
|||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: infrastructure-smoke
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
@@ -72,13 +70,29 @@ jobs:
|
|||||||
# in this workflow pushes, so the credential is never needed after checkout.
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
|
- name: Sweep stale CI stacks from the runner
|
||||||
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
|
- name: Prove the sweep removes exactly what it should, against real Docker resources
|
||||||
|
run: bash scripts/test-ci-sweep.sh
|
||||||
|
|
||||||
- name: Exercise PostgreSQL and S3 infrastructure
|
- name: Exercise PostgreSQL and S3 infrastructure
|
||||||
run: bash scripts/smoke-infrastructure.sh
|
run: bash scripts/smoke-infrastructure.sh
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
migration-smoke:
|
migration-smoke:
|
||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: migration-smoke
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
@@ -88,6 +102,11 @@ jobs:
|
|||||||
# in this workflow pushes, so the credential is never needed after checkout.
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
|
- name: Sweep stale CI stacks from the runner
|
||||||
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
@@ -104,11 +123,18 @@ jobs:
|
|||||||
- name: Exercise transactional migrations against PostgreSQL
|
- name: Exercise transactional migrations against PostgreSQL
|
||||||
run: pnpm test:migrations:integration
|
run: pnpm test:migrations:integration
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
image-build:
|
image-build:
|
||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
env:
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: image-build
|
||||||
# Cache mounts in deploy/Dockerfile need BuildKit; this selects it even where the Docker CLI
|
# Cache mounts in deploy/Dockerfile need BuildKit; this selects it even where the Docker CLI
|
||||||
# has no buildx plugin.
|
# has no buildx plugin.
|
||||||
DOCKER_BUILDKIT: "1"
|
DOCKER_BUILDKIT: "1"
|
||||||
@@ -121,29 +147,32 @@ jobs:
|
|||||||
# in this workflow pushes, so the credential is never needed after checkout.
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
# The platform-only image (ADR 0044): a checkout has no `modules/`, so this proves the image
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
# builds, validates, and assembles with zero modules. Build only — no registry login, no
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
# credentials, no push, no tag anywhere but this runner's local image store.
|
- name: Sweep stale CI stacks from the runner
|
||||||
# Tagged per run and attempt, not only per commit: a pull request and a push of the same commit
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
# can build at once on one runner's Docker daemon, and neither may remove the other's image.
|
|
||||||
- name: Build the production image without modules
|
|
||||||
run: >-
|
|
||||||
docker build
|
|
||||||
--file deploy/Dockerfile
|
|
||||||
--build-arg REVISION="$GITHUB_SHA"
|
|
||||||
--tag ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Remove the image from the runner
|
# The one generic platform image (ADR 0044, ADR 0048), proved rather than assumed: it contains no
|
||||||
|
# module, no module template, and no module manifest; its module storage mount point is empty;
|
||||||
|
# EKKLESIA_MODULES_DIR names it; and a checkout with a populated modules/ directory builds an
|
||||||
|
# identical runtime file system. Build only — no registry login, no credentials, no push, no tag
|
||||||
|
# anywhere but this runner's local image store. Tagged per run and attempt, and labeled like
|
||||||
|
# every CI stack, so this job's final step removes the images even if a build is killed.
|
||||||
|
- name: Build the generic platform image and prove it holds no modules
|
||||||
|
run: bash scripts/check-image.sh
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
if: always()
|
if: always()
|
||||||
run: docker image rm --force ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}" || true
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
deploy-smoke:
|
deploy-smoke:
|
||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
# Both topologies (ADR 0045): same-origin, the default every installation gets, and split, which
|
# Both topologies (ADR 0045), each on the one generic image: same-origin, the default every
|
||||||
# adds a test-only module and drives a real headless browser across the two origins.
|
# installation gets, and split, which installs a test-only module into module storage, removes
|
||||||
|
# it, and restores it (ADR 0048), and drives a real headless browser across the two origins.
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -151,6 +180,8 @@ jobs:
|
|||||||
- same-origin
|
- same-origin
|
||||||
- split
|
- split
|
||||||
env:
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: deploy-smoke-${{ matrix.topology }}
|
||||||
DOCKER_BUILDKIT: "1"
|
DOCKER_BUILDKIT: "1"
|
||||||
EKKLESIA_SMOKE_TOPOLOGY: ${{ matrix.topology }}
|
EKKLESIA_SMOKE_TOPOLOGY: ${{ matrix.topology }}
|
||||||
steps:
|
steps:
|
||||||
@@ -162,10 +193,20 @@ jobs:
|
|||||||
# in this workflow pushes, so the credential is never needed after checkout.
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
# The reference deployment end to end (ADR 0044): a platform-only image, base + proxy with
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
|
- name: Sweep stale CI stacks from the runner
|
||||||
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
|
# The reference deployment end to end (ADR 0044): the generic platform image, base + proxy with
|
||||||
# Caddy's internal CA (test-only), an explicit migration apply against this job's own fresh
|
# Caddy's internal CA (test-only), an explicit migration apply against this job's own fresh
|
||||||
# PostgreSQL, then the API, Dashboard, storage delivery, client-address chain, and verify-full
|
# PostgreSQL, then the API, Dashboard, storage delivery, client-address chain, and verify-full
|
||||||
# database TLS through the proxy. Every secret is generated for this run and discarded with
|
# database TLS through the proxy. Every secret is generated for this run and discarded with
|
||||||
# it; nothing is pushed, published, or logged in to.
|
# it; nothing is pushed, published, or logged in to.
|
||||||
- name: Smoke-test the reference deployment (${{ matrix.topology }})
|
- name: Smoke-test the reference deployment (${{ matrix.topology }})
|
||||||
run: bash scripts/smoke-deploy.sh
|
run: bash scripts/smoke-deploy.sh
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ concurrency and release, exact destructive approvals, and sanitization against t
|
|||||||
adapter. A Core-owned `pnpm doctor` dashboard combines the existing static discovery and read-only
|
adapter. A Core-owned `pnpm doctor` dashboard combines the existing static discovery and read-only
|
||||||
migration-planning services into one concise readiness report.
|
migration-planning services into one concise readiness report.
|
||||||
|
|
||||||
|
**Ekklesia ships one generic image, and installed modules live outside it
|
||||||
|
([ADR 0048](docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)).** The
|
||||||
|
production image built from [`deploy/Dockerfile`](deploy/Dockerfile) contains zero feature modules
|
||||||
|
and is the same for every installation. Installed modules live in installation-owned module storage,
|
||||||
|
selected by `EKKLESIA_MODULES_DIR` (`/var/lib/ekklesia/modules` in the image, the checkout's
|
||||||
|
`modules/` when unset) and mounted read-only by the reference deployment. Each installed module is a
|
||||||
|
self-contained directory carrying its own production dependencies. Installing, updating, or removing
|
||||||
|
a module needs only a controlled restart and, when it has migrations, an explicit plan and apply.
|
||||||
|
Nothing is hot loaded. Removing a module's code keeps its data and its stored state; its applied
|
||||||
|
migration history is retained and reported, and blocks no other migration. Purge, a package format,
|
||||||
|
signing, and any installer or marketplace do not exist yet.
|
||||||
|
|
||||||
Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema
|
Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema
|
||||||
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
|
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
|
||||||
`ekklesia_core_verification`), a first-class Core-owned migration source that coexists with
|
`ekklesia_core_verification`), a first-class Core-owned migration source that coexists with
|
||||||
@@ -218,9 +230,10 @@ undeployed state, and the launch checklist in a single place. **CI is accepted a
|
|||||||
trusted-code-only boundary**: the self-hosted Gitea runner executes only code controlled and
|
trusted-code-only boundary**: the self-hosted Gitea runner executes only code controlled and
|
||||||
reviewed by trusted repository collaborators, and unknown or untrusted pull requests must not
|
reviewed by trusted repository collaborators, and unknown or untrusted pull requests must not
|
||||||
execute on it automatically. What remains is operational and cannot be proven from this repository —
|
execute on it automatically. What remains is operational and cannot be proven from this repository —
|
||||||
upgrading Gitea past 1.24.2 (which *ignores* the workflow's `permissions`, `concurrency`, and
|
configuring and proving job-token authority server-side (the server now runs Gitea 1.27.2, which
|
||||||
`timeout-minutes` declarations), clamping job-token authority server-side, enforcing timeout and
|
honours `concurrency` and documents `permissions`, but neither token limits nor `timeout-minutes` are
|
||||||
teardown in a trusted provisioner/reaper, and proving disposable runner isolation. Those are
|
verified on it yet), enforcing timeout and teardown in a trusted provisioner/reaper, and proving
|
||||||
|
disposable runner isolation. Those are
|
||||||
prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
|
prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
|
||||||
gates a **public launch**; moving future public contribution workflows to a service with isolated
|
gates a **public launch**; moving future public contribution workflows to a service with isolated
|
||||||
hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
|
hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/api",
|
"name": "@ekklesia/api",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
composePlatformPermissionRegistry,
|
composePlatformPermissionRegistry,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
|
resolveModulesDirectory,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
|
|
||||||
export type PlatformPermissionConfigurationErrorCode = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
|
export type PlatformPermissionConfigurationErrorCode = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
|
||||||
@@ -55,10 +56,18 @@ async function readPackageVersion(packagePath: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PrepareStartupModuleInventoryOptions {
|
export interface PrepareStartupModuleInventoryOptions {
|
||||||
|
/**
|
||||||
|
* The deployment's `EKKLESIA_MODULES_DIR`, raw, exactly as host configuration read it (ADR 0048).
|
||||||
|
* A production input, not a test seam: the executable entry point always passes it. Unset means
|
||||||
|
* the repository's own `modules/`; set means that absolute directory, which must exist. It is
|
||||||
|
* resolved by Core's one resolver, so an unusable value fails startup with a sanitized
|
||||||
|
* `ModuleDiscoveryError` before any pool exists.
|
||||||
|
*/
|
||||||
|
readonly configuredModulesDirectory?: string | undefined;
|
||||||
/** Test-only escape hatch: overrides the directory module discovery scans for installed
|
/** Test-only escape hatch: overrides the directory module discovery scans for installed
|
||||||
* modules. Never set by the executable entry point — production always discovers the real
|
* modules, taking precedence over `configuredModulesDirectory`, which is then never resolved.
|
||||||
* repository's `modules/` directory. Platform and Module SDK versions are always read from the
|
* Never set by the executable entry point. Platform and Module SDK versions are always read from
|
||||||
* real repository's own `package.json` files regardless of this override. */
|
* the real repository's own `package.json` files regardless of this override. */
|
||||||
readonly modulesDirectoryOverride?: string;
|
readonly modulesDirectoryOverride?: string;
|
||||||
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping the
|
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping the
|
||||||
* filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry`
|
* filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry`
|
||||||
@@ -109,6 +118,11 @@ export async function prepareStartupModuleInventory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const repositoryRoot = resolveRepositoryRoot();
|
const repositoryRoot = resolveRepositoryRoot();
|
||||||
|
// Chosen before anything is read, so an unusable configured directory fails first and alone.
|
||||||
|
const modulesDirectory =
|
||||||
|
options.modulesDirectoryOverride === undefined
|
||||||
|
? resolveModulesDirectory({ configured: options.configuredModulesDirectory, repositoryRoot })
|
||||||
|
: { modulesDirectory: options.modulesDirectoryOverride, modulesDirectoryRequired: false };
|
||||||
const [platformVersion, moduleSdkVersion] = await Promise.all([
|
const [platformVersion, moduleSdkVersion] = await Promise.all([
|
||||||
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
||||||
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
||||||
@@ -116,7 +130,7 @@ export async function prepareStartupModuleInventory(
|
|||||||
|
|
||||||
const modules = await discoverModules({
|
const modules = await discoverModules({
|
||||||
moduleSdkVersion,
|
moduleSdkVersion,
|
||||||
modulesDirectory: options.modulesDirectoryOverride ?? path.join(repositoryRoot, "modules"),
|
...modulesDirectory,
|
||||||
platformVersion,
|
platformVersion,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+16
-4
@@ -154,8 +154,12 @@ export interface StartApiOptions {
|
|||||||
* test needs somewhere to observe the operational failure the user is never shown.
|
* test needs somewhere to observe the operational failure the user is never shown.
|
||||||
*/
|
*/
|
||||||
readonly reportNotificationFailure?: IdentityNotificationReporter;
|
readonly reportNotificationFailure?: IdentityNotificationReporter;
|
||||||
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
|
/** The deployment's raw `EKKLESIA_MODULES_DIR` (ADR 0048), a production input the executable
|
||||||
* executable entry point. See `prepareStartupModuleInventory`. */
|
* entry point always passes from host configuration. See `prepareStartupModuleInventory`. */
|
||||||
|
readonly configuredModulesDirectory?: string | undefined;
|
||||||
|
/** Test-only escape hatch: overrides the directory module discovery scans, taking precedence
|
||||||
|
* over `configuredModulesDirectory`. Never set by the executable entry point. See
|
||||||
|
* `prepareStartupModuleInventory`. */
|
||||||
readonly modulesDirectoryOverride?: string;
|
readonly modulesDirectoryOverride?: string;
|
||||||
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping
|
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping
|
||||||
* filesystem discovery entirely — this exact list drives both permission composition and module
|
* filesystem discovery entirely — this exact list drives both permission composition and module
|
||||||
@@ -429,6 +433,7 @@ export async function startApi(
|
|||||||
options: StartApiOptions = {},
|
options: StartApiOptions = {},
|
||||||
): Promise<RunningApi> {
|
): Promise<RunningApi> {
|
||||||
const inventory = await prepareStartupModuleInventory({
|
const inventory = await prepareStartupModuleInventory({
|
||||||
|
configuredModulesDirectory: options.configuredModulesDirectory,
|
||||||
...(options.modulesDirectoryOverride === undefined
|
...(options.modulesDirectoryOverride === undefined
|
||||||
? {}
|
? {}
|
||||||
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
|
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
|
||||||
@@ -769,6 +774,7 @@ export function readConfigFromEnvironment(processEnvironment: HostEnvironment):
|
|||||||
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
|
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
|
||||||
readonly environment: "development" | "production";
|
readonly environment: "development" | "production";
|
||||||
readonly moduleSecrets: ModuleSecretSource;
|
readonly moduleSecrets: ModuleSecretSource;
|
||||||
|
readonly modulesDirectory: string | undefined;
|
||||||
} {
|
} {
|
||||||
const host = loadHostConfiguration(processEnvironment);
|
const host = loadHostConfiguration(processEnvironment);
|
||||||
return {
|
return {
|
||||||
@@ -784,13 +790,19 @@ export function readConfigFromEnvironment(processEnvironment: HostEnvironment):
|
|||||||
storage: host.storage,
|
storage: host.storage,
|
||||||
},
|
},
|
||||||
moduleSecrets: host.moduleSecrets,
|
moduleSecrets: host.moduleSecrets,
|
||||||
|
modulesDirectory: host.modulesDirectory,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function main(): Promise<void> {
|
export async function main(): Promise<void> {
|
||||||
const { environment, input, moduleSecrets } = readConfigFromEnvironment(process.env);
|
const { environment, input, moduleSecrets, modulesDirectory } = readConfigFromEnvironment(
|
||||||
|
process.env,
|
||||||
|
);
|
||||||
const config = createApiRuntimeConfig(input, { environment });
|
const config = createApiRuntimeConfig(input, { environment });
|
||||||
const running = await startApi(config, { moduleSecrets });
|
const running = await startApi(config, {
|
||||||
|
configuredModulesDirectory: modulesDirectory,
|
||||||
|
moduleSecrets,
|
||||||
|
});
|
||||||
|
|
||||||
const address = running.server.address();
|
const address = running.server.address();
|
||||||
const boundPort = address && typeof address === "object" ? address.port : config.port;
|
const boundPort = address && typeof address === "object" ? address.port : config.port;
|
||||||
|
|||||||
@@ -2590,6 +2590,7 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
const report = await diagnoseEkklesia({
|
const report = await diagnoseEkklesia({
|
||||||
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
@@ -2599,10 +2600,8 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
runtimeVersion: process.versions.node,
|
runtimeVersion: process.versions.node,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only the module-discovery and permission-composition checks are asserted here — this
|
// Only the module-discovery and permission-composition checks are asserted here; nothing else
|
||||||
// fixture's temporary directory has no `core/drizzle` sibling for migrations discovery to
|
// in the report bears on what this scenario proves.
|
||||||
// find, which is unrelated to what this scenario proves and would otherwise make the check
|
|
||||||
// fail for a reason that has nothing to do with permission composition.
|
|
||||||
const moduleCheck = report.checks.find((entry) => entry.id === "modules.discovery");
|
const moduleCheck = report.checks.find((entry) => entry.id === "modules.discovery");
|
||||||
const permissionsCheck = report.checks.find((entry) => entry.id === "modules.permissions");
|
const permissionsCheck = report.checks.find((entry) => entry.id === "modules.permissions");
|
||||||
expect(moduleCheck).toMatchObject({ status: "pass" });
|
expect(moduleCheck).toMatchObject({ status: "pass" });
|
||||||
|
|||||||
@@ -151,6 +151,13 @@ describe("host configuration parity between startApi and Doctor", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hands the API the same modules directory Doctor and the operator commands read", () => {
|
||||||
|
expect(readConfigFromEnvironment(environment()).modulesDirectory).toBeUndefined();
|
||||||
|
const env = environment({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" });
|
||||||
|
expect(readConfigFromEnvironment(env).modulesDirectory).toBe("/var/lib/ekklesia/modules");
|
||||||
|
expect(loadHostConfiguration(env).modulesDirectory).toBe("/var/lib/ekklesia/modules");
|
||||||
|
});
|
||||||
|
|
||||||
it("reads only this listener's own address and port outside the shared loader", () => {
|
it("reads only this listener's own address and port outside the shared loader", () => {
|
||||||
const config = apiView(
|
const config = apiView(
|
||||||
environment({ EKKLESIA_API_HOST: "0.0.0.0", EKKLESIA_API_PORT: "8080" }),
|
environment({ EKKLESIA_API_HOST: "0.0.0.0", EKKLESIA_API_PORT: "8080" }),
|
||||||
|
|||||||
@@ -1004,6 +1004,73 @@ describe("Ekklesia API module permission composition at startup", () => {
|
|||||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("discovers installed modules from the configured modules directory (ADR 0048)", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
await createFixtureModule(modulesDirectory, "incompatible", {
|
||||||
|
compatibility: { moduleSdk: "^0.1.0", platform: ">=99.0.0" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// The module that fails discovery lives only in the configured directory, so this rejection
|
||||||
|
// proves discovery read it rather than the repository's own `modules/`.
|
||||||
|
await expect(
|
||||||
|
startApi(validConfig(), { configuredModulesDirectory: modulesDirectory }),
|
||||||
|
).rejects.toBeInstanceOf(ModuleDiscoveryError);
|
||||||
|
expect(poolConstructor).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts with zero modules from an empty configured modules directory", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
|
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query: emptyQuery() };
|
||||||
|
});
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
const running = await startApi(validConfig(), { configuredModulesDirectory: modulesDirectory });
|
||||||
|
|
||||||
|
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||||
|
await running.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a relative path", "relative/private-mount-5b2a", "MODULES_DIRECTORY_INVALID"],
|
||||||
|
["a missing directory", "/nonexistent/private-mount-5b2a", "MODULES_DIRECTORY_UNREADABLE"],
|
||||||
|
])("fails closed on %s, before any pool exists, naming no path", async (_label, configured, code) => {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
await startApi(validConfig(), { configuredModulesDirectory: configured });
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(captured).toBeInstanceOf(ModuleDiscoveryError);
|
||||||
|
const issues = (captured as ModuleDiscoveryError).issues;
|
||||||
|
expect(issues.map((issue) => issue.code)).toEqual([code]);
|
||||||
|
expect(JSON.stringify(issues)).not.toContain("private-mount-5b2a");
|
||||||
|
expect((captured as Error).message).not.toContain("private-mount-5b2a");
|
||||||
|
expect(poolConstructor).not.toHaveBeenCalled();
|
||||||
|
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the test-only override take precedence over a configured directory", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
|
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query: emptyQuery() };
|
||||||
|
});
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
// The configured value is unusable; the override wins, so it is never even resolved.
|
||||||
|
const running = await startApi(validConfig(), {
|
||||||
|
configuredModulesDirectory: "relative/never-resolved",
|
||||||
|
modulesDirectoryOverride: modulesDirectory,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||||
|
await running.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not perform module discovery merely by importing the platform-permissions module", async () => {
|
it("does not perform module discovery merely by importing the platform-permissions module", async () => {
|
||||||
const module = await import("../src/platform-permissions.js");
|
const module = await import("../src/platform-permissions.js");
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/core",
|
"name": "@ekklesia/core",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
+23
-3
@@ -8,6 +8,7 @@ import {
|
|||||||
formatDoctorReport,
|
formatDoctorReport,
|
||||||
} from "../diagnostics/index.js";
|
} from "../diagnostics/index.js";
|
||||||
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
|
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
|
||||||
|
import { type ModulesDirectorySelection, resolveModulesDirectory } from "../modules/index.js";
|
||||||
|
|
||||||
interface ModuleSdkPackageDocument {
|
interface ModuleSdkPackageDocument {
|
||||||
readonly version?: unknown;
|
readonly version?: unknown;
|
||||||
@@ -25,8 +26,10 @@ interface RootPackageDocument extends ModuleSdkPackageDocument {
|
|||||||
*/
|
*/
|
||||||
export interface DoctorCommandMetadata {
|
export interface DoctorCommandMetadata {
|
||||||
readonly moduleSdkVersion: string;
|
readonly moduleSdkVersion: string;
|
||||||
readonly modulesDirectory: string;
|
|
||||||
readonly platformVersion: string;
|
readonly platformVersion: string;
|
||||||
|
/** Where the platform is installed: Core's migrations live beneath it, and so does the
|
||||||
|
* development default modules directory. Installed modules may live anywhere else. */
|
||||||
|
readonly repositoryRoot: string;
|
||||||
readonly runtimeRange: string;
|
readonly runtimeRange: string;
|
||||||
readonly runtimeVersion: string;
|
readonly runtimeVersion: string;
|
||||||
}
|
}
|
||||||
@@ -49,6 +52,22 @@ function requiredString(value: unknown): string {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The modules directory exactly as `startApi` would choose it. An unusable configured value is not
|
||||||
|
* thrown here, because Doctor must still produce a report: it is handed on as a required directory
|
||||||
|
* for discovery to refuse, which reports it as a sanitized module-discovery failure.
|
||||||
|
*/
|
||||||
|
function doctorModulesDirectory(
|
||||||
|
configured: string | undefined,
|
||||||
|
repositoryRoot: string,
|
||||||
|
): ModulesDirectorySelection {
|
||||||
|
try {
|
||||||
|
return resolveModulesDirectory({ configured, repositoryRoot });
|
||||||
|
} catch {
|
||||||
|
return { modulesDirectory: configured ?? "", modulesDirectoryRequired: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns one deployment environment into Doctor's inputs.
|
* Turns one deployment environment into Doctor's inputs.
|
||||||
*
|
*
|
||||||
@@ -69,6 +88,7 @@ export function createDoctorOptions(
|
|||||||
const host = loadHostConfiguration(environment);
|
const host = loadHostConfiguration(environment);
|
||||||
return {
|
return {
|
||||||
clientAddress: host.clientAddress,
|
clientAddress: host.clientAddress,
|
||||||
|
coreMigrationsDirectory: path.join(metadata.repositoryRoot, "core", "drizzle"),
|
||||||
databaseEnvironment: host.environment,
|
databaseEnvironment: host.environment,
|
||||||
databaseTransport: host.databaseTransport,
|
databaseTransport: host.databaseTransport,
|
||||||
databaseUrl: host.databaseUrl,
|
databaseUrl: host.databaseUrl,
|
||||||
@@ -76,7 +96,7 @@ export function createDoctorOptions(
|
|||||||
mailConfiguration: host.mail,
|
mailConfiguration: host.mail,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: metadata.moduleSdkVersion,
|
moduleSdkVersion: metadata.moduleSdkVersion,
|
||||||
modulesDirectory: metadata.modulesDirectory,
|
...doctorModulesDirectory(host.modulesDirectory, metadata.repositoryRoot),
|
||||||
platformVersion: metadata.platformVersion,
|
platformVersion: metadata.platformVersion,
|
||||||
},
|
},
|
||||||
moduleSecrets: host.moduleSecrets,
|
moduleSecrets: host.moduleSecrets,
|
||||||
@@ -97,8 +117,8 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
|
|||||||
const report = await diagnoseEkklesia(
|
const report = await diagnoseEkklesia(
|
||||||
createDoctorOptions(process.env, {
|
createDoctorOptions(process.env, {
|
||||||
moduleSdkVersion: requiredString(moduleSdkPackage.version),
|
moduleSdkVersion: requiredString(moduleSdkPackage.version),
|
||||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
|
||||||
platformVersion: requiredString(rootPackage.version),
|
platformVersion: requiredString(rootPackage.version),
|
||||||
|
repositoryRoot,
|
||||||
runtimeRange: requiredString(rootPackage.engines?.node),
|
runtimeRange: requiredString(rootPackage.engines?.node),
|
||||||
runtimeVersion: process.versions.node,
|
runtimeVersion: process.versions.node,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import type { HostEnvironment } from "../host/index.js";
|
||||||
import { discoverAllMigrations } from "../migrations/index.js";
|
import { discoverAllMigrations } from "../migrations/index.js";
|
||||||
import type { ModuleMigrationAggregate } from "../modules/index.js";
|
import type { ModuleMigrationAggregate } from "../modules/index.js";
|
||||||
|
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
|
||||||
|
|
||||||
interface PackageVersion {
|
interface PackageVersion {
|
||||||
readonly version?: unknown;
|
readonly version?: unknown;
|
||||||
@@ -15,9 +17,16 @@ async function readPackageVersion(packagePath: string): Promise<string> {
|
|||||||
return packageData.version;
|
return packageData.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core's migrations come from the platform (`<repositoryRoot>/core/drizzle`); installed modules'
|
||||||
|
* come from the configured modules directory, wherever it is (ADR 0048). Neither is ever derived
|
||||||
|
* from the other.
|
||||||
|
*/
|
||||||
export async function discoverRepositoryMigrations(
|
export async function discoverRepositoryMigrations(
|
||||||
repositoryRoot = process.cwd(),
|
repositoryRoot = process.cwd(),
|
||||||
|
environment: HostEnvironment = process.env,
|
||||||
): Promise<ModuleMigrationAggregate> {
|
): Promise<ModuleMigrationAggregate> {
|
||||||
|
const modulesDirectory = resolveCommandModulesDirectory(environment, repositoryRoot);
|
||||||
const [platformVersion, moduleSdkVersion] = await Promise.all([
|
const [platformVersion, moduleSdkVersion] = await Promise.all([
|
||||||
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
||||||
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
||||||
@@ -27,7 +36,7 @@ export async function discoverRepositoryMigrations(
|
|||||||
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
|
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion,
|
moduleSdkVersion,
|
||||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
...modulesDirectory,
|
||||||
platformVersion,
|
platformVersion,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { ModuleMigrationAggregate } from "../modules/index.js";
|
import type { ModuleMigrationAggregate } from "../modules/index.js";
|
||||||
import type { MigrationApplyResult, MigrationPlanResult } from "../migrations/index.js";
|
import type {
|
||||||
|
MigrationApplyResult,
|
||||||
|
MigrationPlanResult,
|
||||||
|
RetainedMigrationHistory,
|
||||||
|
} from "../migrations/index.js";
|
||||||
|
|
||||||
function plural(count: number, singular: string): string {
|
function plural(count: number, singular: string): string {
|
||||||
return count === 1 ? singular : `${singular}s`;
|
return count === 1 ? singular : `${singular}s`;
|
||||||
@@ -28,10 +32,19 @@ export function formatMigrationValidationReport(
|
|||||||
return Object.freeze(lines);
|
return Object.freeze(lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An absent module's applied history (ADR 0048): kept, reported, and never a reason to stop. */
|
||||||
|
function retainedLines(retained: readonly RetainedMigrationHistory[]): readonly string[] {
|
||||||
|
return retained.map(
|
||||||
|
(module) =>
|
||||||
|
`- [retained] ${module.moduleId}: ${module.appliedCount} applied ${plural(module.appliedCount, "migration")} kept; the module is not installed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly string[] {
|
export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly string[] {
|
||||||
const readyCount = plan.pendingCount - plan.approvalRequiredCount;
|
const readyCount = plan.pendingCount - plan.approvalRequiredCount;
|
||||||
|
const retainedSummary = plan.retained.length === 0 ? "" : `, ${plan.retained.length} retained`;
|
||||||
const lines = [
|
const lines = [
|
||||||
`Migration plan (${plan.appliedCount} applied, ${readyCount} pending, ${plan.approvalRequiredCount} approval-blocked):`,
|
`Migration plan (${plan.appliedCount} applied, ${readyCount} pending, ${plan.approvalRequiredCount} approval-blocked${retainedSummary}):`,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const entry of plan.entries) {
|
for (const entry of plan.entries) {
|
||||||
@@ -40,6 +53,7 @@ export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly s
|
|||||||
if (plan.entries.length === 0) {
|
if (plan.entries.length === 0) {
|
||||||
lines.push("- No installed module migrations.");
|
lines.push("- No installed module migrations.");
|
||||||
}
|
}
|
||||||
|
lines.push(...retainedLines(plan.retained));
|
||||||
lines.push(
|
lines.push(
|
||||||
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
|
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
|
||||||
);
|
);
|
||||||
@@ -49,7 +63,10 @@ export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly s
|
|||||||
|
|
||||||
export function formatMigrationApplyReport(result: MigrationApplyResult): readonly string[] {
|
export function formatMigrationApplyReport(result: MigrationApplyResult): readonly string[] {
|
||||||
if (result.noOp) {
|
if (result.noOp) {
|
||||||
return Object.freeze(["Migration apply completed: database is already current (no changes)."]);
|
return Object.freeze([
|
||||||
|
"Migration apply completed: database is already current (no changes).",
|
||||||
|
...retainedLines(result.retained),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
@@ -58,5 +75,6 @@ export function formatMigrationApplyReport(result: MigrationApplyResult): readon
|
|||||||
for (const migration of result.applied) {
|
for (const migration of result.applied) {
|
||||||
lines.push(`- ${migration.identity} sha256=${migration.checksum}`);
|
lines.push(`- ${migration.identity} sha256=${migration.checksum}`);
|
||||||
}
|
}
|
||||||
|
lines.push(...retainedLines(result.retained));
|
||||||
return Object.freeze(lines);
|
return Object.freeze(lines);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
|
||||||
|
import { type ModulesDirectorySelection, resolveModulesDirectory } from "../modules/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The modules directory an operator command discovers, read through the same host configuration
|
||||||
|
* `startApi` and Doctor use and resolved by the one Core resolver (ADR 0048). Throws the resolver's
|
||||||
|
* sanitized `ModuleDiscoveryError` for an unusable value, which every command already reports.
|
||||||
|
*/
|
||||||
|
export function resolveCommandModulesDirectory(
|
||||||
|
environment: HostEnvironment,
|
||||||
|
repositoryRoot: string,
|
||||||
|
): ModulesDirectorySelection {
|
||||||
|
return resolveModulesDirectory({
|
||||||
|
configured: loadHostConfiguration(environment).modulesDirectory,
|
||||||
|
repositoryRoot,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
resolveModuleEligibility,
|
resolveModuleEligibility,
|
||||||
} from "../modules/index.js";
|
} from "../modules/index.js";
|
||||||
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
||||||
|
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enables or disables one installed module, out of band from the running API.
|
* Enables or disables one installed module, out of band from the running API.
|
||||||
@@ -99,7 +100,7 @@ async function main(): Promise<void> {
|
|||||||
// configurable here, which is the case this command exists for.
|
// configurable here, which is the case this command exists for.
|
||||||
const discovered = await discoverModules({
|
const discovered = await discoverModules({
|
||||||
moduleSdkVersion,
|
moduleSdkVersion,
|
||||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
...resolveCommandModulesDirectory(process.env, repositoryRoot),
|
||||||
platformVersion,
|
platformVersion,
|
||||||
});
|
});
|
||||||
if (!discovered.some((module) => module.manifest.id === moduleId)) {
|
if (!discovered.some((module) => module.manifest.id === moduleId)) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ModulePermissionCompositionError,
|
ModulePermissionCompositionError,
|
||||||
ModuleSchemaDiscoveryError,
|
ModuleSchemaDiscoveryError,
|
||||||
} from "../modules/index.js";
|
} from "../modules/index.js";
|
||||||
|
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
|
||||||
|
|
||||||
interface PackageVersion {
|
interface PackageVersion {
|
||||||
version?: unknown;
|
version?: unknown;
|
||||||
@@ -30,7 +31,7 @@ async function main(): Promise<void> {
|
|||||||
]);
|
]);
|
||||||
const schemaAggregate = await discoverModuleSchemas({
|
const schemaAggregate = await discoverModuleSchemas({
|
||||||
moduleSdkVersion,
|
moduleSdkVersion,
|
||||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
...resolveCommandModulesDirectory(process.env, repositoryRoot),
|
||||||
platformVersion,
|
platformVersion,
|
||||||
});
|
});
|
||||||
const modules = schemaAggregate.discoveredModules;
|
const modules = schemaAggregate.discoveredModules;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import path from "node:path";
|
|
||||||
import {
|
import {
|
||||||
capabilityIdSchema,
|
capabilityIdSchema,
|
||||||
EMPTY_MODULE_SECRET_SOURCE,
|
EMPTY_MODULE_SECRET_SOURCE,
|
||||||
@@ -43,6 +42,7 @@ import {
|
|||||||
} from "../mail/index.js";
|
} from "../mail/index.js";
|
||||||
import {
|
import {
|
||||||
createPostgresMigrationDatabase,
|
createPostgresMigrationDatabase,
|
||||||
|
type DiscoverAllMigrationsOptions,
|
||||||
discoverAllMigrations,
|
discoverAllMigrations,
|
||||||
type MigrationDatabase,
|
type MigrationDatabase,
|
||||||
type MigrationPlanResult,
|
type MigrationPlanResult,
|
||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
isConfigurationBlockedDetail,
|
isConfigurationBlockedDetail,
|
||||||
isStorageBlockedDetail,
|
isStorageBlockedDetail,
|
||||||
type ModuleConfigurationSettings,
|
type ModuleConfigurationSettings,
|
||||||
|
ModuleDiscoveryError,
|
||||||
type ModuleDiscoveryOptions,
|
type ModuleDiscoveryOptions,
|
||||||
type ModuleEligibility,
|
type ModuleEligibility,
|
||||||
type ModuleLifecycleConfiguration,
|
type ModuleLifecycleConfiguration,
|
||||||
@@ -126,6 +127,9 @@ export interface DoctorIdentityConfigurationOptions {
|
|||||||
|
|
||||||
export interface DoctorOptions {
|
export interface DoctorOptions {
|
||||||
readonly clientAddress?: ClientAddressConfigInput | undefined;
|
readonly clientAddress?: ClientAddressConfigInput | undefined;
|
||||||
|
/** Core's own migrations, where the platform is installed. Never derived from the modules
|
||||||
|
* directory: installed modules live in installation-owned storage, anywhere at all (ADR 0048). */
|
||||||
|
readonly coreMigrationsDirectory: string;
|
||||||
/** The environment the executable resolved, used only for transport policy evaluation; identity
|
/** The environment the executable resolved, used only for transport policy evaluation; identity
|
||||||
* configuration keeps its own `identityConfiguration.environment` for the same purpose. */
|
* configuration keeps its own `identityConfiguration.environment` for the same purpose. */
|
||||||
readonly databaseEnvironment?: DatabaseTransportEnvironment | undefined;
|
readonly databaseEnvironment?: DatabaseTransportEnvironment | undefined;
|
||||||
@@ -170,7 +174,7 @@ export interface DoctorDependencies {
|
|||||||
options: PostgresMigrationDatabaseOptions,
|
options: PostgresMigrationDatabaseOptions,
|
||||||
) => MigrationDatabase;
|
) => MigrationDatabase;
|
||||||
readonly discoverMigrations: (
|
readonly discoverMigrations: (
|
||||||
options: ModuleDiscoveryOptions,
|
options: DiscoverAllMigrationsOptions,
|
||||||
) => Promise<ModuleMigrationAggregate>;
|
) => Promise<ModuleMigrationAggregate>;
|
||||||
readonly discoverModules: (
|
readonly discoverModules: (
|
||||||
options: ModuleDiscoveryOptions,
|
options: ModuleDiscoveryOptions,
|
||||||
@@ -207,15 +211,6 @@ const checksumPattern = /^[a-f0-9]{64}$/u;
|
|||||||
const migrationIdentityPattern =
|
const migrationIdentityPattern =
|
||||||
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*:\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u;
|
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*:\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u;
|
||||||
|
|
||||||
async function discoverAllMigrationsForRuntime(
|
|
||||||
options: ModuleDiscoveryOptions,
|
|
||||||
): Promise<ModuleMigrationAggregate> {
|
|
||||||
return discoverAllMigrations({
|
|
||||||
coreMigrationsDirectory: path.join(path.dirname(options.modulesDirectory), "core", "drizzle"),
|
|
||||||
moduleDiscovery: options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Opens its own short-lived connection, reads, and always closes it — Doctor never leaves a pool
|
/** Opens its own short-lived connection, reads, and always closes it — Doctor never leaves a pool
|
||||||
* behind, and never shares one with the migration planner's, whose lifetime it does not control. */
|
* behind, and never shares one with the migration planner's, whose lifetime it does not control. */
|
||||||
async function readModuleLifecycleForRuntime(
|
async function readModuleLifecycleForRuntime(
|
||||||
@@ -273,7 +268,7 @@ async function probeMailForRuntime(config: MailProviderConfig): Promise<void> {
|
|||||||
const productionDependencies: DoctorDependencies = Object.freeze({
|
const productionDependencies: DoctorDependencies = Object.freeze({
|
||||||
composePermissions: composePlatformPermissionRegistry,
|
composePermissions: composePlatformPermissionRegistry,
|
||||||
createMigrationDatabase: createPostgresMigrationDatabase,
|
createMigrationDatabase: createPostgresMigrationDatabase,
|
||||||
discoverMigrations: discoverAllMigrationsForRuntime,
|
discoverMigrations: discoverAllMigrations,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
discoverSchemas: discoverModuleSchemas,
|
discoverSchemas: discoverModuleSchemas,
|
||||||
planMigrations: planModuleMigrations,
|
planMigrations: planModuleMigrations,
|
||||||
@@ -633,11 +628,23 @@ function storageBindingsCheck(
|
|||||||
details,
|
details,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
function moduleDiscoveryFailureDetail(error: unknown): string {
|
||||||
|
if (!(error instanceof ModuleDiscoveryError)) {
|
||||||
|
return "Static module discovery could not be completed.";
|
||||||
|
}
|
||||||
|
// The modules directory itself, rather than anything in it (ADR 0048). Fixed wording, never the
|
||||||
|
// configured value: a path is machine detail.
|
||||||
|
if (error.issues.some((issue) => issue.code === "MODULES_DIRECTORY_INVALID")) {
|
||||||
|
return "The configured modules directory is not usable.";
|
||||||
|
}
|
||||||
|
if (error.issues.some((issue) => issue.code === "MODULES_DIRECTORY_UNREADABLE")) {
|
||||||
|
return "The configured modules directory could not be read.";
|
||||||
|
}
|
||||||
|
return "The installed module inventory is invalid.";
|
||||||
|
}
|
||||||
|
|
||||||
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
|
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
|
||||||
const details =
|
const details = [moduleDiscoveryFailureDetail(error)];
|
||||||
error instanceof Error && error.name === "ModuleDiscoveryError"
|
|
||||||
? ["The installed module inventory is invalid."]
|
|
||||||
: ["Static module discovery could not be completed."];
|
|
||||||
return freezeCheck("modules.discovery", "failure", "Module discovery failed", details);
|
return freezeCheck("modules.discovery", "failure", "Module discovery failed", details);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,6 +740,14 @@ function migrationPlanCheck(plan: MigrationPlanResult): DoctorCheck {
|
|||||||
}
|
}
|
||||||
return `${entry.status} · ${safeMigrationReference(entry.identity, entry.checksum)}`;
|
return `${entry.status} · ${safeMigrationReference(entry.identity, entry.checksum)}`;
|
||||||
}),
|
}),
|
||||||
|
// An absent module's applied history (ADR 0048) is information, never attention: removing a
|
||||||
|
// module's code keeps its data, and nothing here is pending.
|
||||||
|
...plan.retained.map((module) => {
|
||||||
|
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(module.moduleId)) {
|
||||||
|
throw new TypeError("Unsafe migration plan diagnostic value.");
|
||||||
|
}
|
||||||
|
return `retained · ${module.moduleId} · ${module.appliedCount} applied ${plural(module.appliedCount, "migration")} kept; the module is not installed`;
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (pendingCount + blockedCount === 0) {
|
if (pendingCount + blockedCount === 0) {
|
||||||
@@ -905,17 +920,25 @@ function databaseTransportCheck(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Doctor reads only its own process's environment, so it says so: run as the reference
|
||||||
|
* deployment's `migrate`, it is given the API's values by the proxy overlay, but run any other way
|
||||||
|
* it may be checking a configuration the API is not running. */
|
||||||
|
const CLIENT_ADDRESS_SCOPE =
|
||||||
|
"Checked: the environment Doctor runs with. In the reference deployment `migrate` is given the API's client-address values; a running API's own value is shown by `docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE`.";
|
||||||
|
|
||||||
function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
|
function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
|
||||||
try {
|
try {
|
||||||
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
|
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
|
||||||
if (config.mode === "direct") {
|
if (config.mode === "direct") {
|
||||||
return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [
|
return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [
|
||||||
"Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.",
|
"Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.",
|
||||||
|
CLIENT_ADDRESS_SCOPE,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [
|
return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [
|
||||||
`Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`,
|
`Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`,
|
||||||
"The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.",
|
"The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.",
|
||||||
|
CLIENT_ADDRESS_SCOPE,
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code =
|
const code =
|
||||||
@@ -926,7 +949,7 @@ function clientAddressCheck(input: ClientAddressConfigInput | undefined): Doctor
|
|||||||
"identity.client-address",
|
"identity.client-address",
|
||||||
"failure",
|
"failure",
|
||||||
"Client-address configuration invalid",
|
"Client-address configuration invalid",
|
||||||
[`[${code}] Configured client-address values failed validation.`],
|
[`[${code}] Configured client-address values failed validation.`, CLIENT_ADDRESS_SCOPE],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1124,7 +1147,10 @@ export async function diagnoseEkklesia(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
migrations = await dependencies.discoverMigrations(options.moduleDiscovery);
|
migrations = await dependencies.discoverMigrations({
|
||||||
|
coreMigrationsDirectory: options.coreMigrationsDirectory,
|
||||||
|
moduleDiscovery: options.moduleDiscovery,
|
||||||
|
});
|
||||||
const inventory = migrations.migrations.map((migration) =>
|
const inventory = migrations.migrations.map((migration) =>
|
||||||
safeMigrationReference(migration.identity, migration.checksum),
|
safeMigrationReference(migration.identity, migration.checksum),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ export interface HostConfiguration {
|
|||||||
/** The deployment's module secrets, resolvable only under names Core derives from a module's own
|
/** The deployment's module secrets, resolvable only under names Core derives from a module's own
|
||||||
* declared keys — there is no arbitrary variable lookup. */
|
* declared keys — there is no arbitrary variable lookup. */
|
||||||
readonly moduleSecrets: ModuleSecretSource;
|
readonly moduleSecrets: ModuleSecretSource;
|
||||||
|
/** `EKKLESIA_MODULES_DIR`, raw and unvalidated: `undefined` only when the variable is unset. Every
|
||||||
|
* executable passes it to `resolveModulesDirectory`, the one place it is interpreted (ADR 0048),
|
||||||
|
* so a relative or blank value is refused there rather than repaired here. */
|
||||||
|
readonly modulesDirectory: string | undefined;
|
||||||
readonly storage: StorageProviderConfigInput;
|
readonly storage: StorageProviderConfigInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +148,7 @@ export function loadHostConfiguration(environment: HostEnvironment): HostConfigu
|
|||||||
username: environment.EKKLESIA_SMTP_USERNAME,
|
username: environment.EKKLESIA_SMTP_USERNAME,
|
||||||
},
|
},
|
||||||
moduleSecrets: createEnvironmentModuleSecretSource(environment),
|
moduleSecrets: createEnvironmentModuleSecretSource(environment),
|
||||||
|
modulesDirectory: environment.EKKLESIA_MODULES_DIR,
|
||||||
// The `S3_*` names ADR 0004 already established for this repository's Compose stack. Absent
|
// The `S3_*` names ADR 0004 already established for this repository's Compose stack. Absent
|
||||||
// endpoint means this installation has no object storage, which is a complete deployment
|
// endpoint means this installation has no object storage, which is a complete deployment
|
||||||
// (ADR 0030).
|
// (ADR 0030).
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
DiscoveredModuleMigration,
|
DiscoveredModuleMigration,
|
||||||
ModuleMigrationAggregate,
|
ModuleMigrationAggregate,
|
||||||
} from "../modules/migration-discovery.js";
|
} from "../modules/migration-discovery.js";
|
||||||
|
import { CORE_MIGRATION_SOURCE_ID } from "./core-source.js";
|
||||||
|
|
||||||
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 10_000;
|
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
@@ -86,11 +87,24 @@ export interface MigrationPlanEntry {
|
|||||||
readonly tag: string;
|
readonly tag: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applied history kept for a module that is not installed (ADR 0048): the module id and how many of
|
||||||
|
* its migrations were applied. It is reported, never matched against an inventory, and never a
|
||||||
|
* reason to refuse planning or applying anything else.
|
||||||
|
*/
|
||||||
|
export interface RetainedMigrationHistory {
|
||||||
|
readonly appliedCount: number;
|
||||||
|
readonly moduleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MigrationPlanResult {
|
export interface MigrationPlanResult {
|
||||||
readonly appliedCount: number;
|
readonly appliedCount: number;
|
||||||
readonly approvalRequiredCount: number;
|
readonly approvalRequiredCount: number;
|
||||||
readonly entries: readonly MigrationPlanEntry[];
|
readonly entries: readonly MigrationPlanEntry[];
|
||||||
readonly pendingCount: number;
|
readonly pendingCount: number;
|
||||||
|
/** Absent modules' applied history, ordered by module id; empty when every applied migration
|
||||||
|
* belongs to Core or an installed module. */
|
||||||
|
readonly retained: readonly RetainedMigrationHistory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppliedMigrationResult {
|
export interface AppliedMigrationResult {
|
||||||
@@ -105,6 +119,8 @@ export interface MigrationApplyResult {
|
|||||||
readonly applied: readonly AppliedMigrationResult[];
|
readonly applied: readonly AppliedMigrationResult[];
|
||||||
readonly appliedCount: number;
|
readonly appliedCount: number;
|
||||||
readonly noOp: boolean;
|
readonly noOp: boolean;
|
||||||
|
/** The same report as `MigrationPlanResult.retained`, read under the migration lock. */
|
||||||
|
readonly retained: readonly RetainedMigrationHistory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanModuleMigrationsOptions {
|
export interface PlanModuleMigrationsOptions {
|
||||||
@@ -204,10 +220,38 @@ function validateHistoryRecordShape(record: AppliedMigrationRecord): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ValidatedAppliedHistory {
|
||||||
|
/** Identities of applied migrations that belong to Core or an installed module. */
|
||||||
|
readonly applied: ReadonlySet<string>;
|
||||||
|
readonly retained: readonly RetainedMigrationHistory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every module id this inventory treats as installed: each discovered module, whether or not it
|
||||||
|
* owns migrations, every module that contributed migrations, and always Core. A module counts as
|
||||||
|
* absent only when it is none of these, so a module that is installed but no longer ships its old
|
||||||
|
* migrations stays strict, and Core's history can never be classified as an absent module's.
|
||||||
|
*/
|
||||||
|
function installedMigrationSources(aggregate: ModuleMigrationAggregate): ReadonlySet<string> {
|
||||||
|
return new Set<string>([
|
||||||
|
CORE_MIGRATION_SOURCE_ID,
|
||||||
|
...aggregate.discoveredModules.map((module) => module.manifest.id),
|
||||||
|
...aggregate.modules.map((module) => module.moduleId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezeRetained(counts: ReadonlyMap<string, number>): readonly RetainedMigrationHistory[] {
|
||||||
|
return Object.freeze(
|
||||||
|
[...counts.entries()]
|
||||||
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||||
|
.map(([moduleId, appliedCount]) => Object.freeze({ appliedCount, moduleId })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function validateAppliedHistoryUnchecked(
|
function validateAppliedHistoryUnchecked(
|
||||||
aggregate: ModuleMigrationAggregate,
|
aggregate: ModuleMigrationAggregate,
|
||||||
history: readonly AppliedMigrationRecord[],
|
history: readonly AppliedMigrationRecord[],
|
||||||
): ReadonlySet<string> {
|
): ValidatedAppliedHistory {
|
||||||
if (!Array.isArray(history)) {
|
if (!Array.isArray(history)) {
|
||||||
throw runnerError("MIGRATION_HISTORY_INVALID");
|
throw runnerError("MIGRATION_HISTORY_INVALID");
|
||||||
}
|
}
|
||||||
@@ -215,6 +259,9 @@ function validateAppliedHistoryUnchecked(
|
|||||||
const inventory = new Map(
|
const inventory = new Map(
|
||||||
aggregate.migrations.map((migration) => [migration.identity, migration]),
|
aggregate.migrations.map((migration) => [migration.identity, migration]),
|
||||||
);
|
);
|
||||||
|
const installed = installedMigrationSources(aggregate);
|
||||||
|
const applied = new Set<string>();
|
||||||
|
const retained = new Map<string, number>();
|
||||||
const seenOrders = new Set<number>();
|
const seenOrders = new Set<number>();
|
||||||
const seenIdentities = new Set<string>();
|
const seenIdentities = new Set<string>();
|
||||||
const seenModuleIndexes = new Set<string>();
|
const seenModuleIndexes = new Set<string>();
|
||||||
@@ -240,7 +287,19 @@ function validateAppliedHistoryUnchecked(
|
|||||||
|
|
||||||
const migration = knownMigration(inventory, record.identity);
|
const migration = knownMigration(inventory, record.identity);
|
||||||
if (!migration) {
|
if (!migration) {
|
||||||
throw runnerError("MIGRATION_UNKNOWN_APPLIED");
|
if (installed.has(record.moduleId)) {
|
||||||
|
// Core, or a module that is installed now: its history is strict, exactly as ADR 0009
|
||||||
|
// established. A migration it no longer ships is a downgrade or a rewritten package.
|
||||||
|
throw runnerError("MIGRATION_UNKNOWN_APPLIED");
|
||||||
|
}
|
||||||
|
// An absent module's history (ADR 0048): retained and reported, never repaired, deleted, or
|
||||||
|
// matched against anything. It must still name its own module and tag, so a corrupt row is
|
||||||
|
// never mistaken for one.
|
||||||
|
if (record.identity !== `${record.moduleId}:${record.tag}`) {
|
||||||
|
throw runnerError("MIGRATION_HISTORY_INVALID");
|
||||||
|
}
|
||||||
|
retained.set(record.moduleId, (retained.get(record.moduleId) ?? 0) + 1);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
record.moduleId !== migration.moduleId ||
|
record.moduleId !== migration.moduleId ||
|
||||||
@@ -252,11 +311,12 @@ function validateAppliedHistoryUnchecked(
|
|||||||
if (record.checksum !== migration.checksum) {
|
if (record.checksum !== migration.checksum) {
|
||||||
throw runnerError("MIGRATION_CHECKSUM_MISMATCH", migration.identity);
|
throw runnerError("MIGRATION_CHECKSUM_MISMATCH", migration.identity);
|
||||||
}
|
}
|
||||||
|
applied.add(record.identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const module of aggregate.modules) {
|
for (const module of aggregate.modules) {
|
||||||
const applied = history.filter((record) => record.moduleId === module.moduleId);
|
const moduleHistory = history.filter((record) => record.moduleId === module.moduleId);
|
||||||
for (const [position, record] of applied.entries()) {
|
for (const [position, record] of moduleHistory.entries()) {
|
||||||
if (record.identity !== module.migrations[position]?.identity) {
|
if (record.identity !== module.migrations[position]?.identity) {
|
||||||
const migration = knownMigration(inventory, record.identity);
|
const migration = knownMigration(inventory, record.identity);
|
||||||
throw runnerError("MIGRATION_HISTORY_INVALID", migration?.identity);
|
throw runnerError("MIGRATION_HISTORY_INVALID", migration?.identity);
|
||||||
@@ -264,13 +324,13 @@ function validateAppliedHistoryUnchecked(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return seenIdentities;
|
return Object.freeze({ applied, retained: freezeRetained(retained) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateAppliedHistory(
|
function validateAppliedHistory(
|
||||||
aggregate: ModuleMigrationAggregate,
|
aggregate: ModuleMigrationAggregate,
|
||||||
history: readonly AppliedMigrationRecord[],
|
history: readonly AppliedMigrationRecord[],
|
||||||
): ReadonlySet<string> {
|
): ValidatedAppliedHistory {
|
||||||
try {
|
try {
|
||||||
return validateAppliedHistoryUnchecked(aggregate, history);
|
return validateAppliedHistoryUnchecked(aggregate, history);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -285,7 +345,7 @@ function planFromHistory(
|
|||||||
aggregate: ModuleMigrationAggregate,
|
aggregate: ModuleMigrationAggregate,
|
||||||
history: readonly AppliedMigrationRecord[],
|
history: readonly AppliedMigrationRecord[],
|
||||||
): MigrationPlanResult {
|
): MigrationPlanResult {
|
||||||
const applied = validateAppliedHistory(aggregate, history);
|
const { applied, retained } = validateAppliedHistory(aggregate, history);
|
||||||
const entries = Object.freeze(
|
const entries = Object.freeze(
|
||||||
aggregate.migrations.map((migration): MigrationPlanEntry => {
|
aggregate.migrations.map((migration): MigrationPlanEntry => {
|
||||||
const isApplied = applied.has(migration.identity);
|
const isApplied = applied.has(migration.identity);
|
||||||
@@ -314,6 +374,7 @@ function planFromHistory(
|
|||||||
approvalRequiredCount,
|
approvalRequiredCount,
|
||||||
entries,
|
entries,
|
||||||
pendingCount: entries.length - appliedCount,
|
pendingCount: entries.length - appliedCount,
|
||||||
|
retained,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,14 +533,14 @@ export async function applyModuleMigrations(
|
|||||||
} catch {
|
} catch {
|
||||||
throw runnerError("MIGRATION_HISTORY_READ_FAILED");
|
throw runnerError("MIGRATION_HISTORY_READ_FAILED");
|
||||||
}
|
}
|
||||||
const applied = validateAppliedHistory(options.aggregate, history);
|
const { applied, retained } = validateAppliedHistory(options.aggregate, history);
|
||||||
const pending = Object.freeze(
|
const pending = Object.freeze(
|
||||||
options.aggregate.migrations.filter((migration) => !applied.has(migration.identity)),
|
options.aggregate.migrations.filter((migration) => !applied.has(migration.identity)),
|
||||||
);
|
);
|
||||||
await preflightApprovals(pending, options.approvals ?? denyAllMigrationApprovals);
|
await preflightApprovals(pending, options.approvals ?? denyAllMigrationApprovals);
|
||||||
|
|
||||||
if (pending.length === 0) {
|
if (pending.length === 0) {
|
||||||
return Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true });
|
return Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true, retained });
|
||||||
}
|
}
|
||||||
|
|
||||||
let executingIdentity: string | undefined;
|
let executingIdentity: string | undefined;
|
||||||
@@ -508,6 +569,7 @@ export async function applyModuleMigrations(
|
|||||||
applied: resultMigrations,
|
applied: resultMigrations,
|
||||||
appliedCount: resultMigrations.length,
|
appliedCount: resultMigrations.length,
|
||||||
noOp: false,
|
noOp: false,
|
||||||
|
retained,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
primaryError = error;
|
primaryError = error;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export type ModuleDiscoveryIssueCode =
|
|||||||
| "MODULE_PLATFORM_INCOMPATIBLE"
|
| "MODULE_PLATFORM_INCOMPATIBLE"
|
||||||
| "MODULE_SDK_INCOMPATIBLE"
|
| "MODULE_SDK_INCOMPATIBLE"
|
||||||
| "MODULE_VERSION_MISMATCH"
|
| "MODULE_VERSION_MISMATCH"
|
||||||
|
| "MODULES_DIRECTORY_INVALID"
|
||||||
| "MODULES_DIRECTORY_UNREADABLE";
|
| "MODULES_DIRECTORY_UNREADABLE";
|
||||||
|
|
||||||
export interface ModuleDiscoveryIssue {
|
export interface ModuleDiscoveryIssue {
|
||||||
@@ -117,9 +118,35 @@ export interface DiscoveredModule {
|
|||||||
export interface ModuleDiscoveryOptions {
|
export interface ModuleDiscoveryOptions {
|
||||||
readonly moduleSdkVersion: string;
|
readonly moduleSdkVersion: string;
|
||||||
readonly modulesDirectory: string;
|
readonly modulesDirectory: string;
|
||||||
|
/**
|
||||||
|
* True when an operator configured the directory explicitly (`EKKLESIA_MODULES_DIR`, ADR 0048).
|
||||||
|
* A configured directory that is missing, not a directory, or not an absolute path then fails
|
||||||
|
* discovery closed rather than reading as zero modules: a mistyped mount must never quietly start
|
||||||
|
* the platform with nothing installed. False or omitted keeps the development default lenient,
|
||||||
|
* where a checkout with no `modules/` simply has no modules.
|
||||||
|
*/
|
||||||
|
readonly modulesDirectoryRequired?: boolean;
|
||||||
readonly platformVersion: string;
|
readonly platformVersion: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The one sanitized message for an unusable configured modules directory. It never carries the
|
||||||
|
* value: a path is machine detail, and a misplaced value could be anything at all. */
|
||||||
|
export const MODULES_DIRECTORY_INVALID_MESSAGE = "EKKLESIA_MODULES_DIR must be an absolute path.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a configured modules directory is usable as given: a non-empty absolute path with no
|
||||||
|
* surrounding whitespace and no NUL byte. Deliberately strict rather than forgiving, because
|
||||||
|
* trimming or resolving a near-miss would silently pick a directory the operator did not name.
|
||||||
|
*/
|
||||||
|
export function isUsableModulesDirectoryPath(value: string): boolean {
|
||||||
|
return (
|
||||||
|
value.length > 0 &&
|
||||||
|
value.trim() === value &&
|
||||||
|
!value.includes("\u0000") &&
|
||||||
|
path.isAbsolute(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ModulePackageData {
|
interface ModulePackageData {
|
||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -695,6 +722,13 @@ export async function discoverModules(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (options.modulesDirectoryRequired && !isUsableModulesDirectoryPath(options.modulesDirectory)) {
|
||||||
|
issues.push({
|
||||||
|
code: "MODULES_DIRECTORY_INVALID",
|
||||||
|
message: MODULES_DIRECTORY_INVALID_MESSAGE,
|
||||||
|
moduleDirectory: ".",
|
||||||
|
});
|
||||||
|
}
|
||||||
if (issues.length > 0) {
|
if (issues.length > 0) {
|
||||||
throw new ModuleDiscoveryError(issues);
|
throw new ModuleDiscoveryError(issues);
|
||||||
}
|
}
|
||||||
@@ -703,7 +737,7 @@ export async function discoverModules(
|
|||||||
try {
|
try {
|
||||||
directoryEntries = await readdir(options.modulesDirectory, { withFileTypes: true });
|
directoryEntries = await readdir(options.modulesDirectory, { withFileTypes: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (errorCode(error) === "ENOENT") {
|
if (errorCode(error) === "ENOENT" && !options.modulesDirectoryRequired) {
|
||||||
return Object.freeze([]);
|
return Object.freeze([]);
|
||||||
}
|
}
|
||||||
throw new ModuleDiscoveryError([
|
throw new ModuleDiscoveryError([
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export * from "./lifecycle.js";
|
|||||||
export * from "./lifecycle-schema.js";
|
export * from "./lifecycle-schema.js";
|
||||||
export * from "./lifecycle-store.js";
|
export * from "./lifecycle-store.js";
|
||||||
export * from "./migration-discovery.js";
|
export * from "./migration-discovery.js";
|
||||||
|
export * from "./modules-directory.js";
|
||||||
export * from "./permissions.js";
|
export * from "./permissions.js";
|
||||||
export * from "./realtime.js";
|
export * from "./realtime.js";
|
||||||
export * from "./runtime.js";
|
export * from "./runtime.js";
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
isUsableModulesDirectoryPath,
|
||||||
|
MODULES_DIRECTORY_INVALID_MESSAGE,
|
||||||
|
ModuleDiscoveryError,
|
||||||
|
} from "./discovery.js";
|
||||||
|
|
||||||
|
/** The host environment variable that selects the installed-modules directory (ADR 0048). */
|
||||||
|
export const MODULES_DIRECTORY_ENVIRONMENT_VARIABLE = "EKKLESIA_MODULES_DIR";
|
||||||
|
|
||||||
|
export interface ResolveModulesDirectoryOptions {
|
||||||
|
/** The raw configured value, exactly as the host environment supplied it; `undefined` when the
|
||||||
|
* variable is unset. */
|
||||||
|
readonly configured: string | undefined;
|
||||||
|
/** Where the platform itself is installed. Only consulted for the development default. */
|
||||||
|
readonly repositoryRoot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spreads directly into `ModuleDiscoveryOptions`, so every consumer discovers through the same
|
||||||
|
* selection, including whether a missing directory is an error. */
|
||||||
|
export interface ModulesDirectorySelection {
|
||||||
|
readonly modulesDirectory: string;
|
||||||
|
readonly modulesDirectoryRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place an installation's modules directory is chosen (ADR 0048).
|
||||||
|
*
|
||||||
|
* - Unset: `<repositoryRoot>/modules`, the development checkout's own directory. A checkout without
|
||||||
|
* one has no modules, exactly as before.
|
||||||
|
* - Set: an absolute path, normalized and used as given — never resolved against the repository or
|
||||||
|
* the working directory — and required to exist when discovery reads it. The shipping image sets
|
||||||
|
* it to `/var/lib/ekklesia/modules`.
|
||||||
|
*
|
||||||
|
* Anything else throws a `ModuleDiscoveryError` carrying `MODULES_DIRECTORY_INVALID` and a fixed
|
||||||
|
* message, so every executable already reports it through the discovery error path it has, and the
|
||||||
|
* supplied value never reaches an error, a log, or a report.
|
||||||
|
*
|
||||||
|
* Nothing here — and nothing that calls it — locates the platform's own files relative to the
|
||||||
|
* result: Core's migrations and package metadata live under `repositoryRoot`, wherever modules are.
|
||||||
|
*/
|
||||||
|
export function resolveModulesDirectory(
|
||||||
|
options: ResolveModulesDirectoryOptions,
|
||||||
|
): ModulesDirectorySelection {
|
||||||
|
if (options.configured === undefined) {
|
||||||
|
return Object.freeze({
|
||||||
|
modulesDirectory: path.join(options.repositoryRoot, "modules"),
|
||||||
|
modulesDirectoryRequired: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!isUsableModulesDirectoryPath(options.configured)) {
|
||||||
|
throw new ModuleDiscoveryError([
|
||||||
|
{
|
||||||
|
code: "MODULES_DIRECTORY_INVALID",
|
||||||
|
message: MODULES_DIRECTORY_INVALID_MESSAGE,
|
||||||
|
moduleDirectory: ".",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const normalized = path.normalize(options.configured);
|
||||||
|
return Object.freeze({
|
||||||
|
modulesDirectory:
|
||||||
|
normalized.length > 1 && normalized.endsWith(path.sep) ? normalized.slice(0, -1) : normalized,
|
||||||
|
modulesDirectoryRequired: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,10 +8,11 @@ import {
|
|||||||
diagnoseEkklesia,
|
diagnoseEkklesia,
|
||||||
formatDoctorReport,
|
formatDoctorReport,
|
||||||
} from "../src/diagnostics/index.js";
|
} from "../src/diagnostics/index.js";
|
||||||
import type {
|
import {
|
||||||
DiscoveredModule,
|
type DiscoveredModule,
|
||||||
ModuleMigrationAggregate,
|
discoverModules,
|
||||||
ModuleSchemaAggregate,
|
type ModuleMigrationAggregate,
|
||||||
|
type ModuleSchemaAggregate,
|
||||||
} from "../src/modules/index.js";
|
} from "../src/modules/index.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,8 +35,8 @@ const moduleSecret = "module-secret-material";
|
|||||||
|
|
||||||
const metadata = Object.freeze({
|
const metadata = Object.freeze({
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
modulesDirectory: "/not-rendered/modules",
|
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.1.0",
|
||||||
|
repositoryRoot: "/not-rendered",
|
||||||
runtimeRange: ">=24.11.0 <25",
|
runtimeRange: ">=24.11.0 <25",
|
||||||
runtimeVersion: "24.15.0",
|
runtimeVersion: "24.15.0",
|
||||||
});
|
});
|
||||||
@@ -454,3 +455,33 @@ describe("Doctor host-configuration parity — sanitization", () => {
|
|||||||
expect(JSON.stringify(report)).not.toContain("S3_ACCESS_KEY");
|
expect(JSON.stringify(report)).not.toContain("S3_ACCESS_KEY");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Doctor reads the modules directory from the same host configuration (ADR 0048)", () => {
|
||||||
|
it("uses the repository's own modules directory when EKKLESIA_MODULES_DIR is unset", () => {
|
||||||
|
const doctorOptions = createDoctorOptions(environment(), metadata);
|
||||||
|
expect(doctorOptions.moduleDiscovery.modulesDirectory).toBe("/not-rendered/modules");
|
||||||
|
expect(doctorOptions.moduleDiscovery.modulesDirectoryRequired).toBe(false);
|
||||||
|
expect(doctorOptions.coreMigrationsDirectory).toBe("/not-rendered/core/drizzle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a configured directory, and still finds Core's migrations under the platform", () => {
|
||||||
|
const doctorOptions = createDoctorOptions(
|
||||||
|
environment({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" }),
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
expect(doctorOptions.moduleDiscovery.modulesDirectory).toBe("/var/lib/ekklesia/modules");
|
||||||
|
expect(doctorOptions.moduleDiscovery.modulesDirectoryRequired).toBe(true);
|
||||||
|
expect(doctorOptions.coreMigrationsDirectory).toBe("/not-rendered/core/drizzle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands an unusable value to discovery to report, rather than throwing before a report exists", async () => {
|
||||||
|
const doctorOptions = createDoctorOptions(
|
||||||
|
environment({ EKKLESIA_MODULES_DIR: "relative/private-mount-91aa" }),
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
expect(doctorOptions.moduleDiscovery.modulesDirectoryRequired).toBe(true);
|
||||||
|
const report = await diagnoseEkklesia(doctorOptions, dependencies({ discoverModules }));
|
||||||
|
expect(report.checks.find((entry) => entry.id === "modules.discovery")?.status).toBe("failure");
|
||||||
|
expect(JSON.stringify(report)).not.toContain("private-mount-91aa");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ describe("Ekklesia Doctor terminal report", () => {
|
|||||||
it("renders the real zero-module and no-database shape exactly", async () => {
|
it("renders the real zero-module and no-database shape exactly", async () => {
|
||||||
const report = await diagnoseEkklesia(
|
const report = await diagnoseEkklesia(
|
||||||
{
|
{
|
||||||
|
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
modulesDirectory: "/not-rendered/modules",
|
modulesDirectory: "/not-rendered/modules",
|
||||||
@@ -117,6 +118,7 @@ Identity
|
|||||||
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
|
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
|
||||||
✓ Direct client-address mode
|
✓ Direct client-address mode
|
||||||
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
|
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
|
||||||
|
Checked: the environment Doctor runs with. In the reference deployment \`migrate\` is given the API's client-address values; a running API's own value is shown by \`docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE\`.
|
||||||
|
|
||||||
Object storage
|
Object storage
|
||||||
○ Object storage not configured
|
○ Object storage not configured
|
||||||
@@ -134,6 +136,7 @@ Result: READY WITH WARNINGS`);
|
|||||||
it("renders every check the report contains, so none can silently affect the result", async () => {
|
it("renders every check the report contains, so none can silently affect the result", async () => {
|
||||||
const report = await diagnoseEkklesia(
|
const report = await diagnoseEkklesia(
|
||||||
{
|
{
|
||||||
|
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
modulesDirectory: "/not-rendered/modules",
|
modulesDirectory: "/not-rendered/modules",
|
||||||
@@ -158,6 +161,7 @@ Result: READY WITH WARNINGS`);
|
|||||||
it("does not depend on NO_COLOR or terminal interactivity", async () => {
|
it("does not depend on NO_COLOR or terminal interactivity", async () => {
|
||||||
const report = await diagnoseEkklesia(
|
const report = await diagnoseEkklesia(
|
||||||
{
|
{
|
||||||
|
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
modulesDirectory: "/not-rendered/modules",
|
modulesDirectory: "/not-rendered/modules",
|
||||||
@@ -206,6 +210,7 @@ Result: READY WITH WARNINGS`);
|
|||||||
const report = await diagnoseEkklesia(
|
const report = await diagnoseEkklesia(
|
||||||
{
|
{
|
||||||
databaseUrl: "postgresql://another:[email protected]/db",
|
databaseUrl: "postgresql://another:[email protected]/db",
|
||||||
|
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
modulesDirectory: "/not-rendered/modules",
|
modulesDirectory: "/not-rendered/modules",
|
||||||
|
|||||||
+136
-2
@@ -1,5 +1,9 @@
|
|||||||
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
|
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
|
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
|
||||||
import {
|
import {
|
||||||
type DoctorCheck,
|
type DoctorCheck,
|
||||||
@@ -114,7 +118,10 @@ function emptyDatabase(): MigrationDatabase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult {
|
function plan(
|
||||||
|
entries: MigrationPlanResult["entries"] = [],
|
||||||
|
retained: MigrationPlanResult["retained"] = [],
|
||||||
|
): MigrationPlanResult {
|
||||||
const appliedCount = entries.filter((entry) => entry.status === "applied").length;
|
const appliedCount = entries.filter((entry) => entry.status === "applied").length;
|
||||||
const approvalRequiredCount = entries.filter(
|
const approvalRequiredCount = entries.filter(
|
||||||
(entry) => entry.status === "approval-blocked",
|
(entry) => entry.status === "approval-blocked",
|
||||||
@@ -124,6 +131,7 @@ function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult
|
|||||||
approvalRequiredCount,
|
approvalRequiredCount,
|
||||||
entries: Object.freeze([...entries]),
|
entries: Object.freeze([...entries]),
|
||||||
pendingCount: entries.length - appliedCount,
|
pendingCount: entries.length - appliedCount,
|
||||||
|
retained: Object.freeze([...retained]),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +159,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
|
|||||||
function options(databaseUrl?: string): DoctorOptions {
|
function options(databaseUrl?: string): DoctorOptions {
|
||||||
return {
|
return {
|
||||||
...(databaseUrl === undefined ? {} : { databaseUrl }),
|
...(databaseUrl === undefined ? {} : { databaseUrl }),
|
||||||
|
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
modulesDirectory: "/not-rendered/modules",
|
modulesDirectory: "/not-rendered/modules",
|
||||||
@@ -958,6 +967,21 @@ describe("Ekklesia Doctor diagnostics", () => {
|
|||||||
expect(report.result).toBe("ready-with-warnings");
|
expect(report.result).toBe("ready-with-warnings");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("says whose client-address configuration it checked: this process's, not a running API's", async () => {
|
||||||
|
for (const clientAddress of [
|
||||||
|
undefined,
|
||||||
|
{ header: "x-forwarded-for", mode: "trusted-proxy" as const, trustedProxies: ["10.0.0.1"] },
|
||||||
|
{ header: "x-forwarded-for", mode: "trusted-proxy" as const, trustedProxies: ["bad"] },
|
||||||
|
]) {
|
||||||
|
const report = await diagnoseEkklesia(
|
||||||
|
{ ...options(), ...(clientAddress ? { clientAddress } : {}) },
|
||||||
|
dependencies(),
|
||||||
|
);
|
||||||
|
const details = check(report, "identity.client-address").details;
|
||||||
|
expect(details.some((detail) => detail.includes("environment Doctor runs with"))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => {
|
it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => {
|
||||||
const report = await diagnoseEkklesia(
|
const report = await diagnoseEkklesia(
|
||||||
{
|
{
|
||||||
@@ -1642,3 +1666,113 @@ describe("Doctor reports declared browser contributions", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Doctor with installation-owned module storage (ADR 0048)", () => {
|
||||||
|
const platformCoreMigrations = path.join(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"..",
|
||||||
|
"drizzle",
|
||||||
|
);
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds Core's migrations where the platform is installed, not beside the modules", async () => {
|
||||||
|
// A modules directory far from the platform, as the image mounts /var/lib/ekklesia/modules. Its
|
||||||
|
// parent has no `core/drizzle`, so a Doctor that still derived Core's migrations from the
|
||||||
|
// modules directory would fail migration discovery here.
|
||||||
|
const external = await mkdtemp(path.join(tmpdir(), "ekklesia-external-modules-"));
|
||||||
|
temporaryDirectories.push(external);
|
||||||
|
const report = await diagnoseEkklesia({
|
||||||
|
coreMigrationsDirectory: platformCoreMigrations,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: external,
|
||||||
|
modulesDirectoryRequired: true,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
runtimeRange: ">=24.11.0 <25",
|
||||||
|
runtimeVersion: "24.15.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(check(report, "modules.discovery").status).toBe("pass");
|
||||||
|
const migrations = check(report, "migrations.discovery");
|
||||||
|
expect(migrations.status).toBe("pass");
|
||||||
|
expect(migrations.details.some((detail) => detail.startsWith("core:0000_"))).toBe(true);
|
||||||
|
expect(JSON.stringify(report)).not.toContain(external);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an unusable configured modules directory as a discovery failure, naming no path", async () => {
|
||||||
|
const report = await diagnoseEkklesia({
|
||||||
|
coreMigrationsDirectory: platformCoreMigrations,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: "relative/private-mount-3c9d",
|
||||||
|
modulesDirectoryRequired: true,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
runtimeRange: ">=24.11.0 <25",
|
||||||
|
runtimeVersion: "24.15.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
const discovery = check(report, "modules.discovery");
|
||||||
|
expect(discovery.status).toBe("failure");
|
||||||
|
expect(discovery.details).toEqual(["The configured modules directory is not usable."]);
|
||||||
|
expect(check(report, "migrations.discovery").status).toBe("skipped");
|
||||||
|
expect(JSON.stringify(report)).not.toContain("private-mount-3c9d");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a configured modules directory that does not exist, naming no path", async () => {
|
||||||
|
const parent = await mkdtemp(path.join(tmpdir(), "ekklesia-missing-modules-"));
|
||||||
|
temporaryDirectories.push(parent);
|
||||||
|
const report = await diagnoseEkklesia({
|
||||||
|
coreMigrationsDirectory: platformCoreMigrations,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: path.join(parent, "absent"),
|
||||||
|
modulesDirectoryRequired: true,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
runtimeRange: ">=24.11.0 <25",
|
||||||
|
runtimeVersion: "24.15.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
const discovery = check(report, "modules.discovery");
|
||||||
|
expect(discovery.status).toBe("failure");
|
||||||
|
expect(discovery.details).toEqual(["The configured modules directory could not be read."]);
|
||||||
|
expect(JSON.stringify(report)).not.toContain(parent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an absent module's retained history without blocking readiness", async () => {
|
||||||
|
const report = await diagnoseEkklesia(
|
||||||
|
options("postgres://[email protected]/ekklesia"),
|
||||||
|
dependencies({
|
||||||
|
planMigrations: vi.fn(async () =>
|
||||||
|
plan(
|
||||||
|
[planEntry("core:0000_identity_core_tables", "a".repeat(64), "applied")],
|
||||||
|
[Object.freeze({ appliedCount: 2, moduleId: "calendar" })],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const planCheck = check(report, "postgresql.plan");
|
||||||
|
expect(planCheck.status).toBe("pass");
|
||||||
|
expect(planCheck.details).toContain(
|
||||||
|
"retained · calendar · 2 applied migrations kept; the module is not installed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes Core's own migration directory to migration discovery", async () => {
|
||||||
|
const discoverMigrations = vi.fn(async () => migrationAggregate());
|
||||||
|
await diagnoseEkklesia(options(), dependencies({ discoverMigrations }));
|
||||||
|
expect(discoverMigrations).toHaveBeenCalledWith({
|
||||||
|
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||||
|
moduleDiscovery: options().moduleDiscovery,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ const INTERPRETED_VARIABLES = Object.freeze([
|
|||||||
"EKKLESIA_IDENTITY_BASE_URL",
|
"EKKLESIA_IDENTITY_BASE_URL",
|
||||||
"EKKLESIA_IDENTITY_SECRET",
|
"EKKLESIA_IDENTITY_SECRET",
|
||||||
"EKKLESIA_IDENTITY_TRUSTED_ORIGINS",
|
"EKKLESIA_IDENTITY_TRUSTED_ORIGINS",
|
||||||
|
"EKKLESIA_MODULES_DIR",
|
||||||
"EKKLESIA_PUBLIC_SIGNUP_ENABLED",
|
"EKKLESIA_PUBLIC_SIGNUP_ENABLED",
|
||||||
"EKKLESIA_SMTP_FROM_ADDRESS",
|
"EKKLESIA_SMTP_FROM_ADDRESS",
|
||||||
"EKKLESIA_SMTP_FROM_NAME",
|
"EKKLESIA_SMTP_FROM_NAME",
|
||||||
|
|||||||
@@ -48,6 +48,19 @@ describe("host configuration loading", () => {
|
|||||||
expect(host.identity.secret).toBeUndefined();
|
expect(host.identity.secret).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries the configured modules directory verbatim, and nothing when it is unset", () => {
|
||||||
|
expect(loadHostConfiguration({}).modulesDirectory).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
loadHostConfiguration({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" }).modulesDirectory,
|
||||||
|
).toBe("/var/lib/ekklesia/modules");
|
||||||
|
// Validation is the resolver's job (ADR 0048). The loader neither repairs a relative or blank
|
||||||
|
// value nor treats a set-but-empty variable as unset, so the resolver can refuse it.
|
||||||
|
expect(
|
||||||
|
loadHostConfiguration({ EKKLESIA_MODULES_DIR: "relative/modules" }).modulesDirectory,
|
||||||
|
).toBe("relative/modules");
|
||||||
|
expect(loadHostConfiguration({ EKKLESIA_MODULES_DIR: "" }).modulesDirectory).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
it("carries every object-storage field the storage passes established", () => {
|
it("carries every object-storage field the storage passes established", () => {
|
||||||
const host = loadHostConfiguration(productionEnvironment);
|
const host = loadHostConfiguration(productionEnvironment);
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ describe("migration command reports", () => {
|
|||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
pendingCount: 2,
|
pendingCount: 2,
|
||||||
|
retained: Object.freeze([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(formatMigrationPlanReport(plan)).toEqual([
|
expect(formatMigrationPlanReport(plan)).toEqual([
|
||||||
@@ -64,6 +65,7 @@ describe("migration command reports", () => {
|
|||||||
]),
|
]),
|
||||||
appliedCount: 1,
|
appliedCount: 1,
|
||||||
noOp: false,
|
noOp: false,
|
||||||
|
retained: Object.freeze([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(formatMigrationApplyReport(applied)).toEqual([
|
expect(formatMigrationApplyReport(applied)).toEqual([
|
||||||
@@ -72,7 +74,12 @@ describe("migration command reports", () => {
|
|||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
formatMigrationApplyReport(
|
formatMigrationApplyReport(
|
||||||
Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true }),
|
Object.freeze({
|
||||||
|
applied: Object.freeze([]),
|
||||||
|
appliedCount: 0,
|
||||||
|
noOp: true,
|
||||||
|
retained: Object.freeze([]),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
).toEqual(["Migration apply completed: database is already current (no changes)."]);
|
).toEqual(["Migration apply completed: database is already current (no changes)."]);
|
||||||
});
|
});
|
||||||
@@ -85,6 +92,7 @@ describe("migration command reports", () => {
|
|||||||
approvalRequiredCount: 0,
|
approvalRequiredCount: 0,
|
||||||
entries: Object.freeze([]),
|
entries: Object.freeze([]),
|
||||||
pendingCount: 0,
|
pendingCount: 0,
|
||||||
|
retained: Object.freeze([]),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
@@ -93,4 +101,62 @@ describe("migration command reports", () => {
|
|||||||
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
|
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports an absent module's retained history without treating it as work (ADR 0048)", () => {
|
||||||
|
const retained = Object.freeze([
|
||||||
|
Object.freeze({ appliedCount: 1, moduleId: "calendar" }),
|
||||||
|
Object.freeze({ appliedCount: 3, moduleId: "signage" }),
|
||||||
|
]);
|
||||||
|
const retainedLines = [
|
||||||
|
"- [retained] calendar: 1 applied migration kept; the module is not installed",
|
||||||
|
"- [retained] signage: 3 applied migrations kept; the module is not installed",
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
formatMigrationPlanReport(
|
||||||
|
Object.freeze({
|
||||||
|
appliedCount: 0,
|
||||||
|
approvalRequiredCount: 0,
|
||||||
|
entries: Object.freeze([]),
|
||||||
|
pendingCount: 0,
|
||||||
|
retained,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
"Migration plan (0 applied, 0 pending, 0 approval-blocked, 2 retained):",
|
||||||
|
"- No installed module migrations.",
|
||||||
|
...retainedLines,
|
||||||
|
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
formatMigrationApplyReport(
|
||||||
|
Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true, retained }),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
"Migration apply completed: database is already current (no changes).",
|
||||||
|
...retainedLines,
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
formatMigrationApplyReport(
|
||||||
|
Object.freeze({
|
||||||
|
applied: Object.freeze([
|
||||||
|
Object.freeze({
|
||||||
|
checksum: "e".repeat(64),
|
||||||
|
identity: "core:0007_later",
|
||||||
|
index: 7,
|
||||||
|
moduleId: "core",
|
||||||
|
tag: "0007_later",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
appliedCount: 1,
|
||||||
|
noOp: false,
|
||||||
|
retained,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
"Migration apply completed (1 migration applied):",
|
||||||
|
`- core:0007_later sha256=${"e".repeat(64)}`,
|
||||||
|
...retainedLines,
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
@@ -10,34 +10,15 @@ import {
|
|||||||
ModuleMigrationDiscoveryError,
|
ModuleMigrationDiscoveryError,
|
||||||
type ModuleMigrationDiscoveryIssueCode,
|
type ModuleMigrationDiscoveryIssueCode,
|
||||||
} from "../src/modules/index.js";
|
} from "../src/modules/index.js";
|
||||||
|
import {
|
||||||
|
createMigrationModule,
|
||||||
|
manifestFor,
|
||||||
|
originSnapshotId,
|
||||||
|
snapshotId,
|
||||||
|
} from "./support/migration-module-fixture.js";
|
||||||
|
|
||||||
const originSnapshotId = "00000000-0000-0000-0000-000000000000";
|
|
||||||
const temporaryDirectories: string[] = [];
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
interface MigrationFixture {
|
|
||||||
readonly breakpoints?: boolean;
|
|
||||||
readonly destructive?:
|
|
||||||
| { readonly mode: "none" }
|
|
||||||
| { readonly mode: "approval-required"; readonly reason: string };
|
|
||||||
readonly snapshotId?: string;
|
|
||||||
readonly sql: string | Uint8Array;
|
|
||||||
readonly tag: string;
|
|
||||||
readonly timestamp?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MigrationModuleFixture {
|
|
||||||
readonly dependencies?: Record<string, unknown>;
|
|
||||||
readonly manifest?: Record<string, unknown>;
|
|
||||||
readonly migrations?: readonly MigrationFixture[] | false;
|
|
||||||
readonly schemaSource?: string;
|
|
||||||
readonly serverSource?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreatedMigrationModule {
|
|
||||||
readonly migrationDirectory?: string;
|
|
||||||
readonly moduleDirectory: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
temporaryDirectories
|
temporaryDirectories
|
||||||
@@ -52,139 +33,6 @@ async function createModulesDirectory(): Promise<string> {
|
|||||||
return directory;
|
return directory;
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshotId(index: number): string {
|
|
||||||
return `10000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function manifestFor(
|
|
||||||
id: string,
|
|
||||||
options: {
|
|
||||||
readonly dependencies?: Record<string, unknown>;
|
|
||||||
readonly migrations?: boolean;
|
|
||||||
readonly schema?: boolean;
|
|
||||||
} = {},
|
|
||||||
): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
manifestVersion: 7,
|
|
||||||
id,
|
|
||||||
name: `${id} module`,
|
|
||||||
version: "0.1.0",
|
|
||||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
|
||||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
|
||||||
capabilities: [],
|
|
||||||
permissions: [],
|
|
||||||
entrypoints: {
|
|
||||||
server: "./dist/server/index.js",
|
|
||||||
...(options.schema ? { schema: "./dist/schema/index.js" } : {}),
|
|
||||||
...(options.migrations === false ? {} : { migrations: "./drizzle" }),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultMigrations(id: string): readonly MigrationFixture[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
sql: `CREATE TABLE "${id.replaceAll("-", "_")}_records" ("id" integer);\n`,
|
|
||||||
tag: "0000_initial",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createMigrationModule(
|
|
||||||
modulesDirectory: string,
|
|
||||||
id: string,
|
|
||||||
fixture: MigrationModuleFixture = {},
|
|
||||||
): Promise<CreatedMigrationModule> {
|
|
||||||
const migrations =
|
|
||||||
fixture.migrations === false ? undefined : (fixture.migrations ?? defaultMigrations(id));
|
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
|
||||||
const migrationDirectory = migrations ? path.join(moduleDirectory, "drizzle") : undefined;
|
|
||||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
|
||||||
if (fixture.schemaSource !== undefined) {
|
|
||||||
await mkdir(path.join(moduleDirectory, "dist/schema"), { recursive: true });
|
|
||||||
}
|
|
||||||
if (migrationDirectory) {
|
|
||||||
await mkdir(path.join(migrationDirectory, "meta"), { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
writeFile(
|
|
||||||
path.join(moduleDirectory, "package.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
name: `@test/module-${id}`,
|
|
||||||
version: "0.1.0",
|
|
||||||
type: "module",
|
|
||||||
ekklesia: { layer: "module" },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
writeFile(
|
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
|
||||||
JSON.stringify(
|
|
||||||
fixture.manifest ??
|
|
||||||
manifestFor(id, {
|
|
||||||
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
|
||||||
migrations: migrations !== undefined,
|
|
||||||
schema: fixture.schemaSource !== undefined,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
writeFile(
|
|
||||||
path.join(moduleDirectory, "dist/server/index.js"),
|
|
||||||
fixture.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
|
|
||||||
),
|
|
||||||
...(fixture.schemaSource === undefined
|
|
||||||
? []
|
|
||||||
: [writeFile(path.join(moduleDirectory, "dist/schema/index.js"), fixture.schemaSource)]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!migrationDirectory || !migrations) {
|
|
||||||
return { moduleDirectory };
|
|
||||||
}
|
|
||||||
|
|
||||||
const journalEntries = migrations.map((migration, index) => ({
|
|
||||||
idx: index,
|
|
||||||
version: "7",
|
|
||||||
when: migration.timestamp ?? 1_700_000_000_000 + index,
|
|
||||||
tag: migration.tag,
|
|
||||||
breakpoints: migration.breakpoints ?? true,
|
|
||||||
}));
|
|
||||||
const policyEntries = migrations.map((migration) => ({
|
|
||||||
tag: migration.tag,
|
|
||||||
destructive: migration.destructive ?? { mode: "none" },
|
|
||||||
}));
|
|
||||||
await Promise.all([
|
|
||||||
writeFile(
|
|
||||||
path.join(migrationDirectory, "meta/_journal.json"),
|
|
||||||
JSON.stringify({ version: "7", dialect: "postgresql", entries: journalEntries }),
|
|
||||||
),
|
|
||||||
writeFile(
|
|
||||||
path.join(migrationDirectory, "meta/_ekklesia.json"),
|
|
||||||
JSON.stringify({ version: 1, moduleId: id, entries: policyEntries }),
|
|
||||||
),
|
|
||||||
...migrations.flatMap((migration, index) => {
|
|
||||||
const idForSnapshot = migration.snapshotId ?? snapshotId(index);
|
|
||||||
const previousId =
|
|
||||||
index === 0
|
|
||||||
? originSnapshotId
|
|
||||||
: (migrations[index - 1]?.snapshotId ?? snapshotId(index - 1));
|
|
||||||
return [
|
|
||||||
writeFile(path.join(migrationDirectory, `${migration.tag}.sql`), migration.sql),
|
|
||||||
writeFile(
|
|
||||||
path.join(migrationDirectory, `meta/${index.toString().padStart(4, "0")}_snapshot.json`),
|
|
||||||
JSON.stringify({
|
|
||||||
version: "7",
|
|
||||||
dialect: "postgresql",
|
|
||||||
id: idForSnapshot,
|
|
||||||
prevId: previousId,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { migrationDirectory, moduleDirectory };
|
|
||||||
}
|
|
||||||
|
|
||||||
function discoveryOptions(modulesDirectory: string) {
|
function discoveryOptions(modulesDirectory: string) {
|
||||||
return {
|
return {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type MigrationTransaction,
|
type MigrationTransaction,
|
||||||
planModuleMigrations,
|
planModuleMigrations,
|
||||||
} from "../src/migrations/runner.js";
|
} from "../src/migrations/runner.js";
|
||||||
|
import type { DiscoveredModule } from "../src/modules/discovery.js";
|
||||||
import type {
|
import type {
|
||||||
DiscoveredModuleMigration,
|
DiscoveredModuleMigration,
|
||||||
ModuleMigrationAggregate,
|
ModuleMigrationAggregate,
|
||||||
@@ -323,6 +324,7 @@ describe("module migration planning", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
pendingCount: 1,
|
pendingCount: 1,
|
||||||
|
retained: [],
|
||||||
});
|
});
|
||||||
expect(database.events).toEqual(["session:open", "history:read", "session:close"]);
|
expect(database.events).toEqual(["session:open", "history:read", "session:close"]);
|
||||||
expect(database.historyTableExists).toBe(false);
|
expect(database.historyTableExists).toBe(false);
|
||||||
@@ -344,10 +346,11 @@ describe("module migration planning", () => {
|
|||||||
approvalRequiredCount: 0,
|
approvalRequiredCount: 0,
|
||||||
entries: [],
|
entries: [],
|
||||||
pendingCount: 0,
|
pendingCount: 0,
|
||||||
|
retained: [],
|
||||||
});
|
});
|
||||||
expect(Object.isFrozen(result)).toBe(true);
|
expect(Object.isFrozen(result)).toBe(true);
|
||||||
expect(Object.isFrozen(result.entries)).toBe(true);
|
expect(Object.isFrozen(result.entries)).toBe(true);
|
||||||
expect(applyResult).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
expect(applyResult).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||||
expect(database.historyTableExists).toBe(false);
|
expect(database.historyTableExists).toBe(false);
|
||||||
expect(database.events).not.toContain("transaction:begin");
|
expect(database.events).not.toContain("transaction:begin");
|
||||||
});
|
});
|
||||||
@@ -548,6 +551,7 @@ describe("module migration apply", () => {
|
|||||||
],
|
],
|
||||||
appliedCount: 1,
|
appliedCount: 1,
|
||||||
noOp: false,
|
noOp: false,
|
||||||
|
retained: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -580,7 +584,7 @@ describe("module migration apply", () => {
|
|||||||
database,
|
database,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||||
expect(Object.isFrozen(result)).toBe(true);
|
expect(Object.isFrozen(result)).toBe(true);
|
||||||
expect(Object.isFrozen(result.applied)).toBe(true);
|
expect(Object.isFrozen(result.applied)).toBe(true);
|
||||||
expectNoPersistentMutation(database);
|
expectNoPersistentMutation(database);
|
||||||
@@ -601,7 +605,7 @@ describe("module migration apply", () => {
|
|||||||
database,
|
database,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||||
expect(database.historyTableExists).toBe(false);
|
expect(database.historyTableExists).toBe(false);
|
||||||
expect(database.events).toEqual([
|
expect(database.events).toEqual([
|
||||||
"session:open",
|
"session:open",
|
||||||
@@ -980,3 +984,250 @@ describe("module migration failures and side effects", () => {
|
|||||||
expect(database.committedHistory).toEqual([]);
|
expect(database.committedHistory).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks modules as present in the discovered inventory without giving them migrations, the way a
|
||||||
|
* real aggregate lists every discovered module whether or not it owns migration history. Only the
|
||||||
|
* manifest id is read by the runner.
|
||||||
|
*/
|
||||||
|
function withDiscovered(
|
||||||
|
inventory: ModuleMigrationAggregate,
|
||||||
|
...moduleIds: readonly string[]
|
||||||
|
): ModuleMigrationAggregate {
|
||||||
|
return Object.freeze({
|
||||||
|
...inventory,
|
||||||
|
discoveredModules: Object.freeze(
|
||||||
|
moduleIds.map((id) => ({ manifest: { id } }) as unknown as DiscoveredModule),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("retained history for absent modules (ADR 0048)", () => {
|
||||||
|
it("retains and reports an absent module's applied history without failing the plan", async () => {
|
||||||
|
const coreInitial = migration("core", 0);
|
||||||
|
const calendarInitial = migration("calendar", 0);
|
||||||
|
const calendarLater = migration("calendar", 1);
|
||||||
|
const history = [
|
||||||
|
historyRecord(coreInitial, 1),
|
||||||
|
historyRecord(calendarInitial, 2),
|
||||||
|
historyRecord(calendarLater, 3),
|
||||||
|
];
|
||||||
|
const database = new FakeMigrationDatabase({ history });
|
||||||
|
|
||||||
|
const result = await planModuleMigrations({
|
||||||
|
aggregate: aggregate({ id: "core", migrations: [coreInitial] }),
|
||||||
|
database,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.appliedCount).toBe(1);
|
||||||
|
expect(result.pendingCount).toBe(0);
|
||||||
|
expect(result.entries.map((entry) => entry.identity)).toEqual([coreInitial.identity]);
|
||||||
|
expect(result.retained).toEqual([{ appliedCount: 2, moduleId: "calendar" }]);
|
||||||
|
expect(Object.isFrozen(result.retained)).toBe(true);
|
||||||
|
expect(Object.isFrozen(result.retained[0])).toBe(true);
|
||||||
|
expect(database.committedHistory).toEqual(history);
|
||||||
|
expectNoPersistentMutation(database);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a later Core migration while a module is absent, leaving its history intact", async () => {
|
||||||
|
const coreInitial = migration("core", 0);
|
||||||
|
const coreLater = migration("core", 1);
|
||||||
|
const calendarInitial = migration("calendar", 0);
|
||||||
|
const history = [historyRecord(coreInitial, 1), historyRecord(calendarInitial, 2)];
|
||||||
|
const database = new FakeMigrationDatabase({ history });
|
||||||
|
|
||||||
|
const result = await applyModuleMigrations({
|
||||||
|
aggregate: aggregate({ id: "core", migrations: [coreInitial, coreLater] }),
|
||||||
|
database,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.applied.map((applied) => applied.identity)).toEqual([coreLater.identity]);
|
||||||
|
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "calendar" }]);
|
||||||
|
expect(database.executedStatements).toEqual([coreLater.sql]);
|
||||||
|
expect(database.committedHistory.map((record) => record.identity)).toEqual([
|
||||||
|
coreInitial.identity,
|
||||||
|
calendarInitial.identity,
|
||||||
|
coreLater.identity,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a present module's migrations while another module is absent", async () => {
|
||||||
|
const peopleInitial = migration("people", 0);
|
||||||
|
const calendarInitial = migration("calendar", 0);
|
||||||
|
const database = new FakeMigrationDatabase({ history: [historyRecord(calendarInitial, 1)] });
|
||||||
|
|
||||||
|
const result = await applyModuleMigrations({
|
||||||
|
aggregate: aggregate({ id: "people", migrations: [peopleInitial] }),
|
||||||
|
database,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.applied.map((applied) => applied.identity)).toEqual([peopleInitial.identity]);
|
||||||
|
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "calendar" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports retained modules in a stable order with exact counts", async () => {
|
||||||
|
const zeta = [migration("zeta", 0), migration("zeta", 1), migration("zeta", 2)];
|
||||||
|
const alpha = migration("alpha", 0);
|
||||||
|
const database = new FakeMigrationDatabase({
|
||||||
|
history: [
|
||||||
|
historyRecord(zeta[0] as DiscoveredModuleMigration, 1),
|
||||||
|
historyRecord(alpha, 2),
|
||||||
|
historyRecord(zeta[1] as DiscoveredModuleMigration, 3),
|
||||||
|
historyRecord(zeta[2] as DiscoveredModuleMigration, 4),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await planModuleMigrations({ aggregate: aggregate(), database });
|
||||||
|
|
||||||
|
expect(result.retained).toEqual([
|
||||||
|
{ appliedCount: 1, moduleId: "alpha" },
|
||||||
|
{ appliedCount: 3, moduleId: "zeta" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a module to strict validation: valid history plans cleanly and nothing is retained", async () => {
|
||||||
|
const initial = migration("people", 0);
|
||||||
|
const later = migration("people", 1);
|
||||||
|
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
|
||||||
|
|
||||||
|
const result = await planModuleMigrations({
|
||||||
|
aggregate: aggregate({ id: "people", migrations: [initial, later] }),
|
||||||
|
database,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.retained).toEqual([]);
|
||||||
|
expect(result.entries.map((entry) => [entry.identity, entry.status])).toEqual([
|
||||||
|
[initial.identity, "applied"],
|
||||||
|
[later.identity, "pending"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when a returning module no longer ships a previously applied migration", async () => {
|
||||||
|
const initial = migration("people", 0);
|
||||||
|
const later = migration("people", 1);
|
||||||
|
const database = new FakeMigrationDatabase({
|
||||||
|
history: [historyRecord(initial, 1), historyRecord(later, 2)],
|
||||||
|
});
|
||||||
|
const downgraded = aggregate({ id: "people", migrations: [initial] });
|
||||||
|
|
||||||
|
await captureRunnerError(
|
||||||
|
planModuleMigrations({ aggregate: downgraded, database }),
|
||||||
|
"MIGRATION_UNKNOWN_APPLIED",
|
||||||
|
);
|
||||||
|
await captureRunnerError(
|
||||||
|
applyModuleMigrations({ aggregate: downgraded, database }),
|
||||||
|
"MIGRATION_UNKNOWN_APPLIED",
|
||||||
|
);
|
||||||
|
expect(database.executedStatements).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when a returning module's applied migration has different contents", async () => {
|
||||||
|
const initial = migration("people", 0);
|
||||||
|
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
|
||||||
|
const edited = aggregate({
|
||||||
|
id: "people",
|
||||||
|
migrations: [migration("people", 0, { sql: "SELECT 'edited';\n" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await captureRunnerError(
|
||||||
|
planModuleMigrations({ aggregate: edited, database }),
|
||||||
|
"MIGRATION_CHECKSUM_MISMATCH",
|
||||||
|
);
|
||||||
|
expect(error.migrationIdentity).toBe(initial.identity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when a returning module's history is no longer a prefix of its journal", async () => {
|
||||||
|
const initial = migration("people", 0);
|
||||||
|
const later = migration("people", 1);
|
||||||
|
const database = new FakeMigrationDatabase({ history: [historyRecord(later, 1)] });
|
||||||
|
|
||||||
|
await captureRunnerError(
|
||||||
|
planModuleMigrations({
|
||||||
|
aggregate: aggregate({ id: "people", migrations: [initial, later] }),
|
||||||
|
database,
|
||||||
|
}),
|
||||||
|
"MIGRATION_HISTORY_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a present module that no longer ships any migrations strict", async () => {
|
||||||
|
const initial = migration("people", 0);
|
||||||
|
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
|
||||||
|
|
||||||
|
await captureRunnerError(
|
||||||
|
planModuleMigrations({ aggregate: withDiscovered(aggregate(), "people"), database }),
|
||||||
|
"MIGRATION_UNKNOWN_APPLIED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never treats Core's history as an absent module's", async () => {
|
||||||
|
const coreInitial = migration("core", 0);
|
||||||
|
const coreGhost = migration("core", 1);
|
||||||
|
|
||||||
|
// An inventory with no Core source at all: Core's rows are still strict, never retained.
|
||||||
|
await captureRunnerError(
|
||||||
|
planModuleMigrations({
|
||||||
|
aggregate: aggregate({ id: "people", migrations: [] }),
|
||||||
|
database: new FakeMigrationDatabase({ history: [historyRecord(coreInitial, 1)] }),
|
||||||
|
}),
|
||||||
|
"MIGRATION_UNKNOWN_APPLIED",
|
||||||
|
);
|
||||||
|
// A Core row missing from Core's own inventory.
|
||||||
|
await captureRunnerError(
|
||||||
|
planModuleMigrations({
|
||||||
|
aggregate: aggregate({ id: "core", migrations: [coreInitial] }),
|
||||||
|
database: new FakeMigrationDatabase({
|
||||||
|
history: [historyRecord(coreInitial, 1), historyRecord(coreGhost, 2)],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
"MIGRATION_UNKNOWN_APPLIED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"an identity that does not name its own module and tag",
|
||||||
|
(source: DiscoveredModuleMigration) => [
|
||||||
|
historyRecord(source, 1, { identity: "calendar:0099_elsewhere" }),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a duplicate applied order",
|
||||||
|
(source: DiscoveredModuleMigration) => [
|
||||||
|
historyRecord(source, 1),
|
||||||
|
historyRecord(migration("calendar", 1), 1),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a duplicate index",
|
||||||
|
(source: DiscoveredModuleMigration) => [
|
||||||
|
historyRecord(source, 1),
|
||||||
|
historyRecord(migration("calendar", 1), 2, { index: 0 }),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
])("still rejects retained rows with %s", async (_label, makeHistory) => {
|
||||||
|
const database = new FakeMigrationDatabase({
|
||||||
|
history: makeHistory(migration("calendar", 0)),
|
||||||
|
});
|
||||||
|
|
||||||
|
await captureRunnerError(
|
||||||
|
planModuleMigrations({ aggregate: aggregate(), database }),
|
||||||
|
"MIGRATION_HISTORY_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a retained row that claims a present module's migration identity", async () => {
|
||||||
|
const peopleInitial = migration("people", 0);
|
||||||
|
const database = new FakeMigrationDatabase({
|
||||||
|
history: [historyRecord(peopleInitial, 1, { moduleId: "calendar" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
await captureRunnerError(
|
||||||
|
planModuleMigrations({
|
||||||
|
aggregate: aggregate({ id: "people", migrations: [peopleInitial] }),
|
||||||
|
database,
|
||||||
|
}),
|
||||||
|
"MIGRATION_HISTORY_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { discoverRepositoryMigrations } from "../src/cli/migration-command-support.js";
|
||||||
|
import { resolveCommandModulesDirectory } from "../src/cli/modules-directory-environment.js";
|
||||||
|
import { discoverCoreMigrations } from "../src/migrations/index.js";
|
||||||
|
import { ModuleDiscoveryError } from "../src/modules/index.js";
|
||||||
|
import { createMigrationModule } from "./support/migration-module-fixture.js";
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function externalModulesDirectory(): Promise<string> {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-installed-modules-"));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("operator commands select the modules directory through host configuration", () => {
|
||||||
|
it("defaults to the repository's own modules directory", () => {
|
||||||
|
expect(resolveCommandModulesDirectory({}, "/opt/ekklesia")).toEqual({
|
||||||
|
modulesDirectory: "/opt/ekklesia/modules",
|
||||||
|
modulesDirectoryRequired: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours EKKLESIA_MODULES_DIR", () => {
|
||||||
|
expect(
|
||||||
|
resolveCommandModulesDirectory(
|
||||||
|
{ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" },
|
||||||
|
"/opt/ekklesia",
|
||||||
|
),
|
||||||
|
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a relative EKKLESIA_MODULES_DIR without echoing it", () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia"),
|
||||||
|
).toThrow(ModuleDiscoveryError);
|
||||||
|
try {
|
||||||
|
resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia");
|
||||||
|
} catch (error) {
|
||||||
|
expect(JSON.stringify((error as ModuleDiscoveryError).issues)).not.toContain("9e1f");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plans migrations for modules installed outside the repository, with Core's from the platform", async () => {
|
||||||
|
// A minimal installed module generated from nothing in the repository, so this test never
|
||||||
|
// depends on build output or ignored files: a clean checkout runs it before anything is built.
|
||||||
|
const modulesDirectory = await externalModulesDirectory();
|
||||||
|
const installed = await createMigrationModule(modulesDirectory, "example");
|
||||||
|
expect(path.relative(repositoryRoot, modulesDirectory).startsWith("..")).toBe(true);
|
||||||
|
|
||||||
|
const aggregate = await discoverRepositoryMigrations(repositoryRoot, {
|
||||||
|
EKKLESIA_MODULES_DIR: modulesDirectory,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(aggregate.modules.map((module) => module.moduleId)).toEqual(["core", "example"]);
|
||||||
|
|
||||||
|
// Core's migrations are exactly the platform's own, byte for byte, from `core/drizzle`.
|
||||||
|
const platformCore = await discoverCoreMigrations({
|
||||||
|
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
|
||||||
|
});
|
||||||
|
const fingerprint = (migrations: readonly { identity: string; checksum: string }[]) =>
|
||||||
|
migrations.map((migration) => `${migration.identity} ${migration.checksum}`);
|
||||||
|
expect(platformCore.length).toBeGreaterThan(0);
|
||||||
|
expect(fingerprint(aggregate.modules[0]?.migrations ?? [])).toEqual(fingerprint(platformCore));
|
||||||
|
|
||||||
|
// The module's migration is the one installed outside the repository, byte for byte.
|
||||||
|
const installedSql = await readFile(
|
||||||
|
path.join(installed.migrationDirectory ?? "", "0000_initial.sql"),
|
||||||
|
);
|
||||||
|
expect(fingerprint(aggregate.modules[1]?.migrations ?? [])).toEqual([
|
||||||
|
`example:0000_initial ${createHash("sha256").update(installedSql).digest("hex")}`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed when the configured directory does not exist", async () => {
|
||||||
|
const parent = await externalModulesDirectory();
|
||||||
|
await expect(
|
||||||
|
discoverRepositoryMigrations(repositoryRoot, {
|
||||||
|
EKKLESIA_MODULES_DIR: path.join(parent, "absent"),
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ModuleDiscoveryError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("no source chooses a modules directory except the resolver", () => {
|
||||||
|
async function sourceFiles(directory: string): Promise<readonly string[]> {
|
||||||
|
const entries = await readdir(directory, { recursive: true, withFileTypes: true });
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".ts"))
|
||||||
|
.map((entry) => path.join(entry.parentPath, entry.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
it("never joins `modules` onto a root anywhere else in Core or the API", async () => {
|
||||||
|
const files = [
|
||||||
|
...(await sourceFiles(path.join(repositoryRoot, "core", "src"))),
|
||||||
|
...(await sourceFiles(path.join(repositoryRoot, "apps", "api", "src"))),
|
||||||
|
];
|
||||||
|
const resolver = path.join(repositoryRoot, "core", "src", "modules", "modules-directory.ts");
|
||||||
|
for (const file of files) {
|
||||||
|
if (file === resolver) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = await readFile(file, "utf8");
|
||||||
|
expect(text, path.relative(repositoryRoot, file)).not.toMatch(/,\s*"modules"\s*\)/u);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
discoverModules,
|
||||||
|
ModuleDiscoveryError,
|
||||||
|
type ModuleDiscoveryIssueCode,
|
||||||
|
resolveModulesDirectory,
|
||||||
|
} from "../src/modules/index.js";
|
||||||
|
|
||||||
|
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const;
|
||||||
|
const repositoryRoot = path.join(path.sep, "opt", "ekklesia");
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function temporaryDirectory(): Promise<string> {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-modules-directory-"));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureDiscoveryError(operation: () => unknown): ModuleDiscoveryError {
|
||||||
|
try {
|
||||||
|
operation();
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ModuleDiscoveryError);
|
||||||
|
return error as ModuleDiscoveryError;
|
||||||
|
}
|
||||||
|
throw new Error("Expected a ModuleDiscoveryError.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureAsyncDiscoveryError(
|
||||||
|
operation: Promise<unknown>,
|
||||||
|
): Promise<ModuleDiscoveryError> {
|
||||||
|
try {
|
||||||
|
await operation;
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ModuleDiscoveryError);
|
||||||
|
return error as ModuleDiscoveryError;
|
||||||
|
}
|
||||||
|
throw new Error("Expected a ModuleDiscoveryError.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function codes(error: ModuleDiscoveryError): readonly ModuleDiscoveryIssueCode[] {
|
||||||
|
return error.issues.map((issue) => issue.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything an operator, a log, or a test report could ever see from this error. */
|
||||||
|
function rendered(error: ModuleDiscoveryError): string {
|
||||||
|
return JSON.stringify({ issues: error.issues, message: error.message, stack: error.stack });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveModulesDirectory", () => {
|
||||||
|
it("defaults to the repository's own modules directory when nothing is configured", () => {
|
||||||
|
expect(resolveModulesDirectory({ configured: undefined, repositoryRoot })).toEqual({
|
||||||
|
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||||
|
modulesDirectoryRequired: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an explicit absolute path, normalized, and requires it to exist", () => {
|
||||||
|
expect(
|
||||||
|
resolveModulesDirectory({ configured: "/var/lib/ekklesia/modules/", repositoryRoot }),
|
||||||
|
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
|
||||||
|
expect(
|
||||||
|
resolveModulesDirectory({ configured: "/var/lib/./ekklesia//modules", repositoryRoot }),
|
||||||
|
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never resolves a configured path against the repository or the working directory", () => {
|
||||||
|
const resolved = resolveModulesDirectory({ configured: "/srv/modules", repositoryRoot });
|
||||||
|
expect(resolved.modulesDirectory).toBe("/srv/modules");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a relative name", "modules"],
|
||||||
|
["a dot-relative path", "./modules"],
|
||||||
|
["a parent-relative path", "../private/modules"],
|
||||||
|
["a home-relative path", "~/modules"],
|
||||||
|
["the empty string", ""],
|
||||||
|
["whitespace only", " "],
|
||||||
|
["an absolute path with surrounding whitespace", " /var/lib/ekklesia/modules"],
|
||||||
|
["a NUL byte", "/var/lib/ekklesia/\u0000modules"],
|
||||||
|
])("rejects %s with a sanitized MODULES_DIRECTORY_INVALID", (_label, configured) => {
|
||||||
|
const error = captureDiscoveryError(() =>
|
||||||
|
resolveModulesDirectory({ configured, repositoryRoot }),
|
||||||
|
);
|
||||||
|
expect(codes(error)).toEqual(["MODULES_DIRECTORY_INVALID"]);
|
||||||
|
expect(error.issues[0]?.message).toBe("EKKLESIA_MODULES_DIR must be an absolute path.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never echoes the supplied value in the error", () => {
|
||||||
|
const configured = "relative/secret-token-a1b2c3/postgresql://admin:hunter2@db/modules";
|
||||||
|
const error = captureDiscoveryError(() =>
|
||||||
|
resolveModulesDirectory({ configured, repositoryRoot }),
|
||||||
|
);
|
||||||
|
const text = rendered(error);
|
||||||
|
expect(text).not.toContain("secret-token-a1b2c3");
|
||||||
|
expect(text).not.toContain("hunter2");
|
||||||
|
expect(text).not.toContain("relative/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module discovery against a configured modules directory", () => {
|
||||||
|
it("fails closed, without the path, when a configured directory does not exist", async () => {
|
||||||
|
const parent = await temporaryDirectory();
|
||||||
|
const missing = path.join(parent, "private-mount-name-7f3e");
|
||||||
|
const error = await captureAsyncDiscoveryError(
|
||||||
|
discoverModules({ ...versions, modulesDirectory: missing, modulesDirectoryRequired: true }),
|
||||||
|
);
|
||||||
|
expect(codes(error)).toEqual(["MODULES_DIRECTORY_UNREADABLE"]);
|
||||||
|
expect(rendered(error)).not.toContain("private-mount-name-7f3e");
|
||||||
|
expect(rendered(error)).not.toContain(parent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed when a configured path is a file rather than a directory", async () => {
|
||||||
|
const parent = await temporaryDirectory();
|
||||||
|
const file = path.join(parent, "modules");
|
||||||
|
await writeFile(file, "not a directory");
|
||||||
|
const error = await captureAsyncDiscoveryError(
|
||||||
|
discoverModules({ ...versions, modulesDirectory: file, modulesDirectoryRequired: true }),
|
||||||
|
);
|
||||||
|
expect(codes(error)).toEqual(["MODULES_DIRECTORY_UNREADABLE"]);
|
||||||
|
expect(rendered(error)).not.toContain(parent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a relative configured path even when it bypasses the resolver", async () => {
|
||||||
|
const error = await captureAsyncDiscoveryError(
|
||||||
|
discoverModules({ ...versions, modulesDirectory: "modules", modulesDirectoryRequired: true }),
|
||||||
|
);
|
||||||
|
expect(codes(error)).toEqual(["MODULES_DIRECTORY_INVALID"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an existing, empty configured directory as zero installed modules", async () => {
|
||||||
|
const directory = await temporaryDirectory();
|
||||||
|
await expect(
|
||||||
|
discoverModules({ ...versions, modulesDirectory: directory, modulesDirectoryRequired: true }),
|
||||||
|
).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the development default lenient: a missing default directory is zero modules", async () => {
|
||||||
|
const parent = await temporaryDirectory();
|
||||||
|
await expect(
|
||||||
|
discoverModules({ ...versions, modulesDirectory: path.join(parent, "modules") }),
|
||||||
|
).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discovers through the resolver's own selection unchanged", async () => {
|
||||||
|
const directory = await temporaryDirectory();
|
||||||
|
const selection = resolveModulesDirectory({ configured: directory, repositoryRoot });
|
||||||
|
await expect(discoverModules({ ...versions, ...selection })).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createHash, randomBytes } from "node:crypto";
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { Client, type QueryResult, type QueryResultRow } from "pg";
|
import { Client, type QueryResult, type QueryResultRow } from "pg";
|
||||||
@@ -8,6 +10,7 @@ import { reportMigrationCommandError } from "../src/cli/migration-command-errors
|
|||||||
import {
|
import {
|
||||||
applyModuleMigrations,
|
applyModuleMigrations,
|
||||||
createPostgresMigrationDatabase,
|
createPostgresMigrationDatabase,
|
||||||
|
discoverAllMigrations,
|
||||||
discoverCoreMigrations,
|
discoverCoreMigrations,
|
||||||
type MigrationApproval,
|
type MigrationApproval,
|
||||||
type MigrationApprovalProvider,
|
type MigrationApprovalProvider,
|
||||||
@@ -764,7 +767,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
|
|||||||
database: migrationDatabase,
|
database: migrationDatabase,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||||
expect(await databaseState(database.client)).toEqual(before);
|
expect(await databaseState(database.client)).toEqual(before);
|
||||||
const afterData = await safeQuery<QueryResultRow>(
|
const afterData = await safeQuery<QueryResultRow>(
|
||||||
database.client,
|
database.client,
|
||||||
@@ -834,7 +837,48 @@ describe.sequential("live PostgreSQL module migration execution", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an unknown applied identity without repair or further execution", async () => {
|
it("retains an absent module's history and still applies later work (ADR 0048)", async () => {
|
||||||
|
await withIsolatedDatabase("absent_history", async (database) => {
|
||||||
|
const initial = peopleInitial();
|
||||||
|
const later = peopleLater();
|
||||||
|
const initialInventory = aggregate(moduleFixture("people", [initial]));
|
||||||
|
const completeInventory = aggregate(moduleFixture("people", [initial, later]));
|
||||||
|
const migrationDatabase = createPostgresMigrationDatabase(database.url);
|
||||||
|
await applyModuleMigrations({ aggregate: initialInventory, database: migrationDatabase });
|
||||||
|
// History left by a module that is no longer installed: kept, reported, never fatal.
|
||||||
|
await safeQuery(
|
||||||
|
database.client,
|
||||||
|
`
|
||||||
|
INSERT INTO public.ekklesia_core_migrations
|
||||||
|
(identity, module_id, migration_tag, migration_index, checksum)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
`,
|
||||||
|
["ghost:0000_unknown", "ghost", "0000_unknown", 0, "f".repeat(64)],
|
||||||
|
);
|
||||||
|
|
||||||
|
const plan = await planModuleMigrations({
|
||||||
|
aggregate: completeInventory,
|
||||||
|
database: migrationDatabase,
|
||||||
|
});
|
||||||
|
expect(plan.retained).toEqual([{ appliedCount: 1, moduleId: "ghost" }]);
|
||||||
|
expect(plan.pendingCount).toBe(1);
|
||||||
|
|
||||||
|
const result = await applyModuleMigrations({
|
||||||
|
aggregate: completeInventory,
|
||||||
|
database: migrationDatabase,
|
||||||
|
});
|
||||||
|
expect(result.applied.map((migration) => migration.identity)).toEqual([later.identity]);
|
||||||
|
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "ghost" }]);
|
||||||
|
expect(await columnExists(database.client, "people_records", "email")).toBe(true);
|
||||||
|
expect((await readHistory(database.client)).map((row) => row.identity)).toEqual([
|
||||||
|
initial.identity,
|
||||||
|
"ghost:0000_unknown",
|
||||||
|
later.identity,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown applied identity of an installed module without repair or further execution", async () => {
|
||||||
await withIsolatedDatabase("unknown_history", async (database) => {
|
await withIsolatedDatabase("unknown_history", async (database) => {
|
||||||
const initial = peopleInitial();
|
const initial = peopleInitial();
|
||||||
const later = peopleLater();
|
const later = peopleLater();
|
||||||
@@ -849,7 +893,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
|
|||||||
(identity, module_id, migration_tag, migration_index, checksum)
|
(identity, module_id, migration_tag, migration_index, checksum)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
`,
|
`,
|
||||||
["ghost:0000_unknown", "ghost", "0000_unknown", 0, "f".repeat(64)],
|
["people:0099_unknown", "people", "0099_unknown", 99, "f".repeat(64)],
|
||||||
);
|
);
|
||||||
const corrupted = await databaseState(database.client);
|
const corrupted = await databaseState(database.client);
|
||||||
|
|
||||||
@@ -866,7 +910,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
|
|||||||
expect(await columnExists(database.client, "people_records", "email")).toBe(false);
|
expect(await columnExists(database.client, "people_records", "email")).toBe(false);
|
||||||
expect((await readHistory(database.client)).map((row) => row.identity)).toEqual([
|
expect((await readHistory(database.client)).map((row) => row.identity)).toEqual([
|
||||||
initial.identity,
|
initial.identity,
|
||||||
"ghost:0000_unknown",
|
"people:0099_unknown",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1358,7 +1402,7 @@ describe.sequential("live PostgreSQL Core identity migration execution", () => {
|
|||||||
database: migrationDatabase,
|
database: migrationDatabase,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||||
expect(await databaseState(database.client)).toEqual(before);
|
expect(await databaseState(database.client)).toEqual(before);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1851,3 +1895,151 @@ describe.sequential("live PostgreSQL migration timeout precedence", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADR 0048 end to end, with real discovery: Core's committed migrations, the module template
|
||||||
|
* installed in a modules directory outside the repository, and a real PostgreSQL. Core's newest
|
||||||
|
* migration is withheld from a copy of `core/drizzle` at first, so a genuinely later Core migration
|
||||||
|
* can be applied while the module is absent.
|
||||||
|
*/
|
||||||
|
describe.sequential("live PostgreSQL retained history for an uninstalled module", () => {
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
const templateModule = path.join(repositoryRoot, "templates", "module");
|
||||||
|
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const;
|
||||||
|
|
||||||
|
async function coreWithoutNewestMigration(directory: string): Promise<string> {
|
||||||
|
const target = path.join(directory, "core-drizzle");
|
||||||
|
await cp(coreMigrationsDirectory, target, { recursive: true });
|
||||||
|
const journalPath = path.join(target, "meta", "_journal.json");
|
||||||
|
const policyPath = path.join(target, "meta", "_ekklesia.json");
|
||||||
|
const journal = JSON.parse(await readFile(journalPath, "utf8")) as {
|
||||||
|
entries: { idx: number; tag: string }[];
|
||||||
|
};
|
||||||
|
const policy = JSON.parse(await readFile(policyPath, "utf8")) as { entries: unknown[] };
|
||||||
|
const newest = journal.entries.pop();
|
||||||
|
policy.entries.pop();
|
||||||
|
if (!newest) {
|
||||||
|
throw harnessError("Core has no migration to withhold.");
|
||||||
|
}
|
||||||
|
await writeFile(journalPath, `${JSON.stringify(journal, null, 2)}\n`);
|
||||||
|
await writeFile(policyPath, `${JSON.stringify(policy, null, 2)}\n`);
|
||||||
|
await rm(path.join(target, `${newest.tag}.sql`));
|
||||||
|
await rm(path.join(target, "meta", `${String(newest.idx).padStart(4, "0")}_snapshot.json`));
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installExample(modulesDirectory: string): Promise<void> {
|
||||||
|
for (const entry of ["package.json", "ekklesia.module.json", "drizzle", "dist"]) {
|
||||||
|
await cp(path.join(templateModule, entry), path.join(modulesDirectory, "example", entry), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inventory(coreDirectory: string, modulesDirectory: string) {
|
||||||
|
return discoverAllMigrations({
|
||||||
|
coreMigrationsDirectory: coreDirectory,
|
||||||
|
moduleDiscovery: { ...versions, modulesDirectory, modulesDirectoryRequired: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exampleValues(client: Client): Promise<readonly string[]> {
|
||||||
|
const result = await safeQuery<{ readonly value: string } & QueryResultRow>(
|
||||||
|
client,
|
||||||
|
"SELECT value FROM public.example_records ORDER BY value",
|
||||||
|
);
|
||||||
|
return result.rows.map((row) => row.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps an uninstalled module's data and history, applies later Core work, and restores it", async () => {
|
||||||
|
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-retained-history-"));
|
||||||
|
try {
|
||||||
|
const earlyCore = await coreWithoutNewestMigration(scratch);
|
||||||
|
const modulesDirectory = path.join(scratch, "modules");
|
||||||
|
await mkdir(modulesDirectory);
|
||||||
|
await installExample(modulesDirectory);
|
||||||
|
|
||||||
|
await withIsolatedDatabase("retained_module", async (database) => {
|
||||||
|
const migrationDatabase = createPostgresMigrationDatabase(database.url);
|
||||||
|
|
||||||
|
// 1. Core (minus its newest migration) and the installed module, applied together.
|
||||||
|
const first = await applyModuleMigrations({
|
||||||
|
aggregate: await inventory(earlyCore, modulesDirectory),
|
||||||
|
database: migrationDatabase,
|
||||||
|
});
|
||||||
|
expect(first.applied.map((migration) => migration.identity)).toContain(
|
||||||
|
"example:0000_initial",
|
||||||
|
);
|
||||||
|
expect(first.retained).toEqual([]);
|
||||||
|
|
||||||
|
// 2. Module-owned data.
|
||||||
|
await safeQuery(
|
||||||
|
database.client,
|
||||||
|
"INSERT INTO public.example_records (id, value) VALUES (gen_random_uuid(), 'kept')",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. The module is uninstalled: its directory is gone from module storage.
|
||||||
|
await rm(path.join(modulesDirectory, "example"), { recursive: true });
|
||||||
|
|
||||||
|
// 4. A later Core migration plans and applies while the module is absent.
|
||||||
|
const absentInventory = await inventory(coreMigrationsDirectory, modulesDirectory);
|
||||||
|
const absentPlan = await planModuleMigrations({
|
||||||
|
aggregate: absentInventory,
|
||||||
|
database: migrationDatabase,
|
||||||
|
});
|
||||||
|
expect(absentPlan.retained).toEqual([{ appliedCount: 1, moduleId: "example" }]);
|
||||||
|
expect(
|
||||||
|
absentPlan.entries.filter((entry) => entry.status === "pending").map((e) => e.moduleId),
|
||||||
|
).toEqual(["core"]);
|
||||||
|
const later = await applyModuleMigrations({
|
||||||
|
aggregate: absentInventory,
|
||||||
|
database: migrationDatabase,
|
||||||
|
});
|
||||||
|
expect(later.applied).toHaveLength(1);
|
||||||
|
expect(later.applied[0]?.moduleId).toBe("core");
|
||||||
|
expect(later.retained).toEqual([{ appliedCount: 1, moduleId: "example" }]);
|
||||||
|
|
||||||
|
// 5. The module's table and rows are untouched, and so is its history.
|
||||||
|
expect(await exampleValues(database.client)).toEqual(["kept"]);
|
||||||
|
expect((await readHistory(database.client)).map((row) => row.identity)).toContain(
|
||||||
|
"example:0000_initial",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. The module is reinstalled.
|
||||||
|
await installExample(modulesDirectory);
|
||||||
|
const restored = await inventory(coreMigrationsDirectory, modulesDirectory);
|
||||||
|
|
||||||
|
// 7. Its history is strict again, and valid; its data is where it left it.
|
||||||
|
const restoredPlan = await planModuleMigrations({
|
||||||
|
aggregate: restored,
|
||||||
|
database: migrationDatabase,
|
||||||
|
});
|
||||||
|
expect(restoredPlan.retained).toEqual([]);
|
||||||
|
expect(restoredPlan.pendingCount).toBe(0);
|
||||||
|
expect(
|
||||||
|
restoredPlan.entries.find((entry) => entry.identity === "example:0000_initial")?.status,
|
||||||
|
).toBe("applied");
|
||||||
|
const noOp = await applyModuleMigrations({
|
||||||
|
aggregate: restored,
|
||||||
|
database: migrationDatabase,
|
||||||
|
});
|
||||||
|
expect(noOp.noOp).toBe(true);
|
||||||
|
expect(await exampleValues(database.client)).toEqual(["kept"]);
|
||||||
|
|
||||||
|
// A returning package whose applied migration changed is refused, exactly as before.
|
||||||
|
const sqlPath = path.join(modulesDirectory, "example", "drizzle", "0000_initial.sql");
|
||||||
|
await writeFile(sqlPath, `${await readFile(sqlPath, "utf8")}-- edited\n`);
|
||||||
|
await captureRunnerError(
|
||||||
|
planModuleMigrations({
|
||||||
|
aggregate: await inventory(coreMigrationsDirectory, modulesDirectory),
|
||||||
|
database: migrationDatabase,
|
||||||
|
}),
|
||||||
|
"MIGRATION_CHECKSUM_MISMATCH",
|
||||||
|
);
|
||||||
|
expect(await exampleValues(database.client)).toEqual(["kept"]);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rm(scratch, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal, valid installed module written into a directory the caller owns: `package.json`,
|
||||||
|
* `ekklesia.module.json`, a compiled-ESM server stub at `dist/server/index.js`, and, unless
|
||||||
|
* disabled, a native Drizzle migration directory (journal, `_ekklesia.json` policy, snapshot chain,
|
||||||
|
* and SQL) that passes Core's migration discovery.
|
||||||
|
*
|
||||||
|
* Everything is generated here, from nothing in the repository: no build output, no ignored file,
|
||||||
|
* and no `node_modules`. Migration discovery imports no module code, so the stub is never run.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const originSnapshotId = "00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
|
export interface MigrationFixture {
|
||||||
|
readonly breakpoints?: boolean;
|
||||||
|
readonly destructive?:
|
||||||
|
| { readonly mode: "none" }
|
||||||
|
| { readonly mode: "approval-required"; readonly reason: string };
|
||||||
|
readonly snapshotId?: string;
|
||||||
|
readonly sql: string | Uint8Array;
|
||||||
|
readonly tag: string;
|
||||||
|
readonly timestamp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrationModuleFixture {
|
||||||
|
readonly dependencies?: Record<string, unknown>;
|
||||||
|
readonly manifest?: Record<string, unknown>;
|
||||||
|
readonly migrations?: readonly MigrationFixture[] | false;
|
||||||
|
readonly schemaSource?: string;
|
||||||
|
readonly serverSource?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatedMigrationModule {
|
||||||
|
readonly migrationDirectory?: string;
|
||||||
|
readonly moduleDirectory: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapshotId(index: number): string {
|
||||||
|
return `10000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function manifestFor(
|
||||||
|
id: string,
|
||||||
|
options: {
|
||||||
|
readonly dependencies?: Record<string, unknown>;
|
||||||
|
readonly migrations?: boolean;
|
||||||
|
readonly schema?: boolean;
|
||||||
|
} = {},
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
manifestVersion: 7,
|
||||||
|
id,
|
||||||
|
name: `${id} module`,
|
||||||
|
version: "0.1.0",
|
||||||
|
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||||
|
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||||
|
capabilities: [],
|
||||||
|
permissions: [],
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
...(options.schema ? { schema: "./dist/schema/index.js" } : {}),
|
||||||
|
...(options.migrations === false ? {} : { migrations: "./drizzle" }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultMigrations(id: string): readonly MigrationFixture[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sql: `CREATE TABLE "${id.replaceAll("-", "_")}_records" ("id" integer);\n`,
|
||||||
|
tag: "0000_initial",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMigrationModule(
|
||||||
|
modulesDirectory: string,
|
||||||
|
id: string,
|
||||||
|
fixture: MigrationModuleFixture = {},
|
||||||
|
): Promise<CreatedMigrationModule> {
|
||||||
|
const migrations =
|
||||||
|
fixture.migrations === false ? undefined : (fixture.migrations ?? defaultMigrations(id));
|
||||||
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
|
const migrationDirectory = migrations ? path.join(moduleDirectory, "drizzle") : undefined;
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
|
if (fixture.schemaSource !== undefined) {
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/schema"), { recursive: true });
|
||||||
|
}
|
||||||
|
if (migrationDirectory) {
|
||||||
|
await mkdir(path.join(migrationDirectory, "meta"), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
name: `@test/module-${id}`,
|
||||||
|
version: "0.1.0",
|
||||||
|
type: "module",
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
fixture.manifest ??
|
||||||
|
manifestFor(id, {
|
||||||
|
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||||
|
migrations: migrations !== undefined,
|
||||||
|
schema: fixture.schemaSource !== undefined,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
|
fixture.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
|
||||||
|
),
|
||||||
|
...(fixture.schemaSource === undefined
|
||||||
|
? []
|
||||||
|
: [writeFile(path.join(moduleDirectory, "dist/schema/index.js"), fixture.schemaSource)]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!migrationDirectory || !migrations) {
|
||||||
|
return { moduleDirectory };
|
||||||
|
}
|
||||||
|
|
||||||
|
const journalEntries = migrations.map((migration, index) => ({
|
||||||
|
idx: index,
|
||||||
|
version: "7",
|
||||||
|
when: migration.timestamp ?? 1_700_000_000_000 + index,
|
||||||
|
tag: migration.tag,
|
||||||
|
breakpoints: migration.breakpoints ?? true,
|
||||||
|
}));
|
||||||
|
const policyEntries = migrations.map((migration) => ({
|
||||||
|
tag: migration.tag,
|
||||||
|
destructive: migration.destructive ?? { mode: "none" },
|
||||||
|
}));
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(
|
||||||
|
path.join(migrationDirectory, "meta/_journal.json"),
|
||||||
|
JSON.stringify({ version: "7", dialect: "postgresql", entries: journalEntries }),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(migrationDirectory, "meta/_ekklesia.json"),
|
||||||
|
JSON.stringify({ version: 1, moduleId: id, entries: policyEntries }),
|
||||||
|
),
|
||||||
|
...migrations.flatMap((migration, index) => {
|
||||||
|
const idForSnapshot = migration.snapshotId ?? snapshotId(index);
|
||||||
|
const previousId =
|
||||||
|
index === 0
|
||||||
|
? originSnapshotId
|
||||||
|
: (migrations[index - 1]?.snapshotId ?? snapshotId(index - 1));
|
||||||
|
return [
|
||||||
|
writeFile(path.join(migrationDirectory, `${migration.tag}.sql`), migration.sql),
|
||||||
|
writeFile(
|
||||||
|
path.join(migrationDirectory, `meta/${index.toString().padStart(4, "0")}_snapshot.json`),
|
||||||
|
JSON.stringify({
|
||||||
|
version: "7",
|
||||||
|
dialect: "postgresql",
|
||||||
|
id: idForSnapshot,
|
||||||
|
prevId: previousId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { migrationDirectory, moduleDirectory };
|
||||||
|
}
|
||||||
+51
-12
@@ -70,18 +70,57 @@
|
|||||||
|
|
||||||
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
|
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
|
||||||
{$EKKLESIA_STORAGE_HOSTNAME} {
|
{$EKKLESIA_STORAGE_HOSTNAME} {
|
||||||
reverse_proxy https://object-storage:8443 {
|
# Delivery-layer CORS belongs to the deployment, not to the object store: this block decides it,
|
||||||
# An S3 signature covers the Host header: pass the original through unchanged, or every signed
|
# so it stays the same with any S3 backend behind it. Only the origins whose pages read objects
|
||||||
# URL fails verification.
|
# with fetch() get CORS: the Ekklesia origin, which serves the Dashboard and the displays. An <img> needs none. Every object
|
||||||
header_up Host {host}
|
# URL is still a signed, expiring capability; CORS only decides which pages may read the bytes.
|
||||||
header_up -CF-Connecting-IP
|
@cors_origin header Origin https://{$EKKLESIA_HOSTNAME}
|
||||||
header_up -X-Real-IP
|
|
||||||
header_up -Forwarded
|
# A preflight is answered here and never reaches storage. Only GET and HEAD are ever allowed, and
|
||||||
header_up -True-Client-IP
|
# no request header beyond the CORS-safelisted ones.
|
||||||
transport http {
|
@preflight method OPTIONS
|
||||||
# object-storage presents a certificate from the installation-private CA.
|
handle @preflight {
|
||||||
tls_trust_pool file /etc/caddy/ekklesia-ca.crt
|
header @cors_origin {
|
||||||
tls_server_name object-storage
|
Access-Control-Allow-Origin {http.request.header.Origin}
|
||||||
|
Access-Control-Allow-Methods "GET, HEAD"
|
||||||
|
Access-Control-Max-Age 600
|
||||||
|
}
|
||||||
|
header Vary Origin
|
||||||
|
respond 204
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
# Set when the response is written, after the upstream's own CORS headers were removed below.
|
||||||
|
header @cors_origin >Access-Control-Allow-Origin {http.request.header.Origin}
|
||||||
|
header >Vary Origin
|
||||||
|
|
||||||
|
reverse_proxy https://object-storage:8443 {
|
||||||
|
# An S3 signature covers the Host header: pass the original through unchanged, or every
|
||||||
|
# signed URL fails verification.
|
||||||
|
header_up Host {host}
|
||||||
|
header_up -CF-Connecting-IP
|
||||||
|
header_up -X-Real-IP
|
||||||
|
header_up -Forwarded
|
||||||
|
header_up -True-Client-IP
|
||||||
|
# The object store's own CORS answer (SeaweedFS reflects any origin, with every method)
|
||||||
|
# is not the deployment's policy.
|
||||||
|
header_down -Access-Control-Allow-Origin
|
||||||
|
header_down -Access-Control-Allow-Methods
|
||||||
|
header_down -Access-Control-Allow-Headers
|
||||||
|
header_down -Access-Control-Expose-Headers
|
||||||
|
header_down -Access-Control-Allow-Credentials
|
||||||
|
header_down -Access-Control-Max-Age
|
||||||
|
header_down -Vary
|
||||||
|
transport http {
|
||||||
|
# object-storage presents a certificate from the installation-private CA.
|
||||||
|
tls_trust_pool file /etc/caddy/ekklesia-ca.crt
|
||||||
|
tls_server_name object-storage
|
||||||
|
# HTTP/1.1 only. Over HTTP/2, SeaweedFS answers a conditional request with a 304 that
|
||||||
|
# declares a Content-Length it never sends; Caddy then aborts the browser's stream,
|
||||||
|
# which a browser reports as ERR_HTTP2_PROTOCOL_ERROR on every revalidation. Its
|
||||||
|
# HTTP/1.1 304 is clean. Browsers still reach this proxy over HTTP/2 and HTTP/3.
|
||||||
|
versions 1.1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,18 +92,61 @@
|
|||||||
|
|
||||||
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
|
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
|
||||||
{$EKKLESIA_STORAGE_HOSTNAME} {
|
{$EKKLESIA_STORAGE_HOSTNAME} {
|
||||||
reverse_proxy https://object-storage:8443 {
|
# Delivery-layer CORS belongs to the deployment, not to the object store: this block decides it,
|
||||||
# An S3 signature covers the Host header: pass the original through unchanged, or every signed
|
# so it stays the same with any S3 backend behind it. Only the origins whose pages read objects
|
||||||
# URL fails verification.
|
# with fetch() get CORS: the API origin, where displays run, and the Dashboard origin,
|
||||||
header_up Host {host}
|
# where a module's preview may cache images too. An <img> needs none. Every object
|
||||||
header_up -CF-Connecting-IP
|
# URL is still a signed, expiring capability; CORS only decides which pages may read the bytes.
|
||||||
header_up -X-Real-IP
|
@cors_origin {
|
||||||
header_up -Forwarded
|
header Origin https://{$EKKLESIA_HOSTNAME}
|
||||||
header_up -True-Client-IP
|
header Origin https://{$EKKLESIA_DASHBOARD_HOSTNAME}
|
||||||
transport http {
|
}
|
||||||
# object-storage presents a certificate from the installation-private CA.
|
|
||||||
tls_trust_pool file /etc/caddy/ekklesia-ca.crt
|
# A preflight is answered here and never reaches storage. Only GET and HEAD are ever allowed, and
|
||||||
tls_server_name object-storage
|
# no request header beyond the CORS-safelisted ones.
|
||||||
|
@preflight method OPTIONS
|
||||||
|
handle @preflight {
|
||||||
|
header @cors_origin {
|
||||||
|
Access-Control-Allow-Origin {http.request.header.Origin}
|
||||||
|
Access-Control-Allow-Methods "GET, HEAD"
|
||||||
|
Access-Control-Max-Age 600
|
||||||
|
}
|
||||||
|
header Vary Origin
|
||||||
|
respond 204
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
# Set when the response is written, after the upstream's own CORS headers were removed below.
|
||||||
|
header @cors_origin >Access-Control-Allow-Origin {http.request.header.Origin}
|
||||||
|
header >Vary Origin
|
||||||
|
|
||||||
|
reverse_proxy https://object-storage:8443 {
|
||||||
|
# An S3 signature covers the Host header: pass the original through unchanged, or every
|
||||||
|
# signed URL fails verification.
|
||||||
|
header_up Host {host}
|
||||||
|
header_up -CF-Connecting-IP
|
||||||
|
header_up -X-Real-IP
|
||||||
|
header_up -Forwarded
|
||||||
|
header_up -True-Client-IP
|
||||||
|
# The object store's own CORS answer (SeaweedFS reflects any origin, with every method)
|
||||||
|
# is not the deployment's policy.
|
||||||
|
header_down -Access-Control-Allow-Origin
|
||||||
|
header_down -Access-Control-Allow-Methods
|
||||||
|
header_down -Access-Control-Allow-Headers
|
||||||
|
header_down -Access-Control-Expose-Headers
|
||||||
|
header_down -Access-Control-Allow-Credentials
|
||||||
|
header_down -Access-Control-Max-Age
|
||||||
|
header_down -Vary
|
||||||
|
transport http {
|
||||||
|
# object-storage presents a certificate from the installation-private CA.
|
||||||
|
tls_trust_pool file /etc/caddy/ekklesia-ca.crt
|
||||||
|
tls_server_name object-storage
|
||||||
|
# HTTP/1.1 only. Over HTTP/2, SeaweedFS answers a conditional request with a 304 that
|
||||||
|
# declares a Content-Length it never sends; Caddy then aborts the browser's stream,
|
||||||
|
# which a browser reports as ERR_HTTP2_PROTOCOL_ERROR on every revalidation. Its
|
||||||
|
# HTTP/1.1 304 is clean. Browsers still reach this proxy over HTTP/2 and HTTP/3.
|
||||||
|
versions 1.1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-29
@@ -1,9 +1,10 @@
|
|||||||
# Ekklesia's one production image (ADR 0044): the `api` service and every operator-run CLI, from one
|
# Ekklesia's one generic platform image (ADR 0044, ADR 0048): the `api` service and every operator-run
|
||||||
# build. See deploy/README.md for how to build and run it.
|
# 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
|
# The build context is this repository. The image contains **zero feature modules**: installed
|
||||||
# `modules/<id>`, and the `pnpm-lock.yaml` that describes exactly that set. This file clones nothing
|
# modules live in installation-owned storage, mounted read-only at /var/lib/ekklesia/modules
|
||||||
# and chooses no modules. With an empty or absent `modules/`, it builds the platform alone.
|
# (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.
|
# No `# syntax=` directive, deliberately: it would make BuildKit pull an unpinned frontend image.
|
||||||
# The built-in frontend supports everything below, including cache mounts.
|
# The built-in frontend supports everything below, including cache mounts.
|
||||||
@@ -28,9 +29,10 @@ COPY package.json ./
|
|||||||
RUN corepack enable && corepack install
|
RUN corepack enable && corepack install
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------------------------------
|
||||||
# manifests: the lockfile, workspace configuration, and every workspace package.json, at their
|
# manifests: the lockfile, workspace configuration, and the package.json of every platform package
|
||||||
# repository paths. Dependency installation keys on these alone, so a source-only change reuses the
|
# the runtime needs, at their repository paths. Dependency installation keys on these alone, so a
|
||||||
# installed layers.
|
# 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
|
FROM base AS manifests
|
||||||
WORKDIR /context
|
WORKDIR /context
|
||||||
@@ -38,9 +40,8 @@ COPY . .
|
|||||||
RUN set -eu; \
|
RUN set -eu; \
|
||||||
mkdir /manifests; \
|
mkdir /manifests; \
|
||||||
cp package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc /manifests/; \
|
cp package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc /manifests/; \
|
||||||
for manifest in apps/*/package.json builders/*/package.json core/package.json \
|
for manifest in apps/*/package.json core/package.json packages/*/package.json \
|
||||||
modules/*/package.json packages/*/package.json surfaces/*/package.json \
|
surfaces/*/package.json; do \
|
||||||
templates/*/package.json; do \
|
|
||||||
if [ -f "$manifest" ]; then install -D -m 0644 "$manifest" "/manifests/$manifest"; fi; \
|
if [ -f "$manifest" ]; then install -D -m 0644 "$manifest" "/manifests/$manifest"; fi; \
|
||||||
done
|
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
|
# 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.
|
# already exists. The store cache mount keeps a rebuild off the network all the same.
|
||||||
COPY . .
|
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 \
|
RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \
|
||||||
pnpm install --frozen-lockfile --store-dir /pnpm/store
|
pnpm install --frozen-lockfile --store-dir /pnpm/store
|
||||||
# The same gates `pnpm verify` runs for boundaries, modules, and migrations, against exactly the
|
# The same gates `pnpm verify` runs for boundaries, modules, and migrations. With no modules in the
|
||||||
# module set in this context. `pnpm boundaries` comes first and reads only sources: among its rules,
|
# context, module validation covers the empty set and migration validation covers Core's own. Each
|
||||||
# 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
|
|
||||||
# validate script builds first; the final build is a Turbo cache hit that keeps the intent explicit.
|
# 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
|
RUN pnpm boundaries && pnpm modules:validate && pnpm migrations:validate && pnpm build
|
||||||
# What the runtime resolves at startup, at the paths it resolves them from:
|
# 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/`;
|
# the Dashboard and display host assets under `surfaces/shell/dist/browser/`;
|
||||||
# - Core's migrations, `core/drizzle/` with its `meta/` journal;
|
# - Core's migrations, `core/drizzle/` with its `meta/` journal.
|
||||||
# - each module directory whole, minus `node_modules`, because each manifest names its own
|
# Never a module and never the module template. `node_modules` comes from the production-only
|
||||||
# entrypoints, UI artifacts, and migrations directory.
|
# install below, never from this stage.
|
||||||
# `node_modules` comes from the production-only install below, never from this stage.
|
|
||||||
RUN set -eu; \
|
RUN set -eu; \
|
||||||
mkdir -p /runtime/core /runtime/modules; \
|
mkdir -p /runtime/core; \
|
||||||
for package in apps/* core packages/* surfaces/*; do \
|
for package in apps/* core packages/* surfaces/*; do \
|
||||||
if [ -d "$package/dist" ]; then \
|
if [ -d "$package/dist" ]; then \
|
||||||
mkdir -p "/runtime/$package"; \
|
mkdir -p "/runtime/$package"; \
|
||||||
cp -a "$package/dist" "/runtime/$package/dist"; \
|
cp -a "$package/dist" "/runtime/$package/dist"; \
|
||||||
fi; \
|
fi; \
|
||||||
done; \
|
done; \
|
||||||
cp -a core/drizzle /runtime/core/drizzle; \
|
cp -a core/drizzle /runtime/core/drizzle
|
||||||
if [ -d modules ]; then \
|
|
||||||
tar -C modules --exclude='node_modules' -cf - . | tar -C /runtime/modules -xf -; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------------------------------
|
||||||
# prod-deps: the same lockfile, production dependencies only. No compiler, bundler, or test runner
|
# prod-deps: the same lockfile, production dependencies only, for the platform packages alone. No
|
||||||
# reaches the runtime image.
|
# compiler, bundler, or test runner reaches the runtime image, and neither does anything only the
|
||||||
|
# module template depends on.
|
||||||
# ---------------------------------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------------------------------
|
||||||
FROM toolchain AS prod-deps
|
FROM toolchain AS prod-deps
|
||||||
COPY --from=manifests /manifests/ ./
|
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.
|
# the API still binds 127.0.0.1. `production` is stated rather than relied on as the default.
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
EKKLESIA_API_HOST=0.0.0.0 \
|
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
|
# 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
|
WORKDIR /opt/ekklesia
|
||||||
COPY --from=prod-deps /opt/ekklesia/ ./
|
COPY --from=prod-deps /opt/ekklesia/ ./
|
||||||
COPY --from=build /runtime/ ./
|
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.
|
# Files stay root-owned: the unprivileged runtime user can read the installation, never modify it.
|
||||||
USER node
|
USER node
|
||||||
|
|||||||
+194
-53
@@ -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/object-storage.env` | The S3 key pair | `object-storage` |
|
||||||
| `/etc/ekklesia/tunnel.env` | `TUNNEL_TOKEN` | `tunnel` |
|
| `/etc/ekklesia/tunnel.env` | `TUNNEL_TOKEN` | `tunnel` |
|
||||||
| `/etc/ekklesia/tls/` | The CA and server certificates and keys | mounted file by file |
|
| `/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
|
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`
|
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_IDENTITY_BASE_URL` and `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` are `https://` and your
|
||||||
Ekklesia hostname. `S3_DELIVERY_ENDPOINT` is `https://` and your storage hostname.
|
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
|
`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).
|
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
|
```sh
|
||||||
docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait postgres object-storage
|
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
|
Nothing ever applies migrations automatically: the API never does, and `migrate` runs only when
|
||||||
you run it.
|
you run it.
|
||||||
7. **Start everything:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
|
8. **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
|
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
|
`docker compose run --rm migrate node core/dist/cli/doctor.js` reports `READY` (with warnings
|
||||||
for anything optional you left unconfigured, such as email).
|
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
|
([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`.
|
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
|
### Upgrading
|
||||||
|
|
||||||
1. **Pull or build the new image**, and change `EKKLESIA_IMAGE` in `deploy/.env`. Keep the old tag.
|
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)).
|
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`.
|
`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
|
### Rolling back
|
||||||
|
|
||||||
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
|
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
|
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
|
### Backups
|
||||||
|
|
||||||
Slice 6 adds the scheduled backup job and the restore procedure (ADR 0044, Backups). Until then,
|
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
|
### Split Dashboard and API origins
|
||||||
|
|
||||||
@@ -178,6 +256,29 @@ the topology and warns when the two hostnames are not siblings.
|
|||||||
The Dashboard learns the API's origin at run time: the API writes its identity base URL into the page
|
The Dashboard learns the API's origin at run time: the API writes its identity base URL into the page
|
||||||
it serves (`<meta name="ekklesia-api-origin">`). Nothing about the topology is built into the image.
|
it serves (`<meta name="ekklesia-api-origin">`). Nothing about the topology is built into the image.
|
||||||
|
|
||||||
|
### Storage delivery: CORS is the deployment's
|
||||||
|
|
||||||
|
Browsers fetch objects directly from the storage hostname through signed, expiring URLs (ADR 0031).
|
||||||
|
**Which pages may read those bytes with `fetch()` is decided by the delivery layer, the proxy, and
|
||||||
|
never by the object store.** That policy therefore stays the same with SeaweedFS, R2, or any other
|
||||||
|
S3 backend behind it, and an installation that changes backends changes nothing about CORS.
|
||||||
|
|
||||||
|
The reference proxy's storage block:
|
||||||
|
|
||||||
|
- grants CORS only to the origins whose pages read objects with `fetch()`: the Ekklesia origin in the
|
||||||
|
same-origin topology; the API origin (displays) and the Dashboard origin (a module's preview) in
|
||||||
|
the split topology. An `<img>` needs no CORS and is unaffected;
|
||||||
|
- allows `GET` and `HEAD` only, and no request header beyond the CORS-safelisted ones, always with
|
||||||
|
`Vary: Origin`;
|
||||||
|
- answers preflight `OPTIONS` requests itself, so they never reach storage;
|
||||||
|
- removes the object store's own CORS headers. SeaweedFS's default reflects any origin and
|
||||||
|
advertises `PUT`, `POST`, and `DELETE`.
|
||||||
|
|
||||||
|
The proxy reaches storage over **HTTP/1.1 only**. Over HTTP/2, SeaweedFS answers a conditional
|
||||||
|
request with a `304` that declares a `Content-Length` it never sends. The proxy then resets the
|
||||||
|
browser's stream, and every reload of a cached image fails with `ERR_HTTP2_PROTOCOL_ERROR`.
|
||||||
|
Browsers still reach the proxy itself over HTTP/2 and HTTP/3.
|
||||||
|
|
||||||
### Behind an existing reverse proxy
|
### Behind an existing reverse proxy
|
||||||
|
|
||||||
Run the base alone and attach `api` and `object-storage` to your proxy's network with an overlay of
|
Run the base alone and attach `api` and `object-storage` to your proxy's network with an overlay of
|
||||||
@@ -213,6 +314,8 @@ Then make your proxy do what `Caddyfile.example` does:
|
|||||||
- forward the storage hostname to `https://object-storage:8443`, verifying it against
|
- forward the storage hostname to `https://object-storage:8443`, verifying it against
|
||||||
`/etc/ekklesia/tls/ca.crt` with server name `object-storage`, and **pass the original `Host`
|
`/etc/ekklesia/tls/ca.crt` with server name `object-storage`, and **pass the original `Host`
|
||||||
through unchanged**, or every signed URL fails (ADR 0031).
|
through unchanged**, or every signed URL fails (ADR 0031).
|
||||||
|
- reach `object-storage` over **HTTP/1.1** only, and own the storage hostname's CORS as described in
|
||||||
|
[Storage delivery](#storage-delivery-cors-is-the-deployments).
|
||||||
|
|
||||||
The proxy's address must be fixed and must never be handed to anything else. With Docker, give its
|
The proxy's address must be fixed and must never be handed to anything else. With Docker, give its
|
||||||
network an explicit subnet, limit `ip_range` to part of it, and fix the proxy's address outside that
|
network an explicit subnet, limit `ip_range` to part of it, and fix the proxy's address outside that
|
||||||
@@ -284,11 +387,18 @@ deployment:
|
|||||||
|
|
||||||
- `pnpm deploy:config` validates the base, base + proxy, and base + proxy + tunnel with the example
|
- `pnpm deploy:config` validates the base, base + proxy, and base + proxy + tunnel with the example
|
||||||
env files. CI runs it in `pnpm ci:verify`.
|
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
|
- `bash scripts/check-image.sh` builds the image and proves it is generic: no `/opt/ekklesia/modules`,
|
||||||
platform-only image and a disposable PostgreSQL and SeaweedFS that exist only for that run. It
|
no module template, no `ekklesia.module.json` outside installed dependencies, an empty root-owned
|
||||||
follows the first boot above and checks the following through the proxy:
|
`/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;
|
- readiness and the Dashboard;
|
||||||
- a signed object URL fetched through the storage hostname;
|
- 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`;
|
||||||
|
- storage CORS: exactly the allowed origins, `GET` and `HEAD` only, preflights answered by the
|
||||||
|
proxy, and none of the object store's own CORS headers;
|
||||||
- the client-address chain, both from an ordinary peer and from the tunnel connector's address;
|
- the client-address chain, both from an ordinary peer and from the tunnel connector's address;
|
||||||
- initial-owner sign-in and sign-out;
|
- initial-owner sign-in and sign-out;
|
||||||
- that the API's database connection is TLS 1.3, and that it is refused by a foreign CA and by a
|
- that the API's database connection is TLS 1.3, and that it is refused by a foreign CA and by a
|
||||||
@@ -296,12 +406,23 @@ deployment:
|
|||||||
- Doctor.
|
- Doctor.
|
||||||
|
|
||||||
`EKKLESIA_SMOKE_TOPOLOGY=split` runs the [split topology](#split-dashboard-and-api-origins)
|
`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
|
instead, on the **same generic image**, and also proves [Installed modules](#installed-modules)
|
||||||
hostname's served and refused paths, exact CORS for the Dashboard's origin and none for others,
|
end to end:
|
||||||
and then a real headless Chromium (pinned by digest, driven over the DevTools protocol) that signs
|
- the image boots with no module;
|
||||||
in on the Dashboard's hostname, checks the session cookie lives only on the API's, mounts the
|
- the repository's module template, packaged as a self-contained test module
|
||||||
module's UI from the API's origin and sees its own credentialed requests succeed, mounts its display
|
(`scripts/lib/test-module-package.Dockerfile`, test-only and not a package format), is installed
|
||||||
on the API's hostname with its service worker, and signs out.
|
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.
|
CI runs both topologies on every change.
|
||||||
- **Test-only:** the smoke test runs Caddy with its internal CA (`local_certs`), because a test run
|
- **Test-only:** the smoke test runs Caddy with its internal CA (`local_certs`), because a test run
|
||||||
@@ -322,44 +443,47 @@ is no second image and no second build.
|
|||||||
|
|
||||||
### What it builds
|
### What it builds
|
||||||
|
|
||||||
The build context is an **already-assembled workspace**: this repository, plus zero or more module
|
The build context is **this repository**, and the image is the same for every installation
|
||||||
packages already present in `modules/<id>`, plus a `pnpm-lock.yaml` describing exactly that set.
|
([ADR 0048](../docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)). It
|
||||||
The Dockerfile clones nothing and chooses no modules. It builds what it is given. With no
|
contains **zero feature modules**: installed modules live in module storage outside it
|
||||||
`modules/` directory, it builds the platform alone; CI builds that case on every change.
|
([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
|
A `modules/` directory in the checkout never reaches the image. `.dockerignore` keeps it out of the
|
||||||
commit, and the instance's own lockfile) belongs to the installation's instance composition, not to
|
build context, and the build refuses a context that still contains one. The module template is a
|
||||||
this file.
|
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 |
|
| 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. |
|
| `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. |
|
| `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. |
|
| `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` | `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. |
|
| `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`: production dependencies only. |
|
| `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). No pnpm, no compiler, no curl. |
|
| `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
|
The runtime keeps the repository layout at `/opt/ekklesia`, because that layout is how the platform
|
||||||
finds its files:
|
finds its files:
|
||||||
|
|
||||||
- the API takes the repository root as three directories above `apps/api/dist/`, and reads
|
- 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
|
- 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`;
|
`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/`;
|
- 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
|
- installed modules are read from `EKKLESIA_MODULES_DIR`, `/var/lib/ekklesia/modules`, never from
|
||||||
own entrypoints, UI artifacts, and migrations directory.
|
under `/opt/ekklesia`. With nothing mounted there, the image runs with no modules.
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build \
|
docker build \
|
||||||
--file deploy/Dockerfile \
|
--file deploy/Dockerfile \
|
||||||
--build-arg VERSION=0.1.1 \
|
--build-arg VERSION=0.1.2 \
|
||||||
--build-arg REVISION="$(git rev-parse HEAD)" \
|
--build-arg REVISION="$(git rev-parse HEAD)" \
|
||||||
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
||||||
--tag ekklesia:0.1.1 \
|
--tag ekklesia:0.1.2 \
|
||||||
.
|
.
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -371,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
|
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.
|
context entirely, and no registry credentials are needed.
|
||||||
|
|
||||||
**A module missing a production dependency fails the build, not the API.** The runtime receives
|
### The runtime an installed module targets
|
||||||
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.
|
|
||||||
|
|
||||||
**A platform-only image does not exercise native module dependencies.** With no modules in the
|
An installed module runs on the image's Node.js, inside the API process, from its own directory
|
||||||
context, nothing like sharp is installed, so the CI image build proves nothing about them. An
|
([ADR 0048](../docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)):
|
||||||
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
|
- **Node.js 24.18.0 on Debian 13, so glibc**, for the architecture the image was built for. A native
|
||||||
example, process one image).
|
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
|
||||||
|
|
||||||
Configuration comes **only from the container environment**, never from a file inside the image.
|
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 |
|
| Variable | Image value | Why |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `NODE_ENV` | `production` | Stated rather than relied on. Anything other than `development` already validates as production. |
|
| `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_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_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
|
Everything else is the ordinary host configuration in
|
||||||
[API § Required configuration](../docs/development/api.md#required-configuration), supplied by the
|
[API § Required configuration](../docs/development/api.md#required-configuration), supplied by the
|
||||||
@@ -415,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
|
- **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
|
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
|
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
|
writable, and neither is module storage, which is mounted read-only. Anything that needs a
|
||||||
work around.
|
writable path other than `/tmp` is a defect to report, not to work around.
|
||||||
|
|
||||||
### Health
|
### Health
|
||||||
|
|
||||||
@@ -440,20 +571,30 @@ for a development checkout, not for the image.
|
|||||||
`migrate` has `profiles: ["ops"]`, so `up` never starts it. It never restarts, has no health check,
|
`migrate` has `profiles: ["ops"]`, so `up` never starts it. It never restarts, has no health check,
|
||||||
and its default command is the read-only plan. **Nothing applies migrations automatically.**
|
and its default command is the read-only plan. **Nothing applies migrations automatically.**
|
||||||
|
|
||||||
|
Run operator commands with the same Compose files as the stack. `COMPOSE_FILE` in `deploy/.env`
|
||||||
|
(set in [`env/compose.env.example`](env/compose.env.example)) does that for every plain
|
||||||
|
`docker compose` command below. It matters for Doctor in particular: Doctor checks the
|
||||||
|
client-address configuration of the process it runs in. The proxy overlay gives `migrate` the
|
||||||
|
API's trusted-proxy settings, so Doctor reports what the API runs. Without the overlay, Doctor
|
||||||
|
reports direct mode for an API that trusts its proxy. The running API's own value is shown by
|
||||||
|
`docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE`.
|
||||||
|
|
||||||
| Task | Command (from `deploy/`) |
|
| Task | Command (from `deploy/`) |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Plan migrations (read-only) | `docker compose run --rm migrate` |
|
| Plan migrations (read-only) | `docker compose run --rm migrate` |
|
||||||
| Apply migrations | `docker compose run --rm migrate node core/dist/cli/apply-migrations.js` |
|
| 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` |
|
| 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` |
|
| 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`) |
|
| 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
|
Every command reads installed modules from the same `EKKLESIA_MODULES_DIR` the API does. The plan,
|
||||||
enablement is not.
|
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
|
### 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
|
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
|
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
|
`bin/esbuild` to the native binary it ships beside it; when pnpm restores esbuild from the store's
|
||||||
|
|||||||
@@ -13,6 +13,13 @@
|
|||||||
# its lower half; the proxy (and the tunnel connector, if used) take addresses from the upper half,
|
# its lower half; the proxy (and the tunnel connector, if used) take addresses from the upper half,
|
||||||
# which Docker never hands to another container.
|
# which Docker never hands to another container.
|
||||||
|
|
||||||
|
# Exactly one trusted peer: this proxy. Set from the same variable that fixes the proxy's address,
|
||||||
|
# so the two cannot drift apart. These override any value in ekklesia.env.
|
||||||
|
x-ekklesia-client-address: &ekklesia-client-address
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_MODE: trusted-proxy
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_HEADER: x-forwarded-for
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: ${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}/32
|
||||||
|
|
||||||
services:
|
services:
|
||||||
proxy:
|
proxy:
|
||||||
image: caddy:2.11.4-alpine@sha256:6aeddd44c3078b0f9a35206472a11420648a79c184603ef95957d0a20044cb2b
|
image: caddy:2.11.4-alpine@sha256:6aeddd44c3078b0f9a35206472a11420648a79c184603ef95957d0a20044cb2b
|
||||||
@@ -59,12 +66,12 @@ services:
|
|||||||
api:
|
api:
|
||||||
networks:
|
networks:
|
||||||
edge: {}
|
edge: {}
|
||||||
environment:
|
environment: *ekklesia-client-address
|
||||||
# Exactly one trusted peer: this proxy. Set here from the same variable that fixes the proxy's
|
|
||||||
# address, so the two cannot drift apart. These override any value in ekklesia.env.
|
# Operator commands (migrations, Doctor) run as `migrate`. It gets the API's client-address
|
||||||
EKKLESIA_CLIENT_ADDRESS_MODE: trusted-proxy
|
# configuration too, so that Doctor checks what the API actually runs rather than an empty one.
|
||||||
EKKLESIA_CLIENT_ADDRESS_HEADER: x-forwarded-for
|
migrate:
|
||||||
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: ${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}/32
|
environment: *ekklesia-client-address
|
||||||
|
|
||||||
object-storage:
|
object-storage:
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -32,8 +32,20 @@ x-ekklesia-app: &ekklesia-app
|
|||||||
# without any storage TLS exception. The database CA travels separately, as PEM text in
|
# 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.
|
# EKKLESIA_DATABASE_CA, and is the only CA the database connection trusts.
|
||||||
NODE_EXTRA_CA_CERTS: /run/ekklesia/tls/ca.crt
|
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:
|
volumes:
|
||||||
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/ca.crt:/run/ekklesia/tls/ca.crt:ro
|
- ${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:
|
networks:
|
||||||
data: {}
|
data: {}
|
||||||
storage: {}
|
storage: {}
|
||||||
|
|||||||
Vendored
+12
@@ -2,6 +2,12 @@
|
|||||||
# Compose reads automatically from this directory. Nothing here is a secret; secrets live in the
|
# Compose reads automatically from this directory. Nothing here is a secret; secrets live in the
|
||||||
# per-service files under EKKLESIA_ENV_DIR.
|
# per-service files under EKKLESIA_ENV_DIR.
|
||||||
|
|
||||||
|
# Which Compose files make up this installation, so that every plain `docker compose …` command
|
||||||
|
# run from this directory, operator commands through `migrate` included, sees the same services and
|
||||||
|
# settings as the running stack. Add compose.tunnel.yaml if you use the tunnel connector. Without
|
||||||
|
# the proxy overlay here, Doctor checks a `migrate` that lacks the API's client-address settings.
|
||||||
|
COMPOSE_FILE=compose.yaml:compose.proxy.yaml
|
||||||
|
|
||||||
# The image to run, by registry, name, and tag (or digest). Required.
|
# The image to run, by registry, name, and tag (or digest). Required.
|
||||||
EKKLESIA_IMAGE=
|
EKKLESIA_IMAGE=
|
||||||
|
|
||||||
@@ -9,6 +15,12 @@ EKKLESIA_IMAGE=
|
|||||||
EKKLESIA_ENV_DIR=/etc/ekklesia
|
EKKLESIA_ENV_DIR=/etc/ekklesia
|
||||||
EKKLESIA_TLS_DIR=/etc/ekklesia/tls
|
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 ---------------------------------------------------------------------------
|
# --- compose.proxy.yaml ---------------------------------------------------------------------------
|
||||||
# The Ekklesia hostname and the storage hostname (signed object URLs). In the default same-origin
|
# 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.
|
# 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
|
# Never commit the filled-in file. Generate secrets with scripts/gen-secrets.sh, and the database CA
|
||||||
# line with scripts/init-tls.sh.
|
# 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.
|
# The image already sets NODE_ENV=production, EKKLESIA_API_HOST=0.0.0.0, EKKLESIA_API_PORT=3000, and
|
||||||
# Compose sets NODE_EXTRA_CA_CERTS. The proxy overlay sets the three EKKLESIA_CLIENT_ADDRESS_*
|
# EKKLESIA_MODULES_DIR=/var/lib/ekklesia/modules. Compose sets NODE_EXTRA_CA_CERTS and mounts module
|
||||||
# variables. None of those belong here.
|
# 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
|
# Values that are part of the reference topology are filled in; everything installation-specific is
|
||||||
# blank. Full reference: docs/development/api.md#required-configuration.
|
# blank. Full reference: docs/development/api.md#required-configuration.
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# 0047: Every module proves itself inside Ekklesia, with one harness the platform publishes
|
||||||
|
|
||||||
|
- Status: Proposed
|
||||||
|
- Date: 2026-10-05
|
||||||
|
- Supersedes: none
|
||||||
|
- Related: [ADR 0016](0016-module-database-access.md),
|
||||||
|
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||||
|
[ADR 0028](0028-module-operational-lifecycle.md),
|
||||||
|
[ADR 0030](0030-host-provided-module-object-storage.md),
|
||||||
|
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||||
|
[ADR 0036](0036-managed-device-identity-and-pairing.md),
|
||||||
|
[ADR 0041](0041-module-browser-contributions.md),
|
||||||
|
[ADR 0043](0043-display-offline-code-fallback.md),
|
||||||
|
[ADR 0044](0044-self-hosted-reference-deployment.md),
|
||||||
|
[ADR 0045](0045-split-dashboard-and-api-origins.md)
|
||||||
|
- Must be revised against: [ADR 0048](0048-generic-image-and-installed-module-storage.md) —
|
||||||
|
steps 1 and 3 below build the module into a deployment image, which ADR 0048 rules out. Before
|
||||||
|
this proposal is accepted, the harness must run the module from module storage in the generic
|
||||||
|
image.
|
||||||
|
- Should consume: [ADR 0049](0049-module-package-v1.md), when accepted — the harness installs the
|
||||||
|
module's canonical package, the artifact a church would install, rather than a workspace build.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Modules are independently versioned products (ADR 0021). They live in their own repositories, and
|
||||||
|
none of them can be built, run, or rendered without Ekklesia. The first production module, Signage,
|
||||||
|
reached a church with three defects that only show when it runs **inside** Ekklesia, in a real
|
||||||
|
browser:
|
||||||
|
|
||||||
|
- every heartbeat its display sent was refused with a 400, because the display and the server
|
||||||
|
disagreed about `null`;
|
||||||
|
- a display that failed one request at start-up never recovered without a reload;
|
||||||
|
- its media cache leaked one blob per slide per refresh, until the browser refused the display's own
|
||||||
|
requests.
|
||||||
|
|
||||||
|
Signage had unit tests, browser tests against a stubbed host, and no CI. Every test passed, because
|
||||||
|
each half was tested against its own idea of the other, and nothing ever ran the module in the
|
||||||
|
platform it ships into.
|
||||||
|
|
||||||
|
The platform's own deployment smoke (ADR 0044, ADR 0045) does run a module in a real browser, but
|
||||||
|
only the repository's template, and only far enough to mount it. The instance repository runs the
|
||||||
|
smoke against its real modules, but only at release time, after a module has already been tagged.
|
||||||
|
Each module repository that wants more than that has to assemble the platform, build an image,
|
||||||
|
start a deployment, and drive a browser itself. Signage is about to do exactly that by hand. The
|
||||||
|
second module, Classroom, would copy it, and the copies would drift from the platform they test.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### The platform publishes a versioned module harness
|
||||||
|
|
||||||
|
Each platform release publishes one OCI image, `ekklesia-module-harness:<platform version>`. It is
|
||||||
|
built from the platform at that release tag by the platform's own release CI, and pushed to the
|
||||||
|
organization's registry. A version is never overwritten. Modules reference it **by digest**.
|
||||||
|
|
||||||
|
The harness contains the platform source at that version: its `deploy/` directory, its Dockerfile,
|
||||||
|
its lockfile, and its smoke scripts. It also contains the pinned Node.js and pnpm, the browser
|
||||||
|
driver, and the pinned headless Chromium image reference. Given a module checkout, it:
|
||||||
|
|
||||||
|
1. **assembles** the module into the platform at `modules/<id>`, exactly as an instance does (ADR
|
||||||
|
0044), and installs with `--frozen-lockfile`;
|
||||||
|
2. runs the platform's own gates against that set: `pnpm boundaries`, `modules:validate`,
|
||||||
|
`migrations:validate`, and the build;
|
||||||
|
3. **builds** the deployment image from the assembled workspace with the platform's Dockerfile;
|
||||||
|
4. starts the **reference deployment** in the split topology (ADR 0045), with a disposable
|
||||||
|
PostgreSQL and object store, Caddy's internal CA, and freshly generated secrets;
|
||||||
|
5. plans, then **explicitly applies**, migrations against that fresh database. This is the only
|
||||||
|
database the harness ever migrates;
|
||||||
|
6. creates the initial owner, grants the module's declared permissions to a test person, and
|
||||||
|
**mounts every UI surface the manifest declares**, in headless Chromium. That covers:
|
||||||
|
- the primary UI in the Dashboard;
|
||||||
|
- the display, paired as a device with no human session (ADR 0036);
|
||||||
|
- each browser contribution, whenever the module that hosts it is present (ADR 0041);
|
||||||
|
7. runs a short **display soak**, and the **no-dead-ends** injections below;
|
||||||
|
8. tears everything down: containers, networks, volumes, and the work directory.
|
||||||
|
|
||||||
|
It **fails** on any of the following:
|
||||||
|
|
||||||
|
- a `console.error` or uncaught exception on any page;
|
||||||
|
- an unhandled promise rejection;
|
||||||
|
- any 4xx or 5xx response that the harness did not itself expect. Expected ones are listed in the
|
||||||
|
harness, for example a display's descriptor before it is paired;
|
||||||
|
- a page that never mounts;
|
||||||
|
- a failed or refused request (CORS, blocked, network error);
|
||||||
|
- a soak leak.
|
||||||
|
|
||||||
|
Each failure line names the surface, the request path (never its query), and the step.
|
||||||
|
|
||||||
|
### The display soak and the no-dead-ends checks are generic
|
||||||
|
|
||||||
|
These checks come from what Signage got wrong, and they apply to any module with a display.
|
||||||
|
|
||||||
|
- **Soak**, two minutes by default. The display stays mounted with no remount and no offline
|
||||||
|
indicator. Its realtime streams stay healthy. Live object URLs stay bounded. JavaScript heap
|
||||||
|
growth, measured over DevTools, stays within a fixed ratio.
|
||||||
|
- **No dead ends.** One at a time, the harness refuses the first request to each module route the
|
||||||
|
display calls at start-up, and the first realtime connection. Within a bounded time and with no
|
||||||
|
reload, the display must make that request again and succeed. A display has nobody to press
|
||||||
|
Retry.
|
||||||
|
|
||||||
|
Module-specific behaviour, such as Signage's heartbeat being recorded, is not guessed by the
|
||||||
|
harness. A module declares it (below).
|
||||||
|
|
||||||
|
### A module is tested against both ends of its compatibility range
|
||||||
|
|
||||||
|
A module's CI runs the harness twice: against the **lowest** and the **newest** published platform
|
||||||
|
version within its manifest's `compatibility.platform` range. Testing every version in between adds
|
||||||
|
cost and finds little. Testing only the newest misses the floor the manifest promises to work on.
|
||||||
|
|
||||||
|
The two versions, and their harness digests, are pinned in the module's repository. The harness
|
||||||
|
refuses to run if:
|
||||||
|
|
||||||
|
- a pinned version lies outside the module's range;
|
||||||
|
- the lowest pinned version is not the range's floor.
|
||||||
|
|
||||||
|
Moving to a newer platform is a deliberate, reviewed pin change, as an instance's is (ADR 0044). It
|
||||||
|
is never a moving tag.
|
||||||
|
|
||||||
|
A frozen install of the module assembled into each of those two platforms needs a lockfile per
|
||||||
|
pinned version. The module keeps them (`ci/lock/<platform version>.yaml`), and the harness provides
|
||||||
|
the one command that regenerates them inside its own pinned Node.js. A stale lockfile fails the
|
||||||
|
frozen install, which is the signal to regenerate it in a reviewed change.
|
||||||
|
|
||||||
|
### Modules declare extra smoke steps, beside their manifest rather than in it
|
||||||
|
|
||||||
|
A module may add steps of its own in `ekklesia.harness.json`, next to `ekklesia.module.json`. Steps
|
||||||
|
are declarative first:
|
||||||
|
|
||||||
|
- open a surface;
|
||||||
|
- wait for a test id;
|
||||||
|
- call one of the module's routes as a named role, expecting a status;
|
||||||
|
- soak for N minutes;
|
||||||
|
- inject a failure into a named route;
|
||||||
|
- check a value in the module's own table, through its own route.
|
||||||
|
|
||||||
|
For what a declaration cannot express, a step may name a driver script in the module's repository.
|
||||||
|
The harness runs that script with the same DevTools client and the same failure rules.
|
||||||
|
|
||||||
|
**This is deliberately not a manifest field.** Manifest versions are strict, hard cutovers that every
|
||||||
|
host validates (ADR 0033). A test step is not a runtime contract, and a host must never read one.
|
||||||
|
Putting steps in the manifest would force a manifest version bump on every module for something
|
||||||
|
that has no meaning outside CI.
|
||||||
|
|
||||||
|
### Later: the harness checks what a module actually touches against what it declares
|
||||||
|
|
||||||
|
In a later phase, the harness records what the module does while it runs:
|
||||||
|
|
||||||
|
- the tables and schemas it reads and writes;
|
||||||
|
- the buckets it uses;
|
||||||
|
- its outbound network connections, with the egress network blocked and every attempt logged.
|
||||||
|
|
||||||
|
It compares them with what the manifest declares (ADR 0016, ADR 0030). A module that touches what it
|
||||||
|
did not declare fails. This is the module-trust check. The egress half needs a manifest declaration
|
||||||
|
of outbound destinations that does not exist yet, and is a separate decision. This ADR fixes only
|
||||||
|
the harness's shape, so that the recording phases can be added without changing how modules call
|
||||||
|
it.
|
||||||
|
|
||||||
|
### The template's workflow stays short, and calls the harness by digest
|
||||||
|
|
||||||
|
`templates/module` ships a workflow that:
|
||||||
|
|
||||||
|
- checks out the module with `persist-credentials: false` and pinned actions;
|
||||||
|
- runs the module's own unit tests, typecheck, and lint;
|
||||||
|
- sweeps stale CI stacks from the runner, then runs the harness, once per pinned platform version,
|
||||||
|
with one `docker run` of the harness image **by digest**, and finally, always, removes this job's
|
||||||
|
own stacks. The module directory is mounted read-only, and the runner's Docker is used exactly
|
||||||
|
as the platform's smoke uses it.
|
||||||
|
|
||||||
|
It references no secret, needs no registry credential (the harness image is public within the
|
||||||
|
organization), and uses no `pull_request_target`. The trusted-code-only rule for self-hosted runners
|
||||||
|
applies to module repositories exactly as it does to the platform (see `docs/development/security.md`).
|
||||||
|
|
||||||
|
**Why `docker run`, and not a reusable workflow or a composite action.** The server runs Gitea
|
||||||
|
1.27.2. (This proposal first assumed 1.24.2; the reasons below hold on the actual version.)
|
||||||
|
|
||||||
|
- Its runner can call a reusable workflow from another repository by cloning that repository, and
|
||||||
|
since 1.26 that includes private repositories (go-gitea/gitea#32562). That support is still thinly
|
||||||
|
documented, and has known problems in practice.
|
||||||
|
- A reusable workflow would make the harness's behaviour depend on how this server and runner
|
||||||
|
version expand it. An image runs the same everywhere it runs.
|
||||||
|
- Composite actions from another repository work through absolute `uses:` URLs. That would still
|
||||||
|
wrap the same `docker run`, adding a second pinned reference and nothing else.
|
||||||
|
|
||||||
|
An image digest is the one reference that is equally immutable, equally reviewable, and works
|
||||||
|
unchanged on any Gitea release and on GitHub Actions. A composite action can be added later as a
|
||||||
|
convenience over the same image, without changing what a module pins.
|
||||||
|
|
||||||
|
### Nothing the harness starts outlives the job that started it
|
||||||
|
|
||||||
|
The harness starts Docker stacks on a shared runner, and a runner can kill a job outright: a
|
||||||
|
cancelled or superseded job (`concurrency` cancels superseded runs on this server), or the runner's
|
||||||
|
own time limit. A script's own cleanup trap does not run then. The platform's CI leaked a whole
|
||||||
|
migration stack that way, and it ran until someone removed it by hand. So the harness follows the
|
||||||
|
platform's rule (`scripts/ci-sweep.sh`, `docs/development/security.md`):
|
||||||
|
|
||||||
|
- everything it starts is named and labeled as a CI stack of its run, attempt, and job key;
|
||||||
|
- it removes its own stacks in a trap;
|
||||||
|
- the template workflow runs the harness's **stale sweep** before the harness starts, and its
|
||||||
|
**job sweep** in a final `if: always()` step, so a killed run's stacks are removed by that run's
|
||||||
|
last step, or failing that by the next job on the runner.
|
||||||
|
|
||||||
|
The sweep ships inside the harness image, so a module never copies it.
|
||||||
|
|
||||||
|
### Publishing follows the instance's release pattern
|
||||||
|
|
||||||
|
The harness image is pushed only by a `publish` job that runs on `v*` tags, after the platform's CI
|
||||||
|
has built and tested that exact image (checked by image ID). That job:
|
||||||
|
|
||||||
|
- uses a write-only package token, the only secret in the platform's workflow;
|
||||||
|
- refuses to overwrite an existing version;
|
||||||
|
- prints the digest.
|
||||||
|
|
||||||
|
Pull requests and branch builds build the harness and run it against the template, but never push.
|
||||||
|
The registry is reached exactly as the instance's publish job reaches it.
|
||||||
|
|
||||||
|
### Adoption
|
||||||
|
|
||||||
|
- **Signage** uses an interim CI job until the first harness is published. That job assembles
|
||||||
|
Signage into a pinned platform commit and runs the platform's display-device smoke scenarios.
|
||||||
|
Signage switches to the harness as soon as one is published.
|
||||||
|
- **Classroom** follows.
|
||||||
|
- **The template** carries the workflow from the first harness release on, so every new module has
|
||||||
|
it from its first commit.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A module cannot reach a release without having run inside Ekklesia, in a real browser, at both
|
||||||
|
ends of the platform range it claims.
|
||||||
|
- The defects that reached the first production Signage deployment fail a module's CI instead:
|
||||||
|
refused requests, dead ends, leaks, and console errors.
|
||||||
|
- The platform owns one harness, versioned with itself. Modules do not copy deployment scripts that
|
||||||
|
then drift.
|
||||||
|
- Every module's CI needs Docker on its runner, and takes minutes rather than seconds. The harness
|
||||||
|
runs as separate jobs from the unit tests, so a quick failure still reports quickly.
|
||||||
|
- The platform's release process gains one published artifact, one credential, and one more failure
|
||||||
|
mode at release time.
|
||||||
|
- A module now carries a lockfile for each pinned platform version. The harness makes regenerating
|
||||||
|
them one command, and a stale one fails visibly.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Each module keeps its own copy of the smoke scripts.** Rejected. That is today's situation in
|
||||||
|
embryo, and it drifts.
|
||||||
|
- **A reusable workflow in the platform repository.** Rejected for now, for the reasons above: thin
|
||||||
|
support in practice, and a harness whose behaviour depends on whichever runner and server version
|
||||||
|
happens to expand it.
|
||||||
|
- **Publish the harness as an npm package.** Rejected. The harness needs Docker, a browser, and the
|
||||||
|
platform's deployment files anyway, so a package would ship a fraction of it and assume the rest.
|
||||||
|
- **Test only the newest platform version.** Rejected. A module's range floor would go untested
|
||||||
|
until an installation that has not upgraded meets it.
|
||||||
|
- **Put smoke steps in the manifest.** Rejected. Manifest versions are hard cutovers with runtime
|
||||||
|
meaning, and test steps have neither.
|
||||||
|
- **Run the harness in the platform's CI against every known module.** Rejected. The platform would
|
||||||
|
need every module's source and would fail on modules it does not own. The direction is module
|
||||||
|
tests platform, not the reverse.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- The data-access and egress recording phases, and the manifest declaration egress needs.
|
||||||
|
- A composite action over the harness image.
|
||||||
|
- Running the harness against a module's declared dependencies at their own pinned versions, for
|
||||||
|
contributions hosted by another module.
|
||||||
|
- Visual regression of mounted surfaces.
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
# 0048: Ekklesia ships one generic image, and installed modules live outside it
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-10-07
|
||||||
|
- Supersedes (partial): [0044](0044-self-hosted-reference-deployment.md) — only its "Composition
|
||||||
|
from pinned sources" section, its topology table's statement that the `api` image contains "the
|
||||||
|
enabled modules", and its consequence that modules are built inside a platform workspace with a
|
||||||
|
per-instance lockfile. The one reference deployment, its topology, TLS everywhere, object storage,
|
||||||
|
explicit migrations, health endpoints, backups, and secrets remain accepted and unchanged.
|
||||||
|
- Supersedes (partial): [0009](0009-transactional-module-migrations.md) — only its rule that an
|
||||||
|
applied migration absent from the current inventory is rejected, which that ADR left to "a future
|
||||||
|
explicit platform policy". This ADR is that policy, for modules only. The fixed history contract,
|
||||||
|
the per-module exact-prefix rule, checksums, locking, the single transaction, approvals, and
|
||||||
|
forward-only execution remain accepted and unchanged.
|
||||||
|
- Amends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md) — installing a module
|
||||||
|
"by cloning it into `modules/`, installing workspace dependencies, building the workspace" becomes
|
||||||
|
the development workflow only. Inert manifests, atomic discovery, compiled ESM entrypoints, and
|
||||||
|
the trusted-plugin model remain accepted and unchanged.
|
||||||
|
- Extends: [0028](0028-module-operational-lifecycle.md),
|
||||||
|
[0030](0030-host-provided-module-object-storage.md)
|
||||||
|
- Related: [ADR 0007](0007-module-owned-drizzle-schemas.md),
|
||||||
|
[ADR 0008](0008-module-owned-migration-contracts.md),
|
||||||
|
[ADR 0016](0016-module-database-access.md),
|
||||||
|
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||||
|
[ADR 0027](0027-dashboard-navigation-and-flat-modules.md),
|
||||||
|
[ADR 0029](0029-module-configuration-and-secrets.md),
|
||||||
|
[ADR 0047](0047-module-ci-harness.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0044 builds an installation's image from an **instance composition**: a separate repository
|
||||||
|
per installation pins the platform and each module, commits its own lockfile, assembles them into
|
||||||
|
one workspace, and builds the platform's Dockerfile over it. River of Glory Church runs exactly
|
||||||
|
that. In practice:
|
||||||
|
|
||||||
|
- Every module change, however small, is a new image build and a new image. The image an
|
||||||
|
installation runs is never the image the platform's CI built and tested.
|
||||||
|
- The Dockerfile copies whatever `modules/` contains into the image. What a platform build
|
||||||
|
produces therefore depends on what happens to be in the source checkout.
|
||||||
|
- A module resolves its runtime dependencies only through the platform's own workspace install. No
|
||||||
|
module can run anywhere except inside an Ekklesia workspace built together with it.
|
||||||
|
- Each installation needs its own assembly repository, lockfile, and build pipeline.
|
||||||
|
|
||||||
|
Removing a module has a further problem. Its stored platform state (enablement, configuration,
|
||||||
|
Dashboard placement, grants) and its data (tables, buckets) are already retained by design
|
||||||
|
(ADR 0027, ADR 0028, ADR 0029, ADR 0030). Its migration history is not: ADR 0009 rejects any
|
||||||
|
applied migration that is missing from the current inventory. Once a module with migrations is
|
||||||
|
removed, no migration can be planned or applied again, including Core's own on a platform upgrade,
|
||||||
|
until the module's code is put back.
|
||||||
|
|
||||||
|
Ekklesia will eventually install modules from a marketplace. That needs a platform whose image does
|
||||||
|
not change when its modules do. Designing the marketplace is not part of this decision.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### One generic platform image, with no modules in it
|
||||||
|
|
||||||
|
Ekklesia produces **one** platform image for every installation. It contains Core, `apps/api`, the
|
||||||
|
Dashboard and display host, the operator commands, and the platform packages they need. It contains
|
||||||
|
**zero feature modules**: not the module template, and not a test module.
|
||||||
|
|
||||||
|
Building the image does not depend on which modules exist in the source checkout. A `modules/`
|
||||||
|
directory in the build context is never a build input: a platform build produces the same platform
|
||||||
|
whether or not one is present.
|
||||||
|
|
||||||
|
The build takes no installation-specific input: no module list, no per-installation lockfile, no
|
||||||
|
branding, no configuration. Per-installation application images and per-installation assembly
|
||||||
|
repositories are not part of the deployment model. An installation that already runs one may keep
|
||||||
|
it as a rollback path until it has moved; the platform does not support building new ones.
|
||||||
|
|
||||||
|
### Installed modules live in installation-owned storage
|
||||||
|
|
||||||
|
Installed modules live **outside the image**, in persistent storage the installation owns.
|
||||||
|
|
||||||
|
- **`EKKLESIA_MODULES_DIR`** selects the modules directory, as an absolute path. It is read once,
|
||||||
|
through the host configuration the API and Doctor already share. The API and every operator
|
||||||
|
command (migration plan and apply, module and migration validation, module enablement, Doctor)
|
||||||
|
honour the same value. There is one modules directory and one discovery path; discovery itself is
|
||||||
|
unchanged.
|
||||||
|
- **The shipping image sets it to `/var/lib/ekklesia/modules`.**
|
||||||
|
- **Unset**, as in a development checkout, it defaults to `<repository>/modules`. Workspace
|
||||||
|
development, module CI, and the platform's tests keep working as they do now.
|
||||||
|
- **A configured directory that is missing or unreadable fails closed** with a sanitized error. A
|
||||||
|
mistyped mount must never quietly start the platform with no modules.
|
||||||
|
- **Nothing in the platform locates its own files relative to the modules directory.** Core's
|
||||||
|
migrations and the platform's package metadata are found where the platform is installed, never
|
||||||
|
beside the modules.
|
||||||
|
|
||||||
|
In the reference deployment, the modules directory is persistent host storage mounted into the
|
||||||
|
`api` and `migrate` containers, and the mount is **read-only to Ekklesia**. The Ekklesia process
|
||||||
|
never writes to module storage. Whatever installs a module writes to that storage from outside the
|
||||||
|
process: today an operator, later perhaps an installer. Giving Ekklesia itself write access is a
|
||||||
|
separate, future decision. Module storage is installation state, so it belongs in the
|
||||||
|
installation's backups beside the database and the buckets.
|
||||||
|
|
||||||
|
Changes to module storage are discovered **only when a process starts**. The API sees them at a
|
||||||
|
controlled restart, and an operator command sees them when it runs. There is no file watching, hot
|
||||||
|
loading, or hot unloading. Changing modules is therefore a sequence, never an edit to a running
|
||||||
|
API: stop the API, change module storage, plan and explicitly apply migrations, start the API. A
|
||||||
|
running API may still read browser artifacts from disk, so changing files under it is unsupported.
|
||||||
|
|
||||||
|
Discovery's existing rules carry over unchanged. Core scans the immediate, non-hidden directories,
|
||||||
|
rejects a symbolic-link module directory, and rejects the whole inventory if any entry is invalid.
|
||||||
|
Two consequences matter for anything that writes module storage:
|
||||||
|
|
||||||
|
- the directory holds nothing but module directories;
|
||||||
|
- hidden entries are ignored, so a module can be staged under a hidden name and then renamed into
|
||||||
|
place in one step.
|
||||||
|
|
||||||
|
### An installed module directory is self-contained
|
||||||
|
|
||||||
|
An installed module directory is **self-contained for runtime use**: every file the platform reads
|
||||||
|
or loads from it, and everything those files import at run time, resolves inside that directory.
|
||||||
|
|
||||||
|
It contains:
|
||||||
|
|
||||||
|
- `package.json`, whose version matches the manifest's;
|
||||||
|
- `ekklesia.module.json`;
|
||||||
|
- every built artifact its manifest declares: the server entrypoint, the schema entrypoint when it
|
||||||
|
owns tables, the jobs entrypoint, and the primary, display, and contribution browser artifacts;
|
||||||
|
- its migrations directory, with the journal and Ekklesia metadata, when it has migration history;
|
||||||
|
- the **production runtime dependencies** of its server, schema, and jobs code, inside the module
|
||||||
|
directory. That includes `@ekklesia/module-sdk`, `@ekklesia/contracts`, Drizzle, and every
|
||||||
|
third-party package the module imports.
|
||||||
|
|
||||||
|
Copying `dist/` alone does not produce an installed module. Source, tests, and development
|
||||||
|
dependencies are not required.
|
||||||
|
|
||||||
|
**The platform lends a module nothing.** Node resolves a module's imports from the module's own
|
||||||
|
directory upwards, and the platform's own `node_modules` is never on that path. Ekklesia does not
|
||||||
|
add it there through links, `NODE_PATH`, loader hooks, aliases, or import maps. A module whose
|
||||||
|
imports do not resolve fails to load, and because startup is atomic, the API does not start. That
|
||||||
|
failure is sanitized like any other.
|
||||||
|
|
||||||
|
**Native dependencies** must be built for the platform image's runtime: its Node.js version and ABI,
|
||||||
|
its C library, and its CPU architecture. The platform documents that runtime for each image.
|
||||||
|
|
||||||
|
**Browser artifacts** are already self-contained bundles (ADR 0023, ADR 0040). Nothing changes for
|
||||||
|
them.
|
||||||
|
|
||||||
|
**Drizzle, for now.** A module defines its tables with its own copy of Drizzle, and Core runs that
|
||||||
|
module's queries through the platform's Drizzle instance (ADR 0016). Objects from two copies of the
|
||||||
|
library only interoperate when the copies are interchangeable. **The current implementation may
|
||||||
|
therefore require a module to carry exactly the Drizzle version the platform uses.** This is a
|
||||||
|
constraint of the current implementation, not a permanent part of the module contract. A stable
|
||||||
|
module database ABI, or a compatibility rule that permits a range, replaces it when one exists.
|
||||||
|
|
||||||
|
Core already validates a module's runtime and schema definitions structurally, against its own
|
||||||
|
contracts. It never relies on object identity with the module's copy of the SDK. The module's
|
||||||
|
`compatibility.moduleSdk` range continues to be checked against the platform's SDK version.
|
||||||
|
|
||||||
|
The archive or package format a module is delivered in, how a package is signed, and how it is
|
||||||
|
transported to an installation are deferred.
|
||||||
|
|
||||||
|
### Disable, uninstall, and purge are different operations
|
||||||
|
|
||||||
|
| Operation | Module code | Stored platform state | Module data |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **Disable** (ADR 0028) | Stays installed; not loaded | Enablement record set to disabled | Kept |
|
||||||
|
| **Uninstall** | Removed from module storage | Kept and ignored: enablement, configuration, Dashboard placement, grants | Kept: tables and rows, buckets and objects, migration history |
|
||||||
|
| **Purge** | Not installed | Deleted | Deleted |
|
||||||
|
|
||||||
|
**Purge is deferred.** Until it exists, nothing in the platform deletes module data, and removing
|
||||||
|
module code never does. No table is dropped, no bucket or object is deleted (ADR 0030), and no
|
||||||
|
migration history or stored platform record is removed just because a module is absent. There are
|
||||||
|
no down migrations (ADR 0008, ADR 0009).
|
||||||
|
|
||||||
|
**A removed module may be reinstalled** with the same module id, and it recovers its retained state:
|
||||||
|
|
||||||
|
- its enablement decision, configuration, Dashboard placement, and grants apply again;
|
||||||
|
- its tables and objects are where it left them;
|
||||||
|
- its migration history is validated strictly again (next section).
|
||||||
|
|
||||||
|
Reinstalling a newer version plans that version's new migrations on top of the retained history.
|
||||||
|
|
||||||
|
**Dependencies are unchanged.** A present module whose *required* dependency is absent still fails
|
||||||
|
discovery, and the API does not start. Dependents are removed first. An optional dependency may be
|
||||||
|
absent, exactly as before.
|
||||||
|
|
||||||
|
### Migration history for an absent module is retained and reported
|
||||||
|
|
||||||
|
A module is **absent** when no module in the current discovered inventory has its module id.
|
||||||
|
|
||||||
|
**Applied history belonging to an absent module is retained and reported, not treated as fatal.**
|
||||||
|
|
||||||
|
- Its rows are still checked for shape and for global consistency: unique identities, and unique,
|
||||||
|
increasing applied order. A corrupt row still fails.
|
||||||
|
- They are not matched against any inventory. Migration plan, apply, and Doctor report them as
|
||||||
|
retained history for an absent module: the module id and how many migrations it has.
|
||||||
|
- Their presence never blocks planning or applying Core's migrations, or those of any present
|
||||||
|
module.
|
||||||
|
|
||||||
|
**History belonging to a present module stays strict, including a module that has just returned.**
|
||||||
|
Its applied history must be an exact prefix of its current journal, and every row must match its
|
||||||
|
migration's module, tag, index, and checksum. Therefore:
|
||||||
|
|
||||||
|
- a previously applied migration missing from the package fails;
|
||||||
|
- a checksum mismatch fails;
|
||||||
|
- a downgrade to a version with fewer migrations, or any incomplete or gapped history, fails.
|
||||||
|
|
||||||
|
A different package that claims a retained module id is held to that history in exactly the same
|
||||||
|
way.
|
||||||
|
|
||||||
|
**Core's history is never treated as absent.** Rows belonging to Core's migration source are always
|
||||||
|
validated strictly; an applied Core migration missing from Core's inventory is still fatal.
|
||||||
|
|
||||||
|
Applying migrations never touches an absent module's database objects, and nothing runs a down
|
||||||
|
migration or deletes data because a module is absent. Deleting retained history belongs to purge.
|
||||||
|
|
||||||
|
This is safe because nothing applied in the platform can depend on an absent module's objects:
|
||||||
|
|
||||||
|
- a module may not hold a foreign key into another module's tables (ADR 0007);
|
||||||
|
- a module's migrations may change only its own objects (ADR 0008);
|
||||||
|
- Core never depends on a module's tables.
|
||||||
|
|
||||||
|
### The trust boundary does not move
|
||||||
|
|
||||||
|
Installed modules remain **trusted backend code** (ADR 0006, ADR 0015, ADR 0021). They run inside
|
||||||
|
the API process with its database role. Module storage, the read-only mount, and self-contained
|
||||||
|
packages are integrity and operational properties. **They are not a security sandbox.** The
|
||||||
|
read-only mount stops the Ekklesia process from modifying installed code. It does not limit what
|
||||||
|
installed code can do.
|
||||||
|
|
||||||
|
What the platform can still check against an installed directory is unchanged:
|
||||||
|
|
||||||
|
- discovery: the manifest, the package, entrypoint containment, compatibility ranges, and the
|
||||||
|
dependency graph;
|
||||||
|
- module, schema, and migration validation, run against module storage.
|
||||||
|
|
||||||
|
The repository's boundary checks (`pnpm boundaries`) can only prove **source** modules present in a
|
||||||
|
workspace. That includes the rule that every runtime import is a declared production dependency. An
|
||||||
|
installed package normally ships no source, so the platform cannot run those checks on it.
|
||||||
|
Conformance of a published or installed module must therefore be enforced:
|
||||||
|
|
||||||
|
- by the module's own CI;
|
||||||
|
- by packaging validation;
|
||||||
|
- by the module harness (ADR 0047).
|
||||||
|
|
||||||
|
Until packaging validation exists, installing a module means trusting that module's CI and the
|
||||||
|
operator who installed it, exactly as installing trusted code always has.
|
||||||
|
|
||||||
|
### Development keeps the workspace
|
||||||
|
|
||||||
|
In a development checkout, `<repository>/modules` remains the default, and a module is still
|
||||||
|
developed as a workspace package there. Module CI and the module harness may still assemble a module
|
||||||
|
into a platform workspace to build and test it. A deployment image is never built from such a
|
||||||
|
workspace.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Every installation runs the same platform image, and that image is the one the platform's CI
|
||||||
|
tested. Installing, updating, or removing a module needs no image build. It needs only a
|
||||||
|
controlled restart and, when the module has migrations, an explicit migration plan and apply.
|
||||||
|
- Per-installation assembly repositories become unnecessary. River of Glory Church's instance
|
||||||
|
repository keeps running its pinned image as a rollback path until the new model is proven there.
|
||||||
|
Once the platform image stops accepting modules from its build context, that repository cannot
|
||||||
|
move to a newer platform release, which is intended.
|
||||||
|
- Module authors gain a packaging step: a self-contained directory with production dependencies and,
|
||||||
|
where needed, native binaries built for the image's runtime. Packages grow, and modules that share
|
||||||
|
a library each carry their own copy. That cost is accepted.
|
||||||
|
- Removing a module no longer blocks migrations. Its data stays, and reinstalling restores it.
|
||||||
|
- Operators own module storage: they create it, keep it in backups, and keep anything but module
|
||||||
|
directories out of it.
|
||||||
|
- ADR 0047, still proposed, assembles a module into the platform and builds a deployment image from
|
||||||
|
it. Before it is accepted, it must instead run the module from module storage in the generic
|
||||||
|
image.
|
||||||
|
- This ADR records the decision; it changes no code. Selecting `EKKLESIA_MODULES_DIR`, the image
|
||||||
|
build, the retained-history policy, the read-only mount, and the CI proof that the image is
|
||||||
|
independent of installed modules follow in separate changes. Until they land, the repository
|
||||||
|
behaves as `deploy/README.md` and the development guides describe.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Keep per-installation images (ADR 0044).** Rejected: every module change is an image build, no
|
||||||
|
two installations run the same image, and a platform build depends on its checkout.
|
||||||
|
- **Lend modules the platform's dependencies** through links, `NODE_PATH`, loader hooks, or import
|
||||||
|
maps. Rejected:
|
||||||
|
- it couples every installed module to the platform's private dependency graph, so upgrading a
|
||||||
|
platform dependency could silently break an installed module;
|
||||||
|
- ESM ignores `NODE_PATH`;
|
||||||
|
- a resolution hook would be a second module-loading mechanism beside Node's own.
|
||||||
|
- **Require one bundled file per entrypoint as the package format.** Not adopted as a requirement:
|
||||||
|
native dependencies cannot be bundled, and the package format is deferred. Bundling is one
|
||||||
|
permitted way to produce a self-contained directory, not the definition of one.
|
||||||
|
- **Let the API write module storage, so it can install modules itself.** Deferred, not adopted:
|
||||||
|
code that can rewrite the code it executes is a larger trust decision, and it belongs with the
|
||||||
|
marketplace.
|
||||||
|
- **Hot loading and unloading.** Rejected: ES modules cannot be unloaded, and atomic discovery,
|
||||||
|
dependency-ordered startup, and immutable permission composition all assume a fresh process.
|
||||||
|
- **Delete data, or run down migrations, on uninstall.** Rejected: migrations are forward-only, and
|
||||||
|
a mistaken removal must never destroy data. Deleting data is purge, and purge is explicit.
|
||||||
|
- **Keep rejecting history for absent modules (ADR 0009 as written).** Rejected: removing any module
|
||||||
|
with migrations would block every later migration, including Core's.
|
||||||
|
- **Accept history for absent modules only behind an operator flag.** Rejected: applying migrations
|
||||||
|
with a module absent cannot harm that module's data, and the plan already reports what is
|
||||||
|
retained. A flag would turn every routine uninstall into a special case.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- The module archive or package transport format.
|
||||||
|
- Package signatures and trust roots.
|
||||||
|
- A marketplace or catalog.
|
||||||
|
- An installer UI.
|
||||||
|
- Installation by the running platform, and any write access for Ekklesia to module storage.
|
||||||
|
- Hot loading, hot unloading, and file watching.
|
||||||
|
- The purge workflow: deleting a module's tables, buckets and objects, migration history, and stored
|
||||||
|
platform records.
|
||||||
|
- A stable module database ABI, or a compatibility rule wider than the current exact Drizzle
|
||||||
|
requirement.
|
||||||
|
- A platform-provided packaging and package-validation command.
|
||||||
|
- Publishing the platform image and automated deployment from CI, still deferred from ADR 0044.
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
# 0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1
|
||||||
|
|
||||||
|
- Status: Proposed
|
||||||
|
- Date: 2026-10-07
|
||||||
|
- Supersedes: none
|
||||||
|
- Extends: [0048](0048-generic-image-and-installed-module-storage.md)
|
||||||
|
- Related: [ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
|
||||||
|
[ADR 0007](0007-module-owned-drizzle-schemas.md),
|
||||||
|
[ADR 0008](0008-module-owned-migration-contracts.md),
|
||||||
|
[ADR 0009](0009-transactional-module-migrations.md),
|
||||||
|
[ADR 0016](0016-module-database-access.md),
|
||||||
|
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||||
|
[ADR 0023](0023-module-ui-build-pipeline.md),
|
||||||
|
[ADR 0028](0028-module-operational-lifecycle.md),
|
||||||
|
[ADR 0040](0040-module-stylesheets-and-artifact-isolation.md),
|
||||||
|
[ADR 0041](0041-module-browser-contributions.md),
|
||||||
|
[ADR 0044](0044-self-hosted-reference-deployment.md),
|
||||||
|
[ADR 0047](0047-module-ci-harness.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0048 took modules out of the platform image. An installation keeps them in its own module
|
||||||
|
storage, mounted read-only, and discovers them at a controlled restart. Each installed module must
|
||||||
|
be a self-contained directory: the platform lends it no dependencies. ADR 0048 deferred the format
|
||||||
|
a module is delivered in.
|
||||||
|
|
||||||
|
That gap is now the obstacle. Nothing yet produces an installed module directory except a test-only
|
||||||
|
Dockerfile, so a module release cannot be built, distributed, and installed on its own. An
|
||||||
|
investigation against the module template and Signage 0.1.2 established:
|
||||||
|
|
||||||
|
- **`dist/` alone does not run.** Compiled server and schema code imports `@ekklesia/module-sdk`
|
||||||
|
(and through it `@ekklesia/contracts`, zod, and semver) and `drizzle-orm` by name, and Node never
|
||||||
|
resolves those from the platform's `node_modules`.
|
||||||
|
- **Bundling works.** Bundling the template's server and schema with esbuild gave about 720 KB of
|
||||||
|
backend code and no `node_modules` at all. Inside the generic image, run as its unprivileged
|
||||||
|
user from a read-only mount at `/var/lib/ekklesia/modules`, it passed module, schema, and
|
||||||
|
migration validation, and its server entrypoint imported. Core's own Drizzle instance built a
|
||||||
|
query over the bundled module's table.
|
||||||
|
- **Native dependencies cannot be bundled.** Signage needs `sharp`, a Node-API addon whose
|
||||||
|
platform packages carry an 18.6 MB `libvips` shared library for one operating system, CPU, and
|
||||||
|
C library (Linux, x64, glibc ≥ 2.28). With its backend bundled and only `sharp` and its own
|
||||||
|
dependencies kept beside it, Signage was 22 MB and contained no link. It validated and processed
|
||||||
|
an image inside the same image.
|
||||||
|
- **`pnpm deploy` is the wrong tool.** Non-legacy mode refuses unless the whole workspace switches
|
||||||
|
to injected workspace packages. Legacy mode leaves a link escaping the package. Every mode
|
||||||
|
ships packages nothing loads at run time (React and its DOM, type packages, database drivers
|
||||||
|
pulled in as peers) and leaves `workspace:` ranges in copied manifests. Running it inside a
|
||||||
|
workspace rewrites that workspace's install state to production-only, which then breaks the
|
||||||
|
workspace's own builds. It did so in the investigation's scratch workspaces, and earlier in a
|
||||||
|
developer's own checkout.
|
||||||
|
- **A deterministic archive is cheap.** A tar archive with sorted paths, fixed owner and
|
||||||
|
timestamps, and a gzip header without a name or time produced the same SHA-256 twice. Gzip packed
|
||||||
|
Signage to 8.5 MB.
|
||||||
|
|
||||||
|
Module releases also need a contract that does not depend on where they are hosted. Ekklesia's
|
||||||
|
source and CI live on a Gitea server today; a marketplace, another host, object storage, or offline
|
||||||
|
media may carry the same release tomorrow.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
A separately versioned Ekklesia module is distributed as an **Ekklesia Module Package**: one
|
||||||
|
immutable, self-contained archive. Installing, updating, or removing a module never rebuilds the
|
||||||
|
platform image (ADR 0048).
|
||||||
|
|
||||||
|
The package contract is independent of marketplace discovery, of the source-code host, of any
|
||||||
|
artifact registry, of the church installing it, and of the URL it was fetched from. Catalogues,
|
||||||
|
marketplaces, and signing are separate concerns, and none of them is decided here.
|
||||||
|
|
||||||
|
### The artifact is a deterministic `.tar.gz`
|
||||||
|
|
||||||
|
Module Package v1 is a gzip-compressed tar archive. Its identity is its contents, never its
|
||||||
|
transport: the same archive may travel through Gitea, GitHub, an OCI registry, object storage,
|
||||||
|
offline media, or a future Ekklesia marketplace without any change to what it installs. No registry,
|
||||||
|
host, or OCI concept is part of the package format.
|
||||||
|
|
||||||
|
The canonical packager produces the archive **deterministically**: the same packager version and
|
||||||
|
the same inputs produce byte-identical output. At minimum:
|
||||||
|
|
||||||
|
- **Entries.** Every directory and regular file appears in byte-wise sorted path order, directories
|
||||||
|
before their contents.
|
||||||
|
- **Ownership.** uid and gid are 0 and the user and group names are empty.
|
||||||
|
- **Timestamps.** Every entry's modification time is 0, and the archive holds no access, change, or
|
||||||
|
creation time.
|
||||||
|
- **Modes.** Directories are `0755` and files `0644` (see [Archive safety](#archive-safety)).
|
||||||
|
- **No host metadata.** No extended attributes, ACLs, or archive-level headers that carry host or
|
||||||
|
time information.
|
||||||
|
- **gzip.** The header has no file name and a modification time of 0, at a fixed compression level.
|
||||||
|
|
||||||
|
The SHA-256 of the complete archive is distributed beside it, as a separate `.sha256` file in the
|
||||||
|
`sha256sum` format. The **recommended** file name is `<module id>-<module version>-<target>.tar.gz`,
|
||||||
|
for example `signage-0.1.2-linux-x64-glibc.tar.gz`. A file name is a convenience, never trusted:
|
||||||
|
what the archive is comes from its contents and its digest.
|
||||||
|
|
||||||
|
### What a package contains
|
||||||
|
|
||||||
|
A v1 archive contains exactly one top-level directory, named with the module id. Its contents are
|
||||||
|
the installed module directory ADR 0048 requires:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<module id>/
|
||||||
|
package.json generated runtime metadata
|
||||||
|
ekklesia.module.json the module's manifest, unchanged
|
||||||
|
ekklesia.package.json package metadata (below)
|
||||||
|
THIRD_PARTY_NOTICES licence and notice material for everything bundled or included
|
||||||
|
dist/ bundled backend entrypoints, their shared chunks, and the declared
|
||||||
|
UI, display, and contribution artifacts
|
||||||
|
drizzle/ migration SQL, journal, snapshots, and _ekklesia.json, when declared
|
||||||
|
node_modules/ only when the package needs a native runtime dependency (below)
|
||||||
|
```
|
||||||
|
|
||||||
|
Every entrypoint the manifest declares exists at its declared path.
|
||||||
|
|
||||||
|
It contains nothing else, and in particular:
|
||||||
|
|
||||||
|
- source files, unless something at run time reads them;
|
||||||
|
- tests;
|
||||||
|
- TypeScript declarations;
|
||||||
|
- source maps, until a later decision permits them;
|
||||||
|
- development dependencies;
|
||||||
|
- any `workspace:` reference;
|
||||||
|
- any link (see [Archive safety](#archive-safety)).
|
||||||
|
|
||||||
|
**The runtime `package.json` is generated by the packager, never copied** from the module's
|
||||||
|
development workspace. It holds only what module discovery reads:
|
||||||
|
- `name` and `version` (equal to the manifest's version);
|
||||||
|
- `"private": true`;
|
||||||
|
- `"type": "module"`;
|
||||||
|
- `"ekklesia": { "layer": "module" }`.
|
||||||
|
|
||||||
|
It declares no dependencies, scripts, exports, or imports, because nothing at run time resolves
|
||||||
|
through them.
|
||||||
|
|
||||||
|
The manifest is copied unchanged. It stays the module's authoritative runtime contract, and its
|
||||||
|
`compatibility.platform` and `compatibility.moduleSdk` ranges stay the only place those are stated.
|
||||||
|
|
||||||
|
### Backend code is bundled
|
||||||
|
|
||||||
|
The canonical packager bundles the module's backend entrypoints (server, schema, and jobs) with
|
||||||
|
every pure-JavaScript dependency they use. That includes `@ekklesia/module-sdk`,
|
||||||
|
`@ekklesia/contracts`, and ordinary libraries. The bundles are ES modules at the manifest's declared
|
||||||
|
paths, and they may share chunks under `dist/`. Browser artifacts are already self-contained bundles
|
||||||
|
(ADR 0023, ADR 0040, ADR 0041) and are packaged as built.
|
||||||
|
|
||||||
|
The platform lends an installed module nothing (ADR 0048). **`node_modules` in a package is the
|
||||||
|
exception, not the default.** A dependency stays outside the bundle only when it must exist as
|
||||||
|
separate files at run time: a native Node addon, or a package that is equally platform-specific.
|
||||||
|
Then the package carries it, and exactly its own runtime dependencies, flat under
|
||||||
|
`node_modules/<name>`.
|
||||||
|
|
||||||
|
**The canonical packager decides what stays external, and validates it.** It refuses to package a
|
||||||
|
module whose bundled code still needs a file it did not include, or whose externals are not native
|
||||||
|
or platform-specific. Choosing which dependencies to leave unbundled is not part of the module
|
||||||
|
author's interface.
|
||||||
|
|
||||||
|
### Targets
|
||||||
|
|
||||||
|
Every package has one **target**:
|
||||||
|
|
||||||
|
- **`any`**: the package contains no native or platform-specific file. It runs wherever its declared
|
||||||
|
Node.js range is met.
|
||||||
|
- **`<platform>-<arch>[-<libc>]`**: the package contains native code for exactly one environment.
|
||||||
|
- `<platform>` and `<arch>` use Node.js's own `process.platform` and `process.arch` names.
|
||||||
|
- On Linux, `<libc>` is required and is `glibc` or `musl`. On other platforms it is omitted.
|
||||||
|
|
||||||
|
Examples: `linux-x64-glibc`, `linux-arm64-glibc`, `linux-x64-musl`. Signage, because of `sharp`, is
|
||||||
|
`linux-x64-glibc` for the current production image.
|
||||||
|
|
||||||
|
A package has exactly one target. A module that ships for several environments publishes one
|
||||||
|
package per target. Packages carrying several architectures at once are not part of v1.
|
||||||
|
|
||||||
|
### `ekklesia.package.json`
|
||||||
|
|
||||||
|
For example (the values are illustrative):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"packageFormat": 1,
|
||||||
|
"module": { "id": "signage", "version": "0.1.2" },
|
||||||
|
"target": "linux-x64-glibc",
|
||||||
|
"runtime": {
|
||||||
|
"node": ">=24.11.0 <25",
|
||||||
|
"nodeApi": 9,
|
||||||
|
"libcVersion": ">=2.28"
|
||||||
|
},
|
||||||
|
"provenance": {
|
||||||
|
"revision": "86ae907f59458508e3277666cdd214f5bd7c1078",
|
||||||
|
"repository": "https://example.org/your-org/signage.git",
|
||||||
|
"packager": { "name": "@ekklesia/module-pack", "version": "0.2.0" }
|
||||||
|
},
|
||||||
|
"incorporates": {
|
||||||
|
"@ekklesia/module-sdk": "0.1.2",
|
||||||
|
"@ekklesia/contracts": "0.1.2"
|
||||||
|
},
|
||||||
|
"transitional": {
|
||||||
|
"databaseRuntime": { "drizzle-orm": "0.45.2" }
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
{ "path": "THIRD_PARTY_NOTICES", "size": 48211, "sha256": "…" },
|
||||||
|
{ "path": "dist/server/index.js", "size": 1346, "sha256": "…" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The fields are:
|
||||||
|
|
||||||
|
- **`packageFormat`**: the integer `1`. An installer refuses a format it does not implement. Format
|
||||||
|
versions are strict cutovers, like manifest versions.
|
||||||
|
- **`module.id`, `module.version`**: must equal the manifest's and the runtime `package.json`'s.
|
||||||
|
The top-level directory must be named with the id.
|
||||||
|
- **`target`**: as defined above.
|
||||||
|
- **`runtime`**: what the package needs from the Node.js runtime that will load it.
|
||||||
|
- `node`, a semantic-version range, is always present.
|
||||||
|
- A native package also states:
|
||||||
|
- `nodeApi`: the minimum Node-API version, for Node-API addons;
|
||||||
|
- or `nodeAbi`: the exact `process.versions.modules`, for an addon built against one Node
|
||||||
|
ABI;
|
||||||
|
- and on Linux, `libcVersion`: the minimum C library version.
|
||||||
|
- **`provenance`**: how the package was built.
|
||||||
|
- `revision`: the exact source revision, always present.
|
||||||
|
- `repository`: informational only. Nothing trusts it, and it ties the package to no host.
|
||||||
|
- `packager`: the packager's name and version.
|
||||||
|
- **`incorporates`**: the exact versions of the Ekklesia SDK and runtime libraries bundled into the
|
||||||
|
package. The packager refuses a package whose incorporated `@ekklesia/module-sdk` does not satisfy
|
||||||
|
the manifest's own `compatibility.moduleSdk` range, so the two can never disagree.
|
||||||
|
- **`transitional`**: compatibility facts that today's implementation needs and that are not part
|
||||||
|
of the long-term contract. See [Database runtime compatibility](#database-runtime-compatibility).
|
||||||
|
An installer must understand every key it finds there or refuse the package.
|
||||||
|
- **`files`**: one entry for every regular file in the module directory **except
|
||||||
|
`ekklesia.package.json` itself**, sorted by path. Each entry has its path relative to the module
|
||||||
|
directory, its size in bytes, and its SHA-256. The set is exact: a payload file missing from the
|
||||||
|
list, or a listed file missing from the payload, invalidates the package.
|
||||||
|
|
||||||
|
The archive's `.sha256` covers the whole archive, `ekklesia.package.json` included. The per-file
|
||||||
|
digests let an installer, or an operator later, check an extracted directory without the archive.
|
||||||
|
|
||||||
|
Platform and Module SDK compatibility stay in `ekklesia.module.json` and are not repeated here.
|
||||||
|
|
||||||
|
### Database runtime compatibility
|
||||||
|
|
||||||
|
Core runs a module's queries through the platform's Drizzle instance over tables the module defined
|
||||||
|
with its own copy of Drizzle (ADR 0016, ADR 0048). Those objects only interoperate when the two
|
||||||
|
copies are interchangeable. So while it is needed, a package bundles Drizzle with its backend code,
|
||||||
|
records the version in `transitional.databaseRuntime`, and an installer refuses a package whose
|
||||||
|
Drizzle version differs from the platform's.
|
||||||
|
|
||||||
|
**This is an implementation constraint of today's platform, not the Ekklesia Module Database ABI.**
|
||||||
|
Object identity and version agreement between two Drizzle copies are not a contract this ADR
|
||||||
|
establishes or that module authors may rely on. A separate Module Database ABI decision will replace
|
||||||
|
it, and that ABI will make the platform's ORM an implementation detail. When it lands, the
|
||||||
|
`transitional.databaseRuntime` requirement goes away under the format-versioning rules here.
|
||||||
|
|
||||||
|
### Licence obligations
|
||||||
|
|
||||||
|
Bundling copies third-party code into the module's own files, where the licence headers that came
|
||||||
|
with it may not survive. **Every package therefore carries `THIRD_PARTY_NOTICES`**: a generated
|
||||||
|
plain-text file at the module directory's root. It names each third-party package bundled into
|
||||||
|
or included in the package, with its version and licence, and holds the licence and notice text
|
||||||
|
that redistribution requires.
|
||||||
|
|
||||||
|
The packager's implementation must establish exactly how licence and notice material is collected
|
||||||
|
and preserved, including licence comments that bundling would otherwise strip. Until it does, no
|
||||||
|
package is treated as a distributable release.
|
||||||
|
|
||||||
|
### Archive safety
|
||||||
|
|
||||||
|
**v1 packages contain no links of any kind.** Neither symbolic nor hard links are permitted, so no
|
||||||
|
link can escape the package, and there is no case where one is allowed. Native dependencies were
|
||||||
|
packaged without any.
|
||||||
|
|
||||||
|
A conforming archive also contains no:
|
||||||
|
|
||||||
|
- absolute path;
|
||||||
|
- `..` path segment;
|
||||||
|
- path outside the single top-level module directory;
|
||||||
|
- device node, FIFO, or socket;
|
||||||
|
- setuid, setgid, or sticky bit.
|
||||||
|
|
||||||
|
It holds only directories and regular files.
|
||||||
|
|
||||||
|
**Extraction normalizes ownership and modes.** Everything is owned by the installation, not by the
|
||||||
|
uid or gid in the archive. Directories are `0755` and files `0644`, readable by the API's
|
||||||
|
unprivileged user and writable by nobody but the installer. No file in a v1 package is executable.
|
||||||
|
Loading JavaScript and native addons needs no execute bit.
|
||||||
|
|
||||||
|
An installer refuses an archive that breaks any of these rules, and never extracts it partially into
|
||||||
|
module storage.
|
||||||
|
|
||||||
|
### Installation model
|
||||||
|
|
||||||
|
This ADR records the intended installation lifecycle; it does not implement it.
|
||||||
|
|
||||||
|
```text
|
||||||
|
acquire the archive
|
||||||
|
→ verify the archive's SHA-256
|
||||||
|
→ extract into a hidden staging directory inside module storage
|
||||||
|
→ validate ekklesia.package.json, then every file's size and SHA-256
|
||||||
|
→ validate the target and runtime against this platform and host
|
||||||
|
→ validate the manifest and the complete candidate inventory: the installed modules, with this
|
||||||
|
candidate in place of any version of the same module
|
||||||
|
→ load the backend entrypoints once in an isolated, short-lived validation process
|
||||||
|
→ produce the migration plan
|
||||||
|
→ stop the API
|
||||||
|
→ swap the candidate into the active module directory, keeping the previous version
|
||||||
|
→ apply migrations, only with explicit operator approval
|
||||||
|
→ start the API
|
||||||
|
→ verify readiness
|
||||||
|
```
|
||||||
|
|
||||||
|
Details:
|
||||||
|
|
||||||
|
- **Hidden directories.** Staging and previous versions live in dot-prefixed directories inside
|
||||||
|
module storage. Module discovery ignores hidden entries (ADR 0048), so neither is ever loaded.
|
||||||
|
- **The swap.** It happens only while the API is stopped. It is as close to atomic as the
|
||||||
|
filesystem allows, and an interrupted swap is recoverable from the kept previous version.
|
||||||
|
- **Who writes.** Module storage stays read-only to Ekklesia. Whatever performs the installation
|
||||||
|
writes it from outside the API process (ADR 0048).
|
||||||
|
- **No hot loading.** Discovery still happens only when a process starts.
|
||||||
|
- **Migrations stay explicit.** Nothing in this lifecycle applies a migration on its own (ADR 0009,
|
||||||
|
ADR 0044).
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
- **Before any migration from the new version is applied,** restoring the previous module directory
|
||||||
|
and restarting is a supported rollback.
|
||||||
|
- **After forward migrations are applied,** restoring older code is **not** a supported rollback.
|
||||||
|
The older package lacks the newer migrations, so migration planning refuses it as a downgrade
|
||||||
|
(ADR 0048). Even running it would mean old code against a newer schema. A true rollback then
|
||||||
|
means restoring the matching database and storage backup, unless a future, module-specific
|
||||||
|
compatibility mechanism says otherwise.
|
||||||
|
|
||||||
|
There are no down migrations (ADR 0008, ADR 0009). An installer must make the point of no return
|
||||||
|
explicit: back up before applying, and offer code-only rollback only while no new migration has been
|
||||||
|
applied.
|
||||||
|
|
||||||
|
### Trust
|
||||||
|
|
||||||
|
The SHA-256 digest proves integrity: the archive is the one whose digest you were given. It says
|
||||||
|
nothing about who published it. Package signatures, publisher trust roots, marketplace verification,
|
||||||
|
and trust levels for third-party modules are deferred.
|
||||||
|
|
||||||
|
Installed modules remain trusted backend code under ADR 0048. A conforming package is not sandboxed,
|
||||||
|
and passing validation does not make a module safe; it makes it well-formed.
|
||||||
|
|
||||||
|
### Canonical tooling
|
||||||
|
|
||||||
|
Packages are produced by one Ekklesia-owned packager, proposed as the workspace package
|
||||||
|
`packages/module-pack` with the command `ekklesia-pack-module`, run in a module's CI. Its command
|
||||||
|
line is an implementation detail; this ADR defines what it must produce.
|
||||||
|
|
||||||
|
**`pnpm deploy` is not the packaging mechanism**, for the reasons in Context:
|
||||||
|
- it includes runtime dependencies nothing loads;
|
||||||
|
- it leaves `workspace:` metadata;
|
||||||
|
- it leaves links that escape the package, or needs a workspace-wide linking change to avoid them;
|
||||||
|
- it rewrites the install state of the workspace it runs in.
|
||||||
|
|
||||||
|
The repository's test-only packaging of the module template
|
||||||
|
(`scripts/lib/test-module-package.Dockerfile`) is replaced by the canonical packager when one
|
||||||
|
exists.
|
||||||
|
|
||||||
|
### Relationship to the module CI harness
|
||||||
|
|
||||||
|
ADR 0047, still proposed, is already marked for revision against ADR 0048. Its harness should
|
||||||
|
consume canonical module packages: a module's CI builds its package, and the harness installs that
|
||||||
|
exact archive into module storage of the generic image. The harness then tests the artifact a church
|
||||||
|
would install, not a workspace build of it. Until the packager exists, the harness and smoke tests
|
||||||
|
may keep their current test-only packaging.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A module release becomes one file plus its digest, built once by the module's CI and installable
|
||||||
|
on any compatible Ekklesia installation without touching the platform image.
|
||||||
|
- Module authors gain a packaging step and a target per native environment. A pure-JavaScript
|
||||||
|
module ships one `any` package; Signage ships `linux-x64-glibc` packages for the current fleet.
|
||||||
|
- Packages are small and auditable: bundled code plus, only where needed, a native dependency
|
||||||
|
tree. There are no links and no development dependencies, and every file is listed with its
|
||||||
|
digest.
|
||||||
|
- Each package carries its own copy of the SDK and contracts. Version agreement with the platform is
|
||||||
|
checked when the package is built, against the manifest's `moduleSdk` range, and Core keeps
|
||||||
|
validating module definitions structurally (ADR 0048).
|
||||||
|
- Rolling back after a schema-changing update is a backup restore. Operators must take a backup
|
||||||
|
before approving migrations.
|
||||||
|
- Licence handling becomes the packager's responsibility, and until it is proven no package
|
||||||
|
counts as a release.
|
||||||
|
- The Drizzle requirement is visible in package metadata as `transitional`, so its eventual removal
|
||||||
|
is a recorded format change and not a silent behaviour change.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **`pnpm deploy` as the packager.** Rejected for the reasons in Context.
|
||||||
|
- **Copying the workspace's dependency tree without bundling.** Rejected as the default. It ships
|
||||||
|
far more than runs (49 MB against 22 MB for Signage), keeps `workspace:` ranges, and makes every
|
||||||
|
pure-JavaScript dependency a file-layout concern. Bundling is already how browser artifacts are
|
||||||
|
built.
|
||||||
|
- **Platform-lent runtime dependencies.** Rejected by ADR 0048.
|
||||||
|
- **Installing the SDK from a registry when the module is installed.** Rejected: installation would
|
||||||
|
need network access and a resolver on every installation, and an installation's code would depend
|
||||||
|
on what a registry served that day. Publishing the SDK packages for module *builds* is a separate,
|
||||||
|
compatible future step.
|
||||||
|
- **Module-author-chosen externals.** Rejected: an arbitrary external is an undeclared runtime
|
||||||
|
dependency the platform cannot verify, and it reintroduces the `node_modules` layout as a
|
||||||
|
contract.
|
||||||
|
- **An OCI artifact as the package format.** Not adopted for v1: it needs registry tooling and login
|
||||||
|
to install, and adds nothing to integrity that the digest does not already give. Nothing here
|
||||||
|
prevents carrying the same `.tar.gz` inside an OCI artifact.
|
||||||
|
- **`.tar.zst`.** Not adopted for v1: about 17 % smaller for Signage at a high compression level,
|
||||||
|
but about three times slower to produce there, and less universally available than gzip. A later
|
||||||
|
format version may add it.
|
||||||
|
- **Packages carrying several architectures at once.** Not adopted for v1: each extra architecture
|
||||||
|
adds about 18.5 MB for Signage, for a fleet that runs one architecture. A later format version may
|
||||||
|
allow it.
|
||||||
|
- **Permitting internal symbolic links.** Rejected: links are where escape checks are subtle, and
|
||||||
|
the native dependencies that motivated the question were packaged without any.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- Package signatures and publisher trust roots.
|
||||||
|
- A marketplace or catalogue, and registry or hosting-provider integration.
|
||||||
|
- An installer, and any installer UI; an automatic update service.
|
||||||
|
- The purge workflow (ADR 0048).
|
||||||
|
- Packages carrying several architectures at once.
|
||||||
|
- The stable Module Database ABI that retires `transitional.databaseRuntime`.
|
||||||
|
- Source maps and other debugging material in or beside packages.
|
||||||
|
- Publishing the Ekklesia SDK packages for module builds outside a platform workspace.
|
||||||
|
- Moving Signage to the new package format.
|
||||||
@@ -55,3 +55,6 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources](0044-self-hosted-reference-deployment.md) — Accepted
|
- [0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources](0044-self-hosted-reference-deployment.md) — Accepted
|
||||||
- [0045: The Dashboard and the API may live on separate origins](0045-split-dashboard-and-api-origins.md) — Accepted
|
- [0045: The Dashboard and the API may live on separate origins](0045-split-dashboard-and-api-origins.md) — Accepted
|
||||||
- [0046: Ekklesia's branding is never forced on an installation](0046-installation-owned-branding.md) — Proposed
|
- [0046: Ekklesia's branding is never forced on an installation](0046-installation-owned-branding.md) — Proposed
|
||||||
|
- [0047: Every module proves itself inside Ekklesia, with one harness the platform publishes](0047-module-ci-harness.md) — Proposed
|
||||||
|
- [0048: Ekklesia ships one generic image, and installed modules live outside it](0048-generic-image-and-installed-module-storage.md) — Accepted
|
||||||
|
- [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Proposed
|
||||||
|
|||||||
@@ -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
|
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
|
the absolute ceiling; module-owned offline *content*; and every real feature module, Signage
|
||||||
included. Nothing in this pass claims any of them exists.
|
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_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_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_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_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_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)). |
|
| `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
|
exact same installed-module inventory every time it starts, in a fixed order, before anything
|
||||||
else:
|
else:
|
||||||
|
|
||||||
1. Resolve module-discovery configuration (the repository's `modules/` directory, and the
|
1. Resolve module-discovery configuration: the installed-modules directory, chosen by Core's one
|
||||||
platform/Module SDK versions from their own `package.json` files).
|
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`).
|
2. Discover installed modules exactly once (`discoverModules`).
|
||||||
3. Compose the platform permission registry from that exact discovered list
|
3. Compose the platform permission registry from that exact discovered list
|
||||||
(`composePlatformPermissionRegistry`).
|
(`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
|
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()`
|
`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
|
— 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
|
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
|
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;
|
package-level Turborepo task. New packages add their own `lint`, `typecheck`, and `test` scripts;
|
||||||
the root verification command discovers them automatically.
|
the root verification command discovers them automatically.
|
||||||
|
|
||||||
`pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A
|
`pnpm modules:validate` builds the workspace, then scans the installed-modules directory without a
|
||||||
missing or empty modules directory is valid and reports zero installed modules. Installed modules
|
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
|
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
|
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
|
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
|
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
|
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
|
skipped. Pending or approval-blocked migrations are warnings and are never applied. Applied
|
||||||
history, checksum disagreement, unknown applied migrations, or a failed configured connection are
|
history that belongs to a module which is not installed is reported as `retained` and is neither a
|
||||||
failures. If `DATABASE_URL` is missing, PostgreSQL is skipped and all static checks still run.
|
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
|
`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
|
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
|
publishes, or runs it against a real installation. No `.env` has ever been committed; only
|
||||||
`.env.example`, which the image build context excludes.
|
`.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:
|
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
|
- `apps/api` defaults its listen address to loopback (`127.0.0.1`), so it is not reachable off-host
|
||||||
@@ -693,8 +702,10 @@ public Dashboard login must be assessed with the actual edge/proxy controls befo
|
|||||||
|
|
||||||
## Continuous integration boundary
|
## Continuous integration boundary
|
||||||
|
|
||||||
CI runs on self-hosted Gitea Actions runners. The inventoried server is **Gitea 1.24.2**, and that
|
CI runs on self-hosted Gitea Actions runners. The server reports **Gitea 1.27.2** (`GET
|
||||||
version determines which parts of the workflow are real guarantees and which are not.
|
/api/v1/version`, checked 2026-10-06; this document previously said 1.24.2, which was stale). The
|
||||||
|
server's actual version, not the documentation of an earlier one, determines which parts of the
|
||||||
|
workflow are real guarantees and which are not.
|
||||||
|
|
||||||
### The current operating rule: trusted code only
|
### The current operating rule: trusted code only
|
||||||
|
|
||||||
@@ -727,24 +738,53 @@ so a later step in the same job cannot read or reuse it from there.
|
|||||||
It does **not** remove the short-lived `GITEA_TOKEN` from the job environment, and it does **not**
|
It does **not** remove the short-lived `GITEA_TOKEN` from the job environment, and it does **not**
|
||||||
reduce that token's server-side authority. A step that wants the token can still read it.
|
reduce that token's server-side authority. A step that wants the token can still read it.
|
||||||
|
|
||||||
### What Gitea 1.24 ignores — so the branch cannot claim it
|
### What this server honours, and what is not yet verified
|
||||||
|
|
||||||
Per the official comparison for this version
|
Each item separates what Gitea documents for 1.27
|
||||||
(<https://docs.gitea.com/1.24/usage/actions/comparison>), Gitea 1.24's unsupported workflow syntax
|
(<https://docs.gitea.com/1.27/usage/actions/comparison>) from what has been observed on this server:
|
||||||
includes `permissions` and `jobs.<job_id>.permissions`, `concurrency`, and
|
|
||||||
`jobs.<job_id>.timeout-minutes`. On this server those declarations are inert. Therefore:
|
|
||||||
|
|
||||||
- **The workflow's `permissions: contents: read` does not restrict the job token.** This branch
|
- **`concurrency` is honoured.** Gitea supports it since 1.26 (go-gitea/gitea#32751), and on this
|
||||||
cannot claim the `GITEA_TOKEN` is read-only, because nothing in workflow YAML makes it so on
|
server a newer push to a pull request cancels the run it supersedes. Observed: Ekklesia run 133 was
|
||||||
1.24. Job-token permission controls, repository/owner defaults, and server-side
|
cancelled when run 134 started for the same pull request, and run 135 when run 137 did. A
|
||||||
maximum-permission clamping arrive in the 1.26 feature line
|
superseded run's own cleanup is therefore never guaranteed to run, which is one reason for the
|
||||||
(<https://docs.gitea.com/1.26/usage/actions/token-permissions>).
|
stack sweep below.
|
||||||
- **`concurrency` and `timeout-minutes` are not bounded-execution guarantees here.** Cancellation,
|
- **`permissions` is supported by 1.27, with Gitea's own scopes, but is not verified here.** The
|
||||||
timeout, and teardown must be enforced by a trusted provisioner and reaper outside the workflow.
|
server-side token-permission settings are not visible through the API, and nothing has yet proven
|
||||||
|
what the job token can and cannot do on this server. Until that proof exists (requirement 3
|
||||||
|
below), this repository still does not claim the `GITEA_TOKEN` is read-only.
|
||||||
|
- **`timeout-minutes` is no longer listed as unsupported for 1.27, but is not verified here.**
|
||||||
|
Teardown does not rely on it: the stack sweep and a trusted reaper do.
|
||||||
|
- **Still ignored:** `jobs.<job_id>.environment`, and any `runs-on` other than a single label.
|
||||||
|
|
||||||
The declarations are kept deliberately: they are already correct for a supported Gitea release and
|
The declarations stay in the workflow either way: they state the intent, and are correct on GitHub
|
||||||
for GitHub Actions, and keeping them means the upgrade does not require re-deriving the intent. They
|
Actions as well.
|
||||||
are documented as ignored, not removed.
|
|
||||||
|
### Disposable stacks never outlive their job
|
||||||
|
|
||||||
|
Every CI script removes its own Compose stack in an `EXIT` trap, but a trap never runs when the runner
|
||||||
|
kills the job container: a cancelled job, or the runner's own time limit. One such stack ran on the
|
||||||
|
shared runner for two days. So stack cleanup has three layers:
|
||||||
|
|
||||||
|
- **The script's own trap**, for every ordinary failure.
|
||||||
|
- **A final `if: always()` step** in every job that starts stacks: `scripts/ci-sweep.sh --job` removes
|
||||||
|
everything that job started, at any age, even after a failed step.
|
||||||
|
- **A sweep before every such job starts**: `scripts/ci-sweep.sh --stale` removes anything a CI job
|
||||||
|
left more than three hours ago, which catches whatever a killed job could not clean up itself.
|
||||||
|
|
||||||
|
A stack is found by name and label, never by guesswork (`scripts/lib/ci-project.sh`):
|
||||||
|
|
||||||
|
- every CI Compose project is named `ekklesia-ci-<run>-<attempt>-<job key>-…`, and Compose labels
|
||||||
|
its containers, networks, and volumes with that name;
|
||||||
|
- anything a script starts outside Compose carries `org.riverofglorymn.ekklesia.ci.project`;
|
||||||
|
- images also carry the time CI built them, because an image's own creation time can be its base's.
|
||||||
|
|
||||||
|
The job key is per matrix leg, so one leg's cleanup cannot match another's running stack. Nothing
|
||||||
|
outside the `ekklesia-ci-` prefix is ever removed. A job container attached to a stack's network is
|
||||||
|
disconnected, never removed.
|
||||||
|
|
||||||
|
**This is not a substitute for a runner-side reaper.** A stack killed with its job is removed only
|
||||||
|
when another job that sweeps runs on the same runner. Bounded execution and teardown are still the
|
||||||
|
runner host's to enforce, as above.
|
||||||
|
|
||||||
### Runner topology is not verified
|
### Runner topology is not verified
|
||||||
|
|
||||||
@@ -766,16 +806,17 @@ they are not conditions on merging trusted, reviewed work.
|
|||||||
None of them can be satisfied by editing this repository; each requires access to the Gitea server
|
None of them can be satisfied by editing this repository; each requires access to the Gitea server
|
||||||
and runner hosts, and each must be proven there.
|
and runner hosts, and each must be proven there.
|
||||||
|
|
||||||
1. Upgrade Gitea to a reviewed supported release with job-token permission controls — at least the
|
1. ~~Upgrade Gitea to a reviewed supported release with job-token permission controls — at least
|
||||||
1.26 feature line. Select the actual release during infrastructure work, after backup and
|
the 1.26 feature line.~~ **Done:** the server runs 1.27.2. Items 2 and 3 now configure and prove
|
||||||
compatibility review; do not pin a version from this document.
|
what it makes possible.
|
||||||
2. Configure repository and owner defaults, and the server-side maximum permission, so workflow
|
2. Configure repository and owner defaults, and the server-side maximum permission, so workflow
|
||||||
code cannot request more than read-only repository access.
|
code cannot request more than read-only repository access.
|
||||||
3. Prove against the real server that the job's `GITEA_TOKEN` cannot push code, create or update
|
3. Prove against the real server that the job's `GITEA_TOKEN` cannot push code, create or update
|
||||||
releases, packages, issues, or pull requests, change Actions configuration, or reach another
|
releases, packages, issues, or pull requests, change Actions configuration, or reach another
|
||||||
private repository.
|
private repository.
|
||||||
4. Enforce job timeout, cancellation, concurrency, and teardown in the trusted
|
4. Enforce job timeout and teardown in the trusted provisioner/reaper. `concurrency` is honoured
|
||||||
provisioner/reaper rather than relying on the ignored workflow keys above.
|
by the server; `timeout-minutes` is not yet verified, and the in-workflow stack sweep only
|
||||||
|
complements a reaper.
|
||||||
5. Complete a disposable-guest runner isolation proof: one throwaway guest per job, torn down by a
|
5. Complete a disposable-guest runner isolation proof: one throwaway guest per job, torn down by a
|
||||||
trusted reaper outside workflow YAML, holding no deployment credential, registration token, or
|
trusted reaper outside workflow YAML, holding no deployment credential, registration token, or
|
||||||
reusable key.
|
reusable key.
|
||||||
|
|||||||
@@ -159,12 +159,28 @@ also receives a safely named disposable database. Direct host runs use a Docker-
|
|||||||
port bound only to `127.0.0.1`; a containerized Gitea job joins the isolated Compose network and
|
port bound only to `127.0.0.1`; a containerized Gitea job joins the isolated Compose network and
|
||||||
uses the PostgreSQL service name.
|
uses the PostgreSQL service name.
|
||||||
|
|
||||||
|
In CI, a stack whose job was killed before its own cleanup ran is still removed: by that job's final
|
||||||
|
`if: always()` step, or by the stale sweep before a later job starts (`scripts/ci-sweep.sh`, see
|
||||||
|
[Disposable stacks never outlive their job](security.md#disposable-stacks-never-outlive-their-job)).
|
||||||
|
`bash scripts/test-ci-sweep.sh` proves what the sweep removes and what it leaves alone, against real
|
||||||
|
Docker resources.
|
||||||
|
|
||||||
The live suite proves fresh read-only planning, initial and incremental application, genuine
|
The live suite proves fresh read-only planning, initial and incremental application, genuine
|
||||||
no-op behavior, exact applied checksums and history, edited or corrupt history rejection,
|
no-op behavior, exact applied checksums and history, edited or corrupt history rejection,
|
||||||
whole-batch DDL rollback, Drizzle breakpoint execution, real advisory-lock concurrency and
|
whole-batch DDL rollback, Drizzle breakpoint execution, real advisory-lock concurrency and
|
||||||
timeouts, lock release after failure, exact destructive approvals, and public-error sanitization.
|
timeouts, lock release after failure, exact destructive approvals, and public-error sanitization.
|
||||||
It does not make migrations automatic during Core startup.
|
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
|
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
|
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
|
expected `ekklesia_core_*` tables and their indexes exist, a genuine no-op on a second apply, Core
|
||||||
@@ -548,6 +564,33 @@ the following pull-request status context to `main` branch protection:
|
|||||||
CI / migration-smoke (pull_request)
|
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
|
## Safe troubleshooting
|
||||||
|
|
||||||
Confirm the prerequisites without printing configuration:
|
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
|
## 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:
|
strict semantic version, and the module layer marker:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -66,6 +69,73 @@ 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
|
The package version must match the manifest and runtime-definition versions. Internal package
|
||||||
dependencies use `workspace:` ranges and supported public package entrypoints.
|
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.
|
||||||
|
|
||||||
|
The format modules are delivered in is proposed in
|
||||||
|
[ADR 0049](../architecture/decisions/0049-module-package-v1.md): one deterministic archive per
|
||||||
|
target, with bundled backend code, produced by an Ekklesia-owned packager that does not exist yet.
|
||||||
|
Until it does, the repository's smoke test packages the module template with `pnpm deploy` in a
|
||||||
|
disposable Docker build (`scripts/lib/test-module-package.Dockerfile`), a test fixture and not a
|
||||||
|
package format. Never run `pnpm deploy` in your own workspace: it rewrites that workspace's install
|
||||||
|
state to production-only, and the next build or `pnpm verify` then fails until a normal
|
||||||
|
`pnpm install` repairs it.
|
||||||
|
|
||||||
|
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
|
## Canonical manifest
|
||||||
|
|
||||||
`ekklesia.module.json` is inert and authoritative, so Core can validate every installed module
|
`ekklesia.module.json` is inert and authoritative, so Core can validate every installed module
|
||||||
@@ -1447,7 +1517,8 @@ pnpm migrations:plan
|
|||||||
pnpm verify
|
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
|
It rejects malformed manifests, duplicate IDs or package names, version mismatches, incompatible
|
||||||
platform or SDK ranges, missing required modules or capabilities, incompatible installed
|
platform or SDK ranges, missing required modules or capabilities, incompatible installed
|
||||||
dependencies, cycles, and missing, escaping, or non-JavaScript server or schema entrypoints.
|
dependencies, cycles, and missing, escaping, or non-JavaScript server or schema entrypoints.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ekklesia",
|
"name": "ekklesia",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Open, modular church technology platform",
|
"description": "Open, modular church technology platform",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/contracts",
|
"name": "@ekklesia/contracts",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/design-tokens",
|
"name": "@ekklesia/design-tokens",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-sdk",
|
"name": "@ekklesia/module-sdk",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-ui-build",
|
"name": "@ekklesia/module-ui-build",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"bin",
|
"bin",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-ui-host",
|
"name": "@ekklesia/module-ui-host",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-ui-sdk",
|
"name": "@ekklesia/module-ui-sdk",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/typescript-config",
|
"name": "@ekklesia/typescript-config",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"files": [
|
"files": [
|
||||||
"base.json",
|
"base.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/ui",
|
"name": "@ekklesia/ui",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
Executable
+146
@@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Proves that Ekklesia's image is the one generic platform artifact ADR 0048 requires:
|
||||||
|
#
|
||||||
|
# - it contains zero feature modules: no /opt/ekklesia/modules, no module template, and no
|
||||||
|
# ekklesia.module.json anywhere outside installed dependencies;
|
||||||
|
# - its module storage mount point, /var/lib/ekklesia/modules, exists, is root-owned, and is empty;
|
||||||
|
# - EKKLESIA_MODULES_DIR names that directory, in the image configuration and in a running process;
|
||||||
|
# - a checkout with a populated modules/ directory builds the same image: the runtime file system
|
||||||
|
# of both builds is identical, and no module from that directory enters it.
|
||||||
|
#
|
||||||
|
# bash scripts/check-image.sh builds both images from this checkout
|
||||||
|
# EKKLESIA_CHECK_IMAGE=<image> bash scripts/check-image.sh checks one existing image's contents
|
||||||
|
#
|
||||||
|
# Both builds use a copy of the files Git would track (committed, staged, or untracked but not
|
||||||
|
# ignored), so they differ in exactly one thing: the second context also has modules/example. Images
|
||||||
|
# are built and inspected locally only; nothing is pushed, published, or logged in to.
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$repository_root"
|
||||||
|
|
||||||
|
# shellcheck source=scripts/lib/ci-project.sh
|
||||||
|
source "$repository_root/scripts/lib/ci-project.sh"
|
||||||
|
|
||||||
|
command -v docker >/dev/null || {
|
||||||
|
echo "check-image: docker is required." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
project="$(ci_project_name image)"
|
||||||
|
modules_directory="/var/lib/ekklesia/modules"
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
built_images=()
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
for built in "${built_images[@]}"; do
|
||||||
|
docker image rm --force "$built" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
rm -rf "$work" || true
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
step() {
|
||||||
|
echo "==> $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "check-image: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# check_contents <image>: every content guarantee, inside the image and in its configuration.
|
||||||
|
check_contents() {
|
||||||
|
local image="$1" configured
|
||||||
|
configured="$(docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$image")" ||
|
||||||
|
fail "could not inspect ${image}."
|
||||||
|
grep -qx "EKKLESIA_MODULES_DIR=${modules_directory}" <<<"$configured" ||
|
||||||
|
fail "the image does not set EKKLESIA_MODULES_DIR=${modules_directory}."
|
||||||
|
|
||||||
|
# As root, so a directory the runtime user cannot read cannot hide a manifest. No network.
|
||||||
|
docker run --rm --network none --user 0:0 --entrypoint sh "$image" -ec '
|
||||||
|
fail() { echo "check-image: $*" >&2; exit 1; }
|
||||||
|
[ ! -e /opt/ekklesia/modules ] || fail "/opt/ekklesia/modules exists in the image."
|
||||||
|
[ ! -e /opt/ekklesia/templates ] || fail "the module template is in the image."
|
||||||
|
[ -d /var/lib/ekklesia/modules ] || fail "/var/lib/ekklesia/modules is missing."
|
||||||
|
[ "$(stat -c %U:%G:%a /var/lib/ekklesia/modules)" = "root:root:755" ] ||
|
||||||
|
fail "/var/lib/ekklesia/modules is not root-owned with mode 755."
|
||||||
|
[ -z "$(ls -A /var/lib/ekklesia/modules)" ] || fail "/var/lib/ekklesia/modules is not empty."
|
||||||
|
manifests="$(find / -xdev -name ekklesia.module.json -not -path "*/node_modules/*" 2>/dev/null)"
|
||||||
|
[ -z "$manifests" ] || fail "a module manifest is in the image: ${manifests}"
|
||||||
|
[ "$EKKLESIA_MODULES_DIR" = /var/lib/ekklesia/modules ] ||
|
||||||
|
fail "a process in the image does not see EKKLESIA_MODULES_DIR."
|
||||||
|
' || fail "${image} is not the generic platform image."
|
||||||
|
echo "ok ${image}: no modules, no template, empty root-owned module storage, EKKLESIA_MODULES_DIR set"
|
||||||
|
}
|
||||||
|
|
||||||
|
# runtime_listing <image>: every path the platform and its module storage consist of, with its
|
||||||
|
# type, mode, and size, in a stable order. Two builds that differ here differ in content.
|
||||||
|
runtime_listing() {
|
||||||
|
docker run --rm --network none --user 0:0 --entrypoint sh "$1" -ec \
|
||||||
|
'find /opt/ekklesia /var/lib/ekklesia -xdev -printf "%y %m %s %p\n" | LC_ALL=C sort'
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -n "${EKKLESIA_CHECK_IMAGE:-}" ]]; then
|
||||||
|
check_contents "$EKKLESIA_CHECK_IMAGE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v git >/dev/null || fail "git is required to copy the build context."
|
||||||
|
|
||||||
|
# context_copy <directory>: the files Git would track, and nothing it ignores.
|
||||||
|
context_copy() {
|
||||||
|
mkdir -p "$1"
|
||||||
|
git ls-files -z --cached --others --exclude-standard |
|
||||||
|
tar --null --files-from=- --create --file=- |
|
||||||
|
tar -C "$1" -xf -
|
||||||
|
}
|
||||||
|
|
||||||
|
tag_base="ekklesia-ci:${GITHUB_SHA:-local}-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-"
|
||||||
|
generic_image="${tag_base}generic-$$"
|
||||||
|
populated_image="${tag_base}populated-$$"
|
||||||
|
|
||||||
|
# build_image <context> <tag>
|
||||||
|
build_image() {
|
||||||
|
built_images+=("$2")
|
||||||
|
DOCKER_BUILDKIT=1 docker build --quiet --file "$1/deploy/Dockerfile" \
|
||||||
|
--build-arg REVISION="${GITHUB_SHA:-}" \
|
||||||
|
--label "${EKKLESIA_CI_LABEL}=${project}" --label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
|
||||||
|
--tag "$2" "$1" >/dev/null || fail "the image did not build from ${1##*/}."
|
||||||
|
}
|
||||||
|
|
||||||
|
step "Building the platform image from this checkout"
|
||||||
|
context_copy "$work/generic"
|
||||||
|
[[ ! -e "$work/generic/modules" ]] || fail "this checkout itself has a modules/ directory."
|
||||||
|
build_image "$work/generic" "$generic_image"
|
||||||
|
check_contents "$generic_image"
|
||||||
|
|
||||||
|
step "Building again from the same checkout with a populated modules/ directory"
|
||||||
|
context_copy "$work/populated"
|
||||||
|
mkdir -p "$work/populated/modules"
|
||||||
|
cp -R "$work/populated/templates/module" "$work/populated/modules/example"
|
||||||
|
printf 'must never reach an image\n' >"$work/populated/modules/example/canary.txt"
|
||||||
|
build_image "$work/populated" "$populated_image"
|
||||||
|
check_contents "$populated_image"
|
||||||
|
|
||||||
|
step "Comparing the two images' runtime file systems"
|
||||||
|
runtime_listing "$generic_image" >"$work/generic.listing"
|
||||||
|
runtime_listing "$populated_image" >"$work/populated.listing"
|
||||||
|
[[ -s "$work/generic.listing" ]] || fail "could not list the image's file system."
|
||||||
|
if ! diff -u "$work/generic.listing" "$work/populated.listing" >"$work/listing.diff"; then
|
||||||
|
head -40 "$work/listing.diff" >&2
|
||||||
|
fail "a populated modules/ directory changed the image."
|
||||||
|
fi
|
||||||
|
echo "ok both builds have the identical runtime file system ($(wc -l <"$work/generic.listing") paths)"
|
||||||
|
if [[ "$(docker image inspect --format '{{json .RootFS.Layers}}' "$generic_image")" == \
|
||||||
|
"$(docker image inspect --format '{{json .RootFS.Layers}}' "$populated_image")" ]]; then
|
||||||
|
echo "ok both builds share every layer"
|
||||||
|
else
|
||||||
|
echo "note the two builds' layers differ (a cold build cache); their file systems do not"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Generic platform image check passed."
|
||||||
Executable
+206
@@ -0,0 +1,206 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Removes disposable CI stacks from the Docker daemon: containers, networks, volumes, images, and
|
||||||
|
# staging directories on the Docker host.
|
||||||
|
#
|
||||||
|
# bash scripts/ci-sweep.sh --stale [--max-age-seconds N] any CI stack older than N (default 3 h)
|
||||||
|
# bash scripts/ci-sweep.sh --job this job's own stacks, at any age
|
||||||
|
#
|
||||||
|
# Every CI script already removes its own stack in an EXIT trap, but a trap never runs when the runner
|
||||||
|
# kills the job container: a cancelled or superseded job (the server honours `concurrency`), or
|
||||||
|
# the runner's own time limit. So each workflow job that starts stacks ends with `--job` in an
|
||||||
|
# `if: always()` step, and begins with `--stale`, which catches whatever a killed job left before
|
||||||
|
# it could clean up.
|
||||||
|
#
|
||||||
|
# What a stack is, and how it is found, is defined in scripts/lib/ci-project.sh: a Compose project
|
||||||
|
# named with the `ekklesia-ci-` prefix (Compose labels everything it creates with that name), plus
|
||||||
|
# anything labeled EKKLESIA_CI_LABEL. Nothing outside that prefix is ever touched, at any age.
|
||||||
|
#
|
||||||
|
# --stale's threshold is far above any job's run time, so a stack another job on this daemon is using
|
||||||
|
# right now is never young enough to match. --job refuses to run outside CI: locally, its prefix would
|
||||||
|
# match a developer's own stacks.
|
||||||
|
#
|
||||||
|
# Best effort by design: a resource that cannot be removed is reported, and the sweep goes on. Output
|
||||||
|
# names resources only; nothing a stack holds is printed.
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
# shellcheck source=scripts/lib/ci-project.sh
|
||||||
|
source "$repository_root/scripts/lib/ci-project.sh"
|
||||||
|
|
||||||
|
compose_label="com.docker.compose.project"
|
||||||
|
mode=""
|
||||||
|
max_age_seconds="${EKKLESIA_CI_SWEEP_MAX_AGE_SECONDS:-10800}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "usage: ci-sweep.sh --stale [--max-age-seconds N] | --job" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
while (($# > 0)); do
|
||||||
|
case "$1" in
|
||||||
|
--stale | --job)
|
||||||
|
[[ -z "$mode" ]] || usage
|
||||||
|
mode="${1#--}"
|
||||||
|
;;
|
||||||
|
--max-age-seconds)
|
||||||
|
[[ "${2:-}" =~ ^[0-9]+$ ]] || usage
|
||||||
|
max_age_seconds="$2"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
[[ -n "$mode" ]] || usage
|
||||||
|
|
||||||
|
if [[ "$mode" == job ]]; then
|
||||||
|
[[ -n "${GITHUB_RUN_ID:-}" ]] || {
|
||||||
|
echo "ci-sweep: --job runs only in CI (GITHUB_RUN_ID is unset); locally it would match your own stacks." >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
prefix="$(ci_job_prefix)"
|
||||||
|
cutoff=""
|
||||||
|
else
|
||||||
|
prefix="$EKKLESIA_CI_PREFIX"
|
||||||
|
cutoff="$(($(date +%s) - max_age_seconds))"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
echo "ci-sweep: no Docker daemon reachable; nothing to sweep."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
removed=0
|
||||||
|
warn() {
|
||||||
|
echo "ci-sweep: warning: $*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# selected <project> <created>: whether a resource belongs to the sweep.
|
||||||
|
selected() {
|
||||||
|
local project="$1" created="$2" seconds
|
||||||
|
[[ -n "$project" && "$project" == "$prefix"* ]] || return 1
|
||||||
|
[[ -z "$cutoff" ]] && return 0
|
||||||
|
seconds="$(date -d "$created" +%s 2>/dev/null)" || return 1
|
||||||
|
((seconds <= cutoff))
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Containers: Compose's own, and those a script started itself --------------------------------
|
||||||
|
mapfile -t containers < <(
|
||||||
|
{
|
||||||
|
docker ps --all --quiet --no-trunc --filter "label=${compose_label}"
|
||||||
|
docker ps --all --quiet --no-trunc --filter "label=${EKKLESIA_CI_LABEL}"
|
||||||
|
} | sort -u
|
||||||
|
)
|
||||||
|
for id in "${containers[@]}"; do
|
||||||
|
[[ -n "$id" ]] || continue
|
||||||
|
record="$(docker inspect --format \
|
||||||
|
"{{index .Config.Labels \"${compose_label}\"}}|{{index .Config.Labels \"${EKKLESIA_CI_LABEL}\"}}|{{.Created}}|{{.Name}}" \
|
||||||
|
"$id" 2>/dev/null)" || continue
|
||||||
|
IFS='|' read -r compose_project ci_project created name <<<"$record"
|
||||||
|
project="${compose_project:-$ci_project}"
|
||||||
|
selected "$project" "$created" || continue
|
||||||
|
if docker rm --force --volumes "$id" >/dev/null 2>&1; then
|
||||||
|
echo "ci-sweep: removed container ${name#/} (${project})"
|
||||||
|
removed=$((removed + 1))
|
||||||
|
else
|
||||||
|
warn "could not remove container ${name#/}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Networks: anything still attached (a job container) is disconnected, never removed ---------
|
||||||
|
mapfile -t networks < <(docker network ls --quiet --no-trunc --filter "label=${compose_label}")
|
||||||
|
for id in "${networks[@]}"; do
|
||||||
|
[[ -n "$id" ]] || continue
|
||||||
|
# A network's creation time is a time value, not a string: ask for RFC 3339, which `date` reads.
|
||||||
|
record="$(docker network inspect --format \
|
||||||
|
"{{index .Labels \"${compose_label}\"}}|{{.Created.Format \"2006-01-02T15:04:05Z07:00\"}}|{{.Name}}" \
|
||||||
|
"$id" 2>/dev/null)" || continue
|
||||||
|
IFS='|' read -r project created name <<<"$record"
|
||||||
|
selected "$project" "$created" || continue
|
||||||
|
for endpoint in $(docker network inspect --format '{{range $id, $_ := .Containers}}{{$id}} {{end}}' "$id" 2>/dev/null); do
|
||||||
|
docker network disconnect --force "$id" "$endpoint" >/dev/null 2>&1 ||
|
||||||
|
warn "could not disconnect a container from network ${name}"
|
||||||
|
done
|
||||||
|
if docker network rm "$id" >/dev/null 2>&1; then
|
||||||
|
echo "ci-sweep: removed network ${name} (${project})"
|
||||||
|
removed=$((removed + 1))
|
||||||
|
else
|
||||||
|
warn "could not remove network ${name}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Volumes ---------------------------------------------------------------------------------------
|
||||||
|
mapfile -t volumes < <(
|
||||||
|
{
|
||||||
|
docker volume ls --quiet --filter "label=${compose_label}"
|
||||||
|
docker volume ls --quiet --filter "label=${EKKLESIA_CI_LABEL}"
|
||||||
|
} | sort -u
|
||||||
|
)
|
||||||
|
for name in "${volumes[@]}"; do
|
||||||
|
[[ -n "$name" ]] || continue
|
||||||
|
record="$(docker volume inspect --format \
|
||||||
|
"{{index .Labels \"${compose_label}\"}}|{{index .Labels \"${EKKLESIA_CI_LABEL}\"}}|{{.CreatedAt}}" \
|
||||||
|
"$name" 2>/dev/null)" || continue
|
||||||
|
IFS='|' read -r compose_project ci_project created <<<"$record"
|
||||||
|
project="${compose_project:-$ci_project}"
|
||||||
|
selected "$project" "$created" || continue
|
||||||
|
if docker volume rm --force "$name" >/dev/null 2>&1; then
|
||||||
|
echo "ci-sweep: removed volume ${name} (${project})"
|
||||||
|
removed=$((removed + 1))
|
||||||
|
else
|
||||||
|
warn "could not remove volume ${name}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Images built for a stack ------------------------------------------------------------------------
|
||||||
|
mapfile -t images < <(docker image ls --quiet --no-trunc --filter "label=${EKKLESIA_CI_LABEL}" | sort -u)
|
||||||
|
for id in "${images[@]}"; do
|
||||||
|
[[ -n "$id" ]] || continue
|
||||||
|
# Judged by when CI built it (EKKLESIA_CI_CREATED_LABEL), never by the image's own creation time,
|
||||||
|
# which BuildKit may have taken from its base. An image without that label is never swept by age.
|
||||||
|
record="$(docker image inspect --format \
|
||||||
|
"{{index .Config.Labels \"${EKKLESIA_CI_LABEL}\"}}|{{index .Config.Labels \"${EKKLESIA_CI_CREATED_LABEL}\"}}" \
|
||||||
|
"$id" 2>/dev/null)" || continue
|
||||||
|
IFS='|' read -r project built <<<"$record"
|
||||||
|
if [[ -n "$cutoff" ]]; then
|
||||||
|
[[ "$built" =~ ^[0-9]+$ ]] || continue
|
||||||
|
selected "$project" "@${built}" || continue
|
||||||
|
else
|
||||||
|
selected "$project" "" || continue
|
||||||
|
fi
|
||||||
|
if docker image rm --force "$id" >/dev/null 2>&1; then
|
||||||
|
echo "ci-sweep: removed image ${id#sha256:} (${project})"
|
||||||
|
removed=$((removed + 1))
|
||||||
|
else
|
||||||
|
warn "could not remove image ${id#sha256:}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Staging directories on the Docker host ------------------------------------------------------
|
||||||
|
# A deploy smoke run inside a job container stages its mounted files under the Docker host's /tmp,
|
||||||
|
# named for its project, as root-owned files; they hold that run's disposable secrets and keys. They
|
||||||
|
# are removed with the same pinned image that created them. If that image is not on this daemon, no
|
||||||
|
# smoke has staged anything here.
|
||||||
|
image="$(sed -n 's/^[[:space:]]*image: \(postgres:[^[:space:]]*\)$/\1/p' "$repository_root/deploy/compose.yaml" | head -1)"
|
||||||
|
if [[ -n "$image" ]] && docker image inspect "$image" >/dev/null 2>&1; then
|
||||||
|
age=()
|
||||||
|
# `find -mmin +0` does not match something younger than a minute, so a threshold under a minute
|
||||||
|
# sweeps without an age filter, like the rest of this script.
|
||||||
|
if [[ -n "$cutoff" ]] && ((max_age_seconds >= 60)); then
|
||||||
|
age=(-mmin "+$((max_age_seconds / 60))")
|
||||||
|
fi
|
||||||
|
if staged="$(docker run --rm --entrypoint find -v /tmp:/host-tmp "$image" \
|
||||||
|
/host-tmp -mindepth 1 -maxdepth 1 -type d -name "${prefix}*" "${age[@]}" \
|
||||||
|
-print -exec rm -rf {} + 2>/dev/null)"; then
|
||||||
|
while IFS= read -r directory; do
|
||||||
|
[[ -n "$directory" ]] || continue
|
||||||
|
echo "ci-sweep: removed staging directory /tmp/${directory#/host-tmp/}"
|
||||||
|
removed=$((removed + 1))
|
||||||
|
done <<<"$staged"
|
||||||
|
else
|
||||||
|
warn "could not sweep staging directories on the Docker host"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "ci-sweep: ${mode} sweep removed ${removed} resource(s)."
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# shellcheck shell=bash
|
||||||
|
# How CI names, labels, and later finds the disposable stacks it starts.
|
||||||
|
#
|
||||||
|
# Every Compose project a CI script starts is named
|
||||||
|
#
|
||||||
|
# ekklesia-ci-<run id>-<run attempt>-<job key>-<kind>-<pid><random>
|
||||||
|
#
|
||||||
|
# and Compose labels every container, network, and volume it creates with that name. What a script
|
||||||
|
# creates outside Compose (a browser container, a volume, an image) gets EKKLESIA_CI_LABEL with the
|
||||||
|
# same name. scripts/ci-sweep.sh finds stacks by those labels: this job's own, by the prefix up to
|
||||||
|
# the job key (in a final `if: always()` step), and any CI stack by age (before a job starts).
|
||||||
|
#
|
||||||
|
# The job key is EKKLESIA_CI_JOB_KEY, which the workflow sets per job, and per matrix leg where a job
|
||||||
|
# has one, so that one leg's cleanup cannot match another leg's running stack. Outside CI the
|
||||||
|
# identifiers are "local".
|
||||||
|
|
||||||
|
# shellcheck disable=SC2034 # read by the scripts that source this
|
||||||
|
EKKLESIA_CI_LABEL="org.riverofglorymn.ekklesia.ci.project"
|
||||||
|
# On images, when they were built, in Unix seconds. An image's own creation time is not that: BuildKit
|
||||||
|
# gives an image that only adds metadata its base's time, and a reproducible build a fixed one. A
|
||||||
|
# stale sweep that trusted it could remove an image another job is using right now.
|
||||||
|
EKKLESIA_CI_CREATED_LABEL="org.riverofglorymn.ekklesia.ci.created"
|
||||||
|
EKKLESIA_CI_PREFIX="ekklesia-ci-"
|
||||||
|
|
||||||
|
# ci_name_safe <text>: lower case, and only what a Compose project name may contain.
|
||||||
|
ci_name_safe() {
|
||||||
|
local text="${1//[^a-zA-Z0-9_-]/-}"
|
||||||
|
printf '%s' "${text,,}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ci_job_prefix: every project this job starts begins with exactly this.
|
||||||
|
ci_job_prefix() {
|
||||||
|
ci_name_safe "ekklesia-ci-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${EKKLESIA_CI_JOB_KEY:-${GITHUB_JOB:-local}}-"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ci_project_name <kind>: a project name unique to this invocation. Not only per run: several runs,
|
||||||
|
# and several jobs of one run, share a runner's Docker daemon, and job containers often share a PID.
|
||||||
|
ci_project_name() {
|
||||||
|
ci_name_safe "ekklesia-ci-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${EKKLESIA_CI_JOB_KEY:-${GITHUB_JOB:-local}}-${1:?ci_project_name needs a kind}-$$-${RANDOM}${RANDOM}"
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@
|
|||||||
// node deploy-smoke-client.mjs edge an ordinary, untrusted peer of the proxy
|
// 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 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 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,
|
// 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
|
// when set, the Dashboard's. Unset means the same-origin topology, where both are one hostname. Every
|
||||||
@@ -14,6 +17,8 @@
|
|||||||
// Output is one line per check; any failed check exits non-zero. Nothing printed carries a
|
// Output is one line per check; any failed check exits non-zero. Nothing printed carries a
|
||||||
// credential, cookie, signed URL, or response body beyond the fixed shapes asserted here.
|
// credential, cookie, signed URL, or response body beyond the fixed shapes asserted here.
|
||||||
|
|
||||||
|
import { connect as connectHttp2 } from "node:http2";
|
||||||
|
|
||||||
const mode = process.argv[2];
|
const mode = process.argv[2];
|
||||||
const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`;
|
const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`;
|
||||||
const dashboardOrigin = process.env.SMOKE_DASHBOARD_HOSTNAME
|
const dashboardOrigin = process.env.SMOKE_DASHBOARD_HOSTNAME
|
||||||
@@ -192,6 +197,46 @@ async function splitCorsChecks() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One GET over a real HTTP/2 connection. Node's fetch speaks HTTP/1.1, and the failure this guards
|
||||||
|
* against exists only on HTTP/2: a reset stream, which a browser reports as ERR_HTTP2_PROTOCOL_ERROR.
|
||||||
|
*/
|
||||||
|
function http2Get(url, headers = {}) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const session = connectHttp2(url.origin);
|
||||||
|
const finish = (result) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
session.close();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => finish({ error: "timeout" }), 10_000);
|
||||||
|
session.on("error", () => finish({ error: "session" }));
|
||||||
|
const stream = session.request({
|
||||||
|
":method": "GET",
|
||||||
|
":path": `${url.pathname}${url.search}`,
|
||||||
|
...headers,
|
||||||
|
});
|
||||||
|
let status;
|
||||||
|
let responseHeaders = {};
|
||||||
|
let bytes = 0;
|
||||||
|
stream.on("response", (received) => {
|
||||||
|
status = received[":status"];
|
||||||
|
responseHeaders = received;
|
||||||
|
});
|
||||||
|
stream.on("data", (chunk) => {
|
||||||
|
bytes += chunk.length;
|
||||||
|
});
|
||||||
|
stream.on("error", (error) => finish({ error: error.code ?? "stream" }));
|
||||||
|
stream.on("end", () => finish({ bytes, headers: responseHeaders, status }));
|
||||||
|
stream.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The CORS headers of a response, by name: what a browser would act on. */
|
||||||
|
function corsOf(response) {
|
||||||
|
return [...response.headers.keys()].filter((name) => name.startsWith("access-control-"));
|
||||||
|
}
|
||||||
|
|
||||||
async function storageDeliveryCheck() {
|
async function storageDeliveryCheck() {
|
||||||
// Core's own provider, from the image, exactly as the API composes it: it reaches object-storage
|
// Core's own provider, from the image, exactly as the API composes it: it reaches object-storage
|
||||||
// over HTTPS on the private network and signs read URLs for the storage hostname.
|
// over HTTPS on the private network and signs read URLs for the storage hostname.
|
||||||
@@ -238,6 +283,79 @@ async function storageDeliveryCheck() {
|
|||||||
"a delivered object is Cache-Control: private",
|
"a delivered object is Cache-Control: private",
|
||||||
);
|
);
|
||||||
await provider.delete(bucket, "smoke/object.txt");
|
await provider.delete(bucket, "smoke/object.txt");
|
||||||
|
|
||||||
|
// The same image, twice, over HTTP/2: a browser's second load of a signed URL it has cached is a
|
||||||
|
// conditional request, and must come back as a clean 304, not a reset stream.
|
||||||
|
const png = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
await provider.put(bucket, "smoke/image.png", png, {
|
||||||
|
contentLength: png.byteLength,
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
const image = new URL(
|
||||||
|
(await provider.createReadUrl(bucket, "smoke/image.png", { expiresInSeconds: 300 })).url,
|
||||||
|
);
|
||||||
|
const first = await http2Get(image);
|
||||||
|
check(
|
||||||
|
first.status === 200 && first.bytes === png.byteLength && Boolean(first.headers?.etag),
|
||||||
|
"an image loads over HTTP/2 through the proxy, with a validator (200, ETag)",
|
||||||
|
);
|
||||||
|
const revalidated = await http2Get(image, { "if-none-match": first.headers?.etag ?? "" });
|
||||||
|
check(
|
||||||
|
revalidated.status === 304 && revalidated.error === undefined,
|
||||||
|
`the same image loaded again over HTTP/2 revalidates cleanly (${revalidated.status ?? revalidated.error})`,
|
||||||
|
);
|
||||||
|
const sinceModified = await http2Get(image, {
|
||||||
|
"if-modified-since": first.headers?.["last-modified"] ?? "",
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
sinceModified.status === 304 && sinceModified.error === undefined,
|
||||||
|
`a revalidation by date over HTTP/2 is a clean 304 too (${sinceModified.status ?? sinceModified.error})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delivery-layer CORS is the deployment's own, set by the proxy: exactly the origins whose pages
|
||||||
|
// read objects with fetch(), GET and HEAD only, and nothing of the object store's own answer.
|
||||||
|
const fromApi = await fetch(image, { headers: { origin: apiOrigin } });
|
||||||
|
check(
|
||||||
|
fromApi.headers.get("access-control-allow-origin") === apiOrigin &&
|
||||||
|
(fromApi.headers.get("vary") ?? "").toLowerCase().includes("origin") &&
|
||||||
|
corsOf(fromApi).length === 1,
|
||||||
|
"a read from the API origin is allowed for exactly that origin, with Vary: Origin and nothing else",
|
||||||
|
);
|
||||||
|
if (split) {
|
||||||
|
const fromDashboard = await fetch(image, { headers: { origin: dashboardOrigin } });
|
||||||
|
check(
|
||||||
|
fromDashboard.headers.get("access-control-allow-origin") === dashboardOrigin,
|
||||||
|
"a read from the Dashboard origin is allowed for exactly that origin",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const fromElsewhere = await fetch(image, { headers: { origin: untrustedOrigin } });
|
||||||
|
check(
|
||||||
|
fromElsewhere.status === 200 && corsOf(fromElsewhere).length === 0,
|
||||||
|
"a read from any other origin gets no CORS header at all",
|
||||||
|
);
|
||||||
|
const preflight = await fetch(image, {
|
||||||
|
headers: { "access-control-request-method": "GET", origin: apiOrigin },
|
||||||
|
method: "OPTIONS",
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
preflight.status === 204 &&
|
||||||
|
preflight.headers.get("access-control-allow-origin") === apiOrigin &&
|
||||||
|
preflight.headers.get("access-control-allow-methods") === "GET, HEAD" &&
|
||||||
|
preflight.headers.get("x-amz-request-id") === null,
|
||||||
|
"the proxy answers a preflight itself (204, GET and HEAD only), never the object store",
|
||||||
|
);
|
||||||
|
const foreignPreflight = await fetch(image, {
|
||||||
|
headers: { "access-control-request-method": "GET", origin: untrustedOrigin },
|
||||||
|
method: "OPTIONS",
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
foreignPreflight.status === 204 && corsOf(foreignPreflight).length === 0,
|
||||||
|
"a preflight from any other origin is answered without permission",
|
||||||
|
);
|
||||||
|
await provider.delete(bucket, "smoke/image.png");
|
||||||
} finally {
|
} finally {
|
||||||
await provider.close();
|
await provider.close();
|
||||||
}
|
}
|
||||||
@@ -328,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") {
|
if (mode === "edge") {
|
||||||
await edgeChecks();
|
await edgeChecks();
|
||||||
} else if (mode === "tunnel") {
|
} else if (mode === "tunnel") {
|
||||||
await tunnelChecks();
|
await tunnelChecks();
|
||||||
} else if (mode === "session") {
|
} else if (mode === "session") {
|
||||||
await sessionChecks();
|
await sessionChecks();
|
||||||
|
} else if (mode === "module") {
|
||||||
|
await moduleChecks();
|
||||||
} else {
|
} 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) {
|
if (failures > 0) {
|
||||||
|
|||||||
@@ -0,0 +1,979 @@
|
|||||||
|
// Real-browser display-device scenarios for scripts/smoke-deploy.sh in the split topology (ADR 0036,
|
||||||
|
// ADR 0045). Runs inside a disposable container on the deployment's `edge` network and drives two
|
||||||
|
// headless Chromiums over the Chrome DevTools Protocol, with Node.js's own fetch and WebSocket and no
|
||||||
|
// other dependency: the smoke run's browser, where the owner signs in, and a "kiosk" browser whose
|
||||||
|
// profile directory is a Docker volume that outlives the kiosk container.
|
||||||
|
//
|
||||||
|
// node deploy-smoke-display.mjs <step>
|
||||||
|
//
|
||||||
|
// pair seed content, pair the kiosk, then close its browser cleanly
|
||||||
|
// restart same profile, new browser process: still paired and live?
|
||||||
|
// two-pages two pages, one device credential: does either flap?
|
||||||
|
// connectivity-blip the browser reports offline then online: is a live display remounted?
|
||||||
|
// session-blip Signage's first viewer-session request fails once: does the display recover?
|
||||||
|
// stream-failure the first realtime connection is refused: is realtime re-established?
|
||||||
|
// caching-probe can a display on the API origin store storage-origin images in Cache Storage?
|
||||||
|
// stream-recycle one page past the five-minute realtime recycle, with a timestamped timeline
|
||||||
|
// revoke revoke the paired kiosk while its display is open
|
||||||
|
//
|
||||||
|
// Every DevTools command and every page-side request has its own deadline, and a step has an overall
|
||||||
|
// budget. A call that never answers fails the step with a line naming that exact call, because a call
|
||||||
|
// that can hang the driver may be a request that can hang a real display.
|
||||||
|
//
|
||||||
|
// Output is one line per check or observation; nothing printed carries a credential, cookie value,
|
||||||
|
// signed URL, query string, or response body.
|
||||||
|
|
||||||
|
function required(name) {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${name} is required.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A positive whole number of milliseconds from the environment, or the default. */
|
||||||
|
function milliseconds(name, fallback) {
|
||||||
|
const raw = process.env[name];
|
||||||
|
const value = raw === undefined || raw === "" ? fallback : Number(raw);
|
||||||
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||||
|
throw new Error(`${name} must be a positive whole number of milliseconds.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = process.argv[2];
|
||||||
|
const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`;
|
||||||
|
const dashboardOrigin = `https://${required("SMOKE_DASHBOARD_HOSTNAME")}`;
|
||||||
|
const ownerAddress = required("SMOKE_CDP_ADDRESS");
|
||||||
|
const kioskAddress = required("SMOKE_KIOSK_CDP_ADDRESS");
|
||||||
|
const watchMs = milliseconds("SMOKE_WATCH_MS", 120_000);
|
||||||
|
const recycleWatchMs = milliseconds("SMOKE_RECYCLE_WATCH_MS", 330_000);
|
||||||
|
const stepBudgetMs = milliseconds("SMOKE_STEP_BUDGET_MS", 180_000);
|
||||||
|
/** How long one DevTools command may go unanswered. */
|
||||||
|
const callTimeoutMs = milliseconds("SMOKE_CALL_TIMEOUT_MS", 10_000);
|
||||||
|
/** How long one page-side HTTP request may go unanswered; shorter than the call deadline, so a hung
|
||||||
|
* request is reported as the request rather than as the evaluation that awaited it. */
|
||||||
|
const requestTimeoutMs = Math.max(1_000, callTimeoutMs - 2_000);
|
||||||
|
const waitTimeoutMs = 30_000;
|
||||||
|
const deviceCookie = "ekklesia_device_session";
|
||||||
|
/** Far above what three cached slides need, which is one object URL each. */
|
||||||
|
const MAX_LIVE_OBJECT_URLS = 50;
|
||||||
|
const stepStartedAt = Date.now();
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
function check(condition, description) {
|
||||||
|
console.log(`${condition ? "ok " : "FAIL"} ${description}`);
|
||||||
|
if (!condition) {
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function note(description) {
|
||||||
|
console.log(` ${description}`);
|
||||||
|
}
|
||||||
|
const clock = (ms = Date.now()) => new Date(ms).toISOString().slice(11, 19);
|
||||||
|
const since = (from) => `+${Math.round((Date.now() - from) / 1000)}s`;
|
||||||
|
function progress(description) {
|
||||||
|
console.log(`···· ${clock()} ${since(stepStartedAt)} ${description}`);
|
||||||
|
}
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
/** A URL's path only: a signed delivery URL's query is a credential. */
|
||||||
|
const pathOf = (url) => {
|
||||||
|
try {
|
||||||
|
return new URL(url).pathname;
|
||||||
|
} catch {
|
||||||
|
return "(unparseable)";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Hang guards ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class HangError extends Error {
|
||||||
|
constructor(label, ms) {
|
||||||
|
super(`HANG ${label}: no answer within ${ms / 1000}s`);
|
||||||
|
this.name = "HangError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything awaited right now, by name, so a step that overruns its budget says what it was
|
||||||
|
* waiting for. */
|
||||||
|
const inFlight = new Map();
|
||||||
|
let nextOperation = 1;
|
||||||
|
|
||||||
|
function guard(label, promise, ms = callTimeoutMs) {
|
||||||
|
const id = nextOperation;
|
||||||
|
nextOperation += 1;
|
||||||
|
inFlight.set(id, { at: Date.now(), label });
|
||||||
|
let timer;
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new HangError(label, ms)), ms);
|
||||||
|
}),
|
||||||
|
]).finally(() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
inFlight.delete(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeInFlight() {
|
||||||
|
const pending = [...inFlight.values()].map(
|
||||||
|
(operation) => `${operation.label} (${Math.round((Date.now() - operation.at) / 1000)}s)`,
|
||||||
|
);
|
||||||
|
return pending.length === 0 ? "nothing — the step itself overran" : pending.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log(
|
||||||
|
`FAIL ${mode}: step budget of ${stepBudgetMs / 1000}s exceeded; in flight: ${describeInFlight()}`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}, stepBudgetMs).unref();
|
||||||
|
|
||||||
|
// --- DevTools ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The same minimal DevTools client as deploy-smoke-browser.mjs, with a deadline on every command. */
|
||||||
|
async function connect(devtools, name) {
|
||||||
|
const deadline = Date.now() + 30_000;
|
||||||
|
let version;
|
||||||
|
while (version === undefined) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://${devtools}/json/version`, {
|
||||||
|
signal: AbortSignal.timeout(2_000),
|
||||||
|
});
|
||||||
|
version = await response.json();
|
||||||
|
} catch {
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
throw new HangError(`the ${name} browser's DevTools endpoint`, 30_000);
|
||||||
|
}
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const socketUrl = version.webSocketDebuggerUrl.replace(/^ws:\/\/[^/]+/u, `ws://${devtools}`);
|
||||||
|
const socket = new WebSocket(socketUrl);
|
||||||
|
await guard(
|
||||||
|
`the ${name} browser's DevTools socket`,
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
socket.addEventListener("open", resolve, { once: true });
|
||||||
|
socket.addEventListener("error", () => reject(new Error("DevTools connection failed.")), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let nextId = 1;
|
||||||
|
const pending = new Map();
|
||||||
|
const listeners = [];
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
const message = JSON.parse(String(event.data));
|
||||||
|
if (message.id !== undefined) {
|
||||||
|
const waiter = pending.get(message.id);
|
||||||
|
pending.delete(message.id);
|
||||||
|
if (message.error) {
|
||||||
|
waiter?.reject(new Error(`DevTools ${waiter.method} failed.`));
|
||||||
|
} else {
|
||||||
|
waiter?.resolve(message.result);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
close: () => socket.close(),
|
||||||
|
on: (listener) => listeners.push(listener),
|
||||||
|
send(method, params = {}, sessionId = undefined, where = name) {
|
||||||
|
const id = nextId;
|
||||||
|
nextId += 1;
|
||||||
|
socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
||||||
|
return guard(
|
||||||
|
`DevTools ${method} on ${where}`,
|
||||||
|
new Promise((resolve, reject) => pending.set(id, { method, reject, resolve })),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finds a test id anywhere in the document, including inside module artifacts' open shadow roots
|
||||||
|
* (ADR 0040). */
|
||||||
|
const deepFind = `((testId) => {
|
||||||
|
const walk = (root) => {
|
||||||
|
const found = root.querySelector('[data-testid="' + testId + '"]');
|
||||||
|
if (found) return found;
|
||||||
|
for (const element of root.querySelectorAll("*")) {
|
||||||
|
if (element.shadowRoot) {
|
||||||
|
const inner = walk(element.shadowRoot);
|
||||||
|
if (inner) return inner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return walk(document);
|
||||||
|
})`;
|
||||||
|
const find = (testId) => `${deepFind}(${JSON.stringify(testId)})`;
|
||||||
|
|
||||||
|
/** Installed before any page script: the browser's own connectivity events and the display service
|
||||||
|
* worker's fallback reports, which are what the display host remounts on (ADR 0043). */
|
||||||
|
const instrumentation = `(() => {
|
||||||
|
const events = [];
|
||||||
|
// Object URLs created and revoked in this document. Each live one pins its blob in the browser's
|
||||||
|
// blob storage until it is revoked or the document goes away.
|
||||||
|
const blobs = { created: 0, revoked: 0 };
|
||||||
|
Object.defineProperty(window, "__displayRepro", { value: { blobs, events } });
|
||||||
|
const create = URL.createObjectURL.bind(URL);
|
||||||
|
const revoke = URL.revokeObjectURL.bind(URL);
|
||||||
|
URL.createObjectURL = (object) => { blobs.created += 1; return create(object); };
|
||||||
|
URL.revokeObjectURL = (url) => { blobs.revoked += 1; return revoke(url); };
|
||||||
|
const record = (kind) => events.push({ at: Date.now(), kind });
|
||||||
|
addEventListener("online", () => record("online"));
|
||||||
|
addEventListener("offline", () => record("offline"));
|
||||||
|
navigator.serviceWorker?.addEventListener("message", (event) => {
|
||||||
|
const status = event.data && event.data.ekklesiaDisplayOffline;
|
||||||
|
if (status) record(status.usingFallback ? "sw-fallback:" + (status.kind || "") : "sw-network");
|
||||||
|
});
|
||||||
|
})()`;
|
||||||
|
|
||||||
|
/** One page target with its own flat session, and what it does on the network. */
|
||||||
|
async function openPage(cdp, url, label) {
|
||||||
|
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
|
||||||
|
const { sessionId } = await cdp.send("Target.attachToTarget", { flatten: true, targetId });
|
||||||
|
const send = (method, params) => cdp.send(method, params, sessionId, `page "${label}"`);
|
||||||
|
const requests = new Map();
|
||||||
|
const page = {
|
||||||
|
label,
|
||||||
|
/** Every realtime connection: when it opened, its status, and when and how it ended. */
|
||||||
|
streams: [],
|
||||||
|
/** Signage viewer-session requests: one per module mount. */
|
||||||
|
sessionRequests: [],
|
||||||
|
/** Heartbeat statuses, by status code. */
|
||||||
|
heartbeats: new Map(),
|
||||||
|
/** Channel-state fetches and how they went. */
|
||||||
|
stateFetches: [],
|
||||||
|
/** Non-2xx responses and failed requests, by path only. */
|
||||||
|
problems: [],
|
||||||
|
};
|
||||||
|
cdp.on((message) => {
|
||||||
|
if (message.sessionId !== sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const params = message.params ?? {};
|
||||||
|
if (message.method === "Network.requestWillBeSent") {
|
||||||
|
const path = pathOf(params.request.url);
|
||||||
|
const entry = { at: Date.now(), path, type: params.type };
|
||||||
|
requests.set(params.requestId, entry);
|
||||||
|
if (params.type === "EventSource") {
|
||||||
|
entry.stream = {
|
||||||
|
openedAt: Date.now(),
|
||||||
|
path,
|
||||||
|
status: undefined,
|
||||||
|
endedAt: undefined,
|
||||||
|
how: undefined,
|
||||||
|
};
|
||||||
|
page.streams.push(entry.stream);
|
||||||
|
} else if (path.endsWith("/viewers/session")) {
|
||||||
|
page.sessionRequests.push(Date.now());
|
||||||
|
} else if (path.endsWith("/display-state") && !path.includes("module-realtime")) {
|
||||||
|
entry.state = { at: Date.now(), outcome: "pending" };
|
||||||
|
page.stateFetches.push(entry.state);
|
||||||
|
}
|
||||||
|
} else if (message.method === "Network.responseReceived") {
|
||||||
|
const entry = requests.get(params.requestId);
|
||||||
|
const { status } = params.response;
|
||||||
|
if (entry?.stream) {
|
||||||
|
entry.stream.status = status;
|
||||||
|
}
|
||||||
|
if (entry?.state) {
|
||||||
|
entry.state.outcome = String(status);
|
||||||
|
}
|
||||||
|
if (entry?.path.endsWith("/heartbeat")) {
|
||||||
|
page.heartbeats.set(status, (page.heartbeats.get(status) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
if (status >= 400 || status === 0) {
|
||||||
|
page.problems.push(`${clock()} ${status} ${pathOf(params.response.url)}`);
|
||||||
|
}
|
||||||
|
} else if (message.method === "Network.loadingFinished") {
|
||||||
|
const entry = requests.get(params.requestId);
|
||||||
|
if (entry?.stream) {
|
||||||
|
entry.stream.endedAt = Date.now();
|
||||||
|
entry.stream.how = "finished";
|
||||||
|
}
|
||||||
|
} else if (message.method === "Network.loadingFailed") {
|
||||||
|
const entry = requests.get(params.requestId);
|
||||||
|
if (entry?.stream) {
|
||||||
|
entry.stream.endedAt = Date.now();
|
||||||
|
entry.stream.how = params.canceled ? "canceled" : `failed ${params.errorText}`;
|
||||||
|
}
|
||||||
|
if (entry?.state) {
|
||||||
|
entry.state.outcome = params.canceled ? "canceled" : `failed ${params.errorText}`;
|
||||||
|
}
|
||||||
|
if (entry && !params.canceled) {
|
||||||
|
page.problems.push(`${clock()} failed ${params.errorText} ${entry.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await send("Network.enable");
|
||||||
|
await send("Page.enable");
|
||||||
|
await send("Runtime.enable");
|
||||||
|
await send("Page.addScriptToEvaluateOnNewDocument", { source: instrumentation });
|
||||||
|
await send("Emulation.setDeviceMetricsOverride", {
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
height: 1080,
|
||||||
|
mobile: false,
|
||||||
|
width: 1920,
|
||||||
|
});
|
||||||
|
page.send = send;
|
||||||
|
/** Runs an expression in the page. `what` names it if it never comes back. */
|
||||||
|
page.evaluate = async (expression, what = "an evaluation") => {
|
||||||
|
const result = await guard(
|
||||||
|
`page "${label}": ${what}`,
|
||||||
|
send("Runtime.evaluate", { awaitPromise: true, expression, returnByValue: true }),
|
||||||
|
);
|
||||||
|
if (result.exceptionDetails) {
|
||||||
|
throw new Error(`A page expression threw (${what}).`);
|
||||||
|
}
|
||||||
|
return result.result.value;
|
||||||
|
};
|
||||||
|
page.waitFor = async (expression, description, ms = waitTimeoutMs) => {
|
||||||
|
const deadline = Date.now() + ms;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (
|
||||||
|
await page
|
||||||
|
.evaluate(`Boolean(${expression})`, `waiting for ${description}`)
|
||||||
|
.catch((error) => {
|
||||||
|
if (error instanceof HangError) throw error;
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
note(`timed out waiting for ${description}`);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
/** A credentialed request from this page, bounded in the page itself, so a request that never
|
||||||
|
* answers is reported as that request. */
|
||||||
|
page.request = async (method, url, body) => {
|
||||||
|
const result = await page.evaluate(
|
||||||
|
`fetch(${JSON.stringify(url)}, {
|
||||||
|
credentials: "include",
|
||||||
|
method: ${JSON.stringify(method)},
|
||||||
|
signal: AbortSignal.timeout(${requestTimeoutMs}),
|
||||||
|
${body === undefined ? "" : `headers: { "content-type": "application/json" }, body: ${JSON.stringify(JSON.stringify(body))},`}
|
||||||
|
}).then(
|
||||||
|
async (response) => ({ status: response.status, body: await response.json().catch(() => null) }),
|
||||||
|
(error) => ({ status: error && error.name === "TimeoutError" ? "timeout" : "network-error" }),
|
||||||
|
)`,
|
||||||
|
`${method} ${pathOf(url)}`,
|
||||||
|
);
|
||||||
|
if (result.status === "timeout") {
|
||||||
|
throw new HangError(`HTTP ${method} ${pathOf(url)} from page "${label}"`, requestTimeoutMs);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
page.close = () => cdp.send("Target.closeTarget", { targetId });
|
||||||
|
page.navigate = (to) => send("Page.navigate", { url: to });
|
||||||
|
if (url) {
|
||||||
|
await page.navigate(url);
|
||||||
|
}
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The owner, signed in on the Dashboard hostname of the smoke run's own browser. */
|
||||||
|
async function ownerPage() {
|
||||||
|
const cdp = await connect(ownerAddress, "owner");
|
||||||
|
const page = await openPage(cdp, `${dashboardOrigin}/`, "owner");
|
||||||
|
await page.waitFor(`${find("login-email")} || ${find("host-shell")}`, "the Dashboard");
|
||||||
|
if (!(await page.evaluate(`Boolean(${find("host-shell")})`, "checking the session"))) {
|
||||||
|
await page.evaluate(
|
||||||
|
`(() => {
|
||||||
|
const setValue = (element, value) => {
|
||||||
|
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(element, value);
|
||||||
|
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
};
|
||||||
|
setValue(${find("login-email")}, ${JSON.stringify(required("SMOKE_OWNER_EMAIL"))});
|
||||||
|
setValue(${find("login-password")}, ${JSON.stringify(required("SMOKE_OWNER_PASSWORD"))});
|
||||||
|
${find("login-email")}.form.requestSubmit();
|
||||||
|
})()`,
|
||||||
|
"submitting the sign-in form",
|
||||||
|
);
|
||||||
|
check(await page.waitFor(find("host-shell"), "the signed-in Dashboard"), "the owner signs in");
|
||||||
|
}
|
||||||
|
page.api = (method, path, body) => page.request(method, `${apiOrigin}${path}`, body);
|
||||||
|
return { cdp, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the owner's tab as well as the connection: the owner browser is shared by every step, and a
|
||||||
|
* tab left behind by each one accumulates until the Dashboard no longer renders in time. */
|
||||||
|
async function finishOwner(owner) {
|
||||||
|
await owner.page.close().catch(() => undefined);
|
||||||
|
owner.cdp.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function displayModuleId(owner) {
|
||||||
|
const navigation = await owner.api("GET", "/api/v1/dashboard/navigation");
|
||||||
|
const entries = [
|
||||||
|
...navigation.body.ungrouped,
|
||||||
|
...navigation.body.groups.flatMap((group) => group.modules),
|
||||||
|
];
|
||||||
|
const withDisplay = entries.filter((entry) => entry.module.display !== null);
|
||||||
|
const signage = withDisplay.find((entry) => entry.module.id === "signage");
|
||||||
|
return (signage ?? withDisplay[0])?.module.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Three slides of plain colour, on a five-second rotation, so a display has real content to persist
|
||||||
|
* and continue from, as the production screen did. Signage only. */
|
||||||
|
async function seedSignage(owner) {
|
||||||
|
const base = "/api/v1/modules/signage";
|
||||||
|
const images = [];
|
||||||
|
for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) {
|
||||||
|
const uploaded = await owner.evaluate(
|
||||||
|
`(async () => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 640;
|
||||||
|
canvas.height = 360;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.fillStyle = ${JSON.stringify(colour)};
|
||||||
|
context.fillRect(0, 0, 640, 360);
|
||||||
|
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||||
|
const response = await fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, {
|
||||||
|
body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST",
|
||||||
|
signal: AbortSignal.timeout(${requestTimeoutMs}),
|
||||||
|
});
|
||||||
|
return { status: response.status, body: await response.json().catch(() => null) };
|
||||||
|
})()`,
|
||||||
|
`uploading slide ${index}`,
|
||||||
|
);
|
||||||
|
images.push(uploaded.body?.image?.id);
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
images.every((id) => typeof id === "string"),
|
||||||
|
"three slides are uploaded to Signage",
|
||||||
|
);
|
||||||
|
const timeline = await owner.api("POST", `${base}/timelines`, { name: "Display repro" });
|
||||||
|
const timelineId = timeline.body?.timeline?.id ?? timeline.body?.id;
|
||||||
|
for (const itemId of images) {
|
||||||
|
await owner.api("POST", `${base}/timelines/${timelineId}/slots`, {
|
||||||
|
durationOverrideMs: 5_000,
|
||||||
|
itemId,
|
||||||
|
providerId: "signage.native-images",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const groups = await owner.api("GET", `${base}/channel-groups`);
|
||||||
|
const groupId = groups.body?.groups?.[0]?.id;
|
||||||
|
const activated = await owner.api("POST", `${base}/playback/${groupId}/activate`, {
|
||||||
|
timelineId,
|
||||||
|
});
|
||||||
|
check(activated.status === 200, "a three-slide timeline is playing on the default channel group");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signageViewers(owner) {
|
||||||
|
const listed = await owner.api("GET", "/api/v1/modules/signage/viewers");
|
||||||
|
return listed.body?.viewers ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function coreDevices(owner) {
|
||||||
|
const listed = await owner.api("GET", "/api/v1/devices");
|
||||||
|
return listed.body?.devices ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The display's own view of itself: which host state is showing, and Signage's offline flag. */
|
||||||
|
function displayState(page) {
|
||||||
|
return page.evaluate(
|
||||||
|
`(() => {
|
||||||
|
const stage = ${find("signage-stage")};
|
||||||
|
return {
|
||||||
|
pairing: Boolean(${find("display-pairing")}),
|
||||||
|
hostLoading: Boolean(${find("display-host-loading")}) || Boolean(${find("display-loading")}),
|
||||||
|
hostError: Boolean(${find("display-host-error")}) || Boolean(${find("display-error")}),
|
||||||
|
hostOffline: Boolean(${find("display-offline")}),
|
||||||
|
mounted: (${find("display-module-container")}?.childElementCount ?? 0) > 0,
|
||||||
|
signageOffline: stage ? stage.dataset.offline === "true" : null,
|
||||||
|
};
|
||||||
|
})()`,
|
||||||
|
"reading the display state",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blobCounts(page) {
|
||||||
|
return page.evaluate(
|
||||||
|
"window.__displayRepro ? window.__displayRepro.blobs : { created: 0, revoked: 0 }",
|
||||||
|
"reading object-URL counts",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectivityEvents(page) {
|
||||||
|
return page.evaluate(
|
||||||
|
"window.__displayRepro ? window.__displayRepro.events : []",
|
||||||
|
"reading connectivity events",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function describeDeviceCookie(cdp) {
|
||||||
|
const { cookies } = await cdp.send("Storage.getCookies");
|
||||||
|
const cookie = cookies.find((candidate) => candidate.name === deviceCookie);
|
||||||
|
if (!cookie) {
|
||||||
|
note(`${deviceCookie}: absent from the profile`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const days = cookie.session
|
||||||
|
? "session"
|
||||||
|
: `${((cookie.expires * 1000 - Date.now()) / 86_400_000).toFixed(1)} days`;
|
||||||
|
note(
|
||||||
|
`${deviceCookie}: domain=${cookie.domain} session=${cookie.session} lifetime=${days} httpOnly=${cookie.httpOnly} secure=${cookie.secure} sameSite=${cookie.sameSite}`,
|
||||||
|
);
|
||||||
|
return cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveExpression = `(() => {
|
||||||
|
const stage = ${find("signage-stage")};
|
||||||
|
const mounted = (${find("display-module-container")}?.childElementCount ?? 0) > 0;
|
||||||
|
return mounted && !${find("display-pairing")} && (stage === null || stage.dataset.offline === "false");
|
||||||
|
})()`;
|
||||||
|
|
||||||
|
function waitLive(page, description, ms = 45_000) {
|
||||||
|
return page.waitFor(liveExpression, description, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A page's network story, one line each, by path only. */
|
||||||
|
function summarize(page, from) {
|
||||||
|
const at = (ms) => (ms === undefined ? "-" : `${clock(ms)} (${Math.round((ms - from) / 1000)}s)`);
|
||||||
|
note(`${page.label}: viewer sessions opened ${page.sessionRequests.length} (= module mounts)`);
|
||||||
|
note(
|
||||||
|
`${page.label}: heartbeats ${[...page.heartbeats].map(([status, count]) => `${status}×${count}`).join(" ") || "none"}`,
|
||||||
|
);
|
||||||
|
const states = page.stateFetches.reduce((tally, fetch) => {
|
||||||
|
tally[fetch.outcome] = (tally[fetch.outcome] ?? 0) + 1;
|
||||||
|
return tally;
|
||||||
|
}, {});
|
||||||
|
note(`${page.label}: channel-state fetches ${JSON.stringify(states)}`);
|
||||||
|
for (const stream of page.streams) {
|
||||||
|
const lived =
|
||||||
|
stream.endedAt === undefined
|
||||||
|
? "open"
|
||||||
|
: `${Math.round((stream.endedAt - stream.openedAt) / 1000)}s`;
|
||||||
|
note(
|
||||||
|
`${page.label}: stream opened ${at(stream.openedAt)} status ${stream.status ?? "-"} ended ${at(stream.endedAt)} ${stream.how ?? ""} lived ${lived}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
note(`${page.label}: problems ${page.problems.slice(-12).join(" | ") || "none"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Samples display state on an interval for a fixed time, printing progress, and returns a tally. */
|
||||||
|
async function watch(pages, ms, { onSample, viewerOwner } = {}) {
|
||||||
|
const from = Date.now();
|
||||||
|
const tally = new Map(
|
||||||
|
pages.map((page) => [
|
||||||
|
page.label,
|
||||||
|
{ loading: 0, offline: 0, pairing: 0, samples: 0, transitions: [] },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const last = new Map();
|
||||||
|
let nextProgress = from + 15_000;
|
||||||
|
const roster = [];
|
||||||
|
while (Date.now() - from < ms) {
|
||||||
|
for (const page of pages) {
|
||||||
|
const state = await displayState(page);
|
||||||
|
const t = tally.get(page.label);
|
||||||
|
t.samples += 1;
|
||||||
|
if (state.signageOffline || state.hostOffline) t.offline += 1;
|
||||||
|
if (state.pairing) t.pairing += 1;
|
||||||
|
if (state.hostLoading) t.loading += 1;
|
||||||
|
const key = JSON.stringify(state);
|
||||||
|
if (last.get(page.label) !== key) {
|
||||||
|
last.set(page.label, key);
|
||||||
|
t.transitions.push(`${clock()} ${since(from)} ${key}`);
|
||||||
|
}
|
||||||
|
if (onSample && (await onSample(page, state))) {
|
||||||
|
return { from, roster, stopped: true, tally };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Date.now() >= nextProgress) {
|
||||||
|
nextProgress = Date.now() + 15_000;
|
||||||
|
if (viewerOwner) {
|
||||||
|
const viewers = await signageViewers(viewerOwner);
|
||||||
|
roster.push(
|
||||||
|
`${since(from)} ${viewers.map((v) => `${v.name}:${v.online ? "Online" : "Offline"}`).join(",")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const lines = [];
|
||||||
|
for (const page of pages) {
|
||||||
|
const t = tally.get(page.label);
|
||||||
|
const blobs = await blobCounts(page);
|
||||||
|
lines.push(
|
||||||
|
`${page.label}: ${t.samples} samples, offline ${t.offline}, mounts ${page.sessionRequests.length}, streams ${page.streams.length}, live object URLs ${blobs.created - blobs.revoked}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
progress(lines.join(" · "));
|
||||||
|
}
|
||||||
|
await sleep(2_000);
|
||||||
|
}
|
||||||
|
return { from, roster, stopped: false, tally };
|
||||||
|
}
|
||||||
|
|
||||||
|
function printTally(result) {
|
||||||
|
for (const [label, t] of result.tally) {
|
||||||
|
note(
|
||||||
|
`${label}: ${t.samples} samples, offline in ${t.offline}, pairing in ${t.pairing}, loading in ${t.loading}`,
|
||||||
|
);
|
||||||
|
for (const transition of t.transitions.slice(0, 30)) {
|
||||||
|
note(` ${label} ${transition}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.roster.length > 0) {
|
||||||
|
note(`roster over time: ${result.roster.join(" ; ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Steps ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (mode === "pair") {
|
||||||
|
const owner = await ownerPage();
|
||||||
|
const moduleId = await displayModuleId(owner.page);
|
||||||
|
check(moduleId !== undefined, `a module publishes a display (${moduleId})`);
|
||||||
|
if (moduleId === "signage") {
|
||||||
|
await seedSignage(owner.page);
|
||||||
|
}
|
||||||
|
const kiosk = await connect(kioskAddress, "kiosk");
|
||||||
|
const display = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "kiosk");
|
||||||
|
check(
|
||||||
|
await display.waitFor(find("display-pairing-code"), "a pairing code"),
|
||||||
|
"an unpaired kiosk shows a pairing code",
|
||||||
|
);
|
||||||
|
const code = (
|
||||||
|
await display.evaluate(`${find("display-pairing-code")}.textContent`, "reading the code")
|
||||||
|
).replace(/\D/gu, "");
|
||||||
|
const approved = await owner.page.api("POST", "/api/v1/device-pairing/approvals", {
|
||||||
|
code,
|
||||||
|
deviceName: "Repro kiosk",
|
||||||
|
});
|
||||||
|
check(approved.status === 200, "the owner approves the code");
|
||||||
|
check(
|
||||||
|
await waitLive(display, "the paired display to go live"),
|
||||||
|
"the paired kiosk mounts the display and is live",
|
||||||
|
);
|
||||||
|
await describeDeviceCookie(kiosk);
|
||||||
|
progress("letting heartbeats, a persisted snapshot, and media prefetch happen (20s)");
|
||||||
|
await sleep(20_000);
|
||||||
|
for (const viewer of await signageViewers(owner.page)) {
|
||||||
|
note(
|
||||||
|
`signage viewer "${viewer.name}" online=${viewer.online} offlineReady=${viewer.offlineReady} cachedAssets=${viewer.cachedAssetCount} error=${viewer.lastError?.code ?? "none"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
summarize(display, stepStartedAt);
|
||||||
|
// A clean exit: the browser shuts down normally, exactly as a systemd stop of a well-behaved kiosk.
|
||||||
|
await kiosk.send("Browser.close").catch(() => undefined);
|
||||||
|
kiosk.close();
|
||||||
|
await finishOwner(owner);
|
||||||
|
} else if (mode === "restart") {
|
||||||
|
const owner = await ownerPage();
|
||||||
|
const moduleId = await displayModuleId(owner.page);
|
||||||
|
const kiosk = await connect(kioskAddress, "kiosk");
|
||||||
|
const cookie = await describeDeviceCookie(kiosk);
|
||||||
|
check(cookie !== undefined, "the device credential survived a clean browser exit");
|
||||||
|
const display = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "kiosk");
|
||||||
|
const live = await waitLive(display, "the restarted display to go live");
|
||||||
|
const state = await displayState(display);
|
||||||
|
check(
|
||||||
|
live && !state.pairing,
|
||||||
|
`after a clean restart the kiosk is still paired and live (${JSON.stringify(state)})`,
|
||||||
|
);
|
||||||
|
summarize(display, stepStartedAt);
|
||||||
|
await display.close();
|
||||||
|
kiosk.close();
|
||||||
|
await finishOwner(owner);
|
||||||
|
} else if (mode === "two-pages") {
|
||||||
|
const owner = await ownerPage();
|
||||||
|
const moduleId = await displayModuleId(owner.page);
|
||||||
|
const kiosk = await connect(kioskAddress, "kiosk");
|
||||||
|
const url = `${apiOrigin}/display/${moduleId}`;
|
||||||
|
const first = await openPage(kiosk, url, "first");
|
||||||
|
check(await waitLive(first, "the first page"), "the first page is live");
|
||||||
|
const second = await openPage(kiosk, url, "second");
|
||||||
|
check(await waitLive(second, "the second page"), "the second page is live");
|
||||||
|
const result = await watch([first, second], watchMs, { viewerOwner: owner.page });
|
||||||
|
printTally(result);
|
||||||
|
for (const page of [first, second]) {
|
||||||
|
summarize(page, result.from);
|
||||||
|
note(`${page.label}: connectivity events ${JSON.stringify(await connectivityEvents(page))}`);
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
[...result.tally.values()].every((t) => t.offline === 0),
|
||||||
|
`neither page showed an offline state while both were open (${watchMs / 1000}s)`,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
[first, second].every((page) => page.sessionRequests.length === 1),
|
||||||
|
"neither page was remounted while both were open",
|
||||||
|
);
|
||||||
|
await first.close();
|
||||||
|
await second.close();
|
||||||
|
kiosk.close();
|
||||||
|
await finishOwner(owner);
|
||||||
|
} else if (mode === "connectivity-blip") {
|
||||||
|
// The browser's own offline/online events, as a Wi-Fi reassociation or a link flap produces. The
|
||||||
|
// display host re-resolves its descriptor on "online" (ADR 0043); this measures what that costs.
|
||||||
|
const owner = await ownerPage();
|
||||||
|
const moduleId = await displayModuleId(owner.page);
|
||||||
|
const kiosk = await connect(kioskAddress, "kiosk");
|
||||||
|
const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "blip");
|
||||||
|
check(await waitLive(page, "the display"), "the display is live");
|
||||||
|
const mountsBefore = page.sessionRequests.length;
|
||||||
|
await page.evaluate(
|
||||||
|
`dispatchEvent(new Event("offline")); setTimeout(() => dispatchEvent(new Event("online")), 1000)`,
|
||||||
|
"dispatching offline then online",
|
||||||
|
);
|
||||||
|
let firstOfflineAt;
|
||||||
|
let lastOfflineAt;
|
||||||
|
const result = await watch([page], 30_000, {
|
||||||
|
onSample: (_page, state) => {
|
||||||
|
if (state.signageOffline || state.hostOffline || state.hostLoading || !state.mounted) {
|
||||||
|
firstOfflineAt ??= Date.now();
|
||||||
|
lastOfflineAt = Date.now();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
printTally(result);
|
||||||
|
const remounts = page.sessionRequests.length - mountsBefore;
|
||||||
|
note(`remounts caused by one offline/online pair: ${remounts}`);
|
||||||
|
note(
|
||||||
|
firstOfflineAt === undefined
|
||||||
|
? "nothing visible changed"
|
||||||
|
: `visible disruption from ${clock(firstOfflineAt)} to ${clock(lastOfflineAt)} (sampled every 2s)`,
|
||||||
|
);
|
||||||
|
summarize(page, result.from);
|
||||||
|
check(remounts === 0, "a connectivity blip does not remount a live display");
|
||||||
|
check(
|
||||||
|
firstOfflineAt === undefined,
|
||||||
|
"a connectivity blip does not show an offline state on a live display",
|
||||||
|
);
|
||||||
|
await page.close();
|
||||||
|
kiosk.close();
|
||||||
|
await finishOwner(owner);
|
||||||
|
} else if (mode === "session-blip") {
|
||||||
|
// One transient failure of Signage's viewer-session request at start-up, as a kiosk booting
|
||||||
|
// before its network settles, or an API restart, would produce. Everything else reaches the
|
||||||
|
// server.
|
||||||
|
const owner = await ownerPage();
|
||||||
|
const moduleId = await displayModuleId(owner.page);
|
||||||
|
const kiosk = await connect(kioskAddress, "kiosk");
|
||||||
|
const page = await openPage(kiosk, undefined, "session-blip");
|
||||||
|
let blocked = 0;
|
||||||
|
kiosk.on((message) => {
|
||||||
|
if (message.method === "Fetch.requestPaused") {
|
||||||
|
const { requestId, request } = message.params;
|
||||||
|
if (blocked === 0 && pathOf(request.url).endsWith("/viewers/session")) {
|
||||||
|
blocked += 1;
|
||||||
|
page
|
||||||
|
.send("Fetch.failRequest", { errorReason: "ConnectionRefused", requestId })
|
||||||
|
.catch((error) => note(`interception reply failed: ${error.message}`));
|
||||||
|
} else {
|
||||||
|
page
|
||||||
|
.send("Fetch.continueRequest", { requestId })
|
||||||
|
.catch((error) => note(`interception reply failed: ${error.message}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.send("Fetch.enable", { patterns: [{ urlPattern: "*/api/v1/modules/*" }] });
|
||||||
|
await page.navigate(`${apiOrigin}/display/${moduleId}`);
|
||||||
|
await page.waitFor(
|
||||||
|
`(${find("display-module-container")}?.childElementCount ?? 0) > 0`,
|
||||||
|
"the module to mount",
|
||||||
|
);
|
||||||
|
note(`viewer-session requests failed on purpose: ${blocked}`);
|
||||||
|
const recovered = await page.waitFor(
|
||||||
|
`${find("signage-stage")}?.dataset.offline === "false"`,
|
||||||
|
"recovery from one failed viewer-session request",
|
||||||
|
90_000,
|
||||||
|
);
|
||||||
|
summarize(page, stepStartedAt);
|
||||||
|
check(
|
||||||
|
recovered,
|
||||||
|
`one failed viewer-session request does not leave the display offline (${JSON.stringify(await displayState(page))})`,
|
||||||
|
);
|
||||||
|
await page.close();
|
||||||
|
kiosk.close();
|
||||||
|
await finishOwner(owner);
|
||||||
|
} else if (mode === "stream-failure") {
|
||||||
|
// The first realtime connection is refused with a 503, as an API mid-restart or a proxy with no
|
||||||
|
// upstream answers. A non-200 ends an EventSource for good; the question is whether anything
|
||||||
|
// re-establishes realtime without a reload.
|
||||||
|
const owner = await ownerPage();
|
||||||
|
const moduleId = await displayModuleId(owner.page);
|
||||||
|
const kiosk = await connect(kioskAddress, "kiosk");
|
||||||
|
const page = await openPage(kiosk, undefined, "stream-failure");
|
||||||
|
let refused = 0;
|
||||||
|
kiosk.on((message) => {
|
||||||
|
if (message.method === "Fetch.requestPaused") {
|
||||||
|
const { requestId } = message.params;
|
||||||
|
if (refused === 0) {
|
||||||
|
refused += 1;
|
||||||
|
page
|
||||||
|
.send("Fetch.fulfillRequest", {
|
||||||
|
body: Buffer.from('{"code":"REALTIME_UNAVAILABLE"}').toString("base64"),
|
||||||
|
requestId,
|
||||||
|
responseCode: 503,
|
||||||
|
responseHeaders: [
|
||||||
|
{ name: "content-type", value: "application/json" },
|
||||||
|
{ name: "cache-control", value: "no-store" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.catch((error) => note(`interception reply failed: ${error.message}`));
|
||||||
|
} else {
|
||||||
|
page
|
||||||
|
.send("Fetch.continueRequest", { requestId })
|
||||||
|
.catch((error) => note(`interception reply failed: ${error.message}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.send("Fetch.enable", { patterns: [{ urlPattern: "*/api/v1/module-realtime/*" }] });
|
||||||
|
await page.navigate(`${apiOrigin}/display/${moduleId}`);
|
||||||
|
await page.waitFor(
|
||||||
|
`(${find("display-module-container")}?.childElementCount ?? 0) > 0`,
|
||||||
|
"the module to mount",
|
||||||
|
);
|
||||||
|
note(`realtime connections refused on purpose: ${refused}`);
|
||||||
|
const result = await watch([page], 90_000, {
|
||||||
|
onSample: () => page.streams.some((stream) => stream.status === 200),
|
||||||
|
});
|
||||||
|
printTally(result);
|
||||||
|
summarize(page, result.from);
|
||||||
|
check(
|
||||||
|
page.streams.some((stream) => stream.status === 200),
|
||||||
|
"after one refused realtime connection, realtime is re-established without a reload",
|
||||||
|
);
|
||||||
|
await page.close();
|
||||||
|
kiosk.close();
|
||||||
|
await finishOwner(owner);
|
||||||
|
} else if (mode === "caching-probe") {
|
||||||
|
// Whether a display on the API origin can store an image from the storage origin in Cache
|
||||||
|
// Storage, which is what Signage's offline media relies on. Reports only statuses, response
|
||||||
|
// types, and whether CORS headers were present — never a URL.
|
||||||
|
const owner = await ownerPage();
|
||||||
|
const moduleId = await displayModuleId(owner.page);
|
||||||
|
const groups = await owner.page.api("GET", "/api/v1/modules/signage/channel-groups");
|
||||||
|
const channelId = groups.body?.groups?.[0]?.channels?.[0]?.id;
|
||||||
|
const kiosk = await connect(kioskAddress, "kiosk");
|
||||||
|
const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "probe");
|
||||||
|
check(await waitLive(page, "the display"), "the display is live");
|
||||||
|
progress("letting the display prefetch its slides (15s)");
|
||||||
|
await sleep(15_000);
|
||||||
|
const probe = await page.evaluate(
|
||||||
|
`(async () => {
|
||||||
|
const result = {};
|
||||||
|
const state = await fetch(${JSON.stringify(`${apiOrigin}/api/v1/modules/signage/channels/${channelId}/display-state`)}, { credentials: "include", signal: AbortSignal.timeout(${requestTimeoutMs}) }).then((r) => r.json());
|
||||||
|
const url = (state.selections || []).map((s) => s.media && s.media.display && s.media.display.url).find(Boolean);
|
||||||
|
result.selections = (state.selections || []).length;
|
||||||
|
result.haveUrl = Boolean(url);
|
||||||
|
if (!url) return result;
|
||||||
|
result.sameOrigin = new URL(url).origin === location.origin;
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: AbortSignal.timeout(${requestTimeoutMs}) });
|
||||||
|
result.cors = { status: response.status, type: response.type, allowOrigin: response.headers.get("access-control-allow-origin") === null ? "absent" : (response.headers.get("access-control-allow-origin") === location.origin || response.headers.get("access-control-allow-origin") === "*" ? "matches" : "other") };
|
||||||
|
try {
|
||||||
|
const cache = await caches.open("display-repro-probe");
|
||||||
|
await cache.put("/display-repro-probe/cors", response.clone());
|
||||||
|
result.cors.cachePut = "stored";
|
||||||
|
} catch (error) { result.cors.cachePut = "refused: " + error.name; }
|
||||||
|
} catch (error) { result.cors = { error: error.name }; }
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { mode: "no-cors", signal: AbortSignal.timeout(${requestTimeoutMs}) });
|
||||||
|
result.noCors = { status: response.status, type: response.type, ok: response.ok };
|
||||||
|
try {
|
||||||
|
const cache = await caches.open("display-repro-probe");
|
||||||
|
await cache.put("/display-repro-probe/opaque", response.clone());
|
||||||
|
result.noCors.cachePut = "stored";
|
||||||
|
} catch (error) { result.noCors.cachePut = "refused: " + error.name; }
|
||||||
|
} catch (error) { result.noCors = { error: error.name }; }
|
||||||
|
result.imgPlain = await new Promise((resolve) => { const i = new Image(); i.onload = () => resolve("loaded"); i.onerror = () => resolve("error"); i.src = url; });
|
||||||
|
result.imgCors = await new Promise((resolve) => { const i = new Image(); i.crossOrigin = "anonymous"; i.onload = () => resolve("loaded"); i.onerror = () => resolve("error"); i.src = url + (url.includes("?") ? "&" : "?") + "cors-probe=1"; });
|
||||||
|
await caches.delete("display-repro-probe");
|
||||||
|
result.signageCachedEntries = await caches.open("ekklesia-signage-media-v1").then((c) => c.keys()).then((k) => k.length).catch(() => "unavailable");
|
||||||
|
return result;
|
||||||
|
})()`,
|
||||||
|
"probing cross-origin image caching",
|
||||||
|
);
|
||||||
|
note(`caching probe: ${JSON.stringify(probe)}`);
|
||||||
|
summarize(page, stepStartedAt);
|
||||||
|
check(probe.signageCachedEntries > 0, "Signage has stored slide images in Cache Storage");
|
||||||
|
await page.close();
|
||||||
|
kiosk.close();
|
||||||
|
await finishOwner(owner);
|
||||||
|
} else if (mode === "stream-recycle") {
|
||||||
|
// One page, past the five-minute realtime connection age, with every stream's open and end
|
||||||
|
// time in UTC so they can be laid against the proxy's own log.
|
||||||
|
const owner = await ownerPage();
|
||||||
|
const moduleId = await displayModuleId(owner.page);
|
||||||
|
const kiosk = await connect(kioskAddress, "kiosk");
|
||||||
|
const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "recycle");
|
||||||
|
check(await waitLive(page, "the display"), "the display is live");
|
||||||
|
const result = await watch([page], recycleWatchMs, { viewerOwner: owner.page });
|
||||||
|
printTally(result);
|
||||||
|
summarize(page, result.from);
|
||||||
|
note(`connectivity events ${JSON.stringify(await connectivityEvents(page))}`);
|
||||||
|
const blobs = await blobCounts(page);
|
||||||
|
const exhausted = page.stateFetches.filter((fetch) =>
|
||||||
|
fetch.outcome.includes("INSUFFICIENT_RESOURCES"),
|
||||||
|
);
|
||||||
|
note(
|
||||||
|
`object URLs: created ${blobs.created}, revoked ${blobs.revoked}, live ${blobs.created - blobs.revoked}; channel-state fetches refused for insufficient resources: ${exhausted.length}`,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
blobs.created - blobs.revoked <= MAX_LIVE_OBJECT_URLS,
|
||||||
|
`live object URLs stay bounded (at most ${MAX_LIVE_OBJECT_URLS}; ${blobs.created - blobs.revoked} live)`,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
result.tally.get("recycle").offline === 0,
|
||||||
|
`the display stayed live for the whole ${recycleWatchMs / 1000}s watch${recycleWatchMs > 300_000 ? ", across a realtime recycle" : ""}`,
|
||||||
|
);
|
||||||
|
check(page.sessionRequests.length === 1, "the display was never remounted during the watch");
|
||||||
|
await page.close();
|
||||||
|
kiosk.close();
|
||||||
|
await finishOwner(owner);
|
||||||
|
} else if (mode === "revoke") {
|
||||||
|
const owner = await ownerPage();
|
||||||
|
const moduleId = await displayModuleId(owner.page);
|
||||||
|
const kiosk = await connect(kioskAddress, "kiosk");
|
||||||
|
const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "revoked");
|
||||||
|
check(
|
||||||
|
await waitLive(page, "the display before revocation"),
|
||||||
|
"the display is live before revocation",
|
||||||
|
);
|
||||||
|
const devices = (await coreDevices(owner.page)).filter((device) => !device.revoked);
|
||||||
|
for (const device of devices) {
|
||||||
|
await owner.page.api("POST", `/api/v1/devices/${device.id}/revoke`);
|
||||||
|
}
|
||||||
|
note(`revoked ${devices.length} device(s) at ${clock()}; watching the open page`);
|
||||||
|
const result = await watch([page], watchMs, { onSample: (_page, state) => state.pairing });
|
||||||
|
printTally(result);
|
||||||
|
summarize(page, result.from);
|
||||||
|
check(
|
||||||
|
result.stopped,
|
||||||
|
`a revoked display returns to the pairing screen without a reload (within ${watchMs / 1000}s)`,
|
||||||
|
);
|
||||||
|
await page.close();
|
||||||
|
kiosk.close();
|
||||||
|
await finishOwner(owner);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"usage: deploy-smoke-display.mjs pair|restart|two-pages|connectivity-blip|session-blip|stream-failure|caching-probe|stream-recycle|revoke",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HangError) {
|
||||||
|
console.log(`FAIL ${mode}: ${error.message}; also in flight: ${describeInFlight()}`);
|
||||||
|
} else {
|
||||||
|
console.log(`FAIL ${mode}: ${error instanceof Error ? error.message : "an unexpected error"}`);
|
||||||
|
}
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures > 0) {
|
||||||
|
console.error(`${failures} check(s) failed`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.exit(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/ /
|
||||||
+238
-41
@@ -1,19 +1,36 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# End-to-end smoke test of the reference deployment (ADR 0044): deploy/compose.yaml with
|
# End-to-end smoke test of the reference deployment (ADR 0044, ADR 0048): deploy/compose.yaml with
|
||||||
# deploy/compose.proxy.yaml, a platform-only image, and a disposable PostgreSQL and SeaweedFS that
|
# deploy/compose.proxy.yaml, the one generic platform image, installation-owned module storage, and
|
||||||
# exist only for this run. CI runs it; it is safe to run on a workstation.
|
# a disposable PostgreSQL and SeaweedFS that exist only for this run. CI runs it; it is safe to run
|
||||||
|
# on a workstation.
|
||||||
#
|
#
|
||||||
# bash scripts/smoke-deploy.sh builds the image first
|
# bash scripts/smoke-deploy.sh builds the image first
|
||||||
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
|
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
|
||||||
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
|
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
|
||||||
#
|
#
|
||||||
# Either topology (ADR 0045). `same-origin` (the default) serves the Dashboard, the API, and displays
|
# Either topology (ADR 0045), and in both the same generic image, built once from deploy/Dockerfile.
|
||||||
# from one hostname with Caddyfile.example and a platform-only image. `split` serves the Dashboard on
|
# It contains no module; module storage starts empty, as on a fresh installation. `same-origin` (the
|
||||||
# its own hostname with Caddyfile.split.example, builds an image with the repository's module
|
# default) serves the Dashboard, the API, and displays from one hostname with Caddyfile.example.
|
||||||
# template installed as a test-only module (so a module UI, its API routes, and a display exist), and
|
# `split` serves the Dashboard on its own hostname with Caddyfile.split.example and then exercises
|
||||||
# then drives a real headless Chromium through the Dashboard, the module, the display, and sign-out.
|
# ADR 0048 end to end with that same image:
|
||||||
# With EKKLESIA_SMOKE_IMAGE, the given image is used as is in either topology; a split run then
|
#
|
||||||
# exercises whichever of its modules publishes UI and a display.
|
# - it boots with no modules;
|
||||||
|
# - the repository's module template, packaged as one self-contained directory
|
||||||
|
# (scripts/lib/test-module-package.Dockerfile, TEST-ONLY), is installed into module storage, its
|
||||||
|
# migrations are planned and explicitly applied, and the API is restarted;
|
||||||
|
# - a real headless Chromium drives the Dashboard, the module, its display, and sign-out;
|
||||||
|
# - the module is removed and the API restarted: Core still runs, migration planning reports the
|
||||||
|
# module's retained history, and its data survives;
|
||||||
|
# - the module is restored and the API restarted: it returns with its previous data.
|
||||||
|
#
|
||||||
|
# EKKLESIA_SMOKE_IMAGE uses the given image instead of building one. It must be a generic image
|
||||||
|
# (ADR 0048): modules come from module storage, never from the image.
|
||||||
|
#
|
||||||
|
# Opt-in, split topology only: EKKLESIA_SMOKE_DISPLAY_DEVICE=1 adds the display-device scenarios in
|
||||||
|
# scripts/lib/deploy-smoke-display.mjs (pairing a kiosk browser with a persistent profile, a clean
|
||||||
|
# restart, two pages, injected network failures, revocation), each with its own time budget.
|
||||||
|
# EKKLESIA_SMOKE_DISPLAY_SCENARIOS selects a subset; EKKLESIA_SMOKE_WATCH_MS and
|
||||||
|
# EKKLESIA_SMOKE_RECYCLE_WATCH_MS change how long they watch. Several scenarios expect Signage.
|
||||||
#
|
#
|
||||||
# TEST-ONLY: the proxy runs with Caddy's internal CA (`local_certs`) instead of ACME, because a test
|
# TEST-ONLY: the proxy runs with Caddy's internal CA (`local_certs`) instead of ACME, because a test
|
||||||
# run has no public hostname. Never use that option in an installation.
|
# run has no public hostname. Never use that option in an installation.
|
||||||
@@ -33,6 +50,8 @@ cd "$repository_root"
|
|||||||
|
|
||||||
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
||||||
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
||||||
|
# shellcheck source=scripts/lib/ci-project.sh
|
||||||
|
source "$repository_root/scripts/lib/ci-project.sh"
|
||||||
|
|
||||||
for command in docker openssl awk; do
|
for command in docker openssl awk; do
|
||||||
command -v "$command" >/dev/null || {
|
command -v "$command" >/dev/null || {
|
||||||
@@ -61,7 +80,10 @@ same-origin | split) ;;
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
run_id="$(openssl rand -hex 4)"
|
run_id="$(openssl rand -hex 4)"
|
||||||
project="ekklesia-deploy-smoke-${run_id}"
|
# Unique per invocation, and findable by run and job if this script is killed before its trap runs
|
||||||
|
# (scripts/lib/ci-project.sh, scripts/ci-sweep.sh). Everything started outside Compose below carries
|
||||||
|
# the same name in EKKLESIA_CI_LABEL.
|
||||||
|
project="$(ci_project_name "deploy-${topology}")"
|
||||||
work="$(mktemp -d)"
|
work="$(mktemp -d)"
|
||||||
image="${EKKLESIA_SMOKE_IMAGE:-}"
|
image="${EKKLESIA_SMOKE_IMAGE:-}"
|
||||||
built_image=""
|
built_image=""
|
||||||
@@ -102,6 +124,8 @@ choose_edge_network() {
|
|||||||
session_client_address="10.249.${octet}.252"
|
session_client_address="10.249.${octet}.252"
|
||||||
# The headless browser's fixed address, likewise (split topology only).
|
# The headless browser's fixed address, likewise (split topology only).
|
||||||
browser_address="10.249.${octet}.251"
|
browser_address="10.249.${octet}.251"
|
||||||
|
# The installed-module checks' own client address, so their sign-ins have their own bucket.
|
||||||
|
module_client_address="10.249.${octet}.249"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -141,6 +165,12 @@ cleanup() {
|
|||||||
if [[ -n "$browser_container" ]]; then
|
if [[ -n "$browser_container" ]]; then
|
||||||
docker rm --force "$browser_container" >/dev/null 2>&1 || true
|
docker rm --force "$browser_container" >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
|
if [[ -n "${kiosk_container:-}" ]]; then
|
||||||
|
docker ps --all --quiet --filter "name=${project}-display-" | xargs -r docker rm --force \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
docker rm --force "$kiosk_container" >/dev/null 2>&1 || true
|
||||||
|
docker volume rm --force "$kiosk_volume" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
if [[ -f "$work/compose.env" ]]; then
|
if [[ -f "$work/compose.env" ]]; then
|
||||||
"${compose[@]}" --profile ops down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || status=1
|
"${compose[@]}" --profile ops down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || status=1
|
||||||
fi
|
fi
|
||||||
@@ -149,7 +179,7 @@ cleanup() {
|
|||||||
# job itself runs as root), so only the staged copy on the Docker host needs this.
|
# job itself runs as root), so only the staged copy on the Docker host needs this.
|
||||||
if [[ "$daemon_work" == "$work" ]]; then
|
if [[ "$daemon_work" == "$work" ]]; then
|
||||||
docker run --rm --entrypoint rm -v "$work:/work" "$postgres_image" -rf /work/tls /work/env \
|
docker run --rm --entrypoint rm -v "$work:/work" "$postgres_image" -rf /work/tls /work/env \
|
||||||
>/dev/null 2>&1 || true
|
/work/modules >/dev/null 2>&1 || true
|
||||||
else
|
else
|
||||||
docker run --rm --entrypoint rm -v /tmp:/host-tmp "$postgres_image" -rf "/host-tmp/${project}" \
|
docker run --rm --entrypoint rm -v /tmp:/host-tmp "$postgres_image" -rf "/host-tmp/${project}" \
|
||||||
>/dev/null 2>&1 || true
|
>/dev/null 2>&1 || true
|
||||||
@@ -171,38 +201,38 @@ fail() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if [[ -z "$image" && "$topology" == same-origin ]]; then
|
if [[ -z "$image" ]]; then
|
||||||
step "Building the platform-only image"
|
# The one generic platform image, for both topologies. Nothing about either topology, and no
|
||||||
|
# module, is built into it.
|
||||||
|
step "Building the generic platform image"
|
||||||
built_image="ekklesia-deploy-smoke:${run_id}"
|
built_image="ekklesia-deploy-smoke:${run_id}"
|
||||||
DOCKER_BUILDKIT=1 docker build --quiet --file deploy/Dockerfile --tag "$built_image" . >/dev/null
|
DOCKER_BUILDKIT=1 docker build --quiet --file deploy/Dockerfile --tag "$built_image" \
|
||||||
image="$built_image"
|
--label "${EKKLESIA_CI_LABEL}=${project}" --label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
|
||||||
elif [[ -z "$image" ]]; then
|
. >/dev/null
|
||||||
# TEST-ONLY: the repository's module template, installed as the module `example`, in a copy of the
|
|
||||||
# working tree. Its lockfile importer moves with it; the depth is the same, so every relative link
|
|
||||||
# in that importer stays correct and the install stays frozen.
|
|
||||||
step "Building an image with the module template installed as a test-only module"
|
|
||||||
command -v git >/dev/null || fail "git is required to build the split topology's test image."
|
|
||||||
context="$work/build-context"
|
|
||||||
mkdir -p "$context"
|
|
||||||
git ls-files -z --cached --others --exclude-standard |
|
|
||||||
tar --null --files-from=- --create --file=- |
|
|
||||||
tar -C "$context" -xf -
|
|
||||||
mkdir -p "$context/modules"
|
|
||||||
mv "$context/templates/module" "$context/modules/example"
|
|
||||||
[[ "$(grep -c '^ templates/module:$' "$context/pnpm-lock.yaml")" == 1 ]] ||
|
|
||||||
fail "the lockfile has no single templates/module importer to move."
|
|
||||||
sed -i 's|^ templates/module:$| modules/example:|' "$context/pnpm-lock.yaml"
|
|
||||||
built_image="ekklesia-deploy-smoke:${run_id}"
|
|
||||||
DOCKER_BUILDKIT=1 docker build --quiet --file "$context/deploy/Dockerfile" --tag "$built_image" \
|
|
||||||
"$context" >/dev/null || fail "the test-module image did not build."
|
|
||||||
rm -rf "$context"
|
|
||||||
image="$built_image"
|
image="$built_image"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "$topology" == split ]]; then
|
||||||
|
# TEST-ONLY: the module template as one self-contained installed-module directory, built outside
|
||||||
|
# the platform image and installed into module storage below. Never a second platform image.
|
||||||
|
step "Packaging the module template as a self-contained test module"
|
||||||
|
DOCKER_BUILDKIT=1 docker build --quiet --file scripts/lib/test-module-package.Dockerfile \
|
||||||
|
--label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||||
|
--output "type=local,dest=${work}/module-package" . >/dev/null ||
|
||||||
|
fail "the test module package did not build."
|
||||||
|
[[ -f "$work/module-package/example/ekklesia.module.json" ]] ||
|
||||||
|
fail "the test module package has no manifest."
|
||||||
|
fi
|
||||||
|
|
||||||
# The compose project directory: its relative bind mount (postgres/pg_hba.conf) resolves here.
|
# The compose project directory: its relative bind mount (postgres/pg_hba.conf) resolves here.
|
||||||
cp -R deploy "$work/deploy"
|
cp -R deploy "$work/deploy"
|
||||||
stage deploy
|
stage deploy
|
||||||
|
|
||||||
|
# Installation-owned module storage (ADR 0048), empty, as an operator creates it before the first
|
||||||
|
# `up`: Compose refuses to start `api` or `migrate` without it, and never creates it.
|
||||||
|
mkdir -m 755 "$work/modules"
|
||||||
|
stage modules
|
||||||
|
|
||||||
step "Creating the installation-private CA and server certificates"
|
step "Creating the installation-private CA and server certificates"
|
||||||
init_status=0
|
init_status=0
|
||||||
sh deploy/scripts/init-tls.sh "$work/tls" >/dev/null 2>&1 || init_status=$?
|
sh deploy/scripts/init-tls.sh "$work/tls" >/dev/null 2>&1 || init_status=$?
|
||||||
@@ -289,6 +319,7 @@ EKKLESIA_EDGE_SUBNET=${edge_subnet}
|
|||||||
EKKLESIA_EDGE_DYNAMIC_RANGE=${edge_range}
|
EKKLESIA_EDGE_DYNAMIC_RANGE=${edge_range}
|
||||||
EKKLESIA_PROXY_ADDRESS=${proxy_address}
|
EKKLESIA_PROXY_ADDRESS=${proxy_address}
|
||||||
EKKLESIA_TUNNEL_ADDRESS=${tunnel_address}
|
EKKLESIA_TUNNEL_ADDRESS=${tunnel_address}
|
||||||
|
EKKLESIA_MODULES_HOST_DIR=${daemon_work}/modules
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
step "Starting PostgreSQL and object storage"
|
step "Starting PostgreSQL and object storage"
|
||||||
@@ -350,6 +381,7 @@ client() {
|
|||||||
local mode="$1"
|
local mode="$1"
|
||||||
shift
|
shift
|
||||||
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||||
|
--label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||||
--network "$edge_network" "$@" "${host_mappings[@]}" \
|
--network "$edge_network" "$@" "${host_mappings[@]}" \
|
||||||
-e NODE_EXTRA_CA_CERTS=/smoke/client-ca.pem -e SMOKE_HOSTNAME="$hostname" \
|
-e NODE_EXTRA_CA_CERTS=/smoke/client-ca.pem -e SMOKE_HOSTNAME="$hostname" \
|
||||||
-e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
|
-e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
|
||||||
@@ -377,7 +409,70 @@ client session --ip "$session_client_address" \
|
|||||||
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" ||
|
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" ||
|
||||||
fail "session checks failed."
|
fail "session checks failed."
|
||||||
|
|
||||||
|
psql_as_superuser() {
|
||||||
|
"${compose[@]}" exec -T postgres psql -U ekklesia -d ekklesia -Atc "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# module_storage <script>: runs a root shell over this run's module storage, as an operator changing
|
||||||
|
# installed modules on the host would. The API never writes it; it is mounted read-only.
|
||||||
|
module_storage() {
|
||||||
|
docker run --rm -i --network none --entrypoint sh -v "$daemon_work/modules:/modules" \
|
||||||
|
"$postgres_image" -ec "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# install_test_module: stages the package under a hidden name, which discovery ignores, then renames
|
||||||
|
# it into place in one step, owned by root and readable by the API's unprivileged user.
|
||||||
|
install_test_module() {
|
||||||
|
tar -C "$work/module-package" -cf - example |
|
||||||
|
module_storage 'rm -rf /modules/.incoming && mkdir /modules/.incoming &&
|
||||||
|
tar -xf - -C /modules/.incoming && chown -R 0:0 /modules/.incoming/example &&
|
||||||
|
chmod -R a+rX,go-w /modules/.incoming/example &&
|
||||||
|
mv /modules/.incoming/example /modules/example && rmdir /modules/.incoming' ||
|
||||||
|
fail "the test module could not be installed into module storage."
|
||||||
|
}
|
||||||
|
|
||||||
|
# restart_api <command...>: stops the API, runs the command, and starts the API again. A controlled
|
||||||
|
# restart is the only way a module change is picked up; nothing is ever hot loaded.
|
||||||
|
restart_api() {
|
||||||
|
"${compose[@]}" stop api >/dev/null 2>&1 || fail "the API did not stop."
|
||||||
|
"$@"
|
||||||
|
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 180 api 2>&1)"; then
|
||||||
|
compose_report_not_ready "$up_output" "${compose[@]}"
|
||||||
|
fail "the API did not become healthy after a restart."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# module_check <expectation>: "absent", or the exact JSON array of values `example` must return.
|
||||||
|
module_check() {
|
||||||
|
client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \
|
||||||
|
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password"
|
||||||
|
}
|
||||||
|
|
||||||
|
# plan_output: the read-only migration plan, as `docker compose run --rm migrate` prints it.
|
||||||
|
plan_output() {
|
||||||
|
"${compose[@]}" run --rm --no-deps migrate 2>&1 || fail "migration planning failed."
|
||||||
|
}
|
||||||
|
|
||||||
if [[ "$topology" == split ]]; then
|
if [[ "$topology" == split ]]; then
|
||||||
|
step "Booting the generic image with empty module storage"
|
||||||
|
module_check absent || fail "the image without modules did not run module-free."
|
||||||
|
|
||||||
|
step "Installing the test module into module storage, migrating explicitly, and restarting the API"
|
||||||
|
install_module_and_migrate() {
|
||||||
|
install_test_module
|
||||||
|
local plan
|
||||||
|
plan="$(plan_output)"
|
||||||
|
grep -q '^- \[pending\] example:0000_initial ' <<<"$plan" ||
|
||||||
|
fail "the plan does not list the installed module's migration as pending."
|
||||||
|
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
|
||||||
|
fail "applying the installed module's migration failed."
|
||||||
|
}
|
||||||
|
restart_api install_module_and_migrate
|
||||||
|
module_check '[]' || fail "the installed module did not run from module storage."
|
||||||
|
psql_as_superuser "INSERT INTO example_records (id, value) VALUES (gen_random_uuid(), 'kept')" \
|
||||||
|
>/dev/null || fail "could not write the module's own data."
|
||||||
|
module_check '["Hello, kept"]' || fail "the installed module did not read its own data."
|
||||||
|
|
||||||
step "Driving a real browser through the split Dashboard, a module, its display, and sign-out"
|
step "Driving a real browser through the split Dashboard, a module, its display, and sign-out"
|
||||||
# Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA,
|
# Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA,
|
||||||
# which signs every certificate the test proxy serves: no CA is installed anywhere, and nothing but
|
# which signs every certificate the test proxy serves: no CA is installed anywhere, and nothing but
|
||||||
@@ -389,7 +484,7 @@ if [[ "$topology" == split ]]; then
|
|||||||
browser_container="${project}-browser"
|
browser_container="${project}-browser"
|
||||||
# The image's entry script passes its arguments on unquoted, so none may contain a space: the
|
# The image's entry script passes its arguments on unquoted, so none may contain a space: the
|
||||||
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
|
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
|
||||||
docker run --detach --rm --name "$browser_container" \
|
docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||||
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
|
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
|
||||||
--network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \
|
--network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \
|
||||||
"$browser_image" \
|
"$browser_image" \
|
||||||
@@ -398,12 +493,113 @@ if [[ "$topology" == split ]]; then
|
|||||||
cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs"
|
cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs"
|
||||||
stage browser.mjs
|
stage browser.mjs
|
||||||
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||||
|
--label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||||
--network "$edge_network" \
|
--network "$edge_network" \
|
||||||
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
|
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
|
||||||
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
|
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
|
||||||
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
|
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
|
||||||
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
|
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
|
||||||
--entrypoint node "$image" /smoke/browser.mjs || fail "browser checks failed."
|
--entrypoint node "$image" /smoke/browser.mjs || fail "browser checks failed."
|
||||||
|
|
||||||
|
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
|
||||||
|
# A kiosk: a second browser whose profile directory is a volume, so it outlives the container
|
||||||
|
# exactly as a kiosk's --user-data-dir outlives a browser restart.
|
||||||
|
kiosk_container="${project}-kiosk"
|
||||||
|
kiosk_volume="${project}-kiosk-profile"
|
||||||
|
kiosk_address="10.249.${octet}.250"
|
||||||
|
docker volume create --label "${EKKLESIA_CI_LABEL}=${project}" "$kiosk_volume" >/dev/null
|
||||||
|
start_kiosk() {
|
||||||
|
docker run --detach --rm --name "$kiosk_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||||
|
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
|
||||||
|
--network "$edge_network" --ip "$kiosk_address" "${host_mappings[@]}" \
|
||||||
|
-v "${kiosk_volume}:/profile" \
|
||||||
|
"$browser_image" \
|
||||||
|
"--ignore-certificate-errors-spki-list=${spki}" \
|
||||||
|
--user-data-dir=/profile >/dev/null || fail "the kiosk browser did not start."
|
||||||
|
}
|
||||||
|
wait_kiosk_exit() {
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
docker inspect "$kiosk_container" >/dev/null 2>&1 || return 0
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
fail "the kiosk browser did not exit after a clean close."
|
||||||
|
}
|
||||||
|
cp scripts/lib/deploy-smoke-display.mjs "$work/display.mjs"
|
||||||
|
stage display.mjs
|
||||||
|
# display_driver <budget seconds> <step>: one scenario with a hard budget. The driver enforces it
|
||||||
|
# itself and names whatever call was still waiting; `timeout` is the backstop if it cannot.
|
||||||
|
display_driver() {
|
||||||
|
local budget="$1" step_name="$2" driver="${project}-display-${2}"
|
||||||
|
timeout --signal=TERM "$((budget + 30))" \
|
||||||
|
docker run --rm --name "$driver" --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||||
|
--network "$edge_network" \
|
||||||
|
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
|
||||||
|
-e SMOKE_KIOSK_CDP_ADDRESS="${kiosk_address}:9222" \
|
||||||
|
-e SMOKE_STEP_BUDGET_MS="$((budget * 1000))" \
|
||||||
|
-e SMOKE_WATCH_MS="${EKKLESIA_SMOKE_WATCH_MS:-120000}" \
|
||||||
|
-e SMOKE_RECYCLE_WATCH_MS="${EKKLESIA_SMOKE_RECYCLE_WATCH_MS:-330000}" \
|
||||||
|
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
|
||||||
|
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
|
||||||
|
-v "$daemon_work/display.mjs:/smoke/display.mjs:ro" \
|
||||||
|
--entrypoint node "$image" /smoke/display.mjs "$step_name"
|
||||||
|
local status=$?
|
||||||
|
if ((status == 124)); then
|
||||||
|
echo "FAIL ${step_name}: the driver did not stop within its budget plus 30s; removed it"
|
||||||
|
docker rm --force "$driver" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
return "$status"
|
||||||
|
}
|
||||||
|
display_status=0
|
||||||
|
step "Pairing a kiosk display, then closing its browser cleanly"
|
||||||
|
start_kiosk
|
||||||
|
display_driver 180 pair || display_status=1
|
||||||
|
wait_kiosk_exit
|
||||||
|
step "Restarting the kiosk browser on the same profile"
|
||||||
|
start_kiosk
|
||||||
|
display_driver 120 restart || display_status=1
|
||||||
|
for scenario in ${EKKLESIA_SMOKE_DISPLAY_SCENARIOS:-two-pages connectivity-blip session-blip stream-failure caching-probe stream-recycle revoke}; do
|
||||||
|
case "$scenario" in
|
||||||
|
two-pages) budget=240 ;;
|
||||||
|
stream-recycle) budget=420 ;;
|
||||||
|
revoke) budget=210 ;;
|
||||||
|
*) budget=150 ;;
|
||||||
|
esac
|
||||||
|
step "Display scenario: ${scenario} (budget ${budget}s)"
|
||||||
|
display_driver "$budget" "$scenario" || display_status=1
|
||||||
|
done
|
||||||
|
step "Proxy: streams aborted with an incomplete response (UTC time, path only)"
|
||||||
|
# Captured, never printed: only each line's time and path (no query, so no signature) leave it.
|
||||||
|
proxy_logs="$("${compose[@]}" logs --no-color proxy 2>/dev/null)" || proxy_logs=""
|
||||||
|
grep -o '{.*"aborting with incomplete response".*}' <<<"$proxy_logs" |
|
||||||
|
sed -E 's/.*"ts":([0-9]+)[^,]*,.*"uri":"([^"?]*)[^"]*".*/\1 \2/' |
|
||||||
|
while read -r ts uri; do echo " $(date -u -d "@${ts}" +%T) ${uri}"; done || true
|
||||||
|
((display_status == 0)) || fail "display-device checks failed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Removing the module from module storage and restarting: Core runs, its data stays"
|
||||||
|
restart_api module_storage 'rm -rf /modules/example'
|
||||||
|
module_check absent || fail "the removed module is still running."
|
||||||
|
plan="$(plan_output)"
|
||||||
|
grep -q '^- \[retained\] example: 1 applied migration kept; the module is not installed$' \
|
||||||
|
<<<"$plan" || fail "migration planning did not report the removed module's retained history."
|
||||||
|
grep -q '^Migration plan (.*0 pending, 0 approval-blocked, 1 retained):$' <<<"$plan" ||
|
||||||
|
fail "migration planning did not succeed cleanly with the module absent."
|
||||||
|
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
|
||||||
|
fail "applying migrations failed with the module absent."
|
||||||
|
[[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] ||
|
||||||
|
fail "the removed module's data did not survive."
|
||||||
|
echo "ok the module is gone, Core runs, its history is retained, and its data survives"
|
||||||
|
|
||||||
|
step "Restoring the module and restarting: it returns with its previous data"
|
||||||
|
restart_api install_test_module
|
||||||
|
plan="$(plan_output)"
|
||||||
|
grep -q '^- \[applied\] example:0000_initial ' <<<"$plan" ||
|
||||||
|
fail "the restored module's migration history is not valid."
|
||||||
|
if grep -q '\[retained\]\|\[pending\]' <<<"$plan"; then
|
||||||
|
fail "the restored module left retained or pending migrations."
|
||||||
|
fi
|
||||||
|
module_check '["Hello, kept"]' || fail "the restored module did not return with its data."
|
||||||
|
echo "ok the restored module runs again, with its history strict and its data intact"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
step "Proving the API's database connection is certificate-verified TLS"
|
step "Proving the API's database connection is certificate-verified TLS"
|
||||||
@@ -413,9 +609,6 @@ api_data_address="$(
|
|||||||
"$api_container"
|
"$api_container"
|
||||||
)"
|
)"
|
||||||
[[ -n "$api_data_address" ]] || fail "could not find the API's database-network address."
|
[[ -n "$api_data_address" ]] || fail "could not find the API's database-network address."
|
||||||
psql_as_superuser() {
|
|
||||||
"${compose[@]}" exec -T postgres psql -U ekklesia -d ekklesia -Atc "$1"
|
|
||||||
}
|
|
||||||
tls_connections="$(psql_as_superuser "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr = '${api_data_address}' AND s.ssl AND s.version = 'TLSv1.3'")"
|
tls_connections="$(psql_as_superuser "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr = '${api_data_address}' AND s.ssl AND s.version = 'TLSv1.3'")"
|
||||||
plain_connections="$(psql_as_superuser "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr = '${api_data_address}' AND NOT s.ssl")"
|
plain_connections="$(psql_as_superuser "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr = '${api_data_address}' AND NOT s.ssl")"
|
||||||
((tls_connections > 0)) || fail "the API holds no TLS 1.3 database connection."
|
((tls_connections > 0)) || fail "the API holds no TLS 1.3 database connection."
|
||||||
@@ -468,12 +661,16 @@ grep -q 'TLS required with certificate and hostname verification (explicit priva
|
|||||||
<<<"$doctor_output" || fail "Doctor did not report verify-full with the private CA."
|
<<<"$doctor_output" || fail "Doctor did not report verify-full with the private CA."
|
||||||
grep -q '✓ Object storage reachable' <<<"$doctor_output" ||
|
grep -q '✓ Object storage reachable' <<<"$doctor_output" ||
|
||||||
fail "Doctor could not reach object storage over HTTPS."
|
fail "Doctor could not reach object storage over HTTPS."
|
||||||
|
# Doctor runs as `migrate`; it must check the client-address configuration the API runs with, which
|
||||||
|
# behind the proxy is trusted-proxy mode, not the direct mode an unconfigured process reports.
|
||||||
|
grep -q 'Trusted-proxy client-address mode' <<<"$doctor_output" ||
|
||||||
|
fail "Doctor did not check the API's trusted-proxy client-address configuration."
|
||||||
for value in "$postgres_password" "$s3_secret_key" "$identity_secret"; do
|
for value in "$postgres_password" "$s3_secret_key" "$identity_secret"; do
|
||||||
if grep -qF -- "$value" <<<"$doctor_output"; then
|
if grep -qF -- "$value" <<<"$doctor_output"; then
|
||||||
fail "Doctor output contains a secret."
|
fail "Doctor output contains a secret."
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
echo "ok Doctor: READY, verify-full with the private CA, storage reachable, no secret printed"
|
echo "ok Doctor: READY, verify-full with the private CA, storage reachable, the API's client-address mode, no secret printed"
|
||||||
|
|
||||||
api_logs="$("${compose[@]}" logs --no-color api 2>&1)"
|
api_logs="$("${compose[@]}" logs --no-color api 2>&1)"
|
||||||
for value in "$postgres_password" "$s3_secret_key" "$identity_secret" "$owner_password"; do
|
for value in "$postgres_password" "$s3_secret_key" "$identity_secret" "$owner_password"; do
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ cd "$repository_root"
|
|||||||
source "$repository_root/scripts/lib/compose-job-network.sh"
|
source "$repository_root/scripts/lib/compose-job-network.sh"
|
||||||
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
||||||
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
||||||
|
# shellcheck source=scripts/lib/ci-project.sh
|
||||||
|
source "$repository_root/scripts/lib/ci-project.sh"
|
||||||
|
|
||||||
command -v docker >/dev/null || {
|
command -v docker >/dev/null || {
|
||||||
echo "Docker is required for the infrastructure smoke test." >&2
|
echo "Docker is required for the infrastructure smoke test." >&2
|
||||||
@@ -30,11 +32,9 @@ if [[ "$curl_help" != *"--aws-sigv4"* ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Unique per invocation, not only per run: several runs can share a runner's Docker daemon at once,
|
# Unique per invocation, and findable by run and job if this script is killed before its trap runs
|
||||||
# and the shell's PID is often identical across job containers.
|
# (scripts/lib/ci-project.sh, scripts/ci-sweep.sh).
|
||||||
raw_project="ekklesia-smoke-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-$$-${RANDOM}${RANDOM}"
|
project="$(ci_project_name infrastructure)"
|
||||||
project="${raw_project//[^a-zA-Z0-9_-]/-}"
|
|
||||||
project="${project,,}"
|
|
||||||
temporary_directory="$(mktemp -d)"
|
temporary_directory="$(mktemp -d)"
|
||||||
payload_file="$temporary_directory/payload.txt"
|
payload_file="$temporary_directory/payload.txt"
|
||||||
download_file="$temporary_directory/download.txt"
|
download_file="$temporary_directory/download.txt"
|
||||||
|
|||||||
Executable
+129
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Proves scripts/ci-sweep.sh against real Docker resources: what it removes, and above all what it
|
||||||
|
# leaves alone. CI runs it before trusting the sweep with a shared runner's daemon.
|
||||||
|
#
|
||||||
|
# bash scripts/test-ci-sweep.sh
|
||||||
|
#
|
||||||
|
# Every resource it creates is named and labeled for this run alone, under fake run identifiers, so
|
||||||
|
# it can never touch a real stack; and it removes all of them on exit, whatever happens.
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$repository_root"
|
||||||
|
|
||||||
|
image="$(sed -n 's/^[[:space:]]*image: \(postgres:[^[:space:]]*\)$/\1/p' deploy/compose.yaml | head -1)"
|
||||||
|
[[ -n "$image" ]] || {
|
||||||
|
echo "test-ci-sweep: no pinned PostgreSQL image in deploy/compose.yaml." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
docker image inspect "$image" >/dev/null 2>&1 || docker pull --quiet "$image" >/dev/null
|
||||||
|
|
||||||
|
nonce="$(openssl rand -hex 4)"
|
||||||
|
compose_label="com.docker.compose.project"
|
||||||
|
ci_label="org.riverofglorymn.ekklesia.ci.project"
|
||||||
|
failures=0
|
||||||
|
created=()
|
||||||
|
|
||||||
|
check() {
|
||||||
|
if eval "$1"; then
|
||||||
|
echo "ok $2"
|
||||||
|
else
|
||||||
|
echo "FAIL $2"
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# project_exists <project>: any container, network, volume, or image still carries its label.
|
||||||
|
project_exists() {
|
||||||
|
local project="$1"
|
||||||
|
[[ -n "$(
|
||||||
|
docker ps --all --quiet --filter "label=${compose_label}=${project}"
|
||||||
|
docker ps --all --quiet --filter "label=${ci_label}=${project}"
|
||||||
|
docker network ls --quiet --filter "label=${compose_label}=${project}"
|
||||||
|
docker volume ls --quiet --filter "label=${compose_label}=${project}"
|
||||||
|
docker image ls --quiet --filter "label=${ci_label}=${project}"
|
||||||
|
)" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
staging_exists() {
|
||||||
|
docker run --rm --entrypoint test -v /tmp:/host-tmp "$image" -d "/host-tmp/$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# make_stack <project>: what a CI script leaves behind when it is killed: a Compose-labeled network,
|
||||||
|
# volume, and running container; a CI-labeled container and image; and a staging directory on the
|
||||||
|
# Docker host.
|
||||||
|
make_stack() {
|
||||||
|
local project="$1"
|
||||||
|
created+=("$project")
|
||||||
|
docker network create --label "${compose_label}=${project}" "${project}_default" >/dev/null
|
||||||
|
docker volume create --label "${compose_label}=${project}" "${project}_data" >/dev/null
|
||||||
|
docker run --detach --name "${project}-db" --label "${compose_label}=${project}" \
|
||||||
|
--network "${project}_default" -v "${project}_data:/data" --entrypoint sleep "$image" 600 >/dev/null
|
||||||
|
docker run --detach --name "${project}-browser" --label "${ci_label}=${project}" \
|
||||||
|
--entrypoint sleep "$image" 600 >/dev/null
|
||||||
|
printf 'FROM %s\nLABEL %s=%s %s=%s\n' "$image" "$ci_label" "$project" \
|
||||||
|
"org.riverofglorymn.ekklesia.ci.created" "$(date +%s)" |
|
||||||
|
docker build --quiet --tag "${project}-image:test" - >/dev/null
|
||||||
|
docker run --rm --entrypoint mkdir -v /tmp:/host-tmp "$image" -p "/host-tmp/${project}"
|
||||||
|
}
|
||||||
|
|
||||||
|
job_container="test-ci-sweep-job-${nonce}"
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
docker rm --force "$job_container" >/dev/null 2>&1 || true
|
||||||
|
for project in "${created[@]}"; do
|
||||||
|
docker ps --all --quiet --filter "label=${compose_label}=${project}" | xargs -r docker rm --force >/dev/null 2>&1 || true
|
||||||
|
docker ps --all --quiet --filter "label=${ci_label}=${project}" | xargs -r docker rm --force >/dev/null 2>&1 || true
|
||||||
|
docker network rm "${project}_default" >/dev/null 2>&1 || true
|
||||||
|
docker volume rm --force "${project}_data" >/dev/null 2>&1 || true
|
||||||
|
docker image rm --force "${project}-image:test" >/dev/null 2>&1 || true
|
||||||
|
docker run --rm --entrypoint rm -v /tmp:/host-tmp "$image" -rf "/host-tmp/${project}" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
# shellcheck source=scripts/lib/ci-project.sh
|
||||||
|
source "$repository_root/scripts/lib/ci-project.sh"
|
||||||
|
|
||||||
|
# Fake run identifiers: nothing here can match a real run's stacks.
|
||||||
|
export GITHUB_RUN_ID="sweeptest${nonce}"
|
||||||
|
export GITHUB_RUN_ATTEMPT="1"
|
||||||
|
mine="$(EKKLESIA_CI_JOB_KEY=alpha ci_project_name mine)"
|
||||||
|
sibling="$(EKKLESIA_CI_JOB_KEY=beta ci_project_name sibling)"
|
||||||
|
foreign="ekklesia-sweep-control-${nonce}"
|
||||||
|
for project in "$mine" "$sibling" "$foreign"; do
|
||||||
|
make_stack "$project"
|
||||||
|
done
|
||||||
|
# A job container connected to this job's stack network, exactly as compose-job-network.sh does.
|
||||||
|
docker run --detach --name "$job_container" --entrypoint sleep "$image" 600 >/dev/null
|
||||||
|
docker network connect "${mine}_default" "$job_container"
|
||||||
|
|
||||||
|
echo "--- --job removes this job's stacks, at any age, and nothing else"
|
||||||
|
EKKLESIA_CI_JOB_KEY=alpha bash scripts/ci-sweep.sh --job
|
||||||
|
check '! project_exists "$mine"' "this job's containers, network, volume, and image are gone"
|
||||||
|
check '! staging_exists "$mine"' "this job's staging directory on the Docker host is gone"
|
||||||
|
check 'project_exists "$sibling"' "another job's stack in the same run is left alone"
|
||||||
|
check 'project_exists "$foreign"' "a stack outside the CI prefix is left alone"
|
||||||
|
check 'docker inspect "$job_container" >/dev/null 2>&1' \
|
||||||
|
"a job container connected to the stack's network is disconnected, never removed"
|
||||||
|
|
||||||
|
echo "--- --job refuses to run outside CI, where it could remove a developer's own stacks"
|
||||||
|
check '! env -u GITHUB_RUN_ID bash scripts/ci-sweep.sh --job >/dev/null 2>&1' \
|
||||||
|
"--job without GITHUB_RUN_ID fails"
|
||||||
|
|
||||||
|
echo "--- --stale removes only CI stacks older than its threshold"
|
||||||
|
bash scripts/ci-sweep.sh --stale
|
||||||
|
check 'project_exists "$sibling"' "a young CI stack survives the default threshold"
|
||||||
|
bash scripts/ci-sweep.sh --stale --max-age-seconds 0
|
||||||
|
check '! project_exists "$sibling"' "a CI stack older than the threshold is removed"
|
||||||
|
check '! staging_exists "$sibling"' "its staging directory too"
|
||||||
|
check 'project_exists "$foreign"' "a stack outside the CI prefix is never removed, at any age"
|
||||||
|
|
||||||
|
if ((failures > 0)); then
|
||||||
|
echo "test-ci-sweep: ${failures} check(s) failed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "CI sweep checks passed."
|
||||||
@@ -9,6 +9,8 @@ cd "$repository_root"
|
|||||||
source "$repository_root/scripts/lib/compose-job-network.sh"
|
source "$repository_root/scripts/lib/compose-job-network.sh"
|
||||||
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
||||||
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
||||||
|
# shellcheck source=scripts/lib/ci-project.sh
|
||||||
|
source "$repository_root/scripts/lib/ci-project.sh"
|
||||||
|
|
||||||
command -v docker >/dev/null || {
|
command -v docker >/dev/null || {
|
||||||
echo "Docker is required for the PostgreSQL migration integration suite." >&2
|
echo "Docker is required for the PostgreSQL migration integration suite." >&2
|
||||||
@@ -27,11 +29,9 @@ if ! compose_job_network_initialize; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Unique per invocation, not only per run: several runs (and several jobs of one run) can share a
|
# Unique per invocation, and findable by run and job if this script is killed before its trap runs
|
||||||
# runner's Docker daemon at once, and the shell's PID is often identical across job containers.
|
# (scripts/lib/ci-project.sh, scripts/ci-sweep.sh).
|
||||||
raw_project="ekklesia-migration-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-job}-$$-${RANDOM}${RANDOM}"
|
project="$(ci_project_name migration)"
|
||||||
project="${raw_project//[^a-zA-Z0-9_-]/-}"
|
|
||||||
project="${project,,}"
|
|
||||||
|
|
||||||
export POSTGRES_DB="ekklesia_migration_test"
|
export POSTGRES_DB="ekklesia_migration_test"
|
||||||
export POSTGRES_USER="ekklesia_migration_test"
|
export POSTGRES_USER="ekklesia_migration_test"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/shell",
|
"name": "@ekklesia/shell",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ To create a module inside this repository:
|
|||||||
root. The declared production entrypoint is the compiled `dist/server/index.js`, not source
|
root. The declared production entrypoint is the compiled `dist/server/index.js`, not source
|
||||||
TypeScript; the schema entrypoint is likewise compiled to `dist/schema/index.js`.
|
TypeScript; the schema entrypoint is likewise compiled to `dist/schema/index.js`.
|
||||||
|
|
||||||
|
That `modules/<module-id>` directory is where a module is **developed**. An installation never runs
|
||||||
|
it from there: Ekklesia's image contains no modules, and an installation installs a self-contained
|
||||||
|
copy of the module, with its own production dependencies, into its module storage
|
||||||
|
([An installed module is self-contained](../../docs/module-development/README.md#an-installed-module-is-self-contained),
|
||||||
|
ADR 0048). Copying `dist/` alone is not enough, because the compiled server code imports the Module
|
||||||
|
SDK and Drizzle by name.
|
||||||
|
|
||||||
The module owns its feature screens and display renderer. Do not add platform navigation, the
|
The module owns its feature screens and display renderer. Do not add platform navigation, the
|
||||||
containing shell, global theme, authentication screens, or top-level routing; those belong to
|
containing shell, global theme, authentication screens, or top-level routing; those belong to
|
||||||
surfaces and Core. A module UI asks its host for relative API paths and realtime state, and never
|
surfaces and Core. A module UI asks its host for relative API paths and realtime state, and never
|
||||||
|
|||||||
+85
-12
@@ -4,6 +4,7 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The scripts CI runs against a shared Docker daemon. Several runs, and several jobs of one run, can
|
* The scripts CI runs against a shared Docker daemon. Several runs, and several jobs of one run, can
|
||||||
@@ -59,21 +60,68 @@ describe("CI scripts on a shared Docker daemon", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("give every Compose project a name unique to the invocation, not only the run", async () => {
|
it("name every stack through one helper: the reserved CI prefix, the job's key, and a unique suffix", async () => {
|
||||||
expect(await source("scripts/test-migrations-integration.sh")).toMatch(
|
const helper = await source("scripts/lib/ci-project.sh");
|
||||||
/raw_project="ekklesia-migration-[^"\n]*\$\$-\$\{RANDOM\}\$\{RANDOM\}"/u,
|
// Run, attempt, job key, kind, then a per-invocation suffix: unique even when job containers
|
||||||
|
// share a PID, and findable later by run and job alone (scripts/ci-sweep.sh --job).
|
||||||
|
expect(helper).toContain(
|
||||||
|
"ekklesia-ci-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${EKKLESIA_CI_JOB_KEY:-${GITHUB_JOB:-local}}-",
|
||||||
);
|
);
|
||||||
expect(await source("scripts/smoke-infrastructure.sh")).toMatch(
|
expect(helper).toContain("$$-${RANDOM}${RANDOM}");
|
||||||
/raw_project="ekklesia-smoke-[^"\n]*\$\$-\$\{RANDOM\}\$\{RANDOM\}"/u,
|
|
||||||
);
|
|
||||||
const deploy = await source("scripts/smoke-deploy.sh");
|
|
||||||
expect(deploy).toContain('run_id="$(openssl rand -hex 4)"');
|
|
||||||
expect(deploy).toContain('project="ekklesia-deploy-smoke-${run_id}"');
|
|
||||||
for (const relativePath of ciScripts) {
|
for (const relativePath of ciScripts) {
|
||||||
expect(await source(relativePath), relativePath).toContain('--project-name "$project"');
|
const text = await source(relativePath);
|
||||||
|
expect(text, relativePath).toContain('source "$repository_root/scripts/lib/ci-project.sh"');
|
||||||
|
expect(text, relativePath).toMatch(/^project="\$\(ci_project_name [a-z"${}_-]+\)"$/mu);
|
||||||
|
expect(text, relativePath).not.toContain("raw_project=");
|
||||||
|
expect(text, relativePath).toContain('--project-name "$project"');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("label everything the deploy smoke creates outside Compose, so a sweep can find it", async () => {
|
||||||
|
// Continuation lines joined, so each command is checked whole.
|
||||||
|
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
|
||||||
|
const creating = deploy
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => /\bdocker (?:run --detach|volume create|build)\b/u.test(line));
|
||||||
|
expect(creating.length).toBeGreaterThanOrEqual(4);
|
||||||
|
for (const line of creating) {
|
||||||
|
expect(line).toContain('--label "${EKKLESIA_CI_LABEL}=${project}"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sweep stale stacks before, and this job's own stacks after, every job that starts any", async () => {
|
||||||
|
const workflow = parse(await source(".github/workflows/ci.yml")) as {
|
||||||
|
jobs: Record<
|
||||||
|
string,
|
||||||
|
{ env?: Record<string, string>; steps: { if?: string; run?: string; name?: string }[] }
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
const startsStacks =
|
||||||
|
/smoke-infrastructure\.sh|test:migrations:integration|smoke-deploy\.sh|check-image\.sh|docker build/u;
|
||||||
|
const jobs = Object.entries(workflow.jobs).filter(([, job]) =>
|
||||||
|
job.steps.some((step) => startsStacks.test(step.run ?? "")),
|
||||||
|
);
|
||||||
|
expect(jobs.map(([id]) => id).sort()).toEqual(
|
||||||
|
["deploy-smoke", "image-build", "infrastructure-smoke", "migration-smoke"].sort(),
|
||||||
|
);
|
||||||
|
for (const [id, job] of jobs) {
|
||||||
|
expect(job.env?.EKKLESIA_CI_JOB_KEY, id).toBeTruthy();
|
||||||
|
const runs = job.steps.map((step) => step.run ?? "");
|
||||||
|
const firstStack = runs.findIndex((run) => startsStacks.test(run));
|
||||||
|
const stale = runs.findIndex((run) => run.includes("bash scripts/ci-sweep.sh --stale"));
|
||||||
|
const own = job.steps.findIndex(
|
||||||
|
(step) =>
|
||||||
|
step.if === "always()" && (step.run ?? "").includes("bash scripts/ci-sweep.sh --job"),
|
||||||
|
);
|
||||||
|
expect(stale, `${id}: stale sweep`).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(stale, `${id}: stale sweep runs first`).toBeLessThan(firstStack);
|
||||||
|
expect(own, `${id}: its own cleanup, always`).toBeGreaterThan(firstStack);
|
||||||
|
expect(own, `${id}: cleanup is the last step`).toBe(job.steps.length - 1);
|
||||||
|
}
|
||||||
|
// One matrix leg's cleanup must never remove the other leg's running stack.
|
||||||
|
expect(workflow.jobs["deploy-smoke"]?.env?.EKKLESIA_CI_JOB_KEY).toContain("matrix.topology");
|
||||||
|
});
|
||||||
|
|
||||||
it("choose the deploy smoke's edge subnet from those not in use, and choose again on a race", async () => {
|
it("choose the deploy smoke's edge subnet from those not in use, and choose again on a race", async () => {
|
||||||
const deploy = await source("scripts/smoke-deploy.sh");
|
const deploy = await source("scripts/smoke-deploy.sh");
|
||||||
expect(deploy).not.toMatch(/^octet=\$\(\(RANDOM/mu);
|
expect(deploy).not.toMatch(/^octet=\$\(\(RANDOM/mu);
|
||||||
@@ -84,12 +132,37 @@ describe("CI scripts on a shared Docker daemon", () => {
|
|||||||
|
|
||||||
it("tag the CI image per run and attempt, so concurrent runs of one commit cannot remove it", async () => {
|
it("tag the CI image per run and attempt, so concurrent runs of one commit cannot remove it", async () => {
|
||||||
const workflow = await source(".github/workflows/ci.yml");
|
const workflow = await source(".github/workflows/ci.yml");
|
||||||
expect(workflow).toContain(
|
const imageCheck = await source("scripts/check-image.sh");
|
||||||
'ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"',
|
expect(workflow).toContain("bash scripts/check-image.sh");
|
||||||
|
expect(imageCheck).toContain(
|
||||||
|
'tag_base="ekklesia-ci:${GITHUB_SHA:-local}-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-"',
|
||||||
);
|
);
|
||||||
expect(workflow).not.toContain('ekklesia-ci:"$GITHUB_SHA"');
|
expect(workflow).not.toContain('ekklesia-ci:"$GITHUB_SHA"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("label every image the image check builds, so a sweep can find it", async () => {
|
||||||
|
const imageCheck = (await source("scripts/check-image.sh")).replace(/\\\n\s*/gu, " ");
|
||||||
|
expect(imageCheck).toContain('source "$repository_root/scripts/lib/ci-project.sh"');
|
||||||
|
expect(imageCheck).toMatch(/^project="\$\(ci_project_name image\)"$/mu);
|
||||||
|
const builds = imageCheck.split("\n").filter((line) => /\bdocker build\b/u.test(line));
|
||||||
|
expect(builds.length).toBeGreaterThanOrEqual(1);
|
||||||
|
for (const line of builds) {
|
||||||
|
expect(line).toContain('--label "${EKKLESIA_CI_LABEL}=${project}"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("run every deploy smoke against the one generic image, never a second image with a module in it", async () => {
|
||||||
|
// ADR 0048: the split topology installs its test module into module storage at run time.
|
||||||
|
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
|
||||||
|
const imageBuilds = deploy
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => /\bdocker build\b/u.test(line) && line.includes("deploy/Dockerfile"));
|
||||||
|
expect(imageBuilds).toHaveLength(1);
|
||||||
|
expect(deploy).not.toContain('mv "$context/templates/module" "$context/modules/');
|
||||||
|
expect(deploy).not.toMatch(/sed -i 's\|\^ {2}templates\/module:/u);
|
||||||
|
expect(deploy).toContain("EKKLESIA_MODULES_HOST_DIR=");
|
||||||
|
});
|
||||||
|
|
||||||
it("report every readiness failure with the project's containers and logs, through the redactor only", async () => {
|
it("report every readiness failure with the project's containers and logs, through the redactor only", async () => {
|
||||||
for (const relativePath of ciScripts) {
|
for (const relativePath of ciScripts) {
|
||||||
const text = await source(relativePath);
|
const text = await source(relativePath);
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ interface ComposeService {
|
|||||||
read_only?: boolean;
|
read_only?: boolean;
|
||||||
security_opt?: string[];
|
security_opt?: string[];
|
||||||
tmpfs?: string[];
|
tmpfs?: string[];
|
||||||
|
volumes?: (string | ComposeVolume)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComposeVolume {
|
||||||
|
bind?: { create_host_path?: boolean };
|
||||||
|
read_only?: boolean;
|
||||||
|
source?: string;
|
||||||
|
target?: string;
|
||||||
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ComposeDocument {
|
interface ComposeDocument {
|
||||||
@@ -153,6 +162,11 @@ describe("reference deployment compose files", () => {
|
|||||||
EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy",
|
EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy",
|
||||||
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: `${proxyAddress}/32`,
|
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: `${proxyAddress}/32`,
|
||||||
});
|
});
|
||||||
|
// Doctor runs as `migrate`. Given anything else, it validates a configuration the API is not
|
||||||
|
// running, and reports "Direct" for an installation that trusts its proxy.
|
||||||
|
expect(proxyOverlay.services?.migrate?.environment).toEqual(
|
||||||
|
proxyOverlay.services?.api?.environment,
|
||||||
|
);
|
||||||
expect(proxyOverlay.networks?.edge?.ipam?.config).toEqual([
|
expect(proxyOverlay.networks?.edge?.ipam?.config).toEqual([
|
||||||
{
|
{
|
||||||
ip_range: "${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25}",
|
ip_range: "${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25}",
|
||||||
@@ -229,3 +243,68 @@ describe("reference deployment env examples", () => {
|
|||||||
expect(example).toContain("EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>=");
|
expect(example).toContain("EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>=");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("installation-owned module storage (ADR 0048)", () => {
|
||||||
|
const modulesTarget = "/var/lib/ekklesia/modules";
|
||||||
|
|
||||||
|
function moduleMount(service: ComposeService | undefined): ComposeVolume | undefined {
|
||||||
|
return (service?.volumes ?? []).find(
|
||||||
|
(volume): volume is ComposeVolume =>
|
||||||
|
typeof volume === "object" && volume.target === modulesTarget,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"api",
|
||||||
|
"migrate",
|
||||||
|
])("mounts host module storage read-only into %s, and never lets Compose create it", async (name) => {
|
||||||
|
const service = (await compose("compose.yaml")).services?.[name];
|
||||||
|
expect(moduleMount(service)).toEqual({
|
||||||
|
bind: { create_host_path: false },
|
||||||
|
read_only: true,
|
||||||
|
source: "${EKKLESIA_MODULES_HOST_DIR:-/var/lib/ekklesia/modules}",
|
||||||
|
target: modulesTarget,
|
||||||
|
type: "bind",
|
||||||
|
});
|
||||||
|
// The process reads exactly the directory the mount provides.
|
||||||
|
expect(service?.environment?.EKKLESIA_MODULES_DIR).toBe(modulesTarget);
|
||||||
|
// Read-only everywhere else too: the mount is the only module storage there is.
|
||||||
|
expect(service?.read_only).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts module storage into nothing but the API and the operator commands", async () => {
|
||||||
|
const services = (await compose("compose.yaml")).services ?? {};
|
||||||
|
for (const [name, service] of Object.entries(services)) {
|
||||||
|
if (name !== "api" && name !== "migrate") {
|
||||||
|
expect(moduleMount(service), name).toBeUndefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const overlay of ["compose.proxy.yaml", "compose.tunnel.yaml"]) {
|
||||||
|
for (const [name, service] of Object.entries((await compose(overlay)).services ?? {})) {
|
||||||
|
expect(moduleMount(service), `${overlay} ${name}`).toBeUndefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the host-side directory with its own variable, never EKKLESIA_MODULES_DIR", async () => {
|
||||||
|
const composeEnvironment = await text("env/compose.env.example");
|
||||||
|
expect(composeEnvironment).toMatch(
|
||||||
|
/^# ?EKKLESIA_MODULES_HOST_DIR=\/var\/lib\/ekklesia\/modules$/mu,
|
||||||
|
);
|
||||||
|
expect(composeEnvironment).not.toMatch(/^#? ?EKKLESIA_MODULES_DIR=/mu);
|
||||||
|
// The API's own env file must not move the directory away from the mount.
|
||||||
|
expect(envAssignments(await text("env/ekklesia.env.example")).has("EKKLESIA_MODULES_DIR")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds an image that holds no modules and points at empty module storage", async () => {
|
||||||
|
const dockerfile = await text("Dockerfile");
|
||||||
|
const ignore = await readFile(path.join(repositoryRoot, ".dockerignore"), "utf8");
|
||||||
|
expect(ignore).toMatch(/^\/modules$/mu);
|
||||||
|
expect(dockerfile).toMatch(/EKKLESIA_MODULES_DIR=\/var\/lib\/ekklesia\/modules/u);
|
||||||
|
expect(dockerfile).toContain("install -d -o root -g root -m 0755 /var/lib/ekklesia/modules");
|
||||||
|
// Nothing from a module or the module template is copied into, or installed for, the runtime.
|
||||||
|
expect(dockerfile).not.toMatch(/\/runtime\/modules|modules\/\*\/package\.json|templates\/\*/u);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user