feat: one generic image, with installed modules in installation-owned storage (ADR 0048)
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
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4d28a82937
commit
e36b70d8d4
@@ -47,6 +47,7 @@ export type ModuleDiscoveryIssueCode =
|
||||
| "MODULE_PLATFORM_INCOMPATIBLE"
|
||||
| "MODULE_SDK_INCOMPATIBLE"
|
||||
| "MODULE_VERSION_MISMATCH"
|
||||
| "MODULES_DIRECTORY_INVALID"
|
||||
| "MODULES_DIRECTORY_UNREADABLE";
|
||||
|
||||
export interface ModuleDiscoveryIssue {
|
||||
@@ -117,9 +118,35 @@ export interface DiscoveredModule {
|
||||
export interface ModuleDiscoveryOptions {
|
||||
readonly moduleSdkVersion: string;
|
||||
readonly modulesDirectory: string;
|
||||
/**
|
||||
* True when an operator configured the directory explicitly (`EKKLESIA_MODULES_DIR`, ADR 0048).
|
||||
* A configured directory that is missing, not a directory, or not an absolute path then fails
|
||||
* discovery closed rather than reading as zero modules: a mistyped mount must never quietly start
|
||||
* the platform with nothing installed. False or omitted keeps the development default lenient,
|
||||
* where a checkout with no `modules/` simply has no modules.
|
||||
*/
|
||||
readonly modulesDirectoryRequired?: boolean;
|
||||
readonly platformVersion: string;
|
||||
}
|
||||
|
||||
/** The one sanitized message for an unusable configured modules directory. It never carries the
|
||||
* value: a path is machine detail, and a misplaced value could be anything at all. */
|
||||
export const MODULES_DIRECTORY_INVALID_MESSAGE = "EKKLESIA_MODULES_DIR must be an absolute path.";
|
||||
|
||||
/**
|
||||
* Whether a configured modules directory is usable as given: a non-empty absolute path with no
|
||||
* surrounding whitespace and no NUL byte. Deliberately strict rather than forgiving, because
|
||||
* trimming or resolving a near-miss would silently pick a directory the operator did not name.
|
||||
*/
|
||||
export function isUsableModulesDirectoryPath(value: string): boolean {
|
||||
return (
|
||||
value.length > 0 &&
|
||||
value.trim() === value &&
|
||||
!value.includes("\u0000") &&
|
||||
path.isAbsolute(value)
|
||||
);
|
||||
}
|
||||
|
||||
interface ModulePackageData {
|
||||
name: string;
|
||||
version: string;
|
||||
@@ -695,6 +722,13 @@ export async function discoverModules(
|
||||
});
|
||||
}
|
||||
}
|
||||
if (options.modulesDirectoryRequired && !isUsableModulesDirectoryPath(options.modulesDirectory)) {
|
||||
issues.push({
|
||||
code: "MODULES_DIRECTORY_INVALID",
|
||||
message: MODULES_DIRECTORY_INVALID_MESSAGE,
|
||||
moduleDirectory: ".",
|
||||
});
|
||||
}
|
||||
if (issues.length > 0) {
|
||||
throw new ModuleDiscoveryError(issues);
|
||||
}
|
||||
@@ -703,7 +737,7 @@ export async function discoverModules(
|
||||
try {
|
||||
directoryEntries = await readdir(options.modulesDirectory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "ENOENT") {
|
||||
if (errorCode(error) === "ENOENT" && !options.modulesDirectoryRequired) {
|
||||
return Object.freeze([]);
|
||||
}
|
||||
throw new ModuleDiscoveryError([
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from "./lifecycle.js";
|
||||
export * from "./lifecycle-schema.js";
|
||||
export * from "./lifecycle-store.js";
|
||||
export * from "./migration-discovery.js";
|
||||
export * from "./modules-directory.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./realtime.js";
|
||||
export * from "./runtime.js";
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
isUsableModulesDirectoryPath,
|
||||
MODULES_DIRECTORY_INVALID_MESSAGE,
|
||||
ModuleDiscoveryError,
|
||||
} from "./discovery.js";
|
||||
|
||||
/** The host environment variable that selects the installed-modules directory (ADR 0048). */
|
||||
export const MODULES_DIRECTORY_ENVIRONMENT_VARIABLE = "EKKLESIA_MODULES_DIR";
|
||||
|
||||
export interface ResolveModulesDirectoryOptions {
|
||||
/** The raw configured value, exactly as the host environment supplied it; `undefined` when the
|
||||
* variable is unset. */
|
||||
readonly configured: string | undefined;
|
||||
/** Where the platform itself is installed. Only consulted for the development default. */
|
||||
readonly repositoryRoot: string;
|
||||
}
|
||||
|
||||
/** Spreads directly into `ModuleDiscoveryOptions`, so every consumer discovers through the same
|
||||
* selection, including whether a missing directory is an error. */
|
||||
export interface ModulesDirectorySelection {
|
||||
readonly modulesDirectory: string;
|
||||
readonly modulesDirectoryRequired: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place an installation's modules directory is chosen (ADR 0048).
|
||||
*
|
||||
* - Unset: `<repositoryRoot>/modules`, the development checkout's own directory. A checkout without
|
||||
* one has no modules, exactly as before.
|
||||
* - Set: an absolute path, normalized and used as given — never resolved against the repository or
|
||||
* the working directory — and required to exist when discovery reads it. The shipping image sets
|
||||
* it to `/var/lib/ekklesia/modules`.
|
||||
*
|
||||
* Anything else throws a `ModuleDiscoveryError` carrying `MODULES_DIRECTORY_INVALID` and a fixed
|
||||
* message, so every executable already reports it through the discovery error path it has, and the
|
||||
* supplied value never reaches an error, a log, or a report.
|
||||
*
|
||||
* Nothing here — and nothing that calls it — locates the platform's own files relative to the
|
||||
* result: Core's migrations and package metadata live under `repositoryRoot`, wherever modules are.
|
||||
*/
|
||||
export function resolveModulesDirectory(
|
||||
options: ResolveModulesDirectoryOptions,
|
||||
): ModulesDirectorySelection {
|
||||
if (options.configured === undefined) {
|
||||
return Object.freeze({
|
||||
modulesDirectory: path.join(options.repositoryRoot, "modules"),
|
||||
modulesDirectoryRequired: false,
|
||||
});
|
||||
}
|
||||
if (!isUsableModulesDirectoryPath(options.configured)) {
|
||||
throw new ModuleDiscoveryError([
|
||||
{
|
||||
code: "MODULES_DIRECTORY_INVALID",
|
||||
message: MODULES_DIRECTORY_INVALID_MESSAGE,
|
||||
moduleDirectory: ".",
|
||||
},
|
||||
]);
|
||||
}
|
||||
const normalized = path.normalize(options.configured);
|
||||
return Object.freeze({
|
||||
modulesDirectory:
|
||||
normalized.length > 1 && normalized.endsWith(path.sep) ? normalized.slice(0, -1) : normalized,
|
||||
modulesDirectoryRequired: true,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user