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]>
81 lines
2.9 KiB
TypeScript
81 lines
2.9 KiB
TypeScript
import type { ModuleMigrationAggregate } from "../modules/index.js";
|
|
import type {
|
|
MigrationApplyResult,
|
|
MigrationPlanResult,
|
|
RetainedMigrationHistory,
|
|
} from "../migrations/index.js";
|
|
|
|
function plural(count: number, singular: string): string {
|
|
return count === 1 ? singular : `${singular}s`;
|
|
}
|
|
|
|
export function formatMigrationValidationReport(
|
|
aggregate: ModuleMigrationAggregate,
|
|
): readonly string[] {
|
|
const moduleCount = aggregate.modules.length;
|
|
const migrationCount = aggregate.migrations.length;
|
|
const header = `Migration validation passed (${moduleCount} ${plural(moduleCount, "module")}, ${migrationCount} ${plural(migrationCount, "migration")})${moduleCount === 0 ? "." : ":"}`;
|
|
const lines = [header];
|
|
|
|
for (const module of aggregate.modules) {
|
|
if (module.migrations.length === 0) {
|
|
lines.push(`- ${module.moduleId} (no migrations)`);
|
|
continue;
|
|
}
|
|
for (const migration of module.migrations) {
|
|
lines.push(
|
|
`- ${migration.identity} sha256=${migration.checksum} destructive=${migration.destructive.mode}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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${retainedSummary}):`,
|
|
];
|
|
|
|
for (const entry of plan.entries) {
|
|
lines.push(`- [${entry.status}] ${entry.identity} sha256=${entry.checksum}`);
|
|
}
|
|
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.",
|
|
);
|
|
|
|
return Object.freeze(lines);
|
|
}
|
|
|
|
export function formatMigrationApplyReport(result: MigrationApplyResult): readonly string[] {
|
|
if (result.noOp) {
|
|
return Object.freeze([
|
|
"Migration apply completed: database is already current (no changes).",
|
|
...retainedLines(result.retained),
|
|
]);
|
|
}
|
|
|
|
const lines = [
|
|
`Migration apply completed (${result.appliedCount} ${plural(result.appliedCount, "migration")} applied):`,
|
|
];
|
|
for (const migration of result.applied) {
|
|
lines.push(`- ${migration.identity} sha256=${migration.checksum}`);
|
|
}
|
|
lines.push(...retainedLines(result.retained));
|
|
return Object.freeze(lines);
|
|
}
|