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
@@ -1,5 +1,6 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
capabilityIdSchema,
|
||||
isVersionCompatible,
|
||||
moduleIdSchema,
|
||||
permissionIdSchema,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
} from "../identity/config.js";
|
||||
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
|
||||
import { identityAdapterSchema } from "../identity/schema.js";
|
||||
import type { PostgresPoolOptions } from "../database/pool.js";
|
||||
import {
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
@@ -39,13 +41,18 @@ import {
|
||||
} from "../migrations/index.js";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleLifecycleDatabase,
|
||||
createModuleLifecycleStore,
|
||||
type DiscoveredModule,
|
||||
discoverModuleSchemas,
|
||||
discoverModules,
|
||||
type ModuleEligibility,
|
||||
ModulePermissionCompositionError,
|
||||
type ModuleDiscoveryOptions,
|
||||
type ModuleLifecycleConfiguration,
|
||||
type ModuleMigrationAggregate,
|
||||
type ModuleSchemaAggregate,
|
||||
resolveModuleEligibility,
|
||||
} from "../modules/index.js";
|
||||
|
||||
export type DoctorCheckId =
|
||||
@@ -54,6 +61,7 @@ export type DoctorCheckId =
|
||||
| "modules.discovery"
|
||||
| "modules.dependencies"
|
||||
| "modules.permissions"
|
||||
| "modules.lifecycle"
|
||||
| "schemas.discovery"
|
||||
| "migrations.discovery"
|
||||
| "postgresql.connection"
|
||||
@@ -113,6 +121,13 @@ export interface DoctorDependencies {
|
||||
options: ModuleDiscoveryOptions,
|
||||
) => Promise<readonly DiscoveredModule[]>;
|
||||
readonly discoverSchemas: (options: ModuleDiscoveryOptions) => Promise<ModuleSchemaAggregate>;
|
||||
/** Reads the stored module enablement configuration read-only. Separated behind the dependency
|
||||
* boundary like every other I/O Doctor performs, so the whole report stays exercisable without a
|
||||
* database. */
|
||||
readonly readModuleLifecycle: (
|
||||
connectionString: string,
|
||||
options: PostgresPoolOptions,
|
||||
) => Promise<ModuleLifecycleConfiguration>;
|
||||
readonly planMigrations: (options: {
|
||||
readonly aggregate: ModuleMigrationAggregate;
|
||||
readonly database: MigrationDatabase;
|
||||
@@ -134,6 +149,20 @@ async function discoverAllMigrationsForRuntime(
|
||||
});
|
||||
}
|
||||
|
||||
/** Opens its own short-lived connection, reads, and always closes it — Doctor never leaves a pool
|
||||
* behind, and never shares one with the migration planner's, whose lifetime it does not control. */
|
||||
async function readModuleLifecycleForRuntime(
|
||||
connectionString: string,
|
||||
options: PostgresPoolOptions,
|
||||
): Promise<ModuleLifecycleConfiguration> {
|
||||
const { close, db } = createModuleLifecycleDatabase(connectionString, options);
|
||||
try {
|
||||
return await createModuleLifecycleStore(db).readConfiguration();
|
||||
} finally {
|
||||
await close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const productionDependencies: DoctorDependencies = Object.freeze({
|
||||
composePermissions: composePlatformPermissionRegistry,
|
||||
createMigrationDatabase: createPostgresMigrationDatabase,
|
||||
@@ -141,6 +170,7 @@ const productionDependencies: DoctorDependencies = Object.freeze({
|
||||
discoverModules,
|
||||
discoverSchemas: discoverModuleSchemas,
|
||||
planMigrations: planModuleMigrations,
|
||||
readModuleLifecycle: readModuleLifecycleForRuntime,
|
||||
runtimeSatisfies: isVersionCompatible,
|
||||
});
|
||||
|
||||
@@ -249,6 +279,70 @@ function permissionCompositionFailure(error: unknown): DoctorCheck {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one module's operational state in a form that is safe by construction: only a module id, a
|
||||
* semantic version, a fixed state word, a fixed reason code from a closed set, and — for a
|
||||
* capability edge — a capability id. Identity values are re-validated before interpolation, exactly
|
||||
* like `safeModuleDetails` and `safeMigrationReference` do, so a future drift bug cannot turn this
|
||||
* into a disclosure channel. No filesystem path, package name, manifest path, or caught error can
|
||||
* reach this line, because none of them is on the record it reads.
|
||||
*/
|
||||
function safeLifecycleDetail(entry: ModuleEligibility): string {
|
||||
if (
|
||||
!moduleIdSchema.safeParse(entry.moduleId).success ||
|
||||
!semanticVersionSchema.safeParse(entry.version).success
|
||||
) {
|
||||
throw new TypeError("Unsafe module lifecycle diagnostic value.");
|
||||
}
|
||||
const identity = `${entry.moduleId} ${entry.version}`;
|
||||
if (!entry.configured.enabled) {
|
||||
return `${identity} · disabled${entry.configured.explicit ? "" : " (default)"}`;
|
||||
}
|
||||
if (!entry.blocked) {
|
||||
return `${identity} · enabled`;
|
||||
}
|
||||
const dependency = entry.blocked.dependency;
|
||||
if (!moduleIdSchema.safeParse(dependency.moduleId).success) {
|
||||
throw new TypeError("Unsafe module lifecycle diagnostic value.");
|
||||
}
|
||||
const target =
|
||||
dependency.kind === "capability" &&
|
||||
dependency.capabilityId !== undefined &&
|
||||
capabilityIdSchema.safeParse(dependency.capabilityId).success
|
||||
? `${dependency.capabilityId} (${dependency.moduleId})`
|
||||
: dependency.moduleId;
|
||||
return `${identity} · blocked · ${entry.blocked.reason} · ${target}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports what an operator most wants to know before anything else: which installed modules this
|
||||
* deployment would actually run, and why any of them would not.
|
||||
*
|
||||
* Configuration lives in PostgreSQL, so this check is honestly skipped when `DATABASE_URL` is not
|
||||
* configured rather than reporting a default that might not be what is stored. A **disabled** module
|
||||
* is a `pass` with a detail line: it is a decision somebody made, not a problem. A **blocked** module
|
||||
* is a `warning`: an enabled module that cannot run is an anomaly worth surfacing, and it is exactly
|
||||
* the question "why is this module unavailable?" that would otherwise send an operator to the logs.
|
||||
*/
|
||||
function moduleLifecycleCheck(entries: readonly ModuleEligibility[]): DoctorCheck {
|
||||
const disabled = entries.filter((entry) => !entry.configured.enabled).length;
|
||||
const blocked = entries.filter((entry) => entry.blocked !== undefined).length;
|
||||
const eligible = entries.filter((entry) => entry.eligible).length;
|
||||
const details = [
|
||||
`${eligible} eligible · ${disabled} disabled · ${blocked} blocked`,
|
||||
...entries.map(safeLifecycleDetail),
|
||||
];
|
||||
if (blocked > 0) {
|
||||
return freezeCheck(
|
||||
"modules.lifecycle",
|
||||
"warning",
|
||||
blocked === 1 ? "1 enabled module cannot run" : `${blocked} enabled modules cannot run`,
|
||||
details,
|
||||
);
|
||||
}
|
||||
return freezeCheck("modules.lifecycle", "pass", "Module lifecycle resolved", details);
|
||||
}
|
||||
|
||||
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
|
||||
const details =
|
||||
error instanceof Error && error.name === "ModuleDiscoveryError"
|
||||
@@ -574,6 +668,38 @@ export async function diagnoseEkklesia(
|
||||
}
|
||||
}
|
||||
|
||||
// Deliberately independent of permission composition, schema, and migration discovery: an
|
||||
// operator asking "is this module running?" must still get an answer when an unrelated check
|
||||
// failed. It needs only a successful discovery and a configured database.
|
||||
let lifecycleCheck: DoctorCheck;
|
||||
if (!discoveredModules) {
|
||||
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
|
||||
"Complete module discovery is required.",
|
||||
]);
|
||||
} else if (typeof options.databaseUrl !== "string" || options.databaseUrl.trim() === "") {
|
||||
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
|
||||
"DATABASE_URL is not configured.",
|
||||
]);
|
||||
} else {
|
||||
try {
|
||||
const lifecycleTransport = createDatabaseTransportPolicy(options.databaseTransport, {
|
||||
environment: options.databaseEnvironment ?? "production",
|
||||
});
|
||||
const configuration = await dependencies.readModuleLifecycle(options.databaseUrl, {
|
||||
connectionTimeoutMs: doctorPostgresTimeoutMs,
|
||||
operationTimeoutMs: doctorPostgresTimeoutMs,
|
||||
transport: lifecycleTransport,
|
||||
});
|
||||
lifecycleCheck = moduleLifecycleCheck(
|
||||
resolveModuleEligibility(discoveredModules, configuration).entries,
|
||||
);
|
||||
} catch {
|
||||
lifecycleCheck = freezeCheck("modules.lifecycle", "failure", "Module lifecycle unavailable", [
|
||||
"The stored module enablement configuration could not be read safely.",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
let schemaCheck: DoctorCheck;
|
||||
let migrationCheck: DoctorCheck;
|
||||
let migrations: ModuleMigrationAggregate | undefined;
|
||||
@@ -668,6 +794,7 @@ export async function diagnoseEkklesia(
|
||||
moduleCheck,
|
||||
dependencyCheck,
|
||||
permissionsCheck,
|
||||
lifecycleCheck,
|
||||
schemaCheck,
|
||||
migrationCheck,
|
||||
transportCheck,
|
||||
|
||||
Reference in New Issue
Block a user