From 8483221ae653619bcb9c474a73b66dcb18fa6d93 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 5 Jun 2026 13:55:44 -0700 Subject: [PATCH 1/8] feat(drivers): support docker and podman config mounts Signed-off-by: Drew Newberry --- Cargo.lock | 3 + architecture/compute-runtimes.md | 6 + crates/openshell-driver-docker/Cargo.toml | 2 + crates/openshell-driver-docker/README.md | 35 ++ crates/openshell-driver-docker/src/lib.rs | 372 ++++++++++- crates/openshell-driver-docker/src/tests.rs | 162 +++++ crates/openshell-driver-podman/Cargo.toml | 1 + crates/openshell-driver-podman/README.md | 37 ++ crates/openshell-driver-podman/src/client.rs | 17 + .../openshell-driver-podman/src/container.rs | 584 +++++++++++++++++- crates/openshell-driver-podman/src/driver.rs | 43 +- crates/openshell-driver-podman/src/grpc.rs | 1 + docs/reference/sandbox-compute-drivers.mdx | 98 +++ docs/sandboxes/manage-sandboxes.mdx | 23 + 14 files changed, 1362 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4bc657be3..04b561899 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3481,7 +3481,9 @@ dependencies = [ "bytes", "futures", "openshell-core", + "prost-types", "serde", + "serde_json", "tar", "temp-env", "tempfile", @@ -3528,6 +3530,7 @@ dependencies = [ "miette", "nix", "openshell-core", + "prost-types", "rustix 1.1.4", "serde", "serde_json", diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index b70a2fccc..585c13e48 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -40,6 +40,12 @@ template resource limits. Docker and Podman apply them as runtime limits. Kubernetes mirrors each limit into the matching request. VM accepts the fields but currently ignores them. +Docker and Podman also accept per-sandbox driver-config mounts for existing +runtime-managed named volumes and tmpfs mounts. Podman additionally accepts +image mounts through its image-volume API. User-supplied host bind mounts are +excluded from the driver-config contract; bind mounts remain reserved for +driver-owned supervisor, token, and TLS material. + Kubernetes deployments may set an AppArmor profile on sandbox agent containers through the driver configuration. The Helm chart defaults sandbox agents to `Unconfined` so runtime/default AppArmor profiles do not block supervisor diff --git a/crates/openshell-driver-docker/Cargo.toml b/crates/openshell-driver-docker/Cargo.toml index 0cdc205ed..bc0680304 100644 --- a/crates/openshell-driver-docker/Cargo.toml +++ b/crates/openshell-driver-docker/Cargo.toml @@ -20,6 +20,8 @@ tokio-stream = { workspace = true } tracing = { workspace = true } bytes = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } +prost-types = { workspace = true } bollard = { version = "0.20" } tar = "0.4" tempfile = "3" diff --git a/crates/openshell-driver-docker/README.md b/crates/openshell-driver-docker/README.md index ea57f44e4..d71b18a84 100644 --- a/crates/openshell-driver-docker/README.md +++ b/crates/openshell-driver-docker/README.md @@ -36,6 +36,41 @@ contract: The agent child process does not retain these supervisor privileges. +## Driver Config Mounts + +The gateway forwards the `docker` block from `--driver-config-json` to this +driver. The driver accepts user-supplied `mounts` entries with these Docker +mount types: + +- `volume`: mounts an existing Docker named volume. The driver validates that + the volume exists before provisioning and never creates or removes it. +- `tmpfs`: mounts an in-memory filesystem with optional `options`, + `size_bytes`, and `mode`. + +Host bind mounts and image mounts are intentionally not part of the Docker +driver-config schema. The driver still uses internal bind mounts for +OpenShell-owned supervisor, token, and TLS material. + +Docker `volume` mounts may include `subpath`. Mount targets must be absolute +container paths and must not replace the workspace root (`/sandbox`) or overlap +OpenShell supervisor files, auth material, TLS material, or `/run/netns`. + +Example NFS usage relies on Docker's named-volume support rather than a host +bind: + +```shell +docker volume create \ + --driver local \ + --opt type=nfs \ + --opt o=addr=10.0.0.10,rw,nfsvers=4 \ + --opt device=:/exports/work \ + work-nfs + +openshell sandbox create \ + --driver-config-json '{"docker":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work"}]}}' \ + -- claude +``` + ## Supervisor Binary Resolution The Docker driver bind-mounts a host-side Linux `openshell-sandbox` binary into diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 864d91f22..7e4f2bc40 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -9,8 +9,9 @@ use bollard::Docker; use bollard::errors::Error as BollardError; use bollard::models::{ ContainerCreateBody, ContainerSummary, ContainerSummaryStateEnum, CreateImageInfo, - DeviceRequest, EndpointSettings, HostConfig, NetworkCreateRequest, NetworkingConfig, - ProgressDetail, RestartPolicy, RestartPolicyNameEnum, SystemInfo, + DeviceRequest, EndpointSettings, HostConfig, Mount, MountTmpfsOptions, MountTypeEnum, + MountVolumeOptions, NetworkCreateRequest, NetworkingConfig, ProgressDetail, RestartPolicy, + RestartPolicyNameEnum, SystemInfo, }; use bollard::query_parameters::{ CreateContainerOptionsBuilder, CreateImageOptions, DownloadFromContainerOptionsBuilder, @@ -41,7 +42,7 @@ use openshell_core::proto::compute::v1::{ watch_sandboxes_event, }; use openshell_core::{Config, Error, Result as CoreResult}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::io::Read; use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; @@ -258,6 +259,34 @@ struct DockerResourceLimits { memory_bytes: Option, } +#[derive(Debug, Clone, Default, serde::Deserialize)] +#[serde(default, deny_unknown_fields)] +struct DockerSandboxDriverConfig { + mounts: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +enum DockerDriverMountConfig { + Volume { + source: String, + target: String, + #[serde(default)] + read_only: bool, + #[serde(default)] + subpath: Option, + }, + Tmpfs { + target: String, + #[serde(default)] + options: Vec, + #[serde(default)] + size_bytes: Option, + #[serde(default)] + mode: Option, + }, +} + type WatchStream = Pin> + Send + 'static>>; @@ -391,6 +420,7 @@ impl DockerComputeDriver { )); } + let _ = docker_driver_config(template)?; let _ = docker_resource_limits(template)?; Ok(()) } @@ -418,6 +448,35 @@ impl DockerComputeDriver { Ok(()) } + async fn validate_user_volume_mounts_available( + &self, + sandbox: &DriverSandbox, + ) -> Result<(), Status> { + let template = sandbox + .spec + .as_ref() + .and_then(|spec| spec.template.as_ref()) + .ok_or_else(|| Status::invalid_argument("sandbox.spec.template is required"))?; + let config = docker_driver_config(template)?; + for mount in config.mounts { + if let DockerDriverMountConfig::Volume { source, .. } = mount { + match self.docker.inspect_volume(source.trim()).await { + Ok(_) => {} + Err(err) if is_not_found_error(&err) => { + return Err(Status::failed_precondition(format!( + "docker volume '{}' does not exist", + source.trim() + ))); + } + Err(err) => { + return Err(internal_status("inspect docker volume", err)); + } + } + } + } + Ok(()) + } + async fn get_sandbox_snapshot( &self, sandbox_id: &str, @@ -455,6 +514,7 @@ impl DockerComputeDriver { async fn create_sandbox_inner(&self, sandbox: &DriverSandbox) -> Result<(), Status> { Self::validate_sandbox(sandbox, &self.config)?; Self::validate_sandbox_auth(sandbox)?; + self.validate_user_volume_mounts_available(sandbox).await?; let _ = build_container_create_body(sandbox, &self.config)?; if self @@ -1165,6 +1225,7 @@ impl ComputeDriver for DockerComputeDriver { .sandbox .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; Self::validate_sandbox(&sandbox, &self.config)?; + self.validate_user_volume_mounts_available(&sandbox).await?; Ok(Response::new(ValidateSandboxCreateResponse {})) } @@ -1505,6 +1566,309 @@ fn attach_docker_progress_metadata( } } +fn docker_driver_config( + template: &DriverSandboxTemplate, +) -> Result { + let Some(config) = template.driver_config.as_ref() else { + return Ok(DockerSandboxDriverConfig::default()); + }; + + let json = serde_json::Value::Object(proto_struct_to_json_object(config)); + let config: DockerSandboxDriverConfig = serde_json::from_value(json).map_err(|err| { + Status::failed_precondition(format!("invalid docker driver_config: {err}")) + })?; + validate_docker_driver_mounts(&config.mounts)?; + Ok(config) +} + +fn docker_driver_mounts(template: &DriverSandboxTemplate) -> Result, Status> { + let config = docker_driver_config(template)?; + config.mounts.iter().map(docker_mount_from_config).collect() +} + +fn docker_mount_from_config(config: &DockerDriverMountConfig) -> Result { + match config { + DockerDriverMountConfig::Volume { + source, + target, + read_only, + subpath, + } => Ok(Mount { + typ: Some(MountTypeEnum::VOLUME), + source: Some(validate_mount_source(source, "volume source")?), + target: Some(validate_container_mount_target(target)?), + read_only: Some(*read_only), + volume_options: subpath + .as_ref() + .map(|subpath| { + Ok::(MountVolumeOptions { + subpath: Some(validate_mount_subpath(subpath)?), + ..Default::default() + }) + }) + .transpose()?, + ..Default::default() + }), + DockerDriverMountConfig::Tmpfs { + target, + options, + size_bytes, + mode, + } => Ok(Mount { + typ: Some(MountTypeEnum::TMPFS), + target: Some(validate_container_mount_target(target)?), + tmpfs_options: Some(MountTmpfsOptions { + size_bytes: validate_optional_positive_integral_i64( + *size_bytes, + "tmpfs size_bytes", + )?, + mode: validate_optional_nonnegative_integral_i64(*mode, "tmpfs mode")?, + options: (!options.is_empty()) + .then(|| { + options + .iter() + .map(|option| docker_tmpfs_option(option)) + .collect::, _>>() + }) + .transpose()?, + }), + ..Default::default() + }), + } +} + +fn validate_docker_driver_mounts(mounts: &[DockerDriverMountConfig]) -> Result<(), Status> { + let mut targets = HashSet::new(); + for mount in mounts { + let target = match mount { + DockerDriverMountConfig::Volume { + source, + target, + subpath, + .. + } => { + validate_mount_source(source, "volume source")?; + if let Some(subpath) = subpath { + validate_mount_subpath(subpath)?; + } + target + } + DockerDriverMountConfig::Tmpfs { + target, + options, + size_bytes, + mode, + } => { + validate_optional_positive_integral_i64(*size_bytes, "tmpfs size_bytes")?; + validate_optional_nonnegative_integral_i64(*mode, "tmpfs mode")?; + for option in options { + docker_tmpfs_option(option)?; + } + target + } + }; + let target = validate_container_mount_target(target)?; + if !targets.insert(target.clone()) { + return Err(Status::failed_precondition(format!( + "duplicate docker driver_config mount target '{target}'" + ))); + } + } + Ok(()) +} + +fn validate_mount_source(source: &str, field: &str) -> Result { + let source = source.trim(); + if source.is_empty() { + return Err(Status::failed_precondition(format!( + "{field} must not be empty" + ))); + } + if source.as_bytes().contains(&0) { + return Err(Status::failed_precondition(format!( + "{field} must not contain NUL bytes" + ))); + } + Ok(source.to_string()) +} + +fn validate_mount_subpath(subpath: &str) -> Result { + let subpath = subpath.trim(); + if subpath.is_empty() { + return Err(Status::failed_precondition( + "mount subpath must not be empty", + )); + } + let path = Path::new(subpath); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(Status::failed_precondition( + "mount subpath must be relative and must not contain '..'", + )); + } + Ok(subpath.to_string()) +} + +fn validate_optional_positive_integral_i64( + value: Option, + field: &str, +) -> Result, Status> { + let Some(value) = validate_optional_integral_i64(value, field)? else { + return Ok(None); + }; + if value <= 0 { + return Err(Status::failed_precondition(format!( + "{field} must be positive" + ))); + } + Ok(Some(value)) +} + +fn validate_optional_nonnegative_integral_i64( + value: Option, + field: &str, +) -> Result, Status> { + let Some(value) = validate_optional_integral_i64(value, field)? else { + return Ok(None); + }; + if value < 0 { + return Err(Status::failed_precondition(format!( + "{field} must be zero or greater" + ))); + } + Ok(Some(value)) +} + +fn validate_optional_integral_i64(value: Option, field: &str) -> Result, Status> { + let Some(value) = value else { + return Ok(None); + }; + if !value.is_finite() || value.fract() != 0.0 { + return Err(Status::failed_precondition(format!( + "{field} must be an integer" + ))); + } + value.to_string().parse::().map(Some).map_err(|_| { + Status::failed_precondition(format!("{field} must be representable as an i64")) + }) +} + +fn docker_tmpfs_option(option: &str) -> Result, Status> { + let option = option.trim(); + if option.is_empty() { + return Err(Status::failed_precondition( + "tmpfs options must not contain empty values", + )); + } + if let Some((key, value)) = option.split_once('=') { + let key = key.trim(); + let value = value.trim(); + if key.is_empty() || value.is_empty() { + return Err(Status::failed_precondition( + "tmpfs key=value options must include both key and value", + )); + } + Ok(vec![key.to_string(), value.to_string()]) + } else { + Ok(vec![option.to_string()]) + } +} + +fn validate_container_mount_target(target: &str) -> Result { + let target = normalize_container_mount_target(target); + if target.is_empty() { + return Err(Status::failed_precondition( + "mount target must not be empty", + )); + } + if target.as_bytes().contains(&0) { + return Err(Status::failed_precondition( + "mount target must not contain NUL bytes", + )); + } + if !target.starts_with('/') { + return Err(Status::failed_precondition( + "mount target must be an absolute container path", + )); + } + if target == "/" { + return Err(Status::failed_precondition( + "mount target must not be the container root", + )); + } + let path = Path::new(&target); + if path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(Status::failed_precondition( + "mount target must not contain '..'", + )); + } + if target == "/sandbox" { + return Err(Status::failed_precondition( + "mount target '/sandbox' is reserved for the OpenShell workspace", + )); + } + for reserved in [ + "/opt/openshell", + "/etc/openshell/auth", + "/etc/openshell/tls", + "/run/netns", + ] { + if path_is_or_under(&target, reserved) { + return Err(Status::failed_precondition(format!( + "mount target '{target}' conflicts with reserved OpenShell path '{reserved}'" + ))); + } + } + Ok(target) +} + +fn normalize_container_mount_target(target: &str) -> String { + let target = target.trim(); + if target == "/" { + return target.to_string(); + } + target.trim_end_matches('/').to_string() +} + +fn path_is_or_under(path: &str, parent: &str) -> bool { + path == parent + || path + .strip_prefix(parent) + .is_some_and(|rest| rest.starts_with('/')) +} + +fn proto_struct_to_json_object( + config: &prost_types::Struct, +) -> serde_json::Map { + config + .fields + .iter() + .map(|(key, value)| (key.clone(), proto_value_to_json(value))) + .collect() +} + +fn proto_value_to_json(value: &prost_types::Value) -> serde_json::Value { + match value.kind.as_ref() { + Some(prost_types::value::Kind::NumberValue(num)) => serde_json::Number::from_f64(*num) + .map_or(serde_json::Value::Null, serde_json::Value::Number), + Some(prost_types::value::Kind::StringValue(val)) => serde_json::Value::String(val.clone()), + Some(prost_types::value::Kind::BoolValue(val)) => serde_json::Value::Bool(*val), + Some(prost_types::value::Kind::StructValue(val)) => { + serde_json::Value::Object(proto_struct_to_json_object(val)) + } + Some(prost_types::value::Kind::ListValue(list)) => { + serde_json::Value::Array(list.values.iter().map(proto_value_to_json).collect()) + } + Some(prost_types::value::Kind::NullValue(_)) | None => serde_json::Value::Null, + } +} + fn build_binds( sandbox: &DriverSandbox, config: &DockerDriverRuntimeConfig, @@ -1736,6 +2100,7 @@ fn build_container_create_body( .as_ref() .ok_or_else(|| Status::invalid_argument("sandbox.spec.template is required"))?; let resource_limits = docker_resource_limits(template)?; + let user_mounts = docker_driver_mounts(template)?; let mut labels = template.labels.clone(); labels.insert( LABEL_MANAGED_BY.to_string(), @@ -1767,6 +2132,7 @@ fn build_container_create_body( pids_limit: docker_pids_limit(config.sandbox_pids_limit)?, device_requests: docker_gpu_device_requests(spec.gpu, &spec.gpu_device), binds: Some(build_binds(sandbox, config)?), + mounts: (!user_mounts.is_empty()).then_some(user_mounts), restart_policy: Some(RestartPolicy { name: Some(RestartPolicyNameEnum::UNLESS_STOPPED), maximum_retry_count: None, diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 4a902a48b..3864217ea 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -78,6 +78,43 @@ fn runtime_config() -> DockerDriverRuntimeConfig { } } +fn json_struct(value: serde_json::Value) -> prost_types::Struct { + match json_value(value).kind { + Some(prost_types::value::Kind::StructValue(value)) => value, + _ => panic!("expected JSON object"), + } +} + +fn json_value(value: serde_json::Value) -> prost_types::Value { + match value { + serde_json::Value::Null => prost_types::Value { kind: None }, + serde_json::Value::Bool(value) => prost_types::Value { + kind: Some(prost_types::value::Kind::BoolValue(value)), + }, + serde_json::Value::Number(value) => prost_types::Value { + kind: value.as_f64().map(prost_types::value::Kind::NumberValue), + }, + serde_json::Value::String(value) => prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue(value)), + }, + serde_json::Value::Array(values) => prost_types::Value { + kind: Some(prost_types::value::Kind::ListValue( + prost_types::ListValue { + values: values.into_iter().map(json_value).collect(), + }, + )), + }, + serde_json::Value::Object(values) => prost_types::Value { + kind: Some(prost_types::value::Kind::StructValue(prost_types::Struct { + fields: values + .into_iter() + .map(|(key, value)| (key, json_value(value))) + .collect(), + })), + }, + } +} + #[test] fn container_visible_endpoint_rewrites_loopback_hosts() { assert_eq!( @@ -522,6 +559,131 @@ fn build_binds_uses_docker_tls_directory() { ); } +#[test] +fn build_container_create_body_includes_driver_config_mounts() { + let mut sandbox = test_sandbox(); + let template = sandbox.spec.as_mut().unwrap().template.as_mut().unwrap(); + template.driver_config = Some(json_struct(serde_json::json!({ + "mounts": [ + { + "type": "volume", + "source": "work-nfs", + "target": "/sandbox/work", + "read_only": true, + "subpath": "project-a" + }, + { + "type": "tmpfs", + "target": "/sandbox/cache", + "options": ["nosuid", "size=1048576"], + "size_bytes": 1_048_576, + "mode": 511 + } + ] + }))); + + let body = build_container_create_body(&sandbox, &runtime_config()).unwrap(); + let mounts = body + .host_config + .unwrap() + .mounts + .expect("driver config mounts should be set"); + + assert_eq!(mounts.len(), 2); + assert_eq!(mounts[0].typ, Some(MountTypeEnum::VOLUME)); + assert_eq!(mounts[0].source.as_deref(), Some("work-nfs")); + assert_eq!(mounts[0].target.as_deref(), Some("/sandbox/work")); + assert_eq!(mounts[0].read_only, Some(true)); + assert_eq!( + mounts[0] + .volume_options + .as_ref() + .and_then(|options| options.subpath.as_deref()), + Some("project-a") + ); + assert_eq!(mounts[1].typ, Some(MountTypeEnum::TMPFS)); + assert_eq!(mounts[1].target.as_deref(), Some("/sandbox/cache")); + assert_eq!( + mounts[1] + .tmpfs_options + .as_ref() + .and_then(|options| options.size_bytes), + Some(1_048_576) + ); +} + +#[test] +fn driver_config_rejects_bind_mounts() { + let mut sandbox = test_sandbox(); + sandbox + .spec + .as_mut() + .unwrap() + .template + .as_mut() + .unwrap() + .driver_config = Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "bind", + "source": "/host/path", + "target": "/sandbox/host" + }] + }))); + + let err = build_container_create_body(&sandbox, &runtime_config()).unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!(err.message().contains("invalid docker driver_config")); +} + +#[test] +fn driver_config_rejects_image_mounts() { + let mut sandbox = test_sandbox(); + sandbox + .spec + .as_mut() + .unwrap() + .template + .as_mut() + .unwrap() + .driver_config = Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "image", + "source": "ghcr.io/acme/tools:latest", + "target": "/opt/tools" + }] + }))); + + let err = build_container_create_body(&sandbox, &runtime_config()).unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!(err.message().contains("invalid docker driver_config")); +} + +#[test] +fn driver_config_rejects_reserved_mount_targets() { + let mut sandbox = test_sandbox(); + sandbox + .spec + .as_mut() + .unwrap() + .template + .as_mut() + .unwrap() + .driver_config = Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "volume", + "source": "work-nfs", + "target": "/etc/openshell/auth/custom" + }] + }))); + + let err = build_container_create_body(&sandbox, &runtime_config()).unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!(err.message().contains("reserved OpenShell path")); +} + #[test] fn build_environment_uses_token_file_without_raw_token_env() { let mut sandbox = test_sandbox(); diff --git a/crates/openshell-driver-podman/Cargo.toml b/crates/openshell-driver-podman/Cargo.toml index 6f2963d92..4a3196697 100644 --- a/crates/openshell-driver-podman/Cargo.toml +++ b/crates/openshell-driver-podman/Cargo.toml @@ -26,6 +26,7 @@ hyper-util = { workspace = true } http-body-util = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +prost-types = { workspace = true } clap = { workspace = true } nix = { workspace = true } rustix = { workspace = true } diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index 77b42ba37..2f238821f 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -50,6 +50,43 @@ The container spec in `container.rs` sets these security-critical fields: The restricted agent child does not retain these supervisor privileges. +## Driver Config Mounts + +The gateway forwards the `podman` block from `--driver-config-json` to this +driver. The driver accepts user-supplied `mounts` entries with these Podman +mount types: + +- `volume`: mounts an existing Podman named volume. The driver validates that + the volume exists before provisioning and never creates or removes it. +- `tmpfs`: mounts an in-memory filesystem with optional `options`, + `size_bytes`, and `mode`. +- `image`: mounts an OCI image through Podman's image-volume API. The driver + pulls the image during provisioning using the sandbox image pull policy. + +Host bind mounts are intentionally not part of the driver-config schema. The +driver still uses internal bind mounts for OpenShell-owned token and TLS +material. + +Podman image and volume mounts do not support `subpath` in OpenShell driver +config. Mount targets must be absolute container paths and must not replace the +workspace root (`/sandbox`) or overlap OpenShell supervisor files, auth +material, TLS material, or `/run/netns`. + +Example NFS usage relies on Podman's named-volume support rather than a host +bind: + +```shell +podman volume create \ + --opt type=nfs \ + --opt o=addr=10.0.0.10,rw,nfsvers=4 \ + --opt device=:/exports/work \ + work-nfs + +openshell sandbox create \ + --driver-config-json '{"podman":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work"}]}}' \ + -- claude +``` + ### Capability Breakdown | Capability | Purpose | diff --git a/crates/openshell-driver-podman/src/client.rs b/crates/openshell-driver-podman/src/client.rs index 834fb21a2..a9d36b842 100644 --- a/crates/openshell-driver-podman/src/client.rs +++ b/crates/openshell-driver-podman/src/client.rs @@ -497,6 +497,23 @@ impl PodmanClient { } } + /// Return whether a named volume exists. Does not create the volume. + pub async fn volume_exists(&self, name: &str) -> Result { + validate_name(name)?; + match self + .request_json::( + hyper::Method::GET, + &format!("/libpod/volumes/{name}/json"), + None, + ) + .await + { + Ok(_) => Ok(true), + Err(PodmanApiError::NotFound(_)) => Ok(false), + Err(e) => Err(e), + } + } + // ── Network operations ─────────────────────────────────────────────── /// Create a bridge network with DNS enabled. Idempotent. diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 13f053e93..fa9d6c466 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -5,10 +5,11 @@ use crate::config::PodmanComputeConfig; use openshell_core::gpu::cdi_gpu_device_ids; -use openshell_core::proto::compute::v1::DriverSandbox; +use openshell_core::proto::compute::v1::{DriverSandbox, DriverSandboxTemplate}; use serde::Serialize; use serde_json::Value; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; +use std::path::Path; /// Returns `true` when `SELinux` is enabled (enforcing or permissive). /// @@ -52,6 +53,46 @@ const TLS_CERT_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.crt"; const TLS_KEY_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.key"; const SANDBOX_TOKEN_MOUNT_PATH: &str = "/etc/openshell/auth/sandbox.jwt"; +#[derive(Debug, Clone, Default, serde::Deserialize)] +#[serde(default, deny_unknown_fields)] +struct PodmanSandboxDriverConfig { + mounts: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +enum PodmanDriverMountConfig { + Volume { + source: String, + target: String, + #[serde(default)] + read_only: bool, + #[serde(default)] + subpath: Option, + }, + Tmpfs { + target: String, + #[serde(default)] + options: Vec, + #[serde(default)] + size_bytes: Option, + #[serde(default)] + mode: Option, + }, + Image { + source: String, + target: String, + #[serde(default = "default_true")] + read_only: bool, + #[serde(default)] + subpath: Option, + }, +} + +fn default_true() -> bool { + true +} + /// Build a Podman container name from the sandbox name. #[must_use] pub fn container_name(sandbox_name: &str) -> String { @@ -166,6 +207,13 @@ struct NamedVolume { options: Vec, } +#[derive(Default)] +struct PodmanUserMounts { + volumes: Vec, + image_volumes: Vec, + mounts: Vec, +} + #[derive(Serialize)] struct HealthConfig { test: Vec, @@ -388,6 +436,325 @@ fn build_devices(sandbox: &DriverSandbox) -> Option> { }) } +pub fn podman_driver_volume_mount_sources(sandbox: &DriverSandbox) -> Result, String> { + let template = sandbox + .spec + .as_ref() + .and_then(|spec| spec.template.as_ref()); + let Some(template) = template else { + return Ok(Vec::new()); + }; + let config = podman_driver_config(template)?; + Ok(config + .mounts + .into_iter() + .filter_map(|mount| match mount { + PodmanDriverMountConfig::Volume { source, .. } => Some(source.trim().to_string()), + _ => None, + }) + .collect()) +} + +pub fn podman_driver_image_mount_sources(sandbox: &DriverSandbox) -> Result, String> { + let template = sandbox + .spec + .as_ref() + .and_then(|spec| spec.template.as_ref()); + let Some(template) = template else { + return Ok(Vec::new()); + }; + let config = podman_driver_config(template)?; + Ok(config + .mounts + .into_iter() + .filter_map(|mount| match mount { + PodmanDriverMountConfig::Image { source, .. } => Some(source.trim().to_string()), + _ => None, + }) + .collect()) +} + +fn podman_user_mounts(sandbox: &DriverSandbox) -> Result { + let template = sandbox + .spec + .as_ref() + .and_then(|spec| spec.template.as_ref()); + let Some(template) = template else { + return Ok(PodmanUserMounts::default()); + }; + let config = podman_driver_config(template)?; + let mut result = PodmanUserMounts::default(); + for mount in config.mounts { + match mount { + PodmanDriverMountConfig::Volume { + source, + target, + read_only, + subpath, + } => { + reject_subpath(subpath.as_deref(), "podman volume mounts")?; + result.volumes.push(NamedVolume { + name: validate_mount_source(&source, "volume source")?, + dest: validate_container_mount_target(&target)?, + options: vec![if read_only { "ro" } else { "rw" }.to_string()], + }); + } + PodmanDriverMountConfig::Tmpfs { + target, + options, + size_bytes, + mode, + } => { + let mut options = validate_tmpfs_options(&options)?; + if options.is_empty() { + options.push("rw".to_string()); + } + if let Some(size_bytes) = + validate_optional_positive_integral_i64(size_bytes, "tmpfs size_bytes")? + { + options.push(format!("size={size_bytes}")); + } + if let Some(mode) = validate_optional_nonnegative_integral_i64(mode, "tmpfs mode")? + { + options.push(format!("mode={mode:o}")); + } + result.mounts.push(Mount { + kind: "tmpfs".into(), + source: "tmpfs".into(), + destination: validate_container_mount_target(&target)?, + options, + }); + } + PodmanDriverMountConfig::Image { + source, + target, + read_only, + subpath, + } => { + reject_subpath(subpath.as_deref(), "podman image mounts")?; + result.image_volumes.push(ImageVolume { + source: validate_mount_source(&source, "image source")?, + destination: validate_container_mount_target(&target)?, + rw: !read_only, + }); + } + } + } + Ok(result) +} + +fn podman_driver_config( + template: &DriverSandboxTemplate, +) -> Result { + let Some(config) = template.driver_config.as_ref() else { + return Ok(PodmanSandboxDriverConfig::default()); + }; + let json = Value::Object(proto_struct_to_json_object(config)); + let config: PodmanSandboxDriverConfig = serde_json::from_value(json) + .map_err(|err| format!("invalid podman driver_config: {err}"))?; + validate_podman_driver_mounts(&config.mounts)?; + Ok(config) +} + +fn validate_podman_driver_mounts(mounts: &[PodmanDriverMountConfig]) -> Result<(), String> { + let mut targets = HashSet::new(); + for mount in mounts { + let target = match mount { + PodmanDriverMountConfig::Volume { + source, + target, + subpath, + .. + } => { + validate_mount_source(source, "volume source")?; + reject_subpath(subpath.as_deref(), "podman volume mounts")?; + target + } + PodmanDriverMountConfig::Tmpfs { + target, + options, + size_bytes, + mode, + } => { + validate_tmpfs_options(options)?; + validate_optional_positive_integral_i64(*size_bytes, "tmpfs size_bytes")?; + validate_optional_nonnegative_integral_i64(*mode, "tmpfs mode")?; + target + } + PodmanDriverMountConfig::Image { + source, + target, + subpath, + .. + } => { + validate_mount_source(source, "image source")?; + reject_subpath(subpath.as_deref(), "podman image mounts")?; + target + } + }; + let target = validate_container_mount_target(target)?; + if !targets.insert(target.clone()) { + return Err(format!( + "duplicate podman driver_config mount target '{target}'" + )); + } + } + Ok(()) +} + +fn validate_mount_source(source: &str, field: &str) -> Result { + let source = source.trim(); + if source.is_empty() { + return Err(format!("{field} must not be empty")); + } + if source.as_bytes().contains(&0) { + return Err(format!("{field} must not contain NUL bytes")); + } + Ok(source.to_string()) +} + +fn reject_subpath(subpath: Option<&str>, mount_type: &str) -> Result<(), String> { + let Some(subpath) = subpath else { + return Ok(()); + }; + if subpath.trim().is_empty() { + return Err("mount subpath must not be empty".to_string()); + } + Err(format!("{mount_type} do not support subpath")) +} + +fn validate_optional_positive_integral_i64( + value: Option, + field: &str, +) -> Result, String> { + let Some(value) = validate_optional_integral_i64(value, field)? else { + return Ok(None); + }; + if value <= 0 { + return Err(format!("{field} must be positive")); + } + Ok(Some(value)) +} + +fn validate_optional_nonnegative_integral_i64( + value: Option, + field: &str, +) -> Result, String> { + let Some(value) = validate_optional_integral_i64(value, field)? else { + return Ok(None); + }; + if value < 0 { + return Err(format!("{field} must be zero or greater")); + } + Ok(Some(value)) +} + +fn validate_optional_integral_i64(value: Option, field: &str) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + if !value.is_finite() || value.fract() != 0.0 { + return Err(format!("{field} must be an integer")); + } + value + .to_string() + .parse::() + .map(Some) + .map_err(|_| format!("{field} must be representable as an i64")) +} + +fn validate_tmpfs_options(options: &[String]) -> Result, String> { + options + .iter() + .map(|option| { + let option = option.trim(); + if option.is_empty() { + return Err("tmpfs options must not contain empty values".to_string()); + } + Ok(option.to_string()) + }) + .collect() +} + +fn validate_container_mount_target(target: &str) -> Result { + let target = normalize_container_mount_target(target); + if target.is_empty() { + return Err("mount target must not be empty".to_string()); + } + if target.as_bytes().contains(&0) { + return Err("mount target must not contain NUL bytes".to_string()); + } + if !target.starts_with('/') { + return Err("mount target must be an absolute container path".to_string()); + } + if target == "/" { + return Err("mount target must not be the container root".to_string()); + } + let path = Path::new(&target); + if path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err("mount target must not contain '..'".to_string()); + } + if target == "/sandbox" { + return Err("mount target '/sandbox' is reserved for the OpenShell workspace".to_string()); + } + for reserved in [ + "/opt/openshell", + "/etc/openshell/auth", + "/etc/openshell/tls", + "/run/netns", + ] { + if path_is_or_under(&target, reserved) { + return Err(format!( + "mount target '{target}' conflicts with reserved OpenShell path '{reserved}'" + )); + } + } + Ok(target) +} + +fn normalize_container_mount_target(target: &str) -> String { + let target = target.trim(); + if target == "/" { + return target.to_string(); + } + target.trim_end_matches('/').to_string() +} + +fn path_is_or_under(path: &str, parent: &str) -> bool { + path == parent + || path + .strip_prefix(parent) + .is_some_and(|rest| rest.starts_with('/')) +} + +fn proto_struct_to_json_object(config: &prost_types::Struct) -> serde_json::Map { + config + .fields + .iter() + .map(|(key, value)| (key.clone(), proto_value_to_json(value))) + .collect() +} + +fn proto_value_to_json(value: &prost_types::Value) -> Value { + match value.kind.as_ref() { + Some(prost_types::value::Kind::NumberValue(num)) => { + serde_json::Number::from_f64(*num).map_or(Value::Null, Value::Number) + } + Some(prost_types::value::Kind::StringValue(val)) => Value::String(val.clone()), + Some(prost_types::value::Kind::BoolValue(val)) => Value::Bool(*val), + Some(prost_types::value::Kind::StructValue(val)) => { + Value::Object(proto_struct_to_json_object(val)) + } + Some(prost_types::value::Kind::ListValue(list)) => { + Value::Array(list.values.iter().map(proto_value_to_json).collect()) + } + Some(prost_types::value::Kind::NullValue(_)) | None => Value::Null, + } +} + /// Build the Podman container creation JSON spec. #[cfg(test)] #[must_use] @@ -396,11 +763,21 @@ pub fn build_container_spec(sandbox: &DriverSandbox, config: &PodmanComputeConfi } #[must_use] +#[cfg(test)] pub fn build_container_spec_with_token( sandbox: &DriverSandbox, config: &PodmanComputeConfig, - token_host_path: Option<&std::path::Path>, + token_host_path: Option<&Path>, ) -> Value { + try_build_container_spec_with_token(sandbox, config, token_host_path) + .expect("valid Podman container spec") +} + +pub fn try_build_container_spec_with_token( + sandbox: &DriverSandbox, + config: &PodmanComputeConfig, + token_host_path: Option<&Path>, +) -> Result { let image = resolve_image(sandbox, config); let name = container_name(&sandbox.name); let vol = volume_name(&sandbox.id); @@ -409,6 +786,7 @@ pub fn build_container_spec_with_token( let labels = build_labels(sandbox); let resource_limits = build_resource_limits(sandbox, config); let devices = build_devices(sandbox); + let user_mounts = podman_user_mounts(sandbox)?; // Network configuration -- always bridge mode. // Matches libpod's network spec format `{name: {opts}}`; the unit-struct @@ -417,27 +795,33 @@ pub fn build_container_spec_with_token( let mut networks = BTreeMap::new(); networks.insert(config.network_name.clone(), NetworkAttachment {}); + let mut volumes = vec![NamedVolume { + name: vol, + dest: "/sandbox".into(), + options: vec!["rw".into()], + }]; + volumes.extend(user_mounts.volumes); + + let mut image_volumes = vec![ImageVolume { + source: config.supervisor_image.clone(), + destination: "/opt/openshell/bin".into(), + rw: false, + }]; + image_volumes.extend(user_mounts.image_volumes); + let container_spec = ContainerSpec { name, image: image.to_string(), labels, env, - volumes: vec![NamedVolume { - name: vol, - dest: "/sandbox".into(), - options: vec!["rw".into()], - }], + volumes, // Side-load the supervisor binary from a standalone OCI image. // Podman resolves image_volumes at the libpod layer, mounting the // image's filesystem at the destination path without starting a // container from it. The supervisor image is FROM scratch with just // the binary at /openshell-sandbox, so it appears at // /opt/openshell/bin/openshell-sandbox. - image_volumes: vec![ImageVolume { - source: config.supervisor_image.clone(), - destination: "/opt/openshell/bin".into(), - rw: false, - }], + image_volumes, hostname: format!("sandbox-{}", sandbox.name), // Override the image's ENTRYPOINT so the supervisor binary runs // directly. Sandbox images (e.g. the community base image) set @@ -608,6 +992,7 @@ pub fn build_container_spec_with_token( options: ro, }); } + m.extend(user_mounts.mounts); m }, // Publish the SSH port with host_port=0 to get an ephemeral host port. @@ -620,7 +1005,7 @@ pub fn build_container_spec_with_token( }], }; - serde_json::to_value(container_spec).expect("ContainerSpec serialization cannot fail") + Ok(serde_json::to_value(container_spec).expect("ContainerSpec serialization cannot fail")) } fn hostadd_entries(config: &PodmanComputeConfig) -> Vec { @@ -699,6 +1084,43 @@ mod tests { static ENV_LOCK: std::sync::LazyLock> = std::sync::LazyLock::new(|| std::sync::Mutex::new(())); + fn json_struct(value: Value) -> prost_types::Struct { + match json_value(value).kind { + Some(prost_types::value::Kind::StructValue(value)) => value, + _ => panic!("expected JSON object"), + } + } + + fn json_value(value: Value) -> prost_types::Value { + match value { + Value::Null => prost_types::Value { kind: None }, + Value::Bool(value) => prost_types::Value { + kind: Some(prost_types::value::Kind::BoolValue(value)), + }, + Value::Number(value) => prost_types::Value { + kind: value.as_f64().map(prost_types::value::Kind::NumberValue), + }, + Value::String(value) => prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue(value)), + }, + Value::Array(values) => prost_types::Value { + kind: Some(prost_types::value::Kind::ListValue( + prost_types::ListValue { + values: values.into_iter().map(json_value).collect(), + }, + )), + }, + Value::Object(values) => prost_types::Value { + kind: Some(prost_types::value::Kind::StructValue(prost_types::Struct { + fields: values + .into_iter() + .map(|(key, value)| (key, json_value(value))) + .collect(), + })), + }, + } + } + #[test] fn parse_cpu_millicore() { assert_eq!(parse_cpu_to_microseconds("500m"), Some(50_000)); @@ -1164,6 +1586,138 @@ mod tests { ); } + #[test] + fn container_spec_includes_driver_config_mounts() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let mut sandbox = test_sandbox("test-id", "test-name"); + sandbox.spec = Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "mounts": [ + { + "type": "volume", + "source": "work-nfs", + "target": "/sandbox/work", + "read_only": true + }, + { + "type": "tmpfs", + "target": "/sandbox/cache", + "options": ["nosuid", "nodev"], + "size_bytes": 1_048_576, + "mode": 511 + }, + { + "type": "image", + "source": "ghcr.io/acme/tools:latest", + "target": "/opt/tools", + "read_only": true + } + ] + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + let spec = build_container_spec(&sandbox, &config); + + let volumes = spec["volumes"] + .as_array() + .expect("volumes should be an array"); + assert!(volumes.iter().any(|volume| { + volume["name"].as_str() == Some("openshell-sandbox-test-id-workspace") + && volume["dest"].as_str() == Some("/sandbox") + })); + assert!(volumes.iter().any(|volume| { + volume["name"].as_str() == Some("work-nfs") + && volume["dest"].as_str() == Some("/sandbox/work") + && volume["options"].as_array().is_some_and(|options| { + options.iter().any(|option| option.as_str() == Some("ro")) + }) + })); + + let mounts = spec["mounts"] + .as_array() + .expect("mounts should be an array"); + assert!(mounts.iter().any(|mount| { + mount["type"].as_str() == Some("tmpfs") + && mount["destination"].as_str() == Some("/sandbox/cache") + && mount["options"].as_array().is_some_and(|options| { + options + .iter() + .any(|option| option.as_str() == Some("size=1048576")) + && options + .iter() + .any(|option| option.as_str() == Some("mode=777")) + }) + })); + + let image_volumes = spec["image_volumes"] + .as_array() + .expect("image_volumes should be an array"); + assert!(image_volumes.iter().any(|volume| { + volume["source"].as_str() == Some("ghcr.io/nvidia/openshell/supervisor:latest") + && volume["destination"].as_str() == Some("/opt/openshell/bin") + })); + assert!(image_volumes.iter().any(|volume| { + volume["source"].as_str() == Some("ghcr.io/acme/tools:latest") + && volume["destination"].as_str() == Some("/opt/tools") + && volume["rw"].as_bool() == Some(false) + })); + } + + #[test] + fn driver_config_rejects_bind_mounts() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let mut sandbox = test_sandbox("test-id", "test-name"); + sandbox.spec = Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "bind", + "source": "/host/path", + "target": "/sandbox/host" + }] + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + + let err = try_build_container_spec_with_token(&sandbox, &config, None).unwrap_err(); + + assert!(err.contains("invalid podman driver_config")); + } + + #[test] + fn driver_config_rejects_reserved_mount_targets() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let mut sandbox = test_sandbox("test-id", "test-name"); + sandbox.spec = Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "volume", + "source": "work-nfs", + "target": "/etc/openshell/tls/custom" + }] + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + + let err = try_build_container_spec_with_token(&sandbox, &config, None).unwrap_err(); + + assert!(err.contains("reserved OpenShell path")); + } + #[test] fn container_spec_uses_configured_host_gateway_ip() { let sandbox = test_sandbox("test-id", "test-name"); @@ -1266,7 +1820,7 @@ mod tests { ..Default::default() }); let config = test_config(); - let token_path = std::path::Path::new("/host/token.jwt"); + let token_path = Path::new("/host/token.jwt"); let spec = build_container_spec_with_token(&sandbox, &config, Some(token_path)); diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 1358d8945..0d3dcccb0 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -277,12 +277,14 @@ impl PodmanComputeDriver { } /// Validate a sandbox before creation. - pub fn validate_sandbox_create( + pub async fn validate_sandbox_create( &self, sandbox: &DriverSandbox, ) -> Result<(), ComputeDriverError> { let gpu_requested = sandbox.spec.as_ref().is_some_and(|s| s.gpu); - Self::validate_gpu_request(gpu_requested) + Self::validate_gpu_request(gpu_requested)?; + self.validate_user_volume_mounts_available(sandbox).await?; + Ok(()) } fn validate_gpu_request(gpu_requested: bool) -> Result<(), ComputeDriverError> { @@ -294,6 +296,27 @@ impl PodmanComputeDriver { Ok(()) } + async fn validate_user_volume_mounts_available( + &self, + sandbox: &DriverSandbox, + ) -> Result<(), ComputeDriverError> { + let volumes = container::podman_driver_volume_mount_sources(sandbox) + .map_err(ComputeDriverError::Precondition)?; + for volume in volumes { + let exists = self + .client + .volume_exists(&volume) + .await + .map_err(ComputeDriverError::from)?; + if !exists { + return Err(ComputeDriverError::Precondition(format!( + "podman volume '{volume}' does not exist" + ))); + } + } + Ok(()) + } + /// Create a sandbox container. pub async fn create_sandbox(&self, sandbox: &DriverSandbox) -> Result<(), ComputeDriverError> { if sandbox.name.is_empty() { @@ -311,6 +334,7 @@ impl PodmanComputeDriver { // resources (volume), so we don't leave orphans when the name is // invalid. let name = validated_container_name(&sandbox.name)?; + self.validate_sandbox_create(sandbox).await?; let vol_name = container::volume_name(&sandbox.id); @@ -352,6 +376,16 @@ impl PodmanComputeDriver { .await .map_err(ComputeDriverError::from)?; + for image in container::podman_driver_image_mount_sources(sandbox) + .map_err(ComputeDriverError::Precondition)? + { + info!(image = %image, policy = %pull_policy, "Ensuring image mount source"); + self.client + .pull_image(&image, pull_policy) + .await + .map_err(ComputeDriverError::from)?; + } + // 2. Create workspace volume. if let Err(e) = self.client.create_volume(&vol_name).await { return Err(ComputeDriverError::from(e)); @@ -365,11 +399,12 @@ impl PodmanComputeDriver { }; // 3. Create container. - let spec = container::build_container_spec_with_token( + let spec = container::try_build_container_spec_with_token( sandbox, &self.config, token_host_path.as_deref(), - ); + ) + .map_err(ComputeDriverError::Precondition)?; match self.client.create_container(&spec).await { Ok(_) => {} Err(PodmanApiError::Conflict(_)) => { diff --git a/crates/openshell-driver-podman/src/grpc.rs b/crates/openshell-driver-podman/src/grpc.rs index 0c6015776..4840ee281 100644 --- a/crates/openshell-driver-podman/src/grpc.rs +++ b/crates/openshell-driver-podman/src/grpc.rs @@ -50,6 +50,7 @@ impl ComputeDriver for ComputeDriverService { .ok_or_else(|| Status::invalid_argument("sandbox is required"))?; self.driver .validate_sandbox_create(&sandbox) + .await .map_err(Status::from)?; Ok(Response::new(ValidateSandboxCreateResponse {})) } diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index 229bb1bdb..eacac85ef 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -72,6 +72,42 @@ Select Docker with `compute_drivers = ["docker"]` in `[openshell.gateway]`. Conf For GPU-backed Docker sandboxes, configure Docker CDI before starting the gateway so OpenShell can detect the daemon capability. +### Docker Driver Config Mounts + +Docker driver config accepts user-supplied `volume` and `tmpfs` mounts. Host +bind mounts and image mounts are not accepted in Docker sandbox driver config. +Docker's [storage documentation](https://docs.docker.com/engine/storage/) +documents container storage mounts as volumes, bind mounts, tmpfs mounts, and +named pipes. OpenShell intentionally exposes only Docker-managed volumes and +tmpfs mounts for sandbox create. + +Use a `volume` mount for existing Docker named volumes. This includes NFS +volumes created with Docker's local volume driver: + +```shell +docker volume create \ + --driver local \ + --opt type=nfs \ + --opt o=addr=10.0.0.10,rw,nfsvers=4 \ + --opt device=:/exports/work \ + work-nfs + +openshell sandbox create \ + --driver-config-json '{"docker":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work","read_only":false}]}}' \ + -- claude +``` + +Docker mount schema: + +| Type | Fields | +|---|---| +| `volume` | `source`, `target`, optional `read_only` (`false` by default), optional `subpath`. The named volume must already exist. | +| `tmpfs` | `target`, optional `options`, optional `size_bytes`, optional `mode`. | + +OpenShell rejects mount targets that replace the workspace root, container root, +supervisor files, TLS material, authentication material, or network namespace +paths. Mounted paths remain subject to sandbox filesystem policy. + ## Podman Driver [Podman](https://podman.io/)-backed sandboxes run as rootless containers on the gateway host. Use Podman for Linux workstation workflows that avoid a rootful Docker daemon. @@ -84,6 +120,49 @@ Select Podman with `compute_drivers = ["podman"]` in `[openshell.gateway]`. Conf On macOS with `podman machine`, the driver uses gvproxy's host-loopback IP, `192.168.127.254`, for sandbox host aliases by default. Set `host_gateway_ip` only when your Podman machine uses a non-standard host-loopback address. On Linux, an empty `host_gateway_ip` keeps Podman's `host-gateway` resolver behavior. +### Podman Driver Config Mounts + +Podman driver config accepts user-supplied `volume`, `tmpfs`, and `image` +mounts. Host bind mounts are not accepted in Podman sandbox driver config. + +Use a `volume` mount for existing Podman named volumes. This includes NFS +volumes created with Podman's local volume driver: + +```shell +podman volume create \ + --opt type=nfs \ + --opt o=addr=10.0.0.10,rw,nfsvers=4 \ + --opt device=:/exports/work \ + work-nfs + +openshell sandbox create \ + --driver-config-json '{"podman":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work","read_only":false}]}}' \ + -- claude +``` + +Use an `image` mount to mount an OCI image filesystem through Podman's +image-volume API: + +```shell +openshell sandbox create \ + --driver-config-json '{"podman":{"mounts":[{"type":"image","source":"ghcr.io/acme/tools:latest","target":"/opt/tools"}]}}' \ + -- claude +``` + +Podman mount schema: + +| Type | Fields | +|---|---| +| `volume` | `source`, `target`, optional `read_only` (`false` by default). The named volume must already exist. | +| `tmpfs` | `target`, optional `options`, optional `size_bytes`, optional `mode`. | +| `image` | `source`, `target`, optional `read_only` (`true` by default). | + +Podman `volume` and `image` mounts do not support `subpath` in OpenShell driver +config. OpenShell rejects mount targets that replace the workspace root, +container root, supervisor files, TLS material, authentication material, or +network namespace paths. Mounted paths remain subject to sandbox filesystem +policy. + ## MicroVM Driver MicroVM-backed sandboxes run inside VM-backed isolation instead of a container boundary. Use MicroVM when workloads need a VM boundary instead of a local container boundary. @@ -115,6 +194,13 @@ Configure VM driver values such as `grpc_endpoint`, `driver_dir`, `state_dir`, ` The gateway starts `openshell-driver-vm` over a private Unix socket and passes its process ID so the driver can reject unexpected local clients. The driver's standalone TCP listener is disabled unless `--allow-unauthenticated-tcp` is set for local development. +### VM Driver Config Mounts + +The VM driver does not currently support user-supplied mounts through +`--driver-config-json`. Each sandbox VM boots from a cached read-only image disk +plus a per-sandbox writable overlay disk, and the driver owns the guest mount +layout. + ### Local image resolution The VM driver resolves sandbox images from a local container engine before falling back to registry pulls. It tries Docker first, then falls back to the Podman socket (Docker-compatible API). On Linux with Podman, enable the API socket so the driver can find local images: @@ -156,6 +242,18 @@ For maintainer-level implementation details, refer to the [Kubernetes driver REA The Kubernetes driver creates namespaced `agents.x-k8s.io/v1alpha1` `Sandbox` resources from the Kubernetes SIG Apps [agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox) project. The Agent Sandbox controller turns those resources into sandbox pods and related storage. +### Kubernetes Driver Config Mounts + +The Kubernetes driver does not currently support Docker or Podman-style +`mounts` entries in `--driver-config-json`. Kubernetes driver config is limited +to pod scheduling and agent resource fields such as `pod.node_selector`, +`pod.tolerations`, `pod.runtime_class_name`, `pod.priority_class_name`, +`containers.agent.resources.requests`, and `containers.agent.resources.limits`. + +Kubernetes sandbox workspace storage uses Agent Sandbox +`volumeClaimTemplates`. The gateway injects the default workspace claim when the +template does not provide one. + `Sandbox.spec.volumeClaimTemplates` is immutable after creation. To change storage configuration, delete the sandbox and create a new one with the updated spec. diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 0db6d7678..655900821 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -62,6 +62,29 @@ Use this only for driver-specific fields that do not have a stable CLI flag. Prefer stable flags such as `--cpu`, `--memory`, and `--gpu` when they cover the same behavior. +Docker accepts `volume` and `tmpfs` mounts in driver config. Podman accepts +`volume`, `tmpfs`, and `image` mounts. Host bind mounts are not supported +through `--driver-config-json`. Create NFS storage as a Docker or Podman named +volume first, then mount that existing volume into the sandbox: + +```shell +docker volume create \ + --driver local \ + --opt type=nfs \ + --opt o=addr=10.0.0.10,rw,nfsvers=4 \ + --opt device=:/exports/work \ + work-nfs + +openshell sandbox create \ + --driver-config-json '{"docker":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work"}]}}' \ + -- claude +``` + +Use the `podman` envelope key and `podman volume create` for Podman-backed +gateways. The volume must already exist before sandbox creation. Mounted paths +remain subject to the sandbox filesystem policy, so allow the mount target when +the agent needs access outside the default policy. + ### GPU Resources To request GPU resources, add `--gpu`: From 50e3715621110886322a73ca2bbfdc5dc4d94bae Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 5 Jun 2026 14:04:57 -0700 Subject: [PATCH 2/8] docs(drivers): trim mount docs Signed-off-by: Drew Newberry --- docs/reference/sandbox-compute-drivers.mdx | 19 ------------------ docs/sandboxes/manage-sandboxes.mdx | 23 ---------------------- 2 files changed, 42 deletions(-) diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index eacac85ef..a15833497 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -194,13 +194,6 @@ Configure VM driver values such as `grpc_endpoint`, `driver_dir`, `state_dir`, ` The gateway starts `openshell-driver-vm` over a private Unix socket and passes its process ID so the driver can reject unexpected local clients. The driver's standalone TCP listener is disabled unless `--allow-unauthenticated-tcp` is set for local development. -### VM Driver Config Mounts - -The VM driver does not currently support user-supplied mounts through -`--driver-config-json`. Each sandbox VM boots from a cached read-only image disk -plus a per-sandbox writable overlay disk, and the driver owns the guest mount -layout. - ### Local image resolution The VM driver resolves sandbox images from a local container engine before falling back to registry pulls. It tries Docker first, then falls back to the Podman socket (Docker-compatible API). On Linux with Podman, enable the API socket so the driver can find local images: @@ -242,18 +235,6 @@ For maintainer-level implementation details, refer to the [Kubernetes driver REA The Kubernetes driver creates namespaced `agents.x-k8s.io/v1alpha1` `Sandbox` resources from the Kubernetes SIG Apps [agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox) project. The Agent Sandbox controller turns those resources into sandbox pods and related storage. -### Kubernetes Driver Config Mounts - -The Kubernetes driver does not currently support Docker or Podman-style -`mounts` entries in `--driver-config-json`. Kubernetes driver config is limited -to pod scheduling and agent resource fields such as `pod.node_selector`, -`pod.tolerations`, `pod.runtime_class_name`, `pod.priority_class_name`, -`containers.agent.resources.requests`, and `containers.agent.resources.limits`. - -Kubernetes sandbox workspace storage uses Agent Sandbox -`volumeClaimTemplates`. The gateway injects the default workspace claim when the -template does not provide one. - `Sandbox.spec.volumeClaimTemplates` is immutable after creation. To change storage configuration, delete the sandbox and create a new one with the updated spec. diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 655900821..0db6d7678 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -62,29 +62,6 @@ Use this only for driver-specific fields that do not have a stable CLI flag. Prefer stable flags such as `--cpu`, `--memory`, and `--gpu` when they cover the same behavior. -Docker accepts `volume` and `tmpfs` mounts in driver config. Podman accepts -`volume`, `tmpfs`, and `image` mounts. Host bind mounts are not supported -through `--driver-config-json`. Create NFS storage as a Docker or Podman named -volume first, then mount that existing volume into the sandbox: - -```shell -docker volume create \ - --driver local \ - --opt type=nfs \ - --opt o=addr=10.0.0.10,rw,nfsvers=4 \ - --opt device=:/exports/work \ - work-nfs - -openshell sandbox create \ - --driver-config-json '{"docker":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work"}]}}' \ - -- claude -``` - -Use the `podman` envelope key and `podman volume create` for Podman-backed -gateways. The volume must already exist before sandbox creation. Mounted paths -remain subject to the sandbox filesystem policy, so allow the mount target when -the agent needs access outside the default policy. - ### GPU Resources To request GPU resources, add `--gpu`: From 3e692822c604862e3b6f0a5ab5101d7d476c573b Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 5 Jun 2026 14:45:18 -0700 Subject: [PATCH 3/8] test(e2e): cover local driver volume mounts Signed-off-by: Drew Newberry --- e2e/rust/Cargo.toml | 10 +- e2e/rust/tests/driver_config_volume.rs | 159 +++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 e2e/rust/tests/driver_config_volume.rs diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 31c6a3347..2ff799c98 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -23,11 +23,12 @@ e2e = [] # `server.hostGatewayIP` is set, so `e2e-kubernetes` does NOT imply this and # the helm wrapper opts in explicitly when it has resolved an IP. e2e-host-gateway = ["e2e"] -e2e-docker = ["e2e", "e2e-host-gateway"] +e2e-local-container-driver = ["e2e"] +e2e-docker = ["e2e", "e2e-host-gateway", "e2e-local-container-driver"] e2e-gpu = ["e2e"] e2e-docker-gpu = ["e2e-docker", "e2e-gpu"] e2e-kubernetes = ["e2e"] -e2e-podman = ["e2e", "e2e-host-gateway"] +e2e-podman = ["e2e", "e2e-host-gateway", "e2e-local-container-driver"] e2e-podman-gpu = ["e2e-podman", "e2e-gpu"] e2e-vm = ["e2e"] @@ -41,6 +42,11 @@ name = "docker_preflight" path = "tests/docker_preflight.rs" required-features = ["e2e-docker"] +[[test]] +name = "driver_config_volume" +path = "tests/driver_config_volume.rs" +required-features = ["e2e-local-container-driver"] + [[test]] name = "gateway_resume" path = "tests/gateway_resume.rs" diff --git a/e2e/rust/tests/driver_config_volume.rs b/e2e/rust/tests/driver_config_volume.rs new file mode 100644 index 000000000..7124cb0ab --- /dev/null +++ b/e2e/rust/tests/driver_config_volume.rs @@ -0,0 +1,159 @@ +// 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::process::Output; +use std::time::{SystemTime, UNIX_EPOCH}; + +use openshell_e2e::harness::container::{ContainerEngine, e2e_driver}; +use openshell_e2e::harness::sandbox::SandboxGuard; + +const TEST_IMAGE: &str = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"; +const VOLUME_TARGET: &str = "/sandbox/e2e-volume"; + +struct VolumeGuard { + engine: ContainerEngine, + name: String, +} + +impl VolumeGuard { + fn create(engine: ContainerEngine, driver: &str) -> Result { + let name = unique_volume_name(driver); + run_engine(&engine, &["volume", "create", &name])?; + Ok(Self { engine, name }) + } +} + +impl Drop for VolumeGuard { + fn drop(&mut self) { + let _ = self + .engine + .command() + .args(["volume", "rm", "-f", &self.name]) + .output(); + } +} + +#[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 engine = ContainerEngine::from_env(); + let volume = VolumeGuard::create(engine, &driver).expect("create named test volume"); + + seed_volume(&volume).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(&[ + "--no-keep", + "--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).expect("verify sandbox wrote to named test volume"); +} + +fn seed_volume(volume: &VolumeGuard) -> Result<(), String> { + run_engine( + &volume.engine, + &[ + "run", + "--rm", + "--user", + "0:0", + "--volume", + &format!("{}:/vol", volume.name), + "--entrypoint", + "sh", + TEST_IMAGE, + "-lc", + "set -eu; chmod 0777 /vol; printf host-volume-ok > /vol/input.txt", + ], + )?; + Ok(()) +} + +fn verify_volume(volume: &VolumeGuard) -> Result<(), String> { + let output = run_engine( + &volume.engine, + &[ + "run", + "--rm", + "--user", + "0:0", + "--volume", + &format!("{}:/vol:ro", volume.name), + "--entrypoint", + "sh", + TEST_IMAGE, + "-lc", + "set -eu; test \"$(cat /vol/input.txt)\" = host-volume-ok; test \"$(cat /vol/output.txt)\" = sandbox-volume-ok; echo volume-ok", + ], + )?; + if !output.contains("volume-ok") { + return Err(format!( + "volume verification did not print expected marker:\n{output}" + )); + } + Ok(()) +} + +fn run_engine(engine: &ContainerEngine, args: &[&str]) -> Result { + let output = engine + .command() + .args(args) + .output() + .map_err(|err| format!("spawn {} {}: {err}", engine.name(), args.join(" ")))?; + engine_output(engine, args, &output) +} + +fn engine_output( + engine: &ContainerEngine, + args: &[&str], + output: &Output, +) -> Result { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let combined = format!("{stdout}{stderr}"); + if output.status.success() { + return Ok(combined); + } + Err(format!( + "{} {} failed (exit {:?}):\n{combined}", + engine.name(), + args.join(" "), + output.status.code() + )) +} + +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(); + format!( + "openshell-e2e-driver-config-volume-{driver}-{}-{nanos}", + std::process::id() + ) +} From 113bbeaab359220690dc610aaf2b767ad59d6f7e Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 7 Jun 2026 21:14:19 -0700 Subject: [PATCH 4/8] fix(podman): satisfy linux clippy lint Signed-off-by: Drew Newberry --- crates/openshell-driver-podman/src/container.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index fa9d6c466..cfbfeeb7a 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -22,7 +22,7 @@ use std::path::Path; /// exist and this returns `false`, leaving mount options unchanged. #[cfg(target_os = "linux")] fn is_selinux_enabled() -> bool { - std::path::Path::new("/sys/fs/selinux").is_dir() + Path::new("/sys/fs/selinux").is_dir() } #[cfg(not(target_os = "linux"))] From 08b333dd4c8ff2018646bb57aabe7f8abe5890c7 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 8 Jun 2026 11:15:24 -0700 Subject: [PATCH 5/8] feat(drivers): gate bind mounts behind gateway config --- architecture/compute-runtimes.md | 5 +- crates/openshell-driver-docker/README.md | 6 +- crates/openshell-driver-docker/src/lib.rs | 65 +++++++- crates/openshell-driver-docker/src/tests.rs | 75 +++++++++- crates/openshell-driver-podman/README.md | 9 +- crates/openshell-driver-podman/src/config.rs | 7 + .../openshell-driver-podman/src/container.rs | 140 ++++++++++++++++-- crates/openshell-driver-podman/src/driver.rs | 10 +- crates/openshell-driver-podman/src/main.rs | 1 + crates/openshell-server/src/cli.rs | 14 ++ crates/openshell-server/src/lib.rs | 15 ++ docs/reference/gateway-config.mdx | 2 + docs/reference/sandbox-compute-drivers.mdx | 42 +++++- e2e/rust/tests/driver_config_volume.rs | 66 +++++++++ e2e/with-docker-gateway.sh | 1 + e2e/with-podman-gateway.sh | 1 + 16 files changed, 424 insertions(+), 35 deletions(-) diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 585c13e48..d2adb142d 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -43,8 +43,9 @@ but currently ignores them. Docker and Podman also accept per-sandbox driver-config mounts for existing runtime-managed named volumes and tmpfs mounts. Podman additionally accepts image mounts through its image-volume API. User-supplied host bind mounts are -excluded from the driver-config contract; bind mounts remain reserved for -driver-owned supervisor, token, and TLS material. +available only when explicitly enabled in the active local driver table of +`gateway.toml`; driver-owned supervisor, token, and TLS bind mounts stay +reserved. Kubernetes deployments may set an AppArmor profile on sandbox agent containers through the driver configuration. The Helm chart defaults sandbox agents to diff --git a/crates/openshell-driver-docker/README.md b/crates/openshell-driver-docker/README.md index d71b18a84..22a82c753 100644 --- a/crates/openshell-driver-docker/README.md +++ b/crates/openshell-driver-docker/README.md @@ -42,15 +42,19 @@ The gateway forwards the `docker` block from `--driver-config-json` to this driver. The driver accepts user-supplied `mounts` entries with these Docker mount types: +- `bind`: mounts an absolute host path when `[openshell.drivers.docker]` + has `enable_bind_mounts = true`. - `volume`: mounts an existing Docker named volume. The driver validates that the volume exists before provisioning and never creates or removes it. - `tmpfs`: mounts an in-memory filesystem with optional `options`, `size_bytes`, and `mode`. -Host bind mounts and image mounts are intentionally not part of the Docker +Host bind mounts are disabled by default because they expose gateway host +paths to sandbox requests. Image mounts are not part of the Docker driver-config schema. The driver still uses internal bind mounts for OpenShell-owned supervisor, token, and TLS material. +Docker `bind` mounts accept `source`, `target`, and optional `read_only`. Docker `volume` mounts may include `subpath`. Mount targets must be absolute container paths and must not replace the workspace root (`/sandbox`) or overlap OpenShell supervisor files, auth material, TLS material, or `/run/netns`. diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 7e4f2bc40..7414b9a6d 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -177,6 +177,11 @@ pub struct DockerComputeConfig { /// /// Set to `0` to leave Docker's runtime/default PID limit unchanged. pub sandbox_pids_limit: i64, + + /// Allow sandbox requests to attach host bind mounts through + /// `template.driver_config`. + #[serde(default)] + pub enable_bind_mounts: bool, } impl Default for DockerComputeConfig { @@ -195,6 +200,7 @@ impl Default for DockerComputeConfig { host_gateway_ip: String::new(), ssh_socket_path: "/run/openshell/ssh.sock".to_string(), sandbox_pids_limit: DEFAULT_SANDBOX_PIDS_LIMIT, + enable_bind_mounts: false, } } } @@ -222,6 +228,7 @@ struct DockerDriverRuntimeConfig { daemon_version: String, supports_gpu: bool, sandbox_pids_limit: i64, + enable_bind_mounts: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -268,6 +275,12 @@ struct DockerSandboxDriverConfig { #[derive(Debug, Clone, serde::Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] enum DockerDriverMountConfig { + Bind { + source: String, + target: String, + #[serde(default)] + read_only: bool, + }, Volume { source: String, target: String, @@ -356,6 +369,7 @@ impl DockerComputeDriver { daemon_version: version.version.unwrap_or_else(|| "unknown".to_string()), supports_gpu, sandbox_pids_limit: docker_config.sandbox_pids_limit, + enable_bind_mounts: docker_config.enable_bind_mounts, }, events: broadcast::channel(WATCH_BUFFER).0, pending: Arc::new(Mutex::new(HashMap::new())), @@ -420,7 +434,7 @@ impl DockerComputeDriver { )); } - let _ = docker_driver_config(template)?; + let _ = docker_driver_config(template, config.enable_bind_mounts)?; let _ = docker_resource_limits(template)?; Ok(()) } @@ -457,7 +471,7 @@ impl DockerComputeDriver { .as_ref() .and_then(|spec| spec.template.as_ref()) .ok_or_else(|| Status::invalid_argument("sandbox.spec.template is required"))?; - let config = docker_driver_config(template)?; + let config = docker_driver_config(template, self.config.enable_bind_mounts)?; for mount in config.mounts { if let DockerDriverMountConfig::Volume { source, .. } = mount { match self.docker.inspect_volume(source.trim()).await { @@ -1568,6 +1582,7 @@ fn attach_docker_progress_metadata( fn docker_driver_config( template: &DriverSandboxTemplate, + enable_bind_mounts: bool, ) -> Result { let Some(config) = template.driver_config.as_ref() else { return Ok(DockerSandboxDriverConfig::default()); @@ -1577,17 +1592,31 @@ fn docker_driver_config( let config: DockerSandboxDriverConfig = serde_json::from_value(json).map_err(|err| { Status::failed_precondition(format!("invalid docker driver_config: {err}")) })?; - validate_docker_driver_mounts(&config.mounts)?; + validate_docker_driver_mounts(&config.mounts, enable_bind_mounts)?; Ok(config) } -fn docker_driver_mounts(template: &DriverSandboxTemplate) -> Result, Status> { - let config = docker_driver_config(template)?; +fn docker_driver_mounts( + template: &DriverSandboxTemplate, + enable_bind_mounts: bool, +) -> Result, Status> { + let config = docker_driver_config(template, enable_bind_mounts)?; config.mounts.iter().map(docker_mount_from_config).collect() } fn docker_mount_from_config(config: &DockerDriverMountConfig) -> Result { match config { + DockerDriverMountConfig::Bind { + source, + target, + read_only, + } => Ok(Mount { + typ: Some(MountTypeEnum::BIND), + source: Some(validate_absolute_mount_source(source, "bind source")?), + target: Some(validate_container_mount_target(target)?), + read_only: Some(*read_only), + ..Default::default() + }), DockerDriverMountConfig::Volume { source, target, @@ -1637,10 +1666,22 @@ fn docker_mount_from_config(config: &DockerDriverMountConfig) -> Result Result<(), Status> { +fn validate_docker_driver_mounts( + mounts: &[DockerDriverMountConfig], + enable_bind_mounts: bool, +) -> Result<(), Status> { let mut targets = HashSet::new(); for mount in mounts { let target = match mount { + DockerDriverMountConfig::Bind { source, target, .. } => { + if !enable_bind_mounts { + return Err(Status::failed_precondition( + "docker bind mounts require enable_bind_mounts = true in [openshell.drivers.docker]", + )); + } + validate_absolute_mount_source(source, "bind source")?; + target + } DockerDriverMountConfig::Volume { source, target, @@ -1677,6 +1718,16 @@ fn validate_docker_driver_mounts(mounts: &[DockerDriverMountConfig]) -> Result<( Ok(()) } +fn validate_absolute_mount_source(source: &str, field: &str) -> Result { + let source = validate_mount_source(source, field)?; + if !Path::new(&source).is_absolute() { + return Err(Status::failed_precondition(format!( + "{field} must be an absolute host path" + ))); + } + Ok(source) +} + fn validate_mount_source(source: &str, field: &str) -> Result { let source = source.trim(); if source.is_empty() { @@ -2100,7 +2151,7 @@ fn build_container_create_body( .as_ref() .ok_or_else(|| Status::invalid_argument("sandbox.spec.template is required"))?; let resource_limits = docker_resource_limits(template)?; - let user_mounts = docker_driver_mounts(template)?; + let user_mounts = docker_driver_mounts(template, config.enable_bind_mounts)?; let mut labels = template.labels.clone(); labels.insert( LABEL_MANAGED_BY.to_string(), diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 3864217ea..61b93742c 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -75,6 +75,7 @@ fn runtime_config() -> DockerDriverRuntimeConfig { daemon_version: "28.0.0".to_string(), supports_gpu: false, sandbox_pids_limit: DEFAULT_SANDBOX_PIDS_LIMIT, + enable_bind_mounts: false, } } @@ -465,6 +466,12 @@ fn docker_pids_limit_uses_driver_default_and_allows_runtime_inherit() { assert!(docker_pids_limit(-1).is_err()); } +#[test] +fn docker_compute_config_disables_bind_mounts_by_default() { + let cfg = DockerComputeConfig::default(); + assert!(!cfg.enable_bind_mounts); +} + #[test] fn container_create_body_sets_driver_owned_pids_limit() { let body = build_container_create_body(&test_sandbox(), &runtime_config()).unwrap(); @@ -613,7 +620,7 @@ fn build_container_create_body_includes_driver_config_mounts() { } #[test] -fn driver_config_rejects_bind_mounts() { +fn driver_config_rejects_bind_mounts_unless_enabled() { let mut sandbox = test_sandbox(); sandbox .spec @@ -633,7 +640,71 @@ fn driver_config_rejects_bind_mounts() { let err = build_container_create_body(&sandbox, &runtime_config()).unwrap_err(); assert_eq!(err.code(), tonic::Code::FailedPrecondition); - assert!(err.message().contains("invalid docker driver_config")); + assert!(err.message().contains("enable_bind_mounts = true")); +} + +#[test] +fn build_container_create_body_includes_bind_mounts_when_enabled() { + let mut sandbox = test_sandbox(); + sandbox + .spec + .as_mut() + .unwrap() + .template + .as_mut() + .unwrap() + .driver_config = Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "bind", + "source": "/host/path", + "target": "/sandbox/host", + "read_only": true + }] + }))); + let mut config = runtime_config(); + config.enable_bind_mounts = true; + + let body = build_container_create_body(&sandbox, &config).unwrap(); + let mounts = body + .host_config + .unwrap() + .mounts + .expect("driver config mounts should be set"); + + assert_eq!(mounts.len(), 1); + assert_eq!(mounts[0].typ, Some(MountTypeEnum::BIND)); + assert_eq!(mounts[0].source.as_deref(), Some("/host/path")); + assert_eq!(mounts[0].target.as_deref(), Some("/sandbox/host")); + assert_eq!(mounts[0].read_only, Some(true)); +} + +#[test] +fn driver_config_rejects_relative_bind_sources_when_enabled() { + let mut sandbox = test_sandbox(); + sandbox + .spec + .as_mut() + .unwrap() + .template + .as_mut() + .unwrap() + .driver_config = Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "bind", + "source": "relative/path", + "target": "/sandbox/host" + }] + }))); + let mut config = runtime_config(); + config.enable_bind_mounts = true; + + let err = build_container_create_body(&sandbox, &config).unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!( + err.message() + .contains("bind source must be an absolute host path") + ); } #[test] diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index 2f238821f..6dc62da06 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -56,6 +56,8 @@ The gateway forwards the `podman` block from `--driver-config-json` to this driver. The driver accepts user-supplied `mounts` entries with these Podman mount types: +- `bind`: mounts an absolute host path when `[openshell.drivers.podman]` + has `enable_bind_mounts = true`. - `volume`: mounts an existing Podman named volume. The driver validates that the volume exists before provisioning and never creates or removes it. - `tmpfs`: mounts an in-memory filesystem with optional `options`, @@ -63,10 +65,11 @@ mount types: - `image`: mounts an OCI image through Podman's image-volume API. The driver pulls the image during provisioning using the sandbox image pull policy. -Host bind mounts are intentionally not part of the driver-config schema. The -driver still uses internal bind mounts for OpenShell-owned token and TLS -material. +Host bind mounts are disabled by default because they expose gateway host paths +to sandbox requests. The driver still uses internal bind mounts for +OpenShell-owned token and TLS material. +Podman `bind` mounts accept `source`, `target`, and optional `read_only`. Podman image and volume mounts do not support `subpath` in OpenShell driver config. Mount targets must be absolute container paths and must not replace the workspace root (`/sandbox`) or overlap OpenShell supervisor files, auth diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index 79a463b23..e21c66176 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -122,6 +122,10 @@ pub struct PodmanComputeConfig { /// /// Set to `0` to leave Podman's runtime/default PID limit unchanged. pub sandbox_pids_limit: i64, + /// Allow sandbox requests to attach host bind mounts through + /// `template.driver_config`. + #[serde(default)] + pub enable_bind_mounts: bool, } impl PodmanComputeConfig { @@ -246,6 +250,7 @@ impl Default for PodmanComputeConfig { guest_tls_cert: None, guest_tls_key: None, sandbox_pids_limit: DEFAULT_SANDBOX_PIDS_LIMIT, + enable_bind_mounts: false, } } } @@ -267,6 +272,7 @@ impl std::fmt::Debug for PodmanComputeConfig { .field("guest_tls_cert", &self.guest_tls_cert) .field("guest_tls_key", &self.guest_tls_key) .field("sandbox_pids_limit", &self.sandbox_pids_limit) + .field("enable_bind_mounts", &self.enable_bind_mounts) .finish() } } @@ -312,6 +318,7 @@ mod tests { fn default_config_sets_driver_owned_pids_limit() { let cfg = PodmanComputeConfig::default(); assert_eq!(cfg.sandbox_pids_limit, DEFAULT_SANDBOX_PIDS_LIMIT); + assert!(!cfg.enable_bind_mounts); assert!(cfg.validate_runtime_limits().is_ok()); } diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index cfbfeeb7a..baac38e4c 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -62,6 +62,12 @@ struct PodmanSandboxDriverConfig { #[derive(Debug, Clone, serde::Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] enum PodmanDriverMountConfig { + Bind { + source: String, + target: String, + #[serde(default)] + read_only: bool, + }, Volume { source: String, target: String, @@ -436,7 +442,10 @@ fn build_devices(sandbox: &DriverSandbox) -> Option> { }) } -pub fn podman_driver_volume_mount_sources(sandbox: &DriverSandbox) -> Result, String> { +pub fn podman_driver_volume_mount_sources( + sandbox: &DriverSandbox, + enable_bind_mounts: bool, +) -> Result, String> { let template = sandbox .spec .as_ref() @@ -444,7 +453,7 @@ pub fn podman_driver_volume_mount_sources(sandbox: &DriverSandbox) -> Result Result Result, String> { +pub fn podman_driver_image_mount_sources( + sandbox: &DriverSandbox, + enable_bind_mounts: bool, +) -> Result, String> { let template = sandbox .spec .as_ref() @@ -463,7 +475,7 @@ pub fn podman_driver_image_mount_sources(sandbox: &DriverSandbox) -> Result Result Result { +fn podman_user_mounts( + sandbox: &DriverSandbox, + enable_bind_mounts: bool, +) -> Result { let template = sandbox .spec .as_ref() @@ -482,10 +497,25 @@ fn podman_user_mounts(sandbox: &DriverSandbox) -> Result { + result.mounts.push(Mount { + kind: "bind".into(), + source: validate_absolute_mount_source(&source, "bind source")?, + destination: validate_container_mount_target(&target)?, + options: vec![ + if read_only { "ro" } else { "rw" }.to_string(), + "rbind".to_string(), + ], + }); + } PodmanDriverMountConfig::Volume { source, target, @@ -545,6 +575,7 @@ fn podman_user_mounts(sandbox: &DriverSandbox) -> Result Result { let Some(config) = template.driver_config.as_ref() else { return Ok(PodmanSandboxDriverConfig::default()); @@ -552,14 +583,27 @@ fn podman_driver_config( let json = Value::Object(proto_struct_to_json_object(config)); let config: PodmanSandboxDriverConfig = serde_json::from_value(json) .map_err(|err| format!("invalid podman driver_config: {err}"))?; - validate_podman_driver_mounts(&config.mounts)?; + validate_podman_driver_mounts(&config.mounts, enable_bind_mounts)?; Ok(config) } -fn validate_podman_driver_mounts(mounts: &[PodmanDriverMountConfig]) -> Result<(), String> { +fn validate_podman_driver_mounts( + mounts: &[PodmanDriverMountConfig], + enable_bind_mounts: bool, +) -> Result<(), String> { let mut targets = HashSet::new(); for mount in mounts { let target = match mount { + PodmanDriverMountConfig::Bind { source, target, .. } => { + if !enable_bind_mounts { + return Err( + "podman bind mounts require enable_bind_mounts = true in [openshell.drivers.podman]" + .to_string(), + ); + } + validate_absolute_mount_source(source, "bind source")?; + target + } PodmanDriverMountConfig::Volume { source, target, @@ -602,6 +646,14 @@ fn validate_podman_driver_mounts(mounts: &[PodmanDriverMountConfig]) -> Result<( Ok(()) } +fn validate_absolute_mount_source(source: &str, field: &str) -> Result { + let source = validate_mount_source(source, field)?; + if !Path::new(&source).is_absolute() { + return Err(format!("{field} must be an absolute host path")); + } + Ok(source) +} + fn validate_mount_source(source: &str, field: &str) -> Result { let source = source.trim(); if source.is_empty() { @@ -786,7 +838,7 @@ pub fn try_build_container_spec_with_token( let labels = build_labels(sandbox); let resource_limits = build_resource_limits(sandbox, config); let devices = build_devices(sandbox); - let user_mounts = podman_user_mounts(sandbox)?; + let user_mounts = podman_user_mounts(sandbox, config.enable_bind_mounts)?; // Network configuration -- always bridge mode. // Matches libpod's network spec format `{name: {opts}}`; the unit-struct @@ -1669,7 +1721,7 @@ mod tests { } #[test] - fn driver_config_rejects_bind_mounts() { + fn driver_config_rejects_bind_mounts_unless_enabled() { use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); @@ -1690,7 +1742,73 @@ mod tests { let err = try_build_container_spec_with_token(&sandbox, &config, None).unwrap_err(); - assert!(err.contains("invalid podman driver_config")); + assert!(err.contains("enable_bind_mounts = true")); + } + + #[test] + fn container_spec_includes_bind_mounts_when_enabled() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let mut sandbox = test_sandbox("test-id", "test-name"); + sandbox.spec = Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "bind", + "source": "/host/path", + "target": "/sandbox/host", + "read_only": true + }] + }))), + ..Default::default() + }), + ..Default::default() + }); + let mut config = test_config(); + config.enable_bind_mounts = true; + + let spec = build_container_spec(&sandbox, &config); + let mounts = spec["mounts"] + .as_array() + .expect("mounts should be an array"); + + assert!(mounts.iter().any(|mount| { + mount["type"].as_str() == Some("bind") + && mount["source"].as_str() == Some("/host/path") + && mount["destination"].as_str() == Some("/sandbox/host") + && mount["options"].as_array().is_some_and(|options| { + options.iter().any(|option| option.as_str() == Some("ro")) + && options + .iter() + .any(|option| option.as_str() == Some("rbind")) + }) + })); + } + + #[test] + fn driver_config_rejects_relative_bind_sources_when_enabled() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let mut sandbox = test_sandbox("test-id", "test-name"); + sandbox.spec = Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "mounts": [{ + "type": "bind", + "source": "relative/path", + "target": "/sandbox/host" + }] + }))), + ..Default::default() + }), + ..Default::default() + }); + let mut config = test_config(); + config.enable_bind_mounts = true; + + let err = try_build_container_spec_with_token(&sandbox, &config, None).unwrap_err(); + + assert!(err.contains("bind source must be an absolute host path")); } #[test] diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 0d3dcccb0..df955ad1c 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -300,8 +300,9 @@ impl PodmanComputeDriver { &self, sandbox: &DriverSandbox, ) -> Result<(), ComputeDriverError> { - let volumes = container::podman_driver_volume_mount_sources(sandbox) - .map_err(ComputeDriverError::Precondition)?; + let volumes = + container::podman_driver_volume_mount_sources(sandbox, self.config.enable_bind_mounts) + .map_err(ComputeDriverError::Precondition)?; for volume in volumes { let exists = self .client @@ -376,8 +377,9 @@ impl PodmanComputeDriver { .await .map_err(ComputeDriverError::from)?; - for image in container::podman_driver_image_mount_sources(sandbox) - .map_err(ComputeDriverError::Precondition)? + for image in + container::podman_driver_image_mount_sources(sandbox, self.config.enable_bind_mounts) + .map_err(ComputeDriverError::Precondition)? { info!(image = %image, policy = %pull_policy, "Ensuring image mount source"); self.client diff --git a/crates/openshell-driver-podman/src/main.rs b/crates/openshell-driver-podman/src/main.rs index 53af4e190..2d8d4055b 100644 --- a/crates/openshell-driver-podman/src/main.rs +++ b/crates/openshell-driver-podman/src/main.rs @@ -135,6 +135,7 @@ async fn main() -> Result<()> { guest_tls_cert: args.podman_tls_cert, guest_tls_key: args.podman_tls_key, sandbox_pids_limit: args.sandbox_pids_limit, + enable_bind_mounts: false, }) .await .into_diagnostic()?; diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 748cec264..f8815f87a 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -1538,4 +1538,18 @@ default_image = "k8s-specific:1.0" .expect("deserializes"); assert_eq!(parsed.default_image, "k8s-specific:1.0"); } + + #[test] + fn docker_config_reads_bind_mount_opt_in_from_driver_table() { + let file = config_file_from_toml( + r" +[openshell.drivers.docker] +enable_bind_mounts = true +", + ); + + let cfg = super::build_docker_config(Some(&file), None).expect("docker config"); + + assert!(cfg.enable_bind_mounts); + } } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 676e23071..eb8ace0ce 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -1367,6 +1367,21 @@ service_account_name = "sandbox-sa" assert_eq!(cfg.service_account_name, "sandbox-sa"); } + #[test] + fn podman_config_reads_bind_mount_opt_in_from_driver_table() { + let file: crate::config_file::ConfigFile = toml::from_str( + r" +[openshell.drivers.podman] +enable_bind_mounts = true +", + ) + .expect("valid config"); + + let cfg = crate::podman_config_from_file(Some(&file)).expect("podman config"); + + assert!(cfg.enable_bind_mounts); + } + #[test] fn gateway_listener_addresses_skip_driver_address_covered_by_wildcard() { let primary: SocketAddr = "0.0.0.0:8080".parse().unwrap(); diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index c70d8acbd..b22853948 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -215,6 +215,7 @@ guest_tls_key = "/etc/openshell/certs/client-key.pem" network_name = "openshell-docker" host_gateway_ip = "172.17.0.1" ssh_socket_path = "/run/openshell/ssh.sock" +enable_bind_mounts = false # Set to 0 to leave Docker's runtime default unchanged. sandbox_pids_limit = 2048 ``` @@ -250,6 +251,7 @@ supervisor_image = "ghcr.io/nvidia/openshell/supervisor:latest" guest_tls_ca = "/etc/openshell/certs/ca.pem" guest_tls_cert = "/etc/openshell/certs/client.pem" guest_tls_key = "/etc/openshell/certs/client-key.pem" +enable_bind_mounts = false # Set to 0 to leave Podman's runtime default unchanged. sandbox_pids_limit = 2048 ``` diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index a15833497..bd087eff0 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -74,12 +74,14 @@ For GPU-backed Docker sandboxes, configure Docker CDI before starting the gatewa ### Docker Driver Config Mounts -Docker driver config accepts user-supplied `volume` and `tmpfs` mounts. Host -bind mounts and image mounts are not accepted in Docker sandbox driver config. +Docker driver config accepts user-supplied `volume` and `tmpfs` mounts. It also +accepts `bind` mounts when `[openshell.drivers.docker]` sets +`enable_bind_mounts = true` in `gateway.toml`. Host bind mounts expose gateway +host paths to sandbox requests, so they are disabled by default. Image mounts +are not accepted in Docker sandbox driver config. Docker's [storage documentation](https://docs.docker.com/engine/storage/) documents container storage mounts as volumes, bind mounts, tmpfs mounts, and -named pipes. OpenShell intentionally exposes only Docker-managed volumes and -tmpfs mounts for sandbox create. +named pipes. Use a `volume` mount for existing Docker named volumes. This includes NFS volumes created with Docker's local volume driver: @@ -97,10 +99,24 @@ openshell sandbox create \ -- claude ``` +Use a `bind` mount only after enabling it in the Docker driver table: + +```toml +[openshell.drivers.docker] +enable_bind_mounts = true +``` + +```shell +openshell sandbox create \ + --driver-config-json '{"docker":{"mounts":[{"type":"bind","source":"/srv/openshell/work","target":"/sandbox/work","read_only":false}]}}' \ + -- claude +``` + Docker mount schema: | Type | Fields | |---|---| +| `bind` | `source`, `target`, optional `read_only` (`false` by default). `source` must be an absolute host path. Requires `enable_bind_mounts = true`. | | `volume` | `source`, `target`, optional `read_only` (`false` by default), optional `subpath`. The named volume must already exist. | | `tmpfs` | `target`, optional `options`, optional `size_bytes`, optional `mode`. | @@ -123,7 +139,9 @@ On macOS with `podman machine`, the driver uses gvproxy's host-loopback IP, `192 ### Podman Driver Config Mounts Podman driver config accepts user-supplied `volume`, `tmpfs`, and `image` -mounts. Host bind mounts are not accepted in Podman sandbox driver config. +mounts. It also accepts `bind` mounts when `[openshell.drivers.podman]` sets +`enable_bind_mounts = true` in `gateway.toml`. Host bind mounts expose gateway +host paths to sandbox requests, so they are disabled by default. Use a `volume` mount for existing Podman named volumes. This includes NFS volumes created with Podman's local volume driver: @@ -149,10 +167,24 @@ openshell sandbox create \ -- claude ``` +Use a `bind` mount only after enabling it in the Podman driver table: + +```toml +[openshell.drivers.podman] +enable_bind_mounts = true +``` + +```shell +openshell sandbox create \ + --driver-config-json '{"podman":{"mounts":[{"type":"bind","source":"/srv/openshell/work","target":"/sandbox/work","read_only":false}]}}' \ + -- claude +``` + Podman mount schema: | Type | Fields | |---|---| +| `bind` | `source`, `target`, optional `read_only` (`false` by default). `source` must be an absolute host path. Requires `enable_bind_mounts = true`. | | `volume` | `source`, `target`, optional `read_only` (`false` by default). The named volume must already exist. | | `tmpfs` | `target`, optional `options`, optional `size_bytes`, optional `mode`. | | `image` | `source`, `target`, optional `read_only` (`true` by default). | diff --git a/e2e/rust/tests/driver_config_volume.rs b/e2e/rust/tests/driver_config_volume.rs index 7124cb0ab..79f50ea0f 100644 --- a/e2e/rust/tests/driver_config_volume.rs +++ b/e2e/rust/tests/driver_config_volume.rs @@ -3,14 +3,18 @@ #![cfg(feature = "e2e-local-container-driver")] +use std::fs; +use std::os::unix::fs::PermissionsExt; use std::process::Output; use std::time::{SystemTime, UNIX_EPOCH}; use openshell_e2e::harness::container::{ContainerEngine, e2e_driver}; use openshell_e2e::harness::sandbox::SandboxGuard; +use serde_json::{Map, Value}; const TEST_IMAGE: &str = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"; const VOLUME_TARGET: &str = "/sandbox/e2e-volume"; +const BIND_TARGET: &str = "/sandbox/e2e-bind"; struct VolumeGuard { engine: ContainerEngine, @@ -74,6 +78,57 @@ async fn sandbox_mounts_existing_driver_config_volume() { verify_volume(&volume).expect("verify sandbox wrote to named test volume"); } +#[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"); + fs::write(host_dir.path().join("input.txt"), "host-bind-ok") + .expect("seed bind mount host dir"); + + let driver_config = driver_config_mount_json( + &driver, + serde_json::json!({ + "type": "bind", + "source": host_dir.path(), + "target": BIND_TARGET, + "read_only": false + }), + ); + let mut sandbox = SandboxGuard::create(&[ + "--no-keep", + "--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 seed_volume(volume: &VolumeGuard) -> Result<(), String> { run_engine( &volume.engine, @@ -157,3 +212,14 @@ fn unique_volume_name(driver: &str) -> String { 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() +} diff --git a/e2e/with-docker-gateway.sh b/e2e/with-docker-gateway.sh index 2ef5495b8..4c7ccd9ff 100755 --- a/e2e/with-docker-gateway.sh +++ b/e2e/with-docker-gateway.sh @@ -450,6 +450,7 @@ GATEWAY_CONFIG="${STATE_DIR}/gateway.toml" printf 'guest_tls_ca = %s\n' "$(toml_string "${PKI_DIR}/ca.crt")" printf 'guest_tls_cert = %s\n' "$(toml_string "${PKI_DIR}/client/tls.crt")" printf 'guest_tls_key = %s\n' "$(toml_string "${PKI_DIR}/client/tls.key")" + printf 'enable_bind_mounts = true\n' # DOCKER_SUPERVISOR_ARGS holds either ("--docker-supervisor-bin" "") # or ("--docker-supervisor-image" ""); both map to TOML keys on # the docker driver config. diff --git a/e2e/with-podman-gateway.sh b/e2e/with-podman-gateway.sh index dc4f4ede1..cda924b6a 100755 --- a/e2e/with-podman-gateway.sh +++ b/e2e/with-podman-gateway.sh @@ -391,6 +391,7 @@ cp "${ROOT}/deploy/rpm/gateway.toml.default" "${GATEWAY_CONFIG}" printf 'guest_tls_ca = %s\n' "$(toml_string "${PKI_DIR}/ca.crt")" printf 'guest_tls_cert = %s\n' "$(toml_string "${PKI_DIR}/client/tls.crt")" printf 'guest_tls_key = %s\n' "$(toml_string "${PKI_DIR}/client/tls.key")" + printf 'enable_bind_mounts = true\n' # The in-process Podman driver reads `socket_path` from TOML only — the # OPENSHELL_PODMAN_SOCKET env var is honoured by the standalone driver # binary, not the in-process driver used here. Pin the socket to the one From 4a074f3b3fc50c03a626fdabe8cb8e4d7fe8fb57 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 9 Jun 2026 16:52:37 -0700 Subject: [PATCH 6/8] docs(sandbox): simplify mount examples --- crates/openshell-driver-docker/README.md | 12 +--- crates/openshell-driver-podman/README.md | 11 +--- docs/reference/sandbox-compute-drivers.mdx | 64 ++++++++-------------- e2e/rust/tests/driver_config_volume.rs | 34 +++++++++++- e2e/with-podman-gateway.sh | 9 +-- 5 files changed, 66 insertions(+), 64 deletions(-) diff --git a/crates/openshell-driver-docker/README.md b/crates/openshell-driver-docker/README.md index e2f14987e..ebcd9cc40 100644 --- a/crates/openshell-driver-docker/README.md +++ b/crates/openshell-driver-docker/README.md @@ -62,19 +62,13 @@ Docker `volume` mounts may include `subpath`. Mount targets must be absolute container paths and must not replace the workspace root (`/sandbox`) or overlap OpenShell supervisor files, auth material, TLS material, or `/run/netns`. -Example NFS usage relies on Docker's named-volume support rather than a host -bind: +Example named-volume usage: ```shell -docker volume create \ - --driver local \ - --opt type=nfs \ - --opt o=addr=10.0.0.10,rw,nfsvers=4 \ - --opt device=:/exports/work \ - work-nfs +docker volume create openshell-work openshell sandbox create \ - --driver-config-json '{"docker":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work"}]}}' \ + --driver-config-json '{"docker":{"mounts":[{"type":"volume","source":"openshell-work","target":"/sandbox/work"}]}}' \ -- claude ``` diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index c83e339bc..2467f04e9 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -78,18 +78,13 @@ config. Mount targets must be absolute container paths and must not replace the workspace root (`/sandbox`) or overlap OpenShell supervisor files, auth material, TLS material, or `/run/netns`. -Example NFS usage relies on Podman's named-volume support rather than a host -bind: +Example named-volume usage: ```shell -podman volume create \ - --opt type=nfs \ - --opt o=addr=10.0.0.10,rw,nfsvers=4 \ - --opt device=:/exports/work \ - work-nfs +podman volume create openshell-work openshell sandbox create \ - --driver-config-json '{"podman":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work"}]}}' \ + --driver-config-json '{"podman":{"mounts":[{"type":"volume","source":"openshell-work","target":"/sandbox/work"}]}}' \ -- claude ``` diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index 70676d4f8..c88a4c2f8 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -76,35 +76,30 @@ For GPU-backed Docker sandboxes, configure Docker CDI before starting the gatewa Docker driver config accepts user-supplied `volume` and `tmpfs` mounts. It also accepts `bind` mounts when `[openshell.drivers.docker]` sets -`enable_bind_mounts = true` in `gateway.toml`. Host bind mounts expose gateway -host paths to sandbox requests, so they are disabled by default. Host bind -mounts are unsafe: they put gateway-host filesystem state directly inside the -sandbox and can negate OpenShell controls such as workspace isolation and -filesystem policy. Enable them only for trusted local workflows where you -understand and accept that loss of isolation. Image mounts are not accepted in +`enable_bind_mounts = true` in `gateway.toml`. Image mounts are not accepted in Docker sandbox driver config. Docker's [storage documentation](https://docs.docker.com/engine/storage/) documents container storage mounts as volumes, bind mounts, tmpfs mounts, and named pipes. -Use a `volume` mount for existing Docker named volumes. This includes NFS -volumes created with Docker's local volume driver: +Use a `volume` mount for existing Docker named volumes: ```shell -docker volume create \ - --driver local \ - --opt type=nfs \ - --opt o=addr=10.0.0.10,rw,nfsvers=4 \ - --opt device=:/exports/work \ - work-nfs +docker volume create openshell-work openshell sandbox create \ - --driver-config-json '{"docker":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work","read_only":false}]}}' \ + --driver-config-json '{"docker":{"mounts":[{"type":"volume","source":"openshell-work","target":"/sandbox/work","read_only":false}]}}' \ -- claude ``` -Use a `bind` mount only after enabling it in the Docker driver table. Treat -this setting as an unsafe operator override: + +Bind mounts share gateway-host filesystem resources with the sandbox. They may +be considered insecure because they can negate OpenShell controls such as +workspace isolation and filesystem policy. Use them only when you understand and +accept that loss of isolation. + + +Use a `bind` mount only after enabling it in the Docker driver table: ```toml [openshell.drivers.docker] @@ -145,39 +140,26 @@ On macOS with `podman machine`, the driver uses gvproxy's host-loopback IP, `192 Podman driver config accepts user-supplied `volume`, `tmpfs`, and `image` mounts. It also accepts `bind` mounts when `[openshell.drivers.podman]` sets -`enable_bind_mounts = true` in `gateway.toml`. Host bind mounts expose gateway -host paths to sandbox requests, so they are disabled by default. Host bind -mounts are unsafe: they put gateway-host filesystem state directly inside the -sandbox and can negate OpenShell controls such as workspace isolation and -filesystem policy. Enable them only for trusted local workflows where you -understand and accept that loss of isolation. +`enable_bind_mounts = true` in `gateway.toml`. -Use a `volume` mount for existing Podman named volumes. This includes NFS -volumes created with Podman's local volume driver: +Use a `volume` mount for existing Podman named volumes: ```shell -podman volume create \ - --opt type=nfs \ - --opt o=addr=10.0.0.10,rw,nfsvers=4 \ - --opt device=:/exports/work \ - work-nfs +podman volume create openshell-work openshell sandbox create \ - --driver-config-json '{"podman":{"mounts":[{"type":"volume","source":"work-nfs","target":"/sandbox/work","read_only":false}]}}' \ + --driver-config-json '{"podman":{"mounts":[{"type":"volume","source":"openshell-work","target":"/sandbox/work","read_only":false}]}}' \ -- claude ``` -Use an `image` mount to mount an OCI image filesystem through Podman's -image-volume API: - -```shell -openshell sandbox create \ - --driver-config-json '{"podman":{"mounts":[{"type":"image","source":"ghcr.io/acme/tools:latest","target":"/opt/tools"}]}}' \ - -- claude -``` + +Bind mounts share gateway-host filesystem resources with the sandbox. They may +be considered insecure because they can negate OpenShell controls such as +workspace isolation and filesystem policy. Use them only when you understand and +accept that loss of isolation. + -Use a `bind` mount only after enabling it in the Podman driver table. Treat -this setting as an unsafe operator override: +Use a `bind` mount only after enabling it in the Podman driver table: ```toml [openshell.drivers.podman] diff --git a/e2e/rust/tests/driver_config_volume.rs b/e2e/rust/tests/driver_config_volume.rs index 79f50ea0f..4efb9747e 100644 --- a/e2e/rust/tests/driver_config_volume.rs +++ b/e2e/rust/tests/driver_config_volume.rs @@ -4,6 +4,7 @@ #![cfg(feature = "e2e-local-container-driver")] use std::fs; +use std::io::Write; use std::os::unix::fs::PermissionsExt; use std::process::Output; use std::time::{SystemTime, UNIX_EPOCH}; @@ -93,8 +94,10 @@ async fn sandbox_mounts_enabled_driver_config_bind() { .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"); - fs::write(host_dir.path().join("input.txt"), "host-bind-ok") - .expect("seed bind mount host dir"); + 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 driver_config = driver_config_mount_json( &driver, @@ -105,8 +108,14 @@ async fn sandbox_mounts_enabled_driver_config_bind() { "read_only": false }), ); + // 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(&[ "--no-keep", + "--policy", + policy_path, "--driver-config-json", &driver_config, "--", @@ -129,6 +138,27 @@ async fn sandbox_mounts_enabled_driver_config_bind() { assert_eq!(output, "sandbox-bind-ok"); } +fn write_bind_mount_policy() -> Result { + 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 + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox +"#, + ) + .map_err(|err| format!("write bind policy: {err}"))?; + Ok(file) +} + fn seed_volume(volume: &VolumeGuard) -> Result<(), String> { run_engine( &volume.engine, diff --git a/e2e/with-podman-gateway.sh b/e2e/with-podman-gateway.sh index cda924b6a..94ec217c1 100755 --- a/e2e/with-podman-gateway.sh +++ b/e2e/with-podman-gateway.sh @@ -252,10 +252,6 @@ resolve_podman_supervisor_image() { ensure_podman_supervisor_image() { local image=$1 - if podman_cmd image exists "${image}" 2>/dev/null; then - return 0 - fi - if [ "${image}" = "openshell/supervisor:dev" ] \ && [ -z "${OPENSHELL_SUPERVISOR_IMAGE:-}" ] \ && [ -z "${CI:-}" ]; then @@ -270,6 +266,10 @@ ensure_podman_supervisor_image() { exit 2 fi + if podman_cmd image exists "${image}" 2>/dev/null; then + return 0 + fi + echo "Pulling Podman supervisor image ${image}..." if podman_cmd pull "${image}"; then return 0 @@ -342,6 +342,7 @@ HOST_PORT=$(e2e_pick_port) HEALTH_PORT=$(e2e_pick_port) STATE_DIR="${WORKDIR}/state" mkdir -p "${STATE_DIR}" +export XDG_STATE_HOME="${STATE_DIR}" JWT_DIR="${STATE_DIR}/jwt" E2E_NAMESPACE="e2e-podman-$$-${HOST_PORT}" From 2f831863fa7adcc8951c596b73968a0caa7d7864 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 9 Jun 2026 18:01:31 -0700 Subject: [PATCH 7/8] cleanup --- docs/reference/sandbox-compute-drivers.mdx | 6 +- e2e/rust/Cargo.lock | 512 +++++++++++++++++++++ e2e/rust/Cargo.toml | 2 + e2e/rust/tests/driver_config_volume.rs | 340 ++++++++++---- 4 files changed, 772 insertions(+), 88 deletions(-) diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index c88a4c2f8..efde54557 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -76,11 +76,7 @@ For GPU-backed Docker sandboxes, configure Docker CDI before starting the gatewa Docker driver config accepts user-supplied `volume` and `tmpfs` mounts. It also accepts `bind` mounts when `[openshell.drivers.docker]` sets -`enable_bind_mounts = true` in `gateway.toml`. Image mounts are not accepted in -Docker sandbox driver config. -Docker's [storage documentation](https://docs.docker.com/engine/storage/) -documents container storage mounts as volumes, bind mounts, tmpfs mounts, and -named pipes. +`enable_bind_mounts = true` in `gateway.toml`. See Docker's [storage documentation](https://docs.docker.com/engine/storage/) for more information. Use a `volume` mount for existing Docker named volumes: diff --git a/e2e/rust/Cargo.lock b/e2e/rust/Cargo.lock index aceacf682..953449c57 100644 --- a/e2e/rust/Cargo.lock +++ b/e2e/rust/Cargo.lock @@ -35,6 +35,49 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bollard" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" +dependencies = [ + "base64", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-util", + "hyperlocal", + "log", + "pin-project-lite", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.52.1-rc.29.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" +dependencies = [ + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "bytes" version = "1.11.1" @@ -76,6 +119,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.16.0" @@ -110,6 +164,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -125,6 +188,42 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -226,6 +325,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -239,6 +344,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -246,6 +352,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -253,11 +374,114 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", "pin-project-lite", "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] @@ -266,6 +490,27 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -311,6 +556,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -354,7 +605,9 @@ name = "openshell-e2e" version = "0.1.0" dependencies = [ "base64", + "bollard", "bytes", + "futures-util", "hex", "http-body-util", "hyper", @@ -391,12 +644,27 @@ dependencies = [ "windows-link", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -520,6 +788,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -539,6 +813,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -574,6 +849,29 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -606,6 +904,12 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -622,6 +926,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "syn" version = "2.0.117" @@ -633,6 +943,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.26.0" @@ -646,6 +967,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.50.0" @@ -674,6 +1025,44 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -698,6 +1087,24 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "version_check" version = "0.9.5" @@ -771,6 +1178,28 @@ dependencies = [ "semver", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -948,6 +1377,35 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.40" @@ -968,6 +1426,60 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 2ff799c98..083c622df 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -104,7 +104,9 @@ required-features = ["e2e-gpu"] [dependencies] base64 = "0.22" +bollard = "0.20" bytes = "1" +futures-util = "0.3" http-body-util = "0.1" hyper = { version = "1", features = ["client", "http1"] } hyper-util = { version = "0.1", features = ["tokio"] } diff --git a/e2e/rust/tests/driver_config_volume.rs b/e2e/rust/tests/driver_config_volume.rs index 4efb9747e..26725a066 100644 --- a/e2e/rust/tests/driver_config_volume.rs +++ b/e2e/rust/tests/driver_config_volume.rs @@ -6,10 +6,17 @@ use std::fs; use std::io::Write; use std::os::unix::fs::PermissionsExt; -use std::process::Output; +use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; -use openshell_e2e::harness::container::{ContainerEngine, e2e_driver}; +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::e2e_driver; use openshell_e2e::harness::sandbox::SandboxGuard; use serde_json::{Map, Value}; @@ -18,25 +25,37 @@ const VOLUME_TARGET: &str = "/sandbox/e2e-volume"; const BIND_TARGET: &str = "/sandbox/e2e-bind"; struct VolumeGuard { - engine: ContainerEngine, + docker: Docker, name: String, } impl VolumeGuard { - fn create(engine: ContainerEngine, driver: &str) -> Result { + async fn create(driver: &str) -> Result { let name = unique_volume_name(driver); - run_engine(&engine, &["volume", "create", &name])?; - Ok(Self { engine, name }) + 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 _ = self - .engine - .command() - .args(["volume", "rm", "-f", &self.name]) - .output(); + 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; + }); } } @@ -48,10 +67,11 @@ async fn sandbox_mounts_existing_driver_config_volume() { "driver_config volume e2e requires docker or podman, got {driver}" ); - let engine = ContainerEngine::from_env(); - let volume = VolumeGuard::create(engine, &driver).expect("create named test volume"); + let volume = VolumeGuard::create(&driver) + .await + .expect("create named test volume"); - seed_volume(&volume).expect("seed 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}}]}}}}"#, @@ -76,7 +96,9 @@ async fn sandbox_mounts_existing_driver_config_volume() { ); sandbox.cleanup().await; - verify_volume(&volume).expect("verify sandbox wrote to named test volume"); + verify_volume(&volume) + .await + .expect("verify sandbox wrote to named test volume"); } #[tokio::test] @@ -93,21 +115,19 @@ async fn sandbox_mounts_enabled_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"); + .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 driver_config = driver_config_mount_json( - &driver, - serde_json::json!({ - "type": "bind", - "source": host_dir.path(), - "target": BIND_TARGET, - "read_only": false - }), - ); + let bind_mount = serde_json::json!({ + "type": "bind", + "source": host_dir.path(), + "target": BIND_TARGET, + "read_only": false + }); + 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"); @@ -142,7 +162,7 @@ fn write_bind_mount_policy() -> Result { let mut file = tempfile::NamedTempFile::new().map_err(|err| format!("create bind policy: {err}"))?; file.write_all( - br#"version: 1 + br"version: 1 filesystem_policy: include_workdir: false @@ -153,49 +173,31 @@ landlock: process: run_as_user: sandbox run_as_group: sandbox -"#, +", ) .map_err(|err| format!("write bind policy: {err}"))?; Ok(file) } -fn seed_volume(volume: &VolumeGuard) -> Result<(), String> { - run_engine( - &volume.engine, - &[ - "run", - "--rm", - "--user", - "0:0", - "--volume", - &format!("{}:/vol", volume.name), - "--entrypoint", - "sh", - TEST_IMAGE, - "-lc", - "set -eu; chmod 0777 /vol; printf host-volume-ok > /vol/input.txt", - ], - )?; +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(()) } -fn verify_volume(volume: &VolumeGuard) -> Result<(), String> { - let output = run_engine( - &volume.engine, - &[ - "run", - "--rm", - "--user", - "0:0", - "--volume", - &format!("{}:/vol:ro", volume.name), - "--entrypoint", - "sh", - TEST_IMAGE, - "-lc", - "set -eu; test \"$(cat /vol/input.txt)\" = host-volume-ok; test \"$(cat /vol/output.txt)\" = sandbox-volume-ok; echo volume-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}" @@ -204,34 +206,206 @@ fn verify_volume(volume: &VolumeGuard) -> Result<(), String> { Ok(()) } -fn run_engine(engine: &ContainerEngine, args: &[&str]) -> Result { - let output = engine - .command() - .args(args) - .output() - .map_err(|err| format!("spawn {} {}: {err}", engine.name(), args.join(" ")))?; - engine_output(engine, args, &output) +async fn run_volume_container( + volume: &VolumeGuard, + purpose: &str, + read_only: bool, + script: &str, +) -> Result { + 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::) + .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}" + )), + } } -fn engine_output( - engine: &ContainerEngine, - args: &[&str], - output: &Output, -) -> Result { - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let combined = format!("{stdout}{stderr}"); - if output.status.success() { - return Ok(combined); +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::>() + .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::>(); + if pull_errors.is_empty() { + return Ok(()); + } + Err(format!( - "{} {} failed (exit {:?}):\n{combined}", - engine.name(), - args.join(" "), - output.status.code() + "pull helper image {TEST_IMAGE} failed:\n{}", + pull_errors.join("\n") )) } +async fn run_created_container( + volume: &VolumeGuard, + container_name: &str, +) -> Result { + volume + .docker + .start_container(container_name, None::) + .await + .map_err(|err| format!("start helper container {container_name}: {err}"))?; + + let wait_result = volume + .docker + .wait_container(container_name, None::) + .try_collect::>() + .await; + let logs = volume + .docker + .logs( + container_name, + Some(LogsOptions { + stdout: true, + stderr: true, + tail: "all".to_string(), + ..Default::default() + }), + ) + .try_collect::>() + .await + .map(|chunks| { + chunks + .into_iter() + .map(|chunk| chunk.to_string()) + .collect::() + }); + + 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 { + 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() -> PathBuf { + if let Some(path) = std::env::var_os("OPENSHELL_PODMAN_SOCKET") { + return PathBuf::from(path); + } + + #[cfg(target_os = "macos")] + { + let home = std::env::var_os("HOME").unwrap_or_default(); + PathBuf::from(home).join(".local/share/containers/podman/machine/podman.sock") + } + #[cfg(target_os = "linux")] + { + std::env::var_os("XDG_RUNTIME_DIR").map_or_else( + || { + let uid = std::process::Command::new("id") + .arg("-u") + .output() + .ok() + .and_then(|output| { + String::from_utf8(output.stdout) + .ok() + .map(|value| value.trim().to_string()) + }) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "1000".to_string()); + PathBuf::from(format!("/run/user/{uid}/podman/podman.sock")) + }, + |xdg| PathBuf::from(xdg).join("podman/podman.sock"), + ) + } +} + fn unique_volume_name(driver: &str) -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -243,7 +417,7 @@ fn unique_volume_name(driver: &str) -> String { ) } -fn driver_config_mount_json(driver: &str, mount: Value) -> String { +fn driver_config_mount_json(driver: &str, mount: &Value) -> String { let mut root = Map::new(); root.insert( driver.to_string(), From 1c044a4327f1f91ec04e992511489cf3f84f2bb9 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 9 Jun 2026 19:09:20 -0700 Subject: [PATCH 8/8] test(e2e): stabilize branch checks --- e2e/python/test_sandbox_policy.py | 21 ++++++++------------- e2e/rust/tests/driver_config_volume.rs | 23 +++++++++++++++++++++-- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/e2e/python/test_sandbox_policy.py b/e2e/python/test_sandbox_policy.py index 6d797b49b..5ac37bd27 100644 --- a/e2e/python/test_sandbox_policy.py +++ b/e2e/python/test_sandbox_policy.py @@ -30,10 +30,11 @@ # Standard proxy address inside the sandbox network namespace _PROXY_HOST = "10.200.0.1" _PROXY_PORT = 3128 -# sslip.io keeps the wildcard test on deterministic public DNS. Vendor-owned -# telemetry subdomains can be NXDOMAIN or resolve to private ranges in CI. -_PUBLIC_WILDCARD_SUFFIX = "1.1.1.1.sslip.io" +# example.com keeps the wildcard test on public DNS while avoiding sslip.io +# rewrites that can resolve to internal ranges in CI. +_PUBLIC_WILDCARD_SUFFIX = "example.com" _PUBLIC_WILDCARD_PATTERN = f"*.{_PUBLIC_WILDCARD_SUFFIX}" +_PUBLIC_WILDCARD_SUBDOMAIN = f"www.{_PUBLIC_WILDCARD_SUFFIX}" def _base_policy( @@ -1865,19 +1866,13 @@ def test_host_wildcard_matches_subdomain( ) spec = datamodel_pb2.SandboxSpec(policy=policy) with sandbox(spec=spec, delete_on_exit=True) as sb: - first_subdomain = f"alpha.{_PUBLIC_WILDCARD_SUFFIX}" - result = sb.exec_python(_proxy_connect(), args=(first_subdomain, 443)) - assert result.exit_code == 0, result.stderr - assert "200" in result.stdout, ( - f"{_PUBLIC_WILDCARD_PATTERN} should match {first_subdomain}: " - f"{result.stdout}" + result = sb.exec_python( + _proxy_connect(), args=(_PUBLIC_WILDCARD_SUBDOMAIN, 443) ) - - second_subdomain = f"beta.{_PUBLIC_WILDCARD_SUFFIX}" - result = sb.exec_python(_proxy_connect(), args=(second_subdomain, 443)) assert result.exit_code == 0, result.stderr assert "200" in result.stdout, ( - f"{_PUBLIC_WILDCARD_PATTERN} should match {second_subdomain}: " + f"{_PUBLIC_WILDCARD_PATTERN} should match " + f"{_PUBLIC_WILDCARD_SUBDOMAIN}: " f"{result.stdout}" ) diff --git a/e2e/rust/tests/driver_config_volume.rs b/e2e/rust/tests/driver_config_volume.rs index 26725a066..51e56b97c 100644 --- a/e2e/rust/tests/driver_config_volume.rs +++ b/e2e/rust/tests/driver_config_volume.rs @@ -6,7 +6,7 @@ use std::fs; use std::io::Write; use std::os::unix::fs::PermissionsExt; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use bollard::Docker; @@ -121,9 +121,10 @@ async fn sandbox_mounts_enabled_driver_config_bind() { 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": host_dir.path(), + "source": bind_source, "target": BIND_TARGET, "read_only": false }); @@ -427,3 +428,21 @@ fn driver_config_mount_json(driver: &str, mount: &Value) -> String { ); 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 { + 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) +}