Skip to main content

opencode_cloud_core/docker/
client.rs

1//! Docker client wrapper with connection handling
2//!
3//! This module provides a wrapped Docker client that handles connection
4//! errors gracefully and provides clear error messages.
5
6use bollard::Docker;
7use std::path::PathBuf;
8use std::time::Duration;
9
10use super::error::DockerError;
11use crate::host::{HostConfig, SshTunnel};
12
13/// Default Unix socket path used when `DOCKER_HOST` does not specify a socket.
14const DEFAULT_UNIX_SOCKET: &str = "/var/run/docker.sock";
15
16/// Docker client wrapper with connection handling
17pub struct DockerClient {
18    inner: Docker,
19    /// SSH tunnel for remote connections (kept alive for client lifetime)
20    _tunnel: Option<SshTunnel>,
21    /// Host name for remote connections (None = local)
22    host_name: Option<String>,
23    /// Connection info for raw HTTP calls that bypass Bollard models.
24    endpoint: DockerEndpoint,
25}
26
27/// Docker API endpoint details for raw HTTP calls.
28///
29/// We keep this alongside the Bollard client because some Docker API responses
30/// (notably `/system/df` in API v1.52+) do not deserialize cleanly into the
31/// Bollard-generated models. The CLI uses this endpoint to fetch and parse
32/// those responses directly.
33#[derive(Clone, Debug)]
34pub enum DockerEndpoint {
35    /// Unix domain socket path (local Docker).
36    Unix(PathBuf),
37    /// HTTP base URL (remote Docker via SSH tunnel).
38    Http(String),
39}
40
41impl DockerClient {
42    /// Create new client connecting to local Docker daemon
43    ///
44    /// Uses platform-appropriate socket (Unix socket on Linux/macOS).
45    /// Returns a clear error if Docker is not running or accessible.
46    pub fn new() -> Result<Self, DockerError> {
47        let endpoint = Self::resolve_local_endpoint();
48        let docker = Docker::connect_with_local_defaults()
49            .map_err(|e| DockerError::Connection(e.to_string()))?;
50
51        Ok(Self {
52            inner: docker,
53            _tunnel: None,
54            host_name: None,
55            endpoint,
56        })
57    }
58
59    /// Create client with custom timeout (in seconds)
60    ///
61    /// Use for long-running operations like image builds.
62    /// Default timeout is 120 seconds; build timeout should be 600+ seconds.
63    pub fn with_timeout(timeout_secs: u64) -> Result<Self, DockerError> {
64        let endpoint = Self::resolve_local_endpoint();
65        let docker = Docker::connect_with_local_defaults()
66            .map_err(|e| DockerError::Connection(e.to_string()))?
67            .with_timeout(Duration::from_secs(timeout_secs));
68
69        Ok(Self {
70            inner: docker,
71            _tunnel: None,
72            host_name: None,
73            endpoint,
74        })
75    }
76
77    /// Create client connecting to remote Docker daemon via SSH tunnel
78    ///
79    /// Establishes an SSH tunnel to the remote host and connects Bollard
80    /// to the forwarded local port.
81    ///
82    /// # Arguments
83    /// * `host` - Remote host configuration
84    /// * `host_name` - Name of the host (for display purposes)
85    pub async fn connect_remote(host: &HostConfig, host_name: &str) -> Result<Self, DockerError> {
86        // Create SSH tunnel
87        let tunnel = SshTunnel::new(host, host_name)
88            .map_err(|e| DockerError::Connection(format!("SSH tunnel failed: {e}")))?;
89        let endpoint = Self::endpoint_from_tunnel(&tunnel);
90
91        // Wait for tunnel to be ready with exponential backoff
92        tunnel
93            .wait_ready()
94            .await
95            .map_err(|e| DockerError::Connection(format!("SSH tunnel not ready: {e}")))?;
96
97        // Connect Bollard to the tunnel's local port
98        let docker_url = tunnel.docker_url();
99        tracing::debug!("Connecting to remote Docker via {}", docker_url);
100
101        // Retry connection with backoff (tunnel may need a moment)
102        let max_attempts = 3;
103        let mut last_err = None;
104
105        for attempt in 0..max_attempts {
106            if attempt > 0 {
107                let delay = Duration::from_millis(100 * 2u64.pow(attempt));
108                tracing::debug!("Retry attempt {} after {:?}", attempt + 1, delay);
109                tokio::time::sleep(delay).await;
110            }
111
112            match Docker::connect_with_http(&docker_url, 120, bollard::API_DEFAULT_VERSION) {
113                Ok(docker) => {
114                    // Verify connection works
115                    match docker.ping().await {
116                        Ok(_) => {
117                            tracing::info!("Connected to Docker on {} via SSH tunnel", host_name);
118                            return Ok(Self {
119                                inner: docker,
120                                _tunnel: Some(tunnel),
121                                host_name: Some(host_name.to_string()),
122                                endpoint,
123                            });
124                        }
125                        Err(e) => {
126                            tracing::debug!("Ping failed: {}", e);
127                            last_err = Some(e.to_string());
128                        }
129                    }
130                }
131                Err(e) => {
132                    tracing::debug!("Connection failed: {}", e);
133                    last_err = Some(e.to_string());
134                }
135            }
136        }
137
138        Err(DockerError::Connection(format!(
139            "Failed to connect to Docker on {}: {}",
140            host_name,
141            last_err.unwrap_or_else(|| "unknown error".to_string())
142        )))
143    }
144
145    /// Create remote client with custom timeout
146    pub async fn connect_remote_with_timeout(
147        host: &HostConfig,
148        host_name: &str,
149        timeout_secs: u64,
150    ) -> Result<Self, DockerError> {
151        let tunnel = SshTunnel::new(host, host_name)
152            .map_err(|e| DockerError::Connection(format!("SSH tunnel failed: {e}")))?;
153        let endpoint = Self::endpoint_from_tunnel(&tunnel);
154
155        tunnel
156            .wait_ready()
157            .await
158            .map_err(|e| DockerError::Connection(format!("SSH tunnel not ready: {e}")))?;
159
160        let docker_url = tunnel.docker_url();
161
162        let docker =
163            Docker::connect_with_http(&docker_url, timeout_secs, bollard::API_DEFAULT_VERSION)
164                .map_err(|e| DockerError::Connection(e.to_string()))?;
165
166        // Verify connection
167        docker.ping().await.map_err(DockerError::from)?;
168
169        Ok(Self {
170            inner: docker,
171            _tunnel: Some(tunnel),
172            host_name: Some(host_name.to_string()),
173            endpoint,
174        })
175    }
176
177    /// Verify connection to Docker daemon
178    ///
179    /// Returns Ok(()) if connected, descriptive error otherwise.
180    pub async fn verify_connection(&self) -> Result<(), DockerError> {
181        self.inner.ping().await.map_err(DockerError::from)?;
182        Ok(())
183    }
184
185    /// Get Docker version info (useful for debugging)
186    pub async fn version(&self) -> Result<String, DockerError> {
187        let version = self.inner.version().await.map_err(DockerError::from)?;
188
189        let version_str = format!(
190            "Docker {} (API {})",
191            version.version.unwrap_or_else(|| "unknown".to_string()),
192            version.api_version.unwrap_or_else(|| "unknown".to_string())
193        );
194
195        Ok(version_str)
196    }
197
198    /// Get the host name if this is a remote connection
199    pub fn host_name(&self) -> Option<&str> {
200        self.host_name.as_deref()
201    }
202
203    /// Check if this is a remote connection
204    pub fn is_remote(&self) -> bool {
205        self._tunnel.is_some()
206    }
207
208    /// Return the endpoint details used for raw Docker API calls.
209    ///
210    /// This exists to support endpoints whose response schemas are newer than
211    /// the Bollard-generated models (e.g., `/system/df` in newer Docker APIs).
212    pub fn endpoint(&self) -> &DockerEndpoint {
213        &self.endpoint
214    }
215
216    /// Access inner Bollard client for advanced operations
217    pub fn inner(&self) -> &Docker {
218        &self.inner
219    }
220
221    /// Resolve the local Unix socket path used by the Docker client.
222    ///
223    /// Bollard's `connect_with_local_defaults` only honors `DOCKER_HOST` when it
224    /// starts with `unix://`. We mirror that logic so our raw HTTP calls use the
225    /// same socket, which is necessary because Bollard's `/system/df` models
226    /// don't match newer Docker API responses.
227    fn resolve_local_endpoint() -> DockerEndpoint {
228        let socket = std::env::var("DOCKER_HOST")
229            .ok()
230            .and_then(|host| host.strip_prefix("unix://").map(|path| path.to_string()))
231            .unwrap_or_else(|| DEFAULT_UNIX_SOCKET.to_string());
232        DockerEndpoint::Unix(PathBuf::from(socket))
233    }
234
235    /// Build an HTTP base URL for a Docker daemon reachable via SSH tunnel.
236    ///
237    /// We store this so the CLI can query `/system/df` directly when Bollard's
238    /// data-usage models are out of date.
239    fn endpoint_from_tunnel(tunnel: &SshTunnel) -> DockerEndpoint {
240        DockerEndpoint::Http(format!("http://127.0.0.1:{}", tunnel.local_port()))
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn docker_client_creation_does_not_panic() {
250        // This test just verifies the code compiles and doesn't panic
251        // Actual connection test requires Docker to be running
252        let result = DockerClient::new();
253        // We don't assert success because Docker may not be running in CI
254        drop(result);
255    }
256
257    #[test]
258    fn docker_client_with_timeout_does_not_panic() {
259        let result = DockerClient::with_timeout(600);
260        drop(result);
261    }
262
263    #[test]
264    fn test_host_name_methods() {
265        // Local client has no host name
266        if let Ok(client) = DockerClient::new() {
267            assert!(client.host_name().is_none());
268            assert!(!client.is_remote());
269        }
270    }
271}