Skip to main content

atlas_local/client/create_deployment/
mod.rs

1use bollard::{
2    models::ContainerCreateBody,
3    query_parameters::{CreateContainerOptions, StartContainerOptions},
4};
5use tokio::sync::oneshot;
6
7use crate::{
8    GetDeploymentError,
9    client::Client,
10    docker::{
11        DockerCreateContainer, DockerError, DockerInspectContainer, DockerPullImage,
12        DockerStartContainer,
13    },
14    models::{ATLAS_LOCAL_IMAGE, CreateDeploymentOptions, Deployment, WatchOptions},
15};
16
17use super::{PullImageError, WatchDeploymentError};
18
19mod progress;
20
21pub use progress::{CreateDeploymentProgress, CreateDeploymentStepOutcome};
22use progress::{CreateDeploymentProgressSender, create_progress_pairs};
23
24#[derive(Debug, thiserror::Error)]
25pub enum CreateDeploymentError {
26    #[error("Failed to create container: {0}")]
27    CreateContainer(DockerError),
28    #[error(transparent)]
29    PullImage(#[from] PullImageError),
30    #[error("Container already exists: {0}")]
31    ContainerAlreadyExists(String),
32    #[error("Failed to check status of started container: {0}")]
33    ContainerInspect(DockerError),
34    #[error("Created Deployment {0} is not healthy")]
35    UnhealthyDeployment(String),
36    #[error("Unable to get details for Deployment: {0}")]
37    GetDeploymentError(GetDeploymentError),
38    #[error("Error when waiting for deployment to become healthy: {0}")]
39    WatchDeployment(#[from] WatchDeploymentError),
40    #[error("Error when receiving deployment: {0}")]
41    ReceiveDeployment(#[from] oneshot::error::RecvError),
42    #[error(
43        "Image must not include a tag. Use the `image_tag` field to specify a tag. Got: \"{0}\""
44    )]
45    InvalidImage(String),
46}
47
48impl<
49    D: DockerPullImage
50        + DockerCreateContainer
51        + DockerStartContainer
52        + DockerInspectContainer
53        + Send
54        + Sync
55        + 'static,
56> Client<D>
57{
58    /// Creates a local Atlas deployment.
59    pub fn create_deployment(
60        &self,
61        deployment_options: CreateDeploymentOptions,
62    ) -> CreateDeploymentProgress {
63        let (sender, receiver) = create_progress_pairs();
64        let client = self.clone();
65
66        // Spawn the deployment creation in a background task.
67        // Errors from `create_deployment_inner` are forwarded to the receiver via the progress channel.
68        // This code cannot panic: the crate denies unwrap/expect/panic usage (see lib.rs),
69        // and any errors from `create_deployment_inner` are captured in the `Result` and sent
70        // to the receiver through `progress.finalize_deployment()`.
71        tokio::spawn(async move {
72            let mut progress: CreateDeploymentProgressSender = sender;
73
74            let result = client
75                .create_deployment_inner(deployment_options, &mut progress)
76                .await;
77
78            // Forward the result (success or error) to the receiver via the channel.
79            // The caller can await the returned `CreateDeploymentProgress` to receive this result.
80            progress.finalize_deployment(result).await;
81        });
82
83        receiver
84    }
85
86    async fn create_deployment_inner(
87        &self,
88        deployment_options: CreateDeploymentOptions,
89        progress: &mut CreateDeploymentProgressSender,
90    ) -> Result<Deployment, CreateDeploymentError> {
91        if let Some(image) = &deployment_options.image
92            && image.contains(':')
93        {
94            return Err(CreateDeploymentError::InvalidImage(image.clone()));
95        }
96
97        // Pull the image for Atlas Local if requested
98        let will_pull_image = !deployment_options.skip_pull_image.unwrap_or(false);
99        if will_pull_image {
100            let tag = deployment_options
101                .image_tag
102                .as_ref()
103                .map(ToString::to_string)
104                .unwrap_or_else(|| "latest".to_string());
105
106            self.pull_image(
107                deployment_options
108                    .image
109                    .as_ref()
110                    .unwrap_or(&ATLAS_LOCAL_IMAGE.to_string()),
111                tag.as_str(),
112            )
113            .await?;
114        }
115
116        progress
117            .set_pull_image_finished(if will_pull_image {
118                CreateDeploymentStepOutcome::Success
119            } else {
120                CreateDeploymentStepOutcome::Skipped
121            })
122            .await;
123
124        // Create the container with the correct configuration
125        let create_container_options: CreateContainerOptions = (&deployment_options).into();
126        let create_container_config: ContainerCreateBody = (&deployment_options).into();
127
128        // Get the cluster name
129        // It is safe to unwrap because CreateContainerOptions::from will generate a random name if none is provided
130        #[allow(clippy::expect_used)]
131        let cluster_name = create_container_options
132            .name
133            .clone()
134            .expect("Container name to be set by CreateContainerOptions::from");
135
136        self.docker
137            .create_container(Some(create_container_options), create_container_config)
138            .await
139            .map_err(|err| match err {
140                DockerError::Conflict => {
141                    CreateDeploymentError::ContainerAlreadyExists(cluster_name.to_string())
142                }
143                _ => CreateDeploymentError::CreateContainer(err),
144            })?;
145
146        progress
147            .set_create_container_finished(CreateDeploymentStepOutcome::Success)
148            .await;
149
150        // Start the Atlas Local container
151        self.docker
152            .start_container(&cluster_name.to_string(), None::<StartContainerOptions>)
153            .await
154            .map_err(CreateDeploymentError::CreateContainer)?;
155
156        progress
157            .set_start_container_finished(CreateDeploymentStepOutcome::Success)
158            .await;
159
160        // Default to waiting for the deployment to be healthy
161        let will_wait_for_healthy = deployment_options.wait_until_healthy.unwrap_or(true);
162        if will_wait_for_healthy {
163            let watch_options = WatchOptions {
164                timeout_duration: deployment_options.wait_until_healthy_timeout,
165                allow_unhealthy_initial_state: false,
166            };
167            self.wait_for_healthy_deployment(&cluster_name, watch_options)
168                .await?;
169        }
170
171        progress
172            .set_wait_for_healthy_deployment_finished(if will_wait_for_healthy {
173                CreateDeploymentStepOutcome::Success
174            } else {
175                CreateDeploymentStepOutcome::Skipped
176            })
177            .await;
178
179        // Return the deployment details
180        self.get_deployment(&cluster_name)
181            .await
182            .map_err(CreateDeploymentError::GetDeploymentError)
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::client::WatchDeploymentError;
190    use crate::docker::DockerError;
191    use crate::models::{ContainerHealthStatus, ImageTag};
192    use bollard::{
193        models::{
194            ContainerConfig, ContainerCreateResponse, ContainerInspectResponse, ContainerState,
195            ContainerStateStatusEnum, HealthStatusEnum,
196        },
197        query_parameters::InspectContainerOptions,
198    };
199    use maplit::hashmap;
200    use mockall::mock;
201    use pretty_assertions::assert_eq;
202    use tokio::time;
203
204    mock! {
205        Docker {}
206
207        impl DockerPullImage for Docker {
208            async fn pull_image(&self, image: &str, tag: &str) -> Result<(), DockerError>;
209        }
210
211        impl DockerCreateContainer for Docker {
212            async fn create_container(
213                &self,
214                options: Option<CreateContainerOptions>,
215                config: ContainerCreateBody,
216            ) -> Result<ContainerCreateResponse, DockerError>;
217        }
218
219        impl DockerStartContainer for Docker {
220            async fn start_container(
221                &self,
222                container_id: &str,
223                options: Option<StartContainerOptions>,
224            ) -> Result<(), DockerError>;
225        }
226
227        impl DockerInspectContainer for Docker {
228            async fn inspect_container(
229                &self,
230                container_id: &str,
231                options: Option<InspectContainerOptions>,
232            ) -> Result<ContainerInspectResponse, DockerError>;
233        }
234    }
235
236    fn create_test_container_inspect_response() -> ContainerInspectResponse {
237        ContainerInspectResponse {
238            id: Some("test_container_id".to_string()),
239            name: Some("/test-deployment".to_string()),
240            config: Some(ContainerConfig {
241                labels: Some(hashmap! {
242                    "mongodb-atlas-local".to_string() => "container".to_string(),
243                    "version".to_string() => "8.0.0".to_string(),
244                    "mongodb-type".to_string() => "community".to_string(),
245                }),
246                env: Some(vec!["TOOL=ATLASCLI".to_string()]),
247                ..Default::default()
248            }),
249            state: Some(ContainerState {
250                status: Some(ContainerStateStatusEnum::RUNNING),
251                health: Some(bollard::models::Health {
252                    status: Some(HealthStatusEnum::HEALTHY),
253                    ..Default::default()
254                }),
255                ..Default::default()
256            }),
257            ..Default::default()
258        }
259    }
260
261    fn create_test_container_inspect_response_unhealthy() -> ContainerInspectResponse {
262        ContainerInspectResponse {
263            id: Some("test_container_id".to_string()),
264            name: Some("/test-deployment".to_string()),
265            config: Some(ContainerConfig {
266                labels: Some(hashmap! {
267                    "mongodb-atlas-local".to_string() => "container".to_string(),
268                    "version".to_string() => "8.0.0".to_string(),
269                    "mongodb-type".to_string() => "community".to_string(),
270                }),
271                env: Some(vec!["TOOL=ATLASCLI".to_string()]),
272                ..Default::default()
273            }),
274            state: Some(ContainerState {
275                health: Some(bollard::models::Health {
276                    status: Some(HealthStatusEnum::UNHEALTHY),
277                    ..Default::default()
278                }),
279                ..Default::default()
280            }),
281            ..Default::default()
282        }
283    }
284
285    fn create_test_container_inspect_response_starting() -> ContainerInspectResponse {
286        ContainerInspectResponse {
287            id: Some("test_container_id".to_string()),
288            name: Some("/test-deployment".to_string()),
289            config: Some(ContainerConfig {
290                labels: Some(hashmap! {
291                    "mongodb-atlas-local".to_string() => "container".to_string(),
292                    "version".to_string() => "8.0.0".to_string(),
293                    "mongodb-type".to_string() => "community".to_string(),
294                }),
295                env: Some(vec!["TOOL=ATLASCLI".to_string()]),
296                ..Default::default()
297            }),
298            state: Some(ContainerState {
299                health: Some(bollard::models::Health {
300                    status: Some(HealthStatusEnum::STARTING),
301                    ..Default::default()
302                }),
303                ..Default::default()
304            }),
305            ..Default::default()
306        }
307    }
308
309    fn create_test_container_inspect_response_no_state() -> ContainerInspectResponse {
310        ContainerInspectResponse {
311            id: Some("test_container_id".to_string()),
312            name: Some("/test-deployment".to_string()),
313            config: Some(ContainerConfig {
314                labels: Some(hashmap! {
315                    "mongodb-atlas-local".to_string() => "container".to_string(),
316                    "version".to_string() => "8.0.0".to_string(),
317                    "mongodb-type".to_string() => "community".to_string(),
318                }),
319                env: Some(vec!["TOOL=ATLASCLI".to_string()]),
320                ..Default::default()
321            }),
322            state: None,
323            ..Default::default()
324        }
325    }
326
327    fn create_test_container_inspect_response_no_health() -> ContainerInspectResponse {
328        ContainerInspectResponse {
329            id: Some("test_container_id".to_string()),
330            name: Some("/test-deployment".to_string()),
331            config: Some(ContainerConfig {
332                labels: Some(hashmap! {
333                    "mongodb-atlas-local".to_string() => "container".to_string(),
334                    "version".to_string() => "8.0.0".to_string(),
335                    "mongodb-type".to_string() => "community".to_string(),
336                }),
337                env: Some(vec!["TOOL=ATLASCLI".to_string()]),
338                ..Default::default()
339            }),
340            state: Some(ContainerState {
341                health: None,
342                ..Default::default()
343            }),
344            ..Default::default()
345        }
346    }
347
348    fn create_test_container_inspect_response_no_health_status() -> ContainerInspectResponse {
349        ContainerInspectResponse {
350            id: Some("test_container_id".to_string()),
351            name: Some("/test-deployment".to_string()),
352            config: Some(ContainerConfig {
353                labels: Some(hashmap! {
354                    "mongodb-atlas-local".to_string() => "container".to_string(),
355                    "version".to_string() => "8.0.0".to_string(),
356                    "mongodb-type".to_string() => "community".to_string(),
357                }),
358                env: Some(vec!["TOOL=ATLASCLI".to_string()]),
359                ..Default::default()
360            }),
361            state: Some(ContainerState {
362                health: Some(bollard::models::Health {
363                    status: None,
364                    ..Default::default()
365                }),
366                ..Default::default()
367            }),
368            ..Default::default()
369        }
370    }
371
372    #[tokio::test]
373    async fn test_create_deployment() {
374        // Arrange
375        let mut mock_docker = MockDocker::new();
376        let options = CreateDeploymentOptions {
377            name: Some("test-deployment".to_string()),
378            ..Default::default()
379        };
380
381        // Set up expectations
382        mock_docker
383            .expect_pull_image()
384            .with(
385                mockall::predicate::eq(ATLAS_LOCAL_IMAGE),
386                mockall::predicate::eq("latest"),
387            )
388            .times(1)
389            .returning(|_, _| Ok(()));
390
391        mock_docker
392            .expect_create_container()
393            .times(1)
394            .returning(|_, _| {
395                Ok(ContainerCreateResponse {
396                    id: "container_id".to_string(),
397                    warnings: vec![],
398                })
399            });
400
401        mock_docker
402            .expect_start_container()
403            .with(
404                mockall::predicate::eq("test-deployment"),
405                mockall::predicate::eq(None::<StartContainerOptions>),
406            )
407            .times(1)
408            .returning(|_, _| Ok(()));
409
410        mock_docker
411            .expect_inspect_container()
412            .with(
413                mockall::predicate::eq("test-deployment"),
414                mockall::predicate::eq(None::<InspectContainerOptions>),
415            )
416            .times(2)
417            .returning(|_, _| Ok(create_test_container_inspect_response()));
418
419        let client = Client::new(mock_docker);
420
421        // Act
422        let result = client.create_deployment(options).await;
423
424        // Assert
425        assert!(result.is_ok());
426    }
427
428    #[tokio::test]
429    async fn test_create_deployment_pulls_preview_tag_when_image_tag_preview() {
430        // Arrange
431        let mut mock_docker = MockDocker::new();
432        let options = CreateDeploymentOptions {
433            name: Some("test-deployment".to_string()),
434            image_tag: Some(ImageTag::Preview),
435            ..Default::default()
436        };
437
438        // Set up expectations - pull_image should be called with preview tag
439        mock_docker
440            .expect_pull_image()
441            .with(
442                mockall::predicate::eq(ATLAS_LOCAL_IMAGE),
443                mockall::predicate::eq("preview"),
444            )
445            .times(1)
446            .returning(|_, _| Ok(()));
447
448        mock_docker
449            .expect_create_container()
450            .times(1)
451            .returning(|_, _| {
452                Ok(ContainerCreateResponse {
453                    id: "container_id".to_string(),
454                    warnings: vec![],
455                })
456            });
457
458        mock_docker
459            .expect_start_container()
460            .with(
461                mockall::predicate::eq("test-deployment"),
462                mockall::predicate::eq(None::<StartContainerOptions>),
463            )
464            .times(1)
465            .returning(|_, _| Ok(()));
466
467        mock_docker
468            .expect_inspect_container()
469            .with(
470                mockall::predicate::eq("test-deployment"),
471                mockall::predicate::eq(None::<InspectContainerOptions>),
472            )
473            .times(2)
474            .returning(|_, _| Ok(create_test_container_inspect_response()));
475
476        let client = Client::new(mock_docker);
477
478        // Act
479        let result = client.create_deployment(options).await;
480
481        // Assert
482        assert!(result.is_ok());
483    }
484
485    #[tokio::test]
486    async fn test_create_deployment_pull_image_error() {
487        // Arrange
488        let mut mock_docker = MockDocker::new();
489        let options = CreateDeploymentOptions {
490            name: Some("test-deployment".to_string()),
491            ..Default::default()
492        };
493
494        // Set up expectations
495        mock_docker
496            .expect_pull_image()
497            .times(1)
498            .returning(|_, _| Err(DockerError::ServerError));
499
500        let client = Client::new(mock_docker);
501
502        // Act
503        let result = client.create_deployment(options).await;
504
505        // Assert
506        assert!(result.is_err());
507        assert!(matches!(
508            result.unwrap_err(),
509            CreateDeploymentError::PullImage(_)
510        ));
511    }
512
513    #[tokio::test]
514    async fn test_create_deployment_container_already_exists() {
515        // Arrange
516        let mut mock_docker = MockDocker::new();
517        let options = CreateDeploymentOptions {
518            name: Some("test-deployment".to_string()),
519            ..Default::default()
520        };
521
522        // Set up expectations
523        mock_docker
524            .expect_pull_image()
525            .times(1)
526            .returning(|_, _| Ok(()));
527
528        mock_docker
529            .expect_create_container()
530            .times(1)
531            .returning(|_, _| Err(DockerError::Conflict));
532
533        let client = Client::new(mock_docker);
534
535        // Act
536        let result = client.create_deployment(options).await;
537
538        // Assert
539        assert!(result.is_err());
540        match result.unwrap_err() {
541            CreateDeploymentError::ContainerAlreadyExists(name) => {
542                assert_eq!(name, "test-deployment");
543            }
544            _ => panic!("Expected ContainerAlreadyExists error"),
545        }
546    }
547
548    #[tokio::test]
549    async fn test_create_deployment_create_container_error() {
550        // Arrange
551        let mut mock_docker = MockDocker::new();
552        let options = CreateDeploymentOptions {
553            name: Some("test-deployment".to_string()),
554            ..Default::default()
555        };
556
557        // Set up expectations
558        mock_docker
559            .expect_pull_image()
560            .times(1)
561            .returning(|_, _| Ok(()));
562
563        mock_docker
564            .expect_create_container()
565            .times(1)
566            .returning(|_, _| Err(DockerError::ServerError));
567
568        let client = Client::new(mock_docker);
569
570        // Act
571        let result = client.create_deployment(options).await;
572
573        // Assert
574        assert!(result.is_err());
575        assert!(matches!(
576            result.unwrap_err(),
577            CreateDeploymentError::CreateContainer(_)
578        ));
579    }
580
581    #[tokio::test]
582    async fn test_create_deployment_start_container_error() {
583        // Arrange
584        let mut mock_docker = MockDocker::new();
585        let options = CreateDeploymentOptions {
586            name: Some("test-deployment".to_string()),
587            ..Default::default()
588        };
589
590        // Set up expectations
591        mock_docker
592            .expect_pull_image()
593            .times(1)
594            .returning(|_, _| Ok(()));
595
596        mock_docker
597            .expect_create_container()
598            .times(1)
599            .returning(|_, _| {
600                Ok(ContainerCreateResponse {
601                    id: "container_id".to_string(),
602                    warnings: vec![],
603                })
604            });
605
606        mock_docker
607            .expect_start_container()
608            .times(1)
609            .returning(|_, _| Err(DockerError::ServerError));
610
611        let client = Client::new(mock_docker);
612
613        // Act
614        let result = client.create_deployment(options).await;
615
616        // Assert
617        assert!(result.is_err());
618        assert!(matches!(
619            result.unwrap_err(),
620            CreateDeploymentError::CreateContainer(_)
621        ));
622    }
623
624    #[tokio::test]
625    async fn test_create_deployment_wait_for_healthy_deployment_unhealthy() {
626        // Arrange
627        let mut mock_docker = MockDocker::new();
628        let options = CreateDeploymentOptions {
629            name: Some("test-deployment".to_string()),
630            ..Default::default()
631        };
632
633        // Set up expectations
634        mock_docker
635            .expect_pull_image()
636            .with(
637                mockall::predicate::eq(ATLAS_LOCAL_IMAGE),
638                mockall::predicate::eq("latest"),
639            )
640            .times(1)
641            .returning(|_, _| Ok(()));
642
643        mock_docker
644            .expect_create_container()
645            .times(1)
646            .returning(|_, _| {
647                Ok(ContainerCreateResponse {
648                    id: "container_id".to_string(),
649                    warnings: vec![],
650                })
651            });
652
653        mock_docker
654            .expect_start_container()
655            .with(
656                mockall::predicate::eq("test-deployment"),
657                mockall::predicate::eq(None::<StartContainerOptions>),
658            )
659            .times(1)
660            .returning(|_, _| Ok(()));
661
662        mock_docker
663            .expect_inspect_container()
664            .with(
665                mockall::predicate::eq("test-deployment"),
666                mockall::predicate::eq(None::<InspectContainerOptions>),
667            )
668            .times(1)
669            .returning(|_, _| Ok(create_test_container_inspect_response_unhealthy()));
670
671        let client = Client::new(mock_docker);
672
673        // Act
674        let result = client.create_deployment(options).await;
675
676        // Assert
677        assert!(result.is_err());
678        assert!(matches!(
679            result.unwrap_err(),
680            CreateDeploymentError::WatchDeployment(
681                WatchDeploymentError::UnhealthyDeployment { .. }
682            )
683        ));
684    }
685
686    #[tokio::test]
687    async fn test_wait_for_healthy_deployment_retries() {
688        // Arrange
689        let mut mock_docker = MockDocker::new();
690        let options = CreateDeploymentOptions {
691            name: Some("test-deployment".to_string()),
692            ..Default::default()
693        };
694
695        // Set up expectations
696        mock_docker
697            .expect_pull_image()
698            .with(
699                mockall::predicate::eq(ATLAS_LOCAL_IMAGE),
700                mockall::predicate::eq("latest"),
701            )
702            .times(1)
703            .returning(|_, _| Ok(()));
704
705        mock_docker
706            .expect_create_container()
707            .times(1)
708            .returning(|_, _| {
709                Ok(ContainerCreateResponse {
710                    id: "container_id".to_string(),
711                    warnings: vec![],
712                })
713            });
714
715        mock_docker
716            .expect_start_container()
717            .with(
718                mockall::predicate::eq("test-deployment"),
719                mockall::predicate::eq(None::<StartContainerOptions>),
720            )
721            .times(1)
722            .returning(|_, _| Ok(()));
723
724        mock_docker
725            .expect_inspect_container()
726            .with(
727                mockall::predicate::eq("test-deployment"),
728                mockall::predicate::eq(None::<InspectContainerOptions>),
729            )
730            .times(1)
731            .returning(|_, _| Ok(create_test_container_inspect_response_starting()));
732
733        mock_docker
734            .expect_inspect_container()
735            .with(
736                mockall::predicate::eq("test-deployment"),
737                mockall::predicate::eq(None::<InspectContainerOptions>),
738            )
739            .times(2)
740            .returning(|_, _| Ok(create_test_container_inspect_response()));
741
742        let client = Client::new(mock_docker);
743
744        // Act
745        let result = client.create_deployment(options).await;
746
747        // Assert
748        assert!(result.is_ok());
749    }
750
751    #[tokio::test]
752    async fn test_wait_for_healthy_deployment_disabled() {
753        // Arrange
754        let mut mock_docker = MockDocker::new();
755        let options = CreateDeploymentOptions {
756            name: Some("test-deployment".to_string()),
757            wait_until_healthy: Some(false),
758            ..Default::default()
759        };
760
761        // Set up expectations
762        mock_docker
763            .expect_pull_image()
764            .with(
765                mockall::predicate::eq(ATLAS_LOCAL_IMAGE),
766                mockall::predicate::eq("latest"),
767            )
768            .times(1)
769            .returning(|_, _| Ok(()));
770
771        mock_docker
772            .expect_create_container()
773            .times(1)
774            .returning(|_, _| {
775                Ok(ContainerCreateResponse {
776                    id: "container_id".to_string(),
777                    warnings: vec![],
778                })
779            });
780
781        mock_docker
782            .expect_start_container()
783            .with(
784                mockall::predicate::eq("test-deployment"),
785                mockall::predicate::eq(None::<StartContainerOptions>),
786            )
787            .times(1)
788            .returning(|_, _| Ok(()));
789
790        mock_docker
791            .expect_inspect_container()
792            .with(
793                mockall::predicate::eq("test-deployment"),
794                mockall::predicate::eq(None::<InspectContainerOptions>),
795            )
796            .times(1)
797            .returning(|_, _| Ok(create_test_container_inspect_response()));
798
799        let client = Client::new(mock_docker);
800
801        // Act
802        let result = client.create_deployment(options).await;
803
804        // Assert
805        assert!(result.is_ok());
806    }
807
808    #[tokio::test]
809    async fn test_create_deployment_timeout() {
810        // Arrange
811        let mut mock_docker = MockDocker::new();
812        let options = CreateDeploymentOptions {
813            name: Some("test-deployment".to_string()),
814            wait_until_healthy: Some(true),
815            wait_until_healthy_timeout: Some(time::Duration::from_millis(1)),
816            ..Default::default()
817        };
818
819        // Set up expectations
820        mock_docker
821            .expect_pull_image()
822            .with(
823                mockall::predicate::eq(ATLAS_LOCAL_IMAGE),
824                mockall::predicate::eq("latest"),
825            )
826            .times(1)
827            .returning(|_, _| Ok(()));
828
829        mock_docker
830            .expect_create_container()
831            .times(1)
832            .returning(|_, _| {
833                Ok(ContainerCreateResponse {
834                    id: "container_id".to_string(),
835                    warnings: vec![],
836                })
837            });
838
839        mock_docker
840            .expect_start_container()
841            .with(
842                mockall::predicate::eq("test-deployment"),
843                mockall::predicate::eq(None::<StartContainerOptions>),
844            )
845            .times(1)
846            .returning(|_, _| Ok(()));
847
848        // Mock inspect_container to always return STARTING status, which will cause timeout
849        mock_docker
850            .expect_inspect_container()
851            .with(
852                mockall::predicate::eq("test-deployment"),
853                mockall::predicate::eq(None::<InspectContainerOptions>),
854            )
855            .returning(|_, _| Ok(create_test_container_inspect_response_starting()));
856
857        let client = Client::new(mock_docker);
858
859        // Act
860        let result = client.create_deployment(options).await;
861
862        // Assert
863        assert!(result.is_err());
864        match result.unwrap_err() {
865            CreateDeploymentError::WatchDeployment(WatchDeploymentError::Timeout {
866                deployment_name,
867            }) => {
868                assert_eq!(deployment_name, "test-deployment");
869            }
870            _ => panic!("Expected WatchDeployment Timeout error"),
871        }
872    }
873
874    #[tokio::test]
875    async fn test_wait_for_healthy_deployment_no_state() {
876        // Arrange
877        let mut mock_docker = MockDocker::new();
878        let options = CreateDeploymentOptions {
879            name: Some("test-deployment".to_string()),
880            ..Default::default()
881        };
882
883        // Set up expectations
884        mock_docker
885            .expect_pull_image()
886            .with(
887                mockall::predicate::eq(ATLAS_LOCAL_IMAGE),
888                mockall::predicate::eq("latest"),
889            )
890            .times(1)
891            .returning(|_, _| Ok(()));
892
893        mock_docker
894            .expect_create_container()
895            .times(1)
896            .returning(|_, _| {
897                Ok(ContainerCreateResponse {
898                    id: "container_id".to_string(),
899                    warnings: vec![],
900                })
901            });
902
903        mock_docker
904            .expect_start_container()
905            .with(
906                mockall::predicate::eq("test-deployment"),
907                mockall::predicate::eq(None::<StartContainerOptions>),
908            )
909            .times(1)
910            .returning(|_, _| Ok(()));
911
912        mock_docker
913            .expect_inspect_container()
914            .with(
915                mockall::predicate::eq("test-deployment"),
916                mockall::predicate::eq(None::<InspectContainerOptions>),
917            )
918            .times(1)
919            .returning(|_, _| Ok(create_test_container_inspect_response_no_state()));
920
921        let client = Client::new(mock_docker);
922
923        // Act
924        let result = client.create_deployment(options).await;
925
926        // Assert
927        assert!(result.is_err());
928        match result.unwrap_err() {
929            CreateDeploymentError::WatchDeployment(WatchDeploymentError::UnhealthyDeployment {
930                deployment_name,
931                status,
932            }) => {
933                assert_eq!(deployment_name, "test-deployment");
934                assert_eq!(status, ContainerHealthStatus::None);
935            }
936            _ => panic!("Expected WatchDeployment error"),
937        }
938    }
939
940    #[tokio::test]
941    async fn test_wait_for_healthy_deployment_no_health() {
942        // Arrange
943        let mut mock_docker = MockDocker::new();
944        let options = CreateDeploymentOptions {
945            name: Some("test-deployment".to_string()),
946            ..Default::default()
947        };
948
949        // Set up expectations
950        mock_docker
951            .expect_pull_image()
952            .with(
953                mockall::predicate::eq(ATLAS_LOCAL_IMAGE),
954                mockall::predicate::eq("latest"),
955            )
956            .times(1)
957            .returning(|_, _| Ok(()));
958
959        mock_docker
960            .expect_create_container()
961            .times(1)
962            .returning(|_, _| {
963                Ok(ContainerCreateResponse {
964                    id: "container_id".to_string(),
965                    warnings: vec![],
966                })
967            });
968
969        mock_docker
970            .expect_start_container()
971            .with(
972                mockall::predicate::eq("test-deployment"),
973                mockall::predicate::eq(None::<StartContainerOptions>),
974            )
975            .times(1)
976            .returning(|_, _| Ok(()));
977
978        mock_docker
979            .expect_inspect_container()
980            .with(
981                mockall::predicate::eq("test-deployment"),
982                mockall::predicate::eq(None::<InspectContainerOptions>),
983            )
984            .times(1)
985            .returning(|_, _| Ok(create_test_container_inspect_response_no_health()));
986
987        let client = Client::new(mock_docker);
988
989        // Act
990        let result = client.create_deployment(options).await;
991
992        // Assert
993        assert!(result.is_err());
994        match result.unwrap_err() {
995            CreateDeploymentError::WatchDeployment(WatchDeploymentError::UnhealthyDeployment {
996                deployment_name,
997                status,
998            }) => {
999                assert_eq!(deployment_name, "test-deployment");
1000                assert_eq!(status, ContainerHealthStatus::None);
1001            }
1002            _ => panic!("Expected WatchDeployment error"),
1003        }
1004    }
1005
1006    #[tokio::test]
1007    async fn test_create_deployment_rejects_image_with_tag() {
1008        let mock_docker = MockDocker::new();
1009        let options = CreateDeploymentOptions {
1010            image: Some("mongo:6.0".to_string()),
1011            ..Default::default()
1012        };
1013
1014        let client = Client::new(mock_docker);
1015        let result = client.create_deployment(options).await;
1016
1017        assert!(matches!(
1018            result.unwrap_err(),
1019            CreateDeploymentError::InvalidImage(img) if img == "mongo:6.0"
1020        ));
1021    }
1022
1023    #[tokio::test]
1024    async fn test_create_deployment_rejects_image_with_latest_tag() {
1025        let mock_docker = MockDocker::new();
1026        let options = CreateDeploymentOptions {
1027            image: Some("quay.io/mongodb/mongodb-atlas-local:latest".to_string()),
1028            ..Default::default()
1029        };
1030
1031        let client = Client::new(mock_docker);
1032        let result = client.create_deployment(options).await;
1033
1034        assert!(matches!(
1035            result.unwrap_err(),
1036            CreateDeploymentError::InvalidImage(img) if img == "quay.io/mongodb/mongodb-atlas-local:latest"
1037        ));
1038    }
1039
1040    #[tokio::test]
1041    async fn test_create_deployment_accepts_image_without_tag() {
1042        let mut mock_docker = MockDocker::new();
1043        let options = CreateDeploymentOptions {
1044            name: Some("test-deployment".to_string()),
1045            image: Some("quay.io/mongodb/mongodb-atlas-local".to_string()),
1046            ..Default::default()
1047        };
1048
1049        mock_docker
1050            .expect_pull_image()
1051            .with(
1052                mockall::predicate::eq("quay.io/mongodb/mongodb-atlas-local"),
1053                mockall::predicate::eq("latest"),
1054            )
1055            .times(1)
1056            .returning(|_, _| Ok(()));
1057
1058        mock_docker
1059            .expect_create_container()
1060            .times(1)
1061            .returning(|_, _| {
1062                Ok(ContainerCreateResponse {
1063                    id: "container_id".to_string(),
1064                    warnings: vec![],
1065                })
1066            });
1067
1068        mock_docker
1069            .expect_start_container()
1070            .with(
1071                mockall::predicate::eq("test-deployment"),
1072                mockall::predicate::eq(None::<StartContainerOptions>),
1073            )
1074            .times(1)
1075            .returning(|_, _| Ok(()));
1076
1077        mock_docker
1078            .expect_inspect_container()
1079            .with(
1080                mockall::predicate::eq("test-deployment"),
1081                mockall::predicate::eq(None::<InspectContainerOptions>),
1082            )
1083            .times(2)
1084            .returning(|_, _| Ok(create_test_container_inspect_response()));
1085
1086        let client = Client::new(mock_docker);
1087        let result = client.create_deployment(options).await;
1088
1089        assert!(result.is_ok());
1090    }
1091
1092    #[tokio::test]
1093    async fn test_wait_for_healthy_deployment_no_health_status() {
1094        // Arrange
1095        let mut mock_docker = MockDocker::new();
1096        let options = CreateDeploymentOptions {
1097            name: Some("test-deployment".to_string()),
1098            ..Default::default()
1099        };
1100
1101        // Set up expectations
1102        mock_docker
1103            .expect_pull_image()
1104            .with(
1105                mockall::predicate::eq(ATLAS_LOCAL_IMAGE),
1106                mockall::predicate::eq("latest"),
1107            )
1108            .times(1)
1109            .returning(|_, _| Ok(()));
1110
1111        mock_docker
1112            .expect_create_container()
1113            .times(1)
1114            .returning(|_, _| {
1115                Ok(ContainerCreateResponse {
1116                    id: "container_id".to_string(),
1117                    warnings: vec![],
1118                })
1119            });
1120
1121        mock_docker
1122            .expect_start_container()
1123            .with(
1124                mockall::predicate::eq("test-deployment"),
1125                mockall::predicate::eq(None::<StartContainerOptions>),
1126            )
1127            .times(1)
1128            .returning(|_, _| Ok(()));
1129
1130        mock_docker
1131            .expect_inspect_container()
1132            .with(
1133                mockall::predicate::eq("test-deployment"),
1134                mockall::predicate::eq(None::<InspectContainerOptions>),
1135            )
1136            .times(1)
1137            .returning(|_, _| Ok(create_test_container_inspect_response_no_health_status()));
1138
1139        let client = Client::new(mock_docker);
1140
1141        // Act
1142        let result = client.create_deployment(options).await;
1143
1144        // Assert
1145        assert!(result.is_err());
1146        match result.unwrap_err() {
1147            CreateDeploymentError::WatchDeployment(WatchDeploymentError::UnhealthyDeployment {
1148                deployment_name,
1149                status,
1150            }) => {
1151                assert_eq!(deployment_name, "test-deployment");
1152                assert_eq!(status, ContainerHealthStatus::None);
1153            }
1154            _ => panic!("Expected WatchDeployment error"),
1155        }
1156    }
1157}