Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/openshell-driver-podman/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
228 changes: 228 additions & 0 deletions crates/openshell-driver-podman/src/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ pub struct PodmanSandboxDriverConfig {
deserialize_with = "deserialize_optional_non_empty_string_list"
)]
pub cdi_devices: Option<Vec<String>>,
/// 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<PodmanDriverMountConfig>,
}

Expand Down Expand Up @@ -196,6 +202,12 @@ struct ContainerSpec {
/// the gateway server running on the host in rootless mode.
hostadd: Vec<String>,
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<UserNS>,
// 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.
Expand Down Expand Up @@ -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 {}

Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<UserNS> {
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<String> {
let host_gateway_ip = config.host_gateway_ip.trim();
if host_gateway_ip.is_empty() {
Expand Down Expand Up @@ -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());
}
}
4 changes: 4 additions & 0 deletions docs/reference/gateway-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docs/reference/sandbox-compute-drivers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading