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 7a8ec14..1a31b3a 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 7410570..2912a5f 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 e90e26b..26c99dd 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 5a0d27b..3a042e3 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 07e2fef..be0c882 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 901e843..20c69df 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 0000000..f16ac4c --- /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 439f1a2..ab6de48 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 e7f6ec1..e891c67 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 62a45a8..3d94211 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 dfed754..c8ccbc9 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 01cc46f6abb861af0e273b022185b609000e402e Mon Sep 17 00:00:00 2001 From: "Agusti F." <6601142+agustif@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:44:02 +0100 Subject: [PATCH 2/2] Add TLS-aware Docker context resolution in coast-docker Extend `coast-docker::host` so context-driven Docker endpoints can resolve TLS transport from Docker context storage. Preserve explicit env-driven DOCKER_HOST / DOCKER_TLS_VERIFY / DOCKER_CERT_PATH behavior by continuing to defer that path to Bollard's env-aware defaults. Reject ssh:// contexts explicitly in this slice. Refs: #63 Co-authored-by: Codex --- Cargo.lock | 53 ++++++- Cargo.toml | 2 +- coast-docker/src/host.rs | 291 +++++++++++++++++++++++++++++++-------- 3 files changed, 290 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a31b3a..1ab8d9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -258,14 +258,20 @@ dependencies = [ "futures-core", "futures-util", "hex", + "home", "http", "http-body-util", "hyper", "hyper-named-pipe", + "hyper-rustls", "hyper-util", "hyperlocal", "log", "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", "serde", "serde_derive", "serde_json", @@ -931,7 +937,7 @@ dependencies = [ "libc", "libgit2-sys", "log", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "url", ] @@ -1042,6 +1048,15 @@ dependencies = [ "url", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.0" @@ -1663,6 +1678,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openssl-sys" version = "0.9.111" @@ -2176,6 +2197,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -2218,6 +2260,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index cf67ffc..6742f13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ toml = "0.8" clap = { version = "4", features = ["derive", "env"] } # Docker -bollard = "0.18" +bollard = { version = "0.18", features = ["ssl"] } # Database rusqlite = { version = "0.32", features = ["bundled"] } diff --git a/coast-docker/src/host.rs b/coast-docker/src/host.rs index f16ac4c..cd41696 100644 --- a/coast-docker/src/host.rs +++ b/coast-docker/src/host.rs @@ -28,6 +28,14 @@ pub struct DockerEndpoint { pub host: String, pub source: DockerEndpointSource, pub context: Option, + pub tls: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DockerTlsMaterial { + pub ca_path: PathBuf, + pub cert_path: PathBuf, + pub key_path: PathBuf, } #[derive(Debug, Deserialize)] @@ -48,26 +56,28 @@ struct ContextMeta { struct ContextEndpoint { #[serde(rename = "Host")] host: Option, + #[serde(rename = "SkipTLSVerify")] + skip_tls_verify: Option, } pub fn connect_to_host_docker() -> Result { + 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(); + connect_to_host_docker_with( - env::var_os("DOCKER_CONFIG").map(PathBuf::from), - env::var("DOCKER_HOST").ok(), - env::var("DOCKER_CONTEXT").ok(), + docker_config_dir.as_deref(), + env_host.as_deref(), + env_context.as_deref(), ) } fn connect_to_host_docker_with( - docker_config_dir: Option, - env_host: Option, - env_context: Option, + docker_config_dir: Option<&Path>, + env_host: Option<&str>, + env_context: Option<&str>, ) -> Result { - let endpoint = resolve_docker_endpoint( - docker_config_dir.as_deref(), - env_host.as_deref(), - env_context.as_deref(), - )?; + let endpoint = resolve_docker_endpoint(docker_config_dir, env_host, env_context)?; match endpoint.source { DockerEndpointSource::EnvHost => Docker::connect_with_defaults().map_err(|e| { @@ -95,14 +105,16 @@ pub fn resolve_docker_endpoint( host: DEFAULT_LOCAL_DOCKER_HOST.to_string(), source: DockerEndpointSource::DefaultLocal, context: None, + tls: None, }); } - let host = resolve_context_host(config_dir.as_deref(), raw_context)?; + let resolved = resolve_context_endpoint(config_dir.as_deref(), raw_context)?; return Ok(DockerEndpoint { - host, + host: resolved.host, source: DockerEndpointSource::EnvContext, context: Some(raw_context.to_string()), + tls: resolved.tls, }); } @@ -111,16 +123,18 @@ pub fn resolve_docker_endpoint( host: host.to_string(), source: DockerEndpointSource::EnvHost, context: None, + tls: 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)?; + let resolved = resolve_context_endpoint(Some(config_dir), &context)?; return Ok(DockerEndpoint { - host, + host: resolved.host, source: DockerEndpointSource::ConfigContext, context: Some(context), + tls: resolved.tls, }); } } @@ -129,6 +143,7 @@ pub fn resolve_docker_endpoint( host: DEFAULT_LOCAL_DOCKER_HOST.to_string(), source: DockerEndpointSource::DefaultLocal, context: None, + tls: None, }) } @@ -151,14 +166,52 @@ fn connect_to_endpoint(endpoint: &DockerEndpoint) -> Result { }); } + if host.starts_with("ssh://") { + return Err(CoastError::docker(format!( + "Unsupported Docker endpoint '{}' from {context_msg}. \ + SSH Docker contexts are out of scope for this resolver; set DOCKER_HOST explicitly to a supported transport.", + endpoint.host + ))); + } + + if let Some(ref tls) = endpoint.tls { + return Docker::connect_with_ssl( + host, + &tls.key_path, + &tls.cert_path, + &tls.ca_path, + DEFAULT_TIMEOUT_SECS, + API_DEFAULT_VERSION, + ) + .map_err(|e| { + CoastError::docker(format!( + "Failed to connect to {context_msg} at '{}' using TLS material from '{}'. Error: {e}", + endpoint.host, + tls.ca_path + .parent() + .map(Path::display) + .map(|path| path.to_string()) + .unwrap_or_else(|| "".to_string()) + )) + }); + } + + if host.starts_with("https://") { + return Err(CoastError::docker(format!( + "Docker endpoint '{}' from {context_msg} requires TLS material, but none was found in the Docker context storage.", + 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| { + 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,18 +250,27 @@ 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())) } -fn resolve_context_host(config_dir: Option<&Path>, context_name: &str) -> Result { +struct ResolvedContextEndpoint { + host: String, + tls: Option, +} + +fn resolve_context_endpoint( + 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." @@ -250,22 +312,29 @@ 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; } - let host = meta - .endpoints - .get("docker") - .and_then(|endpoint| endpoint.host.as_deref()) + let endpoint = meta.endpoints.get("docker").ok_or_else(|| { + CoastError::docker(format!( + "Docker context '{context_name}' has no docker endpoint metadata in '{}'.", + meta_path.display() + )) + })?; + + let host = endpoint + .host + .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { @@ -275,7 +344,12 @@ fn resolve_context_host(config_dir: Option<&Path>, context_name: &str) -> Result )) })?; - return Ok(host.to_string()); + let tls = resolve_context_tls_material(config_dir, &meta_path, host, endpoint)?; + + return Ok(ResolvedContextEndpoint { + host: host.to_string(), + tls, + }); } Err(CoastError::docker(format!( @@ -284,6 +358,63 @@ fn resolve_context_host(config_dir: Option<&Path>, context_name: &str) -> Result ))) } +fn resolve_context_tls_material( + config_dir: &Path, + meta_path: &Path, + host: &str, + endpoint: &ContextEndpoint, +) -> Result> { + if host.starts_with("unix://") || host.starts_with("npipe://") || host.starts_with("ssh://") { + return Ok(None); + } + + let Some(hash_dir) = meta_path.parent().and_then(Path::file_name) else { + return Err(CoastError::docker(format!( + "Could not resolve the Docker context storage directory for '{}'.", + meta_path.display() + ))); + }; + + let tls_root = config_dir.join("contexts").join("tls").join(hash_dir); + let search_roots = [tls_root.join("docker"), tls_root.clone()]; + let pem_names = ["ca.pem", "cert.pem", "key.pem"]; + + for root in &search_roots { + let found: Vec = pem_names.iter().map(|name| root.join(name)).collect(); + let existing_count = found.iter().filter(|path| path.exists()).count(); + + if existing_count == 0 { + continue; + } + + if existing_count != pem_names.len() { + return Err(CoastError::docker(format!( + "Docker context '{}' has partial TLS material in '{}'. Expected ca.pem, cert.pem, and key.pem.", + host, + root.display() + ))); + } + + return Ok(Some(DockerTlsMaterial { + ca_path: found[0].clone(), + cert_path: found[1].clone(), + key_path: found[2].clone(), + })); + } + + if host.starts_with("https://") + || (host.starts_with("tcp://") && endpoint.skip_tls_verify.unwrap_or(false)) + { + return Err(CoastError::docker(format!( + "Docker context '{}' requires TLS, but no TLS material was found under '{}'.", + host, + tls_root.display() + ))); + } + + Ok(None) +} + #[cfg(test)] mod tests { use super::*; @@ -303,12 +434,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 +450,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!( @@ -331,6 +458,7 @@ mod tests { "unix:///Users/test/.orbstack/run/docker.sock" ); assert_eq!(endpoint.context.as_deref(), Some("orbstack")); + assert_eq!(endpoint.tls, None); } #[test] @@ -371,27 +499,82 @@ mod tests { assert_eq!(endpoint.source, DockerEndpointSource::ConfigContext); assert_eq!(endpoint.context.as_deref(), Some("orbstack")); + assert_eq!(endpoint.tls, None); } #[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); + assert_eq!(endpoint.tls, None); } #[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 resolves_tcp_context_without_tls_material_as_plain_host() { + let temp = TempDir::new().unwrap(); + write_json( + &temp.path().join("contexts/meta/hash/meta.json"), + r#"{"Name":"remote","Endpoints":{"docker":{"Host":"tcp://docker.example:2375","SkipTLSVerify":false}}}"#, + ); + + let endpoint = resolve_docker_endpoint(Some(temp.path()), None, Some("remote")).unwrap(); + + assert_eq!(endpoint.host, "tcp://docker.example:2375"); + assert_eq!(endpoint.tls, None); + } + + #[test] + fn resolves_https_context_with_tls_material() { + let temp = TempDir::new().unwrap(); + write_json( + &temp.path().join("contexts/meta/hash/meta.json"), + r#"{"Name":"secure","Endpoints":{"docker":{"Host":"https://docker.example:2376","SkipTLSVerify":false}}}"#, + ); + write_json(&temp.path().join("contexts/tls/hash/docker/ca.pem"), "ca"); + write_json( + &temp.path().join("contexts/tls/hash/docker/cert.pem"), + "cert", + ); + write_json(&temp.path().join("contexts/tls/hash/docker/key.pem"), "key"); + + let endpoint = resolve_docker_endpoint(Some(temp.path()), None, Some("secure")).unwrap(); + + assert_eq!(endpoint.host, "https://docker.example:2376"); + assert_eq!( + endpoint.tls, + Some(DockerTlsMaterial { + ca_path: temp.path().join("contexts/tls/hash/docker/ca.pem"), + cert_path: temp.path().join("contexts/tls/hash/docker/cert.pem"), + key_path: temp.path().join("contexts/tls/hash/docker/key.pem"), + }) + ); + } + + #[test] + fn ssh_context_is_rejected_explicitly() { + let temp = TempDir::new().unwrap(); + write_json( + &temp.path().join("contexts/meta/hash/meta.json"), + r#"{"Name":"ssh-ctx","Endpoints":{"docker":{"Host":"ssh://docker.example","SkipTLSVerify":false}}}"#, + ); + + let endpoint = resolve_docker_endpoint(Some(temp.path()), None, Some("ssh-ctx")).unwrap(); + let error = connect_to_endpoint(&endpoint).unwrap_err(); + + assert!(error + .to_string() + .contains("SSH Docker contexts are out of scope")); + } }