torrust_tracker_deployer_lib/testing/e2e/containers/
image_builder.rs1use std::path::PathBuf;
51use std::process::Command;
52use std::time::Duration;
53use tracing::info;
54
55#[derive(Debug, thiserror::Error)]
57pub enum ContainerBuildError {
58 #[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 #[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 #[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 #[error("Image name is required but was not provided")]
92 ImageNameRequired,
93
94 #[error("Dockerfile path is required but was not provided")]
96 DockerfilePathRequired,
97
98 #[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 #[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
113pub type Result<T> = std::result::Result<T, Box<ContainerBuildError>>;
115
116#[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 #[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 #[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 #[must_use]
197 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
198 self.tag = tag.into();
199 self
200 }
201
202 #[must_use]
218 pub fn with_dockerfile(mut self, path: PathBuf) -> Self {
219 self.dockerfile_path = Some(path);
220 self
221 }
222
223 #[must_use]
239 pub fn with_context(mut self, path: PathBuf) -> Self {
240 self.context_path = path;
241 self
242 }
243
244 #[must_use]
260 pub fn with_build_timeout(mut self, timeout: Duration) -> Self {
261 self.build_timeout = timeout;
262 self
263 }
264
265 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 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", &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 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 #[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 #[must_use]
408 pub fn image_name(&self) -> Option<&str> {
409 self.image_name.as_deref()
410 }
411
412 #[must_use]
414 pub fn tag(&self) -> &str {
415 &self.tag
416 }
417
418 #[must_use]
420 pub fn dockerfile_path(&self) -> Option<&PathBuf> {
421 self.dockerfile_path.as_ref()
422 }
423
424 #[must_use]
426 pub fn context_path(&self) -> &PathBuf {
427 &self.context_path
428 }
429
430 #[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 }