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]>
157 lines
6.2 KiB
TypeScript
157 lines
6.2 KiB
TypeScript
import { readdir, readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
/**
|
|
* Structural guarantees about where deployment configuration is read, which no runtime assertion can
|
|
* make.
|
|
*
|
|
* The defect this pass fixed was structural: two executables each read their own subset of the same
|
|
* environment variables, and the subsets drifted until `pnpm doctor` reported a correctly configured
|
|
* installation as having no object storage. Restoring parity once is not enough — the arrangement
|
|
* that allowed the drift has to stay gone.
|
|
*/
|
|
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
|
|
async function readTypeScriptFiles(
|
|
directory: string,
|
|
): Promise<ReadonlyArray<{ readonly path: string; readonly text: string }>> {
|
|
const results: Array<{ path: string; text: string }> = [];
|
|
const walk = async (current: string): Promise<void> => {
|
|
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
const full = path.join(current, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (entry.name === "node_modules" || entry.name === "dist") {
|
|
continue;
|
|
}
|
|
await walk(full);
|
|
continue;
|
|
}
|
|
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
|
|
results.push({ path: full, text: await readFile(full, "utf8") });
|
|
}
|
|
}
|
|
};
|
|
await walk(path.join(repositoryRoot, directory));
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Strips block comments and whole-line `//` comments.
|
|
*
|
|
* These assertions are about what the *code* does, and prose that explains a rule inevitably quotes
|
|
* the very token the rule forbids. Trailing comments are deliberately left alone so a URL inside a
|
|
* string literal is never truncated.
|
|
*/
|
|
function withoutComments(text: string): string {
|
|
return text
|
|
.replaceAll(/\/\*[\s\S]*?\*\//gu, "")
|
|
.split("\n")
|
|
.filter((line) => !line.trimStart().startsWith("//"))
|
|
.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Every variable the shared loader *interprets* — parses, defaults, splits, or classifies.
|
|
*
|
|
* `DATABASE_URL` is deliberately absent: it is passed through verbatim, and the small migration and
|
|
* owner-bootstrap commands legitimately read it directly. Interpretation is what drifts.
|
|
*/
|
|
const INTERPRETED_VARIABLES = Object.freeze([
|
|
"EKKLESIA_CLIENT_ADDRESS_HEADER",
|
|
"EKKLESIA_CLIENT_ADDRESS_MODE",
|
|
"EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES",
|
|
"EKKLESIA_DASHBOARD_ORIGIN",
|
|
"EKKLESIA_DATABASE_CA",
|
|
"EKKLESIA_DATABASE_TLS_MODE",
|
|
"EKKLESIA_IDENTITY_BASE_URL",
|
|
"EKKLESIA_IDENTITY_SECRET",
|
|
"EKKLESIA_IDENTITY_TRUSTED_ORIGINS",
|
|
"EKKLESIA_MODULES_DIR",
|
|
"EKKLESIA_PUBLIC_SIGNUP_ENABLED",
|
|
"EKKLESIA_SMTP_FROM_ADDRESS",
|
|
"EKKLESIA_SMTP_FROM_NAME",
|
|
"EKKLESIA_SMTP_HELO_NAME",
|
|
"EKKLESIA_SMTP_HOST",
|
|
"EKKLESIA_SMTP_PASSWORD",
|
|
"EKKLESIA_SMTP_PORT",
|
|
"EKKLESIA_SMTP_SECURITY",
|
|
"EKKLESIA_SMTP_USERNAME",
|
|
"NODE_ENV",
|
|
"S3_ACCESS_KEY",
|
|
"S3_BUCKET_PREFIX",
|
|
"S3_DELIVERY_ENDPOINT",
|
|
"S3_ENDPOINT",
|
|
"S3_FORCE_PATH_STYLE",
|
|
"S3_REGION",
|
|
"S3_SECRET_KEY",
|
|
]);
|
|
|
|
describe("host configuration is read in exactly one place", () => {
|
|
it("never reads the process environment from the shared loader itself", async () => {
|
|
for (const file of await readTypeScriptFiles(path.join("core", "src", "host"))) {
|
|
// The loader is reusable Core code: it takes the environment as an argument, exactly as
|
|
// `createEnvironmentModuleSecretSource` does, so both executables can hand it theirs.
|
|
expect(withoutComments(file.text), file.path).not.toContain("process.env");
|
|
}
|
|
});
|
|
|
|
it("interprets each host infrastructure variable in exactly one file", async () => {
|
|
const sources = [
|
|
...(await readTypeScriptFiles(path.join("core", "src"))),
|
|
...(await readTypeScriptFiles(path.join("apps", "api", "src"))),
|
|
];
|
|
|
|
for (const file of sources) {
|
|
const code = withoutComments(file.text);
|
|
for (const variable of INTERPRETED_VARIABLES) {
|
|
// Reading one of these anywhere else means a second, independently drifting interpretation
|
|
// of the same deployment — which is exactly the defect this pass fixed. Operator-facing
|
|
// message text that *names* a variable is untouched: only an actual read is forbidden.
|
|
expect(code, `${file.path} reads ${variable}`).not.toContain(`process.env.${variable}`);
|
|
expect(code, `${file.path} reads ${variable}`).not.toContain(`process.env["${variable}"]`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("leaves the two executables reading nothing but their own listener address", async () => {
|
|
const doctorCli = withoutComments(
|
|
await readFile(path.join(repositoryRoot, "core", "src", "cli", "doctor.ts"), "utf8"),
|
|
);
|
|
const server = withoutComments(
|
|
await readFile(path.join(repositoryRoot, "apps", "api", "src", "server.ts"), "utf8"),
|
|
);
|
|
|
|
for (const source of [doctorCli, server]) {
|
|
expect(source).toContain("loadHostConfiguration");
|
|
expect(source).not.toContain("DATABASE_URL");
|
|
}
|
|
// `EKKLESIA_API_HOST`/`EKKLESIA_API_PORT` describe this listener rather than the host's
|
|
// infrastructure, so Doctor has no use for them and they stay with the process that binds.
|
|
expect(server).toContain("EKKLESIA_API_HOST");
|
|
expect(doctorCli).not.toContain("EKKLESIA_API_");
|
|
});
|
|
|
|
it("reads the environment only from executables, never from reusable Core", async () => {
|
|
for (const file of await readTypeScriptFiles(path.join("core", "src"))) {
|
|
if (file.path.includes(`${path.sep}cli${path.sep}`)) {
|
|
continue;
|
|
}
|
|
expect(withoutComments(file.text), file.path).not.toContain("process.env");
|
|
}
|
|
});
|
|
|
|
it("gives Doctor the same object-storage and secret inputs the runtime receives", async () => {
|
|
const doctorCli = await readFile(
|
|
path.join(repositoryRoot, "core", "src", "cli", "doctor.ts"),
|
|
"utf8",
|
|
);
|
|
|
|
// The two options whose absence was the bug. A future edit that drops either fails here rather
|
|
// than in a support conversation about storage that Doctor says is missing.
|
|
expect(doctorCli).toContain("storageConfiguration: host.storage");
|
|
expect(doctorCli).toContain("moduleSecrets: host.moduleSecrets");
|
|
});
|
|
});
|