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]>
147 lines
6.3 KiB
Bash
Executable File
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."
|