Skip to content

feat(lab): backend-agnostic ephemeral env API (Docker driver, TTL reaper, hot/cold tiers, audit) - #1

Open
devin-ai-integration[bot] wants to merge 1 commit into
mainfrom
devin/1777385800-lab-envs
Open

feat(lab): backend-agnostic ephemeral env API (Docker driver, TTL reaper, hot/cold tiers, audit)#1
devin-ai-integration[bot] wants to merge 1 commit into
mainfrom
devin/1777385800-lab-envs

Conversation

@devin-ai-integration

Copy link
Copy Markdown

Summary

Introduces an ephemeral JupyterLab environment system with a backend-agnostic API (/api/v1/lab/envs) so Docker, Slurm, and Proxmox VE can all plug in under one surface. This PR ships the Docker reference driver and the full lifecycle; Slurm and Proxmox drivers follow in separate PRs.

What's in this PR

  • internal/lab package

    • Driver interface (Name, OwnsTTL, Launch, Inspect, ExtendTTL, Archive, Destroy) + Registry.
    • DockerDriver reference implementation: random-port Jupyter containers, hot-tier bind mount, optional --gpus / --cpus / --memory, tar.gz archive with sidecar manifest JSON.
    • Manager: launches via the active driver, persists envs to ~/gradient/config/lab-envs.json (atomic JSON), runs a TTL reaper goroutine that archives+destroys expired envs on drivers that don't own their own TTL.
    • StorageConfig (HotTier / ColdTier) persisted at ~/gradient/config/lab.json. Defaults to ~/gradient/envs (hot) and ~/gradient/envs-archive (cold); sysadmin overrides via CLI.
    • AuditWriter with a JSONL FileAuditWriter that captures every launch/extend/archive — this is the first building block of the unified audit log called out in the roadmap.
    • peer_id on every ArchiveRef (machine-id or hostname) so mesh-aware restore is feasible in PR #3.x (concave-web) and PR #4 (Slurm).
  • HTTP surface (internal/api/handlers_lab.go)

    • GET/POST /api/v1/lab/envs — list and launch (viewer / developer-gated).
    • GET/DELETE /api/v1/lab/envs/{id} — inspect and archive-then-destroy (viewer / operator-gated).
    • POST /api/v1/lab/envs/{id}/extend — extend TTL (developer-gated).
    • POST /api/v1/lab/envs/{id}/archive — archive now (operator-gated).
    • GET/PUT /api/v1/lab/storage — inspect / update tier paths (admin-gated).
    • GET/PUT /api/v1/lab/drivers — list / select active driver (admin-gated).
    • Reaper goroutine wired into App.ListenAndServe.
  • CLI (cmd/lab_envs.go)

    • concave lab envs launch|list|extend|archive
    • concave lab storage show|set --hot <path> --cold <path>
    • concave lab driver show|set <driver>
  • Tests (internal/lab/lab_test.go)

    • Store round-trip (create/list/update/delete/persist).
    • Storage config round-trip + validation.
    • Registry active-driver behavior.
    • Manager launch → extend → archive, including audit-event assertions.
    • Reaper: archives only expired envs; skips scheduler-owned-TTL drivers.
    • DockerDriver: docker-run command composition; archive tarball + manifest.
    • Env.Remaining / Env.Expired helpers.

Gradient-specific touches (not "drag-and-drop" upstream)

  • One API across three backends — upstream JupyterHub can't do this.
  • Hot/cold tier choreography with manifest-based archive — every .tar.gz gets a sidecar .tar.gz.json carrying the original EnvSpec, peer_id, and size so restore is deterministic.
  • Mesh-addressable archivespeer_id field lets a future handler restore an env archived on node A onto node B.
  • Audit integrated — every action is written to ~/gradient/config/lab-audit.jsonl, the seed of the unified audit log.

Review & Testing Checklist for Human

  • Read internal/lab/driver.go and confirm the Driver interface covers the Slurm and Proxmox use cases you have in mind.
  • Review the role gating on each route in internal/api/handlers_lab.go (viewer, developer, operator, admin) against your policy expectations.
  • Run concave lab envs launch --image jupyter/minimal-notebook:latest --ttl 10m on a box with Docker, then concave lab envs list, concave lab envs extend <id> --by 5m, concave lab envs archive <id> and confirm:
    • The Jupyter URL in the list output opens a working lab.
    • The archive tarball and .json manifest appear in the configured cold tier after archive.
    • ~/gradient/config/lab-audit.jsonl has three JSONL entries.
  • Run concave lab storage set --cold /mnt/bulk (or any absolute path you control) and confirm ~/gradient/config/lab.json is updated and rejected on relative paths.
  • Verify the reaper actually reaps by launching with --ttl 30s and watching the env transition to archived without a manual archive call.

Notes

  • No new third-party dependencies.
  • Pre-existing TestDriverWizardSetupAndSelfUpdate in ./cmd fails in this environment because it requires a real Docker daemon to bring up the boosting suite — confirmed the same failure on main in a fresh worktree. Not caused by this PR.
  • Web and TUI Lab Envs views are next PRs (same series).
  • Slurm, Proxmox VE drivers, and the gradient-oss-mirrors + gradient-ansible repos are follow-up PRs on the roadmap.

Link to Devin session: https://app.devin.ai/sessions/5d19efa113054ca4953d9ed9309ce705
Requested by: @ElFariss

Introduce /api/v1/lab/envs (backend-agnostic), a Docker reference driver,
hot/cold storage tiers, a TTL reaper, and concave lab CLI subcommands.

The design is driver-pluggable (docker today, slurm and proxmox in
follow-ups) under one API. Archives stamp a peer_id so envs archived on
node A can be restored on node B. Every launch/extend/archive is written
to a JSONL audit log at ~/gradient/config/lab-audit.jsonl.

- internal/lab: Driver interface, Registry, Manager, Store, StorageConfig,
  DockerDriver, FileAuditWriter, TTL reaper goroutine.
- internal/api/handlers_lab.go: CRUD + extend + archive + storage + driver
  endpoints, role-gated (viewer lists, developer launches/extends,
  operator archives, admin reconfigures storage/driver).
- cmd/lab_envs.go: concave lab envs/storage/driver subcommands.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment thread cmd/lab_envs.go
return err
}
out, _ := json.MarshalIndent(storage, "", " ")
fmt.Println(string(out))

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 CONTRIBUTING.md violation: fmt.Println used in cmd/ (runLabStorageShow)

fmt.Println(string(out)) at cmd/lab_envs.go:196 directly writes to stdout, violating the mandatory CONTRIBUTING.md rule: "All terminal output in cmd/ must go through internal/ui/printer.go. Do not use fmt.Println or log.Printf in cmd/." No other cmd/ file uses fmt.Println — the convention is consistently followed elsewhere.

Suggested change
fmt.Println(string(out))
ui.Line(string(out))
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread cmd/lab_envs.go
if err != nil {
return err
}
fmt.Println("active:", mgr.Registry().Active())

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 CONTRIBUTING.md violation: fmt.Println used in cmd/ (runLabDriverShow, line 226)

fmt.Println("active:", ...) at cmd/lab_envs.go:226 directly writes to stdout, violating the mandatory rule: "Do not use fmt.Println or log.Printf in cmd/."

Suggested change
fmt.Println("active:", mgr.Registry().Active())
ui.Info("active", mgr.Registry().Active())
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread cmd/lab_envs.go
return err
}
fmt.Println("active:", mgr.Registry().Active())
fmt.Println("drivers:", strings.Join(mgr.Registry().Names(), ", "))

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 CONTRIBUTING.md violation: fmt.Println used in cmd/ (runLabDriverShow, line 227)

fmt.Println("drivers:", ...) at cmd/lab_envs.go:227 directly writes to stdout, violating the mandatory rule: "Do not use fmt.Println or log.Printf in cmd/."

Suggested change
fmt.Println("drivers:", strings.Join(mgr.Registry().Names(), ", "))
ui.Info("drivers", strings.Join(mgr.Registry().Names(), ", "))
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread internal/lab/manager.go
Comment on lines +219 to +222
if err := driver.Destroy(ctx, env); err != nil {
env.LastError = "destroy: " + err.Error()
_ = m.store.Update(env)
return env, err

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Missing StatusFailed on destroy failure causes infinite reaper retry loop with repeated archiving

In ArchiveAndDestroy, when driver.Destroy fails after a successful archive, env.Status remains StatusArchiving (set at line 209) but is never updated to StatusFailed. Since Active() (internal/lab/types.go:90-96) returns true for StatusArchiving, the reaper (internal/lab/manager.go:264-284) picks this env up again on the next tick (every 30s). Each retry re-runs ArchiveAndDestroy, which re-creates the tarball and retries the destroy. This creates an infinite loop of wasteful I/O on the cold tier (typically an HDD). Note the inconsistency: archive failure at line 213-217 correctly sets StatusFailed, but the analogous destroy failure path at line 219-222 does not.

Suggested change
if err := driver.Destroy(ctx, env); err != nil {
env.LastError = "destroy: " + err.Error()
_ = m.store.Update(env)
return env, err
if err := driver.Destroy(ctx, env); err != nil {
env.Status = StatusFailed
env.LastError = "destroy: " + err.Error()
_ = m.store.Update(env)
return env, err
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant