Skip to main content

torrust_tracker_deployer_lib/adapters/docker/
client.rs

1//! Docker CLI client implementation
2
3use std::path::Path;
4
5use super::error::DockerError;
6use crate::shared::command::CommandExecutor;
7
8/// Client for executing Docker CLI commands
9///
10/// This client wraps Docker CLI operations using our `CommandExecutor` collaborator,
11/// enabling testability and consistency with other external tool clients (Ansible,
12/// `OpenTofu`, LXD). Each Docker subcommand is exposed as a separate method.
13///
14/// # Architecture
15///
16/// The client uses `CommandExecutor` as a collaborator for actual command execution,
17/// following the same pattern as `AnsibleClient`, `TofuClient`, and `LxdClient`.
18///
19/// # Example
20///
21/// ```rust,no_run
22/// use torrust_tracker_deployer_lib::adapters::docker::DockerClient;
23///
24/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
25/// let docker = DockerClient::new();
26///
27/// // Build an image
28/// docker.build_image("docker/app", "my-app", "latest")?;
29///
30/// // Check if it exists
31/// let exists = docker.image_exists("my-app", "latest")?;
32/// assert!(exists);
33/// # Ok(())
34/// # }
35/// ```
36#[derive(Debug)]
37pub struct DockerClient {
38    command_executor: CommandExecutor,
39}
40
41impl Default for DockerClient {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl DockerClient {
48    /// Create a new Docker client
49    ///
50    /// # Example
51    ///
52    /// ```rust
53    /// use torrust_tracker_deployer_lib::adapters::docker::DockerClient;
54    ///
55    /// let docker = DockerClient::new();
56    /// ```
57    #[must_use]
58    pub fn new() -> Self {
59        Self {
60            command_executor: CommandExecutor::new(),
61        }
62    }
63
64    /// Build a Docker image from a Dockerfile directory
65    ///
66    /// Executes `docker build -t <name>:<tag> <path>` to build an image.
67    ///
68    /// # Arguments
69    ///
70    /// * `dockerfile_dir` - Path to directory containing the Dockerfile
71    /// * `image_name` - Name for the Docker image (e.g., "my-ssh-server")
72    /// * `image_tag` - Tag for the image (e.g., "latest")
73    ///
74    /// # Returns
75    ///
76    /// The build output on success
77    ///
78    /// # Errors
79    ///
80    /// Returns `DockerError::BuildFailed` if the build command fails
81    ///
82    /// # Example
83    ///
84    /// ```rust,no_run
85    /// # use torrust_tracker_deployer_lib::adapters::docker::DockerClient;
86    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
87    /// # let docker = DockerClient::new();
88    /// docker.build_image("docker/ssh-server", "my-ssh", "latest")?;
89    /// # Ok(())
90    /// # }
91    /// ```
92    pub fn build_image<P: AsRef<Path>>(
93        &self,
94        dockerfile_dir: P,
95        image_name: &str,
96        image_tag: &str,
97    ) -> Result<String, DockerError> {
98        let image = format!("{image_name}:{image_tag}");
99        let path = dockerfile_dir.as_ref().display().to_string();
100        let args = vec!["build", "-t", &image, &path];
101
102        self.command_executor
103            .run_command("docker", &args, None)
104            .map(|result| result.stdout)
105            .map_err(|source| DockerError::BuildFailed { image, source })
106    }
107
108    /// List Docker images with optional repository filter
109    ///
110    /// Executes `docker images` with formatting to get structured output.
111    ///
112    /// # Arguments
113    ///
114    /// * `repository` - Optional repository name to filter by
115    ///
116    /// # Returns
117    ///
118    /// A vector of image information strings in format:
119    /// "repository:tag|id|size"
120    ///
121    /// # Errors
122    ///
123    /// Returns `DockerError::ListImagesFailed` if the command fails
124    ///
125    /// # Example
126    ///
127    /// ```rust,no_run
128    /// # use torrust_tracker_deployer_lib::adapters::docker::DockerClient;
129    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
130    /// # let docker = DockerClient::new();
131    /// // List all images
132    /// let all_images = docker.list_images(None)?;
133    ///
134    /// // List specific repository
135    /// let ubuntu_images = docker.list_images(Some("ubuntu"))?;
136    /// # Ok(())
137    /// # }
138    /// ```
139    pub fn list_images(&self, repository: Option<&str>) -> Result<Vec<String>, DockerError> {
140        let format_str = "{{.Repository}}:{{.Tag}}|{{.ID}}|{{.Size}}";
141        let mut args = vec!["images", "--format", format_str];
142
143        if let Some(repo) = repository {
144            args.push(repo);
145        }
146
147        let result = self
148            .command_executor
149            .run_command("docker", &args, None)
150            .map_err(DockerError::ListImagesFailed)?;
151
152        Ok(result.stdout.lines().map(ToString::to_string).collect())
153    }
154
155    /// List Docker containers
156    ///
157    /// Executes `docker ps` with formatting to get structured output.
158    ///
159    /// # Arguments
160    ///
161    /// * `all` - If true, shows all containers (including stopped ones)
162    ///
163    /// # Returns
164    ///
165    /// A vector of container information strings in format:
166    /// "id|name|status"
167    ///
168    /// # Errors
169    ///
170    /// Returns `DockerError::ListContainersFailed` if the command fails
171    ///
172    /// # Example
173    ///
174    /// ```rust,no_run
175    /// # use torrust_tracker_deployer_lib::adapters::docker::DockerClient;
176    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
177    /// # let docker = DockerClient::new();
178    /// // List only running containers
179    /// let running = docker.list_containers(false)?;
180    ///
181    /// // List all containers
182    /// let all_containers = docker.list_containers(true)?;
183    /// # Ok(())
184    /// # }
185    /// ```
186    pub fn list_containers(&self, all: bool) -> Result<Vec<String>, DockerError> {
187        let format_str = "{{.ID}}|{{.Names}}|{{.Status}}";
188        let mut args = vec!["ps", "--format", format_str];
189
190        if all {
191            args.push("-a");
192        }
193
194        let result = self
195            .command_executor
196            .run_command("docker", &args, None)
197            .map_err(DockerError::ListContainersFailed)?;
198
199        Ok(result.stdout.lines().map(ToString::to_string).collect())
200    }
201
202    /// Get logs from a Docker container
203    ///
204    /// Executes `docker logs <container-id>` to retrieve container logs.
205    ///
206    /// # Arguments
207    ///
208    /// * `container_id` - ID or name of the container
209    ///
210    /// # Returns
211    ///
212    /// The container's logs as a string
213    ///
214    /// # Errors
215    ///
216    /// Returns `DockerError::GetLogsFailed` if the command fails
217    ///
218    /// # Example
219    ///
220    /// ```rust,no_run
221    /// # use torrust_tracker_deployer_lib::adapters::docker::DockerClient;
222    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
223    /// # let docker = DockerClient::new();
224    /// let logs = docker.get_container_logs("my-container")?;
225    /// println!("Container logs:\n{}", logs);
226    /// # Ok(())
227    /// # }
228    /// ```
229    pub fn get_container_logs(&self, container_id: &str) -> Result<String, DockerError> {
230        let args = vec!["logs", container_id];
231
232        self.command_executor
233            .run_command("docker", &args, None)
234            .map(|result| result.stdout)
235            .map_err(|source| DockerError::GetLogsFailed {
236                container_id: container_id.to_string(),
237                source,
238            })
239    }
240
241    /// Check if a Docker image exists locally
242    ///
243    /// Uses `list_images` to check for the presence of a specific image.
244    ///
245    /// # Arguments
246    ///
247    /// * `image_name` - Name of the image
248    /// * `image_tag` - Tag of the image
249    ///
250    /// # Returns
251    ///
252    /// `true` if the image exists, `false` otherwise
253    ///
254    /// # Errors
255    ///
256    /// Returns `DockerError::ListImagesFailed` if the command fails
257    ///
258    /// # Example
259    ///
260    /// ```rust,no_run
261    /// # use torrust_tracker_deployer_lib::adapters::docker::DockerClient;
262    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
263    /// # let docker = DockerClient::new();
264    /// if docker.image_exists("ubuntu", "latest")? {
265    ///     println!("Ubuntu image is available");
266    /// }
267    /// # Ok(())
268    /// # }
269    /// ```
270    pub fn image_exists(&self, image_name: &str, image_tag: &str) -> Result<bool, DockerError> {
271        let filter = format!("{image_name}:{image_tag}");
272        let images = self.list_images(Some(&filter))?;
273        Ok(!images.is_empty())
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn it_should_create_docker_client() {
283        let _docker = DockerClient::new();
284    }
285}