From 24791928d3f670dfa92241ead79bae6f60119ef4 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Thu, 19 Mar 2026 15:32:04 +0100 Subject: [PATCH 1/5] feat(bootstrap): switch GPU injection to CDI where supported Use an explicit CDI device request (driver="cdi", device_ids=["nvidia.com/gpu=all"]) when the Docker daemon reports CDI spec directories via GET /info (SystemInfo.CDISpecDirs). This makes device injection declarative and decouples spec generation from consumption. When the daemon reports no CDI spec directories, fall back to the legacy NVIDIA device request (driver="nvidia", count=-1) which relies on the NVIDIA Container Runtime hook. Failure modes for both paths are equivalent: a missing or stale NVIDIA Container Toolkit installation will cause container start to fail. CDI spec generation is out of scope for this change; specs are expected to be pre-generated out-of-band, for example by the NVIDIA Container Toolkit. Signed-off-by: Evan Lezar --- architecture/gateway-single-node.md | 8 ++- crates/openshell-bootstrap/src/docker.rs | 77 ++++++++++++++++++++---- crates/openshell-bootstrap/src/lib.rs | 10 +++ 3 files changed, 79 insertions(+), 16 deletions(-) diff --git a/architecture/gateway-single-node.md b/architecture/gateway-single-node.md index 57aebd3a..2dc8ea54 100644 --- a/architecture/gateway-single-node.md +++ b/architecture/gateway-single-node.md @@ -296,8 +296,10 @@ When environment variables are set, the entrypoint modifies the HelmChart manife GPU support is part of the single-node gateway bootstrap path rather than a separate architecture. -- `openshell gateway start --gpu` threads a boolean deploy option through `crates/openshell-cli`, `crates/openshell-bootstrap`, and `crates/openshell-bootstrap/src/docker.rs`. -- When enabled, the cluster container is created with Docker `DeviceRequests`, which is the API equivalent of `docker run --gpus all`. +- `openshell gateway start --gpu` threads GPU device options through `crates/openshell-cli`, `crates/openshell-bootstrap`, and `crates/openshell-bootstrap/src/docker.rs`. +- When enabled, the cluster container is created with Docker `DeviceRequests`. The injection mechanism is selected based on whether CDI is enabled on the daemon (`SystemInfo.CDISpecDirs` via `GET /info`): + - **CDI enabled** (daemon reports non-empty `CDISpecDirs`): CDI device injection — `driver="cdi"` with `nvidia.com/gpu=all`. Specs are expected to be pre-generated on the host (e.g. automatically by the `nvidia-cdi-refresh.service` or manually via `nvidia-ctk generate`). + - **CDI not enabled**: `--gpus all` device request — `driver="nvidia"`, `count=-1`, which relies on the NVIDIA Container Runtime hook. - `deploy/docker/Dockerfile.images` installs NVIDIA Container Toolkit packages in a dedicated Ubuntu stage and copies the runtime binaries, config, and `libnvidia-container` shared libraries into the final Ubuntu-based cluster image. - `deploy/docker/cluster-entrypoint.sh` checks `GPU_ENABLED=true` and copies GPU-only manifests from `/opt/openshell/gpu-manifests/` into k3s's manifests directory. - `deploy/kube/gpu-manifests/nvidia-device-plugin-helmchart.yaml` installs the NVIDIA device plugin chart, currently pinned to `0.18.2`. NFD and GFD are disabled; the device plugin's default `nodeAffinity` (which requires `feature.node.kubernetes.io/pci-10de.present=true` or `nvidia.com/gpu.present=true` from NFD/GFD) is overridden to empty so the DaemonSet schedules on the single-node cluster without requiring those labels. @@ -308,7 +310,7 @@ The runtime chain is: ```text Host GPU drivers & NVIDIA Container Toolkit - └─ Docker: --gpus all (DeviceRequests in bollard API) + └─ Docker: DeviceRequests (CDI when enabled, --gpus all otherwise) └─ k3s/containerd: nvidia-container-runtime on PATH -> auto-detected └─ k8s: nvidia-device-plugin DaemonSet advertises nvidia.com/gpu └─ Pods: request nvidia.com/gpu in resource limits diff --git a/crates/openshell-bootstrap/src/docker.rs b/crates/openshell-bootstrap/src/docker.rs index 9c365bfe..a287d053 100644 --- a/crates/openshell-bootstrap/src/docker.rs +++ b/crates/openshell-bootstrap/src/docker.rs @@ -22,6 +22,16 @@ use std::collections::HashMap; const REGISTRY_NAMESPACE_DEFAULT: &str = "openshell"; +/// Returns true if the Docker daemon has CDI enabled. +/// +/// CDI is considered enabled when the daemon reports at least one CDI spec +/// directory via `GET /info` (`SystemInfo.CDISpecDirs`). An empty list or a +/// missing field means CDI is not configured, and we fall back to the legacy +/// NVIDIA `DeviceRequest` (driver="nvidia"). +fn cdi_enabled(cdi_spec_dirs: Option<&[String]>) -> bool { + cdi_spec_dirs.is_some_and(|dirs| !dirs.is_empty()) +} + const REGISTRY_MODE_EXTERNAL: &str = "external"; fn env_non_empty(key: &str) -> Option { @@ -455,6 +465,7 @@ pub async fn ensure_container( registry_username: Option<&str>, registry_token: Option<&str>, gpu: bool, + cdi_enabled: bool, ) -> Result<()> { let container_name = container_name(name); @@ -542,20 +553,38 @@ pub async fn ensure_container( ..Default::default() }; - // When GPU support is requested, add NVIDIA device requests. - // This is the programmatic equivalent of `docker run --gpus all`. - // The NVIDIA Container Toolkit runtime hook injects /dev/nvidia* devices - // and GPU driver libraries from the host into the container. + // When GPU support is requested, inject GPU devices into the container. + // + // When the daemon reports CDI spec directories (SystemInfo.CDISpecDirs) we + // use an explicit CDI device request: driver="cdi" with the CDI-qualified + // device name "nvidia.com/gpu=all". Docker resolves this against the host + // CDI spec. This makes the injection declarative and decouples spec + // generation from spec consumption. + // + // When CDI is not enabled on the daemon, we fall back to the legacy NVIDIA + // device request (driver="nvidia", count=-1) which triggers the NVIDIA + // Container Runtime hook to inject /dev/nvidia* devices and driver libraries + // at container start. The failure modes for both paths are similar: if the + // NVIDIA Container Toolkit is not installed, or the CDI specs are + // stale/missing, the request will fail at container-start time. if gpu { - host_config.device_requests = Some(vec![DeviceRequest { - driver: Some("nvidia".to_string()), - count: Some(-1), // all GPUs - capabilities: Some(vec![vec![ - "gpu".to_string(), - "utility".to_string(), - "compute".to_string(), - ]]), - ..Default::default() + host_config.device_requests = Some(vec![if cdi_enabled { + DeviceRequest { + driver: Some("cdi".to_string()), + device_ids: Some(vec!["nvidia.com/gpu=all".to_string()]), + ..Default::default() + } + } else { + DeviceRequest { + driver: Some("nvidia".to_string()), + count: Some(-1), // all GPUs + capabilities: Some(vec![vec![ + "gpu".to_string(), + "utility".to_string(), + "compute".to_string(), + ]]), + ..Default::default() + } }]); } @@ -1195,4 +1224,26 @@ mod tests { "should return a reasonable number of sockets" ); } + + #[test] + fn cdi_enabled_with_spec_dirs() { + let dirs = vec!["/etc/cdi".to_string(), "/var/run/cdi".to_string()]; + assert!(cdi_enabled(Some(&dirs))); + } + + #[test] + fn cdi_enabled_with_single_spec_dir() { + let dirs = vec!["/etc/cdi".to_string()]; + assert!(cdi_enabled(Some(&dirs))); + } + + #[test] + fn cdi_enabled_with_empty_spec_dirs() { + assert!(!cdi_enabled(Some(&[]))); + } + + #[test] + fn cdi_enabled_with_none() { + assert!(!cdi_enabled(None)); + } } diff --git a/crates/openshell-bootstrap/src/lib.rs b/crates/openshell-bootstrap/src/lib.rs index 9098fd4a..4d098e10 100644 --- a/crates/openshell-bootstrap/src/lib.rs +++ b/crates/openshell-bootstrap/src/lib.rs @@ -288,6 +288,15 @@ where (preflight.docker, None) }; + // CDI support is detected via SystemInfo.CDISpecDirs (best-effort — failure + // is non-fatal and results in a legacy GPU injection fallback). + let cdi_supported = target_docker + .info() + .await + .ok() + .and_then(|info| info.cdi_spec_dirs) + .is_some_and(|dirs| !dirs.is_empty()); + // If an existing gateway is found, either tear it down (when recreate is // requested) or bail out so the caller can prompt the user / reuse it. if let Some(existing) = check_existing_gateway(&target_docker, &name).await? { @@ -417,6 +426,7 @@ where registry_username.as_deref(), registry_token.as_deref(), gpu, + cdi_supported, ) .await?; start_container(&target_docker, &name).await?; From 6e2f60e8be63ef4041506e978a13356a82c5163b Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Thu, 19 Mar 2026 22:56:21 +0100 Subject: [PATCH 2/5] feat(cli): extend --gpu flag to accept optional legacy argument The --gpu flag on `gateway start` now accepts an optional value: --gpu Auto-select: CDI on Docker >= 28.2.0, legacy otherwise --gpu=legacy Force the legacy nvidia DeviceRequest (driver="nvidia") Internally, the gpu bool parameter to ensure_container is replaced with a device_ids slice. resolve_gpu_device_ids resolves the "auto" sentinel to a concrete device ID list based on the Docker daemon version, keeping the resolution logic in one place at deploy time. Signed-off-by: Evan Lezar --- architecture/gateway-single-node.md | 9 ++ crates/openshell-bootstrap/src/docker.rs | 121 ++++++++++++++++++----- crates/openshell-bootstrap/src/lib.rs | 28 ++++-- crates/openshell-cli/src/bootstrap.rs | 6 +- crates/openshell-cli/src/main.rs | 19 +++- crates/openshell-cli/src/run.rs | 2 +- docs/sandboxes/manage-gateways.md | 2 +- 7 files changed, 145 insertions(+), 42 deletions(-) diff --git a/architecture/gateway-single-node.md b/architecture/gateway-single-node.md index 2dc8ea54..37cd284f 100644 --- a/architecture/gateway-single-node.md +++ b/architecture/gateway-single-node.md @@ -316,6 +316,15 @@ Host GPU drivers & NVIDIA Container Toolkit └─ Pods: request nvidia.com/gpu in resource limits ``` +### `--gpu` flag + +The `--gpu` flag on `gateway start` accepts an optional value that overrides the automatic injection mode: + +| Invocation | Behaviour | +|---|---| +| `--gpu` | Auto-select: CDI when enabled on the daemon, `--gpus all` otherwise | +| `--gpu=legacy` | Force `--gpus all` | + The expected smoke test is a plain pod requesting `nvidia.com/gpu: 1` with `runtimeClassName: nvidia` and running `nvidia-smi`. ## Remote Image Transfer diff --git a/crates/openshell-bootstrap/src/docker.rs b/crates/openshell-bootstrap/src/docker.rs index a287d053..4eba9359 100644 --- a/crates/openshell-bootstrap/src/docker.rs +++ b/crates/openshell-bootstrap/src/docker.rs @@ -32,6 +32,29 @@ fn cdi_enabled(cdi_spec_dirs: Option<&[String]>) -> bool { cdi_spec_dirs.is_some_and(|dirs| !dirs.is_empty()) } +/// Resolve the raw GPU device-ID list, replacing the `"auto"` sentinel with a +/// concrete device ID based on whether CDI is enabled on the daemon. +/// +/// | Input | Output | +/// |--------------|--------------------------------------------------------------| +/// | `[]` | `[]` — no GPU | +/// | `["legacy"]` | `["legacy"]` — pass through | +/// | `["auto"]` | `["nvidia.com/gpu=all"]` if CDI enabled, else `["legacy"]` | +/// | `[cdi-ids…]` | unchanged | +pub(crate) fn resolve_gpu_device_ids(gpu: &[String], cdi_enabled: bool) -> Vec { + match gpu { + [] => vec![], + [v] if v == "auto" => { + if cdi_enabled { + vec!["nvidia.com/gpu=all".to_string()] + } else { + vec!["legacy".to_string()] + } + } + other => other.to_vec(), + } +} + const REGISTRY_MODE_EXTERNAL: &str = "external"; fn env_non_empty(key: &str) -> Option { @@ -464,8 +487,7 @@ pub async fn ensure_container( disable_gateway_auth: bool, registry_username: Option<&str>, registry_token: Option<&str>, - gpu: bool, - cdi_enabled: bool, + device_ids: &[String], ) -> Result<()> { let container_name = container_name(name); @@ -553,29 +575,18 @@ pub async fn ensure_container( ..Default::default() }; - // When GPU support is requested, inject GPU devices into the container. - // - // When the daemon reports CDI spec directories (SystemInfo.CDISpecDirs) we - // use an explicit CDI device request: driver="cdi" with the CDI-qualified - // device name "nvidia.com/gpu=all". Docker resolves this against the host - // CDI spec. This makes the injection declarative and decouples spec - // generation from spec consumption. + // Inject GPU devices into the container based on the resolved device ID list. // - // When CDI is not enabled on the daemon, we fall back to the legacy NVIDIA - // device request (driver="nvidia", count=-1) which triggers the NVIDIA - // Container Runtime hook to inject /dev/nvidia* devices and driver libraries - // at container start. The failure modes for both paths are similar: if the - // NVIDIA Container Toolkit is not installed, or the CDI specs are - // stale/missing, the request will fail at container-start time. - if gpu { - host_config.device_requests = Some(vec![if cdi_enabled { - DeviceRequest { - driver: Some("cdi".to_string()), - device_ids: Some(vec!["nvidia.com/gpu=all".to_string()]), - ..Default::default() - } - } else { - DeviceRequest { + // The list is pre-resolved by `resolve_gpu_device_ids` before reaching here: + // [] — no GPU passthrough + // ["legacy"] — legacy nvidia DeviceRequest (driver="nvidia", count=-1); + // relies on the NVIDIA Container Runtime hook + // [cdi-ids…] — CDI DeviceRequest (driver="cdi") with the given device IDs; + // Docker resolves them against the host CDI spec at /etc/cdi/ + match device_ids { + [] => {} + [id] if id == "legacy" => { + host_config.device_requests = Some(vec![DeviceRequest { driver: Some("nvidia".to_string()), count: Some(-1), // all GPUs capabilities: Some(vec![vec![ @@ -584,8 +595,15 @@ pub async fn ensure_container( "compute".to_string(), ]]), ..Default::default() - } - }]); + }]); + } + ids => { + host_config.device_requests = Some(vec![DeviceRequest { + driver: Some("cdi".to_string()), + device_ids: Some(ids.to_vec()), + ..Default::default() + }]); + } } let mut cmd = vec![ @@ -700,7 +718,7 @@ pub async fn ensure_container( // GPU support: tell the entrypoint to deploy the NVIDIA device plugin // HelmChart CR so k8s workloads can request nvidia.com/gpu resources. - if gpu { + if !device_ids.is_empty() { env_vars.push("GPU_ENABLED=true".to_string()); } @@ -1246,4 +1264,53 @@ mod tests { fn cdi_enabled_with_none() { assert!(!cdi_enabled(None)); } + + // --- resolve_gpu_device_ids --- + + #[test] + fn resolve_gpu_empty_returns_empty() { + assert_eq!(resolve_gpu_device_ids(&[], true), Vec::::new()); + assert_eq!(resolve_gpu_device_ids(&[], false), Vec::::new()); + } + + #[test] + fn resolve_gpu_auto_cdi_enabled() { + assert_eq!( + resolve_gpu_device_ids(&["auto".to_string()], true), + vec!["nvidia.com/gpu=all"], + ); + } + + #[test] + fn resolve_gpu_auto_cdi_disabled() { + assert_eq!( + resolve_gpu_device_ids(&["auto".to_string()], false), + vec!["legacy"], + ); + } + + #[test] + fn resolve_gpu_legacy_passthrough() { + assert_eq!( + resolve_gpu_device_ids(&["legacy".to_string()], true), + vec!["legacy"], + ); + assert_eq!( + resolve_gpu_device_ids(&["legacy".to_string()], false), + vec!["legacy"], + ); + } + + #[test] + fn resolve_gpu_cdi_ids_passthrough() { + let ids = vec!["nvidia.com/gpu=all".to_string()]; + assert_eq!(resolve_gpu_device_ids(&ids, true), ids); + assert_eq!(resolve_gpu_device_ids(&ids, false), ids); + + let multi = vec![ + "nvidia.com/gpu=0".to_string(), + "nvidia.com/gpu=1".to_string(), + ]; + assert_eq!(resolve_gpu_device_ids(&multi, true), multi); + } } diff --git a/crates/openshell-bootstrap/src/lib.rs b/crates/openshell-bootstrap/src/lib.rs index 4d098e10..9c8ebe37 100644 --- a/crates/openshell-bootstrap/src/lib.rs +++ b/crates/openshell-bootstrap/src/lib.rs @@ -31,7 +31,8 @@ use crate::constants::{ }; use crate::docker::{ check_existing_gateway, check_port_conflicts, destroy_gateway_resources, ensure_container, - ensure_image, ensure_network, ensure_volume, start_container, stop_container, + ensure_image, ensure_network, ensure_volume, resolve_gpu_device_ids, start_container, + stop_container, }; use crate::metadata::{ create_gateway_metadata, create_gateway_metadata_with_host, local_gateway_host, @@ -111,10 +112,13 @@ pub struct DeployOptions { /// bootstrap pull and inside the k3s cluster at runtime. Only needed /// for private registries. pub registry_token: Option, - /// Enable NVIDIA GPU passthrough. When true, the Docker container is - /// created with GPU device requests (`--gpus all`) and the NVIDIA - /// k8s-device-plugin is deployed inside the k3s cluster. - pub gpu: bool, + /// GPU device IDs to inject into the gateway container. + /// + /// - `[]` — no GPU passthrough (default) + /// - `["legacy"]` — legacy nvidia DeviceRequest (driver="nvidia", count=-1) + /// - `["auto"]` — resolved at deploy time: CDI if enabled on the daemon, else legacy + /// - `[cdi-ids…]` — CDI DeviceRequest with the given device IDs + pub gpu: Vec, /// When true, destroy any existing gateway resources before deploying. /// When false, an existing gateway is left as-is and deployment is /// skipped (the caller is responsible for prompting the user first). @@ -133,7 +137,7 @@ impl DeployOptions { disable_gateway_auth: false, registry_username: None, registry_token: None, - gpu: false, + gpu: vec![], recreate: false, } } @@ -187,9 +191,13 @@ impl DeployOptions { self } - /// Enable NVIDIA GPU passthrough for the cluster container. + /// Set GPU device IDs for the cluster container. + /// + /// Pass `vec!["auto"]` to auto-select between CDI and legacy based on Docker + /// version at deploy time, or an explicit list of CDI device IDs, or + /// `vec!["legacy"]` to force the legacy nvidia DeviceRequest. #[must_use] - pub fn with_gpu(mut self, gpu: bool) -> Self { + pub fn with_gpu(mut self, gpu: Vec) -> Self { self.gpu = gpu; self } @@ -414,6 +422,7 @@ where // leaving an orphaned volume in a corrupted state that blocks retries. // See: https://github.com/NVIDIA/OpenShell/issues/463 let deploy_result: Result = async { + let device_ids = resolve_gpu_device_ids(&gpu, cdi_supported); ensure_container( &target_docker, &name, @@ -425,8 +434,7 @@ where disable_gateway_auth, registry_username.as_deref(), registry_token.as_deref(), - gpu, - cdi_supported, + &device_ids, ) .await?; start_container(&target_docker, &name).await?; diff --git a/crates/openshell-cli/src/bootstrap.rs b/crates/openshell-cli/src/bootstrap.rs index e976061f..ea6410b9 100644 --- a/crates/openshell-cli/src/bootstrap.rs +++ b/crates/openshell-cli/src/bootstrap.rs @@ -178,7 +178,11 @@ pub async fn run_bootstrap( { options = options.with_gateway_host(host); } - options = options.with_gpu(gpu); + options = options.with_gpu(if gpu { + vec!["auto".to_string()] + } else { + vec![] + }); let handle = deploy_gateway_with_panel(options, &gateway_name, location).await?; let server = handle.gateway_endpoint().to_string(); diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 3799b392..6cd957c9 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -807,8 +807,13 @@ enum GatewayCommands { /// NVIDIA k8s-device-plugin so Kubernetes workloads can request /// `nvidia.com/gpu` resources. Requires NVIDIA drivers and the /// NVIDIA Container Toolkit on the host. - #[arg(long)] - gpu: bool, + /// + /// An optional argument controls the injection mode: + /// + /// --gpu Auto-select: CDI when enabled on the daemon, legacy otherwise + /// --gpu=legacy Force legacy nvidia DeviceRequest + #[arg(long = "gpu", num_args = 0..=1, default_missing_value = "auto", value_name = "MODE")] + gpu: Option, }, /// Stop the gateway (preserves state). @@ -1562,6 +1567,16 @@ async fn main() -> Result<()> { registry_token, gpu, } => { + let gpu = match gpu.as_deref() { + None => vec![], + Some("auto") => vec!["auto".to_string()], + Some("legacy") => vec!["legacy".to_string()], + Some(other) => { + return Err(miette::miette!( + "unknown --gpu value: {other:?}; expected `legacy`" + )); + } + }; run::gateway_admin_deploy( &name, remote.as_deref(), diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 6d6b6b89..d5224bee 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1355,7 +1355,7 @@ pub async fn gateway_admin_deploy( disable_gateway_auth: bool, registry_username: Option<&str>, registry_token: Option<&str>, - gpu: bool, + gpu: Vec, ) -> Result<()> { let location = if remote.is_some() { "remote" } else { "local" }; diff --git a/docs/sandboxes/manage-gateways.md b/docs/sandboxes/manage-gateways.md index 2f3dba7a..bfddea9c 100644 --- a/docs/sandboxes/manage-gateways.md +++ b/docs/sandboxes/manage-gateways.md @@ -168,7 +168,7 @@ $ openshell gateway info --name my-remote-cluster | Flag | Purpose | |---|---| -| `--gpu` | Enable NVIDIA GPU passthrough. Requires NVIDIA drivers and the Container Toolkit on the host. | +| `--gpu` | Enable NVIDIA GPU passthrough. Requires NVIDIA drivers and the Container Toolkit on the host. Accepts an optional value: omit for auto-select (CDI when enabled on the daemon, `--gpus all` otherwise), or `--gpu=legacy` to force `--gpus all`. | | `--plaintext` | Listen on HTTP instead of mTLS. Use behind a TLS-terminating reverse proxy. | | `--disable-gateway-auth` | Skip mTLS client certificate checks. Use when a reverse proxy cannot forward client certs. | | `--registry-username` | Username for registry authentication. Defaults to `__token__` when `--registry-token` is set. Only needed for private registries. Also configurable with `OPENSHELL_REGISTRY_USERNAME`. | From 153bda0ba68670e11316aa7f6cc5c30481d2e678 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Thu, 19 Mar 2026 22:58:49 +0100 Subject: [PATCH 3/5] feat(cli): support explicit CDI device names via --gpu Explicit CDI device IDs can now be passed: --gpu=nvidia.com/gpu=all single CDI device --gpu=nvidia.com/gpu=0 --gpu=nvidia.com/gpu=1 multiple CDI devices parse_gpu_flag validates the input and rejects mixing legacy/auto with CDI device names or specifying them more than once. Signed-off-by: Evan Lezar --- architecture/gateway-single-node.md | 3 + crates/openshell-cli/src/main.rs | 107 ++++++++++++++++++++++++---- docs/sandboxes/manage-gateways.md | 2 +- 3 files changed, 97 insertions(+), 15 deletions(-) diff --git a/architecture/gateway-single-node.md b/architecture/gateway-single-node.md index 37cd284f..5d977b37 100644 --- a/architecture/gateway-single-node.md +++ b/architecture/gateway-single-node.md @@ -324,6 +324,9 @@ The `--gpu` flag on `gateway start` accepts an optional value that overrides the |---|---| | `--gpu` | Auto-select: CDI when enabled on the daemon, `--gpus all` otherwise | | `--gpu=legacy` | Force `--gpus all` | +| `--gpu=` | Inject a specific CDI device (e.g. `nvidia.com/gpu=all`). May be repeated for multiple devices. Note: because the cluster container runs privileged, device-level isolation may not work as expected. | + +Mixing `legacy` or auto-select with explicit CDI device names in the same invocation is an error. The expected smoke test is a plain pod requesting `nvidia.com/gpu: 1` with `runtimeClassName: nvidia` and running `nvidia-smi`. diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 6cd957c9..32c0cab5 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -810,10 +810,19 @@ enum GatewayCommands { /// /// An optional argument controls the injection mode: /// - /// --gpu Auto-select: CDI when enabled on the daemon, legacy otherwise - /// --gpu=legacy Force legacy nvidia DeviceRequest - #[arg(long = "gpu", num_args = 0..=1, default_missing_value = "auto", value_name = "MODE")] - gpu: Option, + /// --gpu Auto-select: CDI when enabled on the daemon, legacy otherwise + /// --gpu=legacy Force legacy nvidia DeviceRequest (specify once only) + /// --gpu= Use explicit CDI device name (repeatable) + /// + /// Example CDI device names: `nvidia.com/gpu=all`, `nvidia.com/gpu=0` + #[arg( + long = "gpu", + num_args = 0..=1, + default_missing_value = "auto", + action = clap::ArgAction::Append, + value_name = "MODE", + )] + gpu: Vec, }, /// Stop the gateway (preserves state). @@ -1519,6 +1528,29 @@ enum ForwardCommands { List, } +/// Validate and normalise the raw values collected from `--gpu`. +/// +/// | Input | Output | +/// |-------------------|---------------------------------| +/// | `[]` | `[]` — no GPU | +/// | `["auto"]` | `["auto"]` — resolve at deploy | +/// | `["legacy"]` | `["legacy"]` | +/// | `[cdi-ids…]` | `[cdi-ids…]` | +/// +/// Returns an error when `legacy` or `auto` is mixed with other values, or +/// appears more than once. +fn parse_gpu_flag(values: &[String]) -> Result> { + match values { + [] => Ok(vec![]), + [v] if v == "auto" || v == "legacy" => Ok(values.to_vec()), + ids if ids.iter().all(|v| v != "auto" && v != "legacy") => Ok(ids.to_vec()), + _ => Err(miette::miette!( + "--gpu=legacy and --gpu=auto can only be specified once \ + and cannot be mixed with CDI device names" + )), + } +} + #[tokio::main] async fn main() -> Result<()> { // Install the rustls crypto provider before completion runs — completers may @@ -1567,16 +1599,7 @@ async fn main() -> Result<()> { registry_token, gpu, } => { - let gpu = match gpu.as_deref() { - None => vec![], - Some("auto") => vec!["auto".to_string()], - Some("legacy") => vec!["legacy".to_string()], - Some(other) => { - return Err(miette::miette!( - "unknown --gpu value: {other:?}; expected `legacy`" - )); - } - }; + let gpu = parse_gpu_flag(&gpu)?; run::gateway_admin_deploy( &name, remote.as_deref(), @@ -3130,4 +3153,60 @@ mod tests { other => panic!("expected settings delete command, got: {other:?}"), } } + + // --- parse_gpu_flag --- + + #[test] + fn parse_gpu_empty_returns_empty() { + assert_eq!(parse_gpu_flag(&[]).unwrap(), Vec::::new()); + } + + #[test] + fn parse_gpu_auto_accepted() { + assert_eq!(parse_gpu_flag(&["auto".to_string()]).unwrap(), vec!["auto"]); + } + + #[test] + fn parse_gpu_legacy_accepted() { + assert_eq!( + parse_gpu_flag(&["legacy".to_string()]).unwrap(), + vec!["legacy"] + ); + } + + #[test] + fn parse_gpu_cdi_device_ids_accepted() { + assert_eq!( + parse_gpu_flag(&["nvidia.com/gpu=all".to_string()]).unwrap(), + vec!["nvidia.com/gpu=all"], + ); + assert_eq!( + parse_gpu_flag(&[ + "nvidia.com/gpu=0".to_string(), + "nvidia.com/gpu=1".to_string() + ]) + .unwrap(), + vec!["nvidia.com/gpu=0", "nvidia.com/gpu=1"], + ); + } + + #[test] + fn parse_gpu_legacy_mixed_with_cdi_is_error() { + assert!(parse_gpu_flag(&["legacy".to_string(), "nvidia.com/gpu=all".to_string()]).is_err()); + } + + #[test] + fn parse_gpu_auto_mixed_with_cdi_is_error() { + assert!(parse_gpu_flag(&["auto".to_string(), "nvidia.com/gpu=all".to_string()]).is_err()); + } + + #[test] + fn parse_gpu_double_legacy_is_error() { + assert!(parse_gpu_flag(&["legacy".to_string(), "legacy".to_string()]).is_err()); + } + + #[test] + fn parse_gpu_double_auto_is_error() { + assert!(parse_gpu_flag(&["auto".to_string(), "auto".to_string()]).is_err()); + } } diff --git a/docs/sandboxes/manage-gateways.md b/docs/sandboxes/manage-gateways.md index bfddea9c..e36592da 100644 --- a/docs/sandboxes/manage-gateways.md +++ b/docs/sandboxes/manage-gateways.md @@ -168,7 +168,7 @@ $ openshell gateway info --name my-remote-cluster | Flag | Purpose | |---|---| -| `--gpu` | Enable NVIDIA GPU passthrough. Requires NVIDIA drivers and the Container Toolkit on the host. Accepts an optional value: omit for auto-select (CDI when enabled on the daemon, `--gpus all` otherwise), or `--gpu=legacy` to force `--gpus all`. | +| `--gpu` | Enable NVIDIA GPU passthrough. Requires NVIDIA drivers and the Container Toolkit on the host. Accepts an optional value: omit for auto-select (CDI when enabled on the daemon, `--gpus all` otherwise), `--gpu=legacy` to force `--gpus all`, or `--gpu=` to inject a specific CDI device (e.g. `nvidia.com/gpu=all`). May be repeated for multiple CDI devices. | | `--plaintext` | Listen on HTTP instead of mTLS. Use behind a TLS-terminating reverse proxy. | | `--disable-gateway-auth` | Skip mTLS client certificate checks. Use when a reverse proxy cannot forward client certs. | | `--registry-username` | Username for registry authentication. Defaults to `__token__` when `--registry-token` is set. Only needed for private registries. Also configurable with `OPENSHELL_REGISTRY_USERNAME`. | From 9321f0f8eb95278e97a4f7164c11798fffadd1e8 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Thu, 19 Mar 2026 22:59:29 +0100 Subject: [PATCH 4/5] feat(cli): add --device as an alias for --gpu Signed-off-by: Evan Lezar --- architecture/gateway-single-node.md | 4 ++-- crates/openshell-cli/src/main.rs | 3 ++- docs/sandboxes/manage-gateways.md | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/architecture/gateway-single-node.md b/architecture/gateway-single-node.md index 5d977b37..9a8ef9c0 100644 --- a/architecture/gateway-single-node.md +++ b/architecture/gateway-single-node.md @@ -316,9 +316,9 @@ Host GPU drivers & NVIDIA Container Toolkit └─ Pods: request nvidia.com/gpu in resource limits ``` -### `--gpu` flag +### `--gpu` / `--device` flag -The `--gpu` flag on `gateway start` accepts an optional value that overrides the automatic injection mode: +The `--gpu` flag (aliased as `--device`) on `gateway start` accepts an optional value that overrides the automatic injection mode: | Invocation | Behaviour | |---|---| diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 32c0cab5..b21e1c13 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -808,7 +808,7 @@ enum GatewayCommands { /// `nvidia.com/gpu` resources. Requires NVIDIA drivers and the /// NVIDIA Container Toolkit on the host. /// - /// An optional argument controls the injection mode: + /// An optional argument controls the injection mode (`--device` is an alias): /// /// --gpu Auto-select: CDI when enabled on the daemon, legacy otherwise /// --gpu=legacy Force legacy nvidia DeviceRequest (specify once only) @@ -817,6 +817,7 @@ enum GatewayCommands { /// Example CDI device names: `nvidia.com/gpu=all`, `nvidia.com/gpu=0` #[arg( long = "gpu", + alias = "device", num_args = 0..=1, default_missing_value = "auto", action = clap::ArgAction::Append, diff --git a/docs/sandboxes/manage-gateways.md b/docs/sandboxes/manage-gateways.md index e36592da..462f20db 100644 --- a/docs/sandboxes/manage-gateways.md +++ b/docs/sandboxes/manage-gateways.md @@ -168,7 +168,7 @@ $ openshell gateway info --name my-remote-cluster | Flag | Purpose | |---|---| -| `--gpu` | Enable NVIDIA GPU passthrough. Requires NVIDIA drivers and the Container Toolkit on the host. Accepts an optional value: omit for auto-select (CDI when enabled on the daemon, `--gpus all` otherwise), `--gpu=legacy` to force `--gpus all`, or `--gpu=` to inject a specific CDI device (e.g. `nvidia.com/gpu=all`). May be repeated for multiple CDI devices. | +| `--gpu` / `--device` | Enable NVIDIA GPU passthrough. Requires NVIDIA drivers and the Container Toolkit on the host. Accepts an optional value: omit for auto-select (CDI when enabled on the daemon, `--gpus all` otherwise), `--gpu=legacy` to force `--gpus all`, or `--gpu=` to inject a specific CDI device (e.g. `nvidia.com/gpu=all`). May be repeated for multiple CDI devices. | | `--plaintext` | Listen on HTTP instead of mTLS. Use behind a TLS-terminating reverse proxy. | | `--disable-gateway-auth` | Skip mTLS client certificate checks. Use when a reverse proxy cannot forward client certs. | | `--registry-username` | Username for registry authentication. Defaults to `__token__` when `--registry-token` is set. Only needed for private registries. Also configurable with `OPENSHELL_REGISTRY_USERNAME`. | From 501b0c162a513ccbf8078ef7092c6b79f3fde845 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 20 Mar 2026 09:48:36 +0100 Subject: [PATCH 5/5] test(e2e): extend gateway start help smoke test to cover key flags Signed-off-by: Evan Lezar --- e2e/rust/tests/cli_smoke.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/e2e/rust/tests/cli_smoke.rs b/e2e/rust/tests/cli_smoke.rs index 35b2801c..0abc24b4 100644 --- a/e2e/rust/tests/cli_smoke.rs +++ b/e2e/rust/tests/cli_smoke.rs @@ -122,17 +122,22 @@ async fn sandbox_connect_help_shows_editor_flag() { ); } -/// `openshell gateway start --help` must show `--recreate`. +/// `openshell gateway start --help` must show key flags. #[tokio::test] -async fn gateway_start_help_shows_recreate() { +async fn gateway_start_help_shows_key_flags() { let (output, code) = run_isolated(&["gateway", "start", "--help"]).await; assert_eq!(code, 0, "openshell gateway start --help should exit 0"); let clean = strip_ansi(&output); - assert!( - clean.contains("--recreate"), - "expected '--recreate' in gateway start --help:\n{clean}" - ); + for flag in [ + "--gpu", + "--recreate", + ] { + assert!( + clean.contains(flag), + "expected '{flag}' in gateway start --help:\n{clean}" + ); + } } // -------------------------------------------------------------------