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}