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,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