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:
2026-08-18 01:21:31 -05:00
co-authored by Claude Sonnet 5
parent 60fbf0c385
commit 62241d26d2
8 changed files with 257 additions and 0 deletions
@@ -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);