Compare commits
11
Commits
v0.2.2
...
ae976ca229
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae976ca229 | ||
|
|
f9585cc7ec | ||
|
|
6dae29412f | ||
|
|
40ef0ff802 | ||
|
|
a0e9948820 | ||
|
|
6e64b3c5f9 | ||
|
|
fa27603133 | ||
|
|
9e01679fcb | ||
|
|
227c92e35d | ||
|
|
505c9e8c0a | ||
|
|
4be046c8d0 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/api",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/core",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -25,6 +25,7 @@ class VerifyArgumentsError extends Error {}
|
||||
|
||||
const USAGE =
|
||||
"Usage: verify-module-package <archive> [--sha256 <hex> | --sha256-file <file>] [--extract-to <new directory>]";
|
||||
export const MODULE_VERIFIER_SCRATCH_ENV = "EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR";
|
||||
|
||||
export interface VerifyModulePackageArguments {
|
||||
readonly archivePath: string;
|
||||
@@ -90,6 +91,16 @@ export function parseSha256File(contents: string): string {
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
/** The CLI's operator-facing bridge to Core's otherwise explicit verifier option. */
|
||||
export function moduleVerifierScratchDirectory(environment: NodeJS.ProcessEnv): string | undefined {
|
||||
const configured = environment[MODULE_VERIFIER_SCRATCH_ENV];
|
||||
if (configured === undefined) return undefined;
|
||||
if (configured === "" || !path.isAbsolute(configured)) {
|
||||
throw new VerifyArgumentsError(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
async function readJson(file: string): Promise<Record<string, unknown>> {
|
||||
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
@@ -121,6 +132,7 @@ export async function readModulePackagePlatform(
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const parsed = parseVerifyModulePackageArguments(process.argv.slice(2));
|
||||
const executionScratchDirectory = moduleVerifierScratchDirectory(process.env);
|
||||
let expectedSha256 = parsed.expectedSha256;
|
||||
if (parsed.sha256File !== undefined) {
|
||||
let contents: string;
|
||||
@@ -137,6 +149,7 @@ async function main(): Promise<void> {
|
||||
const result = await verifyModulePackage({
|
||||
archivePath: parsed.archivePath,
|
||||
platform,
|
||||
...(executionScratchDirectory === undefined ? {} : { executionScratchDirectory }),
|
||||
...(expectedSha256 === undefined ? {} : { expectedSha256 }),
|
||||
...(parsed.extractTo === undefined ? {} : { extractTo: parsed.extractTo }),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fork } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -73,7 +73,8 @@ export type ModulePackageVerificationErrorCode =
|
||||
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
|
||||
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED";
|
||||
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"
|
||||
| "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE";
|
||||
|
||||
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
|
||||
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
|
||||
@@ -118,6 +119,8 @@ const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = O
|
||||
MODULE_PACKAGE_TOO_LARGE: "The archive exceeds the module package size limits.",
|
||||
MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED:
|
||||
"The package metadata contains transitional facts this platform does not understand.",
|
||||
MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE:
|
||||
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
|
||||
MODULE_PACKAGE_UNREADABLE: "The file is not a readable gzip-compressed ustar archive.",
|
||||
MODULE_PACKAGE_UNSAFE_MODE: "The archive contains a setuid, setgid, or sticky entry.",
|
||||
MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.",
|
||||
@@ -157,6 +160,12 @@ export interface VerifyModulePackageOptions {
|
||||
readonly platform: ModulePackagePlatform;
|
||||
/** A directory that must not exist yet; the verified module directory is written there. */
|
||||
readonly extractTo?: string;
|
||||
/**
|
||||
* Existing writable directory where backend candidates may execute. Defaults to `os.tmpdir()`.
|
||||
* A hardened caller whose system temporary directory is `noexec` must provide a dedicated
|
||||
* executable scratch mount; each verification creates and removes its own child directory.
|
||||
*/
|
||||
readonly executionScratchDirectory?: string;
|
||||
readonly loadTimeoutMs?: number;
|
||||
}
|
||||
|
||||
@@ -414,12 +423,66 @@ function probeEntrypointPath(): string {
|
||||
return path.join(path.dirname(self), `module-package-probe${path.extname(self)}`);
|
||||
}
|
||||
|
||||
function decodedMountPath(value: string): string {
|
||||
return value.replace(/\\([0-7]{3})/gu, (_match, octal: string) =>
|
||||
String.fromCharCode(Number.parseInt(octal, 8)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Linux exposes the effective options of every mount, including a container's tmpfs mounts. A
|
||||
* verifier cannot infer `noexec` from file permissions: native addons are mapped executable rather
|
||||
* than invoked as files. On systems without mountinfo, the load remains the final authority. */
|
||||
async function isNoExecMount(directory: string): Promise<boolean> {
|
||||
if (process.platform !== "linux") return false;
|
||||
let mountinfo: string;
|
||||
let canonicalDirectory: string;
|
||||
try {
|
||||
[mountinfo, canonicalDirectory] = await Promise.all([
|
||||
readFile("/proc/self/mountinfo", "utf8"),
|
||||
realpath(directory),
|
||||
]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const mounts = mountinfo
|
||||
.split("\n")
|
||||
.map((line) => line.split(" "))
|
||||
.filter((fields) => fields.length > 6 && fields.includes("-"))
|
||||
.map((fields) => ({
|
||||
mountPoint: decodedMountPath(fields[4] ?? ""),
|
||||
options: (fields[5] ?? "").split(","),
|
||||
}))
|
||||
.filter(
|
||||
({ mountPoint }) =>
|
||||
canonicalDirectory === mountPoint ||
|
||||
canonicalDirectory.startsWith(mountPoint === "/" ? "/" : `${mountPoint}/`),
|
||||
)
|
||||
.sort((left, right) => right.mountPoint.length - left.mountPoint.length);
|
||||
return mounts[0]?.options.includes("noexec") ?? false;
|
||||
}
|
||||
|
||||
async function createExecutionScratch(baseDirectory: string): Promise<string> {
|
||||
if (!path.isAbsolute(baseDirectory)) {
|
||||
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
|
||||
}
|
||||
try {
|
||||
if (await isNoExecMount(baseDirectory)) {
|
||||
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
|
||||
}
|
||||
return await mkdtemp(path.join(baseDirectory, "ekklesia-package-verify-"));
|
||||
} catch (error) {
|
||||
if (error instanceof ModulePackageVerificationError) throw error;
|
||||
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads every backend entrypoint in a short-lived child with an empty environment, and reports
|
||||
* only whether each loaded and exported a definition naming this module. */
|
||||
async function loadEntrypoints(
|
||||
manifest: Pick<ModuleManifest, "database" | "id" | "version">,
|
||||
entrypoints: Readonly<Record<string, string>>,
|
||||
timeoutMs: number,
|
||||
executionDirectory: string,
|
||||
): Promise<void> {
|
||||
const outcome = await new Promise<"ok" | "failed">((resolve) => {
|
||||
let settled = false;
|
||||
@@ -445,7 +508,7 @@ async function loadEntrypoints(
|
||||
}),
|
||||
],
|
||||
{
|
||||
cwd: tmpdir(),
|
||||
cwd: executionDirectory,
|
||||
env: {},
|
||||
execArgv: [],
|
||||
serialization: "json",
|
||||
@@ -514,7 +577,7 @@ export async function verifyModulePackage(
|
||||
const manifest = checkIdentity(archive, metadata, files);
|
||||
checkCompatibility(metadata, manifest, files, options.platform);
|
||||
|
||||
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-verify-"));
|
||||
const scratch = await createExecutionScratch(options.executionScratchDirectory ?? tmpdir());
|
||||
try {
|
||||
// The candidate alone, in a scratch inventory of its own: never module storage.
|
||||
const modulesDirectory = path.join(scratch, "candidates");
|
||||
@@ -550,7 +613,7 @@ export async function verifyModulePackage(
|
||||
|
||||
// Executed only now, and first in isolation: a schema is imported in this process below only
|
||||
// after the probe proved it loads on its own.
|
||||
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000);
|
||||
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000, scratch);
|
||||
try {
|
||||
await discoverModuleSchemas(discoveryOptions);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -48,6 +48,12 @@ async function archiveFile(options: PackageFixtureOptions = {}): Promise<{
|
||||
return { archivePath, sha256: fixture.sha256 };
|
||||
}
|
||||
|
||||
async function executionScratch(): Promise<string> {
|
||||
const directory = path.join(await scratch(), "execution");
|
||||
await mkdir(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function rejection(
|
||||
options: PackageFixtureOptions,
|
||||
code: ModulePackageVerificationErrorCode,
|
||||
@@ -100,6 +106,52 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an explicit execution scratch for pure JavaScript and cleans concurrent runs", async () => {
|
||||
const { archivePath } = await archiveFile();
|
||||
const executionScratchDirectory = await executionScratch();
|
||||
const runs = Array.from({ length: 4 }, () =>
|
||||
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||
);
|
||||
await expect(Promise.all(runs)).resolves.toHaveLength(4);
|
||||
expect(await readdir(executionScratchDirectory)).toEqual([]);
|
||||
});
|
||||
|
||||
it("cleans its execution scratch after a backend load failure", async () => {
|
||||
const { archivePath } = await archiveFile({
|
||||
serverSource:
|
||||
'import "dependency-not-in-this-package";\nexport default { id: "example", version: "0.1.0" };\n',
|
||||
});
|
||||
const executionScratchDirectory = await executionScratch();
|
||||
await expect(
|
||||
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||
).rejects.toMatchObject({ code: "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED" });
|
||||
expect(await readdir(executionScratchDirectory)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a missing execution scratch as a verifier environment error", async () => {
|
||||
const { archivePath } = await archiveFile();
|
||||
const executionScratchDirectory = path.join(await scratch(), "missing");
|
||||
await expect(
|
||||
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||
).rejects.toMatchObject({
|
||||
code: "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE",
|
||||
message:
|
||||
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
|
||||
});
|
||||
await expect(stat(executionScratchDirectory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("rejects an unsafe archive before consulting the execution scratch", async () => {
|
||||
const { archivePath } = await archiveFile(
|
||||
withEntry({ path: "example/../attacker-chosen", data: "x" }),
|
||||
);
|
||||
const executionScratchDirectory = path.join(await scratch(), "missing");
|
||||
await expect(
|
||||
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||
).rejects.toMatchObject({ code: "MODULE_PACKAGE_UNSAFE_PATH" });
|
||||
await expect(stat(executionScratchDirectory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("extracts only a verified package, with normalized modes, into a new directory", async () => {
|
||||
const { archivePath } = await archiveFile();
|
||||
const target = path.join(await scratch(), "example");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
EMPTY_MODULE_STORAGE,
|
||||
isModuleStorageError,
|
||||
isValidModuleStorageKey,
|
||||
MODULE_STORAGE_KEY_MAX_BYTES,
|
||||
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
|
||||
@@ -322,6 +323,17 @@ describe("module storage scoping", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("recognizes a storage error from an installed module's separate package copy", () => {
|
||||
const external = Object.assign(new Error("The storage service could not be reached."), {
|
||||
code: "STORAGE_PROVIDER_UNAVAILABLE",
|
||||
name: "ModuleStorageError",
|
||||
});
|
||||
|
||||
expect(external).not.toBeInstanceOf(ModuleStorageError);
|
||||
expect(isModuleStorageError(external)).toBe(true);
|
||||
expect(isModuleStorageError(Object.assign(new Error("no"), { code: "UNKNOWN" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("gives a module no way to reach another module's storage by knowing its name", async () => {
|
||||
const shared = fakeProvider();
|
||||
const announcements = createModuleStorage({
|
||||
|
||||
@@ -3,6 +3,8 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MODULE_VERIFIER_SCRATCH_ENV,
|
||||
moduleVerifierScratchDirectory,
|
||||
parseSha256File,
|
||||
parseVerifyModulePackageArguments,
|
||||
readModulePackagePlatform,
|
||||
@@ -57,6 +59,21 @@ describe("verify-module-package digest files", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("verify-module-package execution scratch", () => {
|
||||
it("accepts an absolute operator-configured directory and otherwise leaves the library default", () => {
|
||||
expect(moduleVerifierScratchDirectory({})).toBeUndefined();
|
||||
expect(
|
||||
moduleVerifierScratchDirectory({ [MODULE_VERIFIER_SCRATCH_ENV]: "/run/ekklesia-verifier" }),
|
||||
).toBe("/run/ekklesia-verifier");
|
||||
});
|
||||
|
||||
it.each(["", "relative/path"])("refuses the invalid configured path %j", (configured) => {
|
||||
expect(() =>
|
||||
moduleVerifierScratchDirectory({ [MODULE_VERIFIER_SCRATCH_ENV]: configured }),
|
||||
).toThrow(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verify-module-package platform facts", () => {
|
||||
it("reads this platform's versions and its transitional database runtime from the repository", async () => {
|
||||
const root = JSON.parse(await readFile(path.join(repositoryRoot, "package.json"), "utf8"));
|
||||
|
||||
+14
-3
@@ -170,9 +170,20 @@ identity, target, and Node.js range against this platform, runs the platform's m
|
||||
validation, and loads the backend once in a short-lived process with no environment. Only then does
|
||||
it write the verified module directory, to a new directory outside module storage:
|
||||
|
||||
The backend probe executes the package's server-side code; the verifier container is its isolation
|
||||
boundary. Keep the general-purpose `/tmp` mount `noexec`, and give only this one-shot verifier a
|
||||
separate executable scratch tmpfs. The mount below is ephemeral, bounded at 1100 MiB (just above the
|
||||
package format's 1 GiB expanded limit), `nodev`, `nosuid`, and writable only by the image's
|
||||
unprivileged `node` user. `EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR` tells the CLI where that mount is;
|
||||
the verifier creates a unique child for each run and removes it on success or failure.
|
||||
|
||||
```sh
|
||||
mkdir -m 777 staging # writable by the image's unprivileged user; nothing else is
|
||||
docker run --rm --network none --read-only --tmpfs /tmp \
|
||||
docker run --rm --network none --read-only \
|
||||
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
|
||||
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
||||
-v "$PWD:/package:ro" -v "$PWD/staging:/staging" \
|
||||
--entrypoint node "$EKKLESIA_IMAGE" core/dist/cli/verify-module-package.js \
|
||||
/package/<id>-<version>-any.tar.gz --sha256-file /package/<id>-<version>-any.tar.gz.sha256 \
|
||||
@@ -500,10 +511,10 @@ finds its files:
|
||||
```sh
|
||||
docker build \
|
||||
--file deploy/Dockerfile \
|
||||
--build-arg VERSION=0.2.2 \
|
||||
--build-arg VERSION=0.2.4 \
|
||||
--build-arg REVISION="$(git rev-parse HEAD)" \
|
||||
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
||||
--tag ekklesia:0.2.2 \
|
||||
--tag ekklesia:0.2.4 \
|
||||
.
|
||||
```
|
||||
|
||||
|
||||
@@ -132,6 +132,10 @@ services:
|
||||
command:
|
||||
- mini
|
||||
- -dir=/data
|
||||
# A bucket is a SeaweedFS collection and claims a block of volumes. The ~1 GB automatic
|
||||
# volume size leaves modest hosts with room for only a few collections; after that, writes
|
||||
# stall instead of failing. This changes allocation granularity, not object or total capacity.
|
||||
- -master.volumeSizeLimitMB=32
|
||||
- -s3.port.https=8443
|
||||
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
|
||||
- -s3.key.file=/run/ekklesia/tls/object-storage.key
|
||||
|
||||
@@ -701,7 +701,10 @@ Module Package v1, a deterministic `.tar.gz` with its SHA-256.
|
||||
- **The verifier.** Core's `verifyModulePackage` reads the archive in memory and refuses links,
|
||||
special files, and escaping paths. It checks every digest and identity, the target and runtime,
|
||||
runs the platform's own module, schema, and migration validation, and loads the backend once in a
|
||||
short-lived process with an empty environment. Only then does it extract anything.
|
||||
short-lived process with an empty environment. Only then does it copy the package to a requested
|
||||
output directory. In the hardened container invocation, `/tmp` remains `noexec`; the temporary
|
||||
candidate lives on a dedicated, bounded executable tmpfs because a native addon's code must be
|
||||
mapped executable to load.
|
||||
- **A native package's host checks.** It is accepted only on a host whose operating system, CPU,
|
||||
C library family and version, and Node-API version Node.js itself reports as compatible.
|
||||
- **ELF files.** Every ELF file in it must be built for the target's machine.
|
||||
|
||||
@@ -484,7 +484,13 @@ before it is installed: its digest, its archive safety (no links, special files,
|
||||
escaping paths), every file's digest, and its identity and compatibility. The digest proves
|
||||
integrity, not who published the package; signing is not implemented. The verifier loads the backend
|
||||
in a short-lived process with an empty environment, so module code never sees a database URL or a
|
||||
secret while it is being checked.
|
||||
secret while it is being checked. That load executes package code: `noexec` is retained on the
|
||||
general `/tmp`, but native packages require the verifier's narrow, bounded execution scratch to be
|
||||
executable. The one-shot hardened verifier container—not the scratch mount—is the isolation
|
||||
boundary, and it is not claimed to be a perfect sandbox. Use the supported invocation in
|
||||
[`deploy/README.md`](../../deploy/README.md#installed-modules), which also keeps the root filesystem
|
||||
read-only, runs as the image's non-root user, disables networking and privilege escalation, and
|
||||
drops Linux capabilities.
|
||||
|
||||
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
|
||||
|
||||
|
||||
@@ -318,7 +318,10 @@ require exact agreement with Core. The legacy facade has not been removed.
|
||||
`node core/dist/cli/verify-module-package.js <archive> --sha256-file <archive>.sha256`, run from an
|
||||
Ekklesia installation (`/opt/ekklesia` in the image), checks a package against that platform without
|
||||
installing it. Pass `--extract-to <new directory>` to receive the verified module directory.
|
||||
[deploy/README.md](../../deploy/README.md#installed-modules) shows the whole installation.
|
||||
[deploy/README.md](../../deploy/README.md#installed-modules) shows the supported hardened container
|
||||
invocation; use it rather than inventing mount flags. Backend verification executes package code.
|
||||
Its container is therefore the isolation boundary: general `/tmp` remains `noexec`, while a narrow,
|
||||
bounded, ephemeral verifier scratch tmpfs permits execution so native addons can load.
|
||||
|
||||
The repository's boundary checks (`pnpm boundaries`) prove only source modules in a workspace. An
|
||||
installed package normally has no source, so the platform cannot check it the same way. Your
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ekklesia",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/contracts",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -87,6 +87,28 @@ export class ModuleStorageError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `value` is a module-storage error, judged by its stable public shape rather than its class.
|
||||
*
|
||||
* An installed module bundles its own copy of the contracts package (ADR 0049), so an error created
|
||||
* by the host's storage provider does not share class identity with the module's
|
||||
* `ModuleStorageError`. `instanceof ModuleStorageError` is therefore not a valid check across the
|
||||
* module boundary.
|
||||
*/
|
||||
export function isModuleStorageError(
|
||||
value: unknown,
|
||||
): value is Pick<ModuleStorageError, "code" | "key" | "message" | "name"> {
|
||||
if (!(value instanceof Error) || value.name !== "ModuleStorageError") {
|
||||
return false;
|
||||
}
|
||||
const { code, key } = value as { code?: unknown; key?: unknown };
|
||||
return (
|
||||
typeof code === "string" &&
|
||||
(MODULE_STORAGE_ERROR_CODES as readonly string[]).includes(code) &&
|
||||
(key === undefined || typeof key === "string")
|
||||
);
|
||||
}
|
||||
|
||||
/** The S3 object-key ceiling, which every S3-compatible provider shares. Measured in UTF-8 bytes,
|
||||
* not characters, because that is what the wire format bounds. */
|
||||
export const MODULE_STORAGE_KEY_MAX_BYTES = 1024;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/design-tokens",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/module-database",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/module-pack",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds the repository's real sharp proof as a native Module Package for the hardened verifier
|
||||
// container test. Run inside deploy/Dockerfile's build stage, where the workspace is already built
|
||||
// and has exactly the locked dependencies the runtime image was built against.
|
||||
|
||||
import { cp, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { packModule } from "@ekklesia/module-pack";
|
||||
|
||||
const repositoryRoot = path.resolve(import.meta.dirname, "..", "..", "..", "..");
|
||||
const outDirectory = process.argv[2];
|
||||
if (outDirectory === undefined || !path.isAbsolute(outDirectory)) {
|
||||
throw new Error("An absolute output directory is required.");
|
||||
}
|
||||
|
||||
const temporaryRoot = await mkdtemp(path.join(tmpdir(), "ekklesia-native-verifier-proof-"));
|
||||
try {
|
||||
const moduleDirectory = path.join(temporaryRoot, "native-proof");
|
||||
await cp(
|
||||
path.join(repositoryRoot, "packages", "module-pack", "tests", "fixtures", "native-module"),
|
||||
moduleDirectory,
|
||||
{ recursive: true },
|
||||
);
|
||||
const packageFile = path.join(moduleDirectory, "package.json");
|
||||
const packageJson = JSON.parse(await readFile(packageFile, "utf8"));
|
||||
packageJson.engines = { node: ">=24.11.0 <25" };
|
||||
await writeFile(packageFile, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
const modulesDirectory = path.join(moduleDirectory, "node_modules");
|
||||
await mkdir(path.join(modulesDirectory, "@ekklesia"), { recursive: true });
|
||||
await symlink(
|
||||
await realpath(path.join(repositoryRoot, "packages", "module-pack", "node_modules", "sharp")),
|
||||
path.join(modulesDirectory, "sharp"),
|
||||
);
|
||||
await symlink(
|
||||
await realpath(
|
||||
path.join(
|
||||
repositoryRoot,
|
||||
"packages",
|
||||
"module-pack",
|
||||
"node_modules",
|
||||
"@ekklesia",
|
||||
"module-sdk",
|
||||
),
|
||||
),
|
||||
path.join(modulesDirectory, "@ekklesia", "module-sdk"),
|
||||
);
|
||||
|
||||
const result = await packModule({
|
||||
licenseFile: path.join(repositoryRoot, "LICENSE"),
|
||||
moduleDirectory,
|
||||
outDirectory,
|
||||
sourceRevision: "0".repeat(40),
|
||||
target: "linux-x64-glibc",
|
||||
});
|
||||
process.stdout.write(`${path.basename(result.archivePath)}\n`);
|
||||
} finally {
|
||||
await rm(temporaryRoot, { force: true, recursive: true });
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/module-sdk",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -37,6 +37,7 @@ export {
|
||||
isDevicePrincipal,
|
||||
isModuleHttpMethod,
|
||||
isModuleRouteBodyMode,
|
||||
isModuleStorageError,
|
||||
isValidModuleStorageKey,
|
||||
isValidModuleStorageReadUrlFilename,
|
||||
MODULE_HTTP_BODY_MODES,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/module-ui-build",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/module-ui-host",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/module-ui-sdk",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/typescript-config",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"files": [
|
||||
"base.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/ui",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
+67
-1
@@ -6,7 +6,9 @@
|
||||
# - its module storage mount point, /var/lib/ekklesia/modules, exists, is root-owned, and is empty;
|
||||
# - EKKLESIA_MODULES_DIR names that directory, in the image configuration and in a running process;
|
||||
# - a checkout with a populated modules/ directory builds the same image: the runtime file system
|
||||
# of both builds is identical, and no module from that directory enters it.
|
||||
# of both builds is identical, and no module from that directory enters it;
|
||||
# - the hardened verifier keeps /tmp noexec while a dedicated bounded execution scratch loads and
|
||||
# runs the real sharp 0.35.5/libvips 1.3.4 native proof package.
|
||||
#
|
||||
# bash scripts/check-image.sh builds both images from this checkout
|
||||
# EKKLESIA_CHECK_IMAGE=<image> bash scripts/check-image.sh checks one existing image's contents
|
||||
@@ -103,6 +105,7 @@ context_copy() {
|
||||
tag_base="ekklesia-ci:${GITHUB_SHA:-local}-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-"
|
||||
generic_image="${tag_base}generic-$$"
|
||||
populated_image="${tag_base}populated-$$"
|
||||
builder_image="${tag_base}native-proof-builder-$$"
|
||||
|
||||
# build_image <context> <tag>
|
||||
build_image() {
|
||||
@@ -143,4 +146,67 @@ else
|
||||
echo "note the two builds' layers differ (a cold build cache); their file systems do not"
|
||||
fi
|
||||
|
||||
step "Proving native package verification with /tmp noexec and a dedicated execution scratch"
|
||||
built_images+=("$builder_image")
|
||||
DOCKER_BUILDKIT=1 docker build --quiet --file "$work/generic/deploy/Dockerfile" \
|
||||
--target build --label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||
--label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
|
||||
--tag "$builder_image" "$work/generic" >/dev/null || fail "the native proof builder did not build."
|
||||
mkdir "$work/native-package"
|
||||
package_archive="$({
|
||||
docker run --rm --network none --user "$(id -u):$(id -g)" \
|
||||
-v "$work/native-package:/proof" --entrypoint node "$builder_image" \
|
||||
packages/module-pack/tests/support/pack-native-verifier-proof.mjs /proof
|
||||
} | tail -1)" || fail "the real native proof package did not build."
|
||||
[[ "$package_archive" == native-proof-0.1.0-linux-x64-glibc.tar.gz ]] ||
|
||||
fail "the native proof builder returned an unexpected archive."
|
||||
|
||||
docker run --rm --network none --read-only \
|
||||
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
|
||||
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
-e "PROOF_ARCHIVE=$package_archive" -v "$work/native-package:/package:ro" \
|
||||
--entrypoint sh "$generic_image" -ec '
|
||||
fail() { echo "check-image: $*" >&2; exit 1; }
|
||||
verifier() {
|
||||
EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
||||
node core/dist/cli/verify-module-package.js "/package/$PROOF_ARCHIVE" \
|
||||
--sha256-file "/package/$PROOF_ARCHIVE.sha256" "$@"
|
||||
}
|
||||
scratch_failure() {
|
||||
if output="$(EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR="$1" \
|
||||
node core/dist/cli/verify-module-package.js "/package/$PROOF_ARCHIVE" \
|
||||
--sha256-file "/package/$PROOF_ARCHIVE.sha256" 2>&1)"; then
|
||||
fail "the verifier accepted unusable execution scratch $1."
|
||||
fi
|
||||
printf "%s\n" "$output" | grep -Fq "[MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE]" ||
|
||||
fail "the verifier did not identify unusable execution scratch $1."
|
||||
}
|
||||
|
||||
grep -Eq " /tmp .*noexec" /proc/mounts || fail "/tmp is not mounted noexec."
|
||||
verifier --extract-to /tmp/noexec-installed >/tmp/noexec-verify.log
|
||||
if env -i /usr/local/bin/node --input-type=module --eval \
|
||||
"await import(\"file:///tmp/noexec-installed/server/index.js\");" \
|
||||
>/tmp/noexec-load.log 2>&1; then
|
||||
fail "the native addon unexpectedly loaded from noexec /tmp."
|
||||
fi
|
||||
rm -rf /tmp/noexec-installed
|
||||
scratch_failure /tmp
|
||||
scratch_failure /missing-verifier-scratch
|
||||
scratch_failure /package
|
||||
|
||||
verifier --extract-to /run/ekklesia-verifier/installed >/tmp/native-verify.log
|
||||
env -i /usr/local/bin/node --input-type=module --eval \
|
||||
"const m=await import(\"file:///run/ekklesia-verifier/installed/server/index.js\"); const actual=await m.proof(); const expected={pixels:[255,0,0,255,0,0,255,0,0,255,0,0],png:{format:\"png\",height:3,width:5},resized:{channels:3,height:2,width:2}}; if(JSON.stringify(actual)!==JSON.stringify(expected)) throw new Error(\"sharp proof mismatch\");"
|
||||
rm -rf /run/ekklesia-verifier/installed
|
||||
|
||||
verifier >/tmp/native-verify-a.log 2>&1 & first=$!
|
||||
verifier >/tmp/native-verify-b.log 2>&1 & second=$!
|
||||
wait "$first" || { cat /tmp/native-verify-a.log >&2; fail "first concurrent verification failed."; }
|
||||
wait "$second" || { cat /tmp/native-verify-b.log >&2; fail "second concurrent verification failed."; }
|
||||
[ -z "$(find /run/ekklesia-verifier -mindepth 1 -print -quit)" ] ||
|
||||
fail "the verifier left execution scratch behind."
|
||||
' || fail "the hardened native verifier proof failed."
|
||||
echo "ok /tmp stayed noexec; native verification, sharp execution, rejection, concurrency, and cleanup passed"
|
||||
|
||||
echo "Generic platform image check passed."
|
||||
|
||||
@@ -35,6 +35,10 @@ function required(name) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function optional(name) {
|
||||
return process.env[name] || "";
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
|
||||
function check(condition, description) {
|
||||
@@ -462,37 +466,51 @@ async function ownerCookie() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The installed-module checks (ADR 0048): whether `example` is in the module inventory, and what
|
||||
* its own route returns. Absent, it is in no inventory and its routes are not mounted at all;
|
||||
* present, its records route returns exactly the expected values, which proves its tables, its
|
||||
* dependencies, and the platform's database facade all work from module storage.
|
||||
* The installed-module checks (ADR 0048): whether the selected module is in the UI inventory and,
|
||||
* when one is supplied, whether its module-owned probe route responds. Core's own `example` fixture
|
||||
* retains its stronger records assertion, which also proves its database facade.
|
||||
*/
|
||||
async function moduleChecks() {
|
||||
const expectation = required("SMOKE_MODULE_EXPECT");
|
||||
const moduleId = optional("SMOKE_MODULE_ID") || "example";
|
||||
const probePath = optional("SMOKE_MODULE_PROBE_PATH");
|
||||
const cookie = await ownerCookie();
|
||||
const headers = { cookie, origin: dashboardOrigin };
|
||||
|
||||
const inventory = await fetch(`${apiOrigin}/api/v1/module-ui`, { headers });
|
||||
const inventoryBody = await inventory.json();
|
||||
const installed =
|
||||
inventory.status === 200 && inventoryBody.modules.some((module) => module.id === "example");
|
||||
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
|
||||
inventory.status === 200 && inventoryBody.modules.some((module) => module.id === moduleId);
|
||||
|
||||
if (expectation === "absent") {
|
||||
check(inventory.status === 200 && !installed, "the module inventory has no `example` module");
|
||||
check(
|
||||
inventory.status === 200 && !installed,
|
||||
`the module inventory has no \`${moduleId}\` module`,
|
||||
);
|
||||
} else {
|
||||
check(installed, `the module inventory lists the \`${moduleId}\` module`);
|
||||
}
|
||||
|
||||
if (moduleId === "example") {
|
||||
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
|
||||
if (expectation === "absent") {
|
||||
check(
|
||||
records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}',
|
||||
"the absent module's routes are not mounted",
|
||||
);
|
||||
} else {
|
||||
const expected = JSON.parse(expectation);
|
||||
check(installed, "the module inventory lists the `example` module");
|
||||
const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null;
|
||||
check(
|
||||
JSON.stringify(values) === JSON.stringify(expected),
|
||||
`the module's records route returns ${JSON.stringify(expected)}`,
|
||||
);
|
||||
}
|
||||
} else if (expectation !== "absent" && probePath) {
|
||||
const probe = await fetch(`${apiOrigin}${probePath}`, { headers });
|
||||
await probe.arrayBuffer();
|
||||
check(probe.status === 200, `the module probe ${probePath} responds`);
|
||||
}
|
||||
|
||||
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
|
||||
body: "{}",
|
||||
|
||||
@@ -426,7 +426,9 @@ async function seedSignage(owner) {
|
||||
const base = "/api/v1/modules/signage";
|
||||
const images = [];
|
||||
for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) {
|
||||
const uploaded = await owner.evaluate(
|
||||
let uploaded;
|
||||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||||
uploaded = await owner.evaluate(
|
||||
`(async () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 640;
|
||||
@@ -435,14 +437,37 @@ async function seedSignage(owner) {
|
||||
context.fillStyle = ${JSON.stringify(colour)};
|
||||
context.fillRect(0, 0, 640, 360);
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||
const response = await fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, {
|
||||
return fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, {
|
||||
body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST",
|
||||
signal: AbortSignal.timeout(${requestTimeoutMs}),
|
||||
});
|
||||
return { status: response.status, body: await response.json().catch(() => null) };
|
||||
}).then(
|
||||
async (response) => ({ status: response.status, body: await response.json().catch(() => null) }),
|
||||
(error) => ({ status: error && error.name === "TimeoutError" ? "timeout" : "network-error" }),
|
||||
);
|
||||
})()`,
|
||||
`uploading slide ${index}`,
|
||||
);
|
||||
if (
|
||||
attempt === 1 &&
|
||||
uploaded.status === 503 &&
|
||||
uploaded.body?.code === "SIGNAGE_STORAGE_UNAVAILABLE"
|
||||
) {
|
||||
note(`slide ${index} met one transient storage outage; retrying once`);
|
||||
await sleep(1_000);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (uploaded.status === "timeout") {
|
||||
throw new HangError(`HTTP POST ${base}/images from page "owner"`, requestTimeoutMs);
|
||||
}
|
||||
if (uploaded.status !== 200 && uploaded.status !== 201) {
|
||||
const code =
|
||||
typeof uploaded.body?.code === "string" && /^SIGNAGE_[A-Z_]+$/u.test(uploaded.body.code)
|
||||
? ` (${uploaded.body.code})`
|
||||
: "";
|
||||
throw new Error(`Uploading slide ${index} returned HTTP ${uploaded.status}${code}.`);
|
||||
}
|
||||
images.push(uploaded.body?.image?.id);
|
||||
}
|
||||
check(
|
||||
|
||||
+164
-20
@@ -7,6 +7,8 @@
|
||||
# bash scripts/smoke-deploy.sh builds the image first
|
||||
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
|
||||
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
|
||||
# EKKLESIA_SMOKE_MODULE_RELEASE_DIR=<dir> EKKLESIA_SMOKE_MODULE_ID=<id> \
|
||||
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
|
||||
#
|
||||
# Either topology (ADR 0045), and in both the same generic image, built once from deploy/Dockerfile.
|
||||
# It contains no module; module storage starts empty, as on a fresh installation. `same-origin` (the
|
||||
@@ -15,10 +17,10 @@
|
||||
# ADR 0048 end to end with that same image:
|
||||
#
|
||||
# - it boots with no modules;
|
||||
# - the repository's module template is packaged by the canonical packager as an Ekklesia Module
|
||||
# Package v1 (ADR 0049; scripts/lib/build-module-package.Dockerfile only provides the build
|
||||
# environment), verified by the same image's own verifier, extracted to staging, installed into
|
||||
# module storage, its migrations planned and explicitly applied, and the API restarted;
|
||||
# - either the repository's module template is packaged by the canonical packager, or an external
|
||||
# canonical release pair is supplied through EKKLESIA_SMOKE_MODULE_RELEASE_DIR; the package is
|
||||
# verified by the same image's own verifier, extracted to staging, installed into module storage,
|
||||
# its migrations planned and explicitly applied, and the API restarted;
|
||||
# - a real headless Chromium drives the Dashboard, the module, its display, and sign-out;
|
||||
# - the module is removed and the API restarted: Core still runs, migration planning reports the
|
||||
# module's retained history, and its data survives;
|
||||
@@ -83,6 +85,28 @@ same-origin | split) ;;
|
||||
;;
|
||||
esac
|
||||
|
||||
module_release_directory="${EKKLESIA_SMOKE_MODULE_RELEASE_DIR:-}"
|
||||
module_id="${EKKLESIA_SMOKE_MODULE_ID:-example}"
|
||||
module_probe_path="${EKKLESIA_SMOKE_MODULE_PROBE_PATH:-}"
|
||||
[[ "$module_id" =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]] || {
|
||||
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_ID is not a valid module id." >&2
|
||||
exit 1
|
||||
}
|
||||
if [[ -n "$module_release_directory" ]]; then
|
||||
[[ "$topology" == split ]] || {
|
||||
echo "smoke-deploy: an external module release requires the split topology." >&2
|
||||
exit 1
|
||||
}
|
||||
[[ -d "$module_release_directory" ]] || {
|
||||
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_RELEASE_DIR is not a directory." >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
if [[ -n "$module_probe_path" && "$module_probe_path" != "/api/v1/modules/${module_id}/"* ]]; then
|
||||
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_PROBE_PATH must be inside the selected module's API namespace." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_id="$(openssl rand -hex 4)"
|
||||
# Unique per invocation, and findable by run and job if this script is killed before its trap runs
|
||||
# (scripts/lib/ci-project.sh, scripts/ci-sweep.sh). Everything started outside Compose below carries
|
||||
@@ -217,8 +241,10 @@ if [[ -z "$image" ]]; then
|
||||
fi
|
||||
|
||||
if [[ "$topology" == split ]]; then
|
||||
# The module template as a Module Package v1, from the canonical packager. Never a second platform
|
||||
# image: the package is installed into module storage below, after the image verifies it.
|
||||
# Never a second platform image: the package is installed into module storage below, after the
|
||||
# generic image verifies it. The default keeps Core's own template smoke self-contained; downstream
|
||||
# module CI hands this harness the exact release pair it built instead.
|
||||
if [[ -z "$module_release_directory" ]]; then
|
||||
step "Packaging the module template with the canonical Ekklesia packager"
|
||||
source_revision="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || true)}"
|
||||
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || fail "no source revision to record in the package."
|
||||
@@ -226,13 +252,20 @@ if [[ "$topology" == split ]]; then
|
||||
--label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \
|
||||
--output "type=local,dest=${work}/module-package" . >/dev/null ||
|
||||
fail "the module package did not build."
|
||||
package_output="$work/module-package"
|
||||
else
|
||||
step "Accepting the external module's canonical release pair"
|
||||
package_output="$module_release_directory"
|
||||
fi
|
||||
# The archive and its digest are one release: copied together, from this run's own build output,
|
||||
# into a fresh directory the verifier's unprivileged user can read. BuildKit's output directory is
|
||||
# mode 0700 and owned by whoever built it, so it is never handed to the verifier itself.
|
||||
package_archive="$(module_package_release "$work/module-package" "$work/package")" ||
|
||||
package_archive="$(module_package_release "$package_output" "$work/package")" ||
|
||||
fail "the packager did not produce exactly one archive and its digest."
|
||||
if [[ -z "$module_release_directory" ]]; then
|
||||
[[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] ||
|
||||
fail "the packager produced an unexpected archive name."
|
||||
fi
|
||||
echo "ok packaged ${package_archive} ($(wc -c <"$work/package/$package_archive") bytes, sha256 $(cut -d' ' -f1 "$work/package/${package_archive}.sha256"))"
|
||||
fi
|
||||
|
||||
@@ -446,22 +479,48 @@ module_storage() {
|
||||
install_test_module() {
|
||||
local verified
|
||||
verified="$(
|
||||
docker run --rm --network none --read-only --tmpfs /tmp --cap-drop ALL \
|
||||
--security-opt no-new-privileges \
|
||||
docker run --rm --network none --read-only \
|
||||
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
|
||||
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
||||
-v "$daemon_work/package:/package:ro" -v "$daemon_work/staging:/staging" \
|
||||
--entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \
|
||||
--sha256-file "/package/${package_archive}.sha256" --extract-to /staging/example 2>&1
|
||||
--sha256-file "/package/${package_archive}.sha256" --extract-to "/staging/${module_id}" 2>&1
|
||||
)" || fail "the module package did not verify: ${verified}"
|
||||
echo "ok ${verified%%$'\n'*}"
|
||||
docker run --rm --network none --entrypoint sh -v "$daemon_work/modules:/modules" \
|
||||
-v "$daemon_work/staging:/staging" "$postgres_image" -ec '
|
||||
rm -rf /modules/.incoming-example && cp -R /staging/example /modules/.incoming-example &&
|
||||
rm -rf /staging/example && chown -R 0:0 /modules/.incoming-example &&
|
||||
chmod -R a+rX,go-w /modules/.incoming-example &&
|
||||
mv /modules/.incoming-example /modules/example' ||
|
||||
-v "$daemon_work/staging:/staging" -e MODULE_ID="$module_id" "$postgres_image" -ec '
|
||||
incoming="/modules/.incoming-${MODULE_ID}"
|
||||
installed="/modules/${MODULE_ID}"
|
||||
staged="/staging/${MODULE_ID}"
|
||||
rm -rf "$incoming" && cp -R "$staged" "$incoming" && rm -rf "$staged" &&
|
||||
chown -R 0:0 "$incoming" && chmod -R a+rX,go-w "$incoming" && mv "$incoming" "$installed"' ||
|
||||
fail "the verified module could not be installed into module storage."
|
||||
}
|
||||
|
||||
# The package and its claimed digest are deliberately made to disagree. The verifier must reject it
|
||||
# before extraction; the valid release pair is left untouched for the installation that follows.
|
||||
reject_invalid_package() {
|
||||
local rejected zero_digest
|
||||
zero_digest="$(printf '0%.0s' {1..64})"
|
||||
if rejected="$(
|
||||
docker run --rm --network none --read-only \
|
||||
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
|
||||
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
||||
-v "$daemon_work/package:/package:ro" \
|
||||
--entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \
|
||||
--sha256 "$zero_digest" 2>&1
|
||||
)"; then
|
||||
fail "the verifier accepted a package whose archive did not match its claimed digest."
|
||||
fi
|
||||
grep -q '^\[MODULE_PACKAGE_DIGEST_MISMATCH\]' <<<"$rejected" ||
|
||||
fail "the invalid package was rejected for an unexpected reason."
|
||||
echo "ok an invalid package is rejected before installation"
|
||||
}
|
||||
|
||||
# restart_api <command...>: stops the API, runs the command, and starts the API again. A controlled
|
||||
# restart is the only way a module change is picked up; nothing is ever hot loaded.
|
||||
restart_api() {
|
||||
@@ -473,9 +532,21 @@ restart_api() {
|
||||
fi
|
||||
}
|
||||
|
||||
# recreate_api removes the application container, not its installation-owned module bind mount or
|
||||
# database volumes, then creates a fresh container from the same generic image.
|
||||
recreate_api() {
|
||||
"${compose[@]}" stop api >/dev/null 2>&1 || fail "the API did not stop for recreation."
|
||||
"${compose[@]}" rm --force api >/dev/null 2>&1 || fail "the API container was not removed."
|
||||
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 180 api 2>&1)"; then
|
||||
compose_report_not_ready "$up_output" "${compose[@]}"
|
||||
fail "the recreated API did not become healthy."
|
||||
fi
|
||||
}
|
||||
|
||||
# module_check <expectation>: "absent", or the exact JSON array of values `example` must return.
|
||||
module_check() {
|
||||
client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \
|
||||
-e SMOKE_MODULE_ID="$module_id" -e SMOKE_MODULE_PROBE_PATH="$module_probe_path" \
|
||||
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password"
|
||||
}
|
||||
|
||||
@@ -488,21 +559,36 @@ if [[ "$topology" == split ]]; then
|
||||
step "Booting the generic image with empty module storage"
|
||||
module_check absent || fail "the image without modules did not run module-free."
|
||||
|
||||
step "Rejecting an invalid package before installation"
|
||||
reject_invalid_package
|
||||
|
||||
step "Installing the test module into module storage, migrating explicitly, and restarting the API"
|
||||
install_module_and_migrate() {
|
||||
install_test_module
|
||||
local plan
|
||||
plan="$(plan_output)"
|
||||
grep -q '^- \[pending\] example:0000_initial ' <<<"$plan" ||
|
||||
grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan" ||
|
||||
fail "the plan does not list the installed module's migration as pending."
|
||||
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
|
||||
fail "applying the installed module's migration failed."
|
||||
plan="$(plan_output)"
|
||||
grep -q "^- \\[applied\\] ${module_id}:" <<<"$plan" ||
|
||||
fail "the plan does not list the installed module's migration as applied."
|
||||
if grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan"; then
|
||||
fail "the installed module still has a pending migration after apply."
|
||||
fi
|
||||
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
|
||||
fail "reapplying migrations was not idempotent."
|
||||
}
|
||||
restart_api install_module_and_migrate
|
||||
if [[ "$module_id" == example ]]; then
|
||||
module_check '[]' || fail "the installed module did not run from module storage."
|
||||
psql_as_superuser "INSERT INTO example_records (id, value) VALUES (gen_random_uuid(), 'kept')" \
|
||||
>/dev/null || fail "could not write the module's own data."
|
||||
module_check '["Hello, kept"]' || fail "the installed module did not read its own data."
|
||||
else
|
||||
module_check present || fail "the installed module did not run from module storage."
|
||||
fi
|
||||
|
||||
step "Driving a real browser through the split Dashboard, a module, its display, and sign-out"
|
||||
# Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA,
|
||||
@@ -513,6 +599,8 @@ if [[ "$topology" == split ]]; then
|
||||
spki="$(openssl x509 -in "$work/caddy-intermediate.crt" -pubkey -noout |
|
||||
openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl base64 -A)"
|
||||
browser_container="${project}-browser"
|
||||
start_owner_browser() {
|
||||
docker rm --force "$browser_container" >/dev/null 2>&1 || true
|
||||
# The image's entry script passes its arguments on unquoted, so none may contain a space: the
|
||||
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
|
||||
docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||
@@ -521,8 +609,11 @@ if [[ "$topology" == split ]]; then
|
||||
"$browser_image" \
|
||||
"--ignore-certificate-errors-spki-list=${spki}" \
|
||||
--user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start."
|
||||
}
|
||||
cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs"
|
||||
stage browser.mjs
|
||||
run_browser_checks() {
|
||||
start_owner_browser
|
||||
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||
--label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||
--network "$edge_network" \
|
||||
@@ -530,7 +621,9 @@ if [[ "$topology" == split ]]; then
|
||||
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
|
||||
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
|
||||
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
|
||||
--entrypoint node "$image" /smoke/browser.mjs || fail "browser checks failed."
|
||||
--entrypoint node "$image" /smoke/browser.mjs
|
||||
}
|
||||
run_browser_checks || fail "browser checks failed."
|
||||
|
||||
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
|
||||
# A kiosk: a second browser whose profile directory is a volume, so it outlives the container
|
||||
@@ -566,6 +659,7 @@ if [[ "$topology" == split ]]; then
|
||||
--network "$edge_network" \
|
||||
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
|
||||
-e SMOKE_KIOSK_CDP_ADDRESS="${kiosk_address}:9222" \
|
||||
-e SMOKE_CALL_TIMEOUT_MS="${EKKLESIA_SMOKE_CALL_TIMEOUT_MS:-30000}" \
|
||||
-e SMOKE_STEP_BUDGET_MS="$((budget * 1000))" \
|
||||
-e SMOKE_WATCH_MS="${EKKLESIA_SMOKE_WATCH_MS:-120000}" \
|
||||
-e SMOKE_RECYCLE_WATCH_MS="${EKKLESIA_SMOKE_RECYCLE_WATCH_MS:-330000}" \
|
||||
@@ -607,29 +701,79 @@ if [[ "$topology" == split ]]; then
|
||||
((display_status == 0)) || fail "display-device checks failed."
|
||||
fi
|
||||
|
||||
step "Restarting the API process with the installed module and data intact"
|
||||
restart_api
|
||||
if [[ "$module_id" == example ]]; then
|
||||
module_check '["Hello, kept"]' || fail "the module or its data did not survive an API restart."
|
||||
else
|
||||
module_check present || fail "the module did not survive an API restart."
|
||||
fi
|
||||
run_browser_checks || fail "the module UI did not survive an API restart."
|
||||
|
||||
step "Recreating the API container with the same module storage and database volumes"
|
||||
recreate_api
|
||||
if [[ "$module_id" == example ]]; then
|
||||
module_check '["Hello, kept"]' || fail "the module or its data did not survive container recreation."
|
||||
else
|
||||
module_check present || fail "the module did not survive container recreation."
|
||||
fi
|
||||
plan="$(plan_output)"
|
||||
if grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan"; then
|
||||
fail "container recreation made an applied module migration pending again."
|
||||
fi
|
||||
run_browser_checks || fail "the module UI did not survive container recreation."
|
||||
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
|
||||
display_driver 120 restart || fail "the display runtime or its data did not survive container recreation."
|
||||
fi
|
||||
|
||||
step "Disabling and re-enabling the module through the supported lifecycle command"
|
||||
restart_api "${compose[@]}" run --rm --no-deps migrate node \
|
||||
core/dist/cli/set-module-enabled.js --disable --module "$module_id"
|
||||
module_check absent || fail "the disabled module was still reported as running."
|
||||
module_storage "test -d /modules/${module_id}" ||
|
||||
fail "disabling the module removed its installed package."
|
||||
restart_api "${compose[@]}" run --rm --no-deps migrate node \
|
||||
core/dist/cli/set-module-enabled.js --enable --module "$module_id"
|
||||
if [[ "$module_id" == example ]]; then
|
||||
module_check '["Hello, kept"]' || fail "the re-enabled module did not return with its data."
|
||||
else
|
||||
module_check present || fail "the re-enabled module did not return."
|
||||
fi
|
||||
run_browser_checks || fail "the re-enabled module UI did not return."
|
||||
|
||||
step "Removing the module from module storage and restarting: Core runs, its data stays"
|
||||
restart_api module_storage 'rm -rf /modules/example'
|
||||
restart_api module_storage "rm -rf /modules/${module_id}"
|
||||
module_check absent || fail "the removed module is still running."
|
||||
plan="$(plan_output)"
|
||||
grep -q '^- \[retained\] example: 1 applied migration kept; the module is not installed$' \
|
||||
grep -q "^- \\[retained\\] ${module_id}: .*applied migration.*kept; the module is not installed$" \
|
||||
<<<"$plan" || fail "migration planning did not report the removed module's retained history."
|
||||
grep -q '^Migration plan (.*0 pending, 0 approval-blocked, 1 retained):$' <<<"$plan" ||
|
||||
fail "migration planning did not succeed cleanly with the module absent."
|
||||
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
|
||||
fail "applying migrations failed with the module absent."
|
||||
if [[ "$module_id" == example ]]; then
|
||||
[[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] ||
|
||||
fail "the removed module's data did not survive."
|
||||
fi
|
||||
echo "ok the module is gone, Core runs, its history is retained, and its data survives"
|
||||
|
||||
step "Reinstalling the exact same package and restarting: the module returns with its previous data"
|
||||
restart_api install_test_module
|
||||
plan="$(plan_output)"
|
||||
grep -q '^- \[applied\] example:0000_initial ' <<<"$plan" ||
|
||||
grep -q "^- \\[applied\\] ${module_id}:" <<<"$plan" ||
|
||||
fail "the restored module's migration history is not valid."
|
||||
if grep -q '\[retained\]\|\[pending\]' <<<"$plan"; then
|
||||
fail "the restored module left retained or pending migrations."
|
||||
fi
|
||||
if [[ "$module_id" == example ]]; then
|
||||
module_check '["Hello, kept"]' || fail "the restored module did not return with its data."
|
||||
else
|
||||
module_check present || fail "the restored module did not return."
|
||||
fi
|
||||
run_browser_checks || fail "the restored module UI did not return."
|
||||
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
|
||||
display_driver 120 restart || fail "the restored display runtime did not return with its data."
|
||||
fi
|
||||
echo "ok the restored module runs again, with its history strict and its data intact"
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/shell",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -157,6 +157,21 @@ describe("CI scripts on a shared Docker daemon", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("exercise a real native package through the hardened verifier container", async () => {
|
||||
const imageCheck = (await source("scripts/check-image.sh")).replace(/\\\n\s*/gu, " ");
|
||||
expect(imageCheck).toContain(
|
||||
"packages/module-pack/tests/support/pack-native-verifier-proof.mjs",
|
||||
);
|
||||
expect(imageCheck).toContain("--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m");
|
||||
expect(imageCheck).toContain(
|
||||
"--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000",
|
||||
);
|
||||
expect(imageCheck).toContain("const actual=await m.proof()");
|
||||
expect(imageCheck).toContain("file:///tmp/noexec-installed/server/index.js");
|
||||
expect(imageCheck).toContain("scratch_failure /tmp");
|
||||
expect(imageCheck).toContain("scratch_failure /package");
|
||||
});
|
||||
|
||||
it("install the smoke test's module only as a canonical package the image itself verified (ADR 0049)", async () => {
|
||||
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
|
||||
const packaging = await source("scripts/lib/build-module-package.Dockerfile");
|
||||
@@ -164,19 +179,28 @@ describe("CI scripts on a shared Docker daemon", () => {
|
||||
expect(packaging).not.toMatch(/pnpm[^\n]*\bdeploy\b/u);
|
||||
expect(deploy).toContain("scripts/lib/build-module-package.Dockerfile");
|
||||
expect(deploy).toMatch(
|
||||
/--entrypoint node "\$image" core\/dist\/cli\/verify-module-package\.js[^\n]*--sha256-file[^\n]*--extract-to \/staging\/example/u,
|
||||
/--entrypoint node "\$image" core\/dist\/cli\/verify-module-package\.js[^\n]*--sha256-file[^\n]*--extract-to "\/staging\/\$\{module_id\}"/u,
|
||||
);
|
||||
expect(deploy).toContain("--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m");
|
||||
expect(deploy).toContain(
|
||||
"--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000",
|
||||
);
|
||||
expect(deploy).toContain("-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier");
|
||||
// Module storage only ever receives the verified tree from staging.
|
||||
expect(deploy).toContain("cp -R /staging/example /modules/.incoming-example");
|
||||
expect(deploy).toContain(`staged="/staging/\${MODULE_ID}"`);
|
||||
expect(deploy).toContain('cp -R "$staged" "$incoming"');
|
||||
expect(deploy).toContain('mv "$incoming" "$installed"');
|
||||
});
|
||||
|
||||
it("hand the verifier the package's archive and digest together, from a fresh release pair, never from build output", async () => {
|
||||
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
|
||||
expect(deploy).toContain('source "$repository_root/scripts/lib/module-package-release.sh"');
|
||||
// The pair is made from this run's own build output, into a directory that must not exist yet.
|
||||
expect(deploy).toContain('--output "type=local,dest=${work}/module-package"');
|
||||
expect(deploy).toContain(`--output "type=local,dest=\${work}/module-package"`);
|
||||
expect(deploy).toContain('package_output="$work/module-package"');
|
||||
expect(deploy).toContain('package_output="$module_release_directory"');
|
||||
expect(deploy).toContain(
|
||||
'package_archive="$(module_package_release "$work/module-package" "$work/package")"',
|
||||
'package_archive="$(module_package_release "$package_output" "$work/package")"',
|
||||
);
|
||||
expect(deploy).toMatch(/^\s*stage package$/mu);
|
||||
// BuildKit's output directory is 0700 and owned by whoever built it: it is never mounted.
|
||||
@@ -187,11 +211,36 @@ describe("CI scripts on a shared Docker daemon", () => {
|
||||
.split("\n")
|
||||
.find((line) => line.includes("core/dist/cli/verify-module-package.js"));
|
||||
expect(verifier).toContain('-v "$daemon_work/package:/package:ro"');
|
||||
expect(verifier).toContain('"/package/${package_archive}"');
|
||||
expect(verifier).toContain('--sha256-file "/package/${package_archive}.sha256"');
|
||||
expect(verifier).toContain(`"/package/\${package_archive}"`);
|
||||
expect(verifier).toContain(`--sha256-file "/package/\${package_archive}.sha256"`);
|
||||
expect(verifier).not.toMatch(/--sha256\s/u);
|
||||
});
|
||||
|
||||
it("accepts one exact external module release without putting its source in the platform image", async () => {
|
||||
const deploy = await source("scripts/smoke-deploy.sh");
|
||||
expect(deploy).toContain(`module_release_directory="\${EKKLESIA_SMOKE_MODULE_RELEASE_DIR:-}"`);
|
||||
expect(deploy).toContain(`module_id="\${EKKLESIA_SMOKE_MODULE_ID:-example}"`);
|
||||
expect(deploy).toContain('package_output="$module_release_directory"');
|
||||
expect(deploy).toContain('-e SMOKE_MODULE_ID="$module_id"');
|
||||
expect(deploy).toContain(`--extract-to "/staging/\${module_id}"`);
|
||||
expect(deploy).toContain(`installed="/modules/\${MODULE_ID}"`);
|
||||
});
|
||||
|
||||
it("proves installed module state across restart, container recreation, and lifecycle changes", async () => {
|
||||
const deploy = await source("scripts/smoke-deploy.sh");
|
||||
expect(deploy).toContain(`"\${compose[@]}" rm --force api`);
|
||||
expect(deploy).toContain("container recreation made an applied module migration pending again");
|
||||
expect(deploy).toContain('core/dist/cli/set-module-enabled.js --disable --module "$module_id"');
|
||||
expect(deploy).toContain('core/dist/cli/set-module-enabled.js --enable --module "$module_id"');
|
||||
expect(deploy).toContain("the restored display runtime did not return with its data");
|
||||
});
|
||||
|
||||
it("gives page-side display requests enough time on a constrained shared runner", async () => {
|
||||
const deploy = await source("scripts/smoke-deploy.sh");
|
||||
expect(deploy).toContain('-e SMOKE_CALL_TIMEOUT_MS="');
|
||||
expect(deploy).toContain("EKKLESIA_SMOKE_CALL_TIMEOUT_MS:-30000");
|
||||
});
|
||||
|
||||
it("run every deploy smoke against the one generic image, never a second image with a module in it", async () => {
|
||||
// ADR 0048: the split topology installs its test module into module storage at run time.
|
||||
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
|
||||
|
||||
@@ -138,7 +138,7 @@ describe("reference deployment compose files", () => {
|
||||
]),
|
||||
);
|
||||
expect(services["object-storage"]?.command).toEqual(
|
||||
expect.arrayContaining(["-s3.port.https=8443"]),
|
||||
expect.arrayContaining(["-master.volumeSizeLimitMB=32", "-s3.port.https=8443"]),
|
||||
);
|
||||
|
||||
const hba = (await text("postgres/pg_hba.conf"))
|
||||
|
||||
+8
-8
@@ -13,18 +13,18 @@
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ekklesia/design-tokens": "^0.2.2",
|
||||
"@ekklesia/module-database": "^0.2.2",
|
||||
"@ekklesia/module-sdk": "^0.2.2",
|
||||
"@ekklesia/module-ui-sdk": "^0.2.2",
|
||||
"@ekklesia/ui": "^0.2.2",
|
||||
"@ekklesia/design-tokens": "^0.2.4",
|
||||
"@ekklesia/module-database": "^0.2.4",
|
||||
"@ekklesia/module-sdk": "^0.2.4",
|
||||
"@ekklesia/module-ui-sdk": "^0.2.4",
|
||||
"@ekklesia/ui": "^0.2.4",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ekklesia/module-pack": "0.2.2",
|
||||
"@ekklesia/module-ui-build": "^0.2.2",
|
||||
"@ekklesia/typescript-config": "^0.2.2",
|
||||
"@ekklesia/module-pack": "0.2.4",
|
||||
"@ekklesia/module-ui-build": "^0.2.4",
|
||||
"@ekklesia/typescript-config": "^0.2.4",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
|
||||
Reference in New Issue
Block a user