Background loop (src/reaper.js), config-driven idle/abandoned thresholds and
poll interval (no hard-coded numbers) — without this, "ephemeral by default"
is just a description: a closed browser tab does not stop a running k3s
cluster on its own.
Past the idle threshold: logs a warning event only. Past the abandoned
threshold: destroys via the same path DELETE /environments/:id uses, so
cleanup is identical either way, and logs event_type='reaped_idle'.
Failure-safe — one environment's teardown throwing never stops the reaper
from checking the rest, and a Postgres/Docker hiccup on one tick just means
"try again next tick," never a crashed process.
Uses COALESCE(last_active_at, created_at) rather than bare last_active_at.
Found during testing: an environment that fails during provisioning (before
ever reaching 'running') has a NULL last_active_at, and NULL < anything is
never true in SQL — without the fallback, a failed-and-never-cleaned-up
environment would never have aged out here regardless of how long it sat
there. This is the second, independent safety net on top of
provisionEnvironment's own best-effort cleanup on failure (see the
platform-provisioner commit).
Verified end-to-end: backdated an environment's last_active_at past the
abandoned threshold, waited for a real reaper tick, confirmed full
deterministic cleanup (container, network, firewall chain all gone) with the
correct actor='reaper' attribution in environment_events.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Egress enforcement is a firewall-layer control, not an application-layer
convention (correction B). HTTP_PROXY/HTTPS_PROXY configure containerd for a
clean pull path; they enforce nothing on their own.
- src/egress.js — per-environment dedicated iptables chain
(ROOTNODE-ENV-<short-id>), built with -A (append, order-preserving) rather
than the -I (insert-at-head) pattern an earlier design draft used, which
would have installed rules in reverse order and let the default-deny shadow
the proxy/DNS accepts. Required infrastructure (egress proxy, DNS) is
explicitly permitted first, then RFC1918 ranges are dropped unconditionally,
then a final default-deny catches the public internet. One jump rule per
environment hooks its subnet into its chain from DOCKER-USER (the chain
Docker guarantees it won't silently rewrite). Destroy is deterministic:
unhook, flush, delete — no comment-grep matching.
The ordering fix is empirically verified, not just reasoned about: the
egress proxy's own address is itself an RFC1918 address (it's Platform-side
infrastructure), and testing confirms it's reachable while the broader
RFC1918 range is still blocked — exactly the scenario the original ordering
bug would have broken.
- platform/egress-proxy/ — shared Squid instance (one per deployment, not
per-student), destination-allowlisted (dstdomain ACL against a plain-text
allowlist file), starting set covers docker.io/ghcr.io/gcr.io/quay.io/k8s.io.
Proxy performs domain allowlisting; the firewall chain above is what makes
it unbypassable, not the other way around.
The egress proxy's address is resolved per-student-network (each network's
Docker gateway IP, since the proxy runs with host networking and is present
on every bridge gateway) rather than a fixed hostname — a static address
wouldn't be reachable from more than one isolated student network at a time.
Found while testing with two simultaneous environments.
Verified: student-to-student isolation (A cannot reach B's internal IP),
RFC1918 blocking (A cannot reach a simulated trusted-infra address), and the
proxy allowlist itself (CONNECT tunnel to an allowlisted host succeeds,
non-allowlisted gets a clean 403 from Squid).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
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]>
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]>