Skip to main content

opendev_docker/
deployment.rs

1//! Docker container lifecycle management.
2//!
3//! Ports the Python `DockerDeployment` class — pull images, create/start/stop
4//! containers, and inspect their status.
5
6use 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
15/// Find a free TCP port on the host.
16pub 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
26/// Run a Docker CLI command and return (stdout, stderr, exit_code).
27pub 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
63/// Manages Docker container lifecycle (create, start, stop, remove, inspect).
64pub 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    /// Create a new deployment with the given config.
76    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    /// Set status callback.
93    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    /// Whether the deployment has started.
102    pub fn is_started(&self) -> bool {
103        self.started
104    }
105
106    /// The container ID (if started).
107    pub fn container_id(&self) -> Option<&str> {
108        self.container_id.as_deref()
109    }
110
111    /// The container name.
112    pub fn container_name(&self) -> &str {
113        &self.container_name
114    }
115
116    /// The host port mapped to the container server port.
117    pub fn host_port(&self) -> u16 {
118        self.host_port
119    }
120
121    /// The auth token for the in-container server.
122    pub fn auth_token(&self) -> &str {
123        &self.auth_token
124    }
125
126    /// Pull the Docker image according to the pull policy.
127    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            _ => {} // "always" — fall through to pull
140        }
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    /// Start the container in detached mode.
156    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        // Volume mounts
170        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        // Environment variables
179        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        // Image
188        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    /// Full start flow: pull image + start container.
199    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    /// Stop and remove the container.
214    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            // Graceful stop
224            if let Err(e) = run_docker_command(&["stop", "-t", "5", id], 30.0, false).await {
225                warn!("Error stopping container: {e}");
226            }
227            // Force remove
228            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    /// Inspect the container and return its status.
238    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    /// Remove the container forcefully.
266    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            // Best-effort synchronous cleanup
280            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;