From bf4e967c014d6afffd2e20f11df9b2c103a6fea2 Mon Sep 17 00:00:00 2001 From: junho Date: Sat, 29 Aug 2026 12:52:48 +0900 Subject: [PATCH] Support SHA256 digests and Image IDs in image cache lookup Previously, LocalImagesCache only mapped RepoTags from Docker image metadata when caching locally available images. Images referenced by SHA256 digests or raw Image IDs (or images lacking repository tags) caused cache misses and triggered redundant pull attempts. Update LocalImagesCache to populate the cache using RepoDigests and Image IDs in addition to RepoTags, filtering out placeholder values such as : and @. Fixes #1406 --- .../images/LocalImagesCache.java | 72 ++++++++++++++----- .../images/LocalImagesCacheTest.java | 69 ++++++++++++++++++ 2 files changed, 124 insertions(+), 17 deletions(-) create mode 100644 core/src/test/java/org/testcontainers/images/LocalImagesCacheTest.java diff --git a/core/src/main/java/org/testcontainers/images/LocalImagesCache.java b/core/src/main/java/org/testcontainers/images/LocalImagesCache.java index c98339e9f4c..53139534247 100644 --- a/core/src/main/java/org/testcontainers/images/LocalImagesCache.java +++ b/core/src/main/java/org/testcontainers/images/LocalImagesCache.java @@ -14,8 +14,6 @@ import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.stream.Collectors; -import java.util.stream.Stream; @Slf4j enum LocalImagesCache { @@ -35,8 +33,6 @@ public ImageData get(DockerImageName imageName) { public Optional refreshCache(DockerImageName imageName) { DockerClient dockerClient = DockerClientFactory.instance().client(); if (!maybeInitCache(dockerClient)) { - // Cache may be stale, trying inspectImageCmd... - InspectImageResponse response = null; try { response = dockerClient.inspectImageCmd(imageName.asCanonicalNameString()).exec(); @@ -46,6 +42,24 @@ public Optional refreshCache(DockerImageName imageName) { if (response != null) { ImageData imageData = ImageData.from(response); cache.put(imageName, imageData); + if (response.getRepoDigests() != null) { + for (String repoDigest : response.getRepoDigests()) { + if (repoDigest != null && !"@".equals(repoDigest)) { + try { + cache.put(DockerImageName.parse(repoDigest), imageData); + } catch (IllegalArgumentException ignored) {} + } + } + } + String imageId = response.getId(); + if (imageId != null) { + try { + cache.put(DockerImageName.parse(imageId), imageData); + if (imageId.startsWith("sha256:")) { + cache.put(DockerImageName.parse(imageId.substring(7)), imageData); + } + } catch (IllegalArgumentException ignored) {} + } return Optional.of(imageData); } else { cache.remove(imageName); @@ -56,7 +70,8 @@ public Optional refreshCache(DockerImageName imageName) { return Optional.ofNullable(cache.get(imageName)); } - private synchronized boolean maybeInitCache(DockerClient dockerClient) { + @VisibleForTesting + synchronized boolean maybeInitCache(DockerClient dockerClient) { if (!initialized.compareAndSet(false, true)) { return false; } @@ -72,20 +87,43 @@ private synchronized boolean maybeInitCache(DockerClient dockerClient) { private void populateFromList(List images) { for (Image image : images) { - String[] repoTags = image.getRepoTags(); - if (repoTags == null) { - log.debug("repoTags is null, skipping image: {}", image); - continue; + ImageData imageData = ImageData.from(image); + + if (image.getRepoTags() != null) { + for (String repoTag : image.getRepoTags()) { + if (repoTag != null && !":".equals(repoTag)) { + try { + cache.put(DockerImageName.parse(repoTag), imageData); + } catch (IllegalArgumentException e) { + log.debug("Failed to parse repoTag: {}", repoTag, e); + } + } + } } - cache.putAll( - Stream - .of(repoTags) - // Protection against some edge case where local image repository tags end up with duplicates - // making toMap crash at merge time. - .distinct() - .collect(Collectors.toMap(DockerImageName::new, it -> ImageData.from(image))) - ); + if (image.getRepoDigests() != null) { + for (String repoDigest : image.getRepoDigests()) { + if (repoDigest != null && !"@".equals(repoDigest)) { + try { + cache.put(DockerImageName.parse(repoDigest), imageData); + } catch (IllegalArgumentException e) { + log.debug("Failed to parse repoDigest: {}", repoDigest, e); + } + } + } + } + + String imageId = image.getId(); + if (imageId != null) { + try { + cache.put(DockerImageName.parse(imageId), imageData); + if (imageId.startsWith("sha256:")) { + cache.put(DockerImageName.parse(imageId.substring(7)), imageData); + } + } catch (IllegalArgumentException e) { + log.debug("Failed to parse image id: {}", imageId, e); + } + } } } } diff --git a/core/src/test/java/org/testcontainers/images/LocalImagesCacheTest.java b/core/src/test/java/org/testcontainers/images/LocalImagesCacheTest.java new file mode 100644 index 00000000000..987a838731c --- /dev/null +++ b/core/src/test/java/org/testcontainers/images/LocalImagesCacheTest.java @@ -0,0 +1,69 @@ +package org.testcontainers.images; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.command.ListImagesCmd; +import com.github.dockerjava.api.model.Image; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.testcontainers.utility.DockerImageName; + +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +class LocalImagesCacheTest { + + @BeforeEach + @AfterEach + void resetCache() { + LocalImagesCacheAccessor.clearCache(); + } + + @Test + void shouldCacheRepoDigestsAndImageIds() { + DockerClient dockerClient = Mockito.mock(DockerClient.class); + ListImagesCmd listImagesCmd = Mockito.mock(ListImagesCmd.class); + + when(dockerClient.listImagesCmd()).thenReturn(listImagesCmd); + + Image image = Mockito.mock(Image.class); + when(image.getRepoTags()).thenReturn(new String[] { "test-repo:1.0", ":" }); + when(image.getRepoDigests()) + .thenReturn( + new String[] { + "test-repo@sha256:e1594798e61a75abde649ed1432fa955853a7816f516fe49360d623213a01d96", + "@", + } + ); + when(image.getId()).thenReturn("sha256:e1594798e61a75abde649ed1432fa955853a7816f516fe49360d623213a01d96"); + when(image.getCreated()).thenReturn(1595874211L); + + when(listImagesCmd.exec()).thenReturn(Collections.singletonList(image)); + + LocalImagesCache.INSTANCE.maybeInitCache(dockerClient); + + ImageData byTag = LocalImagesCache.INSTANCE.cache.get(DockerImageName.parse("test-repo:1.0")); + assertThat(byTag).isNotNull(); + + ImageData byDigest = LocalImagesCache.INSTANCE.cache.get( + DockerImageName.parse("test-repo@sha256:e1594798e61a75abde649ed1432fa955853a7816f516fe49360d623213a01d96") + ); + assertThat(byDigest).isNotNull(); + + ImageData byIdWithPrefix = LocalImagesCache.INSTANCE.cache.get( + DockerImageName.parse("sha256:e1594798e61a75abde649ed1432fa955853a7816f516fe49360d623213a01d96") + ); + assertThat(byIdWithPrefix).isNotNull(); + + ImageData byIdWithoutPrefix = LocalImagesCache.INSTANCE.cache.get( + DockerImageName.parse("e1594798e61a75abde649ed1432fa955853a7816f516fe49360d623213a01d96") + ); + assertThat(byIdWithoutPrefix).isNotNull(); + + ImageData noneTag = LocalImagesCache.INSTANCE.cache.get(DockerImageName.parse(":")); + assertThat(noneTag).isNull(); + } +}