diff --git a/CHANGELOG.md b/CHANGELOG.md index fec2590..3540f74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to Devboxes are documented here. The project follows [Keep a ## [Unreleased] +## [0.4.0] - 2026-07-20 + +### Added + +- Added opt-in, operator-approved GPU profiles across Helm, the API, CLI, dashboard, and in-product documentation, including default and explicitly named profile selection. +- Added portable extended-resource requests for NVIDIA, AMD, Intel, partitioned, shared, device-plugin, and compatible Dynamic Resource Allocation bridge configurations, with optional per-profile workspace images, RuntimeClasses, supplemental groups, node selectors, and tolerations. +- Added authenticated installation capability discovery, GPU allocation reporting, scheduler diagnostics, strict chart and controller validation, Helm contract tests, and end-to-end controller coverage. + +### Changed + +- Persist the fully resolved GPU profile on each workspace so stop, start, and Insights reconciliation cannot silently change or remove an existing hardware allocation after Helm configuration changes. +- Show accelerator allocation in CLI list and status output and in the browser workbench while keeping CPU-only creation as the default. + +### Fixed + +- Made the Insights HTTP integration fixture use the current observation time so the full controller suite remains deterministic after its original fixture date. + +### Security + +- Restrict clients to named operator-owned profiles instead of accepting arbitrary images, resource names, RuntimeClasses, supplemental groups, selectors, tolerations, privileged mode, or host mounts. + ## [0.3.0] - 2026-07-14 ### Added @@ -90,7 +111,8 @@ All notable changes to Devboxes are documented here. The project follows [Keep a - Portable Helm chart with values schema, namespace-scoped RBAC, configurable storage, ingress, LoadBalancer or NodePort SSH, ServiceMonitor, and disruption budget. - macOS and Linux CLI releases, SHA-256 verification installer, GHCR images, OCI chart publishing, image provenance attestations, and clean Kind install CI. -[Unreleased]: https://github.com/vicotrbb/devboxes/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/vicotrbb/devboxes/compare/v0.4.0...HEAD +[0.4.0]: https://github.com/vicotrbb/devboxes/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/vicotrbb/devboxes/compare/v0.2.1...v0.3.0 [0.2.1]: https://github.com/vicotrbb/devboxes/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/vicotrbb/devboxes/compare/v0.1.2...v0.2.0 diff --git a/Makefile b/Makefile index b9b84f4..36e0ba9 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,7 @@ test: helm: scripts/test-helm-insights.sh + scripts/test-helm-gpu.sh helm template devboxes charts/devboxes --namespace devboxes --set workspace.sshService.type=NodePort --set workspace.sshService.host=192.0.2.10 >/dev/null images: diff --git a/README.md b/README.md index 7c0d5c6..a8832a1 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Each workspace includes Rust, Node.js, Python, `uv`, GitHub CLI, Codex CLI, Clau - A Rust `devbox` CLI for create, list, inspect, SSH, start, stop, delete, and opt-in Insights workflows. - A FastAPI controller with an authenticated API, accessible browser workbench, Insights dashboard, documentation, metrics, health checks, and TTL cleanup. - A versioned Helm chart with values schema validation and namespace-scoped RBAC. +- Optional operator-approved GPU profiles for NVIDIA, AMD, Intel, partitioned, or shared accelerators. - Multi-architecture controller and workspace images for `linux/amd64` and `linux/arm64`. - Persistent SSH host identity, shell state, tool installs, account state, and source under `/home/dev`. - GitHub Releases with macOS and Linux CLI binaries and SHA-256 checksums. @@ -40,6 +41,8 @@ Devboxes is currently a single-operator system: one shared token controls every - An SSH public key. - An ingress controller and TLS certificate only if you expose the dashboard through ingress. Port-forwarding works without either. +GPU acceleration additionally requires GPU nodes, a working vendor device plugin or a Dynamic Resource Allocation driver with a compatible extended-resource bridge, and a workspace image containing the user-space libraries needed by the workload. GPU support is opt-in and CPU-only remains the safe default. + The workspace container intentionally supports passwordless `sudo` for the trusted development user. Its pod drops all capabilities and adds back a small set needed by `sudo` and OpenSSH PTY auditing, but it is not compatible with the Kubernetes `restricted` Pod Security profile. Use the `baseline` profile or an equivalent policy in the Devboxes namespace. ## Install @@ -84,7 +87,7 @@ kubectl -n devboxes create secret generic devboxes-workspace \ --from-file=SSH_AUTHORIZED_KEYS="$HOME/.ssh/id_ed25519.pub" helm install devboxes oci://ghcr.io/vicotrbb/charts/devboxes \ - --version 0.3.0 \ + --version 0.4.0 \ --namespace devboxes ``` @@ -132,6 +135,40 @@ For clusters without a load balancer, let Kubernetes allocate a distinct NodePor See [configuration](docs/configuration.md) for every supported value and platform examples. +### Enable GPU acceleration + +Operators expose trusted, named profiles instead of allowing clients to inject Kubernetes pod fields. Each profile binds a user-facing name to an extended resource, count, optional GPU-ready workspace image, and optional scheduling policy: + +```yaml +gpu: + enabled: true + defaultProfile: nvidia-l4 + profiles: + - name: nvidia-l4 + displayName: NVIDIA L4 + description: One dedicated L4 for inference and CUDA development + resourceName: nvidia.com/gpu + count: 1 + workspaceImage: ghcr.io/example/devboxes-workspace-cuda:12.8 + runtimeClassName: nvidia + nodeSelector: + accelerator: nvidia-l4 + tolerations: + - key: nvidia.com/gpu + operator: Exists + effect: NoSchedule +``` + +After upgrading the release with this values file, users can discover and request profiles through every supported interface: + +```bash +devbox gpu profiles +devbox create inference --gpu --ssh +devbox create training --gpu-profile nvidia-l4 --preset large --ssh +``` + +The dashboard exposes the same profiles in its create form. Devboxes sets the resource in both container requests and limits, preserves the resolved allocation across stop and start, and surfaces scheduler reasons when capacity is unavailable. Read [GPU acceleration](docs/gpu.md) for driver prerequisites, NVIDIA and AMD examples, image contracts, sharing, security, upgrades, and troubleshooting. + ### Enable Insights Insights is disabled by default. Enable it to collect privacy-bounded local AI metrics and aggregate Git activity into a persistent controller database: @@ -178,6 +215,7 @@ Authenticate and create a box: ```bash devbox login --url https://devboxes.example.com devbox create atlas --preset medium --ttl 24 --repo owner/project --ssh +devbox create inference --gpu --ssh ``` Login opens the system browser, asks the current Devboxes browser session to approve the @@ -247,7 +285,7 @@ devbox CLI / browser Devboxes controller ─── Kubernetes API │ │ │ ├─ Secret (scoped Insights ingest credential) - │ ├─ Deployment (disposable compute) + │ ├─ Deployment (disposable CPU or GPU compute) │ ├─ Service (LoadBalancer or NodePort SSH) │ └─ PVC (persistent /home/dev) ├─ TTL cleanup and lifecycle state @@ -274,6 +312,7 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) before proposing a change. Security repo - [Golden path](docs/golden-path.md) for a performance-oriented installation and daily workflow. - [CLI reference](docs/cli.md) and [API reference](docs/api.md) for client contracts. +- [GPU acceleration](docs/gpu.md) for accelerator profiles, images, scheduling, and operations. - [Insights](docs/insights.md) for telemetry semantics, privacy, storage, backup, and purge. - [Configuration](docs/configuration.md) and [credentials](docs/credentials.md) for installation details. - [Operations](docs/operations.md) and [troubleshooting](docs/troubleshooting.md) for production ownership. @@ -281,7 +320,7 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) before proposing a change. Security repo ## Project status -Devboxes is at `v0.3`: useful and installable, with an intentionally narrow trust model. Compatibility follows semantic versioning after `v1.0`; before then, minor releases may include documented configuration or API changes. PVC data is never automatically deleted, including at TTL expiry. +Devboxes is at `v0.4`: useful and installable, with an intentionally narrow trust model. Compatibility follows semantic versioning after `v1.0`; before then, minor releases may include documented configuration or API changes. PVC data is never automatically deleted, including at TTL expiry. ## License diff --git a/SECURITY.md b/SECURITY.md index b73c360..1162a4f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -28,6 +28,7 @@ Recommended deployment controls: - use fine-grained, least-privilege provider tokens; - enable Kubernetes Secret encryption at rest and an auditable secret manager; - enforce the chart's namespace-scoped RBAC and tokenless workspace service account; +- expose GPU devices only through reviewed operator-owned profiles and trusted derived images; - back up important PVCs and test restore procedures; - keep Kubernetes, ingress, CSI, images, chart, and CLI releases current; - verify release checksums and image provenance attestations. @@ -39,6 +40,8 @@ Workspace SSH disables password, keyboard-interactive, and root login and uses p Ed25519 host keys. Incoming terminal names are validated against installed terminfo before tmux starts. +GPU profiles preserve this trust model. Clients can select only a configured name; they cannot inject an image, extended resource, RuntimeClass, supplemental group, selector, toleration, privileged mode, host path, or device path. The assigned device is fully available to the trusted workspace user, including through passwordless `sudo`, so GPU profiles do not make Devboxes suitable for untrusted workloads. Vendor drivers, device plugins, runtimes, and derived GPU images are part of the operator's trusted supply chain. + ## Dependency and supply-chain policy Dependabot monitors npm, Cargo, Python, Docker, and GitHub Actions dependencies. Pull requests receive dependency review, and CI audits npm, Python, and Cargo dependencies, builds both images, performs strict JavaScript, documentation, controller, and CLI checks, validates Helm output, and installs into a clean Kind cluster. Release images are published for amd64 and arm64 with GitHub artifact attestations; CLI archives include SHA-256 checksums. diff --git a/SUPPORT.md b/SUPPORT.md index 827d503..4840461 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -11,8 +11,11 @@ kubectl get deployment,service,pvc,pod -n devboxes -o wide kubectl logs -n devboxes deployment/devboxes --tail=200 kubectl get events -n devboxes --sort-by=.lastTimestamp devbox --version +devbox gpu profiles ``` For SSH problems, state whether the installation uses `LoadBalancer` or `NodePort`, whether the address is reachable from the CLI machine, and the exact OpenSSH error. For storage problems, include the StorageClass name, access mode, expansion support, and PVC events. +For GPU problems, include the selected profile, redacted `devbox status` output, the pod's scheduling events, Kubernetes RuntimeClass presence, and the exact extended resource shown in node allocatable capacity. State whether the vendor plugin uses dedicated, partitioned, or shared devices. Do not include private registry credentials, image pull Secrets, public node addresses, or proprietary workload output. + Security issues must follow [SECURITY.md](SECURITY.md) and must not be posted publicly. diff --git a/charts/devboxes/Chart.yaml b/charts/devboxes/Chart.yaml index 0c3ebdf..25cc2bb 100644 --- a/charts/devboxes/Chart.yaml +++ b/charts/devboxes/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: devboxes description: Self-hosted, ephemeral development environments on Kubernetes type: application -version: 0.3.0 -appVersion: "0.3.0" +version: 0.4.0 +appVersion: "0.4.0" kubeVersion: ">=1.29.0-0" home: https://github.com/vicotrbb/devboxes icon: https://raw.githubusercontent.com/vicotrbb/devboxes/main/docs/assets/devboxes-mark.svg @@ -17,8 +17,8 @@ annotations: artifacthub.io/category: integration-delivery artifacthub.io/changes: | - kind: added - description: Add privacy-bounded Devboxes Insights for AI metrics and aggregate Git activity + description: Add operator-approved GPU profiles across Helm, API, CLI, and dashboard workflows - kind: added - description: Add dashboard and CLI summary, status, activity, export, and purge workflows + description: Add accelerator discovery, allocation reporting, and scheduler diagnostics - kind: security - description: Add dual allowlists and scoped per-workspace ingest credentials + description: Restrict users to named operator-owned hardware profiles and pinned workload contracts diff --git a/charts/devboxes/templates/NOTES.txt b/charts/devboxes/templates/NOTES.txt index 0657817..d175d82 100644 --- a/charts/devboxes/templates/NOTES.txt +++ b/charts/devboxes/templates/NOTES.txt @@ -26,3 +26,11 @@ Devboxes is installed in namespace {{ .Release.Namespace }}. Important: every workspace needs an externally reachable SSH service. Configure workspace.sshService for your cluster's LoadBalancer implementation or use NodePort with workspace.sshService.host. +{{- if .Values.gpu.enabled }} + +GPU acceleration is enabled with {{ len .Values.gpu.profiles }} operator-approved profile(s). +Confirm the matching node drivers, device resources, runtimes, and workspace images, +then inspect the user catalog with: + + devbox gpu profiles +{{- end }} diff --git a/charts/devboxes/templates/deployment.yaml b/charts/devboxes/templates/deployment.yaml index f894137..a0c3988 100644 --- a/charts/devboxes/templates/deployment.yaml +++ b/charts/devboxes/templates/deployment.yaml @@ -1,6 +1,29 @@ {{- if and .Values.insights.enabled (ne (int .Values.controller.replicaCount) 1) }} {{- fail "controller.replicaCount must be exactly 1 when insights.enabled=true" }} {{- end }} +{{- $gpuProfileNames := dict }} +{{- $gpuDefaultFound := false }} +{{- range .Values.gpu.profiles }} +{{- if hasKey $gpuProfileNames .name }} +{{- fail (printf "gpu.profiles contains duplicate name %q" .name) }} +{{- end }} +{{- if not (trim .displayName) }} +{{- fail (printf "gpu profile %q displayName must not be blank" .name) }} +{{- end }} +{{- if and .workspaceImage (contains "://" .workspaceImage) }} +{{- fail (printf "gpu profile %q workspaceImage must not contain a URL scheme" .name) }} +{{- end }} +{{- $_ := set $gpuProfileNames .name true }} +{{- if eq .name $.Values.gpu.defaultProfile }} +{{- $gpuDefaultFound = true }} +{{- end }} +{{- end }} +{{- if and .Values.gpu.defaultProfile (not $gpuDefaultFound) }} +{{- fail "gpu.defaultProfile must name an entry in gpu.profiles" }} +{{- end }} +{{- if and .Values.gpu.enabled (not .Values.gpu.defaultProfile) }} +{{- fail "gpu.defaultProfile is required when gpu.enabled=true" }} +{{- end }} apiVersion: apps/v1 kind: Deployment metadata: @@ -99,6 +122,12 @@ spec: {{- end }} - name: DEVBOXES_LOG_LEVEL value: {{ .Values.controller.logLevel | quote }} + - name: DEVBOXES_GPU_ENABLED + value: {{ .Values.gpu.enabled | quote }} + - name: DEVBOXES_GPU_DEFAULT_PROFILE + value: {{ .Values.gpu.defaultProfile | quote }} + - name: DEVBOXES_GPU_PROFILES + value: {{ .Values.gpu.profiles | toJson | quote }} - name: DEVBOXES_INSIGHTS_ENABLED value: {{ .Values.insights.enabled | quote }} {{- if .Values.insights.enabled }} diff --git a/charts/devboxes/values.schema.json b/charts/devboxes/values.schema.json index 9bb7f17..15ad818 100644 --- a/charts/devboxes/values.schema.json +++ b/charts/devboxes/values.schema.json @@ -76,6 +76,94 @@ } } }, + "gpu": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "defaultProfile", "profiles"], + "properties": { + "enabled": {"type": "boolean"}, + "defaultProfile": { + "type": "string", + "maxLength": 40, + "pattern": "^(?:[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?)?$" + }, + "profiles": { + "type": "array", + "maxItems": 32, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "displayName", "resourceName", "count"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 40, + "pattern": "^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$" + }, + "displayName": {"type": "string", "minLength": 1, "maxLength": 80}, + "description": {"type": "string", "maxLength": 160}, + "resourceName": { + "type": "string", + "minLength": 3, + "maxLength": 317, + "pattern": "^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?)*/[A-Za-z0-9](?:[-_.A-Za-z0-9]{0,61}[A-Za-z0-9])?$" + }, + "count": {"type": "integer", "minimum": 1, "maximum": 64}, + "workspaceImage": { + "type": "string", + "maxLength": 512, + "pattern": "^(?:[^[:space:]]+)?$" + }, + "runtimeClassName": { + "type": "string", + "maxLength": 253, + "pattern": "^(?:[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?)*)?$" + }, + "supplementalGroups": { + "type": "array", + "maxItems": 8, + "uniqueItems": true, + "items": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + }, + "nodeSelector": { + "type": "object", + "maxProperties": 16, + "propertyNames": { + "maxLength": 317, + "pattern": "^(?:[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?)*/)?[A-Za-z0-9](?:[-_.A-Za-z0-9]{0,61}[A-Za-z0-9])?$" + }, + "additionalProperties": { + "type": "string", + "maxLength": 63, + "pattern": "^(?:[A-Za-z0-9](?:[-_.A-Za-z0-9]{0,61}[A-Za-z0-9])?)?$" + } + }, + "tolerations": { + "type": "array", + "maxItems": 16, + "items": {"$ref": "#/definitions/gpuToleration"} + } + } + } + } + }, + "allOf": [ + { + "if": {"properties": {"enabled": {"const": true}}}, + "then": { + "properties": { + "defaultProfile": {"minLength": 1}, + "profiles": {"minItems": 1} + } + } + } + ] + }, "insights": { "type": "object", "additionalProperties": false, @@ -185,6 +273,63 @@ "repository": {"type": "string", "minLength": 1}, "tag": {"type": "string"} } + }, + "gpuToleration": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "maxLength": 317, + "pattern": "^(?:(?:[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?)*/)?[A-Za-z0-9](?:[-_.A-Za-z0-9]{0,61}[A-Za-z0-9])?)?$" + }, + "operator": {"type": "string", "enum": ["Equal", "Exists"]}, + "value": { + "type": "string", + "maxLength": 63, + "pattern": "^(?:[A-Za-z0-9](?:[-_.A-Za-z0-9]{0,61}[A-Za-z0-9])?)?$" + }, + "effect": { + "type": "string", + "enum": ["NoSchedule", "PreferNoSchedule", "NoExecute"] + }, + "tolerationSeconds": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647 + } + }, + "allOf": [ + { + "if": { + "properties": {"operator": {"const": "Exists"}}, + "required": ["operator"] + }, + "then": {"not": {"required": ["value"]}} + }, + { + "if": { + "anyOf": [ + {"not": {"required": ["operator"]}}, + { + "properties": {"operator": {"const": "Equal"}}, + "required": ["operator"] + } + ] + }, + "then": { + "required": ["key"], + "properties": {"key": {"minLength": 1}} + } + }, + { + "if": {"required": ["tolerationSeconds"]}, + "then": { + "required": ["effect"], + "properties": {"effect": {"const": "NoExecute"}} + } + } + ] } } } diff --git a/charts/devboxes/values.yaml b/charts/devboxes/values.yaml index fcde8b7..8011757 100644 --- a/charts/devboxes/values.yaml +++ b/charts/devboxes/values.yaml @@ -57,6 +57,28 @@ workspace: externalTrafficPolicy: Cluster loadBalancerSourceRanges: [] +# GPU acceleration is opt-in. Each profile maps a stable user-facing name to +# one device-plugin or DRA-bridged extended resource and trusted scheduling policy. +gpu: + enabled: false + defaultProfile: "" + # Example profile fields: + # - name: nvidia-l4 + # displayName: NVIDIA L4 + # description: One dedicated GPU + # resourceName: nvidia.com/gpu + # count: 1 + # workspaceImage: registry.example/devboxes-workspace-cuda:12.8 + # runtimeClassName: nvidia + # supplementalGroups: [] + # nodeSelector: + # accelerator: nvidia-l4 + # tolerations: + # - key: nvidia.com/gpu + # operator: Exists + # effect: NoSchedule + profiles: [] + insights: enabled: false # Optional key in controller.existingSecret. Empty derives a dedicated, diff --git a/cli/Cargo.lock b/cli/Cargo.lock index e579efd..fb3ace4 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -313,7 +313,7 @@ dependencies = [ [[package]] name = "devbox-cli" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "axum", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 581729a..1a2ddc8 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devbox-cli" -version = "0.3.0" +version = "0.4.0" edition = "2024" rust-version = "1.96" description = "Terminal client for self-hosted Kubernetes development environments" diff --git a/cli/README.md b/cli/README.md index a18660d..dbcd8c6 100644 --- a/cli/README.md +++ b/cli/README.md @@ -6,6 +6,8 @@ The `devbox` terminal client creates, inspects, connects to, stops, and deletes cargo install --locked --git https://github.com/vicotrbb/devboxes devbox-cli devbox login --url https://devboxes.example.com devbox create atlas --repo owner/project --ssh +devbox gpu profiles +devbox create inference --gpu --ssh ``` -See the [CLI reference](../docs/cli.md) for every command, option, environment variable, output contract, and SSH workflow. The [golden path](../docs/golden-path.md) covers the recommended installation and performance setup. +See the [CLI reference](../docs/cli.md) for every command, option, environment variable, output contract, and SSH workflow. [GPU acceleration](../docs/gpu.md) covers operator-approved accelerator profiles. The [golden path](../docs/golden-path.md) covers the recommended installation and performance setup. diff --git a/cli/src/client.rs b/cli/src/client.rs index bf4fe1f..513e811 100644 --- a/cli/src/client.rs +++ b/cli/src/client.rs @@ -7,8 +7,8 @@ use serde::de::DeserializeOwned; use serde_json::Value; use crate::models::{ - CliTokenRequest, CliTokenResponse, CreateDevbox, DeleteResult, Devbox, DevboxList, - InsightsActivityData, InsightsEnvelope, InsightsPurgeResult, InsightsStatusData, + Capabilities, CliTokenRequest, CliTokenResponse, CreateDevbox, DeleteResult, Devbox, + DevboxList, InsightsActivityData, InsightsEnvelope, InsightsPurgeResult, InsightsStatusData, InsightsSummary, WhoAmI, }; @@ -54,6 +54,11 @@ impl ApiClient { .await } + pub async fn capabilities(&self) -> Result { + self.request(Method::GET, "/api/v1/capabilities", Option::<&()>::None) + .await + } + pub async fn list(&self) -> Result> { Ok(self .request::<(), DevboxList>(Method::GET, "/api/v1/devboxes", None) diff --git a/cli/src/main.rs b/cli/src/main.rs index 15ef87e..86113b9 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -16,8 +16,8 @@ use tokio::time::{Instant, sleep}; use client::ApiClient; use config::StoredConfig; use models::{ - CollectorStatus, CreateDevbox, Devbox, InsightsActivity, InsightsActivityData, - InsightsEnvelope, InsightsStatusData, InsightsSummary, Preset, + CollectorStatus, CreateDevbox, Devbox, GpuCapabilities, GpuRequest, InsightsActivity, + InsightsActivityData, InsightsEnvelope, InsightsStatusData, InsightsSummary, Preset, }; #[derive(Parser)] @@ -63,6 +63,8 @@ enum Commands { Delete(DeleteArgs), /// Inspect privacy-preserving AI and repository metrics. Metrics(MetricsArgs), + /// Inspect GPU acceleration profiles configured by the operator. + Gpu(GpuArgs), } #[derive(Args)] @@ -92,6 +94,14 @@ struct CreateArgs { #[arg(long)] repo: Option, + /// Request the operator's default GPU profile. + #[arg(long)] + gpu: bool, + + /// Request a specific operator-approved GPU profile (implies --gpu). + #[arg(long, value_name = "PROFILE", value_parser = validate_name)] + gpu_profile: Option, + /// Return immediately instead of waiting for SSH readiness. #[arg(long)] no_wait: bool, @@ -101,6 +111,18 @@ struct CreateArgs { ssh: bool, } +#[derive(Args)] +struct GpuArgs { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum GpuCommand { + /// List the GPU profiles available for new devboxes. + Profiles, +} + #[derive(Args)] struct NameArgs { #[arg(value_parser = validate_name)] @@ -249,6 +271,7 @@ async fn main() -> Result<()> { Commands::Stop(args) => lifecycle(&client, &args.name, false, cli.json).await, Commands::Delete(args) => delete(&client, args).await, Commands::Metrics(args) => metrics(&client, args, cli.json).await, + Commands::Gpu(args) => gpu(&client, args, cli.json).await, } } @@ -358,11 +381,19 @@ async fn create( json: bool, server_alias: &str, ) -> Result<()> { + let gpu = if args.gpu || args.gpu_profile.is_some() { + Some(GpuRequest { + profile: args.gpu_profile.as_deref(), + }) + } else { + None + }; let payload = CreateDevbox { name: &args.name, preset: args.preset, ttl_hours: args.ttl, repository: args.repo.as_deref(), + gpu, }; let mut box_info = client.create(&payload).await?; if !args.no_wait || args.ssh { @@ -387,16 +418,17 @@ async fn list(client: &ApiClient, json: bool) -> Result<()> { return Ok(()); } println!( - "{:<22} {:<11} {:<8} {:<16} SSH", - "NAME", "STATE", "SIZE", "AUTO-STOP" + "{:<22} {:<11} {:<8} {:<16} {:<18} SSH", + "NAME", "STATE", "SIZE", "AUTO-STOP", "ACCELERATOR" ); for box_info in boxes { println!( - "{:<22} {:<11} {:<8} {:<16} {}", + "{:<22} {:<11} {:<8} {:<16} {:<18} {}", box_info.name, box_info.state, box_info.preset, human_expiry(&box_info), + gpu_label(&box_info), box_info.ssh_command.as_deref().unwrap_or("pending"), ); } @@ -522,6 +554,43 @@ async fn metrics(client: &ApiClient, args: MetricsArgs, json: bool) -> Result<() Ok(()) } +async fn gpu(client: &ApiClient, args: GpuArgs, json: bool) -> Result<()> { + let GpuArgs { command } = args; + match command { + None | Some(GpuCommand::Profiles) => { + let capabilities = client.capabilities().await?.gpu; + if json { + println!("{}", serde_json::to_string_pretty(&capabilities)?); + } else { + print_gpu_profiles(&capabilities); + } + } + } + Ok(()) +} + +fn print_gpu_profiles(capabilities: &GpuCapabilities) { + if !capabilities.enabled { + println!("GPU acceleration is disabled by the operator."); + return; + } + println!( + "{:<18} {:<26} {:>5} {:<28} DESCRIPTION", + "PROFILE", "NAME", "COUNT", "RESOURCE" + ); + for profile in &capabilities.profiles { + println!( + "{:<18} {:<26} {:>5} {:<28} {}{}", + profile.name, + profile.display_name, + profile.count, + profile.resource_name, + profile.description.as_deref().unwrap_or(""), + if profile.is_default { " (default)" } else { "" }, + ); + } +} + fn metrics_query(filters: &MetricsFilters) -> Vec<(String, String)> { let mut query = vec![("since".to_owned(), filters.since.clone())]; for (key, value) in [ @@ -706,7 +775,11 @@ async fn wait_until_ready(client: &ApiClient, name: &str, timeout: Duration) -> ); } if Instant::now() >= deadline { - bail!("timed out waiting for {name} to become ready"); + let detail = box_info + .message + .as_deref() + .map_or_else(String::new, |message| format!(": {message}")); + bail!("timed out waiting for {name} to become ready{detail}"); } sleep(Duration::from_secs(2)).await; } @@ -768,6 +841,12 @@ fn print_box(box_info: &Devbox, json: bool) -> Result<()> { println!("{} {}", box_info.name, box_info.state); println!(" preset: {}", box_info.preset); println!(" storage: {}", box_info.storage_size); + if let Some(gpu) = &box_info.gpu { + println!( + " accelerator: {} ({} x {})", + gpu.display_name, gpu.count, gpu.resource_name + ); + } println!(" auto-stop: {}", box_info.expires_at.to_rfc3339()); if let Some(repository) = &box_info.repository { println!(" repository: {repository}"); @@ -780,6 +859,13 @@ fn print_box(box_info: &Devbox, json: bool) -> Result<()> { Ok(()) } +fn gpu_label(box_info: &Devbox) -> &str { + box_info + .gpu + .as_ref() + .map_or("cpu", |gpu| gpu.profile.as_str()) +} + fn human_expiry(box_info: &Devbox) -> String { if box_info.state == "stopped" { return "stopped".to_owned(); @@ -797,8 +883,8 @@ mod tests { use clap::Parser; use super::{ - Cli, Commands, MetricsCommand, human_duration, metrics_query, resolve_login_token, - ssh_arguments, validate_name, + Cli, Commands, GpuCommand, MetricsCommand, human_duration, metrics_query, + resolve_login_token, ssh_arguments, validate_name, }; #[test] @@ -889,6 +975,51 @@ mod tests { assert!(Cli::try_parse_from(["devbox", "metrics", "--box", "Invalid"]).is_err()); } + #[test] + fn create_accepts_default_and_named_gpu_profiles() { + let default_gpu = Cli::try_parse_from(["devbox", "create", "atlas", "--gpu"]).unwrap(); + let Commands::Create(default_gpu) = default_gpu.command else { + panic!("expected create command"); + }; + assert!(default_gpu.gpu); + assert!(default_gpu.gpu_profile.is_none()); + + let named_gpu = + Cli::try_parse_from(["devbox", "create", "atlas", "--gpu-profile", "nvidia-l4"]) + .unwrap(); + let Commands::Create(named_gpu) = named_gpu.command else { + panic!("expected create command"); + }; + assert!(!named_gpu.gpu); + assert_eq!(named_gpu.gpu_profile.as_deref(), Some("nvidia-l4")); + assert!( + Cli::try_parse_from([ + "devbox", + "create", + "atlas", + "--gpu-profile", + "Invalid_Profile", + ]) + .is_err() + ); + } + + #[test] + fn gpu_profiles_command_is_discoverable() { + let root = Cli::try_parse_from(["devbox", "gpu"]).unwrap(); + let Commands::Gpu(root) = root.command else { + panic!("expected gpu command"); + }; + assert!(root.command.is_none()); + + let profiles = Cli::try_parse_from(["devbox", "gpu", "profiles", "--json"]).unwrap(); + assert!(profiles.json); + let Commands::Gpu(profiles) = profiles.command else { + panic!("expected gpu command"); + }; + assert!(matches!(profiles.command, Some(GpuCommand::Profiles))); + } + #[test] fn active_time_format_is_compact_and_bounded() { assert_eq!(human_duration(0.0), "0m"); diff --git a/cli/src/models.rs b/cli/src/models.rs index 80d8fb4..edeedb3 100644 --- a/cli/src/models.rs +++ b/cli/src/models.rs @@ -21,12 +21,28 @@ impl std::fmt::Display for Preset { } } +#[derive(Debug, Serialize)] +pub struct GpuRequest<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + pub profile: Option<&'a str>, +} + #[derive(Debug, Serialize)] pub struct CreateDevbox<'a> { pub name: &'a str, pub preset: Preset, pub ttl_hours: u16, pub repository: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub gpu: Option>, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct GpuAllocation { + pub profile: String, + pub display_name: String, + pub resource_name: String, + pub count: u16, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -45,6 +61,8 @@ pub struct Devbox { pub restarts: u32, pub storage_size: String, pub message: Option, + #[serde(default)] + pub gpu: Option, } #[derive(Debug, Deserialize)] @@ -63,6 +81,29 @@ pub struct WhoAmI { pub mode: String, } +#[derive(Debug, Deserialize, Serialize)] +pub struct Capabilities { + pub gpu: GpuCapabilities, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct GpuCapabilities { + pub enabled: bool, + pub default_profile: Option, + pub profiles: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct GpuProfileSummary { + pub name: String, + pub display_name: String, + pub description: Option, + pub resource_name: String, + pub count: u16, + #[serde(rename = "default")] + pub is_default: bool, +} + #[derive(Debug, Serialize)] pub struct CliTokenRequest<'a> { pub grant_type: &'static str, @@ -210,3 +251,80 @@ pub struct InsightsPurgeResult { pub box_name: String, pub purged_instances: u64, } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{CreateDevbox, Devbox, GpuRequest, Preset}; + + #[test] + fn create_payload_omits_cpu_only_gpu_state() { + let payload = CreateDevbox { + name: "atlas", + preset: Preset::Medium, + ttl_hours: 24, + repository: None, + gpu: None, + }; + + assert_eq!( + serde_json::to_value(payload).unwrap(), + json!({ + "name": "atlas", + "preset": "medium", + "ttl_hours": 24, + "repository": null + }) + ); + } + + #[test] + fn create_payload_distinguishes_default_and_named_gpu_profiles() { + let default_gpu = CreateDevbox { + name: "inference", + preset: Preset::Small, + ttl_hours: 24, + repository: None, + gpu: Some(GpuRequest { profile: None }), + }; + let named_gpu = CreateDevbox { + name: "training", + preset: Preset::Large, + ttl_hours: 72, + repository: None, + gpu: Some(GpuRequest { + profile: Some("nvidia-l4"), + }), + }; + + assert_eq!(serde_json::to_value(default_gpu).unwrap()["gpu"], json!({})); + assert_eq!( + serde_json::to_value(named_gpu).unwrap()["gpu"], + json!({"profile": "nvidia-l4"}) + ); + } + + #[test] + fn devbox_deserialization_accepts_pre_gpu_controller_responses() { + let box_info: Devbox = serde_json::from_value(json!({ + "name": "atlas", + "state": "ready", + "preset": "medium", + "created_at": "2026-07-20T12:00:00Z", + "expires_at": "2026-07-21T12:00:00Z", + "repository": null, + "ssh_host": "192.0.2.10", + "ssh_port": 22, + "ssh_command": "ssh dev@192.0.2.10", + "pod_name": "devbox-atlas-example", + "pod_ready": true, + "restarts": 0, + "storage_size": "30Gi", + "message": null + })) + .unwrap(); + + assert!(box_info.gpu.is_none()); + } +} diff --git a/controller/README.md b/controller/README.md index e43da8d..6ebfc2f 100644 --- a/controller/README.md +++ b/controller/README.md @@ -13,4 +13,4 @@ uv run pytest For local development, set `DEVBOXES_KUBECONFIG_CONTEXT` to a disposable Kubernetes context and provide a non-production `DEVBOXES_ACCESS_TOKEN`. -See the [API reference](../docs/api.md), [architecture](../docs/architecture.md), and [operations runbook](../docs/operations.md) for supported behavior and deployment guidance. +See the [API reference](../docs/api.md), [architecture](../docs/architecture.md), [GPU acceleration](../docs/gpu.md), and [operations runbook](../docs/operations.md) for supported behavior and deployment guidance. diff --git a/controller/pyproject.toml b/controller/pyproject.toml index 034c95d..b82db3f 100644 --- a/controller/pyproject.toml +++ b/controller/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "devboxes-controller" -version = "0.3.0" +version = "0.4.0" description = "Controller and dashboard for self-hosted Kubernetes development environments" readme = "README.md" requires-python = ">=3.12" diff --git a/controller/src/devboxes_controller/__init__.py b/controller/src/devboxes_controller/__init__.py index 2c28f37..22766f3 100644 --- a/controller/src/devboxes_controller/__init__.py +++ b/controller/src/devboxes_controller/__init__.py @@ -1,3 +1,3 @@ """Devboxes controller package.""" -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/controller/src/devboxes_controller/app.py b/controller/src/devboxes_controller/app.py index 7242ab1..16ff769 100644 --- a/controller/src/devboxes_controller/app.py +++ b/controller/src/devboxes_controller/app.py @@ -49,12 +49,15 @@ from .insights_store import QueryFilters from .manager import DevboxConflictError, DevboxManager, DevboxNotFoundError from .models import ( + Capabilities, CliTokenRequest, CliTokenResponse, CreateDevboxRequest, DeleteResult, Devbox, DevboxList, + GpuCapabilities, + GpuProfileSummary, WhoAmI, ) from .resources import PRESETS @@ -72,6 +75,28 @@ ] +def _capabilities(settings: Settings) -> Capabilities: + """Build the safe installation feature contract exposed to clients.""" + return Capabilities( + gpu=GpuCapabilities( + enabled=settings.gpu_enabled, + default_profile=settings.gpu_default_profile if settings.gpu_enabled else None, + profiles=[ + GpuProfileSummary( + name=profile.name, + display_name=profile.display_name, + description=profile.description, + resource_name=profile.resource_name, + count=profile.count, + default=profile.name == settings.gpu_default_profile, + ) + for profile in settings.gpu_profiles + if settings.gpu_enabled + ], + ) + ) + + def create_app( settings: Settings | None = None, manager: DevboxManager | None = None, @@ -87,6 +112,7 @@ def create_app( maximum_codes=settings.authorization_code_store_size, ) templates = Jinja2Templates(directory=PACKAGE_DIR / "templates") + capabilities = _capabilities(settings) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: @@ -372,6 +398,7 @@ async def dashboard(request: Request) -> Response: "cluster_name": settings.cluster_name, "storage_class": settings.storage_class or "cluster default", "workspace_service_type": settings.workspace_service_type, + "gpu": capabilities.gpu, "version": __version__, }, ) @@ -416,6 +443,7 @@ async def documentation(request: Request) -> Response: for preset, resources in PRESETS.items() ], "insights_enabled": insights.enabled, + "gpu": capabilities.gpu, "version": __version__, }, ) @@ -424,6 +452,10 @@ async def documentation(request: Request) -> Response: async def whoami(auth: Auth) -> WhoAmI: return WhoAmI(user=auth.subject, mode=auth.mode) + @app.get("/api/v1/capabilities", tags=["system"]) + async def installation_capabilities(_: Auth) -> Capabilities: + return capabilities + @app.get("/api/v1/devboxes", tags=["devboxes"]) async def list_devboxes(_: Auth) -> DevboxList: return DevboxList(items=await manager.list()) diff --git a/controller/src/devboxes_controller/config.py b/controller/src/devboxes_controller/config.py index a99c7eb..6bf6602 100644 --- a/controller/src/devboxes_controller/config.py +++ b/controller/src/devboxes_controller/config.py @@ -1,12 +1,190 @@ """Validate controller configuration loaded from environment variables.""" +import re from functools import lru_cache -from typing import Literal, Self +from typing import Annotated, Literal, Self from urllib.parse import urlsplit -from pydantic import Field, SecretStr, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +GPU_PROFILE_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$") +DNS_LABEL_RE = re.compile(r"^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$") +QUALIFIED_NAME_PART_RE = re.compile(r"^[A-Za-z0-9](?:[-_.A-Za-z0-9]{0,61}[A-Za-z0-9])?$") +LABEL_VALUE_RE = re.compile(r"^(?:[A-Za-z0-9](?:[-_.A-Za-z0-9]{0,61}[A-Za-z0-9])?)?$") +SupplementalGroup = Annotated[int, Field(strict=True, ge=1, le=2_147_483_647)] + + +def _valid_dns_subdomain(value: str) -> bool: + """Return whether a string follows Kubernetes DNS subdomain syntax.""" + return 1 <= len(value) <= 253 and all( + DNS_LABEL_RE.fullmatch(label) for label in value.split(".") + ) + + +def _valid_qualified_name(value: str, *, require_prefix: bool) -> bool: + """Return whether a string follows Kubernetes qualified-name syntax.""" + if "/" in value: + prefix, name = value.split("/", 1) + return bool(_valid_dns_subdomain(prefix) and QUALIFIED_NAME_PART_RE.fullmatch(name)) + return not require_prefix and bool(QUALIFIED_NAME_PART_RE.fullmatch(value)) + + +class GpuToleration(BaseModel): + """Describe one operator-approved toleration applied to GPU workspaces.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True, frozen=True) + + key: str | None = Field(default=None, max_length=317) + operator: Literal["Equal", "Exists"] = "Equal" + value: str | None = Field(default=None, max_length=63) + effect: Literal["NoSchedule", "PreferNoSchedule", "NoExecute"] | None = None + toleration_seconds: int | None = Field( + default=None, + alias="tolerationSeconds", + ge=0, + le=2_147_483_647, + ) + + @field_validator("key") + @classmethod + def key_is_qualified(cls, value: str | None) -> str | None: + """Validate a non-empty Kubernetes label key when supplied.""" + if value is None or not value.strip(): + return None + value = value.strip() + if not _valid_qualified_name(value, require_prefix=False): + raise ValueError("must be a valid Kubernetes label key") + return value + + @field_validator("value") + @classmethod + def value_is_label_value(cls, value: str | None) -> str | None: + """Validate an optional Kubernetes label value.""" + if value is None: + return None + value = value.strip() + if not LABEL_VALUE_RE.fullmatch(value): + raise ValueError("must be a valid Kubernetes label value") + return value or None + + @model_validator(mode="after") + def fields_are_consistent(self) -> Self: + """Reject toleration combinations Kubernetes would treat ambiguously.""" + if self.operator == "Equal" and self.key is None: + raise ValueError("Equal tolerations require a key") + if self.operator == "Exists" and self.value is not None: + raise ValueError("Exists tolerations cannot set a value") + if self.toleration_seconds is not None and self.effect != "NoExecute": + raise ValueError("tolerationSeconds requires effect=NoExecute") + return self + + +class GpuProfile(BaseModel): + """Define one frozen, operator-approved GPU scheduling profile.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True, frozen=True) + + name: str = Field(min_length=1, max_length=40) + display_name: str = Field(alias="displayName", min_length=1, max_length=80) + description: str | None = Field(default=None, max_length=160) + resource_name: str = Field(alias="resourceName", min_length=3, max_length=317) + count: int = Field(ge=1, le=64) + workspace_image: str | None = Field(default=None, alias="workspaceImage", max_length=512) + runtime_class_name: str | None = Field( + default=None, + alias="runtimeClassName", + max_length=253, + ) + supplemental_groups: list[SupplementalGroup] = Field( + default_factory=list, + alias="supplementalGroups", + max_length=8, + ) + node_selector: dict[str, str] = Field(default_factory=dict, alias="nodeSelector") + tolerations: list[GpuToleration] = Field(default_factory=list, max_length=16) + + @field_validator("name") + @classmethod + def name_is_safe(cls, value: str) -> str: + """Require a compact profile identifier safe for API and CLI use.""" + value = value.strip().lower() + if not GPU_PROFILE_NAME_RE.fullmatch(value): + raise ValueError( + "use 1-40 lowercase letters, digits, or hyphens; start and end alphanumeric" + ) + return value + + @field_validator("display_name") + @classmethod + def display_name_is_not_blank(cls, value: str) -> str: + """Normalize the user-visible profile label.""" + value = value.strip() + if not value: + raise ValueError("must not be blank") + return value + + @field_validator("description", "workspace_image", mode="before") + @classmethod + def optional_text_is_normalized(cls, value: object) -> object: + """Trim optional profile text and treat blank values as absent.""" + if isinstance(value, str): + return value.strip() or None + return value + + @field_validator("resource_name") + @classmethod + def resource_name_is_extended(cls, value: str) -> str: + """Require the vendor-qualified resource exposed by a device driver.""" + value = value.strip() + if not _valid_qualified_name(value, require_prefix=True): + raise ValueError("must be a vendor-qualified Kubernetes extended resource") + return value + + @field_validator("workspace_image") + @classmethod + def workspace_image_is_valid(cls, value: str | None) -> str | None: + """Reject image values Kubernetes cannot interpret as references.""" + if value is not None and ( + any(character.isspace() for character in value) or "://" in value + ): + raise ValueError( + "must be a whitespace-free container image reference without a URL scheme" + ) + return value + + @field_validator("runtime_class_name") + @classmethod + def runtime_class_name_is_dns_safe(cls, value: str | None) -> str | None: + """Validate an optional existing Kubernetes RuntimeClass name.""" + if value is None or not value.strip(): + return None + value = value.strip() + if not _valid_dns_subdomain(value): + raise ValueError("must be a valid Kubernetes DNS subdomain") + return value + + @field_validator("supplemental_groups") + @classmethod + def supplemental_groups_are_unique(cls, value: list[int]) -> list[int]: + """Keep pod-level device group membership explicit and bounded.""" + if len(value) != len(set(value)): + raise ValueError("must not contain duplicate group IDs") + return value + + @field_validator("node_selector") + @classmethod + def node_selector_is_valid(cls, value: dict[str, str]) -> dict[str, str]: + """Validate bounded Kubernetes node selector terms.""" + if len(value) > 16: + raise ValueError("must contain at most 16 entries") + for key, label_value in value.items(): + if not _valid_qualified_name(key, require_prefix=False): + raise ValueError(f"invalid node selector key {key!r}") + if not LABEL_VALUE_RE.fullmatch(label_value): + raise ValueError(f"invalid node selector value for {key!r}") + return value + class Settings(BaseSettings): """Define validated runtime settings for one Devboxes installation.""" @@ -23,6 +201,9 @@ class Settings(BaseSettings): workspace_priority_class: str | None = None image_pull_secret: str | None = None storage_class: str | None = None + gpu_enabled: bool = False + gpu_default_profile: str | None = None + gpu_profiles: list[GpuProfile] = Field(default_factory=list, max_length=32) workspace_service_type: Literal["LoadBalancer", "NodePort"] = "LoadBalancer" workspace_service_host: str | None = None workspace_service_annotations: dict[str, str] = Field(default_factory=dict) @@ -79,6 +260,7 @@ def text_is_not_blank(cls, value: str) -> str: "kubeconfig_context", "cli_signing_key", "insights_signing_key", + "gpu_default_profile", mode="before", ) @classmethod @@ -171,8 +353,31 @@ def settings_are_consistent(self) -> Self: raise ValueError("Insights retention must satisfy rawDays <= hourlyDays <= dailyDays") if self.insights_max_compressed_bytes > self.insights_max_expanded_bytes: raise ValueError("Insights compressed limit cannot exceed expanded limit") + profile_names = [profile.name for profile in self.gpu_profiles] + if len(profile_names) != len(set(profile_names)): + raise ValueError("GPU profile names must be unique") + if self.gpu_default_profile is not None: + self.gpu_default_profile = self.gpu_default_profile.strip().lower() + if self.gpu_default_profile not in profile_names: + raise ValueError("gpu_default_profile must name a configured GPU profile") + if self.gpu_enabled: + if not profile_names: + raise ValueError("gpu_enabled requires at least one GPU profile") + if self.gpu_default_profile is None: + raise ValueError("gpu_enabled requires gpu_default_profile") return self + def resolve_gpu_profile(self, requested_name: str | None) -> GpuProfile: + """Resolve a user request to one trusted installation profile.""" + if not self.gpu_enabled: + raise ValueError("GPU acceleration is disabled by the operator") + profile_name = requested_name or self.gpu_default_profile + for profile in self.gpu_profiles: + if profile.name == profile_name: + return profile + available = ", ".join(profile.name for profile in self.gpu_profiles) + raise ValueError(f"unknown GPU profile {profile_name!r}; available profiles: {available}") + @lru_cache def get_settings() -> Settings: diff --git a/controller/src/devboxes_controller/manager.py b/controller/src/devboxes_controller/manager.py index 5ed2806..184534c 100644 --- a/controller/src/devboxes_controller/manager.py +++ b/controller/src/devboxes_controller/manager.py @@ -15,12 +15,13 @@ from kubernetes.utils.quantity import parse_quantity from .auth import Authenticator -from .config import Settings +from .config import GpuProfile, Settings from .models import ( CreateDevboxRequest, DeleteResult, Devbox, DevboxState, + GpuAllocation, InsightsState, Preset, ) @@ -28,6 +29,10 @@ ANNOTATION_AUTO_STOPPED_AT, ANNOTATION_CREATED_AT, ANNOTATION_EXPIRES_AT, + ANNOTATION_GPU_CONFIG, + ANNOTATION_GPU_COUNT, + ANNOTATION_GPU_PROFILE, + ANNOTATION_GPU_RESOURCE, ANNOTATION_INSIGHTS_STATE, ANNOTATION_INSIGHTS_TEMPLATE_HASH, ANNOTATION_INSTANCE_ID, @@ -97,6 +102,11 @@ async def create(self, request: CreateDevboxRequest) -> Devbox: """Create compute, SSH, and persistent storage for a devbox.""" if request.ttl_hours > self.settings.max_ttl_hours: raise ValueError(f"ttl_hours cannot exceed {self.settings.max_ttl_hours}") + gpu_profile = ( + self.settings.resolve_gpu_profile(request.gpu.profile) + if request.gpu is not None + else None + ) name = resource_name(request.name) if await self._deployment_exists(name): @@ -157,6 +167,7 @@ async def create(self, request: CreateDevboxRequest) -> Devbox: self.settings.workspace_service_account_name, self.settings.workspace_priority_class, self.settings.image_pull_secret, + gpu_profile=gpu_profile, instance_id=instance_id, insights_enabled=self.settings.insights_enabled, insights_endpoint=self.settings.insights_controller_url, @@ -555,6 +566,7 @@ async def _desired_insights_deployment(self, deployment: Any) -> tuple[dict[str, ), repository=annotations.get(ANNOTATION_REPOSITORY), ) + gpu_profile = _resolved_gpu_profile(annotations, self.settings) credential = self.authenticator.issue_insights_token(instance_id, name) secret_name = await self._ensure_insights_secret(name, instance_id, credential) desired = build_deployment( @@ -565,6 +577,7 @@ async def _desired_insights_deployment(self, deployment: Any) -> tuple[dict[str, self.settings.workspace_service_account_name, self.settings.workspace_priority_class, self.settings.image_pull_secret, + gpu_profile=gpu_profile, instance_id=instance_id, insights_enabled=True, insights_endpoint=self.settings.insights_controller_url, @@ -645,6 +658,7 @@ def _to_model(self, deployment: Any, service: Any | None, pod: Any | None) -> De restarts=restarts, storage_size=annotations.get(ANNOTATION_STORAGE, "20Gi"), message=message, + gpu=_gpu_allocation(annotations), instance_id=annotations.get(ANNOTATION_INSTANCE_ID), insights_state=_insights_state(self.settings.insights_enabled, deployment, desired), ) @@ -731,6 +745,20 @@ def _state( ) if phase == "Failed" or failure: return DevboxState.DEGRADED, failure or "Pod failed" + scheduling_condition = next( + ( + condition + for condition in (getattr(pod.status, "conditions", None) or []) + if getattr(condition, "type", None) == "PodScheduled" + and getattr(condition, "status", None) == "False" + ), + None, + ) + if scheduling_condition is not None: + detail = getattr(scheduling_condition, "message", None) + if detail: + return DevboxState.STARTING, f"Scheduling blocked: {str(detail)[:320]}" + return DevboxState.STARTING, "Waiting for a node with the requested resources" if pod_ready: return DevboxState.STARTING, "Waiting for an SSH service address" return DevboxState.STARTING, "Preparing workspace and SSH" @@ -744,6 +772,58 @@ def _valid_instance_id(value: str) -> bool: return parsed.version == 4 and str(parsed) == value +def _resolved_gpu_profile( + annotations: dict[str, str], + settings: Settings, +) -> GpuProfile | None: + """Recover the pinned GPU profile snapshot used by an existing workspace.""" + raw_profile = annotations.get(ANNOTATION_GPU_CONFIG) + if raw_profile: + try: + return GpuProfile.model_validate_json(raw_profile) + except ValueError as error: + raise ValueError("stored GPU profile configuration is invalid") from error + profile_name = annotations.get(ANNOTATION_GPU_PROFILE) + if profile_name: + for profile in settings.gpu_profiles: + if profile.name == profile_name: + return profile + raise ValueError( + f"stored GPU profile {profile_name!r} is unavailable and has no resolved snapshot" + ) + return None + + +def _gpu_allocation(annotations: dict[str, str]) -> GpuAllocation | None: + """Build a stable user-facing GPU allocation from Deployment annotations.""" + profile_name = annotations.get(ANNOTATION_GPU_PROFILE) + if not profile_name: + return None + raw_profile = annotations.get(ANNOTATION_GPU_CONFIG) + if raw_profile: + try: + profile = GpuProfile.model_validate_json(raw_profile) + except ValueError: + profile = None + if profile is not None: + return GpuAllocation( + profile=profile.name, + display_name=profile.display_name, + resource_name=profile.resource_name, + count=profile.count, + ) + try: + count = min(64, max(1, int(annotations.get(ANNOTATION_GPU_COUNT, "1")))) + except ValueError: + count = 1 + return GpuAllocation( + profile=profile_name, + display_name=profile_name, + resource_name=annotations.get(ANNOTATION_GPU_RESOURCE, "unknown"), + count=count, + ) + + def _has_insights_sidecar(deployment: Any) -> bool: containers = getattr( getattr(getattr(deployment, "spec", None), "template", None), diff --git a/controller/src/devboxes_controller/models.py b/controller/src/devboxes_controller/models.py index 8dfa4a5..d8175c5 100644 --- a/controller/src/devboxes_controller/models.py +++ b/controller/src/devboxes_controller/models.py @@ -37,6 +37,27 @@ class InsightsState(StrEnum): RESTART_REQUIRED = "restart_required" +class GpuRequest(BaseModel): + """Request an operator-approved GPU profile for one workspace.""" + + model_config = ConfigDict(extra="forbid") + + profile: str | None = Field(default=None, min_length=1, max_length=40) + + @field_validator("profile") + @classmethod + def valid_profile(cls, value: str | None) -> str | None: + """Normalize and validate a configured GPU profile name.""" + if value is None: + return None + value = value.strip().lower() + if not DEVBOX_NAME_RE.fullmatch(value): + raise ValueError( + "use 1-40 lowercase letters, digits, or hyphens; start and end alphanumeric" + ) + return value + + class CreateDevboxRequest(BaseModel): """Validate a request to create a devbox.""" @@ -46,6 +67,7 @@ class CreateDevboxRequest(BaseModel): preset: Preset = Preset.SMALL ttl_hours: int = Field(default=24, ge=1, le=168) repository: str | None = Field(default=None, max_length=240) + gpu: GpuRequest | None = None @field_validator("name") @classmethod @@ -70,6 +92,15 @@ def valid_repository(cls, value: str | None) -> str | None: return value +class GpuAllocation(BaseModel): + """Describe the pinned GPU allocation attached to a devbox.""" + + profile: str + display_name: str + resource_name: str + count: int + + class Devbox(BaseModel): """Represent the observable state of one managed devbox.""" @@ -87,6 +118,7 @@ class Devbox(BaseModel): restarts: int = 0 storage_size: str message: str | None = None + gpu: GpuAllocation | None = None instance_id: str | None = None insights_state: InsightsState = InsightsState.DISABLED @@ -104,6 +136,31 @@ class WhoAmI(BaseModel): mode: str +class GpuProfileSummary(BaseModel): + """Expose a safe user-facing view of one configured GPU profile.""" + + name: str + display_name: str + description: str | None = None + resource_name: str + count: int + default: bool = False + + +class GpuCapabilities(BaseModel): + """Describe the GPU feature and profiles available to API clients.""" + + enabled: bool + default_profile: str | None = None + profiles: list[GpuProfileSummary] + + +class Capabilities(BaseModel): + """Describe installation capabilities that shape user workflows.""" + + gpu: GpuCapabilities + + class CliTokenRequest(BaseModel): """Validate a native CLI authorization-code exchange.""" diff --git a/controller/src/devboxes_controller/resources.py b/controller/src/devboxes_controller/resources.py index 58969ec..b9b98a2 100644 --- a/controller/src/devboxes_controller/resources.py +++ b/controller/src/devboxes_controller/resources.py @@ -5,6 +5,7 @@ from datetime import UTC, datetime, timedelta from typing import Any +from .config import GpuProfile from .models import CreateDevboxRequest, Preset MANAGED_BY = "devboxes-controller" @@ -20,6 +21,10 @@ ANNOTATION_INSTANCE_ID = "insights.devboxes.bonalab.org/instance-id" ANNOTATION_INSIGHTS_STATE = "insights.devboxes.bonalab.org/state" ANNOTATION_INSIGHTS_TEMPLATE_HASH = "insights.devboxes.bonalab.org/template-hash" +ANNOTATION_GPU_PROFILE = "gpu.devboxes.bonalab.org/profile" +ANNOTATION_GPU_RESOURCE = "gpu.devboxes.bonalab.org/resource" +ANNOTATION_GPU_COUNT = "gpu.devboxes.bonalab.org/count" +ANNOTATION_GPU_CONFIG = "gpu.devboxes.bonalab.org/resolved-config" PRESETS: dict[Preset, dict[str, str]] = { @@ -64,6 +69,7 @@ def annotations( request: CreateDevboxRequest, now: datetime | None = None, instance_id: str | None = None, + gpu_profile: GpuProfile | None = None, ) -> dict[str, str]: """Return lifecycle and user-input annotations for a new devbox.""" now = now or datetime.now(UTC) @@ -78,6 +84,19 @@ def annotations( result[ANNOTATION_REPOSITORY] = request.repository if instance_id: result[ANNOTATION_INSTANCE_ID] = instance_id + if gpu_profile is not None: + result.update( + { + ANNOTATION_GPU_PROFILE: gpu_profile.name, + ANNOTATION_GPU_RESOURCE: gpu_profile.resource_name, + ANNOTATION_GPU_COUNT: str(gpu_profile.count), + ANNOTATION_GPU_CONFIG: json.dumps( + gpu_profile.model_dump(mode="json", by_alias=True, exclude_none=True), + sort_keys=True, + separators=(",", ":"), + ), + } + ) return result @@ -117,6 +136,7 @@ def build_deployment( workspace_service_account_name: str, workspace_priority_class: str | None = None, image_pull_secret: str | None = None, + gpu_profile: GpuProfile | None = None, now: datetime | None = None, instance_id: str | None = None, insights_enabled: bool = False, @@ -131,12 +151,32 @@ def build_deployment( """Build the disposable workspace Deployment for a devbox.""" name = resource_name(request.name) box_labels = labels(request.name) + effective_workspace_image = ( + gpu_profile.workspace_image + if gpu_profile is not None and gpu_profile.workspace_image is not None + else workspace_image + ) env = [ {"name": "DEVBOX_NAME", "value": request.name}, {"name": "DEVBOX_PRESET", "value": request.preset.value}, ] if request.repository: env.append({"name": "DEVBOX_REPOSITORY", "value": request.repository}) + if gpu_profile is not None: + env.extend( + [ + {"name": "DEVBOX_GPU_PROFILE", "value": gpu_profile.name}, + {"name": "DEVBOX_GPU_RESOURCE", "value": gpu_profile.resource_name}, + {"name": "DEVBOX_GPU_COUNT", "value": str(gpu_profile.count)}, + ] + ) + if gpu_profile.supplemental_groups: + env.append( + { + "name": "DEVBOX_GPU_SUPPLEMENTAL_GROUPS", + "value": ",".join(str(group) for group in gpu_profile.supplemental_groups), + } + ) if insights_enabled: env.extend( [ @@ -180,7 +220,7 @@ def build_deployment( "containers": [ { "name": "devbox", - "image": workspace_image, + "image": effective_workspace_image, "imagePullPolicy": "IfNotPresent", "ports": [{"name": "ssh", "containerPort": 2222, "protocol": "TCP"}], "env": env, @@ -255,6 +295,23 @@ def build_deployment( pod_spec["priorityClassName"] = workspace_priority_class if image_pull_secret: pod_spec["imagePullSecrets"] = [{"name": image_pull_secret}] + if gpu_profile is not None: + main_resources = pod_spec["containers"][0]["resources"] + main_resources["requests"][gpu_profile.resource_name] = gpu_profile.count + main_resources["limits"][gpu_profile.resource_name] = gpu_profile.count + if gpu_profile.runtime_class_name: + pod_spec["runtimeClassName"] = gpu_profile.runtime_class_name + if gpu_profile.supplemental_groups: + pod_spec["securityContext"]["supplementalGroups"] = list( + gpu_profile.supplemental_groups + ) + if gpu_profile.node_selector: + pod_spec["nodeSelector"] = dict(gpu_profile.node_selector) + if gpu_profile.tolerations: + pod_spec["tolerations"] = [ + toleration.model_dump(mode="json", by_alias=True, exclude_none=True) + for toleration in gpu_profile.tolerations + ] if insights_enabled: if not all((instance_id, insights_endpoint, insights_credential)): @@ -274,6 +331,8 @@ def build_deployment( pod_spec["containers"].append( { "name": "insights-agent", + # Keep the privacy boundary on the release workspace image. A + # profile image overrides only the interactive workspace. "image": workspace_image, "imagePullPolicy": "IfNotPresent", "command": ["python3", "/usr/local/bin/devbox-insights-agent"], @@ -315,10 +374,19 @@ def build_deployment( } ) - deployment_annotations = annotations(request, now, instance_id) + deployment_annotations = annotations(request, now, instance_id, gpu_profile) deployment_annotations[ANNOTATION_INSIGHTS_STATE] = ( "collecting" if insights_enabled else "disabled" ) + template_annotations = {ANNOTATION_INSTANCE_ID: instance_id} if instance_id is not None else {} + if gpu_profile is not None: + template_annotations.update( + { + ANNOTATION_GPU_PROFILE: gpu_profile.name, + ANNOTATION_GPU_RESOURCE: gpu_profile.resource_name, + ANNOTATION_GPU_COUNT: str(gpu_profile.count), + } + ) manifest: dict[str, Any] = { "apiVersion": "apps/v1", "kind": "Deployment", @@ -339,9 +407,7 @@ def build_deployment( "template": { "metadata": { "labels": box_labels, - "annotations": ( - {ANNOTATION_INSTANCE_ID: instance_id} if instance_id is not None else {} - ), + "annotations": template_annotations, }, "spec": pod_spec, }, diff --git a/controller/src/devboxes_controller/static/app.js b/controller/src/devboxes_controller/static/app.js index 0e60d2e..07cacc3 100644 --- a/controller/src/devboxes_controller/static/app.js +++ b/controller/src/devboxes_controller/static/app.js @@ -119,6 +119,10 @@ function renderBox(box) { nameCell.scope = "row"; nameCell.append(node("span", "devbox-name", box.name)); const details = [box.preset, box.storage_size]; + if (box.gpu) { + const unit = box.gpu.count === 1 ? "unit" : "units"; + details.push(`${box.gpu.display_name} · ${box.gpu.count} ${unit}`); + } if (box.repository) { details.push(box.repository); } @@ -265,6 +269,7 @@ elements.createForm.addEventListener("submit", async (event) => { preset: form.get("preset"), ttl_hours: Number(form.get("ttl_hours")), repository: form.get("repository") || null, + gpu: form.get("gpu_profile") ? { profile: form.get("gpu_profile") } : null, }; submit.disabled = true; submit.setAttribute("aria-busy", "true"); diff --git a/controller/src/devboxes_controller/static/styles.css b/controller/src/devboxes_controller/static/styles.css index 7bcec25..f91a37e 100644 --- a/controller/src/devboxes_controller/static/styles.css +++ b/controller/src/devboxes_controller/static/styles.css @@ -357,7 +357,7 @@ h3 { .create-form { display: grid; - grid-template-columns: minmax(9rem, 1fr) minmax(10rem, 0.9fr) minmax(9rem, 0.8fr) minmax(14rem, 1.5fr) auto; + grid-template-columns: minmax(9rem, 1fr) minmax(9rem, 0.8fr) minmax(9rem, 0.8fr) minmax(11rem, 1fr) minmax(14rem, 1.4fr) auto; align-items: end; gap: var(--space-3); } @@ -994,6 +994,11 @@ select:focus { color: var(--ink); } +.docs-article p code, +.docs-article li code { + white-space: nowrap; +} + .command-block { margin: var(--space-5) 0; overflow: hidden; @@ -1527,11 +1532,11 @@ kbd { @media (max-width: 70rem) { .create-form { - grid-template-columns: 1fr 1fr 1fr; + grid-template-columns: repeat(4, minmax(0, 1fr)); } .field-repository { - grid-column: span 2; + grid-column: span 3; } .docs-layout { @@ -1610,6 +1615,11 @@ kbd { grid-column: span 2; } + .create-button { + width: 100%; + grid-column: span 2; + } + .login-page { padding: 0; } @@ -1680,6 +1690,7 @@ kbd { .create-button { width: 100%; + grid-column: auto; } .devbox-table, @@ -1702,6 +1713,7 @@ kbd { .devbox-table td, .devbox-table tbody th, + .devbox-table th:first-child, .devbox-table td:first-child, .devbox-table td:last-child { width: 100%; diff --git a/controller/src/devboxes_controller/templates/cli_authorize.html b/controller/src/devboxes_controller/templates/cli_authorize.html index eb19465..f4b3a80 100644 --- a/controller/src/devboxes_controller/templates/cli_authorize.html +++ b/controller/src/devboxes_controller/templates/cli_authorize.html @@ -5,8 +5,8 @@ Authorize Devbox CLI · Devboxes - - + +
diff --git a/controller/src/devboxes_controller/templates/docs.html b/controller/src/devboxes_controller/templates/docs.html index aec90ab..1deef90 100644 --- a/controller/src/devboxes_controller/templates/docs.html +++ b/controller/src/devboxes_controller/templates/docs.html @@ -37,6 +37,7 @@ Quick start What persists Create a box + GPU acceleration SSH and tmux Accounts and tools Daily workflow @@ -223,6 +224,62 @@

Create the right box

+
+

Use an operator-approved GPU

+ {% if gpu.enabled %} +

+ This installation exposes {{ gpu.profiles | length }} GPU profile{% if gpu.profiles | length != 1 %}s{% endif %}. + CPU-only remains the default. In the CLI, --gpu selects the operator's default and + --gpu-profile selects an exact profile. The workbench offers the same catalog. +

+ +
+ + + + + + + + + + + {% for profile in gpu.profiles %} + + + + + + + {% endfor %} + +
ProfileAcceleratorAllocationPurpose
{{ profile.name }}{% if profile.default %} · default{% endif %}{{ profile.display_name }}{{ profile.count }} x {{ profile.resource_name }}{{ profile.description or "Operator-approved GPU workload" }}
+
+ +
+
+ Discover and request GPU profiles + +
+
devbox gpu profiles
+devbox create inference --gpu --ssh
+devbox create training --gpu-profile {{ gpu.profiles[0].name }} --preset large --ssh
+
+ +

+ The selected profile is pinned to the devbox Deployment, including its resource name, + count, image override, runtime class, supplemental groups, node selector, and tolerations. Stop and start + preserve that allocation; deleting and recreating lets you choose a different profile. +

+ {% else %} +

+ GPU acceleration is disabled on this installation. New boxes are CPU-only. An operator + can enable named profiles through the Helm chart after installing a compatible device + driver and container runtime on GPU nodes. +

+ {% endif %} +
+

Work through SSH and tmux

diff --git a/controller/src/devboxes_controller/templates/index.html b/controller/src/devboxes_controller/templates/index.html index 1a105fb..0f352d6 100644 --- a/controller/src/devboxes_controller/templates/index.html +++ b/controller/src/devboxes_controller/templates/index.html @@ -54,7 +54,9 @@

Development machines, on demand.

Create a devbox

Compute expires into a safe stopped state. Your volume is never deleted automatically.

- {{ storage_class }} storage · {{ workspace_service_type }} SSH · OCI images + + {{ storage_class }} storage · {{ workspace_service_type }} SSH{% if gpu.enabled %} · GPU profiles{% endif %} +
@@ -87,6 +89,19 @@

Create a devbox

{% endfor %} +
+ + +
diff --git a/controller/src/devboxes_controller/templates/login.html b/controller/src/devboxes_controller/templates/login.html index e05055e..7222688 100644 --- a/controller/src/devboxes_controller/templates/login.html +++ b/controller/src/devboxes_controller/templates/login.html @@ -5,8 +5,8 @@ Sign in · Devboxes - - + +
diff --git a/controller/tests/fakes.py b/controller/tests/fakes.py index 8f39a66..dcea097 100644 --- a/controller/tests/fakes.py +++ b/controller/tests/fakes.py @@ -8,6 +8,7 @@ DeleteResult, Devbox, DevboxState, + GpuAllocation, Preset, ) @@ -16,6 +17,7 @@ def sample_devbox( name: str = "atlas", state: DevboxState = DevboxState.READY, preset: Preset = Preset.MEDIUM, + gpu: GpuAllocation | None = None, ) -> Devbox: now = datetime.now(UTC) ready = state is DevboxState.READY @@ -41,13 +43,21 @@ def sample_devbox( else "Preparing workspace and SSH" ) ), + gpu=gpu, ) class FakeManager: def __init__(self) -> None: self.boxes = [ - sample_devbox(), + sample_devbox( + gpu=GpuAllocation( + profile="nvidia-l4", + display_name="NVIDIA L4", + resource_name="nvidia.com/gpu", + count=1, + ) + ), sample_devbox("paperclip", DevboxState.STOPPED, Preset.SMALL), sample_devbox("nightly", DevboxState.STARTING, Preset.LARGE), ] @@ -69,6 +79,18 @@ async def create(self, request: CreateDevboxRequest) -> Devbox: raise DevboxConflictError(f"devbox {request.name!r} already exists") box = sample_devbox(request.name, DevboxState.STARTING, request.preset) box.repository = request.repository + if request.gpu is not None: + profile = request.gpu.profile or "nvidia-l4" + display_name, resource_name = { + "nvidia-l4": ("NVIDIA L4", "nvidia.com/gpu"), + "nvidia-shared": ("NVIDIA shared", "nvidia.com/gpu.shared"), + }.get(profile, (profile, "nvidia.com/gpu")) + box.gpu = GpuAllocation( + profile=profile, + display_name=display_name, + resource_name=resource_name, + count=1, + ) box.ssh_host = None box.ssh_command = None box.pod_ready = False diff --git a/controller/tests/preview_app.py b/controller/tests/preview_app.py index cfc612c..82a76c7 100644 --- a/controller/tests/preview_app.py +++ b/controller/tests/preview_app.py @@ -1,5 +1,5 @@ from devboxes_controller.app import create_app -from devboxes_controller.config import Settings +from devboxes_controller.config import GpuProfile, Settings from .fakes import FakeManager @@ -7,5 +7,23 @@ access_token="preview-access-token-at-least-32-characters", cookie_secure=False, cleanup_interval_seconds=3600, + gpu_enabled=True, + gpu_default_profile="nvidia-l4", + gpu_profiles=[ + GpuProfile( + name="nvidia-l4", + displayName="NVIDIA L4", + description="One dedicated GPU for inference and CUDA development", + resourceName="nvidia.com/gpu", + count=1, + ), + GpuProfile( + name="nvidia-shared", + displayName="NVIDIA shared", + description="One time-sliced GPU allocation for interactive experiments", + resourceName="nvidia.com/gpu.shared", + count=1, + ), + ], ) app = create_app(settings, FakeManager()) # type: ignore[arg-type] diff --git a/controller/tests/test_app.py b/controller/tests/test_app.py index b22d701..08f8787 100644 --- a/controller/tests/test_app.py +++ b/controller/tests/test_app.py @@ -4,13 +4,13 @@ from devboxes_controller.app import create_app from devboxes_controller.auth import pkce_s256 -from devboxes_controller.config import Settings +from devboxes_controller.config import GpuProfile, Settings from .fakes import FakeManager -def app_client() -> TestClient: - settings = Settings( +def app_client(settings: Settings | None = None) -> TestClient: + settings = settings or Settings( access_token="test-access-token-at-least-32-characters", cookie_secure=False, cleanup_interval_seconds=3600, @@ -79,7 +79,7 @@ def test_browser_login_and_dashboard_session() -> None: assert dashboard.headers["x-content-type-options"] == "nosniff" assert "Kubernetes connected" in dashboard.text assert "cluster default storage" in dashboard.text - styles = client.get("/static/styles.css?v=0.3.0") + styles = client.get("/static/styles.css?v=0.4.0") assert "[hidden]" in styles.text assert "display: none !important" in styles.text payload = client.get("/api/v1/devboxes").json() @@ -192,6 +192,74 @@ def test_api_maps_conflicts_and_missing_names() -> None: def test_api_requires_authentication() -> None: with app_client() as client: assert client.get("/api/v1/devboxes").status_code == 401 + assert client.get("/api/v1/capabilities").status_code == 401 + + +def test_gpu_capabilities_expose_only_safe_profile_metadata() -> None: + settings = Settings( + access_token="test-access-token-at-least-32-characters", + cookie_secure=False, + cleanup_interval_seconds=3600, + gpu_enabled=True, + gpu_default_profile="nvidia-l4", + gpu_profiles=[ + GpuProfile( + name="nvidia-l4", + displayName="NVIDIA L4", + description="One dedicated inference GPU", + resourceName="nvidia.com/gpu", + count=1, + workspaceImage="private.example/devboxes-cuda:12.8", + runtimeClassName="nvidia", + supplementalGroups=[44], + ) + ], + ) + headers = {"Authorization": "Bearer test-access-token-at-least-32-characters"} + + with app_client(settings) as client: + response = client.get("/api/v1/capabilities", headers=headers) + created = client.post( + "/api/v1/devboxes", + headers=headers, + json={"name": "inference", "gpu": {"profile": "nvidia-l4"}}, + ) + browser_login(client) + dashboard = client.get("/") + documentation = client.get("/docs") + + assert response.status_code == 200 + assert response.json() == { + "gpu": { + "enabled": True, + "default_profile": "nvidia-l4", + "profiles": [ + { + "name": "nvidia-l4", + "display_name": "NVIDIA L4", + "description": "One dedicated inference GPU", + "resource_name": "nvidia.com/gpu", + "count": 1, + "default": True, + } + ], + } + } + assert "workspaceImage" not in response.text + assert "runtimeClassName" not in response.text + assert "supplementalGroups" not in response.text + assert created.status_code == 201 + assert created.json()["gpu"] == { + "profile": "nvidia-l4", + "display_name": "NVIDIA L4", + "resource_name": "nvidia.com/gpu", + "count": 1, + } + assert '