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
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]>
148 lines
5.2 KiB
TypeScript
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;
|
|
});
|
|
}
|