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
21 changes: 19 additions & 2 deletions coast-cli/src/commands/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = Vec::new();
let mut findings: Vec<String> = Vec::new();
Expand Down
4 changes: 4 additions & 0 deletions coast-core/src/protocol/api_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,10 @@ pub struct DockerInfoResponse {
pub server_version: String,
pub can_adjust: bool,
pub provider: String,
pub endpoint_source: Option<String>,
pub endpoint_host: Option<String>,
pub context_name: Option<String>,
pub connect_error: Option<String>,
}

/// Response after requesting Docker Desktop settings to be opened.
Expand Down
10 changes: 10 additions & 0 deletions coast-core/src/protocol/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1026,13 +1026,23 @@ 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);
assert_eq!(json["cpus"], 4);
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]
Expand Down
27 changes: 27 additions & 0 deletions coast-daemon/src/api/query/docker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -53,6 +54,19 @@ async fn docker_info(State(state): State<Arc<AppState>>) -> Json<DockerInfoRespo
server_version: String::new(),
can_adjust: false,
provider: String::new(),
endpoint_source: state
.docker_endpoint
.as_ref()
.map(|endpoint| docker_endpoint_source_label(&endpoint.source).to_string()),
endpoint_host: state
.docker_endpoint
.as_ref()
.map(|endpoint| endpoint.host.clone()),
context_name: state
.docker_endpoint
.as_ref()
.and_then(|endpoint| endpoint.context.clone()),
connect_error: state.docker_connect_error.clone(),
});

let Some(docker) = state.docker.as_ref() else {
Expand Down Expand Up @@ -81,6 +95,19 @@ async fn docker_info(State(state): State<Arc<AppState>>) -> Json<DockerInfoRespo
server_version,
can_adjust,
provider,
endpoint_source: state
.docker_endpoint
.as_ref()
.map(|endpoint| docker_endpoint_source_label(&endpoint.source).to_string()),
endpoint_host: state
.docker_endpoint
.as_ref()
.map(|endpoint| endpoint.host.clone()),
context_name: state
.docker_endpoint
.as_ref()
.and_then(|endpoint| endpoint.context.clone()),
connect_error: state.docker_connect_error.clone(),
})
}

Expand Down
4 changes: 4 additions & 0 deletions coast-daemon/src/api/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,10 @@ mod tests {
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["connected"], false);
assert!(json.get("endpoint_source").is_some());
assert!(json.get("endpoint_host").is_some());
assert!(json.get("context_name").is_some());
assert!(json.get("connect_error").is_some());
}

#[tokio::test]
Expand Down
34 changes: 30 additions & 4 deletions coast-daemon/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use crate::analytics::{self, AnalyticsClient, CommandSource};
use crate::api::streaming::spawn_agent_shell_if_configured;
use crate::handlers;
use crate::state::StateDb;
use coast_docker::host::{docker_endpoint_source_label, DockerEndpoint};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateOperationKind {
Expand Down Expand Up @@ -120,6 +121,10 @@ pub struct AppState {
/// Bollard Docker client connected to the host daemon.
/// None in test environments where Docker is not available.
pub docker: Option<bollard::Docker>,
/// Resolved Docker endpoint metadata, if endpoint resolution succeeded.
pub docker_endpoint: Option<DockerEndpoint>,
/// Last Docker connection error captured at daemon startup, if any.
pub docker_connect_error: Option<String>,
/// Broadcast channel for WebSocket event notifications.
pub event_bus: tokio::sync::broadcast::Sender<CoastEvent>,
/// Persistent PTY sessions for the host terminal feature.
Expand Down Expand Up @@ -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);
Expand All @@ -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()),
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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
}

Expand Down
62 changes: 56 additions & 6 deletions coast-docker/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ pub struct DockerEndpoint {
pub context: Option<String>,
}

#[derive(Debug)]
pub struct HostDockerProbe {
pub endpoint: Option<DockerEndpoint>,
pub docker: Result<Docker>,
}

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")]
Expand All @@ -51,32 +66,49 @@ struct ContextEndpoint {
}

pub fn connect_to_host_docker() -> Result<Docker> {
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<PathBuf>,
env_host: Option<String>,
env_context: Option<String>,
) -> Result<Docker> {
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}",
endpoint.host
))
}),
_ => connect_to_endpoint(&endpoint),
};

HostDockerProbe {
endpoint: Some(endpoint),
docker,
}
}

Expand Down Expand Up @@ -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());
}
}