Skip to content

Commit 5505c61

Browse files
committed
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 <bergmann.f@gmail.com>
1 parent f852d07 commit 5505c61

4 files changed

Lines changed: 246 additions & 1 deletion

File tree

crates/openshell-driver-podman/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ The gateway forwards the `podman` block from `--driver-config-json` to this
5656
driver. The driver accepts user-supplied `mounts` entries with these Podman
5757
mount types:
5858

59+
- `userns_mode`: optional per-sandbox user namespace mode. Accepts the same
60+
values as `podman run --userns`, such as `keep-id`,
61+
`keep-id:uid=200,gid=210`, `auto`, `nomap`, or `host`.
5962
- `bind`: mounts an absolute host path when `[openshell.drivers.podman]`
6063
has `enable_bind_mounts = true`.
6164
- `volume`: mounts an existing Podman named volume. The driver validates that
@@ -89,6 +92,14 @@ openshell sandbox create \
8992
-- claude
9093
```
9194

95+
Example per-sandbox user namespace selection:
96+
97+
```shell
98+
openshell sandbox create \
99+
--driver-config-json '{"podman":{"userns_mode":"keep-id:uid=200,gid=210"}}' \
100+
-- claude
101+
```
102+
92103
### Capability Breakdown
93104

94105
| Capability | Purpose |

crates/openshell-driver-podman/src/container.rs

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ pub struct PodmanSandboxDriverConfig {
7171
deserialize_with = "deserialize_optional_non_empty_string_list"
7272
)]
7373
pub cdi_devices: Option<Vec<String>>,
74+
/// Optional per-sandbox user namespace mode.
75+
///
76+
/// Accepts the same values as `podman run --userns`, e.g. `"keep-id"`,
77+
/// `"keep-id:uid=200,gid=210"`, `"auto"`, `"nomap"`, or `"host"`.
78+
#[serde(default)]
79+
pub userns_mode: String,
7480
mounts: Vec<PodmanDriverMountConfig>,
7581
}
7682

@@ -196,6 +202,12 @@ struct ContainerSpec {
196202
/// the gateway server running on the host in rootless mode.
197203
hostadd: Vec<String>,
198204
netns: NetNS,
205+
/// User namespace mode. Defaults to Podman's own behavior when
206+
/// `driver_config.userns_mode` is unset. When set to e.g.
207+
/// `"keep-id:uid=200,gid=210"`, the sandbox container runs inside
208+
/// a user namespace where container UID 0 maps to the host UID 200.
209+
#[serde(skip_serializing_if = "Option::is_none")]
210+
userns: Option<UserNS>,
199211
// Matches libpod's network spec format, which is `{name: {opts}}` where
200212
// empty opts is a unit struct rather than `()`. Keep as a map so JSON
201213
// serialization matches the API exactly.
@@ -298,6 +310,20 @@ struct NetNS {
298310
nsmode: String,
299311
}
300312

313+
/// User namespace specification for the libpod container create API.
314+
///
315+
/// Mirrors Podman's `specgen.Namespace` struct, which has two fields:
316+
/// `nsmode` (e.g. `"keep-id"`, `"host"`, `"auto"`) and an optional `value`
317+
/// string holding the mode-specific options (e.g. `"uid=200,gid=210"` for
318+
/// `keep-id`). The CLI `--userns keep-id:uid=200,gid=210` is split on the
319+
/// first colon: `nsmode = "keep-id"`, `value = "uid=200,gid=210"`.
320+
#[derive(Serialize)]
321+
struct UserNS {
322+
nsmode: String,
323+
#[serde(skip_serializing_if = "String::is_empty")]
324+
value: String,
325+
}
326+
301327
#[derive(Serialize)]
302328
struct NetworkAttachment {}
303329

@@ -813,10 +839,12 @@ pub fn build_container_spec_with_token_and_gpu_devices(
813839
let image = resolve_image(sandbox, config);
814840
let name = container_name(&sandbox.name);
815841
let vol = volume_name(&sandbox.id);
842+
let sandbox_driver_config = PodmanSandboxDriverConfig::from_sandbox(sandbox)?;
816843

817844
let env = build_env(sandbox, config, image);
818845
let labels = build_labels(sandbox);
819846
let resource_limits = build_resource_limits(sandbox, config);
847+
820848
let user_mounts = podman_user_mounts(sandbox, config.enable_bind_mounts)
821849
.map_err(ComputeDriverError::InvalidArgument)?;
822850
let devices = gpu_device_ids.map(|device_ids| {
@@ -971,6 +999,7 @@ pub fn build_container_spec_with_token_and_gpu_devices(
971999
netns: NetNS {
9721000
nsmode: "bridge".to_string(),
9731001
},
1002+
userns: build_userns(&sandbox_driver_config.userns_mode),
9741003
networks,
9751004
devices,
9761005
// 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(
10501079
Ok(serde_json::to_value(container_spec).expect("ContainerSpec serialization cannot fail"))
10511080
}
10521081

1082+
/// Parse a `--userns` style string (e.g. `"keep-id:uid=200,gid=210"`)
1083+
/// into a libpod `Namespace` object.
1084+
///
1085+
/// Splits on the first colon: the left side becomes `nsmode`, the right
1086+
/// side becomes `value`. If there is no colon, the entire string is the
1087+
/// `nsmode` and `value` is empty.
1088+
///
1089+
/// Returns `None` when the input is empty, so the `userns` field is
1090+
/// omitted from the JSON and Podman uses its default (host namespace).
1091+
fn build_userns(userns_mode: &str) -> Option<UserNS> {
1092+
let userns_mode = userns_mode.trim();
1093+
if userns_mode.is_empty() {
1094+
return None;
1095+
}
1096+
match userns_mode.split_once(':') {
1097+
Some((nsmode, value)) => Some(UserNS {
1098+
nsmode: nsmode.to_string(),
1099+
value: value.to_string(),
1100+
}),
1101+
None => Some(UserNS {
1102+
nsmode: userns_mode.to_string(),
1103+
value: String::new(),
1104+
}),
1105+
}
1106+
}
1107+
10531108
fn hostadd_entries(config: &PodmanComputeConfig) -> Vec<String> {
10541109
let host_gateway_ip = config.host_gateway_ip.trim();
10551110
if host_gateway_ip.is_empty() {
@@ -2361,4 +2416,177 @@ mod tests {
23612416
.count();
23622417
assert_eq!(bind_count, 0, "no bind mounts without TLS config");
23632418
}
2419+
2420+
// ── userns tests ─────────────────────────────────────────────────────
2421+
2422+
#[test]
2423+
fn container_spec_omits_userns_when_unset() {
2424+
let sandbox = test_sandbox("test-id", "test-name");
2425+
let config = test_config();
2426+
let spec = build_container_spec(&sandbox, &config);
2427+
2428+
assert!(
2429+
spec.get("userns").is_none(),
2430+
"userns should be omitted when userns_mode is empty"
2431+
);
2432+
}
2433+
2434+
#[test]
2435+
fn container_spec_omits_userns_when_whitespace_only() {
2436+
use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate};
2437+
2438+
let sandbox = test_sandbox("test-id", "test-name");
2439+
let mut sandbox = sandbox;
2440+
sandbox.spec = Some(DriverSandboxSpec {
2441+
template: Some(DriverSandboxTemplate {
2442+
driver_config: Some(json_struct(serde_json::json!({
2443+
"userns_mode": " "
2444+
}))),
2445+
..Default::default()
2446+
}),
2447+
..Default::default()
2448+
});
2449+
let config = test_config();
2450+
let spec = build_container_spec(&sandbox, &config);
2451+
2452+
assert!(
2453+
spec.get("userns").is_none(),
2454+
"userns should be omitted when userns_mode is whitespace only"
2455+
);
2456+
}
2457+
2458+
#[test]
2459+
fn container_spec_userns_keep_id_without_options_from_driver_config() {
2460+
use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate};
2461+
2462+
let mut sandbox = test_sandbox("test-id", "test-name");
2463+
sandbox.spec = Some(DriverSandboxSpec {
2464+
template: Some(DriverSandboxTemplate {
2465+
driver_config: Some(json_struct(serde_json::json!({
2466+
"userns_mode": "keep-id"
2467+
}))),
2468+
..Default::default()
2469+
}),
2470+
..Default::default()
2471+
});
2472+
let config = test_config();
2473+
let spec = build_container_spec(&sandbox, &config);
2474+
2475+
let userns = spec.get("userns").expect("userns should be present");
2476+
assert_eq!(userns["nsmode"].as_str(), Some("keep-id"));
2477+
assert!(
2478+
userns.get("value").is_none() || userns["value"].as_str() == Some(""),
2479+
"value should be omitted or empty for keep-id without options"
2480+
);
2481+
}
2482+
2483+
#[test]
2484+
fn container_spec_userns_keep_id_with_uid_and_gid_from_driver_config() {
2485+
use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate};
2486+
2487+
let mut sandbox = test_sandbox("test-id", "test-name");
2488+
sandbox.spec = Some(DriverSandboxSpec {
2489+
template: Some(DriverSandboxTemplate {
2490+
driver_config: Some(json_struct(serde_json::json!({
2491+
"userns_mode": "keep-id:uid=200,gid=210"
2492+
}))),
2493+
..Default::default()
2494+
}),
2495+
..Default::default()
2496+
});
2497+
let config = test_config();
2498+
let spec = build_container_spec(&sandbox, &config);
2499+
2500+
let userns = spec.get("userns").expect("userns should be present");
2501+
assert_eq!(userns["nsmode"].as_str(), Some("keep-id"));
2502+
assert_eq!(userns["value"].as_str(), Some("uid=200,gid=210"));
2503+
}
2504+
2505+
#[test]
2506+
fn container_spec_userns_auto_from_driver_config() {
2507+
use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate};
2508+
2509+
let mut sandbox = test_sandbox("test-id", "test-name");
2510+
sandbox.spec = Some(DriverSandboxSpec {
2511+
template: Some(DriverSandboxTemplate {
2512+
driver_config: Some(json_struct(serde_json::json!({
2513+
"userns_mode": "auto"
2514+
}))),
2515+
..Default::default()
2516+
}),
2517+
..Default::default()
2518+
});
2519+
let config = test_config();
2520+
let spec = build_container_spec(&sandbox, &config);
2521+
2522+
let userns = spec.get("userns").expect("userns should be present");
2523+
assert_eq!(userns["nsmode"].as_str(), Some("auto"));
2524+
}
2525+
2526+
#[test]
2527+
fn container_spec_userns_trims_whitespace_from_driver_config() {
2528+
use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate};
2529+
2530+
let mut sandbox = test_sandbox("test-id", "test-name");
2531+
sandbox.spec = Some(DriverSandboxSpec {
2532+
template: Some(DriverSandboxTemplate {
2533+
driver_config: Some(json_struct(serde_json::json!({
2534+
"userns_mode": " keep-id:uid=200,gid=210 "
2535+
}))),
2536+
..Default::default()
2537+
}),
2538+
..Default::default()
2539+
});
2540+
let config = test_config();
2541+
let spec = build_container_spec(&sandbox, &config);
2542+
2543+
let userns = spec.get("userns").expect("userns should be present");
2544+
assert_eq!(userns["nsmode"].as_str(), Some("keep-id"));
2545+
assert_eq!(userns["value"].as_str(), Some("uid=200,gid=210"));
2546+
}
2547+
2548+
#[test]
2549+
fn driver_config_accepts_userns_mode_alongside_mounts() {
2550+
use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate};
2551+
2552+
let mut sandbox = test_sandbox("test-id", "test-name");
2553+
sandbox.spec = Some(DriverSandboxSpec {
2554+
template: Some(DriverSandboxTemplate {
2555+
driver_config: Some(json_struct(serde_json::json!({
2556+
"userns_mode": "nomap",
2557+
"mounts": [{
2558+
"type": "tmpfs",
2559+
"target": "/sandbox/tmp"
2560+
}]
2561+
}))),
2562+
..Default::default()
2563+
}),
2564+
..Default::default()
2565+
});
2566+
let config = test_config();
2567+
let spec = build_container_spec(&sandbox, &config);
2568+
2569+
assert_eq!(spec["userns"]["nsmode"].as_str(), Some("nomap"));
2570+
}
2571+
2572+
#[test]
2573+
fn build_userns_splits_on_first_colon() {
2574+
// Single colon: left = nsmode, right = value
2575+
let result = build_userns("keep-id:uid=200,gid=210").unwrap();
2576+
assert_eq!(result.nsmode, "keep-id");
2577+
assert_eq!(result.value, "uid=200,gid=210");
2578+
}
2579+
2580+
#[test]
2581+
fn build_userns_no_colon_returns_nsmode_only() {
2582+
let result = build_userns("auto").unwrap();
2583+
assert_eq!(result.nsmode, "auto");
2584+
assert!(result.value.is_empty());
2585+
}
2586+
2587+
#[test]
2588+
fn build_userns_empty_returns_none() {
2589+
assert!(build_userns("").is_none());
2590+
assert!(build_userns(" ").is_none());
2591+
}
23642592
}

docs/reference/gateway-config.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,10 @@ guest_tls_key = "/etc/openshell/certs/client-key.pem"
280280
enable_bind_mounts = false
281281
# Set to 0 to leave Podman's runtime default unchanged.
282282
sandbox_pids_limit = 2048
283+
# User namespace mode for sandbox containers. Accepts the same values as
284+
# `podman run --userns`, e.g. "keep-id", "keep-id:uid=200,gid=210",
285+
# "auto", "nomap", or "host". Empty (default) = Podman default behavior.
286+
# userns_mode = "keep-id:uid=200,gid=210"
283287
# Health check interval in seconds. Lower values detect readiness faster
284288
# but increase process churn (each check spawns a conmon subprocess).
285289
# Set to 0 to disable health checks entirely. Default: 10.

docs/reference/sandbox-compute-drivers.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,12 @@ The gateway talks to the Podman API socket. The Podman driver requires Podman 5.
179179

180180
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).
181181

182-
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]`.
182+
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]`.
183183

184184
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.
185185

186+
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.
187+
186188
### Podman Driver Config Mounts
187189

188190
Podman driver config accepts user-supplied `volume`, `tmpfs`, and `image`

0 commit comments

Comments
 (0)