Skip to content

Commit df87bc6

Browse files
authored
Partial index copy with Platform filters (#744)
Signed-off-by: Valentin Delaye <jonesbusy@users.noreply.github.com>
1 parent 1684f66 commit df87bc6

4 files changed

Lines changed: 229 additions & 5 deletions

File tree

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

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,13 @@
2020
* =LICENSEEND=
2121
*/
2222

23+
import java.util.List;
2324
import java.util.Objects;
25+
import java.util.Set;
2426
import java.util.concurrent.CompletableFuture;
2527
import land.oras.exception.OrasException;
2628
import org.jspecify.annotations.NonNull;
29+
import org.jspecify.annotations.Nullable;
2730
import org.slf4j.Logger;
2831
import org.slf4j.LoggerFactory;
2932

@@ -47,23 +50,37 @@ private CopyUtils() {
4750
/**
4851
* Options for copy.
4952
* @param includeReferrers Whether to include referrers in the copy
53+
* @param platformFilter Optional platform filter. When set on an index copy, only manifests matching
54+
* one of the given platforms are copied and the resulting index contains only
55+
* those manifests. The resulting index digest will differ from the source index.
5056
*/
51-
public record CopyOptions(boolean includeReferrers) {
57+
public record CopyOptions(boolean includeReferrers, @Nullable Set<Platform> platformFilter) {
5258

5359
/**
5460
* The default copy options with includeReferrers to false
5561
* @return The default copy options
5662
*/
5763
public static CopyOptions shallow() {
58-
return new CopyOptions(false);
64+
return new CopyOptions(false, null);
5965
}
6066

6167
/**
6268
* The copy options with includeReferrers and recursive set to true.
6369
* @return The copy options with includeReferrers and recursive set to true
6470
*/
6571
public static CopyOptions deep() {
66-
return new CopyOptions(true);
72+
return new CopyOptions(true, null);
73+
}
74+
75+
/**
76+
* Return a new CopyOptions with the given platform filter.
77+
* When copying an index, only manifests matching one of the given platforms will be copied.
78+
* The resulting index will contain only the filtered manifests and will have a different digest.
79+
* @param platforms The platforms to filter by
80+
* @return New CopyOptions with the platform filter set
81+
*/
82+
public CopyOptions withPlatformFilter(Set<Platform> platforms) {
83+
return new CopyOptions(includeReferrers, platforms);
6784
}
6885
}
6986

@@ -220,8 +237,32 @@ else if (source.isIndexMediaType(contentType)) {
220237
Index index = source.getIndex(effectiveSourceRef);
221238
String targetTag = effectiveTargetRef.getTag();
222239

240+
// Apply platform filter if set — partial copy produces a new index with fewer manifests
241+
List<ManifestDescriptor> manifestsToCopy;
242+
Index indexToPush;
243+
if (options.platformFilter() != null) {
244+
List<ManifestDescriptor> filtered = index.getManifests().stream()
245+
.filter(d ->
246+
options.platformFilter().stream().anyMatch(p -> Platform.matches(d.getPlatform(), p)))
247+
.toList();
248+
if (filtered.isEmpty()) {
249+
throw new OrasException(
250+
"No manifests found in index matching platform filter: " + options.platformFilter());
251+
}
252+
manifestsToCopy = filtered;
253+
indexToPush = index.withManifests(filtered);
254+
LOG.debug(
255+
"Platform filter {} matched {}/{} manifests",
256+
options.platformFilter(),
257+
filtered.size(),
258+
index.getManifests().size());
259+
} else {
260+
manifestsToCopy = index.getManifests();
261+
indexToPush = index;
262+
}
263+
223264
// Write all manifests and their config
224-
for (ManifestDescriptor manifestDescriptor : index.getManifests()) {
265+
for (ManifestDescriptor manifestDescriptor : manifestsToCopy) {
225266

226267
// Copy manifest
227268
if (source.isManifestMediaType(manifestDescriptor.getMediaType())) {
@@ -260,7 +301,7 @@ else if (source.isIndexMediaType(contentType)) {
260301
}
261302

262303
LOG.debug("Copying index {}", manifestDigest);
263-
Index pushedIndex = target.pushIndex(effectiveTargetRef.withDigest(targetTag), index);
304+
Index pushedIndex = target.pushIndex(effectiveTargetRef.withDigest(targetTag), indexToPush);
264305
LOG.debug("Copied index {} with tag {}", pushedIndex, targetTag);
265306

266307
} else {

src/main/java/land/oras/Index.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,16 @@ public Subject getSubject() {
336336
return subject;
337337
}
338338

339+
/**
340+
* Return a new index with the given manifests
341+
* @param manifests The manifests
342+
* @return The index
343+
*/
344+
public Index withManifests(List<ManifestDescriptor> manifests) {
345+
return new Index(
346+
schemaVersion, mediaType, artifactType, manifests, annotations, subject, descriptor, registry, null);
347+
}
348+
339349
/**
340350
* Return a new index with the given subject
341351
* @param subject The subject

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import java.nio.file.Path;
3030
import java.util.List;
3131
import java.util.Map;
32+
import java.util.Set;
3233
import land.oras.exception.OrasException;
3334
import land.oras.utils.Const;
3435
import land.oras.utils.SupportedAlgorithm;
@@ -780,6 +781,85 @@ void testShouldCopyArtifactFromRegistryIntoOciLayout() throws IOException {
780781
assertBlobAbsent(layoutPath, SupportedAlgorithm.SHA256.digest(file2));
781782
}
782783

784+
@Test
785+
void testShouldCopyIndexWithPlatformFilterFromRegistryIntoOciLayout() throws IOException {
786+
787+
Registry registry = Registry.Builder.builder()
788+
.defaults("myuser", "mypass")
789+
.withInsecure(true)
790+
.build();
791+
792+
Path ociLayoutPath = layoutPath.resolve("testShouldCopyIndexWithPlatformFilterFromRegistryIntoOciLayout");
793+
OCILayout ociLayout =
794+
OCILayout.Builder.builder().defaults(ociLayoutPath).build();
795+
LayoutRef layoutRef = LayoutRef.parse("%s:latest".formatted(ociLayoutPath.toString()));
796+
797+
ContainerRef containerRef =
798+
ContainerRef.parse("%s/library/platform-filter-source".formatted(this.registry.getRegistry()));
799+
800+
// Push two manifests with different content, one per platform
801+
Path fileAmd64 = blobDir.resolve("platform-filter-amd64.txt");
802+
Path fileArm64 = blobDir.resolve("platform-filter-arm64.txt");
803+
Files.writeString(fileAmd64, "content-amd64");
804+
Files.writeString(fileArm64, "content-arm64");
805+
806+
Manifest manifestAmd64 = registry.pushArtifact(containerRef.withTag("amd64"), LocalPath.of(fileAmd64));
807+
Manifest manifestArm64 = registry.pushArtifact(containerRef.withTag("arm64"), LocalPath.of(fileArm64));
808+
809+
assertNotNull(manifestAmd64.getDescriptor());
810+
assertNotNull(manifestArm64.getDescriptor());
811+
812+
// Build a multi-platform index and push it
813+
ManifestDescriptor descAmd64 = manifestAmd64.getDescriptor().withPlatform(Platform.linuxAmd64());
814+
ManifestDescriptor descArm64 = manifestArm64.getDescriptor().withPlatform(Platform.linuxArm64V8());
815+
Index sourceIndex = Index.fromManifests(List.of(descAmd64, descArm64));
816+
registry.pushIndex(containerRef.withTag("latest"), sourceIndex);
817+
818+
// Copy only linux/amd64 into the OCI layout
819+
CopyUtils.copy(
820+
registry,
821+
containerRef.withTag("latest"),
822+
ociLayout,
823+
layoutRef,
824+
CopyUtils.CopyOptions.shallow().withPlatformFilter(Set.of(Platform.linuxAmd64())));
825+
826+
// The layout must be a valid OCI layout
827+
assertOciLayout(ociLayoutPath);
828+
829+
// The OCI layout root index.json has two entries:
830+
// 1. the amd64 manifest blob (pushed individually, no tag)
831+
// 2. the filtered index blob (tagged "latest")
832+
Index ociIndex = Index.fromPath(ociLayoutPath.resolve(Const.OCI_LAYOUT_INDEX));
833+
assertEquals(2, ociIndex.getManifests().size(), "OCI layout index.json must have two entries");
834+
835+
// Find the filtered-index entry by its tag annotation
836+
ManifestDescriptor filteredIndexDescriptor = ociIndex.getManifests().stream()
837+
.filter(d -> d.getAnnotations() != null
838+
&& "latest".equals(d.getAnnotations().get(Const.ANNOTATION_REF)))
839+
.findFirst()
840+
.orElseThrow(() -> new AssertionError("No entry with tag 'latest' found in OCI layout index.json"));
841+
assertBlobExists(ociLayoutPath, filteredIndexDescriptor.getDigest());
842+
843+
// The filtered index blob itself contains only the amd64 manifest descriptor
844+
Index filteredIndex = Index.fromJson(Files.readString(ociLayoutPath
845+
.resolve("blobs")
846+
.resolve("sha256")
847+
.resolve(SupportedAlgorithm.getDigest(filteredIndexDescriptor.getDigest()))));
848+
assertEquals(1, filteredIndex.getManifests().size(), "Filtered index must contain exactly one manifest");
849+
assertEquals(
850+
Platform.linuxAmd64(),
851+
filteredIndex.getManifests().get(0).getPlatform(),
852+
"The single manifest in the filtered index must be linux/amd64");
853+
854+
// The amd64 layer blob and its config must be present
855+
assertBlobExists(ociLayoutPath, SupportedAlgorithm.SHA256.digest(fileAmd64));
856+
assertBlobContent(ociLayoutPath, SupportedAlgorithm.SHA256.digest(fileAmd64), "content-amd64");
857+
assertBlobContent(ociLayoutPath, Config.empty().getDigest(), "{}");
858+
859+
// The arm64 layer blob must be absent — it was filtered out
860+
assertBlobAbsent(ociLayoutPath, SupportedAlgorithm.SHA256.digest(fileArm64));
861+
}
862+
783863
@Test
784864
void testShouldCopyFromOciLayoutIntoOciLayoutRecursive() throws IOException {
785865

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

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import java.util.List;
3333
import java.util.Map;
3434
import java.util.Random;
35+
import java.util.Set;
3536
import java.util.concurrent.ExecutorService;
3637
import java.util.concurrent.Executors;
3738
import land.oras.exception.OrasException;
@@ -2275,4 +2276,96 @@ void shouldPushBlobChunkedFromStreamViaInsecureRegistryConfig(@TempDir Path home
22752276
registry.deleteBlob(containerRef.withDigest(expectedDigest));
22762277
});
22772278
}
2279+
2280+
@Test
2281+
void testShouldCopyIndexWithPlatformFilter() throws IOException {
2282+
Registry registry = Registry.Builder.builder()
2283+
.defaults("myuser", "mypass")
2284+
.withInsecure(true)
2285+
.build();
2286+
2287+
ContainerRef containerSource =
2288+
ContainerRef.parse("%s/library/multi-platform-source".formatted(this.registry.getRegistry()));
2289+
2290+
// Push two manifests with different content
2291+
Path fileAmd64 = blobDir.resolve("amd64.txt");
2292+
Files.writeString(fileAmd64, "content-amd64");
2293+
Path fileArm64 = blobDir.resolve("arm64.txt");
2294+
Files.writeString(fileArm64, "content-arm64");
2295+
2296+
Manifest manifestAmd64 = registry.pushArtifact(containerSource.withTag("amd64"), LocalPath.of(fileAmd64));
2297+
Manifest manifestArm64 = registry.pushArtifact(containerSource.withTag("arm64"), LocalPath.of(fileArm64));
2298+
2299+
assertNotNull(manifestAmd64.getDescriptor());
2300+
assertNotNull(manifestArm64.getDescriptor());
2301+
2302+
// Build a multi-platform index
2303+
ManifestDescriptor descAmd64 = manifestAmd64.getDescriptor().withPlatform(Platform.linuxAmd64());
2304+
ManifestDescriptor descArm64 = manifestArm64.getDescriptor().withPlatform(Platform.linuxArm64V8());
2305+
2306+
Index sourceIndex = Index.fromManifests(List.of(descAmd64, descArm64));
2307+
registry.pushIndex(containerSource.withTag("latest"), sourceIndex);
2308+
2309+
// Copy only linux/amd64 to target
2310+
ContainerRef containerTarget =
2311+
ContainerRef.parse("%s/library/multi-platform-target:latest".formatted(this.registry.getRegistry()));
2312+
CopyUtils.copy(
2313+
registry,
2314+
containerSource.withTag("latest"),
2315+
registry,
2316+
containerTarget,
2317+
CopyUtils.CopyOptions.shallow().withPlatformFilter(Set.of(Platform.linuxAmd64())));
2318+
2319+
// Verify the target index only contains the amd64 manifest
2320+
Index targetIndex = registry.getIndex(containerTarget);
2321+
assertEquals(1, targetIndex.getManifests().size(), "Filtered index should contain exactly one manifest");
2322+
assertEquals(
2323+
Platform.linuxAmd64(),
2324+
targetIndex.getManifests().get(0).getPlatform(),
2325+
"The single manifest should be linux/amd64");
2326+
2327+
// The target index digest must differ from the source index digest
2328+
assertNotEquals(
2329+
sourceIndex.getDescriptor() != null
2330+
? sourceIndex.getDescriptor().getDigest()
2331+
: null,
2332+
targetIndex.getDescriptor() != null
2333+
? targetIndex.getDescriptor().getDigest()
2334+
: null,
2335+
"Partial copy must produce a different index digest");
2336+
}
2337+
2338+
@Test
2339+
void testShouldThrowWhenPlatformFilterMatchesNothing() throws IOException {
2340+
Registry registry = Registry.Builder.builder()
2341+
.defaults("myuser", "mypass")
2342+
.withInsecure(true)
2343+
.build();
2344+
2345+
ContainerRef containerSource =
2346+
ContainerRef.parse("%s/library/no-match-source".formatted(this.registry.getRegistry()));
2347+
2348+
// Push a single manifest and build an index with linux/amd64
2349+
Path file = blobDir.resolve("no-match.txt");
2350+
Files.writeString(file, "no-match");
2351+
Manifest manifest = registry.pushArtifact(containerSource.withTag("v1"), LocalPath.of(file));
2352+
assertNotNull(manifest.getDescriptor());
2353+
2354+
Index sourceIndex = Index.fromManifests(List.of(manifest.getDescriptor().withPlatform(Platform.linuxAmd64())));
2355+
registry.pushIndex(containerSource.withTag("latest"), sourceIndex);
2356+
2357+
ContainerRef containerTarget =
2358+
ContainerRef.parse("%s/library/no-match-target:latest".formatted(this.registry.getRegistry()));
2359+
2360+
// Filtering for a platform not present in the index must throw
2361+
assertThrows(
2362+
OrasException.class,
2363+
() -> CopyUtils.copy(
2364+
registry,
2365+
containerSource.withTag("latest"),
2366+
registry,
2367+
containerTarget,
2368+
CopyUtils.CopyOptions.shallow().withPlatformFilter(Set.of(Platform.linuxArm64V8()))),
2369+
"Copy with non-matching platform filter should throw OrasException");
2370+
}
22782371
}

0 commit comments

Comments
 (0)