/** * 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, 'all'); * const a = serverA(handle); * const res = await fetch(`${a.baseUrl}/api/federation/list/tasks`, { * headers: { Authorization: `Bearer ${seedResult.adminTokenA}` }, * }); * expect(res.status).toBe(200); * }); * * NOTE: The `seed()` helper currently only supports scenario='all'. Passing any * other value throws immediately. Per-variant narrowing is deferred to M3-11. * * 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; /** Bootstrap password used for POST /api/bootstrap/setup on a pristine gateway */ bootstrapPassword: 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; } /** * Scenario to seed. Currently only 'all' is implemented; per-variant narrowing * is tracked as M3-11. Passing any other value throws immediately with a clear * error rather than silently over-seeding. */ 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_BOOTSTRAP_PASSWORD_A = process.env['ADMIN_BOOTSTRAP_PASSWORD_A'] ?? 'harness-admin-password-a'; const ADMIN_BOOTSTRAP_PASSWORD_B = process.env['ADMIN_BOOTSTRAP_PASSWORD_B'] ?? 'harness-admin-password-b'; const READINESS_TIMEOUT_MS = 180_000; const READINESS_POLL_MS = 3_000; // ─── Internal helpers ───────────────────────────────────────────────────────── async function isGatewayHealthy(baseUrl: string): Promise { try { const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(5_000) }); return res.ok; } catch { return false; } } /** * Poll both gateways in parallel until both are healthy or the shared deadline * expires. Polling in parallel (rather than sequentially) avoids the bug where * a slow gateway-a consumes all of the readiness budget before gateway-b is * checked. */ async function waitForStack(handle: HarnessHandle): Promise { const gateways: Array<{ label: string; url: string }> = [ { label: 'gateway-a', url: handle.a.baseUrl }, { label: 'gateway-b', url: handle.b.baseUrl }, ]; await Promise.all( gateways.map(async (gw) => { // Each gateway gets its own independent deadline. const deadline = Date.now() + READINESS_TIMEOUT_MS; process.stdout.write(`[harness] Waiting for ${gw.label}...`); while (Date.now() < deadline) { if (await isGatewayHealthy(gw.url)) { process.stdout.write(` ready\n`); return; } if (Date.now() + READINESS_POLL_MS > deadline) { throw new Error( `[harness] ${gw.label} did not become healthy within ${READINESS_TIMEOUT_MS.toString()}ms`, ); } await new Promise((r) => setTimeout(r, READINESS_POLL_MS)); process.stdout.write('.'); } throw new Error( `[harness] ${gw.label} did not become healthy within ${READINESS_TIMEOUT_MS.toString()}ms`, ); }), ); } 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 { const handle: HarnessHandle = { a: { baseUrl: GATEWAY_A_URL, bootstrapPassword: ADMIN_BOOTSTRAP_PASSWORD_A, internalHostname: 'gateway-a', }, b: { baseUrl: GATEWAY_B_URL, bootstrapPassword: ADMIN_BOOTSTRAP_PASSWORD_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 { 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. Currently only 'all' is * supported — passing any other value throws immediately with a * clear error. Per-variant narrowing is tracked as M3-11. * * Returns a SeedResult with grant IDs, peer IDs, and admin tokens for each * gateway, which test assertions can reference. * * IMPORTANT: The harness assumes a pristine database on both gateways. The seed * bootstraps an admin user on each gateway via POST /api/bootstrap/setup. If * either gateway already has users, seed() throws with a clear error message. * Run 'docker compose down -v' to reset state. */ export async function seed( handle: HarnessHandle, scenario: SeedScenario = 'all', ): Promise { if (scenario !== 'all') { throw new Error( `seed: scenario narrowing not yet implemented; pass "all" for now. ` + `Got: "${scenario}". Per-variant narrowing is tracked as M3-11.`, ); } const result = await runSeed({ serverAUrl: handle.a.baseUrl, serverBUrl: handle.b.baseUrl, adminBootstrapPasswordA: handle.a.bootstrapPassword, adminBootstrapPasswordB: handle.b.bootstrapPassword, }); handle.seedResult = result; return result; }