feat: one generic image, with installed modules in installation-owned storage (ADR 0048)
CI / verify (pull_request) Failing after 47s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped

The production image now contains zero feature modules and is the same for every
installation. Installed modules live in module storage that one Core resolver
selects from EKKLESIA_MODULES_DIR: the checkout's modules/ when unset, an
absolute path that must exist when set (/var/lib/ekklesia/modules in the image).
A relative, blank, missing, or unreadable configured directory fails closed with
a sanitized error that never contains the value.

- Host configuration reads EKKLESIA_MODULES_DIR. The API, migration plan, apply
  and validate, module validation, module enablement, and Doctor all resolve it
  the same way; the test-only API override still takes precedence.
- Doctor receives Core's migrations directory explicitly instead of deriving it
  from the modules directory's parent.
- The migration runner retains and reports the applied history of a module that
  is not installed, and plans and applies Core's and present modules' migrations
  around it. Core's history and every installed module's history stay strict: a
  missing or changed applied migration, or a downgrade, still fails. No down
  migrations, no data deletion.
- deploy/Dockerfile copies no module and installs nothing for the module
  template; .dockerignore excludes modules/ and the build refuses a context that
  contains it; the runtime has an empty root-owned /var/lib/ekklesia/modules and
  sets EKKLESIA_MODULES_DIR.
- deploy/compose.yaml bind-mounts EKKLESIA_MODULES_HOST_DIR read-only into api
  and migrate, with create_host_path disabled.
- scripts/check-image.sh proves the image holds no modules and that a populated
  modules/ directory leaves its runtime file system identical; CI runs it.
- The split deploy smoke now uses the same generic image: it boots with no
  modules, installs a self-contained test module packaged from the template
  (test-only), migrates and restarts, removes it (Core runs, planning reports the
  retained history, data survives), and restores it with its data.

Docs: deploy README (module storage, install/update/remove/restore, the runtime
modules target), .env.example, API configuration, module development guide,
testing, workflow, security, and the architecture overview.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-07 08:38:14 -05:00
co-authored by Claude Opus 5.5
parent 4d28a82937
commit e36b70d8d4
50 changed files with 2275 additions and 224 deletions
+134 -38
View File
@@ -1,19 +1,30 @@
#!/usr/bin/env bash
# End-to-end smoke test of the reference deployment (ADR 0044): deploy/compose.yaml with
# deploy/compose.proxy.yaml, a platform-only image, and a disposable PostgreSQL and SeaweedFS that
# exist only for this run. CI runs it; it is safe to run on a workstation.
# End-to-end smoke test of the reference deployment (ADR 0044, ADR 0048): deploy/compose.yaml with
# deploy/compose.proxy.yaml, the one generic platform image, installation-owned module storage, and
# 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
# EKKLESIA_SMOKE_IMAGE=<image> 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
# from one hostname with Caddyfile.example and a platform-only image. `split` serves the Dashboard on
# its own hostname with Caddyfile.split.example, builds an image with the repository's module
# template installed as a test-only module (so a module UI, its API routes, and a display exist), and
# then drives a real headless Chromium through the Dashboard, the module, the display, and sign-out.
# 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.
# Either topology (ADR 0045), and in both the same generic image, built once from deploy/Dockerfile.
# It contains no module; module storage starts empty, as on a fresh installation. `same-origin` (the
# default) serves the Dashboard, the API, and displays from one hostname with Caddyfile.example.
# `split` serves the Dashboard on its own hostname with Caddyfile.split.example and then exercises
# ADR 0048 end to end with that same image:
#
# - 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
@@ -113,6 +124,8 @@ choose_edge_network() {
session_client_address="10.249.${octet}.252"
# The headless browser's fixed address, likewise (split topology only).
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
fi
done
@@ -166,7 +179,7 @@ cleanup() {
# job itself runs as root), so only the staged copy on the Docker host needs this.
if [[ "$daemon_work" == "$work" ]]; then
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
docker run --rm --entrypoint rm -v /tmp:/host-tmp "$postgres_image" -rf "/host-tmp/${project}" \
>/dev/null 2>&1 || true
@@ -188,41 +201,38 @@ fail() {
exit 1
}
if [[ -z "$image" && "$topology" == same-origin ]]; then
step "Building the platform-only image"
if [[ -z "$image" ]]; then
# 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}"
DOCKER_BUILDKIT=1 docker build --quiet --file deploy/Dockerfile --tag "$built_image" \
--label "${EKKLESIA_CI_LABEL}=${project}" --label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
. >/dev/null
image="$built_image"
elif [[ -z "$image" ]]; then
# 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" \
--label "${EKKLESIA_CI_LABEL}=${project}" --label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
"$context" >/dev/null || fail "the test-module image did not build."
rm -rf "$context"
image="$built_image"
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.
cp -R deploy "$work/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"
init_status=0
sh deploy/scripts/init-tls.sh "$work/tls" >/dev/null 2>&1 || init_status=$?
@@ -309,6 +319,7 @@ EKKLESIA_EDGE_SUBNET=${edge_subnet}
EKKLESIA_EDGE_DYNAMIC_RANGE=${edge_range}
EKKLESIA_PROXY_ADDRESS=${proxy_address}
EKKLESIA_TUNNEL_ADDRESS=${tunnel_address}
EKKLESIA_MODULES_HOST_DIR=${daemon_work}/modules
EOF
step "Starting PostgreSQL and object storage"
@@ -398,7 +409,70 @@ client session --ip "$session_client_address" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" ||
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
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"
# 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
@@ -501,6 +575,31 @@ if [[ "$topology" == split ]]; then
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
step "Proving the API's database connection is certificate-verified TLS"
@@ -510,9 +609,6 @@ api_data_address="$(
"$api_container"
)"
[[ -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'")"
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."