Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/main/java/land/oras/Registry.java
Original file line number Diff line number Diff line change
Expand Up @@ -1218,7 +1218,7 @@ boolean exists(ContainerRef containerRef) {
* @return The result from the first successful invocation
*/
private <T> T withMirrorFallback(ContainerRef containerRef, BiFunction<Registry, ContainerRef, T> operation) {
List<RegistriesConf.MirrorConfig> mirrors = registriesConf.getMirrors(containerRef);
List<RegistriesConf.MirrorConfig> mirrors = registriesConf.getApplicableMirrors(containerRef);
for (RegistriesConf.MirrorConfig mirror : mirrors) {
String mirrorLocation = mirror.location();
if (mirrorLocation == null || mirrorLocation.isBlank()) continue;
Expand Down
102 changes: 101 additions & 1 deletion src/main/java/land/oras/auth/RegistriesConf.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -104,17 +105,28 @@ 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
*/
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;
}
}

/**
Expand 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
Expand All @@ -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<MirrorConfig> mirrors) {
/**
* Return true if this registry is blocked and cannot be used for pulling or pushing images.
Expand All @@ -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;
}
}

/**
Expand Down Expand Up @@ -205,6 +227,55 @@ public String getKey() {
private String value;
}

/**
* Controls which pull operations may use a mirror.
* <ul>
* <li>{@link #ALL} – the mirror is used for all pull operations (default)</li>
* <li>{@link #DIGEST_ONLY} – mirror is only used when the reference includes a digest</li>
* <li>{@link #TAG_ONLY} – mirror is only used when the reference includes a tag</li>
* </ul>
*/
@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.
Expand Down Expand Up @@ -377,6 +448,35 @@ public List<MirrorConfig> 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<MirrorConfig> getApplicableMirrors(ContainerRef ref) {
Optional<RegistryConfig> 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.
Expand Down
4 changes: 2 additions & 2 deletions src/test/java/land/oras/ClassAnnotationsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -83,7 +83,7 @@ void shouldHaveAnnotationOnAuthPackage() {
.loadClasses());

// Check number of classes
assertEquals(9, modelClasses.size());
assertEquals(10, modelClasses.size());
}
}
}
128 changes: 128 additions & 0 deletions src/test/java/land/oras/RegistryMirrorTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading