diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index c9f8a86b21..b200eded64 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -171,9 +171,19 @@ pub fn detect_driver() -> Option { } fn is_podman_available() -> bool { - podman_socket_candidates() + detect_podman_socket().is_some() +} + +/// Return the first responsive Podman API socket, or `None` if none respond. +pub fn detect_podman_socket() -> Option { + detect_podman_socket_from_candidates(&podman_socket_candidates()) +} + +fn detect_podman_socket_from_candidates(candidates: &[PathBuf]) -> Option { + candidates .iter() - .any(|path| podman_socket_responds(path)) + .find(|path| podman_socket_responds(path)) + .cloned() } fn podman_socket_candidates() -> Vec { @@ -972,8 +982,9 @@ mod tests { ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayJwtConfig, GatewayProviderProfileSourceConfig, detect_docker_socket_from_candidates, detect_driver, - docker_host_unix_socket_path, docker_socket_responds, is_unix_socket, - normalize_compute_driver_name, podman_socket_candidates_from_env, podman_socket_responds, + detect_podman_socket_from_candidates, docker_host_unix_socket_path, docker_socket_responds, + is_unix_socket, normalize_compute_driver_name, podman_socket_candidates_from_env, + podman_socket_responds, }; #[cfg(unix)] use std::io::{Read as _, Write as _}; @@ -1349,6 +1360,34 @@ mod tests { ))); } + #[cfg(unix)] + #[test] + fn podman_socket_detection_returns_the_responsive_candidate() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let inactive_path = temp_dir.path().join("inactive.sock"); + let inactive_listener = UnixListener::bind(&inactive_path).expect("bind inactive socket"); + drop(inactive_listener); + + let responsive_path = temp_dir.path().join("responsive.sock"); + let listener = UnixListener::bind(&responsive_path).expect("bind responsive socket"); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept podman probe"); + let mut request = [0_u8; 128]; + let _ = stream.read(&mut request).expect("read podman probe"); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nLibpod-Api-Version: 5.8.2\r\nContent-Length: 2\r\n\r\nOK", + ) + .expect("write podman ping response"); + }); + + assert_eq!( + detect_podman_socket_from_candidates(&[inactive_path, responsive_path.clone(),]), + Some(responsive_path) + ); + handle.join().expect("probe server exits"); + } + #[test] #[allow(unsafe_code)] // std::env::set_var/remove_var require unsafe in Rust 2024 fn detect_driver_prefers_kubernetes_when_k8s_env_is_set() { diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index c6a619f3d1..6a58cdcd23 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -331,7 +331,7 @@ Podman resources after out-of-band container removal or label drift. | Environment Variable | CLI Flag | Default | Description | |---|---|---|---| -| `OPENSHELL_PODMAN_SOCKET` | `--podman-socket` | `$XDG_RUNTIME_DIR/podman/podman.sock` on Linux, `$HOME/.local/share/containers/podman/machine/podman.sock` on macOS | Podman API Unix socket path. | +| `OPENSHELL_PODMAN_SOCKET` | `--podman-socket` | Probes known local Podman API sockets and uses the first responsive socket. Fails to start if none respond. | Podman API Unix socket path. | | `OPENSHELL_SANDBOX_IMAGE` | `--sandbox-image` | From gateway config | Default OCI image for sandboxes. | | `OPENSHELL_SANDBOX_IMAGE_PULL_POLICY` | `--sandbox-image-pull-policy` | `missing` | Pull policy: `always`, `missing`, `never`, or `newer`. | | `OPENSHELL_GRPC_ENDPOINT` | `--grpc-endpoint` | Auto-detected via `host.containers.internal` | Gateway gRPC endpoint for sandbox callbacks. | diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index def8e5f3dc..e9bd8619dc 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -69,10 +69,9 @@ impl FromStr for ImagePullPolicy { #[derive(Clone, serde::Serialize, serde::Deserialize)] #[serde(default, deny_unknown_fields)] pub struct PodmanComputeConfig { - /// Path to the Podman API Unix socket. - /// Default: `$XDG_RUNTIME_DIR/podman/podman.sock` (Linux), - /// `$HOME/.local/share/containers/podman/machine/podman.sock` (macOS). - pub socket_path: PathBuf, + /// Podman API Unix socket. When unset, use the socket selected by + /// gateway auto-detection. + pub socket_path: Option, /// Default OCI image for sandboxes. pub default_image: String, /// Image pull policy for sandbox images. @@ -215,38 +214,12 @@ impl PodmanComputeConfig { String::new() } } - - /// Resolve the default socket path from the environment. - /// - /// - **macOS**: `$HOME/.local/share/containers/podman/machine/podman.sock` - /// (the symlink created by `podman machine` pointing to the VM API socket). - /// - **Linux**: `$XDG_RUNTIME_DIR/podman/podman.sock` when set (by - /// `pam_systemd`/logind), otherwise `/run/user/{uid}/podman/podman.sock` - /// using the real UID via `getuid()`. - #[must_use] - pub fn default_socket_path() -> PathBuf { - #[cfg(target_os = "macos")] - { - let home = std::env::var("HOME").expect("HOME must be set on macOS"); - PathBuf::from(home).join(".local/share/containers/podman/machine/podman.sock") - } - #[cfg(target_os = "linux")] - { - std::env::var("XDG_RUNTIME_DIR").map_or_else( - |_| { - let uid = rustix::process::getuid().as_raw(); - PathBuf::from(format!("/run/user/{uid}/podman/podman.sock")) - }, - |xdg| PathBuf::from(xdg).join("podman/podman.sock"), - ) - } - } } impl Default for PodmanComputeConfig { fn default() -> Self { Self { - socket_path: Self::default_socket_path(), + socket_path: None, default_image: openshell_core::image::default_sandbox_image(), image_pull_policy: ImagePullPolicy::default(), grpc_endpoint: String::new(), @@ -296,39 +269,6 @@ impl std::fmt::Debug for PodmanComputeConfig { mod tests { use super::*; - /// Serialises env-mutating tests so that parallel test threads cannot - /// observe each other's changes to `XDG_RUNTIME_DIR`. - static ENV_LOCK: std::sync::LazyLock> = - std::sync::LazyLock::new(|| std::sync::Mutex::new(())); - - #[test] - #[cfg(target_os = "linux")] - fn default_socket_path_respects_xdg_runtime_dir() { - let _guard = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - temp_env::with_vars([("XDG_RUNTIME_DIR", Some("/tmp/test-xdg"))], || { - let path = PodmanComputeConfig::default_socket_path(); - assert_eq!(path, PathBuf::from("/tmp/test-xdg/podman/podman.sock")); - }); - } - - #[test] - #[cfg(target_os = "linux")] - fn default_socket_path_falls_back_to_uid() { - let _guard = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - temp_env::with_vars([("XDG_RUNTIME_DIR", None::<&str>)], || { - let path = PodmanComputeConfig::default_socket_path(); - let uid = rustix::process::getuid().as_raw(); - assert_eq!( - path, - PathBuf::from(format!("/run/user/{uid}/podman/podman.sock")) - ); - }); - } - #[test] fn default_config_sets_health_check_interval() { let cfg = PodmanComputeConfig::default(); @@ -382,21 +322,6 @@ mod tests { assert!(err.to_string().contains("sandbox_pids_limit")); } - #[test] - #[cfg(target_os = "macos")] - fn default_socket_path_uses_podman_machine_on_macos() { - let _guard = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - temp_env::with_vars([("HOME", Some("/Users/testuser"))], || { - let path = PodmanComputeConfig::default_socket_path(); - assert_eq!( - path, - PathBuf::from("/Users/testuser/.local/share/containers/podman/machine/podman.sock") - ); - }); - } - // ── TLS config validation ───────────────────────────────────────── #[test] diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 9ee26c0735..c199c221a2 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -1779,7 +1779,7 @@ mod tests { fn test_config() -> PodmanComputeConfig { PodmanComputeConfig { - socket_path: std::path::PathBuf::from("/tmp/test.sock"), + socket_path: Some(std::path::PathBuf::from("/tmp/test.sock")), default_image: "test-image:latest".to_string(), grpc_endpoint: "http://localhost:50051".to_string(), host_gateway_ip: String::new(), diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index a86d58dee0..6aab1ecd62 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -19,7 +19,7 @@ use openshell_core::gpu::{ use openshell_core::proto::compute::v1::{ DriverSandbox, GetCapabilitiesResponse, GpuResourceRequirements, }; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use tracing::{info, warn}; @@ -157,22 +157,48 @@ fn podman_gpu_selection_error(err: CdiGpuSelectionError) -> ComputeDriverError { ComputeDriverError::Precondition(err.to_string()) } +/// Resolve the socket to connect to: explicit configuration wins, otherwise +/// fall back to `detect`. Returns an error if neither resolves. +/// +/// Takes `detect` as a parameter (rather than calling +/// [`openshell_core::config::detect_podman_socket`] directly) so tests can +/// exercise the precedence deterministically, without touching real +/// environment variables or the filesystem. +fn resolve_socket_path( + configured: Option, + detect: impl FnOnce() -> Option, +) -> Result { + configured.or_else(detect).ok_or_else(|| { + PodmanApiError::InvalidInput( + "no responsive Podman API socket found; set OPENSHELL_PODMAN_SOCKET \ + or configure socket_path" + .to_string(), + ) + }) +} + impl PodmanComputeDriver { /// Create a new driver, verifying the Podman socket is reachable. pub async fn new(mut config: PodmanComputeConfig) -> Result { const MAX_PING_RETRIES: u32 = 5; const PING_RETRY_DELAY: Duration = Duration::from_secs(2); - if !config.socket_path.exists() { + let socket_path = resolve_socket_path( + config.socket_path.clone(), + openshell_core::config::detect_podman_socket, + )?; + config.socket_path = Some(socket_path.clone()); + + if !socket_path.exists() { if cfg!(target_os = "macos") { warn!( - path = %config.socket_path.display(), + path = %socket_path.display(), "Podman socket not found; is podman machine running? \ Try `podman machine start` or set OPENSHELL_PODMAN_SOCKET to override." ); } else { warn!( - path = %config.socket_path.display(), + path = %socket_path.display(), "Podman socket not found; is the Podman service running? \ Set OPENSHELL_PODMAN_SOCKET or XDG_RUNTIME_DIR to override." ); @@ -186,7 +212,7 @@ impl PodmanComputeDriver { config.validate_runtime_limits()?; config.validate_host_gateway_ip()?; - let client = PodmanClient::new(config.socket_path.clone()); + let client = PodmanClient::new(socket_path); // Verify connectivity, retrying briefly to tolerate transient socket // unavailability (e.g. podman.socket restarting after a package @@ -774,7 +800,7 @@ impl PodmanComputeDriver { gpu_inventory: CdiGpuInventory, allow_all_default_gpu: bool, ) -> Self { - let client = PodmanClient::new(config.socket_path.clone()); + let client = PodmanClient::new(config.socket_path.clone().unwrap_or_default()); let refresh_inventory = gpu_inventory.clone(); Self { client, @@ -850,6 +876,36 @@ mod tests { use std::fs; use std::path::PathBuf; + // ── socket resolution ─────────────────────────────────────────────── + // + // These test resolve_socket_path directly with an injected detector, so + // they are deterministic regardless of the host's real environment + // variables or whether a Podman socket happens to be running. + + #[test] + fn resolve_socket_path_prefers_explicit_configuration() { + let path = resolve_socket_path(Some(PathBuf::from("/explicit.sock")), || { + Some(PathBuf::from("/detected.sock")) + }) + .unwrap(); + + assert_eq!(path, PathBuf::from("/explicit.sock")); + } + + #[test] + fn resolve_socket_path_uses_detected_socket_when_unconfigured() { + let path = resolve_socket_path(None, || Some(PathBuf::from("/detected.sock"))).unwrap(); + + assert_eq!(path, PathBuf::from("/detected.sock")); + } + + #[test] + fn resolve_socket_path_errors_when_neither_source_resolves() { + let err = resolve_socket_path(None, || None).unwrap_err(); + + assert!(err.to_string().contains("no responsive Podman API socket")); + } + fn cdi_devices_config(device_ids: &[&str]) -> prost_types::Struct { prost_types::Struct { fields: std::iter::once(( @@ -1245,7 +1301,7 @@ mod tests { fn test_driver(socket_path: PathBuf) -> PodmanComputeDriver { let config = PodmanComputeConfig { - socket_path, + socket_path: Some(socket_path), stop_timeout_secs: 10, ..PodmanComputeConfig::default() }; @@ -1424,7 +1480,7 @@ mod tests { )], ); let config = PodmanComputeConfig { - socket_path: socket_path.clone(), + socket_path: Some(socket_path.clone()), enable_bind_mounts: true, ..PodmanComputeConfig::default() }; diff --git a/crates/openshell-driver-podman/src/grpc.rs b/crates/openshell-driver-podman/src/grpc.rs index 4840ee2810..ecbfacdbff 100644 --- a/crates/openshell-driver-podman/src/grpc.rs +++ b/crates/openshell-driver-podman/src/grpc.rs @@ -179,7 +179,7 @@ mod tests { fn test_service(socket_path: PathBuf) -> ComputeDriverService { let config = PodmanComputeConfig { - socket_path, + socket_path: Some(socket_path), stop_timeout_secs: 10, ..PodmanComputeConfig::default() }; diff --git a/crates/openshell-driver-podman/src/main.rs b/crates/openshell-driver-podman/src/main.rs index c57aff4277..831d338a30 100644 --- a/crates/openshell-driver-podman/src/main.rs +++ b/crates/openshell-driver-podman/src/main.rs @@ -114,12 +114,8 @@ async fn main() -> Result<()> { ) .init(); - let socket_path = args - .podman_socket - .unwrap_or_else(PodmanComputeConfig::default_socket_path); - let driver = PodmanComputeDriver::new(PodmanComputeConfig { - socket_path, + socket_path: args.podman_socket, default_image: args.sandbox_image.unwrap_or_default(), image_pull_policy: args.sandbox_image_pull_policy, grpc_endpoint: args.grpc_endpoint.unwrap_or_default(), diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index 90f5467927..e83cd76a33 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -3270,10 +3270,9 @@ async fn connect_local_container_engine() -> Option { return Some(docker); } - let podman_socket = podman_socket_path(); - if podman_socket.exists() - && let Ok(docker) = - Docker::connect_with_unix(podman_socket.to_str()?, 120, bollard::API_DEFAULT_VERSION) + let podman_socket = openshell_core::config::detect_podman_socket()?; + if let Ok(docker) = + Docker::connect_with_unix(podman_socket.to_str()?, 120, bollard::API_DEFAULT_VERSION) && docker.ping().await.is_ok() { info!( @@ -3286,25 +3285,6 @@ async fn connect_local_container_engine() -> Option { None } -/// Podman user socket path for the current platform. -fn podman_socket_path() -> PathBuf { - #[cfg(target_os = "macos")] - { - let home = std::env::var("HOME").unwrap_or_default(); - PathBuf::from(home).join(".local/share/containers/podman/machine/podman.sock") - } - #[cfg(target_os = "linux")] - { - std::env::var("XDG_RUNTIME_DIR").map_or_else( - |_| { - let uid = nix::unistd::getuid(); - PathBuf::from(format!("/run/user/{uid}/podman/podman.sock")) - }, - |xdg| PathBuf::from(xdg).join("podman/podman.sock"), - ) - } -} - fn is_openshell_local_build_image_ref(image_ref: &str) -> bool { image_ref.starts_with("openshell/sandbox-from:") } diff --git a/crates/openshell-server/src/compute/driver_config.rs b/crates/openshell-server/src/compute/driver_config.rs index fa44cd3a38..59fed439bc 100644 --- a/crates/openshell-server/src/compute/driver_config.rs +++ b/crates/openshell-server/src/compute/driver_config.rs @@ -195,7 +195,7 @@ fn apply_vm_runtime_defaults(cfg: &mut VmComputeConfig, context: DriverStartupCo fn apply_podman_env_overrides(podman: &mut PodmanComputeConfig) { if let Ok(p) = std::env::var("OPENSHELL_PODMAN_SOCKET") { - podman.socket_path = PathBuf::from(p); + podman.socket_path = Some(PathBuf::from(p)); } if let Ok(ip) = std::env::var("OPENSHELL_PODMAN_HOST_GATEWAY_IP") { podman.host_gateway_ip = ip; diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 3dde7643d5..56f5d15348 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -362,6 +362,8 @@ compute_drivers = ["podman"] [openshell.drivers.podman] # Rootless socket path. For root Podman use /run/podman/podman.sock. +# Omit to auto-detect: the driver probes for a responsive Podman socket and +# fails to start if none respond. socket_path = "/run/user/1000/podman/podman.sock" default_image = "ghcr.io/nvidia/openshell/sandbox:latest" image_pull_policy = "missing" # always | missing | never | newer diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index a0167ae72a..280849b5f6 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -176,7 +176,7 @@ checks do not make host bind mounts safe. [Podman](https://podman.io/)-backed sandboxes run as rootless containers on the gateway host. Use Podman for Linux workstation workflows that avoid a rootful Docker daemon. -The gateway talks to the Podman API socket. The Podman driver requires Podman 5.x, cgroups v2, rootless networking, and an active Podman user socket. +The gateway talks to the Podman API socket. The Podman driver requires Podman 5.x, cgroups v2, rootless networking, and an active Podman user socket. When `socket_path` is not set, the driver probes for a responsive Podman socket and fails to start if none respond. 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).