diff --git a/platform/provisioner/config/lab-hosts.seed.yaml b/platform/provisioner/config/lab-hosts.seed.yaml new file mode 100644 index 0000000..cd30122 --- /dev/null +++ b/platform/provisioner/config/lab-hosts.seed.yaml @@ -0,0 +1,23 @@ +# Phase 1 lab_hosts seed — applied to the DB at Provisioner startup (config.js +# applyLabHostsSeed), not managed via a runtime API (no host-management endpoints in Phase 1, +# matching "don't overbuild scheduling yet" — see docs/PHASE1_PLATFORM_FOUNDATION.md §4). +# +# Exactly one enabled row for Phase 1. Swapping this for a remote tcp://:2376 +# entry with TLS material is the entire code change needed to point at a real, separate Lab +# Host VM in production — see docs/LAB_ARCHITECTURE.md §1 and correction D. + +lab_hosts: + - lab_host_id: dev-local + display_name: "Local development Docker socket" + endpoint: "unix:///var/run/docker.sock" + enabled: true + + # Production example (not active — shown for reference; uncomment and fill in when a real + # Lab Host VM exists): + # - lab_host_id: labhost-01 + # display_name: "RootNode Lab Host VM 01" + # endpoint: "tcp://10.20.30.5:2376" + # tls_ca_path: /etc/rootnode/tls/labhost-01/ca.pem + # tls_cert_path: /etc/rootnode/tls/labhost-01/cert.pem + # tls_key_path: /etc/rootnode/tls/labhost-01/key.pem + # enabled: true diff --git a/platform/provisioner/config/resource-policy.yaml b/platform/provisioner/config/resource-policy.yaml new file mode 100644 index 0000000..99c1599 --- /dev/null +++ b/platform/provisioner/config/resource-policy.yaml @@ -0,0 +1,22 @@ +# Phase 1 resource policy — config-driven, not hard-coded (decision #4). +# No specific production numbers chosen yet; target host class for planning is ~16 cores/32 +# threads, 128GB RAM, SSD/NVMe. Actual per-lab limits get set by benchmarking (see +# docs/PHASE1_PLATFORM_FOUNDATION.md §8 test 9), not guessed backward from the spec sheet. +# These defaults are sized for local single-machine Phase 1 dev/testing only. + +default: + cpu_limit: 2.0 # vCPUs + mem_limit_mb: 4096 + pids_limit: 512 + storage_limit_mb: 8192 # only enforced if the host's Docker storage driver supports + # --storage-opt size= (see known-limitations in the SOP) + +idle_threshold_minutes: 30 # past this: log a warning event only +abandoned_threshold_minutes: 120 # past this: reaper destroys for real + +reaper_poll_interval_seconds: 60 + +health_check: + path: /api/health + timeout_ms: 240000 # k3s cold start in a nested container can be slow — matches the + # ~60-attempt wait already in scripts/entrypoint.sh diff --git a/platform/provisioner/migrations/001_lab_hosts.sql b/platform/provisioner/migrations/001_lab_hosts.sql new file mode 100644 index 0000000..ac2d140 --- /dev/null +++ b/platform/provisioner/migrations/001_lab_hosts.sql @@ -0,0 +1,16 @@ +-- lab_hosts — correction D: the abstraction that keeps Docker connectivity out of code. +-- See docs/LAB_ARCHITECTURE.md §3/§4 and docs/PHASE1_PLATFORM_FOUNDATION.md §2. + +CREATE TABLE IF NOT EXISTS lab_hosts ( + lab_host_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + endpoint TEXT NOT NULL, -- 'unix:///var/run/docker.sock' (dev) or + -- 'tcp://10.x.x.x:2376' (prod, TLS-verified) + tls_ca_path TEXT, -- null for the local-socket dev case + tls_cert_path TEXT, + tls_key_path TEXT, + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + -- Future (not Phase 1): capacity/status columns for real scheduling once there's more + -- than one host. Phase 1 selects "the one enabled row" — no scheduler. +); diff --git a/platform/provisioner/migrations/002_environments.sql b/platform/provisioner/migrations/002_environments.sql new file mode 100644 index 0000000..5550e82 --- /dev/null +++ b/platform/provisioner/migrations/002_environments.sql @@ -0,0 +1,51 @@ +-- environments — the disposable per-student lab registry. +-- See docs/LAB_ARCHITECTURE.md §3 and docs/PHASE1_PLATFORM_FOUNDATION.md §2. + +DO $$ BEGIN + CREATE TYPE environment_status AS ENUM ( + 'requested', 'provisioning', 'running', 'resetting', + 'suspended', -- experimental path only, see decision #3 / correction A + 'destroying', 'destroyed', 'error' + ); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS environments ( + environment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + student_id TEXT NOT NULL, -- opaque external identifier (correction C) — + -- no FK: students table isn't in this database + -- until a later, separate Platform DB migration. + -- The Provisioner trusts its caller on this value. + lab_host_id TEXT NOT NULL REFERENCES lab_hosts(lab_host_id), -- real FK: both + -- tables are Provisioner-owned Postgres. + status environment_status NOT NULL DEFAULT 'requested', + routing_token TEXT NOT NULL, -- CSPRNG, opaque, unique + container_id TEXT, + container_name TEXT, + network_id TEXT, + network_name TEXT, + network_subnet CIDR, -- needed to install/remove the per-env firewall chain + image_ref TEXT NOT NULL, + cpu_limit NUMERIC NOT NULL, + mem_limit_mb INTEGER NOT NULL, + pids_limit INTEGER NOT NULL, + storage_limit_mb INTEGER NOT NULL, + internal_ip TEXT, + internal_port INTEGER NOT NULL DEFAULT 80, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + last_active_at TIMESTAMPTZ, + destroy_at TIMESTAMPTZ, -- idle-reaper's scheduled destroy time + error_reason TEXT +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_environments_routing_token ON environments(routing_token); +CREATE INDEX IF NOT EXISTS idx_environments_student ON environments(student_id); +CREATE INDEX IF NOT EXISTS idx_environments_status ON environments(status); +CREATE INDEX IF NOT EXISTS idx_environments_lab_host ON environments(lab_host_id); + +-- Phase 1 invariant "at most one non-terminal environment per student" is enforced in +-- application logic (lifecycle.js), not a DB constraint yet — a partial unique index is the +-- natural upgrade once this needs to be bulletproof under concurrent requests, not needed for +-- a single-instance provisioner in this phase. diff --git a/platform/provisioner/migrations/003_environment_events.sql b/platform/provisioner/migrations/003_environment_events.sql new file mode 100644 index 0000000..f458c44 --- /dev/null +++ b/platform/provisioner/migrations/003_environment_events.sql @@ -0,0 +1,17 @@ +-- environment_events — audit trail for every lifecycle transition. +-- See docs/LAB_ARCHITECTURE.md §3/§8 and docs/PHASE1_PLATFORM_FOUNDATION.md §2/§9. +-- Never log secrets or routing tokens in plaintext here (see lifecycle.js emitEvent()). + +CREATE TABLE IF NOT EXISTS environment_events ( + id BIGSERIAL PRIMARY KEY, + environment_id UUID NOT NULL REFERENCES environments(environment_id), + event_type TEXT NOT NULL, -- provision_requested | host_selected | network_created | + -- firewall_configured | container_created | ready | + -- reset | destroyed | error | reaped_idle | suspend | resume + actor TEXT NOT NULL, -- 'system' | 'instructor:' | 'student:' | 'reaper' + detail JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_env_events_environment ON environment_events(environment_id); +CREATE INDEX IF NOT EXISTS idx_env_events_type ON environment_events(event_type); diff --git a/platform/provisioner/package.json b/platform/provisioner/package.json new file mode 100644 index 0000000..9bb29a9 --- /dev/null +++ b/platform/provisioner/package.json @@ -0,0 +1,17 @@ +{ + "name": "rootnode-provisioner", + "version": "0.1.0", + "description": "RootNode Academy Phase 1 platform provisioner — disposable per-student lab lifecycle.", + "private": true, + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "test": "node test/run.js" + }, + "dependencies": { + "dockerode": "^4.0.2", + "express": "^4.19.2", + "js-yaml": "^4.1.0", + "pg": "^8.11.5" + } +} diff --git a/platform/provisioner/src/config.js b/platform/provisioner/src/config.js new file mode 100644 index 0000000..194397c --- /dev/null +++ b/platform/provisioner/src/config.js @@ -0,0 +1,50 @@ +// Loads the two Phase 1 config files (resource-policy.yaml, lab-hosts.seed.yaml) — plain +// files an operator edits, not database tables, since there's no admin UI to manage them via +// a database yet (see docs/PHASE1_PLATFORM_FOUNDATION.md §2). Reloadable on SIGHUP so a limits +// change doesn't require a restart. + +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); + +const CONFIG_DIR = process.env.PROVISIONER_CONFIG_DIR || path.join(__dirname, '../config'); + +function loadResourcePolicy() { + const raw = yaml.load(fs.readFileSync(path.join(CONFIG_DIR, 'resource-policy.yaml'), 'utf8')); + return raw; +} + +function loadLabHostsSeed() { + const raw = yaml.load(fs.readFileSync(path.join(CONFIG_DIR, 'lab-hosts.seed.yaml'), 'utf8')); + return raw.lab_hosts || []; +} + +// Upserts the seed rows into lab_hosts — applied at startup, not exposed as a runtime API +// (no lab-host management endpoints in Phase 1, matching "don't overbuild scheduling yet"). +async function applyLabHostsSeed(pool) { + const hosts = loadLabHostsSeed(); + for (const h of hosts) { + await pool.query( + `INSERT INTO lab_hosts (lab_host_id, display_name, endpoint, tls_ca_path, tls_cert_path, tls_key_path, enabled) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (lab_host_id) DO UPDATE SET + display_name = excluded.display_name, endpoint = excluded.endpoint, + tls_ca_path = excluded.tls_ca_path, tls_cert_path = excluded.tls_cert_path, + tls_key_path = excluded.tls_key_path, enabled = excluded.enabled`, + [h.lab_host_id, h.display_name, h.endpoint, h.tls_ca_path || null, h.tls_cert_path || null, h.tls_key_path || null, h.enabled !== false] + ); + } + return hosts.length; +} + +let _resourcePolicy = null; +function getResourcePolicy() { + if (!_resourcePolicy) _resourcePolicy = loadResourcePolicy(); + return _resourcePolicy; +} +function reloadResourcePolicy() { + _resourcePolicy = loadResourcePolicy(); + return _resourcePolicy; +} + +module.exports = { loadResourcePolicy, loadLabHostsSeed, applyLabHostsSeed, getResourcePolicy, reloadResourcePolicy }; diff --git a/platform/provisioner/src/db.js b/platform/provisioner/src/db.js new file mode 100644 index 0000000..e683981 --- /dev/null +++ b/platform/provisioner/src/db.js @@ -0,0 +1,61 @@ +// Postgres connection + migration runner for the Platform DB. +// +// Migration pattern matches the one already established in the Lab image's +// backend/server.js (check-before-alter, idempotent) — here it's file-based rather than +// inline, since this is a fresh schema with a real migrations/ directory instead of a single +// growing CREATE TABLE IF NOT EXISTS block. + +const fs = require('fs'); +const path = require('path'); +const { Pool } = require('pg'); + +const MIGRATIONS_DIR = path.join(__dirname, '../migrations'); + +let _pool = null; +function getPool() { + if (_pool) return _pool; + _pool = new Pool({ + connectionString: process.env.PLATFORM_DB_URL || 'postgres://postgres:postgres@localhost:5433/rootnode_platform', + }); + return _pool; +} + +// Applies every .sql file in migrations/ that hasn't run yet, in filename order. +// Tracked in schema_migrations so re-running is a no-op — same idempotency guarantee the +// existing SQLite migrations already rely on. +async function runMigrations() { + const pool = getPool(); + await pool.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + + const applied = new Set( + (await pool.query('SELECT version FROM schema_migrations')).rows.map(r => r.version) + ); + + const files = fs.readdirSync(MIGRATIONS_DIR).filter(f => f.endsWith('.sql')).sort(); + const ranNow = []; + for (const file of files) { + if (applied.has(file)) continue; + const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf8'); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query(sql); + await client.query('INSERT INTO schema_migrations (version) VALUES ($1)', [file]); + await client.query('COMMIT'); + ranNow.push(file); + } catch (e) { + await client.query('ROLLBACK'); + throw new Error(`Migration ${file} failed: ${e.message}`); + } finally { + client.release(); + } + } + return ranNow; +} + +module.exports = { getPool, runMigrations };