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
+121
-2
@@ -1,5 +1,9 @@
|
||||
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
|
||||
import {
|
||||
type DoctorCheck,
|
||||
@@ -114,7 +118,10 @@ function emptyDatabase(): MigrationDatabase {
|
||||
});
|
||||
}
|
||||
|
||||
function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult {
|
||||
function plan(
|
||||
entries: MigrationPlanResult["entries"] = [],
|
||||
retained: MigrationPlanResult["retained"] = [],
|
||||
): MigrationPlanResult {
|
||||
const appliedCount = entries.filter((entry) => entry.status === "applied").length;
|
||||
const approvalRequiredCount = entries.filter(
|
||||
(entry) => entry.status === "approval-blocked",
|
||||
@@ -124,6 +131,7 @@ function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult
|
||||
approvalRequiredCount,
|
||||
entries: Object.freeze([...entries]),
|
||||
pendingCount: entries.length - appliedCount,
|
||||
retained: Object.freeze([...retained]),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,6 +159,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
|
||||
function options(databaseUrl?: string): DoctorOptions {
|
||||
return {
|
||||
...(databaseUrl === undefined ? {} : { databaseUrl }),
|
||||
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: "/not-rendered/modules",
|
||||
@@ -1657,3 +1666,113 @@ describe("Doctor reports declared browser contributions", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Doctor with installation-owned module storage (ADR 0048)", () => {
|
||||
const platformCoreMigrations = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"drizzle",
|
||||
);
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it("finds Core's migrations where the platform is installed, not beside the modules", async () => {
|
||||
// A modules directory far from the platform, as the image mounts /var/lib/ekklesia/modules. Its
|
||||
// parent has no `core/drizzle`, so a Doctor that still derived Core's migrations from the
|
||||
// modules directory would fail migration discovery here.
|
||||
const external = await mkdtemp(path.join(tmpdir(), "ekklesia-external-modules-"));
|
||||
temporaryDirectories.push(external);
|
||||
const report = await diagnoseEkklesia({
|
||||
coreMigrationsDirectory: platformCoreMigrations,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: external,
|
||||
modulesDirectoryRequired: true,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
runtimeRange: ">=24.11.0 <25",
|
||||
runtimeVersion: "24.15.0",
|
||||
});
|
||||
|
||||
expect(check(report, "modules.discovery").status).toBe("pass");
|
||||
const migrations = check(report, "migrations.discovery");
|
||||
expect(migrations.status).toBe("pass");
|
||||
expect(migrations.details.some((detail) => detail.startsWith("core:0000_"))).toBe(true);
|
||||
expect(JSON.stringify(report)).not.toContain(external);
|
||||
});
|
||||
|
||||
it("reports an unusable configured modules directory as a discovery failure, naming no path", async () => {
|
||||
const report = await diagnoseEkklesia({
|
||||
coreMigrationsDirectory: platformCoreMigrations,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: "relative/private-mount-3c9d",
|
||||
modulesDirectoryRequired: true,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
runtimeRange: ">=24.11.0 <25",
|
||||
runtimeVersion: "24.15.0",
|
||||
});
|
||||
|
||||
const discovery = check(report, "modules.discovery");
|
||||
expect(discovery.status).toBe("failure");
|
||||
expect(discovery.details).toEqual(["The configured modules directory is not usable."]);
|
||||
expect(check(report, "migrations.discovery").status).toBe("skipped");
|
||||
expect(JSON.stringify(report)).not.toContain("private-mount-3c9d");
|
||||
});
|
||||
|
||||
it("reports a configured modules directory that does not exist, naming no path", async () => {
|
||||
const parent = await mkdtemp(path.join(tmpdir(), "ekklesia-missing-modules-"));
|
||||
temporaryDirectories.push(parent);
|
||||
const report = await diagnoseEkklesia({
|
||||
coreMigrationsDirectory: platformCoreMigrations,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: path.join(parent, "absent"),
|
||||
modulesDirectoryRequired: true,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
runtimeRange: ">=24.11.0 <25",
|
||||
runtimeVersion: "24.15.0",
|
||||
});
|
||||
|
||||
const discovery = check(report, "modules.discovery");
|
||||
expect(discovery.status).toBe("failure");
|
||||
expect(discovery.details).toEqual(["The configured modules directory could not be read."]);
|
||||
expect(JSON.stringify(report)).not.toContain(parent);
|
||||
});
|
||||
|
||||
it("reports an absent module's retained history without blocking readiness", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
options("postgres://[email protected]/ekklesia"),
|
||||
dependencies({
|
||||
planMigrations: vi.fn(async () =>
|
||||
plan(
|
||||
[planEntry("core:0000_identity_core_tables", "a".repeat(64), "applied")],
|
||||
[Object.freeze({ appliedCount: 2, moduleId: "calendar" })],
|
||||
),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const planCheck = check(report, "postgresql.plan");
|
||||
expect(planCheck.status).toBe("pass");
|
||||
expect(planCheck.details).toContain(
|
||||
"retained · calendar · 2 applied migrations kept; the module is not installed",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes Core's own migration directory to migration discovery", async () => {
|
||||
const discoverMigrations = vi.fn(async () => migrationAggregate());
|
||||
await diagnoseEkklesia(options(), dependencies({ discoverMigrations }));
|
||||
expect(discoverMigrations).toHaveBeenCalledWith({
|
||||
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||
moduleDiscovery: options().moduleDiscovery,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user