Skip to content
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ license = "MIT OR Apache-2.0"
readme = "README.md"
repository = "https://github.com/testcontainers/testcontainers-rs"
rust-version = "1.88"

[patch.crates-io]
bollard = { git = "https://github.com/DDtKey/bollard.git", branch = "fix/providerless-session" }
Comment on lines +19 to +20
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: drop and update version once fussybeaver/bollard#597 merged and released

2 changes: 1 addition & 1 deletion testcontainers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ rustdoc-args = ["--cfg", "docsrs"]

[dependencies]
async-trait = { version = "0.1" }
bollard = { version = "0.19.1", features = ["buildkit"] }
bollard = { version = "0.19.3", features = ["buildkit_providerless"] }
bytes = "1.6.0"
conquer-once = { version = "0.4", optional = true }
docker_credential = "1.3.1"
Expand Down
9 changes: 5 additions & 4 deletions testcontainers/src/core.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#[cfg(feature = "reusable-containers")]
pub use self::image::ReuseDirective;
pub use self::{
build_context::BuildContextBuilder,
buildable::BuildableImage,
build::{
build_context::BuildContextBuilder, build_options::BuildImageOptions,
buildable::BuildableImage,
},
containers::*,
copy::{CopyDataSource, CopyToContainer, CopyToContainerCollection, CopyToContainerError},
healthcheck::Healthcheck,
Expand All @@ -12,11 +14,10 @@ pub use self::{
wait::{cmd_wait::CmdWaitFor, WaitFor},
};

mod buildable;
mod image;

pub(crate) mod async_drop;
pub(crate) mod build_context;
pub mod build;
pub mod client;
pub(crate) mod containers;
pub(crate) mod copy;
Expand Down
118 changes: 118 additions & 0 deletions testcontainers/src/core/build/build_options.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
use std::collections::HashMap;

/// Options for configuring image build behavior.
///
/// Provides control over various aspects of the Docker image build process,
/// such as caching, whether to skip building if the image already exists, and build arguments.
///
/// # Example
///
/// ```rust,no_run
/// use testcontainers::{
/// core::BuildImageOptions,
/// runners::AsyncBuilder,
/// GenericBuildableImage,
/// };
///
/// # async fn example() -> anyhow::Result<()> {
/// let image = GenericBuildableImage::new("my-app", "latest")
/// .with_dockerfile_string("FROM alpine:latest\nARG VERSION\nRUN echo $VERSION")
/// .build_image_with(
/// BuildImageOptions::new()
/// .with_skip_if_exists(true)
/// .with_build_arg("VERSION", "1.0.0")
/// )
/// .await?;
/// # Ok(())
/// # }
/// ```
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct BuildImageOptions {
pub(crate) skip_if_exists: bool,
pub(crate) no_cache: bool,
pub(crate) build_args: HashMap<String, String>,
}

impl BuildImageOptions {
/// Creates a new `BuildImageOptions` with default values.
///
/// All options default to `false` and `build_args` is empty.
pub fn new() -> Self {
Self::default()
}

/// Sets whether to skip building if the image already exists.
///
/// When `true`, the build process will first check if an image with the
/// specified descriptor (name:tag) already exists. If it does, the build
/// is skipped and the existing image is used.
///
/// Default: `false`
pub fn with_skip_if_exists(mut self, skip_if_exists: bool) -> Self {
self.skip_if_exists = skip_if_exists;
self
}

/// Sets whether to disable build cache.
///
/// When `true`, Docker will not use cached layers from previous builds,
/// ensuring a completely fresh build from scratch.
///
/// Default: `false`
pub fn with_no_cache(mut self, no_cache: bool) -> Self {
self.no_cache = no_cache;
self
}

/// Adds a single build argument.
///
/// Build arguments are passed to the Docker build process and can be used
/// in the Dockerfile with `ARG` instructions. This method appends to existing
/// build arguments, allowing multiple calls to add different arguments.
///
/// # Arguments
///
/// * `key` - The name of the build argument
/// * `value` - The value of the build argument
///
/// # Example
///
/// ```rust,no_run
/// use testcontainers::core::BuildImageOptions;
///
/// let opts = BuildImageOptions::new()
/// .with_build_arg("VERSION", "1.0.0")
/// .with_build_arg("BUILD_DATE", "2024-01-01");
/// ```
pub fn with_build_arg(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.build_args.insert(key.into(), value.into());
self
}

/// Replaces all build arguments with the provided HashMap.
///
/// Build arguments are passed to the Docker build process and can be used
/// in the Dockerfile with `ARG` instructions. This method replaces any
/// existing build arguments.
///
/// # Arguments
///
/// * `build_args` - A HashMap of build argument names to values
///
/// # Example
///
/// ```rust,no_run
/// use std::collections::HashMap;
/// use testcontainers::core::BuildImageOptions;
///
/// let mut args = HashMap::new();
/// args.insert("VERSION".to_string(), "1.0.0".to_string());
/// args.insert("BUILD_DATE".to_string(), "2024-01-01".to_string());
///
/// let opts = BuildImageOptions::new().with_build_args(args);
/// ```
pub fn with_build_args(mut self, build_args: HashMap<String, String>) -> Self {
self.build_args = build_args;
self
}
}
3 changes: 3 additions & 0 deletions testcontainers/src/core/build/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub mod build_context;
pub mod build_options;
pub mod buildable;
74 changes: 66 additions & 8 deletions testcontainers/src/core/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use std::{
collections::HashMap,
io::{self},
str::FromStr,
sync::Arc,
};

use bollard::{
Expand All @@ -24,7 +25,7 @@ use bollard::{
Docker,
};
use futures::{StreamExt, TryStreamExt};
use tokio::sync::OnceCell;
use tokio::sync::{Mutex, OnceCell};
use url::Url;

use crate::core::{
Expand All @@ -46,6 +47,20 @@ pub use factory::docker_client_instance;

static IN_A_CONTAINER: OnceCell<bool> = OnceCell::const_new();

type BuildLockMap = Mutex<HashMap<String, Arc<Mutex<()>>>>;
static BUILD_LOCKS: OnceCell<BuildLockMap> = OnceCell::const_new();

async fn get_build_lock(descriptor: &str) -> Arc<Mutex<()>> {
let locks = BUILD_LOCKS
.get_or_init(|| async { Mutex::new(HashMap::new()) })
.await;

let mut map = locks.lock().await;
map.entry(descriptor.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}

// See https://github.com/docker/docker/blob/a9fa38b1edf30b23cae3eade0be48b3d4b1de14b/daemon/initlayer/setup_unix.go#L25
// and Java impl: https://github.com/testcontainers/testcontainers-java/blob/994b385761dde7d832ab7b6c10bc62747fe4b340/core/src/main/java/org/testcontainers/dockerclient/DockerClientConfigUtils.java#L16C5-L17
async fn is_in_container() -> bool {
Expand Down Expand Up @@ -407,6 +422,44 @@ impl Client {
&self,
descriptor: &str,
build_context: &CopyToContainerCollection,
options: crate::core::build::build_options::BuildImageOptions,
) -> Result<(), ClientError> {
if options.skip_if_exists {
let lock = get_build_lock(descriptor).await;
let _guard = lock.lock().await;

match self.bollard.inspect_image(descriptor).await {
Ok(_) => {
log::info!("Image '{}' already exists, skipping build", descriptor);
return Ok(());
}
Err(BollardError::DockerResponseServerError {
status_code: 404, ..
}) => {
log::info!("Image '{}' not found, proceeding with build", descriptor);
}
Err(err) => {
log::warn!(
"Failed to inspect image '{}': {:?}, proceeding with build",
descriptor,
err
);
}
}

self.build_image_impl(descriptor, build_context, options)
.await
} else {
self.build_image_impl(descriptor, build_context, options)
.await
}
}

async fn build_image_impl(
&self,
descriptor: &str,
build_context: &CopyToContainerCollection,
options: crate::core::build::build_options::BuildImageOptions,
) -> Result<(), ClientError> {
let tar = build_context
.tar()
Expand All @@ -415,20 +468,25 @@ impl Client {

let session = ulid::Ulid::new().to_string();

let options = BuildImageOptionsBuilder::new()
let mut builder = BuildImageOptionsBuilder::new()
.dockerfile("Dockerfile")
.t(descriptor)
.rm(true)
.nocache(false)
.nocache(options.no_cache)
.version(BuilderVersion::BuilderBuildKit)
.session(&session)
.build();
.session(&session);

if !options.build_args.is_empty() {
builder = builder.buildargs(&options.build_args);
}

let build_options = builder.build();

let credentials = None;

let mut building = self
.bollard
.build_image(options, credentials, Some(body_full(tar)));
let mut building =
self.bollard
.build_image(build_options, credentials, Some(body_full(tar)));

while let Some(result) = building.next().await {
match result {
Expand Down
Loading