diff --git a/crates/openshell-bootstrap/src/build.rs b/crates/openshell-bootstrap/src/build.rs index 9624e01b..51a1f17b 100644 --- a/crates/openshell-bootstrap/src/build.rs +++ b/crates/openshell-bootstrap/src/build.rs @@ -47,7 +47,7 @@ pub async fn build_and_push_image( on_log(format!( "Pushing image {tag} into gateway \"{gateway_name}\"" )); - let local_docker = Docker::connect_with_local_defaults() + let local_docker = crate::docker::connect_local_with_extended_timeout() .into_diagnostic() .wrap_err("failed to connect to local Docker daemon")?; let container = container_name(gateway_name); diff --git a/crates/openshell-bootstrap/src/docker.rs b/crates/openshell-bootstrap/src/docker.rs index 9c365bfe..10539f8a 100644 --- a/crates/openshell-bootstrap/src/docker.rs +++ b/crates/openshell-bootstrap/src/docker.rs @@ -107,6 +107,9 @@ pub struct DockerPreflight { /// - `/var/run/docker.sock` — default for Docker Desktop, `OrbStack`, Colima /// - `$HOME/.colima/docker.sock` — Colima (older installs) /// - `$HOME/.orbstack/run/docker.sock` — `OrbStack` (if symlink is missing) +/// +/// Podman sockets are discovered dynamically via `podman machine inspect` +/// because the path varies by VM backend (applehv, qemu, libkrun). const WELL_KNOWN_SOCKET_PATHS: &[&str] = &[ "/var/run/docker.sock", // Expanded at runtime via home_dir(): @@ -120,27 +123,82 @@ const WELL_KNOWN_SOCKET_PATHS: &[&str] = &[ /// deploy work begins. On failure it produces a user-friendly error with /// actionable recovery steps instead of a raw bollard connection error. pub async fn check_docker_available() -> Result { - // Step 1: Try to connect using bollard's default resolution - // (respects DOCKER_HOST, then falls back to /var/run/docker.sock). - let docker = match Docker::connect_with_local_defaults() { - Ok(d) => d, - Err(err) => { - return Err(docker_not_reachable_error( - &format!("{err}"), - "Failed to create Docker client", - )); + // Step 1: Try DOCKER_HOST if set. + if let Some(host) = env_non_empty("DOCKER_HOST") { + return try_connect_and_ping(&host, "DOCKER_HOST").await; + } + + // Step 2: Try CONTAINER_HOST if set (Podman convention). + if let Some(host) = env_non_empty("CONTAINER_HOST") { + return try_connect_and_ping(&host, "CONTAINER_HOST").await; + } + + // Step 3: Try bollard's default resolution (/var/run/docker.sock). + if let Ok(preflight) = try_default_connect().await { + return Ok(preflight); + } + + // Step 4: On macOS, try to discover the Podman socket dynamically. + // cfg! is a runtime bool so it cannot be combined with `if let`. + #[allow(clippy::collapsible_if)] + if cfg!(target_os = "macos") { + if let Some(sock_path) = + discover_podman_socket().filter(|p| std::path::Path::new(p).exists()) + { + let host = format!("unix://{sock_path}"); + if let Ok(preflight) = try_connect_and_ping(&host, "podman socket").await { + return Ok(preflight); + } } + } + + // Nothing worked — produce a helpful error. + Err(docker_not_reachable_error( + "No reachable container runtime found", + "Failed to connect to Docker or Podman", + )) +} + +/// Try bollard's default socket resolution (no explicit host). +async fn try_default_connect() -> Result { + let docker = Docker::connect_with_local_defaults().map_err(|err| miette::miette!("{err}"))?; + docker + .ping() + .await + .map_err(|err| miette::miette!("{err}"))?; + let version = match docker.version().await { + Ok(v) => v.version, + Err(_) => None, + }; + Ok(DockerPreflight { docker, version }) +} + +/// Try connecting to a specific Docker/Podman host URL. +async fn try_connect_and_ping(host: &str, source: &str) -> Result { + let docker = if host.starts_with("unix://") { + let path = host.strip_prefix("unix://").unwrap_or(host); + Docker::connect_with_unix(path, 600, API_DEFAULT_VERSION).map_err(|err| { + docker_not_reachable_error( + &format!("{err}"), + &format!("Failed to create Docker client from {source}={host}"), + ) + })? + } else { + Docker::connect_with_local_defaults().map_err(|err| { + docker_not_reachable_error( + &format!("{err}"), + &format!("Failed to create Docker client from {source}={host}"), + ) + })? }; - // Step 2: Ping the daemon to confirm it's responsive. if let Err(err) = docker.ping().await { return Err(docker_not_reachable_error( &format!("{err}"), - "Docker socket exists but the daemon is not responding", + &format!("Container runtime at {source}={host} is not responding"), )); } - // Step 3: Query version info (best-effort — don't fail on this). let version = match docker.version().await { Ok(v) => v.version, Err(_) => None, @@ -152,22 +210,32 @@ pub async fn check_docker_available() -> Result { /// Build a rich, user-friendly error when Docker is not reachable. fn docker_not_reachable_error(raw_err: &str, summary: &str) -> miette::Report { let docker_host = std::env::var("DOCKER_HOST").ok(); + let container_host = std::env::var("CONTAINER_HOST").ok(); let socket_exists = std::path::Path::new("/var/run/docker.sock").exists(); let mut hints: Vec = Vec::new(); - if !socket_exists && docker_host.is_none() { - // No socket and no DOCKER_HOST — likely nothing is installed or started + if !socket_exists && docker_host.is_none() && container_host.is_none() { + // No socket and no env vars — likely nothing is installed or started hints.push( - "No Docker socket found at /var/run/docker.sock and DOCKER_HOST is not set." - .to_string(), - ); - hints.push( - "Install and start a Docker-compatible runtime. See the support matrix \ - in the OpenShell docs for tested configurations." + "No Docker socket found at /var/run/docker.sock and neither DOCKER_HOST nor \ + CONTAINER_HOST is set." .to_string(), ); + if cfg!(target_os = "macos") { + hints.push( + "Start Docker Desktop or run `podman machine start` to start a container runtime." + .to_string(), + ); + } else { + hints.push( + "Install and start a Docker-compatible runtime. See the support matrix \ + in the OpenShell docs for tested configurations." + .to_string(), + ); + } + // Check for alternative sockets that might exist let alt_sockets = find_alternative_sockets(); if !alt_sockets.is_empty() { @@ -179,15 +247,14 @@ fn docker_not_reachable_error(raw_err: &str, summary: &str) -> miette::Report { alt_sockets[0], )); } - } else if docker_host.is_some() { - // DOCKER_HOST is set but daemon didn't respond - let host_val = docker_host.unwrap(); + } else if docker_host.is_some() || container_host.is_some() { + // An env var is set but daemon didn't respond + let host_val = docker_host.or(container_host).unwrap_or_default(); hints.push(format!( - "DOCKER_HOST is set to '{host_val}' but the Docker daemon is not responding." + "DOCKER_HOST/CONTAINER_HOST is set to '{host_val}' but the container daemon is not responding." )); hints.push( - "Verify your Docker runtime is started and the DOCKER_HOST value is correct." - .to_string(), + "Verify your container runtime is started and the host value is correct.".to_string(), ); } else { // Socket exists but daemon isn't responding @@ -218,10 +285,11 @@ fn find_alternative_sockets() -> Vec { // Check home-relative paths if let Some(home) = home_dir() { - let home_sockets = [ + let home_sockets = vec![ format!("{home}/.colima/docker.sock"), format!("{home}/.orbstack/run/docker.sock"), ]; + for path in &home_sockets { if std::path::Path::new(path).exists() && !found.contains(path) { found.push(path.clone()); @@ -229,6 +297,16 @@ fn find_alternative_sockets() -> Vec { } } + // On macOS, try to discover Podman socket dynamically + #[allow(clippy::collapsible_if)] + if cfg!(target_os = "macos") { + if let Some(podman_path) = discover_podman_socket() + .filter(|p| std::path::Path::new(p).exists() && !found.contains(p)) + { + found.push(podman_path); + } + } + found } @@ -236,6 +314,75 @@ fn home_dir() -> Option { std::env::var("HOME").ok() } +/// Detect whether the connected Docker daemon is actually Podman. +/// +/// Checks the Docker version response for the "Podman" component, which +/// Podman always includes in its compatibility API responses. +async fn is_podman_runtime(docker: &Docker) -> bool { + if let Ok(version) = docker.version().await { + // Podman sets the version string and components to indicate itself + if let Some(ref v) = version.version { + if v.to_lowercase().contains("podman") { + return true; + } + } + // Also check Components array for Podman Engine + if let Some(ref components) = version.components { + for c in components { + if c.name.to_lowercase().contains("podman") { + return true; + } + } + } + } + false +} + +/// Discover the Podman machine socket path by running `podman machine inspect`. +/// +/// On macOS, Podman places its API socket in a temp directory that varies by +/// VM backend (applehv, qemu, libkrun). This function queries the running +/// machine to find the actual socket path. +fn discover_podman_socket() -> Option { + let output = std::process::Command::new("podman") + .args([ + "machine", + "inspect", + "--format", + "{{.ConnectionInfo.PodmanSocket.Path}}", + ]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() || !path.starts_with('/') { + return None; + } + + Some(path) +} + +/// Create a local Docker client with an extended timeout suitable for large +/// image transfers. +/// +/// When `DOCKER_HOST` points to a Unix socket (including Podman sockets), +/// this uses a 600-second timeout instead of the default 120 seconds. +/// Falls back to `connect_with_local_defaults()` for other transports. +pub fn connect_local_with_extended_timeout() -> std::result::Result +{ + if let Some(path) = std::env::var("DOCKER_HOST") + .ok() + .and_then(|h| h.strip_prefix("unix://").map(String::from)) + { + return Docker::connect_with_unix(&path, 600, API_DEFAULT_VERSION); + } + Docker::connect_with_local_defaults() +} + /// Create an SSH Docker client from remote options. pub async fn create_ssh_docker_client(remote: &RemoteOptions) -> Result { // Ensure destination has ssh:// prefix @@ -522,6 +669,8 @@ pub async fn ensure_container( ); let exposed_ports = vec!["30051/tcp".to_string()]; + let running_under_podman = is_podman_runtime(docker).await; + let mut host_config = HostConfig { privileged: Some(true), // Use host cgroup namespace so k3s kubelet can manage cgroup controllers @@ -539,6 +688,16 @@ pub async fn ensure_container( "host.docker.internal:host-gateway".to_string(), "host.openshell.internal:host-gateway".to_string(), ]), + // Under Podman (rootless), unmask /sys/fs/cgroup so k3s kubelet can + // create cgroup hierarchies for pod QoS management. + security_opt: if running_under_podman { + Some(vec![ + "unmask=/sys/fs/cgroup".to_string(), + "unmask=/dev/kmsg".to_string(), + ]) + } else { + None + }, ..Default::default() }; @@ -566,6 +725,16 @@ pub async fn ensure_container( "--tls-san=localhost".to_string(), "--tls-san=host.docker.internal".to_string(), ]; + + // When running under Podman (rootless), k3s kubelet cannot access + // /dev/kmsg and cannot create cgroup hierarchies. Enable + // KubeletInUserNamespace and disable cgroup-per-QoS enforcement. + if running_under_podman { + cmd.push("--kubelet-arg=feature-gates=KubeletInUserNamespace=true".to_string()); + cmd.push("--kubelet-arg=cgroups-per-qos=false".to_string()); + cmd.push("--kubelet-arg=enforce-node-allocatable=".to_string()); + } + for san in extra_sans { cmd.push(format!("--tls-san={san}")); } @@ -1195,4 +1364,97 @@ mod tests { "should return a reasonable number of sockets" ); } + + // -- discover_podman_socket tests -- + // + // We cannot mock the `podman` command, but we can verify the function + // does not panic and returns an `Option`. When podman is not + // installed (the common CI case) the function must return `None`. + + #[test] + fn discover_podman_socket_returns_option() { + // On hosts without podman this should return None. + // On hosts with podman it should return Some(path) starting with '/'. + let result = discover_podman_socket(); + if let Some(ref path) = result { + assert!( + path.starts_with('/'), + "discovered podman socket should be an absolute path, got: {path}" + ); + } + // Either way, no panic — the test passes. + } + + // -- connect_local_with_extended_timeout tests -- + // + // Note: bollard client construction may fail when no Docker socket exists + // on the host (e.g. in CI). These tests verify the branching logic by + // checking that the unix:// path is handled differently from other + // transports, rather than asserting success unconditionally. + + #[test] + fn connect_local_with_extended_timeout_takes_unix_path() { + // When DOCKER_HOST is a unix:// path the function should use + // `connect_with_unix` (the extended-timeout path). Client + // construction may still fail if bollard cannot stat the socket, + // so we just verify it does not panic. + let prev = std::env::var("DOCKER_HOST").ok(); + // SAFETY: test-only, single-threaded test runner for this test + unsafe { + std::env::set_var("DOCKER_HOST", "unix:///tmp/fake-test-socket.sock"); + } + + let _result = connect_local_with_extended_timeout(); + + // SAFETY: test-only, restoring previous state + unsafe { + match prev { + Some(val) => std::env::set_var("DOCKER_HOST", val), + None => std::env::remove_var("DOCKER_HOST"), + } + } + // No panic — the branch was exercised. + } + + #[test] + fn connect_local_with_extended_timeout_non_unix_falls_back() { + // When DOCKER_HOST is a non-unix value (e.g. tcp://), the function + // should fall back to `connect_with_local_defaults()`. + let prev = std::env::var("DOCKER_HOST").ok(); + // SAFETY: test-only, single-threaded test runner for this test + unsafe { + std::env::set_var("DOCKER_HOST", "tcp://127.0.0.1:2375"); + } + + let _result = connect_local_with_extended_timeout(); + + // SAFETY: test-only, restoring previous state + unsafe { + match prev { + Some(val) => std::env::set_var("DOCKER_HOST", val), + None => std::env::remove_var("DOCKER_HOST"), + } + } + // No panic — the fallback branch was exercised. + } + + #[test] + fn connect_local_with_extended_timeout_unset_uses_defaults() { + // When DOCKER_HOST is unset, the function should use local defaults. + let prev = std::env::var("DOCKER_HOST").ok(); + // SAFETY: test-only, single-threaded test runner for this test + unsafe { + std::env::remove_var("DOCKER_HOST"); + } + + let _result = connect_local_with_extended_timeout(); + + // SAFETY: test-only, restoring previous state + unsafe { + if let Some(val) = prev { + std::env::set_var("DOCKER_HOST", val); + } + } + // No panic — the default branch was exercised. + } } diff --git a/crates/openshell-bootstrap/src/errors.rs b/crates/openshell-bootstrap/src/errors.rs index b487c94a..2b2a3a51 100644 --- a/crates/openshell-bootstrap/src/errors.rs +++ b/crates/openshell-bootstrap/src/errors.rs @@ -403,11 +403,15 @@ fn diagnose_docker_not_running(_gateway_name: &str) -> GatewayFailureDiagnosis { a Docker-compatible container runtime to manage gateway clusters." .to_string(), recovery_steps: vec![ - RecoveryStep::new("Start your Docker runtime"), + RecoveryStep::new( + "Start your container runtime (Docker Desktop, Colima, or `podman machine start`)", + ), RecoveryStep::with_command("Verify Docker is accessible", "docker info"), RecoveryStep::new( "If using a non-default Docker socket, set DOCKER_HOST:\n \ - export DOCKER_HOST=unix:///var/run/docker.sock", + export DOCKER_HOST=unix:///var/run/docker.sock\n \ + Or for Podman on macOS:\n \ + export DOCKER_HOST=unix://$HOME/.local/share/containers/podman/machine/podman.sock", ), RecoveryStep::new("Then retry: openshell gateway start"), ], diff --git a/crates/openshell-bootstrap/src/lib.rs b/crates/openshell-bootstrap/src/lib.rs index 9098fd4a..81a99383 100644 --- a/crates/openshell-bootstrap/src/lib.rs +++ b/crates/openshell-bootstrap/src/lib.rs @@ -469,7 +469,8 @@ where .collect(); if !images.is_empty() { log("[status] Deploying components".to_string()); - let local_docker = Docker::connect_with_local_defaults().into_diagnostic()?; + let local_docker = + docker::connect_local_with_extended_timeout().into_diagnostic()?; let container = container_name(&name); let on_log_ref = Arc::clone(&on_log); let mut push_log = move |msg: String| { diff --git a/crates/openshell-bootstrap/src/push.rs b/crates/openshell-bootstrap/src/push.rs index 0dcbaa6d..3ba5775d 100644 --- a/crates/openshell-bootstrap/src/push.rs +++ b/crates/openshell-bootstrap/src/push.rs @@ -49,10 +49,15 @@ pub async fn push_local_images( image_tar.len() / (1024 * 1024) )); - // 2. Wrap the image tar as a file inside an outer tar archive and upload - // it into the container filesystem via the Docker put_archive API. + // 2. Upload the image tar into the container filesystem. + // Try the Docker put_archive API first; fall back to `docker cp` for + // Podman compatibility with large payloads. let outer_tar = wrap_in_tar(IMPORT_TAR_PATH, &image_tar)?; - upload_archive(gateway_docker, container_name, &outer_tar).await?; + let api_ok = upload_archive_api(gateway_docker, container_name, &outer_tar).await; + if api_ok.is_err() { + on_log("[progress] API upload failed, falling back to docker cp...".to_string()); + upload_via_docker_cp(container_name, &image_tar).await?; + } on_log("[progress] Uploaded to gateway".to_string()); // 3. Import the tar into containerd via ctr. @@ -130,9 +135,12 @@ fn wrap_in_tar(file_path: &str, data: &[u8]) -> Result> { .wrap_err("failed to finalize tar archive") } -/// Upload a tar archive into the container at the parent directory of -/// [`IMPORT_TAR_PATH`]. -async fn upload_archive(docker: &Docker, container_name: &str, archive: &[u8]) -> Result<()> { +/// Upload a tar archive via the bollard `put_archive` API. +async fn upload_archive_api( + docker: &Docker, + container_name: &str, + archive: &[u8], +) -> std::result::Result<(), bollard::errors::Error> { let parent_dir = IMPORT_TAR_PATH.rsplit_once('/').map_or("/", |(dir, _)| dir); let options = UploadToContainerOptionsBuilder::default() @@ -146,6 +154,115 @@ async fn upload_archive(docker: &Docker, container_name: &str, archive: &[u8]) - bollard::body_full(Bytes::copy_from_slice(archive)), ) .await +} + +/// Fallback upload: write the raw image tar to a temp file on the host, then +/// use `docker cp` to copy it into the container. This streams in chunks and +/// is more reliable with Podman for large payloads. +async fn upload_via_docker_cp(container_name: &str, image_tar: &[u8]) -> Result<()> { + use std::io::Write; + + let tmp_dir = std::env::temp_dir(); + let tmp_path = tmp_dir.join("openshell-images.tar"); + { + let mut f = std::fs::File::create(&tmp_path) + .into_diagnostic() + .wrap_err("failed to create temp file for image upload")?; + f.write_all(image_tar) + .into_diagnostic() + .wrap_err("failed to write image tar to temp file")?; + } + + let target = format!("{container_name}:{IMPORT_TAR_PATH}"); + let status = tokio::process::Command::new("docker") + .args(["cp", tmp_path.to_str().unwrap_or(""), &target]) + .status() + .await .into_diagnostic() - .wrap_err("failed to upload image tar into container") + .wrap_err("failed to run `docker cp`")?; + + let _ = std::fs::remove_file(&tmp_path); + + if !status.success() { + return Err(miette::miette!( + "`docker cp` failed with exit code {}", + status.code().unwrap_or(-1) + )); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // -- wrap_in_tar tests -- + + #[test] + fn wrap_in_tar_produces_valid_archive() { + let data = b"hello world"; + let result = wrap_in_tar("/tmp/test-file.tar", data); + assert!(result.is_ok(), "wrap_in_tar should succeed"); + + let tar_bytes = result.unwrap(); + assert!(!tar_bytes.is_empty(), "tar archive should not be empty"); + + // Verify the archive contains exactly one entry with the correct name + let mut archive = tar::Archive::new(tar_bytes.as_slice()); + let entries: Vec<_> = archive.entries().unwrap().collect(); + assert_eq!(entries.len(), 1, "tar should contain exactly one entry"); + } + + #[test] + fn wrap_in_tar_uses_basename() { + let data = b"payload"; + let tar_bytes = wrap_in_tar("/some/deep/path/image.tar", data).unwrap(); + + let mut archive = tar::Archive::new(tar_bytes.as_slice()); + let entry = archive.entries().unwrap().next().unwrap().unwrap(); + let path = entry.path().unwrap(); + assert_eq!( + path.to_str().unwrap(), + "image.tar", + "tar entry should use basename only" + ); + } + + // -- upload_via_docker_cp tests -- + // + // This function writes a temp file, invokes `docker cp`, then cleans up. + // We can test the temp-file lifecycle even though `docker cp` will fail + // (docker daemon is not available in unit tests). + + #[tokio::test] + async fn upload_via_docker_cp_creates_and_cleans_up_temp_file() { + let image_tar = b"fake image tar content"; + let tmp_path = std::env::temp_dir().join("openshell-images.tar"); + + // The function will fail because `docker cp` won't find a real + // container, but we can verify cleanup behavior. + let result = upload_via_docker_cp("nonexistent-container-12345", image_tar).await; + + // The call should fail because docker cp will either not find docker + // or fail to reach the container. + assert!(result.is_err(), "should fail with a nonexistent container"); + + // The temp file should have been cleaned up even on failure. + assert!( + !tmp_path.exists(), + "temp file should be removed after docker cp (even on failure)" + ); + } + + #[tokio::test] + async fn upload_via_docker_cp_error_message_is_descriptive() { + let result = upload_via_docker_cp("fake-container", b"data").await; + let err_msg = format!("{:?}", result.unwrap_err()); + // The error should mention either "docker cp" or the underlying failure + assert!( + err_msg.contains("docker cp") || err_msg.contains("docker"), + "error should reference docker cp, got: {err_msg}" + ); + } } diff --git a/docs/get-started/quickstart.md b/docs/get-started/quickstart.md index 5f3607c1..b6448730 100644 --- a/docs/get-started/quickstart.md +++ b/docs/get-started/quickstart.md @@ -30,9 +30,10 @@ This page gets you from zero to a running, policy-enforced sandbox in two comman ## Prerequisites -Before you begin, make sure you have: +Before you begin, make sure you have a container runtime running on your machine: -- Docker Desktop running on your machine. +- Docker Desktop (recommended), or +- Podman in rootful mode (macOS). See {ref}`podman-macos-setup` for setup instructions. For a complete list of requirements, refer to {doc}`../reference/support-matrix`. @@ -56,6 +57,28 @@ After installing the CLI, run `openshell --help` in your terminal to see the ful You can also clone the [NVIDIA OpenShell GitHub repository](https://github.com/NVIDIA/OpenShell) and use the `/openshell-cli` skill to load the CLI reference into your agent. ::: +(podman-macos-setup)= +## Set Up Podman on macOS + +Podman is supported on macOS as an alternative to Docker Desktop. OpenShell requires Podman to run in rootful mode. + +Install Podman and configure a machine: + +```console +$ brew install podman +$ podman machine init --memory 8192 --cpus 4 +$ podman machine set --rootful +$ podman machine start +``` + +OpenShell automatically discovers the Podman socket through `podman machine inspect`. No manual `DOCKER_HOST` configuration is needed. + +:::{note} +The Podman machine must run in rootful mode (`--rootful`). A minimum of 8 GiB memory is recommended for the VM. +::: + +If you use Docker Desktop instead, skip this section. + ## Create Your First OpenShell Sandbox Create a sandbox and launch an agent inside it. diff --git a/docs/index.md b/docs/index.md index 37d49047..0c9b3753 100644 --- a/docs/index.md +++ b/docs/index.md @@ -228,6 +228,7 @@ reference/gateway-auth reference/default-policy reference/policy-schema reference/support-matrix +reference/troubleshooting ``` ```{toctree} diff --git a/docs/reference/support-matrix.md b/docs/reference/support-matrix.md index 96ece585..12f81c3d 100644 --- a/docs/reference/support-matrix.md +++ b/docs/reference/support-matrix.md @@ -16,6 +16,7 @@ OpenShell publishes multi-architecture container images for `linux/amd64` and `l | Linux (Debian/Ubuntu) | x86_64 (amd64) | Supported | | Linux (Debian/Ubuntu) | aarch64 (arm64) | Supported | | macOS (Docker Desktop) | Apple Silicon (arm64) | Supported | +| macOS (Podman) | Apple Silicon (arm64) | Supported | | Windows (WSL 2 + Docker Desktop) | x86_64 | Experimental | ## Software Prerequisites @@ -25,6 +26,7 @@ The following software must be installed on the host before using the OpenShell | Component | Minimum Version | Notes | | ------------------------------- | --------------- | ----------------------------------------------- | | Docker Desktop or Docker Engine | 28.04 | Must be running before any `openshell` command. | +| Podman (macOS alternative) | 5.0 | Requires rootful mode. See {ref}`podman-macos-setup`. | ## Sandbox Runtime Versions diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md new file mode 100644 index 00000000..9f65f435 --- /dev/null +++ b/docs/reference/troubleshooting.md @@ -0,0 +1,56 @@ +--- +title: + page: Troubleshooting + nav: Troubleshooting +description: Solutions for common issues when running OpenShell. +topics: +- Generative AI +- Cybersecurity +tags: +- Troubleshooting +- Podman +- Docker +content: + type: reference + difficulty: technical_intermediate + audience: + - engineer +--- + + + +# Troubleshooting + +This page covers common issues and their solutions when running OpenShell. + +## Podman on macOS + +The following issues are specific to running OpenShell with Podman on macOS. + +### Gateway fails with "cgroup permission denied" + +The gateway container requires access to cgroup controllers that are only available in rootful mode. If you see a permission denied error related to cgroups, confirm that your Podman machine is configured for rootful mode: + +```console +$ podman machine set --rootful +$ podman machine stop +$ podman machine start +``` + +### Image push fails with "connection closed" + +Large image operations can fail when the Podman VM does not have enough memory. Increase the VM memory to at least 8 GiB and prune unused images to free space: + +```console +$ podman machine stop +$ podman machine set --memory 8192 +$ podman machine start +$ podman image prune -f +``` + +### "/dev/kmsg: operation not permitted" + +Some container images attempt to read `/dev/kmsg` at startup. OpenShell handles this automatically by providing a safe fallback inside the sandbox. No action is required.