Files
Ekklesia/core/src/cli/set-module-enabled.ts
T
ekklesiaandClaude Opus 5.5 e36b70d8d4
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
feat: one generic image, with installed modules in installation-owned storage (ADR 0048)
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]>
2026-10-07 08:38:14 -05:00

185 lines
6.9 KiB
TypeScript

import path from "node:path";
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import {
createModuleLifecycleDatabase,
createModuleLifecycleStore,
discoverModules,
isDependencyBlockedDetail,
isStorageBlockedDetail,
ModuleDiscoveryError,
ModuleLifecycleStoreError,
resolveModuleEligibility,
} from "../modules/index.js";
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
import { resolveCommandModulesDirectory } from "./modules-directory-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,
...resolveCommandModulesDirectory(process.env, repositoryRoot),
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.`,
);
for (const entry of resolution.entries) {
const blocked = entry.blocked;
if (!blocked) {
continue;
}
// Names the dependency, the configuration key, or the logical storage bucket — never a
// configuration value, an endpoint, or a credential.
const cause = isDependencyBlockedDetail(blocked)
? blocked.dependency.moduleId
: isStorageBlockedDetail(blocked)
? blocked.storageBucketId
: blocked.configurationKey;
console.log(`- "${entry.moduleId}" will not start: ${blocked.reason} (${cause}).`);
}
} 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;
});
}