feat(platform-db): add lab host and environment registry
New Phase 1 Platform database — PostgreSQL, separate from the Lab image's own SQLite (which keeps its current students/progress data untouched, decision #2 / correction C in docs/LAB_ARCHITECTURE.md). Schema (platform/provisioner/migrations/): - lab_hosts: the abstraction that keeps Docker connectivity out of code — endpoint (unix:// or tcp://+TLS), enabled flag. Phase 1 ships one row. - environments: environment_id (UUID), student_id (opaque external identifier, no FK — students table isn't in this database yet), lab_host_id (real FK), status, routing_token (CSPRNG via crypto.randomBytes, opaque), container/ network identity, resource limits, lifecycle timestamps. - environment_events: append-only audit trail, one row per lifecycle transition, never logs the routing token in plaintext (see lifecycle.js's redact()). File-based migration runner (db.js) matching the check-before-alter idempotency pattern already used in the Lab image's backend/server.js, applied on every Provisioner boot. Resource limits and the lab_hosts seed are config files (config/resource-policy.yaml, config/lab-hosts.seed.yaml), not database tables — there's no admin UI to manage them via a database yet, so a DB-backed policy table would just be a write path nothing uses. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -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://<lab-host-vm>: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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
);
|
||||||
@@ -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.
|
||||||
@@ -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:<id>' | 'student:<id>' | '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);
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user