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