feat: one generic image, with installed modules in installation-owned storage (ADR 0048)
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
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4d28a82937
commit
e36b70d8d4
+23
-3
@@ -8,6 +8,7 @@ import {
|
||||
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;
|
||||
@@ -25,8 +26,10 @@ interface RootPackageDocument extends ModuleSdkPackageDocument {
|
||||
*/
|
||||
export interface DoctorCommandMetadata {
|
||||
readonly moduleSdkVersion: string;
|
||||
readonly modulesDirectory: 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;
|
||||
}
|
||||
@@ -49,6 +52,22 @@ function requiredString(value: unknown): string {
|
||||
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.
|
||||
*
|
||||
@@ -69,6 +88,7 @@ export function createDoctorOptions(
|
||||
const host = loadHostConfiguration(environment);
|
||||
return {
|
||||
clientAddress: host.clientAddress,
|
||||
coreMigrationsDirectory: path.join(metadata.repositoryRoot, "core", "drizzle"),
|
||||
databaseEnvironment: host.environment,
|
||||
databaseTransport: host.databaseTransport,
|
||||
databaseUrl: host.databaseUrl,
|
||||
@@ -76,7 +96,7 @@ export function createDoctorOptions(
|
||||
mailConfiguration: host.mail,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: metadata.moduleSdkVersion,
|
||||
modulesDirectory: metadata.modulesDirectory,
|
||||
...doctorModulesDirectory(host.modulesDirectory, metadata.repositoryRoot),
|
||||
platformVersion: metadata.platformVersion,
|
||||
},
|
||||
moduleSecrets: host.moduleSecrets,
|
||||
@@ -97,8 +117,8 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
|
||||
const report = await diagnoseEkklesia(
|
||||
createDoctorOptions(process.env, {
|
||||
moduleSdkVersion: requiredString(moduleSdkPackage.version),
|
||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||
platformVersion: requiredString(rootPackage.version),
|
||||
repositoryRoot,
|
||||
runtimeRange: requiredString(rootPackage.engines?.node),
|
||||
runtimeVersion: process.versions.node,
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { HostEnvironment } from "../host/index.js";
|
||||
import { discoverAllMigrations } from "../migrations/index.js";
|
||||
import type { ModuleMigrationAggregate } from "../modules/index.js";
|
||||
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
|
||||
|
||||
interface PackageVersion {
|
||||
readonly version?: unknown;
|
||||
@@ -15,9 +17,16 @@ async function readPackageVersion(packagePath: string): Promise<string> {
|
||||
return packageData.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's migrations come from the platform (`<repositoryRoot>/core/drizzle`); installed modules'
|
||||
* come from the configured modules directory, wherever it is (ADR 0048). Neither is ever derived
|
||||
* from the other.
|
||||
*/
|
||||
export async function discoverRepositoryMigrations(
|
||||
repositoryRoot = process.cwd(),
|
||||
environment: HostEnvironment = process.env,
|
||||
): Promise<ModuleMigrationAggregate> {
|
||||
const modulesDirectory = resolveCommandModulesDirectory(environment, repositoryRoot);
|
||||
const [platformVersion, moduleSdkVersion] = await Promise.all([
|
||||
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
||||
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
||||
@@ -27,7 +36,7 @@ export async function discoverRepositoryMigrations(
|
||||
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion,
|
||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||
...modulesDirectory,
|
||||
platformVersion,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { ModuleMigrationAggregate } from "../modules/index.js";
|
||||
import type { MigrationApplyResult, MigrationPlanResult } from "../migrations/index.js";
|
||||
import type {
|
||||
MigrationApplyResult,
|
||||
MigrationPlanResult,
|
||||
RetainedMigrationHistory,
|
||||
} from "../migrations/index.js";
|
||||
|
||||
function plural(count: number, singular: string): string {
|
||||
return count === 1 ? singular : `${singular}s`;
|
||||
@@ -28,10 +32,19 @@ export function formatMigrationValidationReport(
|
||||
return Object.freeze(lines);
|
||||
}
|
||||
|
||||
/** An absent module's applied history (ADR 0048): kept, reported, and never a reason to stop. */
|
||||
function retainedLines(retained: readonly RetainedMigrationHistory[]): readonly string[] {
|
||||
return retained.map(
|
||||
(module) =>
|
||||
`- [retained] ${module.moduleId}: ${module.appliedCount} applied ${plural(module.appliedCount, "migration")} kept; the module is not installed`,
|
||||
);
|
||||
}
|
||||
|
||||
export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly string[] {
|
||||
const readyCount = plan.pendingCount - plan.approvalRequiredCount;
|
||||
const retainedSummary = plan.retained.length === 0 ? "" : `, ${plan.retained.length} retained`;
|
||||
const lines = [
|
||||
`Migration plan (${plan.appliedCount} applied, ${readyCount} pending, ${plan.approvalRequiredCount} approval-blocked):`,
|
||||
`Migration plan (${plan.appliedCount} applied, ${readyCount} pending, ${plan.approvalRequiredCount} approval-blocked${retainedSummary}):`,
|
||||
];
|
||||
|
||||
for (const entry of plan.entries) {
|
||||
@@ -40,6 +53,7 @@ export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly s
|
||||
if (plan.entries.length === 0) {
|
||||
lines.push("- No installed module migrations.");
|
||||
}
|
||||
lines.push(...retainedLines(plan.retained));
|
||||
lines.push(
|
||||
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
|
||||
);
|
||||
@@ -49,7 +63,10 @@ export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly s
|
||||
|
||||
export function formatMigrationApplyReport(result: MigrationApplyResult): readonly string[] {
|
||||
if (result.noOp) {
|
||||
return Object.freeze(["Migration apply completed: database is already current (no changes)."]);
|
||||
return Object.freeze([
|
||||
"Migration apply completed: database is already current (no changes).",
|
||||
...retainedLines(result.retained),
|
||||
]);
|
||||
}
|
||||
|
||||
const lines = [
|
||||
@@ -58,5 +75,6 @@ export function formatMigrationApplyReport(result: MigrationApplyResult): readon
|
||||
for (const migration of result.applied) {
|
||||
lines.push(`- ${migration.identity} sha256=${migration.checksum}`);
|
||||
}
|
||||
lines.push(...retainedLines(result.retained));
|
||||
return Object.freeze(lines);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
|
||||
import { type ModulesDirectorySelection, resolveModulesDirectory } from "../modules/index.js";
|
||||
|
||||
/**
|
||||
* The modules directory an operator command discovers, read through the same host configuration
|
||||
* `startApi` and Doctor use and resolved by the one Core resolver (ADR 0048). Throws the resolver's
|
||||
* sanitized `ModuleDiscoveryError` for an unusable value, which every command already reports.
|
||||
*/
|
||||
export function resolveCommandModulesDirectory(
|
||||
environment: HostEnvironment,
|
||||
repositoryRoot: string,
|
||||
): ModulesDirectorySelection {
|
||||
return resolveModulesDirectory({
|
||||
configured: loadHostConfiguration(environment).modulesDirectory,
|
||||
repositoryRoot,
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
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.
|
||||
@@ -99,7 +100,7 @@ async function main(): Promise<void> {
|
||||
// configurable here, which is the case this command exists for.
|
||||
const discovered = await discoverModules({
|
||||
moduleSdkVersion,
|
||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||
...resolveCommandModulesDirectory(process.env, repositoryRoot),
|
||||
platformVersion,
|
||||
});
|
||||
if (!discovered.some((module) => module.manifest.id === moduleId)) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ModulePermissionCompositionError,
|
||||
ModuleSchemaDiscoveryError,
|
||||
} from "../modules/index.js";
|
||||
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
|
||||
|
||||
interface PackageVersion {
|
||||
version?: unknown;
|
||||
@@ -30,7 +31,7 @@ async function main(): Promise<void> {
|
||||
]);
|
||||
const schemaAggregate = await discoverModuleSchemas({
|
||||
moduleSdkVersion,
|
||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||
...resolveCommandModulesDirectory(process.env, repositoryRoot),
|
||||
platformVersion,
|
||||
});
|
||||
const modules = schemaAggregate.discoveredModules;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
capabilityIdSchema,
|
||||
EMPTY_MODULE_SECRET_SOURCE,
|
||||
@@ -43,6 +42,7 @@ import {
|
||||
} from "../mail/index.js";
|
||||
import {
|
||||
createPostgresMigrationDatabase,
|
||||
type DiscoverAllMigrationsOptions,
|
||||
discoverAllMigrations,
|
||||
type MigrationDatabase,
|
||||
type MigrationPlanResult,
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
isConfigurationBlockedDetail,
|
||||
isStorageBlockedDetail,
|
||||
type ModuleConfigurationSettings,
|
||||
ModuleDiscoveryError,
|
||||
type ModuleDiscoveryOptions,
|
||||
type ModuleEligibility,
|
||||
type ModuleLifecycleConfiguration,
|
||||
@@ -126,6 +127,9 @@ export interface DoctorIdentityConfigurationOptions {
|
||||
|
||||
export interface DoctorOptions {
|
||||
readonly clientAddress?: ClientAddressConfigInput | undefined;
|
||||
/** Core's own migrations, where the platform is installed. Never derived from the modules
|
||||
* directory: installed modules live in installation-owned storage, anywhere at all (ADR 0048). */
|
||||
readonly coreMigrationsDirectory: string;
|
||||
/** The environment the executable resolved, used only for transport policy evaluation; identity
|
||||
* configuration keeps its own `identityConfiguration.environment` for the same purpose. */
|
||||
readonly databaseEnvironment?: DatabaseTransportEnvironment | undefined;
|
||||
@@ -170,7 +174,7 @@ export interface DoctorDependencies {
|
||||
options: PostgresMigrationDatabaseOptions,
|
||||
) => MigrationDatabase;
|
||||
readonly discoverMigrations: (
|
||||
options: ModuleDiscoveryOptions,
|
||||
options: DiscoverAllMigrationsOptions,
|
||||
) => Promise<ModuleMigrationAggregate>;
|
||||
readonly discoverModules: (
|
||||
options: ModuleDiscoveryOptions,
|
||||
@@ -207,15 +211,6 @@ const checksumPattern = /^[a-f0-9]{64}$/u;
|
||||
const migrationIdentityPattern =
|
||||
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*:\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u;
|
||||
|
||||
async function discoverAllMigrationsForRuntime(
|
||||
options: ModuleDiscoveryOptions,
|
||||
): Promise<ModuleMigrationAggregate> {
|
||||
return discoverAllMigrations({
|
||||
coreMigrationsDirectory: path.join(path.dirname(options.modulesDirectory), "core", "drizzle"),
|
||||
moduleDiscovery: options,
|
||||
});
|
||||
}
|
||||
|
||||
/** 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(
|
||||
@@ -273,7 +268,7 @@ async function probeMailForRuntime(config: MailProviderConfig): Promise<void> {
|
||||
const productionDependencies: DoctorDependencies = Object.freeze({
|
||||
composePermissions: composePlatformPermissionRegistry,
|
||||
createMigrationDatabase: createPostgresMigrationDatabase,
|
||||
discoverMigrations: discoverAllMigrationsForRuntime,
|
||||
discoverMigrations: discoverAllMigrations,
|
||||
discoverModules,
|
||||
discoverSchemas: discoverModuleSchemas,
|
||||
planMigrations: planModuleMigrations,
|
||||
@@ -633,11 +628,23 @@ function storageBindingsCheck(
|
||||
details,
|
||||
);
|
||||
}
|
||||
function moduleDiscoveryFailureDetail(error: unknown): string {
|
||||
if (!(error instanceof ModuleDiscoveryError)) {
|
||||
return "Static module discovery could not be completed.";
|
||||
}
|
||||
// The modules directory itself, rather than anything in it (ADR 0048). Fixed wording, never the
|
||||
// configured value: a path is machine detail.
|
||||
if (error.issues.some((issue) => issue.code === "MODULES_DIRECTORY_INVALID")) {
|
||||
return "The configured modules directory is not usable.";
|
||||
}
|
||||
if (error.issues.some((issue) => issue.code === "MODULES_DIRECTORY_UNREADABLE")) {
|
||||
return "The configured modules directory could not be read.";
|
||||
}
|
||||
return "The installed module inventory is invalid.";
|
||||
}
|
||||
|
||||
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
|
||||
const details =
|
||||
error instanceof Error && error.name === "ModuleDiscoveryError"
|
||||
? ["The installed module inventory is invalid."]
|
||||
: ["Static module discovery could not be completed."];
|
||||
const details = [moduleDiscoveryFailureDetail(error)];
|
||||
return freezeCheck("modules.discovery", "failure", "Module discovery failed", details);
|
||||
}
|
||||
|
||||
@@ -733,6 +740,14 @@ function migrationPlanCheck(plan: MigrationPlanResult): DoctorCheck {
|
||||
}
|
||||
return `${entry.status} · ${safeMigrationReference(entry.identity, entry.checksum)}`;
|
||||
}),
|
||||
// An absent module's applied history (ADR 0048) is information, never attention: removing a
|
||||
// module's code keeps its data, and nothing here is pending.
|
||||
...plan.retained.map((module) => {
|
||||
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(module.moduleId)) {
|
||||
throw new TypeError("Unsafe migration plan diagnostic value.");
|
||||
}
|
||||
return `retained · ${module.moduleId} · ${module.appliedCount} applied ${plural(module.appliedCount, "migration")} kept; the module is not installed`;
|
||||
}),
|
||||
];
|
||||
|
||||
if (pendingCount + blockedCount === 0) {
|
||||
@@ -1132,7 +1147,10 @@ export async function diagnoseEkklesia(
|
||||
}
|
||||
|
||||
try {
|
||||
migrations = await dependencies.discoverMigrations(options.moduleDiscovery);
|
||||
migrations = await dependencies.discoverMigrations({
|
||||
coreMigrationsDirectory: options.coreMigrationsDirectory,
|
||||
moduleDiscovery: options.moduleDiscovery,
|
||||
});
|
||||
const inventory = migrations.migrations.map((migration) =>
|
||||
safeMigrationReference(migration.identity, migration.checksum),
|
||||
);
|
||||
|
||||
@@ -59,6 +59,10 @@ export interface HostConfiguration {
|
||||
/** The deployment's module secrets, resolvable only under names Core derives from a module's own
|
||||
* declared keys — there is no arbitrary variable lookup. */
|
||||
readonly moduleSecrets: ModuleSecretSource;
|
||||
/** `EKKLESIA_MODULES_DIR`, raw and unvalidated: `undefined` only when the variable is unset. Every
|
||||
* executable passes it to `resolveModulesDirectory`, the one place it is interpreted (ADR 0048),
|
||||
* so a relative or blank value is refused there rather than repaired here. */
|
||||
readonly modulesDirectory: string | undefined;
|
||||
readonly storage: StorageProviderConfigInput;
|
||||
}
|
||||
|
||||
@@ -144,6 +148,7 @@ export function loadHostConfiguration(environment: HostEnvironment): HostConfigu
|
||||
username: environment.EKKLESIA_SMTP_USERNAME,
|
||||
},
|
||||
moduleSecrets: createEnvironmentModuleSecretSource(environment),
|
||||
modulesDirectory: environment.EKKLESIA_MODULES_DIR,
|
||||
// The `S3_*` names ADR 0004 already established for this repository's Compose stack. Absent
|
||||
// endpoint means this installation has no object storage, which is a complete deployment
|
||||
// (ADR 0030).
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
DiscoveredModuleMigration,
|
||||
ModuleMigrationAggregate,
|
||||
} from "../modules/migration-discovery.js";
|
||||
import { CORE_MIGRATION_SOURCE_ID } from "./core-source.js";
|
||||
|
||||
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 10_000;
|
||||
|
||||
@@ -86,11 +87,24 @@ export interface MigrationPlanEntry {
|
||||
readonly tag: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applied history kept for a module that is not installed (ADR 0048): the module id and how many of
|
||||
* its migrations were applied. It is reported, never matched against an inventory, and never a
|
||||
* reason to refuse planning or applying anything else.
|
||||
*/
|
||||
export interface RetainedMigrationHistory {
|
||||
readonly appliedCount: number;
|
||||
readonly moduleId: string;
|
||||
}
|
||||
|
||||
export interface MigrationPlanResult {
|
||||
readonly appliedCount: number;
|
||||
readonly approvalRequiredCount: number;
|
||||
readonly entries: readonly MigrationPlanEntry[];
|
||||
readonly pendingCount: number;
|
||||
/** Absent modules' applied history, ordered by module id; empty when every applied migration
|
||||
* belongs to Core or an installed module. */
|
||||
readonly retained: readonly RetainedMigrationHistory[];
|
||||
}
|
||||
|
||||
export interface AppliedMigrationResult {
|
||||
@@ -105,6 +119,8 @@ export interface MigrationApplyResult {
|
||||
readonly applied: readonly AppliedMigrationResult[];
|
||||
readonly appliedCount: number;
|
||||
readonly noOp: boolean;
|
||||
/** The same report as `MigrationPlanResult.retained`, read under the migration lock. */
|
||||
readonly retained: readonly RetainedMigrationHistory[];
|
||||
}
|
||||
|
||||
export interface PlanModuleMigrationsOptions {
|
||||
@@ -204,10 +220,38 @@ function validateHistoryRecordShape(record: AppliedMigrationRecord): void {
|
||||
}
|
||||
}
|
||||
|
||||
interface ValidatedAppliedHistory {
|
||||
/** Identities of applied migrations that belong to Core or an installed module. */
|
||||
readonly applied: ReadonlySet<string>;
|
||||
readonly retained: readonly RetainedMigrationHistory[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every module id this inventory treats as installed: each discovered module, whether or not it
|
||||
* owns migrations, every module that contributed migrations, and always Core. A module counts as
|
||||
* absent only when it is none of these, so a module that is installed but no longer ships its old
|
||||
* migrations stays strict, and Core's history can never be classified as an absent module's.
|
||||
*/
|
||||
function installedMigrationSources(aggregate: ModuleMigrationAggregate): ReadonlySet<string> {
|
||||
return new Set<string>([
|
||||
CORE_MIGRATION_SOURCE_ID,
|
||||
...aggregate.discoveredModules.map((module) => module.manifest.id),
|
||||
...aggregate.modules.map((module) => module.moduleId),
|
||||
]);
|
||||
}
|
||||
|
||||
function freezeRetained(counts: ReadonlyMap<string, number>): readonly RetainedMigrationHistory[] {
|
||||
return Object.freeze(
|
||||
[...counts.entries()]
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([moduleId, appliedCount]) => Object.freeze({ appliedCount, moduleId })),
|
||||
);
|
||||
}
|
||||
|
||||
function validateAppliedHistoryUnchecked(
|
||||
aggregate: ModuleMigrationAggregate,
|
||||
history: readonly AppliedMigrationRecord[],
|
||||
): ReadonlySet<string> {
|
||||
): ValidatedAppliedHistory {
|
||||
if (!Array.isArray(history)) {
|
||||
throw runnerError("MIGRATION_HISTORY_INVALID");
|
||||
}
|
||||
@@ -215,6 +259,9 @@ function validateAppliedHistoryUnchecked(
|
||||
const inventory = new Map(
|
||||
aggregate.migrations.map((migration) => [migration.identity, migration]),
|
||||
);
|
||||
const installed = installedMigrationSources(aggregate);
|
||||
const applied = new Set<string>();
|
||||
const retained = new Map<string, number>();
|
||||
const seenOrders = new Set<number>();
|
||||
const seenIdentities = new Set<string>();
|
||||
const seenModuleIndexes = new Set<string>();
|
||||
@@ -240,7 +287,19 @@ function validateAppliedHistoryUnchecked(
|
||||
|
||||
const migration = knownMigration(inventory, record.identity);
|
||||
if (!migration) {
|
||||
throw runnerError("MIGRATION_UNKNOWN_APPLIED");
|
||||
if (installed.has(record.moduleId)) {
|
||||
// Core, or a module that is installed now: its history is strict, exactly as ADR 0009
|
||||
// established. A migration it no longer ships is a downgrade or a rewritten package.
|
||||
throw runnerError("MIGRATION_UNKNOWN_APPLIED");
|
||||
}
|
||||
// An absent module's history (ADR 0048): retained and reported, never repaired, deleted, or
|
||||
// matched against anything. It must still name its own module and tag, so a corrupt row is
|
||||
// never mistaken for one.
|
||||
if (record.identity !== `${record.moduleId}:${record.tag}`) {
|
||||
throw runnerError("MIGRATION_HISTORY_INVALID");
|
||||
}
|
||||
retained.set(record.moduleId, (retained.get(record.moduleId) ?? 0) + 1);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
record.moduleId !== migration.moduleId ||
|
||||
@@ -252,11 +311,12 @@ function validateAppliedHistoryUnchecked(
|
||||
if (record.checksum !== migration.checksum) {
|
||||
throw runnerError("MIGRATION_CHECKSUM_MISMATCH", migration.identity);
|
||||
}
|
||||
applied.add(record.identity);
|
||||
}
|
||||
|
||||
for (const module of aggregate.modules) {
|
||||
const applied = history.filter((record) => record.moduleId === module.moduleId);
|
||||
for (const [position, record] of applied.entries()) {
|
||||
const moduleHistory = history.filter((record) => record.moduleId === module.moduleId);
|
||||
for (const [position, record] of moduleHistory.entries()) {
|
||||
if (record.identity !== module.migrations[position]?.identity) {
|
||||
const migration = knownMigration(inventory, record.identity);
|
||||
throw runnerError("MIGRATION_HISTORY_INVALID", migration?.identity);
|
||||
@@ -264,13 +324,13 @@ function validateAppliedHistoryUnchecked(
|
||||
}
|
||||
}
|
||||
|
||||
return seenIdentities;
|
||||
return Object.freeze({ applied, retained: freezeRetained(retained) });
|
||||
}
|
||||
|
||||
function validateAppliedHistory(
|
||||
aggregate: ModuleMigrationAggregate,
|
||||
history: readonly AppliedMigrationRecord[],
|
||||
): ReadonlySet<string> {
|
||||
): ValidatedAppliedHistory {
|
||||
try {
|
||||
return validateAppliedHistoryUnchecked(aggregate, history);
|
||||
} catch (error) {
|
||||
@@ -285,7 +345,7 @@ function planFromHistory(
|
||||
aggregate: ModuleMigrationAggregate,
|
||||
history: readonly AppliedMigrationRecord[],
|
||||
): MigrationPlanResult {
|
||||
const applied = validateAppliedHistory(aggregate, history);
|
||||
const { applied, retained } = validateAppliedHistory(aggregate, history);
|
||||
const entries = Object.freeze(
|
||||
aggregate.migrations.map((migration): MigrationPlanEntry => {
|
||||
const isApplied = applied.has(migration.identity);
|
||||
@@ -314,6 +374,7 @@ function planFromHistory(
|
||||
approvalRequiredCount,
|
||||
entries,
|
||||
pendingCount: entries.length - appliedCount,
|
||||
retained,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -472,14 +533,14 @@ export async function applyModuleMigrations(
|
||||
} catch {
|
||||
throw runnerError("MIGRATION_HISTORY_READ_FAILED");
|
||||
}
|
||||
const applied = validateAppliedHistory(options.aggregate, history);
|
||||
const { applied, retained } = validateAppliedHistory(options.aggregate, history);
|
||||
const pending = Object.freeze(
|
||||
options.aggregate.migrations.filter((migration) => !applied.has(migration.identity)),
|
||||
);
|
||||
await preflightApprovals(pending, options.approvals ?? denyAllMigrationApprovals);
|
||||
|
||||
if (pending.length === 0) {
|
||||
return Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true });
|
||||
return Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true, retained });
|
||||
}
|
||||
|
||||
let executingIdentity: string | undefined;
|
||||
@@ -508,6 +569,7 @@ export async function applyModuleMigrations(
|
||||
applied: resultMigrations,
|
||||
appliedCount: resultMigrations.length,
|
||||
noOp: false,
|
||||
retained,
|
||||
});
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
|
||||
@@ -47,6 +47,7 @@ export type ModuleDiscoveryIssueCode =
|
||||
| "MODULE_PLATFORM_INCOMPATIBLE"
|
||||
| "MODULE_SDK_INCOMPATIBLE"
|
||||
| "MODULE_VERSION_MISMATCH"
|
||||
| "MODULES_DIRECTORY_INVALID"
|
||||
| "MODULES_DIRECTORY_UNREADABLE";
|
||||
|
||||
export interface ModuleDiscoveryIssue {
|
||||
@@ -117,9 +118,35 @@ export interface DiscoveredModule {
|
||||
export interface ModuleDiscoveryOptions {
|
||||
readonly moduleSdkVersion: string;
|
||||
readonly modulesDirectory: string;
|
||||
/**
|
||||
* True when an operator configured the directory explicitly (`EKKLESIA_MODULES_DIR`, ADR 0048).
|
||||
* A configured directory that is missing, not a directory, or not an absolute path then fails
|
||||
* discovery closed rather than reading as zero modules: a mistyped mount must never quietly start
|
||||
* the platform with nothing installed. False or omitted keeps the development default lenient,
|
||||
* where a checkout with no `modules/` simply has no modules.
|
||||
*/
|
||||
readonly modulesDirectoryRequired?: boolean;
|
||||
readonly platformVersion: string;
|
||||
}
|
||||
|
||||
/** The one sanitized message for an unusable configured modules directory. It never carries the
|
||||
* value: a path is machine detail, and a misplaced value could be anything at all. */
|
||||
export const MODULES_DIRECTORY_INVALID_MESSAGE = "EKKLESIA_MODULES_DIR must be an absolute path.";
|
||||
|
||||
/**
|
||||
* Whether a configured modules directory is usable as given: a non-empty absolute path with no
|
||||
* surrounding whitespace and no NUL byte. Deliberately strict rather than forgiving, because
|
||||
* trimming or resolving a near-miss would silently pick a directory the operator did not name.
|
||||
*/
|
||||
export function isUsableModulesDirectoryPath(value: string): boolean {
|
||||
return (
|
||||
value.length > 0 &&
|
||||
value.trim() === value &&
|
||||
!value.includes("\u0000") &&
|
||||
path.isAbsolute(value)
|
||||
);
|
||||
}
|
||||
|
||||
interface ModulePackageData {
|
||||
name: string;
|
||||
version: string;
|
||||
@@ -695,6 +722,13 @@ export async function discoverModules(
|
||||
});
|
||||
}
|
||||
}
|
||||
if (options.modulesDirectoryRequired && !isUsableModulesDirectoryPath(options.modulesDirectory)) {
|
||||
issues.push({
|
||||
code: "MODULES_DIRECTORY_INVALID",
|
||||
message: MODULES_DIRECTORY_INVALID_MESSAGE,
|
||||
moduleDirectory: ".",
|
||||
});
|
||||
}
|
||||
if (issues.length > 0) {
|
||||
throw new ModuleDiscoveryError(issues);
|
||||
}
|
||||
@@ -703,7 +737,7 @@ export async function discoverModules(
|
||||
try {
|
||||
directoryEntries = await readdir(options.modulesDirectory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "ENOENT") {
|
||||
if (errorCode(error) === "ENOENT" && !options.modulesDirectoryRequired) {
|
||||
return Object.freeze([]);
|
||||
}
|
||||
throw new ModuleDiscoveryError([
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from "./lifecycle.js";
|
||||
export * from "./lifecycle-schema.js";
|
||||
export * from "./lifecycle-store.js";
|
||||
export * from "./migration-discovery.js";
|
||||
export * from "./modules-directory.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./realtime.js";
|
||||
export * from "./runtime.js";
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
isUsableModulesDirectoryPath,
|
||||
MODULES_DIRECTORY_INVALID_MESSAGE,
|
||||
ModuleDiscoveryError,
|
||||
} from "./discovery.js";
|
||||
|
||||
/** The host environment variable that selects the installed-modules directory (ADR 0048). */
|
||||
export const MODULES_DIRECTORY_ENVIRONMENT_VARIABLE = "EKKLESIA_MODULES_DIR";
|
||||
|
||||
export interface ResolveModulesDirectoryOptions {
|
||||
/** The raw configured value, exactly as the host environment supplied it; `undefined` when the
|
||||
* variable is unset. */
|
||||
readonly configured: string | undefined;
|
||||
/** Where the platform itself is installed. Only consulted for the development default. */
|
||||
readonly repositoryRoot: string;
|
||||
}
|
||||
|
||||
/** Spreads directly into `ModuleDiscoveryOptions`, so every consumer discovers through the same
|
||||
* selection, including whether a missing directory is an error. */
|
||||
export interface ModulesDirectorySelection {
|
||||
readonly modulesDirectory: string;
|
||||
readonly modulesDirectoryRequired: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place an installation's modules directory is chosen (ADR 0048).
|
||||
*
|
||||
* - Unset: `<repositoryRoot>/modules`, the development checkout's own directory. A checkout without
|
||||
* one has no modules, exactly as before.
|
||||
* - Set: an absolute path, normalized and used as given — never resolved against the repository or
|
||||
* the working directory — and required to exist when discovery reads it. The shipping image sets
|
||||
* it to `/var/lib/ekklesia/modules`.
|
||||
*
|
||||
* Anything else throws a `ModuleDiscoveryError` carrying `MODULES_DIRECTORY_INVALID` and a fixed
|
||||
* message, so every executable already reports it through the discovery error path it has, and the
|
||||
* supplied value never reaches an error, a log, or a report.
|
||||
*
|
||||
* Nothing here — and nothing that calls it — locates the platform's own files relative to the
|
||||
* result: Core's migrations and package metadata live under `repositoryRoot`, wherever modules are.
|
||||
*/
|
||||
export function resolveModulesDirectory(
|
||||
options: ResolveModulesDirectoryOptions,
|
||||
): ModulesDirectorySelection {
|
||||
if (options.configured === undefined) {
|
||||
return Object.freeze({
|
||||
modulesDirectory: path.join(options.repositoryRoot, "modules"),
|
||||
modulesDirectoryRequired: false,
|
||||
});
|
||||
}
|
||||
if (!isUsableModulesDirectoryPath(options.configured)) {
|
||||
throw new ModuleDiscoveryError([
|
||||
{
|
||||
code: "MODULES_DIRECTORY_INVALID",
|
||||
message: MODULES_DIRECTORY_INVALID_MESSAGE,
|
||||
moduleDirectory: ".",
|
||||
},
|
||||
]);
|
||||
}
|
||||
const normalized = path.normalize(options.configured);
|
||||
return Object.freeze({
|
||||
modulesDirectory:
|
||||
normalized.length > 1 && normalized.endsWith(path.sep) ? normalized.slice(0, -1) : normalized,
|
||||
modulesDirectoryRequired: true,
|
||||
});
|
||||
}
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
diagnoseEkklesia,
|
||||
formatDoctorReport,
|
||||
} from "../src/diagnostics/index.js";
|
||||
import type {
|
||||
DiscoveredModule,
|
||||
ModuleMigrationAggregate,
|
||||
ModuleSchemaAggregate,
|
||||
import {
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
type ModuleMigrationAggregate,
|
||||
type ModuleSchemaAggregate,
|
||||
} from "../src/modules/index.js";
|
||||
|
||||
/**
|
||||
@@ -34,8 +35,8 @@ const moduleSecret = "module-secret-material";
|
||||
|
||||
const metadata = Object.freeze({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: "/not-rendered/modules",
|
||||
platformVersion: "0.1.0",
|
||||
repositoryRoot: "/not-rendered",
|
||||
runtimeRange: ">=24.11.0 <25",
|
||||
runtimeVersion: "24.15.0",
|
||||
});
|
||||
@@ -454,3 +455,33 @@ describe("Doctor host-configuration parity — sanitization", () => {
|
||||
expect(JSON.stringify(report)).not.toContain("S3_ACCESS_KEY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Doctor reads the modules directory from the same host configuration (ADR 0048)", () => {
|
||||
it("uses the repository's own modules directory when EKKLESIA_MODULES_DIR is unset", () => {
|
||||
const doctorOptions = createDoctorOptions(environment(), metadata);
|
||||
expect(doctorOptions.moduleDiscovery.modulesDirectory).toBe("/not-rendered/modules");
|
||||
expect(doctorOptions.moduleDiscovery.modulesDirectoryRequired).toBe(false);
|
||||
expect(doctorOptions.coreMigrationsDirectory).toBe("/not-rendered/core/drizzle");
|
||||
});
|
||||
|
||||
it("uses a configured directory, and still finds Core's migrations under the platform", () => {
|
||||
const doctorOptions = createDoctorOptions(
|
||||
environment({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" }),
|
||||
metadata,
|
||||
);
|
||||
expect(doctorOptions.moduleDiscovery.modulesDirectory).toBe("/var/lib/ekklesia/modules");
|
||||
expect(doctorOptions.moduleDiscovery.modulesDirectoryRequired).toBe(true);
|
||||
expect(doctorOptions.coreMigrationsDirectory).toBe("/not-rendered/core/drizzle");
|
||||
});
|
||||
|
||||
it("hands an unusable value to discovery to report, rather than throwing before a report exists", async () => {
|
||||
const doctorOptions = createDoctorOptions(
|
||||
environment({ EKKLESIA_MODULES_DIR: "relative/private-mount-91aa" }),
|
||||
metadata,
|
||||
);
|
||||
expect(doctorOptions.moduleDiscovery.modulesDirectoryRequired).toBe(true);
|
||||
const report = await diagnoseEkklesia(doctorOptions, dependencies({ discoverModules }));
|
||||
expect(report.checks.find((entry) => entry.id === "modules.discovery")?.status).toBe("failure");
|
||||
expect(JSON.stringify(report)).not.toContain("private-mount-91aa");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ describe("Ekklesia Doctor terminal report", () => {
|
||||
it("renders the real zero-module and no-database shape exactly", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: "/not-rendered/modules",
|
||||
@@ -135,6 +136,7 @@ Result: READY WITH WARNINGS`);
|
||||
it("renders every check the report contains, so none can silently affect the result", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: "/not-rendered/modules",
|
||||
@@ -159,6 +161,7 @@ Result: READY WITH WARNINGS`);
|
||||
it("does not depend on NO_COLOR or terminal interactivity", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: "/not-rendered/modules",
|
||||
@@ -207,6 +210,7 @@ Result: READY WITH WARNINGS`);
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
databaseUrl: "postgresql://another:[email protected]/db",
|
||||
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: "/not-rendered/modules",
|
||||
|
||||
+121
-2
@@ -1,5 +1,9 @@
|
||||
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
|
||||
import {
|
||||
type DoctorCheck,
|
||||
@@ -114,7 +118,10 @@ function emptyDatabase(): MigrationDatabase {
|
||||
});
|
||||
}
|
||||
|
||||
function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult {
|
||||
function plan(
|
||||
entries: MigrationPlanResult["entries"] = [],
|
||||
retained: MigrationPlanResult["retained"] = [],
|
||||
): MigrationPlanResult {
|
||||
const appliedCount = entries.filter((entry) => entry.status === "applied").length;
|
||||
const approvalRequiredCount = entries.filter(
|
||||
(entry) => entry.status === "approval-blocked",
|
||||
@@ -124,6 +131,7 @@ function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult
|
||||
approvalRequiredCount,
|
||||
entries: Object.freeze([...entries]),
|
||||
pendingCount: entries.length - appliedCount,
|
||||
retained: Object.freeze([...retained]),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,6 +159,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
|
||||
function options(databaseUrl?: string): DoctorOptions {
|
||||
return {
|
||||
...(databaseUrl === undefined ? {} : { databaseUrl }),
|
||||
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: "/not-rendered/modules",
|
||||
@@ -1657,3 +1666,113 @@ describe("Doctor reports declared browser contributions", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Doctor with installation-owned module storage (ADR 0048)", () => {
|
||||
const platformCoreMigrations = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"drizzle",
|
||||
);
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it("finds Core's migrations where the platform is installed, not beside the modules", async () => {
|
||||
// A modules directory far from the platform, as the image mounts /var/lib/ekklesia/modules. Its
|
||||
// parent has no `core/drizzle`, so a Doctor that still derived Core's migrations from the
|
||||
// modules directory would fail migration discovery here.
|
||||
const external = await mkdtemp(path.join(tmpdir(), "ekklesia-external-modules-"));
|
||||
temporaryDirectories.push(external);
|
||||
const report = await diagnoseEkklesia({
|
||||
coreMigrationsDirectory: platformCoreMigrations,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: external,
|
||||
modulesDirectoryRequired: true,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
runtimeRange: ">=24.11.0 <25",
|
||||
runtimeVersion: "24.15.0",
|
||||
});
|
||||
|
||||
expect(check(report, "modules.discovery").status).toBe("pass");
|
||||
const migrations = check(report, "migrations.discovery");
|
||||
expect(migrations.status).toBe("pass");
|
||||
expect(migrations.details.some((detail) => detail.startsWith("core:0000_"))).toBe(true);
|
||||
expect(JSON.stringify(report)).not.toContain(external);
|
||||
});
|
||||
|
||||
it("reports an unusable configured modules directory as a discovery failure, naming no path", async () => {
|
||||
const report = await diagnoseEkklesia({
|
||||
coreMigrationsDirectory: platformCoreMigrations,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: "relative/private-mount-3c9d",
|
||||
modulesDirectoryRequired: true,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
runtimeRange: ">=24.11.0 <25",
|
||||
runtimeVersion: "24.15.0",
|
||||
});
|
||||
|
||||
const discovery = check(report, "modules.discovery");
|
||||
expect(discovery.status).toBe("failure");
|
||||
expect(discovery.details).toEqual(["The configured modules directory is not usable."]);
|
||||
expect(check(report, "migrations.discovery").status).toBe("skipped");
|
||||
expect(JSON.stringify(report)).not.toContain("private-mount-3c9d");
|
||||
});
|
||||
|
||||
it("reports a configured modules directory that does not exist, naming no path", async () => {
|
||||
const parent = await mkdtemp(path.join(tmpdir(), "ekklesia-missing-modules-"));
|
||||
temporaryDirectories.push(parent);
|
||||
const report = await diagnoseEkklesia({
|
||||
coreMigrationsDirectory: platformCoreMigrations,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: path.join(parent, "absent"),
|
||||
modulesDirectoryRequired: true,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
runtimeRange: ">=24.11.0 <25",
|
||||
runtimeVersion: "24.15.0",
|
||||
});
|
||||
|
||||
const discovery = check(report, "modules.discovery");
|
||||
expect(discovery.status).toBe("failure");
|
||||
expect(discovery.details).toEqual(["The configured modules directory could not be read."]);
|
||||
expect(JSON.stringify(report)).not.toContain(parent);
|
||||
});
|
||||
|
||||
it("reports an absent module's retained history without blocking readiness", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
options("postgres://[email protected]/ekklesia"),
|
||||
dependencies({
|
||||
planMigrations: vi.fn(async () =>
|
||||
plan(
|
||||
[planEntry("core:0000_identity_core_tables", "a".repeat(64), "applied")],
|
||||
[Object.freeze({ appliedCount: 2, moduleId: "calendar" })],
|
||||
),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const planCheck = check(report, "postgresql.plan");
|
||||
expect(planCheck.status).toBe("pass");
|
||||
expect(planCheck.details).toContain(
|
||||
"retained · calendar · 2 applied migrations kept; the module is not installed",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes Core's own migration directory to migration discovery", async () => {
|
||||
const discoverMigrations = vi.fn(async () => migrationAggregate());
|
||||
await diagnoseEkklesia(options(), dependencies({ discoverMigrations }));
|
||||
expect(discoverMigrations).toHaveBeenCalledWith({
|
||||
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||
moduleDiscovery: options().moduleDiscovery,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ const INTERPRETED_VARIABLES = Object.freeze([
|
||||
"EKKLESIA_IDENTITY_BASE_URL",
|
||||
"EKKLESIA_IDENTITY_SECRET",
|
||||
"EKKLESIA_IDENTITY_TRUSTED_ORIGINS",
|
||||
"EKKLESIA_MODULES_DIR",
|
||||
"EKKLESIA_PUBLIC_SIGNUP_ENABLED",
|
||||
"EKKLESIA_SMTP_FROM_ADDRESS",
|
||||
"EKKLESIA_SMTP_FROM_NAME",
|
||||
|
||||
@@ -48,6 +48,19 @@ describe("host configuration loading", () => {
|
||||
expect(host.identity.secret).toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries the configured modules directory verbatim, and nothing when it is unset", () => {
|
||||
expect(loadHostConfiguration({}).modulesDirectory).toBeUndefined();
|
||||
expect(
|
||||
loadHostConfiguration({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" }).modulesDirectory,
|
||||
).toBe("/var/lib/ekklesia/modules");
|
||||
// Validation is the resolver's job (ADR 0048). The loader neither repairs a relative or blank
|
||||
// value nor treats a set-but-empty variable as unset, so the resolver can refuse it.
|
||||
expect(
|
||||
loadHostConfiguration({ EKKLESIA_MODULES_DIR: "relative/modules" }).modulesDirectory,
|
||||
).toBe("relative/modules");
|
||||
expect(loadHostConfiguration({ EKKLESIA_MODULES_DIR: "" }).modulesDirectory).toBe("");
|
||||
});
|
||||
|
||||
it("carries every object-storage field the storage passes established", () => {
|
||||
const host = loadHostConfiguration(productionEnvironment);
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ describe("migration command reports", () => {
|
||||
}),
|
||||
]),
|
||||
pendingCount: 2,
|
||||
retained: Object.freeze([]),
|
||||
});
|
||||
|
||||
expect(formatMigrationPlanReport(plan)).toEqual([
|
||||
@@ -64,6 +65,7 @@ describe("migration command reports", () => {
|
||||
]),
|
||||
appliedCount: 1,
|
||||
noOp: false,
|
||||
retained: Object.freeze([]),
|
||||
});
|
||||
|
||||
expect(formatMigrationApplyReport(applied)).toEqual([
|
||||
@@ -72,7 +74,12 @@ describe("migration command reports", () => {
|
||||
]);
|
||||
expect(
|
||||
formatMigrationApplyReport(
|
||||
Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true }),
|
||||
Object.freeze({
|
||||
applied: Object.freeze([]),
|
||||
appliedCount: 0,
|
||||
noOp: true,
|
||||
retained: Object.freeze([]),
|
||||
}),
|
||||
),
|
||||
).toEqual(["Migration apply completed: database is already current (no changes)."]);
|
||||
});
|
||||
@@ -85,6 +92,7 @@ describe("migration command reports", () => {
|
||||
approvalRequiredCount: 0,
|
||||
entries: Object.freeze([]),
|
||||
pendingCount: 0,
|
||||
retained: Object.freeze([]),
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
@@ -93,4 +101,62 @@ describe("migration command reports", () => {
|
||||
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports an absent module's retained history without treating it as work (ADR 0048)", () => {
|
||||
const retained = Object.freeze([
|
||||
Object.freeze({ appliedCount: 1, moduleId: "calendar" }),
|
||||
Object.freeze({ appliedCount: 3, moduleId: "signage" }),
|
||||
]);
|
||||
const retainedLines = [
|
||||
"- [retained] calendar: 1 applied migration kept; the module is not installed",
|
||||
"- [retained] signage: 3 applied migrations kept; the module is not installed",
|
||||
];
|
||||
|
||||
expect(
|
||||
formatMigrationPlanReport(
|
||||
Object.freeze({
|
||||
appliedCount: 0,
|
||||
approvalRequiredCount: 0,
|
||||
entries: Object.freeze([]),
|
||||
pendingCount: 0,
|
||||
retained,
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
"Migration plan (0 applied, 0 pending, 0 approval-blocked, 2 retained):",
|
||||
"- No installed module migrations.",
|
||||
...retainedLines,
|
||||
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
|
||||
]);
|
||||
expect(
|
||||
formatMigrationApplyReport(
|
||||
Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true, retained }),
|
||||
),
|
||||
).toEqual([
|
||||
"Migration apply completed: database is already current (no changes).",
|
||||
...retainedLines,
|
||||
]);
|
||||
expect(
|
||||
formatMigrationApplyReport(
|
||||
Object.freeze({
|
||||
applied: Object.freeze([
|
||||
Object.freeze({
|
||||
checksum: "e".repeat(64),
|
||||
identity: "core:0007_later",
|
||||
index: 7,
|
||||
moduleId: "core",
|
||||
tag: "0007_later",
|
||||
}),
|
||||
]),
|
||||
appliedCount: 1,
|
||||
noOp: false,
|
||||
retained,
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
"Migration apply completed (1 migration applied):",
|
||||
`- core:0007_later sha256=${"e".repeat(64)}`,
|
||||
...retainedLines,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type MigrationTransaction,
|
||||
planModuleMigrations,
|
||||
} from "../src/migrations/runner.js";
|
||||
import type { DiscoveredModule } from "../src/modules/discovery.js";
|
||||
import type {
|
||||
DiscoveredModuleMigration,
|
||||
ModuleMigrationAggregate,
|
||||
@@ -323,6 +324,7 @@ describe("module migration planning", () => {
|
||||
},
|
||||
],
|
||||
pendingCount: 1,
|
||||
retained: [],
|
||||
});
|
||||
expect(database.events).toEqual(["session:open", "history:read", "session:close"]);
|
||||
expect(database.historyTableExists).toBe(false);
|
||||
@@ -344,10 +346,11 @@ describe("module migration planning", () => {
|
||||
approvalRequiredCount: 0,
|
||||
entries: [],
|
||||
pendingCount: 0,
|
||||
retained: [],
|
||||
});
|
||||
expect(Object.isFrozen(result)).toBe(true);
|
||||
expect(Object.isFrozen(result.entries)).toBe(true);
|
||||
expect(applyResult).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
||||
expect(applyResult).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||
expect(database.historyTableExists).toBe(false);
|
||||
expect(database.events).not.toContain("transaction:begin");
|
||||
});
|
||||
@@ -548,6 +551,7 @@ describe("module migration apply", () => {
|
||||
],
|
||||
appliedCount: 1,
|
||||
noOp: false,
|
||||
retained: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -580,7 +584,7 @@ describe("module migration apply", () => {
|
||||
database,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||
expect(Object.isFrozen(result)).toBe(true);
|
||||
expect(Object.isFrozen(result.applied)).toBe(true);
|
||||
expectNoPersistentMutation(database);
|
||||
@@ -601,7 +605,7 @@ describe("module migration apply", () => {
|
||||
database,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||
expect(database.historyTableExists).toBe(false);
|
||||
expect(database.events).toEqual([
|
||||
"session:open",
|
||||
@@ -980,3 +984,250 @@ describe("module migration failures and side effects", () => {
|
||||
expect(database.committedHistory).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Marks modules as present in the discovered inventory without giving them migrations, the way a
|
||||
* real aggregate lists every discovered module whether or not it owns migration history. Only the
|
||||
* manifest id is read by the runner.
|
||||
*/
|
||||
function withDiscovered(
|
||||
inventory: ModuleMigrationAggregate,
|
||||
...moduleIds: readonly string[]
|
||||
): ModuleMigrationAggregate {
|
||||
return Object.freeze({
|
||||
...inventory,
|
||||
discoveredModules: Object.freeze(
|
||||
moduleIds.map((id) => ({ manifest: { id } }) as unknown as DiscoveredModule),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
describe("retained history for absent modules (ADR 0048)", () => {
|
||||
it("retains and reports an absent module's applied history without failing the plan", async () => {
|
||||
const coreInitial = migration("core", 0);
|
||||
const calendarInitial = migration("calendar", 0);
|
||||
const calendarLater = migration("calendar", 1);
|
||||
const history = [
|
||||
historyRecord(coreInitial, 1),
|
||||
historyRecord(calendarInitial, 2),
|
||||
historyRecord(calendarLater, 3),
|
||||
];
|
||||
const database = new FakeMigrationDatabase({ history });
|
||||
|
||||
const result = await planModuleMigrations({
|
||||
aggregate: aggregate({ id: "core", migrations: [coreInitial] }),
|
||||
database,
|
||||
});
|
||||
|
||||
expect(result.appliedCount).toBe(1);
|
||||
expect(result.pendingCount).toBe(0);
|
||||
expect(result.entries.map((entry) => entry.identity)).toEqual([coreInitial.identity]);
|
||||
expect(result.retained).toEqual([{ appliedCount: 2, moduleId: "calendar" }]);
|
||||
expect(Object.isFrozen(result.retained)).toBe(true);
|
||||
expect(Object.isFrozen(result.retained[0])).toBe(true);
|
||||
expect(database.committedHistory).toEqual(history);
|
||||
expectNoPersistentMutation(database);
|
||||
});
|
||||
|
||||
it("applies a later Core migration while a module is absent, leaving its history intact", async () => {
|
||||
const coreInitial = migration("core", 0);
|
||||
const coreLater = migration("core", 1);
|
||||
const calendarInitial = migration("calendar", 0);
|
||||
const history = [historyRecord(coreInitial, 1), historyRecord(calendarInitial, 2)];
|
||||
const database = new FakeMigrationDatabase({ history });
|
||||
|
||||
const result = await applyModuleMigrations({
|
||||
aggregate: aggregate({ id: "core", migrations: [coreInitial, coreLater] }),
|
||||
database,
|
||||
});
|
||||
|
||||
expect(result.applied.map((applied) => applied.identity)).toEqual([coreLater.identity]);
|
||||
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "calendar" }]);
|
||||
expect(database.executedStatements).toEqual([coreLater.sql]);
|
||||
expect(database.committedHistory.map((record) => record.identity)).toEqual([
|
||||
coreInitial.identity,
|
||||
calendarInitial.identity,
|
||||
coreLater.identity,
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies a present module's migrations while another module is absent", async () => {
|
||||
const peopleInitial = migration("people", 0);
|
||||
const calendarInitial = migration("calendar", 0);
|
||||
const database = new FakeMigrationDatabase({ history: [historyRecord(calendarInitial, 1)] });
|
||||
|
||||
const result = await applyModuleMigrations({
|
||||
aggregate: aggregate({ id: "people", migrations: [peopleInitial] }),
|
||||
database,
|
||||
});
|
||||
|
||||
expect(result.applied.map((applied) => applied.identity)).toEqual([peopleInitial.identity]);
|
||||
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "calendar" }]);
|
||||
});
|
||||
|
||||
it("reports retained modules in a stable order with exact counts", async () => {
|
||||
const zeta = [migration("zeta", 0), migration("zeta", 1), migration("zeta", 2)];
|
||||
const alpha = migration("alpha", 0);
|
||||
const database = new FakeMigrationDatabase({
|
||||
history: [
|
||||
historyRecord(zeta[0] as DiscoveredModuleMigration, 1),
|
||||
historyRecord(alpha, 2),
|
||||
historyRecord(zeta[1] as DiscoveredModuleMigration, 3),
|
||||
historyRecord(zeta[2] as DiscoveredModuleMigration, 4),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await planModuleMigrations({ aggregate: aggregate(), database });
|
||||
|
||||
expect(result.retained).toEqual([
|
||||
{ appliedCount: 1, moduleId: "alpha" },
|
||||
{ appliedCount: 3, moduleId: "zeta" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns a module to strict validation: valid history plans cleanly and nothing is retained", async () => {
|
||||
const initial = migration("people", 0);
|
||||
const later = migration("people", 1);
|
||||
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
|
||||
|
||||
const result = await planModuleMigrations({
|
||||
aggregate: aggregate({ id: "people", migrations: [initial, later] }),
|
||||
database,
|
||||
});
|
||||
|
||||
expect(result.retained).toEqual([]);
|
||||
expect(result.entries.map((entry) => [entry.identity, entry.status])).toEqual([
|
||||
[initial.identity, "applied"],
|
||||
[later.identity, "pending"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails when a returning module no longer ships a previously applied migration", async () => {
|
||||
const initial = migration("people", 0);
|
||||
const later = migration("people", 1);
|
||||
const database = new FakeMigrationDatabase({
|
||||
history: [historyRecord(initial, 1), historyRecord(later, 2)],
|
||||
});
|
||||
const downgraded = aggregate({ id: "people", migrations: [initial] });
|
||||
|
||||
await captureRunnerError(
|
||||
planModuleMigrations({ aggregate: downgraded, database }),
|
||||
"MIGRATION_UNKNOWN_APPLIED",
|
||||
);
|
||||
await captureRunnerError(
|
||||
applyModuleMigrations({ aggregate: downgraded, database }),
|
||||
"MIGRATION_UNKNOWN_APPLIED",
|
||||
);
|
||||
expect(database.executedStatements).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails when a returning module's applied migration has different contents", async () => {
|
||||
const initial = migration("people", 0);
|
||||
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
|
||||
const edited = aggregate({
|
||||
id: "people",
|
||||
migrations: [migration("people", 0, { sql: "SELECT 'edited';\n" })],
|
||||
});
|
||||
|
||||
const error = await captureRunnerError(
|
||||
planModuleMigrations({ aggregate: edited, database }),
|
||||
"MIGRATION_CHECKSUM_MISMATCH",
|
||||
);
|
||||
expect(error.migrationIdentity).toBe(initial.identity);
|
||||
});
|
||||
|
||||
it("fails when a returning module's history is no longer a prefix of its journal", async () => {
|
||||
const initial = migration("people", 0);
|
||||
const later = migration("people", 1);
|
||||
const database = new FakeMigrationDatabase({ history: [historyRecord(later, 1)] });
|
||||
|
||||
await captureRunnerError(
|
||||
planModuleMigrations({
|
||||
aggregate: aggregate({ id: "people", migrations: [initial, later] }),
|
||||
database,
|
||||
}),
|
||||
"MIGRATION_HISTORY_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a present module that no longer ships any migrations strict", async () => {
|
||||
const initial = migration("people", 0);
|
||||
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
|
||||
|
||||
await captureRunnerError(
|
||||
planModuleMigrations({ aggregate: withDiscovered(aggregate(), "people"), database }),
|
||||
"MIGRATION_UNKNOWN_APPLIED",
|
||||
);
|
||||
});
|
||||
|
||||
it("never treats Core's history as an absent module's", async () => {
|
||||
const coreInitial = migration("core", 0);
|
||||
const coreGhost = migration("core", 1);
|
||||
|
||||
// An inventory with no Core source at all: Core's rows are still strict, never retained.
|
||||
await captureRunnerError(
|
||||
planModuleMigrations({
|
||||
aggregate: aggregate({ id: "people", migrations: [] }),
|
||||
database: new FakeMigrationDatabase({ history: [historyRecord(coreInitial, 1)] }),
|
||||
}),
|
||||
"MIGRATION_UNKNOWN_APPLIED",
|
||||
);
|
||||
// A Core row missing from Core's own inventory.
|
||||
await captureRunnerError(
|
||||
planModuleMigrations({
|
||||
aggregate: aggregate({ id: "core", migrations: [coreInitial] }),
|
||||
database: new FakeMigrationDatabase({
|
||||
history: [historyRecord(coreInitial, 1), historyRecord(coreGhost, 2)],
|
||||
}),
|
||||
}),
|
||||
"MIGRATION_UNKNOWN_APPLIED",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"an identity that does not name its own module and tag",
|
||||
(source: DiscoveredModuleMigration) => [
|
||||
historyRecord(source, 1, { identity: "calendar:0099_elsewhere" }),
|
||||
],
|
||||
],
|
||||
[
|
||||
"a duplicate applied order",
|
||||
(source: DiscoveredModuleMigration) => [
|
||||
historyRecord(source, 1),
|
||||
historyRecord(migration("calendar", 1), 1),
|
||||
],
|
||||
],
|
||||
[
|
||||
"a duplicate index",
|
||||
(source: DiscoveredModuleMigration) => [
|
||||
historyRecord(source, 1),
|
||||
historyRecord(migration("calendar", 1), 2, { index: 0 }),
|
||||
],
|
||||
],
|
||||
])("still rejects retained rows with %s", async (_label, makeHistory) => {
|
||||
const database = new FakeMigrationDatabase({
|
||||
history: makeHistory(migration("calendar", 0)),
|
||||
});
|
||||
|
||||
await captureRunnerError(
|
||||
planModuleMigrations({ aggregate: aggregate(), database }),
|
||||
"MIGRATION_HISTORY_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a retained row that claims a present module's migration identity", async () => {
|
||||
const peopleInitial = migration("people", 0);
|
||||
const database = new FakeMigrationDatabase({
|
||||
history: [historyRecord(peopleInitial, 1, { moduleId: "calendar" })],
|
||||
});
|
||||
|
||||
await captureRunnerError(
|
||||
planModuleMigrations({
|
||||
aggregate: aggregate({ id: "people", migrations: [peopleInitial] }),
|
||||
database,
|
||||
}),
|
||||
"MIGRATION_HISTORY_INVALID",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { cp, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { discoverRepositoryMigrations } from "../src/cli/migration-command-support.js";
|
||||
import { resolveCommandModulesDirectory } from "../src/cli/modules-directory-environment.js";
|
||||
import { ModuleDiscoveryError } from "../src/modules/index.js";
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const templateModule = path.join(repositoryRoot, "templates", "module");
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function externalModulesDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-installed-modules-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
/** Installs the module template's migration-bearing package files as module `example`. Migration
|
||||
* discovery imports nothing, so the template's manifest, package metadata, and migrations are all
|
||||
* it reads; the compiled artifacts discovery checks for are copied too when they exist. */
|
||||
async function installTemplate(modulesDirectory: string): Promise<void> {
|
||||
const target = path.join(modulesDirectory, "example");
|
||||
for (const entry of ["package.json", "ekklesia.module.json", "drizzle", "dist"]) {
|
||||
await cp(path.join(templateModule, entry), path.join(target, entry), { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("operator commands select the modules directory through host configuration", () => {
|
||||
it("defaults to the repository's own modules directory", () => {
|
||||
expect(resolveCommandModulesDirectory({}, "/opt/ekklesia")).toEqual({
|
||||
modulesDirectory: "/opt/ekklesia/modules",
|
||||
modulesDirectoryRequired: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("honours EKKLESIA_MODULES_DIR", () => {
|
||||
expect(
|
||||
resolveCommandModulesDirectory(
|
||||
{ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" },
|
||||
"/opt/ekklesia",
|
||||
),
|
||||
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
|
||||
});
|
||||
|
||||
it("refuses a relative EKKLESIA_MODULES_DIR without echoing it", () => {
|
||||
expect(() =>
|
||||
resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia"),
|
||||
).toThrow(ModuleDiscoveryError);
|
||||
try {
|
||||
resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia");
|
||||
} catch (error) {
|
||||
expect(JSON.stringify((error as ModuleDiscoveryError).issues)).not.toContain("9e1f");
|
||||
}
|
||||
});
|
||||
|
||||
it("plans migrations for modules installed outside the repository, with Core's from the platform", async () => {
|
||||
const modulesDirectory = await externalModulesDirectory();
|
||||
await installTemplate(modulesDirectory);
|
||||
|
||||
const aggregate = await discoverRepositoryMigrations(repositoryRoot, {
|
||||
EKKLESIA_MODULES_DIR: modulesDirectory,
|
||||
});
|
||||
|
||||
const sources = aggregate.modules.map((module) => module.moduleId);
|
||||
expect(sources[0]).toBe("core");
|
||||
expect(sources).toContain("example");
|
||||
expect(
|
||||
aggregate.migrations.some((migration) => migration.identity === "example:0000_initial"),
|
||||
).toBe(true);
|
||||
const coreTags = (await readdir(path.join(repositoryRoot, "core", "drizzle"))).filter((name) =>
|
||||
name.endsWith(".sql"),
|
||||
);
|
||||
expect(aggregate.modules[0]?.migrations).toHaveLength(coreTags.length);
|
||||
});
|
||||
|
||||
it("fails closed when the configured directory does not exist", async () => {
|
||||
const parent = await externalModulesDirectory();
|
||||
await expect(
|
||||
discoverRepositoryMigrations(repositoryRoot, {
|
||||
EKKLESIA_MODULES_DIR: path.join(parent, "absent"),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ModuleDiscoveryError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no source chooses a modules directory except the resolver", () => {
|
||||
async function sourceFiles(directory: string): Promise<readonly string[]> {
|
||||
const entries = await readdir(directory, { recursive: true, withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".ts"))
|
||||
.map((entry) => path.join(entry.parentPath, entry.name));
|
||||
}
|
||||
|
||||
it("never joins `modules` onto a root anywhere else in Core or the API", async () => {
|
||||
const files = [
|
||||
...(await sourceFiles(path.join(repositoryRoot, "core", "src"))),
|
||||
...(await sourceFiles(path.join(repositoryRoot, "apps", "api", "src"))),
|
||||
];
|
||||
const resolver = path.join(repositoryRoot, "core", "src", "modules", "modules-directory.ts");
|
||||
for (const file of files) {
|
||||
if (file === resolver) {
|
||||
continue;
|
||||
}
|
||||
const text = await readFile(file, "utf8");
|
||||
expect(text, path.relative(repositoryRoot, file)).not.toMatch(/,\s*"modules"\s*\)/u);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
discoverModules,
|
||||
ModuleDiscoveryError,
|
||||
type ModuleDiscoveryIssueCode,
|
||||
resolveModulesDirectory,
|
||||
} from "../src/modules/index.js";
|
||||
|
||||
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const;
|
||||
const repositoryRoot = path.join(path.sep, "opt", "ekklesia");
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-modules-directory-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function captureDiscoveryError(operation: () => unknown): ModuleDiscoveryError {
|
||||
try {
|
||||
operation();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ModuleDiscoveryError);
|
||||
return error as ModuleDiscoveryError;
|
||||
}
|
||||
throw new Error("Expected a ModuleDiscoveryError.");
|
||||
}
|
||||
|
||||
async function captureAsyncDiscoveryError(
|
||||
operation: Promise<unknown>,
|
||||
): Promise<ModuleDiscoveryError> {
|
||||
try {
|
||||
await operation;
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ModuleDiscoveryError);
|
||||
return error as ModuleDiscoveryError;
|
||||
}
|
||||
throw new Error("Expected a ModuleDiscoveryError.");
|
||||
}
|
||||
|
||||
function codes(error: ModuleDiscoveryError): readonly ModuleDiscoveryIssueCode[] {
|
||||
return error.issues.map((issue) => issue.code);
|
||||
}
|
||||
|
||||
/** Everything an operator, a log, or a test report could ever see from this error. */
|
||||
function rendered(error: ModuleDiscoveryError): string {
|
||||
return JSON.stringify({ issues: error.issues, message: error.message, stack: error.stack });
|
||||
}
|
||||
|
||||
describe("resolveModulesDirectory", () => {
|
||||
it("defaults to the repository's own modules directory when nothing is configured", () => {
|
||||
expect(resolveModulesDirectory({ configured: undefined, repositoryRoot })).toEqual({
|
||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||
modulesDirectoryRequired: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts an explicit absolute path, normalized, and requires it to exist", () => {
|
||||
expect(
|
||||
resolveModulesDirectory({ configured: "/var/lib/ekklesia/modules/", repositoryRoot }),
|
||||
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
|
||||
expect(
|
||||
resolveModulesDirectory({ configured: "/var/lib/./ekklesia//modules", repositoryRoot }),
|
||||
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
|
||||
});
|
||||
|
||||
it("never resolves a configured path against the repository or the working directory", () => {
|
||||
const resolved = resolveModulesDirectory({ configured: "/srv/modules", repositoryRoot });
|
||||
expect(resolved.modulesDirectory).toBe("/srv/modules");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a relative name", "modules"],
|
||||
["a dot-relative path", "./modules"],
|
||||
["a parent-relative path", "../private/modules"],
|
||||
["a home-relative path", "~/modules"],
|
||||
["the empty string", ""],
|
||||
["whitespace only", " "],
|
||||
["an absolute path with surrounding whitespace", " /var/lib/ekklesia/modules"],
|
||||
["a NUL byte", "/var/lib/ekklesia/\u0000modules"],
|
||||
])("rejects %s with a sanitized MODULES_DIRECTORY_INVALID", (_label, configured) => {
|
||||
const error = captureDiscoveryError(() =>
|
||||
resolveModulesDirectory({ configured, repositoryRoot }),
|
||||
);
|
||||
expect(codes(error)).toEqual(["MODULES_DIRECTORY_INVALID"]);
|
||||
expect(error.issues[0]?.message).toBe("EKKLESIA_MODULES_DIR must be an absolute path.");
|
||||
});
|
||||
|
||||
it("never echoes the supplied value in the error", () => {
|
||||
const configured = "relative/secret-token-a1b2c3/postgresql://admin:hunter2@db/modules";
|
||||
const error = captureDiscoveryError(() =>
|
||||
resolveModulesDirectory({ configured, repositoryRoot }),
|
||||
);
|
||||
const text = rendered(error);
|
||||
expect(text).not.toContain("secret-token-a1b2c3");
|
||||
expect(text).not.toContain("hunter2");
|
||||
expect(text).not.toContain("relative/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("module discovery against a configured modules directory", () => {
|
||||
it("fails closed, without the path, when a configured directory does not exist", async () => {
|
||||
const parent = await temporaryDirectory();
|
||||
const missing = path.join(parent, "private-mount-name-7f3e");
|
||||
const error = await captureAsyncDiscoveryError(
|
||||
discoverModules({ ...versions, modulesDirectory: missing, modulesDirectoryRequired: true }),
|
||||
);
|
||||
expect(codes(error)).toEqual(["MODULES_DIRECTORY_UNREADABLE"]);
|
||||
expect(rendered(error)).not.toContain("private-mount-name-7f3e");
|
||||
expect(rendered(error)).not.toContain(parent);
|
||||
});
|
||||
|
||||
it("fails closed when a configured path is a file rather than a directory", async () => {
|
||||
const parent = await temporaryDirectory();
|
||||
const file = path.join(parent, "modules");
|
||||
await writeFile(file, "not a directory");
|
||||
const error = await captureAsyncDiscoveryError(
|
||||
discoverModules({ ...versions, modulesDirectory: file, modulesDirectoryRequired: true }),
|
||||
);
|
||||
expect(codes(error)).toEqual(["MODULES_DIRECTORY_UNREADABLE"]);
|
||||
expect(rendered(error)).not.toContain(parent);
|
||||
});
|
||||
|
||||
it("rejects a relative configured path even when it bypasses the resolver", async () => {
|
||||
const error = await captureAsyncDiscoveryError(
|
||||
discoverModules({ ...versions, modulesDirectory: "modules", modulesDirectoryRequired: true }),
|
||||
);
|
||||
expect(codes(error)).toEqual(["MODULES_DIRECTORY_INVALID"]);
|
||||
});
|
||||
|
||||
it("treats an existing, empty configured directory as zero installed modules", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
await expect(
|
||||
discoverModules({ ...versions, modulesDirectory: directory, modulesDirectoryRequired: true }),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the development default lenient: a missing default directory is zero modules", async () => {
|
||||
const parent = await temporaryDirectory();
|
||||
await expect(
|
||||
discoverModules({ ...versions, modulesDirectory: path.join(parent, "modules") }),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("discovers through the resolver's own selection unchanged", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const selection = resolveModulesDirectory({ configured: directory, repositoryRoot });
|
||||
await expect(discoverModules({ ...versions, ...selection })).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client, type QueryResult, type QueryResultRow } from "pg";
|
||||
@@ -8,6 +10,7 @@ import { reportMigrationCommandError } from "../src/cli/migration-command-errors
|
||||
import {
|
||||
applyModuleMigrations,
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
discoverCoreMigrations,
|
||||
type MigrationApproval,
|
||||
type MigrationApprovalProvider,
|
||||
@@ -764,7 +767,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
|
||||
database: migrationDatabase,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||
expect(await databaseState(database.client)).toEqual(before);
|
||||
const afterData = await safeQuery<QueryResultRow>(
|
||||
database.client,
|
||||
@@ -834,7 +837,48 @@ describe.sequential("live PostgreSQL module migration execution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unknown applied identity without repair or further execution", async () => {
|
||||
it("retains an absent module's history and still applies later work (ADR 0048)", async () => {
|
||||
await withIsolatedDatabase("absent_history", async (database) => {
|
||||
const initial = peopleInitial();
|
||||
const later = peopleLater();
|
||||
const initialInventory = aggregate(moduleFixture("people", [initial]));
|
||||
const completeInventory = aggregate(moduleFixture("people", [initial, later]));
|
||||
const migrationDatabase = createPostgresMigrationDatabase(database.url);
|
||||
await applyModuleMigrations({ aggregate: initialInventory, database: migrationDatabase });
|
||||
// History left by a module that is no longer installed: kept, reported, never fatal.
|
||||
await safeQuery(
|
||||
database.client,
|
||||
`
|
||||
INSERT INTO public.ekklesia_core_migrations
|
||||
(identity, module_id, migration_tag, migration_index, checksum)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`,
|
||||
["ghost:0000_unknown", "ghost", "0000_unknown", 0, "f".repeat(64)],
|
||||
);
|
||||
|
||||
const plan = await planModuleMigrations({
|
||||
aggregate: completeInventory,
|
||||
database: migrationDatabase,
|
||||
});
|
||||
expect(plan.retained).toEqual([{ appliedCount: 1, moduleId: "ghost" }]);
|
||||
expect(plan.pendingCount).toBe(1);
|
||||
|
||||
const result = await applyModuleMigrations({
|
||||
aggregate: completeInventory,
|
||||
database: migrationDatabase,
|
||||
});
|
||||
expect(result.applied.map((migration) => migration.identity)).toEqual([later.identity]);
|
||||
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "ghost" }]);
|
||||
expect(await columnExists(database.client, "people_records", "email")).toBe(true);
|
||||
expect((await readHistory(database.client)).map((row) => row.identity)).toEqual([
|
||||
initial.identity,
|
||||
"ghost:0000_unknown",
|
||||
later.identity,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unknown applied identity of an installed module without repair or further execution", async () => {
|
||||
await withIsolatedDatabase("unknown_history", async (database) => {
|
||||
const initial = peopleInitial();
|
||||
const later = peopleLater();
|
||||
@@ -849,7 +893,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
|
||||
(identity, module_id, migration_tag, migration_index, checksum)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`,
|
||||
["ghost:0000_unknown", "ghost", "0000_unknown", 0, "f".repeat(64)],
|
||||
["people:0099_unknown", "people", "0099_unknown", 99, "f".repeat(64)],
|
||||
);
|
||||
const corrupted = await databaseState(database.client);
|
||||
|
||||
@@ -866,7 +910,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
|
||||
expect(await columnExists(database.client, "people_records", "email")).toBe(false);
|
||||
expect((await readHistory(database.client)).map((row) => row.identity)).toEqual([
|
||||
initial.identity,
|
||||
"ghost:0000_unknown",
|
||||
"people:0099_unknown",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1358,7 +1402,7 @@ describe.sequential("live PostgreSQL Core identity migration execution", () => {
|
||||
database: migrationDatabase,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||
expect(await databaseState(database.client)).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -1851,3 +1895,151 @@ describe.sequential("live PostgreSQL migration timeout precedence", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* ADR 0048 end to end, with real discovery: Core's committed migrations, the module template
|
||||
* installed in a modules directory outside the repository, and a real PostgreSQL. Core's newest
|
||||
* migration is withheld from a copy of `core/drizzle` at first, so a genuinely later Core migration
|
||||
* can be applied while the module is absent.
|
||||
*/
|
||||
describe.sequential("live PostgreSQL retained history for an uninstalled module", () => {
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const templateModule = path.join(repositoryRoot, "templates", "module");
|
||||
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const;
|
||||
|
||||
async function coreWithoutNewestMigration(directory: string): Promise<string> {
|
||||
const target = path.join(directory, "core-drizzle");
|
||||
await cp(coreMigrationsDirectory, target, { recursive: true });
|
||||
const journalPath = path.join(target, "meta", "_journal.json");
|
||||
const policyPath = path.join(target, "meta", "_ekklesia.json");
|
||||
const journal = JSON.parse(await readFile(journalPath, "utf8")) as {
|
||||
entries: { idx: number; tag: string }[];
|
||||
};
|
||||
const policy = JSON.parse(await readFile(policyPath, "utf8")) as { entries: unknown[] };
|
||||
const newest = journal.entries.pop();
|
||||
policy.entries.pop();
|
||||
if (!newest) {
|
||||
throw harnessError("Core has no migration to withhold.");
|
||||
}
|
||||
await writeFile(journalPath, `${JSON.stringify(journal, null, 2)}\n`);
|
||||
await writeFile(policyPath, `${JSON.stringify(policy, null, 2)}\n`);
|
||||
await rm(path.join(target, `${newest.tag}.sql`));
|
||||
await rm(path.join(target, "meta", `${String(newest.idx).padStart(4, "0")}_snapshot.json`));
|
||||
return target;
|
||||
}
|
||||
|
||||
async function installExample(modulesDirectory: string): Promise<void> {
|
||||
for (const entry of ["package.json", "ekklesia.module.json", "drizzle", "dist"]) {
|
||||
await cp(path.join(templateModule, entry), path.join(modulesDirectory, "example", entry), {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function inventory(coreDirectory: string, modulesDirectory: string) {
|
||||
return discoverAllMigrations({
|
||||
coreMigrationsDirectory: coreDirectory,
|
||||
moduleDiscovery: { ...versions, modulesDirectory, modulesDirectoryRequired: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function exampleValues(client: Client): Promise<readonly string[]> {
|
||||
const result = await safeQuery<{ readonly value: string } & QueryResultRow>(
|
||||
client,
|
||||
"SELECT value FROM public.example_records ORDER BY value",
|
||||
);
|
||||
return result.rows.map((row) => row.value);
|
||||
}
|
||||
|
||||
it("keeps an uninstalled module's data and history, applies later Core work, and restores it", async () => {
|
||||
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-retained-history-"));
|
||||
try {
|
||||
const earlyCore = await coreWithoutNewestMigration(scratch);
|
||||
const modulesDirectory = path.join(scratch, "modules");
|
||||
await mkdir(modulesDirectory);
|
||||
await installExample(modulesDirectory);
|
||||
|
||||
await withIsolatedDatabase("retained_module", async (database) => {
|
||||
const migrationDatabase = createPostgresMigrationDatabase(database.url);
|
||||
|
||||
// 1. Core (minus its newest migration) and the installed module, applied together.
|
||||
const first = await applyModuleMigrations({
|
||||
aggregate: await inventory(earlyCore, modulesDirectory),
|
||||
database: migrationDatabase,
|
||||
});
|
||||
expect(first.applied.map((migration) => migration.identity)).toContain(
|
||||
"example:0000_initial",
|
||||
);
|
||||
expect(first.retained).toEqual([]);
|
||||
|
||||
// 2. Module-owned data.
|
||||
await safeQuery(
|
||||
database.client,
|
||||
"INSERT INTO public.example_records (id, value) VALUES (gen_random_uuid(), 'kept')",
|
||||
);
|
||||
|
||||
// 3. The module is uninstalled: its directory is gone from module storage.
|
||||
await rm(path.join(modulesDirectory, "example"), { recursive: true });
|
||||
|
||||
// 4. A later Core migration plans and applies while the module is absent.
|
||||
const absentInventory = await inventory(coreMigrationsDirectory, modulesDirectory);
|
||||
const absentPlan = await planModuleMigrations({
|
||||
aggregate: absentInventory,
|
||||
database: migrationDatabase,
|
||||
});
|
||||
expect(absentPlan.retained).toEqual([{ appliedCount: 1, moduleId: "example" }]);
|
||||
expect(
|
||||
absentPlan.entries.filter((entry) => entry.status === "pending").map((e) => e.moduleId),
|
||||
).toEqual(["core"]);
|
||||
const later = await applyModuleMigrations({
|
||||
aggregate: absentInventory,
|
||||
database: migrationDatabase,
|
||||
});
|
||||
expect(later.applied).toHaveLength(1);
|
||||
expect(later.applied[0]?.moduleId).toBe("core");
|
||||
expect(later.retained).toEqual([{ appliedCount: 1, moduleId: "example" }]);
|
||||
|
||||
// 5. The module's table and rows are untouched, and so is its history.
|
||||
expect(await exampleValues(database.client)).toEqual(["kept"]);
|
||||
expect((await readHistory(database.client)).map((row) => row.identity)).toContain(
|
||||
"example:0000_initial",
|
||||
);
|
||||
|
||||
// 6. The module is reinstalled.
|
||||
await installExample(modulesDirectory);
|
||||
const restored = await inventory(coreMigrationsDirectory, modulesDirectory);
|
||||
|
||||
// 7. Its history is strict again, and valid; its data is where it left it.
|
||||
const restoredPlan = await planModuleMigrations({
|
||||
aggregate: restored,
|
||||
database: migrationDatabase,
|
||||
});
|
||||
expect(restoredPlan.retained).toEqual([]);
|
||||
expect(restoredPlan.pendingCount).toBe(0);
|
||||
expect(
|
||||
restoredPlan.entries.find((entry) => entry.identity === "example:0000_initial")?.status,
|
||||
).toBe("applied");
|
||||
const noOp = await applyModuleMigrations({
|
||||
aggregate: restored,
|
||||
database: migrationDatabase,
|
||||
});
|
||||
expect(noOp.noOp).toBe(true);
|
||||
expect(await exampleValues(database.client)).toEqual(["kept"]);
|
||||
|
||||
// A returning package whose applied migration changed is refused, exactly as before.
|
||||
const sqlPath = path.join(modulesDirectory, "example", "drizzle", "0000_initial.sql");
|
||||
await writeFile(sqlPath, `${await readFile(sqlPath, "utf8")}-- edited\n`);
|
||||
await captureRunnerError(
|
||||
planModuleMigrations({
|
||||
aggregate: await inventory(coreMigrationsDirectory, modulesDirectory),
|
||||
database: migrationDatabase,
|
||||
}),
|
||||
"MIGRATION_CHECKSUM_MISMATCH",
|
||||
);
|
||||
expect(await exampleValues(database.client)).toEqual(["kept"]);
|
||||
});
|
||||
} finally {
|
||||
await rm(scratch, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user