Skip to content

Commit c09aa44

Browse files
committed
Add support for mirror-by-digest-only and pull-from-mirror
Signed-off-by: Valentin Delaye <jonesbusy@users.noreply.github.com>
1 parent e0611a8 commit c09aa44

5 files changed

Lines changed: 398 additions & 29 deletions

File tree

src/main/java/land/oras/Registry.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1218,7 +1218,7 @@ boolean exists(ContainerRef containerRef) {
12181218
* @return The result from the first successful invocation
12191219
*/
12201220
private <T> T withMirrorFallback(ContainerRef containerRef, BiFunction<Registry, ContainerRef, T> operation) {
1221-
List<RegistriesConf.MirrorConfig> mirrors = registriesConf.getMirrors(containerRef);
1221+
List<RegistriesConf.MirrorConfig> mirrors = registriesConf.getApplicableMirrors(containerRef);
12221222
for (RegistriesConf.MirrorConfig mirror : mirrors) {
12231223
String mirrorLocation = mirror.location();
12241224
if (mirrorLocation == null || mirrorLocation.isBlank()) continue;

src/main/java/land/oras/auth/RegistriesConf.java

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import java.util.Map;
3434
import java.util.Objects;
3535
import java.util.Optional;
36+
import java.util.stream.Collectors;
3637
import land.oras.ContainerRef;
3738
import land.oras.OrasModel;
3839
import land.oras.Registry;
@@ -104,17 +105,28 @@ public static RegistriesConf newConf() {
104105
* The model of a mirror entry within a [[registry]] table.
105106
* @param location The mirror registry location (host[:port][/path]).
106107
* @param insecure Whether the mirror is insecure.
108+
* @param pullFromMirror Controls which pull operations may use this mirror (default: {@link PullFromMirror#ALL}).
107109
*/
108110
@OrasModel
109111
public record MirrorConfig(
110-
@Nullable @JsonProperty("location") String location, @Nullable @JsonProperty("insecure") Boolean insecure) {
112+
@Nullable @JsonProperty("location") String location,
113+
@Nullable @JsonProperty("insecure") Boolean insecure,
114+
@Nullable @JsonProperty("pull-from-mirror") PullFromMirror pullFromMirror) {
111115
/**
112116
* Return true if this mirror should be accessed over plain HTTP or with unverified TLS.
113117
* @return true if insecure
114118
*/
115119
public boolean isInsecure() {
116120
return insecure != null && insecure;
117121
}
122+
123+
/**
124+
* Return the effective pull-from-mirror setting, defaulting to {@link PullFromMirror#ALL}.
125+
* @return the pull-from-mirror setting
126+
*/
127+
public PullFromMirror effectivePullFromMirror() {
128+
return pullFromMirror != null ? pullFromMirror : PullFromMirror.ALL;
129+
}
118130
}
119131

120132
/**
@@ -123,6 +135,7 @@ public boolean isInsecure() {
123135
* @param location The registry location
124136
* @param blocked Whether the registry is blocked. If true, the registry is blocked and cannot be used for pulling or pushing images.
125137
* @param insecure Whether the registry is insecure. If true, the registry is considered insecure and may allow connections over HTTP or with invalid TLS certificates.
138+
* @param mirrorByDigestOnly If true, all mirrors for this registry are treated as digest-only (equivalent to setting pull-from-mirror=digest-only on every mirror).
126139
* @param mirrors Ordered list of mirror entries to try before the registry location.
127140
*/
128141
@OrasModel
@@ -131,6 +144,7 @@ record RegistryConfig(
131144
@Nullable @JsonProperty("location") String location,
132145
@Nullable @JsonProperty("blocked") Boolean blocked,
133146
@Nullable @JsonProperty("insecure") Boolean insecure,
147+
@Nullable @JsonProperty("mirror-by-digest-only") Boolean mirrorByDigestOnly,
134148
@Nullable @JsonProperty("mirror") List<MirrorConfig> mirrors) {
135149
/**
136150
* Return true if this registry is blocked and cannot be used for pulling or pushing images.
@@ -147,6 +161,14 @@ public boolean isBlocked() {
147161
public boolean isInsecure() {
148162
return insecure != null && insecure;
149163
}
164+
165+
/**
166+
* Return true if all mirrors for this registry should only be used when the reference includes a digest.
167+
* @return true if mirror-by-digest-only is set
168+
*/
169+
public boolean isMirrorByDigestOnly() {
170+
return mirrorByDigestOnly != null && mirrorByDigestOnly;
171+
}
150172
}
151173

152174
/**
@@ -205,6 +227,40 @@ public String getKey() {
205227
private String value;
206228
}
207229

230+
/**
231+
* Controls which pull operations may use a mirror.
232+
* <ul>
233+
* <li>{@link #ALL} – the mirror is used for all pull operations (default)</li>
234+
* <li>{@link #DIGEST_ONLY} – mirror is only used when the reference includes a digest</li>
235+
* <li>{@link #TAG_ONLY} – mirror is only used when the reference includes a tag</li>
236+
* </ul>
237+
*/
238+
@OrasModel
239+
public enum PullFromMirror {
240+
ALL("all"),
241+
DIGEST_ONLY("digest-only"),
242+
TAG_ONLY("tag-only");
243+
244+
PullFromMirror(String value) {
245+
this.value = value;
246+
}
247+
248+
@JsonCreator
249+
public static PullFromMirror fromString(String key) {
250+
for (PullFromMirror v : values()) {
251+
if (v.value.equalsIgnoreCase(key)) return v;
252+
}
253+
throw new IllegalArgumentException("Unknown pull-from-mirror value: " + key);
254+
}
255+
256+
@JsonValue
257+
public String getKey() {
258+
return value;
259+
}
260+
261+
private final String value;
262+
}
263+
208264
/**
209265
* The model of the configuration file, which contains the list of registry configurations, aliases, and unqualified registries.
210266
* @param registries The list of registry configurations, each containing the registry location, whether it is blocked, and whether it is insecure.
@@ -377,6 +433,35 @@ public List<MirrorConfig> getMirrors(ContainerRef ref) {
377433
return Collections.unmodifiableList(matchingConfig.get().mirrors());
378434
}
379435

436+
/**
437+
* Return the ordered list of mirrors that are applicable for the given reference, filtering out mirrors
438+
* whose {@code pull-from-mirror} setting does not match the reference type (tag vs. digest).
439+
* The registry-level {@code mirror-by-digest-only} flag, when true, overrides all per-mirror settings
440+
* and restricts every mirror to digest-only pulls.
441+
* @param ref the container reference to look up applicable mirrors for.
442+
* @return an unmodifiable list of applicable mirror configs (may be empty).
443+
*/
444+
public List<MirrorConfig> getApplicableMirrors(ContainerRef ref) {
445+
Optional<RegistryConfig> matchingConfig = selectMatchingTable(ref);
446+
if (matchingConfig.isEmpty() || matchingConfig.get().mirrors() == null) {
447+
return Collections.emptyList();
448+
}
449+
boolean registryDigestOnly = matchingConfig.get().isMirrorByDigestOnly();
450+
boolean refHasDigest = ref.getDigest() != null && !ref.getDigest().isEmpty();
451+
boolean refHasTag = ref.getTag() != null && !ref.getTag().isEmpty();
452+
return matchingConfig.get().mirrors().stream()
453+
.filter(mirror -> {
454+
PullFromMirror effective =
455+
registryDigestOnly ? PullFromMirror.DIGEST_ONLY : mirror.effectivePullFromMirror();
456+
return switch (effective) {
457+
case DIGEST_ONLY -> refHasDigest;
458+
case TAG_ONLY -> refHasTag;
459+
case ALL -> true;
460+
};
461+
})
462+
.collect(Collectors.toUnmodifiableList());
463+
}
464+
380465
/**
381466
* Rewrite the given container reference to use the mirror's location, replacing the registry host.
382467
* @param ref the original container reference.

src/test/java/land/oras/ClassAnnotationsTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ void shouldHaveAnnotationOnModel() {
4949
.loadClasses());
5050

5151
// Check number of classes
52-
assertEquals(27, modelClasses.size());
52+
assertEquals(28, modelClasses.size());
5353

5454
// Check classes
5555
assertTrue(modelClasses.contains(Annotations.class));
@@ -83,7 +83,7 @@ void shouldHaveAnnotationOnAuthPackage() {
8383
.loadClasses());
8484

8585
// Check number of classes
86-
assertEquals(9, modelClasses.size());
86+
assertEquals(10, modelClasses.size());
8787
}
8888
}
8989
}

src/test/java/land/oras/RegistryMirrorTest.java

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
2424
import static org.junit.jupiter.api.Assertions.assertNotNull;
25+
import static org.junit.jupiter.api.Assertions.assertThrows;
2526
import static org.junit.jupiter.api.Assertions.assertTrue;
2627

2728
import java.io.IOException;
@@ -263,6 +264,133 @@ void shouldFetchManifestViaUnqualifiedReference(@TempDir Path blobDir) throws Ex
263264
});
264265
}
265266

267+
@Test
268+
void shouldSkipDigestOnlyMirrorWhenPullingByTag(@TempDir Path blobDir) throws Exception {
269+
270+
// Push artifact to the working mirror
271+
String mirrorRegistry = mirrorUp.getRegistry();
272+
Registry setupRegistry = Registry.builder().insecure(mirrorRegistry).build();
273+
Path testFile = createTestFile(blobDir, "digest-only.txt", "digest-only mirror content");
274+
ContainerRef mirrorArtifact = ContainerRef.parse(mirrorRegistry + "/test/digest-only-mirror:v1");
275+
setupRegistry.pushArtifact(mirrorArtifact, LocalPath.of(testFile));
276+
277+
// Mirror configured as digest-only — a tag-based pull must NOT use it
278+
// language=toml
279+
String registriesConf =
280+
"""
281+
[[registry]]
282+
prefix = "localhost:59998"
283+
location = "localhost:59998"
284+
285+
[[registry.mirror]]
286+
location = "%s"
287+
insecure = true
288+
pull-from-mirror = "digest-only"
289+
"""
290+
.formatted(mirrorRegistry);
291+
292+
TestUtils.createRegistriesConfFile(homeDir, registriesConf);
293+
294+
TestUtils.withHome(homeDir, () -> {
295+
Registry registry = Registry.builder().insecure().defaults().build();
296+
// Pull by tag: digest-only mirror is skipped; fallback to "original" (also down) → must fail
297+
ContainerRef ref = ContainerRef.parse("localhost:59998/test/digest-only-mirror:v1");
298+
assertThrows(
299+
land.oras.exception.OrasException.class,
300+
() -> registry.getManifest(ref),
301+
"digest-only mirror must be skipped for a tag-based pull");
302+
});
303+
}
304+
305+
@Test
306+
void shouldUseDigestOnlyMirrorWhenPullingByDigest(@TempDir Path blobDir) throws Exception {
307+
308+
// Push artifact to the working mirror
309+
String mirrorRegistry = mirrorUp.getRegistry();
310+
Registry setupRegistry = Registry.builder().insecure(mirrorRegistry).build();
311+
Path testFile = createTestFile(blobDir, "digest-pull.txt", "digest mirror content");
312+
ContainerRef mirrorArtifact = ContainerRef.parse(mirrorRegistry + "/test/digest-pull-mirror:v1");
313+
setupRegistry.pushArtifact(mirrorArtifact, LocalPath.of(testFile));
314+
315+
// Resolve the digest so we can pull by digest
316+
Manifest pushed = setupRegistry.getManifest(mirrorArtifact);
317+
assertNotNull(pushed);
318+
String digest = pushed.getDescriptor().getDigest();
319+
320+
// language=toml
321+
String registriesConf =
322+
"""
323+
[[registry]]
324+
prefix = "localhost:59998"
325+
location = "localhost:59998"
326+
327+
[[registry.mirror]]
328+
location = "%s"
329+
insecure = true
330+
pull-from-mirror = "digest-only"
331+
"""
332+
.formatted(mirrorRegistry);
333+
334+
TestUtils.createRegistriesConfFile(homeDir, registriesConf);
335+
336+
TestUtils.withHome(homeDir, () -> {
337+
Registry registry = Registry.builder().insecure().defaults().build();
338+
// Pull by digest: digest-only mirror must be used
339+
ContainerRef ref = ContainerRef.parse("localhost:59998/test/digest-pull-mirror:v1")
340+
.withDigest(digest);
341+
Manifest manifest = registry.getManifest(ref);
342+
assertNotNull(manifest, "digest-only mirror must be used for a digest-based pull");
343+
});
344+
}
345+
346+
@Test
347+
void shouldApplyMirrorByDigestOnly(@TempDir Path blobDir) throws Exception {
348+
349+
// Push artifact to the working mirror
350+
String mirrorRegistry = mirrorUp.getRegistry();
351+
Registry setupRegistry = Registry.builder().insecure(mirrorRegistry).build();
352+
Path testFile = createTestFile(blobDir, "mbd.txt", "mirror-by-digest-only content");
353+
ContainerRef mirrorArtifact = ContainerRef.parse(mirrorRegistry + "/test/mbd-mirror:v1");
354+
setupRegistry.pushArtifact(mirrorArtifact, LocalPath.of(testFile));
355+
356+
Manifest pushed = setupRegistry.getManifest(mirrorArtifact);
357+
assertNotNull(pushed);
358+
String digest = pushed.getDescriptor().getDigest();
359+
360+
// language=toml
361+
String registriesConf =
362+
"""
363+
[[registry]]
364+
prefix = "localhost:59998"
365+
location = "localhost:59998"
366+
mirror-by-digest-only = true
367+
368+
[[registry.mirror]]
369+
location = "%s"
370+
insecure = true
371+
"""
372+
.formatted(mirrorRegistry);
373+
374+
TestUtils.createRegistriesConfFile(homeDir, registriesConf);
375+
376+
TestUtils.withHome(homeDir, () -> {
377+
Registry registry = Registry.builder().insecure().defaults().build();
378+
379+
// Tag pull → mirror-by-digest-only skips all mirrors → fails with original down
380+
ContainerRef tagRef = ContainerRef.parse("localhost:59998/test/mbd-mirror:v1");
381+
assertThrows(
382+
land.oras.exception.OrasException.class,
383+
() -> registry.getManifest(tagRef),
384+
"mirror-by-digest-only must skip mirrors for tag-based pulls");
385+
386+
// Digest pull → mirror is used
387+
ContainerRef digestRef =
388+
ContainerRef.parse("localhost:59998/test/mbd-mirror:v1").withDigest(digest);
389+
Manifest manifest = registry.getManifest(digestRef);
390+
assertNotNull(manifest, "mirror-by-digest-only must allow mirrors for digest-based pulls");
391+
});
392+
}
393+
266394
private Path createTestFile(Path dir, String name, String content) throws IOException {
267395
Path file = dir.resolve(name);
268396
Files.writeString(file, content);

0 commit comments

Comments
 (0)