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
@@ -0,0 +1,159 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
discoverModules,
|
||||
ModuleDiscoveryError,
|
||||
type ModuleDiscoveryIssueCode,
|
||||
resolveModulesDirectory,
|
||||
} from "../src/modules/index.js";
|
||||
|
||||
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const;
|
||||
const repositoryRoot = path.join(path.sep, "opt", "ekklesia");
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-modules-directory-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function captureDiscoveryError(operation: () => unknown): ModuleDiscoveryError {
|
||||
try {
|
||||
operation();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ModuleDiscoveryError);
|
||||
return error as ModuleDiscoveryError;
|
||||
}
|
||||
throw new Error("Expected a ModuleDiscoveryError.");
|
||||
}
|
||||
|
||||
async function captureAsyncDiscoveryError(
|
||||
operation: Promise<unknown>,
|
||||
): Promise<ModuleDiscoveryError> {
|
||||
try {
|
||||
await operation;
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ModuleDiscoveryError);
|
||||
return error as ModuleDiscoveryError;
|
||||
}
|
||||
throw new Error("Expected a ModuleDiscoveryError.");
|
||||
}
|
||||
|
||||
function codes(error: ModuleDiscoveryError): readonly ModuleDiscoveryIssueCode[] {
|
||||
return error.issues.map((issue) => issue.code);
|
||||
}
|
||||
|
||||
/** Everything an operator, a log, or a test report could ever see from this error. */
|
||||
function rendered(error: ModuleDiscoveryError): string {
|
||||
return JSON.stringify({ issues: error.issues, message: error.message, stack: error.stack });
|
||||
}
|
||||
|
||||
describe("resolveModulesDirectory", () => {
|
||||
it("defaults to the repository's own modules directory when nothing is configured", () => {
|
||||
expect(resolveModulesDirectory({ configured: undefined, repositoryRoot })).toEqual({
|
||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||
modulesDirectoryRequired: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts an explicit absolute path, normalized, and requires it to exist", () => {
|
||||
expect(
|
||||
resolveModulesDirectory({ configured: "/var/lib/ekklesia/modules/", repositoryRoot }),
|
||||
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
|
||||
expect(
|
||||
resolveModulesDirectory({ configured: "/var/lib/./ekklesia//modules", repositoryRoot }),
|
||||
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
|
||||
});
|
||||
|
||||
it("never resolves a configured path against the repository or the working directory", () => {
|
||||
const resolved = resolveModulesDirectory({ configured: "/srv/modules", repositoryRoot });
|
||||
expect(resolved.modulesDirectory).toBe("/srv/modules");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a relative name", "modules"],
|
||||
["a dot-relative path", "./modules"],
|
||||
["a parent-relative path", "../private/modules"],
|
||||
["a home-relative path", "~/modules"],
|
||||
["the empty string", ""],
|
||||
["whitespace only", " "],
|
||||
["an absolute path with surrounding whitespace", " /var/lib/ekklesia/modules"],
|
||||
["a NUL byte", "/var/lib/ekklesia/\u0000modules"],
|
||||
])("rejects %s with a sanitized MODULES_DIRECTORY_INVALID", (_label, configured) => {
|
||||
const error = captureDiscoveryError(() =>
|
||||
resolveModulesDirectory({ configured, repositoryRoot }),
|
||||
);
|
||||
expect(codes(error)).toEqual(["MODULES_DIRECTORY_INVALID"]);
|
||||
expect(error.issues[0]?.message).toBe("EKKLESIA_MODULES_DIR must be an absolute path.");
|
||||
});
|
||||
|
||||
it("never echoes the supplied value in the error", () => {
|
||||
const configured = "relative/secret-token-a1b2c3/postgresql://admin:hunter2@db/modules";
|
||||
const error = captureDiscoveryError(() =>
|
||||
resolveModulesDirectory({ configured, repositoryRoot }),
|
||||
);
|
||||
const text = rendered(error);
|
||||
expect(text).not.toContain("secret-token-a1b2c3");
|
||||
expect(text).not.toContain("hunter2");
|
||||
expect(text).not.toContain("relative/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("module discovery against a configured modules directory", () => {
|
||||
it("fails closed, without the path, when a configured directory does not exist", async () => {
|
||||
const parent = await temporaryDirectory();
|
||||
const missing = path.join(parent, "private-mount-name-7f3e");
|
||||
const error = await captureAsyncDiscoveryError(
|
||||
discoverModules({ ...versions, modulesDirectory: missing, modulesDirectoryRequired: true }),
|
||||
);
|
||||
expect(codes(error)).toEqual(["MODULES_DIRECTORY_UNREADABLE"]);
|
||||
expect(rendered(error)).not.toContain("private-mount-name-7f3e");
|
||||
expect(rendered(error)).not.toContain(parent);
|
||||
});
|
||||
|
||||
it("fails closed when a configured path is a file rather than a directory", async () => {
|
||||
const parent = await temporaryDirectory();
|
||||
const file = path.join(parent, "modules");
|
||||
await writeFile(file, "not a directory");
|
||||
const error = await captureAsyncDiscoveryError(
|
||||
discoverModules({ ...versions, modulesDirectory: file, modulesDirectoryRequired: true }),
|
||||
);
|
||||
expect(codes(error)).toEqual(["MODULES_DIRECTORY_UNREADABLE"]);
|
||||
expect(rendered(error)).not.toContain(parent);
|
||||
});
|
||||
|
||||
it("rejects a relative configured path even when it bypasses the resolver", async () => {
|
||||
const error = await captureAsyncDiscoveryError(
|
||||
discoverModules({ ...versions, modulesDirectory: "modules", modulesDirectoryRequired: true }),
|
||||
);
|
||||
expect(codes(error)).toEqual(["MODULES_DIRECTORY_INVALID"]);
|
||||
});
|
||||
|
||||
it("treats an existing, empty configured directory as zero installed modules", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
await expect(
|
||||
discoverModules({ ...versions, modulesDirectory: directory, modulesDirectoryRequired: true }),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the development default lenient: a missing default directory is zero modules", async () => {
|
||||
const parent = await temporaryDirectory();
|
||||
await expect(
|
||||
discoverModules({ ...versions, modulesDirectory: path.join(parent, "modules") }),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("discovers through the resolver's own selection unchanged", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const selection = resolveModulesDirectory({ configured: directory, repositoryRoot });
|
||||
await expect(discoverModules({ ...versions, ...selection })).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user