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