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::time::Duration;
8
9use super::error::DockerError;
10use crate::host::{HostConfig, SshTunnel};
11
12/// Docker client wrapper with connection handling
13pub struct DockerClient {
14    inner: Docker,
15    /// SSH tunnel for remote connections (kept alive for client lifetime)
16    _tunnel: Option<SshTunnel>,
17    /// Host name for remote connections (None = local)
18    host_name: Option<String>,
19}
20
21impl DockerClient {
22    /// Create new client connecting to local Docker daemon
23    ///
24    /// Uses platform-appropriate socket (Unix socket on Linux/macOS).
25    /// Returns a clear error if Docker is not running or accessible.
26    pub fn new() -> Result<Self, DockerError> {
27        let docker = Docker::connect_with_local_defaults()
28            .map_err(|e| DockerError::Connection(e.to_string()))?;
29
30        Ok(Self {
31            inner: docker,
32            _tunnel: None,
33            host_name: None,
34        })
35    }
36
37    /// Create client with custom timeout (in seconds)
38    ///
39    /// Use for long-running operations like image builds.
40    /// Default timeout is 120 seconds; build timeout should be 600+ seconds.
41    pub fn with_timeout(timeout_secs: u64) -> Result<Self, DockerError> {
42        let docker = Docker::connect_with_local_defaults()
43            .map_err(|e| DockerError::Connection(e.to_string()))?
44            .with_timeout(Duration::from_secs(timeout_secs));
45
46        Ok(Self {
47            inner: docker,
48            _tunnel: None,
49            host_name: None,
50        })
51    }
52
53    /// Create client connecting to remote Docker daemon via SSH tunnel
54    ///
55    /// Establishes an SSH tunnel to the remote host and connects Bollard
56    /// to the forwarded local port.
57    ///
58    /// # Arguments
59    /// * `host` - Remote host configuration
60    /// * `host_name` - Name of the host (for display purposes)
61    pub async fn connect_remote(host: &HostConfig, host_name: &str) -> Result<Self, DockerError> {
62        // Create SSH tunnel
63        let tunnel = SshTunnel::new(host, host_name)
64            .map_err(|e| DockerError::Connection(format!("SSH tunnel failed: {e}")))?;
65
66        // Wait for tunnel to be ready with exponential backoff
67        tunnel
68            .wait_ready()
69            .await
70            .map_err(|e| DockerError::Connection(format!("SSH tunnel not ready: {e}")))?;
71
72        // Connect Bollard to the tunnel's local port
73        let docker_url = tunnel.docker_url();
74        tracing::debug!("Connecting to remote Docker via {}", docker_url);
75
76        // Retry connection with backoff (tunnel may need a moment)
77        let max_attempts = 3;
78        let mut last_err = None;
79
80        for attempt in 0..max_attempts {
81            if attempt > 0 {
82                let delay = Duration::from_millis(100 * 2u64.pow(attempt));
83                tracing::debug!("Retry attempt {} after {:?}", attempt + 1, delay);
84                tokio::time::sleep(delay).await;
85            }
86
87            match Docker::connect_with_http(&docker_url, 120, bollard::API_DEFAULT_VERSION) {
88                Ok(docker) => {
89                    // Verify connection works
90                    match docker.ping().await {
91                        Ok(_) => {
92                            tracing::info!("Connected to Docker on {} via SSH tunnel", host_name);
93                            return Ok(Self {
94                                inner: docker,
95                                _tunnel: Some(tunnel),
96                                host_name: Some(host_name.to_string()),
97                            });
98                        }
99                        Err(e) => {
100                            tracing::debug!("Ping failed: {}", e);
101                            last_err = Some(e.to_string());
102                        }
103                    }
104                }
105                Err(e) => {
106                    tracing::debug!("Connection failed: {}", e);
107                    last_err = Some(e.to_string());
108                }
109            }
110        }
111
112        Err(DockerError::Connection(format!(
113            "Failed to connect to Docker on {}: {}",
114            host_name,
115            last_err.unwrap_or_else(|| "unknown error".to_string())
116        )))
117    }
118
119    /// Create remote client with custom timeout
120    pub async fn connect_remote_with_timeout(
121        host: &HostConfig,
122        host_name: &str,
123        timeout_secs: u64,
124    ) -> Result<Self, DockerError> {
125        let tunnel = SshTunnel::new(host, host_name)
126            .map_err(|e| DockerError::Connection(format!("SSH tunnel failed: {e}")))?;
127
128        tunnel
129            .wait_ready()
130            .await
131            .map_err(|e| DockerError::Connection(format!("SSH tunnel not ready: {e}")))?;
132
133        let docker_url = tunnel.docker_url();
134
135        let docker =
136            Docker::connect_with_http(&docker_url, timeout_secs, bollard::API_DEFAULT_VERSION)
137                .map_err(|e| DockerError::Connection(e.to_string()))?;
138
139        // Verify connection
140        docker.ping().await.map_err(DockerError::from)?;
141
142        Ok(Self {
143            inner: docker,
144            _tunnel: Some(tunnel),
145            host_name: Some(host_name.to_string()),
146        })
147    }
148
149    /// Verify connection to Docker daemon
150    ///
151    /// Returns Ok(()) if connected, descriptive error otherwise.
152    pub async fn verify_connection(&self) -> Result<(), DockerError> {
153        self.inner.ping().await.map_err(DockerError::from)?;
154        Ok(())
155    }
156
157    /// Get Docker version info (useful for debugging)
158    pub async fn version(&self) -> Result<String, DockerError> {
159        let version = self.inner.version().await.map_err(DockerError::from)?;
160
161        let version_str = format!(
162            "Docker {} (API {})",
163            version.version.unwrap_or_else(|| "unknown".to_string()),
164            version.api_version.unwrap_or_else(|| "unknown".to_string())
165        );
166
167        Ok(version_str)
168    }
169
170    /// Get the host name if this is a remote connection
171    pub fn host_name(&self) -> Option<&str> {
172        self.host_name.as_deref()
173    }
174
175    /// Check if this is a remote connection
176    pub fn is_remote(&self) -> bool {
177        self._tunnel.is_some()
178    }
179
180    /// Access inner Bollard client for advanced operations
181    pub fn inner(&self) -> &Docker {
182        &self.inner
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn docker_client_creation_does_not_panic() {
192        // This test just verifies the code compiles and doesn't panic
193        // Actual connection test requires Docker to be running
194        let result = DockerClient::new();
195        // We don't assert success because Docker may not be running in CI
196        drop(result);
197    }
198
199    #[test]
200    fn docker_client_with_timeout_does_not_panic() {
201        let result = DockerClient::with_timeout(600);
202        drop(result);
203    }
204
205    #[test]
206    fn test_host_name_methods() {
207        // Local client has no host name
208        if let Ok(client) = DockerClient::new() {
209            assert!(client.host_name().is_none());
210            assert!(!client.is_remote());
211        }
212    }
213}