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..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 = bollard::Docker::connect_with_local_defaults() - .context("Failed to connect to Docker. Is Docker running?")?; + 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-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-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,7 +191,25 @@ 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 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) => { + 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); let initial_lang = db.get_language().unwrap_or_else(|_| "en".to_string()); let (language_tx, language_rx) = tokio::sync::watch::channel(initial_lang); @@ -208,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()), @@ -243,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()), @@ -284,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/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..6d8b124d --- /dev/null +++ b/coast-docker/src/host.rs @@ -0,0 +1,438 @@ +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)] +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")] + 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 { + probe_host_docker().docker +} + +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), + }; + } + }; + + 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, + } +} + +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'")); + } + + #[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()); + } +} 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 }) }