Skip to content

Commit 871fad3

Browse files
committed
feat(gpu): add GPU count resource requests
Signed-off-by: Evan Lezar <elezar@nvidia.com>
1 parent 886f46e commit 871fad3

17 files changed

Lines changed: 499 additions & 64 deletions

File tree

crates/openshell-cli/src/main.rs

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,6 +1029,7 @@ enum DoctorCommands {
10291029
Check,
10301030
}
10311031

1032+
#[allow(clippy::large_enum_variant)]
10321033
#[derive(Subcommand, Debug)]
10331034
enum SandboxCommands {
10341035
/// Create a sandbox.
@@ -1086,10 +1087,14 @@ enum SandboxCommands {
10861087

10871088
/// Target a driver-specific GPU device. Docker and Podman use CDI device IDs
10881089
/// (for example "nvidia.com/gpu=0"); VM uses a PCI BDF or index.
1089-
/// Implies a GPU request. When omitted with --gpu, the driver uses its default GPU selection.
1090-
#[arg(long)]
1090+
/// Implies a GPU request. Mutually exclusive with --gpu-count.
1091+
#[arg(long, conflicts_with = "gpu_count")]
10911092
gpu_device: Option<String>,
10921093

1094+
/// Request a specific number of GPUs. Mutually exclusive with --gpu-device.
1095+
#[arg(long, value_parser = clap::value_parser!(u32).range(1..), conflicts_with = "gpu_device")]
1096+
gpu_count: Option<u32>,
1097+
10931098
/// CPU limit for the sandbox (for example: 500m, 1, 2.5).
10941099
#[arg(long)]
10951100
cpu: Option<String>,
@@ -2373,6 +2378,7 @@ async fn main() -> Result<()> {
23732378
editor,
23742379
gpu,
23752380
gpu_device,
2381+
gpu_count,
23762382
cpu,
23772383
memory,
23782384
providers,
@@ -2441,6 +2447,7 @@ async fn main() -> Result<()> {
24412447
keep,
24422448
gpu,
24432449
gpu_device.as_deref(),
2450+
gpu_count,
24442451
cpu.as_deref(),
24452452
memory.as_deref(),
24462453
editor,
@@ -3709,6 +3716,52 @@ mod tests {
37093716
}
37103717
}
37113718

3719+
#[test]
3720+
fn sandbox_create_gpu_count_parses_without_gpu_flag() {
3721+
let cli = Cli::try_parse_from(["openshell", "sandbox", "create", "--gpu-count", "2"])
3722+
.expect("sandbox create --gpu-count should parse");
3723+
3724+
if let Some(Commands::Sandbox {
3725+
command: Some(SandboxCommands::Create { gpu, gpu_count, .. }),
3726+
..
3727+
}) = cli.command
3728+
{
3729+
assert!(!gpu);
3730+
assert_eq!(gpu_count, Some(2));
3731+
} else {
3732+
panic!("expected SandboxCommands::Create");
3733+
}
3734+
}
3735+
3736+
#[test]
3737+
fn sandbox_create_gpu_count_rejects_zero() {
3738+
let result = Cli::try_parse_from(["openshell", "sandbox", "create", "--gpu-count", "0"]);
3739+
3740+
assert!(
3741+
result.is_err(),
3742+
"sandbox create --gpu-count 0 should be rejected"
3743+
);
3744+
}
3745+
3746+
#[test]
3747+
fn sandbox_create_gpu_count_conflicts_with_gpu_device() {
3748+
let result = Cli::try_parse_from([
3749+
"openshell",
3750+
"sandbox",
3751+
"create",
3752+
"--gpu",
3753+
"--gpu-device",
3754+
"0",
3755+
"--gpu-count",
3756+
"2",
3757+
]);
3758+
3759+
assert!(
3760+
result.is_err(),
3761+
"sandbox create should reject --gpu-count with --gpu-device"
3762+
);
3763+
}
3764+
37123765
#[test]
37133766
fn service_expose_accepts_positional_target_port_and_service() {
37143767
let cli = Cli::try_parse_from([

crates/openshell-cli/src/run.rs

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1564,6 +1564,7 @@ pub async fn sandbox_create(
15641564
keep: bool,
15651565
gpu: bool,
15661566
gpu_device: Option<&str>,
1567+
gpu_count: Option<u32>,
15671568
cpu: Option<&str>,
15681569
memory: Option<&str>,
15691570
editor: Option<Editor>,
@@ -1618,6 +1619,7 @@ pub async fn sandbox_create(
16181619
};
16191620
let requested_gpu = gpu
16201621
|| gpu_device.is_some_and(|device_id| !device_id.is_empty())
1622+
|| gpu_count.is_some()
16211623
|| image.as_deref().is_some_and(image_requests_gpu);
16221624

16231625
let inferred_types: Vec<String> = inferred_provider_type(command).into_iter().collect();
@@ -1644,7 +1646,7 @@ pub async fn sandbox_create(
16441646

16451647
let request = CreateSandboxRequest {
16461648
spec: Some(SandboxSpec {
1647-
placement: resource_requirements_from_cli(requested_gpu, gpu_device),
1649+
placement: resource_requirements_from_cli(requested_gpu, gpu_device, gpu_count),
16481650
policy,
16491651
providers: configured_providers,
16501652
template,
@@ -2079,13 +2081,22 @@ pub async fn sandbox_create(
20792081
fn resource_requirements_from_cli(
20802082
requested_gpu: bool,
20812083
gpu_device: Option<&str>,
2084+
gpu_count: Option<u32>,
20822085
) -> Option<ResourceRequirements> {
2086+
let requested_gpu = requested_gpu
2087+
|| gpu_device.is_some_and(|device_id| !device_id.is_empty())
2088+
|| gpu_count.is_some();
20832089
requested_gpu.then(|| ResourceRequirements {
20842090
gpu: Some(GpuSpec {
2085-
device_ids: gpu_device
2086-
.filter(|device_id| !device_id.is_empty())
2087-
.map(|device_id| vec![device_id.to_string()])
2088-
.unwrap_or_default(),
2091+
device_ids: if gpu_count.is_none() {
2092+
gpu_device
2093+
.filter(|device_id| !device_id.is_empty())
2094+
.map(|device_id| vec![device_id.to_string()])
2095+
.unwrap_or_default()
2096+
} else {
2097+
Vec::new()
2098+
},
2099+
count: gpu_count,
20892100
}),
20902101
})
20912102
}
@@ -6135,7 +6146,6 @@ mod tests {
61356146
parse_credential_pairs, plaintext_gateway_is_remote, provisioning_timeout_message,
61366147
ready_false_condition_message, resolve_from, resource_requirements_from_cli,
61376148
sandbox_should_persist, service_expose_status_error, service_url_for_gateway,
6138-
source_requests_gpu,
61396149
};
61406150
use crate::TEST_ENV_LOCK;
61416151
use hyper::StatusCode;
@@ -6462,32 +6472,54 @@ mod tests {
64626472
}
64636473

64646474
#[test]
6465-
fn source_requests_gpu_detects_known_community_gpu_name() {
6466-
assert!(source_requests_gpu("nvidia-gpu"));
6467-
assert!(!source_requests_gpu("base"));
6475+
fn image_requests_gpu_detects_known_community_gpu_name() {
6476+
assert!(image_requests_gpu("nvidia-gpu"));
6477+
assert!(!image_requests_gpu("base"));
64686478
}
64696479

64706480
#[test]
64716481
fn resource_requirements_from_cli_uses_presence_with_empty_device_ids_for_default_gpu() {
6472-
let request = resource_requirements_from_cli(true, None)
6482+
let request = resource_requirements_from_cli(true, None, None)
64736483
.expect("resource requirements should be present");
64746484
let gpu = request.gpu.expect("gpu request should be present");
64756485

64766486
assert!(gpu.device_ids.is_empty());
6487+
assert_eq!(gpu.count, None);
64776488
}
64786489

64796490
#[test]
64806491
fn resource_requirements_from_cli_maps_gpu_device_to_one_device_id() {
6481-
let request = resource_requirements_from_cli(true, Some("0000:2d:00.0"))
6492+
let request = resource_requirements_from_cli(true, Some("0000:2d:00.0"), None)
64826493
.expect("resource requirements should be present");
64836494
let gpu = request.gpu.expect("gpu request should be present");
64846495

64856496
assert_eq!(gpu.device_ids, vec!["0000:2d:00.0"]);
6497+
assert_eq!(gpu.count, None);
6498+
}
6499+
6500+
#[test]
6501+
fn resource_requirements_from_cli_maps_gpu_device_without_explicit_gpu_flag() {
6502+
let request = resource_requirements_from_cli(false, Some("0000:2d:00.0"), None)
6503+
.expect("resource requirements should be present");
6504+
let gpu = request.gpu.expect("gpu request should be present");
6505+
6506+
assert_eq!(gpu.device_ids, vec!["0000:2d:00.0"]);
6507+
assert_eq!(gpu.count, None);
6508+
}
6509+
6510+
#[test]
6511+
fn resource_requirements_from_cli_maps_gpu_count() {
6512+
let request = resource_requirements_from_cli(false, None, Some(2))
6513+
.expect("resource requirements should be present");
6514+
let gpu = request.gpu.expect("gpu request should be present");
6515+
6516+
assert!(gpu.device_ids.is_empty());
6517+
assert_eq!(gpu.count, Some(2));
64866518
}
64876519

64886520
#[test]
64896521
fn resource_requirements_from_cli_omits_placement_when_not_requested() {
6490-
assert!(resource_requirements_from_cli(false, Some("0")).is_none());
6522+
assert!(resource_requirements_from_cli(false, None, None).is_none());
64916523
}
64926524

64936525
#[test]

crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() {
601601
None,
602602
None,
603603
None,
604+
None,
604605
&[],
605606
None,
606607
None,
@@ -639,6 +640,7 @@ async fn sandbox_create_sends_cpu_and_memory_limits_only() {
639640
true,
640641
false,
641642
None,
643+
None,
642644
Some("500m"),
643645
Some("2Gi"),
644646
None,
@@ -717,6 +719,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() {
717719
None,
718720
None,
719721
None,
722+
None,
720723
&[],
721724
None,
722725
None,
@@ -761,6 +764,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() {
761764
None,
762765
None,
763766
None,
767+
None,
764768
&[],
765769
None,
766770
None,
@@ -805,6 +809,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() {
805809
None,
806810
None,
807811
None,
812+
None,
808813
&[],
809814
None,
810815
None,
@@ -849,6 +854,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() {
849854
None,
850855
None,
851856
None,
857+
None,
852858
&[],
853859
None,
854860
Some(openshell_core::forward::ForwardSpec::new(forward_port)),

crates/openshell-core/src/gpu.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ mod tests {
3030

3131
#[test]
3232
fn cdi_gpu_device_ids_defaults_empty_request_to_all_gpus() {
33-
let request = GpuSpec { device_ids: vec![] };
33+
let request = GpuSpec {
34+
device_ids: vec![],
35+
count: None,
36+
};
3437

3538
assert_eq!(
3639
cdi_gpu_device_ids(Some(&request)),
@@ -42,6 +45,7 @@ mod tests {
4245
fn cdi_gpu_device_ids_passes_single_device_id_through() {
4346
let request = GpuSpec {
4447
device_ids: vec!["nvidia.com/gpu=0".to_string()],
48+
count: None,
4549
};
4650

4751
assert_eq!(
@@ -57,6 +61,7 @@ mod tests {
5761
"nvidia.com/gpu=0".to_string(),
5862
"nvidia.com/gpu=1".to_string(),
5963
],
64+
count: None,
6065
};
6166

6267
assert_eq!(

crates/openshell-driver-docker/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ contract:
3030
| `cap_add` | Grants supervisor-only capabilities required for namespace setup and process inspection. |
3131
| `apparmor=unconfined` | Avoids Docker's default profile blocking required mount operations. |
3232
| `restart_policy = unless-stopped` | Keeps managed sandboxes resumable across daemon or gateway restarts. |
33-
| CDI GPU request | Uses explicit placement GPU device IDs when set; otherwise requests all NVIDIA GPUs for GPU placement requests when daemon CDI support is detected. |
33+
| CDI GPU request | Uses explicit placement GPU device IDs when set; otherwise requests all NVIDIA GPUs for GPU placement requests when daemon CDI support is detected. GPU count requests are rejected by this driver. |
3434

3535
The agent child process does not retain these supervisor privileges.
3636

crates/openshell-driver-docker/src/lib.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,21 @@ impl DockerComputeDriver {
336336
}
337337

338338
fn validate_gpu_request(gpu: Option<&GpuSpec>, supports_gpu: bool) -> Result<(), Status> {
339+
if let Some(gpu) = gpu {
340+
if gpu.count == Some(0) {
341+
return Err(Status::invalid_argument("gpu.count must be greater than 0"));
342+
}
343+
if gpu.count.is_some() && !gpu.device_ids.is_empty() {
344+
return Err(Status::invalid_argument(
345+
"gpu.count is mutually exclusive with gpu.device_ids",
346+
));
347+
}
348+
if gpu.count.is_some() {
349+
return Err(Status::invalid_argument(
350+
"docker compute driver does not support GPU count requests",
351+
));
352+
}
353+
}
339354
if gpu.is_some() && !supports_gpu {
340355
return Err(Status::failed_precondition(
341356
"docker GPU sandboxes require Docker CDI support. Enable CDI on the Docker daemon, then restart the OpenShell gateway/server so GPU capability is detected.",
@@ -1012,7 +1027,7 @@ fn build_container_create_body(
10121027
.as_ref()
10131028
.and_then(|placement| placement.gpu.as_ref()),
10141029
),
1015-
mounts: Some(build_mounts(config)),
1030+
binds: Some(build_binds(config)),
10161031
restart_policy: Some(RestartPolicy {
10171032
name: Some(RestartPolicyNameEnum::UNLESS_STOPPED),
10181033
maximum_retry_count: None,

crates/openshell-driver-docker/src/tests.rs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,10 @@ fn validate_sandbox_rejects_gpu_when_cdi_unavailable() {
500500
let config = runtime_config();
501501
let mut sandbox = test_sandbox();
502502
sandbox.spec.as_mut().unwrap().placement = Some(ResourceRequirements {
503-
gpu: Some(GpuSpec { device_ids: vec![] }),
503+
gpu: Some(GpuSpec {
504+
device_ids: vec![],
505+
count: None,
506+
}),
504507
});
505508

506509
let err = DockerComputeDriver::validate_sandbox(&sandbox, &config).unwrap_err();
@@ -509,13 +512,34 @@ fn validate_sandbox_rejects_gpu_when_cdi_unavailable() {
509512
assert!(err.message().contains("Docker CDI"));
510513
}
511514

515+
#[test]
516+
fn validate_sandbox_rejects_gpu_count() {
517+
let mut config = runtime_config();
518+
config.supports_gpu = true;
519+
let mut sandbox = test_sandbox();
520+
sandbox.spec.as_mut().unwrap().placement = Some(ResourceRequirements {
521+
gpu: Some(GpuSpec {
522+
device_ids: vec![],
523+
count: Some(2),
524+
}),
525+
});
526+
527+
let err = DockerComputeDriver::validate_sandbox(&sandbox, &config).unwrap_err();
528+
529+
assert_eq!(err.code(), tonic::Code::InvalidArgument);
530+
assert!(err.message().contains("does not support GPU count"));
531+
}
532+
512533
#[test]
513534
fn build_container_create_body_maps_gpu_to_all_cdi_device() {
514535
let mut config = runtime_config();
515536
config.supports_gpu = true;
516537
let mut sandbox = test_sandbox();
517538
sandbox.spec.as_mut().unwrap().placement = Some(ResourceRequirements {
518-
gpu: Some(GpuSpec { device_ids: vec![] }),
539+
gpu: Some(GpuSpec {
540+
device_ids: vec![],
541+
count: None,
542+
}),
519543
});
520544

521545
let create_body = build_container_create_body(&sandbox, &config).unwrap();
@@ -544,6 +568,7 @@ fn build_container_create_body_passes_explicit_cdi_device_ids_through() {
544568
"nvidia.com/gpu=0".to_string(),
545569
"nvidia.com/gpu=1".to_string(),
546570
],
571+
count: None,
547572
}),
548573
});
549574

0 commit comments

Comments
 (0)