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]>
117 lines
4.8 KiB
TypeScript
117 lines
4.8 KiB
TypeScript
import { cp, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { discoverRepositoryMigrations } from "../src/cli/migration-command-support.js";
|
|
import { resolveCommandModulesDirectory } from "../src/cli/modules-directory-environment.js";
|
|
import { ModuleDiscoveryError } from "../src/modules/index.js";
|
|
|
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
const templateModule = path.join(repositoryRoot, "templates", "module");
|
|
const temporaryDirectories: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
|
);
|
|
});
|
|
|
|
async function externalModulesDirectory(): Promise<string> {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-installed-modules-"));
|
|
temporaryDirectories.push(directory);
|
|
return directory;
|
|
}
|
|
|
|
/** Installs the module template's migration-bearing package files as module `example`. Migration
|
|
* discovery imports nothing, so the template's manifest, package metadata, and migrations are all
|
|
* it reads; the compiled artifacts discovery checks for are copied too when they exist. */
|
|
async function installTemplate(modulesDirectory: string): Promise<void> {
|
|
const target = path.join(modulesDirectory, "example");
|
|
for (const entry of ["package.json", "ekklesia.module.json", "drizzle", "dist"]) {
|
|
await cp(path.join(templateModule, entry), path.join(target, entry), { recursive: true });
|
|
}
|
|
}
|
|
|
|
describe("operator commands select the modules directory through host configuration", () => {
|
|
it("defaults to the repository's own modules directory", () => {
|
|
expect(resolveCommandModulesDirectory({}, "/opt/ekklesia")).toEqual({
|
|
modulesDirectory: "/opt/ekklesia/modules",
|
|
modulesDirectoryRequired: false,
|
|
});
|
|
});
|
|
|
|
it("honours EKKLESIA_MODULES_DIR", () => {
|
|
expect(
|
|
resolveCommandModulesDirectory(
|
|
{ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" },
|
|
"/opt/ekklesia",
|
|
),
|
|
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
|
|
});
|
|
|
|
it("refuses a relative EKKLESIA_MODULES_DIR without echoing it", () => {
|
|
expect(() =>
|
|
resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia"),
|
|
).toThrow(ModuleDiscoveryError);
|
|
try {
|
|
resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia");
|
|
} catch (error) {
|
|
expect(JSON.stringify((error as ModuleDiscoveryError).issues)).not.toContain("9e1f");
|
|
}
|
|
});
|
|
|
|
it("plans migrations for modules installed outside the repository, with Core's from the platform", async () => {
|
|
const modulesDirectory = await externalModulesDirectory();
|
|
await installTemplate(modulesDirectory);
|
|
|
|
const aggregate = await discoverRepositoryMigrations(repositoryRoot, {
|
|
EKKLESIA_MODULES_DIR: modulesDirectory,
|
|
});
|
|
|
|
const sources = aggregate.modules.map((module) => module.moduleId);
|
|
expect(sources[0]).toBe("core");
|
|
expect(sources).toContain("example");
|
|
expect(
|
|
aggregate.migrations.some((migration) => migration.identity === "example:0000_initial"),
|
|
).toBe(true);
|
|
const coreTags = (await readdir(path.join(repositoryRoot, "core", "drizzle"))).filter((name) =>
|
|
name.endsWith(".sql"),
|
|
);
|
|
expect(aggregate.modules[0]?.migrations).toHaveLength(coreTags.length);
|
|
});
|
|
|
|
it("fails closed when the configured directory does not exist", async () => {
|
|
const parent = await externalModulesDirectory();
|
|
await expect(
|
|
discoverRepositoryMigrations(repositoryRoot, {
|
|
EKKLESIA_MODULES_DIR: path.join(parent, "absent"),
|
|
}),
|
|
).rejects.toBeInstanceOf(ModuleDiscoveryError);
|
|
});
|
|
});
|
|
|
|
describe("no source chooses a modules directory except the resolver", () => {
|
|
async function sourceFiles(directory: string): Promise<readonly string[]> {
|
|
const entries = await readdir(directory, { recursive: true, withFileTypes: true });
|
|
return entries
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".ts"))
|
|
.map((entry) => path.join(entry.parentPath, entry.name));
|
|
}
|
|
|
|
it("never joins `modules` onto a root anywhere else in Core or the API", async () => {
|
|
const files = [
|
|
...(await sourceFiles(path.join(repositoryRoot, "core", "src"))),
|
|
...(await sourceFiles(path.join(repositoryRoot, "apps", "api", "src"))),
|
|
];
|
|
const resolver = path.join(repositoryRoot, "core", "src", "modules", "modules-directory.ts");
|
|
for (const file of files) {
|
|
if (file === resolver) {
|
|
continue;
|
|
}
|
|
const text = await readFile(file, "utf8");
|
|
expect(text, path.relative(repositoryRoot, file)).not.toMatch(/,\s*"modules"\s*\)/u);
|
|
}
|
|
});
|
|
});
|