1use tracing::{debug, info};
7
8use crate::errors::{DockerError, Result};
9use crate::models::{BashObservation, DockerConfig, IsAliveResponse};
10
11#[derive(Debug)]
13pub struct RemoteRuntime {
14 ssh_target: String,
16 ssh_key_path: Option<String>,
18 container_id: Option<String>,
20 closed: bool,
22}
23
24impl RemoteRuntime {
25 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 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 pub fn set_container_id(&mut self, id: impl Into<String>) {
53 self.container_id = Some(id.into());
54 }
55
56 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 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 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 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 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 let remote_tmp = format!("/tmp/opendev_transfer_{}", uuid::Uuid::new_v4());
174
175 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 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 let _ = self
212 .run_remote_docker(&["exec", container_id, "rm", "-f", &remote_tmp], 10.0)
213 .await;
214
215 Ok(())
216 }
217
218 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 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 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 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;