Skip to content

Commit cbbb604

Browse files
committed
feat(driver-podman): add userns config with supervisor bind-mount fallback
Add a `userns` option to the Podman compute driver that maps to Podman's user namespace modes. The mode string is split on the first colon into the API's `nsmode` and `value` fields so parameterized values like `auto:size=65536` and `keep-id:uid=1000,gid=1000` are forwarded correctly. When the mode is `auto`, the container spec also sets `idmappings.AutoUserNs = true` as required by the API. An allowlist validates the mode at startup: `auto` and `keep-id` accept optional parameters; `host`, `private`, and `nomap` reject them; everything else is an error. Podman image volumes use overlay mounts internally and the kernel does not support idmapped mounts on overlay (`mount_setattr` returns EINVAL). When userns is configured (any mode except `host`), the driver extracts the supervisor binary from the image to a host-side cache and bind-mounts it instead of using an image volume. Configurable via TOML `userns = "auto"`, CLI `--userns`, or environment variable `OPENSHELL_PODMAN_USERNS`. Signed-off-by: Giuseppe Scrivano <gscrivan@redhat.com>
1 parent dcec2ab commit cbbb604

11 files changed

Lines changed: 871 additions & 29 deletions

File tree

.agents/skills/debug-openshell-cluster/SKILL.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,24 @@ Common findings:
216216
cannot bypass slirp4netns host-loopback isolation. Do not work around
217217
discovery failures by broadening the primary gateway listener to `0.0.0.0`.
218218

219+
When `userns` is configured (e.g. `userns = "auto"` or `userns = "keep-id"`):
220+
221+
- Supervisor delivery uses bind-mount fallback instead of image volumes because
222+
overlay mounts do not support `idmapped` mounts. The supervisor binary is
223+
extracted from the supervisor image and cached at
224+
`$XDG_DATA_HOME/openshell/podman-supervisor/` (typically
225+
`~/.local/share/openshell/podman-supervisor/`).
226+
- Stale cache: if the supervisor image is updated but the cached binary is not
227+
refreshed, sandbox creation may fail with an ELF validation error or version
228+
mismatch. Remove the cache directory and retry.
229+
- `auto` mode requires subuid/subgid ranges for the current user in
230+
`/etc/subuid` and `/etc/subgid`. If missing, Podman returns a user-namespace
231+
mapping error at container creation.
232+
- `private` mode is not supported — it requires explicit UID/GID mappings that
233+
the driver does not expose. Use `auto`, `keep-id`, or `no-map` instead.
234+
- `nomap` (without hyphen) is accepted as input but canonicalized to `no-map`
235+
for Podman's API.
236+
219237
### Step 6: Check Kubernetes Helm Gateways
220238

221239
```bash

architecture/compute-runtimes.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ delete, reconciliation removes the row; otherwise it can remain `Deleting`.
117117
| Runtime | Best fit | Sandbox boundary | Notes |
118118
|---|---|---|---|
119119
| Docker | Local development with Docker available. | Container plus nested sandbox namespace. | Uses host networking so loopback gateway endpoints work from the supervisor. |
120-
| Podman | Rootless or single-machine deployments. | Container plus nested sandbox namespace. | Uses the Podman REST API, OCI image volumes, and CDI GPU devices when available. |
120+
| Podman | Rootless or single-machine deployments. | Container plus nested sandbox namespace. | Uses the Podman REST API and CDI GPU devices when available. Delivers the supervisor via OCI image volume by default; falls back to extracting the binary to a host-side cache and bind-mounting it when `userns` is configured (overlay does not support idmapped mounts). |
121121
| Kubernetes | Cluster deployment through Helm. | Pod plus nested sandbox namespace. | Uses Kubernetes API objects, service accounts, secrets, PVC-backed workspace storage, and GPU resources. |
122122
| VM | Experimental microVM isolation. | Per-sandbox libkrun VM. | Managed endpoint-backed driver. The gateway spawns `openshell-driver-vm`, waits for its Unix socket, and then consumes it through the same remote `compute_driver.proto` path used by unmanaged endpoint drivers. The VM driver boots a cached bootstrap `rootfs.ext4`, prepares requested OCI images inside a bootstrap VM with `umoci`, attaches the prepared image disk read-only, and gives each sandbox a writable `overlay.ext4` for merged-root changes and runtime material. The driver persists each accepted launch request beside the overlay and restarts those VMs on driver startup without recreating the overlay. |
123123
| Extension | Out-of-tree drivers operated alongside the gateway. | Whatever boundary the driver implements. | Selected by a non-reserved custom `compute_drivers = ["<name>"]` entry with `[openshell.drivers.<name>].socket_path`, or at launch time by pairing `--drivers <name>` with `--compute-driver-socket=<path>`. Reserved built-in names such as `vm`, `docker`, `podman`, and `kubernetes` cannot be used as unmanaged socket endpoints. The gateway connects to a UDS the operator already provisioned, runs `GetCapabilities`, logs the advertised `driver_name`, and dispatches all sandbox lifecycle calls through `compute_driver.proto`. The driver process and socket lifecycle are operator-owned; the gateway does not spawn, supervise, or remove unmanaged extension drivers. The trust boundary is the socket's filesystem permissions: the operator must ensure only the gateway uid can read/write it. |
@@ -170,7 +170,7 @@ The supervisor must be available inside each sandbox workload:
170170
| Runtime | Delivery model |
171171
|---|---|
172172
| Docker | Bind-mounted local supervisor binary, or a binary extracted from the configured supervisor image. |
173-
| Podman | Read-only OCI image volume containing the supervisor binary. |
173+
| Podman | Read-only OCI image volume by default; host-cached bind mount when `userns` is configured. |
174174
| Kubernetes | Supervisor image side-loaded into the sandbox pod by image volume or init container. |
175175
| VM | Embedded in the guest rootfs bundle. |
176176
| Extension | Defined by the out-of-tree driver. |

crates/openshell-driver-podman/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,7 @@ Podman resources after out-of-band container removal or label drift.
360360
| `OPENSHELL_SANDBOX_PROXY_AUTH_FILE` | `--sandbox-proxy-auth-file` | unset | Path to a file containing the proxy credentials as `user:pass`. Staged as a root-only Podman secret so credentials never appear in config or container metadata. Requires the insecure-auth acknowledgement below. |
361361
| `OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE` | `--sandbox-proxy-auth-allow-insecure` | unset | Explicit acknowledgement (`true`) that the credential is sent as cleartext Basic auth over the plain-TCP connection to the `http://` proxy. Required when the auth file is set; rejected when it is not. |
362362
| `OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME` | `--sandbox-proxy-connect-by-hostname` | unset | Send the destination hostname in CONNECT requests instead of a validated IP. Last resort for proxies whose ACLs filter on hostnames: the proxy then resolves the name itself, so sandbox SSRF/`allowed_ips` validation no longer binds the connection. |
363+
| `OPENSHELL_PODMAN_USERNS` | `--userns` | unset | User namespace mode for sandbox containers (e.g. `auto`). When unset, containers use the default user namespace. |
363364

364365
Through the gateway, the same settings are the `https_proxy`, `no_proxy`,
365366
`proxy_auth_file`, `proxy_auth_allow_insecure`, and

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,32 @@ impl PodmanClient {
513513
}
514514
}
515515

516+
/// Download a file from a container as a tar archive.
517+
///
518+
/// Calls `GET /libpod/containers/{name}/archive?path={path}` and returns
519+
/// the raw tar bytes. The container does not need to be running.
520+
pub async fn copy_from_container(
521+
&self,
522+
name: &str,
523+
path: &str,
524+
) -> Result<Bytes, PodmanApiError> {
525+
validate_name(name)?;
526+
let encoded_path = url_encode(path);
527+
let (status, bytes) = self
528+
.request(
529+
hyper::Method::GET,
530+
&format!("/libpod/containers/{name}/archive?path={encoded_path}"),
531+
None,
532+
API_TIMEOUT,
533+
)
534+
.await?;
535+
if status.is_success() {
536+
Ok(bytes)
537+
} else {
538+
Err(error_from_response(status.as_u16(), &bytes))
539+
}
540+
}
541+
516542
/// Inspect a container by name or ID.
517543
pub async fn inspect_container(&self, name: &str) -> Result<ContainerInspect, PodmanApiError> {
518544
validate_name(name)?;

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

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,9 @@ pub struct PodmanComputeConfig {
185185
/// pointing the gateway host at the corporate resolver so validated-IP
186186
/// CONNECT works in split-horizon networks.
187187
pub proxy_connect_by_hostname: Option<bool>,
188+
/// User namespace mode for sandbox containers (e.g. `auto`).
189+
/// When unset, containers use the default user namespace.
190+
pub userns: Option<String>,
188191
}
189192

190193
pub const DEFAULT_HEALTH_CHECK_INTERVAL_SECS: u64 = 10;
@@ -328,6 +331,53 @@ impl PodmanComputeConfig {
328331
Ok(())
329332
}
330333

334+
/// Validate and canonicalize the optional `userns` mode.
335+
///
336+
/// Supported modes: `auto` (with optional params, e.g. `auto:size=65536`),
337+
/// `host`, `keep-id` (with optional params), and `no-map` (alias `nomap`).
338+
/// Modes that don't accept parameters (`host`, `no-map`) are rejected when
339+
/// a colon-separated suffix is present.
340+
///
341+
/// On success, `self.userns` is rewritten with the canonical lowercase
342+
/// mode string so downstream code can rely on exact matches.
343+
pub fn canonicalize_userns(&mut self) -> Result<(), crate::client::PodmanApiError> {
344+
let Some(mode) = self.userns.as_deref() else {
345+
return Ok(());
346+
};
347+
let (base, has_params) = mode
348+
.split_once(':')
349+
.map_or((mode, false), |(b, _)| (b, true));
350+
let canonical = match base.to_ascii_lowercase().as_str() {
351+
"auto" => "auto",
352+
"host" => "host",
353+
"keep-id" => "keep-id",
354+
"nomap" | "no-map" => "no-map",
355+
_ => {
356+
return Err(crate::client::PodmanApiError::InvalidInput(format!(
357+
"unsupported userns mode '{mode}'; \
358+
supported modes: auto, host, keep-id, no-map",
359+
)));
360+
}
361+
};
362+
if has_params {
363+
match canonical {
364+
"auto" | "keep-id" => {}
365+
_ => {
366+
return Err(crate::client::PodmanApiError::InvalidInput(format!(
367+
"userns mode '{canonical}' does not accept parameters",
368+
)));
369+
}
370+
}
371+
}
372+
self.userns = Some(if has_params {
373+
let params = mode.split_once(':').unwrap().1;
374+
format!("{canonical}:{params}")
375+
} else {
376+
canonical.to_string()
377+
});
378+
Ok(())
379+
}
380+
331381
/// Validate optional host gateway override.
332382
pub fn validate_host_gateway_ip(&self) -> Result<(), crate::client::PodmanApiError> {
333383
let trimmed = self.host_gateway_ip.trim();
@@ -380,6 +430,7 @@ impl Default for PodmanComputeConfig {
380430
proxy_auth_file: None,
381431
proxy_auth_allow_insecure: None,
382432
proxy_connect_by_hostname: None,
433+
userns: None,
383434
}
384435
}
385436
}
@@ -412,6 +463,7 @@ impl std::fmt::Debug for PodmanComputeConfig {
412463
.field("proxy_auth_file", &self.proxy_auth_file.is_some())
413464
.field("proxy_auth_allow_insecure", &self.proxy_auth_allow_insecure)
414465
.field("proxy_connect_by_hostname", &self.proxy_connect_by_hostname)
466+
.field("userns", &self.userns)
415467
.finish()
416468
}
417469
}
@@ -794,4 +846,89 @@ mod tests {
794846
assert!(!msg.contains("OPENSHELL_PODMAN_TLS_CERT"), "{msg}");
795847
assert!(!msg.contains("OPENSHELL_PODMAN_TLS_KEY"), "{msg}");
796848
}
849+
850+
#[test]
851+
fn canonicalize_userns_accepts_supported_modes() {
852+
for mode in [
853+
"auto",
854+
"host",
855+
"keep-id",
856+
"no-map",
857+
"auto:size=65536",
858+
"keep-id:uid=1000,gid=1000",
859+
] {
860+
let mut cfg = PodmanComputeConfig {
861+
userns: Some(mode.to_string()),
862+
..PodmanComputeConfig::default()
863+
};
864+
cfg.canonicalize_userns()
865+
.unwrap_or_else(|_| panic!("mode '{mode}' should be accepted"));
866+
}
867+
}
868+
869+
#[test]
870+
fn canonicalize_userns_normalizes_case_and_aliases() {
871+
let cases = [
872+
("Auto", "auto"),
873+
("HOST", "host"),
874+
("KEEP-ID:uid=1000", "keep-id:uid=1000"),
875+
("nomap", "no-map"),
876+
("no-map", "no-map"),
877+
("auto:SIZE=65536", "auto:SIZE=65536"),
878+
];
879+
for (input, expected) in cases {
880+
let mut cfg = PodmanComputeConfig {
881+
userns: Some(input.to_string()),
882+
..PodmanComputeConfig::default()
883+
};
884+
cfg.canonicalize_userns()
885+
.unwrap_or_else(|_| panic!("mode '{input}' should be accepted"));
886+
assert_eq!(
887+
cfg.userns.as_deref(),
888+
Some(expected),
889+
"input '{input}' should canonicalize to '{expected}'"
890+
);
891+
}
892+
}
893+
894+
#[test]
895+
fn canonicalize_userns_rejects_unsupported_modes() {
896+
for mode in [
897+
"container:foo",
898+
"ns:/proc/1/ns/user",
899+
"4000:5000",
900+
"private",
901+
] {
902+
let mut cfg = PodmanComputeConfig {
903+
userns: Some(mode.to_string()),
904+
..PodmanComputeConfig::default()
905+
};
906+
let err = cfg
907+
.canonicalize_userns()
908+
.expect_err(&format!("mode '{mode}' should be rejected"));
909+
let msg = err.to_string();
910+
assert!(msg.contains("unsupported userns mode"), "{msg}");
911+
}
912+
}
913+
914+
#[test]
915+
fn canonicalize_userns_rejects_params_on_non_parameterizable_modes() {
916+
for mode in ["host:foo", "no-map:x=1"] {
917+
let mut cfg = PodmanComputeConfig {
918+
userns: Some(mode.to_string()),
919+
..PodmanComputeConfig::default()
920+
};
921+
let err = cfg
922+
.canonicalize_userns()
923+
.expect_err(&format!("mode '{mode}' should be rejected"));
924+
let msg = err.to_string();
925+
assert!(msg.contains("does not accept parameters"), "{msg}");
926+
}
927+
}
928+
929+
#[test]
930+
fn canonicalize_userns_accepts_none() {
931+
let mut cfg = PodmanComputeConfig::default();
932+
cfg.canonicalize_userns().expect("None should be accepted");
933+
}
797934
}

0 commit comments

Comments
 (0)