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]>
163 lines
5.2 KiB
TypeScript
163 lines
5.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
formatMigrationApplyReport,
|
|
formatMigrationPlanReport,
|
|
} from "../src/cli/migration-report.js";
|
|
import type { MigrationApplyResult, MigrationPlanResult } from "../src/migrations/index.js";
|
|
|
|
describe("migration command reports", () => {
|
|
it("reports a deterministic safe plan and its stale-plan warning", () => {
|
|
const plan: MigrationPlanResult = Object.freeze({
|
|
appliedCount: 1,
|
|
approvalRequiredCount: 1,
|
|
entries: Object.freeze([
|
|
Object.freeze({
|
|
approvalRequired: false,
|
|
checksum: "a".repeat(64),
|
|
identity: "people:0000_initial",
|
|
index: 0,
|
|
moduleId: "people",
|
|
status: "applied",
|
|
tag: "0000_initial",
|
|
}),
|
|
Object.freeze({
|
|
approvalRequired: false,
|
|
checksum: "b".repeat(64),
|
|
identity: "people:0001_add_email",
|
|
index: 1,
|
|
moduleId: "people",
|
|
status: "pending",
|
|
tag: "0001_add_email",
|
|
}),
|
|
Object.freeze({
|
|
approvalRequired: true,
|
|
checksum: "c".repeat(64),
|
|
identity: "calendar:0000_replace_events",
|
|
index: 0,
|
|
moduleId: "calendar",
|
|
status: "approval-blocked",
|
|
tag: "0000_replace_events",
|
|
}),
|
|
]),
|
|
pendingCount: 2,
|
|
retained: Object.freeze([]),
|
|
});
|
|
|
|
expect(formatMigrationPlanReport(plan)).toEqual([
|
|
"Migration plan (1 applied, 1 pending, 1 approval-blocked):",
|
|
`- [applied] people:0000_initial sha256=${"a".repeat(64)}`,
|
|
`- [pending] people:0001_add_email sha256=${"b".repeat(64)}`,
|
|
`- [approval-blocked] calendar:0000_replace_events sha256=${"c".repeat(64)}`,
|
|
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
|
|
]);
|
|
});
|
|
|
|
it("reports applied identities without SQL and handles a genuine no-op", () => {
|
|
const applied: MigrationApplyResult = Object.freeze({
|
|
applied: Object.freeze([
|
|
Object.freeze({
|
|
checksum: "d".repeat(64),
|
|
identity: "people:0000_initial",
|
|
index: 0,
|
|
moduleId: "people",
|
|
tag: "0000_initial",
|
|
}),
|
|
]),
|
|
appliedCount: 1,
|
|
noOp: false,
|
|
retained: Object.freeze([]),
|
|
});
|
|
|
|
expect(formatMigrationApplyReport(applied)).toEqual([
|
|
"Migration apply completed (1 migration applied):",
|
|
`- people:0000_initial sha256=${"d".repeat(64)}`,
|
|
]);
|
|
expect(
|
|
formatMigrationApplyReport(
|
|
Object.freeze({
|
|
applied: Object.freeze([]),
|
|
appliedCount: 0,
|
|
noOp: true,
|
|
retained: Object.freeze([]),
|
|
}),
|
|
),
|
|
).toEqual(["Migration apply completed: database is already current (no changes)."]);
|
|
});
|
|
|
|
it("renders an empty installed inventory explicitly", () => {
|
|
expect(
|
|
formatMigrationPlanReport(
|
|
Object.freeze({
|
|
appliedCount: 0,
|
|
approvalRequiredCount: 0,
|
|
entries: Object.freeze([]),
|
|
pendingCount: 0,
|
|
retained: Object.freeze([]),
|
|
}),
|
|
),
|
|
).toEqual([
|
|
"Migration plan (0 applied, 0 pending, 0 approval-blocked):",
|
|
"- No installed module migrations.",
|
|
"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,
|
|
]);
|
|
});
|
|
});
|