Skip to content

Commit d51a653

Browse files
authored
feat(driver-podman): add userns config (#2562)
* refactor(driver): extract shared supervisor binary helpers Move supervisor binary extraction, caching, and validation helpers from the Docker driver into openshell-core::driver_utils so both Docker and Podman drivers can reuse them. Moved helpers: extract_first_tar_entry, write_cache_binary_atomic, supervisor_cache_path, temp_extract_container_name, and validate_linux_elf_binary. The shared extract_first_tar_entry gains entry-type and empty-payload checks that the Docker-local version lacked. supervisor_cache_path takes a driver_subdir parameter so each driver caches under its own namespace (docker-supervisor vs podman-supervisor). Signed-off-by: Giuseppe Scrivano <gscrivan@redhat.com> * feat(driver-podman): add userns config 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> --------- Signed-off-by: Giuseppe Scrivano <gscrivan@redhat.com>
1 parent 44bf0df commit d51a653

20 files changed

Lines changed: 1476 additions & 176 deletions

File tree

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,26 @@ Common findings:
222222
cannot bypass slirp4netns host-loopback isolation. Do not work around
223223
discovery failures by broadening the primary gateway listener to `0.0.0.0`.
224224

225+
When `userns` is configured (e.g. `userns = "auto"` or `userns = "keep-id"`):
226+
227+
- Supervisor delivery uses bind-mount fallback instead of image volumes because
228+
overlay mounts do not support `idmapped` mounts. The supervisor binary is
229+
extracted from the supervisor image and cached at
230+
`$XDG_DATA_HOME/openshell/podman-supervisor/` (typically
231+
`~/.local/share/openshell/podman-supervisor/`).
232+
- Stale cache: if the supervisor image is updated but the cached binary is not
233+
refreshed, sandbox creation may fail with an ELF validation error or version
234+
mismatch. Remove the cache directory and retry.
235+
- `auto` mode requires subuid/subgid ranges for the current user in
236+
`/etc/subuid` and `/etc/subgid`. If missing, Podman returns a user-namespace
237+
mapping error at container creation.
238+
- `private` mode requires explicit `uidmap` and `gidmap` arrays in the TOML
239+
config. Without both, the gateway rejects the config at startup.
240+
Rootless Podman uses intermediate IDs (e.g. `uidmap = ["0:0:1", "1:1:65535"]`);
241+
rootful Podman uses absolute host IDs (e.g. `uidmap = ["0:1000:1", "1:100000:65536"]`).
242+
- `nomap` (without hyphen) is accepted as input but canonicalized to `no-map`
243+
for Podman's API.
244+
225245
### Step 6: Check Kubernetes Helm Gateways
226246

227247
```bash

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

architecture/compute-runtimes.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ delete, reconciliation removes the row; otherwise it can remain `Deleting`.
140140
| Runtime | Best fit | Sandbox boundary | Notes |
141141
|---|---|---|---|
142142
| Docker | Local development with Docker available. | Container plus nested sandbox namespace. | Uses host networking so loopback gateway endpoints work from the supervisor. |
143-
| 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. |
143+
| 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). |
144144
| Kubernetes | Cluster deployment through Helm. | Pod plus nested sandbox namespace. | Uses Kubernetes API objects, service accounts, secrets, PVC-backed workspace storage, and GPU resources. |
145145
| 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. |
146146
| 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. |
@@ -193,7 +193,7 @@ The supervisor must be available inside each sandbox workload:
193193
| Runtime | Delivery model |
194194
|---|---|
195195
| Docker | Bind-mounted local supervisor binary, or a binary extracted from the configured supervisor image. |
196-
| Podman | Read-only OCI image volume containing the supervisor binary. |
196+
| Podman | Read-only OCI image volume by default; host-cached bind mount when `userns` is configured. |
197197
| Kubernetes | Supervisor image side-loaded into the sandbox pod by image volume or init container. |
198198
| VM | Embedded in the guest rootfs bundle. |
199199
| Extension | Defined by the out-of-tree driver. |

crates/openshell-core/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ ipnet = "2"
3030
base64 = { workspace = true }
3131
chrono = { version = "0.4", default-features = false, features = ["clock", "std"], optional = true }
3232
reqwest = { workspace = true, features = ["blocking", "rustls-tls-native-roots"], optional = true }
33+
tar = { version = "0.4", optional = true }
34+
tempfile = { version = "3", optional = true }
3335

3436
[target.'cfg(unix)'.dependencies]
3537
nix = { workspace = true }
@@ -39,6 +41,7 @@ default = ["telemetry"]
3941
## Compile in anonymous telemetry emission support. On by default; disable with
4042
## `--no-default-features` (plus any other features you need) for a build that
4143
## contains no telemetry endpoint, no HTTP client, and no emission code at all.
44+
driver-extraction = ["dep:tar", "dep:tempfile"]
4245
telemetry = ["dep:reqwest", "dep:chrono"]
4346

4447
[build-dependencies]

crates/openshell-core/src/driver_utils.rs

Lines changed: 141 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
//! Utility helpers shared across compute-driver crates.
55
6-
use std::path::PathBuf;
6+
use std::path::{Path, PathBuf};
77

88
use crate::proto::compute::v1::{DriverSandbox, GetCapabilitiesResponse};
99

@@ -437,6 +437,145 @@ pub fn supervisor_image_should_refresh(image: &str) -> bool {
437437
matches!(supervisor_image_tag(image), Some("dev" | "latest"))
438438
}
439439

440+
// ---------------------------------------------------------------------------
441+
// Supervisor binary extraction helpers (shared by Docker and Podman drivers)
442+
// ---------------------------------------------------------------------------
443+
444+
#[cfg(feature = "driver-extraction")]
445+
/// Extract the payload of the first regular-file entry in a tar archive.
446+
///
447+
/// Container archive endpoints return a single-file tar when `path` points to
448+
/// a file, so only the first entry is consumed. Returns an error when the
449+
/// archive is empty, the first entry is not a regular file, or the payload is
450+
/// empty.
451+
pub fn extract_first_tar_entry(tar_bytes: &[u8]) -> Result<Vec<u8>, String> {
452+
let mut archive = tar::Archive::new(std::io::Cursor::new(tar_bytes));
453+
let mut entries = archive
454+
.entries()
455+
.map_err(|err| format!("open tar archive: {err}"))?;
456+
let mut entry = entries
457+
.next()
458+
.ok_or_else(|| "tar archive was empty".to_string())?
459+
.map_err(|err| format!("read tar entry: {err}"))?;
460+
let kind = entry.header().entry_type();
461+
if !kind.is_file() {
462+
return Err(format!(
463+
"expected a regular file in tar archive, got type {kind:?}"
464+
));
465+
}
466+
let mut bytes = Vec::new();
467+
std::io::Read::read_to_end(&mut entry, &mut bytes)
468+
.map_err(|err| format!("read tar entry payload: {err}"))?;
469+
if bytes.is_empty() {
470+
return Err("tar entry payload was empty".to_string());
471+
}
472+
Ok(bytes)
473+
}
474+
475+
#[cfg(feature = "driver-extraction")]
476+
/// Atomically write `bytes` to `final_path` via a sibling temp file.
477+
///
478+
/// Creates parent directories as needed. The temp file is synced, `chmod 755`
479+
/// (on Unix), and renamed into place so concurrent readers never observe a
480+
/// partial write. Returns a human-readable error string on failure.
481+
pub fn write_cache_binary_atomic(final_path: &Path, bytes: &[u8]) -> Result<(), String> {
482+
let dir = final_path
483+
.parent()
484+
.ok_or_else(|| format!("cache path '{}' has no parent", final_path.display()))?;
485+
std::fs::create_dir_all(dir)
486+
.map_err(|err| format!("failed to create cache dir '{}': {err}", dir.display()))?;
487+
488+
let mut temp = tempfile::Builder::new()
489+
.prefix(".openshell-sandbox-")
490+
.tempfile_in(dir)
491+
.map_err(|err| format!("failed to create temp file in '{}': {err}", dir.display()))?;
492+
std::io::Write::write_all(&mut temp, bytes)
493+
.map_err(|err| format!("failed to write supervisor binary: {err}"))?;
494+
temp.as_file()
495+
.sync_all()
496+
.map_err(|err| format!("failed to sync supervisor binary: {err}"))?;
497+
498+
#[cfg(unix)]
499+
{
500+
use std::os::unix::fs::PermissionsExt;
501+
std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o755))
502+
.map_err(|err| format!("failed to chmod supervisor binary: {err}"))?;
503+
}
504+
505+
temp.persist(final_path).map_err(|err| {
506+
format!(
507+
"failed to persist supervisor binary to '{}': {}",
508+
final_path.display(),
509+
err.error,
510+
)
511+
})?;
512+
Ok(())
513+
}
514+
515+
/// Return the host-side cache path for an extracted supervisor binary.
516+
///
517+
/// The path is `$XDG_DATA_HOME/openshell/<driver_subdir>/<sanitized-digest>/openshell-sandbox`.
518+
/// `driver_subdir` distinguishes caches across drivers (e.g. `"docker-supervisor"`,
519+
/// `"podman-supervisor"`).
520+
pub fn supervisor_cache_path(driver_subdir: &str, digest: &str) -> Result<PathBuf, String> {
521+
let base = crate::paths::xdg_data_dir()
522+
.map_err(|err| format!("failed to resolve XDG data dir: {err}"))?;
523+
Ok(supervisor_cache_path_with_base(
524+
&base,
525+
driver_subdir,
526+
digest,
527+
))
528+
}
529+
530+
/// [`supervisor_cache_path`] with an explicit base directory (for testing).
531+
pub fn supervisor_cache_path_with_base(base: &Path, driver_subdir: &str, digest: &str) -> PathBuf {
532+
let sanitized = digest.replace(':', "-");
533+
base.join("openshell")
534+
.join(driver_subdir)
535+
.join(sanitized)
536+
.join("openshell-sandbox")
537+
}
538+
539+
/// Generate a unique container name for supervisor binary extraction.
540+
///
541+
/// Uses the process ID and an atomic counter to avoid collisions across
542+
/// concurrent gateway starts.
543+
pub fn temp_extract_container_name() -> String {
544+
use std::sync::atomic::{AtomicU64, Ordering};
545+
static SEQ: AtomicU64 = AtomicU64::new(0);
546+
let pid = std::process::id();
547+
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
548+
format!("openshell-supervisor-extract-{pid}-{seq}")
549+
}
550+
551+
/// Validate that the file at `path` starts with the ELF magic bytes (`\x7fELF`).
552+
///
553+
/// Returns a human-readable error when the file cannot be read or is not a
554+
/// Linux ELF binary.
555+
pub fn validate_linux_elf_binary(path: &Path) -> Result<(), String> {
556+
use std::io::Read;
557+
let mut file = std::fs::File::open(path).map_err(|err| {
558+
format!(
559+
"failed to open supervisor binary '{}': {err}",
560+
path.display()
561+
)
562+
})?;
563+
let mut magic = [0u8; 4];
564+
file.read_exact(&mut magic).map_err(|err| {
565+
format!(
566+
"failed to read supervisor binary '{}': {err}",
567+
path.display()
568+
)
569+
})?;
570+
if magic != [0x7f, b'E', b'L', b'F'] {
571+
return Err(format!(
572+
"supervisor binary '{}' is not a Linux ELF executable",
573+
path.display(),
574+
));
575+
}
576+
Ok(())
577+
}
578+
440579
#[cfg(test)]
441580
mod tests {
442581
use super::*;
@@ -628,7 +767,7 @@ mod tests {
628767
let err = read_upstream_proxy_credential_file(dir.path().to_str().unwrap()).unwrap_err();
629768
assert!(err.contains("regular file"), "{err}");
630769

631-
if std::path::Path::new("/dev/zero").exists() {
770+
if Path::new("/dev/zero").exists() {
632771
let err = read_upstream_proxy_credential_file("/dev/zero").unwrap_err();
633772
assert!(err.contains("regular file"), "{err}");
634773
}

crates/openshell-driver-docker/Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ license.workspace = true
1111
repository.workspace = true
1212

1313
[dependencies]
14-
openshell-core = { path = "../openshell-core", default-features = false }
14+
openshell-core = { path = "../openshell-core", default-features = false, features = ["driver-extraction"] }
1515

1616
tokio = { workspace = true }
1717
tonic = { workspace = true }
@@ -23,13 +23,13 @@ serde = { workspace = true }
2323
serde_json = { workspace = true }
2424
prost-types = { workspace = true }
2525
bollard = { version = "0.20" }
26-
tar = "0.4"
27-
tempfile = "3"
2826
url = { workspace = true }
2927

3028
[dev-dependencies]
3129
prost-types = { workspace = true }
30+
tar = "0.4"
3231
temp-env = "0.3"
32+
tempfile = "3"
3333

3434
[lints]
3535
workspace = true

0 commit comments

Comments
 (0)