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

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:
ekklesia
2026-10-07 08:38:14 -05:00
co-authored by Claude Opus 5.5
parent 4d28a82937
commit e36b70d8d4
50 changed files with 2275 additions and 224 deletions
+35 -1
View File
@@ -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([