Skip to main content

torrust_tracker_deployer_lib/testing/e2e/containers/
image_builder.rs

1//! Container Image Builder for E2E Testing Containers
2//!
3//! This module provides a builder pattern for constructing and building container images
4//! used in E2E testing scenarios. It separates the container image building logic from
5//! the container lifecycle management, following the Single Responsibility Principle.
6//!
7//! ## Design
8//!
9//! The builder follows the standard Rust builder pattern with method chaining:
10//!
11//! ```rust,no_run
12//! use torrust_tracker_deployer_lib::testing::e2e::containers::ContainerImageBuilder;
13//! use std::path::PathBuf;
14//! use std::time::Duration;
15//!
16//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
17//! let builder = ContainerImageBuilder::new()
18//!     .with_name("torrust-provisioned-instance")
19//!     .with_tag("latest")
20//!     .with_dockerfile(PathBuf::from("docker/provisioned-instance/Dockerfile"))
21//!     .with_context(PathBuf::from("."))
22//!     .with_build_timeout(Duration::from_secs(300));
23//!
24//! builder.build()?;
25//! # Ok(())
26//! # }
27//! ```
28//!
29//! ## Error Handling
30//!
31//! The module provides specific error types for container build operations:
32//! - `ContainerBuildExecution` - Command execution failures
33//! - `ContainerBuildFailed` - Build process failures with stderr output
34//! - `ImageNameRequired` - Image name not provided (use `with_name()`)
35//! - `DockerfilePathRequired` - Dockerfile path not provided (use `with_dockerfile()`)
36//!
37//! ## Required Configuration
38//!
39//! The following must be provided before calling `build()`:
40//! - **Image name**: Must be set with `with_name()`
41//! - **Dockerfile path**: Must be set with `with_dockerfile()`
42//!
43//! ## Default Configuration
44//!
45//! The builder provides sensible defaults for optional parameters:
46//! - **Tag**: "latest"
47//! - **Build context**: "." (current directory)
48//! - **Build timeout**: 300 seconds
49
50use std::path::PathBuf;
51use std::process::Command;
52use std::time::Duration;
53use tracing::info;
54
55/// Specific error types for container image building operations
56#[derive(Debug, thiserror::Error)]
57pub enum ContainerBuildError {
58    /// Container build command execution failed
59    #[error("Failed to execute docker build command for image '{image_name}:{tag}' using dockerfile '{dockerfile_path}' in context '{context_path}': {source}")]
60    ContainerBuildExecution {
61        image_name: String,
62        tag: String,
63        dockerfile_path: String,
64        context_path: String,
65        #[source]
66        source: std::io::Error,
67    },
68
69    /// Container build process failed with non-zero exit code
70    #[error("Docker build failed for image '{image_name}:{tag}' using dockerfile '{dockerfile_path}' in context '{context_path}' after {build_duration_secs}s with stderr: {stderr}")]
71    ContainerBuildFailed {
72        image_name: String,
73        tag: String,
74        dockerfile_path: String,
75        context_path: String,
76        build_duration_secs: u64,
77        stderr: String,
78    },
79
80    /// Container build process timed out
81    #[error("Docker build timed out after {timeout_secs}s for image '{image_name}:{tag}' using dockerfile '{dockerfile_path}' in context '{context_path}'")]
82    ContainerBuildTimeout {
83        image_name: String,
84        tag: String,
85        dockerfile_path: String,
86        context_path: String,
87        timeout_secs: u64,
88    },
89
90    /// Required image name was not provided
91    #[error("Image name is required but was not provided")]
92    ImageNameRequired,
93
94    /// Required dockerfile path was not provided
95    #[error("Dockerfile path is required but was not provided")]
96    DockerfilePathRequired,
97
98    /// Dockerfile does not exist at the specified path
99    #[error("Dockerfile not found at path '{dockerfile_path}' (resolved to '{absolute_path}')")]
100    DockerfileNotFound {
101        dockerfile_path: String,
102        absolute_path: String,
103    },
104
105    /// Context path does not exist
106    #[error("Context path not found at '{context_path}' (resolved to '{absolute_path}')")]
107    ContextPathNotFound {
108        context_path: String,
109        absolute_path: String,
110    },
111}
112
113/// Result type alias for container build operations
114pub type Result<T> = std::result::Result<T, Box<ContainerBuildError>>;
115
116/// Builder for constructing and building container images
117///
118/// This builder follows the standard Rust builder pattern, allowing
119/// method chaining to configure container image build parameters.
120///
121/// # Required Values
122///
123/// The following values must be provided before calling `build()`:
124/// - **Image name**: Must be set with `with_name()`
125/// - **Dockerfile path**: Must be set with `with_dockerfile()`
126///
127/// # Default Values
128///
129/// - **Tag**: "latest"
130/// - **Build context**: "." (current directory)
131/// - **Build timeout**: 300 seconds
132#[derive(Debug, Clone)]
133pub struct ContainerImageBuilder {
134    image_name: Option<String>,
135    tag: String,
136    dockerfile_path: Option<PathBuf>,
137    context_path: PathBuf,
138    build_timeout: Duration,
139}
140
141impl ContainerImageBuilder {
142    /// Create a new container image builder with default configuration
143    ///
144    /// # Examples
145    ///
146    /// ```rust
147    /// use torrust_tracker_deployer_lib::testing::e2e::containers::ContainerImageBuilder;
148    ///
149    /// let builder = ContainerImageBuilder::new();
150    /// ```
151    #[must_use]
152    pub fn new() -> Self {
153        Self {
154            image_name: None,
155            tag: "latest".to_string(),
156            dockerfile_path: None,
157            context_path: PathBuf::from("."),
158            build_timeout: Duration::from_mins(5),
159        }
160    }
161
162    /// Set the container image name
163    ///
164    /// # Arguments
165    ///
166    /// * `name` - The container image name
167    ///
168    /// # Examples
169    ///
170    /// ```rust
171    /// use torrust_tracker_deployer_lib::testing::e2e::containers::ContainerImageBuilder;
172    ///
173    /// let builder = ContainerImageBuilder::new()
174    ///     .with_name("my-custom-image");
175    /// ```
176    #[must_use]
177    pub fn with_name(mut self, name: impl Into<String>) -> Self {
178        self.image_name = Some(name.into());
179        self
180    }
181
182    /// Set the container image tag
183    ///
184    /// # Arguments
185    ///
186    /// * `tag` - The container image tag
187    ///
188    /// # Examples
189    ///
190    /// ```rust
191    /// use torrust_tracker_deployer_lib::testing::e2e::containers::ContainerImageBuilder;
192    ///
193    /// let builder = ContainerImageBuilder::new()
194    ///     .with_tag("v1.0.0");
195    /// ```
196    #[must_use]
197    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
198        self.tag = tag.into();
199        self
200    }
201
202    /// Set the path to the Dockerfile
203    ///
204    /// # Arguments
205    ///
206    /// * `path` - Path to the Dockerfile
207    ///
208    /// # Examples
209    ///
210    /// ```rust
211    /// use torrust_tracker_deployer_lib::testing::e2e::containers::ContainerImageBuilder;
212    /// use std::path::PathBuf;
213    ///
214    /// let builder = ContainerImageBuilder::new()
215    ///     .with_dockerfile(PathBuf::from("custom/Dockerfile"));
216    /// ```
217    #[must_use]
218    pub fn with_dockerfile(mut self, path: PathBuf) -> Self {
219        self.dockerfile_path = Some(path);
220        self
221    }
222
223    /// Set the Docker build context path
224    ///
225    /// # Arguments
226    ///
227    /// * `path` - Path to the build context directory
228    ///
229    /// # Examples
230    ///
231    /// ```rust
232    /// use torrust_tracker_deployer_lib::testing::e2e::containers::ContainerImageBuilder;
233    /// use std::path::PathBuf;
234    ///
235    /// let builder = ContainerImageBuilder::new()
236    ///     .with_context(PathBuf::from("./app"));
237    /// ```
238    #[must_use]
239    pub fn with_context(mut self, path: PathBuf) -> Self {
240        self.context_path = path;
241        self
242    }
243
244    /// Set the build timeout duration
245    ///
246    /// # Arguments
247    ///
248    /// * `timeout` - Maximum time to wait for build completion
249    ///
250    /// # Examples
251    ///
252    /// ```rust
253    /// use torrust_tracker_deployer_lib::testing::e2e::containers::ContainerImageBuilder;
254    /// use std::time::Duration;
255    ///
256    /// let builder = ContainerImageBuilder::new()
257    ///     .with_build_timeout(Duration::from_secs(600));
258    /// ```
259    #[must_use]
260    pub fn with_build_timeout(mut self, timeout: Duration) -> Self {
261        self.build_timeout = timeout;
262        self
263    }
264
265    /// Build the container image using the configured parameters
266    ///
267    /// This method executes the `docker build` command with the configured
268    /// parameters. It provides detailed error information if the build fails.
269    ///
270    /// # Errors
271    ///
272    /// Returns an error if:
273    /// - Image name was not provided (use `with_name()`)
274    /// - Dockerfile path was not provided (use `with_dockerfile()`)
275    /// - Docker command cannot be executed (e.g., Docker not installed)
276    /// - Docker build process fails (non-zero exit code)
277    ///
278    /// # Examples
279    ///
280    /// ```rust,no_run
281    /// use torrust_tracker_deployer_lib::testing::e2e::containers::ContainerImageBuilder;
282    /// use std::path::PathBuf;
283    ///
284    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
285    /// let builder = ContainerImageBuilder::new()
286    ///     .with_name("my-image")
287    ///     .with_dockerfile(PathBuf::from("Dockerfile"));
288    /// builder.build()?;
289    /// # Ok(())
290    /// # }
291    /// ```
292    pub fn build(&self) -> Result<()> {
293        let image_name = self
294            .image_name
295            .as_ref()
296            .ok_or_else(|| Box::new(ContainerBuildError::ImageNameRequired))?;
297        let dockerfile_path = self
298            .dockerfile_path
299            .as_ref()
300            .ok_or_else(|| Box::new(ContainerBuildError::DockerfilePathRequired))?;
301
302        let image_tag = format!("{}:{}", image_name, self.tag);
303        let dockerfile_path_str = dockerfile_path.display().to_string();
304        let context_path_str = self.context_path.display().to_string();
305
306        // When parallel tests build the same image concurrently, the second build
307        // may fail with "already exists" at the tagging step — this is handled
308        // gracefully below by treating it as success.
309
310        info!(
311            image_name = %image_name,
312            tag = %self.tag,
313            dockerfile = %dockerfile_path.display(),
314            context = %self.context_path.display(),
315            timeout_secs = self.build_timeout.as_secs(),
316            "Building Docker image"
317        );
318
319        let start_time = std::time::Instant::now();
320        let output = Command::new("docker")
321            .args([
322                "build",
323                "-t",
324                &image_tag,
325                "-f",
326                &dockerfile_path_str,
327                "--force-rm", // Cleanup intermediate containers even on build failure
328                &context_path_str,
329            ])
330            .output()
331            .map_err(|source| {
332                Box::new(ContainerBuildError::ContainerBuildExecution {
333                    image_name: image_name.clone(),
334                    tag: self.tag.clone(),
335                    dockerfile_path: dockerfile_path_str.clone(),
336                    context_path: context_path_str.clone(),
337                    source,
338                })
339            })?;
340
341        let build_duration = start_time.elapsed();
342
343        if !output.status.success() {
344            let stderr = String::from_utf8_lossy(&output.stderr);
345
346            // When multiple tests run in parallel, they may all attempt to build the
347            // same image simultaneously. The first build succeeds and tags the image.
348            // Subsequent builds complete all steps successfully but fail at the final
349            // export/tagging step with "already exists" because the tag was claimed
350            // by the first build. This is not a real failure — the image is available.
351            if stderr.contains("already exists") {
352                info!(
353                    image_name = %image_name,
354                    tag = %self.tag,
355                    "Docker image was built by a concurrent process, treating as success"
356                );
357                return Ok(());
358            }
359
360            return Err(Box::new(ContainerBuildError::ContainerBuildFailed {
361                image_name: image_name.clone(),
362                tag: self.tag.clone(),
363                dockerfile_path: dockerfile_path_str,
364                context_path: context_path_str,
365                build_duration_secs: build_duration.as_secs(),
366                stderr: stderr.to_string(),
367            }));
368        }
369
370        info!(
371            image_name = %image_name,
372            tag = %self.tag,
373            build_duration_ms = build_duration.as_millis(),
374            "Docker image built successfully"
375        );
376
377        Ok(())
378    }
379
380    /// Get the full image tag (name:tag) that will be used for the build
381    ///
382    /// # Panics
383    ///
384    /// Panics if image name has not been set. Use `with_name()` first.
385    ///
386    /// # Examples
387    ///
388    /// ```rust
389    /// use torrust_tracker_deployer_lib::testing::e2e::containers::ContainerImageBuilder;
390    ///
391    /// let builder = ContainerImageBuilder::new()
392    ///     .with_name("my-app")
393    ///     .with_tag("v1.0");
394    ///     
395    /// assert_eq!(builder.image_tag(), "my-app:v1.0");
396    /// ```
397    #[must_use]
398    pub fn image_tag(&self) -> String {
399        let image_name = self
400            .image_name
401            .as_ref()
402            .expect("Image name must be set before calling image_tag()");
403        format!("{}:{}", image_name, self.tag)
404    }
405
406    /// Get the image name if it has been set
407    #[must_use]
408    pub fn image_name(&self) -> Option<&str> {
409        self.image_name.as_deref()
410    }
411
412    /// Get the image tag
413    #[must_use]
414    pub fn tag(&self) -> &str {
415        &self.tag
416    }
417
418    /// Get the dockerfile path if it has been set
419    #[must_use]
420    pub fn dockerfile_path(&self) -> Option<&PathBuf> {
421        self.dockerfile_path.as_ref()
422    }
423
424    /// Get the build context path
425    #[must_use]
426    pub fn context_path(&self) -> &PathBuf {
427        &self.context_path
428    }
429
430    /// Get the build timeout
431    #[must_use]
432    pub fn build_timeout(&self) -> Duration {
433        self.build_timeout
434    }
435}
436
437impl Default for ContainerImageBuilder {
438    fn default() -> Self {
439        Self::new()
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use std::error::Error;
447
448    #[test]
449    fn it_should_create_builder_with_default_values() {
450        let builder = ContainerImageBuilder::new();
451
452        assert_eq!(builder.image_name(), None);
453        assert_eq!(builder.tag(), "latest");
454        assert_eq!(builder.dockerfile_path(), None);
455        assert_eq!(builder.context_path(), &PathBuf::from("."));
456        assert_eq!(builder.build_timeout(), Duration::from_mins(5));
457    }
458
459    #[test]
460    fn it_should_create_default_builder() {
461        let builder = ContainerImageBuilder::default();
462
463        assert_eq!(builder.image_name(), None);
464        assert_eq!(builder.tag(), "latest");
465    }
466
467    #[test]
468    fn it_should_configure_image_name() {
469        let builder = ContainerImageBuilder::new().with_name("custom-image");
470
471        assert_eq!(builder.image_name(), Some("custom-image"));
472        assert_eq!(builder.image_tag(), "custom-image:latest");
473    }
474
475    #[test]
476    fn it_should_configure_image_tag() {
477        let builder = ContainerImageBuilder::new()
478            .with_name("test-image")
479            .with_tag("v1.2.3");
480
481        assert_eq!(builder.tag(), "v1.2.3");
482        assert_eq!(builder.image_tag(), "test-image:v1.2.3");
483    }
484
485    #[test]
486    fn it_should_configure_dockerfile_path() {
487        let dockerfile_path = PathBuf::from("custom/Dockerfile");
488        let builder = ContainerImageBuilder::new().with_dockerfile(dockerfile_path.clone());
489
490        assert_eq!(builder.dockerfile_path(), Some(&dockerfile_path));
491    }
492
493    #[test]
494    fn it_should_configure_context_path() {
495        let context_path = PathBuf::from("./app");
496        let builder = ContainerImageBuilder::new().with_context(context_path.clone());
497
498        assert_eq!(builder.context_path(), &context_path);
499    }
500
501    #[test]
502    fn it_should_configure_build_timeout() {
503        let timeout = Duration::from_mins(10);
504        let builder = ContainerImageBuilder::new().with_build_timeout(timeout);
505
506        assert_eq!(builder.build_timeout(), timeout);
507    }
508
509    #[test]
510    fn it_should_chain_configuration_methods() {
511        let builder = ContainerImageBuilder::new()
512            .with_name("my-app")
513            .with_tag("v2.0")
514            .with_dockerfile(PathBuf::from("custom/Dockerfile"))
515            .with_context(PathBuf::from("./src"))
516            .with_build_timeout(Duration::from_mins(15));
517
518        assert_eq!(builder.image_name(), Some("my-app"));
519        assert_eq!(builder.tag(), "v2.0");
520        assert_eq!(builder.image_tag(), "my-app:v2.0");
521        assert_eq!(
522            builder.dockerfile_path(),
523            Some(&PathBuf::from("custom/Dockerfile"))
524        );
525        assert_eq!(builder.context_path(), &PathBuf::from("./src"));
526        assert_eq!(builder.build_timeout(), Duration::from_mins(15));
527    }
528
529    #[test]
530    fn it_should_have_proper_error_display_messages() {
531        let error = ContainerBuildError::ContainerBuildFailed {
532            image_name: "test-image".to_string(),
533            tag: "v1.0".to_string(),
534            dockerfile_path: "/path/to/Dockerfile".to_string(),
535            context_path: "/build/context".to_string(),
536            build_duration_secs: 120,
537            stderr: "build error message".to_string(),
538        };
539
540        let message = error.to_string();
541        assert!(message.contains("Docker build failed"));
542        assert!(message.contains("test-image:v1.0"));
543        assert!(message.contains("build error message"));
544        assert!(message.contains("/path/to/Dockerfile"));
545        assert!(message.contains("/build/context"));
546        assert!(message.contains("120s"));
547    }
548
549    #[test]
550    fn it_should_preserve_error_chain_for_docker_build_execution() {
551        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "docker not found");
552        let error = ContainerBuildError::ContainerBuildExecution {
553            image_name: "test-image".to_string(),
554            tag: "v1.0".to_string(),
555            dockerfile_path: "/path/to/Dockerfile".to_string(),
556            context_path: "/build/context".to_string(),
557            source: io_error,
558        };
559
560        assert!(error
561            .to_string()
562            .contains("Failed to execute docker build command"));
563        assert!(error.to_string().contains("test-image:v1.0"));
564        assert!(error.to_string().contains("/path/to/Dockerfile"));
565        assert!(error.to_string().contains("/build/context"));
566        assert!(error.source().is_some());
567    }
568
569    #[test]
570    fn it_should_fail_build_when_image_name_not_provided() {
571        let builder = ContainerImageBuilder::new().with_dockerfile(PathBuf::from("Dockerfile"));
572
573        let result = builder.build();
574        assert!(result.is_err());
575        let error = result.unwrap_err();
576        assert!(matches!(*error, ContainerBuildError::ImageNameRequired));
577        assert!(error.to_string().contains("Image name is required"));
578    }
579
580    #[test]
581    fn it_should_fail_build_when_dockerfile_path_not_provided() {
582        let builder = ContainerImageBuilder::new().with_name("test-image");
583
584        let result = builder.build();
585        assert!(result.is_err());
586        let error = result.unwrap_err();
587        assert!(matches!(
588            *error,
589            ContainerBuildError::DockerfilePathRequired
590        ));
591        assert!(error.to_string().contains("Dockerfile path is required"));
592    }
593
594    #[test]
595    #[should_panic(expected = "Image name must be set before calling image_tag()")]
596    fn it_should_panic_when_calling_image_tag_without_image_name() {
597        let builder = ContainerImageBuilder::new();
598        drop(builder.image_tag());
599    }
600
601    // Note: Actual docker build integration tests would require Docker
602    // and are better suited for the e2e test binaries
603}