opencode_cloud_core/docker/
client.rs1use bollard::Docker;
7use std::path::PathBuf;
8use std::time::Duration;
9
10use super::error::DockerError;
11use crate::host::{HostConfig, SshTunnel};
12
13const DEFAULT_UNIX_SOCKET: &str = "/var/run/docker.sock";
15
16pub struct DockerClient {
18 inner: Docker,
19 _tunnel: Option<SshTunnel>,
21 host_name: Option<String>,
23 endpoint: DockerEndpoint,
25}
26
27#[derive(Clone, Debug)]
34pub enum DockerEndpoint {
35 Unix(PathBuf),
37 Http(String),
39}
40
41impl DockerClient {
42 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 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 pub async fn connect_remote(host: &HostConfig, host_name: &str) -> Result<Self, DockerError> {
86 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 tunnel
93 .wait_ready()
94 .await
95 .map_err(|e| DockerError::Connection(format!("SSH tunnel not ready: {e}")))?;
96
97 let docker_url = tunnel.docker_url();
99 tracing::debug!("Connecting to remote Docker via {}", docker_url);
100
101 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 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 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 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 pub async fn verify_connection(&self) -> Result<(), DockerError> {
181 self.inner.ping().await.map_err(DockerError::from)?;
182 Ok(())
183 }
184
185 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 pub fn host_name(&self) -> Option<&str> {
200 self.host_name.as_deref()
201 }
202
203 pub fn is_remote(&self) -> bool {
205 self._tunnel.is_some()
206 }
207
208 pub fn endpoint(&self) -> &DockerEndpoint {
213 &self.endpoint
214 }
215
216 pub fn inner(&self) -> &Docker {
218 &self.inner
219 }
220
221 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 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 let result = DockerClient::new();
253 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 if let Ok(client) = DockerClient::new() {
267 assert!(client.host_name().is_none());
268 assert!(!client.is_remote());
269 }
270 }
271}