diff --git a/coast-cli/src/commands/doctor.rs b/coast-cli/src/commands/doctor.rs index 26c99dd..eee77ae 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 32a7f5b..349d352 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 782a4e5..235a788 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 8b45a22..6cce36e 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 f16ac4c..c03bf30 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( + probe_host_docker().docker +} + +pub fn probe_host_docker() -> HostDockerProbe { + probe_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( +fn probe_host_docker_with( docker_config_dir: Option, env_host: Option, env_context: Option, -) -> Result { - let endpoint = resolve_docker_endpoint( +) -> HostDockerProbe { + let endpoint = match resolve_docker_endpoint( docker_config_dir.as_deref(), env_host.as_deref(), env_context.as_deref(), - )?; + ) { + 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, } } @@ -394,4 +426,22 @@ mod tests { 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().to_path_buf()), + None, + Some("orbstack".to_string()), + ); + + assert!(probe.endpoint.is_some()); + assert!(probe.docker.is_err()); + } }