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]>
311 lines
13 KiB
TypeScript
311 lines
13 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it } from "vitest";
|
|
import { parse } from "yaml";
|
|
|
|
/**
|
|
* Structural guarantees of the reference deployment (ADR 0044), checked on the files themselves so
|
|
* an edit cannot quietly weaken them. `scripts/smoke-deploy.sh` proves the same configuration
|
|
* actually runs; this proves it still says what the documentation promises.
|
|
*/
|
|
|
|
interface ComposeService {
|
|
build?: unknown;
|
|
cap_add?: string[];
|
|
cap_drop?: string[];
|
|
command?: string[];
|
|
env_file?: string[];
|
|
environment?: Record<string, string>;
|
|
healthcheck?: { disable?: boolean };
|
|
image?: string;
|
|
init?: boolean;
|
|
networks?: Record<string, { ipv4_address?: string } | null>;
|
|
ports?: string[];
|
|
profiles?: string[];
|
|
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 {
|
|
networks?: Record<
|
|
string,
|
|
{ internal?: boolean; ipam?: { config?: { subnet?: string; ip_range?: string }[] } }
|
|
>;
|
|
services?: Record<string, ComposeService>;
|
|
}
|
|
|
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const deployDirectory = path.join(repositoryRoot, "deploy");
|
|
|
|
async function compose(file: string): Promise<ComposeDocument> {
|
|
// `merge: true` resolves the base file's `<<:` hardening anchors exactly as Compose does.
|
|
return parse(await readFile(path.join(deployDirectory, file), "utf8"), {
|
|
merge: true,
|
|
}) as ComposeDocument;
|
|
}
|
|
|
|
async function text(file: string): Promise<string> {
|
|
return readFile(path.join(deployDirectory, file), "utf8");
|
|
}
|
|
|
|
function envAssignments(source: string): Map<string, string> {
|
|
const assignments = new Map<string, string>();
|
|
for (const line of source.split("\n")) {
|
|
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/u.exec(line);
|
|
if (match?.[1]) {
|
|
assignments.set(match[1], match[2] ?? "");
|
|
}
|
|
}
|
|
return assignments;
|
|
}
|
|
|
|
const DIGEST_PINNED = /^[^\s:@]+(?::[^\s@]+)?@sha256:[0-9a-f]{64}$/u;
|
|
|
|
describe("reference deployment compose files", () => {
|
|
it("keeps PostgreSQL on its internal network only, and publishes no port from the base", async () => {
|
|
const base = await compose("compose.yaml");
|
|
const services = base.services ?? {};
|
|
|
|
expect(Object.keys(services).sort()).toEqual(["api", "migrate", "object-storage", "postgres"]);
|
|
expect(Object.keys(services.postgres?.networks ?? {})).toEqual(["data"]);
|
|
expect(base.networks?.data?.internal).toBe(true);
|
|
expect(base.networks?.storage?.internal).toBe(true);
|
|
for (const [name, service] of Object.entries(services)) {
|
|
expect(service.ports, name).toBeUndefined();
|
|
}
|
|
|
|
for (const overlay of ["compose.proxy.yaml", "compose.tunnel.yaml"]) {
|
|
const document = await compose(overlay);
|
|
expect(document.services?.postgres, overlay).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("pins every third-party image by digest and runs the API only from EKKLESIA_IMAGE", async () => {
|
|
const documents = await Promise.all(
|
|
["compose.yaml", "compose.proxy.yaml", "compose.tunnel.yaml"].map(compose),
|
|
);
|
|
for (const document of documents) {
|
|
for (const [name, service] of Object.entries(document.services ?? {})) {
|
|
expect(service.build, name).toBeUndefined();
|
|
if (service.image === undefined) {
|
|
continue;
|
|
}
|
|
if (name === "api" || name === "migrate") {
|
|
expect(service.image, name).toMatch(/^\$\{EKKLESIA_IMAGE:\?/u);
|
|
} else {
|
|
expect(service.image, name).toMatch(DIGEST_PINNED);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("hardens api and migrate, keeps migrate operator-only, and never lets it apply by default", async () => {
|
|
const services = (await compose("compose.yaml")).services ?? {};
|
|
for (const name of ["api", "migrate"]) {
|
|
const service = services[name];
|
|
expect(service?.init, name).toBe(true);
|
|
expect(service?.read_only, name).toBe(true);
|
|
expect(service?.tmpfs, name).toEqual(["/tmp"]);
|
|
expect(service?.cap_drop, name).toEqual(["ALL"]);
|
|
expect(service?.security_opt, name).toContain("no-new-privileges:true");
|
|
expect(service?.environment?.NODE_EXTRA_CA_CERTS, name).toBe("/run/ekklesia/tls/ca.crt");
|
|
}
|
|
const migrate = services.migrate;
|
|
expect(migrate?.profiles).toEqual(["ops"]);
|
|
expect(migrate?.healthcheck?.disable).toBe(true);
|
|
expect(migrate?.command).toEqual(["node", "core/dist/cli/plan-migrations.js"]);
|
|
expect(JSON.stringify(services)).not.toContain("apply-migrations");
|
|
});
|
|
|
|
it("serves PostgreSQL and object storage over TLS from the installation-private CA", async () => {
|
|
const services = (await compose("compose.yaml")).services ?? {};
|
|
expect(services.postgres?.command).toEqual(
|
|
expect.arrayContaining([
|
|
"ssl=on",
|
|
"ssl_min_protocol_version=TLSv1.3",
|
|
"hba_file=/etc/ekklesia/pg_hba.conf",
|
|
]),
|
|
);
|
|
expect(services["object-storage"]?.command).toEqual(
|
|
expect.arrayContaining(["-s3.port.https=8443"]),
|
|
);
|
|
|
|
const hba = (await text("postgres/pg_hba.conf"))
|
|
.split("\n")
|
|
.filter((line) => line.trim() !== "" && !line.trim().startsWith("#"))
|
|
.map((line) => line.trim().split(/\s+/u));
|
|
expect(hba).toEqual([
|
|
["local", "all", "all", "trust"],
|
|
["hostssl", "all", "all", "all", "scram-sha-256"],
|
|
["hostnossl", "all", "all", "all", "reject"],
|
|
]);
|
|
});
|
|
|
|
it("makes the proxy the API's only trusted peer, at a fixed address outside the dynamic range", async () => {
|
|
const proxyOverlay = await compose("compose.proxy.yaml");
|
|
const proxy = proxyOverlay.services?.proxy;
|
|
const proxyAddress = proxy?.networks?.edge?.ipv4_address;
|
|
expect(proxyAddress).toBe("${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}");
|
|
expect(proxyOverlay.services?.api?.environment).toEqual({
|
|
EKKLESIA_CLIENT_ADDRESS_HEADER: "x-forwarded-for",
|
|
EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy",
|
|
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: `${proxyAddress}/32`,
|
|
});
|
|
// Doctor runs as `migrate`. Given anything else, it validates a configuration the API is not
|
|
// running, and reports "Direct" for an installation that trusts its proxy.
|
|
expect(proxyOverlay.services?.migrate?.environment).toEqual(
|
|
proxyOverlay.services?.api?.environment,
|
|
);
|
|
expect(proxyOverlay.networks?.edge?.ipam?.config).toEqual([
|
|
{
|
|
ip_range: "${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25}",
|
|
subnet: "${EKKLESIA_EDGE_SUBNET:-10.249.250.0/24}",
|
|
},
|
|
]);
|
|
expect(proxy?.cap_drop).toEqual(["ALL"]);
|
|
expect(proxy?.cap_add).toEqual(["NET_BIND_SERVICE"]);
|
|
|
|
const tunnel = (await compose("compose.tunnel.yaml")).services?.tunnel;
|
|
expect(tunnel?.networks?.edge?.ipv4_address).toBe("${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}");
|
|
expect(tunnel?.env_file).toEqual(["${EKKLESIA_ENV_DIR:-/etc/ekklesia}/tunnel.env"]);
|
|
});
|
|
|
|
it("routes only the API's own paths, overwrites the forwarding header, and never ships local_certs", async () => {
|
|
const caddyfile = await text("Caddyfile.example");
|
|
const directives = caddyfile
|
|
.split("\n")
|
|
.filter((line) => !line.trim().startsWith("#"))
|
|
.join("\n");
|
|
|
|
expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*");
|
|
expect(directives).toContain("header_up X-Forwarded-For {client_ip}");
|
|
expect(directives).toContain("header_up -CF-Connecting-IP");
|
|
expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32");
|
|
expect(directives).toContain("header_up Host {host}");
|
|
expect(directives).toContain("tls_trust_pool file /etc/caddy/ekklesia-ca.crt");
|
|
// Caddy's internal CA is a test-only substitution made by scripts/smoke-deploy.sh.
|
|
expect(directives).not.toContain("local_certs");
|
|
expect(directives).not.toMatch(/tls\s+internal/u);
|
|
});
|
|
});
|
|
|
|
describe("reference deployment env examples", () => {
|
|
it("contain no secret values", async () => {
|
|
const secretKeys = new Map([
|
|
["env/ekklesia.env.example", ["EKKLESIA_IDENTITY_SECRET", "S3_ACCESS_KEY", "S3_SECRET_KEY"]],
|
|
["env/postgres.env.example", ["POSTGRES_PASSWORD"]],
|
|
["env/object-storage.env.example", ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]],
|
|
["env/tunnel.env.example", ["TUNNEL_TOKEN"]],
|
|
]);
|
|
for (const [file, keys] of secretKeys) {
|
|
const assignments = envAssignments(await text(file));
|
|
for (const key of keys) {
|
|
expect(assignments.get(key), `${file} ${key}`).toBe("");
|
|
}
|
|
}
|
|
const ekklesia = envAssignments(await text("env/ekklesia.env.example"));
|
|
expect(ekklesia.get("DATABASE_URL")).toBe(
|
|
"postgresql://<user>:<password>@postgres:5432/<database>",
|
|
);
|
|
expect(ekklesia.get("EKKLESIA_DATABASE_TLS_MODE")).toBe("verify-full");
|
|
expect(ekklesia.get("S3_ENDPOINT")).toBe("https://object-storage:8443");
|
|
});
|
|
|
|
it("names every variable Core's host configuration reads", async () => {
|
|
// The single place executables read the environment (core/src/host/configuration.ts). A new
|
|
// variable there without a line here would leave an operator guessing.
|
|
const hostConfiguration = await readFile(
|
|
path.join(repositoryRoot, "core", "src", "host", "configuration.ts"),
|
|
"utf8",
|
|
);
|
|
const read = new Set(
|
|
[...hostConfiguration.matchAll(/environment\.([A-Z][A-Z0-9_]+)/gu)].map((match) => match[1]),
|
|
);
|
|
const example = await text("env/ekklesia.env.example");
|
|
// Set by the image, not by an operator.
|
|
read.delete("NODE_ENV");
|
|
// Guards against a pattern that silently stops matching and checks nothing.
|
|
expect(read.size).toBeGreaterThan(20);
|
|
for (const name of read) {
|
|
expect(example, String(name)).toMatch(new RegExp(`^#? ?${name}=`, "mu"));
|
|
}
|
|
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);
|
|
});
|
|
});
|