Skip to content

Commit 74f005a

Browse files
agustifcodex
andcommitted
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 <noreply@openai.com>
1 parent 37a12cd commit 74f005a

File tree

7 files changed

+150
-12
lines changed

7 files changed

+150
-12
lines changed

coast-cli/src/commands/doctor.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,25 @@ pub async fn execute(args: &DoctorArgs) -> Result<()> {
9999

100100
let db = rusqlite::Connection::open(&db_path).context("Failed to open state database")?;
101101

102-
let docker = coast_docker::host::connect_to_host_docker()
103-
.context("Failed to connect to Docker. Is Docker running and is your active Docker context reachable?")?;
102+
let probe = coast_docker::host::probe_host_docker();
103+
let docker = match probe.docker {
104+
Ok(docker) => docker,
105+
Err(error) => {
106+
let mut detail =
107+
"Failed to connect to Docker. Is Docker running and is your active Docker context reachable?".to_string();
108+
if let Some(endpoint) = probe.endpoint {
109+
detail.push_str(&format!(
110+
"\nResolved endpoint source: {}\nResolved endpoint host: {}",
111+
coast_docker::host::docker_endpoint_source_label(&endpoint.source),
112+
endpoint.host
113+
));
114+
if let Some(context) = endpoint.context {
115+
detail.push_str(&format!("\nResolved context: {context}"));
116+
}
117+
}
118+
return Err(anyhow::anyhow!("{detail}\n{error}"));
119+
}
120+
};
104121

105122
let mut fixes: Vec<String> = Vec::new();
106123
let mut findings: Vec<String> = Vec::new();

coast-core/src/protocol/api_types.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,10 @@ pub struct DockerInfoResponse {
338338
pub server_version: String,
339339
pub can_adjust: bool,
340340
pub provider: String,
341+
pub endpoint_source: Option<String>,
342+
pub endpoint_host: Option<String>,
343+
pub context_name: Option<String>,
344+
pub connect_error: Option<String>,
341345
}
342346

343347
/// Response after requesting Docker Desktop settings to be opened.

coast-core/src/protocol/tests.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,13 +1026,23 @@ fn test_docker_info_response_serialization() {
10261026
server_version: "28.3.3".to_string(),
10271027
can_adjust: true,
10281028
provider: "docker-desktop".to_string(),
1029+
endpoint_source: Some("config_context".to_string()),
1030+
endpoint_host: Some("unix:///Users/test/.orbstack/run/docker.sock".to_string()),
1031+
context_name: Some("orbstack".to_string()),
1032+
connect_error: None,
10291033
};
10301034
let json = serde_json::to_value(&resp).unwrap();
10311035
assert_eq!(json["mem_total_bytes"], 8_589_934_592u64);
10321036
assert_eq!(json["cpus"], 4);
10331037
assert_eq!(json["os"], "Docker Desktop");
10341038
assert_eq!(json["server_version"], "28.3.3");
10351039
assert_eq!(json["can_adjust"], true);
1040+
assert_eq!(json["endpoint_source"], "config_context");
1041+
assert_eq!(
1042+
json["endpoint_host"],
1043+
"unix:///Users/test/.orbstack/run/docker.sock"
1044+
);
1045+
assert_eq!(json["context_name"], "orbstack");
10361046
}
10371047

10381048
#[test]

coast-daemon/src/api/query/docker.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use axum::{Json, Router};
77
use tokio::sync::OnceCell;
88

99
use coast_core::protocol::{DockerInfoResponse, OpenDockerSettingsResponse};
10+
use coast_docker::host::docker_endpoint_source_label;
1011

1112
use crate::server::AppState;
1213

@@ -53,6 +54,19 @@ async fn docker_info(State(state): State<Arc<AppState>>) -> Json<DockerInfoRespo
5354
server_version: String::new(),
5455
can_adjust: false,
5556
provider: String::new(),
57+
endpoint_source: state
58+
.docker_endpoint
59+
.as_ref()
60+
.map(|endpoint| docker_endpoint_source_label(&endpoint.source).to_string()),
61+
endpoint_host: state
62+
.docker_endpoint
63+
.as_ref()
64+
.map(|endpoint| endpoint.host.clone()),
65+
context_name: state
66+
.docker_endpoint
67+
.as_ref()
68+
.and_then(|endpoint| endpoint.context.clone()),
69+
connect_error: state.docker_connect_error.clone(),
5670
});
5771

5872
let Some(docker) = state.docker.as_ref() else {
@@ -81,6 +95,19 @@ async fn docker_info(State(state): State<Arc<AppState>>) -> Json<DockerInfoRespo
8195
server_version,
8296
can_adjust,
8397
provider,
98+
endpoint_source: state
99+
.docker_endpoint
100+
.as_ref()
101+
.map(|endpoint| docker_endpoint_source_label(&endpoint.source).to_string()),
102+
endpoint_host: state
103+
.docker_endpoint
104+
.as_ref()
105+
.map(|endpoint| endpoint.host.clone()),
106+
context_name: state
107+
.docker_endpoint
108+
.as_ref()
109+
.and_then(|endpoint| endpoint.context.clone()),
110+
connect_error: state.docker_connect_error.clone(),
84111
})
85112
}
86113

coast-daemon/src/api/tests.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1391,6 +1391,10 @@ mod tests {
13911391
.unwrap();
13921392
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
13931393
assert_eq!(json["connected"], false);
1394+
assert!(json.get("endpoint_source").is_some());
1395+
assert!(json.get("endpoint_host").is_some());
1396+
assert!(json.get("context_name").is_some());
1397+
assert!(json.get("connect_error").is_some());
13941398
}
13951399

13961400
#[tokio::test]

coast-daemon/src/server.rs

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use crate::analytics::{self, AnalyticsClient, CommandSource};
2121
use crate::api::streaming::spawn_agent_shell_if_configured;
2222
use crate::handlers;
2323
use crate::state::StateDb;
24+
use coast_docker::host::{docker_endpoint_source_label, DockerEndpoint};
2425

2526
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2627
pub enum UpdateOperationKind {
@@ -120,6 +121,10 @@ pub struct AppState {
120121
/// Bollard Docker client connected to the host daemon.
121122
/// None in test environments where Docker is not available.
122123
pub docker: Option<bollard::Docker>,
124+
/// Resolved Docker endpoint metadata, if endpoint resolution succeeded.
125+
pub docker_endpoint: Option<DockerEndpoint>,
126+
/// Last Docker connection error captured at daemon startup, if any.
127+
pub docker_connect_error: Option<String>,
123128
/// Broadcast channel for WebSocket event notifications.
124129
pub event_bus: tokio::sync::broadcast::Sender<CoastEvent>,
125130
/// Persistent PTY sessions for the host terminal feature.
@@ -186,11 +191,23 @@ pub struct AppState {
186191
impl AppState {
187192
/// Create a new `AppState` with the given state database and Docker client.
188193
pub fn new(db: StateDb) -> Self {
189-
let docker = match coast_docker::host::connect_to_host_docker() {
190-
Ok(docker) => Some(docker),
194+
let probe = coast_docker::host::probe_host_docker();
195+
let docker_endpoint = probe.endpoint.clone();
196+
let (docker, docker_connect_error) = match probe.docker {
197+
Ok(docker) => (Some(docker), None),
191198
Err(error) => {
192-
warn!(error = %error, "Docker is unavailable at daemon startup");
193-
None
199+
if let Some(ref endpoint) = docker_endpoint {
200+
warn!(
201+
source = docker_endpoint_source_label(&endpoint.source),
202+
host = %endpoint.host,
203+
context = endpoint.context.as_deref().unwrap_or(""),
204+
error = %error,
205+
"Docker is unavailable at daemon startup"
206+
);
207+
} else {
208+
warn!(error = %error, "Docker is unavailable at daemon startup");
209+
}
210+
(None, Some(error.to_string()))
194211
}
195212
};
196213
let (event_bus, _) = tokio::sync::broadcast::channel(256);
@@ -214,6 +231,8 @@ impl AppState {
214231
Self {
215232
db: Mutex::new(db),
216233
docker,
234+
docker_endpoint,
235+
docker_connect_error,
217236
event_bus,
218237
pty_sessions: Mutex::new(std::collections::HashMap::new()),
219238
exec_sessions: Mutex::new(std::collections::HashMap::new()),
@@ -249,6 +268,8 @@ impl AppState {
249268
Self {
250269
db: Mutex::new(db),
251270
docker: None,
271+
docker_endpoint: None,
272+
docker_connect_error: None,
252273
event_bus,
253274
pty_sessions: Mutex::new(std::collections::HashMap::new()),
254275
exec_sessions: Mutex::new(std::collections::HashMap::new()),
@@ -290,6 +311,11 @@ impl AppState {
290311
)
291312
.expect("bollard stub client creation should not fail"),
292313
);
314+
s.docker_endpoint = Some(DockerEndpoint {
315+
host: "http://127.0.0.1:0".to_string(),
316+
source: coast_docker::host::DockerEndpointSource::EnvHost,
317+
context: None,
318+
});
293319
s
294320
}
295321

coast-docker/src/host.rs

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@ pub struct DockerEndpoint {
3030
pub context: Option<String>,
3131
}
3232

33+
#[derive(Debug)]
34+
pub struct HostDockerProbe {
35+
pub endpoint: Option<DockerEndpoint>,
36+
pub docker: Result<Docker>,
37+
}
38+
39+
pub fn docker_endpoint_source_label(source: &DockerEndpointSource) -> &'static str {
40+
match source {
41+
DockerEndpointSource::EnvHost => "env_host",
42+
DockerEndpointSource::EnvContext => "env_context",
43+
DockerEndpointSource::ConfigContext => "config_context",
44+
DockerEndpointSource::DefaultLocal => "default_local",
45+
}
46+
}
47+
3348
#[derive(Debug, Deserialize)]
3449
struct DockerCliConfig {
3550
#[serde(rename = "currentContext")]
@@ -51,32 +66,49 @@ struct ContextEndpoint {
5166
}
5267

5368
pub fn connect_to_host_docker() -> Result<Docker> {
54-
connect_to_host_docker_with(
69+
probe_host_docker().docker
70+
}
71+
72+
pub fn probe_host_docker() -> HostDockerProbe {
73+
probe_host_docker_with(
5574
env::var_os("DOCKER_CONFIG").map(PathBuf::from),
5675
env::var("DOCKER_HOST").ok(),
5776
env::var("DOCKER_CONTEXT").ok(),
5877
)
5978
}
6079

61-
fn connect_to_host_docker_with(
80+
fn probe_host_docker_with(
6281
docker_config_dir: Option<PathBuf>,
6382
env_host: Option<String>,
6483
env_context: Option<String>,
65-
) -> Result<Docker> {
66-
let endpoint = resolve_docker_endpoint(
84+
) -> HostDockerProbe {
85+
let endpoint = match resolve_docker_endpoint(
6786
docker_config_dir.as_deref(),
6887
env_host.as_deref(),
6988
env_context.as_deref(),
70-
)?;
89+
) {
90+
Ok(endpoint) => endpoint,
91+
Err(error) => {
92+
return HostDockerProbe {
93+
endpoint: None,
94+
docker: Err(error),
95+
};
96+
}
97+
};
7198

72-
match endpoint.source {
99+
let docker = match endpoint.source {
73100
DockerEndpointSource::EnvHost => Docker::connect_with_defaults().map_err(|e| {
74101
CoastError::docker(format!(
75102
"Failed to connect to Docker using DOCKER_HOST='{}'. Error: {e}",
76103
endpoint.host
77104
))
78105
}),
79106
_ => connect_to_endpoint(&endpoint),
107+
};
108+
109+
HostDockerProbe {
110+
endpoint: Some(endpoint),
111+
docker,
80112
}
81113
}
82114

@@ -394,4 +426,22 @@ mod tests {
394426

395427
assert!(error.to_string().contains("Docker context 'missing'"));
396428
}
429+
430+
#[test]
431+
fn probe_captures_endpoint_on_connection_failure() {
432+
let temp = TempDir::new().unwrap();
433+
write_json(
434+
&temp.path().join("contexts/meta/hash/meta.json"),
435+
r#"{"Name":"orbstack","Endpoints":{"docker":{"Host":"unix:///tmp/does-not-exist.sock"}}}"#,
436+
);
437+
438+
let probe = probe_host_docker_with(
439+
Some(temp.path().to_path_buf()),
440+
None,
441+
Some("orbstack".to_string()),
442+
);
443+
444+
assert!(probe.endpoint.is_some());
445+
assert!(probe.docker.is_err());
446+
}
397447
}

0 commit comments

Comments
 (0)