`manifest.storage.buckets` has existed since manifest version 1 and meant nothing — discovery validated it and no code read it. This turns it into a real capability, which is what stands between Announcements and the module system: that product currently fetches a hardcoded LAN S3 endpoint, parses ListObjectsV2 XML by hand, and hands the browser direct object URLs. - A module declares a logical bucket and receives a scoped facade at `context.storage` — list, head, get, put, delete. The contract names no provider: no endpoint, region, credential, or physical bucket is reachable from it, which is what lets the same module run under a future standalone host over its own or a shared S3-compatible service. - Core's first provider speaks S3 through the AWS SDK. SeaweedFS is the deployment target (ADR 0004), never an API name. Probing the pinned image found `CreateBucket` reporting `BucketAlreadyExists` rather than AWS's `BucketAlreadyOwnedByYou`; treating only the AWS spelling as success would have failed every second boot. A live test pins it. - Logical → physical mapping is deterministic and collision-safe: an installation prefix, the module and bucket ids joined by `--` (which cannot occur inside either identifier, so `a-b`+`c` and `a`+`b-c` cannot collide), and a digest over the full untruncated identity. Two modules declaring `assets` always get separate namespaces; neither can see the physical name. - Declaring a bucket is now a requirement: with no provider configured the module is `blocked · storage_unavailable` through ADR 0028's existing resolver, propagating to required dependents. An installation with no object storage stays a complete deployment. - Binding availability is not provider health. Eligibility asks whether storage is configured, never whether it answered; bucket creation is lazy on first use so a storage outage cannot stop the platform booting; a running module whose store blinks stays running and retries. Nothing deletes a bucket. - Credentials are deployment-supplied, never persisted, never routed through module configuration, and never visible to modules, module UI, diagnostics, or API responses. Provider errors translate to a closed code set carrying no endpoint, bucket, request id, or XML. - Retires ADR 0029's transitional `bucket` configuration key. No manifest version change. The template declares no bucket, so it still runs without S3. See ADR 0030. Co-Authored-By: Claude Opus 5 <[email protected]>
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
import { parseModuleManifest } from "@ekklesia/contracts";
|
|
import { describe, expect, it } from "vitest";
|
|
import type { DiscoveredModule } from "../src/modules/discovery.js";
|
|
import {
|
|
isStorageBlockedDetail,
|
|
resolveModuleEligibility,
|
|
resolveModuleLifecycleView,
|
|
} from "../src/modules/lifecycle.js";
|
|
|
|
/** A minimal in-memory `DiscoveredModule`, built through `parseModuleManifest` so a fixture real
|
|
* discovery would reject can never stand in for one it would accept. */
|
|
function fakeModule(
|
|
id: string,
|
|
options: {
|
|
readonly buckets?: readonly string[];
|
|
readonly dependencies?: Record<string, unknown>;
|
|
readonly configuration?: readonly Record<string, unknown>[];
|
|
} = {},
|
|
): DiscoveredModule {
|
|
const manifest = parseModuleManifest({
|
|
manifestVersion: 5,
|
|
id,
|
|
name: `${id} module`,
|
|
version: "1.0.0",
|
|
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
|
...(options.configuration ? { configuration: options.configuration } : {}),
|
|
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
|
...(options.buckets ? { storage: { buckets: options.buckets.map((id) => ({ id })) } } : {}),
|
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
|
});
|
|
return {
|
|
directory: `/modules/${id}`,
|
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
|
manifest,
|
|
manifestPath: `/modules/${id}/ekklesia.module.json`,
|
|
packageName: `@test/${id}`,
|
|
};
|
|
}
|
|
|
|
function enablement(...records: readonly (readonly [string, boolean])[]) {
|
|
return { modules: records.map(([moduleId, enabled]) => ({ moduleId, enabled })) };
|
|
}
|
|
|
|
const noConfiguration = { settings: { records: [] } };
|
|
const withStorage = { providerConfigured: true };
|
|
const withoutStorage = { providerConfigured: false };
|
|
|
|
function ids(modules: readonly DiscoveredModule[]): readonly string[] {
|
|
return modules.map((module) => module.manifest.id);
|
|
}
|
|
|
|
function entry(
|
|
resolution: ReturnType<typeof resolveModuleEligibility>,
|
|
moduleId: string,
|
|
): ReturnType<typeof resolveModuleEligibility>["entries"][number] {
|
|
const found = resolution.entries.find((candidate) => candidate.moduleId === moduleId);
|
|
if (!found) {
|
|
throw new Error(`No eligibility entry for ${moduleId}.`);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
describe("declared storage decides eligibility", () => {
|
|
it("keeps a module requiring storage eligible when a provider is configured", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[fakeModule("announcements", { buckets: ["announcement-assets"] })],
|
|
enablement(),
|
|
noConfiguration,
|
|
withStorage,
|
|
);
|
|
|
|
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
|
expect(entry(resolution, "announcements").blocked).toBeUndefined();
|
|
});
|
|
|
|
it("blocks a module requiring storage when the host has no provider, naming the logical bucket", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[fakeModule("announcements", { buckets: ["announcement-assets"] })],
|
|
enablement(),
|
|
noConfiguration,
|
|
withoutStorage,
|
|
);
|
|
|
|
expect(resolution.eligibleModules).toEqual([]);
|
|
expect(entry(resolution, "announcements").blocked).toEqual({
|
|
reason: "storage_unavailable",
|
|
storageBucketId: "announcement-assets",
|
|
});
|
|
});
|
|
|
|
it("leaves a module declaring no buckets entirely unaffected by storage", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[fakeModule("calendar")],
|
|
enablement(),
|
|
noConfiguration,
|
|
withoutStorage,
|
|
);
|
|
|
|
// An installation with no object storage is a complete, valid deployment.
|
|
expect(ids(resolution.eligibleModules)).toEqual(["calendar"]);
|
|
expect(entry(resolution, "calendar").blocked).toBeUndefined();
|
|
});
|
|
|
|
it("does not evaluate a disabled module's storage requirement", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[fakeModule("announcements", { buckets: ["announcement-assets"] })],
|
|
enablement(["announcements", false]),
|
|
noConfiguration,
|
|
withoutStorage,
|
|
);
|
|
|
|
// Disabled is not blocked: reporting a storage problem for something nobody asked to run would
|
|
// be noise.
|
|
expect(entry(resolution, "announcements").blocked).toBeUndefined();
|
|
expect(entry(resolution, "announcements").configured.enabled).toBe(false);
|
|
});
|
|
|
|
it("defaults to no provider when the caller supplies no storage input at all", () => {
|
|
// Every pre-existing caller keeps its exact behaviour: the parameter is optional, and a module
|
|
// that declares no bucket is unaffected either way.
|
|
const withoutArgument = resolveModuleEligibility([fakeModule("calendar")], enablement());
|
|
expect(ids(withoutArgument.eligibleModules)).toEqual(["calendar"]);
|
|
|
|
const requiring = resolveModuleEligibility(
|
|
[fakeModule("announcements", { buckets: ["assets"] })],
|
|
enablement(),
|
|
);
|
|
expect(requiring.eligibleModules).toEqual([]);
|
|
});
|
|
|
|
it("names only the logical bucket — never an endpoint, credential, or physical bucket", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[fakeModule("announcements", { buckets: ["announcement-assets"] })],
|
|
enablement(),
|
|
noConfiguration,
|
|
withoutStorage,
|
|
);
|
|
const serialized = JSON.stringify(resolution.entries);
|
|
|
|
expect(serialized).toContain("announcement-assets");
|
|
expect(serialized).not.toContain("ekklesia-announcements--");
|
|
expect(serialized).not.toContain("http");
|
|
expect(serialized).not.toMatch(/accessKey|secretKey|endpoint/iu);
|
|
});
|
|
});
|
|
|
|
describe("storage blocking propagates through the dependency graph", () => {
|
|
const graph = [
|
|
fakeModule("media-storage", { buckets: ["objects"] }),
|
|
fakeModule("media", { dependencies: { modules: { required: { "media-storage": "^1.0.0" } } } }),
|
|
fakeModule("announcements", { dependencies: { modules: { required: { media: "^1.0.0" } } } }),
|
|
];
|
|
|
|
it("blocks required dependents of a storage-blocked module, transitively", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
graph,
|
|
enablement(),
|
|
noConfiguration,
|
|
withoutStorage,
|
|
);
|
|
|
|
expect(resolution.eligibleModules).toEqual([]);
|
|
// One mechanism, not two: the chain terminates at the module whose own requirement is
|
|
// unsatisfiable, exactly as it terminates at a disabled or misconfigured module.
|
|
expect(entry(resolution, "media-storage").blocked).toEqual({
|
|
reason: "storage_unavailable",
|
|
storageBucketId: "objects",
|
|
});
|
|
expect(entry(resolution, "media").blocked).toEqual({
|
|
dependency: { kind: "module", moduleId: "media-storage" },
|
|
reason: "required_dependency_blocked",
|
|
});
|
|
expect(entry(resolution, "announcements").blocked).toEqual({
|
|
dependency: { kind: "module", moduleId: "media" },
|
|
reason: "required_dependency_blocked",
|
|
});
|
|
});
|
|
|
|
it("unblocks the whole chain once a provider is configured", () => {
|
|
const resolution = resolveModuleEligibility(graph, enablement(), noConfiguration, withStorage);
|
|
|
|
expect(ids(resolution.eligibleModules)).toEqual(["media-storage", "media", "announcements"]);
|
|
});
|
|
|
|
it("never blocks an unrelated module because another module needs storage", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[...graph, fakeModule("calendar")],
|
|
enablement(),
|
|
noConfiguration,
|
|
withoutStorage,
|
|
);
|
|
|
|
expect(ids(resolution.eligibleModules)).toEqual(["calendar"]);
|
|
});
|
|
});
|
|
|
|
describe("storage and configuration blocking coexist without a second mechanism", () => {
|
|
it("reports the storage requirement first when both are unsatisfiable", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("announcements", {
|
|
buckets: ["assets"],
|
|
configuration: [{ key: "bucketLabel", type: "string", required: true }],
|
|
}),
|
|
],
|
|
enablement(),
|
|
noConfiguration,
|
|
withoutStorage,
|
|
);
|
|
|
|
// Deterministic ordering — storage is evaluated before configuration — so an operator always
|
|
// sees the same reason for the same state rather than one that depends on evaluation order.
|
|
const blocked = entry(resolution, "announcements").blocked;
|
|
expect(blocked && isStorageBlockedDetail(blocked)).toBe(true);
|
|
});
|
|
|
|
it("falls through to the configuration reason once storage is available", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("announcements", {
|
|
buckets: ["assets"],
|
|
configuration: [{ key: "bucketLabel", type: "string", required: true }],
|
|
}),
|
|
],
|
|
enablement(),
|
|
noConfiguration,
|
|
withStorage,
|
|
);
|
|
|
|
expect(entry(resolution, "announcements").blocked).toEqual({
|
|
configurationKey: "bucketLabel",
|
|
reason: "configuration_missing",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("the lifecycle view reports storage state honestly", () => {
|
|
const discoveredModules = [fakeModule("announcements", { buckets: ["announcement-assets"] })];
|
|
|
|
function bootSnapshot(storage: { providerConfigured: boolean }) {
|
|
const resolution = resolveModuleEligibility(
|
|
discoveredModules,
|
|
enablement(),
|
|
noConfiguration,
|
|
storage,
|
|
);
|
|
return {
|
|
discoveredModules,
|
|
eligibility: resolution.entries,
|
|
startedModuleIds: resolution.eligibleModules.map((module) => module.manifest.id),
|
|
};
|
|
}
|
|
|
|
it("reports a storage-blocked module with the logical bucket and no pending restart", () => {
|
|
const view = resolveModuleLifecycleView(
|
|
bootSnapshot(withoutStorage),
|
|
enablement(),
|
|
noConfiguration,
|
|
withoutStorage,
|
|
);
|
|
|
|
expect(view[0]?.runtime).toEqual({
|
|
reason: "storage_unavailable",
|
|
state: "blocked",
|
|
storageBucketId: "announcement-assets",
|
|
});
|
|
expect(view[0]?.restartRequired).toBe(false);
|
|
});
|
|
|
|
it("reports a pending restart once a provider is configured for a blocked module", () => {
|
|
const view = resolveModuleLifecycleView(
|
|
bootSnapshot(withoutStorage),
|
|
enablement(),
|
|
noConfiguration,
|
|
withStorage,
|
|
);
|
|
|
|
// The next boot really will start it, so the honest answer is that a restart is pending — while
|
|
// the runtime line still describes what *this* process did.
|
|
expect(view[0]?.runtime.state).toBe("blocked");
|
|
expect(view[0]?.restartRequired).toBe(true);
|
|
});
|
|
|
|
it("reports a running module with storage as ordinary and unremarkable", () => {
|
|
const view = resolveModuleLifecycleView(
|
|
bootSnapshot(withStorage),
|
|
enablement(),
|
|
noConfiguration,
|
|
withStorage,
|
|
);
|
|
|
|
expect(view[0]?.runtime).toEqual({ state: "running" });
|
|
expect(view[0]?.restartRequired).toBe(false);
|
|
});
|
|
|
|
it("puts no infrastructure detail into the view", () => {
|
|
const view = resolveModuleLifecycleView(
|
|
bootSnapshot(withoutStorage),
|
|
enablement(),
|
|
noConfiguration,
|
|
withoutStorage,
|
|
);
|
|
const serialized = JSON.stringify(view);
|
|
|
|
expect(serialized).toContain("announcement-assets");
|
|
expect(serialized).not.toContain("ekklesia-announcements--");
|
|
expect(serialized).not.toMatch(/http|accessKey|secretKey|seaweed/iu);
|
|
});
|
|
});
|