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
@@ -97,7 +97,7 @@ describe("CI scripts on a shared Docker daemon", () => {
|
||||
>;
|
||||
};
|
||||
const startsStacks =
|
||||
/smoke-infrastructure\.sh|test:migrations:integration|smoke-deploy\.sh|docker build/u;
|
||||
/smoke-infrastructure\.sh|test:migrations:integration|smoke-deploy\.sh|check-image\.sh|docker build/u;
|
||||
const jobs = Object.entries(workflow.jobs).filter(([, job]) =>
|
||||
job.steps.some((step) => startsStacks.test(step.run ?? "")),
|
||||
);
|
||||
@@ -132,12 +132,37 @@ describe("CI scripts on a shared Docker daemon", () => {
|
||||
|
||||
it("tag the CI image per run and attempt, so concurrent runs of one commit cannot remove it", async () => {
|
||||
const workflow = await source(".github/workflows/ci.yml");
|
||||
expect(workflow).toContain(
|
||||
'ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"',
|
||||
const imageCheck = await source("scripts/check-image.sh");
|
||||
expect(workflow).toContain("bash scripts/check-image.sh");
|
||||
expect(imageCheck).toContain(
|
||||
'tag_base="ekklesia-ci:${GITHUB_SHA:-local}-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-"',
|
||||
);
|
||||
expect(workflow).not.toContain('ekklesia-ci:"$GITHUB_SHA"');
|
||||
});
|
||||
|
||||
it("label every image the image check builds, so a sweep can find it", async () => {
|
||||
const imageCheck = (await source("scripts/check-image.sh")).replace(/\\\n\s*/gu, " ");
|
||||
expect(imageCheck).toContain('source "$repository_root/scripts/lib/ci-project.sh"');
|
||||
expect(imageCheck).toMatch(/^project="\$\(ci_project_name image\)"$/mu);
|
||||
const builds = imageCheck.split("\n").filter((line) => /\bdocker build\b/u.test(line));
|
||||
expect(builds.length).toBeGreaterThanOrEqual(1);
|
||||
for (const line of builds) {
|
||||
expect(line).toContain('--label "${EKKLESIA_CI_LABEL}=${project}"');
|
||||
}
|
||||
});
|
||||
|
||||
it("run every deploy smoke against the one generic image, never a second image with a module in it", async () => {
|
||||
// ADR 0048: the split topology installs its test module into module storage at run time.
|
||||
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
|
||||
const imageBuilds = deploy
|
||||
.split("\n")
|
||||
.filter((line) => /\bdocker build\b/u.test(line) && line.includes("deploy/Dockerfile"));
|
||||
expect(imageBuilds).toHaveLength(1);
|
||||
expect(deploy).not.toContain('mv "$context/templates/module" "$context/modules/');
|
||||
expect(deploy).not.toMatch(/sed -i 's\|\^ {2}templates\/module:/u);
|
||||
expect(deploy).toContain("EKKLESIA_MODULES_HOST_DIR=");
|
||||
});
|
||||
|
||||
it("report every readiness failure with the project's containers and logs, through the redactor only", async () => {
|
||||
for (const relativePath of ciScripts) {
|
||||
const text = await source(relativePath);
|
||||
|
||||
@@ -26,6 +26,15 @@ interface ComposeService {
|
||||
read_only?: boolean;
|
||||
security_opt?: string[];
|
||||
tmpfs?: string[];
|
||||
volumes?: (string | ComposeVolume)[];
|
||||
}
|
||||
|
||||
interface ComposeVolume {
|
||||
bind?: { create_host_path?: boolean };
|
||||
read_only?: boolean;
|
||||
source?: string;
|
||||
target?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface ComposeDocument {
|
||||
@@ -234,3 +243,68 @@ describe("reference deployment env examples", () => {
|
||||
expect(example).toContain("EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("installation-owned module storage (ADR 0048)", () => {
|
||||
const modulesTarget = "/var/lib/ekklesia/modules";
|
||||
|
||||
function moduleMount(service: ComposeService | undefined): ComposeVolume | undefined {
|
||||
return (service?.volumes ?? []).find(
|
||||
(volume): volume is ComposeVolume =>
|
||||
typeof volume === "object" && volume.target === modulesTarget,
|
||||
);
|
||||
}
|
||||
|
||||
it.each([
|
||||
"api",
|
||||
"migrate",
|
||||
])("mounts host module storage read-only into %s, and never lets Compose create it", async (name) => {
|
||||
const service = (await compose("compose.yaml")).services?.[name];
|
||||
expect(moduleMount(service)).toEqual({
|
||||
bind: { create_host_path: false },
|
||||
read_only: true,
|
||||
source: "${EKKLESIA_MODULES_HOST_DIR:-/var/lib/ekklesia/modules}",
|
||||
target: modulesTarget,
|
||||
type: "bind",
|
||||
});
|
||||
// The process reads exactly the directory the mount provides.
|
||||
expect(service?.environment?.EKKLESIA_MODULES_DIR).toBe(modulesTarget);
|
||||
// Read-only everywhere else too: the mount is the only module storage there is.
|
||||
expect(service?.read_only).toBe(true);
|
||||
});
|
||||
|
||||
it("mounts module storage into nothing but the API and the operator commands", async () => {
|
||||
const services = (await compose("compose.yaml")).services ?? {};
|
||||
for (const [name, service] of Object.entries(services)) {
|
||||
if (name !== "api" && name !== "migrate") {
|
||||
expect(moduleMount(service), name).toBeUndefined();
|
||||
}
|
||||
}
|
||||
for (const overlay of ["compose.proxy.yaml", "compose.tunnel.yaml"]) {
|
||||
for (const [name, service] of Object.entries((await compose(overlay)).services ?? {})) {
|
||||
expect(moduleMount(service), `${overlay} ${name}`).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("names the host-side directory with its own variable, never EKKLESIA_MODULES_DIR", async () => {
|
||||
const composeEnvironment = await text("env/compose.env.example");
|
||||
expect(composeEnvironment).toMatch(
|
||||
/^# ?EKKLESIA_MODULES_HOST_DIR=\/var\/lib\/ekklesia\/modules$/mu,
|
||||
);
|
||||
expect(composeEnvironment).not.toMatch(/^#? ?EKKLESIA_MODULES_DIR=/mu);
|
||||
// The API's own env file must not move the directory away from the mount.
|
||||
expect(envAssignments(await text("env/ekklesia.env.example")).has("EKKLESIA_MODULES_DIR")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("builds an image that holds no modules and points at empty module storage", async () => {
|
||||
const dockerfile = await text("Dockerfile");
|
||||
const ignore = await readFile(path.join(repositoryRoot, ".dockerignore"), "utf8");
|
||||
expect(ignore).toMatch(/^\/modules$/mu);
|
||||
expect(dockerfile).toMatch(/EKKLESIA_MODULES_DIR=\/var\/lib\/ekklesia\/modules/u);
|
||||
expect(dockerfile).toContain("install -d -o root -g root -m 0755 /var/lib/ekklesia/modules");
|
||||
// Nothing from a module or the module template is copied into, or installed for, the runtime.
|
||||
expect(dockerfile).not.toMatch(/\/runtime\/modules|modules\/\*\/package\.json|templates\/\*/u);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user