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
+28 -3
View File
@@ -97,7 +97,7 @@ describe("CI scripts on a shared Docker daemon", () => {
>;
};
const startsStacks =
/smoke-infrastructure\.sh|test:migrations:integration|smoke-deploy\.sh|docker build/u;
/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 ?? "")),
);
@@ -132,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 () => {
const workflow = await source(".github/workflows/ci.yml");
expect(workflow).toContain(
'ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"',
const imageCheck = await source("scripts/check-image.sh");
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"');
});
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 () => {
for (const relativePath of ciScripts) {
const text = await source(relativePath);