diff --git a/docs/quickstart/community_modules.md b/docs/quickstart/community_modules.md index f7058251..6afcc476 100644 --- a/docs/quickstart/community_modules.md +++ b/docs/quickstart/community_modules.md @@ -59,3 +59,26 @@ fn create_redis() -> ContainerRequest { .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::default() + .with_tag("6.2-alpine") + .with_digest("sha256:e2c2f1f4c0a8b6d4e3f9a1b7c5d2e8f0a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4") +} +``` diff --git a/testcontainers/src/core/containers/request.rs b/testcontainers/src/core/containers/request.rs index 01e212cf..3659318e 100644 --- a/testcontainers/src/core/containers/request.rs +++ b/testcontainers/src/core/containers/request.rs @@ -28,6 +28,7 @@ pub struct ContainerRequest { pub(crate) overridden_cmd: Vec, pub(crate) image_name: Option, pub(crate) image_tag: Option, + pub(crate) image_digest: Option, pub(crate) container_name: Option, pub(crate) platform: Option, pub(crate) network: Option, @@ -181,13 +182,22 @@ impl ContainerRequest { } 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()) { + Some(digest) => format!("{name}:{tag}@{digest}"), + None => format!("{name}:{tag}"), + } } pub fn ready_conditions(&self) -> Vec { @@ -265,6 +275,7 @@ impl From for ContainerRequest { overridden_cmd: Vec::new(), image_name: None, image_tag: None, + image_digest: None, container_name: None, platform: None, network: None, @@ -327,6 +338,7 @@ impl Debug for ContainerRequest { .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) @@ -368,3 +380,67 @@ impl Debug for ContainerRequest { 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 { + 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" + ); + } +} diff --git a/testcontainers/src/core/image.rs b/testcontainers/src/core/image.rs index 1207fd7b..05625410 100644 --- a/testcontainers/src/core/image.rs +++ b/testcontainers/src/core/image.rs @@ -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 diff --git a/testcontainers/src/core/image/image_ext.rs b/testcontainers/src/core/image/image_ext.rs index 6566643d..c002d3e6 100644 --- a/testcontainers/src/core/image/image_ext.rs +++ b/testcontainers/src/core/image/image_ext.rs @@ -65,6 +65,30 @@ pub trait ImageExt { /// running container. Users of this API are advised to use this at their own risk. fn with_tag(self, tag: impl Into) -> ContainerRequest; + /// 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) -> ContainerRequest; + /// Sets the container name. fn with_container_name(self, name: impl Into) -> ContainerRequest; @@ -325,6 +349,14 @@ impl>, I: Image> ImageExt for RI { } } + fn with_digest(self, digest: impl Into) -> ContainerRequest { + let container_req = self.into(); + ContainerRequest { + image_digest: Some(digest.into()), + ..container_req + } + } + fn with_container_name(self, name: impl Into) -> ContainerRequest { let container_req = self.into(); diff --git a/testcontainers/tests/async_runner.rs b/testcontainers/tests/async_runner.rs index 45f5073c..baed5083 100644 --- a/testcontainers/tests/async_runner.rs +++ b/testcontainers/tests/async_runner.rs @@ -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();