Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 84 additions & 13 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) {
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()?;
Expand All @@ -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<u16>) -> bool {
Expand Down
4 changes: 3 additions & 1 deletion docs/how-it-works/gateways/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions e2e/rust/tests/docker_preflight.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <args>` 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}"
);
}
Loading