Skip to content

Commit 896b2c7

Browse files
committed
feat(docker): support GPU sandboxes
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
1 parent 5975805 commit 896b2c7

11 files changed

Lines changed: 432 additions & 29 deletions

File tree

.agents/skills/openshell-cli/cli-reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ Provision or start a cluster (local or remote).
8585
| `--port <PORT>` | 8080 | Host port mapped to gateway |
8686
| `--gateway-host <HOST>` | none | Override gateway host in metadata |
8787
| `--recreate` | false | Destroy and recreate from scratch if a gateway already exists (skips interactive prompt) |
88+
| `--gpu` | false | Force NVIDIA GPU passthrough |
89+
| `--no-gpu` | false | Disable automatic NVIDIA GPU passthrough detection |
8890

8991
### `openshell gateway stop`
9092

architecture/gateway-single-node.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,8 @@ When environment variables are set, the entrypoint modifies the HelmChart manife
297297

298298
GPU support is part of the single-node gateway bootstrap path rather than a separate architecture.
299299

300-
- `openshell gateway start --gpu` threads GPU device options through `crates/openshell-cli`, `crates/openshell-bootstrap`, and `crates/openshell-bootstrap/src/docker.rs`.
300+
- `openshell gateway start` auto-detects GPU support and threads GPU device options through `crates/openshell-cli`, `crates/openshell-bootstrap`, and `crates/openshell-bootstrap/src/docker.rs`. Users can force passthrough with `--gpu` or disable auto-detection with `--no-gpu`.
301+
- Auto-detection enables passthrough when Docker reports NVIDIA CDI devices. For local non-CDI hosts, it also enables passthrough when `/dev/nvidia*` devices exist and Docker reports the NVIDIA runtime. Remote legacy-runtime hosts still require explicit `--gpu`.
301302
- 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`):
302303
- **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`).
303304
- **CDI not enabled**: `--gpus all` device request — `driver="nvidia"`, `count=-1`, which relies on the NVIDIA Container Runtime hook.
@@ -317,9 +318,11 @@ Host GPU drivers & NVIDIA Container Toolkit
317318
└─ Pods: request nvidia.com/gpu in resource limits (CDI injection — no runtimeClassName needed)
318319
```
319320

320-
### `--gpu` flag
321+
### GPU flags
321322

322-
The `--gpu` flag on `gateway start` enables GPU passthrough. OpenShell auto-selects CDI when enabled on the daemon and falls back to Docker's NVIDIA GPU request path (`--gpus all`) otherwise.
323+
`gateway start` enables GPU passthrough automatically when it detects NVIDIA GPU support. The `--gpu` flag forces GPU passthrough even when auto-detection does not find a device. The `--no-gpu` flag disables auto-detection.
324+
325+
OpenShell auto-selects CDI when enabled on the daemon and falls back to Docker's NVIDIA GPU request path (`--gpus all`) otherwise.
323326

324327
Device injection uses CDI (`deviceListStrategy: cdi-cri`): the device plugin injects devices via direct CDI device requests in the CRI. Sandbox pods only need `nvidia.com/gpu: 1` in their resource limits, and GPU pods do not set `runtimeClassName`.
325328

architecture/gateway.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -606,7 +606,7 @@ The gateway reaches the sandbox exclusively through the supervisor-initiated `Co
606606

607607
The Docker driver (`crates/openshell-driver-docker/src/lib.rs`) is an in-process compute backend for local standalone gateways. It creates one Docker container per sandbox, labels each container with `openshell.ai/managed-by=openshell`, `openshell.ai/sandbox-id`, `openshell.ai/sandbox-name`, and `openshell.ai/sandbox-namespace`, and bind-mounts a Linux `openshell-sandbox` supervisor binary into the container.
608608

609-
- **Create**: Pulls or validates the sandbox image according to `sandbox_image_pull_policy`, creates a labeled container, mounts the supervisor binary and optional TLS material, and starts the container with the supervisor as entrypoint.
609+
- **Create**: Pulls or validates the sandbox image according to `sandbox_image_pull_policy`, creates a labeled container, mounts the supervisor binary and optional TLS material, and starts the container with the supervisor as entrypoint. When the sandbox spec requests GPU and Docker exposes NVIDIA CDI devices or the NVIDIA runtime, the driver adds a Docker `DeviceRequest` for those GPUs.
610610
- **List/Get/Watch**: Reads labeled containers in the configured sandbox namespace and derives driver-native sandbox status from Docker state plus supervisor relay readiness.
611611
- **Stop**: Stops the matching labeled container without deleting it.
612612
- **Delete**: Force-removes the matching labeled container.

crates/openshell-bootstrap/src/docker.rs

Lines changed: 137 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ use bollard::API_DEFAULT_VERSION;
88
use bollard::Docker;
99
use bollard::errors::Error as BollardError;
1010
use bollard::models::{
11-
ContainerCreateBody, DeviceRequest, EndpointSettings, HostConfig, HostConfigCgroupnsModeEnum,
12-
NetworkConnectRequest, NetworkCreateRequest, NetworkDisconnectRequest, PortBinding,
13-
RestartPolicy, RestartPolicyNameEnum, VolumeCreateRequest,
11+
ContainerCreateBody, DeviceInfo, DeviceRequest, EndpointSettings, HostConfig,
12+
HostConfigCgroupnsModeEnum, NetworkConnectRequest, NetworkCreateRequest,
13+
NetworkDisconnectRequest, PortBinding, RestartPolicy, RestartPolicyNameEnum, Runtime,
14+
SystemInfo, VolumeCreateRequest,
1415
};
1516
use bollard::query_parameters::{
1617
CreateContainerOptions, CreateImageOptions, InspectContainerOptions, InspectNetworkOptions,
@@ -19,6 +20,7 @@ use bollard::query_parameters::{
1920
};
2021
use futures::StreamExt;
2122
use miette::{IntoDiagnostic, Result, WrapErr};
23+
use openshell_core::config::CDI_GPU_DEVICE_ALL;
2224
use std::collections::HashMap;
2325

2426
const REGISTRY_NAMESPACE_DEFAULT: &str = "openshell";
@@ -64,6 +66,92 @@ pub(crate) fn resolve_gpu_device_ids(gpu: &[String], cdi_enabled: bool) -> Vec<S
6466
}
6567
}
6668

69+
/// Detect concrete GPU device IDs for automatic gateway GPU enablement.
70+
///
71+
/// Auto-detection is intentionally stricter than explicit `--gpu`: CDI is
72+
/// selected only when Docker reports NVIDIA CDI devices, while the legacy
73+
/// NVIDIA runtime path is selected only when the local host exposes NVIDIA
74+
/// device files.
75+
pub(crate) fn auto_detect_gpu_device_ids(
76+
info: Option<&SystemInfo>,
77+
local_nvidia_devices_present: bool,
78+
) -> Vec<String> {
79+
let Some(info) = info else {
80+
return Vec::new();
81+
};
82+
83+
let cdi_device_ids = nvidia_cdi_device_ids(info);
84+
if !cdi_device_ids.is_empty() {
85+
return cdi_device_ids;
86+
}
87+
88+
if local_nvidia_devices_present && docker_info_has_nvidia_runtime(info) {
89+
return vec!["legacy".to_string()];
90+
}
91+
92+
Vec::new()
93+
}
94+
95+
pub(crate) fn docker_info_cdi_enabled(info: Option<&SystemInfo>) -> bool {
96+
info.and_then(|info| info.cdi_spec_dirs.as_ref())
97+
.is_some_and(|dirs| !dirs.is_empty())
98+
}
99+
100+
pub(crate) fn local_nvidia_devices_present() -> bool {
101+
["/dev/nvidia0", "/dev/nvidiactl", "/proc/driver/nvidia/gpus"]
102+
.iter()
103+
.any(|path| std::path::Path::new(path).exists())
104+
}
105+
106+
fn nvidia_cdi_device_ids(info: &SystemInfo) -> Vec<String> {
107+
let Some(devices) = info.discovered_devices.as_ref() else {
108+
return Vec::new();
109+
};
110+
111+
let mut ids = devices
112+
.iter()
113+
.filter_map(nvidia_cdi_device_id)
114+
.collect::<Vec<_>>();
115+
ids.sort();
116+
ids.dedup();
117+
118+
if ids.iter().any(|id| id == CDI_GPU_DEVICE_ALL) {
119+
vec![CDI_GPU_DEVICE_ALL.to_string()]
120+
} else {
121+
ids
122+
}
123+
}
124+
125+
fn nvidia_cdi_device_id(device: &DeviceInfo) -> Option<String> {
126+
let id = device.id.as_ref()?;
127+
if id.contains("nvidia.com/gpu=")
128+
|| (id.contains("/gpu=") && device.source.as_deref().is_some_and(contains_nvidia))
129+
{
130+
return Some(id.clone());
131+
}
132+
None
133+
}
134+
135+
fn docker_info_has_nvidia_runtime(info: &SystemInfo) -> bool {
136+
info.runtimes.as_ref().is_some_and(|runtimes| {
137+
runtimes
138+
.iter()
139+
.any(|(name, runtime)| is_nvidia_runtime(name, runtime))
140+
})
141+
}
142+
143+
fn is_nvidia_runtime(name: &str, runtime: &Runtime) -> bool {
144+
contains_nvidia(name)
145+
|| runtime
146+
.path
147+
.as_deref()
148+
.is_some_and(|path| path.contains("nvidia-container-runtime"))
149+
}
150+
151+
fn contains_nvidia(value: &str) -> bool {
152+
value.to_ascii_lowercase().contains("nvidia")
153+
}
154+
67155
const REGISTRY_MODE_EXTERNAL: &str = "external";
68156

69157
fn env_non_empty(key: &str) -> Option<String> {
@@ -1466,4 +1554,50 @@ mod tests {
14661554
];
14671555
assert_eq!(resolve_gpu_device_ids(&multi, true), multi);
14681556
}
1557+
1558+
#[test]
1559+
fn auto_detect_gpu_prefers_reported_nvidia_cdi_devices() {
1560+
let info = SystemInfo {
1561+
discovered_devices: Some(vec![DeviceInfo {
1562+
source: Some("cdi".to_string()),
1563+
id: Some("nvidia.com/gpu=all".to_string()),
1564+
}]),
1565+
runtimes: Some(HashMap::from([("nvidia".to_string(), Runtime::default())])),
1566+
..Default::default()
1567+
};
1568+
1569+
assert_eq!(
1570+
auto_detect_gpu_device_ids(Some(&info), false),
1571+
vec!["nvidia.com/gpu=all".to_string()]
1572+
);
1573+
}
1574+
1575+
#[test]
1576+
fn auto_detect_gpu_uses_legacy_runtime_only_when_local_devices_exist() {
1577+
let info = SystemInfo {
1578+
runtimes: Some(HashMap::from([("nvidia".to_string(), Runtime::default())])),
1579+
..Default::default()
1580+
};
1581+
1582+
assert_eq!(
1583+
auto_detect_gpu_device_ids(Some(&info), false),
1584+
Vec::<String>::new()
1585+
);
1586+
assert_eq!(
1587+
auto_detect_gpu_device_ids(Some(&info), true),
1588+
vec!["legacy".to_string()]
1589+
);
1590+
}
1591+
1592+
#[test]
1593+
fn docker_info_cdi_enabled_requires_cdi_dirs() {
1594+
assert!(!docker_info_cdi_enabled(None));
1595+
assert!(!docker_info_cdi_enabled(Some(&SystemInfo::default())));
1596+
1597+
let info = SystemInfo {
1598+
cdi_spec_dirs: Some(vec!["/etc/cdi".to_string()]),
1599+
..Default::default()
1600+
};
1601+
assert!(docker_info_cdi_enabled(Some(&info)));
1602+
}
14691603
}

crates/openshell-bootstrap/src/lib.rs

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ pub struct DeployOptions {
119119
/// - `["auto"]` — resolved at deploy time: CDI if enabled on the daemon, else the non-CDI fallback
120120
/// - `[cdi-ids…]` — CDI DeviceRequest with the given device IDs
121121
pub gpu: Vec<String>,
122+
/// Detect NVIDIA GPU support during deploy and enable passthrough when no
123+
/// explicit GPU device IDs were supplied.
124+
pub gpu_auto_detect: bool,
122125
/// When true, destroy any existing gateway resources before deploying.
123126
/// When false, an existing gateway is left as-is and deployment is
124127
/// skipped (the caller is responsible for prompting the user first).
@@ -138,6 +141,7 @@ impl DeployOptions {
138141
registry_username: None,
139142
registry_token: None,
140143
gpu: vec![],
144+
gpu_auto_detect: false,
141145
recreate: false,
142146
}
143147
}
@@ -202,6 +206,13 @@ impl DeployOptions {
202206
self
203207
}
204208

209+
/// Enable or disable automatic GPU passthrough detection.
210+
#[must_use]
211+
pub fn with_gpu_auto_detect(mut self, auto_detect: bool) -> Self {
212+
self.gpu_auto_detect = auto_detect;
213+
self
214+
}
215+
205216
/// Set whether to destroy and recreate existing gateway resources.
206217
#[must_use]
207218
pub fn with_recreate(mut self, recreate: bool) -> Self {
@@ -270,7 +281,8 @@ where
270281
let disable_gateway_auth = options.disable_gateway_auth;
271282
let registry_username = options.registry_username;
272283
let registry_token = options.registry_token;
273-
let gpu = options.gpu;
284+
let mut gpu = options.gpu;
285+
let gpu_auto_detect = options.gpu_auto_detect;
274286
let recreate = options.recreate;
275287

276288
// Wrap on_log in Arc<Mutex<>> so we can share it with pull_remote_image
@@ -296,17 +308,22 @@ where
296308
(preflight.docker, None)
297309
};
298310

299-
// CDI is considered enabled when the daemon reports at least one CDI spec
300-
// directory via `GET /info` (`SystemInfo.CDISpecDirs`). An empty list or
301-
// missing field means CDI is not configured and we fall back to the legacy
302-
// NVIDIA `DeviceRequest` (driver="nvidia"). Detection is best-effort —
303-
// failure to query daemon info is non-fatal.
304-
let cdi_supported = target_docker
305-
.info()
306-
.await
307-
.ok()
308-
.and_then(|info| info.cdi_spec_dirs)
309-
.is_some_and(|dirs| !dirs.is_empty());
311+
// GPU discovery is best-effort. Explicit `--gpu` still uses the legacy
312+
// CDI-enabled check below, while auto-detection only enables GPU when the
313+
// daemon reports NVIDIA CDI devices or the local host has NVIDIA devices
314+
// plus the NVIDIA Docker runtime.
315+
let docker_info = target_docker.info().await.ok();
316+
let cdi_supported = docker::docker_info_cdi_enabled(docker_info.as_ref());
317+
if gpu_auto_detect && gpu.is_empty() {
318+
let detected_gpu = docker::auto_detect_gpu_device_ids(
319+
docker_info.as_ref(),
320+
remote_opts.is_none() && docker::local_nvidia_devices_present(),
321+
);
322+
if !detected_gpu.is_empty() {
323+
log("[status] Detected NVIDIA GPU support".to_string());
324+
gpu = detected_gpu;
325+
}
326+
}
310327

311328
// If an existing gateway is found, decide how to proceed:
312329
// - recreate: destroy everything and start fresh

crates/openshell-cli/src/main.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,14 @@ enum GatewayCommands {
821821
/// (`--gpus all`) otherwise.
822822
#[arg(long)]
823823
gpu: bool,
824+
825+
/// Disable automatic NVIDIA GPU passthrough detection.
826+
///
827+
/// By default, `gateway start` enables GPU passthrough when Docker
828+
/// reports NVIDIA CDI devices, or when the local host exposes NVIDIA
829+
/// devices and the NVIDIA Docker runtime is configured.
830+
#[arg(long, conflicts_with = "gpu")]
831+
no_gpu: bool,
824832
},
825833

826834
/// Stop the gateway (preserves state).
@@ -1733,12 +1741,14 @@ async fn main() -> Result<()> {
17331741
registry_username,
17341742
registry_token,
17351743
gpu,
1744+
no_gpu,
17361745
} => {
17371746
let gpu = if gpu {
17381747
vec!["auto".to_string()]
17391748
} else {
17401749
vec![]
17411750
};
1751+
let gpu_auto_detect = !no_gpu && gpu.is_empty();
17421752
run::gateway_admin_deploy(
17431753
&name,
17441754
remote.as_deref(),
@@ -1751,6 +1761,7 @@ async fn main() -> Result<()> {
17511761
registry_username.as_deref(),
17521762
registry_token.as_deref(),
17531763
gpu,
1764+
gpu_auto_detect,
17541765
)
17551766
.await?;
17561767
}
@@ -3408,6 +3419,31 @@ mod tests {
34083419
}
34093420
}
34103421

3422+
#[test]
3423+
fn gateway_start_parses_no_gpu_flag() {
3424+
let cli = Cli::try_parse_from(["openshell", "gateway", "start", "--no-gpu"])
3425+
.expect("gateway start --no-gpu should parse");
3426+
3427+
match cli.command {
3428+
Some(Commands::Gateway {
3429+
command: Some(GatewayCommands::Start { no_gpu, gpu, .. }),
3430+
}) => {
3431+
assert!(no_gpu);
3432+
assert!(!gpu);
3433+
}
3434+
other => panic!("expected gateway start command, got: {other:?}"),
3435+
}
3436+
}
3437+
3438+
#[test]
3439+
fn gateway_start_rejects_gpu_and_no_gpu_together() {
3440+
let result = Cli::try_parse_from(["openshell", "gateway", "start", "--gpu", "--no-gpu"]);
3441+
assert!(
3442+
result.is_err(),
3443+
"gateway start should reject conflicting GPU flags"
3444+
);
3445+
}
3446+
34113447
// ── sandbox create arg-shape tests ───────────────────────────────────
34123448

34133449
/// Verify that `sandbox create --name <value>` still parses as a named

crates/openshell-cli/src/run.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1436,6 +1436,7 @@ pub async fn gateway_admin_deploy(
14361436
registry_username: Option<&str>,
14371437
registry_token: Option<&str>,
14381438
gpu: Vec<String>,
1439+
gpu_auto_detect: bool,
14391440
) -> Result<()> {
14401441
let location = if remote.is_some() { "remote" } else { "local" };
14411442

@@ -1489,6 +1490,7 @@ pub async fn gateway_admin_deploy(
14891490
.with_disable_tls(disable_tls)
14901491
.with_disable_gateway_auth(disable_gateway_auth)
14911492
.with_gpu(gpu)
1493+
.with_gpu_auto_detect(gpu_auto_detect)
14921494
.with_recreate(recreate);
14931495
if let Some(opts) = remote_opts {
14941496
options = options.with_remote(opts);

0 commit comments

Comments
 (0)