Skip to main content

torrust_tracker_deployer_lib/testing/e2e/containers/
provisioned.rs

1//! Provisioned Instance Container for E2E Testing
2//!
3//! This module provides a state machine pattern for managing Docker containers
4//! that represent provisioned instances in the deployment workflow.
5//!
6//! ## State Machine Pattern
7//!
8//! The container follows a state machine pattern similar to the Torrust Tracker `MySQL` driver:
9//! - `StoppedProvisionedContainer` - Initial state, can only be started
10//! - `RunningProvisionedContainer` - Running state, can be queried, configured, and stopped
11//! - State transitions are enforced at compile time through different types
12//!
13//! ## Error Handling
14//!
15//! This module uses explicit error types through [`ContainerError`] instead of
16//! generic `anyhow` errors. Each error variant provides specific information about what
17//! went wrong, making it easier to handle different failure modes appropriately.
18//!
19//! ## Usage
20//!
21//! ```rust,no_run
22//! use torrust_tracker_deployer_lib::testing::e2e::containers::{
23//!     StoppedProvisionedContainer, ContainerError,
24//!     actions::{SshWaitAction, SshKeySetupAction}
25//! };
26//! use torrust_tracker_deployer_lib::shared::Username;
27//! use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials;
28//! use std::path::PathBuf;
29//! use std::time::Duration;
30//! use std::net::SocketAddr;
31//!
32//! async fn example() -> Result<(), Box<dyn std::error::Error>> {
33//!     // Start with stopped state
34//!     let stopped = StoppedProvisionedContainer::default();
35//!     
36//!     // Transition to running state (expose SSH port only)
37//!     let running = stopped.start(None, 22, &[]).await?;
38//!     
39//!     // Get connection details
40//!     let socket_addr = running.ssh_socket_addr();
41//!     
42//!     // Wait for SSH server using action directly
43//!     let ssh_wait_action = SshWaitAction::new(Duration::from_secs(30), 10);
44//!     ssh_wait_action.execute(socket_addr)?;
45//!     
46//!     // Setup SSH keys with credentials using action directly
47//!     let ssh_credentials = SshCredentials::new(
48//!         PathBuf::from("/path/to/private_key"),
49//!         PathBuf::from("/path/to/public_key.pub"),
50//!         Username::new("torrust").unwrap(),
51//!     );
52//!     let ssh_key_setup_action = SshKeySetupAction::new();
53//!     ssh_key_setup_action.execute(&running, &ssh_credentials).await?;
54//!     
55//!     // Transition back to stopped state
56//!     let _stopped = running.stop();
57//!     Ok(())
58//! }
59//! ```
60
61use std::net::{IpAddr, Ipv4Addr, SocketAddr};
62use std::time::Duration;
63use testcontainers::{core::WaitFor, runners::AsyncRunner, ContainerAsync, GenericImage, ImageExt};
64use tracing::info;
65
66use super::config_builder::ContainerConfigBuilder;
67#[cfg(test)]
68use super::errors::ContainerNetworkingError;
69use super::errors::{ContainerError, ContainerImageError, ContainerRuntimeError, Result};
70use super::executor::ContainerExecutor;
71use super::image_builder::ContainerImageBuilder;
72use super::timeout::ContainerTimeouts;
73
74/// Default Docker image name for provisioned instances
75const DEFAULT_IMAGE_NAME: &str = "torrust-provisioned-instance";
76
77/// Default Docker image tag for provisioned instances  
78const DEFAULT_IMAGE_TAG: &str = "latest";
79
80/// Container configuration following state machine pattern
81///
82/// Following the pattern from Torrust Tracker `MySQL` driver, where different states
83/// have different capabilities enforced at compile time.
84/// Initial state - container is stopped/not started yet
85#[derive(Debug)]
86pub struct StoppedProvisionedContainer {
87    /// Timeout configurations for container operations
88    pub timeouts: ContainerTimeouts,
89}
90
91#[allow(clippy::derivable_impls)]
92impl Default for StoppedProvisionedContainer {
93    fn default() -> Self {
94        Self {
95            timeouts: ContainerTimeouts::default(),
96        }
97    }
98}
99
100impl StoppedProvisionedContainer {
101    /// Create a new stopped container with custom timeout configurations
102    ///
103    /// # Arguments
104    /// * `timeouts` - Custom timeout configuration for container operations
105    ///
106    /// # Example
107    /// ```rust,no_run
108    /// use torrust_tracker_deployer_lib::testing::e2e::containers::{StoppedProvisionedContainer, ContainerTimeouts};
109    /// use std::time::Duration;
110    ///
111    /// let mut timeouts = ContainerTimeouts::default();
112    /// timeouts.ssh_ready = Duration::from_secs(60);
113    ///
114    /// let container = StoppedProvisionedContainer::with_timeouts(timeouts);
115    /// ```
116    #[must_use]
117    pub fn with_timeouts(timeouts: ContainerTimeouts) -> Self {
118        Self { timeouts }
119    }
120
121    /// Create a new stopped container with custom SSH ready timeout
122    ///
123    /// This is a convenience method for the most commonly customized timeout.
124    ///
125    /// # Arguments
126    /// * `ssh_ready_timeout` - How long to wait for SSH to become available
127    ///
128    /// # Example
129    /// ```rust,no_run
130    /// use torrust_tracker_deployer_lib::testing::e2e::containers::StoppedProvisionedContainer;
131    /// use std::time::Duration;
132    ///
133    /// let container = StoppedProvisionedContainer::with_ssh_ready_timeout(
134    ///     Duration::from_secs(60)
135    /// );
136    /// ```
137    #[must_use]
138    pub fn with_ssh_ready_timeout(ssh_ready_timeout: Duration) -> Self {
139        let timeouts = ContainerTimeouts {
140            ssh_ready: ssh_ready_timeout,
141            ..ContainerTimeouts::default()
142        };
143        Self { timeouts }
144    }
145
146    /// Build the Docker image if needed using the `ContainerImageBuilder`
147    fn build_image(docker_build_timeout: Duration) -> Result<()> {
148        let builder = ContainerImageBuilder::new()
149            .with_name(DEFAULT_IMAGE_NAME)
150            .with_tag(DEFAULT_IMAGE_TAG)
151            .with_dockerfile(std::path::PathBuf::from(
152                "docker/provisioned-instance/Dockerfile",
153            ))
154            .with_context(std::path::PathBuf::from("docker/provisioned-instance"))
155            .with_build_timeout(docker_build_timeout);
156        builder.build().map_err(|e| {
157            Box::new(ContainerError::ContainerImage {
158                source: ContainerImageError::BuildFailed {
159                    image_name: DEFAULT_IMAGE_NAME.to_string(),
160                    image_tag: DEFAULT_IMAGE_TAG.to_string(),
161                    reason: "Docker image build process failed".to_string(),
162                    source: *e,
163                },
164            })
165        })?;
166        Ok(())
167    }
168
169    /// Start the container and transition to running state
170    ///
171    /// # Arguments
172    ///
173    /// * `container_name` - Optional name for the running container. If provided, the container will be named accordingly.
174    /// * `ssh_port` - The internal SSH port to expose from the container
175    /// * `additional_ports` - Additional TCP ports to expose (e.g., tracker API, HTTP tracker)
176    ///
177    /// # Errors
178    ///
179    /// Returns an error if:
180    /// - Docker image build fails
181    /// - Container fails to start
182    /// - Container networking setup fails
183    pub async fn start(
184        self,
185        container_name: Option<String>,
186        ssh_port: u16,
187        additional_ports: &[u16],
188    ) -> Result<RunningProvisionedContainer> {
189        // First build the Docker image if needed
190        Self::build_image(self.timeouts.docker_build)?;
191
192        info!(
193            ssh_port = %ssh_port,
194            additional_ports = ?additional_ports,
195            "Starting provisioned instance container with Docker-in-Docker support"
196        );
197
198        // Create and start the container using the configuration builder
199        // Wait for both SSH and Docker daemon to be ready
200        let mut config_builder =
201            ContainerConfigBuilder::new(format!("{DEFAULT_IMAGE_NAME}:{DEFAULT_IMAGE_TAG}"))
202                .with_exposed_port(ssh_port)
203                .with_wait_condition(WaitFor::message_on_stdout("dockerd entered RUNNING state"));
204
205        // Add additional ports (tracker API, HTTP tracker, etc.)
206        for port in additional_ports {
207            config_builder = config_builder.with_exposed_port(*port);
208        }
209
210        let image = config_builder.build().map_err(|source| {
211            Box::new(ContainerError::ContainerRuntime {
212                source: ContainerRuntimeError::InvalidConfiguration {
213                    image_name: DEFAULT_IMAGE_NAME.to_string(),
214                    image_tag: DEFAULT_IMAGE_TAG.to_string(),
215                    reason: "Container configuration validation failed".to_string(),
216                    source: *source,
217                },
218            })
219        })?;
220
221        // Start the container with privileged mode for Docker-in-Docker support
222        // and optional container name
223        let container = if let Some(name) = container_name {
224            info!(container_name = %name, "Starting container with custom name and privileged mode");
225            image
226                .with_privileged(true)
227                .with_container_name(name)
228                .start()
229                .await
230        } else {
231            image.with_privileged(true).start().await
232        }
233        .map_err(|source| {
234            Box::new(ContainerError::ContainerRuntime {
235                source: ContainerRuntimeError::StartupFailed {
236                    image_name: DEFAULT_IMAGE_NAME.to_string(),
237                    image_tag: DEFAULT_IMAGE_TAG.to_string(),
238                    reason: "Container failed to start or reach expected state".to_string(),
239                    source,
240                },
241            })
242        })?;
243
244        // Get the dynamically assigned ports from Docker's port mapping (bridge networking)
245        let mapped_ssh_port = container.get_host_port_ipv4(ssh_port).await.map_err(|e| {
246            Box::new(ContainerError::ContainerRuntime {
247                source: ContainerRuntimeError::StartupFailed {
248                    image_name: DEFAULT_IMAGE_NAME.to_string(),
249                    image_tag: DEFAULT_IMAGE_TAG.to_string(),
250                    reason: format!("Failed to get mapped SSH port: {e}"),
251                    source: e,
252                },
253            })
254        })?;
255
256        // Get mapped ports for all additional ports (tracker services)
257        let mut mapped_additional_ports = Vec::new();
258        for port in additional_ports {
259            let mapped_port = container.get_host_port_ipv4(*port).await.map_err(|e| {
260                Box::new(ContainerError::ContainerRuntime {
261                    source: ContainerRuntimeError::StartupFailed {
262                        image_name: DEFAULT_IMAGE_NAME.to_string(),
263                        image_tag: DEFAULT_IMAGE_TAG.to_string(),
264                        reason: format!("Failed to get mapped port for {port}: {e}"),
265                        source: e,
266                    },
267                })
268            })?;
269            mapped_additional_ports.push(mapped_port);
270        }
271
272        info!(
273            container_id = %container.id(),
274            mapped_ssh_port,
275            mapped_additional_ports = ?mapped_additional_ports,
276            "Container started successfully with bridge networking"
277        );
278
279        Ok(RunningProvisionedContainer::new(
280            container,
281            mapped_ssh_port,
282            mapped_additional_ports,
283        ))
284    }
285}
286
287/// Running state - container is started and can be configured
288pub struct RunningProvisionedContainer {
289    container: ContainerAsync<GenericImage>,
290    ssh_port: u16,
291    additional_mapped_ports: Vec<u16>,
292}
293
294impl ContainerExecutor for RunningProvisionedContainer {
295    async fn exec(
296        &self,
297        command: testcontainers::core::ExecCommand,
298    ) -> std::result::Result<(), testcontainers::TestcontainersError> {
299        self.container.exec(command).await.map(|_| ())
300    }
301}
302
303impl RunningProvisionedContainer {
304    pub(crate) fn new(
305        container: ContainerAsync<GenericImage>,
306        ssh_port: u16,
307        additional_mapped_ports: Vec<u16>,
308    ) -> Self {
309        Self {
310            container,
311            ssh_port,
312            additional_mapped_ports,
313        }
314    }
315
316    /// Get the SSH connection details
317    #[must_use]
318    pub fn ssh_socket_addr(&self) -> SocketAddr {
319        SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), self.ssh_port)
320    }
321
322    /// Get the mapped additional ports (tracker API, HTTP tracker, UDP tracker, etc.)
323    /// Returns ports in the same order they were requested when starting the container
324    #[must_use]
325    pub fn additional_mapped_ports(&self) -> &[u16] {
326        &self.additional_mapped_ports
327    }
328
329    /// Get the container ID for logging/debugging
330    #[must_use]
331    pub fn container_id(&self) -> &str {
332        self.container.id()
333    }
334
335    /// Stop the container and transition back to stopped state
336    pub fn stop(self) -> StoppedProvisionedContainer {
337        info!(container_id = %self.container.id(), "Stopping container");
338        // Container will be automatically cleaned up when dropped
339        StoppedProvisionedContainer::default()
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use std::error::Error;
347
348    #[test]
349    fn it_should_create_default_stopped_container() {
350        let container = StoppedProvisionedContainer::default();
351        assert!(std::ptr::eq(
352            std::ptr::addr_of!(container),
353            std::ptr::addr_of!(container)
354        )); // Just test it exists
355    }
356
357    #[test]
358    fn it_should_have_proper_error_display_messages() {
359        let error = ContainerError::ContainerImage {
360            source: ContainerImageError::BuildFailed {
361                image_name: "test-image".to_string(),
362                image_tag: "test-tag".to_string(),
363                reason: "Docker build compilation failed".to_string(),
364                source: crate::testing::e2e::containers::image_builder::ContainerBuildError::ContainerBuildFailed {
365                    image_name: "test-image".to_string(),
366                    tag: "test-tag".to_string(),
367                    dockerfile_path: "/path/to/Dockerfile".to_string(),
368                    context_path: "/build/context".to_string(),
369                    build_duration_secs: 60,
370                    stderr: "test error message".to_string(),
371                },
372            },
373        };
374        assert!(error.to_string().contains("Container image problem"));
375        assert!(error.to_string().contains("Failed to build Docker image"));
376        assert!(error.to_string().contains("test-image:test-tag"));
377        assert!(error
378            .to_string()
379            .contains("Docker build compilation failed"));
380    }
381
382    #[test]
383    fn it_should_preserve_error_chain_for_docker_command_execution() {
384        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "docker not found");
385        let image_error = ContainerImageError::DockerCommandFailed {
386            image_name: "test-image".to_string(),
387            image_tag: "test-tag".to_string(),
388            reason: "Docker daemon not available".to_string(),
389            source: io_error,
390        };
391        let error = ContainerError::ContainerImage {
392            source: image_error,
393        };
394
395        assert!(error
396            .to_string()
397            .contains("Docker command execution failed"));
398        assert!(error.to_string().contains("test-image:test-tag"));
399        assert!(error.to_string().contains("Docker daemon not available"));
400        assert!(error.source().is_some());
401    }
402
403    #[test]
404    fn it_should_convert_docker_build_error_to_provisioned_container_error() {
405        use crate::testing::e2e::containers::image_builder::ContainerBuildError;
406
407        let docker_build_error = ContainerBuildError::ContainerBuildFailed {
408            image_name: "test-image".to_string(),
409            tag: "v1.0".to_string(),
410            dockerfile_path: "/path/to/Dockerfile".to_string(),
411            context_path: "/build/context".to_string(),
412            build_duration_secs: 60,
413            stderr: "build failed".to_string(),
414        };
415
416        let image_error = ContainerImageError::BuildFailed {
417            image_name: "test-image".to_string(),
418            image_tag: "v1.0".to_string(),
419            reason: "Docker image build process failed".to_string(),
420            source: docker_build_error,
421        };
422        let provisioned_error = ContainerError::ContainerImage {
423            source: image_error,
424        };
425
426        assert!(provisioned_error
427            .to_string()
428            .contains("Container image problem"));
429        assert!(std::error::Error::source(&provisioned_error).is_some());
430    }
431
432    #[test]
433    fn it_should_group_networking_errors_logically() {
434        // Test port mapping error
435        let testcontainers_error = testcontainers::TestcontainersError::other("port conflict");
436        let networking_error = ContainerNetworkingError::PortMappingFailed {
437            container_id: "container123".to_string(),
438            internal_port: 22,
439            reason: "Port already in use".to_string(),
440            source: testcontainers_error,
441        };
442        let provisioned_error = ContainerError::ContainerNetworking {
443            source: networking_error,
444        };
445
446        assert!(provisioned_error
447            .to_string()
448            .contains("Container networking problem"));
449        assert!(provisioned_error
450            .to_string()
451            .contains("Failed to get mapped port 22"));
452        assert!(provisioned_error
453            .to_string()
454            .contains("Port already in use"));
455    }
456
457    #[test]
458    fn it_should_group_runtime_errors_logically() {
459        let testcontainers_error = testcontainers::TestcontainersError::other("resource limit");
460        let runtime_error = ContainerRuntimeError::StartupFailed {
461            image_name: "test-image".to_string(),
462            image_tag: "latest".to_string(),
463            reason: "Insufficient memory".to_string(),
464            source: testcontainers_error,
465        };
466        let provisioned_error = ContainerError::ContainerRuntime {
467            source: runtime_error,
468        };
469
470        assert!(provisioned_error
471            .to_string()
472            .contains("Container runtime problem"));
473        assert!(provisioned_error
474            .to_string()
475            .contains("Container failed to start"));
476        assert!(provisioned_error
477            .to_string()
478            .contains("Insufficient memory"));
479    }
480
481    #[test]
482    fn it_should_allow_matching_on_logical_error_categories() {
483        let image_error = ContainerImageError::BuildFailed {
484            image_name: "test".to_string(),
485            image_tag: "latest".to_string(),
486            reason: "Build failed".to_string(),
487            source: crate::testing::e2e::containers::image_builder::ContainerBuildError::ImageNameRequired,
488        };
489        let error = ContainerError::ContainerImage {
490            source: image_error,
491        };
492
493        // Test that we can match on logical error categories
494        match error {
495            ContainerError::ContainerImage { .. } => {
496                // This should match - demonstrates logical error categorization
497            }
498            ContainerError::ContainerRuntime { .. } => {
499                panic!("Should not match runtime category");
500            }
501            ContainerError::ContainerNetworking { .. } => {
502                panic!("Should not match networking category");
503            }
504            ContainerError::SshSetup { .. } => {
505                panic!("Should not match SSH category");
506            }
507        }
508    }
509
510    // Note: Integration tests that actually start containers would require Docker
511    // and are better suited for the e2e test binaries
512}