Skip to main content

torrust_tracker_deployer_lib/testing/e2e/containers/
config_builder.rs

1//! Container Configuration Builder
2//!
3//! This module provides a flexible builder pattern for configuring Docker containers
4//! used in E2E testing. It replaces hardcoded container configurations with explicit,
5//! testable, and reusable configuration builders.
6//!
7//! ## Key Features
8//!
9//! - **Builder Pattern**: Fluent API for configuring containers
10//! - **Type Safety**: Compile-time validation of configuration
11//! - **Input Validation**: Runtime validation of image names and ports
12//! - **Flexibility**: Support for ports and wait conditions
13//! - **Testability**: Easy to create different configurations for testing
14//!
15//! ## Usage Example
16//!
17//! ```rust,no_run
18//! use torrust_tracker_deployer_lib::testing::e2e::containers::config_builder::ContainerConfigBuilder;
19//! use testcontainers::core::WaitFor;
20//!
21//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
22//! let config = ContainerConfigBuilder::new("my-app:latest")
23//!     .with_exposed_port(22)
24//!     .with_wait_condition(WaitFor::message_on_stdout("Server started"))
25//!     .build()?;
26//! # Ok(())
27//! # }
28//! ```
29
30use testcontainers::{
31    core::{IntoContainerPort, WaitFor},
32    GenericImage,
33};
34
35/// Specific error types for container configuration building
36#[derive(Debug, thiserror::Error)]
37pub enum ContainerConfigError {
38    /// Invalid Docker image name format
39    #[error("Invalid image name '{image_name}': {reason}")]
40    InvalidImageName { image_name: String, reason: String },
41
42    /// Invalid port number
43    #[error("Invalid port number {port}: {reason}")]
44    InvalidPort { port: u16, reason: String },
45
46    /// Empty image name provided
47    #[error("Image name cannot be empty")]
48    EmptyImageName,
49
50    /// Too many wait conditions (potential performance issue)
51    #[error("Too many wait conditions ({count}): maximum {max_allowed} wait conditions are recommended for optimal container startup performance")]
52    TooManyWaitConditions { count: usize, max_allowed: usize },
53}
54
55/// Result type alias for container configuration operations
56pub type Result<T> = std::result::Result<T, Box<ContainerConfigError>>;
57
58/// Flexible container configuration builder
59///
60/// This struct provides a builder pattern for configuring Docker containers
61/// with explicit configuration options instead of hardcoded values.
62///
63/// Currently supports the minimal set of features needed by the provisioned container:
64/// - Image name and tag validation
65/// - Container name customization
66/// - Exposed ports (with validation)
67/// - Wait conditions (with reasonable limits)
68#[derive(Debug, Clone)]
69pub struct ContainerConfigBuilder {
70    /// Docker image name (e.g., "torrust-provisioned-instance:latest")
71    image: String,
72
73    /// Optional container name for the running container
74    container_name: Option<String>,
75
76    /// List of ports to expose from the container (as u16)
77    exposed_ports: Vec<u16>,
78
79    /// Wait conditions to determine when container is ready
80    wait_conditions: Vec<WaitFor>,
81}
82
83impl ContainerConfigBuilder {
84    /// Create a new container configuration builder with the specified image
85    ///
86    /// # Arguments
87    ///
88    /// * `image` - Docker image name with optional tag (e.g., "redis:7", "app:latest")
89    ///
90    /// # Example
91    ///
92    /// ```rust
93    /// use torrust_tracker_deployer_lib::testing::e2e::containers::config_builder::ContainerConfigBuilder;
94    ///
95    /// let builder = ContainerConfigBuilder::new("torrust-provisioned-instance:latest");
96    /// ```
97    pub fn new(image: impl Into<String>) -> Self {
98        Self {
99            image: image.into(),
100            container_name: None,
101            exposed_ports: Vec::new(),
102            wait_conditions: Vec::new(),
103        }
104    }
105
106    /// Add an exposed port to the container configuration
107    ///
108    /// # Arguments
109    ///
110    /// * `port` - Port number to expose (as u16)
111    ///
112    /// # Example
113    ///
114    /// ```rust
115    /// use torrust_tracker_deployer_lib::testing::e2e::containers::config_builder::ContainerConfigBuilder;
116    ///
117    /// let builder = ContainerConfigBuilder::new("torrust-provisioned-instance:latest")
118    ///     .with_exposed_port(22)
119    ///     .with_exposed_port(80);
120    /// ```
121    #[must_use]
122    pub fn with_exposed_port(mut self, port: u16) -> Self {
123        if port == 0 {
124            // Note: We'll handle this validation in the build() method to maintain
125            // the current API which doesn't return Result. This is a design choice
126            // to keep the builder pattern simple and ergonomic.
127            tracing::warn!("Port 0 is reserved and will cause issues during container build");
128        }
129
130        if self.exposed_ports.contains(&port) {
131            tracing::warn!("Port {port} is already exposed, skipping duplicate");
132        } else {
133            self.exposed_ports.push(port);
134        }
135        self
136    }
137
138    /// Set a custom container name for the running container
139    ///
140    /// # Arguments
141    ///
142    /// * `name` - Container name to use when starting the container
143    ///
144    /// # Example
145    ///
146    /// ```rust
147    /// use torrust_tracker_deployer_lib::testing::e2e::containers::config_builder::ContainerConfigBuilder;
148    ///
149    /// let builder = ContainerConfigBuilder::new("torrust-provisioned-instance:latest")
150    ///     .with_container_name("my-custom-container-name");
151    /// ```
152    #[must_use]
153    pub fn with_container_name(mut self, name: impl Into<String>) -> Self {
154        self.container_name = Some(name.into());
155        self
156    }
157
158    /// Add a wait condition to determine when the container is ready
159    ///
160    /// # Arguments
161    ///
162    /// * `condition` - Wait condition (e.g., message on stdout, HTTP endpoint, etc.)
163    ///
164    /// # Example
165    ///
166    /// ```rust
167    /// use torrust_tracker_deployer_lib::testing::e2e::containers::config_builder::ContainerConfigBuilder;
168    /// use testcontainers::core::WaitFor;
169    ///
170    /// let builder = ContainerConfigBuilder::new("torrust-provisioned-instance:latest")
171    ///     .with_wait_condition(WaitFor::message_on_stdout("sshd entered RUNNING state"));
172    /// ```
173    #[must_use]
174    pub fn with_wait_condition(mut self, condition: WaitFor) -> Self {
175        self.wait_conditions.push(condition);
176        self
177    }
178
179    /// Build the final `GenericImage` with all configured options and validation
180    ///
181    /// This method creates a `GenericImage` with all the configuration options
182    /// that were specified using the builder methods. It also validates the
183    /// configuration to catch common issues early.
184    ///
185    /// # Returns
186    ///
187    /// A configured `GenericImage` ready to be used with testcontainers
188    ///
189    /// # Errors
190    ///
191    /// Returns an error if:
192    /// - Image name is empty or invalid
193    /// - Any port number is invalid (e.g., 0)
194    /// - Too many wait conditions (performance concern)
195    ///
196    /// # Example
197    ///
198    /// ```rust,no_run
199    /// use torrust_tracker_deployer_lib::testing::e2e::containers::config_builder::ContainerConfigBuilder;
200    /// use testcontainers::core::WaitFor;
201    ///
202    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
203    /// let image = ContainerConfigBuilder::new("torrust-provisioned-instance:latest")
204    ///     .with_exposed_port(22)
205    ///     .with_wait_condition(WaitFor::message_on_stdout("sshd entered RUNNING state"))
206    ///     .build()?;
207    /// # Ok(())
208    /// # }
209    /// ```
210    pub fn build(self) -> Result<GenericImage> {
211        const MAX_RECOMMENDED_WAIT_CONDITIONS: usize = 5;
212
213        // Validate image name
214        if self.image.is_empty() {
215            return Err(Box::new(ContainerConfigError::EmptyImageName));
216        }
217
218        if self.image.trim().is_empty() {
219            return Err(Box::new(ContainerConfigError::InvalidImageName {
220                image_name: self.image.clone(),
221                reason: "image name contains only whitespace".to_string(),
222            }));
223        }
224
225        // Basic image name format validation
226        if self.image.contains("//") || self.image.starts_with('/') || self.image.ends_with('/') {
227            return Err(Box::new(ContainerConfigError::InvalidImageName {
228                image_name: self.image.clone(),
229                reason: "image name contains invalid path separators".to_string(),
230            }));
231        }
232
233        // Validate ports
234        for &port in &self.exposed_ports {
235            if port == 0 {
236                return Err(Box::new(ContainerConfigError::InvalidPort {
237                    port,
238                    reason: "port 0 is reserved and cannot be exposed".to_string(),
239                }));
240            }
241        }
242
243        // Check for reasonable number of wait conditions (performance concern)
244        if self.wait_conditions.len() > MAX_RECOMMENDED_WAIT_CONDITIONS {
245            return Err(Box::new(ContainerConfigError::TooManyWaitConditions {
246                count: self.wait_conditions.len(),
247                max_allowed: MAX_RECOMMENDED_WAIT_CONDITIONS,
248            }));
249        }
250
251        // Split the image name and tag if present
252        let parts: Vec<&str> = self.image.split(':').collect();
253        let (image_name, image_tag) = if parts.len() == 2 {
254            (parts[0], parts[1])
255        } else {
256            (self.image.as_str(), "latest")
257        };
258
259        // Additional validation for image name part
260        if image_name.is_empty() {
261            return Err(Box::new(ContainerConfigError::InvalidImageName {
262                image_name: self.image.clone(),
263                reason: "image name part is empty".to_string(),
264            }));
265        }
266
267        let mut image = GenericImage::new(image_name, image_tag);
268
269        // Add exposed ports using the testcontainers pattern
270        for &port_num in &self.exposed_ports {
271            image = image.with_exposed_port(port_num.tcp());
272        }
273
274        // Add wait conditions
275        for condition in self.wait_conditions {
276            image = image.with_wait_for(condition);
277        }
278
279        Ok(image)
280    }
281
282    /// Get the configured image name
283    ///
284    /// # Returns
285    ///
286    /// The Docker image name that was configured for this builder
287    #[must_use]
288    pub fn image_name(&self) -> &str {
289        &self.image
290    }
291
292    /// Get the configured exposed ports
293    ///
294    /// # Returns
295    ///
296    /// A slice of port numbers that will be exposed
297    #[must_use]
298    pub fn exposed_ports(&self) -> &[u16] {
299        &self.exposed_ports
300    }
301
302    /// Get the number of configured wait conditions
303    ///
304    /// # Returns
305    ///
306    /// The number of wait conditions configured
307    #[must_use]
308    pub fn wait_conditions_count(&self) -> usize {
309        self.wait_conditions.len()
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use testcontainers::core::WaitFor;
317
318    #[test]
319    fn it_should_create_builder_with_image_name() {
320        let builder = ContainerConfigBuilder::new("torrust-provisioned-instance:latest");
321        assert_eq!(builder.image_name(), "torrust-provisioned-instance:latest");
322        assert_eq!(builder.exposed_ports().len(), 0);
323        assert_eq!(builder.wait_conditions_count(), 0);
324    }
325
326    #[test]
327    fn it_should_add_exposed_ports() {
328        let builder = ContainerConfigBuilder::new("torrust-provisioned-instance:latest")
329            .with_exposed_port(22)
330            .with_exposed_port(80);
331
332        let ports = builder.exposed_ports();
333        assert_eq!(ports.len(), 2);
334        assert!(ports.contains(&22));
335        assert!(ports.contains(&80));
336    }
337
338    #[test]
339    fn it_should_add_wait_conditions() {
340        let builder = ContainerConfigBuilder::new("torrust-provisioned-instance:latest")
341            .with_wait_condition(WaitFor::message_on_stdout("sshd entered RUNNING state"))
342            .with_wait_condition(WaitFor::seconds(2));
343
344        assert_eq!(builder.wait_conditions_count(), 2);
345    }
346
347    #[test]
348    fn it_should_build_generic_image_with_all_options() {
349        let image = ContainerConfigBuilder::new("torrust-provisioned-instance:latest")
350            .with_exposed_port(22)
351            .with_wait_condition(WaitFor::message_on_stdout("sshd entered RUNNING state"))
352            .build();
353
354        // Since GenericImage doesn't provide direct getters for configuration,
355        // we can only test that it builds successfully without error
356        // The actual configuration is tested through integration tests
357        std::mem::drop(image); // Just verify it builds
358    }
359
360    #[test]
361    fn it_should_handle_empty_configuration() {
362        let image = ContainerConfigBuilder::new("alpine:latest").build();
363        std::mem::drop(image); // Just verify it builds
364    }
365
366    #[test]
367    fn it_should_chain_builder_methods_fluently() {
368        let builder = ContainerConfigBuilder::new("torrust-provisioned-instance:latest")
369            .with_exposed_port(22)
370            .with_wait_condition(WaitFor::seconds(1));
371
372        assert_eq!(builder.image_name(), "torrust-provisioned-instance:latest");
373        assert_eq!(builder.exposed_ports().len(), 1);
374        assert_eq!(builder.wait_conditions_count(), 1);
375    }
376
377    #[test]
378    fn it_should_accept_string_and_str_for_image_name() {
379        let builder1 = ContainerConfigBuilder::new("app:latest");
380        let builder2 = ContainerConfigBuilder::new(String::from("app:latest"));
381
382        assert_eq!(builder1.image_name(), builder2.image_name());
383    }
384
385    #[test]
386    fn it_should_deduplicate_same_port_numbers() {
387        let builder = ContainerConfigBuilder::new("app:latest")
388            .with_exposed_port(8080)
389            .with_exposed_port(8080);
390
391        // Duplicate ports should be deduplicated since Docker/testcontainers
392        // doesn't support exposing the same port number multiple times
393        assert_eq!(builder.exposed_ports().len(), 1);
394        assert_eq!(builder.exposed_ports()[0], 8080);
395    }
396
397    #[test]
398    fn it_should_split_image_name_and_tag_correctly() {
399        // Test with explicit tag
400        let image1 = ContainerConfigBuilder::new("redis:7").build();
401        std::mem::drop(image1);
402
403        // Test without tag (should default to latest)
404        let image2 = ContainerConfigBuilder::new("redis").build();
405        std::mem::drop(image2);
406
407        // Test with complex image name
408        let image3 = ContainerConfigBuilder::new("registry.example.com/myapp:v1.2.3").build();
409        std::mem::drop(image3);
410    }
411}