Files
OpenShell/e2e/rust/tests/gpu_device_selection.rs
Taylor MutchandDrew Newberry ba77967fc4 refactor(docker): split gateway/supervisor Dockerfiles and use native rust builds (#1316)
* wip

* refactor(docker): use native rust builds for split gateway/supervisor images

Drop the in-Docker BUILD_FROM_SOURCE path so both images consume only
prebuilt binaries staged natively via tasks/scripts/stage-prebuilt-binaries.sh.
This mirrors what CI does and reuses the host's cargo target cache and
sccache across rebuilds.

- Dockerfile.gateway: nvcr.io/nvidia/distroless/cc:v4.0.4 base (the 4.0.0
  tag does not exist on nvcr.io; the registry uses a v prefix). GNU-linked
  binary copied to /usr/local/bin.
- Dockerfile.supervisor: scratch base, static musl binary. Static linkage
  lets the image stay scratch while still being executable as a Kubernetes
  init container.
- skaffold.yaml: each artifact invokes tasks/scripts/docker-build-image.sh,
  which stages the binary natively (cargo / cargo-zigbuild) and then builds
  the image. Drops the cross-build.sh dependency from the supervisor build.
- seccomp.rs: add a local SYS_kexec_file_load constant for musl/aarch64.
  libc 0.2.185 omits the symbol from its musl/aarch64 bindings, so the
  supervisor's seccomp filter previously failed to compile for that target.
- architecture/build.md: describe the native-first pipeline and per-image
  runtime choices.

Local validation: gateway image 101MB (was 194MB), supervisor image 21.7MB.
helm:skaffold:run deploys cleanly; the static musl supervisor binary runs
correctly in a non-glibc agent container.

* refactor(docker): tighten binary perms via --chown + 0550

Replace `COPY --chmod=755` with `COPY --chown=<user> --chmod=0550` in
the gateway and supervisor Dockerfiles. The binary is no longer
world-readable or world-executable; ownership is pinned to the runtime
user.

- Gateway uses `--chown=nvs:nvs` + `USER nvs:nvs`, matching the only
  non-root user defined in `nvcr.io/nvidia/distroless/cc` (UID 1000) and
  the Helm chart's `securityContext.runAsUser: 1000`, which overrides
  the Dockerfile USER at runtime.
- Supervisor uses numeric `--chown=65534:65534` because the scratch base
  has no `/etc/passwd` for name resolution. The supervisor image is
  only consumed by the init-container copy-self path; the destination
  pod's runAsUser governs execute access.

Validated by deploying to a local k3d cluster via `helm:skaffold:run`
and confirming the gateway StatefulSet reaches 1/1 Running.

* ci(gpu): repoint GPU probe image lookup at Dockerfile.gateway

The previous awk parsed `FROM <image> AS gateway` from the now-deleted
`Dockerfile.images`. The new `Dockerfile.gateway` uses an ARG with a
default (`ARG GATEWAY_BASE_IMAGE=nvcr.io/nvidia/distroless/cc:v4.0.4`)
and `FROM ${GATEWAY_BASE_IMAGE} AS gateway`, so the old script returns
nothing.

Parse the ARG default value directly so the GPU prerequisites check
keeps using the gateway base image as a `nvidia-smi` probe target.

* ci(gpu): pin GPU probe to nvcr.io/nvidia/base/ubuntu:noble

The previous probe parsed the gateway base image out of the Dockerfile,
relying on the fact that the gateway ran on `nvcr.io/nvidia/base/ubuntu`
and that NVIDIA Container Toolkit CDI injection would populate
`nvidia-smi` and the supporting libs at runtime. The new gateway base
(`nvcr.io/nvidia/distroless/cc`) lacks `ldconfig`, a populated
`/usr/bin`, and the broader filesystem layout CDI injection assumes,
so it cannot serve as a GPU probe.

Pin the probe image explicitly to the NVIDIA-managed Ubuntu base. The
probe is independent of the gateway runtime and survives future base
swaps.

* fix(e2e-gpu): pass GPU probe image via env, drop Dockerfile.images parse

The Rust e2e test `gpu_request_for_each_discovered_device_matches_plain_container`
was still parsing the deleted `Dockerfile.images` to derive its GPU probe
image, panicking with `No such file or directory` after this PR's
Dockerfile split.

Move the probe image to a single source of truth in the workflow
(`OPENSHELL_E2E_GPU_PROBE_IMAGE` env at the job level) and require the
e2e test to read it from there. No silent codebase default — the test
panics with a pointer at the workflow if the env is missing, so it
fails loudly rather than drifting from CI.

The prereq probe step in the workflow now consumes the same env, so
the probe image is declared exactly once.

* fix(docker): keep supervisor binary root-owned for rootless Podman

The Podman driver mounts the supervisor image read-only into the sandbox
container at /opt/openshell/bin and runs that container as UID 0, but
deliberately drops DAC_OVERRIDE for hardening (container.rs:419). With
--chown=65534:65534 --chmod=0550 the binary was r-xr-x--- owned by UID
65534, so the container's UID 0 fell into "other" with no read or exec
access and the supervisor crashed on start (ContainerExited code 1).

Docker and Kubernetes both retain DAC_OVERRIDE, so root could still
exec the file — which is why this regression only surfaced in the
Podman e2e job.

Drop --chown from the supervisor COPY so the binary stays root-owned.
Keep --chmod=0550: the security win was dropping world-execute, not
changing the owner. The chown bought nothing here because the container
is always UID 0 regardless of driver, but it actively broke the only
driver that drops DAC_OVERRIDE.

---------

Co-authored-by: Drew Newberry <anewberry@nvidia.com>
2026-05-12 16:05:49 -07:00

369 lines
11 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
#![cfg(feature = "e2e-gpu")]
//! GPU device selection e2e tests.
//!
//! Requires a GPU-backed gateway and a sandbox image containing `nvidia-smi`.
use std::process::Stdio;
use std::time::Duration;
use openshell_e2e::harness::binary::openshell_cmd;
use openshell_e2e::harness::container::ContainerEngine;
use openshell_e2e::harness::output::strip_ansi;
use openshell_e2e::harness::sandbox::SandboxGuard;
use serde_json::{Map, Value};
use tokio::time::timeout;
const SANDBOX_CREATE_TIMEOUT: Duration = Duration::from_secs(600);
const CDI_GPU_DEVICE_ALL: &str = "nvidia.com/gpu=all";
const CDI_GPU_DEVICE_PREFIX: &str = "nvidia.com/gpu=";
const GPU_PROBE_IMAGE_ENV: &str = "OPENSHELL_E2E_GPU_PROBE_IMAGE";
fn gpu_lines(output: &str) -> Vec<String> {
strip_ansi(output)
.lines()
.map(str::trim)
.filter(|line| line.starts_with("GPU "))
.map(ToOwned::to_owned)
.collect()
}
fn gpu_probe_image() -> String {
std::env::var(GPU_PROBE_IMAGE_ENV)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
panic!(
"{GPU_PROBE_IMAGE_ENV} must be set to a container image that supports \
NVIDIA Container Toolkit CDI injection (set by \
.github/workflows/e2e-gpu-test.yaml in CI)"
)
})
}
fn object_string<'a>(object: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
object
.get(key)
.or_else(|| object.get(&key.to_ascii_lowercase()))
.and_then(Value::as_str)
}
fn discovered_devices_array(info: &Value) -> Option<&Vec<Value>> {
info.get("DiscoveredDevices")
.or_else(|| info.get("discoveredDevices"))
.and_then(Value::as_array)
}
fn host_discovered_devices_array(info: &Value) -> Option<&Vec<Value>> {
info.get("Host")
.or_else(|| info.get("host"))
.and_then(discovered_devices_array)
}
fn collect_cdi_gpu_device_ids_from_devices(devices: &[Value], device_ids: &mut Vec<String>) {
for device in devices {
let Some(device) = device.as_object() else {
continue;
};
if object_string(device, "Source") == Some("cdi")
&& let Some(device_id) = object_string(device, "ID")
&& device_id.starts_with(CDI_GPU_DEVICE_PREFIX)
{
device_ids.push(device_id.to_string());
}
}
}
fn parse_cdi_gpu_device_ids(info: &Value) -> Vec<String> {
let mut device_ids = Vec::new();
if let Some(devices) = discovered_devices_array(info) {
collect_cdi_gpu_device_ids_from_devices(devices, &mut device_ids);
}
if let Some(devices) = host_discovered_devices_array(info) {
collect_cdi_gpu_device_ids_from_devices(devices, &mut device_ids);
}
device_ids.sort();
device_ids.dedup();
device_ids
}
fn discovered_cdi_gpu_device_ids() -> Vec<String> {
let engine = ContainerEngine::from_env();
let output = engine
.command()
.args(["info", "--format", "json"])
.output()
.unwrap_or_else(|err| panic!("failed to run {} info: {err}", engine.name()));
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{stdout}{stderr}");
assert!(
output.status.success(),
"{} info --format json failed with status {:?}:\n{}",
engine.name(),
output.status.code(),
combined
);
let info: Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|err| {
panic!(
"failed to parse {} info JSON: {err}\n{combined}",
engine.name()
)
});
let device_ids = parse_cdi_gpu_device_ids(&info);
assert!(
!device_ids.is_empty(),
"{} info --format json did not report any discovered NVIDIA CDI GPU devices. \
Expected DiscoveredDevices entries with Source=cdi and ID like nvidia.com/gpu=all.",
engine.name()
);
device_ids
}
fn has_cdi_gpu_device(device_id: &str) -> bool {
discovered_cdi_gpu_device_ids()
.iter()
.any(|discovered| discovered == device_id)
}
fn runtime_gpu_lines(gpu_device: &str) -> Vec<String> {
let engine = ContainerEngine::from_env();
let image = gpu_probe_image();
let output = engine
.command()
.args([
"run",
"--rm",
"--device",
gpu_device,
image.as_str(),
"nvidia-smi",
"-L",
])
.output()
.unwrap_or_else(|err| {
panic!(
"failed to run {} GPU probe container with image {image}: {err}",
engine.name()
)
});
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{stdout}{stderr}");
assert!(
output.status.success(),
"{} GPU probe failed for {gpu_device} with image {image} and status {:?}:\n{}",
engine.name(),
output.status.code(),
combined
);
let lines = gpu_lines(&stdout);
assert!(
!lines.is_empty(),
"{} GPU probe for {gpu_device} did not report any GPU lines with image {image}:\n{combined}",
engine.name()
);
lines
}
async fn sandbox_gpu_lines(gpu_device: Option<&str>) -> Vec<String> {
let mut args = vec!["--gpu"];
if let Some(gpu_device) = gpu_device {
args.push("--gpu-device");
args.push(gpu_device);
}
args.extend(["--", "sh", "-lc", "nvidia-smi -L"]);
let mut guard = SandboxGuard::create(&args)
.await
.expect("GPU sandbox create should succeed");
let lines = gpu_lines(&guard.create_output);
guard.cleanup().await;
lines
}
async fn sandbox_create_output(args: &[&str]) -> String {
let mut cmd = openshell_cmd();
cmd.arg("sandbox").arg("create").args(args);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let output = timeout(SANDBOX_CREATE_TIMEOUT, cmd.output())
.await
.expect("sandbox create should complete before timeout")
.expect("openshell command should spawn");
assert!(
!output.status.success(),
"sandbox create unexpectedly succeeded with invalid GPU device"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
strip_ansi(&format!("{stdout}{stderr}"))
}
#[tokio::test]
async fn gpu_request_without_device_matches_plain_all_gpu_container() {
if !has_cdi_gpu_device(CDI_GPU_DEVICE_ALL) {
eprintln!(
"skipping default GPU request test because {CDI_GPU_DEVICE_ALL} was not discovered"
);
return;
}
let expected = runtime_gpu_lines(CDI_GPU_DEVICE_ALL);
let actual = sandbox_gpu_lines(None).await;
assert_eq!(
actual, expected,
"default GPU request should expose the same GPU lines as a plain all-GPU container"
);
}
#[tokio::test]
async fn gpu_request_for_each_discovered_device_matches_plain_container() {
let device_ids: Vec<_> = discovered_cdi_gpu_device_ids()
.into_iter()
.filter(|device_id| device_id != CDI_GPU_DEVICE_ALL)
.collect();
if device_ids.is_empty() {
eprintln!(
"skipping per-device GPU request test because no per-device NVIDIA CDI IDs were discovered"
);
return;
}
for gpu_device in device_ids {
let expected = runtime_gpu_lines(&gpu_device);
let actual = sandbox_gpu_lines(Some(&gpu_device)).await;
assert_eq!(
actual, expected,
"GPU request for {gpu_device} should expose the same GPU lines as a plain container"
);
}
}
#[tokio::test]
async fn gpu_all_device_request_matches_plain_all_gpu_container() {
if !has_cdi_gpu_device(CDI_GPU_DEVICE_ALL) {
eprintln!(
"skipping explicit all-GPU request test because {CDI_GPU_DEVICE_ALL} was not discovered"
);
return;
}
let expected = runtime_gpu_lines(CDI_GPU_DEVICE_ALL);
let actual = sandbox_gpu_lines(Some(CDI_GPU_DEVICE_ALL)).await;
assert_eq!(
actual, expected,
"explicit all-GPU request should expose the same GPU lines as a plain all-GPU container"
);
}
#[tokio::test]
async fn gpu_invalid_device_request_fails() {
let output = sandbox_create_output(&[
"--gpu",
"--gpu-device",
"nvidia.com/gpu=invalid",
"--",
"sh",
"-lc",
"nvidia-smi -L",
])
.await;
let output_lower = output.to_ascii_lowercase();
assert!(
output.contains("nvidia.com/gpu=invalid")
|| output_lower.contains("cdi")
|| output_lower.contains("device"),
"expected invalid GPU device failure to mention the requested device or CDI/device resolution:\n{output}"
);
}
#[test]
fn parse_cdi_gpu_device_ids_reads_discovered_devices() {
let info = serde_json::json!({
"DiscoveredDevices": [
{
"Source": "cdi",
"ID": "example.com/device=foo"
},
{
"Source": "cdi",
"ID": "nvidia.com/gpu=0"
},
{
"Source": "cdi",
"ID": "nvidia.com/gpu=all"
}
]
});
assert_eq!(
parse_cdi_gpu_device_ids(&info),
vec![
"nvidia.com/gpu=0".to_string(),
CDI_GPU_DEVICE_ALL.to_string()
]
);
}
#[test]
fn parse_cdi_gpu_device_ids_reads_lowercase_host_discovered_devices() {
let info = serde_json::json!({
"host": {
"discoveredDevices": [
{
"source": "cdi",
"id": "nvidia.com/gpu=1"
},
{
"Source": "cdi",
"ID": "nvidia.com/gpu=1"
},
{
"Source": "udev",
"ID": "nvidia.com/gpu=2"
}
]
}
});
assert_eq!(
parse_cdi_gpu_device_ids(&info),
vec!["nvidia.com/gpu=1".to_string()]
);
}
#[test]
fn parse_cdi_gpu_device_ids_ignores_unexpected_nested_devices() {
let info = serde_json::json!({
"host": {
"devices": [
{
"Source": "cdi",
"ID": "nvidia.com/gpu=2"
}
]
}
});
assert!(parse_cdi_gpu_device_ids(&info).is_empty());
}