ci.yml runs on every push to main: backend tsc, frontend vue-tsc+vite, cargo test (cached), then an integration job with postgres:16 + nats service containers — real migrations applied to a fresh DB, real backend booted (admin seed provides the license), real agent binary spawned. contract-tests/agent-backend.contract.mjs proves the entire v2 pipeline: heartbeat shape + measured telemetry, auto-registered server_connections row flipping connected, instance start/stop/status round-trips with push events, and the offline beacon flipping the row back. This is the test that could not run before a production rebuild until now — it now runs before every push lands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
153 lines
6.4 KiB
JavaScript
153 lines
6.4 KiB
JavaScript
// Full-pipeline contract test: Rust host agent → NATS → NestJS consumer → Postgres.
|
|
//
|
|
// Proves the wire protocol v2 chain end to end against a REAL backend and DB:
|
|
// 1. agent heartbeat arrives with schema 2 + measured telemetry
|
|
// 2. backend auto-registers the server_connections row and marks it connected
|
|
// 3. instance command channel round-trips (start/status/stop) with push events
|
|
// 4. graceful agent shutdown publishes the offline beacon and the row flips offline
|
|
//
|
|
// Required env:
|
|
// LICENSE_ID — existing license uuid (CI: from the admin seed)
|
|
// DATABASE_URL — postgres connection string for assertions
|
|
// NATS_URL — broker both agent and backend use (default nats://localhost:4222)
|
|
// AGENT_BIN — path to the corrosion-host-agent binary
|
|
//
|
|
// Uses the backend's own node_modules (nats, pg) so the client libs under test
|
|
// are exactly what production runs.
|
|
|
|
import { createRequire } from 'node:module';
|
|
import { spawn } from 'node:child_process';
|
|
import { writeFileSync, mkdtempSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const require = createRequire(join(repoRoot, 'backend-nest', 'node_modules', 'x.js'));
|
|
const { connect, StringCodec } = require('nats');
|
|
const { Client: PgClient } = require('pg');
|
|
|
|
const LICENSE = process.env.LICENSE_ID;
|
|
const NATS_URL = process.env.NATS_URL ?? 'nats://localhost:4222';
|
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
const AGENT_BIN = process.env.AGENT_BIN ?? join(repoRoot, 'corrosion-host-agent', 'target', 'debug', 'corrosion-host-agent');
|
|
|
|
if (!LICENSE || !DATABASE_URL) {
|
|
console.error('LICENSE_ID and DATABASE_URL are required');
|
|
process.exit(2);
|
|
}
|
|
|
|
const sc = StringCodec();
|
|
const errs = [];
|
|
const check = (cond, msg) => { if (!cond) errs.push(msg); };
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
async function pollDb(pg, predicate, label, timeoutMs = 30_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
for (;;) {
|
|
const { rows } = await pg.query(
|
|
'SELECT connection_type, connection_status, companion_last_seen FROM server_connections WHERE license_id = $1',
|
|
[LICENSE],
|
|
);
|
|
if (predicate(rows)) return rows;
|
|
if (Date.now() > deadline) {
|
|
errs.push(`${label}: timeout after ${timeoutMs}ms — rows: ${JSON.stringify(rows)}`);
|
|
return rows;
|
|
}
|
|
await sleep(1000);
|
|
}
|
|
}
|
|
|
|
const main = async () => {
|
|
const pg = new PgClient({ connectionString: DATABASE_URL });
|
|
await pg.connect();
|
|
const nc = await connect({ servers: NATS_URL });
|
|
|
|
const heartbeats = [];
|
|
const statusEvents = [];
|
|
(async () => { for await (const m of nc.subscribe(`corrosion.${LICENSE}.host.heartbeat`)) heartbeats.push(JSON.parse(sc.decode(m.data))); })();
|
|
(async () => { for await (const m of nc.subscribe(`corrosion.${LICENSE}.ci-instance.status`)) statusEvents.push(JSON.parse(sc.decode(m.data))); })();
|
|
|
|
// --- spawn the real agent ---
|
|
const dir = mkdtempSync(join(tmpdir(), 'cha-contract-'));
|
|
const cfgPath = join(dir, 'agent.toml');
|
|
writeFileSync(cfgPath, `
|
|
[agent]
|
|
license_id = "${LICENSE}"
|
|
nats_url = "${NATS_URL}"
|
|
heartbeat_seconds = 10
|
|
log_level = "info"
|
|
|
|
[[instance]]
|
|
id = "ci-instance"
|
|
game = "rust"
|
|
root = "/tmp"
|
|
label = "Contract CI"
|
|
executable = "/bin/sleep"
|
|
args = ["300"]
|
|
`);
|
|
const agent = spawn(AGENT_BIN, ['--config', cfgPath], { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
const agentExited = new Promise((r) => agent.on('exit', r));
|
|
|
|
// --- 1. heartbeat shape + real telemetry ---
|
|
const hbDeadline = Date.now() + 20_000;
|
|
while (heartbeats.length === 0 && Date.now() < hbDeadline) await sleep(500);
|
|
check(heartbeats.length > 0, 'no heartbeat within 20s');
|
|
if (heartbeats.length) {
|
|
const hb = heartbeats[0];
|
|
check(hb.schema === 2, `schema != 2: ${hb.schema}`);
|
|
check(typeof hb.host?.cpu_percent === 'number', 'missing host.cpu_percent');
|
|
check(hb.host?.mem_total_mb > 0, 'mem_total_mb not measured');
|
|
check(Array.isArray(hb.host?.disks) && hb.host.disks.length > 0, 'no disks reported');
|
|
check(hb.instances?.[0]?.id === 'ci-instance', 'instance missing from heartbeat');
|
|
check(!!hb.agent?.version && !!hb.agent?.commit, 'agent version/commit missing');
|
|
}
|
|
|
|
// --- 2. backend auto-registers + connects ---
|
|
const rows = await pollDb(pg, (r) => r.length === 1 && r[0].connection_status === 'connected', 'auto-register connected');
|
|
if (rows.length === 1) {
|
|
check(rows[0].connection_type === 'bare_metal', `connection_type: ${rows[0].connection_type}`);
|
|
check(rows[0].companion_last_seen !== null, 'companion_last_seen not set');
|
|
}
|
|
|
|
// --- 3. instance command channel ---
|
|
const cmd = async (payload) =>
|
|
JSON.parse(sc.decode((await nc.request(`corrosion.${LICENSE}.ci-instance.cmd`, sc.encode(JSON.stringify(payload)), { timeout: 8000 })).data));
|
|
|
|
const st0 = await cmd({ func: 'status' });
|
|
check(st0.state?.state === 'stopped', `initial state: ${JSON.stringify(st0.state)}`);
|
|
const start = await cmd({ func: 'start' });
|
|
check(start.status === 'success', `start: ${JSON.stringify(start)}`);
|
|
await sleep(1000);
|
|
const st1 = await cmd({ func: 'status' });
|
|
check(st1.state?.state === 'running', `post-start state: ${JSON.stringify(st1.state)}`);
|
|
check((await cmd({ func: 'start' })).status === 'error', 'double start must error');
|
|
check((await cmd({ func: 'bogus' })).status === 'error', 'unknown func must error');
|
|
const stop = await cmd({ func: 'stop' });
|
|
check(stop.status === 'success', `stop: ${JSON.stringify(stop)}`);
|
|
await sleep(1000);
|
|
const seq = statusEvents.map((e) => e.event?.state);
|
|
check(seq.includes('running') && seq.includes('stopped'), `status events incomplete: ${seq.join(',')}`);
|
|
|
|
// --- 4. graceful shutdown → offline beacon → DB flips offline ---
|
|
agent.kill('SIGTERM');
|
|
await Promise.race([agentExited, sleep(8000)]);
|
|
await pollDb(pg, (r) => r.length === 1 && r[0].connection_status === 'offline', 'beacon offline', 20_000);
|
|
|
|
await nc.close();
|
|
await pg.end();
|
|
|
|
if (errs.length) {
|
|
console.error('\nCONTRACT FAIL:');
|
|
errs.forEach((e) => console.error(' -', e));
|
|
process.exit(1);
|
|
}
|
|
console.log('\nCONTRACT PASS: heartbeat shape, auto-register, connected/offline lifecycle, instance command channel, push events');
|
|
process.exit(0);
|
|
};
|
|
|
|
main().catch((e) => {
|
|
console.error('contract test crashed:', e);
|
|
process.exit(1);
|
|
});
|