Skip to main content

atlas_local/
docker.rs

1use bollard::{
2    Docker,
3    container::LogOutput,
4    exec::{CreateExecOptions, StartExecOptions, StartExecResults},
5    models::{
6        ContainerCreateBody, ContainerCreateResponse, ContainerInspectResponse, ContainerSummary,
7    },
8    query_parameters::{
9        CreateContainerOptions, CreateImageOptionsBuilder, InspectContainerOptions,
10        ListContainersOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions,
11        StopContainerOptions,
12    },
13};
14use futures_util::{Stream, StreamExt, TryStreamExt};
15
16use crate::models::ContainerHealthStatus;
17
18#[derive(Debug, Clone, PartialEq, thiserror::Error)]
19pub enum DockerError {
20    #[error("resource not modified")]
21    NotModified,
22    #[error("bad request")]
23    BadRequest,
24    #[error("unauthorized")]
25    Unauthorized,
26    #[error("forbidden")]
27    Forbidden,
28    #[error("not found")]
29    NotFound,
30    #[error("conflict")]
31    Conflict,
32    #[error("internal server error")]
33    ServerError,
34    #[error("docker error (status {status_code:?}): {message}")]
35    Other {
36        status_code: Option<u16>,
37        message: String,
38    },
39}
40
41impl From<bollard::errors::Error> for DockerError {
42    fn from(err: bollard::errors::Error) -> Self {
43        match err {
44            bollard::errors::Error::DockerResponseServerError {
45                status_code,
46                message,
47            } => match status_code {
48                304 => DockerError::NotModified,
49                400 => DockerError::BadRequest,
50                401 => DockerError::Unauthorized,
51                403 => DockerError::Forbidden,
52                404 => DockerError::NotFound,
53                409 => DockerError::Conflict,
54                500 => DockerError::ServerError,
55                _ => DockerError::Other {
56                    status_code: Some(status_code),
57                    message,
58                },
59            },
60            _ => DockerError::Other {
61                status_code: None,
62                message: err.to_string(),
63            },
64        }
65    }
66}
67
68impl From<bollard::models::HealthStatusEnum> for ContainerHealthStatus {
69    fn from(status: bollard::models::HealthStatusEnum) -> Self {
70        match status {
71            bollard::models::HealthStatusEnum::EMPTY => ContainerHealthStatus::Empty,
72            bollard::models::HealthStatusEnum::HEALTHY => ContainerHealthStatus::Healthy,
73            bollard::models::HealthStatusEnum::UNHEALTHY => ContainerHealthStatus::Unhealthy,
74            bollard::models::HealthStatusEnum::NONE => ContainerHealthStatus::None,
75            bollard::models::HealthStatusEnum::STARTING => ContainerHealthStatus::Starting,
76        }
77    }
78}
79
80pub trait DockerInspectContainer {
81    fn inspect_container(
82        &self,
83        container_id: &str,
84        options: Option<InspectContainerOptions>,
85    ) -> impl Future<Output = Result<ContainerInspectResponse, DockerError>> + Send;
86}
87
88impl DockerInspectContainer for Docker {
89    async fn inspect_container(
90        &self,
91        container_id: &str,
92        options: Option<InspectContainerOptions>,
93    ) -> Result<ContainerInspectResponse, DockerError> {
94        self.inspect_container(container_id, options)
95            .await
96            .map_err(DockerError::from)
97    }
98}
99
100pub trait DockerListContainers {
101    fn list_containers(
102        &self,
103        options: Option<ListContainersOptions>,
104    ) -> impl Future<Output = Result<Vec<ContainerSummary>, DockerError>> + Send;
105}
106
107impl DockerListContainers for Docker {
108    async fn list_containers(
109        &self,
110        options: Option<ListContainersOptions>,
111    ) -> Result<Vec<ContainerSummary>, DockerError> {
112        self.list_containers(options)
113            .await
114            .map_err(DockerError::from)
115    }
116}
117
118pub trait DockerPullImage {
119    fn pull_image(
120        &self,
121        image: &str,
122        tag: &str,
123    ) -> impl Future<Output = Result<(), DockerError>> + Send;
124}
125
126impl DockerPullImage for Docker {
127    async fn pull_image(&self, image: &str, tag: &str) -> Result<(), DockerError> {
128        let create_image_options = CreateImageOptionsBuilder::default()
129            .from_image(image)
130            .tag(tag)
131            .build();
132
133        let mut stream = self.create_image(Some(create_image_options), None, None);
134
135        while let Some(result) = stream.next().await {
136            result.map_err(DockerError::from)?;
137        }
138
139        Ok(())
140    }
141}
142
143pub trait DockerStopContainer {
144    fn stop_container(
145        &self,
146        container_id: &str,
147        options: Option<StopContainerOptions>,
148    ) -> impl Future<Output = Result<(), DockerError>> + Send;
149}
150
151impl DockerStopContainer for Docker {
152    async fn stop_container(
153        &self,
154        container_id: &str,
155        options: Option<StopContainerOptions>,
156    ) -> Result<(), DockerError> {
157        self.stop_container(container_id, options)
158            .await
159            .map_err(DockerError::from)
160    }
161}
162
163pub trait DockerRemoveContainer {
164    fn remove_container(
165        &self,
166        container_id: &str,
167        options: Option<RemoveContainerOptions>,
168    ) -> impl Future<Output = Result<(), DockerError>> + Send;
169}
170
171impl DockerRemoveContainer for Docker {
172    async fn remove_container(
173        &self,
174        container_id: &str,
175        options: Option<RemoveContainerOptions>,
176    ) -> Result<(), DockerError> {
177        self.remove_container(container_id, options)
178            .await
179            .map_err(DockerError::from)
180    }
181}
182
183pub trait DockerCreateContainer {
184    fn create_container(
185        &self,
186        options: Option<CreateContainerOptions>,
187        config: ContainerCreateBody,
188    ) -> impl Future<Output = Result<ContainerCreateResponse, DockerError>> + Send;
189}
190
191impl DockerCreateContainer for Docker {
192    async fn create_container(
193        &self,
194        options: Option<CreateContainerOptions>,
195        config: ContainerCreateBody,
196    ) -> Result<ContainerCreateResponse, DockerError> {
197        self.create_container(options, config)
198            .await
199            .map_err(DockerError::from)
200    }
201}
202
203pub trait DockerStartContainer {
204    fn start_container(
205        &self,
206        container_id: &str,
207        options: Option<StartContainerOptions>,
208    ) -> impl Future<Output = Result<(), DockerError>> + Send;
209}
210
211impl DockerStartContainer for Docker {
212    async fn start_container(
213        &self,
214        container_id: &str,
215        options: Option<StartContainerOptions>,
216    ) -> Result<(), DockerError> {
217        self.start_container(container_id, options)
218            .await
219            .map_err(DockerError::from)
220    }
221}
222
223pub trait DockerPauseContainer {
224    fn pause_container(
225        &self,
226        container_id: &str,
227    ) -> impl Future<Output = Result<(), DockerError>> + Send;
228}
229
230impl DockerPauseContainer for Docker {
231    async fn pause_container(&self, container_id: &str) -> Result<(), DockerError> {
232        self.pause_container(container_id)
233            .await
234            .map_err(DockerError::from)
235    }
236}
237
238pub trait DockerUnpauseContainer {
239    fn unpause_container(
240        &self,
241        container_id: &str,
242    ) -> impl Future<Output = Result<(), DockerError>> + Send;
243}
244
245impl DockerUnpauseContainer for Docker {
246    async fn unpause_container(&self, container_id: &str) -> Result<(), DockerError> {
247        self.unpause_container(container_id)
248            .await
249            .map_err(DockerError::from)
250    }
251}
252
253pub trait RunCommandInContainer {
254    fn run_command_in_container(
255        &self,
256        container_id: &str,
257        command: Vec<String>,
258    ) -> impl Future<Output = Result<CommandOutput, RunCommandInContainerError>> + Send;
259}
260
261pub struct CommandOutput {
262    pub stdout: Vec<String>,
263    pub stderr: Vec<String>,
264}
265
266#[derive(Debug, thiserror::Error)]
267pub enum RunCommandInContainerError {
268    #[error("Failed to create exec: {0}")]
269    CreateExec(DockerError),
270    #[error("Failed to start exec: {0}")]
271    StartExec(DockerError),
272    #[error("Failed to get output, output was not attached")]
273    GetOutput,
274    #[error("Failed to get output: {0}")]
275    GetOutputError(DockerError),
276}
277
278impl RunCommandInContainer for Docker {
279    async fn run_command_in_container(
280        &self,
281        container_id: &str,
282        command: Vec<String>,
283    ) -> Result<CommandOutput, RunCommandInContainerError> {
284        let exec = self
285            .create_exec(
286                container_id,
287                CreateExecOptions {
288                    attach_stdout: Some(true),
289                    attach_stderr: Some(true),
290                    cmd: Some(command),
291                    ..Default::default()
292                },
293            )
294            .await
295            .map_err(|e| RunCommandInContainerError::CreateExec(DockerError::from(e)))?;
296
297        let exec = self
298            .start_exec(
299                &exec.id,
300                Some(StartExecOptions {
301                    detach: false,
302                    tty: false,
303                    output_capacity: None,
304                }),
305            )
306            .await
307            .map_err(|e| RunCommandInContainerError::StartExec(DockerError::from(e)))?;
308
309        let StartExecResults::Attached { mut output, .. } = exec else {
310            return Err(RunCommandInContainerError::GetOutput);
311        };
312
313        let mut stdout = String::new();
314        let mut stderr = String::new();
315
316        while let Some(result) = output.next().await {
317            let log_ouput = result
318                .map_err(|e| RunCommandInContainerError::GetOutputError(DockerError::from(e)))?;
319
320            match log_ouput {
321                LogOutput::StdOut { message } => {
322                    stdout.push_str(&String::from_utf8_lossy(message.as_ref()));
323                }
324                LogOutput::StdErr { message } => {
325                    stderr.push_str(&String::from_utf8_lossy(message.as_ref()));
326                }
327                _ => {}
328            }
329        }
330
331        Ok(CommandOutput {
332            stdout: stdout.lines().map(str::to_string).collect(),
333            stderr: stderr.lines().map(str::to_string).collect(),
334        })
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn test_docker_error_from_bollard_not_modified() {
344        let err = bollard::errors::Error::DockerResponseServerError {
345            status_code: 304,
346            message: "Not Modified".to_string(),
347        };
348        assert_eq!(DockerError::from(err), DockerError::NotModified);
349    }
350
351    #[test]
352    fn test_docker_error_from_bollard_bad_request() {
353        let err = bollard::errors::Error::DockerResponseServerError {
354            status_code: 400,
355            message: "Bad Request".to_string(),
356        };
357        assert_eq!(DockerError::from(err), DockerError::BadRequest);
358    }
359
360    #[test]
361    fn test_docker_error_from_bollard_unauthorized() {
362        let err = bollard::errors::Error::DockerResponseServerError {
363            status_code: 401,
364            message: "Unauthorized".to_string(),
365        };
366        assert_eq!(DockerError::from(err), DockerError::Unauthorized);
367    }
368
369    #[test]
370    fn test_docker_error_from_bollard_forbidden() {
371        let err = bollard::errors::Error::DockerResponseServerError {
372            status_code: 403,
373            message: "Forbidden".to_string(),
374        };
375        assert_eq!(DockerError::from(err), DockerError::Forbidden);
376    }
377
378    #[test]
379    fn test_docker_error_from_bollard_not_found() {
380        let err = bollard::errors::Error::DockerResponseServerError {
381            status_code: 404,
382            message: "Not Found".to_string(),
383        };
384        assert_eq!(DockerError::from(err), DockerError::NotFound);
385    }
386
387    #[test]
388    fn test_docker_error_from_bollard_conflict() {
389        let err = bollard::errors::Error::DockerResponseServerError {
390            status_code: 409,
391            message: "Conflict".to_string(),
392        };
393        assert_eq!(DockerError::from(err), DockerError::Conflict);
394    }
395
396    #[test]
397    fn test_docker_error_from_bollard_server_error() {
398        let err = bollard::errors::Error::DockerResponseServerError {
399            status_code: 500,
400            message: "Internal Server Error".to_string(),
401        };
402        assert_eq!(DockerError::from(err), DockerError::ServerError);
403    }
404
405    #[test]
406    fn test_docker_error_from_bollard_other_status_code() {
407        let err = bollard::errors::Error::DockerResponseServerError {
408            status_code: 503,
409            message: "Service Unavailable".to_string(),
410        };
411        assert_eq!(
412            DockerError::from(err),
413            DockerError::Other {
414                status_code: Some(503),
415                message: "Service Unavailable".to_string(),
416            }
417        );
418    }
419
420    #[test]
421    fn test_docker_error_from_bollard_non_server_error() {
422        let err = bollard::errors::Error::RequestTimeoutError;
423        let result = DockerError::from(err);
424        assert!(matches!(
425            result,
426            DockerError::Other {
427                status_code: None,
428                ..
429            }
430        ));
431    }
432
433    #[test]
434    fn test_docker_error_display() {
435        assert_eq!(
436            DockerError::NotModified.to_string(),
437            "resource not modified"
438        );
439        assert_eq!(DockerError::BadRequest.to_string(), "bad request");
440        assert_eq!(DockerError::Unauthorized.to_string(), "unauthorized");
441        assert_eq!(DockerError::Forbidden.to_string(), "forbidden");
442        assert_eq!(DockerError::NotFound.to_string(), "not found");
443        assert_eq!(DockerError::Conflict.to_string(), "conflict");
444        assert_eq!(
445            DockerError::ServerError.to_string(),
446            "internal server error"
447        );
448        assert_eq!(
449            DockerError::Other {
450                status_code: Some(503),
451                message: "oops".to_string(),
452            }
453            .to_string(),
454            "docker error (status Some(503)): oops"
455        );
456    }
457
458    #[test]
459    fn test_container_health_status_from_bollard_empty() {
460        assert_eq!(
461            ContainerHealthStatus::from(bollard::models::HealthStatusEnum::EMPTY),
462            ContainerHealthStatus::Empty
463        );
464    }
465
466    #[test]
467    fn test_container_health_status_from_bollard_healthy() {
468        assert_eq!(
469            ContainerHealthStatus::from(bollard::models::HealthStatusEnum::HEALTHY),
470            ContainerHealthStatus::Healthy
471        );
472    }
473
474    #[test]
475    fn test_container_health_status_from_bollard_unhealthy() {
476        assert_eq!(
477            ContainerHealthStatus::from(bollard::models::HealthStatusEnum::UNHEALTHY),
478            ContainerHealthStatus::Unhealthy
479        );
480    }
481
482    #[test]
483    fn test_container_health_status_from_bollard_none() {
484        assert_eq!(
485            ContainerHealthStatus::from(bollard::models::HealthStatusEnum::NONE),
486            ContainerHealthStatus::None
487        );
488    }
489
490    #[test]
491    fn test_container_health_status_from_bollard_starting() {
492        assert_eq!(
493            ContainerHealthStatus::from(bollard::models::HealthStatusEnum::STARTING),
494            ContainerHealthStatus::Starting
495        );
496    }
497}
498
499pub trait DockerLogContainer {
500    fn logs<'a>(
501        &'a self,
502        container_id: &'a str,
503        options: Option<LogsOptions>,
504    ) -> impl Stream<Item = Result<LogOutput, String>> + 'a;
505}
506
507impl DockerLogContainer for Docker {
508    fn logs<'a>(
509        &'a self,
510        container_id: &'a str,
511        options: Option<LogsOptions>,
512    ) -> impl Stream<Item = Result<LogOutput, String>> + 'a {
513        self.logs(container_id, options).map_err(|e| e.to_string())
514    }
515}