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
@@ -1,5 +1,7 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client, type QueryResult, type QueryResultRow } from "pg";
|
||||
@@ -8,6 +10,7 @@ import { reportMigrationCommandError } from "../src/cli/migration-command-errors
|
||||
import {
|
||||
applyModuleMigrations,
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
discoverCoreMigrations,
|
||||
type MigrationApproval,
|
||||
type MigrationApprovalProvider,
|
||||
@@ -764,7 +767,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
|
||||
database: migrationDatabase,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||
expect(await databaseState(database.client)).toEqual(before);
|
||||
const afterData = await safeQuery<QueryResultRow>(
|
||||
database.client,
|
||||
@@ -834,7 +837,48 @@ describe.sequential("live PostgreSQL module migration execution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unknown applied identity without repair or further execution", async () => {
|
||||
it("retains an absent module's history and still applies later work (ADR 0048)", async () => {
|
||||
await withIsolatedDatabase("absent_history", async (database) => {
|
||||
const initial = peopleInitial();
|
||||
const later = peopleLater();
|
||||
const initialInventory = aggregate(moduleFixture("people", [initial]));
|
||||
const completeInventory = aggregate(moduleFixture("people", [initial, later]));
|
||||
const migrationDatabase = createPostgresMigrationDatabase(database.url);
|
||||
await applyModuleMigrations({ aggregate: initialInventory, database: migrationDatabase });
|
||||
// History left by a module that is no longer installed: kept, reported, never fatal.
|
||||
await safeQuery(
|
||||
database.client,
|
||||
`
|
||||
INSERT INTO public.ekklesia_core_migrations
|
||||
(identity, module_id, migration_tag, migration_index, checksum)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`,
|
||||
["ghost:0000_unknown", "ghost", "0000_unknown", 0, "f".repeat(64)],
|
||||
);
|
||||
|
||||
const plan = await planModuleMigrations({
|
||||
aggregate: completeInventory,
|
||||
database: migrationDatabase,
|
||||
});
|
||||
expect(plan.retained).toEqual([{ appliedCount: 1, moduleId: "ghost" }]);
|
||||
expect(plan.pendingCount).toBe(1);
|
||||
|
||||
const result = await applyModuleMigrations({
|
||||
aggregate: completeInventory,
|
||||
database: migrationDatabase,
|
||||
});
|
||||
expect(result.applied.map((migration) => migration.identity)).toEqual([later.identity]);
|
||||
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "ghost" }]);
|
||||
expect(await columnExists(database.client, "people_records", "email")).toBe(true);
|
||||
expect((await readHistory(database.client)).map((row) => row.identity)).toEqual([
|
||||
initial.identity,
|
||||
"ghost:0000_unknown",
|
||||
later.identity,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unknown applied identity of an installed module without repair or further execution", async () => {
|
||||
await withIsolatedDatabase("unknown_history", async (database) => {
|
||||
const initial = peopleInitial();
|
||||
const later = peopleLater();
|
||||
@@ -849,7 +893,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
|
||||
(identity, module_id, migration_tag, migration_index, checksum)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`,
|
||||
["ghost:0000_unknown", "ghost", "0000_unknown", 0, "f".repeat(64)],
|
||||
["people:0099_unknown", "people", "0099_unknown", 99, "f".repeat(64)],
|
||||
);
|
||||
const corrupted = await databaseState(database.client);
|
||||
|
||||
@@ -866,7 +910,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
|
||||
expect(await columnExists(database.client, "people_records", "email")).toBe(false);
|
||||
expect((await readHistory(database.client)).map((row) => row.identity)).toEqual([
|
||||
initial.identity,
|
||||
"ghost:0000_unknown",
|
||||
"people:0099_unknown",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1358,7 +1402,7 @@ describe.sequential("live PostgreSQL Core identity migration execution", () => {
|
||||
database: migrationDatabase,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||
expect(await databaseState(database.client)).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -1851,3 +1895,151 @@ describe.sequential("live PostgreSQL migration timeout precedence", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* ADR 0048 end to end, with real discovery: Core's committed migrations, the module template
|
||||
* installed in a modules directory outside the repository, and a real PostgreSQL. Core's newest
|
||||
* migration is withheld from a copy of `core/drizzle` at first, so a genuinely later Core migration
|
||||
* can be applied while the module is absent.
|
||||
*/
|
||||
describe.sequential("live PostgreSQL retained history for an uninstalled module", () => {
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const templateModule = path.join(repositoryRoot, "templates", "module");
|
||||
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const;
|
||||
|
||||
async function coreWithoutNewestMigration(directory: string): Promise<string> {
|
||||
const target = path.join(directory, "core-drizzle");
|
||||
await cp(coreMigrationsDirectory, target, { recursive: true });
|
||||
const journalPath = path.join(target, "meta", "_journal.json");
|
||||
const policyPath = path.join(target, "meta", "_ekklesia.json");
|
||||
const journal = JSON.parse(await readFile(journalPath, "utf8")) as {
|
||||
entries: { idx: number; tag: string }[];
|
||||
};
|
||||
const policy = JSON.parse(await readFile(policyPath, "utf8")) as { entries: unknown[] };
|
||||
const newest = journal.entries.pop();
|
||||
policy.entries.pop();
|
||||
if (!newest) {
|
||||
throw harnessError("Core has no migration to withhold.");
|
||||
}
|
||||
await writeFile(journalPath, `${JSON.stringify(journal, null, 2)}\n`);
|
||||
await writeFile(policyPath, `${JSON.stringify(policy, null, 2)}\n`);
|
||||
await rm(path.join(target, `${newest.tag}.sql`));
|
||||
await rm(path.join(target, "meta", `${String(newest.idx).padStart(4, "0")}_snapshot.json`));
|
||||
return target;
|
||||
}
|
||||
|
||||
async function installExample(modulesDirectory: string): Promise<void> {
|
||||
for (const entry of ["package.json", "ekklesia.module.json", "drizzle", "dist"]) {
|
||||
await cp(path.join(templateModule, entry), path.join(modulesDirectory, "example", entry), {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function inventory(coreDirectory: string, modulesDirectory: string) {
|
||||
return discoverAllMigrations({
|
||||
coreMigrationsDirectory: coreDirectory,
|
||||
moduleDiscovery: { ...versions, modulesDirectory, modulesDirectoryRequired: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function exampleValues(client: Client): Promise<readonly string[]> {
|
||||
const result = await safeQuery<{ readonly value: string } & QueryResultRow>(
|
||||
client,
|
||||
"SELECT value FROM public.example_records ORDER BY value",
|
||||
);
|
||||
return result.rows.map((row) => row.value);
|
||||
}
|
||||
|
||||
it("keeps an uninstalled module's data and history, applies later Core work, and restores it", async () => {
|
||||
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-retained-history-"));
|
||||
try {
|
||||
const earlyCore = await coreWithoutNewestMigration(scratch);
|
||||
const modulesDirectory = path.join(scratch, "modules");
|
||||
await mkdir(modulesDirectory);
|
||||
await installExample(modulesDirectory);
|
||||
|
||||
await withIsolatedDatabase("retained_module", async (database) => {
|
||||
const migrationDatabase = createPostgresMigrationDatabase(database.url);
|
||||
|
||||
// 1. Core (minus its newest migration) and the installed module, applied together.
|
||||
const first = await applyModuleMigrations({
|
||||
aggregate: await inventory(earlyCore, modulesDirectory),
|
||||
database: migrationDatabase,
|
||||
});
|
||||
expect(first.applied.map((migration) => migration.identity)).toContain(
|
||||
"example:0000_initial",
|
||||
);
|
||||
expect(first.retained).toEqual([]);
|
||||
|
||||
// 2. Module-owned data.
|
||||
await safeQuery(
|
||||
database.client,
|
||||
"INSERT INTO public.example_records (id, value) VALUES (gen_random_uuid(), 'kept')",
|
||||
);
|
||||
|
||||
// 3. The module is uninstalled: its directory is gone from module storage.
|
||||
await rm(path.join(modulesDirectory, "example"), { recursive: true });
|
||||
|
||||
// 4. A later Core migration plans and applies while the module is absent.
|
||||
const absentInventory = await inventory(coreMigrationsDirectory, modulesDirectory);
|
||||
const absentPlan = await planModuleMigrations({
|
||||
aggregate: absentInventory,
|
||||
database: migrationDatabase,
|
||||
});
|
||||
expect(absentPlan.retained).toEqual([{ appliedCount: 1, moduleId: "example" }]);
|
||||
expect(
|
||||
absentPlan.entries.filter((entry) => entry.status === "pending").map((e) => e.moduleId),
|
||||
).toEqual(["core"]);
|
||||
const later = await applyModuleMigrations({
|
||||
aggregate: absentInventory,
|
||||
database: migrationDatabase,
|
||||
});
|
||||
expect(later.applied).toHaveLength(1);
|
||||
expect(later.applied[0]?.moduleId).toBe("core");
|
||||
expect(later.retained).toEqual([{ appliedCount: 1, moduleId: "example" }]);
|
||||
|
||||
// 5. The module's table and rows are untouched, and so is its history.
|
||||
expect(await exampleValues(database.client)).toEqual(["kept"]);
|
||||
expect((await readHistory(database.client)).map((row) => row.identity)).toContain(
|
||||
"example:0000_initial",
|
||||
);
|
||||
|
||||
// 6. The module is reinstalled.
|
||||
await installExample(modulesDirectory);
|
||||
const restored = await inventory(coreMigrationsDirectory, modulesDirectory);
|
||||
|
||||
// 7. Its history is strict again, and valid; its data is where it left it.
|
||||
const restoredPlan = await planModuleMigrations({
|
||||
aggregate: restored,
|
||||
database: migrationDatabase,
|
||||
});
|
||||
expect(restoredPlan.retained).toEqual([]);
|
||||
expect(restoredPlan.pendingCount).toBe(0);
|
||||
expect(
|
||||
restoredPlan.entries.find((entry) => entry.identity === "example:0000_initial")?.status,
|
||||
).toBe("applied");
|
||||
const noOp = await applyModuleMigrations({
|
||||
aggregate: restored,
|
||||
database: migrationDatabase,
|
||||
});
|
||||
expect(noOp.noOp).toBe(true);
|
||||
expect(await exampleValues(database.client)).toEqual(["kept"]);
|
||||
|
||||
// A returning package whose applied migration changed is refused, exactly as before.
|
||||
const sqlPath = path.join(modulesDirectory, "example", "drizzle", "0000_initial.sql");
|
||||
await writeFile(sqlPath, `${await readFile(sqlPath, "utf8")}-- edited\n`);
|
||||
await captureRunnerError(
|
||||
planModuleMigrations({
|
||||
aggregate: await inventory(coreMigrationsDirectory, modulesDirectory),
|
||||
database: migrationDatabase,
|
||||
}),
|
||||
"MIGRATION_CHECKSUM_MISMATCH",
|
||||
);
|
||||
expect(await exampleValues(database.client)).toEqual(["kept"]);
|
||||
});
|
||||
} finally {
|
||||
await rm(scratch, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user