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
+18 -4
View File
@@ -6,6 +6,7 @@ import {
composePlatformPermissionRegistry,
discoverModules,
type DiscoveredModule,
resolveModulesDirectory,
} from "@ekklesia/core/modules";
export type PlatformPermissionConfigurationErrorCode = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
@@ -55,10 +56,18 @@ async function readPackageVersion(packagePath: string): Promise<string> {
}
export interface PrepareStartupModuleInventoryOptions {
/**
* The deployment's `EKKLESIA_MODULES_DIR`, raw, exactly as host configuration read it (ADR 0048).
* A production input, not a test seam: the executable entry point always passes it. Unset means
* the repository's own `modules/`; set means that absolute directory, which must exist. It is
* resolved by Core's one resolver, so an unusable value fails startup with a sanitized
* `ModuleDiscoveryError` before any pool exists.
*/
readonly configuredModulesDirectory?: string | undefined;
/** Test-only escape hatch: overrides the directory module discovery scans for installed
* modules. Never set by the executable entry point — production always discovers the real
* repository's `modules/` directory. Platform and Module SDK versions are always read from the
* real repository's own `package.json` files regardless of this override. */
* modules, taking precedence over `configuredModulesDirectory`, which is then never resolved.
* Never set by the executable entry point. Platform and Module SDK versions are always read from
* the real repository's own `package.json` files regardless of this override. */
readonly modulesDirectoryOverride?: string;
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping the
* filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry`
@@ -109,6 +118,11 @@ export async function prepareStartupModuleInventory(
}
const repositoryRoot = resolveRepositoryRoot();
// Chosen before anything is read, so an unusable configured directory fails first and alone.
const modulesDirectory =
options.modulesDirectoryOverride === undefined
? resolveModulesDirectory({ configured: options.configuredModulesDirectory, repositoryRoot })
: { modulesDirectory: options.modulesDirectoryOverride, modulesDirectoryRequired: false };
const [platformVersion, moduleSdkVersion] = await Promise.all([
readPackageVersion(path.join(repositoryRoot, "package.json")),
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
@@ -116,7 +130,7 @@ export async function prepareStartupModuleInventory(
const modules = await discoverModules({
moduleSdkVersion,
modulesDirectory: options.modulesDirectoryOverride ?? path.join(repositoryRoot, "modules"),
...modulesDirectory,
platformVersion,
});
+16 -4
View File
@@ -154,8 +154,12 @@ export interface StartApiOptions {
* test needs somewhere to observe the operational failure the user is never shown.
*/
readonly reportNotificationFailure?: IdentityNotificationReporter;
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
* executable entry point. See `prepareStartupModuleInventory`. */
/** The deployment's raw `EKKLESIA_MODULES_DIR` (ADR 0048), a production input the executable
* entry point always passes from host configuration. See `prepareStartupModuleInventory`. */
readonly configuredModulesDirectory?: string | undefined;
/** Test-only escape hatch: overrides the directory module discovery scans, taking precedence
* over `configuredModulesDirectory`. Never set by the executable entry point. See
* `prepareStartupModuleInventory`. */
readonly modulesDirectoryOverride?: string;
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping
* filesystem discovery entirely — this exact list drives both permission composition and module
@@ -429,6 +433,7 @@ export async function startApi(
options: StartApiOptions = {},
): Promise<RunningApi> {
const inventory = await prepareStartupModuleInventory({
configuredModulesDirectory: options.configuredModulesDirectory,
...(options.modulesDirectoryOverride === undefined
? {}
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
@@ -769,6 +774,7 @@ export function readConfigFromEnvironment(processEnvironment: HostEnvironment):
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
readonly environment: "development" | "production";
readonly moduleSecrets: ModuleSecretSource;
readonly modulesDirectory: string | undefined;
} {
const host = loadHostConfiguration(processEnvironment);
return {
@@ -784,13 +790,19 @@ export function readConfigFromEnvironment(processEnvironment: HostEnvironment):
storage: host.storage,
},
moduleSecrets: host.moduleSecrets,
modulesDirectory: host.modulesDirectory,
};
}
export async function main(): Promise<void> {
const { environment, input, moduleSecrets } = readConfigFromEnvironment(process.env);
const { environment, input, moduleSecrets, modulesDirectory } = readConfigFromEnvironment(
process.env,
);
const config = createApiRuntimeConfig(input, { environment });
const running = await startApi(config, { moduleSecrets });
const running = await startApi(config, {
configuredModulesDirectory: modulesDirectory,
moduleSecrets,
});
const address = running.server.address();
const boundPort = address && typeof address === "object" ? address.port : config.port;