Files
Ekklesia/tests/infrastructure.test.ts
ekklesiaandClaude Opus 5 b9ca0d560d
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Failing after 2m20s
feat: add host-provided module object storage
`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]>
2026-08-23 08:45:53 -05:00

205 lines
8.6 KiB
TypeScript

import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
interface ComposeService {
environment?: Record<string, string>;
healthcheck?: unknown;
image?: string;
ports?: string[];
volumes?: string[];
}
interface ComposeDocument {
services?: Record<string, ComposeService>;
volumes?: Record<string, unknown>;
}
interface PackageDocument {
scripts?: Record<string, string>;
}
interface WorkflowJob {
needs?: string;
steps?: WorkflowStep[];
"timeout-minutes"?: number;
}
interface WorkflowStep {
run?: string;
uses?: string;
}
interface TurboDocument {
tasks?: Record<string, unknown>;
}
interface WorkspaceDocument {
packages?: string[];
}
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
describe("development infrastructure", () => {
it("has valid, local-only, version-pinned Compose services", async () => {
const source = await readFile(
path.join(repositoryRoot, "infrastructure", "compose.yaml"),
"utf8",
);
const compose = parse(source) as ComposeDocument;
const postgres = compose.services?.postgres;
const objectStorage = compose.services?.["object-storage"];
expect(postgres?.image).toBe("postgres:18.4-alpine3.24");
expect(objectStorage?.image).toBe("chrislusf/seaweedfs:4.28");
expect(postgres?.healthcheck).toBeDefined();
expect(objectStorage?.healthcheck).toBeDefined();
expect(postgres?.environment?.POSTGRES_INITDB_ARGS).toContain("--auth-host=scram-sha-256");
for (const service of [postgres, objectStorage]) {
expect(service).toBeDefined();
expect(service?.image).not.toMatch(/:latest$/u);
expect(service?.ports?.every((port) => port.startsWith("127.0.0.1:"))).toBe(true);
expect(service?.volumes?.length).toBeGreaterThan(0);
}
expect(compose.volumes).toHaveProperty("postgres-data");
expect(compose.volumes).toHaveProperty("object-storage-data");
});
it("has a parseable CI workflow with static and live infrastructure gates", async () => {
const source = await readFile(
path.join(repositoryRoot, ".github", "workflows", "ci.yml"),
"utf8",
);
const workflow = parse(source) as {
jobs?: Record<string, WorkflowJob>;
name?: string;
};
const verify = workflow.jobs?.verify;
const infrastructure = workflow.jobs?.["infrastructure-smoke"];
const migration = workflow.jobs?.["migration-smoke"];
const commands = verify?.steps?.flatMap((step) => step.run ?? []) ?? [];
const infrastructureCommands = infrastructure?.steps?.flatMap((step) => step.run ?? []) ?? [];
const migrationCommands = migration?.steps?.flatMap((step) => step.run ?? []) ?? [];
const verifyActions = verify?.steps?.flatMap((step) => step.uses ?? []) ?? [];
const migrationActions = migration?.steps?.flatMap((step) => step.uses ?? []) ?? [];
expect(workflow.name).toBe("CI");
expect(verify).toBeDefined();
expect(commands).toContain("pnpm install --frozen-lockfile");
expect(commands).toContain("pnpm ci:verify");
expect(infrastructureCommands).toContain("bash scripts/smoke-infrastructure.sh");
expect(migration?.needs).toBe("verify");
expect(migration?.["timeout-minutes"]).toBe(15);
expect(migrationActions).toEqual(verifyActions);
expect(migrationCommands).toContain("pnpm install --frozen-lockfile");
expect(migrationCommands).toContain("pnpm test:migrations:integration");
});
it("routes containerized S3 smoke checks through the isolated Compose network", async () => {
const source = await readFile(
path.join(repositoryRoot, "scripts", "smoke-infrastructure.sh"),
"utf8",
);
const helper = await readFile(
path.join(repositoryRoot, "scripts", "lib", "compose-job-network.sh"),
"utf8",
);
expect(source).toContain('source "$repository_root/scripts/lib/compose-job-network.sh"');
expect(source).toContain(`compose_job_network_connect object-storage "\${compose[@]}"`);
expect(source).toContain('s3_endpoint="http://object-storage:8333"');
expect(source).toContain('s3_endpoint="http://127.0.0.1:$s3_port"');
expect(source).not.toContain("0.0.0.0:${S3_PORT");
expect(source.indexOf("compose_job_network_disconnect")).toBeLessThan(
source.indexOf(`"\${compose[@]}" down`),
);
expect(helper).toContain("[[ ! -f /.dockerenv ]]");
expect(helper).toContain('"$@" ps -q "$service"');
expect(helper).toContain("docker inspect");
expect(helper).toContain("docker network connect");
expect(helper).toContain("docker network disconnect");
});
it("provisions isolated PostgreSQL for the live migration suite", async () => {
const packageSource = await readFile(path.join(repositoryRoot, "package.json"), "utf8");
const packageDocument = JSON.parse(packageSource) as PackageDocument;
const source = await readFile(
path.join(repositoryRoot, "scripts", "test-migrations-integration.sh"),
"utf8",
);
expect(packageDocument.scripts?.["test:migrations:integration"]).toBe(
"pnpm build && bash scripts/test-migrations-integration.sh",
);
expect(source).toContain('source "$repository_root/scripts/lib/compose-job-network.sh"');
expect(source).toContain('export POSTGRES_PORT="0"');
// Both live services come from the repository's own pinned Compose file, so the integration
// suites exercise exactly what a developer runs locally (ADR 0030 added the object-storage half).
expect(source).toContain("up --detach --wait --wait-timeout 180 postgres object-storage");
expect(source).toContain(`compose_job_network_connect postgres "\${compose[@]}"`);
expect(source).toContain(`compose_job_network_connect object-storage "\${compose[@]}"`);
// The storage suite receives an endpoint and the same credentials the service was started with,
// and never a hardcoded host.
expect(source).toContain("EKKLESIA_TEST_STORAGE_ENDPOINT=");
expect(source).toContain("EKKLESIA_TEST_STORAGE_ACCESS_KEY=");
expect(source).toContain('database_host="postgres"');
expect(source).toContain('database_port="5432"');
expect(source).toContain('database_host="127.0.0.1"');
expect(source).toContain(
'EKKLESIA_MIGRATION_TEST_DATABASE_URL="postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@$database_host:$database_port/$POSTGRES_DB"',
);
expect(source).toContain("--config vitest.migrations.integration.config.ts");
expect(source).not.toContain("0.0.0.0");
expect(source).not.toContain('compose[@]}" logs');
expect(source.indexOf("compose_job_network_disconnect")).toBeLessThan(
source.indexOf("down --volumes --remove-orphans"),
);
});
it("includes every official platform layer in the workspace", async () => {
const source = await readFile(path.join(repositoryRoot, "pnpm-workspace.yaml"), "utf8");
const workspace = parse(source) as WorkspaceDocument;
expect(workspace.packages).toEqual(
expect.arrayContaining([
"apps/*",
"builders/*",
"core",
"modules/*",
"surfaces/*",
"templates/*",
]),
);
});
it("dispatches root and future package quality tasks through Turborepo", async () => {
const packageSource = await readFile(path.join(repositoryRoot, "package.json"), "utf8");
const doctorSource = await readFile(
path.join(repositoryRoot, "scripts", "run-doctor.ts"),
"utf8",
);
const turboSource = await readFile(path.join(repositoryRoot, "turbo.json"), "utf8");
const packageDocument = JSON.parse(packageSource) as PackageDocument;
const turboDocument = JSON.parse(turboSource) as TurboDocument;
expect(packageDocument.scripts?.lint).toBe("turbo run lint lint:root");
expect(packageDocument.scripts?.test).toBe("turbo run test test:root");
expect(packageDocument.scripts?.typecheck).toBe("turbo run typecheck typecheck:root");
expect(packageDocument.scripts?.["modules:validate"]).toBe(
"turbo run build && node core/dist/cli/validate-modules.js",
);
expect(packageDocument.scripts?.doctor).toBe("node scripts/run-doctor.ts");
expect(doctorSource).toContain('runCommand("turbo", ["run", "build"])');
expect(doctorSource).toContain('path.join(repositoryRoot, "core", "dist", "cli", "doctor.js")');
expect(packageDocument.scripts?.verify).toContain("pnpm modules:validate");
expect(turboDocument.tasks).toHaveProperty("//#lint:root");
expect(turboDocument.tasks).toHaveProperty("//#test:root");
expect(turboDocument.tasks).toHaveProperty("//#typecheck:root");
});
});