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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user