Adds tools/federation-harness/ — the permanent test bed for M3+ federation E2E tests. Boots two gateways (Server A + Server B) on a shared Docker bridge network with per-gateway Postgres/pgvector + Valkey and a shared Step-CA. - docker-compose.two-gateways.yml: gateway-a/b, postgres-a/b, valkey-a/b, step-ca; image digest-pinned to sha256:1069117740e... (sha-9f1a081, #491) - seed.ts: provisions scope variants A/B/C via real admin REST API; walks full enrollment flow (peer keypair → grant → token → redeem → cert store) - harness.ts: bootHarness/tearDownHarness/serverA/serverB/seed helpers for vitest; idempotent boot (reuses running stack when both gateways healthy) - README.md: prereqs, topology, seed usage, vitest integration, port override, troubleshooting, image digest note No production code modified. Quality gates: typecheck ✓ lint ✓ format ✓ Closes #462 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
259 lines
8.4 KiB
TypeScript
259 lines
8.4 KiB
TypeScript
/**
|
|
* tools/federation-harness/harness.ts
|
|
*
|
|
* Vitest-consumable helpers for the two-gateway federation harness.
|
|
*
|
|
* USAGE (in a vitest test file):
|
|
*
|
|
* import { bootHarness, tearDownHarness, serverA, serverB, seed } from
|
|
* '../../tools/federation-harness/harness.js';
|
|
*
|
|
* let handle: HarnessHandle;
|
|
*
|
|
* beforeAll(async () => {
|
|
* handle = await bootHarness();
|
|
* }, 180_000);
|
|
*
|
|
* afterAll(async () => {
|
|
* await tearDownHarness(handle);
|
|
* });
|
|
*
|
|
* test('variant A — list tasks', async () => {
|
|
* const seedResult = await seed(handle, 'variantA');
|
|
* const a = serverA(handle);
|
|
* const res = await fetch(`${a.baseUrl}/api/federation/list/tasks`, {
|
|
* headers: { 'x-admin-key': a.adminKey },
|
|
* });
|
|
* expect(res.status).toBe(200);
|
|
* });
|
|
*
|
|
* ESM / NodeNext: all imports use .js extensions.
|
|
*/
|
|
|
|
import { execSync, execFileSync } from 'node:child_process';
|
|
import { resolve, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { runSeed, type SeedResult } from './seed.js';
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
export interface GatewayAccessor {
|
|
/** Base URL reachable from the host machine, e.g. http://localhost:14001 */
|
|
baseUrl: string;
|
|
/** Admin key for X-Admin-Key header */
|
|
adminKey: string;
|
|
/** Internal Docker network hostname (for container-to-container calls) */
|
|
internalHostname: string;
|
|
}
|
|
|
|
export interface HarnessHandle {
|
|
/** Server A accessor */
|
|
a: GatewayAccessor;
|
|
/** Server B accessor */
|
|
b: GatewayAccessor;
|
|
/** Absolute path to the docker-compose file */
|
|
composeFile: string;
|
|
/** Whether this instance booted the stack (vs. reusing an existing one) */
|
|
ownedStack: boolean;
|
|
/** Optional seed result if seed() was called */
|
|
seedResult?: SeedResult;
|
|
}
|
|
|
|
export type SeedScenario = 'variantA' | 'variantB' | 'variantC' | 'all';
|
|
|
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const COMPOSE_FILE = resolve(__dirname, 'docker-compose.two-gateways.yml');
|
|
|
|
const GATEWAY_A_URL = process.env['GATEWAY_A_URL'] ?? 'http://localhost:14001';
|
|
const GATEWAY_B_URL = process.env['GATEWAY_B_URL'] ?? 'http://localhost:14002';
|
|
const ADMIN_KEY_A = process.env['ADMIN_KEY_A'] ?? 'harness-admin-key-a';
|
|
const ADMIN_KEY_B = process.env['ADMIN_KEY_B'] ?? 'harness-admin-key-b';
|
|
|
|
const READINESS_TIMEOUT_MS = 180_000;
|
|
const READINESS_POLL_MS = 3_000;
|
|
|
|
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
|
|
async function isGatewayHealthy(baseUrl: string): Promise<boolean> {
|
|
try {
|
|
const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(5_000) });
|
|
return res.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function waitForStack(handle: HarnessHandle): Promise<void> {
|
|
const deadline = Date.now() + READINESS_TIMEOUT_MS;
|
|
const gateways: Array<{ label: string; url: string }> = [
|
|
{ label: 'gateway-a', url: handle.a.baseUrl },
|
|
{ label: 'gateway-b', url: handle.b.baseUrl },
|
|
];
|
|
|
|
for (const gw of gateways) {
|
|
process.stdout.write(`[harness] Waiting for ${gw.label}...`);
|
|
while (Date.now() < deadline) {
|
|
if (await isGatewayHealthy(gw.url)) {
|
|
process.stdout.write(' ready\n');
|
|
break;
|
|
}
|
|
if (Date.now() + READINESS_POLL_MS > deadline) {
|
|
throw new Error(
|
|
`[harness] ${gw.label} did not become healthy within ${READINESS_TIMEOUT_MS}ms`,
|
|
);
|
|
}
|
|
await new Promise((r) => setTimeout(r, READINESS_POLL_MS));
|
|
process.stdout.write('.');
|
|
}
|
|
}
|
|
}
|
|
|
|
function isStackRunning(): boolean {
|
|
try {
|
|
const output = execFileSync(
|
|
'docker',
|
|
['compose', '-f', COMPOSE_FILE, 'ps', '--format', 'json'],
|
|
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] },
|
|
);
|
|
|
|
if (!output.trim()) return false;
|
|
|
|
// Parse JSON lines — each running service emits a JSON object per line
|
|
const lines = output.trim().split('\n').filter(Boolean);
|
|
const runningServices = lines.filter((line) => {
|
|
try {
|
|
const obj = JSON.parse(line) as { State?: string };
|
|
return obj.State === 'running';
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
|
|
// Expect at least gateway-a and gateway-b running
|
|
return runningServices.length >= 2;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Boot the harness stack.
|
|
*
|
|
* Idempotent: if the stack is already running and both gateways are healthy,
|
|
* this function reuses the existing stack and returns a handle with
|
|
* `ownedStack: false`. Callers that set `ownedStack: false` should NOT call
|
|
* `tearDownHarness` unless they explicitly want to tear down a pre-existing stack.
|
|
*
|
|
* If the stack is not running, it starts it with `docker compose up -d` and
|
|
* waits for both gateways to pass their /api/health probe.
|
|
*/
|
|
export async function bootHarness(): Promise<HarnessHandle> {
|
|
const handle: HarnessHandle = {
|
|
a: {
|
|
baseUrl: GATEWAY_A_URL,
|
|
adminKey: ADMIN_KEY_A,
|
|
internalHostname: 'gateway-a',
|
|
},
|
|
b: {
|
|
baseUrl: GATEWAY_B_URL,
|
|
adminKey: ADMIN_KEY_B,
|
|
internalHostname: 'gateway-b',
|
|
},
|
|
composeFile: COMPOSE_FILE,
|
|
ownedStack: false,
|
|
};
|
|
|
|
// Check if both gateways are already healthy
|
|
const [aHealthy, bHealthy] = await Promise.all([
|
|
isGatewayHealthy(handle.a.baseUrl),
|
|
isGatewayHealthy(handle.b.baseUrl),
|
|
]);
|
|
|
|
if (aHealthy && bHealthy) {
|
|
console.log('[harness] Stack already running — reusing existing stack.');
|
|
handle.ownedStack = false;
|
|
return handle;
|
|
}
|
|
|
|
console.log('[harness] Starting federation harness stack...');
|
|
execSync(`docker compose -f "${COMPOSE_FILE}" up -d`, { stdio: 'inherit' });
|
|
handle.ownedStack = true;
|
|
|
|
await waitForStack(handle);
|
|
console.log('[harness] Stack is ready.');
|
|
|
|
return handle;
|
|
}
|
|
|
|
/**
|
|
* Tear down the harness stack.
|
|
*
|
|
* Runs `docker compose down -v` to remove containers AND volumes (ephemeral state).
|
|
* Only tears down if `handle.ownedStack` is true unless `force` is set.
|
|
*/
|
|
export async function tearDownHarness(
|
|
handle: HarnessHandle,
|
|
opts?: { force?: boolean },
|
|
): Promise<void> {
|
|
if (!handle.ownedStack && !opts?.force) {
|
|
console.log(
|
|
'[harness] Stack not owned by this handle — skipping teardown (pass force: true to override).',
|
|
);
|
|
return;
|
|
}
|
|
|
|
console.log('[harness] Tearing down federation harness stack...');
|
|
execSync(`docker compose -f "${handle.composeFile}" down -v`, { stdio: 'inherit' });
|
|
console.log('[harness] Stack torn down.');
|
|
}
|
|
|
|
/**
|
|
* Return the Server A accessor from a harness handle.
|
|
* Convenience wrapper for test readability.
|
|
*/
|
|
export function serverA(handle: HarnessHandle): GatewayAccessor {
|
|
return handle.a;
|
|
}
|
|
|
|
/**
|
|
* Return the Server B accessor from a harness handle.
|
|
* Convenience wrapper for test readability.
|
|
*/
|
|
export function serverB(handle: HarnessHandle): GatewayAccessor {
|
|
return handle.b;
|
|
}
|
|
|
|
/**
|
|
* Seed the harness with test data for one or more scenarios.
|
|
*
|
|
* @param handle The harness handle returned by bootHarness().
|
|
* @param scenario Which scope variants to provision:
|
|
* 'variantA' | 'variantB' | 'variantC' | 'all'
|
|
*
|
|
* Returns a SeedResult with grant IDs and peer IDs for each variant,
|
|
* which test assertions can reference.
|
|
*/
|
|
export async function seed(
|
|
handle: HarnessHandle,
|
|
scenario: SeedScenario = 'all',
|
|
): Promise<SeedResult> {
|
|
// For now all scenarios run the full seed — M3-11 can narrow this.
|
|
// The seed script is idempotent in the sense that it creates new grants
|
|
// each time; tests should start with a clean stack for isolation.
|
|
void scenario; // narrowing deferred to M3-11
|
|
|
|
const result = await runSeed({
|
|
serverAUrl: handle.a.baseUrl,
|
|
serverBUrl: handle.b.baseUrl,
|
|
adminKeyA: handle.a.adminKey,
|
|
adminKeyB: handle.b.adminKey,
|
|
});
|
|
|
|
handle.seedResult = result;
|
|
return result;
|
|
}
|