From 5505c614efa42d7ebc6fac3acf6e1e4552d0acd5 Mon Sep 17 00:00:00 2001 From: Florian Bergmann Date: Fri, 3 Jul 2026 23:35:55 +0200 Subject: [PATCH] feat(driver-podman): add per-sandbox user namespace mode Add a `userns_mode` field to `PodmanSandboxDriverConfig` so each sandbox can independently select its user namespace mode via `--driver-config-json`. Accepts the same values as `podman run --userns`, e.g. "keep-id", "keep-id:uid=200,gid=210", "auto", "nomap", or "host". Empty (default) leaves Podman's default behavior unchanged. The `build_userns` helper reads from the per-sandbox driver config and parses the string into a libpod `Namespace` object, splitting on the first colon: the left side becomes `nsmode` and the right side becomes `value`. When unset or whitespace-only, the `userns` field is omitted from the container spec JSON so Podman uses its default (host namespace). Includes unit tests covering all parsing paths, whitespace trimming, and coexistence with mounts in driver config. Updates the driver README with usage examples. Signed-off-by: Florian Bergmann --- crates/openshell-driver-podman/README.md | 11 + .../openshell-driver-podman/src/container.rs | 228 ++++++++++++++++++ docs/reference/gateway-config.mdx | 4 + docs/reference/sandbox-compute-drivers.mdx | 4 +- 4 files changed, 246 insertions(+), 1 deletion(-) diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index c0c84132b..18c9159b4 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -56,6 +56,9 @@ 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: +- `userns_mode`: optional per-sandbox user namespace mode. Accepts the same + values as `podman run --userns`, such as `keep-id`, + `keep-id:uid=200,gid=210`, `auto`, `nomap`, or `host`. - `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 @@ -89,6 +92,14 @@ openshell sandbox create \ -- claude ``` +Example per-sandbox user namespace selection: + +```shell +openshell sandbox create \ + --driver-config-json '{"podman":{"userns_mode":"keep-id:uid=200,gid=210"}}' \ + -- claude +``` + ### Capability Breakdown | Capability | Purpose | diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 7c9ab269f..49b831347 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -71,6 +71,12 @@ pub struct PodmanSandboxDriverConfig { deserialize_with = "deserialize_optional_non_empty_string_list" )] pub cdi_devices: Option>, + /// Optional per-sandbox user namespace mode. + /// + /// Accepts the same values as `podman run --userns`, e.g. `"keep-id"`, + /// `"keep-id:uid=200,gid=210"`, `"auto"`, `"nomap"`, or `"host"`. + #[serde(default)] + pub userns_mode: String, mounts: Vec, } @@ -196,6 +202,12 @@ struct ContainerSpec { /// the gateway server running on the host in rootless mode. hostadd: Vec, netns: NetNS, + /// User namespace mode. Defaults to Podman's own behavior when + /// `driver_config.userns_mode` is unset. When set to e.g. + /// `"keep-id:uid=200,gid=210"`, the sandbox container runs inside + /// a user namespace where container UID 0 maps to the host UID 200. + #[serde(skip_serializing_if = "Option::is_none")] + userns: Option, // Matches libpod's network spec format, which is `{name: {opts}}` where // empty opts is a unit struct rather than `()`. Keep as a map so JSON // serialization matches the API exactly. @@ -298,6 +310,20 @@ struct NetNS { nsmode: String, } +/// User namespace specification for the libpod container create API. +/// +/// Mirrors Podman's `specgen.Namespace` struct, which has two fields: +/// `nsmode` (e.g. `"keep-id"`, `"host"`, `"auto"`) and an optional `value` +/// string holding the mode-specific options (e.g. `"uid=200,gid=210"` for +/// `keep-id`). The CLI `--userns keep-id:uid=200,gid=210` is split on the +/// first colon: `nsmode = "keep-id"`, `value = "uid=200,gid=210"`. +#[derive(Serialize)] +struct UserNS { + nsmode: String, + #[serde(skip_serializing_if = "String::is_empty")] + value: String, +} + #[derive(Serialize)] struct NetworkAttachment {} @@ -813,10 +839,12 @@ pub fn build_container_spec_with_token_and_gpu_devices( let image = resolve_image(sandbox, config); let name = container_name(&sandbox.name); let vol = volume_name(&sandbox.id); + let sandbox_driver_config = PodmanSandboxDriverConfig::from_sandbox(sandbox)?; let env = build_env(sandbox, config, image); let labels = build_labels(sandbox); let resource_limits = build_resource_limits(sandbox, config); + let user_mounts = podman_user_mounts(sandbox, config.enable_bind_mounts) .map_err(ComputeDriverError::InvalidArgument)?; let devices = gpu_device_ids.map(|device_ids| { @@ -971,6 +999,7 @@ pub fn build_container_spec_with_token_and_gpu_devices( netns: NetNS { nsmode: "bridge".to_string(), }, + userns: build_userns(&sandbox_driver_config.userns_mode), networks, devices, // Mount a tmpfs at /run/netns so the sandbox supervisor can create @@ -1050,6 +1079,32 @@ pub fn build_container_spec_with_token_and_gpu_devices( Ok(serde_json::to_value(container_spec).expect("ContainerSpec serialization cannot fail")) } +/// Parse a `--userns` style string (e.g. `"keep-id:uid=200,gid=210"`) +/// into a libpod `Namespace` object. +/// +/// Splits on the first colon: the left side becomes `nsmode`, the right +/// side becomes `value`. If there is no colon, the entire string is the +/// `nsmode` and `value` is empty. +/// +/// Returns `None` when the input is empty, so the `userns` field is +/// omitted from the JSON and Podman uses its default (host namespace). +fn build_userns(userns_mode: &str) -> Option { + let userns_mode = userns_mode.trim(); + if userns_mode.is_empty() { + return None; + } + match userns_mode.split_once(':') { + Some((nsmode, value)) => Some(UserNS { + nsmode: nsmode.to_string(), + value: value.to_string(), + }), + None => Some(UserNS { + nsmode: userns_mode.to_string(), + value: String::new(), + }), + } +} + fn hostadd_entries(config: &PodmanComputeConfig) -> Vec { let host_gateway_ip = config.host_gateway_ip.trim(); if host_gateway_ip.is_empty() { @@ -2361,4 +2416,177 @@ mod tests { .count(); assert_eq!(bind_count, 0, "no bind mounts without TLS config"); } + + // ── userns tests ───────────────────────────────────────────────────── + + #[test] + fn container_spec_omits_userns_when_unset() { + let sandbox = test_sandbox("test-id", "test-name"); + let config = test_config(); + let spec = build_container_spec(&sandbox, &config); + + assert!( + spec.get("userns").is_none(), + "userns should be omitted when userns_mode is empty" + ); + } + + #[test] + fn container_spec_omits_userns_when_whitespace_only() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let sandbox = test_sandbox("test-id", "test-name"); + let mut sandbox = sandbox; + sandbox.spec = Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "userns_mode": " " + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + let spec = build_container_spec(&sandbox, &config); + + assert!( + spec.get("userns").is_none(), + "userns should be omitted when userns_mode is whitespace only" + ); + } + + #[test] + fn container_spec_userns_keep_id_without_options_from_driver_config() { + 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!({ + "userns_mode": "keep-id" + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + let spec = build_container_spec(&sandbox, &config); + + let userns = spec.get("userns").expect("userns should be present"); + assert_eq!(userns["nsmode"].as_str(), Some("keep-id")); + assert!( + userns.get("value").is_none() || userns["value"].as_str() == Some(""), + "value should be omitted or empty for keep-id without options" + ); + } + + #[test] + fn container_spec_userns_keep_id_with_uid_and_gid_from_driver_config() { + 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!({ + "userns_mode": "keep-id:uid=200,gid=210" + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + let spec = build_container_spec(&sandbox, &config); + + let userns = spec.get("userns").expect("userns should be present"); + assert_eq!(userns["nsmode"].as_str(), Some("keep-id")); + assert_eq!(userns["value"].as_str(), Some("uid=200,gid=210")); + } + + #[test] + fn container_spec_userns_auto_from_driver_config() { + 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!({ + "userns_mode": "auto" + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + let spec = build_container_spec(&sandbox, &config); + + let userns = spec.get("userns").expect("userns should be present"); + assert_eq!(userns["nsmode"].as_str(), Some("auto")); + } + + #[test] + fn container_spec_userns_trims_whitespace_from_driver_config() { + 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!({ + "userns_mode": " keep-id:uid=200,gid=210 " + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + let spec = build_container_spec(&sandbox, &config); + + let userns = spec.get("userns").expect("userns should be present"); + assert_eq!(userns["nsmode"].as_str(), Some("keep-id")); + assert_eq!(userns["value"].as_str(), Some("uid=200,gid=210")); + } + + #[test] + fn driver_config_accepts_userns_mode_alongside_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!({ + "userns_mode": "nomap", + "mounts": [{ + "type": "tmpfs", + "target": "/sandbox/tmp" + }] + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + let spec = build_container_spec(&sandbox, &config); + + assert_eq!(spec["userns"]["nsmode"].as_str(), Some("nomap")); + } + + #[test] + fn build_userns_splits_on_first_colon() { + // Single colon: left = nsmode, right = value + let result = build_userns("keep-id:uid=200,gid=210").unwrap(); + assert_eq!(result.nsmode, "keep-id"); + assert_eq!(result.value, "uid=200,gid=210"); + } + + #[test] + fn build_userns_no_colon_returns_nsmode_only() { + let result = build_userns("auto").unwrap(); + assert_eq!(result.nsmode, "auto"); + assert!(result.value.is_empty()); + } + + #[test] + fn build_userns_empty_returns_none() { + assert!(build_userns("").is_none()); + assert!(build_userns(" ").is_none()); + } } diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 88b82870d..ab7069a5c 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -280,6 +280,10 @@ 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 +# User namespace mode for sandbox containers. Accepts the same values as +# `podman run --userns`, e.g. "keep-id", "keep-id:uid=200,gid=210", +# "auto", "nomap", or "host". Empty (default) = Podman default behavior. +# userns_mode = "keep-id:uid=200,gid=210" # Health check interval in seconds. Lower values detect readiness faster # but increase process churn (each check spawns a conmon subprocess). # Set to 0 to disable health checks entirely. Default: 10. diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index f860d64d4..125ca5128 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -179,10 +179,12 @@ The gateway talks to the Podman API socket. The Podman driver requires Podman 5. For maintainer-level implementation details, refer to the [Podman driver README](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-driver-podman/README.md) and [Podman networking notes](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-driver-podman/NETWORKING.md). -Select Podman with `compute_drivers = ["podman"]` in `[openshell.gateway]`. Configure Podman driver values such as `socket_path`, `network_name`, `supervisor_image`, `stop_timeout_secs`, `image_pull_policy`, `grpc_endpoint`, `host_gateway_ip`, `sandbox_ssh_socket_path`, `sandbox_pids_limit`, and `guest_tls_*` in `[openshell.drivers.podman]`. +Select Podman with `compute_drivers = ["podman"]` in `[openshell.gateway]`. Configure Podman driver values such as `socket_path`, `network_name`, `supervisor_image`, `stop_timeout_secs`, `image_pull_policy`, `grpc_endpoint`, `host_gateway_ip`, `sandbox_ssh_socket_path`, `sandbox_pids_limit`, `guest_tls_*`, and `userns_mode` in `[openshell.drivers.podman]`. 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. +Set `userns_mode` in `[openshell.drivers.podman]` to run sandbox containers inside a user namespace. Accepts the same values as `podman run --userns`, such as `"keep-id"`, `"keep-id:uid=200,gid=210"`, `"auto"`, `"nomap"`, or `"host"`. Empty (default) leaves Podman's default behavior unchanged. + ### Podman Driver Config Mounts Podman driver config accepts user-supplied `volume`, `tmpfs`, and `image`