feat(federation): two-gateway test harness scaffold (FED-M3-02)
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>
This commit is contained in:
258
tools/federation-harness/harness.ts
Normal file
258
tools/federation-harness/harness.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user