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

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:
ekklesia
2026-10-07 08:38:14 -05:00
co-authored by Claude Opus 5.5
parent 4d28a82937
commit e36b70d8d4
50 changed files with 2275 additions and 224 deletions
+71 -9
View File
@@ -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;