Skip to content

Commit c167524

Browse files
Support SHA256 digest lookup in local image cache
Index images by their repoDigests in addition to repoTags when populating LocalImagesCache so that digest-based image references (e.g. alpine@sha256:...) resolve from cache without requiring an extra inspectImageCmd call. Closes #1406
1 parent 990a7dc commit c167524

2 files changed

Lines changed: 173 additions & 9 deletions

File tree

core/src/main/java/org/testcontainers/images/LocalImagesCache.java

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
import java.util.Optional;
1515
import java.util.concurrent.ConcurrentHashMap;
1616
import java.util.concurrent.atomic.AtomicBoolean;
17-
import java.util.stream.Collectors;
1817
import java.util.stream.Stream;
1918

2019
@Slf4j
@@ -72,20 +71,35 @@ private synchronized boolean maybeInitCache(DockerClient dockerClient) {
7271

7372
private void populateFromList(List<Image> images) {
7473
for (Image image : images) {
75-
String[] repoTags = image.getRepoTags();
76-
if (repoTags == null) {
77-
log.debug("repoTags is null, skipping image: {}", image);
78-
continue;
79-
}
74+
ImageData imageData = ImageData.from(image);
8075

81-
cache.putAll(
76+
String[] repoTags = image.getRepoTags();
77+
if (repoTags != null) {
8278
Stream
8379
.of(repoTags)
8480
// Protection against some edge case where local image repository tags end up with duplicates
8581
// making toMap crash at merge time.
8682
.distinct()
87-
.collect(Collectors.toMap(DockerImageName::new, it -> ImageData.from(image)))
88-
);
83+
.forEach(tag -> cache.put(new DockerImageName(tag), imageData));
84+
}
85+
86+
String[] repoDigests = image.getRepoDigests();
87+
if (repoDigests != null) {
88+
Stream
89+
.of(repoDigests)
90+
.distinct()
91+
.forEach(digest -> {
92+
try {
93+
cache.put(new DockerImageName(digest), imageData);
94+
} catch (IllegalArgumentException e) {
95+
log.debug("Unable to parse image digest '{}', skipping", digest, e);
96+
}
97+
});
98+
}
99+
100+
if (repoTags == null && repoDigests == null) {
101+
log.debug("repoTags and repoDigests are both null, skipping image: {}", image);
102+
}
89103
}
90104
}
91105
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package org.testcontainers.images;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.github.dockerjava.api.model.Image;
5+
import org.junit.jupiter.api.BeforeEach;
6+
import org.junit.jupiter.api.Test;
7+
import org.testcontainers.utility.DockerImageName;
8+
9+
import java.util.Arrays;
10+
import java.util.HashMap;
11+
import java.util.Map;
12+
13+
import static org.assertj.core.api.Assertions.assertThat;
14+
15+
class LocalImagesCacheTest {
16+
17+
private static final ObjectMapper MAPPER = new ObjectMapper();
18+
19+
@BeforeEach
20+
void setUp() {
21+
LocalImagesCache.INSTANCE.cache.clear();
22+
LocalImagesCache.INSTANCE.initialized.set(false);
23+
}
24+
25+
@Test
26+
void shouldCacheImageByRepoTag() {
27+
Image image = createImage(new String[] { "alpine:3.17" }, null, "sha256:aaa111", 1000L);
28+
29+
populateCache(image);
30+
31+
assertThat(LocalImagesCache.INSTANCE.cache).containsKey(new DockerImageName("alpine:3.17"));
32+
}
33+
34+
@Test
35+
void shouldCacheImageByRepoDigest() {
36+
Image image = createImage(
37+
null,
38+
new String[] { "alpine@sha256:1775bebec23e1f3ce486989bfc9ff3c4e951690df84aa9f926497d82f2ffca9d" },
39+
"sha256:bbb222",
40+
1000L
41+
);
42+
43+
populateCache(image);
44+
45+
assertThat(LocalImagesCache.INSTANCE.cache)
46+
.containsKey(
47+
new DockerImageName("alpine@sha256:1775bebec23e1f3ce486989bfc9ff3c4e951690df84aa9f926497d82f2ffca9d")
48+
);
49+
}
50+
51+
@Test
52+
void shouldCacheImageByBothTagAndDigest() {
53+
Image image = createImage(
54+
new String[] { "alpine:3.17" },
55+
new String[] { "alpine@sha256:1775bebec23e1f3ce486989bfc9ff3c4e951690df84aa9f926497d82f2ffca9d" },
56+
"sha256:ccc333",
57+
1000L
58+
);
59+
60+
populateCache(image);
61+
62+
assertThat(LocalImagesCache.INSTANCE.cache)
63+
.containsKey(new DockerImageName("alpine:3.17"))
64+
.containsKey(
65+
new DockerImageName("alpine@sha256:1775bebec23e1f3ce486989bfc9ff3c4e951690df84aa9f926497d82f2ffca9d")
66+
);
67+
}
68+
69+
@Test
70+
void shouldSkipImageWithNullTagsAndNullDigests() {
71+
Image image = createImage(null, null, "sha256:ddd444", 1000L);
72+
73+
populateCache(image);
74+
75+
assertThat(LocalImagesCache.INSTANCE.cache).isEmpty();
76+
}
77+
78+
@Test
79+
void shouldHandleMultipleDigestsForSameImage() {
80+
Image image = createImage(
81+
new String[] { "myapp:latest" },
82+
new String[] {
83+
"myapp@sha256:aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111",
84+
"registry.example.com/myapp@sha256:bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222",
85+
},
86+
"sha256:eee555",
87+
1000L
88+
);
89+
90+
populateCache(image);
91+
92+
assertThat(LocalImagesCache.INSTANCE.cache)
93+
.containsKey(new DockerImageName("myapp:latest"))
94+
.containsKey(
95+
new DockerImageName("myapp@sha256:aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111")
96+
)
97+
.containsKey(
98+
new DockerImageName(
99+
"registry.example.com/myapp@sha256:bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222"
100+
)
101+
);
102+
}
103+
104+
@Test
105+
void shouldHandleDuplicateRepoTags() {
106+
Image image = createImage(new String[] { "alpine:3.17", "alpine:3.17" }, null, "sha256:fff666", 1000L);
107+
108+
populateCache(image);
109+
110+
assertThat(LocalImagesCache.INSTANCE.cache).containsKey(new DockerImageName("alpine:3.17"));
111+
}
112+
113+
@Test
114+
void shouldCacheDigestOnlyImageWithoutTags() {
115+
Image image = createImage(
116+
new String[] { "<none>:<none>" },
117+
new String[] { "myrepo@sha256:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" },
118+
"sha256:ggg777",
119+
1000L
120+
);
121+
122+
populateCache(image);
123+
124+
// The <none>:<none> tag entry may fail to be useful, but the digest entry should be present
125+
assertThat(LocalImagesCache.INSTANCE.cache)
126+
.containsKey(
127+
new DockerImageName("myrepo@sha256:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234")
128+
);
129+
}
130+
131+
private static Image createImage(String[] repoTags, String[] repoDigests, String id, Long created) {
132+
Map<String, Object> fields = new HashMap<>();
133+
fields.put("RepoTags", repoTags);
134+
fields.put("RepoDigests", repoDigests);
135+
fields.put("Id", id);
136+
fields.put("Created", created);
137+
return MAPPER.convertValue(fields, Image.class);
138+
}
139+
140+
private static void populateCache(Image... images) {
141+
try {
142+
java.lang.reflect.Method method =
143+
LocalImagesCache.class.getDeclaredMethod("populateFromList", java.util.List.class);
144+
method.setAccessible(true);
145+
method.invoke(LocalImagesCache.INSTANCE, Arrays.asList(images));
146+
} catch (Exception e) {
147+
throw new RuntimeException("Failed to invoke populateFromList", e);
148+
}
149+
}
150+
}

0 commit comments

Comments
 (0)