From 9cd6448de509cad6c7f1ba62d0557688f6473a61 Mon Sep 17 00:00:00 2001 From: Valentin Delaye Date: Thu, 18 Jun 2026 11:58:47 +0200 Subject: [PATCH] Add support for mirror-by-digest-only and pull-from-mirror Signed-off-by: Valentin Delaye --- src/main/java/land/oras/Registry.java | 2 +- .../java/land/oras/auth/RegistriesConf.java | 102 ++++++++- .../java/land/oras/ClassAnnotationsTest.java | 4 +- .../java/land/oras/RegistryMirrorTest.java | 128 +++++++++++ .../java/land/oras/auth/RegistryConfTest.java | 206 +++++++++++++++--- 5 files changed, 413 insertions(+), 29 deletions(-) diff --git a/src/main/java/land/oras/Registry.java b/src/main/java/land/oras/Registry.java index 17371c5f..a178abd8 100644 --- a/src/main/java/land/oras/Registry.java +++ b/src/main/java/land/oras/Registry.java @@ -1218,7 +1218,7 @@ boolean exists(ContainerRef containerRef) { * @return The result from the first successful invocation */ private T withMirrorFallback(ContainerRef containerRef, BiFunction operation) { - List mirrors = registriesConf.getMirrors(containerRef); + List mirrors = registriesConf.getApplicableMirrors(containerRef); for (RegistriesConf.MirrorConfig mirror : mirrors) { String mirrorLocation = mirror.location(); if (mirrorLocation == null || mirrorLocation.isBlank()) continue; diff --git a/src/main/java/land/oras/auth/RegistriesConf.java b/src/main/java/land/oras/auth/RegistriesConf.java index acbb4c33..cb1fcf71 100644 --- a/src/main/java/land/oras/auth/RegistriesConf.java +++ b/src/main/java/land/oras/auth/RegistriesConf.java @@ -33,6 +33,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; import land.oras.ContainerRef; import land.oras.OrasModel; import land.oras.Registry; @@ -104,10 +105,13 @@ public static RegistriesConf newConf() { * The model of a mirror entry within a [[registry]] table. * @param location The mirror registry location (host[:port][/path]). * @param insecure Whether the mirror is insecure. + * @param pullFromMirror Controls which pull operations may use this mirror (default: {@link PullFromMirror#ALL}). */ @OrasModel public record MirrorConfig( - @Nullable @JsonProperty("location") String location, @Nullable @JsonProperty("insecure") Boolean insecure) { + @Nullable @JsonProperty("location") String location, + @Nullable @JsonProperty("insecure") Boolean insecure, + @Nullable @JsonProperty("pull-from-mirror") PullFromMirror pullFromMirror) { /** * Return true if this mirror should be accessed over plain HTTP or with unverified TLS. * @return true if insecure @@ -115,6 +119,14 @@ public record MirrorConfig( public boolean isInsecure() { return insecure != null && insecure; } + + /** + * Return the effective pull-from-mirror setting, defaulting to {@link PullFromMirror#ALL}. + * @return the pull-from-mirror setting + */ + public PullFromMirror effectivePullFromMirror() { + return pullFromMirror != null ? pullFromMirror : PullFromMirror.ALL; + } } /** @@ -123,6 +135,7 @@ public boolean isInsecure() { * @param location The registry location * @param blocked Whether the registry is blocked. If true, the registry is blocked and cannot be used for pulling or pushing images. * @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. + * @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). * @param mirrors Ordered list of mirror entries to try before the registry location. */ @OrasModel @@ -131,6 +144,7 @@ record RegistryConfig( @Nullable @JsonProperty("location") String location, @Nullable @JsonProperty("blocked") Boolean blocked, @Nullable @JsonProperty("insecure") Boolean insecure, + @Nullable @JsonProperty("mirror-by-digest-only") Boolean mirrorByDigestOnly, @Nullable @JsonProperty("mirror") List mirrors) { /** * Return true if this registry is blocked and cannot be used for pulling or pushing images. @@ -147,6 +161,14 @@ public boolean isBlocked() { public boolean isInsecure() { return insecure != null && insecure; } + + /** + * Return true if all mirrors for this registry should only be used when the reference includes a digest. + * @return true if mirror-by-digest-only is set + */ + public boolean isMirrorByDigestOnly() { + return mirrorByDigestOnly != null && mirrorByDigestOnly; + } } /** @@ -205,6 +227,55 @@ public String getKey() { private String value; } + /** + * Controls which pull operations may use a mirror. + *
    + *
  • {@link #ALL} – the mirror is used for all pull operations (default)
  • + *
  • {@link #DIGEST_ONLY} – mirror is only used when the reference includes a digest
  • + *
  • {@link #TAG_ONLY} – mirror is only used when the reference includes a tag
  • + *
+ */ + @OrasModel + public enum PullFromMirror { + + /** Use this mirror for all pull operations (default). */ + ALL("all"), + + /** Use this mirror only when the image reference includes a digest. */ + DIGEST_ONLY("digest-only"), + + /** Use this mirror only when the image reference includes a tag. */ + TAG_ONLY("tag-only"); + + PullFromMirror(String value) { + this.value = value; + } + + /** + * Deserialize from the TOML string value (e.g. {@code "digest-only"}). + * @param key the string value from the configuration file. + * @return the matching enum constant. + */ + @JsonCreator + public static PullFromMirror fromString(String key) { + for (PullFromMirror v : values()) { + if (v.value.equalsIgnoreCase(key)) return v; + } + throw new IllegalArgumentException("Unknown pull-from-mirror value: " + key); + } + + /** + * Return the TOML string value for this constant. + * @return the string value. + */ + @JsonValue + public String getKey() { + return value; + } + + private final String value; + } + /** * The model of the configuration file, which contains the list of registry configurations, aliases, and unqualified registries. * @param registries The list of registry configurations, each containing the registry location, whether it is blocked, and whether it is insecure. @@ -377,6 +448,35 @@ public List getMirrors(ContainerRef ref) { return Collections.unmodifiableList(matchingConfig.get().mirrors()); } + /** + * Return the ordered list of mirrors that are applicable for the given reference, filtering out mirrors + * whose {@code pull-from-mirror} setting does not match the reference type (tag vs. digest). + * The registry-level {@code mirror-by-digest-only} flag, when true, overrides all per-mirror settings + * and restricts every mirror to digest-only pulls. + * @param ref the container reference to look up applicable mirrors for. + * @return an unmodifiable list of applicable mirror configs (may be empty). + */ + public List getApplicableMirrors(ContainerRef ref) { + Optional matchingConfig = selectMatchingTable(ref); + if (matchingConfig.isEmpty() || matchingConfig.get().mirrors() == null) { + return Collections.emptyList(); + } + boolean registryDigestOnly = matchingConfig.get().isMirrorByDigestOnly(); + boolean refHasDigest = ref.getDigest() != null && !ref.getDigest().isEmpty(); + boolean refHasTag = ref.getTag() != null && !ref.getTag().isEmpty(); + return matchingConfig.get().mirrors().stream() + .filter(mirror -> { + PullFromMirror effective = + registryDigestOnly ? PullFromMirror.DIGEST_ONLY : mirror.effectivePullFromMirror(); + return switch (effective) { + case DIGEST_ONLY -> refHasDigest; + case TAG_ONLY -> refHasTag; + case ALL -> true; + }; + }) + .collect(Collectors.toUnmodifiableList()); + } + /** * Rewrite the given container reference to use the mirror's location, replacing the registry host. * @param ref the original container reference. diff --git a/src/test/java/land/oras/ClassAnnotationsTest.java b/src/test/java/land/oras/ClassAnnotationsTest.java index 7d9d8dcd..6bbb1fe9 100644 --- a/src/test/java/land/oras/ClassAnnotationsTest.java +++ b/src/test/java/land/oras/ClassAnnotationsTest.java @@ -49,7 +49,7 @@ void shouldHaveAnnotationOnModel() { .loadClasses()); // Check number of classes - assertEquals(27, modelClasses.size()); + assertEquals(28, modelClasses.size()); // Check classes assertTrue(modelClasses.contains(Annotations.class)); @@ -83,7 +83,7 @@ void shouldHaveAnnotationOnAuthPackage() { .loadClasses()); // Check number of classes - assertEquals(9, modelClasses.size()); + assertEquals(10, modelClasses.size()); } } } diff --git a/src/test/java/land/oras/RegistryMirrorTest.java b/src/test/java/land/oras/RegistryMirrorTest.java index 4fcf75c1..a923ff50 100644 --- a/src/test/java/land/oras/RegistryMirrorTest.java +++ b/src/test/java/land/oras/RegistryMirrorTest.java @@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; @@ -263,6 +264,133 @@ void shouldFetchManifestViaUnqualifiedReference(@TempDir Path blobDir) throws Ex }); } + @Test + void shouldSkipDigestOnlyMirrorWhenPullingByTag(@TempDir Path blobDir) throws Exception { + + // Push artifact to the working mirror + String mirrorRegistry = mirrorUp.getRegistry(); + Registry setupRegistry = Registry.builder().insecure(mirrorRegistry).build(); + Path testFile = createTestFile(blobDir, "digest-only.txt", "digest-only mirror content"); + ContainerRef mirrorArtifact = ContainerRef.parse(mirrorRegistry + "/test/digest-only-mirror:v1"); + setupRegistry.pushArtifact(mirrorArtifact, LocalPath.of(testFile)); + + // Mirror configured as digest-only — a tag-based pull must NOT use it + // language=toml + String registriesConf = + """ + [[registry]] + prefix = "localhost:59998" + location = "localhost:59998" + + [[registry.mirror]] + location = "%s" + insecure = true + pull-from-mirror = "digest-only" + """ + .formatted(mirrorRegistry); + + TestUtils.createRegistriesConfFile(homeDir, registriesConf); + + TestUtils.withHome(homeDir, () -> { + Registry registry = Registry.builder().insecure().defaults().build(); + // Pull by tag: digest-only mirror is skipped; fallback to "original" (also down) → must fail + ContainerRef ref = ContainerRef.parse("localhost:59998/test/digest-only-mirror:v1"); + assertThrows( + land.oras.exception.OrasException.class, + () -> registry.getManifest(ref), + "digest-only mirror must be skipped for a tag-based pull"); + }); + } + + @Test + void shouldUseDigestOnlyMirrorWhenPullingByDigest(@TempDir Path blobDir) throws Exception { + + // Push artifact to the working mirror + String mirrorRegistry = mirrorUp.getRegistry(); + Registry setupRegistry = Registry.builder().insecure(mirrorRegistry).build(); + Path testFile = createTestFile(blobDir, "digest-pull.txt", "digest mirror content"); + ContainerRef mirrorArtifact = ContainerRef.parse(mirrorRegistry + "/test/digest-pull-mirror:v1"); + setupRegistry.pushArtifact(mirrorArtifact, LocalPath.of(testFile)); + + // Resolve the digest so we can pull by digest + Manifest pushed = setupRegistry.getManifest(mirrorArtifact); + assertNotNull(pushed); + String digest = pushed.getDescriptor().getDigest(); + + // language=toml + String registriesConf = + """ + [[registry]] + prefix = "localhost:59998" + location = "localhost:59998" + + [[registry.mirror]] + location = "%s" + insecure = true + pull-from-mirror = "digest-only" + """ + .formatted(mirrorRegistry); + + TestUtils.createRegistriesConfFile(homeDir, registriesConf); + + TestUtils.withHome(homeDir, () -> { + Registry registry = Registry.builder().insecure().defaults().build(); + // Pull by digest: digest-only mirror must be used + ContainerRef ref = ContainerRef.parse("localhost:59998/test/digest-pull-mirror:v1") + .withDigest(digest); + Manifest manifest = registry.getManifest(ref); + assertNotNull(manifest, "digest-only mirror must be used for a digest-based pull"); + }); + } + + @Test + void shouldApplyMirrorByDigestOnly(@TempDir Path blobDir) throws Exception { + + // Push artifact to the working mirror + String mirrorRegistry = mirrorUp.getRegistry(); + Registry setupRegistry = Registry.builder().insecure(mirrorRegistry).build(); + Path testFile = createTestFile(blobDir, "mbd.txt", "mirror-by-digest-only content"); + ContainerRef mirrorArtifact = ContainerRef.parse(mirrorRegistry + "/test/mbd-mirror:v1"); + setupRegistry.pushArtifact(mirrorArtifact, LocalPath.of(testFile)); + + Manifest pushed = setupRegistry.getManifest(mirrorArtifact); + assertNotNull(pushed); + String digest = pushed.getDescriptor().getDigest(); + + // language=toml + String registriesConf = + """ + [[registry]] + prefix = "localhost:59998" + location = "localhost:59998" + mirror-by-digest-only = true + + [[registry.mirror]] + location = "%s" + insecure = true + """ + .formatted(mirrorRegistry); + + TestUtils.createRegistriesConfFile(homeDir, registriesConf); + + TestUtils.withHome(homeDir, () -> { + Registry registry = Registry.builder().insecure().defaults().build(); + + // Tag pull → mirror-by-digest-only skips all mirrors → fails with original down + ContainerRef tagRef = ContainerRef.parse("localhost:59998/test/mbd-mirror:v1"); + assertThrows( + land.oras.exception.OrasException.class, + () -> registry.getManifest(tagRef), + "mirror-by-digest-only must skip mirrors for tag-based pulls"); + + // Digest pull → mirror is used + ContainerRef digestRef = + ContainerRef.parse("localhost:59998/test/mbd-mirror:v1").withDigest(digest); + Manifest manifest = registry.getManifest(digestRef); + assertNotNull(manifest, "mirror-by-digest-only must allow mirrors for digest-based pulls"); + }); + } + private Path createTestFile(Path dir, String name, String content) throws IOException { Path file = dir.resolve(name); Files.writeString(file, content); diff --git a/src/test/java/land/oras/auth/RegistryConfTest.java b/src/test/java/land/oras/auth/RegistryConfTest.java index b509b8e9..03d41e5e 100644 --- a/src/test/java/land/oras/auth/RegistryConfTest.java +++ b/src/test/java/land/oras/auth/RegistryConfTest.java @@ -56,7 +56,7 @@ void shouldCheckRegistryStatusWithLocationOnly() { // With null RegistriesConf.RegistryConfig registryConfig = - new RegistriesConf.RegistryConfig(null, "localhost:5000", null, null, null); + new RegistriesConf.RegistryConfig(null, "localhost:5000", null, null, null, null); assertEquals("localhost:5000", registryConfig.location()); assertNull(registryConfig.blocked(), "Blocked should be null when not set"); assertNull(registryConfig.insecure(), "Insecure should be null when not set"); @@ -70,7 +70,7 @@ void shouldCheckRegistryStatusWithLocationOnly() { assertDoesNotThrow(conf::enforceShortNameMode); // With blocked true - registryConfig = new RegistriesConf.RegistryConfig(null, "localhost:5000", true, null, null); + registryConfig = new RegistriesConf.RegistryConfig(null, "localhost:5000", true, null, null, null); assertTrue(registryConfig.isBlocked(), "Registry should be blocked when blocked is true"); conf = new RegistriesConf(new RegistriesConf.Config(List.of(registryConfig))); assertTrue(conf.isBlocked(ContainerRef.parse("localhost:5000/library/test:latest"))); @@ -78,7 +78,7 @@ void shouldCheckRegistryStatusWithLocationOnly() { assertDoesNotThrow(conf::enforceShortNameMode); // With insecure true - registryConfig = new RegistriesConf.RegistryConfig(null, "localhost:5000", null, true, null); + registryConfig = new RegistriesConf.RegistryConfig(null, "localhost:5000", null, true, null, null); assertTrue(registryConfig.isInsecure(), "Registry should be insecure when insecure is true"); conf = new RegistriesConf(new RegistriesConf.Config(List.of(registryConfig))); assertTrue(conf.isInsecure(registry, ContainerRef.parse("localhost:5000/library/test:latest"))); @@ -86,7 +86,7 @@ void shouldCheckRegistryStatusWithLocationOnly() { assertDoesNotThrow(conf::enforceShortNameMode); // With blocked false - registryConfig = new RegistriesConf.RegistryConfig(null, "localhost:5000", false, null, null); + registryConfig = new RegistriesConf.RegistryConfig(null, "localhost:5000", false, null, null, null); assertFalse(registryConfig.isBlocked(), "Registry should not be blocked when blocked is false"); conf = new RegistriesConf(new RegistriesConf.Config(List.of(registryConfig))); assertFalse(conf.isBlocked(ContainerRef.parse("localhost:5000/library/test:latest"))); @@ -94,7 +94,7 @@ void shouldCheckRegistryStatusWithLocationOnly() { assertDoesNotThrow(conf::enforceShortNameMode); // With insecure false - registryConfig = new RegistriesConf.RegistryConfig(null, "localhost:5000", null, false, null); + registryConfig = new RegistriesConf.RegistryConfig(null, "localhost:5000", null, false, null, null); assertFalse(registryConfig.isInsecure(), "Registry should be insecure when insecure is false"); conf = new RegistriesConf(new RegistriesConf.Config(List.of(registryConfig))); assertFalse(conf.isInsecure(registry, ContainerRef.parse("localhost:5000/library/test:latest"))); @@ -109,7 +109,7 @@ void shouldCheckRegistryStatusWithPrefixExactMatch() { // With null RegistriesConf.RegistryConfig registryConfig = - new RegistriesConf.RegistryConfig("localhost:5000", null, null, null, null); + new RegistriesConf.RegistryConfig("localhost:5000", null, null, null, null, null); assertEquals("localhost:5000", registryConfig.prefix()); assertNull(registryConfig.blocked(), "Blocked should be null when not set"); assertNull(registryConfig.insecure(), "Insecure should be null when not set"); @@ -123,28 +123,28 @@ void shouldCheckRegistryStatusWithPrefixExactMatch() { assertDoesNotThrow(conf::enforceShortNameMode); // With blocked true - registryConfig = new RegistriesConf.RegistryConfig("localhost:5000", null, true, null, null); + registryConfig = new RegistriesConf.RegistryConfig("localhost:5000", null, true, null, null, null); assertTrue(registryConfig.isBlocked(), "Registry should be blocked when blocked is true"); conf = new RegistriesConf(new RegistriesConf.Config(List.of(registryConfig))); assertTrue(conf.isBlocked(ContainerRef.parse("localhost:5000/library/test:latest"))); assertFalse(conf.isBlocked(ContainerRef.parse("localhost:5001/library/test:latest"))); // With insecure true - registryConfig = new RegistriesConf.RegistryConfig("localhost:5000", null, null, true, null); + registryConfig = new RegistriesConf.RegistryConfig("localhost:5000", null, null, true, null, null); assertTrue(registryConfig.isInsecure(), "Registry should be insecure when insecure is true"); conf = new RegistriesConf(new RegistriesConf.Config(List.of(registryConfig))); assertTrue(conf.isInsecure(registry, ContainerRef.parse("localhost:5000/library/test:latest"))); assertFalse(conf.isInsecure(registry, ContainerRef.parse("localhost:5001/library/test:latest"))); // With blocked false - registryConfig = new RegistriesConf.RegistryConfig("localhost:5000", null, false, null, null); + registryConfig = new RegistriesConf.RegistryConfig("localhost:5000", null, false, null, null, null); assertFalse(registryConfig.isBlocked(), "Registry should not be blocked when blocked is false"); conf = new RegistriesConf(new RegistriesConf.Config(List.of(registryConfig))); assertFalse(conf.isBlocked(ContainerRef.parse("localhost:5000/library/test:latest"))); assertFalse(conf.isBlocked(ContainerRef.parse("localhost:5001/library/test:latest"))); // With insecure false - registryConfig = new RegistriesConf.RegistryConfig("localhost:5000", null, null, false, null); + registryConfig = new RegistriesConf.RegistryConfig("localhost:5000", null, null, false, null, null); assertFalse(registryConfig.isInsecure(), "Registry should be secure when insecure is false"); conf = new RegistriesConf(new RegistriesConf.Config(List.of(registryConfig))); assertFalse(conf.isInsecure(registry, ContainerRef.parse("localhost:5000/library/test:latest"))); @@ -154,7 +154,7 @@ void shouldCheckRegistryStatusWithPrefixExactMatch() { @Test void checkWithHostNamePrefix() { RegistriesConf.RegistryConfig registry = - new RegistriesConf.RegistryConfig("*.example.com", null, true, null, null); + new RegistriesConf.RegistryConfig("*.example.com", null, true, null, null, null); RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); assertTrue(conf.isBlocked(ContainerRef.parse("registry.example.com/library/test:latest"))); assertTrue(conf.isBlocked(ContainerRef.parse("foobar.example.com/library/test:latest"))); @@ -167,9 +167,9 @@ void checkMultipleSettings() { Registry registry = Registry.builder().build(); RegistriesConf.RegistryConfig registry1 = - new RegistriesConf.RegistryConfig("*.internal.local", null, false, true, null); + new RegistriesConf.RegistryConfig("*.internal.local", null, false, true, null, null); RegistriesConf.RegistryConfig registry2 = - new RegistriesConf.RegistryConfig("*.internal.local/public", null, true, null, null); + new RegistriesConf.RegistryConfig("*.internal.local/public", null, true, null, null, null); RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry1, registry2))); assertTrue(conf.isInsecure(registry, ContainerRef.parse("registry.internal.local/library/test:latest"))); assertFalse(conf.isInsecure( @@ -184,7 +184,7 @@ void checkMultipleSettings() { void shouldRewriteContainerRef() { // Just the domain RegistriesConf.RegistryConfig registry = - new RegistriesConf.RegistryConfig("localhost:5000", "registry.example.com", null, null, null); + new RegistriesConf.RegistryConfig("localhost:5000", "registry.example.com", null, null, null, null); RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); ContainerRef originalRef = ContainerRef.parse("localhost:5000/library/test:latest"); ContainerRef rewrittenRef = conf.rewrite(originalRef); @@ -194,7 +194,7 @@ void shouldRewriteContainerRef() { // prefix = "example.com/foo" // location = "internal-registry-for-example.com/bar" registry = new RegistriesConf.RegistryConfig( - "example.com/foo", "internal-registry-for-example.com/bar", null, null, null); + "example.com/foo", "internal-registry-for-example.com/bar", null, null, null, null); conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); originalRef = ContainerRef.parse("example.com/foo/library/test:latest"); rewrittenRef = conf.rewrite(originalRef); @@ -204,8 +204,8 @@ void shouldRewriteContainerRef() { @Test void shouldRewriteUnqualifiedContainerRef() { - RegistriesConf.RegistryConfig registry = - new RegistriesConf.RegistryConfig("docker.io", "internal-registry-for-example.com", null, null, null); + RegistriesConf.RegistryConfig registry = new RegistriesConf.RegistryConfig( + "docker.io", "internal-registry-for-example.com", null, null, null, null); RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); // Without tag @@ -252,7 +252,7 @@ void shouldParseMirrorsFromToml() { @Test void shouldReturnEmptyMirrorsWhenNoneConfigured() { RegistriesConf.RegistryConfig registry = - new RegistriesConf.RegistryConfig("docker.io", "docker.io", null, null, null); + new RegistriesConf.RegistryConfig("docker.io", "docker.io", null, null, null, null); RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); ContainerRef ref = ContainerRef.parse("docker.io/library/alpine:latest"); @@ -266,7 +266,8 @@ void shouldReturnEmptyMirrorsWhenNoMatchingRegistry() { "other-registry.example.com", null, null, - List.of(new RegistriesConf.MirrorConfig("mirror.example.com", false))); + null, + List.of(new RegistriesConf.MirrorConfig("mirror.example.com", false, null))); RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); ContainerRef ref = ContainerRef.parse("docker.io/library/alpine:latest"); @@ -282,19 +283,19 @@ void shouldDefaultToSecureWhenInsecureNotSpecified() { // Secure by default RegistriesConf.RegistryConfig noInsecureField = - new RegistriesConf.RegistryConfig("localhost:5000", null, null, null, null); + new RegistriesConf.RegistryConfig("localhost:5000", null, null, null, null, null); RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of(noInsecureField))); assertFalse(conf.isInsecure(registry, ref), "No insecure field means secure by default"); // Secure RegistriesConf.RegistryConfig explicitFalse = - new RegistriesConf.RegistryConfig("localhost:5000", null, null, false, null); + new RegistriesConf.RegistryConfig("localhost:5000", null, null, false, null, null); conf = new RegistriesConf(new RegistriesConf.Config(List.of(explicitFalse))); assertFalse(conf.isInsecure(registry, ref), "Registry must be secure by default"); // Insecure RegistriesConf.RegistryConfig explicitTrue = - new RegistriesConf.RegistryConfig("localhost:5000", null, null, true, null); + new RegistriesConf.RegistryConfig("localhost:5000", null, null, true, null, null); conf = new RegistriesConf(new RegistriesConf.Config(List.of(explicitTrue))); assertTrue(conf.isInsecure(registry, ref), "Registry is insecure"); @@ -305,7 +306,7 @@ void shouldDefaultToSecureWhenInsecureNotSpecified() { @Test void shouldRewriteForMirror() { - RegistriesConf.MirrorConfig mirror = new RegistriesConf.MirrorConfig("mirror.example.com", false); + RegistriesConf.MirrorConfig mirror = new RegistriesConf.MirrorConfig("mirror.example.com", false, null); RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of())); // Qualified ref @@ -347,19 +348,174 @@ void shouldRewriteForMirrorWithTrailingSlash() { ContainerRef original = ContainerRef.parse("docker.io/library/alpine:latest"); // Single trailing slash - RegistriesConf.MirrorConfig trailingSlash = new RegistriesConf.MirrorConfig("mirror.example.com/", false); + RegistriesConf.MirrorConfig trailingSlash = new RegistriesConf.MirrorConfig("mirror.example.com/", false, null); assertEquals( "mirror.example.com/library/alpine:latest", conf.rewriteForMirror(original, trailingSlash).toString()); // Multiple trailing slashes RegistriesConf.MirrorConfig multiTrailingSlash = - new RegistriesConf.MirrorConfig("mirror.example.com/prefix//", false); + new RegistriesConf.MirrorConfig("mirror.example.com/prefix//", false, null); assertEquals( "mirror.example.com/prefix/library/alpine:latest", conf.rewriteForMirror(original, multiTrailingSlash).toString()); } + @Test + void shouldParsePullFromMirrorFromToml() { + // language=toml + String toml = + """ + [[registry]] + prefix = "docker.io" + location = "docker.io" + + [[registry.mirror]] + location = "mirror-digest.example.com" + pull-from-mirror = "digest-only" + + [[registry.mirror]] + location = "mirror-tag.example.com" + pull-from-mirror = "tag-only" + + [[registry.mirror]] + location = "mirror-all.example.com" + pull-from-mirror = "all" + + [[registry.mirror]] + location = "mirror-default.example.com" + """; + + RegistriesConf conf = RegistriesConf.newConf(List.of(writeTempToml(toml))); + ContainerRef ref = ContainerRef.parse("docker.io/library/alpine:latest"); + List mirrors = conf.getMirrors(ref); + + assertEquals(4, mirrors.size()); + assertEquals(RegistriesConf.PullFromMirror.DIGEST_ONLY, mirrors.get(0).effectivePullFromMirror()); + assertEquals(RegistriesConf.PullFromMirror.TAG_ONLY, mirrors.get(1).effectivePullFromMirror()); + assertEquals(RegistriesConf.PullFromMirror.ALL, mirrors.get(2).effectivePullFromMirror()); + assertEquals(RegistriesConf.PullFromMirror.ALL, mirrors.get(3).effectivePullFromMirror()); + } + + @Test + void shouldFilterMirrorsByPullFromMirrorForTagRef() { + // language=toml + String toml = + """ + [[registry]] + prefix = "docker.io" + location = "docker.io" + + [[registry.mirror]] + location = "mirror-digest.example.com" + pull-from-mirror = "digest-only" + + [[registry.mirror]] + location = "mirror-tag.example.com" + pull-from-mirror = "tag-only" + + [[registry.mirror]] + location = "mirror-all.example.com" + """; + + RegistriesConf conf = RegistriesConf.newConf(List.of(writeTempToml(toml))); + + // Tag-only ref: digest-only mirror is excluded + ContainerRef tagRef = ContainerRef.parse("docker.io/library/alpine:latest"); + assertNull(tagRef.getDigest()); + List applicableForTag = conf.getApplicableMirrors(tagRef); + assertEquals(2, applicableForTag.size()); + assertEquals("mirror-tag.example.com", applicableForTag.get(0).location()); + assertEquals("mirror-all.example.com", applicableForTag.get(1).location()); + } + + @Test + void shouldFilterMirrorsByPullFromMirrorForDigestRef() { + // language=toml + String toml = + """ + [[registry]] + prefix = "docker.io" + location = "docker.io" + + [[registry.mirror]] + location = "mirror-digest.example.com" + pull-from-mirror = "digest-only" + + [[registry.mirror]] + location = "mirror-tag.example.com" + pull-from-mirror = "tag-only" + + [[registry.mirror]] + location = "mirror-all.example.com" + """; + + RegistriesConf conf = RegistriesConf.newConf(List.of(writeTempToml(toml))); + + // Digest-only ref: tag-only mirror is excluded + ContainerRef digestRef = ContainerRef.parse("docker.io/library/alpine:latest") + .withDigest("sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"); + assertNull(digestRef.getTag()); + List applicableForDigest = conf.getApplicableMirrors(digestRef); + assertEquals(2, applicableForDigest.size()); + assertEquals("mirror-digest.example.com", applicableForDigest.get(0).location()); + assertEquals("mirror-all.example.com", applicableForDigest.get(1).location()); + } + + @Test + void shouldApplyMirrorByDigestOnly() { + // language=toml + String toml = + """ + [[registry]] + prefix = "docker.io" + location = "docker.io" + mirror-by-digest-only = true + + [[registry.mirror]] + location = "mirror-a.example.com" + + [[registry.mirror]] + location = "mirror-b.example.com" + pull-from-mirror = "tag-only" + """; + + RegistriesConf conf = RegistriesConf.newConf(List.of(writeTempToml(toml))); + + // Tag ref → no mirrors (mirror-by-digest-only overrides per-mirror settings) + ContainerRef tagRef = ContainerRef.parse("docker.io/library/alpine:latest"); + assertTrue( + conf.getApplicableMirrors(tagRef).isEmpty(), + "mirror-by-digest-only must block tag-only pulls from all mirrors"); + + // Digest ref → both mirrors are applicable + ContainerRef digestRef = ContainerRef.parse("docker.io/library/alpine:latest") + .withDigest("sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"); + List applicable = conf.getApplicableMirrors(digestRef); + assertEquals(2, applicable.size()); + } + + @Test + void shouldReturnAllMirrorsWhenNoFilterConfigured() { + // language=toml + String toml = + """ + [[registry]] + prefix = "docker.io" + location = "docker.io" + + [[registry.mirror]] + location = "mirror-a.example.com" + + [[registry.mirror]] + location = "mirror-b.example.com" + """; + + RegistriesConf conf = RegistriesConf.newConf(List.of(writeTempToml(toml))); + ContainerRef ref = ContainerRef.parse("docker.io/library/alpine:latest"); + assertEquals(2, conf.getApplicableMirrors(ref).size()); + } + private Path writeTempToml(String content) { try { Path temp = Files.createTempFile("registries", ".conf");