Files
rootnode-academy/platform/provisioner/src/db.js
T
ced4568andClaude Sonnet 5 62241d26d2 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]>
2026-08-18 01:21:31 -05:00

62 lines
2.0 KiB
JavaScript

// 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 };