- infra/scripts/simulate-two-host/: real two-Docker-network simulation (Gateway genuinely isolated from any Lab bridge network) proving the Lab Host Router fix - 13/13 real assertions (HTTP+WebSocket through the Router, Alice/Bob isolation, garbage/destroyed tokens, mTLS-only Router access). - infra/scripts/drills/: Lab Host loss/rebuild (9/9), deployment rollback, CI-fails-closed (6 required failure modes), fake cohort dress rehearsal (14/14, one continuous narrative through the real app). - infra/ci/gitea-workflows/build-and-test.yml: new security-scan job (npm audit, gitleaks, hadolint, shellcheck, trivy - all free/open-source), severity policy in docs/SECURITY_SCANNING.md (CRITICAL/HIGH blocks, MEDIUM/LOW reported only). Gates build-images alongside regression. Found and fixed two real pre-existing bugs via the CI-fails-closed drill: staging-smoke-test.sh and production-smoke-test.sh both had a classic set -e gotcha (curl ... && echo "OK" as one statement - a failing command that isn't last in an && chain doesn't trigger -e) that let an unreachable/broken endpoint fall through silently instead of failing the smoke test. platform/test/e2e-phase3a.js updated for the new paginated /api/admin/audit-events response shape (Phase 4.1 pagination work).
105 lines
5.8 KiB
JavaScript
105 lines
5.8 KiB
JavaScript
#!/usr/bin/env node
|
|
// Phase 4.1 §21 — Lab Host loss/rebuild drill. Proves disposable infrastructure is genuinely
|
|
// disposable: a student's Lab container is destroyed OUT FROM UNDER the Provisioner (no clean
|
|
// destroy call — simulating the whole rn-lab-host-01 VM disappearing), and the system must:
|
|
// - keep the Platform (API/Gateway) up throughout — it never depended on that one container
|
|
// - never present the now-gone environment as healthy/running once reconciliation runs
|
|
// - leave academic data (progress/attempts) completely untouched
|
|
// - let the student launch a brand-new Lab afterward and continue where they left off
|
|
//
|
|
// Run via run-lab-host-loss-drill.sh, which brings up platform/docker-compose.dev.yml first.
|
|
// No framework, matching project convention.
|
|
|
|
const assert = require('assert');
|
|
const { execFileSync } = require('child_process');
|
|
const { makeSession } = require('../../../platform/test/session-helper');
|
|
|
|
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
|
const API = process.env.PLATFORM_API_URL || 'http://localhost:4300';
|
|
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
|
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
|
if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); }
|
|
|
|
let passed = 0, failed = 0;
|
|
async function test(name, fn) {
|
|
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
|
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
|
|
}
|
|
|
|
async function main() {
|
|
console.log('Phase 4.1 Lab Host loss/rebuild drill — slow, real k3s boots\n');
|
|
|
|
const admin = makeSession(GW);
|
|
const { status: loginStatus } = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
|
assert.equal(loginStatus, 200, 'owner login must succeed');
|
|
|
|
let studentId;
|
|
await test('roster: add a student', async () => {
|
|
const { body } = await admin.request('POST', '/api/admin/students', { display_name: 'Drill-LabHostLoss', cohort_id: null });
|
|
studentId = body.student_id;
|
|
});
|
|
|
|
const session = makeSession(GW);
|
|
await session.request('POST', '/api/login', { student_id: studentId });
|
|
|
|
let launch;
|
|
await test('launch: student gets a running Lab (real k3s boot)', async () => {
|
|
const { status, body } = await session.request('POST', '/api/environment/launch');
|
|
assert.equal(status, 200); assert.equal(body.status, 'running');
|
|
launch = body;
|
|
});
|
|
|
|
await test('progress: student completes a real scenario before the Lab Host is lost', async () => {
|
|
const res = await session.request('POST', `${launch.launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'b' });
|
|
assert.equal(res.status, 200);
|
|
const progress = await session.request('GET', '/api/progress');
|
|
assert.equal(progress.body.details['pod-basics-mcq']?.status, 'completed', 'progress must show completed before we destroy anything');
|
|
});
|
|
|
|
const token = launch.launch_path.split('/')[2];
|
|
|
|
await test('§21 — simulate total Lab Host loss: kill the Lab container directly, bypassing the Provisioner entirely', async () => {
|
|
const containers = execFileSync('docker', ['ps', '--filter', 'name=rootnode-lab-', '--format', '{{.Names}}']).toString().trim().split('\n').filter(Boolean);
|
|
assert.ok(containers.length >= 1, 'expected at least one running Lab container to destroy');
|
|
for (const c of containers) execFileSync('docker', ['rm', '-f', c]);
|
|
console.log(` destroyed ${containers.length} Lab container(s) out from under the Provisioner`);
|
|
});
|
|
|
|
await test('§21 — Platform stays available throughout (it never depended on that container)', async () => {
|
|
const api = await fetch(`${API}/healthz`);
|
|
const gw = await fetch(`${GW}/healthz`);
|
|
assert.equal(api.status, 200);
|
|
assert.equal(gw.status, 200);
|
|
});
|
|
|
|
await test('§21 — academic progress is completely untouched by the infrastructure loss', async () => {
|
|
const progress = await session.request('GET', '/api/progress');
|
|
assert.equal(progress.body.details['pod-basics-mcq']?.status, 'completed', 'progress must survive the Lab Host loss unchanged');
|
|
});
|
|
|
|
await test('§21 — the now-gone environment is NEVER presented as healthy once reconciliation runs (Provisioner restart, its real startup path)', async () => {
|
|
// cwd is already platform/ — run-lab-host-loss-drill.sh cd's there before invoking this script.
|
|
execFileSync('docker', ['compose', '-f', 'docker-compose.dev.yml', 'restart', 'provisioner'], { stdio: 'inherit' });
|
|
// Give the Provisioner a moment to finish its real reconcileOnStartup pass.
|
|
await new Promise((r) => setTimeout(r, 5000));
|
|
const res = await session.rawFetch(`/lab/${token}/`);
|
|
assert.ok([503, 404].includes(res.status), `expected the stale environment to be rejected (503/404), got ${res.status} — a healthy-looking response here would mean a destroyed environment is being silently presented as available`);
|
|
});
|
|
|
|
await test('§21 — a replacement Lab can be launched and boots successfully (the "replacement Lab Host" case, minus needing a literal second VM)', async () => {
|
|
const { status, body } = await session.request('POST', '/api/environment/launch');
|
|
assert.equal(status, 200); assert.equal(body.status, 'running');
|
|
assert.notEqual(body.launch_path, launch.launch_path, 'the replacement environment must be a genuinely new one, not the destroyed one still being served');
|
|
});
|
|
|
|
await test('§21 — previous academic progress is still there after the replacement Lab boots', async () => {
|
|
const progress = await session.request('GET', '/api/progress');
|
|
assert.equal(progress.body.details['pod-basics-mcq']?.status, 'completed');
|
|
});
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
if (failed > 0) process.exit(1);
|
|
}
|
|
|
|
main().catch((e) => { console.error(e); process.exit(1); });
|