From 37a12cdb49f0109be81fb4184934f644a154f5af Mon Sep 17 00:00:00 2001 From: "Agusti F." <6601142+agustif@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:22:17 +0100 Subject: [PATCH 1/2] Respect Docker contexts when resolving the host engine Honor Docker's documented precedence by letting DOCKER_CONTEXT override DOCKER_HOST, treating DOCKER_CONTEXT=default as the default local engine selection, and only consulting currentContext when neither env var is set. Keep the shared resolver wiring limited to local/context socket discovery. Refs: #60 Co-authored-by: Codex --- Cargo.lock | 1 + coast-cli/Cargo.toml | 1 + coast-cli/src/commands/doctor.rs | 4 +- coast-cli/src/commands/nuke.rs | 2 +- coast-daemon/src/server.rs | 8 +- coast-docker/src/dind.rs | 6 +- coast-docker/src/host.rs | 397 +++++++++++++++++++++++++++++++ coast-docker/src/lib.rs | 1 + coast-docker/src/network.rs | 7 +- coast-docker/src/podman.rs | 10 +- coast-docker/src/sysbox.rs | 6 +- 11 files changed, 419 insertions(+), 24 deletions(-) create mode 100644 coast-docker/src/host.rs diff --git a/Cargo.lock b/Cargo.lock index 7a8ec14b..1a31b3ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -401,6 +401,7 @@ dependencies = [ "chrono", "clap", "coast-core", + "coast-docker", "coast-i18n", "coast-update", "colored", diff --git a/coast-cli/Cargo.toml b/coast-cli/Cargo.toml index 74105708..2912a5fd 100644 --- a/coast-cli/Cargo.toml +++ b/coast-cli/Cargo.toml @@ -18,6 +18,7 @@ path = "src/bin/coast-dev.rs" [dependencies] coast-core = { path = "../coast-core" } +coast-docker = { path = "../coast-docker" } coast-i18n = { path = "../coast-i18n" } coast-update = { path = "../coast-update" } rust-i18n = { workspace = true } diff --git a/coast-cli/src/commands/doctor.rs b/coast-cli/src/commands/doctor.rs index e90e26b3..26c99ddb 100644 --- a/coast-cli/src/commands/doctor.rs +++ b/coast-cli/src/commands/doctor.rs @@ -99,8 +99,8 @@ pub async fn execute(args: &DoctorArgs) -> Result<()> { let db = rusqlite::Connection::open(&db_path).context("Failed to open state database")?; - let docker = bollard::Docker::connect_with_local_defaults() - .context("Failed to connect to Docker. Is Docker running?")?; + let docker = coast_docker::host::connect_to_host_docker() + .context("Failed to connect to Docker. Is Docker running and is your active Docker context reachable?")?; let mut fixes: Vec = Vec::new(); let mut findings: Vec = Vec::new(); diff --git a/coast-cli/src/commands/nuke.rs b/coast-cli/src/commands/nuke.rs index 5a0d27b1..3a042e3c 100644 --- a/coast-cli/src/commands/nuke.rs +++ b/coast-cli/src/commands/nuke.rs @@ -63,7 +63,7 @@ pub async fn execute(args: &NukeArgs) -> Result<()> { ..Default::default() }; - match bollard::Docker::connect_with_local_defaults() { + match coast_docker::host::connect_to_host_docker() { Ok(docker) => { report.containers_removed = remove_containers(&docker).await; report.volumes_removed = remove_volumes(&docker).await; diff --git a/coast-daemon/src/server.rs b/coast-daemon/src/server.rs index 07e2fef9..be0c8824 100644 --- a/coast-daemon/src/server.rs +++ b/coast-daemon/src/server.rs @@ -186,7 +186,13 @@ pub struct AppState { impl AppState { /// Create a new `AppState` with the given state database and Docker client. pub fn new(db: StateDb) -> Self { - let docker = bollard::Docker::connect_with_local_defaults().ok(); + let docker = match coast_docker::host::connect_to_host_docker() { + Ok(docker) => Some(docker), + Err(error) => { + warn!(error = %error, "Docker is unavailable at daemon startup"); + None + } + }; let (event_bus, _) = tokio::sync::broadcast::channel(256); let initial_lang = db.get_language().unwrap_or_else(|_| "en".to_string()); let (language_tx, language_rx) = tokio::sync::watch::channel(initial_lang); diff --git a/coast-docker/src/dind.rs b/coast-docker/src/dind.rs index 901e8430..20c69df5 100644 --- a/coast-docker/src/dind.rs +++ b/coast-docker/src/dind.rs @@ -16,6 +16,7 @@ use tracing::{debug, info}; use coast_core::error::{CoastError, Result}; +use crate::host::connect_to_host_docker; use crate::runtime::{BindMount, ContainerConfig, ExecResult, Runtime, VolumeMount}; /// The default Docker image used for DinD coast containers. @@ -34,10 +35,7 @@ pub struct DindRuntime { impl DindRuntime { /// Create a new DinD runtime connected to the default Docker socket. pub fn new() -> Result { - let docker = Docker::connect_with_local_defaults().map_err(|e| CoastError::Docker { - message: format!("Failed to connect to Docker daemon. Is Docker running? Error: {e}"), - source: Some(Box::new(e)), - })?; + let docker = connect_to_host_docker()?; Ok(Self { docker }) } diff --git a/coast-docker/src/host.rs b/coast-docker/src/host.rs new file mode 100644 index 00000000..f16ac4ca --- /dev/null +++ b/coast-docker/src/host.rs @@ -0,0 +1,397 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use bollard::{Docker, API_DEFAULT_VERSION}; +use serde::Deserialize; + +use coast_core::error::{CoastError, Result}; + +const DEFAULT_TIMEOUT_SECS: u64 = 120; + +#[cfg(unix)] +const DEFAULT_LOCAL_DOCKER_HOST: &str = "unix:///var/run/docker.sock"; + +#[cfg(windows)] +const DEFAULT_LOCAL_DOCKER_HOST: &str = "npipe:////./pipe/docker_engine"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DockerEndpointSource { + EnvHost, + EnvContext, + ConfigContext, + DefaultLocal, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DockerEndpoint { + pub host: String, + pub source: DockerEndpointSource, + pub context: Option, +} + +#[derive(Debug, Deserialize)] +struct DockerCliConfig { + #[serde(rename = "currentContext")] + current_context: Option, +} + +#[derive(Debug, Deserialize)] +struct ContextMeta { + #[serde(rename = "Name")] + name: String, + #[serde(rename = "Endpoints")] + endpoints: std::collections::HashMap, +} + +#[derive(Debug, Deserialize)] +struct ContextEndpoint { + #[serde(rename = "Host")] + host: Option, +} + +pub fn connect_to_host_docker() -> Result { + connect_to_host_docker_with( + env::var_os("DOCKER_CONFIG").map(PathBuf::from), + env::var("DOCKER_HOST").ok(), + env::var("DOCKER_CONTEXT").ok(), + ) +} + +fn connect_to_host_docker_with( + docker_config_dir: Option, + env_host: Option, + env_context: Option, +) -> Result { + let endpoint = resolve_docker_endpoint( + docker_config_dir.as_deref(), + env_host.as_deref(), + env_context.as_deref(), + )?; + + match endpoint.source { + DockerEndpointSource::EnvHost => Docker::connect_with_defaults().map_err(|e| { + CoastError::docker(format!( + "Failed to connect to Docker using DOCKER_HOST='{}'. Error: {e}", + endpoint.host + )) + }), + _ => connect_to_endpoint(&endpoint), + } +} + +pub fn resolve_docker_endpoint( + docker_config_dir: Option<&Path>, + env_host: Option<&str>, + env_context: Option<&str>, +) -> Result { + let config_dir = docker_config_dir + .map(Path::to_path_buf) + .or_else(default_docker_config_dir); + + if let Some(raw_context) = normalize_env_value(env_context) { + if raw_context == "default" { + return Ok(DockerEndpoint { + host: DEFAULT_LOCAL_DOCKER_HOST.to_string(), + source: DockerEndpointSource::DefaultLocal, + context: None, + }); + } + + let host = resolve_context_host(config_dir.as_deref(), raw_context)?; + return Ok(DockerEndpoint { + host, + source: DockerEndpointSource::EnvContext, + context: Some(raw_context.to_string()), + }); + } + + if let Some(host) = normalize_env_value(env_host) { + return Ok(DockerEndpoint { + host: host.to_string(), + source: DockerEndpointSource::EnvHost, + context: None, + }); + } + + if let Some(config_dir) = config_dir.as_deref() { + if let Some(context) = current_context_from_config(config_dir)? { + let host = resolve_context_host(Some(config_dir), &context)?; + return Ok(DockerEndpoint { + host, + source: DockerEndpointSource::ConfigContext, + context: Some(context), + }); + } + } + + Ok(DockerEndpoint { + host: DEFAULT_LOCAL_DOCKER_HOST.to_string(), + source: DockerEndpointSource::DefaultLocal, + context: None, + }) +} + +fn connect_to_endpoint(endpoint: &DockerEndpoint) -> Result { + let host = endpoint.host.as_str(); + let context_msg = endpoint + .context + .as_ref() + .map(|name| format!("Docker context '{name}'")) + .unwrap_or_else(|| "resolved Docker host".to_string()); + + #[cfg(any(unix, windows))] + if host.starts_with("unix://") || host.starts_with("npipe://") { + return Docker::connect_with_socket(host, DEFAULT_TIMEOUT_SECS, API_DEFAULT_VERSION) + .map_err(|e| { + CoastError::docker(format!( + "Failed to connect to {context_msg} at '{}'. Error: {e}", + endpoint.host + )) + }); + } + + if host.starts_with("tcp://") || host.starts_with("http://") { + return Docker::connect_with_http(host, DEFAULT_TIMEOUT_SECS, API_DEFAULT_VERSION) + .map_err(|e| { + CoastError::docker(format!( + "Failed to connect to {context_msg} at '{}'. Error: {e}", + endpoint.host + )) + }); + } + + Err(CoastError::docker(format!( + "Unsupported Docker endpoint '{}' from {context_msg}. \ + Set DOCKER_HOST explicitly if this engine requires a transport Coasts does not yet auto-resolve.", + endpoint.host + ))) +} + +fn normalize_env_value(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +fn normalize_context_name(value: Option<&str>) -> Option { + match normalize_env_value(value) { + Some("default") | None => None, + Some(value) => Some(value.to_string()), + } +} + +fn default_docker_config_dir() -> Option { + dirs::home_dir().map(|home| home.join(".docker")) +} + +fn current_context_from_config(config_dir: &Path) -> Result> { + let config_path = config_dir.join("config.json"); + if !config_path.exists() { + return Ok(None); + } + + let contents = fs::read_to_string(&config_path).map_err(|e| CoastError::Docker { + message: format!( + "Failed to read Docker config '{}'. Error: {e}", + config_path.display() + ), + source: Some(Box::new(e)), + })?; + + let config: DockerCliConfig = serde_json::from_str(&contents).map_err(|e| CoastError::Docker { + message: format!( + "Failed to parse Docker config '{}'. Error: {e}", + config_path.display() + ), + source: Some(Box::new(e)), + })?; + + Ok(normalize_context_name(config.current_context.as_deref())) +} + +fn resolve_context_host(config_dir: Option<&Path>, context_name: &str) -> Result { + let Some(config_dir) = config_dir else { + return Err(CoastError::docker(format!( + "Docker context '{context_name}' was requested, but no Docker config directory could be found." + ))); + }; + + let meta_root = config_dir.join("contexts").join("meta"); + if !meta_root.exists() { + return Err(CoastError::docker(format!( + "Docker context '{context_name}' was requested, but '{}' does not exist.", + meta_root.display() + ))); + } + + for entry in fs::read_dir(&meta_root).map_err(|e| CoastError::Docker { + message: format!( + "Failed to read Docker contexts in '{}'. Error: {e}", + meta_root.display() + ), + source: Some(Box::new(e)), + })? { + let entry = entry.map_err(|e| CoastError::Docker { + message: format!( + "Failed to inspect Docker context metadata in '{}'. Error: {e}", + meta_root.display() + ), + source: Some(Box::new(e)), + })?; + + let meta_path = entry.path().join("meta.json"); + if !meta_path.exists() { + continue; + } + + let contents = fs::read_to_string(&meta_path).map_err(|e| CoastError::Docker { + message: format!( + "Failed to read Docker context metadata '{}'. Error: {e}", + meta_path.display() + ), + source: Some(Box::new(e)), + })?; + let meta: ContextMeta = serde_json::from_str(&contents).map_err(|e| CoastError::Docker { + message: format!( + "Failed to parse Docker context metadata '{}'. Error: {e}", + meta_path.display() + ), + source: Some(Box::new(e)), + })?; + + if meta.name != context_name { + continue; + } + + let host = meta + .endpoints + .get("docker") + .and_then(|endpoint| endpoint.host.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + CoastError::docker(format!( + "Docker context '{context_name}' has no docker endpoint host in '{}'.", + meta_path.display() + )) + })?; + + return Ok(host.to_string()); + } + + Err(CoastError::docker(format!( + "Docker context '{context_name}' was not found under '{}'.", + meta_root.display() + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + + use tempfile::TempDir; + + fn write_json(path: &Path, contents: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, contents).unwrap(); + } + + #[test] + fn resolves_env_host_before_config_context() { + let temp = TempDir::new().unwrap(); + write_json( + &temp.path().join("config.json"), + r#"{"currentContext":"orbstack"}"#, + ); + + let endpoint = resolve_docker_endpoint( + Some(temp.path()), + Some("unix:///tmp/docker.sock"), + None, + ) + .unwrap(); + + assert_eq!(endpoint.source, DockerEndpointSource::EnvHost); + assert_eq!(endpoint.host, "unix:///tmp/docker.sock"); + } + + #[test] + fn resolves_explicit_context_from_meta_store() { + let temp = TempDir::new().unwrap(); + write_json( + &temp.path().join("contexts/meta/hash/meta.json"), + r#"{"Name":"orbstack","Endpoints":{"docker":{"Host":"unix:///Users/test/.orbstack/run/docker.sock"}}}"#, + ); + + let endpoint = + resolve_docker_endpoint(Some(temp.path()), None, Some("orbstack")).unwrap(); + + assert_eq!(endpoint.source, DockerEndpointSource::EnvContext); + assert_eq!( + endpoint.host, + "unix:///Users/test/.orbstack/run/docker.sock" + ); + assert_eq!(endpoint.context.as_deref(), Some("orbstack")); + } + + #[test] + fn explicit_context_overrides_docker_host() { + let temp = TempDir::new().unwrap(); + write_json( + &temp.path().join("contexts/meta/hash/meta.json"), + r#"{"Name":"orbstack","Endpoints":{"docker":{"Host":"unix:///Users/test/.orbstack/run/docker.sock"}}}"#, + ); + + let endpoint = resolve_docker_endpoint( + Some(temp.path()), + Some("unix:///tmp/docker.sock"), + Some("orbstack"), + ) + .unwrap(); + + assert_eq!(endpoint.source, DockerEndpointSource::EnvContext); + assert_eq!( + endpoint.host, + "unix:///Users/test/.orbstack/run/docker.sock" + ); + } + + #[test] + fn resolves_current_context_from_config_when_env_is_unset() { + let temp = TempDir::new().unwrap(); + write_json( + &temp.path().join("config.json"), + r#"{"currentContext":"orbstack"}"#, + ); + write_json( + &temp.path().join("contexts/meta/hash/meta.json"), + r#"{"Name":"orbstack","Endpoints":{"docker":{"Host":"unix:///Users/test/.orbstack/run/docker.sock"}}}"#, + ); + + let endpoint = resolve_docker_endpoint(Some(temp.path()), None, None).unwrap(); + + assert_eq!(endpoint.source, DockerEndpointSource::ConfigContext); + assert_eq!(endpoint.context.as_deref(), Some("orbstack")); + } + + #[test] + fn explicit_default_context_falls_back_to_default_socket() { + let endpoint = resolve_docker_endpoint( + None, + Some("unix:///tmp/docker.sock"), + Some("default"), + ) + .unwrap(); + + assert_eq!(endpoint.source, DockerEndpointSource::DefaultLocal); + assert_eq!(endpoint.host, DEFAULT_LOCAL_DOCKER_HOST); + } + + #[test] + fn missing_context_is_actionable() { + let temp = TempDir::new().unwrap(); + let error = + resolve_docker_endpoint(Some(temp.path()), None, Some("missing")).unwrap_err(); + + assert!(error.to_string().contains("Docker context 'missing'")); + } +} diff --git a/coast-docker/src/lib.rs b/coast-docker/src/lib.rs index 439f1a27..ab6de48c 100644 --- a/coast-docker/src/lib.rs +++ b/coast-docker/src/lib.rs @@ -7,6 +7,7 @@ pub mod compose; pub mod compose_build; pub mod container; pub mod dind; +pub mod host; pub mod image_cache; pub mod network; pub mod podman; diff --git a/coast-docker/src/network.rs b/coast-docker/src/network.rs index e7f6ec1a..e891c673 100644 --- a/coast-docker/src/network.rs +++ b/coast-docker/src/network.rs @@ -11,6 +11,8 @@ use tracing::{debug, info, warn}; use coast_core::error::{CoastError, Result}; +use crate::host::connect_to_host_docker; + /// Prefix for coast shared network names. pub const NETWORK_PREFIX: &str = "coast-shared-"; @@ -36,10 +38,7 @@ pub struct NetworkManager { impl NetworkManager { /// Create a new network manager connected to the default Docker socket. pub fn new() -> Result { - let docker = Docker::connect_with_local_defaults().map_err(|e| CoastError::Docker { - message: format!("Failed to connect to Docker daemon: {e}"), - source: Some(Box::new(e)), - })?; + let docker = connect_to_host_docker()?; Ok(Self { docker }) } diff --git a/coast-docker/src/podman.rs b/coast-docker/src/podman.rs index 62a45a8e..3d94211d 100644 --- a/coast-docker/src/podman.rs +++ b/coast-docker/src/podman.rs @@ -14,6 +14,7 @@ use tracing::{debug, info}; use coast_core::error::{CoastError, Result}; +use crate::host::connect_to_host_docker; use crate::runtime::{ContainerConfig, ExecResult, Runtime}; /// The default image used for Podman coast containers. @@ -41,14 +42,7 @@ pub struct PodmanRuntime { impl PodmanRuntime { /// Create a new Podman runtime connected to the default Docker socket. pub fn new() -> Result { - let docker = Docker::connect_with_local_defaults().map_err(|e| CoastError::Docker { - message: format!( - "Failed to connect to Docker daemon. Is Docker running? \ - The Podman runtime still requires Docker on the host to \ - manage coast containers. Error: {e}" - ), - source: Some(Box::new(e)), - })?; + let docker = connect_to_host_docker()?; Ok(Self { docker }) } diff --git a/coast-docker/src/sysbox.rs b/coast-docker/src/sysbox.rs index dfed7541..c8ccbc95 100644 --- a/coast-docker/src/sysbox.rs +++ b/coast-docker/src/sysbox.rs @@ -14,6 +14,7 @@ use tracing::{debug, info}; use coast_core::error::{CoastError, Result}; +use crate::host::connect_to_host_docker; use crate::runtime::{ContainerConfig, ExecResult, Runtime}; /// The default Docker image used for Sysbox coast containers. @@ -41,10 +42,7 @@ pub struct SysboxRuntime { impl SysboxRuntime { /// Create a new Sysbox runtime connected to the default Docker socket. pub fn new() -> Result { - let docker = Docker::connect_with_local_defaults().map_err(|e| CoastError::Docker { - message: format!("Failed to connect to Docker daemon. Is Docker running? Error: {e}"), - source: Some(Box::new(e)), - })?; + let docker = connect_to_host_docker()?; Ok(Self { docker }) } From eb7f0672ecde01c563fde80a4d63939826560a8c Mon Sep 17 00:00:00 2001 From: "Agusti F." <6601142+agustif@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:02:53 +0100 Subject: [PATCH 2/2] Expose resolved Docker endpoint and failure reason in diagnostics Record the resolved Docker endpoint summary and last connection error in daemon startup state, surface them through the docker info API, and include the resolved endpoint details in coast doctor failures. Keep transport behavior unchanged. Refs: #65 Co-authored-by: Codex --- coast-cli/src/commands/doctor.rs | 21 ++++- coast-core/src/protocol/api_types.rs | 4 + coast-core/src/protocol/tests.rs | 10 ++ coast-daemon/src/api/query/docker.rs | 27 ++++++ coast-daemon/src/api/tests.rs | 4 + coast-daemon/src/server.rs | 34 ++++++- coast-docker/src/host.rs | 133 ++++++++++++++++++--------- 7 files changed, 181 insertions(+), 52 deletions(-) diff --git a/coast-cli/src/commands/doctor.rs b/coast-cli/src/commands/doctor.rs index 26c99ddb..eee77ae3 100644 --- a/coast-cli/src/commands/doctor.rs +++ b/coast-cli/src/commands/doctor.rs @@ -99,8 +99,25 @@ pub async fn execute(args: &DoctorArgs) -> Result<()> { let db = rusqlite::Connection::open(&db_path).context("Failed to open state database")?; - let docker = coast_docker::host::connect_to_host_docker() - .context("Failed to connect to Docker. Is Docker running and is your active Docker context reachable?")?; + let probe = coast_docker::host::probe_host_docker(); + let docker = match probe.docker { + Ok(docker) => docker, + Err(error) => { + let mut detail = + "Failed to connect to Docker. Is Docker running and is your active Docker context reachable?".to_string(); + if let Some(endpoint) = probe.endpoint { + detail.push_str(&format!( + "\nResolved endpoint source: {}\nResolved endpoint host: {}", + coast_docker::host::docker_endpoint_source_label(&endpoint.source), + endpoint.host + )); + if let Some(context) = endpoint.context { + detail.push_str(&format!("\nResolved context: {context}")); + } + } + return Err(anyhow::anyhow!("{detail}\n{error}")); + } + }; let mut fixes: Vec = Vec::new(); let mut findings: Vec = Vec::new(); diff --git a/coast-core/src/protocol/api_types.rs b/coast-core/src/protocol/api_types.rs index 32a7f5b3..349d3524 100644 --- a/coast-core/src/protocol/api_types.rs +++ b/coast-core/src/protocol/api_types.rs @@ -338,6 +338,10 @@ pub struct DockerInfoResponse { pub server_version: String, pub can_adjust: bool, pub provider: String, + pub endpoint_source: Option, + pub endpoint_host: Option, + pub context_name: Option, + pub connect_error: Option, } /// Response after requesting Docker Desktop settings to be opened. diff --git a/coast-core/src/protocol/tests.rs b/coast-core/src/protocol/tests.rs index 782a4e59..235a7884 100644 --- a/coast-core/src/protocol/tests.rs +++ b/coast-core/src/protocol/tests.rs @@ -1026,6 +1026,10 @@ fn test_docker_info_response_serialization() { server_version: "28.3.3".to_string(), can_adjust: true, provider: "docker-desktop".to_string(), + endpoint_source: Some("config_context".to_string()), + endpoint_host: Some("unix:///Users/test/.orbstack/run/docker.sock".to_string()), + context_name: Some("orbstack".to_string()), + connect_error: None, }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["mem_total_bytes"], 8_589_934_592u64); @@ -1033,6 +1037,12 @@ fn test_docker_info_response_serialization() { assert_eq!(json["os"], "Docker Desktop"); assert_eq!(json["server_version"], "28.3.3"); assert_eq!(json["can_adjust"], true); + assert_eq!(json["endpoint_source"], "config_context"); + assert_eq!( + json["endpoint_host"], + "unix:///Users/test/.orbstack/run/docker.sock" + ); + assert_eq!(json["context_name"], "orbstack"); } #[test] diff --git a/coast-daemon/src/api/query/docker.rs b/coast-daemon/src/api/query/docker.rs index 8b45a228..6cce36eb 100644 --- a/coast-daemon/src/api/query/docker.rs +++ b/coast-daemon/src/api/query/docker.rs @@ -7,6 +7,7 @@ use axum::{Json, Router}; use tokio::sync::OnceCell; use coast_core::protocol::{DockerInfoResponse, OpenDockerSettingsResponse}; +use coast_docker::host::docker_endpoint_source_label; use crate::server::AppState; @@ -53,6 +54,19 @@ async fn docker_info(State(state): State>) -> Json>) -> Json, + /// Resolved Docker endpoint metadata, if endpoint resolution succeeded. + pub docker_endpoint: Option, + /// Last Docker connection error captured at daemon startup, if any. + pub docker_connect_error: Option, /// Broadcast channel for WebSocket event notifications. pub event_bus: tokio::sync::broadcast::Sender, /// Persistent PTY sessions for the host terminal feature. @@ -186,11 +191,23 @@ pub struct AppState { impl AppState { /// Create a new `AppState` with the given state database and Docker client. pub fn new(db: StateDb) -> Self { - let docker = match coast_docker::host::connect_to_host_docker() { - Ok(docker) => Some(docker), + let probe = coast_docker::host::probe_host_docker(); + let docker_endpoint = probe.endpoint.clone(); + let (docker, docker_connect_error) = match probe.docker { + Ok(docker) => (Some(docker), None), Err(error) => { - warn!(error = %error, "Docker is unavailable at daemon startup"); - None + if let Some(ref endpoint) = docker_endpoint { + warn!( + source = docker_endpoint_source_label(&endpoint.source), + host = %endpoint.host, + context = endpoint.context.as_deref().unwrap_or(""), + error = %error, + "Docker is unavailable at daemon startup" + ); + } else { + warn!(error = %error, "Docker is unavailable at daemon startup"); + } + (None, Some(error.to_string())) } }; let (event_bus, _) = tokio::sync::broadcast::channel(256); @@ -214,6 +231,8 @@ impl AppState { Self { db: Mutex::new(db), docker, + docker_endpoint, + docker_connect_error, event_bus, pty_sessions: Mutex::new(std::collections::HashMap::new()), exec_sessions: Mutex::new(std::collections::HashMap::new()), @@ -249,6 +268,8 @@ impl AppState { Self { db: Mutex::new(db), docker: None, + docker_endpoint: None, + docker_connect_error: None, event_bus, pty_sessions: Mutex::new(std::collections::HashMap::new()), exec_sessions: Mutex::new(std::collections::HashMap::new()), @@ -290,6 +311,11 @@ impl AppState { ) .expect("bollard stub client creation should not fail"), ); + s.docker_endpoint = Some(DockerEndpoint { + host: "http://127.0.0.1:0".to_string(), + source: coast_docker::host::DockerEndpointSource::EnvHost, + context: None, + }); s } diff --git a/coast-docker/src/host.rs b/coast-docker/src/host.rs index f16ac4ca..6d8b124d 100644 --- a/coast-docker/src/host.rs +++ b/coast-docker/src/host.rs @@ -30,6 +30,21 @@ pub struct DockerEndpoint { pub context: Option, } +#[derive(Debug)] +pub struct HostDockerProbe { + pub endpoint: Option, + pub docker: Result, +} + +pub fn docker_endpoint_source_label(source: &DockerEndpointSource) -> &'static str { + match source { + DockerEndpointSource::EnvHost => "env_host", + DockerEndpointSource::EnvContext => "env_context", + DockerEndpointSource::ConfigContext => "config_context", + DockerEndpointSource::DefaultLocal => "default_local", + } +} + #[derive(Debug, Deserialize)] struct DockerCliConfig { #[serde(rename = "currentContext")] @@ -51,25 +66,37 @@ struct ContextEndpoint { } pub fn connect_to_host_docker() -> Result { - connect_to_host_docker_with( - env::var_os("DOCKER_CONFIG").map(PathBuf::from), - env::var("DOCKER_HOST").ok(), - env::var("DOCKER_CONTEXT").ok(), - ) + probe_host_docker().docker } -fn connect_to_host_docker_with( - docker_config_dir: Option, - env_host: Option, - env_context: Option, -) -> Result { - let endpoint = resolve_docker_endpoint( +pub fn probe_host_docker() -> HostDockerProbe { + let docker_config_dir = env::var_os("DOCKER_CONFIG").map(PathBuf::from); + let env_host = env::var("DOCKER_HOST").ok(); + let env_context = env::var("DOCKER_CONTEXT").ok(); + + probe_host_docker_with( docker_config_dir.as_deref(), env_host.as_deref(), env_context.as_deref(), - )?; + ) +} + +fn probe_host_docker_with( + docker_config_dir: Option<&Path>, + env_host: Option<&str>, + env_context: Option<&str>, +) -> HostDockerProbe { + let endpoint = match resolve_docker_endpoint(docker_config_dir, env_host, env_context) { + Ok(endpoint) => endpoint, + Err(error) => { + return HostDockerProbe { + endpoint: None, + docker: Err(error), + }; + } + }; - match endpoint.source { + let docker = match endpoint.source { DockerEndpointSource::EnvHost => Docker::connect_with_defaults().map_err(|e| { CoastError::docker(format!( "Failed to connect to Docker using DOCKER_HOST='{}'. Error: {e}", @@ -77,6 +104,11 @@ fn connect_to_host_docker_with( )) }), _ => connect_to_endpoint(&endpoint), + }; + + HostDockerProbe { + endpoint: Some(endpoint), + docker, } } @@ -152,13 +184,14 @@ fn connect_to_endpoint(endpoint: &DockerEndpoint) -> Result { } if host.starts_with("tcp://") || host.starts_with("http://") { - return Docker::connect_with_http(host, DEFAULT_TIMEOUT_SECS, API_DEFAULT_VERSION) - .map_err(|e| { + return Docker::connect_with_http(host, DEFAULT_TIMEOUT_SECS, API_DEFAULT_VERSION).map_err( + |e| { CoastError::docker(format!( "Failed to connect to {context_msg} at '{}'. Error: {e}", endpoint.host )) - }); + }, + ); } Err(CoastError::docker(format!( @@ -197,13 +230,14 @@ fn current_context_from_config(config_dir: &Path) -> Result> { source: Some(Box::new(e)), })?; - let config: DockerCliConfig = serde_json::from_str(&contents).map_err(|e| CoastError::Docker { - message: format!( - "Failed to parse Docker config '{}'. Error: {e}", - config_path.display() - ), - source: Some(Box::new(e)), - })?; + let config: DockerCliConfig = + serde_json::from_str(&contents).map_err(|e| CoastError::Docker { + message: format!( + "Failed to parse Docker config '{}'. Error: {e}", + config_path.display() + ), + source: Some(Box::new(e)), + })?; Ok(normalize_context_name(config.current_context.as_deref())) } @@ -250,13 +284,14 @@ fn resolve_context_host(config_dir: Option<&Path>, context_name: &str) -> Result ), source: Some(Box::new(e)), })?; - let meta: ContextMeta = serde_json::from_str(&contents).map_err(|e| CoastError::Docker { - message: format!( - "Failed to parse Docker context metadata '{}'. Error: {e}", - meta_path.display() - ), - source: Some(Box::new(e)), - })?; + let meta: ContextMeta = + serde_json::from_str(&contents).map_err(|e| CoastError::Docker { + message: format!( + "Failed to parse Docker context metadata '{}'. Error: {e}", + meta_path.display() + ), + source: Some(Box::new(e)), + })?; if meta.name != context_name { continue; @@ -303,12 +338,9 @@ mod tests { r#"{"currentContext":"orbstack"}"#, ); - let endpoint = resolve_docker_endpoint( - Some(temp.path()), - Some("unix:///tmp/docker.sock"), - None, - ) - .unwrap(); + let endpoint = + resolve_docker_endpoint(Some(temp.path()), Some("unix:///tmp/docker.sock"), None) + .unwrap(); assert_eq!(endpoint.source, DockerEndpointSource::EnvHost); assert_eq!(endpoint.host, "unix:///tmp/docker.sock"); @@ -322,8 +354,7 @@ mod tests { r#"{"Name":"orbstack","Endpoints":{"docker":{"Host":"unix:///Users/test/.orbstack/run/docker.sock"}}}"#, ); - let endpoint = - resolve_docker_endpoint(Some(temp.path()), None, Some("orbstack")).unwrap(); + let endpoint = resolve_docker_endpoint(Some(temp.path()), None, Some("orbstack")).unwrap(); assert_eq!(endpoint.source, DockerEndpointSource::EnvContext); assert_eq!( @@ -375,12 +406,9 @@ mod tests { #[test] fn explicit_default_context_falls_back_to_default_socket() { - let endpoint = resolve_docker_endpoint( - None, - Some("unix:///tmp/docker.sock"), - Some("default"), - ) - .unwrap(); + let endpoint = + resolve_docker_endpoint(None, Some("unix:///tmp/docker.sock"), Some("default")) + .unwrap(); assert_eq!(endpoint.source, DockerEndpointSource::DefaultLocal); assert_eq!(endpoint.host, DEFAULT_LOCAL_DOCKER_HOST); @@ -389,9 +417,22 @@ mod tests { #[test] fn missing_context_is_actionable() { let temp = TempDir::new().unwrap(); - let error = - resolve_docker_endpoint(Some(temp.path()), None, Some("missing")).unwrap_err(); + let error = resolve_docker_endpoint(Some(temp.path()), None, Some("missing")).unwrap_err(); assert!(error.to_string().contains("Docker context 'missing'")); } + + #[test] + fn probe_captures_endpoint_on_connection_failure() { + let temp = TempDir::new().unwrap(); + write_json( + &temp.path().join("contexts/meta/hash/meta.json"), + r#"{"Name":"orbstack","Endpoints":{"docker":{"Host":"unix:///tmp/does-not-exist.sock"}}}"#, + ); + + let probe = probe_host_docker_with(Some(temp.path()), None, Some("orbstack")); + + assert!(probe.endpoint.is_some()); + assert!(probe.docker.is_err()); + } }