Skip to content

Commit 2d98253

Browse files
committed
Add support for registries mirror
Signed-off-by: Valentin Delaye <jonesbusy@users.noreply.github.com>
1 parent df87bc6 commit 2d98253

5 files changed

Lines changed: 542 additions & 25 deletions

File tree

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

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import java.util.concurrent.CompletableFuture;
4040
import java.util.concurrent.ExecutorService;
4141
import java.util.concurrent.Executors;
42+
import java.util.function.BiFunction;
4243
import java.util.function.Supplier;
4344
import land.oras.auth.AuthProvider;
4445
import land.oras.auth.AuthStoreAuthenticationProvider;
@@ -526,9 +527,16 @@ public void deleteBlob(ContainerRef containerRef) {
526527

527528
@Override
528529
public void pullArtifact(ContainerRef containerRef, Path path, boolean overwrite) {
530+
withMirrorFallback(containerRef, (reg, ref) -> {
531+
reg.pullArtifactDirect(ref, path, overwrite);
532+
return null;
533+
});
534+
}
535+
536+
private void pullArtifactDirect(ContainerRef containerRef, Path path, boolean overwrite) {
529537
ContainerRef ref = containerRef.forRegistry(this).checkBlocked(this);
530538
if (ref.isInsecure(this) && !this.isInsecure()) {
531-
asInsecure().pullArtifact(containerRef, path, overwrite);
539+
asInsecure().pullArtifactDirect(containerRef, path, overwrite);
532540
return;
533541
}
534542
// Only collect layer that are files
@@ -945,9 +953,13 @@ private HttpClient.ResponseWrapper<String> headBlob(ContainerRef containerRef) {
945953
*/
946954
@Override
947955
public byte[] getBlob(ContainerRef containerRef) {
956+
return withMirrorFallback(containerRef, (reg, ref) -> reg.getBlobDirect(ref));
957+
}
958+
959+
private byte[] getBlobDirect(ContainerRef containerRef) {
948960
ContainerRef ref = containerRef.forRegistry(this).checkBlocked(this);
949961
if (ref.isInsecure(this) && !this.isInsecure()) {
950-
return asInsecure().getBlob(containerRef);
962+
return asInsecure().getBlobDirect(containerRef);
951963
}
952964
URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getBlobsPath(this)));
953965
HttpClient.ResponseWrapper<String> response = client.get(
@@ -964,9 +976,16 @@ public byte[] getBlob(ContainerRef containerRef) {
964976

965977
@Override
966978
public void fetchBlob(ContainerRef containerRef, Path path) {
979+
withMirrorFallback(containerRef, (reg, ref) -> {
980+
reg.fetchBlobDirect(ref, path);
981+
return null;
982+
});
983+
}
984+
985+
private void fetchBlobDirect(ContainerRef containerRef, Path path) {
967986
ContainerRef ref = containerRef.forRegistry(this).checkBlocked(this);
968987
if (ref.isInsecure(this) && !this.isInsecure()) {
969-
asInsecure().fetchBlob(containerRef, path);
988+
asInsecure().fetchBlobDirect(containerRef, path);
970989
return;
971990
}
972991
URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getBlobsPath(this)));
@@ -983,9 +1002,13 @@ public void fetchBlob(ContainerRef containerRef, Path path) {
9831002

9841003
@Override
9851004
public InputStream fetchBlob(ContainerRef containerRef) {
1005+
return withMirrorFallback(containerRef, (reg, ref) -> reg.fetchBlobDirect(ref));
1006+
}
1007+
1008+
private InputStream fetchBlobDirect(ContainerRef containerRef) {
9861009
ContainerRef ref = containerRef.forRegistry(this).checkBlocked(this);
9871010
if (ref.isInsecure(this) && !this.isInsecure()) {
988-
return asInsecure().fetchBlob(containerRef);
1011+
return asInsecure().fetchBlobDirect(containerRef);
9891012
}
9901013
URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getBlobsPath(this)));
9911014
HttpClient.ResponseWrapper<InputStream> response = client.download(
@@ -1106,14 +1129,54 @@ boolean exists(ContainerRef containerRef) {
11061129
}
11071130

11081131
/**
1109-
* Get a manifest response
1132+
* Execute an operation with mirror fallback. Mirrors are tried in order; if all fail the
1133+
* operation is retried against the original registry.
1134+
* @param containerRef The original container reference used to look up mirrors
1135+
* @param operation A function (registry, ref) → result; called for each candidate
1136+
* @return The result from the first successful invocation
1137+
*/
1138+
private <T> T withMirrorFallback(ContainerRef containerRef, BiFunction<Registry, ContainerRef, T> operation) {
1139+
List<RegistriesConf.MirrorConfig> mirrors = registriesConf.getMirrors(containerRef);
1140+
for (RegistriesConf.MirrorConfig mirror : mirrors) {
1141+
String mirrorLocation = mirror.location();
1142+
if (mirrorLocation == null || mirrorLocation.isBlank()) continue;
1143+
ContainerRef mirrorRef = registriesConf.rewriteForMirror(containerRef, mirror);
1144+
// Use only the host[:port] for copy() — the path prefix is already baked into mirrorRef
1145+
// by rewriteForMirror, so including it here would double-apply the path.
1146+
// Strip any scheme (e.g., "https://") before extracting the host.
1147+
String locationNoScheme = mirrorLocation.contains("://")
1148+
? mirrorLocation.substring(mirrorLocation.indexOf("://") + 3)
1149+
: mirrorLocation;
1150+
String mirrorHost = locationNoScheme.contains("/")
1151+
? locationNoScheme.substring(0, locationNoScheme.indexOf('/'))
1152+
: locationNoScheme;
1153+
Registry mirrorRegistry = copy(mirrorHost);
1154+
if (mirror.isInsecure() && !isInsecure()) {
1155+
mirrorRegistry = mirrorRegistry.asInsecure();
1156+
}
1157+
try {
1158+
LOG.debug("Trying mirror {} for {}", mirrorLocation, containerRef);
1159+
return operation.apply(mirrorRegistry, mirrorRef);
1160+
} catch (OrasException e) {
1161+
LOG.warn("Mirror {} failed for {}: {}", mirrorLocation, containerRef, e.getMessage());
1162+
}
1163+
}
1164+
return operation.apply(this, containerRef);
1165+
}
1166+
1167+
/**
1168+
* Get a manifest response, trying configured mirrors before the original registry.
11101169
* @param containerRef The container
11111170
* @return The response
11121171
*/
11131172
private HttpClient.ResponseWrapper<String> getManifestResponse(ContainerRef containerRef) {
1173+
return withMirrorFallback(containerRef, (reg, ref) -> reg.getManifestResponseDirect(ref));
1174+
}
1175+
1176+
private HttpClient.ResponseWrapper<String> getManifestResponseDirect(ContainerRef containerRef) {
11141177
ContainerRef ref = containerRef.forRegistry(this).checkBlocked(this);
11151178
if (ref.isInsecure(this) && !this.isInsecure()) {
1116-
return asInsecure().getManifestResponse(containerRef);
1179+
return asInsecure().getManifestResponseDirect(containerRef);
11171180
}
11181181
URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getManifestsPath(this)));
11191182
HttpClient.ResponseWrapper<String> response =

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

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,23 +99,50 @@ public static RegistriesConf newConf() {
9999
return newConf(paths);
100100
}
101101

102+
/**
103+
* The model of a mirror entry within a [[registry]] table.
104+
* @param location The mirror registry location (host[:port][/path]).
105+
* @param insecure Whether the mirror is insecure.
106+
*/
107+
@OrasModel
108+
public record MirrorConfig(
109+
@Nullable @JsonProperty("location") String location, @Nullable @JsonProperty("insecure") Boolean insecure) {
110+
/**
111+
* Return true if this mirror should be accessed over plain HTTP or with unverified TLS.
112+
* @return true if insecure
113+
*/
114+
public boolean isInsecure() {
115+
return insecure != null && insecure;
116+
}
117+
}
118+
102119
/**
103120
* The model of the registry configuration
104121
* @param prefix The prefix to match against container references.
105122
* @param location The registry location
106123
* @param blocked Whether the registry is blocked. If true, the registry is blocked and cannot be used for pulling or pushing images.
107124
* @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.
125+
* @param mirrors Ordered list of mirror entries to try before the registry location.
108126
*/
109127
@OrasModel
110128
record RegistryConfig(
111129
@Nullable @JsonProperty("prefix") String prefix,
112130
@Nullable @JsonProperty("location") String location,
113131
@Nullable @JsonProperty("blocked") Boolean blocked,
114-
@Nullable @JsonProperty("insecure") Boolean insecure) {
132+
@Nullable @JsonProperty("insecure") Boolean insecure,
133+
@Nullable @JsonProperty("mirror") List<MirrorConfig> mirrors) {
134+
/**
135+
* Return true if this registry is blocked and cannot be used for pulling or pushing images.
136+
* @return true if blocked
137+
*/
115138
public boolean isBlocked() {
116139
return blocked != null && blocked;
117140
}
118141

142+
/**
143+
* Return true if this registry should be accessed over plain HTTP or with unverified TLS.
144+
* @return true if insecure
145+
*/
119146
public boolean isInsecure() {
120147
return insecure != null && insecure;
121148
}
@@ -333,6 +360,56 @@ public ContainerRef rewrite(ContainerRef ref) {
333360
return ContainerRef.parse(rewrittenRefString);
334361
}
335362

363+
/**
364+
* Return the ordered list of mirrors configured for the registry that matches the given reference.
365+
* @param ref the container reference to look up mirrors for.
366+
* @return an unmodifiable list of mirror configs (may be empty).
367+
*/
368+
public List<MirrorConfig> getMirrors(ContainerRef ref) {
369+
Optional<RegistryConfig> matchingConfig = selectMatchingTable(ref);
370+
if (matchingConfig.isEmpty() || matchingConfig.get().mirrors() == null) {
371+
return Collections.emptyList();
372+
}
373+
return Collections.unmodifiableList(matchingConfig.get().mirrors());
374+
}
375+
376+
/**
377+
* Rewrite the given container reference to use the mirror's location, replacing the registry host.
378+
* @param ref the original container reference.
379+
* @param mirror the mirror configuration to apply.
380+
* @return the rewritten reference pointing at the mirror.
381+
*/
382+
public ContainerRef rewriteForMirror(ContainerRef ref, MirrorConfig mirror) {
383+
String mirrorLocation = mirror.location();
384+
if (mirrorLocation == null || mirrorLocation.isBlank()) {
385+
return ref;
386+
}
387+
if (!ref.isUnqualified() && ref.getRegistry() == null) {
388+
return ref;
389+
}
390+
// Always build from components to correctly handle:
391+
// - unqualified refs (toString() omits the registry)
392+
// - digest-only refs (tag is null, toString() would produce ":null")
393+
String namespace = ref.getNamespace();
394+
String repository = ref.getRepository();
395+
String tag = ref.getTag();
396+
String digest = ref.getDigest();
397+
StringBuilder sb = new StringBuilder(mirrorLocation);
398+
if (namespace != null && !namespace.isEmpty()) {
399+
sb.append("/").append(namespace);
400+
}
401+
sb.append("/").append(repository);
402+
if (tag != null && !tag.isEmpty()) {
403+
sb.append(":").append(tag);
404+
}
405+
if (digest != null && !digest.isEmpty()) {
406+
sb.append("@").append(digest);
407+
}
408+
String rewrittenRefString = sb.toString();
409+
LOG.debug("Rewriting '{}' to mirror '{}'", ref, rewrittenRefString);
410+
return ContainerRef.parse(rewrittenRefString);
411+
}
412+
336413
/**
337414
* Select the matching registry configuration table for the container reference.
338415
* @param ref the container reference to find the matching registry configuration for.

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(26, modelClasses.size());
52+
assertEquals(27, 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(8, modelClasses.size());
86+
assertEquals(9, modelClasses.size());
8787
}
8888
}
8989
}

0 commit comments

Comments
 (0)