Skip to content

Commit 2a70b02

Browse files
Copilotjonesbusy
andcommitted
feat: add blob mounting support for Registry and OCILayout
- Add getBlobsMountPath() to ContainerRef for mount URL construction - Add abstract mountBlob() method to OCI base class - Implement mountBlob() in Registry using POST /v2/<name>/blobs/uploads/?mount=<digest>&from=<other_name> - Implement mountBlob() in OCILayout using direct file copy - Update CopyUtils to attempt mounting when copying between same-type OCI on same registry - Add tests for mountBlob in RegistryTest and OCILayoutTest Co-authored-by: jonesbusy <825750+jonesbusy@users.noreply.github.com>
1 parent f5bfb39 commit 2a70b02

8 files changed

Lines changed: 274 additions & 839 deletions

File tree

REPOSITORY_ANALYSIS.md

Lines changed: 0 additions & 829 deletions
This file was deleted.

src/main/java/land/oras/ContainerRef.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,20 @@ public String getBlobsUploadPath(Registry registry) {
377377
return "%s/blobs/uploads/".formatted(getApiPrefix(registry));
378378
}
379379

380+
/**
381+
* Return the blobs mount URL for cross-repository blob mounting
382+
* @param registry The registry
383+
* @param sourceRepository The source repository to mount the blob from
384+
* @return The blobs mount URL
385+
*/
386+
public String getBlobsMountPath(Registry registry, String sourceRepository) {
387+
if (digest == null) {
388+
throw new OrasException("You are required to include a digest");
389+
}
390+
return "%s/blobs/uploads/?mount=%s&from=%s".formatted(
391+
getApiPrefix(registry), digest, URLEncoder.encode(sourceRepository, StandardCharsets.UTF_8));
392+
}
393+
380394
/**
381395
* Return the blobs URL
382396
* @param registry The registry

src/main/java/land/oras/CopyUtils.java

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,16 @@ void copyLayers(
113113
Objects.requireNonNull(layer.getDigest(), "Layer digest is required for streaming copy");
114114
Objects.requireNonNull(layer.getSize(), "Layer size is required for streaming copy");
115115
return CompletableFuture.runAsync(
116-
() -> target.pushBlob(
117-
targetRef.withDigest(layer.getDigest()),
118-
layer.getSize(),
119-
() -> source.fetchBlob(sourceRef.withDigest(layer.getDigest())),
120-
layer.getAnnotations()),
116+
() -> {
117+
if (!tryMountBlob(source, sourceRef, target, targetRef, layer.getDigest())) {
118+
target.pushBlob(
119+
targetRef.withDigest(layer.getDigest()),
120+
layer.getSize(),
121+
() -> source.fetchBlob(
122+
sourceRef.withDigest(layer.getDigest())),
123+
layer.getAnnotations());
124+
}
125+
},
121126
source.getExecutorService());
122127
})
123128
.toArray(CompletableFuture[]::new))
@@ -267,11 +272,64 @@ void copyConfig(
267272
Config config = manifest.getConfig();
268273
Objects.requireNonNull(config.getDigest(), "Config digest is required for streaming copy");
269274
Objects.requireNonNull(config.getSize(), "Config size is required for streaming copy");
270-
target.pushBlob(
271-
targetRef.forTarget(target).withDigest(manifest.getConfig().getDigest()),
272-
config.getSize(),
273-
() -> source.pullConfig(sourceRef, manifest.getConfig()),
274-
config.getAnnotations());
275+
TargetRefType configTargetRef =
276+
targetRef.forTarget(target).withDigest(manifest.getConfig().getDigest());
277+
if (!tryMountBlob(source, sourceRef, target, targetRef, config.getDigest())) {
278+
target.pushBlob(
279+
configTargetRef,
280+
config.getSize(),
281+
() -> source.pullConfig(sourceRef, manifest.getConfig()),
282+
config.getAnnotations());
283+
}
275284
LOG.debug("Copied config {}", manifest.getConfig().getDigest());
276285
}
286+
287+
/**
288+
* Attempt to mount a blob from source to target without downloading and re-uploading.
289+
* Mounting is only attempted when source and target are the same OCI type and,
290+
* for registries, when they share the same registry host.
291+
* @param source The source OCI
292+
* @param sourceRef The source reference
293+
* @param target The target OCI
294+
* @param targetRef The target reference
295+
* @param digest The digest of the blob to mount
296+
* @param <SourceRefType> The source reference type
297+
* @param <TargetRefType> The target reference type
298+
* @return {@code true} if the blob was successfully mounted, {@code false} if a regular upload is required
299+
*/
300+
@SuppressWarnings("unchecked")
301+
private static <
302+
SourceRefType extends Ref<@NonNull SourceRefType>,
303+
TargetRefType extends Ref<@NonNull TargetRefType>>
304+
boolean tryMountBlob(
305+
OCI<SourceRefType> source,
306+
SourceRefType sourceRef,
307+
OCI<TargetRefType> target,
308+
TargetRefType targetRef,
309+
String digest) {
310+
// Registry-to-Registry mounting: only when pointing at the same registry host
311+
if (source instanceof Registry sourceRegistry && target instanceof Registry targetRegistry) {
312+
ContainerRef srcRef = (ContainerRef) sourceRef;
313+
ContainerRef tgtRef = (ContainerRef) targetRef;
314+
String sourceApiRegistry = srcRef.getApiRegistry(sourceRegistry);
315+
String targetApiRegistry = tgtRef.getApiRegistry(targetRegistry);
316+
if (sourceApiRegistry.equals(targetApiRegistry)) {
317+
ContainerRef layerSrcRef = srcRef.withDigest(digest);
318+
ContainerRef layerTgtRef = tgtRef.withDigest(digest);
319+
LOG.debug("Attempting mount of {} from {} to {}", digest, srcRef.getFullRepository(),
320+
tgtRef.getFullRepository());
321+
return targetRegistry.mountBlob(layerTgtRef, layerSrcRef);
322+
}
323+
}
324+
// OCILayout-to-OCILayout mounting: direct file copy between layouts
325+
if (source instanceof OCILayout && target instanceof OCILayout targetLayout) {
326+
LayoutRef srcRef = (LayoutRef) sourceRef;
327+
LayoutRef tgtRef = (LayoutRef) targetRef;
328+
LayoutRef layerSrcRef = srcRef.withDigest(digest);
329+
LayoutRef layerTgtRef = tgtRef.withDigest(digest);
330+
LOG.debug("Attempting mount of {} from {} to {}", digest, srcRef.getFolder(), tgtRef.getFolder());
331+
return targetLayout.mountBlob(layerTgtRef, layerSrcRef);
332+
}
333+
return false;
334+
}
277335
}

src/main/java/land/oras/OCI.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,18 @@ public abstract Manifest pushArtifact(
388388
*/
389389
public abstract Layer pushBlob(T ref, byte[] data);
390390

391+
/**
392+
* Mount a blob from another repository in the same OCI target.
393+
* For a {@link Registry}, this uses the cross-repository blob mounting endpoint
394+
* ({@code POST /v2/<name>/blobs/uploads/?mount=<digest>&from=<other_name>}).
395+
* For an {@link OCILayout}, this copies the blob from the source layout folder.
396+
* @param targetRef The target reference containing the digest to mount
397+
* @param sourceRef The source reference containing the source repository or layout path
398+
* @return {@code true} if the blob was successfully mounted without a full upload,
399+
* {@code false} if mounting failed and a regular upload is required
400+
*/
401+
public abstract boolean mountBlob(T targetRef, T sourceRef);
402+
391403
/**
392404
* Get the referrers of a container
393405
* @param ref The ref

src/main/java/land/oras/OCILayout.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,37 @@ public Layer pushBlob(LayoutRef ref, byte[] data) {
331331
}
332332
}
333333

334+
@Override
335+
public boolean mountBlob(LayoutRef targetRef, LayoutRef sourceRef) {
336+
String digest = targetRef.getTag();
337+
if (digest == null || !SupportedAlgorithm.isSupported(digest)) {
338+
throw new OrasException("Digest is required to mount blob");
339+
}
340+
ensureAlgorithmPath(digest);
341+
Path targetBlobPath = getBlobPath(targetRef);
342+
if (Files.exists(targetBlobPath)) {
343+
LOG.info("Blob already exists: {}", digest);
344+
return true;
345+
}
346+
// Compute source blob path from the source layout folder
347+
SupportedAlgorithm algorithm = SupportedAlgorithm.fromDigest(digest);
348+
Path sourceBlobPath = sourceRef.getFolder()
349+
.resolve(Const.OCI_LAYOUT_BLOBS)
350+
.resolve(algorithm.getPrefix())
351+
.resolve(SupportedAlgorithm.getDigest(digest));
352+
if (!Files.exists(sourceBlobPath)) {
353+
LOG.info("Source blob not found at {}, upload required", sourceBlobPath);
354+
return false;
355+
}
356+
try {
357+
Files.copy(sourceBlobPath, targetBlobPath);
358+
LOG.info("Blob mounted from {}: {}", sourceRef.getFolder(), digest);
359+
return true;
360+
} catch (IOException e) {
361+
throw new OrasException("Failed to mount blob", e);
362+
}
363+
}
364+
334365
@Override
335366
public Tags getTags(LayoutRef ref) {
336367
Index index = Index.fromPath(getIndexPath());

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,42 @@ public Layer pushBlob(ContainerRef containerRef, byte[] data) {
656656
return Layer.fromData(ref, data);
657657
}
658658

659+
@Override
660+
public boolean mountBlob(ContainerRef targetRef, ContainerRef sourceRef) {
661+
String digest = targetRef.getDigest();
662+
if (digest == null) {
663+
throw new OrasException("Digest is required to mount blob");
664+
}
665+
ContainerRef ref = targetRef.forRegistry(this).checkBlocked(this);
666+
if (ref.isInsecure(this) && !this.isInsecure()) {
667+
return asInsecure().mountBlob(targetRef, sourceRef);
668+
}
669+
if (hasBlob(ref)) {
670+
LOG.info("Blob already exists: {}", digest);
671+
return true;
672+
}
673+
String sourceRepository = sourceRef.getFullRepository();
674+
URI uri = URI.create(
675+
"%s://%s".formatted(getScheme(), ref.getBlobsMountPath(this, sourceRepository)));
676+
HttpClient.ResponseWrapper<String> response = client.post(
677+
uri,
678+
new byte[0],
679+
Map.of(Const.CONTENT_TYPE_HEADER, Const.APPLICATION_OCTET_STREAM_HEADER_VALUE),
680+
Scopes.of(ref),
681+
authProvider);
682+
logResponse(response);
683+
if (response.statusCode() == 201) {
684+
LOG.info("Blob mounted successfully from {}: {}", sourceRepository, digest);
685+
return true;
686+
}
687+
if (response.statusCode() == 202) {
688+
LOG.info("Mount not possible for blob {} from {}, upload required", digest, sourceRepository);
689+
return false;
690+
}
691+
handleError(response);
692+
return false;
693+
}
694+
659695
/**
660696
* Return if the registry contains already the blob
661697
* @param containerRef The container

src/test/java/land/oras/OCILayoutTest.java

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,4 +1119,70 @@ private void assertBlobContent(Path ociLayoutPath, String digest, String content
11191119
.resolve(SupportedAlgorithm.getDigest(digest))),
11201120
"Expect blob content to match");
11211121
}
1122+
1123+
@Test
1124+
void shouldMountBlobFromAnotherLayout() throws IOException {
1125+
1126+
Path sourcePath = layoutPath.resolve("shouldMountBlobSource");
1127+
Path targetPath = layoutPath.resolve("shouldMountBlobTarget");
1128+
1129+
byte[] content = "mount-test-content".getBytes(StandardCharsets.UTF_8);
1130+
String digest = SupportedAlgorithm.SHA256.digest(content);
1131+
1132+
// Push blob to source layout
1133+
OCILayout sourceLayout = OCILayout.Builder.builder().defaults(sourcePath).build();
1134+
LayoutRef sourceRef = LayoutRef.of(sourceLayout, digest);
1135+
sourceLayout.pushBlob(sourceRef, content);
1136+
assertBlobExists(sourcePath, digest);
1137+
1138+
// Mount blob into target layout
1139+
OCILayout targetLayout = OCILayout.Builder.builder().defaults(targetPath).build();
1140+
LayoutRef targetRef = LayoutRef.of(targetLayout, digest);
1141+
boolean mounted = targetLayout.mountBlob(targetRef, sourceRef);
1142+
1143+
// Assert mount succeeded
1144+
assertTrue(mounted, "Blob should be mounted successfully");
1145+
assertBlobExists(targetPath, digest);
1146+
assertBlobContent(targetPath, digest, "mount-test-content");
1147+
}
1148+
1149+
@Test
1150+
void shouldMountBlobReturnTrueIfAlreadyExists() {
1151+
1152+
Path layoutPathDir = layoutPath.resolve("shouldMountBlobExisting");
1153+
1154+
byte[] content = "existing-blob".getBytes(StandardCharsets.UTF_8);
1155+
String digest = SupportedAlgorithm.SHA256.digest(content);
1156+
1157+
OCILayout ociLayout = OCILayout.Builder.builder().defaults(layoutPathDir).build();
1158+
LayoutRef ref = LayoutRef.of(ociLayout, digest);
1159+
1160+
// Push blob first
1161+
ociLayout.pushBlob(ref, content);
1162+
assertBlobExists(layoutPathDir, digest);
1163+
1164+
// Mounting again should return true (already exists)
1165+
boolean mounted = ociLayout.mountBlob(ref, ref);
1166+
assertTrue(mounted, "Mount should return true when blob already exists");
1167+
}
1168+
1169+
@Test
1170+
void shouldMountBlobReturnFalseWhenSourceNotFound() {
1171+
1172+
Path sourcePath = layoutPath.resolve("mountMissingSource");
1173+
Path targetPath = layoutPath.resolve("mountMissingTarget");
1174+
1175+
String digest = "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
1176+
1177+
OCILayout sourceLayout = OCILayout.Builder.builder().defaults(sourcePath).build();
1178+
OCILayout targetLayout = OCILayout.Builder.builder().defaults(targetPath).build();
1179+
1180+
LayoutRef sourceRef = LayoutRef.of(sourceLayout, digest);
1181+
LayoutRef targetRef = LayoutRef.of(targetLayout, digest);
1182+
1183+
// Mounting a non-existent blob should return false
1184+
boolean mounted = targetLayout.mountBlob(targetRef, sourceRef);
1185+
assertFalse(mounted, "Mount should return false when source blob does not exist");
1186+
assertBlobAbsent(targetPath, digest);
1187+
}
11221188
}

src/test/java/land/oras/RegistryTest.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1863,4 +1863,51 @@ void shouldHandleLargeStreamContent() throws IOException {
18631863
Files.delete(largeFile);
18641864
registry.deleteBlob(containerRef.withDigest(layer.getDigest()));
18651865
}
1866+
1867+
@Test
1868+
void shouldMountBlobFromAnotherRepository() {
1869+
Registry registry = Registry.Builder.builder()
1870+
.defaults("myuser", "mypass")
1871+
.withInsecure(true)
1872+
.build();
1873+
1874+
// Push a blob to source repository
1875+
String content = "hello-mount";
1876+
byte[] contentBytes = content.getBytes(StandardCharsets.UTF_8);
1877+
String digest = SupportedAlgorithm.SHA256.digest(contentBytes);
1878+
ContainerRef sourceRef = ContainerRef.parse(
1879+
"%s/library/mount-source@%s".formatted(this.registry.getRegistry(), digest));
1880+
registry.pushBlob(sourceRef, contentBytes);
1881+
1882+
// Mount the blob into a different repository on the same registry
1883+
ContainerRef targetRef = ContainerRef.parse(
1884+
"%s/library/mount-target@%s".formatted(this.registry.getRegistry(), digest));
1885+
boolean mounted = registry.mountBlob(targetRef, sourceRef);
1886+
1887+
// Mounting should succeed on Zot registry
1888+
assertTrue(mounted, "Blob should be mounted successfully");
1889+
1890+
// Verify the blob is now accessible in the target repository
1891+
byte[] blobContent = registry.getBlob(targetRef);
1892+
assertEquals(content, new String(blobContent, StandardCharsets.UTF_8));
1893+
}
1894+
1895+
@Test
1896+
void shouldMountBlobReturnFalseWhenMountNotPossible() {
1897+
Registry registry = Registry.Builder.builder()
1898+
.defaults("myuser", "mypass")
1899+
.withInsecure(true)
1900+
.build();
1901+
1902+
// Try to mount a non-existent blob
1903+
String digest = "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
1904+
ContainerRef sourceRef = ContainerRef.parse(
1905+
"%s/library/nonexistent-source@%s".formatted(this.registry.getRegistry(), digest));
1906+
ContainerRef targetRef = ContainerRef.parse(
1907+
"%s/library/mount-fallback-target@%s".formatted(this.registry.getRegistry(), digest));
1908+
1909+
// Should return false when mount is not possible (source doesn't exist)
1910+
boolean mounted = registry.mountBlob(targetRef, sourceRef);
1911+
assertFalse(mounted, "Mount should fail when source blob does not exist");
1912+
}
18661913
}

0 commit comments

Comments
 (0)