use testcontainers::{
core::{IntoContainerPort, WaitFor},
GenericImage,
};
#[derive(Debug, thiserror::Error)]
pub enum ContainerConfigError {
#[error("Invalid image name '{image_name}': {reason}")]
InvalidImageName { image_name: String, reason: String },
#[error("Invalid port number {port}: {reason}")]
InvalidPort { port: u16, reason: String },
#[error("Image name cannot be empty")]
EmptyImageName,
#[error("Too many wait conditions ({count}): maximum {max_allowed} wait conditions are recommended for optimal container startup performance")]
TooManyWaitConditions { count: usize, max_allowed: usize },
}
pub type Result<T> = std::result::Result<T, Box<ContainerConfigError>>;
#[derive(Debug, Clone)]
pub struct ContainerConfigBuilder {
image: String,
container_name: Option<String>,
exposed_ports: Vec<u16>,
wait_conditions: Vec<WaitFor>,
}
impl ContainerConfigBuilder {
pub fn new(image: impl Into<String>) -> Self {
Self {
image: image.into(),
container_name: None,
exposed_ports: Vec::new(),
wait_conditions: Vec::new(),
}
}
#[must_use]
pub fn with_exposed_port(mut self, port: u16) -> Self {
if port == 0 {
tracing::warn!("Port 0 is reserved and will cause issues during container build");
}
if self.exposed_ports.contains(&port) {
tracing::warn!("Port {port} is already exposed, skipping duplicate");
} else {
self.exposed_ports.push(port);
}
self
}
#[must_use]
pub fn with_container_name(mut self, name: impl Into<String>) -> Self {
self.container_name = Some(name.into());
self
}
#[must_use]
pub fn with_wait_condition(mut self, condition: WaitFor) -> Self {
self.wait_conditions.push(condition);
self
}
pub fn build(self) -> Result<GenericImage> {
const MAX_RECOMMENDED_WAIT_CONDITIONS: usize = 5;
if self.image.is_empty() {
return Err(Box::new(ContainerConfigError::EmptyImageName));
}
if self.image.trim().is_empty() {
return Err(Box::new(ContainerConfigError::InvalidImageName {
image_name: self.image.clone(),
reason: "image name contains only whitespace".to_string(),
}));
}
if self.image.contains("//") || self.image.starts_with('/') || self.image.ends_with('/') {
return Err(Box::new(ContainerConfigError::InvalidImageName {
image_name: self.image.clone(),
reason: "image name contains invalid path separators".to_string(),
}));
}
for &port in &self.exposed_ports {
if port == 0 {
return Err(Box::new(ContainerConfigError::InvalidPort {
port,
reason: "port 0 is reserved and cannot be exposed".to_string(),
}));
}
}
if self.wait_conditions.len() > MAX_RECOMMENDED_WAIT_CONDITIONS {
return Err(Box::new(ContainerConfigError::TooManyWaitConditions {
count: self.wait_conditions.len(),
max_allowed: MAX_RECOMMENDED_WAIT_CONDITIONS,
}));
}
let parts: Vec<&str> = self.image.split(':').collect();
let (image_name, image_tag) = if parts.len() == 2 {
(parts[0], parts[1])
} else {
(self.image.as_str(), "latest")
};
if image_name.is_empty() {
return Err(Box::new(ContainerConfigError::InvalidImageName {
image_name: self.image.clone(),
reason: "image name part is empty".to_string(),
}));
}
let mut image = GenericImage::new(image_name, image_tag);
for &port_num in &self.exposed_ports {
image = image.with_exposed_port(port_num.tcp());
}
for condition in self.wait_conditions {
image = image.with_wait_for(condition);
}
Ok(image)
}
#[must_use]
pub fn image_name(&self) -> &str {
&self.image
}
#[must_use]
pub fn exposed_ports(&self) -> &[u16] {
&self.exposed_ports
}
#[must_use]
pub fn wait_conditions_count(&self) -> usize {
self.wait_conditions.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use testcontainers::core::WaitFor;
#[test]
fn it_should_create_builder_with_image_name() {
let builder = ContainerConfigBuilder::new("torrust-provisioned-instance:latest");
assert_eq!(builder.image_name(), "torrust-provisioned-instance:latest");
assert_eq!(builder.exposed_ports().len(), 0);
assert_eq!(builder.wait_conditions_count(), 0);
}
#[test]
fn it_should_add_exposed_ports() {
let builder = ContainerConfigBuilder::new("torrust-provisioned-instance:latest")
.with_exposed_port(22)
.with_exposed_port(80);
let ports = builder.exposed_ports();
assert_eq!(ports.len(), 2);
assert!(ports.contains(&22));
assert!(ports.contains(&80));
}
#[test]
fn it_should_add_wait_conditions() {
let builder = ContainerConfigBuilder::new("torrust-provisioned-instance:latest")
.with_wait_condition(WaitFor::message_on_stdout("sshd entered RUNNING state"))
.with_wait_condition(WaitFor::seconds(2));
assert_eq!(builder.wait_conditions_count(), 2);
}
#[test]
fn it_should_build_generic_image_with_all_options() {
let image = ContainerConfigBuilder::new("torrust-provisioned-instance:latest")
.with_exposed_port(22)
.with_wait_condition(WaitFor::message_on_stdout("sshd entered RUNNING state"))
.build();
std::mem::drop(image); }
#[test]
fn it_should_handle_empty_configuration() {
let image = ContainerConfigBuilder::new("alpine:latest").build();
std::mem::drop(image); }
#[test]
fn it_should_chain_builder_methods_fluently() {
let builder = ContainerConfigBuilder::new("torrust-provisioned-instance:latest")
.with_exposed_port(22)
.with_wait_condition(WaitFor::seconds(1));
assert_eq!(builder.image_name(), "torrust-provisioned-instance:latest");
assert_eq!(builder.exposed_ports().len(), 1);
assert_eq!(builder.wait_conditions_count(), 1);
}
#[test]
fn it_should_accept_string_and_str_for_image_name() {
let builder1 = ContainerConfigBuilder::new("app:latest");
let builder2 = ContainerConfigBuilder::new(String::from("app:latest"));
assert_eq!(builder1.image_name(), builder2.image_name());
}
#[test]
fn it_should_deduplicate_same_port_numbers() {
let builder = ContainerConfigBuilder::new("app:latest")
.with_exposed_port(8080)
.with_exposed_port(8080);
assert_eq!(builder.exposed_ports().len(), 1);
assert_eq!(builder.exposed_ports()[0], 8080);
}
#[test]
fn it_should_split_image_name_and_tag_correctly() {
let image1 = ContainerConfigBuilder::new("redis:7").build();
std::mem::drop(image1);
let image2 = ContainerConfigBuilder::new("redis").build();
std::mem::drop(image2);
let image3 = ContainerConfigBuilder::new("registry.example.com/myapp:v1.2.3").build();
std::mem::drop(image3);
}
}