Ekklesia had no notion of a module being on or off: the accepted module set was exactly "whatever discovery found on disk", and `ModuleLifecycleStage` named nine states nothing produced or consumed. This replaces that vocabulary with two axes that real code produces — persistent, administrator-owned configuration (`enabled`/`disabled`) and per-boot runtime observation (`running`/`disabled`/`blocked`) — and wires them through startup. - One Core table (`ekklesia_core_module_state`, migration 0004) holding only `module_id` and `enabled`. A module with no record is enabled by default, so deploying a module is still sufficient to run it and discovery never needs an initialization write. A record for an undeployed module is retained and ignored, so redeploying restores the decision. - `resolveModuleEligibility` is pure: a disabled module transitively blocks every module that *requires* it (module and capability edges alike) with a structured reason naming the provider; optional dependencies never block; and a missing, incompatible, or cyclic dependency stays discovery's concern. - `startApi` starts only the eligible set and builds the UI publication registry from that same list, so routes, artifacts, browser inventory, and Dashboard navigation all follow one authoritative set. A disabled module is never imported, so there is no per-request enablement check anywhere. - Startup stays all-or-nothing, deliberately; there is consequently no `failed` runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band recovery path for a module that breaks startup. - Changes take effect at the next start, and the read model reports configuration, runtime, and `restartRequired` separately rather than claiming a module stopped when it has not. - Migration participation and permission composition still span the deployed and discovered inventories respectively, unchanged. - New restricted platform permission `core.modules.lifecycle.manage` gates both reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`) that cannot collide with the module route namespace. - `pnpm doctor` gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading logs. See ADR 0028. Co-Authored-By: Claude Opus 5 <[email protected]>
530 lines
20 KiB
TypeScript
530 lines
20 KiB
TypeScript
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { parseModuleManifest } from "@ekklesia/contracts";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { type DiscoveredModule, discoverModules } from "../src/modules/discovery.js";
|
|
import {
|
|
type ModuleLifecycleConfiguration,
|
|
resolveModuleEligibility,
|
|
resolveModuleLifecycleView,
|
|
} from "../src/modules/lifecycle.js";
|
|
|
|
/** A minimal, in-memory `DiscoveredModule` double, built the same way `module-permissions.test.ts`
|
|
* builds one: the manifest still goes through `parseModuleManifest`, so a fixture real discovery
|
|
* would reject can never silently stand in for one it would accept. Only `manifest` is read by
|
|
* eligibility resolution; the rest exist to satisfy the type. */
|
|
function fakeModule(
|
|
id: string,
|
|
options: {
|
|
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
|
readonly dependencies?: Record<string, unknown>;
|
|
} = {},
|
|
): DiscoveredModule {
|
|
const manifest = parseModuleManifest({
|
|
manifestVersion: 4,
|
|
id,
|
|
name: `${id} module`,
|
|
version: "0.1.0",
|
|
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
|
capabilities: options.capabilities ?? [],
|
|
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
|
entrypoints: {
|
|
// Deliberately a path nothing will ever resolve: eligibility resolution must never import,
|
|
// evaluate, or otherwise touch a module's server entrypoint.
|
|
server: "./dist/does-not-exist/index.js",
|
|
},
|
|
});
|
|
return {
|
|
directory: `/modules/${id}`,
|
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
|
manifest,
|
|
manifestPath: `/modules/${id}/ekklesia.module.json`,
|
|
packageName: `@test/${id}`,
|
|
};
|
|
}
|
|
|
|
function configuration(
|
|
...records: readonly (readonly [string, boolean])[]
|
|
): ModuleLifecycleConfiguration {
|
|
return { modules: records.map(([moduleId, enabled]) => ({ moduleId, enabled })) };
|
|
}
|
|
|
|
function ids(modules: readonly DiscoveredModule[]): readonly string[] {
|
|
return modules.map((module) => module.manifest.id);
|
|
}
|
|
|
|
function entry(
|
|
resolution: ReturnType<typeof resolveModuleEligibility>,
|
|
moduleId: string,
|
|
): ReturnType<typeof resolveModuleEligibility>["entries"][number] {
|
|
const found = resolution.entries.find((candidate) => candidate.moduleId === moduleId);
|
|
if (!found) {
|
|
throw new Error(`No eligibility entry for ${moduleId}.`);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
describe("resolveModuleEligibility — default policy for a newly discovered module", () => {
|
|
it("treats a module with no stored record as enabled, so deploying a module is enough to run it", () => {
|
|
const resolution = resolveModuleEligibility([fakeModule("announcements")], configuration());
|
|
|
|
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
|
expect(entry(resolution, "announcements").configured).toEqual({
|
|
enabled: true,
|
|
explicit: false,
|
|
});
|
|
});
|
|
|
|
it("distinguishes the default from a decision somebody actually made", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[fakeModule("announcements")],
|
|
configuration(["announcements", true]),
|
|
);
|
|
|
|
// Same effect, different provenance — which is exactly what an operator needs to know when
|
|
// they are wondering whether a module is deliberately on.
|
|
expect(entry(resolution, "announcements").configured).toEqual({
|
|
enabled: true,
|
|
explicit: true,
|
|
});
|
|
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
|
});
|
|
|
|
it("never writes, and never enables anything: an explicit disable always wins over the default", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[fakeModule("announcements")],
|
|
configuration(["announcements", false]),
|
|
);
|
|
|
|
expect(resolution.eligibleModules).toEqual([]);
|
|
expect(entry(resolution, "announcements")).toEqual({
|
|
configured: { enabled: false, explicit: true },
|
|
eligible: false,
|
|
moduleId: "announcements",
|
|
version: "0.1.0",
|
|
});
|
|
// A disabled module is not "blocked" — it is doing exactly what it was told.
|
|
expect(entry(resolution, "announcements").blocked).toBeUndefined();
|
|
});
|
|
|
|
it("ignores a stored record for a module that is not deployed, rather than failing startup", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[fakeModule("announcements")],
|
|
configuration(["announcements", true], ["removed-long-ago", false]),
|
|
);
|
|
|
|
expect(resolution.entries.map((candidate) => candidate.moduleId)).toEqual(["announcements"]);
|
|
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
|
});
|
|
|
|
it("restores a retained decision when the module is deployed again", () => {
|
|
const stale = configuration(["announcements", false]);
|
|
|
|
// Absent: the record is inert.
|
|
expect(resolveModuleEligibility([], stale).entries).toEqual([]);
|
|
// Redeployed: the same record decides again, with no new write.
|
|
expect(resolveModuleEligibility([fakeModule("announcements")], stale).eligibleModules).toEqual(
|
|
[],
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("resolveModuleEligibility — required dependencies", () => {
|
|
it("keeps a dependent eligible while its required dependency is enabled", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("storage"),
|
|
fakeModule("announcements", {
|
|
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
|
}),
|
|
],
|
|
configuration(),
|
|
);
|
|
|
|
expect(ids(resolution.eligibleModules)).toEqual(["storage", "announcements"]);
|
|
});
|
|
|
|
it("blocks a dependent when its required module dependency is disabled, and names the dependency", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("storage"),
|
|
fakeModule("announcements", {
|
|
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
|
}),
|
|
],
|
|
configuration(["storage", false]),
|
|
);
|
|
|
|
expect(resolution.eligibleModules).toEqual([]);
|
|
expect(entry(resolution, "announcements").blocked).toEqual({
|
|
dependency: { kind: "module", moduleId: "storage" },
|
|
reason: "required_dependency_disabled",
|
|
});
|
|
// Administrator intent is never overridden: disabling `storage` does not silently re-enable it
|
|
// because something needs it.
|
|
expect(entry(resolution, "storage").configured.enabled).toBe(false);
|
|
});
|
|
|
|
it("blocks a dependent when the required capability's only provider is disabled", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("storage", { capabilities: [{ id: "storage.objects", version: "1.0.0" }] }),
|
|
fakeModule("announcements", {
|
|
dependencies: { capabilities: { required: { "storage.objects": "^1.0.0" } } },
|
|
}),
|
|
],
|
|
configuration(["storage", false]),
|
|
);
|
|
|
|
expect(resolution.eligibleModules).toEqual([]);
|
|
// A required capability is exactly as required as a required module, and the reason names the
|
|
// module an administrator would have to enable — not just the capability id.
|
|
expect(entry(resolution, "announcements").blocked).toEqual({
|
|
dependency: { capabilityId: "storage.objects", kind: "capability", moduleId: "storage" },
|
|
reason: "required_dependency_disabled",
|
|
});
|
|
});
|
|
|
|
it("propagates blocking transitively, and distinguishes the direct cause from the inherited one", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("storage"),
|
|
fakeModule("media", {
|
|
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
|
}),
|
|
fakeModule("announcements", {
|
|
dependencies: { modules: { required: { media: "^0.1.0" } } },
|
|
}),
|
|
],
|
|
configuration(["storage", false]),
|
|
);
|
|
|
|
expect(resolution.eligibleModules).toEqual([]);
|
|
expect(entry(resolution, "media").blocked?.reason).toBe("required_dependency_disabled");
|
|
// Following the chain always terminates at a `..._disabled` entry, so an operator can walk from
|
|
// any blocked module to the one decision that caused it.
|
|
expect(entry(resolution, "announcements").blocked).toEqual({
|
|
dependency: { kind: "module", moduleId: "media" },
|
|
reason: "required_dependency_blocked",
|
|
});
|
|
});
|
|
|
|
it("blocks only the dependents of the disabled module, never an unrelated one", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("storage"),
|
|
fakeModule("announcements", {
|
|
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
|
}),
|
|
fakeModule("calendar"),
|
|
],
|
|
configuration(["storage", false]),
|
|
);
|
|
|
|
expect(ids(resolution.eligibleModules)).toEqual(["calendar"]);
|
|
expect(entry(resolution, "calendar").blocked).toBeUndefined();
|
|
});
|
|
|
|
it("preserves discovery order in the eligible list rather than re-sorting it", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("storage"),
|
|
fakeModule("media", { dependencies: { modules: { required: { storage: "^0.1.0" } } } }),
|
|
fakeModule("announcements", {
|
|
dependencies: { modules: { required: { media: "^0.1.0" } } },
|
|
}),
|
|
],
|
|
configuration(),
|
|
);
|
|
|
|
// Filtering, never re-ordering: a started module's required dependencies still precede it.
|
|
expect(ids(resolution.eligibleModules)).toEqual(["storage", "media", "announcements"]);
|
|
});
|
|
});
|
|
|
|
describe("resolveModuleEligibility — optional dependencies", () => {
|
|
it("leaves a dependent eligible when its optional module dependency is disabled", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("analytics"),
|
|
fakeModule("announcements", {
|
|
dependencies: { modules: { optional: { analytics: "^0.1.0" } } },
|
|
}),
|
|
],
|
|
configuration(["analytics", false]),
|
|
);
|
|
|
|
// A module declaring an optional dependency already accepted that it may be absent — discovery
|
|
// permits an optional dependency to not be installed at all — and "disabled" is that same
|
|
// situation arriving a different way.
|
|
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
|
expect(entry(resolution, "announcements").blocked).toBeUndefined();
|
|
});
|
|
|
|
it("leaves a dependent eligible when its optional capability's provider is disabled", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("analytics", { capabilities: [{ id: "analytics.events", version: "1.0.0" }] }),
|
|
fakeModule("announcements", {
|
|
dependencies: { capabilities: { optional: { "analytics.events": "^1.0.0" } } },
|
|
}),
|
|
],
|
|
configuration(["analytics", false]),
|
|
);
|
|
|
|
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
|
});
|
|
|
|
it("leaves a dependent eligible when its optional dependency is itself blocked", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("storage"),
|
|
fakeModule("analytics", {
|
|
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
|
}),
|
|
fakeModule("announcements", {
|
|
dependencies: { modules: { optional: { analytics: "^0.1.0" } } },
|
|
}),
|
|
],
|
|
configuration(["storage", false]),
|
|
);
|
|
|
|
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
|
});
|
|
|
|
it("blocks on the required edge only, when a module declares both kinds", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("analytics"),
|
|
fakeModule("storage"),
|
|
fakeModule("announcements", {
|
|
dependencies: {
|
|
modules: { optional: { analytics: "^0.1.0" }, required: { storage: "^0.1.0" } },
|
|
},
|
|
}),
|
|
],
|
|
configuration(["analytics", false], ["storage", false]),
|
|
);
|
|
|
|
expect(entry(resolution, "announcements").blocked).toEqual({
|
|
dependency: { kind: "module", moduleId: "storage" },
|
|
reason: "required_dependency_disabled",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveModuleEligibility — over a real discovered inventory", () => {
|
|
const temporaryDirectories: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
|
);
|
|
});
|
|
|
|
async function writeModule(
|
|
modulesDirectory: string,
|
|
id: string,
|
|
dependencies?: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const moduleDirectory = path.join(modulesDirectory, id);
|
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
|
await Promise.all([
|
|
writeFile(
|
|
path.join(moduleDirectory, "package.json"),
|
|
JSON.stringify({
|
|
ekklesia: { layer: "module" },
|
|
name: `@test/module-${id}`,
|
|
type: "module",
|
|
version: "0.1.0",
|
|
}),
|
|
),
|
|
writeFile(
|
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
|
JSON.stringify({
|
|
manifestVersion: 4,
|
|
id,
|
|
name: `${id} module`,
|
|
version: "0.1.0",
|
|
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
|
...(dependencies ? { dependencies } : {}),
|
|
entrypoints: { server: "./dist/server/index.js" },
|
|
}),
|
|
),
|
|
writeFile(
|
|
path.join(moduleDirectory, "dist/server/index.js"),
|
|
'throw new Error("eligibility resolution must never import a module server entrypoint");',
|
|
),
|
|
]);
|
|
}
|
|
|
|
it("resolves against the exact list real discovery produces, without importing any entrypoint", async () => {
|
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-lifecycle-"));
|
|
temporaryDirectories.push(modulesDirectory);
|
|
await writeModule(modulesDirectory, "storage");
|
|
await writeModule(modulesDirectory, "announcements", {
|
|
modules: { required: { storage: "^0.1.0" } },
|
|
});
|
|
|
|
const discovered = await discoverModules({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
});
|
|
|
|
// Discovery's own topological order, which eligibility resolution relies on and never redoes.
|
|
expect(ids(discovered)).toEqual(["storage", "announcements"]);
|
|
|
|
const blocked = resolveModuleEligibility(discovered, configuration(["storage", false]));
|
|
expect(blocked.eligibleModules).toEqual([]);
|
|
// Every fixture entrypoint throws on import; reaching this assertion is direct evidence that
|
|
// nothing here imported one.
|
|
expect(entry(blocked, "announcements").blocked?.reason).toBe("required_dependency_disabled");
|
|
});
|
|
|
|
it("leaves a missing required dependency to discovery, rather than modelling it as a lifecycle state", async () => {
|
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-lifecycle-"));
|
|
temporaryDirectories.push(modulesDirectory);
|
|
await writeModule(modulesDirectory, "announcements", {
|
|
modules: { required: { storage: "^0.1.0" } },
|
|
});
|
|
|
|
// There is deliberately no eligibility state for "required dependency not installed": discovery
|
|
// rejects the whole inventory long before enablement is consulted, so there is exactly one place
|
|
// the dependency graph is validated.
|
|
await expect(
|
|
discoverModules({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
}),
|
|
).rejects.toMatchObject({ name: "ModuleDiscoveryError" });
|
|
});
|
|
});
|
|
|
|
describe("resolveModuleLifecycleView — configuration versus what this process actually did", () => {
|
|
const storage = fakeModule("storage");
|
|
const announcements = fakeModule("announcements", {
|
|
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
|
});
|
|
const discoveredModules = [storage, announcements];
|
|
|
|
function snapshotFor(bootConfiguration: ModuleLifecycleConfiguration) {
|
|
const resolution = resolveModuleEligibility(discoveredModules, bootConfiguration);
|
|
return {
|
|
discoveredModules,
|
|
eligibility: resolution.entries,
|
|
startedModuleIds: resolution.eligibleModules.map((module) => module.manifest.id),
|
|
};
|
|
}
|
|
|
|
it("reports a started module as running, with no restart pending", () => {
|
|
const view = resolveModuleLifecycleView(snapshotFor(configuration()), configuration());
|
|
|
|
expect(view).toEqual([
|
|
{
|
|
configured: { enabled: true, explicit: false },
|
|
moduleId: "storage",
|
|
restartRequired: false,
|
|
runtime: { state: "running" },
|
|
version: "0.1.0",
|
|
},
|
|
{
|
|
configured: { enabled: true, explicit: false },
|
|
moduleId: "announcements",
|
|
restartRequired: false,
|
|
runtime: { state: "running" },
|
|
version: "0.1.0",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("does not claim a module stopped when configuration changed but the process did not", () => {
|
|
const view = resolveModuleLifecycleView(
|
|
snapshotFor(configuration()),
|
|
configuration(["announcements", false]),
|
|
);
|
|
|
|
const changed = view.find((entry) => entry.moduleId === "announcements");
|
|
// The honest triple: configured off, still running, restart pending. Reporting `disabled` here
|
|
// would be a lie about a process that is still serving that module's routes.
|
|
expect(changed?.configured).toEqual({ enabled: false, explicit: true });
|
|
expect(changed?.runtime).toEqual({ state: "running" });
|
|
expect(changed?.restartRequired).toBe(true);
|
|
// An unrelated module is untouched.
|
|
expect(view.find((entry) => entry.moduleId === "storage")?.restartRequired).toBe(false);
|
|
});
|
|
|
|
it("marks restart pending for a dependent too, because it really will stop on the next boot", () => {
|
|
const view = resolveModuleLifecycleView(
|
|
snapshotFor(configuration()),
|
|
configuration(["storage", false]),
|
|
);
|
|
|
|
expect(view.map((entry) => [entry.moduleId, entry.restartRequired])).toEqual([
|
|
["storage", true],
|
|
["announcements", true],
|
|
]);
|
|
expect(view.every((entry) => entry.runtime.state === "running")).toBe(true);
|
|
});
|
|
|
|
it("marks restart pending in the other direction, without claiming the module started", () => {
|
|
const view = resolveModuleLifecycleView(
|
|
snapshotFor(configuration(["announcements", false])),
|
|
configuration(["announcements", true]),
|
|
);
|
|
|
|
const changed = view.find((entry) => entry.moduleId === "announcements");
|
|
expect(changed?.configured).toEqual({ enabled: true, explicit: true });
|
|
expect(changed?.runtime).toEqual({ state: "disabled" });
|
|
expect(changed?.restartRequired).toBe(true);
|
|
});
|
|
|
|
it("explains a blocked module with the boot-time reason, not a recomputed one", () => {
|
|
const view = resolveModuleLifecycleView(
|
|
snapshotFor(configuration(["storage", false])),
|
|
configuration(["storage", false]),
|
|
);
|
|
|
|
expect(view.find((entry) => entry.moduleId === "storage")?.runtime).toEqual({
|
|
state: "disabled",
|
|
});
|
|
expect(view.find((entry) => entry.moduleId === "announcements")?.runtime).toEqual({
|
|
dependency: { kind: "module", moduleId: "storage" },
|
|
reason: "required_dependency_disabled",
|
|
state: "blocked",
|
|
});
|
|
expect(view.every((entry) => entry.restartRequired === false)).toBe(true);
|
|
});
|
|
|
|
it("reports what the runtime actually started, not what eligibility predicted", () => {
|
|
// A snapshot whose started set disagrees with its own eligibility is not reachable while
|
|
// startup stays all-or-nothing. It is constructed here deliberately: the read model must be an
|
|
// observation of the runtime, so if that guarantee ever changes this can never silently report
|
|
// a module as `running` because it was merely eligible.
|
|
const eligible = resolveModuleEligibility(discoveredModules, configuration());
|
|
const view = resolveModuleLifecycleView(
|
|
{
|
|
discoveredModules,
|
|
eligibility: eligible.entries,
|
|
startedModuleIds: ["storage"],
|
|
},
|
|
configuration(),
|
|
);
|
|
|
|
expect(view.find((entry) => entry.moduleId === "storage")?.runtime.state).toBe("running");
|
|
expect(view.find((entry) => entry.moduleId === "announcements")?.runtime.state).toBe("blocked");
|
|
expect(view.find((entry) => entry.moduleId === "announcements")?.restartRequired).toBe(true);
|
|
});
|
|
|
|
it("ignores a stored record for an undeployed module rather than inventing a row for it", () => {
|
|
const view = resolveModuleLifecycleView(
|
|
snapshotFor(configuration()),
|
|
configuration(["removed-long-ago", false]),
|
|
);
|
|
|
|
expect(view.map((entry) => entry.moduleId)).toEqual(["storage", "announcements"]);
|
|
});
|
|
});
|