Skip to content
Open
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
23 changes: 23 additions & 0 deletions docs/quickstart/community_modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,26 @@ fn create_redis() -> ContainerRequest<Redis> {
.with_env_var(("REDIS_PASSWORD", "my_secret_password"))
}
```

### Pinning an image by digest

For reproducible pulls you can pin a module to an immutable content digest with
[`with_digest`](https://docs.rs/testcontainers/latest/testcontainers/core/trait.ImageExt.html#tymethod.with_digest).
Unlike a tag, a digest can't be overwritten in the registry, so the exact same
image is used on every run. The digest must include the algorithm prefix (e.g.
`sha256:...`); the reference sent to Docker becomes `name:tag@digest`, and Docker
resolves the image by digest while the tag is kept for readability:

```rust
use testcontainers_modules::{
redis::Redis,
testcontainers::{ContainerRequest, ImageExt},
};

/// Pin the Redis module to a specific image digest
fn create_pinned_redis() -> ContainerRequest<Redis> {
Redis::default()
.with_tag("6.2-alpine")
.with_digest("sha256:e2c2f1f4c0a8b6d4e3f9a1b7c5d2e8f0a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4")
}
```
90 changes: 83 additions & 7 deletions testcontainers/src/core/containers/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub struct ContainerRequest<I: Image> {
pub(crate) overridden_cmd: Vec<String>,
pub(crate) image_name: Option<String>,
pub(crate) image_tag: Option<String>,
pub(crate) image_digest: Option<String>,
pub(crate) container_name: Option<String>,
pub(crate) platform: Option<String>,
pub(crate) network: Option<String>,
Expand Down Expand Up @@ -181,13 +182,22 @@ impl<I: Image> ContainerRequest<I> {
}

pub fn descriptor(&self) -> String {
let original_name = self.image.name();
let original_tag = self.image.tag();

let name = self.image_name.as_deref().unwrap_or(original_name);
let tag = self.image_tag.as_deref().unwrap_or(original_tag);

format!("{name}:{tag}")
let name = self
.image_name
.as_deref()
.unwrap_or_else(|| self.image.name());
let tag = self
.image_tag
.as_deref()
.unwrap_or_else(|| self.image.tag());

// An explicit `with_digest` override takes precedence over a digest baked into the image.
// When a digest is present, the reference becomes `name:tag@digest`: Docker resolves the
// image by digest, while the tag is kept for readability.
match self.image_digest.as_deref().or_else(|| self.image.digest()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Drop baked-in digests when name or tag is overridden

When an Image implementation supplies a default digest, with_tag(...) and with_name(...) only set the override fields, but this line still falls back to self.image.digest(). That makes MyImage.with_tag("2.0") produce name:2.0@<digest-for-the-default-image>, and because Docker resolves the reference by digest, the caller still runs the pinned default image content rather than the requested tag or repository; this will affect any module that adopts Image::digest() while continuing to support image overrides.

Useful? React with 👍 / 👎.

Some(digest) => format!("{name}:{tag}@{digest}"),
None => format!("{name}:{tag}"),
}
}

pub fn ready_conditions(&self) -> Vec<WaitFor> {
Expand Down Expand Up @@ -265,6 +275,7 @@ impl<I: Image> From<I> for ContainerRequest<I> {
overridden_cmd: Vec::new(),
image_name: None,
image_tag: None,
image_digest: None,
container_name: None,
platform: None,
network: None,
Expand Down Expand Up @@ -327,6 +338,7 @@ impl<I: Image + Debug> Debug for ContainerRequest<I> {
.field("overridden_cmd", &self.overridden_cmd)
.field("image_name", &self.image_name)
.field("image_tag", &self.image_tag)
.field("image_digest", &self.image_digest)
.field("container_name", &self.container_name)
.field("platform", &self.platform)
.field("network", &self.network)
Expand Down Expand Up @@ -368,3 +380,67 @@ impl<I: Image + Debug> Debug for ContainerRequest<I> {
repr.finish()
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::{images::generic::GenericImage, ImageExt};

/// Minimal image that pins a digest via the [`Image`] trait itself.
#[derive(Debug, Default)]
struct DigestPinnedImage;

impl Image for DigestPinnedImage {
fn name(&self) -> &str {
"pinned"
}

fn tag(&self) -> &str {
"1.0"
}

fn digest(&self) -> Option<&str> {
Some("sha256:aaaa")
}

fn ready_conditions(&self) -> Vec<WaitFor> {
Vec::new()
}
}

#[test]
fn descriptor_without_digest_uses_name_and_tag() {
let request: ContainerRequest<_> = GenericImage::new("nginx", "1.25").into();
assert_eq!(request.descriptor(), "nginx:1.25");
}

#[test]
fn descriptor_with_digest_override_keeps_tag() {
let request = GenericImage::new("nginx", "1.25").with_digest("sha256:abc123");
assert_eq!(request.descriptor(), "nginx:1.25@sha256:abc123");
}

#[test]
fn descriptor_uses_digest_from_image_trait() {
let request: ContainerRequest<_> = DigestPinnedImage.into();
assert_eq!(request.descriptor(), "pinned:1.0@sha256:aaaa");
}

#[test]
fn with_digest_overrides_image_trait_digest() {
let request = DigestPinnedImage.with_digest("sha256:bbbb");
assert_eq!(request.descriptor(), "pinned:1.0@sha256:bbbb");
}

#[test]
fn descriptor_combines_name_tag_and_digest_overrides() {
let request = GenericImage::new("nginx", "1.25")
.with_name("ghcr.io/library/nginx")
.with_tag("mainline")
.with_digest("sha256:abc123");
assert_eq!(
request.descriptor(),
"ghcr.io/library/nginx:mainline@sha256:abc123"
);
}
}
14 changes: 14 additions & 0 deletions testcontainers/src/core/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ where
/// suddenly changed.
fn tag(&self) -> &str;

/// An optional content digest used to pin the image to an immutable manifest.
///
/// Pinning by digest provides the strongest guarantee that the exact same image is used
/// across runs, since a digest references immutable content whereas a tag can be overwritten
/// in the registry. The returned value must include the algorithm prefix, e.g.
/// `sha256:e9b8...`.
///
/// When set, the image reference passed to Docker becomes `name:tag@digest`. Docker resolves
/// the image by digest; the tag is retained only for readability. Returning `None` (the
/// default) leaves the image resolved by tag alone.
fn digest(&self) -> Option<&str> {
None
}

/// Returns a list of conditions that need to be met before a started container is considered ready.
///
/// This method is the **🍞 and butter** of the whole testcontainers library. Containers are
Expand Down
32 changes: 32 additions & 0 deletions testcontainers/src/core/image/image_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,30 @@ pub trait ImageExt<I: Image> {
/// running container. Users of this API are advised to use this at their own risk.
fn with_tag(self, tag: impl Into<String>) -> ContainerRequest<I>;

/// Pins the image to a specific content digest.
///
/// Pinning by digest guarantees the exact same image content is used across runs, since a
/// digest references an immutable manifest whereas a tag can be overwritten in the registry.
/// The digest must include the algorithm prefix, e.g. `sha256:e9b8...`.
///
/// The image reference sent to Docker becomes `name:tag@digest`. Docker resolves the image by
/// digest, so it takes precedence over the tag; the tag is retained only for readability.
/// This override takes precedence over any digest provided by the image's [`Image::digest`].
///
/// There is no guarantee that the specified digest for an image would result in a running
/// container. Users of this API are advised to use this at their own risk.
///
/// # Examples
/// ```rust,no_run
/// use testcontainers::{GenericImage, ImageExt};
///
/// let image = GenericImage::new("hello-world", "latest")
/// .with_digest("sha256:0e760fdfbc48ba8041e7c6db999bb40bfca508b4be580ac75d32c4e29d202ce1");
/// ```
///
/// [`Image::digest`]: crate::Image::digest
fn with_digest(self, digest: impl Into<String>) -> ContainerRequest<I>;

/// Sets the container name.
fn with_container_name(self, name: impl Into<String>) -> ContainerRequest<I>;

Expand Down Expand Up @@ -325,6 +349,14 @@ impl<RI: Into<ContainerRequest<I>>, I: Image> ImageExt<I> for RI {
}
}

fn with_digest(self, digest: impl Into<String>) -> ContainerRequest<I> {
let container_req = self.into();
ContainerRequest {
image_digest: Some(digest.into()),
..container_req
}
}

fn with_container_name(self, name: impl Into<String>) -> ContainerRequest<I> {
let container_req = self.into();

Expand Down
23 changes: 23 additions & 0 deletions testcontainers/tests/async_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,29 @@ async fn bollard_pull_missing_image_hello_world() -> anyhow::Result<()> {
Ok(())
}

#[tokio::test]
async fn run_hello_world_pinned_by_digest() -> anyhow::Result<()> {
let _ = pretty_env_logger::try_init();
cleanup_hello_world_image().await?;

// Immutable manifest-list (multi-arch) digest of `hello-world:latest`.
// `with_wait_for` is a `GenericImage` method, so it must come before the
// `ImageExt::with_digest` call that turns the image into a `ContainerRequest`.
let request = GenericImage::new("hello-world", "latest")
.with_wait_for(WaitFor::message_on_stdout("Hello from Docker!"))
.with_wait_for(WaitFor::exit(ExitWaitStrategy::new().with_exit_code(0)))
.with_digest("sha256:0e760fdfbc48ba8041e7c6db999bb40bfca508b4be580ac75d32c4e29d202ce1");

assert_eq!(
request.descriptor(),
"hello-world:latest@sha256:0e760fdfbc48ba8041e7c6db999bb40bfca508b4be580ac75d32c4e29d202ce1"
);

// Pulling and starting proves Docker accepts and resolves the `name:tag@digest` reference.
let _container = request.start().await?;
Ok(())
}

#[tokio::test]
async fn explicit_call_to_pull_missing_image_hello_world() -> anyhow::Result<()> {
let _ = pretty_env_logger::try_init();
Expand Down
Loading