Files
Ekklesia/core/src/cli/doctor.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

148 lines
5.2 KiB
TypeScript

import { readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
type DoctorOptions,
diagnoseEkklesia,
doctorExitCode,
formatDoctorReport,
} from "../diagnostics/index.js";
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
import { type ModulesDirectorySelection, resolveModulesDirectory } from "../modules/index.js";
interface ModuleSdkPackageDocument {
readonly version?: unknown;
}
interface RootPackageDocument extends ModuleSdkPackageDocument {
readonly engines?: {
readonly node?: unknown;
};
}
/**
* Everything Doctor needs that comes from the repository itself rather than the deployment
* environment, resolved by the caller so the environment mapping below stays pure and testable.
*/
export interface DoctorCommandMetadata {
readonly moduleSdkVersion: string;
readonly platformVersion: string;
/** Where the platform is installed: Core's migrations live beneath it, and so does the
* development default modules directory. Installed modules may live anywhere else. */
readonly repositoryRoot: string;
readonly runtimeRange: string;
readonly runtimeVersion: string;
}
async function readJson(pathname: string): Promise<unknown> {
return JSON.parse(await readFile(pathname, "utf8")) as unknown;
}
function rootPackageDocument(value: unknown): RootPackageDocument {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new TypeError("Invalid workspace package metadata.");
}
return value as RootPackageDocument;
}
function requiredString(value: unknown): string {
if (typeof value !== "string" || value.length === 0) {
throw new TypeError("Missing workspace package metadata.");
}
return value;
}
/**
* The modules directory exactly as `startApi` would choose it. An unusable configured value is not
* thrown here, because Doctor must still produce a report: it is handed on as a required directory
* for discovery to refuse, which reports it as a sanitized module-discovery failure.
*/
function doctorModulesDirectory(
configured: string | undefined,
repositoryRoot: string,
): ModulesDirectorySelection {
try {
return resolveModulesDirectory({ configured, repositoryRoot });
} catch {
return { modulesDirectory: configured ?? "", modulesDirectoryRequired: true };
}
}
/**
* Turns one deployment environment into Doctor's inputs.
*
* Every infrastructure value comes from `loadHostConfiguration` — the same function `apps/api` calls
* with the same environment — so `pnpm doctor` and `startApi` cannot disagree about what this
* deployment is configured to use. Doctor previously read its own subset here and silently omitted
* object storage and module secrets entirely, which made it report a correctly configured
* installation as having neither.
*
* Everything is handed over **raw**. Doctor validates it and reports a sanitized code, so a
* malformed value appears in the report — which is the whole reason an operator runs this command —
* instead of throwing before a report exists.
*/
export function createDoctorOptions(
environment: HostEnvironment,
metadata: DoctorCommandMetadata,
): DoctorOptions {
const host = loadHostConfiguration(environment);
return {
clientAddress: host.clientAddress,
coreMigrationsDirectory: path.join(metadata.repositoryRoot, "core", "drizzle"),
databaseEnvironment: host.environment,
databaseTransport: host.databaseTransport,
databaseUrl: host.databaseUrl,
identityConfiguration: { ...host.identity, environment: host.environment },
mailConfiguration: host.mail,
moduleDiscovery: {
moduleSdkVersion: metadata.moduleSdkVersion,
...doctorModulesDirectory(host.modulesDirectory, metadata.repositoryRoot),
platformVersion: metadata.platformVersion,
},
moduleSecrets: host.moduleSecrets,
runtimeRange: metadata.runtimeRange,
runtimeVersion: metadata.runtimeVersion,
storageConfiguration: host.storage,
};
}
export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<number> {
const [rootValue, moduleSdkValue] = await Promise.all([
readJson(path.join(repositoryRoot, "package.json")),
readJson(path.join(repositoryRoot, "packages", "module-sdk", "package.json")),
]);
const rootPackage = rootPackageDocument(rootValue);
const moduleSdkPackage = rootPackageDocument(moduleSdkValue);
const report = await diagnoseEkklesia(
createDoctorOptions(process.env, {
moduleSdkVersion: requiredString(moduleSdkPackage.version),
platformVersion: requiredString(rootPackage.version),
repositoryRoot,
runtimeRange: requiredString(rootPackage.engines?.node),
runtimeVersion: process.versions.node,
}),
);
console.log(formatDoctorReport(report).join("\n"));
return doctorExitCode(report);
}
function isDirectExecution(): boolean {
const entrypoint = process.argv[1];
return (
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
);
}
if (isDirectExecution()) {
runDoctorCommand()
.then((exitCode) => {
process.exitCode = exitCode;
})
.catch(() => {
console.error("Ekklesia Doctor failed before diagnostics could be composed.");
process.exitCode = 1;
});
}