diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 04d6928da..ab41aa7d1 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -11,7 +11,7 @@ use std::net::SocketAddr; #[cfg(unix)] use std::os::unix::fs::FileTypeExt; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; use std::str::FromStr; #[cfg(unix)] use std::time::Duration; @@ -92,6 +92,34 @@ impl FromStr for ComputeDriverKind { } } +/// Result of [`detect_driver`] or an explicitly configured driver, carrying +/// any driver-specific connection metadata discovered during probing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DetectedDriver { + Kubernetes, + Docker, + /// VM is never auto-detected but flows through the same path when + /// explicitly configured via `--drivers vm`. + Vm, + Podman { + /// API socket path verified during detection. + socket_path: PathBuf, + }, +} + +impl DetectedDriver { + /// The [`ComputeDriverKind`] for logging and match guards. + #[must_use] + pub fn kind(&self) -> ComputeDriverKind { + match self { + Self::Kubernetes => ComputeDriverKind::Kubernetes, + Self::Docker => ComputeDriverKind::Docker, + Self::Vm => ComputeDriverKind::Vm, + Self::Podman { .. } => ComputeDriverKind::Podman, + } + } +} + /// Auto-detect the appropriate compute driver based on the runtime environment. /// /// Priority order: Kubernetes → Podman → Docker. @@ -99,20 +127,21 @@ impl FromStr for ComputeDriverKind { /// /// Returns the first driver where the environment check passes. /// Returns `None` if no compatible driver is found. -pub fn detect_driver() -> Option { +pub fn detect_driver() -> Option { // Kubernetes: check for KUBERNETES_SERVICE_HOST env var (set inside pods) if std::env::var_os("KUBERNETES_SERVICE_HOST").is_some() { - return Some(ComputeDriverKind::Kubernetes); + return Some(DetectedDriver::Kubernetes); } - // Podman: check for a reachable local API socket. - if is_podman_available() { - return Some(ComputeDriverKind::Podman); + // Podman: check for a reachable local API socket, falling back to CLI + // discovery which also resolves the host-side socket path. + if let Some(socket_path) = detect_podman() { + return Some(DetectedDriver::Podman { socket_path }); } // Docker: check if the CLI is available or a local Docker socket exists. if is_docker_available() { - return Some(ComputeDriverKind::Docker); + return Some(DetectedDriver::Docker); } None @@ -126,10 +155,91 @@ fn is_binary_available(name: &str) -> bool { .is_ok_and(|output| output.status.success()) } -fn is_podman_available() -> bool { - podman_socket_candidates() - .iter() - .any(|path| podman_socket_responds(path)) +/// Detect whether Podman is available and discover the API socket path. +/// +/// Returns the verified socket path, or `None` when Podman is not +/// available at all. +fn detect_podman() -> Option { + // Fast path: one of the well-known socket candidates responds. + if let Some(path) = podman_socket_candidates() + .into_iter() + .find(|path| podman_socket_responds(path)) + { + return Some(path); + } + + // Slow path: the socket symlink is missing or at a non-standard + // location. Ask the CLI to discover the host-side socket. + discover_podman_socket() +} + +/// Query the Podman CLI to discover the host-side API socket path. +/// +/// Strategy: +/// 1. Run `podman info --format json` to check connectivity and whether +/// the service is remote (macOS/Windows VM) or local (native Linux). +/// 2. If `serviceIsRemote` is true, run `podman machine inspect` to get +/// the host-side forwarded socket (the `remoteSocket` from `podman info` +/// is the VM-internal path, which is not reachable from the host). +/// 3. If `serviceIsRemote` is false, use `remoteSocket.path` directly +/// (on native Linux this IS the real local socket). +fn discover_podman_socket() -> Option { + let output = Command::new("podman") + .args(["info", "--format", "json"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .ok() + .filter(|o| o.status.success())?; + + let info: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; + let is_remote = info["host"]["serviceIsRemote"].as_bool().unwrap_or(false); + + if is_remote { + discover_podman_machine_socket() + } else { + parse_podman_info_socket(&info) + } +} + +/// Extract the socket path from `podman info` JSON output. +/// Used on native Linux where `remoteSocket.path` is the real local socket. +fn parse_podman_info_socket(info: &serde_json::Value) -> Option { + let path_str = info["host"]["remoteSocket"]["path"].as_str()?; + let path = path_str.strip_prefix("unix://").unwrap_or(path_str); + if path.is_empty() { + return None; + } + Some(PathBuf::from(path)) +} + +/// Run `podman machine inspect` to discover the host-side forwarded socket. +/// Used on macOS/Windows where the Podman service runs inside a VM. +fn discover_podman_machine_socket() -> Option { + let output = Command::new("podman") + .args(["machine", "inspect"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .ok() + .filter(|o| o.status.success())?; + + let machines: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; + parse_podman_machine_inspect(&machines) +} + +/// Extract the host-side socket path from `podman machine inspect` JSON. +fn parse_podman_machine_inspect(machines: &serde_json::Value) -> Option { + let path_str = machines + .as_array() + .and_then(|arr| arr.first()) + .and_then(|m| m["ConnectionInfo"]["PodmanSocket"]["Path"].as_str())?; + if path_str.is_empty() { + return None; + } + Some(PathBuf::from(path_str)) } fn podman_socket_candidates() -> Vec { @@ -727,8 +837,9 @@ mod tests { #[cfg(unix)] use super::is_reachable_unix_socket; use super::{ - ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayJwtConfig, detect_driver, - docker_host_unix_socket_path, is_unix_socket, podman_socket_candidates_from_env, + ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, DetectedDriver, + GatewayJwtConfig, detect_driver, docker_host_unix_socket_path, is_unix_socket, + parse_podman_info_socket, parse_podman_machine_inspect, podman_socket_candidates_from_env, podman_socket_responds, }; #[cfg(unix)] @@ -956,7 +1067,7 @@ mod tests { } let result = detect_driver(); - assert_eq!(result, Some(ComputeDriverKind::Kubernetes)); + assert_eq!(result, Some(DetectedDriver::Kubernetes)); // Restore the original env var unsafe { @@ -966,4 +1077,98 @@ mod tests { } } } + + #[test] + fn parse_podman_info_socket_extracts_linux_local_socket() { + let info: serde_json::Value = serde_json::json!({ + "host": { + "serviceIsRemote": false, + "remoteSocket": { + "path": "unix:///run/user/1000/podman/podman.sock", + "exists": true + } + } + }); + assert_eq!( + parse_podman_info_socket(&info), + Some(PathBuf::from("/run/user/1000/podman/podman.sock")) + ); + } + + #[test] + fn parse_podman_info_socket_handles_path_without_unix_prefix() { + let info: serde_json::Value = serde_json::json!({ + "host": { + "remoteSocket": { + "path": "/run/user/1000/podman/podman.sock", + "exists": true + } + } + }); + assert_eq!( + parse_podman_info_socket(&info), + Some(PathBuf::from("/run/user/1000/podman/podman.sock")) + ); + } + + #[test] + fn parse_podman_info_socket_returns_none_for_missing_path() { + let info: serde_json::Value = serde_json::json!({ + "host": { + "remoteSocket": {} + } + }); + assert_eq!(parse_podman_info_socket(&info), None); + } + + #[test] + fn parse_podman_info_socket_returns_none_for_empty_path() { + let info: serde_json::Value = serde_json::json!({ + "host": { + "remoteSocket": { + "path": "", + "exists": false + } + } + }); + assert_eq!(parse_podman_info_socket(&info), None); + } + + #[test] + fn parse_podman_machine_inspect_extracts_macos_socket() { + let machines: serde_json::Value = serde_json::json!([ + { + "ConnectionInfo": { + "PodmanSocket": { + "Path": "/var/folders/1q/jx7s14b928n8zvstgfk98lj00000gn/T/podman/podman-machine-default-api.sock" + }, + "PodmanPipe": null + }, + "Name": "podman-machine-default" + } + ]); + assert_eq!( + parse_podman_machine_inspect(&machines), + Some(PathBuf::from( + "/var/folders/1q/jx7s14b928n8zvstgfk98lj00000gn/T/podman/podman-machine-default-api.sock" + )) + ); + } + + #[test] + fn parse_podman_machine_inspect_returns_none_for_empty_array() { + let machines: serde_json::Value = serde_json::json!([]); + assert_eq!(parse_podman_machine_inspect(&machines), None); + } + + #[test] + fn parse_podman_machine_inspect_returns_none_for_missing_socket() { + let machines: serde_json::Value = serde_json::json!([ + { + "ConnectionInfo": {}, + "Name": "podman-machine-default" + } + ]); + assert_eq!(parse_podman_machine_inspect(&machines), None); + } } diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index c3241cdd8..f3980a4f9 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -28,8 +28,8 @@ pub mod telemetry; pub mod time; pub use config::{ - ComputeDriverKind, Config, GatewayAuthConfig, GatewayJwtConfig, MtlsAuthConfig, OidcConfig, - TlsConfig, + ComputeDriverKind, Config, DetectedDriver, GatewayAuthConfig, GatewayJwtConfig, + MtlsAuthConfig, OidcConfig, TlsConfig, }; pub use error::{ComputeDriverError, Error, Result}; pub use metadata::{GetResourceVersion, ObjectId, ObjectLabels, ObjectName, SetResourceVersion}; diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 748cec264..719b90948 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -612,7 +612,7 @@ fn merge_file_into_args(args: &mut RunArgs, file: &GatewayFileSection, matches: fn effective_single_driver(args: &RunArgs) -> Option { match args.drivers.as_slice() { - [] => openshell_core::config::detect_driver(), + [] => openshell_core::config::detect_driver().map(|d| d.kind()), [driver] => Some(*driver), _ => None, } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 676e23071..1be1416bf 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -44,7 +44,7 @@ pub mod tracing_bus; mod ws_tunnel; use metrics_exporter_prometheus::PrometheusBuilder; -use openshell_core::{ComputeDriverKind, Config, Error, Result}; +use openshell_core::{ComputeDriverKind, Config, DetectedDriver, Error, Result}; use std::collections::HashMap; use std::io::ErrorKind; use std::net::SocketAddr; @@ -703,12 +703,12 @@ async fn build_compute_runtime( tracing_log_bus: TracingLogBus, supervisor_sessions: Arc, ) -> Result { - let driver = configured_compute_driver(config)?; - info!(driver = %driver, "Using compute driver"); - warn_if_kubernetes_sandbox_jwt_expiry_disabled(config, driver); + let detected = configured_compute_driver(config)?; + info!(driver = %detected.kind(), "Using compute driver"); + warn_if_kubernetes_sandbox_jwt_expiry_disabled(config, detected.kind()); - match driver { - ComputeDriverKind::Kubernetes => { + match detected { + DetectedDriver::Kubernetes => { let mut k8s = kubernetes_config_from_file(file)?; if let Ok(size) = std::env::var("OPENSHELL_K8S_WORKSPACE_DEFAULT_STORAGE_SIZE") { k8s.workspace_default_storage_size = size; @@ -724,7 +724,7 @@ async fn build_compute_runtime( .await .map_err(|e| Error::execution(format!("failed to create compute runtime: {e}"))) } - ComputeDriverKind::Docker => ComputeRuntime::new_docker( + DetectedDriver::Docker => ComputeRuntime::new_docker( config.clone(), docker_config.clone(), store, @@ -735,7 +735,7 @@ async fn build_compute_runtime( ) .await .map_err(|e| Error::execution(format!("failed to create compute runtime: {e}"))), - ComputeDriverKind::Vm => { + DetectedDriver::Vm => { let (channel, driver_process) = compute::vm::spawn(config, vm_config).await?; ComputeRuntime::new_remote_vm( channel, @@ -749,11 +749,13 @@ async fn build_compute_runtime( .await .map_err(|e| Error::execution(format!("failed to create compute runtime: {e}"))) } - ComputeDriverKind::Podman => { + DetectedDriver::Podman { socket_path } => { let mut podman = podman_config_from_file(file)?; podman.gateway_port = config.bind_address.port(); if let Ok(p) = std::env::var("OPENSHELL_PODMAN_SOCKET") { podman.socket_path = PathBuf::from(p); + } else { + podman.socket_path = socket_path; } if let Ok(ip) = std::env::var("OPENSHELL_PODMAN_HOST_GATEWAY_IP") { podman.host_gateway_ip = ip; @@ -850,24 +852,19 @@ fn apply_podman_local_tls_defaults( Ok(()) } -fn configured_compute_driver(config: &Config) -> Result { +fn configured_compute_driver(config: &Config) -> Result { match config.compute_drivers.as_slice() { [] => match openshell_core::config::detect_driver() { - Some(ComputeDriverKind::Vm) => Err(Error::config( + Some(DetectedDriver::Vm) => Err(Error::config( "vm compute driver is opt-in only; set --drivers vm or OPENSHELL_DRIVERS=vm", )), - Some(driver) => Ok(driver), + Some(detected) => Ok(detected), None => Err(Error::config( "no compute driver configured and auto-detection found no suitable driver; \ set --drivers or OPENSHELL_DRIVERS to kubernetes, podman, docker, or vm", )), }, - [ - driver @ (ComputeDriverKind::Kubernetes - | ComputeDriverKind::Vm - | ComputeDriverKind::Docker - | ComputeDriverKind::Podman), - ] => Ok(*driver), + [driver] => explicit_driver(*driver), drivers => Err(Error::config(format!( "multiple compute drivers are not supported yet; configured drivers: {}", drivers @@ -879,6 +876,17 @@ fn configured_compute_driver(config: &Config) -> Result { } } +fn explicit_driver(kind: ComputeDriverKind) -> Result { + Ok(match kind { + ComputeDriverKind::Kubernetes => DetectedDriver::Kubernetes, + ComputeDriverKind::Docker => DetectedDriver::Docker, + ComputeDriverKind::Vm => DetectedDriver::Vm, + ComputeDriverKind::Podman => DetectedDriver::Podman { + socket_path: openshell_driver_podman::PodmanComputeConfig::default_socket_path(), + }, + }) +} + fn kubernetes_sandbox_jwt_expiry_disabled(config: &Config, driver: ComputeDriverKind) -> bool { matches!(driver, ComputeDriverKind::Kubernetes) && config @@ -905,7 +913,7 @@ mod tests { serve_gateway_listener, }; use openshell_core::{ - ComputeDriverKind, Config, + ComputeDriverKind, Config, DetectedDriver, proto::{HealthRequest, open_shell_client::OpenShellClient}, }; use rcgen::{CertificateParams, IsCa, KeyPair}; @@ -1246,15 +1254,15 @@ mod tests { let result = configured_compute_driver(&config); // Either we get a detected driver or an error about none being detected. match result { - Ok(driver) => { + Ok(detected) => { assert!( matches!( - driver, - ComputeDriverKind::Kubernetes - | ComputeDriverKind::Docker - | ComputeDriverKind::Podman + detected, + DetectedDriver::Kubernetes + | DetectedDriver::Docker + | DetectedDriver::Podman { .. } ), - "auto-detected unexpected driver: {driver:?}" + "auto-detected unexpected driver: {detected:?}" ); } Err(e) => { @@ -1282,10 +1290,8 @@ mod tests { #[test] fn configured_compute_driver_accepts_podman() { let config = Config::new(None).with_compute_drivers([ComputeDriverKind::Podman]); - assert_eq!( - configured_compute_driver(&config).unwrap(), - ComputeDriverKind::Podman - ); + let detected = configured_compute_driver(&config).unwrap(); + assert_eq!(detected.kind(), ComputeDriverKind::Podman); } #[test] @@ -1293,7 +1299,7 @@ mod tests { let config = Config::new(None).with_compute_drivers([ComputeDriverKind::Vm]); assert_eq!( configured_compute_driver(&config).unwrap(), - ComputeDriverKind::Vm + DetectedDriver::Vm ); } @@ -1302,7 +1308,7 @@ mod tests { let config = Config::new(None).with_compute_drivers([ComputeDriverKind::Docker]); assert_eq!( configured_compute_driver(&config).unwrap(), - ComputeDriverKind::Docker + DetectedDriver::Docker ); }