Files
Ekklesia/scripts/check-image.sh
T
ekklesiaandClaude Opus 5.5 e36b70d8d4
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
feat: one generic image, with installed modules in installation-owned storage (ADR 0048)
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]>
2026-10-07 08:38:14 -05:00

147 lines
6.3 KiB
Bash
Executable File

#!/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."