From dbca9dd7257a60c7099303bc329dd115b6d4a4f0 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 6 Oct 2026 22:09:14 +0000 Subject: [PATCH 1/8] fix: honor selected Docker context for local API clients --- Cargo.lock | 3 + crates/alien-local/Cargo.toml | 3 + crates/alien-local/src/container_manager.rs | 6 +- crates/alien-local/src/docker_connection.rs | 436 ++++++++++++++++++++ crates/alien-local/src/docker_network.rs | 38 +- crates/alien-local/src/error.rs | 18 + crates/alien-local/src/lib.rs | 2 + crates/alien-local/src/postgres_manager.rs | 2 +- crates/alien-local/src/sandbox_manager.rs | 6 +- 9 files changed, 487 insertions(+), 27 deletions(-) create mode 100644 crates/alien-local/src/docker_connection.rs diff --git a/Cargo.lock b/Cargo.lock index 53fb37b78f..ea09fd953c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -935,6 +935,7 @@ dependencies = [ "chrono", "container-registry 0.3.1 (git+https://github.com/alienplatform/container_registry-rs.git?rev=fd28e7c)", "cron", + "dirs 6.0.0", "dockdash", "futures", "futures-util", @@ -950,6 +951,7 @@ dependencies = [ "sec", "serde", "serde_json", + "sha2 0.10.9", "tar", "tempfile", "thiserror 2.0.20", @@ -960,6 +962,7 @@ dependencies = [ "tracing", "tracing-subscriber", "turso", + "url", "urlencoding", "uuid", "workspace_root", diff --git a/crates/alien-local/Cargo.toml b/crates/alien-local/Cargo.toml index d72a3b7c9c..01d016718a 100644 --- a/crates/alien-local/Cargo.toml +++ b/crates/alien-local/Cargo.toml @@ -40,6 +40,9 @@ object_store = { workspace = true, features = ["http"] } # Docker client bollard = { workspace = true } +sha2 = { workspace = true } +dirs = { workspace = true } +url = { workspace = true } axum = { workspace = true, features = ["tokio", "http1", "json", "query"] } base64 = { workspace = true } bytes = { workspace = true } diff --git a/crates/alien-local/src/container_manager.rs b/crates/alien-local/src/container_manager.rs index 88fae2e029..bb1a6975e2 100644 --- a/crates/alien-local/src/container_manager.rs +++ b/crates/alien-local/src/container_manager.rs @@ -453,11 +453,7 @@ impl LocalContainerManager { /// # Arguments /// * `state_dir` - Base directory for container metadata pub fn new(state_dir: PathBuf) -> Result { - let docker = Docker::connect_with_local_defaults() - .into_alien_error() - .context(ErrorData::DockerConnectionFailed { - reason: "Failed to connect to Docker daemon. Is Docker running?".to_string(), - })?; + let docker = crate::connect_docker()?; let containers = Self::load_metadata_from_disk(&state_dir)? .into_iter() diff --git a/crates/alien-local/src/docker_connection.rs b/crates/alien-local/src/docker_connection.rs new file mode 100644 index 0000000000..e586ac7219 --- /dev/null +++ b/crates/alien-local/src/docker_connection.rs @@ -0,0 +1,436 @@ +//! Docker CLI-compatible endpoint selection without changing process environment. + +use std::{ + collections::HashMap, + env, fs, + io::ErrorKind, + path::{Path, PathBuf}, +}; + +use alien_error::{AlienError, Context, IntoAlienError}; +use bollard::{Docker, API_DEFAULT_VERSION}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use url::Url; + +use crate::{ErrorData, Result}; + +/// Connect to the endpoint selected by Docker's environment and context store. +/// TLS and SSH require transports this client does not currently support; they +/// fail explicitly rather than connecting without the requested protection. +pub fn connect_docker() -> Result { + DockerEnvironment::capture()?.connect() +} + +#[derive(Default)] +struct DockerEnvironment { + config_dir: PathBuf, + context: Option, + host: Option, + tls: bool, +} + +impl DockerEnvironment { + fn capture() -> Result { + let value = |key: &str| -> Result> { + match env::var(key) { + Ok(value) => Ok((!value.is_empty()).then_some(value)), + Err(env::VarError::NotPresent) => Ok(None), + Err(error) => Err(error) + .into_alien_error() + .context(config_error(format!("{key} must contain Unicode"))), + } + }; + let config_dir = match value("DOCKER_CONFIG")? { + Some(path) => PathBuf::from(path), + None => dirs::home_dir() + .ok_or_else(|| { + AlienError::new(config_error( + "Set DOCKER_CONFIG: home directory is unavailable".into(), + )) + })? + .join(".docker"), + }; + Ok(Self { + config_dir, + context: value("DOCKER_CONTEXT")?, + host: value("DOCKER_HOST")?, + tls: value("DOCKER_TLS")?.is_some() || value("DOCKER_TLS_VERIFY")?.is_some(), + }) + } + + fn endpoint(&self) -> Result { + // DOCKER_CONTEXT overrides DOCKER_HOST; a host override in turn bypasses + // the persisted currentContext. Explicit "default" still honors HOST. + let context = if let Some(context) = &self.context { + context.clone() + } else if self.host.is_some() { + "default".into() + } else { + let path = self.config_dir.join("config.json"); + match fs::read(&path) { + Ok(bytes) => { + serde_json::from_slice::(&bytes) + .into_alien_error() + .context(config_error(format!( + "Invalid Docker configuration at {}", + path.display() + )))? + .current_context + } + Err(error) if error.kind() == ErrorKind::NotFound => String::new(), + Err(error) => { + return Err(error) + .into_alien_error() + .context(config_error(format!("Cannot read {}", path.display()))) + } + } + }; + if context.is_empty() || context == "default" { + if self.tls { + return Err(AlienError::new(unsupported("TLS", "Select a Unix/npipe or plaintext TCP endpoint, or use the Docker CLI for TLS operations"))); + } + return Ok(self.host.clone().unwrap_or_else(default_host)); + } + let id = format!("{:x}", Sha256::digest(context.as_bytes())); + let path = self + .config_dir + .join("contexts/meta") + .join(&id) + .join("meta.json"); + let metadata: ContextMetadata = read_json(&path)?; + if metadata.name != context { + return Err(AlienError::new(config_error(format!( + "Context metadata at {} does not match selected context '{context}'", + path.display() + )))); + } + let endpoint = metadata.endpoints.get("docker").ok_or_else(|| { + AlienError::new(config_error(format!( + "Context '{context}' has no Docker endpoint" + ))) + })?; + if endpoint.host.trim().is_empty() { + return Err(AlienError::new(config_error(format!( + "Context '{context}' has an empty Docker endpoint" + )))); + } + let tls_path = self.config_dir.join("contexts/tls").join(id).join("docker"); + let has_tls = match fs::read_dir(&tls_path) { + Ok(mut files) => files + .next() + .transpose() + .into_alien_error() + .context(config_error(format!( + "Cannot inspect TLS material at {}", + tls_path.display() + )))? + .is_some(), + Err(error) if error.kind() == ErrorKind::NotFound => false, + Err(error) => { + return Err(error).into_alien_error().context(config_error(format!( + "Cannot inspect TLS material at {}", + tls_path.display() + ))) + } + }; + if has_tls || endpoint.skip_tls_verify { + return Err(AlienError::new(unsupported("TLS context", "Use the Docker CLI for this context; context TLS verification settings and certificates cannot be honored by this client"))); + } + Ok(endpoint.host.clone()) + } + + fn connect(&self) -> Result { + connect_endpoint(&self.endpoint()?) + } +} + +fn default_host() -> String { + #[cfg(windows)] + return "npipe:////./pipe/docker_engine".into(); + #[cfg(not(windows))] + return "unix:///var/run/docker.sock".into(); +} + +fn connect_endpoint(host: &str) -> Result { + let host = host.trim(); + let (scheme, address) = host.split_once("://").unwrap_or(("tcp", host)); + let connection = match scheme { + #[cfg(unix)] + "unix" => Docker::connect_with_unix(if address.is_empty() { "/var/run/docker.sock" } else { address }, 120, API_DEFAULT_VERSION), + #[cfg(windows)] + "npipe" => Docker::connect_with_named_pipe(if address.is_empty() { "//./pipe/docker_engine" } else { address }, 120, API_DEFAULT_VERSION), + "tcp" => { + let address = if address.is_empty() { "localhost:2375" } else { address }; + let mut url = Url::parse(&format!("http://{address}")) + .into_alien_error().context(config_error("Invalid TCP endpoint; specify tcp://host:port".into()))?; + if url.host_str().is_none() || !url.username().is_empty() || url.password().is_some() || url.path() != "/" || url.query().is_some() || url.fragment().is_some() { + return Err(AlienError::new(config_error("TCP endpoint must contain only a host and optional port".into()))); + } + if url.port().is_none() { + url.set_port(Some(2375)).map_err(|_| AlienError::new(config_error("Invalid TCP port".into())))?; + } + Docker::connect_with_http(url.as_str().trim_end_matches('/'), 120, API_DEFAULT_VERSION) + } + _ => return Err(AlienError::new(unsupported(scheme, "Select a supported Unix/npipe or plaintext TCP Docker endpoint; use the Docker CLI for SSH or TLS"))), + }; + connection + .into_alien_error() + .context(ErrorData::DockerConnectionFailed { + reason: + "Cannot initialize the selected Docker endpoint; check its socket and Docker daemon" + .into(), + }) +} + +fn config_error(message: String) -> ErrorData { + ErrorData::DockerConfigurationInvalid { message } +} + +fn unsupported(transport: &str, message: &str) -> ErrorData { + ErrorData::DockerTransportUnsupported { + transport: transport.into(), + message: message.into(), + } +} + +fn read_json(path: &Path) -> Result { + let bytes = fs::read(path).into_alien_error().context(config_error(format!("Cannot read selected Docker context at {}; select an existing context with 'docker context use'", path.display())))?; + serde_json::from_slice(&bytes) + .into_alien_error() + .context(config_error(format!( + "Invalid Docker context metadata at {}", + path.display() + ))) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CliConfig { + #[serde(default)] + current_context: String, +} + +// Docker owns this on-disk format and uses PascalCase field names. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ContextMetadata { + #[serde(rename = "Name")] + name: String, + #[serde(rename = "Endpoints")] + endpoints: HashMap, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ContextEndpoint { + #[serde(rename = "Host")] + host: String, + #[serde(default, rename = "SkipTLSVerify")] + skip_tls_verify: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + #[cfg(unix)] + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::UnixListener, + }; + + fn fixture() -> (TempDir, DockerEnvironment) { + let directory = TempDir::new().expect("temporary Docker config"); + let environment = DockerEnvironment { + config_dir: directory.path().into(), + ..Default::default() + }; + (directory, environment) + } + + fn write_context(environment: &DockerEnvironment, name: &str, host: &str) { + let id = format!("{:x}", Sha256::digest(name.as_bytes())); + let directory = environment.config_dir.join("contexts/meta").join(id); + fs::create_dir_all(&directory).unwrap(); + fs::write(directory.join("meta.json"), serde_json::to_vec(&serde_json::json!({"Name": name, "Endpoints": {"docker": {"Host": host, "SkipTLSVerify": false}}})).unwrap()).unwrap(); + } + + #[test] + fn selection_precedence_and_synthetic_default() { + let (_directory, mut environment) = fixture(); + write_context(&environment, "stored", "unix:///stored.sock"); + write_context(&environment, "explicit", "unix:///explicit.sock"); + fs::write( + environment.config_dir.join("config.json"), + r#"{"currentContext":"stored"}"#, + ) + .unwrap(); + assert_eq!(environment.endpoint().unwrap(), "unix:///stored.sock"); + environment.host = Some("tcp://localhost:1234".into()); + assert_eq!(environment.endpoint().unwrap(), "tcp://localhost:1234"); + environment.context = Some("explicit".into()); + environment.tls = true; // Named contexts ignore environment TLS options. + assert_eq!(environment.endpoint().unwrap(), "unix:///explicit.sock"); + environment.context = Some("default".into()); + assert!(matches!( + environment.endpoint().unwrap_err().data, + ErrorData::DockerTransportUnsupported { .. } + )); + environment.tls = false; + assert_eq!(environment.endpoint().unwrap(), "tcp://localhost:1234"); + environment.host = None; + assert_eq!(environment.endpoint().unwrap(), default_host()); + environment.context = None; + fs::remove_file(environment.config_dir.join("config.json")).unwrap(); + assert_eq!(environment.endpoint().unwrap(), default_host()); + } + + #[test] + fn invalid_selected_configuration_never_falls_back() { + let (_directory, mut environment) = fixture(); + fs::write(environment.config_dir.join("config.json"), b"invalid json").unwrap(); + let error = environment.endpoint().unwrap_err(); + assert!(matches!( + error.data, + ErrorData::DockerConfigurationInvalid { .. } + )); + assert!(std::error::Error::source(&error).is_some()); + environment.context = Some("missing".into()); + environment.host = Some("unix:///other.sock".into()); + let error = environment.endpoint().unwrap_err(); + assert!(matches!( + error.data, + ErrorData::DockerConfigurationInvalid { .. } + )); + assert!(std::error::Error::source(&error).is_some()); + write_context(&environment, "missing", ""); + assert!(matches!( + environment.endpoint().unwrap_err().data, + ErrorData::DockerConfigurationInvalid { .. } + )); + let id = format!("{:x}", Sha256::digest(b"missing")); + fs::write( + environment + .config_dir + .join("contexts/meta") + .join(id) + .join("meta.json"), + r#"{"Name":"missing","Endpoints":{}}"#, + ) + .unwrap(); + assert!(matches!( + environment.endpoint().unwrap_err().data, + ErrorData::DockerConfigurationInvalid { .. } + )); + } + + #[test] + fn tls_material_and_unsupported_transports_fail_explicitly() { + let (_directory, mut environment) = fixture(); + environment.context = Some("secure".into()); + write_context(&environment, "secure", "tcp://localhost:2376"); + let id = format!("{:x}", Sha256::digest(b"secure")); + let directory = environment + .config_dir + .join("contexts/tls") + .join(id) + .join("docker"); + fs::create_dir_all(&directory).unwrap(); + fs::write(directory.join("ca.pem"), "fixture certificate").unwrap(); + assert!(matches!( + environment.connect().unwrap_err().data, + ErrorData::DockerTransportUnsupported { .. } + )); + for host in ["ssh://localhost", "https://localhost:2376", "fd://3"] { + assert!(matches!( + connect_endpoint(host).unwrap_err().data, + ErrorData::DockerTransportUnsupported { .. } + )); + } + for host in [ + "tcp://user:password@localhost", + "tcp://localhost/path", + "tcp://localhost:bad", + "tcp://localhost?x=1", + ] { + assert!(matches!( + connect_endpoint(host).unwrap_err().data, + ErrorData::DockerConfigurationInvalid { .. } + )); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn selected_unix_context_sends_real_api_request() { + let (_directory, environment) = fixture(); + let socket = environment.config_dir.join("engine.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + write_context( + &environment, + "fixture", + &format!("unix://{}", socket.display()), + ); + fs::write( + environment.config_dir.join("config.json"), + r#"{"currentContext":"fixture"}"#, + ) + .unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = vec![0; 4096]; + let count = stream.read(&mut request).await.unwrap(); + let request = String::from_utf8(request[..count].to_vec()).unwrap(); + assert!( + request.lines().next().unwrap().ends_with("/_ping HTTP/1.1"), + "{request}" + ); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK") + .await + .unwrap(); + }); + tokio::time::timeout( + std::time::Duration::from_secs(5), + environment.connect().unwrap().ping(), + ) + .await + .unwrap() + .unwrap(); + server.await.unwrap(); + } + + /// Run in a child process with an isolated Docker config and dedicated engine. + /// This exercises the public production environment capture, not just inputs. + #[tokio::test] + #[ignore = "requires a dedicated Docker engine and isolated Docker environment"] + async fn dedicated_engine_proof() { + let docker = connect_docker().expect("selected endpoint initializes"); + docker.ping().await.expect("selected engine responds"); + let version = docker.version().await.expect("engine version"); + assert!(version.version.is_some()); + let containers = docker + .list_containers::(None) + .await + .expect("list dedicated engine containers"); + assert!( + containers.is_empty(), + "proof engine must have no containers" + ); + } + + /// The old connector must fail against the same isolated nondefault context. + #[tokio::test] + #[ignore = "requires isolated config selecting a nondefault socket"] + async fn dedicated_engine_baseline() { + let result = match Docker::connect_with_local_defaults() { + Ok(docker) => docker.ping().await, + Err(error) => Err(error), + }; + assert!( + result.is_err(), + "old connector unexpectedly reached a daemon" + ); + } +} diff --git a/crates/alien-local/src/docker_network.rs b/crates/alien-local/src/docker_network.rs index 15be3d6c20..4522dd5a79 100644 --- a/crates/alien-local/src/docker_network.rs +++ b/crates/alien-local/src/docker_network.rs @@ -2,7 +2,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::time::Duration; use alien_error::{Context, IntoAlienError}; -use bollard::{network::InspectNetworkOptions, Docker}; +use bollard::network::InspectNetworkOptions; use tokio::io::copy_bidirectional; use tokio::net::{TcpListener, TcpStream}; use tracing::{debug, info, warn}; @@ -11,27 +11,33 @@ use crate::{ErrorData, Result}; /// Returns a Docker bridge gateway that belongs to the private range used by /// Local services and is bindable by this host. -pub(crate) async fn bindable_docker_bridge_gateway() -> Option { - let docker = Docker::connect_with_local_defaults().ok()?; - let network = docker - .inspect_network("bridge", None::>) - .await - .ok()?; - let gateway = network - .ipam? - .config? - .into_iter() - .filter_map(|config| config.gateway) - .find_map(|gateway| docker_bridge_gateway(&gateway))?; - std::net::TcpListener::bind((gateway, 0)).ok()?; - Some(gateway) +pub(crate) async fn bindable_docker_bridge_gateway() -> Result> { + let docker = crate::connect_docker()?; + // Bridge discovery is optional on hosts without a local bridge (for + // example Docker Desktop). Endpoint selection errors above are mandatory. + let gateway = async { + let network = docker + .inspect_network("bridge", None::>) + .await + .ok()?; + let gateway = network + .ipam? + .config? + .into_iter() + .filter_map(|config| config.gateway) + .find_map(|gateway| docker_bridge_gateway(&gateway))?; + std::net::TcpListener::bind((gateway, 0)).ok()?; + Some(gateway) + } + .await; + Ok(gateway) } /// Makes a loopback service reachable at the same port on Docker's private /// host gateway. Hosts where that gateway cannot be bound, such as Docker /// Desktop, retain their native `host.docker.internal` behavior. pub async fn start_docker_bridge_proxy(target: SocketAddr) -> Result> { - let Some(gateway) = bindable_docker_bridge_gateway().await else { + let Some(gateway) = bindable_docker_bridge_gateway().await? else { return Ok(None); }; let listen_addr = SocketAddr::new(IpAddr::V4(gateway), target.port()); diff --git a/crates/alien-local/src/error.rs b/crates/alien-local/src/error.rs index f8fcab9c7b..c883da8bd8 100644 --- a/crates/alien-local/src/error.rs +++ b/crates/alien-local/src/error.rs @@ -5,6 +5,24 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, AlienErrorData, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ErrorData { + /// The selected Docker configuration cannot be used. + #[error( + code = "DOCKER_CONFIGURATION_INVALID", + message = "Docker configuration is invalid: {message}", + retryable = "false", + internal = "false" + )] + DockerConfigurationInvalid { message: String }, + + /// The selected endpoint requires an unavailable transport. + #[error( + code = "DOCKER_TRANSPORT_UNSUPPORTED", + message = "Docker transport '{transport}' is unsupported: {message}", + retryable = "false", + internal = "false" + )] + DockerTransportUnsupported { transport: String, message: String }, + /// Failed to create or access local directory. #[error( code = "LOCAL_DIRECTORY_ERROR", diff --git a/crates/alien-local/src/lib.rs b/crates/alien-local/src/lib.rs index d879cbc2e3..0ece3e5ea5 100644 --- a/crates/alien-local/src/lib.rs +++ b/crates/alien-local/src/lib.rs @@ -54,7 +54,9 @@ mod artifact_registry_manager; mod container_manager; mod daemon_supervisor; +mod docker_connection; mod docker_network; +pub use docker_connection::connect_docker; mod error; mod kv_manager; mod local_bindings_provider; diff --git a/crates/alien-local/src/postgres_manager.rs b/crates/alien-local/src/postgres_manager.rs index d4aa4cfa6a..7243394dd5 100644 --- a/crates/alien-local/src/postgres_manager.rs +++ b/crates/alien-local/src/postgres_manager.rs @@ -305,7 +305,7 @@ impl LocalPostgresManager { // Listen on loopback plus the docker bridge gateway when present, so a same-stack local // container can reach pg via `host.docker.internal` — never 0.0.0.0, so pg stays off every // public/LAN interface. Falls back to loopback-only when the bridge gateway can't be bound. - let listen_addresses = match bindable_docker_bridge_gateway().await { + let listen_addresses = match bindable_docker_bridge_gateway().await? { Some(gateway) => format!("127.0.0.1,{gateway}"), None => { debug!( diff --git a/crates/alien-local/src/sandbox_manager.rs b/crates/alien-local/src/sandbox_manager.rs index 97f0e03007..03499b1a04 100644 --- a/crates/alien-local/src/sandbox_manager.rs +++ b/crates/alien-local/src/sandbox_manager.rs @@ -108,11 +108,7 @@ pub struct LocalSandboxManager { impl LocalSandboxManager { /// Connects to the local Docker daemon. pub fn new(state_dir: PathBuf) -> Result { - let docker = Docker::connect_with_local_defaults() - .into_alien_error() - .context(ErrorData::DockerConnectionFailed { - reason: "could not reach the local Docker daemon".to_string(), - })?; + let docker = crate::connect_docker()?; Ok(Self { docker, state_dir }) } From 13774d99a99b4250a35d839adf98a9846a11a786 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 6 Oct 2026 22:13:02 +0000 Subject: [PATCH 2/8] test: prove Docker context selection against an isolated engine --- crates/alien-local/README.md | 35 +++ crates/alien-local/src/container_manager.rs | 6 +- crates/alien-local/src/docker_connection.rs | 214 ++++++++++++++---- crates/alien-local/src/docker_network.rs | 8 +- .../tests/shared_image_identity.rs | 5 +- .../tests/registry_proxy_test.rs | 21 +- 6 files changed, 225 insertions(+), 64 deletions(-) diff --git a/crates/alien-local/README.md b/crates/alien-local/README.md index ef2c143457..f95d77941f 100644 --- a/crates/alien-local/README.md +++ b/crates/alien-local/README.md @@ -13,3 +13,38 @@ Local platform implementation. "Local" is a full Alien platform — applications Application code is identical across platforms — the same `storage.put(key, data)` call works on Local, AWS, GCP, and Azure. Used by `alien dev` for local development, and deployable to any machine via `alien-deploy deploy --platform local`. + +## Docker endpoint selection + +Local container and sandbox API clients read `DOCKER_CONFIG` (or the home +`.docker` directory) and select an endpoint in this order: + +1. A nonempty `DOCKER_CONTEXT`. +2. A nonempty `DOCKER_HOST`. +3. `currentContext` in `config.json`. +4. Docker's platform default socket or named pipe. + +The `default` context is synthetic and honors `DOCKER_HOST`. Named contexts use +Docker's context metadata and ignore environment TLS options. Missing or invalid +selected context metadata fails with a structured error; another daemon is never +tried. This follows the documented context-first precedence; Docker CLI versions +that prefer `DOCKER_HOST` when both variables are set differ in that case. + +Unix sockets, Windows named pipes, and plaintext TCP endpoints are supported. +SSH, TLS environment settings, and contexts with TLS material or verification +settings return actionable unsupported-transport errors. TLS is never silently +removed. Image loading through Docker CLI is pinned to the endpoint captured by +the container API client, including after the active context changes. + +Docker bridge discovery remains optional for native services on hosts without a +bindable bridge. Configuration and unsupported-transport errors still propagate. + +To validate selection against a real engine, create a dedicated empty daemon with +its own Unix socket, data directory, execution directory, and PID file. Use an +isolated `DOCKER_CONFIG`, create and select a context pointing to that socket, and +run the ignored `docker_connection::tests::dedicated_engine_proof` test with +nextest. It must ping the engine, obtain its version, and confirm there are no +containers. `dedicated_engine_baseline` verifies that Bollard's old local-default +connector cannot select that engine. `dedicated_pinned_subprocess` verifies CLI +selection after the persisted context changes. These fixtures perform read-only +API operations and require no application builds or containers. diff --git a/crates/alien-local/src/container_manager.rs b/crates/alien-local/src/container_manager.rs index bb1a6975e2..3830443869 100644 --- a/crates/alien-local/src/container_manager.rs +++ b/crates/alien-local/src/container_manager.rs @@ -371,6 +371,7 @@ pub struct LocalRuntimeStatus { #[derive(Debug)] pub struct LocalContainerManager { docker: Docker, + docker_host: String, state_dir: PathBuf, /// Tracked containers (container_id → metadata) containers: Arc>>, @@ -453,7 +454,7 @@ impl LocalContainerManager { /// # Arguments /// * `state_dir` - Base directory for container metadata pub fn new(state_dir: PathBuf) -> Result { - let docker = crate::connect_docker()?; + let (docker, docker_host) = crate::docker_connection::connect_docker_with_host()?; let containers = Self::load_metadata_from_disk(&state_dir)? .into_iter() @@ -462,6 +463,7 @@ impl LocalContainerManager { Ok(Self { docker, + docker_host, state_dir, containers: Arc::new(RwLock::new(containers)), image_load_locks: tokio::sync::Mutex::new(HashMap::new()), @@ -679,7 +681,7 @@ impl LocalContainerManager { // Use `docker load` instead of `import_image` to preserve CMD/ENTRYPOINT // docker import is for filesystem tarballs, docker load is for OCI image tarballs - let output = tokio::process::Command::new("docker") + let output = crate::docker_connection::docker_command(&self.docker_host) .args(&["load", "-i", &tarball_path.to_string_lossy()]) .output() .await diff --git a/crates/alien-local/src/docker_connection.rs b/crates/alien-local/src/docker_connection.rs index e586ac7219..aea6e81c45 100644 --- a/crates/alien-local/src/docker_connection.rs +++ b/crates/alien-local/src/docker_connection.rs @@ -1,4 +1,4 @@ -//! Docker CLI-compatible endpoint selection without changing process environment. +//! Docker endpoint selection from the documented context and host settings. use std::{ collections::HashMap, @@ -19,7 +19,26 @@ use crate::{ErrorData, Result}; /// TLS and SSH require transports this client does not currently support; they /// fail explicitly rather than connecting without the requested protection. pub fn connect_docker() -> Result { - DockerEnvironment::capture()?.connect() + connect_docker_with_host().map(|(docker, _)| docker) +} + +/// Keep subprocesses on the same endpoint captured by their API client. +pub(crate) fn connect_docker_with_host() -> Result<(Docker, String)> { + let endpoint = DockerEnvironment::capture()?.endpoint()?; + Ok((connect_endpoint(&endpoint)?, endpoint)) +} + +/// Build a CLI command pinned to an already validated, non-TLS endpoint. +pub(crate) fn docker_command(endpoint: &str) -> tokio::process::Command { + let mut command = tokio::process::Command::new("docker"); + command + .arg("--host") + .arg(endpoint) + .env_remove("DOCKER_CONTEXT") + .env_remove("DOCKER_TLS") + .env_remove("DOCKER_TLS_VERIFY") + .env_remove("DOCKER_CERT_PATH"); + command } #[derive(Default)] @@ -140,6 +159,7 @@ impl DockerEnvironment { Ok(endpoint.host.clone()) } + #[cfg(test)] fn connect(&self) -> Result { connect_endpoint(&self.endpoint()?) } @@ -160,18 +180,7 @@ fn connect_endpoint(host: &str) -> Result { "unix" => Docker::connect_with_unix(if address.is_empty() { "/var/run/docker.sock" } else { address }, 120, API_DEFAULT_VERSION), #[cfg(windows)] "npipe" => Docker::connect_with_named_pipe(if address.is_empty() { "//./pipe/docker_engine" } else { address }, 120, API_DEFAULT_VERSION), - "tcp" => { - let address = if address.is_empty() { "localhost:2375" } else { address }; - let mut url = Url::parse(&format!("http://{address}")) - .into_alien_error().context(config_error("Invalid TCP endpoint; specify tcp://host:port".into()))?; - if url.host_str().is_none() || !url.username().is_empty() || url.password().is_some() || url.path() != "/" || url.query().is_some() || url.fragment().is_some() { - return Err(AlienError::new(config_error("TCP endpoint must contain only a host and optional port".into()))); - } - if url.port().is_none() { - url.set_port(Some(2375)).map_err(|_| AlienError::new(config_error("Invalid TCP port".into())))?; - } - Docker::connect_with_http(url.as_str().trim_end_matches('/'), 120, API_DEFAULT_VERSION) - } + "tcp" => Docker::connect_with_http(&tcp_endpoint(address)?, 120, API_DEFAULT_VERSION), _ => return Err(AlienError::new(unsupported(scheme, "Select a supported Unix/npipe or plaintext TCP Docker endpoint; use the Docker CLI for SSH or TLS"))), }; connection @@ -183,6 +192,36 @@ fn connect_endpoint(host: &str) -> Result { }) } +fn tcp_endpoint(address: &str) -> Result { + let address = if address.is_empty() { + "localhost:2375".into() + } else if address.starts_with(':') { + format!("localhost{address}") + } else { + address.to_string() + }; + // Parse with the original scheme so explicit port 80 is not normalized + // away as HTTP's default port and accidentally replaced with port 2375. + let url = Url::parse(&format!("tcp://{address}")) + .into_alien_error() + .context(config_error( + "Invalid TCP endpoint; specify tcp://host:port".into(), + ))?; + if url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || !matches!(url.path(), "" | "/") + || url.query().is_some() + || url.fragment().is_some() + { + return Err(AlienError::new(config_error( + "TCP endpoint must contain only a host and optional port".into(), + ))); + } + let host = url.host().expect("host validated above"); + Ok(format!("http://{host}:{}", url.port().unwrap_or(2375))) +} + fn config_error(message: String) -> ErrorData { ErrorData::DockerConfigurationInvalid { message } } @@ -234,11 +273,9 @@ struct ContextEndpoint { mod tests { use super::*; use tempfile::TempDir; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[cfg(unix)] - use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - net::UnixListener, - }; + use tokio::net::UnixListener; fn fixture() -> (TempDir, DockerEnvironment) { let directory = TempDir::new().expect("temporary Docker config"); @@ -274,8 +311,8 @@ mod tests { assert_eq!(environment.endpoint().unwrap(), "unix:///explicit.sock"); environment.context = Some("default".into()); assert!(matches!( - environment.endpoint().unwrap_err().data, - ErrorData::DockerTransportUnsupported { .. } + environment.endpoint().unwrap_err().error, + Some(ErrorData::DockerTransportUnsupported { .. }) )); environment.tls = false; assert_eq!(environment.endpoint().unwrap(), "tcp://localhost:1234"); @@ -292,22 +329,22 @@ mod tests { fs::write(environment.config_dir.join("config.json"), b"invalid json").unwrap(); let error = environment.endpoint().unwrap_err(); assert!(matches!( - error.data, - ErrorData::DockerConfigurationInvalid { .. } + error.error, + Some(ErrorData::DockerConfigurationInvalid { .. }) )); assert!(std::error::Error::source(&error).is_some()); environment.context = Some("missing".into()); environment.host = Some("unix:///other.sock".into()); let error = environment.endpoint().unwrap_err(); assert!(matches!( - error.data, - ErrorData::DockerConfigurationInvalid { .. } + error.error, + Some(ErrorData::DockerConfigurationInvalid { .. }) )); assert!(std::error::Error::source(&error).is_some()); write_context(&environment, "missing", ""); assert!(matches!( - environment.endpoint().unwrap_err().data, - ErrorData::DockerConfigurationInvalid { .. } + environment.endpoint().unwrap_err().error, + Some(ErrorData::DockerConfigurationInvalid { .. }) )); let id = format!("{:x}", Sha256::digest(b"missing")); fs::write( @@ -320,8 +357,8 @@ mod tests { ) .unwrap(); assert!(matches!( - environment.endpoint().unwrap_err().data, - ErrorData::DockerConfigurationInvalid { .. } + environment.endpoint().unwrap_err().error, + Some(ErrorData::DockerConfigurationInvalid { .. }) )); } @@ -339,13 +376,13 @@ mod tests { fs::create_dir_all(&directory).unwrap(); fs::write(directory.join("ca.pem"), "fixture certificate").unwrap(); assert!(matches!( - environment.connect().unwrap_err().data, - ErrorData::DockerTransportUnsupported { .. } + environment.connect().unwrap_err().error, + Some(ErrorData::DockerTransportUnsupported { .. }) )); for host in ["ssh://localhost", "https://localhost:2376", "fd://3"] { assert!(matches!( - connect_endpoint(host).unwrap_err().data, - ErrorData::DockerTransportUnsupported { .. } + connect_endpoint(host).unwrap_err().error, + Some(ErrorData::DockerTransportUnsupported { .. }) )); } for host in [ @@ -355,8 +392,8 @@ mod tests { "tcp://localhost?x=1", ] { assert!(matches!( - connect_endpoint(host).unwrap_err().data, - ErrorData::DockerConfigurationInvalid { .. } + connect_endpoint(host).unwrap_err().error, + Some(ErrorData::DockerConfigurationInvalid { .. }) )); } } @@ -401,6 +438,47 @@ mod tests { server.await.unwrap(); } + #[test] + fn tcp_normalization_preserves_explicit_ports_and_ipv6() { + for (address, expected) in [ + ("localhost:80", "http://localhost:80"), + ("localhost", "http://localhost:2375"), + ("", "http://localhost:2375"), + (":1234", "http://localhost:1234"), + ("[::1]:1234", "http://[::1]:1234"), + ] { + assert_eq!(tcp_endpoint(address).unwrap(), expected); + } + } + + #[tokio::test] + async fn selected_tcp_host_sends_real_api_request() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("tcp://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = vec![0; 4096]; + let count = stream.read(&mut request).await.unwrap(); + let request = String::from_utf8(request[..count].to_vec()).unwrap(); + assert!( + request.lines().next().unwrap().ends_with("/_ping HTTP/1.1"), + "{request}" + ); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK") + .await + .unwrap(); + }); + tokio::time::timeout( + std::time::Duration::from_secs(5), + connect_endpoint(&endpoint).unwrap().ping(), + ) + .await + .unwrap() + .unwrap(); + server.await.unwrap(); + } + /// Run in a child process with an isolated Docker config and dedicated engine. /// This exercises the public production environment capture, not just inputs. #[tokio::test] @@ -420,17 +498,67 @@ mod tests { ); } - /// The old connector must fail against the same isolated nondefault context. + /// Prove subprocess pinning against a real engine after context selection changes. #[tokio::test] - #[ignore = "requires isolated config selecting a nondefault socket"] - async fn dedicated_engine_baseline() { - let result = match Docker::connect_with_local_defaults() { - Ok(docker) => docker.ping().await, - Err(error) => Err(error), - }; + #[ignore = "requires a dedicated Docker engine and Docker CLI"] + async fn dedicated_pinned_subprocess() { + let (docker, endpoint) = connect_docker_with_host().expect("selected endpoint"); + let selected = docker + .info() + .await + .expect("selected engine info") + .id + .expect("engine ID"); + let directory = TempDir::new().unwrap(); + fs::write( + directory.path().join("config.json"), + r#"{"currentContext":"missing"}"#, + ) + .unwrap(); + let output = docker_command(&endpoint) + .env("DOCKER_CONFIG", directory.path()) + .env("DOCKER_HOST", "unix:///nonexistent-fixture.sock") + .args(["info", "--format", "{{.ID}}"]) + .output() + .await + .expect("Docker CLI"); assert!( - result.is_err(), - "old connector unexpectedly reached a daemon" + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) ); + assert_eq!(String::from_utf8(output.stdout).unwrap().trim(), selected); + } + + /// Validate production error capture in an isolated child process. + #[test] + #[ignore = "requires isolated invalid Docker configuration"] + fn dedicated_configuration_error() { + let expected = env::var("ALIEN_TEST_DOCKER_ERROR_CODE").expect("expected error code"); + let error = connect_docker().expect_err("invalid selection must fail"); + assert_eq!(error.code, expected); + assert!(!error.retryable); + } + + /// The old connector must fail to select the same isolated context engine. + #[tokio::test] + #[ignore = "requires isolated config selecting a nondefault socket"] + async fn dedicated_engine_baseline() { + let selected = connect_docker() + .expect("selected endpoint") + .info() + .await + .expect("dedicated engine info"); + assert!(selected.id.is_some()); + match Docker::connect_with_local_defaults() { + Ok(docker) => match docker.info().await { + Ok(other) => assert_ne!( + other.id, selected.id, + "baseline unexpectedly selected the context engine" + ), + Err(_) => {} // The old endpoint being unreachable also proves failure. + }, + Err(_) => {} // No default socket: the old connector cannot initialize. + } } } diff --git a/crates/alien-local/src/docker_network.rs b/crates/alien-local/src/docker_network.rs index 4522dd5a79..5e1f5a950b 100644 --- a/crates/alien-local/src/docker_network.rs +++ b/crates/alien-local/src/docker_network.rs @@ -12,7 +12,13 @@ use crate::{ErrorData, Result}; /// Returns a Docker bridge gateway that belongs to the private range used by /// Local services and is bindable by this host. pub(crate) async fn bindable_docker_bridge_gateway() -> Result> { - let docker = crate::connect_docker()?; + let docker = match crate::connect_docker() { + Ok(docker) => docker, + // A Docker engine is optional for native services. Configuration and + // unsupported transport errors still propagate; no other daemon is tried. + Err(error) if error.code == "DOCKER_CONNECTION_FAILED" => return Ok(None), + Err(error) => return Err(error), + }; // Bridge discovery is optional on hosts without a local bridge (for // example Docker Desktop). Endpoint selection errors above are mandatory. let gateway = async { diff --git a/crates/alien-local/tests/shared_image_identity.rs b/crates/alien-local/tests/shared_image_identity.rs index f6e84a563f..53dce725f1 100644 --- a/crates/alien-local/tests/shared_image_identity.rs +++ b/crates/alien-local/tests/shared_image_identity.rs @@ -1,5 +1,4 @@ use alien_local::{ContainerConfig, LocalContainerManager}; -use bollard::Docker; use dockdash::{Arch, Image}; use futures::FutureExt; use std::{collections::HashMap, panic::AssertUnwindSafe, sync::Arc}; @@ -47,7 +46,7 @@ async fn concurrent_shared_image_loads_keep_both_containers_inspectable() { .await .unwrap(); assert_eq!(first_image.config_digest(), second_image.config_digest()); - let docker = Docker::connect_with_local_defaults().unwrap(); + let docker = alien_local::connect_docker().unwrap(); let mut archive = tar::Archive::new(std::fs::File::open(first_image.path()).unwrap()); let index = archive .entries_with_seek() @@ -150,7 +149,7 @@ async fn registry_container_uses_and_labels_its_immutable_image_id() { let directory = tempfile::tempdir().unwrap(); let manager = LocalContainerManager::new(directory.path().to_path_buf()).unwrap(); let name = format!("registry-identity-{}", Uuid::new_v4()); - let docker = Docker::connect_with_local_defaults().unwrap(); + let docker = alien_local::connect_docker().unwrap(); let result = manager .start_container(&name, config("alpine:3.22".to_string())) .await; diff --git a/crates/alien-manager/tests/registry_proxy_test.rs b/crates/alien-manager/tests/registry_proxy_test.rs index 3fde20a194..e84d530ca0 100644 --- a/crates/alien-manager/tests/registry_proxy_test.rs +++ b/crates/alien-manager/tests/registry_proxy_test.rs @@ -16,7 +16,6 @@ use std::sync::Arc; use std::time::Duration; use bollard::image::CreateImageOptions; -use bollard::Docker; use container_registry::ContainerRegistry; use futures_util::StreamExt; @@ -639,23 +638,15 @@ async fn test_proxy_pull_manifest_and_blob() { /// This test verifies that: Docker connects, authenticates via Basic auth, and /// the proxy correctly routes the initial manifest request. #[tokio::test] +#[ignore = "requires a Docker engine and reachable registry proxy"] async fn test_proxy_pull_with_bollard() { let s = setup().await; - // Connect to Docker daemon - let docker = match Docker::connect_with_local_defaults() { - Ok(d) => d, - Err(e) => { - eprintln!("Skipping bollard test — Docker not available: {}", e); - return; - } - }; - - // Verify Docker is reachable - if docker.ping().await.is_err() { - eprintln!("Skipping bollard test — Docker daemon not reachable"); - return; - } + let docker = alien_local::connect_docker().expect("selected Docker endpoint initializes"); + docker + .ping() + .await + .expect("selected Docker engine is reachable"); let manager_host = s.manager_url.strip_prefix("http://").unwrap(); let image_ref = format!("{}/artifacts/test-fn", manager_host); From 1fc658baaacceaaa8aefc4a5d9fceaf8aae47896 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 6 Oct 2026 22:15:43 +0000 Subject: [PATCH 3/8] fix: surface failures for explicitly selected Docker endpoints --- crates/alien-local/src/docker_connection.rs | 102 +++++++++++++++----- crates/alien-local/src/docker_network.rs | 57 ++++++----- 2 files changed, 111 insertions(+), 48 deletions(-) diff --git a/crates/alien-local/src/docker_connection.rs b/crates/alien-local/src/docker_connection.rs index aea6e81c45..1c6b5f8c2d 100644 --- a/crates/alien-local/src/docker_connection.rs +++ b/crates/alien-local/src/docker_connection.rs @@ -28,6 +28,22 @@ pub(crate) fn connect_docker_with_host() -> Result<(Docker, String)> { Ok((connect_endpoint(&endpoint)?, endpoint)) } +/// Native services can operate without an unconfigured default Docker socket. +/// An explicitly selected host or named context must never be ignored. +pub(crate) fn connect_optional_docker() -> Result> { + let environment = DockerEnvironment::capture()?; + let context = environment.selected_context()?; + let unconfigured_default = environment.context.is_none() + && environment.host.is_none() + && (context.is_empty() || context == "default"); + let endpoint = environment.endpoint_for_context(&context)?; + match connect_endpoint(&endpoint) { + Ok(docker) => Ok(Some(docker)), + Err(error) if unconfigured_default && error.code == "DOCKER_CONNECTION_FAILED" => Ok(None), + Err(error) => Err(error), + } +} + /// Build a CLI command pinned to an already validated, non-TLS endpoint. pub(crate) fn docker_command(endpoint: &str) -> tokio::process::Command { let mut command = tokio::process::Command::new("docker"); @@ -78,33 +94,38 @@ impl DockerEnvironment { }) } - fn endpoint(&self) -> Result { + fn selected_context(&self) -> Result { // DOCKER_CONTEXT overrides DOCKER_HOST; a host override in turn bypasses // the persisted currentContext. Explicit "default" still honors HOST. - let context = if let Some(context) = &self.context { - context.clone() - } else if self.host.is_some() { - "default".into() - } else { - let path = self.config_dir.join("config.json"); - match fs::read(&path) { - Ok(bytes) => { - serde_json::from_slice::(&bytes) - .into_alien_error() - .context(config_error(format!( - "Invalid Docker configuration at {}", - path.display() - )))? - .current_context - } - Err(error) if error.kind() == ErrorKind::NotFound => String::new(), - Err(error) => { - return Err(error) - .into_alien_error() - .context(config_error(format!("Cannot read {}", path.display()))) - } - } - }; + // Some CLI versions prefer HOST when both variables are set; pin our + // CLI subprocess endpoint rather than re-resolving that ambiguity. + if let Some(context) = &self.context { + return Ok(context.clone()); + } + if self.host.is_some() { + return Ok("default".into()); + } + let path = self.config_dir.join("config.json"); + match fs::read(&path) { + Ok(bytes) => Ok(serde_json::from_slice::(&bytes) + .into_alien_error() + .context(config_error(format!( + "Invalid Docker configuration at {}", + path.display() + )))? + .current_context), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(String::new()), + Err(error) => Err(error) + .into_alien_error() + .context(config_error(format!("Cannot read {}", path.display()))), + } + } + + fn endpoint(&self) -> Result { + self.endpoint_for_context(&self.selected_context()?) + } + + fn endpoint_for_context(&self, context: &str) -> Result { if context.is_empty() || context == "default" { if self.tls { return Err(AlienError::new(unsupported("TLS", "Select a Unix/npipe or plaintext TCP endpoint, or use the Docker CLI for TLS operations"))); @@ -360,6 +381,22 @@ mod tests { environment.endpoint().unwrap_err().error, Some(ErrorData::DockerConfigurationInvalid { .. }) )); + let path = environment + .config_dir + .join("contexts/meta") + .join(format!("{:x}", Sha256::digest(b"missing"))) + .join("meta.json"); + for metadata in [ + "invalid json", + "null", + r#"{"Name":"other","Endpoints":{"docker":{"Host":"unix:///fixture.sock"}}}"#, + r#"{"Name":"missing","Endpoints":{"docker":{"Host":42}}}"#, + ] { + fs::write(&path, metadata).unwrap(); + let error = environment.endpoint().unwrap_err(); + assert_eq!(error.code, "DOCKER_CONFIGURATION_INVALID"); + assert!(!error.retryable); + } } #[test] @@ -484,6 +521,11 @@ mod tests { #[tokio::test] #[ignore = "requires a dedicated Docker engine and isolated Docker environment"] async fn dedicated_engine_proof() { + let state = TempDir::new().unwrap(); + crate::LocalContainerManager::new(state.path().join("containers")) + .expect("container client selects context"); + crate::LocalSandboxManager::new(state.path().join("sandboxes")) + .expect("sandbox client selects context"); let docker = connect_docker().expect("selected endpoint initializes"); docker.ping().await.expect("selected engine responds"); let version = docker.version().await.expect("engine version"); @@ -538,6 +580,16 @@ mod tests { let error = connect_docker().expect_err("invalid selection must fail"); assert_eq!(error.code, expected); assert!(!error.retryable); + let state = TempDir::new().unwrap(); + let container_error = crate::LocalContainerManager::new(state.path().join("containers")) + .expect_err("container client must reject invalid selection"); + assert_eq!(container_error.code, expected); + let sandbox_error = crate::LocalSandboxManager::new(state.path().join("sandboxes")) + .expect_err("sandbox client must reject invalid selection"); + assert_eq!(sandbox_error.code, expected); + let bridge_error = + connect_optional_docker().expect_err("bridge discovery must reject invalid selection"); + assert_eq!(bridge_error.code, expected); } /// The old connector must fail to select the same isolated context engine. diff --git a/crates/alien-local/src/docker_network.rs b/crates/alien-local/src/docker_network.rs index 5e1f5a950b..9a25d250ac 100644 --- a/crates/alien-local/src/docker_network.rs +++ b/crates/alien-local/src/docker_network.rs @@ -12,31 +12,42 @@ use crate::{ErrorData, Result}; /// Returns a Docker bridge gateway that belongs to the private range used by /// Local services and is bindable by this host. pub(crate) async fn bindable_docker_bridge_gateway() -> Result> { - let docker = match crate::connect_docker() { - Ok(docker) => docker, - // A Docker engine is optional for native services. Configuration and - // unsupported transport errors still propagate; no other daemon is tried. - Err(error) if error.code == "DOCKER_CONNECTION_FAILED" => return Ok(None), - Err(error) => return Err(error), + let Some(docker) = crate::docker_connection::connect_optional_docker()? else { + return Ok(None); }; - // Bridge discovery is optional on hosts without a local bridge (for - // example Docker Desktop). Endpoint selection errors above are mandatory. - let gateway = async { - let network = docker - .inspect_network("bridge", None::>) - .await - .ok()?; - let gateway = network - .ipam? - .config? - .into_iter() - .filter_map(|config| config.gateway) - .find_map(|gateway| docker_bridge_gateway(&gateway))?; - std::net::TcpListener::bind((gateway, 0)).ok()?; - Some(gateway) + // A bridge may be absent on a remote engine or unavailable for host binding + // on Docker Desktop. Unexpected API failures must still be visible. + let network = match docker + .inspect_network("bridge", None::>) + .await + { + Ok(network) => network, + Err(bollard::errors::Error::DockerResponseServerError { + status_code: 404, .. + }) => return Ok(None), + Err(error) => { + return Err(error) + .into_alien_error() + .context(ErrorData::DockerConnectionFailed { + reason: "Cannot inspect the bridge network on the selected Docker engine" + .into(), + }) + } + }; + let gateway = network + .ipam + .and_then(|ipam| ipam.config) + .into_iter() + .flatten() + .filter_map(|config| config.gateway) + .find_map(|gateway| docker_bridge_gateway(&gateway)); + let Some(gateway) = gateway else { + return Ok(None); + }; + if std::net::TcpListener::bind((gateway, 0)).is_err() { + return Ok(None); } - .await; - Ok(gateway) + Ok(Some(gateway)) } /// Makes a loopback service reachable at the same port on Docker's private From 1bb047221aa992a7ea83b6c095226483a07c8b3c Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 6 Oct 2026 22:16:17 +0000 Subject: [PATCH 4/8] test: verify optional bridge discovery on the selected engine --- crates/alien-local/src/docker_connection.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/alien-local/src/docker_connection.rs b/crates/alien-local/src/docker_connection.rs index 1c6b5f8c2d..46fa48e4a8 100644 --- a/crates/alien-local/src/docker_connection.rs +++ b/crates/alien-local/src/docker_connection.rs @@ -526,6 +526,13 @@ mod tests { .expect("container client selects context"); crate::LocalSandboxManager::new(state.path().join("sandboxes")) .expect("sandbox client selects context"); + assert_eq!( + crate::docker_network::bindable_docker_bridge_gateway() + .await + .expect("bridge discovery on dedicated engine"), + None, + "dedicated fixture must have no bridge" + ); let docker = connect_docker().expect("selected endpoint initializes"); docker.ping().await.expect("selected engine responds"); let version = docker.version().await.expect("engine version"); From cbaf1278b55a2c5d4a83e8db205b19abf33352e3 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 6 Oct 2026 22:18:55 +0000 Subject: [PATCH 5/8] fix: match Docker CLI precedence and inspect named contexts --- Cargo.lock | 3 - crates/alien-local/Cargo.toml | 3 - crates/alien-local/README.md | 19 +- crates/alien-local/src/docker_connection.rs | 198 ++++++++++++-------- 4 files changed, 135 insertions(+), 88 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ea09fd953c..53fb37b78f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -935,7 +935,6 @@ dependencies = [ "chrono", "container-registry 0.3.1 (git+https://github.com/alienplatform/container_registry-rs.git?rev=fd28e7c)", "cron", - "dirs 6.0.0", "dockdash", "futures", "futures-util", @@ -951,7 +950,6 @@ dependencies = [ "sec", "serde", "serde_json", - "sha2 0.10.9", "tar", "tempfile", "thiserror 2.0.20", @@ -962,7 +960,6 @@ dependencies = [ "tracing", "tracing-subscriber", "turso", - "url", "urlencoding", "uuid", "workspace_root", diff --git a/crates/alien-local/Cargo.toml b/crates/alien-local/Cargo.toml index 01d016718a..d72a3b7c9c 100644 --- a/crates/alien-local/Cargo.toml +++ b/crates/alien-local/Cargo.toml @@ -40,9 +40,6 @@ object_store = { workspace = true, features = ["http"] } # Docker client bollard = { workspace = true } -sha2 = { workspace = true } -dirs = { workspace = true } -url = { workspace = true } axum = { workspace = true, features = ["tokio", "http1", "json", "query"] } base64 = { workspace = true } bytes = { workspace = true } diff --git a/crates/alien-local/README.md b/crates/alien-local/README.md index f95d77941f..7d9645a37e 100644 --- a/crates/alien-local/README.md +++ b/crates/alien-local/README.md @@ -19,25 +19,28 @@ Used by `alien dev` for local development, and deployable to any machine via `al Local container and sandbox API clients read `DOCKER_CONFIG` (or the home `.docker` directory) and select an endpoint in this order: -1. A nonempty `DOCKER_CONTEXT`. -2. A nonempty `DOCKER_HOST`. +1. A nonempty `DOCKER_HOST`. +2. A nonempty `DOCKER_CONTEXT`. 3. `currentContext` in `config.json`. 4. Docker's platform default socket or named pipe. The `default` context is synthetic and honors `DOCKER_HOST`. Named contexts use -Docker's context metadata and ignore environment TLS options. Missing or invalid +Docker CLI's `context inspect` output and ignore environment TLS options. Missing or invalid selected context metadata fails with a structured error; another daemon is never -tried. This follows the documented context-first precedence; Docker CLI versions -that prefer `DOCKER_HOST` when both variables are set differ in that case. +tried. This matches Docker CLI's source and installed behavior, including host-first +precedence when both environment variables are set. Unix sockets, Windows named pipes, and plaintext TCP endpoints are supported. -SSH, TLS environment settings, and contexts with TLS material or verification -settings return actionable unsupported-transport errors. TLS is never silently +SSH, TLS environment settings, and TCP contexts with TLS material or verification +settings return actionable unsupported-transport errors. Socket contexts ignore +TLS verification settings, as Docker CLI does. TLS is never silently removed. Image loading through Docker CLI is pinned to the endpoint captured by the container API client, including after the active context changes. Docker bridge discovery remains optional for native services on hosts without a -bindable bridge. Configuration and unsupported-transport errors still propagate. +bindable bridge. Configuration and unsupported-transport errors still propagate. Native services +using the default socket do not require Docker CLI; named contexts require the +CLI to inspect their metadata without duplicating its context-store format. To validate selection against a real engine, create a dedicated empty daemon with its own Unix socket, data directory, execution directory, and PID file. Use an diff --git a/crates/alien-local/src/docker_connection.rs b/crates/alien-local/src/docker_connection.rs index 46fa48e4a8..6b00cc5ad0 100644 --- a/crates/alien-local/src/docker_connection.rs +++ b/crates/alien-local/src/docker_connection.rs @@ -1,17 +1,11 @@ //! Docker endpoint selection from the documented context and host settings. -use std::{ - collections::HashMap, - env, fs, - io::ErrorKind, - path::{Path, PathBuf}, -}; +use std::{collections::HashMap, env, fs, io::ErrorKind, path::PathBuf, process::Command}; use alien_error::{AlienError, Context, IntoAlienError}; use bollard::{Docker, API_DEFAULT_VERSION}; +use reqwest::Url; use serde::Deserialize; -use sha2::{Digest, Sha256}; -use url::Url; use crate::{ErrorData, Result}; @@ -78,13 +72,20 @@ impl DockerEnvironment { }; let config_dir = match value("DOCKER_CONFIG")? { Some(path) => PathBuf::from(path), - None => dirs::home_dir() - .ok_or_else(|| { - AlienError::new(config_error( - "Set DOCKER_CONFIG: home directory is unavailable".into(), - )) - })? - .join(".docker"), + None => { + // Docker CLI uses os.UserHomeDir: HOME on Unix, USERPROFILE on Windows. + let key = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + PathBuf::from( + env::var_os(key) + .filter(|home| !home.is_empty()) + .ok_or_else(|| { + AlienError::new(config_error( + "Set DOCKER_CONFIG: home directory is unavailable".into(), + )) + })?, + ) + .join(".docker") + } }; Ok(Self { config_dir, @@ -95,16 +96,14 @@ impl DockerEnvironment { } fn selected_context(&self) -> Result { - // DOCKER_CONTEXT overrides DOCKER_HOST; a host override in turn bypasses - // the persisted currentContext. Explicit "default" still honors HOST. - // Some CLI versions prefer HOST when both variables are set; pin our - // CLI subprocess endpoint rather than re-resolving that ambiguity. - if let Some(context) = &self.context { - return Ok(context.clone()); - } + // Match Docker CLI resolveContextName: HOST selects the synthetic + // default context before CONTEXT or the persisted currentContext. if self.host.is_some() { return Ok("default".into()); } + if let Some(context) = &self.context { + return Ok(context.clone()); + } let path = self.config_dir.join("config.json"); match fs::read(&path) { Ok(bytes) => Ok(serde_json::from_slice::(&bytes) @@ -127,23 +126,43 @@ impl DockerEnvironment { fn endpoint_for_context(&self, context: &str) -> Result { if context.is_empty() || context == "default" { - if self.tls { - return Err(AlienError::new(unsupported("TLS", "Select a Unix/npipe or plaintext TCP endpoint, or use the Docker CLI for TLS operations"))); + let host = self.host.clone().unwrap_or_else(default_host); + if self.tls && !is_socket(&host) { + return Err(AlienError::new(unsupported( + "TLS", + "Use the Docker CLI for TLS operations, or select a local socket endpoint", + ))); } - return Ok(self.host.clone().unwrap_or_else(default_host)); + return Ok(host); } - let id = format!("{:x}", Sha256::digest(context.as_bytes())); - let path = self - .config_dir - .join("contexts/meta") - .join(&id) - .join("meta.json"); - let metadata: ContextMetadata = read_json(&path)?; + // Let the CLI own its context-store format and TLS material discovery. + // Native services using the default endpoint need no Docker executable. + let output = Command::new("docker") + .env("DOCKER_CONFIG", &self.config_dir) + .env_remove("DOCKER_HOST") + .env_remove("DOCKER_CONTEXT") + .env_remove("DOCKER_TLS") + .env_remove("DOCKER_TLS_VERIFY") + .env_remove("DOCKER_CERT_PATH") + .args(["context", "inspect", context, "--format", "{{json .}}"]) + .output() + .into_alien_error() + .context(config_error("Cannot inspect selected Docker context; install Docker CLI and select an existing context".into()))?; + if !output.status.success() { + // Do not expose CLI stderr: context metadata can contain credentials. + return Err(std::io::Error::other(format!("docker context inspect exited with {}", output.status))) + .into_alien_error() + .context(config_error(format!("Cannot inspect Docker context '{context}'; select an existing context with 'docker context use'"))); + } + let metadata: ContextMetadata = serde_json::from_slice(&output.stdout) + .into_alien_error() + .context(config_error( + "Docker CLI returned invalid context metadata".into(), + ))?; if metadata.name != context { - return Err(AlienError::new(config_error(format!( - "Context metadata at {} does not match selected context '{context}'", - path.display() - )))); + return Err(AlienError::new(config_error( + "Docker CLI returned a different context than requested".into(), + ))); } let endpoint = metadata.endpoints.get("docker").ok_or_else(|| { AlienError::new(config_error(format!( @@ -155,27 +174,14 @@ impl DockerEnvironment { "Context '{context}' has an empty Docker endpoint" )))); } - let tls_path = self.config_dir.join("contexts/tls").join(id).join("docker"); - let has_tls = match fs::read_dir(&tls_path) { - Ok(mut files) => files - .next() - .transpose() - .into_alien_error() - .context(config_error(format!( - "Cannot inspect TLS material at {}", - tls_path.display() - )))? - .is_some(), - Err(error) if error.kind() == ErrorKind::NotFound => false, - Err(error) => { - return Err(error).into_alien_error().context(config_error(format!( - "Cannot inspect TLS material at {}", - tls_path.display() - ))) - } - }; - if has_tls || endpoint.skip_tls_verify { - return Err(AlienError::new(unsupported("TLS context", "Use the Docker CLI for this context; context TLS verification settings and certificates cannot be honored by this client"))); + // Docker CLI bypasses TLS configuration for Unix sockets/named pipes. + // For TCP, SkipTLSVerify requests TLS even without certificate files. + let has_tls = metadata + .tls_material + .get("docker") + .is_some_and(|files| !files.is_empty()); + if !is_socket(&endpoint.host) && (has_tls || endpoint.skip_tls_verify) { + return Err(AlienError::new(unsupported("TLS context", "Use the Docker CLI for this context; its TLS settings and certificates cannot be honored by this client"))); } Ok(endpoint.host.clone()) } @@ -186,6 +192,10 @@ impl DockerEnvironment { } } +fn is_socket(host: &str) -> bool { + host.starts_with("unix://") || host.starts_with("npipe://") +} + fn default_host() -> String { #[cfg(windows)] return "npipe:////./pipe/docker_engine".into(); @@ -254,16 +264,6 @@ fn unsupported(transport: &str, message: &str) -> ErrorData { } } -fn read_json(path: &Path) -> Result { - let bytes = fs::read(path).into_alien_error().context(config_error(format!("Cannot read selected Docker context at {}; select an existing context with 'docker context use'", path.display())))?; - serde_json::from_slice(&bytes) - .into_alien_error() - .context(config_error(format!( - "Invalid Docker context metadata at {}", - path.display() - ))) -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CliConfig { @@ -279,6 +279,8 @@ struct ContextMetadata { name: String, #[serde(rename = "Endpoints")] endpoints: HashMap, + #[serde(default, rename = "TLSMaterial")] + tls_material: HashMap>, } #[derive(Deserialize)] @@ -307,8 +309,20 @@ mod tests { (directory, environment) } + // Fixture directory names from Docker's public SHA256 context-store format. + fn context_id(name: &str) -> &'static str { + match name { + "stored" => "87b04e58961f9a99d853d4046a0b5b793e7c3e4bbd21f5aca8fb17c20cdb1d8b", + "explicit" => "3b283e93debf035e990dfce1f21468476dc57c69313c5574f43ad1a185840277", + "missing" => "ffa63583dfa6706b87d284b86b0d693a161e4840aad2c5cf6b5d27c3b9621f7d", + "secure" => "6a934b45144e3758911efa29ed68fb2d420fa7bd568739cdcda9251fa9609b1e", + "fixture" => "f16d05ec6b29248d2c61adb1e9263f78e4f7bace1b955014a2d17872cfe4064d", + _ => panic!("unknown fixture context"), + } + } + fn write_context(environment: &DockerEnvironment, name: &str, host: &str) { - let id = format!("{:x}", Sha256::digest(name.as_bytes())); + let id = context_id(name); let directory = environment.config_dir.join("contexts/meta").join(id); fs::create_dir_all(&directory).unwrap(); fs::write(directory.join("meta.json"), serde_json::to_vec(&serde_json::json!({"Name": name, "Endpoints": {"docker": {"Host": host, "SkipTLSVerify": false}}})).unwrap()).unwrap(); @@ -328,8 +342,11 @@ mod tests { environment.host = Some("tcp://localhost:1234".into()); assert_eq!(environment.endpoint().unwrap(), "tcp://localhost:1234"); environment.context = Some("explicit".into()); + assert_eq!(environment.endpoint().unwrap(), "tcp://localhost:1234"); + environment.host = None; environment.tls = true; // Named contexts ignore environment TLS options. assert_eq!(environment.endpoint().unwrap(), "unix:///explicit.sock"); + environment.host = Some("tcp://localhost:1234".into()); environment.context = Some("default".into()); assert!(matches!( environment.endpoint().unwrap_err().error, @@ -355,7 +372,7 @@ mod tests { )); assert!(std::error::Error::source(&error).is_some()); environment.context = Some("missing".into()); - environment.host = Some("unix:///other.sock".into()); + let error = environment.endpoint().unwrap_err(); assert!(matches!( error.error, @@ -367,7 +384,7 @@ mod tests { environment.endpoint().unwrap_err().error, Some(ErrorData::DockerConfigurationInvalid { .. }) )); - let id = format!("{:x}", Sha256::digest(b"missing")); + let id = context_id("missing"); fs::write( environment .config_dir @@ -384,7 +401,7 @@ mod tests { let path = environment .config_dir .join("contexts/meta") - .join(format!("{:x}", Sha256::digest(b"missing"))) + .join(context_id("missing")) .join("meta.json"); for metadata in [ "invalid json", @@ -404,7 +421,7 @@ mod tests { let (_directory, mut environment) = fixture(); environment.context = Some("secure".into()); write_context(&environment, "secure", "tcp://localhost:2376"); - let id = format!("{:x}", Sha256::digest(b"secure")); + let id = context_id("secure"); let directory = environment .config_dir .join("contexts/tls") @@ -435,6 +452,28 @@ mod tests { } } + #[test] + fn socket_context_ignores_tls_verification_settings() { + let (_directory, mut environment) = fixture(); + environment.context = Some("secure".into()); + environment.tls = true; + let path = environment + .config_dir + .join("contexts/meta") + .join(context_id("secure")) + .join("meta.json"); + write_context(&environment, "secure", "unix:///fixture.sock"); + fs::write(&path, r#"{"Name":"secure","Endpoints":{"docker":{"Host":"unix:///fixture.sock","SkipTLSVerify":true}}}"#).unwrap(); + assert_eq!(environment.endpoint().unwrap(), "unix:///fixture.sock"); + // For TCP, Docker CLI tlsConfig enables TLS when SkipTLSVerify is true, + // including when no certificate files are present. Never downgrade it. + fs::write(&path, r#"{"Name":"secure","Endpoints":{"docker":{"Host":"tcp://localhost:2376","SkipTLSVerify":true}}}"#).unwrap(); + assert_eq!( + environment.endpoint().unwrap_err().code, + "DOCKER_TRANSPORT_UNSUPPORTED" + ); + } + #[cfg(unix)] #[tokio::test] async fn selected_unix_context_sends_real_api_request() { @@ -522,10 +561,21 @@ mod tests { #[ignore = "requires a dedicated Docker engine and isolated Docker environment"] async fn dedicated_engine_proof() { let state = TempDir::new().unwrap(); - crate::LocalContainerManager::new(state.path().join("containers")) + let containers = crate::LocalContainerManager::new(state.path().join("containers")) .expect("container client selects context"); - crate::LocalSandboxManager::new(state.path().join("sandboxes")) + assert!(containers + .runtime_status() + .await + .expect("container manager API") + .docker_version + .is_some()); + let sandboxes = crate::LocalSandboxManager::new(state.path().join("sandboxes")) .expect("sandbox client selects context"); + assert!(sandboxes + .list_sessions("fixture") + .await + .expect("sandbox manager API") + .is_empty()); assert_eq!( crate::docker_network::bindable_docker_bridge_gateway() .await From 2933e20c24cbf0f1a8de270b1c9b46e47c22054a Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 6 Oct 2026 22:28:40 +0000 Subject: [PATCH 6/8] fix: resolve Docker configuration only when selection needs it --- crates/alien-local/src/docker_connection.rs | 97 +++++++++++++++------ 1 file changed, 70 insertions(+), 27 deletions(-) diff --git a/crates/alien-local/src/docker_connection.rs b/crates/alien-local/src/docker_connection.rs index 6b00cc5ad0..005de47778 100644 --- a/crates/alien-local/src/docker_connection.rs +++ b/crates/alien-local/src/docker_connection.rs @@ -53,7 +53,7 @@ pub(crate) fn docker_command(endpoint: &str) -> tokio::process::Command { #[derive(Default)] struct DockerEnvironment { - config_dir: PathBuf, + config_dir: Option, context: Option, host: Option, tls: bool, @@ -70,27 +70,31 @@ impl DockerEnvironment { .context(config_error(format!("{key} must contain Unicode"))), } }; - let config_dir = match value("DOCKER_CONFIG")? { - Some(path) => PathBuf::from(path), - None => { - // Docker CLI uses os.UserHomeDir: HOME on Unix, USERPROFILE on Windows. - let key = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; - PathBuf::from( - env::var_os(key) + let host = value("DOCKER_HOST")?; + // HOST wins before CONTEXT and does not need any configuration directory. + let context = if host.is_none() { + value("DOCKER_CONTEXT")? + } else { + None + }; + let config_dir = if host.is_some() || context.as_deref() == Some("default") { + None + } else { + // Docker CLI uses os.UserHomeDir: HOME on Unix, USERPROFILE on Windows. + let home_key = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + env::var_os("DOCKER_CONFIG") + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .or_else(|| { + env::var_os(home_key) .filter(|home| !home.is_empty()) - .ok_or_else(|| { - AlienError::new(config_error( - "Set DOCKER_CONFIG: home directory is unavailable".into(), - )) - })?, - ) - .join(".docker") - } + .map(|home| PathBuf::from(home).join(".docker")) + }) }; Ok(Self { config_dir, - context: value("DOCKER_CONTEXT")?, - host: value("DOCKER_HOST")?, + context, + host, tls: value("DOCKER_TLS")?.is_some() || value("DOCKER_TLS_VERIFY")?.is_some(), }) } @@ -104,7 +108,10 @@ impl DockerEnvironment { if let Some(context) = &self.context { return Ok(context.clone()); } - let path = self.config_dir.join("config.json"); + let Some(config_dir) = &self.config_dir else { + return Ok(String::new()); + }; + let path = config_dir.join("config.json"); match fs::read(&path) { Ok(bytes) => Ok(serde_json::from_slice::(&bytes) .into_alien_error() @@ -137,8 +144,11 @@ impl DockerEnvironment { } // Let the CLI own its context-store format and TLS material discovery. // Native services using the default endpoint need no Docker executable. + let config_dir = self.config_dir.as_ref().ok_or_else(|| AlienError::new(config_error( + "Set DOCKER_CONFIG to inspect the selected named context when the home directory is unavailable".into(), + )))?; let output = Command::new("docker") - .env("DOCKER_CONFIG", &self.config_dir) + .env("DOCKER_CONFIG", config_dir) .env_remove("DOCKER_HOST") .env_remove("DOCKER_CONTEXT") .env_remove("DOCKER_TLS") @@ -303,12 +313,28 @@ mod tests { fn fixture() -> (TempDir, DockerEnvironment) { let directory = TempDir::new().expect("temporary Docker config"); let environment = DockerEnvironment { - config_dir: directory.path().into(), + config_dir: Some(directory.path().into()), ..Default::default() }; (directory, environment) } + #[test] + fn absent_configuration_does_not_block_host_or_native_default() { + let mut environment = DockerEnvironment::default(); + assert_eq!(environment.endpoint().unwrap(), default_host()); + environment.host = Some("tcp://localhost:1234".into()); + environment.context = Some("missing".into()); + assert_eq!(environment.endpoint().unwrap(), "tcp://localhost:1234"); + environment.host = None; + assert_eq!( + environment.endpoint().unwrap_err().code, + "DOCKER_CONFIGURATION_INVALID" + ); + environment.context = Some("default".into()); + assert_eq!(environment.endpoint().unwrap(), default_host()); + } + // Fixture directory names from Docker's public SHA256 context-store format. fn context_id(name: &str) -> &'static str { match name { @@ -323,7 +349,12 @@ mod tests { fn write_context(environment: &DockerEnvironment, name: &str, host: &str) { let id = context_id(name); - let directory = environment.config_dir.join("contexts/meta").join(id); + let directory = environment + .config_dir + .as_ref() + .unwrap() + .join("contexts/meta") + .join(id); fs::create_dir_all(&directory).unwrap(); fs::write(directory.join("meta.json"), serde_json::to_vec(&serde_json::json!({"Name": name, "Endpoints": {"docker": {"Host": host, "SkipTLSVerify": false}}})).unwrap()).unwrap(); } @@ -334,7 +365,7 @@ mod tests { write_context(&environment, "stored", "unix:///stored.sock"); write_context(&environment, "explicit", "unix:///explicit.sock"); fs::write( - environment.config_dir.join("config.json"), + environment.config_dir.as_ref().unwrap().join("config.json"), r#"{"currentContext":"stored"}"#, ) .unwrap(); @@ -357,14 +388,18 @@ mod tests { environment.host = None; assert_eq!(environment.endpoint().unwrap(), default_host()); environment.context = None; - fs::remove_file(environment.config_dir.join("config.json")).unwrap(); + fs::remove_file(environment.config_dir.as_ref().unwrap().join("config.json")).unwrap(); assert_eq!(environment.endpoint().unwrap(), default_host()); } #[test] fn invalid_selected_configuration_never_falls_back() { let (_directory, mut environment) = fixture(); - fs::write(environment.config_dir.join("config.json"), b"invalid json").unwrap(); + fs::write( + environment.config_dir.as_ref().unwrap().join("config.json"), + b"invalid json", + ) + .unwrap(); let error = environment.endpoint().unwrap_err(); assert!(matches!( error.error, @@ -388,6 +423,8 @@ mod tests { fs::write( environment .config_dir + .as_ref() + .unwrap() .join("contexts/meta") .join(id) .join("meta.json"), @@ -400,6 +437,8 @@ mod tests { )); let path = environment .config_dir + .as_ref() + .unwrap() .join("contexts/meta") .join(context_id("missing")) .join("meta.json"); @@ -424,6 +463,8 @@ mod tests { let id = context_id("secure"); let directory = environment .config_dir + .as_ref() + .unwrap() .join("contexts/tls") .join(id) .join("docker"); @@ -459,6 +500,8 @@ mod tests { environment.tls = true; let path = environment .config_dir + .as_ref() + .unwrap() .join("contexts/meta") .join(context_id("secure")) .join("meta.json"); @@ -478,7 +521,7 @@ mod tests { #[tokio::test] async fn selected_unix_context_sends_real_api_request() { let (_directory, environment) = fixture(); - let socket = environment.config_dir.join("engine.sock"); + let socket = environment.config_dir.as_ref().unwrap().join("engine.sock"); let listener = UnixListener::bind(&socket).unwrap(); write_context( &environment, @@ -486,7 +529,7 @@ mod tests { &format!("unix://{}", socket.display()), ); fs::write( - environment.config_dir.join("config.json"), + environment.config_dir.as_ref().unwrap().join("config.json"), r#"{"currentContext":"fixture"}"#, ) .unwrap(); From cda5a0565b92152073be784d23184784eb39013c Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 6 Oct 2026 22:31:29 +0000 Subject: [PATCH 7/8] test: inject context inspection into Docker unit tests --- crates/alien-local/src/docker_connection.rs | 234 +++++++++++--------- 1 file changed, 134 insertions(+), 100 deletions(-) diff --git a/crates/alien-local/src/docker_connection.rs b/crates/alien-local/src/docker_connection.rs index 005de47778..760ecf6a7b 100644 --- a/crates/alien-local/src/docker_connection.rs +++ b/crates/alien-local/src/docker_connection.rs @@ -1,6 +1,12 @@ //! Docker endpoint selection from the documented context and host settings. -use std::{collections::HashMap, env, fs, io::ErrorKind, path::PathBuf, process::Command}; +use std::{ + collections::HashMap, + env, fs, + io::ErrorKind, + path::{Path, PathBuf}, + process::Command, +}; use alien_error::{AlienError, Context, IntoAlienError}; use bollard::{Docker, API_DEFAULT_VERSION}; @@ -30,7 +36,7 @@ pub(crate) fn connect_optional_docker() -> Result> { let unconfigured_default = environment.context.is_none() && environment.host.is_none() && (context.is_empty() || context == "default"); - let endpoint = environment.endpoint_for_context(&context)?; + let endpoint = environment.endpoint_for_context(&context, inspect_context)?; match connect_endpoint(&endpoint) { Ok(docker) => Ok(Some(docker)), Err(error) if unconfigured_default && error.code == "DOCKER_CONNECTION_FAILED" => Ok(None), @@ -128,10 +134,21 @@ impl DockerEnvironment { } fn endpoint(&self) -> Result { - self.endpoint_for_context(&self.selected_context()?) + self.endpoint_with(inspect_context) } - fn endpoint_for_context(&self, context: &str) -> Result { + fn endpoint_with( + &self, + inspect: impl FnOnce(&Path, &str) -> Result>, + ) -> Result { + self.endpoint_for_context(&self.selected_context()?, inspect) + } + + fn endpoint_for_context( + &self, + context: &str, + inspect: impl FnOnce(&Path, &str) -> Result>, + ) -> Result { if context.is_empty() || context == "default" { let host = self.host.clone().unwrap_or_else(default_host); if self.tls && !is_socket(&host) { @@ -147,28 +164,11 @@ impl DockerEnvironment { let config_dir = self.config_dir.as_ref().ok_or_else(|| AlienError::new(config_error( "Set DOCKER_CONFIG to inspect the selected named context when the home directory is unavailable".into(), )))?; - let output = Command::new("docker") - .env("DOCKER_CONFIG", config_dir) - .env_remove("DOCKER_HOST") - .env_remove("DOCKER_CONTEXT") - .env_remove("DOCKER_TLS") - .env_remove("DOCKER_TLS_VERIFY") - .env_remove("DOCKER_CERT_PATH") - .args(["context", "inspect", context, "--format", "{{json .}}"]) - .output() - .into_alien_error() - .context(config_error("Cannot inspect selected Docker context; install Docker CLI and select an existing context".into()))?; - if !output.status.success() { - // Do not expose CLI stderr: context metadata can contain credentials. - return Err(std::io::Error::other(format!("docker context inspect exited with {}", output.status))) - .into_alien_error() - .context(config_error(format!("Cannot inspect Docker context '{context}'; select an existing context with 'docker context use'"))); - } - let metadata: ContextMetadata = serde_json::from_slice(&output.stdout) + let metadata: ContextMetadata = serde_json::from_slice(&inspect(config_dir, context)?) .into_alien_error() .context(config_error( - "Docker CLI returned invalid context metadata".into(), - ))?; + "Docker CLI returned invalid context metadata".into(), + ))?; if metadata.name != context { return Err(AlienError::new(config_error( "Docker CLI returned a different context than requested".into(), @@ -197,9 +197,30 @@ impl DockerEnvironment { } #[cfg(test)] - fn connect(&self) -> Result { - connect_endpoint(&self.endpoint()?) + fn connect_with(&self, inspect: impl FnOnce(&Path, &str) -> Result>) -> Result { + connect_endpoint(&self.endpoint_with(inspect)?) + } +} + +fn inspect_context(config_dir: &Path, context: &str) -> Result> { + let output = Command::new("docker") + .env("DOCKER_CONFIG", config_dir) + .env_remove("DOCKER_HOST") + .env_remove("DOCKER_CONTEXT") + .env_remove("DOCKER_TLS") + .env_remove("DOCKER_TLS_VERIFY") + .env_remove("DOCKER_CERT_PATH") + .args(["context", "inspect", context, "--format", "{{json .}}"]) + .output() + .into_alien_error() + .context(config_error("Cannot inspect selected Docker context; install Docker CLI and select an existing context".into()))?; + if !output.status.success() { + // Do not expose CLI stderr: context metadata can contain credentials. + return Err(std::io::Error::other(format!("docker context inspect exited with {}", output.status))) + .into_alien_error() + .context(config_error(format!("Cannot inspect Docker context '{context}'; select an existing context with 'docker context use'"))); } + Ok(output.stdout) } fn is_socket(host: &str) -> bool { @@ -322,41 +343,39 @@ mod tests { #[test] fn absent_configuration_does_not_block_host_or_native_default() { let mut environment = DockerEnvironment::default(); - assert_eq!(environment.endpoint().unwrap(), default_host()); + assert_eq!( + environment.endpoint_with(fixture_inspect).unwrap(), + default_host() + ); environment.host = Some("tcp://localhost:1234".into()); environment.context = Some("missing".into()); - assert_eq!(environment.endpoint().unwrap(), "tcp://localhost:1234"); + assert_eq!( + environment.endpoint_with(fixture_inspect).unwrap(), + "tcp://localhost:1234" + ); environment.host = None; assert_eq!( - environment.endpoint().unwrap_err().code, + environment.endpoint_with(fixture_inspect).unwrap_err().code, "DOCKER_CONFIGURATION_INVALID" ); environment.context = Some("default".into()); - assert_eq!(environment.endpoint().unwrap(), default_host()); + assert_eq!( + environment.endpoint_with(fixture_inspect).unwrap(), + default_host() + ); } - // Fixture directory names from Docker's public SHA256 context-store format. - fn context_id(name: &str) -> &'static str { - match name { - "stored" => "87b04e58961f9a99d853d4046a0b5b793e7c3e4bbd21f5aca8fb17c20cdb1d8b", - "explicit" => "3b283e93debf035e990dfce1f21468476dc57c69313c5574f43ad1a185840277", - "missing" => "ffa63583dfa6706b87d284b86b0d693a161e4840aad2c5cf6b5d27c3b9621f7d", - "secure" => "6a934b45144e3758911efa29ed68fb2d420fa7bd568739cdcda9251fa9609b1e", - "fixture" => "f16d05ec6b29248d2c61adb1e9263f78e4f7bace1b955014a2d17872cfe4064d", - _ => panic!("unknown fixture context"), - } + fn fixture_inspect(directory: &Path, context: &str) -> Result> { + fs::read(directory.join(format!("{context}.json"))) + .into_alien_error() + .context(config_error( + "Context inspection fixture is unavailable".into(), + )) } fn write_context(environment: &DockerEnvironment, name: &str, host: &str) { - let id = context_id(name); - let directory = environment - .config_dir - .as_ref() - .unwrap() - .join("contexts/meta") - .join(id); - fs::create_dir_all(&directory).unwrap(); - fs::write(directory.join("meta.json"), serde_json::to_vec(&serde_json::json!({"Name": name, "Endpoints": {"docker": {"Host": host, "SkipTLSVerify": false}}})).unwrap()).unwrap(); + let directory = environment.config_dir.as_ref().unwrap(); + fs::write(directory.join(format!("{name}.json")), serde_json::to_vec(&serde_json::json!({"Name": name, "Endpoints": {"docker": {"Host": host, "SkipTLSVerify": false}}})).unwrap()).unwrap(); } #[test] @@ -369,27 +388,51 @@ mod tests { r#"{"currentContext":"stored"}"#, ) .unwrap(); - assert_eq!(environment.endpoint().unwrap(), "unix:///stored.sock"); + assert_eq!( + environment.endpoint_with(fixture_inspect).unwrap(), + "unix:///stored.sock" + ); environment.host = Some("tcp://localhost:1234".into()); - assert_eq!(environment.endpoint().unwrap(), "tcp://localhost:1234"); + assert_eq!( + environment.endpoint_with(fixture_inspect).unwrap(), + "tcp://localhost:1234" + ); environment.context = Some("explicit".into()); - assert_eq!(environment.endpoint().unwrap(), "tcp://localhost:1234"); + assert_eq!( + environment.endpoint_with(fixture_inspect).unwrap(), + "tcp://localhost:1234" + ); environment.host = None; environment.tls = true; // Named contexts ignore environment TLS options. - assert_eq!(environment.endpoint().unwrap(), "unix:///explicit.sock"); + assert_eq!( + environment.endpoint_with(fixture_inspect).unwrap(), + "unix:///explicit.sock" + ); environment.host = Some("tcp://localhost:1234".into()); environment.context = Some("default".into()); assert!(matches!( - environment.endpoint().unwrap_err().error, + environment + .endpoint_with(fixture_inspect) + .unwrap_err() + .error, Some(ErrorData::DockerTransportUnsupported { .. }) )); environment.tls = false; - assert_eq!(environment.endpoint().unwrap(), "tcp://localhost:1234"); + assert_eq!( + environment.endpoint_with(fixture_inspect).unwrap(), + "tcp://localhost:1234" + ); environment.host = None; - assert_eq!(environment.endpoint().unwrap(), default_host()); + assert_eq!( + environment.endpoint_with(fixture_inspect).unwrap(), + default_host() + ); environment.context = None; fs::remove_file(environment.config_dir.as_ref().unwrap().join("config.json")).unwrap(); - assert_eq!(environment.endpoint().unwrap(), default_host()); + assert_eq!( + environment.endpoint_with(fixture_inspect).unwrap(), + default_host() + ); } #[test] @@ -400,7 +443,7 @@ mod tests { b"invalid json", ) .unwrap(); - let error = environment.endpoint().unwrap_err(); + let error = environment.endpoint_with(fixture_inspect).unwrap_err(); assert!(matches!( error.error, Some(ErrorData::DockerConfigurationInvalid { .. }) @@ -408,7 +451,7 @@ mod tests { assert!(std::error::Error::source(&error).is_some()); environment.context = Some("missing".into()); - let error = environment.endpoint().unwrap_err(); + let error = environment.endpoint_with(fixture_inspect).unwrap_err(); assert!(matches!( error.error, Some(ErrorData::DockerConfigurationInvalid { .. }) @@ -416,32 +459,22 @@ mod tests { assert!(std::error::Error::source(&error).is_some()); write_context(&environment, "missing", ""); assert!(matches!( - environment.endpoint().unwrap_err().error, - Some(ErrorData::DockerConfigurationInvalid { .. }) - )); - let id = context_id("missing"); - fs::write( environment - .config_dir - .as_ref() - .unwrap() - .join("contexts/meta") - .join(id) - .join("meta.json"), - r#"{"Name":"missing","Endpoints":{}}"#, - ) - .unwrap(); - assert!(matches!( - environment.endpoint().unwrap_err().error, + .endpoint_with(fixture_inspect) + .unwrap_err() + .error, Some(ErrorData::DockerConfigurationInvalid { .. }) )); let path = environment .config_dir .as_ref() .unwrap() - .join("contexts/meta") - .join(context_id("missing")) - .join("meta.json"); + .join("missing.json"); + fs::write(&path, r#"{"Name":"missing","Endpoints":{}}"#).unwrap(); + assert_eq!( + environment.endpoint_with(fixture_inspect).unwrap_err().code, + "DOCKER_CONFIGURATION_INVALID" + ); for metadata in [ "invalid json", "null", @@ -449,7 +482,7 @@ mod tests { r#"{"Name":"missing","Endpoints":{"docker":{"Host":42}}}"#, ] { fs::write(&path, metadata).unwrap(); - let error = environment.endpoint().unwrap_err(); + let error = environment.endpoint_with(fixture_inspect).unwrap_err(); assert_eq!(error.code, "DOCKER_CONFIGURATION_INVALID"); assert!(!error.retryable); } @@ -460,18 +493,10 @@ mod tests { let (_directory, mut environment) = fixture(); environment.context = Some("secure".into()); write_context(&environment, "secure", "tcp://localhost:2376"); - let id = context_id("secure"); - let directory = environment - .config_dir - .as_ref() - .unwrap() - .join("contexts/tls") - .join(id) - .join("docker"); - fs::create_dir_all(&directory).unwrap(); - fs::write(directory.join("ca.pem"), "fixture certificate").unwrap(); + fs::write(environment.config_dir.as_ref().unwrap().join("secure.json"), + serde_json::to_vec(&serde_json::json!({"Name":"secure","Endpoints":{"docker":{"Host":"tcp://localhost:2376"}},"TLSMaterial":{"docker":["ca.pem"]}})).unwrap()).unwrap(); assert!(matches!( - environment.connect().unwrap_err().error, + environment.connect_with(fixture_inspect).unwrap_err().error, Some(ErrorData::DockerTransportUnsupported { .. }) )); for host in ["ssh://localhost", "https://localhost:2376", "fd://3"] { @@ -498,21 +523,18 @@ mod tests { let (_directory, mut environment) = fixture(); environment.context = Some("secure".into()); environment.tls = true; - let path = environment - .config_dir - .as_ref() - .unwrap() - .join("contexts/meta") - .join(context_id("secure")) - .join("meta.json"); + let path = environment.config_dir.as_ref().unwrap().join("secure.json"); write_context(&environment, "secure", "unix:///fixture.sock"); fs::write(&path, r#"{"Name":"secure","Endpoints":{"docker":{"Host":"unix:///fixture.sock","SkipTLSVerify":true}}}"#).unwrap(); - assert_eq!(environment.endpoint().unwrap(), "unix:///fixture.sock"); + assert_eq!( + environment.endpoint_with(fixture_inspect).unwrap(), + "unix:///fixture.sock" + ); // For TCP, Docker CLI tlsConfig enables TLS when SkipTLSVerify is true, // including when no certificate files are present. Never downgrade it. fs::write(&path, r#"{"Name":"secure","Endpoints":{"docker":{"Host":"tcp://localhost:2376","SkipTLSVerify":true}}}"#).unwrap(); assert_eq!( - environment.endpoint().unwrap_err().code, + environment.endpoint_with(fixture_inspect).unwrap_err().code, "DOCKER_TRANSPORT_UNSUPPORTED" ); } @@ -549,7 +571,7 @@ mod tests { }); tokio::time::timeout( std::time::Duration::from_secs(5), - environment.connect().unwrap().ping(), + environment.connect_with(fixture_inspect).unwrap().ping(), ) .await .unwrap() @@ -672,6 +694,18 @@ mod tests { assert_eq!(String::from_utf8(output.stdout).unwrap().trim(), selected); } + /// Run with a cleared child environment and no Docker executable in PATH. + #[test] + #[ignore = "requires an isolated child environment without HOME or Docker configuration"] + fn native_default_without_home() { + assert!(env::var_os("HOME").is_none()); + assert!(env::var_os("USERPROFILE").is_none()); + assert!(env::var_os("DOCKER_CONFIG").is_none()); + let environment = DockerEnvironment::capture().expect("native default capture"); + assert_eq!(environment.endpoint().unwrap(), default_host()); + connect_optional_docker().expect("native optional Docker must not require a home"); + } + /// Validate production error capture in an isolated child process. #[test] #[ignore = "requires isolated invalid Docker configuration"] From f741eb072f968163919e811d6d9fc9b294c2b0ce Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 6 Oct 2026 22:32:20 +0000 Subject: [PATCH 8/8] docs: clarify Docker configuration and integration prerequisites --- crates/alien-local/README.md | 8 ++++++++ crates/alien-local/src/docker_connection.rs | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/alien-local/README.md b/crates/alien-local/README.md index 7d9645a37e..397ea534ad 100644 --- a/crates/alien-local/README.md +++ b/crates/alien-local/README.md @@ -51,3 +51,11 @@ containers. `dedicated_engine_baseline` verifies that Bollard's old local-defaul connector cannot select that engine. `dedicated_pinned_subprocess` verifies CLI selection after the persisted context changes. These fixtures perform read-only API operations and require no application builds or containers. + +Host overrides and native default selection work without `HOME`, `USERPROFILE`, +or `DOCKER_CONFIG`. A configuration directory is required only to inspect a +selected named context. Ordinary library tests inject context-inspection JSON +fixtures and do not require Docker CLI or modify process environment. The ignored +named-context integration proofs require Docker CLI, an isolated configuration, +and a dedicated empty engine. Separate child-process proofs validate host and +native default selection with a cleared environment and no Docker executable. diff --git a/crates/alien-local/src/docker_connection.rs b/crates/alien-local/src/docker_connection.rs index 760ecf6a7b..381fca05ae 100644 --- a/crates/alien-local/src/docker_connection.rs +++ b/crates/alien-local/src/docker_connection.rs @@ -623,7 +623,7 @@ mod tests { /// Run in a child process with an isolated Docker config and dedicated engine. /// This exercises the public production environment capture, not just inputs. #[tokio::test] - #[ignore = "requires a dedicated Docker engine and isolated Docker environment"] + #[ignore = "requires a dedicated empty engine; named contexts also require Docker CLI"] async fn dedicated_engine_proof() { let state = TempDir::new().unwrap(); let containers = crate::LocalContainerManager::new(state.path().join("containers")) @@ -728,7 +728,7 @@ mod tests { /// The old connector must fail to select the same isolated context engine. #[tokio::test] - #[ignore = "requires isolated config selecting a nondefault socket"] + #[ignore = "requires Docker CLI and isolated config selecting a dedicated nondefault socket"] async fn dedicated_engine_baseline() { let selected = connect_docker() .expect("selected endpoint")