Files
OpenShell/e2e/rust/tests/driver_config_volume.rs
50230616d5 refactor(runtime): retire Community image dependencies (#3386)
* feat(sandbox): default to official Alpine sandbox image

default_sandbox_image() now returns docker.io/library/alpine:3.22, a generic
version-qualified official image, so a fresh install no longer depends on the
community sandbox image catalog. All compute drivers (docker, podman,
kubernetes, vm) inherit this fallback.

Part of #3116.

Signed-off-by: Akram
Signed-off-by: Akram <akram.benaissi@gmail.com>

* feat(deploy): default deployment configs to the official Alpine sandbox image

Update the shared gateway default_image, Helm chart values, the standalone
Kubernetes manifest, and the dev gateway task scripts to use
docker.io/library/alpine:3.22 instead of the community base image, consistent
with default_sandbox_image(). GPU e2e image-build base is left unchanged (CUDA
needs a glibc base).

Part of #3116.

Signed-off-by: Akram
Signed-off-by: Akram <akram.benaissi@gmail.com>

* feat(driver): default to numeric non-root identity for USER-less images

With the default sandbox image now Alpine, images that declare no OCI USER
must start instead of being rejected. When the image declares no USER and
the policy requests none, the Podman and Docker drivers now supply a numeric
non-root identity (DEFAULT_SANDBOX_UID/GID = 1000) instead of rejecting,
matching the numeric-identity behavior of the Kubernetes and VM drivers. The
supervisor's resolved-identity path runs the sandbox as a synthesized
non-root account without the account existing in the image. Images that
declare a USER keep the OCI resolution path unchanged.

Part of #3116.

Signed-off-by: Akram <akram.benaissi@gmail.com>
Signed-off-by: Evan Lezar <elezar@nvidia.com>

* test(conformance): use Alpine workload image

Signed-off-by: Evan Lezar <elezar@nvidia.com>

* refactor(policy): drop community image /app path from default policy

The restrictive default policy granted read-only access to /app, a directory
that only existed in the community base image. A generic Alpine default has no
/app, so remove it. Landlock best-effort already ignores absent paths; this
just stops advertising a community-specific layout in the default.

Part of #3116.

Signed-off-by: Akram
Signed-off-by: Akram <akram.benaissi@gmail.com>

* docs(config): document Alpine default images

Signed-off-by: Evan Lezar <elezar@nvidia.com>

* fix(podman): report early sandbox termination

Signed-off-by: Evan Lezar <elezar@nvidia.com>

* fix(podman): initialize rootless workspace ownership

Signed-off-by: Evan Lezar <elezar@nvidia.com>

* fix(sandbox): qualify NVIDIA Ubuntu default

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(podman): initialize rootful default workspace

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* feat(sftp): add native sandbox adapter

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(sftp): gate runtime helper support to Linux

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(sftp): support standard OpenSSH file operations

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(sftp): harden rename and special file handling

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* refactor(runtime): remove community image dependencies

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* test(e2e): build provider readiness tool fixture

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(e2e): use a dedicated Noble fixture for Docker tests

Signed-off-by: Evan Lezar <elezar@nvidia.com>

---------

Signed-off-by: Akram
Signed-off-by: Akram <akram.benaissi@gmail.com>
Signed-off-by: Evan Lezar <elezar@nvidia.com>
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
Co-authored-by: Evan Lezar <elezar@nvidia.com>
Co-authored-by: Drew Newberry <anewberry@nvidia.com>
2026-09-22 14:43:51 +02:00

538 lines
17 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
#![cfg(feature = "e2e-local-container-driver")]
use std::fs;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use bollard::Docker;
use bollard::models::{ContainerCreateBody, HostConfig, Mount, MountTypeEnum, VolumeCreateRequest};
use bollard::query_parameters::{
CreateContainerOptionsBuilder, CreateImageOptionsBuilder, LogsOptions, RemoveContainerOptions,
RemoveVolumeOptionsBuilder, StartContainerOptions, WaitContainerOptions,
};
use futures_util::TryStreamExt;
use openshell_e2e::harness::container::{ImageGuard, e2e_driver};
use openshell_e2e::harness::sandbox::SandboxGuard;
use serde_json::{Map, Value};
const TEST_IMAGE: &str = "nvcr.io/nvidia/base/ubuntu:24.04";
const VOLUME_TARGET: &str = "/sandbox/e2e-volume";
const BIND_TARGET: &str = "/sandbox/e2e-bind";
#[cfg(feature = "e2e-docker")]
const OCI_VOLUME_TARGET: &str = "/workspace/project/e2e-volume";
#[cfg(feature = "e2e-docker")]
const OCI_USER_DOCKERFILE: &str = r#"FROM public.ecr.aws/docker/library/python:3.13-slim
RUN apt-get update && apt-get install -y --no-install-recommends iproute2 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace/project
RUN chown 2234:2235 .
USER 2234:2235
CMD ["sleep", "infinity"]
"#;
static NEXT_VOLUME_ID: AtomicU64 = AtomicU64::new(0);
struct VolumeGuard {
docker: Docker,
name: String,
}
impl VolumeGuard {
async fn create(driver: &str) -> Result<Self, String> {
let name = unique_volume_name(driver);
let docker = connect_container_api(driver).await?;
docker
.create_volume(VolumeCreateRequest {
name: Some(name.clone()),
..Default::default()
})
.await
.map_err(|err| format!("create {driver} volume {name}: {err}"))?;
Ok(Self { docker, name })
}
}
impl Drop for VolumeGuard {
fn drop(&mut self) {
let docker = self.docker.clone();
let name = self.name.clone();
tokio::spawn(async move {
let _ = docker
.remove_volume(
&name,
Some(RemoveVolumeOptionsBuilder::new().force(true).build()),
)
.await;
});
}
}
#[tokio::test]
async fn sandbox_mounts_existing_driver_config_volume() {
let driver = e2e_driver().expect("OPENSHELL_E2E_DRIVER must be set by the e2e wrapper");
assert!(
matches!(driver.as_str(), "docker" | "podman"),
"driver_config volume e2e requires docker or podman, got {driver}"
);
let volume = VolumeGuard::create(&driver)
.await
.expect("create named test volume");
seed_volume(&volume).await.expect("seed named test volume");
let driver_config = format!(
r#"{{"{driver}":{{"mounts":[{{"type":"volume","source":"{}","target":"{VOLUME_TARGET}","read_only":false}}]}}}}"#,
volume.name
);
let mut sandbox = SandboxGuard::create(&[
"--driver-config-json",
&driver_config,
"--",
"sh",
"-lc",
"set -eu; test \"$(cat /sandbox/e2e-volume/input.txt)\" = host-volume-ok; printf sandbox-volume-ok > /sandbox/e2e-volume/output.txt; cat /sandbox/e2e-volume/output.txt",
])
.await
.expect("sandbox create with driver-config volume");
assert!(
sandbox.create_output.contains("sandbox-volume-ok"),
"sandbox should read and write the mounted volume:\n{}",
sandbox.create_output
);
sandbox.cleanup().await;
verify_volume(&volume)
.await
.expect("verify sandbox wrote to named test volume");
}
#[tokio::test]
#[cfg(feature = "e2e-docker")]
async fn oci_workspace_preparation_skips_nested_volume_ownership() {
let driver = e2e_driver().expect("OPENSHELL_E2E_DRIVER must be set by the e2e wrapper");
assert!(
driver == "docker",
"OCI workspace mount e2e requires docker, got {driver}"
);
let volume = VolumeGuard::create(&driver)
.await
.expect("create named test volume");
seed_volume(&volume).await.expect("seed named test volume");
let image_context = tempfile::tempdir().expect("create OCI image context");
let dockerfile = image_context.path().join("Dockerfile");
fs::write(&dockerfile, OCI_USER_DOCKERFILE).expect("write OCI image Dockerfile");
let image = ImageGuard::build(
&format!("{driver}-oci-user"),
&dockerfile,
image_context.path(),
)
.expect("build OCI-user image with selected container engine");
let driver_config = format!(
r#"{{"{driver}":{{"mounts":[{{"type":"volume","source":"{}","target":"{OCI_VOLUME_TARGET}","read_only":false}}]}}}}"#,
volume.name
);
let mut sandbox = SandboxGuard::create_keep_with_args(
&[
"--from",
image.tag(),
"--driver-config-json",
&driver_config,
"--no-tty",
],
&[
"sh",
"-lc",
"set -eu; test \"$(id -u):$(id -g)\" = 2234:2235; \
test \"$(pwd -P)\" = /workspace/project; test \"$HOME\" = /workspace/project; \
test \"$(stat -c %u:%g /workspace/project/e2e-volume/input.txt)\" = 0:0; \
touch direct-write; echo Ready; sleep infinity",
],
"Ready",
)
.await
.expect("create OCI-user sandbox with nested volume");
let ssh_output = sandbox
.exec(&[
"sh",
"-lc",
"set -eu; test \"$(pwd -P)\" = /workspace/project; \
test \"$HOME\" = /workspace/project; \
test \"$(stat -c %u:%g /workspace/project/e2e-volume/input.txt)\" = 0:0; \
touch ssh-write; echo nested-mount-owner-ok",
])
.await
.expect("SSH child should preserve nested volume ownership");
assert!(
ssh_output.contains("nested-mount-owner-ok"),
"expected nested mount ownership marker:\n{ssh_output}"
);
sandbox.cleanup().await;
verify_volume_ownership(&volume)
.await
.expect("nested volume ownership should remain unchanged");
}
#[tokio::test]
async fn sandbox_mounts_enabled_driver_config_bind() {
let driver = e2e_driver().expect("OPENSHELL_E2E_DRIVER must be set by the e2e wrapper");
assert!(
matches!(driver.as_str(), "docker" | "podman"),
"driver_config bind e2e requires docker or podman, got {driver}"
);
let cwd = std::env::current_dir().expect("resolve current dir");
let host_dir = tempfile::Builder::new()
.prefix("openshell-e2e-driver-config-bind-")
.tempdir_in(cwd)
.expect("create bind mount host dir");
fs::set_permissions(host_dir.path(), fs::Permissions::from_mode(0o777))
.expect("make bind mount host dir writable by sandbox user");
let input_path = host_dir.path().join("input.txt");
fs::write(&input_path, "host-bind-ok").expect("seed bind mount host dir");
fs::set_permissions(&input_path, fs::Permissions::from_mode(0o666))
.expect("make bind mount input readable by sandbox user");
let bind_source = bind_mount_source_path(&driver, host_dir.path());
let bind_mount = serde_json::json!({
"type": "bind",
"source": bind_source,
"target": BIND_TARGET,
"read_only": false,
"selinux_label": "private"
});
let driver_config = driver_config_mount_json(&driver, &bind_mount);
// Host bind mounts are explicitly unsafe: this test validates driver mount
// wiring, not Landlock enforcement over Docker Desktop's fakeowner mounts.
let policy = write_bind_mount_policy().expect("write bind mount policy");
let policy_path = policy.path().to_str().expect("policy path must be utf-8");
let mut sandbox = SandboxGuard::create(&[
"--policy",
policy_path,
"--driver-config-json",
&driver_config,
"--",
"sh",
"-lc",
"set -eu; test \"$(cat /sandbox/e2e-bind/input.txt)\" = host-bind-ok; printf sandbox-bind-ok > /sandbox/e2e-bind/output.txt; cat /sandbox/e2e-bind/output.txt",
])
.await
.expect("sandbox create with driver-config bind mount");
assert!(
sandbox.create_output.contains("sandbox-bind-ok"),
"sandbox should read and write the bind mount:\n{}",
sandbox.create_output
);
sandbox.cleanup().await;
let output = fs::read_to_string(host_dir.path().join("output.txt"))
.expect("read sandbox output from bind mount host dir");
assert_eq!(output, "sandbox-bind-ok");
}
fn write_bind_mount_policy() -> Result<tempfile::NamedTempFile, String> {
let mut file =
tempfile::NamedTempFile::new().map_err(|err| format!("create bind policy: {err}"))?;
file.write_all(
br#"version: 1
filesystem_policy:
include_workdir: false
read_only:
- "/bin"
- "/dev"
- "/etc"
- "/lib"
- "/proc"
- "/usr"
read_write:
- "/sandbox/e2e-bind"
- "/tmp"
landlock:
compatibility: best_effort
process:
run_as_user: sandbox
run_as_group: sandbox
"#,
)
.map_err(|err| format!("write bind policy: {err}"))?;
Ok(file)
}
async fn seed_volume(volume: &VolumeGuard) -> Result<(), String> {
run_volume_container(
volume,
"seed",
false,
"set -eu; chmod 0777 /vol; printf host-volume-ok > /vol/input.txt",
)
.await?;
Ok(())
}
async fn verify_volume(volume: &VolumeGuard) -> Result<(), String> {
let output = run_volume_container(
volume,
"verify",
true,
"set -eu; test \"$(cat /vol/input.txt)\" = host-volume-ok; test \"$(cat /vol/output.txt)\" = sandbox-volume-ok; echo volume-ok",
)
.await?;
if !output.contains("volume-ok") {
return Err(format!(
"volume verification did not print expected marker:\n{output}"
));
}
Ok(())
}
#[cfg(feature = "e2e-docker")]
async fn verify_volume_ownership(volume: &VolumeGuard) -> Result<(), String> {
let output = run_volume_container(
volume,
"verify-owner",
true,
"set -eu; test \"$(stat -c %u:%g /vol/input.txt)\" = 0:0; echo owner-ok",
)
.await?;
if !output.contains("owner-ok") {
return Err(format!(
"volume ownership verification did not print expected marker:\n{output}"
));
}
Ok(())
}
async fn run_volume_container(
volume: &VolumeGuard,
purpose: &str,
read_only: bool,
script: &str,
) -> Result<String, String> {
ensure_test_image(&volume.docker).await?;
let container_name = format!("{}-{purpose}", volume.name);
let create_options = CreateContainerOptionsBuilder::new()
.name(&container_name)
.build();
let host_config = HostConfig {
mounts: Some(vec![Mount {
target: Some("/vol".to_string()),
source: Some(volume.name.clone()),
typ: Some(MountTypeEnum::VOLUME),
read_only: Some(read_only),
..Default::default()
}]),
..Default::default()
};
volume
.docker
.create_container(
Some(create_options),
ContainerCreateBody {
image: Some(TEST_IMAGE.to_string()),
user: Some("0:0".to_string()),
entrypoint: Some(vec!["sh".to_string()]),
cmd: Some(vec!["-lc".to_string(), script.to_string()]),
attach_stdout: Some(true),
attach_stderr: Some(true),
host_config: Some(host_config),
..Default::default()
},
)
.await
.map_err(|err| format!("create helper container {container_name}: {err}"))?;
let result = run_created_container(volume, &container_name).await;
let remove_result = volume
.docker
.remove_container(&container_name, None::<RemoveContainerOptions>)
.await;
match (result, remove_result) {
(Ok(output), Ok(())) => Ok(output),
(Ok(_), Err(err)) => Err(format!("remove helper container {container_name}: {err}")),
(Err(err), Ok(())) => Err(err),
(Err(err), Err(remove_err)) => Err(format!(
"{err}\nremove helper container {container_name}: {remove_err}"
)),
}
}
async fn ensure_test_image(docker: &Docker) -> Result<(), String> {
if docker.inspect_image(TEST_IMAGE).await.is_ok() {
return Ok(());
}
let pull_events = docker
.create_image(
Some(
CreateImageOptionsBuilder::new()
.from_image(TEST_IMAGE)
.build(),
),
None,
None,
)
.try_collect::<Vec<_>>()
.await
.map_err(|err| format!("pull helper image {TEST_IMAGE}: {err}"))?;
let pull_errors = pull_events
.iter()
.filter_map(|event| {
event
.error_detail
.as_ref()
.and_then(|detail| detail.message.as_deref())
})
.collect::<Vec<_>>();
if pull_errors.is_empty() {
return Ok(());
}
Err(format!(
"pull helper image {TEST_IMAGE} failed:\n{}",
pull_errors.join("\n")
))
}
async fn run_created_container(
volume: &VolumeGuard,
container_name: &str,
) -> Result<String, String> {
volume
.docker
.start_container(container_name, None::<StartContainerOptions>)
.await
.map_err(|err| format!("start helper container {container_name}: {err}"))?;
let wait_result = volume
.docker
.wait_container(container_name, None::<WaitContainerOptions>)
.try_collect::<Vec<_>>()
.await;
let logs = volume
.docker
.logs(
container_name,
Some(LogsOptions {
stdout: true,
stderr: true,
tail: "all".to_string(),
..Default::default()
}),
)
.try_collect::<Vec<_>>()
.await
.map(|chunks| {
chunks
.into_iter()
.map(|chunk| chunk.to_string())
.collect::<String>()
});
match (wait_result, logs) {
(Ok(_), Ok(output)) => Ok(output),
(Ok(_), Err(err)) => Err(format!(
"read helper container {container_name} logs: {err}"
)),
(Err(err), Ok(output)) => Err(format!(
"helper container {container_name} failed: {err}\n{output}"
)),
(Err(err), Err(log_err)) => Err(format!(
"helper container {container_name} failed: {err}\nread logs failed: {log_err}"
)),
}
}
async fn connect_container_api(driver: &str) -> Result<Docker, String> {
let docker = match driver {
"docker" => Docker::connect_with_local_defaults()
.map_err(|err| format!("connect to Docker API: {err}"))?,
"podman" => {
let socket = podman_socket_path()?;
let socket_display = socket.display().to_string();
Docker::connect_with_unix(
socket
.to_str()
.ok_or_else(|| format!("podman socket path is not UTF-8: {socket_display}"))?,
120,
bollard::API_DEFAULT_VERSION,
)
.map_err(|err| format!("connect to Podman Docker-compatible API: {err}"))?
}
other => return Err(format!("unsupported e2e driver for volume API: {other}")),
};
docker
.ping()
.await
.map_err(|err| format!("ping {driver} Docker-compatible API: {err}"))?;
Ok(docker)
}
fn podman_socket_path() -> Result<PathBuf, String> {
let path = std::env::var_os("OPENSHELL_PODMAN_SOCKET").ok_or_else(|| {
"OPENSHELL_PODMAN_SOCKET must be set by e2e/with-podman-gateway.sh".to_string()
})?;
Ok(PathBuf::from(path))
}
fn unique_volume_name(driver: &str) -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after Unix epoch")
.as_nanos();
let sequence = NEXT_VOLUME_ID.fetch_add(1, Ordering::Relaxed);
format!(
"openshell-e2e-driver-config-volume-{driver}-{}-{nanos}-{sequence}",
std::process::id()
)
}
fn driver_config_mount_json(driver: &str, mount: &Value) -> String {
let mut root = Map::new();
root.insert(
driver.to_string(),
serde_json::json!({
"mounts": [mount]
}),
);
Value::Object(root).to_string()
}
fn bind_mount_source_path(driver: &str, path: &Path) -> PathBuf {
if driver == "docker" {
github_actions_host_work_path(path).unwrap_or_else(|| path.to_path_buf())
} else {
path.to_path_buf()
}
}
fn github_actions_host_work_path(path: &Path) -> Option<PathBuf> {
if std::env::var("GITHUB_ACTIONS").ok().as_deref() != Some("true") {
return None;
}
let relative = path.strip_prefix("/__w").ok()?;
let mapped = Path::new("/home/runner/_work").join(relative);
mapped.exists().then_some(mapped)
}