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
@@ -6,6 +6,7 @@ import {
|
||||
composePlatformPermissionRegistry,
|
||||
discoverModules,
|
||||
type DiscoveredModule,
|
||||
resolveModulesDirectory,
|
||||
} from "@ekklesia/core/modules";
|
||||
|
||||
export type PlatformPermissionConfigurationErrorCode = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
|
||||
@@ -55,10 +56,18 @@ async function readPackageVersion(packagePath: string): Promise<string> {
|
||||
}
|
||||
|
||||
export interface PrepareStartupModuleInventoryOptions {
|
||||
/**
|
||||
* The deployment's `EKKLESIA_MODULES_DIR`, raw, exactly as host configuration read it (ADR 0048).
|
||||
* A production input, not a test seam: the executable entry point always passes it. Unset means
|
||||
* the repository's own `modules/`; set means that absolute directory, which must exist. It is
|
||||
* resolved by Core's one resolver, so an unusable value fails startup with a sanitized
|
||||
* `ModuleDiscoveryError` before any pool exists.
|
||||
*/
|
||||
readonly configuredModulesDirectory?: string | undefined;
|
||||
/** Test-only escape hatch: overrides the directory module discovery scans for installed
|
||||
* modules. Never set by the executable entry point — production always discovers the real
|
||||
* repository's `modules/` directory. Platform and Module SDK versions are always read from the
|
||||
* real repository's own `package.json` files regardless of this override. */
|
||||
* modules, taking precedence over `configuredModulesDirectory`, which is then never resolved.
|
||||
* Never set by the executable entry point. Platform and Module SDK versions are always read from
|
||||
* the real repository's own `package.json` files regardless of this override. */
|
||||
readonly modulesDirectoryOverride?: string;
|
||||
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping the
|
||||
* filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry`
|
||||
@@ -109,6 +118,11 @@ export async function prepareStartupModuleInventory(
|
||||
}
|
||||
|
||||
const repositoryRoot = resolveRepositoryRoot();
|
||||
// Chosen before anything is read, so an unusable configured directory fails first and alone.
|
||||
const modulesDirectory =
|
||||
options.modulesDirectoryOverride === undefined
|
||||
? resolveModulesDirectory({ configured: options.configuredModulesDirectory, repositoryRoot })
|
||||
: { modulesDirectory: options.modulesDirectoryOverride, modulesDirectoryRequired: false };
|
||||
const [platformVersion, moduleSdkVersion] = await Promise.all([
|
||||
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
||||
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
||||
@@ -116,7 +130,7 @@ export async function prepareStartupModuleInventory(
|
||||
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion,
|
||||
modulesDirectory: options.modulesDirectoryOverride ?? path.join(repositoryRoot, "modules"),
|
||||
...modulesDirectory,
|
||||
platformVersion,
|
||||
});
|
||||
|
||||
|
||||
+16
-4
@@ -154,8 +154,12 @@ export interface StartApiOptions {
|
||||
* test needs somewhere to observe the operational failure the user is never shown.
|
||||
*/
|
||||
readonly reportNotificationFailure?: IdentityNotificationReporter;
|
||||
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
|
||||
* executable entry point. See `prepareStartupModuleInventory`. */
|
||||
/** The deployment's raw `EKKLESIA_MODULES_DIR` (ADR 0048), a production input the executable
|
||||
* entry point always passes from host configuration. See `prepareStartupModuleInventory`. */
|
||||
readonly configuredModulesDirectory?: string | undefined;
|
||||
/** Test-only escape hatch: overrides the directory module discovery scans, taking precedence
|
||||
* over `configuredModulesDirectory`. Never set by the executable entry point. See
|
||||
* `prepareStartupModuleInventory`. */
|
||||
readonly modulesDirectoryOverride?: string;
|
||||
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping
|
||||
* filesystem discovery entirely — this exact list drives both permission composition and module
|
||||
@@ -429,6 +433,7 @@ export async function startApi(
|
||||
options: StartApiOptions = {},
|
||||
): Promise<RunningApi> {
|
||||
const inventory = await prepareStartupModuleInventory({
|
||||
configuredModulesDirectory: options.configuredModulesDirectory,
|
||||
...(options.modulesDirectoryOverride === undefined
|
||||
? {}
|
||||
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
|
||||
@@ -769,6 +774,7 @@ export function readConfigFromEnvironment(processEnvironment: HostEnvironment):
|
||||
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
|
||||
readonly environment: "development" | "production";
|
||||
readonly moduleSecrets: ModuleSecretSource;
|
||||
readonly modulesDirectory: string | undefined;
|
||||
} {
|
||||
const host = loadHostConfiguration(processEnvironment);
|
||||
return {
|
||||
@@ -784,13 +790,19 @@ export function readConfigFromEnvironment(processEnvironment: HostEnvironment):
|
||||
storage: host.storage,
|
||||
},
|
||||
moduleSecrets: host.moduleSecrets,
|
||||
modulesDirectory: host.modulesDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
export async function main(): Promise<void> {
|
||||
const { environment, input, moduleSecrets } = readConfigFromEnvironment(process.env);
|
||||
const { environment, input, moduleSecrets, modulesDirectory } = readConfigFromEnvironment(
|
||||
process.env,
|
||||
);
|
||||
const config = createApiRuntimeConfig(input, { environment });
|
||||
const running = await startApi(config, { moduleSecrets });
|
||||
const running = await startApi(config, {
|
||||
configuredModulesDirectory: modulesDirectory,
|
||||
moduleSecrets,
|
||||
});
|
||||
|
||||
const address = running.server.address();
|
||||
const boundPort = address && typeof address === "object" ? address.port : config.port;
|
||||
|
||||
@@ -2590,6 +2590,7 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
@@ -2599,10 +2600,8 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
||||
runtimeVersion: process.versions.node,
|
||||
});
|
||||
|
||||
// Only the module-discovery and permission-composition checks are asserted here — this
|
||||
// fixture's temporary directory has no `core/drizzle` sibling for migrations discovery to
|
||||
// find, which is unrelated to what this scenario proves and would otherwise make the check
|
||||
// fail for a reason that has nothing to do with permission composition.
|
||||
// Only the module-discovery and permission-composition checks are asserted here; nothing else
|
||||
// in the report bears on what this scenario proves.
|
||||
const moduleCheck = report.checks.find((entry) => entry.id === "modules.discovery");
|
||||
const permissionsCheck = report.checks.find((entry) => entry.id === "modules.permissions");
|
||||
expect(moduleCheck).toMatchObject({ status: "pass" });
|
||||
|
||||
@@ -151,6 +151,13 @@ describe("host configuration parity between startApi and Doctor", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("hands the API the same modules directory Doctor and the operator commands read", () => {
|
||||
expect(readConfigFromEnvironment(environment()).modulesDirectory).toBeUndefined();
|
||||
const env = environment({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" });
|
||||
expect(readConfigFromEnvironment(env).modulesDirectory).toBe("/var/lib/ekklesia/modules");
|
||||
expect(loadHostConfiguration(env).modulesDirectory).toBe("/var/lib/ekklesia/modules");
|
||||
});
|
||||
|
||||
it("reads only this listener's own address and port outside the shared loader", () => {
|
||||
const config = apiView(
|
||||
environment({ EKKLESIA_API_HOST: "0.0.0.0", EKKLESIA_API_PORT: "8080" }),
|
||||
|
||||
@@ -1004,6 +1004,73 @@ describe("Ekklesia API module permission composition at startup", () => {
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("discovers installed modules from the configured modules directory (ADR 0048)", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
await createFixtureModule(modulesDirectory, "incompatible", {
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: ">=99.0.0" },
|
||||
});
|
||||
|
||||
// The module that fails discovery lives only in the configured directory, so this rejection
|
||||
// proves discovery read it rather than the repository's own `modules/`.
|
||||
await expect(
|
||||
startApi(validConfig(), { configuredModulesDirectory: modulesDirectory }),
|
||||
).rejects.toBeInstanceOf(ModuleDiscoveryError);
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts with zero modules from an empty configured modules directory", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig(), { configuredModulesDirectory: modulesDirectory });
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
await running.shutdown();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a relative path", "relative/private-mount-5b2a", "MODULES_DIRECTORY_INVALID"],
|
||||
["a missing directory", "/nonexistent/private-mount-5b2a", "MODULES_DIRECTORY_UNREADABLE"],
|
||||
])("fails closed on %s, before any pool exists, naming no path", async (_label, configured, code) => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
await startApi(validConfig(), { configuredModulesDirectory: configured });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(ModuleDiscoveryError);
|
||||
const issues = (captured as ModuleDiscoveryError).issues;
|
||||
expect(issues.map((issue) => issue.code)).toEqual([code]);
|
||||
expect(JSON.stringify(issues)).not.toContain("private-mount-5b2a");
|
||||
expect((captured as Error).message).not.toContain("private-mount-5b2a");
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets the test-only override take precedence over a configured directory", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
|
||||
// The configured value is unusable; the override wins, so it is never even resolved.
|
||||
const running = await startApi(validConfig(), {
|
||||
configuredModulesDirectory: "relative/never-resolved",
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
});
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
await running.shutdown();
|
||||
});
|
||||
|
||||
it("does not perform module discovery merely by importing the platform-permissions module", async () => {
|
||||
const module = await import("../src/platform-permissions.js");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user