Skip to main content

opendev_docker/
remote_runtime.rs

1//! Remote Docker runtime — interact with a Docker host via SSH + Docker CLI.
2//!
3//! Ports the Python `RemoteRuntime` class. Instead of HTTP to an in-container
4//! server, we shell out to `docker -H ssh://user@host` or `ssh user@host docker ...`.
5
6use tracing::{debug, info};
7
8use crate::errors::{DockerError, Result};
9use crate::models::{BashObservation, DockerConfig, IsAliveResponse};
10
11/// Remote runtime that interacts with a Docker host over SSH.
12#[derive(Debug)]
13pub struct RemoteRuntime {
14    /// SSH connection string, e.g. `user@host`.
15    ssh_target: String,
16    /// Optional SSH key path.
17    ssh_key_path: Option<String>,
18    /// Container ID on the remote host.
19    container_id: Option<String>,
20    /// Whether the runtime is closed.
21    closed: bool,
22}
23
24impl RemoteRuntime {
25    /// Create a new remote runtime.
26    pub fn new(host: &str, user: Option<&str>, ssh_key_path: Option<String>) -> Self {
27        let ssh_target = match user {
28            Some(u) => format!("{u}@{host}"),
29            None => host.to_string(),
30        };
31        Self {
32            ssh_target,
33            ssh_key_path,
34            container_id: None,
35            closed: false,
36        }
37    }
38
39    /// Create from a `DockerConfig`.
40    pub fn from_config(config: &DockerConfig) -> Result<Self> {
41        let host = config.remote_host.as_deref().ok_or_else(|| {
42            DockerError::Other("remote_host is required for RemoteRuntime".into())
43        })?;
44        Ok(Self::new(
45            host,
46            config.remote_user.as_deref(),
47            config.ssh_key_path.clone(),
48        ))
49    }
50
51    /// Set the container ID on the remote host.
52    pub fn set_container_id(&mut self, id: impl Into<String>) {
53        self.container_id = Some(id.into());
54    }
55
56    /// Run a docker command on the remote host via SSH.
57    async fn run_remote_docker(
58        &self,
59        args: &[&str],
60        timeout_secs: f64,
61    ) -> Result<(String, String, i32)> {
62        let mut cmd_args: Vec<String> = Vec::new();
63
64        if let Some(ref key) = self.ssh_key_path {
65            cmd_args.extend(["-i".into(), key.clone()]);
66        }
67
68        cmd_args.push(self.ssh_target.clone());
69        cmd_args.push("docker".into());
70        cmd_args.extend(args.iter().map(|s| s.to_string()));
71
72        debug!("Running remote: ssh {}", cmd_args.join(" "));
73
74        let output = tokio::time::timeout(
75            std::time::Duration::from_secs_f64(timeout_secs),
76            tokio::process::Command::new("ssh").args(&cmd_args).output(),
77        )
78        .await
79        .map_err(|_| DockerError::Timeout {
80            seconds: timeout_secs,
81            operation: format!("ssh docker {}", args.join(" ")),
82        })?
83        .map_err(|e| DockerError::CommandFailed {
84            message: format!("SSH command failed: {e}"),
85            stderr: String::new(),
86        })?;
87
88        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
89        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
90        let code = output.status.code().unwrap_or(-1);
91
92        Ok((stdout, stderr, code))
93    }
94
95    /// Health check — verify we can reach the remote Docker daemon.
96    pub async fn is_alive(&self) -> IsAliveResponse {
97        if self.closed {
98            return IsAliveResponse {
99                status: "error".into(),
100                message: "Runtime is closed".into(),
101            };
102        }
103
104        match self
105            .run_remote_docker(&["info", "--format", "{{.ServerVersion}}"], 10.0)
106            .await
107        {
108            Ok((_, _, 0)) => IsAliveResponse::default(),
109            Ok((_, stderr, _)) => IsAliveResponse {
110                status: "error".into(),
111                message: stderr,
112            },
113            Err(e) => IsAliveResponse {
114                status: "error".into(),
115                message: e.to_string(),
116            },
117        }
118    }
119
120    /// Wait for the remote Docker daemon to become reachable.
121    pub async fn wait_for_ready(&self, timeout: f64, poll_interval: f64) -> bool {
122        let start = std::time::Instant::now();
123        while start.elapsed().as_secs_f64() < timeout {
124            let resp = self.is_alive().await;
125            if resp.status == "ok" {
126                return true;
127            }
128            tokio::time::sleep(std::time::Duration::from_secs_f64(poll_interval)).await;
129        }
130        false
131    }
132
133    /// Execute a command inside the remote container.
134    pub async fn exec_in_container(
135        &self,
136        command: &str,
137        timeout_secs: f64,
138    ) -> Result<BashObservation> {
139        let container_id = self
140            .container_id
141            .as_deref()
142            .ok_or_else(|| DockerError::Other("No container ID set on remote runtime".into()))?;
143
144        let (stdout, stderr, code) = self
145            .run_remote_docker(&["exec", container_id, "bash", "-c", command], timeout_secs)
146            .await?;
147
148        let output = if stderr.is_empty() {
149            stdout
150        } else {
151            format!("{stdout}{stderr}")
152        };
153
154        Ok(BashObservation {
155            output,
156            exit_code: Some(code),
157            failure_reason: if code != 0 {
158                Some(format!("Exit code {code}"))
159            } else {
160                None
161            },
162        })
163    }
164
165    /// Copy a file from host to the remote container.
166    pub async fn copy_to_container(&self, local_path: &str, container_path: &str) -> Result<()> {
167        let container_id = self
168            .container_id
169            .as_deref()
170            .ok_or_else(|| DockerError::Other("No container ID set".into()))?;
171
172        // First scp to remote host, then docker cp
173        let remote_tmp = format!("/tmp/opendev_transfer_{}", uuid::Uuid::new_v4());
174
175        // SCP to remote
176        let mut scp_args: Vec<String> = Vec::new();
177        if let Some(ref key) = self.ssh_key_path {
178            scp_args.extend(["-i".into(), key.clone()]);
179        }
180        scp_args.push(local_path.to_string());
181        scp_args.push(format!("{}:{}", self.ssh_target, remote_tmp));
182
183        let output = tokio::process::Command::new("scp")
184            .args(&scp_args)
185            .output()
186            .await
187            .map_err(|e| DockerError::CommandFailed {
188                message: format!("SCP failed: {e}"),
189                stderr: String::new(),
190            })?;
191
192        if !output.status.success() {
193            return Err(DockerError::CommandFailed {
194                message: "SCP to remote host failed".into(),
195                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
196            });
197        }
198
199        // docker cp on remote
200        self.run_remote_docker(
201            &[
202                "cp",
203                &remote_tmp,
204                &format!("{container_id}:{container_path}"),
205            ],
206            60.0,
207        )
208        .await?;
209
210        // Clean up temp file
211        let _ = self
212            .run_remote_docker(&["exec", container_id, "rm", "-f", &remote_tmp], 10.0)
213            .await;
214
215        Ok(())
216    }
217
218    /// Copy a file from the remote container to host.
219    pub async fn copy_from_container(&self, container_path: &str, local_path: &str) -> Result<()> {
220        let container_id = self
221            .container_id
222            .as_deref()
223            .ok_or_else(|| DockerError::Other("No container ID set".into()))?;
224
225        let remote_tmp = format!("/tmp/opendev_transfer_{}", uuid::Uuid::new_v4());
226
227        // docker cp on remote
228        self.run_remote_docker(
229            &[
230                "cp",
231                &format!("{container_id}:{container_path}"),
232                &remote_tmp,
233            ],
234            60.0,
235        )
236        .await?;
237
238        // SCP from remote
239        let mut scp_args: Vec<String> = Vec::new();
240        if let Some(ref key) = self.ssh_key_path {
241            scp_args.extend(["-i".into(), key.clone()]);
242        }
243        scp_args.push(format!("{}:{}", self.ssh_target, remote_tmp));
244        scp_args.push(local_path.to_string());
245
246        let output = tokio::process::Command::new("scp")
247            .args(&scp_args)
248            .output()
249            .await
250            .map_err(|e| DockerError::CommandFailed {
251                message: format!("SCP failed: {e}"),
252                stderr: String::new(),
253            })?;
254
255        if !output.status.success() {
256            return Err(DockerError::CommandFailed {
257                message: "SCP from remote host failed".into(),
258                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
259            });
260        }
261
262        Ok(())
263    }
264
265    /// Close the runtime.
266    pub async fn close(&mut self) {
267        self.closed = true;
268        info!("Remote runtime closed");
269    }
270}
271
272#[cfg(test)]
273#[path = "remote_runtime_tests.rs"]
274mod tests;