355 lines
13 KiB
TypeScript
355 lines
13 KiB
TypeScript
import {
|
|
type ModuleConfigurationDeclarationInput,
|
|
type ModuleSecretSource,
|
|
parseModuleManifest,
|
|
} from "@ekklesia/contracts";
|
|
import { describe, expect, it } from "vitest";
|
|
import type { DiscoveredModule } from "../src/modules/discovery.js";
|
|
import { resolveModuleEligibility, resolveModuleLifecycleView } from "../src/modules/lifecycle.js";
|
|
|
|
/** A minimal in-memory `DiscoveredModule`, built through `parseModuleManifest` so a fixture real
|
|
* discovery would reject can never stand in for one it would accept. */
|
|
function fakeModule(
|
|
id: string,
|
|
options: {
|
|
readonly configuration?: readonly ModuleConfigurationDeclarationInput[];
|
|
readonly dependencies?: Record<string, unknown>;
|
|
} = {},
|
|
): DiscoveredModule {
|
|
const manifest = parseModuleManifest({
|
|
manifestVersion: 6,
|
|
id,
|
|
name: `${id} module`,
|
|
version: "1.0.0",
|
|
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
|
configuration: options.configuration ?? [],
|
|
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
|
entrypoints: { 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 enablement(...records: readonly (readonly [string, boolean])[]) {
|
|
return { modules: records.map(([moduleId, enabled]) => ({ moduleId, enabled })) };
|
|
}
|
|
|
|
function settings(...records: readonly (readonly [string, string, string])[]) {
|
|
return {
|
|
records: records.map(([moduleId, key, value]) => ({ key, moduleId, value })),
|
|
};
|
|
}
|
|
|
|
function secretsFor(
|
|
map: Readonly<Record<string, Readonly<Record<string, string>>>>,
|
|
): ModuleSecretSource {
|
|
return { get: (moduleId, key) => map[moduleId]?.[key] };
|
|
}
|
|
|
|
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("configuration decides eligibility, through the existing lifecycle resolver", () => {
|
|
it("keeps a module with satisfied configuration eligible, and hands it a snapshot", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("announcements", {
|
|
configuration: [{ key: "bucket", type: "string", required: true }],
|
|
}),
|
|
],
|
|
enablement(),
|
|
{ settings: settings(["announcements", "bucket", "announcements"]) },
|
|
);
|
|
|
|
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
|
expect(entry(resolution, "announcements").blocked).toBeUndefined();
|
|
// The same pass that decided eligibility produced the snapshot, so the configuration that made
|
|
// the module eligible is the identical object it will receive.
|
|
expect(resolution.configurations.get("announcements")?.getString("bucket")).toBe(
|
|
"announcements",
|
|
);
|
|
});
|
|
|
|
it("blocks an enabled module whose required configuration is missing, naming the key", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("announcements", {
|
|
configuration: [{ key: "bucket", type: "string", required: true }],
|
|
}),
|
|
],
|
|
enablement(),
|
|
{ settings: settings() },
|
|
);
|
|
|
|
expect(resolution.eligibleModules).toEqual([]);
|
|
expect(entry(resolution, "announcements").blocked).toEqual({
|
|
configurationKey: "bucket",
|
|
reason: "configuration_missing",
|
|
});
|
|
// No snapshot for a module that is not going to start.
|
|
expect(resolution.configurations.has("announcements")).toBe(false);
|
|
});
|
|
|
|
it("blocks on an invalid stored value rather than starting on a default nobody chose", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("announcements", {
|
|
configuration: [{ key: "slideSeconds", type: "integer", default: 7, maximum: 600 }],
|
|
}),
|
|
],
|
|
enablement(),
|
|
{ settings: settings(["announcements", "slideSeconds", "100000"]) },
|
|
);
|
|
|
|
expect(entry(resolution, "announcements").blocked).toEqual({
|
|
configurationKey: "slideSeconds",
|
|
reason: "configuration_invalid",
|
|
});
|
|
});
|
|
|
|
it("blocks on a missing required secret, distinctly from a missing ordinary value", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("announcements", {
|
|
configuration: [{ key: "storageCredential", type: "secret", required: true }],
|
|
}),
|
|
],
|
|
enablement(),
|
|
{ settings: settings() },
|
|
);
|
|
|
|
// A different reason because it has a different fix: set an environment variable on the
|
|
// deployment, not a value in the database.
|
|
expect(entry(resolution, "announcements").blocked).toEqual({
|
|
configurationKey: "storageCredential",
|
|
reason: "secret_missing",
|
|
});
|
|
});
|
|
|
|
it("becomes eligible once the deployment supplies the secret, with no stored value involved", () => {
|
|
const module = fakeModule("announcements", {
|
|
configuration: [{ key: "storageCredential", type: "secret", required: true }],
|
|
});
|
|
|
|
expect(
|
|
resolveModuleEligibility([module], enablement(), { settings: settings() }).eligibleModules,
|
|
).toEqual([]);
|
|
expect(
|
|
ids(
|
|
resolveModuleEligibility([module], enablement(), {
|
|
secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }),
|
|
settings: settings(),
|
|
}).eligibleModules,
|
|
),
|
|
).toEqual(["announcements"]);
|
|
});
|
|
|
|
it("does not evaluate a disabled module's configuration at all", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("announcements", {
|
|
configuration: [{ key: "bucket", type: "string", required: true }],
|
|
}),
|
|
],
|
|
enablement(["announcements", false]),
|
|
{ settings: settings() },
|
|
);
|
|
|
|
// Disabled is not blocked: it is doing what it was told, and reporting a configuration problem
|
|
// for something nobody asked to run would be noise.
|
|
expect(entry(resolution, "announcements").blocked).toBeUndefined();
|
|
expect(entry(resolution, "announcements").configured.enabled).toBe(false);
|
|
});
|
|
|
|
it("leaves a module declaring no configuration exactly as it was", () => {
|
|
const resolution = resolveModuleEligibility([fakeModule("calendar")], enablement(), {
|
|
settings: settings(),
|
|
});
|
|
|
|
expect(ids(resolution.eligibleModules)).toEqual(["calendar"]);
|
|
expect(resolution.configurations.get("calendar")?.keys()).toEqual([]);
|
|
});
|
|
|
|
it("resolves identically when no configuration input is supplied at all", () => {
|
|
// The third argument is optional, so every pre-existing caller keeps its exact behaviour.
|
|
const withoutInput = resolveModuleEligibility([fakeModule("calendar")], enablement());
|
|
const withEmptyInput = resolveModuleEligibility([fakeModule("calendar")], enablement(), {
|
|
settings: settings(),
|
|
});
|
|
|
|
expect(withoutInput.entries).toEqual(withEmptyInput.entries);
|
|
expect(ids(withoutInput.eligibleModules)).toEqual(["calendar"]);
|
|
});
|
|
});
|
|
|
|
describe("configuration blocking propagates through the dependency graph", () => {
|
|
const graph = [
|
|
fakeModule("storage", {
|
|
configuration: [{ key: "bucket", type: "string", required: true }],
|
|
}),
|
|
fakeModule("media", { dependencies: { modules: { required: { storage: "^1.0.0" } } } }),
|
|
fakeModule("announcements", { dependencies: { modules: { required: { media: "^1.0.0" } } } }),
|
|
];
|
|
|
|
it("blocks required dependents of a configuration-blocked module, transitively", () => {
|
|
const resolution = resolveModuleEligibility(graph, enablement(), { settings: settings() });
|
|
|
|
expect(resolution.eligibleModules).toEqual([]);
|
|
// The chain terminates at the module whose own configuration is at fault, exactly as it
|
|
// terminates at a disabled module — one mechanism, not two.
|
|
expect(entry(resolution, "storage").blocked).toEqual({
|
|
configurationKey: "bucket",
|
|
reason: "configuration_missing",
|
|
});
|
|
expect(entry(resolution, "media").blocked).toEqual({
|
|
dependency: { kind: "module", moduleId: "storage" },
|
|
reason: "required_dependency_blocked",
|
|
});
|
|
expect(entry(resolution, "announcements").blocked).toEqual({
|
|
dependency: { kind: "module", moduleId: "media" },
|
|
reason: "required_dependency_blocked",
|
|
});
|
|
});
|
|
|
|
it("unblocks the whole chain once the configuration is supplied", () => {
|
|
const resolution = resolveModuleEligibility(graph, enablement(), {
|
|
settings: settings(["storage", "bucket", "media-objects"]),
|
|
});
|
|
|
|
expect(ids(resolution.eligibleModules)).toEqual(["storage", "media", "announcements"]);
|
|
});
|
|
|
|
it("never blocks an unrelated module because of another module's configuration", () => {
|
|
const resolution = resolveModuleEligibility([...graph, fakeModule("calendar")], enablement(), {
|
|
settings: settings(),
|
|
});
|
|
|
|
expect(ids(resolution.eligibleModules)).toEqual(["calendar"]);
|
|
expect(entry(resolution, "calendar").blocked).toBeUndefined();
|
|
});
|
|
|
|
it("drops the snapshot of a module that resolved its own configuration but lost eligibility", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("storage"),
|
|
fakeModule("announcements", {
|
|
configuration: [{ key: "bucket", type: "string", default: "b" }],
|
|
dependencies: { modules: { required: { storage: "^1.0.0" } } },
|
|
}),
|
|
],
|
|
enablement(["storage", false]),
|
|
{ settings: settings() },
|
|
);
|
|
|
|
// Its own configuration was fine; it is blocked by its dependency. The map must still contain
|
|
// exactly the modules that are going to start.
|
|
expect(entry(resolution, "announcements").blocked?.reason).toBe("required_dependency_disabled");
|
|
expect(resolution.configurations.has("announcements")).toBe(false);
|
|
expect([...resolution.configurations.keys()]).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("configuration changes are restart-required, and reported honestly", () => {
|
|
const module = fakeModule("announcements", {
|
|
configuration: [{ key: "slideSeconds", type: "integer", default: 7, minimum: 1, maximum: 600 }],
|
|
});
|
|
const discoveredModules = [module];
|
|
|
|
function bootSnapshot(bootSettings: ReturnType<typeof settings>) {
|
|
const resolution = resolveModuleEligibility(discoveredModules, enablement(), {
|
|
settings: bootSettings,
|
|
});
|
|
return {
|
|
discoveredModules,
|
|
eligibility: resolution.entries,
|
|
startedModuleIds: resolution.eligibleModules.map((entry) => entry.manifest.id),
|
|
};
|
|
}
|
|
|
|
it("does not claim a running module changed when only stored configuration did", () => {
|
|
const view = resolveModuleLifecycleView(bootSnapshot(settings()), enablement(), {
|
|
settings: settings(["announcements", "slideSeconds", "12"]),
|
|
});
|
|
|
|
// The module really is still running with 7; a configuration change is applied by the next
|
|
// restart, and this reports exactly that rather than pretending the value took effect.
|
|
expect(view[0]?.runtime).toEqual({ state: "running" });
|
|
expect(view[0]?.restartRequired).toBe(false);
|
|
});
|
|
|
|
it("reports a pending restart when new configuration would change whether the module runs", () => {
|
|
// Boot with a value that blocked the module, then correct it.
|
|
const blocked = resolveModuleEligibility(discoveredModules, enablement(), {
|
|
settings: settings(["announcements", "slideSeconds", "0"]),
|
|
});
|
|
expect(blocked.eligibleModules).toEqual([]);
|
|
|
|
const view = resolveModuleLifecycleView(
|
|
{
|
|
discoveredModules,
|
|
eligibility: blocked.entries,
|
|
startedModuleIds: [],
|
|
},
|
|
enablement(),
|
|
{ settings: settings(["announcements", "slideSeconds", "12"]) },
|
|
);
|
|
|
|
// Correcting the value must show as restart-pending: the next boot really will start it.
|
|
expect(view[0]?.runtime).toEqual({
|
|
configurationKey: "slideSeconds",
|
|
reason: "configuration_invalid",
|
|
state: "blocked",
|
|
});
|
|
expect(view[0]?.restartRequired).toBe(true);
|
|
});
|
|
|
|
it("reports the boot-time configuration reason, not a recomputed one", () => {
|
|
const blocked = resolveModuleEligibility(discoveredModules, enablement(), {
|
|
settings: settings(["announcements", "slideSeconds", "0"]),
|
|
});
|
|
const view = resolveModuleLifecycleView(
|
|
{ discoveredModules, eligibility: blocked.entries, startedModuleIds: [] },
|
|
enablement(),
|
|
{ settings: settings(["announcements", "slideSeconds", "0"]) },
|
|
);
|
|
|
|
expect(view[0]?.runtime).toEqual({
|
|
configurationKey: "slideSeconds",
|
|
reason: "configuration_invalid",
|
|
state: "blocked",
|
|
});
|
|
expect(view[0]?.restartRequired).toBe(false);
|
|
});
|
|
|
|
it("never puts a configuration value into the lifecycle view, only the key", () => {
|
|
const blocked = resolveModuleEligibility(discoveredModules, enablement(), {
|
|
settings: settings(["announcements", "slideSeconds", "0"]),
|
|
});
|
|
const view = resolveModuleLifecycleView(
|
|
{ discoveredModules, eligibility: blocked.entries, startedModuleIds: [] },
|
|
enablement(),
|
|
{ settings: settings(["announcements", "slideSeconds", "0"]) },
|
|
);
|
|
|
|
expect(JSON.stringify(view)).toContain("slideSeconds");
|
|
expect(JSON.stringify(view)).not.toContain('"0"');
|
|
});
|
|
});
|