feat: add the minimal operational module lifecycle
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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
db67cf7700
commit
7729c99e24
@@ -0,0 +1,173 @@
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
createModuleLifecycleDatabase,
|
||||
createModuleLifecycleStore,
|
||||
discoverModules,
|
||||
ModuleDiscoveryError,
|
||||
ModuleLifecycleStoreError,
|
||||
resolveModuleEligibility,
|
||||
} from "../modules/index.js";
|
||||
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
||||
|
||||
/**
|
||||
* Enables or disables one installed module, out of band from the running API.
|
||||
*
|
||||
* This exists because module startup is deliberately all-or-nothing (ADR 0015, ADR 0020, preserved
|
||||
* by ADR 0028): a module whose `start` hook throws takes API startup down with it, and the HTTP
|
||||
* lifecycle API that could otherwise turn it off is served by that same process. Without an
|
||||
* out-of-band path, the only recovery from one bad module would be editing the database by hand.
|
||||
* This command is that path — the same shape as `pnpm authorization:bootstrap-owner`: explicit,
|
||||
* operator-invoked, never HTTP-exposed, and never inferred.
|
||||
*
|
||||
* It writes configuration and nothing else. It starts nothing, stops nothing, imports no module
|
||||
* server entrypoint, runs no lifecycle hook, and applies no migration. The change takes effect on
|
||||
* the next API start.
|
||||
*/
|
||||
class ModuleLifecycleCliError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ModuleLifecycleCliError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
const usage =
|
||||
"Usage: pnpm modules:enable -- --module <module-id> | pnpm modules:disable -- --module <module-id>";
|
||||
|
||||
interface ParsedArgs {
|
||||
readonly enabled: boolean;
|
||||
readonly moduleId: string;
|
||||
}
|
||||
|
||||
/** A minimal, hand-rolled flag parser, exactly like `bootstrap-platform-owner.ts`: this command
|
||||
* takes one named flag plus one mode flag, so a general-purpose argument dependency is not
|
||||
* warranted. `--enable` and `--disable` are mutually exclusive and one is required, so the intent
|
||||
* is always stated rather than defaulted. */
|
||||
function parseArgs(argv: readonly string[]): ParsedArgs {
|
||||
let moduleId: string | undefined;
|
||||
let enabled: boolean | undefined;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const flag = argv[index];
|
||||
if (flag === "--module") {
|
||||
moduleId = argv[index + 1];
|
||||
index += 1;
|
||||
} else if (flag === "--enable") {
|
||||
if (enabled === false) {
|
||||
throw new ModuleLifecycleCliError(usage);
|
||||
}
|
||||
enabled = true;
|
||||
} else if (flag === "--disable") {
|
||||
if (enabled === true) {
|
||||
throw new ModuleLifecycleCliError(usage);
|
||||
}
|
||||
enabled = false;
|
||||
}
|
||||
}
|
||||
if (moduleId === undefined || enabled === undefined) {
|
||||
throw new ModuleLifecycleCliError(usage);
|
||||
}
|
||||
return { enabled, moduleId };
|
||||
}
|
||||
|
||||
interface PackageVersion {
|
||||
readonly version?: unknown;
|
||||
}
|
||||
|
||||
async function readPackageVersion(packagePath: string): Promise<string> {
|
||||
const packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersion;
|
||||
if (typeof packageData.version !== "string" || packageData.version.trim().length === 0) {
|
||||
throw new ModuleLifecycleCliError("The repository package metadata could not be read.");
|
||||
}
|
||||
return packageData.version;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { enabled, moduleId } = parseArgs(process.argv.slice(2));
|
||||
const repositoryRoot = process.cwd();
|
||||
const [platformVersion, moduleSdkVersion] = await Promise.all([
|
||||
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
||||
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
||||
]);
|
||||
|
||||
// The same rule the HTTP API enforces: a module this deployment cannot currently see may not be
|
||||
// configured. Manifest-only, exactly like `pnpm modules:validate` — no module server entrypoint
|
||||
// is imported and no lifecycle hook runs, so a module that crashes during `start` is still fully
|
||||
// configurable here, which is the case this command exists for.
|
||||
const discovered = await discoverModules({
|
||||
moduleSdkVersion,
|
||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||
platformVersion,
|
||||
});
|
||||
if (!discovered.some((module) => module.manifest.id === moduleId)) {
|
||||
throw new ModuleLifecycleCliError(
|
||||
`No installed module declares the id "${moduleId}". No changes were made.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Read only when this command actually executes — never merely by importing this module.
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
|
||||
throw new ModuleLifecycleCliError("DATABASE_URL is required.");
|
||||
}
|
||||
|
||||
// Asserted before the connection is constructed, so an unsafe production transport never opens a
|
||||
// client at all.
|
||||
const transport = resolveExecutableDatabaseTransport(databaseUrl);
|
||||
const { close, db } = createModuleLifecycleDatabase(databaseUrl, {
|
||||
connectionTimeoutMs: 10_000,
|
||||
operationTimeoutMs: 10_000,
|
||||
transport,
|
||||
});
|
||||
try {
|
||||
const store = createModuleLifecycleStore(db);
|
||||
await store.setEnabled({ enabled, moduleId });
|
||||
const resolution = resolveModuleEligibility(discovered, await store.readConfiguration());
|
||||
console.log(
|
||||
`Module "${moduleId}" is now configured ${enabled ? "enabled" : "disabled"}. Restart the API to apply it.`,
|
||||
);
|
||||
const blocked = resolution.entries.filter((entry) => entry.blocked !== undefined);
|
||||
for (const entry of blocked) {
|
||||
console.log(
|
||||
`- "${entry.moduleId}" will not start: ${entry.blocked?.reason} (${entry.blocked?.dependency.moduleId}).`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function reportCliError(error: unknown): void {
|
||||
if (error instanceof ModuleLifecycleCliError) {
|
||||
console.error(error.message);
|
||||
return;
|
||||
}
|
||||
if (error instanceof ModuleLifecycleStoreError) {
|
||||
console.error(`[${error.code}] ${error.message}`);
|
||||
return;
|
||||
}
|
||||
if (error instanceof ModuleDiscoveryError) {
|
||||
console.error(error.message);
|
||||
for (const issue of error.issues) {
|
||||
const issuePath = issue.path ? ` (${issue.path})` : "";
|
||||
console.error(`[${issue.code}] ${issue.moduleDirectory}${issuePath}: ${issue.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error("The module enablement change failed.");
|
||||
}
|
||||
|
||||
function isDirectExecution(): boolean {
|
||||
const entrypoint = process.argv[1];
|
||||
return (
|
||||
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
|
||||
);
|
||||
}
|
||||
|
||||
if (isDirectExecution()) {
|
||||
main().catch((error: unknown) => {
|
||||
reportCliError(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user