From 870bb0476c0df76c40c06cc4821ff866c6b39ac5 Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Sun, 27 Sep 2026 13:36:58 +0100 Subject: [PATCH] feat(cli): support Podman in doctor check Falls back to Podman when Docker is absent. Docker stays the default. Closes #3694. Signed-off-by: Eric Curtin --- crates/openshell-cli/src/run.rs | 97 +++++++++++++++++++++---- docs/how-it-works/gateways/overview.mdx | 4 +- e2e/rust/tests/docker_preflight.rs | 69 ++++++++++++++++++ 3 files changed, 156 insertions(+), 14 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 15d91b5891..20b0901256 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -209,36 +209,101 @@ fn current_user_to_json(view: &CurrentUserView) -> serde_json::Value { }) } +/// Container runtime checked by `doctor check`. +enum ComputeRuntime { + Docker, + Podman, +} + +impl ComputeRuntime { + /// Prefer Docker when it is installed, since it is the default driver. + /// Fall back to Podman only when Docker is absent and Podman is present. + fn detect() -> Self { + if command_exists("docker") || !command_exists("podman") { + Self::Docker + } else { + Self::Podman + } + } + + /// Left-aligned label line, padded to match the existing check style. + fn label_line(&self) -> &'static str { + match self { + Self::Docker => " Docker ............. ", + Self::Podman => " Podman ............. ", + } + } + + fn info_command(&self) -> (&'static str, [&'static str; 3]) { + match self { + Self::Docker => ("docker", ["info", "--format", "{{.ServerVersion}}"]), + Self::Podman => ("podman", ["info", "--format", "{{.Version.Version}}"]), + } + } + + /// Env var name and current value shown after a successful check. + fn host_env(&self) -> (&'static str, Option) { + match self { + Self::Docker => ("DOCKER_HOST", std::env::var("DOCKER_HOST").ok()), + Self::Podman => ( + "OPENSHELL_PODMAN_SOCKET", + std::env::var("OPENSHELL_PODMAN_SOCKET").ok(), + ), + } + } + + /// Guidance appended to the error when the check fails. + fn failure_hint(&self) -> &'static str { + match self { + Self::Docker => "check DOCKER_HOST and run docker info", + Self::Podman => "check OPENSHELL_PODMAN_SOCKET (or CONTAINER_HOST) and run podman info", + } + } +} + +/// Return true if `bin` can be spawned at all, regardless of its exit code. +fn command_exists(bin: &str) -> bool { + Command::new(bin) + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok() +} + /// Validate system prerequisites for running a gateway. /// -/// Checks Docker connectivity and reports the result. Returns exit code 0 -/// if all checks pass, 1 otherwise. +/// Checks connectivity for the detected compute runtime (Docker, or Podman +/// when Docker is absent) and reports the result. Returns exit code 0 if all +/// checks pass, 1 otherwise. pub fn doctor_check() -> Result<()> { use std::io::Write; let mut stdout = std::io::stdout().lock(); writeln!(stdout, "Checking system prerequisites...\n").into_diagnostic()?; - // --- Docker connectivity --- - write!(stdout, " Docker ............. ").into_diagnostic()?; + let runtime = ComputeRuntime::detect(); + let (program, args) = runtime.info_command(); + + write!(stdout, "{}", runtime.label_line()).into_diagnostic()?; stdout.flush().into_diagnostic()?; - let output = Command::new("docker") - .args(["info", "--format", "{{.ServerVersion}}"]) + let output = Command::new(program) + .args(args) .output() .into_diagnostic() - .wrap_err("failed to execute docker info")?; + .wrap_err(format!("failed to execute {program} info"))?; if output.status.success() { let version = String::from_utf8_lossy(&output.stdout); let version_str = version.trim(); writeln!(stdout, "ok (version {version_str})").into_diagnostic()?; - // --- DOCKER_HOST --- - write!(stdout, " DOCKER_HOST ........ ").into_diagnostic()?; - match std::env::var("DOCKER_HOST") { - Ok(val) => writeln!(stdout, "{val}").into_diagnostic()?, - Err(_) => writeln!(stdout, "(not set, using default socket)").into_diagnostic()?, + let (env_name, env_value) = runtime.host_env(); + write!(stdout, " {env_name} ........ ").into_diagnostic()?; + match env_value { + Some(val) => writeln!(stdout, "{val}").into_diagnostic()?, + None => writeln!(stdout, "(not set, using default socket)").into_diagnostic()?, } writeln!(stdout, "\nAll checks passed.").into_diagnostic()?; @@ -248,7 +313,13 @@ pub fn doctor_check() -> Result<()> { writeln!(stdout, "FAILED").into_diagnostic()?; writeln!(stdout).into_diagnostic()?; let stderr = String::from_utf8_lossy(&output.stderr); - Err(miette::miette!("docker info failed: {}", stderr.trim())) + let stderr = stderr.trim(); + let hint = runtime.failure_hint(); + if stderr.is_empty() { + Err(miette::miette!("{program} info failed: {hint}")) + } else { + Err(miette::miette!("{program} info failed: {stderr}; {hint}")) + } } fn sandbox_should_persist(keep: bool, forward: Option<&ForwardSpec>, expose: Option) -> bool { diff --git a/docs/how-it-works/gateways/overview.mdx b/docs/how-it-works/gateways/overview.mdx index ee1f735fa3..efce208366 100644 --- a/docs/how-it-works/gateways/overview.mdx +++ b/docs/how-it-works/gateways/overview.mdx @@ -160,13 +160,15 @@ openshell status openshell gateway info ``` -For Docker-backed local gateways, inspect Docker and the gateway process or container started by your local workflow: +For Docker- or Podman-backed local gateways, inspect the container runtime and the gateway process or container started by your local workflow: ```shell openshell doctor check openshell gateway list ``` +`doctor check` validates Docker when it is installed, and falls back to Podman when Docker is absent. + For Kubernetes gateways, inspect the gateway workload and cluster events: ```shell diff --git a/e2e/rust/tests/docker_preflight.rs b/e2e/rust/tests/docker_preflight.rs index bf05a132d9..f2f8770be3 100644 --- a/e2e/rust/tests/docker_preflight.rs +++ b/e2e/rust/tests/docker_preflight.rs @@ -169,3 +169,72 @@ async fn doctor_check_passes_with_docker() { "doctor check should show 'ok' for Docker:\n{clean}" ); } + +// ------------------------------------------------------------------- +// doctor check: falls back to Podman when Docker is absent +// ------------------------------------------------------------------- + +/// Run `openshell ` where only a fake `podman` is on `PATH`, +/// guaranteeing Docker cannot be found so the Podman check runs instead. +async fn run_podman_only(args: &[&str], podman_ok: bool) -> (String, i32) { + let tmpdir = tempfile::tempdir().expect("create isolated config dir"); + let bin_dir = tmpdir.path().join("bin"); + fs::create_dir(&bin_dir).expect("create fake bin dir"); + let fake_podman = bin_dir.join("podman"); + let script = if podman_ok { + "#!/bin/sh\necho '5.0.0'\n" + } else { + "#!/bin/sh\necho 'Cannot connect to Podman socket.' >&2\nexit 1\n" + }; + fs::write(&fake_podman, script).expect("write fake podman"); + #[cfg(unix)] + fs::set_permissions(&fake_podman, fs::Permissions::from_mode(0o755)) + .expect("chmod fake podman"); + + let mut cmd = openshell_cmd(); + cmd.args(args) + .env("XDG_CONFIG_HOME", tmpdir.path()) + .env("HOME", tmpdir.path()) + .env("PATH", &bin_dir) + .env_remove("OPENSHELL_GATEWAY") + .env_remove("OPENSHELL_GATEWAY_ENDPOINT") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let output = cmd.output().await.expect("spawn openshell"); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let code = output.status.code().unwrap_or(-1); + (format!("{stdout}{stderr}"), code) +} + +/// `openshell doctor check` should validate Podman, not Docker, when +/// Docker is entirely absent from `PATH`. +#[tokio::test] +async fn doctor_check_falls_back_to_podman_label() { + let (output, _) = run_podman_only(&["doctor", "check"], true).await; + let clean = strip_ansi(&output); + + assert!( + clean.contains("Podman"), + "doctor check output should include 'Podman' label:\n{clean}" + ); + assert!( + !clean.contains("Docker"), + "doctor check should not mention Docker when it is absent:\n{clean}" + ); +} + +/// `openshell doctor check` with Podman unreachable should fail and +/// mention the Podman socket env var. +#[tokio::test] +async fn doctor_check_podman_failure_includes_guidance() { + let (output, code) = run_podman_only(&["doctor", "check"], false).await; + + assert_ne!(code, 0, "doctor check should fail:\n{output}"); + let clean = strip_ansi(&output); + assert!( + clean.contains("OPENSHELL_PODMAN_SOCKET"), + "doctor check error should mention OPENSHELL_PODMAN_SOCKET:\n{clean}" + ); +}