env-builder
Companion service to JupyterHub. Phases 2 + 3 of the per-notebook conda
env paradigm (architectural decisions live in
docs/notebook-envs-phase-0-decisions.md).
What it does
Receives manifest YAML from the Hub, runs micromamba to resolve a pinned
explicit lock and install it on the shared /workspace volume,
writes the lock + status back to Supabase's notebook_environments
table. Stateless except for an in-memory job queue.
Phase 2 produced the lock only. Phase 3 extends this service with
end-to-end env-on-disk lifecycle: per-notebook envs land at
/workspace/<workspace_id>/<notebook_id>/.env/ with atomic move
semantics (failed builds never displace the live env), shared package
cache via hardlinks (100 notebooks sharing pandas → one copy on disk),
and on-disk audit trail at .env-meta/.
Lifecycle
Hub: POST /api/notebooks/:id/environment/build
└─ HMAC-signed POST → env-builder /jobs
└─ enqueue
└─ worker:
├─ DB: build_status='solving'
├─ micromamba create --prefix /workspace/<ws>/<id>/.env.tmp/ ...
├─ micromamba env export --explicit → lock body
├─ DB: build_status='building' (callback fires here)
├─ atomic swap:
│ · rm -rf .env.previous
│ · mv .env → .env.previous (rollback target, Phase 6)
│ · mv .env.tmp → .env
├─ write .env-meta/{manifest.yaml, lock.yaml, build.log}
└─ DB: build_status='ready', lock_yaml=<...>, last_build_*=now
On-disk layout (per Phase 0 D4)
/workspace/
├── .mamba-cache/ # global pkg cache, hardlink source
└── <workspace_id>/
└── <notebook_id>/
├── *.ipynb # user files
├── .env/ # current usable env
├── .env.previous/ # last good env (Phase 6 rollback)
└── .env-meta/
├── manifest.yaml
├── lock.yaml
└── build.log
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /health | none | liveness; returns queue depth + busy workers |
POST | /jobs | HMAC | enqueue a build for (notebook_id, workspace_id, manifest_yaml) |
GET | /jobs/{id} | none (id is opaque) | status + lifecycle timestamps |
HMAC contract
The Hub computes:
sig = hex(hmac.sha256(ENV_BUILDER_SHARED_SECRET, raw_body))
and sends header X-Node-Signature: <sig>. env-builder recomputes and
compares with hmac.compare_digest. Mismatch → 401.
Required environment variables
| Name | Purpose | Example |
|---|---|---|
SUPABASE_URL | PostgREST base URL | https://abc.supabase.co |
SUPABASE_SERVICE_ROLE_KEY | Service-role key (bypasses RLS) | eyJhbGciOi... |
ENV_BUILDER_SHARED_SECRET | HMAC secret, shared with Hub | random 32+ char string |
PORT | Set by Railway | 8000 |
Optional knobs
| Name | Default | Effect |
|---|---|---|
ENV_BUILDER_SOLVE_TIMEOUT_SECONDS | 600 | Hard cap per build (10 min) |
ENV_BUILDER_LOG_TAIL_BYTES | 100000 | Log tail persisted to DB column |
ENV_BUILDER_MAX_CONCURRENT_SOLVES | 1 | Workers in parallel (Phase 7 raises to 3) |
ENV_BUILDER_JOB_HISTORY_SIZE | 256 | In-memory GET /jobs/{id} retention |
ENV_BUILDER_WORKSPACE_VOLUME_ROOT | /workspace | Mount point of the shared Railway volume. Must match the JupyterHub service's mount. |
ENV_BUILDER_MAMBA_CACHE_DIR | /workspace/.mamba-cache | Shared package cache. Must live on the SAME filesystem as .env/ so hardlinks work. |
Railway volume setup (Phase 3 requirement)
Both this service and the JupyterHub service MUST mount the SAME
Railway volume at /workspace. Without it, env-builder writes to
ephemeral container storage and the Hub-side kernel spawner can't
find the env on disk.
In the Railway dashboard:
- Create a volume named
node-workspace(or any name; consistency matters across services). - Attach it to the JupyterHub service at mount path
/workspace. - Attach the SAME volume to this env-builder service at the same
mount path
/workspace. - Set
ENV_BUILDER_WORKSPACE_VOLUME_ROOT=/workspace(or override).
Local development
docker build -t node-env-builder .
docker run --rm -p 8000:8000 \
-e SUPABASE_URL=... \
-e SUPABASE_SERVICE_ROLE_KEY=... \
-e ENV_BUILDER_SHARED_SECRET=$(openssl rand -hex 32) \
node-env-builder
Deployment (Railway)
railway.json wires Railway to build from this Dockerfile and run the
ASGI server via uvicorn. After deploy, set the env vars above in the
service dashboard. The Hub side reads ENV_BUILDER_URL (the public
Railway URL of this service) + the same ENV_BUILDER_SHARED_SECRET.
What it doesn't do (yet)
- Build log streaming via SSE: Phase 5 nicety, currently the Hub polls the DB for the truncated tail.
- Persistent job queue: Phase 7. Today the queue is in process memory; service crash loses in-flight jobs, the user retries.
- Per-workspace quotas / GC: Phase 7.
- Multi-version env history: Phase 6. Today only
.env/(current) and.env.previous/(last good) survive; older builds are evicted on the next successful build. - Per-notebook env owner UID coordination: Phase 7. Today envs are
written as root inside the env-builder container, mode 755 so the
kernel reads but can't
__pycache__-write inside.env/. Python tolerates this gracefully.
What it deliberately does
- Verifies HMAC on every write request: even on Railway's internal network — defense-in-depth against future leaks of the URL.
- Times out long solves: 10-minute cap. micromamba on a sane manifest resolves in seconds; anything longer is a pathological dependency graph or a stuck network call.
- Truncates build logs: full log goes to disk (Phase 3); DB column keeps the last 100 KB, which is what the UI shows.
- Classifies failure reasons:
solver_conflict,channel_unreachable,timeout,runtime_error. The full text is inbuild_log; the tag is for queryability + Phase 7 telemetry.