ci: full test gate — types, frontend build, agent tests, agent<->backend contract suite
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>
This commit is contained in:
122
.gitea/workflows/ci.yml
Normal file
122
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,122 @@
|
||||
name: CI
|
||||
|
||||
# Test gate for every push to main. The deploy story: main must be green here
|
||||
# before the stack is rebuilt (deploy workflow enforces it once SSH transport
|
||||
# secrets land). Jobs run in the runner's bare node:20-bullseye container —
|
||||
# toolchains bootstrap per-run.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
backend-types:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Type-check NestJS backend
|
||||
run: |
|
||||
cd backend-nest
|
||||
npm ci --no-audit --no-fund 2>&1 | tail -2
|
||||
npx tsc --noEmit
|
||||
|
||||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build frontend (vue-tsc gate + vite)
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci --no-audit --no-fund 2>&1 | tail -2
|
||||
npm run build
|
||||
|
||||
agent-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
corrosion-host-agent/target
|
||||
key: cargo-${{ hashFiles('corrosion-host-agent/Cargo.lock') }}
|
||||
- name: Install Rust
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq build-essential curl
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
- name: Test agent
|
||||
run: |
|
||||
cd corrosion-host-agent
|
||||
cargo test
|
||||
- name: Upload agent binary for integration
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: agent-debug
|
||||
path: corrosion-host-agent/target/debug/corrosion-host-agent
|
||||
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
needs: agent-tests
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: corrosion
|
||||
POSTGRES_PASSWORD: citest
|
||||
POSTGRES_DB: corrosion
|
||||
nats:
|
||||
image: nats:2.10-alpine
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download agent binary
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: agent-debug
|
||||
path: agent-bin
|
||||
|
||||
- name: Apply migrations to fresh DB
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq postgresql-client
|
||||
until PGPASSWORD=citest psql -h postgres -U corrosion -d corrosion -c 'SELECT 1' >/dev/null 2>&1; do sleep 1; done
|
||||
for f in $(ls backend/migrations/*.sql | sort); do
|
||||
echo "applying $f"
|
||||
PGPASSWORD=citest psql -h postgres -U corrosion -d corrosion -v ON_ERROR_STOP=1 -q -f "$f"
|
||||
done
|
||||
|
||||
- name: Build + boot backend
|
||||
run: |
|
||||
cd backend-nest
|
||||
npm ci --no-audit --no-fund 2>&1 | tail -2
|
||||
npm run build
|
||||
DATABASE_URL=postgres://corrosion:citest@postgres:5432/corrosion \
|
||||
NATS_URL=nats://nats:4222 \
|
||||
JWT_SECRET=ci-secret ENCRYPTION_KEY=ci-encryption-key \
|
||||
ADMIN_EMAIL=ci@corrosion.test ADMIN_PASSWORD=ci-password-123 ADMIN_USERNAME=CI \
|
||||
nohup node dist/main.js > /tmp/backend.log 2>&1 &
|
||||
for i in $(seq 1 30); do
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/auth/login -X POST -H 'Content-Type: application/json' -d '{}' || true)
|
||||
[ "$code" = "400" ] && echo "backend up" && exit 0
|
||||
sleep 2
|
||||
done
|
||||
echo "backend failed to come up"; cat /tmp/backend.log; exit 1
|
||||
|
||||
- name: Run agent↔backend contract suite
|
||||
run: |
|
||||
chmod +x agent-bin/corrosion-host-agent
|
||||
LICENSE_ID=$(PGPASSWORD=citest psql -h postgres -U corrosion -d corrosion -t -A -c 'SELECT id FROM licenses LIMIT 1')
|
||||
echo "license under test: $LICENSE_ID"
|
||||
[ -n "$LICENSE_ID" ] || { echo "admin seed did not create a license"; cat /tmp/backend.log; exit 1; }
|
||||
LICENSE_ID="$LICENSE_ID" \
|
||||
DATABASE_URL=postgres://corrosion:citest@postgres:5432/corrosion \
|
||||
NATS_URL=nats://nats:4222 \
|
||||
AGENT_BIN=$PWD/agent-bin/corrosion-host-agent \
|
||||
node contract-tests/agent-backend.contract.mjs
|
||||
|
||||
- name: Backend log on failure
|
||||
if: failure()
|
||||
run: cat /tmp/backend.log || true
|
||||
152
contract-tests/agent-backend.contract.mjs
Normal file
152
contract-tests/agent-backend.contract.mjs
Normal file
@@ -0,0 +1,152 @@
|
||||
// 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);
|
||||
});
|
||||
Reference in New Issue
Block a user