Internal-only service (platform/provisioner/) implementing the environment lifecycle from docs/PHASE1_PLATFORM_FOUNDATION.md: provision -> running -> (reset -> running | destroy -> destroyed), plus experimental suspend/resume (decision #3 — never on the guaranteed path, never relied on for correctness). - src/docker.js — the lab-host abstraction (correction D). Resolves a lab_hosts row into a connected dockerode client, unix:// socket or tcp:// with TLS client-cert auth. Refuses to connect to a tcp:// endpoint with no TLS material configured rather than connecting unauthenticated. Validated against both a local socket and a real remote Docker-in-Docker TLS endpoint during testing — same code path either way, only the lab_hosts config differs, which is the entire point. - src/lifecycle.js — provision/destroy/reset/suspend/resume orchestration, wired to docker.js + egress.js + the Postgres registry. Every Lab container is privileged (required for k3s, not avoidable), gets its own Docker network, no published host ports, deterministic naming (rootnode-lab-<short-id> / rootnode-lab-net-<short-id>), and a CSPRNG routing token. Includes reconcileOnStartup(), implementing the "don't trust stale state after a crash/restart" requirement from docs/LAB_ARCHITECTURE.md §8 — verified by killing a container out-of-band and confirming a Provisioner restart flags the mismatch instead of trusting the stale 'running' row. Found and fixed during testing: a provisioning failure (e.g. readiness timeout) left an orphaned container/network/firewall chain behind with only the DB row marked 'error'. provisionEnvironment's catch block now does best-effort teardown of whatever was already created before marking the row error — confirmed by deliberately failing a provision and checking no container/network/chain survived it. - src/server.js — the internal HTTP API (X-Provisioner-Key shared-secret auth on every route, matching the instructor-passcode pattern already used for the roster endpoint). Input validation on student_id/environment_id, structured JSON errors with a request id, routing tokens redacted from every response. - docker-compose.dev.yml — Postgres + egress-proxy + provisioner on one machine (the documented Phase 1 dev exception). provisioner and egress-proxy run with network_mode: host + NET_ADMIN/NET_RAW, which is what lets src/egress.js manipulate the host's DOCKER-USER chain without needing interactive host sudo. Co-Authored-By: Claude Sonnet 5 <[email protected]>
67 lines
2.8 KiB
JavaScript
67 lines
2.8 KiB
JavaScript
// Lab-host abstraction (correction D) — resolves a `lab_hosts` row into a connected dockerode
|
|
// client. Nothing in this module or its callers may assume Docker is local; the endpoint
|
|
// always comes from a `lab_hosts` row, whether that row points at a Unix socket (Phase 1 dev)
|
|
// or a remote TCP+TLS endpoint (production Lab Host VM, per docs/LAB_ARCHITECTURE.md §1).
|
|
|
|
const fs = require('fs');
|
|
const Docker = require('dockerode');
|
|
|
|
// Caches one dockerode client per lab_host_id — cheap to keep around, avoids re-parsing TLS
|
|
// material on every call.
|
|
const clientCache = new Map();
|
|
|
|
function buildClient(labHost) {
|
|
if (labHost.endpoint.startsWith('unix://')) {
|
|
return new Docker({ socketPath: labHost.endpoint.replace('unix://', '') });
|
|
}
|
|
if (labHost.endpoint.startsWith('tcp://')) {
|
|
const url = new URL(labHost.endpoint.replace('tcp://', 'http://'));
|
|
const opts = { host: url.hostname, port: Number(url.port) || 2376, protocol: 'https' };
|
|
if (labHost.tls_ca_path && labHost.tls_cert_path && labHost.tls_key_path) {
|
|
opts.ca = fs.readFileSync(labHost.tls_ca_path);
|
|
opts.cert = fs.readFileSync(labHost.tls_cert_path);
|
|
opts.key = fs.readFileSync(labHost.tls_key_path);
|
|
} else {
|
|
// ponytail: no TLS material configured for a remote endpoint is a real misconfiguration,
|
|
// not a silently-degraded mode — fail loudly rather than connect unauthenticated to a
|
|
// remote Docker API (see docs/PHASE1_PLATFORM_FOUNDATION.md §6 on why that cert matters
|
|
// as much as the Provisioner API's own shared secret).
|
|
throw new Error(
|
|
`lab_host '${labHost.lab_host_id}' has a tcp:// endpoint but no TLS cert/key/ca configured — refusing to connect unauthenticated.`
|
|
);
|
|
}
|
|
return new Docker(opts);
|
|
}
|
|
throw new Error(`lab_host '${labHost.lab_host_id}' has an unrecognized endpoint scheme: ${labHost.endpoint}`);
|
|
}
|
|
|
|
function clientFor(labHost) {
|
|
let client = clientCache.get(labHost.lab_host_id);
|
|
if (!client) {
|
|
client = buildClient(labHost);
|
|
clientCache.set(labHost.lab_host_id, client);
|
|
}
|
|
return client;
|
|
}
|
|
|
|
// Phase 1: no scheduler, just "the one enabled row" (docs/PHASE1_PLATFORM_FOUNDATION.md §4).
|
|
async function selectLabHost(pool) {
|
|
const { rows } = await pool.query(
|
|
`SELECT * FROM lab_hosts WHERE enabled = true ORDER BY created_at ASC LIMIT 1`
|
|
);
|
|
if (!rows.length) throw new Error('No enabled lab_hosts row configured — cannot provision.');
|
|
return rows[0];
|
|
}
|
|
|
|
async function healthCheck(labHost) {
|
|
try {
|
|
const client = clientFor(labHost);
|
|
const info = await client.info();
|
|
return { ok: true, containers: info.Containers, serverVersion: info.ServerVersion };
|
|
} catch (e) {
|
|
return { ok: false, error: e.message };
|
|
}
|
|
}
|
|
|
|
module.exports = { clientFor, selectLabHost, healthCheck };
|