Files
ced4568 51a09241ad feat(loadtest): load-test tooling + capacity model
boot-labs.js drives the real admin Prepare-Labs endpoint (reusing the
existing session-helper.js from Phase 3A) and records boot time/failure
rate; sample-resources.sh samples real docker stats for running Labs.
capacity-model.js turns measured per-Lab CPU/memory into a recommended/
warning/hard-admission-control concurrency threshold - no concurrency
number is hardcoded as a marketing figure, every input is explicitly
placeholder data until measured against the real host.

Plus deploy/rollback/smoke-test scripts used by the CI/CD and deployment
runbooks. All syntax-checked; validate-compose.sh and capacity-model.js
actually run against real inputs.
2026-08-18 17:13:32 -05:00

80 lines
3.6 KiB
JavaScript

#!/usr/bin/env node
// Load test tooling (Phase 4 §24) — provisions N concurrent Labs via the real admin
// Prepare-Labs endpoint (the same code path a real instructor's "Prepare Labs" click uses,
// platform/api/src/admin-routes.js), measures boot time and failure rate, and (if run ON the
// Lab Host, or given SSH access to it) samples per-Lab CPU/RAM/disk via `docker stats`.
//
// This produces REAL numbers against whatever stack it's pointed at — a local dev stack today,
// the real rn-lab-host-01 once it exists. It does NOT fabricate a concurrency number; the
// report is only as meaningful as the host it ran against (see the report template's WARNING
// banner when run against anything other than real target-class hardware).
//
// Usage: GATEWAY_URL=... OWNER_EMAIL=... OWNER_PASSWORD=... node boot-labs.js --count 10
const fs = require('fs');
const path = require('path');
const { makeSession } = require('../../../platform/test/session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
const COUNT = Number((process.argv.find((a) => a.startsWith('--count=')) || '--count=5').split('=')[1]);
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
console.error('Set OWNER_EMAIL/OWNER_PASSWORD (bootstrap one first — see platform/api/bootstrap-owner.js)');
process.exit(1);
}
async function main() {
const owner = makeSession(GW);
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
const cohortId = `loadtest-${Date.now()}`;
console.log(`Creating cohort ${cohortId} with ${COUNT} students...`);
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'Load Test' });
for (let i = 0; i < COUNT; i++) {
await owner.request('POST', '/api/admin/students', { display_name: `LoadTest-${i}`, cohort_id: cohortId });
}
console.log(`Prepare Labs: booting ${COUNT} Labs concurrently...`);
const start = Date.now();
const { status, body } = await owner.request('POST', `/api/admin/cohorts/${cohortId}/prepare-labs`);
const elapsedMs = Date.now() - start;
if (status !== 200) {
console.error('Prepare Labs call itself failed:', status, body);
process.exit(1);
}
const running = body.results.filter((r) => r.status === 'running').length;
const failed = body.results.filter((r) => r.status === 'error');
const report = {
timestamp: new Date().toISOString(),
gateway_url: GW,
requested_count: COUNT,
running_count: running,
failed_count: failed.length,
failure_rate_pct: Math.round((failed.length / COUNT) * 1000) / 10,
total_boot_wall_time_ms: elapsedMs,
avg_boot_time_ms_estimate: Math.round(elapsedMs / COUNT), // concurrent, so this is a rough
// lower bound, not a real per-Lab figure
failures: failed,
note: 'Run infra/scripts/loadtest/sample-resources.sh DURING this script (in another '
+ 'terminal) for real per-Lab CPU/RAM numbers — this script only measures boot '
+ 'timing/success rate, not resource consumption.',
};
const outDir = path.join(__dirname, 'results');
fs.mkdirSync(outDir, { recursive: true });
const outFile = path.join(outDir, `boot-labs-${Date.now()}.json`);
fs.writeFileSync(outFile, JSON.stringify(report, null, 2));
console.log(JSON.stringify(report, null, 2));
console.log(`\nWritten to ${outFile}`);
console.log(`\nCleanup: POST /api/admin/cohorts/${cohortId}/destroy-labs as the owner when done.`);
}
main().catch((e) => { console.error('Load test crashed:', e); process.exit(1); });