Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions testcontainers/src/buildables/generic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,76 @@ use crate::{
BuildableImage, GenericImage,
};

/// A generic implementation of [`BuildableImage`] for building custom Docker images.
///
/// `GenericBuildableImage` provides a fluent interface for constructing Docker images from
/// Dockerfiles and build contexts. It supports adding files and directories from the filesystem,
/// embedding data directly, and customizing the build process.
///
/// # Build Context Management
///
/// The build context is managed through a [`BuildContextBuilder`] that collects all files
/// and data needed for the Docker build. Files are automatically packaged into a TAR archive
/// that gets sent to the Docker daemon.
///
/// # Example: Basic Image Build
///
/// ```rust,no_run
/// use testcontainers::{GenericBuildableImage, runners::AsyncBuilder};
///
/// #[tokio::test]
/// async fn test_hello() -> anyhow::Result<()> {
/// let image = GenericBuildableImage::new("hello-world", "latest")
/// .with_dockerfile_string(
/// r#"FROM alpine:latest
/// COPY hello.sh /usr/local/bin/
/// RUN chmod +x /usr/local/bin/hello.sh
/// CMD ["/usr/local/bin/hello.sh"]"#
/// )
/// .with_data(
/// "#!/bin/sh\necho 'Hello from custom image!'",
/// "./hello.sh"
/// )
/// .build_image().await?;
/// // start container
/// // use it
/// }
/// ```
///
/// # Example: Multi-File Build Context
///
/// ```rust,no_run
/// use testcontainers::{GenericBuildableImage, runners::AsyncBuilder};
///
/// #[tokio::test]
/// async fn test_webapp() -> anyhow::Result<()> {
/// let image = GenericBuildableImage::new("web-app", "1.0")
/// .with_dockerfile("./Dockerfile")
/// .with_file("./package.json", "./package.json")
/// .with_file("./src", "./src")
/// .with_data(vec![0x00, 0x01, 0x02], "./data.dat")
/// .build_image().await?;
/// // start container
/// // use it
/// }
/// ```
#[derive(Debug)]
pub struct GenericBuildableImage {
/// The name of the Docker image to be built
name: String,
/// The tag assigned to the built image and passed down to the [`Image`]
tag: String,
/// Wrapped builder for managing the build context
build_context_builder: BuildContextBuilder,
}

impl GenericBuildableImage {
/// Creates a new buildable image with the specified name and tag.
///
/// # Arguments
///
/// * `name` - The name for the Docker image (e.g., "my-app", "registry.com/service")
/// * `tag` - The tag for the image (e.g., "latest", "1.0", "dev")
pub fn new(name: impl Into<String>, tag: impl Into<String>) -> Self {
Self {
name: name.into(),
Expand All @@ -21,21 +83,54 @@ impl GenericBuildableImage {
}
}

/// Adds a Dockerfile from the filesystem to the build context.
///
/// # Arguments
///
/// * `source` - Path to the Dockerfile on the local filesystem
pub fn with_dockerfile(mut self, source: impl Into<PathBuf>) -> Self {
self.build_context_builder = self.build_context_builder.with_dockerfile(source);
self
}

/// Adds a Dockerfile from a string to the build context.
///
/// This is useful for generating Dockerfiles programmatically or embedding
/// simple Dockerfiles directly in test code.
///
/// # Arguments
///
/// * `content` - The complete Dockerfile content as a string
pub fn with_dockerfile_string(mut self, content: impl Into<String>) -> Self {
self.build_context_builder = self.build_context_builder.with_dockerfile_string(content);
self
}

/// Adds a file or directory from the filesystem to the build context.
///
/// Be aware, that if you don't add the Dockerfile with the specific `with_dockerfile()`
/// or `with_dockerfile_string()` functions it has to be named `Dockerfile`in the build
/// context. Containerfile won't be recognized!
///
/// # Arguments
///
/// * `source` - Path to the file or directory on the local filesystem
/// * `target` - Path where the file should be placed in the build context
pub fn with_file(mut self, source: impl Into<PathBuf>, target: impl Into<String>) -> Self {
self.build_context_builder = self.build_context_builder.with_file(source, target);
self
}

/// Adds data directly to the build context as a file.
///
/// This method allows you to embed file content directly without requiring
/// files to exist on the filesystem. Useful for generated content, templates,
/// or small configuration files.
///
/// # Arguments
///
/// * `data` - The file content as bytes
/// * `target` - Path where the file should be placed in the build context
pub fn with_data(mut self, data: impl Into<Vec<u8>>, target: impl Into<String>) -> Self {
self.build_context_builder = self.build_context_builder.with_data(data, target);
self
Expand Down
70 changes: 70 additions & 0 deletions testcontainers/src/core/build_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,106 @@ use std::path::PathBuf;

use crate::{core::copy::CopyToContainerCollection, CopyToContainer};

/// Builder for managing Docker BuildKit build contexts.
///
/// A build context contains all the files and data that Docker needs to build an image.
/// This includes the Dockerfile, source code, configuration files, and any other materials
/// referenced by the Dockerfile's `COPY` or `ADD` instructions.
/// More information see: <https://docs.docker.com/build/concepts/context/>
///
/// The `BuildContextBuilder` collects these materials and packages them into a TAR archive
/// that can be sent to the Docker daemon for building.
///
/// # Example
///
/// ```rust,no_run
/// use testcontainers::core::BuildContextBuilder;
///
/// let context = BuildContextBuilder::default()
/// .with_dockerfile_string("FROM alpine:latest\nCOPY app /usr/local/bin/")
/// .with_file("./target/release/app", "./app")
/// .with_data(b"#!/bin/sh\necho 'Hello World'", "./hello.sh")
/// .collect();
/// ```
#[derive(Debug, Default, Clone)]
pub struct BuildContextBuilder {
build_context_parts: Vec<CopyToContainer>,
}

impl BuildContextBuilder {
/// Adds a Dockerfile from the filesystem to the build context.
///
/// # Arguments
///
/// * `source` - Path to the Dockerfile on the local filesystem
pub fn with_dockerfile(self, source: impl Into<PathBuf>) -> Self {
self.with_file(source.into(), "Dockerfile")
}

/// Adds a Dockerfile from a string to the build context.
///
/// This is useful for generating Dockerfiles programmatically or embedding
/// simple Dockerfiles directly in test code.
///
/// # Arguments
///
/// * `content` - The complete Dockerfile content as a string
pub fn with_dockerfile_string(self, content: impl Into<String>) -> Self {
self.with_data(content.into(), "Dockerfile")
}

/// Adds a file or directory from the filesystem to the build context.
///
/// Be aware, that if you don't add the Dockerfile with the specific `with_dockerfile()`
/// or `with_dockerfile_string()` functions it has to be named `Dockerfile`in the build
/// context. Containerfile won't be recognized!
///
/// # Arguments
///
/// * `source` - Path to the file or directory on the local filesystem
/// * `target` - Path where the file should be placed in the build context
pub fn with_file(mut self, source: impl Into<PathBuf>, target: impl Into<String>) -> Self {
self.build_context_parts
.push(CopyToContainer::new(source.into(), target));
self
}

/// Adds data directly to the build context as a file.
///
/// This method allows you to embed file content directly without requiring
/// files to exist on the filesystem. Useful for generated content, templates,
/// or small configuration files.
///
/// # Arguments
///
/// * `data` - The file content as bytes
/// * `target` - Path where the file should be placed in the build context
pub fn with_data(mut self, data: impl Into<Vec<u8>>, target: impl Into<String>) -> Self {
self.build_context_parts
.push(CopyToContainer::new(data.into(), target));
self
}

/// Consumes the builder and returns the collected build context.
///
/// This method finalizes the build context and returns a [`CopyToContainerCollection`]
/// that can be converted to a TAR archive for Docker.
///
/// # Returns
///
/// A [`CopyToContainerCollection`] containing all the build context materials.
pub fn collect(self) -> CopyToContainerCollection {
CopyToContainerCollection::new(self.build_context_parts)
}

/// Returns the build context without consuming the builder.
///
/// This method creates a clone of the current build context state, allowing
/// the builder to be reused or modified further.
///
/// # Returns
///
/// A [`CopyToContainerCollection`] containing all the current build context materials.
pub fn as_copy_to_container_collection(&self) -> CopyToContainerCollection {
CopyToContainerCollection::new(self.build_context_parts.clone())
}
Expand Down
63 changes: 63 additions & 0 deletions testcontainers/src/core/buildable.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,73 @@
use crate::{core::copy::CopyToContainerCollection, Image};

/// Trait for images that can be built from within your tests or testcontainer libraries.
///
/// Unlike the [`Image`] trait which represents existing Docker images, `BuildableImage`
/// represents images that need to be constructed from a, possibly even dynamic, `Dockerfile``
/// and the needed Docker build context.
///
/// If you want to dynamically create Dockerfiles look at Dockerfile generator crates like:
/// <https://crates.io/crates/dockerfile_builder>
///
/// The build process, executed by [`crate::runners::SyncBuilder`] / [`crate::runners::AsyncBuilder`], follows these steps:
/// 1. Collect build context via `build_context()` which will be tarred and sent to buildkit.
/// 2. Generate image descriptor via `descriptor()` which will be passed to the container
/// 3. Build the Docker image using the Docker API
/// 4. Convert to runnable [`Image`] via `into_image()` which consumes the `BuildableImage`
/// into an `Image`
///
/// # Example
///
/// ```rust
/// use testcontainers::{GenericBuildableImage, runners::AsyncBuilder};
///
/// #[tokio::test]
/// async fn test_example() -> anyhow::Result<()> {
/// let image = GenericBuildableImage::new("example-tc", "1.0")
/// .with_dockerfile_string("FROM alpine:latest\nRUN echo 'hello'")
/// .build_image().await?;
/// // start container
/// // use it
/// }
/// ```
///
pub trait BuildableImage {
/// The type of [`Image`] that this buildable image produces after building.
type Built: Image;

/// Returns the build context containing all files and data needed to build the image.
///
/// The build context consist of at least the `Dockerfile` and needs all the resources
/// referred to by the Dockerfile.
/// This is more or less equivalent to the directory you would pass to `docker build`.
///
/// <https://docs.docker.com/build/concepts/context/>
///
/// For creating build contexts, use the [`crate::core::BuildContextBuilder`] API when not using
/// [`crate::GenericBuildableImage`], which wraps `BuildContextBuilder` builder functions.
///
/// # Returns
///
/// A [`CopyToContainerCollection`] containing the build context in a form we
/// can send it to buildkit.
fn build_context(&self) -> CopyToContainerCollection;

/// Returns the image descriptor (name:tag) that will be assigned to the built image and be
/// passed down to the container for running.
///
/// # Returns
///
/// A string in the format "name:tag" that uniquely identifies the built image.
fn descriptor(&self) -> String;

/// Consumes this buildable image and converts it into a runnable [`Image`].
///
/// This method is called after the Docker image has been successfully built.
/// It transforms the build specification into a standard [`Image`] that can be
/// started as a container.
///
/// # Returns
///
/// An [`Image`] instance configured to run the built Docker image.
fn into_image(self) -> Self::Built;
}
4 changes: 1 addition & 3 deletions testcontainers/src/images/generic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,12 @@ use crate::{
///
/// For example:
///
/// ```
/// ```rust,ignore
/// use testcontainers::{
/// core::{IntoContainerPort, WaitFor}, runners::AsyncRunner, GenericImage, ImageExt
/// };
///
/// # /*
/// #[tokio::test]
/// # */
/// async fn test_redis() {
/// let container = GenericImage::new("redis", "7.2.4")
/// .with_exposed_port(6379.tcp())
Expand Down
13 changes: 11 additions & 2 deletions testcontainers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@
//!
//! Unsurprisingly, working with testcontainers is very similar to working with Docker itself.
//!
//! First, you need to define the [`Image`] that you want to run, and then simply call the `start` method on it from either the [`AsyncRunner`] or [`SyncRunner`] trait.
//! If you need to build an image first, then you need to define the [`BuildableImage`] that specifies the build context
//! and the Dockerfile, then call the `build_image` method on it from either the [`AsyncBuilder`] or [`SyncBuilder`] trait.
//! This will yield an [`Image`] you could actually start.
//!
//! If you already have a Docker image you can just define your [`Image`] that you want to run, and then simply call the
//! `start` method on it from either the [`AsyncRunner`] or [`SyncRunner`] trait.
//!
//! This will return you [`ContainerAsync`] or [`Container`] respectively.
//! Containers implement `Drop`. As soon as they go out of scope, the underlying docker container is removed.
//! To disable this behavior, you can set ENV variable `TESTCONTAINERS_COMMAND` to `keep`.
Expand Down Expand Up @@ -60,7 +66,8 @@
//! # Ecosystem
//!
//! `testcontainers` is the core crate that provides an API for working with containers in a test environment.
//! The only image that is provided by the core crate is the [`GenericImage`], which is a simple wrapper around any docker image.
//! The only buildable image and image implementations that are provided by the core crate are the [`GenericBuildableImage`]
//! and [`GenericImage`], respectively.
//!
//! However, it does not provide ready-to-use modules, you can implement your [`Image`]s using the library directly or use community supported [`testcontainers-modules`].
//!
Expand All @@ -70,6 +77,8 @@
//!
//! [tc_website]: https://testcontainers.org
//! [`Docker`]: https://docker.com
//! [`AsyncBuilder`]: runners::AsyncBuilder
//! [`SyncBuilder`]: runners::SyncBuilder
//! [`AsyncRunner`]: runners::AsyncRunner
//! [`SyncRunner`]: runners::SyncRunner
//! [`testcontainers-modules`]: https://crates.io/crates/testcontainers-modules
Expand Down
23 changes: 23 additions & 0 deletions testcontainers/src/runners/async_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,29 @@ pub trait AsyncBuilder<B: BuildableImage> {
}

#[async_trait]
/// Helper trait to build Docker images asynchronously from [`BuildableImage`] instances.
///
/// Provides an asynchronous interface for building custom Docker images within test environments.
/// This trait is automatically implemented for any type that implements [`BuildableImage`] + [`Send`].
///
/// # Example
///
/// ```rust,no_run
/// use testcontainers::{core::WaitFor, runners::AsyncBuilder, runners::AsyncRunner, GenericBuildableImage};
///
/// #[test]
/// async fn test_custom_image() -> anyhow::Result<()> {
/// let image = GenericBuildableImage::new("my-test-app", "latest")
/// .with_dockerfile_string("FROM alpine:latest\nRUN echo 'hello'")
/// .build_image()?.await;
/// // Use the built image in containers
/// let container = image
/// .with_wait_for(WaitFor::message_on_stdout("Hello from test!"))
/// .start()?.await;
///
/// Ok(())
/// }
/// ```
impl<T> AsyncBuilder<T> for T
where
T: BuildableImage + Send,
Expand Down
Loading
Loading