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
+67 -1
View File
@@ -40,6 +40,7 @@ describe("migration command reports", () => {
}),
]),
pendingCount: 2,
retained: Object.freeze([]),
});
expect(formatMigrationPlanReport(plan)).toEqual([
@@ -64,6 +65,7 @@ describe("migration command reports", () => {
]),
appliedCount: 1,
noOp: false,
retained: Object.freeze([]),
});
expect(formatMigrationApplyReport(applied)).toEqual([
@@ -72,7 +74,12 @@ describe("migration command reports", () => {
]);
expect(
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)."]);
});
@@ -85,6 +92,7 @@ describe("migration command reports", () => {
approvalRequiredCount: 0,
entries: Object.freeze([]),
pendingCount: 0,
retained: Object.freeze([]),
}),
),
).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.",
]);
});
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,
]);
});
});