Files
Ekklesia/tests/deploy.test.ts
T
ekklesiaandClaude Opus 5.5 e36b70d8d4
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
feat: one generic image, with installed modules in installation-owned storage (ADR 0048)
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]>
2026-10-07 08:38:14 -05:00

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);
});
});