// RootNode Academy — Phase 1 Provisioner API. // Internal-only (requirement #3/#8) — every route below requires X-Provisioner-Key. Nothing // here is reachable by a student or the public internet in this phase; there's no frontend/ // Gateway wiring yet, so this is exercised directly (curl / the test suite in test/run.js). // // See docs/PHASE1_PLATFORM_FOUNDATION.md §4 for the approved route list this implements. const express = require('express'); const { randomUUID } = require('crypto'); const { getPool, runMigrations } = require('./db'); const { applyLabHostsSeed, getResourcePolicy, reloadResourcePolicy } = require('./config'); const { healthCheck: dockerHealthCheck } = require('./docker'); const lifecycle = require('./lifecycle'); const { startReaper } = require('./reaper'); const PORT = Number(process.env.PROVISIONER_PORT) || 4100; const SHARED_SECRET = process.env.PROVISIONER_SHARED_SECRET; const LAB_IMAGE_REF = process.env.LAB_IMAGE_REF || 'rootnode-academy:test'; // No fixed EGRESS_PROXY_HOST: the proxy runs with host networking and is reached via each // environment's own network gateway IP, resolved per-environment in lifecycle.js — a single // static hostname wouldn't be reachable from every isolated student network. Only the port // is fixed/shared. const EGRESS_PROXY_PORT = Number(process.env.EGRESS_PROXY_PORT) || 3128; const DNS_RESOLVER = process.env.DNS_RESOLVER || '127.0.0.11'; // Docker's embedded DNS by default const NO_PROXY_CIDRS = (process.env.NO_PROXY_CIDRS || '10.42.0.0/16,10.43.0.0/16').split(','); if (!SHARED_SECRET) { console.error('FATAL: PROVISIONER_SHARED_SECRET is not set. Refusing to start with an unauthenticated internal API.'); process.exit(1); } function egressConfig() { return { proxyPort: EGRESS_PROXY_PORT, dnsResolver: DNS_RESOLVER, extraAllowedHosts: [], // none required in Phase 1 — the Lab image doesn't call back to the Platform yet noProxyCidrs: NO_PROXY_CIDRS, }; } function resourcePolicyFor() { const policy = getResourcePolicy().default; // Phase 4.1 §26 — optional; null/unset (the default) means no cap is enforced, matching the // Phase 4 rule against fabricating a concurrency number before it's measured. Set this from // infra/scripts/capacity-model.js's real output once the host exists (see // infra/environments/production/capacity.yml.example's hard-admission-control field) — see // docs/PHASE4_1_PREPRODUCTION_HARDENING.md §26. const admission = getResourcePolicy().admission_control || {}; return { cpuLimit: policy.cpu_limit, memLimitMb: policy.mem_limit_mb, pidsLimit: policy.pids_limit, storageLimitMb: policy.storage_limit_mb, maxConcurrentLabs: admission.max_concurrent_labs || null, }; } function healthOpts() { const hc = getResourcePolicy().health_check || {}; return { path: hc.path || '/api/health', timeoutMs: hc.timeout_ms || 120000 }; } const app = express(); app.use(express.json()); // Every request gets a request id for structured error correlation, and every response is // JSON — no HTML error pages, no leaking stack traces to a caller. app.use((req, res, next) => { req.requestId = randomUUID(); next(); }); function requireAuth(req, res, next) { const key = req.get('X-Provisioner-Key'); if (!key || key !== SHARED_SECRET) { return res.status(401).json({ error: 'unauthorized', request_id: req.requestId }); } next(); } function isValidStudentId(v) { return typeof v === 'string' && v.length > 0 && v.length <= 128 && /^[a-zA-Z0-9_.-]+$/.test(v); } function isValidUUID(v) { return typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v); } function asyncRoute(fn) { return (req, res) => fn(req, res).catch((e) => { console.error(`[${req.requestId}]`, e); // CAPACITY_EXCEEDED (Phase 4.1 §26) is 503, not 500 — the host being full is an expected, // recoverable operational state, not an application error. See lifecycle.js's admission // check, which runs BEFORE any Docker/network resources are touched. const status = e.code === 'NOT_FOUND' ? 404 : e.code === 'ALREADY_EXISTS' ? 409 : e.code === 'CAPACITY_EXCEEDED' ? 503 : 500; res.status(status).json({ error: e.message, code: e.code || 'INTERNAL', request_id: req.requestId }); }); } app.use(requireAuth); // GET /health — Provisioner's own health: Docker reachable, Postgres reachable. app.get('/health', asyncRoute(async (req, res) => { const pool = getPool(); const dbOk = await pool.query('SELECT 1').then(() => true).catch(() => false); const labHosts = await pool.query('SELECT * FROM lab_hosts WHERE enabled = true').catch(() => ({ rows: [] })); const hostChecks = await Promise.all(labHosts.rows.map(async (h) => ({ lab_host_id: h.lab_host_id, ...(await dockerHealthCheck(h)) }))); const ok = dbOk && hostChecks.every((h) => h.ok); res.status(ok ? 200 : 503).json({ ok, db: dbOk, lab_hosts: hostChecks }); })); // GET /capacity — Phase 4.1 §26. Internal-only, same shared-secret auth as everything else // here — lets the Platform API's instructor dashboard show real capacity state instead of // instructors only discovering a full host when a student's launch fails. app.get('/capacity', asyncRoute(async (req, res) => { const { rows } = await getPool().query(`SELECT count(*)::int AS n FROM environments WHERE status NOT IN ('destroyed', 'error')`); res.json({ current: rows[0].n, max: resourcePolicyFor().maxConcurrentLabs }); })); // POST /environments — { student_id } app.post('/environments', asyncRoute(async (req, res) => { const { student_id } = req.body || {}; if (!isValidStudentId(student_id)) { return res.status(400).json({ error: 'student_id is required and must be a simple identifier', request_id: req.requestId }); } const env = await lifecycle.provisionEnvironment({ pool: getPool(), studentId: student_id, resourcePolicy: resourcePolicyFor(), imageRef: LAB_IMAGE_REF, egressConfig: egressConfig(), health: healthOpts(), }); res.status(201).json({ environment_id: env.environment_id, status: env.status, routing_token: env.routing_token }); })); // GET /environments?student_id= app.get('/environments', asyncRoute(async (req, res) => { const { student_id } = req.query; if (!isValidStudentId(student_id)) { return res.status(400).json({ error: 'student_id query param is required', request_id: req.requestId }); } const envs = await lifecycle.listEnvironments(getPool(), student_id); res.json(envs.map(redactRow)); })); // GET /environments/by-token/:token — Phase 2A addition, used by the Gateway to resolve a // student's opaque routing token to the environment it addresses (internal_ip, status, // owning student_id) before proxying. Internal-only, same shared-secret auth as every other // route here — the Gateway is the only caller, browsers never reach this directly. app.get('/environments/by-token/:token', asyncRoute(async (req, res) => { // Phase 4.1: also joins lab_host_routing_mode/lab_host_router_endpoint so the Gateway can // decide direct-vs-router without knowing anything about Docker topology itself — see // lifecycle.getEnvironmentByTokenWithRouting and docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1. const env = await lifecycle.getEnvironmentByTokenWithRouting(getPool(), req.params.token); if (!env) return res.status(404).json({ error: 'not found', request_id: req.requestId }); res.json(redactRow(env)); })); // GET /environments/:id/token — Phase 2A addition. Every other read path deliberately redacts // routing_token (see redactRow, and the "never log secrets in plaintext" requirement) — this // is the one narrow exception, for the Gateway's own legitimate need to reconstruct a // student's /lab// launch URL for an environment that already exists (not just one it // just created, where the token comes back un-redacted from POST /environments directly). // Still shared-secret gated like every other route — nothing about this is student-reachable. app.get('/environments/:id/token', asyncRoute(async (req, res) => { if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId }); const env = await lifecycle.getEnvironment(getPool(), req.params.id); if (!env) return res.status(404).json({ error: 'not found', request_id: req.requestId }); res.json({ environment_id: env.environment_id, routing_token: env.routing_token }); })); // GET /environments/:id app.get('/environments/:id', asyncRoute(async (req, res) => { if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId }); const env = await lifecycle.getEnvironment(getPool(), req.params.id); if (!env) return res.status(404).json({ error: 'not found', request_id: req.requestId }); res.json(redactRow(env)); })); // POST /environments/:id/reset app.post('/environments/:id/reset', asyncRoute(async (req, res) => { if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId }); const env = await lifecycle.resetEnvironment({ pool: getPool(), environmentId: req.params.id, resourcePolicy: resourcePolicyFor(), egressConfig: egressConfig(), health: healthOpts(), actor: req.get('X-Actor') || 'system', }); res.json(redactRow(env)); })); // DELETE /environments/:id app.delete('/environments/:id', asyncRoute(async (req, res) => { if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId }); const env = await lifecycle.destroyEnvironment({ pool: getPool(), environmentId: req.params.id, actor: req.get('X-Actor') || 'system' }); res.json(redactRow(env)); })); // POST /environments/:id/suspend — EXPERIMENTAL, see decision #3 / correction A. app.post('/environments/:id/suspend', asyncRoute(async (req, res) => { if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId }); const env = await lifecycle.suspendEnvironment({ pool: getPool(), environmentId: req.params.id, actor: req.get('X-Actor') || 'system' }); res.json({ ...redactRow(env), experimental: true }); })); // POST /environments/:id/resume — EXPERIMENTAL, see decision #3 / correction A. app.post('/environments/:id/resume', asyncRoute(async (req, res) => { if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId }); const env = await lifecycle.resumeEnvironment({ pool: getPool(), environmentId: req.params.id, health: healthOpts(), actor: req.get('X-Actor') || 'system' }); res.json({ ...redactRow(env), experimental: true }); })); // GET /environments/:id/events app.get('/environments/:id/events', asyncRoute(async (req, res) => { if (!isValidUUID(req.params.id)) return res.status(400).json({ error: 'invalid environment id', request_id: req.requestId }); const { rows } = await getPool().query( 'SELECT id, event_type, actor, detail, created_at FROM environment_events WHERE environment_id = $1 ORDER BY created_at ASC', [req.params.id] ); res.json(rows); })); function redactRow(env) { const { routing_token, ...rest } = env; return { ...rest, routing_token: routing_token ? '[redacted]' : null }; } process.on('SIGHUP', () => { console.log('SIGHUP received, reloading resource-policy.yaml'); try { reloadResourcePolicy(); } catch (e) { console.error('reload failed:', e.message); } }); async function main() { const pool = getPool(); const applied = await runMigrations(); if (applied.length) console.log('Applied migrations:', applied.join(', ')); const seeded = await applyLabHostsSeed(pool); console.log(`Applied lab_hosts seed (${seeded} row(s))`); const reconcileResult = await lifecycle.reconcileOnStartup(pool); console.log(`Startup reconciliation: checked=${reconcileResult.checked} ok=${reconcileResult.ok} flagged=${reconcileResult.flagged}`); const policy = getResourcePolicy(); const reaper = startReaper({ pool, idleThresholdMinutes: policy.idle_threshold_minutes, abandonedThresholdMinutes: policy.abandoned_threshold_minutes, pollIntervalSeconds: policy.reaper_poll_interval_seconds, }); process.on('SIGTERM', () => { reaper.stop(); process.exit(0); }); app.listen(PORT, () => console.log(`Provisioner API listening on :${PORT}`)); } if (require.main === module) { main().catch((e) => { console.error('FATAL:', e); process.exit(1); }); } module.exports = { app, main };