1use std::collections::HashMap;
7use std::net::TcpListener;
8
9use tokio::process::Command;
10use tracing::{debug, error, info, warn};
11
12use crate::errors::{DockerError, Result};
13use crate::models::{ContainerStatus, DockerConfig};
14
15pub fn find_free_port() -> Result<u16> {
17 let listener =
18 TcpListener::bind("127.0.0.1:0").map_err(|e| DockerError::Other(e.to_string()))?;
19 let port = listener
20 .local_addr()
21 .map_err(|e| DockerError::Other(e.to_string()))?
22 .port();
23 Ok(port)
24}
25
26pub async fn run_docker_command(
28 args: &[&str],
29 timeout_secs: f64,
30 check: bool,
31) -> Result<(String, String, i32)> {
32 debug!("Running: docker {}", args.join(" "));
33
34 let output = tokio::time::timeout(
35 std::time::Duration::from_secs_f64(timeout_secs),
36 Command::new("docker").args(args).output(),
37 )
38 .await
39 .map_err(|_| DockerError::Timeout {
40 seconds: timeout_secs,
41 operation: format!("docker {}", args.join(" ")),
42 })?
43 .map_err(|e| DockerError::CommandFailed {
44 message: format!("Failed to run docker command: {e}"),
45 stderr: String::new(),
46 })?;
47
48 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
49 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
50 let code = output.status.code().unwrap_or(-1);
51
52 if check && code != 0 {
53 error!("Docker command failed: {stderr}");
54 return Err(DockerError::CommandFailed {
55 message: format!("docker {} exited with code {code}", args.join(" ")),
56 stderr,
57 });
58 }
59
60 Ok((stdout, stderr, code))
61}
62
63pub struct DockerDeployment {
65 config: DockerConfig,
66 container_name: String,
67 container_id: Option<String>,
68 host_port: u16,
69 auth_token: String,
70 started: bool,
71 on_status: Box<dyn Fn(&str) + Send + Sync>,
72}
73
74impl DockerDeployment {
75 pub fn new(config: DockerConfig) -> Result<Self> {
77 let host_port = find_free_port()?;
78 let auth_token = uuid::Uuid::new_v4().to_string();
79 let container_name = format!("opendev-runtime-{}", &uuid::Uuid::new_v4().to_string()[..8]);
80
81 Ok(Self {
82 config,
83 container_name,
84 container_id: None,
85 host_port,
86 auth_token,
87 started: false,
88 on_status: Box::new(|_| {}),
89 })
90 }
91
92 pub fn with_status_callback<F>(mut self, f: F) -> Self
94 where
95 F: Fn(&str) + Send + Sync + 'static,
96 {
97 self.on_status = Box::new(f);
98 self
99 }
100
101 pub fn is_started(&self) -> bool {
103 self.started
104 }
105
106 pub fn container_id(&self) -> Option<&str> {
108 self.container_id.as_deref()
109 }
110
111 pub fn container_name(&self) -> &str {
113 &self.container_name
114 }
115
116 pub fn host_port(&self) -> u16 {
118 self.host_port
119 }
120
121 pub fn auth_token(&self) -> &str {
123 &self.auth_token
124 }
125
126 pub async fn pull_image(&self) -> Result<()> {
128 match self.config.pull_policy.as_str() {
129 "never" => return Ok(()),
130 "if-not-present" => {
131 let (_, _, code) =
132 run_docker_command(&["image", "inspect", &self.config.image], 30.0, false)
133 .await?;
134 if code == 0 {
135 info!("Image {} already exists locally", self.config.image);
136 return Ok(());
137 }
138 }
139 _ => {} }
141
142 (self.on_status)(&format!("Pulling Docker image: {}", self.config.image));
143 info!("Pulling Docker image: {}", self.config.image);
144
145 run_docker_command(&["pull", &self.config.image], 600.0, true)
146 .await
147 .map_err(|e| DockerError::ImagePullFailed {
148 image: self.config.image.clone(),
149 reason: e.to_string(),
150 })?;
151
152 Ok(())
153 }
154
155 pub async fn start_container(&mut self) -> Result<()> {
157 (self.on_status)(&format!("Starting container: {}", self.container_name));
158
159 let mut args: Vec<String> = vec![
160 "run".into(),
161 "--detach".into(),
162 "--rm".into(),
163 format!("--name={}", self.container_name),
164 format!("--memory={}", self.config.memory),
165 format!("--cpus={}", self.config.cpus),
166 format!("--publish={}:{}", self.host_port, self.config.server_port),
167 ];
168
169 for v in &self.config.volumes {
171 let ro = if v.read_only { ":ro" } else { "" };
172 args.push(format!(
173 "--volume={}:{}{}",
174 v.host_path, v.container_path, ro
175 ));
176 }
177
178 let mut env: HashMap<String, String> = HashMap::new();
180 env.insert("OPENDEV_AUTH_TOKEN".into(), self.auth_token.clone());
181 env.insert("OPENDEV_PORT".into(), self.config.server_port.to_string());
182 env.extend(self.config.environment.clone());
183 for (k, v) in &env {
184 args.push(format!("--env={k}={v}"));
185 }
186
187 args.push(self.config.image.clone());
189
190 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
191 let (stdout, _, _) = run_docker_command(&arg_refs, 300.0, true).await?;
192 self.container_id = Some(stdout.trim().to_string());
193 info!("Container started: {}", self.container_name);
194
195 Ok(())
196 }
197
198 pub async fn start(&mut self) -> Result<()> {
200 if self.started {
201 warn!("Deployment already started");
202 return Ok(());
203 }
204
205 self.pull_image().await?;
206 self.start_container().await?;
207 self.started = true;
208
209 (self.on_status)(&format!("Container ready on port {}", self.host_port));
210 Ok(())
211 }
212
213 pub async fn stop(&mut self) -> Result<()> {
215 if !self.started && self.container_id.is_none() {
216 return Ok(());
217 }
218
219 (self.on_status)("Stopping container...");
220 info!("Stopping container: {}", self.container_name);
221
222 if let Some(ref id) = self.container_id {
223 if let Err(e) = run_docker_command(&["stop", "-t", "5", id], 30.0, false).await {
225 warn!("Error stopping container: {e}");
226 }
227 let _ = run_docker_command(&["rm", "-f", id], 30.0, false).await;
229 self.container_id = None;
230 }
231
232 self.started = false;
233 info!("Container stopped");
234 Ok(())
235 }
236
237 pub async fn inspect(&self) -> Result<ContainerStatus> {
239 let id = match &self.container_id {
240 Some(id) => id.clone(),
241 None => return Ok(ContainerStatus::Unknown),
242 };
243
244 let (stdout, _, code) = run_docker_command(
245 &["inspect", "--format", "{{.State.Status}}", &id],
246 10.0,
247 false,
248 )
249 .await?;
250
251 if code != 0 {
252 return Ok(ContainerStatus::Unknown);
253 }
254
255 match stdout.trim() {
256 "created" => Ok(ContainerStatus::Created),
257 "running" => Ok(ContainerStatus::Running),
258 "paused" => Ok(ContainerStatus::Paused),
259 "exited" | "dead" => Ok(ContainerStatus::Stopped),
260 "removing" => Ok(ContainerStatus::Removing),
261 _ => Ok(ContainerStatus::Unknown),
262 }
263 }
264
265 pub async fn remove(&mut self) -> Result<()> {
267 if let Some(ref id) = self.container_id {
268 run_docker_command(&["rm", "-f", id], 30.0, false).await?;
269 self.container_id = None;
270 self.started = false;
271 }
272 Ok(())
273 }
274}
275
276impl Drop for DockerDeployment {
277 fn drop(&mut self) {
278 if let Some(ref id) = self.container_id {
279 let _ = std::process::Command::new("docker")
281 .args(["rm", "-f", id])
282 .output();
283 }
284 }
285}
286
287#[cfg(test)]
288#[path = "deployment_tests.rs"]
289mod tests;