Skip to content

Commit dc21ff0

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

18 files changed

Lines changed: 543 additions & 122 deletions

File tree

crates/openshell-cli/src/main.rs

Lines changed: 57 additions & 4 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,
@@ -2432,15 +2438,16 @@ async fn main() -> Result<()> {
24322438
let endpoint = &ctx.endpoint;
24332439
let mut tls = tls.with_gateway_name(&ctx.name);
24342440
apply_auth(&mut tls, &ctx.name);
2441+
let resource_requirements =
2442+
run::resource_requirements_from_cli(gpu, gpu_device.as_deref(), gpu_count);
24352443
Box::pin(run::sandbox_create(
24362444
endpoint,
24372445
name.as_deref(),
24382446
from.as_deref(),
24392447
&ctx.name,
24402448
upload_spec.as_ref(),
24412449
keep,
2442-
gpu,
2443-
gpu_device.as_deref(),
2450+
resource_requirements,
24442451
cpu.as_deref(),
24452452
memory.as_deref(),
24462453
editor,
@@ -3713,6 +3720,52 @@ mod tests {
37133720
}
37143721
}
37153722

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

crates/openshell-cli/src/run.rs

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1611,8 +1611,7 @@ pub async fn sandbox_create(
16111611
gateway_name: &str,
16121612
upload: Option<&(String, Option<String>, bool)>,
16131613
keep: bool,
1614-
gpu: bool,
1615-
gpu_device: Option<&str>,
1614+
resource_requirements: Option<ResourceRequirements>,
16161615
cpu: Option<&str>,
16171616
memory: Option<&str>,
16181617
editor: Option<Editor>,
@@ -1665,9 +1664,13 @@ pub async fn sandbox_create(
16651664
}
16661665
None => None,
16671666
};
1668-
let requested_gpu = gpu
1669-
|| gpu_device.is_some_and(|device_id| !device_id.is_empty())
1670-
|| image.as_deref().is_some_and(image_requests_gpu);
1667+
let resource_requirements = resource_requirements.or_else(|| {
1668+
resource_requirements_from_cli(image.as_deref().is_some_and(image_requests_gpu), None, None)
1669+
});
1670+
let requested_gpu = resource_requirements
1671+
.as_ref()
1672+
.and_then(|requirements| requirements.gpu.as_ref())
1673+
.is_some();
16711674

16721675
let inferred_types: Vec<String> = inferred_provider_type(command).into_iter().collect();
16731676
let configured_providers = ensure_required_providers(
@@ -1693,7 +1696,7 @@ pub async fn sandbox_create(
16931696

16941697
let request = CreateSandboxRequest {
16951698
spec: Some(SandboxSpec {
1696-
placement: resource_requirements_from_cli(requested_gpu, gpu_device),
1699+
resource_requirements,
16971700
policy,
16981701
providers: configured_providers,
16991702
template,
@@ -2083,16 +2086,25 @@ pub async fn sandbox_create(
20832086
}
20842087
}
20852088

2086-
fn resource_requirements_from_cli(
2089+
pub fn resource_requirements_from_cli(
20872090
requested_gpu: bool,
20882091
gpu_device: Option<&str>,
2092+
gpu_count: Option<u32>,
20892093
) -> Option<ResourceRequirements> {
2094+
let requested_gpu = requested_gpu
2095+
|| gpu_device.is_some_and(|device_id| !device_id.is_empty())
2096+
|| gpu_count.is_some();
20902097
requested_gpu.then(|| ResourceRequirements {
20912098
gpu: Some(GpuSpec {
2092-
device_ids: gpu_device
2093-
.filter(|device_id| !device_id.is_empty())
2094-
.map(|device_id| vec![device_id.to_string()])
2095-
.unwrap_or_default(),
2099+
device_ids: if gpu_count.is_none() {
2100+
gpu_device
2101+
.filter(|device_id| !device_id.is_empty())
2102+
.map(|device_id| vec![device_id.to_string()])
2103+
.unwrap_or_default()
2104+
} else {
2105+
Vec::new()
2106+
},
2107+
count: gpu_count,
20962108
}),
20972109
})
20982110
}
@@ -6296,7 +6308,7 @@ mod tests {
62966308
parse_credential_pairs, plaintext_gateway_is_remote, progress_step_from_metadata,
62976309
provisioning_timeout_message, ready_false_condition_message, resolve_from,
62986310
resource_requirements_from_cli, sandbox_should_persist, service_expose_status_error,
6299-
service_url_for_gateway, source_requests_gpu,
6311+
service_url_for_gateway,
63006312
};
63016313
use crate::TEST_ENV_LOCK;
63026314
use hyper::StatusCode;
@@ -6644,32 +6656,54 @@ mod tests {
66446656
}
66456657

66466658
#[test]
6647-
fn source_requests_gpu_detects_known_community_gpu_name() {
6648-
assert!(source_requests_gpu("nvidia-gpu"));
6649-
assert!(!source_requests_gpu("base"));
6659+
fn image_requests_gpu_detects_known_community_gpu_name() {
6660+
assert!(image_requests_gpu("nvidia-gpu"));
6661+
assert!(!image_requests_gpu("base"));
66506662
}
66516663

66526664
#[test]
66536665
fn resource_requirements_from_cli_uses_presence_with_empty_device_ids_for_default_gpu() {
6654-
let request = resource_requirements_from_cli(true, None)
6666+
let request = resource_requirements_from_cli(true, None, None)
66556667
.expect("resource requirements should be present");
66566668
let gpu = request.gpu.expect("gpu request should be present");
66576669

66586670
assert!(gpu.device_ids.is_empty());
6671+
assert_eq!(gpu.count, None);
66596672
}
66606673

66616674
#[test]
66626675
fn resource_requirements_from_cli_maps_gpu_device_to_one_device_id() {
6663-
let request = resource_requirements_from_cli(true, Some("0000:2d:00.0"))
6676+
let request = resource_requirements_from_cli(true, Some("0000:2d:00.0"), None)
6677+
.expect("resource requirements should be present");
6678+
let gpu = request.gpu.expect("gpu request should be present");
6679+
6680+
assert_eq!(gpu.device_ids, vec!["0000:2d:00.0"]);
6681+
assert_eq!(gpu.count, None);
6682+
}
6683+
6684+
#[test]
6685+
fn resource_requirements_from_cli_maps_gpu_device_without_explicit_gpu_flag() {
6686+
let request = resource_requirements_from_cli(false, Some("0000:2d:00.0"), None)
66646687
.expect("resource requirements should be present");
66656688
let gpu = request.gpu.expect("gpu request should be present");
66666689

66676690
assert_eq!(gpu.device_ids, vec!["0000:2d:00.0"]);
6691+
assert_eq!(gpu.count, None);
6692+
}
6693+
6694+
#[test]
6695+
fn resource_requirements_from_cli_maps_gpu_count() {
6696+
let request = resource_requirements_from_cli(false, None, Some(2))
6697+
.expect("resource requirements should be present");
6698+
let gpu = request.gpu.expect("gpu request should be present");
6699+
6700+
assert!(gpu.device_ids.is_empty());
6701+
assert_eq!(gpu.count, Some(2));
66686702
}
66696703

66706704
#[test]
6671-
fn resource_requirements_from_cli_omits_placement_when_not_requested() {
6672-
assert!(resource_requirements_from_cli(false, Some("0")).is_none());
6705+
fn resource_requirements_from_cli_omits_requirements_when_not_requested() {
6706+
assert!(resource_requirements_from_cli(false, None, None).is_none());
66736707
}
66746708

66756709
#[test]

crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -727,7 +727,6 @@ async fn sandbox_create_keeps_command_sessions_by_default() {
727727
"openshell",
728728
None,
729729
true,
730-
false,
731730
None,
732731
None,
733732
None,
@@ -768,7 +767,6 @@ async fn sandbox_create_sends_cpu_and_memory_limits_only() {
768767
"openshell",
769768
None,
770769
true,
771-
false,
772770
None,
773771
Some("500m"),
774772
Some("2Gi"),
@@ -853,7 +851,6 @@ async fn sandbox_create_returns_vm_error_without_waiting_for_timeout() {
853851
"openshell",
854852
None,
855853
true,
856-
false,
857854
None,
858855
None,
859856
None,
@@ -905,7 +902,6 @@ async fn sandbox_create_keeps_waiting_while_vm_progress_arrives() {
905902
"openshell",
906903
None,
907904
true,
908-
false,
909905
None,
910906
None,
911907
None,
@@ -949,7 +945,6 @@ async fn sandbox_create_times_out_when_only_logs_arrive() {
949945
"openshell",
950946
None,
951947
true,
952-
false,
953948
None,
954949
None,
955950
None,
@@ -989,7 +984,6 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() {
989984
"openshell",
990985
None,
991986
false,
992-
false,
993987
None,
994988
None,
995989
None,
@@ -1033,7 +1027,6 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() {
10331027
"openshell",
10341028
None,
10351029
false,
1036-
false,
10371030
None,
10381031
None,
10391032
None,
@@ -1077,7 +1070,6 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() {
10771070
"openshell",
10781071
None,
10791072
true,
1080-
false,
10811073
None,
10821074
None,
10831075
None,
@@ -1121,7 +1113,6 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() {
11211113
"openshell",
11221114
None,
11231115
false,
1124-
false,
11251116
None,
11261117
None,
11271118
None,

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 GPU resource device IDs when set; otherwise requests all NVIDIA GPUs for GPU resource 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

0 commit comments

Comments
 (0)