diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index 965a295d19..dff18fe6df 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -393,11 +393,14 @@ resolution, making the proxy's ACLs the effective egress control. The Podman driver is designed for rootless operation. The following adaptations matter compared to cluster or rootful runtimes: -1. subuid/subgid preflight check: on non-macOS hosts, `check_subuid_range()` in - `driver.rs` warns operators if `/etc/subuid` or `/etc/subgid` entries are - missing for the current user. This is not a hard error because some systems - use LDAP or other mechanisms. macOS skips the check because `podman machine` - runs the Podman service inside a Linux VM. +1. subuid/subgid preflight check: on non-macOS, non-root hosts, + `check_subuid_range()` in `driver.rs` warns operators if `/etc/subuid` or + `/etc/subgid` entries are missing for the current user. This is not a hard + error because some systems use LDAP or other mechanisms. The check is + skipped when the gateway itself runs in a container because those files do + not describe the Podman host. For containerized gateways using a mounted + rootless Podman socket, run the gateway container with `--userns=keep-id` so + its UID matches the host user that owns the socket. 2. cgroups v2 requirement: the driver refuses to start if cgroups v1 is detected. Rootless Podman requires the unified cgroup hierarchy. 3. `nsenter` for namespace operations: `openshell-sandbox` uses diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index aa3df9cbd7..a969c9e76b 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -276,6 +276,7 @@ impl PodmanComputeDriver { Ok(()) => break, Err(e) if attempts < MAX_PING_RETRIES => { attempts += 1; + let e = enrich_socket_connection_error(e, running_inside_container()); warn!( attempt = attempts, max_retries = MAX_PING_RETRIES, @@ -284,7 +285,12 @@ impl PodmanComputeDriver { ); tokio::time::sleep(PING_RETRY_DELAY).await; } - Err(e) => return Err(e), + Err(e) => { + return Err(enrich_socket_connection_error( + e, + running_inside_container(), + )); + } } } @@ -316,7 +322,11 @@ impl PodmanComputeDriver { // Rootless pre-flight: warn if subuid/subgid ranges look missing. // Not a hard error because some systems configure these via LDAP or // other mechanisms that /etc/subuid does not reflect. - if !cfg!(target_os = "macos") && rustix::process::getuid().as_raw() != 0 { + if should_check_subuid_range( + cfg!(target_os = "macos"), + rustix::process::getuid().as_raw() == 0, + running_inside_container(), + ) { check_subuid_range(); } @@ -911,6 +921,35 @@ fn supervisor_image_pull_policy(image: &str) -> &'static str { } } +fn running_inside_container() -> bool { + Path::new("/run/.containerenv").exists() || Path::new("/.dockerenv").exists() +} + +fn enrich_socket_connection_error( + err: PodmanApiError, + is_containerized_gateway: bool, +) -> PodmanApiError { + match err { + // PodmanClient has already flattened io::Error into this connection message. + PodmanApiError::Connection(message) + if is_containerized_gateway && message.contains("Permission denied") => + { + PodmanApiError::Connection(format!( + "{message}. Gateway is running inside a container and cannot access the mounted Podman socket; for rootless Podman socket deployments run the gateway container with --userns=keep-id so the container UID matches the host user that owns the socket" + )) + } + other => other, + } +} + +fn should_check_subuid_range( + is_macos: bool, + is_root: bool, + is_containerized_gateway: bool, +) -> bool { + !is_macos && !is_root && !is_containerized_gateway +} + /// Check whether the current user has subuid/subgid ranges configured. /// /// Rootless Podman requires entries in `/etc/subuid` and `/etc/subgid` for @@ -1033,6 +1072,53 @@ mod tests { assert!(matches!(err, ComputeDriverError::Message(_))); } + #[test] + fn socket_permission_error_in_container_includes_keep_id_guidance() { + let err = enrich_socket_connection_error( + PodmanApiError::Connection("Permission denied".into()), + true, + ); + + assert!(err.to_string().contains("Permission denied")); + assert!(err.to_string().contains("--userns=keep-id")); + } + + #[test] + fn socket_permission_error_on_host_is_unchanged() { + let err = enrich_socket_connection_error( + PodmanApiError::Connection("Permission denied".into()), + false, + ); + + assert_eq!(err.to_string(), "connection error: Permission denied"); + } + + #[test] + fn unrelated_socket_error_in_container_is_unchanged() { + let err = enrich_socket_connection_error( + PodmanApiError::Connection("Connection refused".into()), + true, + ); + + assert_eq!(err.to_string(), "connection error: Connection refused"); + } + + #[test] + fn subuid_preflight_skips_containerized_gateway() { + assert!(!should_check_subuid_range(false, false, true)); + } + + #[test] + fn subuid_preflight_runs_for_non_root_linux_host() { + assert!(should_check_subuid_range(false, false, false)); + } + + #[test] + fn subuid_preflight_skips_root_and_macos() { + assert!(!should_check_subuid_range(false, true, false)); + assert!(!should_check_subuid_range(true, false, false)); + } + #[test] fn validate_gpu_request_accepts_gpu_count_request_shape() { let gpu = GpuResourceRequirements { count: Some(2) };