Skip to content

Commit f28473e

Browse files
committed
feat: support pinning images by digest
Add the ability to pin a Docker image to an immutable content digest (`sha256:...`) instead of relying solely on a mutable tag, which can be overwritten in the registry. - `Image::digest()` returns an optional digest, letting image implementations ship a default pin. - `ImageExt::with_digest()` overrides the digest per run, taking precedence over the image's own `digest()`. - When a digest resolves, the reference passed to Docker becomes `name:tag@digest`: Docker resolves by digest while the tag is kept for readability. Closes #411
1 parent 3067fcd commit f28473e

5 files changed

Lines changed: 175 additions & 7 deletions

File tree

docs/quickstart/community_modules.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,26 @@ fn create_redis() -> ContainerRequest<Redis> {
5959
.with_env_var(("REDIS_PASSWORD", "my_secret_password"))
6060
}
6161
```
62+
63+
### Pinning an image by digest
64+
65+
For reproducible pulls you can pin a module to an immutable content digest with
66+
[`with_digest`](https://docs.rs/testcontainers/latest/testcontainers/core/trait.ImageExt.html#tymethod.with_digest).
67+
Unlike a tag, a digest can't be overwritten in the registry, so the exact same
68+
image is used on every run. The digest must include the algorithm prefix (e.g.
69+
`sha256:...`); the reference sent to Docker becomes `name:tag@digest`, and Docker
70+
resolves the image by digest while the tag is kept for readability:
71+
72+
```rust
73+
use testcontainers_modules::{
74+
redis::Redis,
75+
testcontainers::{ContainerRequest, ImageExt},
76+
};
77+
78+
/// Pin the Redis module to a specific image digest
79+
fn create_pinned_redis() -> ContainerRequest<Redis> {
80+
Redis::default()
81+
.with_tag("6.2-alpine")
82+
.with_digest("sha256:e2c2f1f4c0a8b6d4e3f9a1b7c5d2e8f0a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4")
83+
}
84+
```

testcontainers/src/core/containers/request.rs

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub struct ContainerRequest<I: Image> {
2828
pub(crate) overridden_cmd: Vec<String>,
2929
pub(crate) image_name: Option<String>,
3030
pub(crate) image_tag: Option<String>,
31+
pub(crate) image_digest: Option<String>,
3132
pub(crate) container_name: Option<String>,
3233
pub(crate) platform: Option<String>,
3334
pub(crate) network: Option<String>,
@@ -181,13 +182,22 @@ impl<I: Image> ContainerRequest<I> {
181182
}
182183

183184
pub fn descriptor(&self) -> String {
184-
let original_name = self.image.name();
185-
let original_tag = self.image.tag();
186-
187-
let name = self.image_name.as_deref().unwrap_or(original_name);
188-
let tag = self.image_tag.as_deref().unwrap_or(original_tag);
189-
190-
format!("{name}:{tag}")
185+
let name = self
186+
.image_name
187+
.as_deref()
188+
.unwrap_or_else(|| self.image.name());
189+
let tag = self
190+
.image_tag
191+
.as_deref()
192+
.unwrap_or_else(|| self.image.tag());
193+
194+
// An explicit `with_digest` override takes precedence over a digest baked into the image.
195+
// When a digest is present, the reference becomes `name:tag@digest`: Docker resolves the
196+
// image by digest, while the tag is kept for readability.
197+
match self.image_digest.as_deref().or_else(|| self.image.digest()) {
198+
Some(digest) => format!("{name}:{tag}@{digest}"),
199+
None => format!("{name}:{tag}"),
200+
}
191201
}
192202

193203
pub fn ready_conditions(&self) -> Vec<WaitFor> {
@@ -265,6 +275,7 @@ impl<I: Image> From<I> for ContainerRequest<I> {
265275
overridden_cmd: Vec::new(),
266276
image_name: None,
267277
image_tag: None,
278+
image_digest: None,
268279
container_name: None,
269280
platform: None,
270281
network: None,
@@ -327,6 +338,7 @@ impl<I: Image + Debug> Debug for ContainerRequest<I> {
327338
.field("overridden_cmd", &self.overridden_cmd)
328339
.field("image_name", &self.image_name)
329340
.field("image_tag", &self.image_tag)
341+
.field("image_digest", &self.image_digest)
330342
.field("container_name", &self.container_name)
331343
.field("platform", &self.platform)
332344
.field("network", &self.network)
@@ -368,3 +380,67 @@ impl<I: Image + Debug> Debug for ContainerRequest<I> {
368380
repr.finish()
369381
}
370382
}
383+
384+
#[cfg(test)]
385+
mod tests {
386+
use super::*;
387+
use crate::{images::generic::GenericImage, ImageExt};
388+
389+
/// Minimal image that pins a digest via the [`Image`] trait itself.
390+
#[derive(Debug, Default)]
391+
struct DigestPinnedImage;
392+
393+
impl Image for DigestPinnedImage {
394+
fn name(&self) -> &str {
395+
"pinned"
396+
}
397+
398+
fn tag(&self) -> &str {
399+
"1.0"
400+
}
401+
402+
fn digest(&self) -> Option<&str> {
403+
Some("sha256:aaaa")
404+
}
405+
406+
fn ready_conditions(&self) -> Vec<WaitFor> {
407+
Vec::new()
408+
}
409+
}
410+
411+
#[test]
412+
fn descriptor_without_digest_uses_name_and_tag() {
413+
let request: ContainerRequest<_> = GenericImage::new("nginx", "1.25").into();
414+
assert_eq!(request.descriptor(), "nginx:1.25");
415+
}
416+
417+
#[test]
418+
fn descriptor_with_digest_override_keeps_tag() {
419+
let request = GenericImage::new("nginx", "1.25").with_digest("sha256:abc123");
420+
assert_eq!(request.descriptor(), "nginx:1.25@sha256:abc123");
421+
}
422+
423+
#[test]
424+
fn descriptor_uses_digest_from_image_trait() {
425+
let request: ContainerRequest<_> = DigestPinnedImage.into();
426+
assert_eq!(request.descriptor(), "pinned:1.0@sha256:aaaa");
427+
}
428+
429+
#[test]
430+
fn with_digest_overrides_image_trait_digest() {
431+
let request = DigestPinnedImage.with_digest("sha256:bbbb");
432+
assert_eq!(request.descriptor(), "pinned:1.0@sha256:bbbb");
433+
}
434+
435+
#[test]
436+
fn descriptor_combines_name_tag_and_digest_overrides() {
437+
let request = GenericImage::new("nginx", "1.25")
438+
.with_name("ghcr.io/library/nginx")
439+
.with_tag("mainline")
440+
.with_digest("sha256:abc123");
441+
assert_eq!(
442+
request.descriptor(),
443+
"ghcr.io/library/nginx:mainline@sha256:abc123"
444+
);
445+
}
446+
}

testcontainers/src/core/image.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@ where
4040
/// suddenly changed.
4141
fn tag(&self) -> &str;
4242

43+
/// An optional content digest used to pin the image to an immutable manifest.
44+
///
45+
/// Pinning by digest provides the strongest guarantee that the exact same image is used
46+
/// across runs, since a digest references immutable content whereas a tag can be overwritten
47+
/// in the registry. The returned value must include the algorithm prefix, e.g.
48+
/// `sha256:e9b8...`.
49+
///
50+
/// When set, the image reference passed to Docker becomes `name:tag@digest`. Docker resolves
51+
/// the image by digest; the tag is retained only for readability. Returning `None` (the
52+
/// default) leaves the image resolved by tag alone.
53+
fn digest(&self) -> Option<&str> {
54+
None
55+
}
56+
4357
/// Returns a list of conditions that need to be met before a started container is considered ready.
4458
///
4559
/// This method is the **🍞 and butter** of the whole testcontainers library. Containers are

testcontainers/src/core/image/image_ext.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,30 @@ pub trait ImageExt<I: Image> {
6565
/// running container. Users of this API are advised to use this at their own risk.
6666
fn with_tag(self, tag: impl Into<String>) -> ContainerRequest<I>;
6767

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

@@ -325,6 +349,14 @@ impl<RI: Into<ContainerRequest<I>>, I: Image> ImageExt<I> for RI {
325349
}
326350
}
327351

352+
fn with_digest(self, digest: impl Into<String>) -> ContainerRequest<I> {
353+
let container_req = self.into();
354+
ContainerRequest {
355+
image_digest: Some(digest.into()),
356+
..container_req
357+
}
358+
}
359+
328360
fn with_container_name(self, name: impl Into<String>) -> ContainerRequest<I> {
329361
let container_req = self.into();
330362

testcontainers/tests/async_runner.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,29 @@ async fn bollard_pull_missing_image_hello_world() -> anyhow::Result<()> {
8989
Ok(())
9090
}
9191

92+
#[tokio::test]
93+
async fn run_hello_world_pinned_by_digest() -> anyhow::Result<()> {
94+
let _ = pretty_env_logger::try_init();
95+
cleanup_hello_world_image().await?;
96+
97+
// Immutable manifest-list (multi-arch) digest of `hello-world:latest`.
98+
// `with_wait_for` is a `GenericImage` method, so it must come before the
99+
// `ImageExt::with_digest` call that turns the image into a `ContainerRequest`.
100+
let request = GenericImage::new("hello-world", "latest")
101+
.with_wait_for(WaitFor::message_on_stdout("Hello from Docker!"))
102+
.with_wait_for(WaitFor::exit(ExitWaitStrategy::new().with_exit_code(0)))
103+
.with_digest("sha256:0e760fdfbc48ba8041e7c6db999bb40bfca508b4be580ac75d32c4e29d202ce1");
104+
105+
assert_eq!(
106+
request.descriptor(),
107+
"hello-world:latest@sha256:0e760fdfbc48ba8041e7c6db999bb40bfca508b4be580ac75d32c4e29d202ce1"
108+
);
109+
110+
// Pulling and starting proves Docker accepts and resolves the `name:tag@digest` reference.
111+
let _container = request.start().await?;
112+
Ok(())
113+
}
114+
92115
#[tokio::test]
93116
async fn explicit_call_to_pull_missing_image_hello_world() -> anyhow::Result<()> {
94117
let _ = pretty_env_logger::try_init();

0 commit comments

Comments
 (0)