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
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:
co-authored by
Claude Opus 5.5
parent
4d28a82937
commit
e36b70d8d4
@@ -13,6 +13,7 @@ import {
|
||||
type MigrationTransaction,
|
||||
planModuleMigrations,
|
||||
} from "../src/migrations/runner.js";
|
||||
import type { DiscoveredModule } from "../src/modules/discovery.js";
|
||||
import type {
|
||||
DiscoveredModuleMigration,
|
||||
ModuleMigrationAggregate,
|
||||
@@ -323,6 +324,7 @@ describe("module migration planning", () => {
|
||||
},
|
||||
],
|
||||
pendingCount: 1,
|
||||
retained: [],
|
||||
});
|
||||
expect(database.events).toEqual(["session:open", "history:read", "session:close"]);
|
||||
expect(database.historyTableExists).toBe(false);
|
||||
@@ -344,10 +346,11 @@ describe("module migration planning", () => {
|
||||
approvalRequiredCount: 0,
|
||||
entries: [],
|
||||
pendingCount: 0,
|
||||
retained: [],
|
||||
});
|
||||
expect(Object.isFrozen(result)).toBe(true);
|
||||
expect(Object.isFrozen(result.entries)).toBe(true);
|
||||
expect(applyResult).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
||||
expect(applyResult).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||
expect(database.historyTableExists).toBe(false);
|
||||
expect(database.events).not.toContain("transaction:begin");
|
||||
});
|
||||
@@ -548,6 +551,7 @@ describe("module migration apply", () => {
|
||||
],
|
||||
appliedCount: 1,
|
||||
noOp: false,
|
||||
retained: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -580,7 +584,7 @@ describe("module migration apply", () => {
|
||||
database,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||
expect(Object.isFrozen(result)).toBe(true);
|
||||
expect(Object.isFrozen(result.applied)).toBe(true);
|
||||
expectNoPersistentMutation(database);
|
||||
@@ -601,7 +605,7 @@ describe("module migration apply", () => {
|
||||
database,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||
expect(database.historyTableExists).toBe(false);
|
||||
expect(database.events).toEqual([
|
||||
"session:open",
|
||||
@@ -980,3 +984,250 @@ describe("module migration failures and side effects", () => {
|
||||
expect(database.committedHistory).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Marks modules as present in the discovered inventory without giving them migrations, the way a
|
||||
* real aggregate lists every discovered module whether or not it owns migration history. Only the
|
||||
* manifest id is read by the runner.
|
||||
*/
|
||||
function withDiscovered(
|
||||
inventory: ModuleMigrationAggregate,
|
||||
...moduleIds: readonly string[]
|
||||
): ModuleMigrationAggregate {
|
||||
return Object.freeze({
|
||||
...inventory,
|
||||
discoveredModules: Object.freeze(
|
||||
moduleIds.map((id) => ({ manifest: { id } }) as unknown as DiscoveredModule),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
describe("retained history for absent modules (ADR 0048)", () => {
|
||||
it("retains and reports an absent module's applied history without failing the plan", async () => {
|
||||
const coreInitial = migration("core", 0);
|
||||
const calendarInitial = migration("calendar", 0);
|
||||
const calendarLater = migration("calendar", 1);
|
||||
const history = [
|
||||
historyRecord(coreInitial, 1),
|
||||
historyRecord(calendarInitial, 2),
|
||||
historyRecord(calendarLater, 3),
|
||||
];
|
||||
const database = new FakeMigrationDatabase({ history });
|
||||
|
||||
const result = await planModuleMigrations({
|
||||
aggregate: aggregate({ id: "core", migrations: [coreInitial] }),
|
||||
database,
|
||||
});
|
||||
|
||||
expect(result.appliedCount).toBe(1);
|
||||
expect(result.pendingCount).toBe(0);
|
||||
expect(result.entries.map((entry) => entry.identity)).toEqual([coreInitial.identity]);
|
||||
expect(result.retained).toEqual([{ appliedCount: 2, moduleId: "calendar" }]);
|
||||
expect(Object.isFrozen(result.retained)).toBe(true);
|
||||
expect(Object.isFrozen(result.retained[0])).toBe(true);
|
||||
expect(database.committedHistory).toEqual(history);
|
||||
expectNoPersistentMutation(database);
|
||||
});
|
||||
|
||||
it("applies a later Core migration while a module is absent, leaving its history intact", async () => {
|
||||
const coreInitial = migration("core", 0);
|
||||
const coreLater = migration("core", 1);
|
||||
const calendarInitial = migration("calendar", 0);
|
||||
const history = [historyRecord(coreInitial, 1), historyRecord(calendarInitial, 2)];
|
||||
const database = new FakeMigrationDatabase({ history });
|
||||
|
||||
const result = await applyModuleMigrations({
|
||||
aggregate: aggregate({ id: "core", migrations: [coreInitial, coreLater] }),
|
||||
database,
|
||||
});
|
||||
|
||||
expect(result.applied.map((applied) => applied.identity)).toEqual([coreLater.identity]);
|
||||
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "calendar" }]);
|
||||
expect(database.executedStatements).toEqual([coreLater.sql]);
|
||||
expect(database.committedHistory.map((record) => record.identity)).toEqual([
|
||||
coreInitial.identity,
|
||||
calendarInitial.identity,
|
||||
coreLater.identity,
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies a present module's migrations while another module is absent", async () => {
|
||||
const peopleInitial = migration("people", 0);
|
||||
const calendarInitial = migration("calendar", 0);
|
||||
const database = new FakeMigrationDatabase({ history: [historyRecord(calendarInitial, 1)] });
|
||||
|
||||
const result = await applyModuleMigrations({
|
||||
aggregate: aggregate({ id: "people", migrations: [peopleInitial] }),
|
||||
database,
|
||||
});
|
||||
|
||||
expect(result.applied.map((applied) => applied.identity)).toEqual([peopleInitial.identity]);
|
||||
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "calendar" }]);
|
||||
});
|
||||
|
||||
it("reports retained modules in a stable order with exact counts", async () => {
|
||||
const zeta = [migration("zeta", 0), migration("zeta", 1), migration("zeta", 2)];
|
||||
const alpha = migration("alpha", 0);
|
||||
const database = new FakeMigrationDatabase({
|
||||
history: [
|
||||
historyRecord(zeta[0] as DiscoveredModuleMigration, 1),
|
||||
historyRecord(alpha, 2),
|
||||
historyRecord(zeta[1] as DiscoveredModuleMigration, 3),
|
||||
historyRecord(zeta[2] as DiscoveredModuleMigration, 4),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await planModuleMigrations({ aggregate: aggregate(), database });
|
||||
|
||||
expect(result.retained).toEqual([
|
||||
{ appliedCount: 1, moduleId: "alpha" },
|
||||
{ appliedCount: 3, moduleId: "zeta" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns a module to strict validation: valid history plans cleanly and nothing is retained", async () => {
|
||||
const initial = migration("people", 0);
|
||||
const later = migration("people", 1);
|
||||
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
|
||||
|
||||
const result = await planModuleMigrations({
|
||||
aggregate: aggregate({ id: "people", migrations: [initial, later] }),
|
||||
database,
|
||||
});
|
||||
|
||||
expect(result.retained).toEqual([]);
|
||||
expect(result.entries.map((entry) => [entry.identity, entry.status])).toEqual([
|
||||
[initial.identity, "applied"],
|
||||
[later.identity, "pending"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails when a returning module no longer ships a previously applied migration", async () => {
|
||||
const initial = migration("people", 0);
|
||||
const later = migration("people", 1);
|
||||
const database = new FakeMigrationDatabase({
|
||||
history: [historyRecord(initial, 1), historyRecord(later, 2)],
|
||||
});
|
||||
const downgraded = aggregate({ id: "people", migrations: [initial] });
|
||||
|
||||
await captureRunnerError(
|
||||
planModuleMigrations({ aggregate: downgraded, database }),
|
||||
"MIGRATION_UNKNOWN_APPLIED",
|
||||
);
|
||||
await captureRunnerError(
|
||||
applyModuleMigrations({ aggregate: downgraded, database }),
|
||||
"MIGRATION_UNKNOWN_APPLIED",
|
||||
);
|
||||
expect(database.executedStatements).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails when a returning module's applied migration has different contents", async () => {
|
||||
const initial = migration("people", 0);
|
||||
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
|
||||
const edited = aggregate({
|
||||
id: "people",
|
||||
migrations: [migration("people", 0, { sql: "SELECT 'edited';\n" })],
|
||||
});
|
||||
|
||||
const error = await captureRunnerError(
|
||||
planModuleMigrations({ aggregate: edited, database }),
|
||||
"MIGRATION_CHECKSUM_MISMATCH",
|
||||
);
|
||||
expect(error.migrationIdentity).toBe(initial.identity);
|
||||
});
|
||||
|
||||
it("fails when a returning module's history is no longer a prefix of its journal", async () => {
|
||||
const initial = migration("people", 0);
|
||||
const later = migration("people", 1);
|
||||
const database = new FakeMigrationDatabase({ history: [historyRecord(later, 1)] });
|
||||
|
||||
await captureRunnerError(
|
||||
planModuleMigrations({
|
||||
aggregate: aggregate({ id: "people", migrations: [initial, later] }),
|
||||
database,
|
||||
}),
|
||||
"MIGRATION_HISTORY_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a present module that no longer ships any migrations strict", async () => {
|
||||
const initial = migration("people", 0);
|
||||
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
|
||||
|
||||
await captureRunnerError(
|
||||
planModuleMigrations({ aggregate: withDiscovered(aggregate(), "people"), database }),
|
||||
"MIGRATION_UNKNOWN_APPLIED",
|
||||
);
|
||||
});
|
||||
|
||||
it("never treats Core's history as an absent module's", async () => {
|
||||
const coreInitial = migration("core", 0);
|
||||
const coreGhost = migration("core", 1);
|
||||
|
||||
// An inventory with no Core source at all: Core's rows are still strict, never retained.
|
||||
await captureRunnerError(
|
||||
planModuleMigrations({
|
||||
aggregate: aggregate({ id: "people", migrations: [] }),
|
||||
database: new FakeMigrationDatabase({ history: [historyRecord(coreInitial, 1)] }),
|
||||
}),
|
||||
"MIGRATION_UNKNOWN_APPLIED",
|
||||
);
|
||||
// A Core row missing from Core's own inventory.
|
||||
await captureRunnerError(
|
||||
planModuleMigrations({
|
||||
aggregate: aggregate({ id: "core", migrations: [coreInitial] }),
|
||||
database: new FakeMigrationDatabase({
|
||||
history: [historyRecord(coreInitial, 1), historyRecord(coreGhost, 2)],
|
||||
}),
|
||||
}),
|
||||
"MIGRATION_UNKNOWN_APPLIED",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"an identity that does not name its own module and tag",
|
||||
(source: DiscoveredModuleMigration) => [
|
||||
historyRecord(source, 1, { identity: "calendar:0099_elsewhere" }),
|
||||
],
|
||||
],
|
||||
[
|
||||
"a duplicate applied order",
|
||||
(source: DiscoveredModuleMigration) => [
|
||||
historyRecord(source, 1),
|
||||
historyRecord(migration("calendar", 1), 1),
|
||||
],
|
||||
],
|
||||
[
|
||||
"a duplicate index",
|
||||
(source: DiscoveredModuleMigration) => [
|
||||
historyRecord(source, 1),
|
||||
historyRecord(migration("calendar", 1), 2, { index: 0 }),
|
||||
],
|
||||
],
|
||||
])("still rejects retained rows with %s", async (_label, makeHistory) => {
|
||||
const database = new FakeMigrationDatabase({
|
||||
history: makeHistory(migration("calendar", 0)),
|
||||
});
|
||||
|
||||
await captureRunnerError(
|
||||
planModuleMigrations({ aggregate: aggregate(), database }),
|
||||
"MIGRATION_HISTORY_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a retained row that claims a present module's migration identity", async () => {
|
||||
const peopleInitial = migration("people", 0);
|
||||
const database = new FakeMigrationDatabase({
|
||||
history: [historyRecord(peopleInitial, 1, { moduleId: "calendar" })],
|
||||
});
|
||||
|
||||
await captureRunnerError(
|
||||
planModuleMigrations({
|
||||
aggregate: aggregate({ id: "people", migrations: [peopleInitial] }),
|
||||
database,
|
||||
}),
|
||||
"MIGRATION_HISTORY_INVALID",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user