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]>
185 lines
7.4 KiB
TypeScript
185 lines
7.4 KiB
TypeScript
import { loadHostConfiguration } from "@ekklesia/core/host";
|
|
import { createMailProviderConfig } from "@ekklesia/core/mail";
|
|
import { createStorageProviderConfig } from "@ekklesia/core/storage";
|
|
import { describe, expect, it } from "vitest";
|
|
import { createApiRuntimeConfig } from "../src/config.js";
|
|
import { readConfigFromEnvironment } from "../src/server.js";
|
|
|
|
/**
|
|
* `pnpm doctor` and `startApi` must reach the same conclusions from the same environment. They
|
|
* previously could not: each executable read its own subset of the variables, and Doctor's subset
|
|
* omitted `S3_*` and the module secrets entirely.
|
|
*
|
|
* These tests assert the property directly — the API's startup inputs and the inputs Doctor
|
|
* validates come from one `loadHostConfiguration` call and produce identical validated
|
|
* configuration, including every default.
|
|
*/
|
|
|
|
const secretKey = "sk-example-secret-material";
|
|
const smtpPassword = "sup3r-secret-relay-password";
|
|
|
|
function environment(
|
|
overrides: Readonly<Record<string, string | undefined>> = {},
|
|
): Readonly<Record<string, string | undefined>> {
|
|
return {
|
|
DATABASE_URL: "postgres://[email protected]:5432/ekklesia",
|
|
EKKLESIA_IDENTITY_BASE_URL: "https://ekklesia.example.org",
|
|
EKKLESIA_IDENTITY_SECRET: "b7QwZ2rTn4Xs9LmVpKdF6yHc3JgEuNaW",
|
|
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "https://ekklesia.example.org",
|
|
NODE_ENV: "production",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const configuredInfrastructure = Object.freeze({
|
|
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
|
|
EKKLESIA_SMTP_HELO_NAME: "mail.example.org",
|
|
EKKLESIA_SMTP_HOST: "smtp.example.org",
|
|
EKKLESIA_SMTP_PASSWORD: smtpPassword,
|
|
EKKLESIA_SMTP_USERNAME: "[email protected]",
|
|
S3_ACCESS_KEY: "AKIAEXAMPLEACCESSID",
|
|
S3_ENDPOINT: "https://storage.example.org",
|
|
S3_SECRET_KEY: secretKey,
|
|
});
|
|
|
|
/** Exactly what Doctor validates, from exactly the same load the API performs. */
|
|
function doctorView(env: Readonly<Record<string, string | undefined>>) {
|
|
const host = loadHostConfiguration(env);
|
|
return {
|
|
mail: createMailProviderConfig(host.mail, { environment: host.environment }),
|
|
storage: createStorageProviderConfig(host.storage, { environment: host.environment }),
|
|
};
|
|
}
|
|
|
|
function apiView(env: Readonly<Record<string, string | undefined>>) {
|
|
const { environment: resolved, input } = readConfigFromEnvironment(env);
|
|
return createApiRuntimeConfig(input, { environment: resolved });
|
|
}
|
|
|
|
describe("host configuration parity between startApi and Doctor", () => {
|
|
it("derives identical object-storage configuration, defaults included", () => {
|
|
const env = environment(configuredInfrastructure);
|
|
|
|
expect(apiView(env).storage).toEqual(doctorView(env).storage);
|
|
// Named explicitly rather than only compared, so a change to either default fails here loudly.
|
|
expect(apiView(env).storage).toEqual({
|
|
accessKey: "AKIAEXAMPLEACCESSID",
|
|
bucketPrefix: "ekklesia",
|
|
deliveryEndpoint: "https://storage.example.org",
|
|
endpoint: "https://storage.example.org",
|
|
forcePathStyle: true,
|
|
region: "us-east-1",
|
|
secretKey,
|
|
});
|
|
});
|
|
|
|
it("derives identical outbound-email configuration, defaults included", () => {
|
|
const env = environment(configuredInfrastructure);
|
|
|
|
expect(apiView(env).mail).toEqual(doctorView(env).mail);
|
|
expect(apiView(env).mail).toMatchObject({
|
|
// The SMTP client identity is part of that equivalence: a relay that refuses the greeting
|
|
// must fail in Doctor for the same reason it fails at runtime.
|
|
heloName: "mail.example.org",
|
|
host: "smtp.example.org",
|
|
port: 587,
|
|
security: "starttls",
|
|
});
|
|
});
|
|
|
|
it("agrees that an installation with neither service is complete", () => {
|
|
const env = environment();
|
|
|
|
expect(apiView(env).storage).toBeUndefined();
|
|
expect(doctorView(env).storage).toBeUndefined();
|
|
expect(apiView(env).mail).toBeUndefined();
|
|
expect(doctorView(env).mail).toBeUndefined();
|
|
});
|
|
|
|
it("carries every storage field the direct-delivery pass established", () => {
|
|
const env = environment({
|
|
...configuredInfrastructure,
|
|
S3_BUCKET_PREFIX: "rogc",
|
|
S3_DELIVERY_ENDPOINT: "https://files.example.org",
|
|
S3_FORCE_PATH_STYLE: "false",
|
|
S3_REGION: "eu-west-1",
|
|
});
|
|
|
|
expect(apiView(env).storage).toEqual(doctorView(env).storage);
|
|
expect(apiView(env).storage).toMatchObject({
|
|
bucketPrefix: "rogc",
|
|
deliveryEndpoint: "https://files.example.org",
|
|
forcePathStyle: false,
|
|
region: "eu-west-1",
|
|
});
|
|
});
|
|
|
|
it("rejects the same malformed environment with the same sanitized code", () => {
|
|
for (const [overrides, code] of [
|
|
[{ S3_ENDPOINT: "https://storage.example.org" }, "STORAGE_ACCESS_KEY_MISSING"],
|
|
[{ ...configuredInfrastructure, S3_ENDPOINT: "not-a-url" }, "STORAGE_ENDPOINT_INVALID"],
|
|
[
|
|
{ ...configuredInfrastructure, S3_ENDPOINT: "http://storage.example.org" },
|
|
"STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION",
|
|
],
|
|
[
|
|
{ ...configuredInfrastructure, EKKLESIA_SMTP_PASSWORD: undefined },
|
|
"MAIL_USERNAME_WITHOUT_PASSWORD",
|
|
],
|
|
[{ ...configuredInfrastructure, EKKLESIA_SMTP_PORT: "70000" }, "MAIL_PORT_INVALID"],
|
|
[
|
|
{ ...configuredInfrastructure, EKKLESIA_SMTP_HELO_NAME: "localhost" },
|
|
"MAIL_HELO_NAME_INVALID",
|
|
],
|
|
] as const) {
|
|
const env = environment(overrides);
|
|
expect(() => apiView(env)).toThrowError(expect.objectContaining({ code }));
|
|
expect(() => doctorView(env)).toThrowError(expect.objectContaining({ code }));
|
|
}
|
|
});
|
|
|
|
it("resolves module secrets from the same source the runtime is given", () => {
|
|
const env = environment({
|
|
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "module-secret-material",
|
|
});
|
|
|
|
expect(
|
|
readConfigFromEnvironment(env).moduleSecrets.get("announcements", "storageCredential"),
|
|
).toBe("module-secret-material");
|
|
expect(loadHostConfiguration(env).moduleSecrets.get("announcements", "storageCredential")).toBe(
|
|
"module-secret-material",
|
|
);
|
|
});
|
|
|
|
it("hands the API the same modules directory Doctor and the operator commands read", () => {
|
|
expect(readConfigFromEnvironment(environment()).modulesDirectory).toBeUndefined();
|
|
const env = environment({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" });
|
|
expect(readConfigFromEnvironment(env).modulesDirectory).toBe("/var/lib/ekklesia/modules");
|
|
expect(loadHostConfiguration(env).modulesDirectory).toBe("/var/lib/ekklesia/modules");
|
|
});
|
|
|
|
it("reads only this listener's own address and port outside the shared loader", () => {
|
|
const config = apiView(
|
|
environment({ EKKLESIA_API_HOST: "0.0.0.0", EKKLESIA_API_PORT: "8080" }),
|
|
);
|
|
|
|
expect(config.host).toBe("0.0.0.0");
|
|
expect(config.port).toBe(8080);
|
|
// Defaults, when unset, stay where they always were.
|
|
const defaults = apiView(environment());
|
|
expect(defaults.host).toBe("127.0.0.1");
|
|
expect(defaults.port).toBe(3000);
|
|
});
|
|
|
|
it("interprets a set-but-empty flag identically instead of one side ignoring it", () => {
|
|
// Doctor used to drop any falsy identity value before validating, so `startApi` refused to
|
|
// start on `EKKLESIA_PUBLIC_SIGNUP_ENABLED=` while Doctor reported the deployment ready.
|
|
const env = environment({ EKKLESIA_PUBLIC_SIGNUP_ENABLED: "" });
|
|
|
|
expect(loadHostConfiguration(env).identity.publicSignupEnabled).toBe("");
|
|
expect(() => apiView(env)).toThrowError(
|
|
expect.objectContaining({ code: "PUBLIC_SIGNUP_ENABLED_INVALID" }),
|
|
);
|
|
});
|
|
});
|