diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..4319803 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/contract-tests/agent-backend.contract.mjs b/contract-tests/agent-backend.contract.mjs new file mode 100644 index 0000000..4e030b4 --- /dev/null +++ b/contract-tests/agent-backend.contract.mjs @@ -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); +});