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
51 changes: 46 additions & 5 deletions src/main/java/land/oras/CopyUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@
* =LICENSEEND=
*/

import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import land.oras.exception.OrasException;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

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

/**
* The default copy options with includeReferrers to false
* @return The default copy options
*/
public static CopyOptions shallow() {
return new CopyOptions(false);
return new CopyOptions(false, null);
}

/**
* The copy options with includeReferrers and recursive set to true.
* @return The copy options with includeReferrers and recursive set to true
*/
public static CopyOptions deep() {
return new CopyOptions(true);
return new CopyOptions(true, null);
}

/**
* Return a new CopyOptions with the given platform filter.
* When copying an index, only manifests matching one of the given platforms will be copied.
* The resulting index will contain only the filtered manifests and will have a different digest.
* @param platforms The platforms to filter by
* @return New CopyOptions with the platform filter set
*/
public CopyOptions withPlatformFilter(Set<Platform> platforms) {
return new CopyOptions(includeReferrers, platforms);
}
}

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

// Apply platform filter if set — partial copy produces a new index with fewer manifests
List<ManifestDescriptor> manifestsToCopy;
Index indexToPush;
if (options.platformFilter() != null) {
List<ManifestDescriptor> filtered = index.getManifests().stream()
.filter(d ->
options.platformFilter().stream().anyMatch(p -> Platform.matches(d.getPlatform(), p)))
.toList();
if (filtered.isEmpty()) {
throw new OrasException(
"No manifests found in index matching platform filter: " + options.platformFilter());
}
manifestsToCopy = filtered;
indexToPush = index.withManifests(filtered);
LOG.debug(
"Platform filter {} matched {}/{} manifests",
options.platformFilter(),
filtered.size(),
index.getManifests().size());
} else {
manifestsToCopy = index.getManifests();
indexToPush = index;
}

// Write all manifests and their config
for (ManifestDescriptor manifestDescriptor : index.getManifests()) {
for (ManifestDescriptor manifestDescriptor : manifestsToCopy) {

// Copy manifest
if (source.isManifestMediaType(manifestDescriptor.getMediaType())) {
Expand Down Expand Up @@ -260,7 +301,7 @@ else if (source.isIndexMediaType(contentType)) {
}

LOG.debug("Copying index {}", manifestDigest);
Index pushedIndex = target.pushIndex(effectiveTargetRef.withDigest(targetTag), index);
Index pushedIndex = target.pushIndex(effectiveTargetRef.withDigest(targetTag), indexToPush);
LOG.debug("Copied index {} with tag {}", pushedIndex, targetTag);

} else {
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/land/oras/Index.java
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,16 @@ public Subject getSubject() {
return subject;
}

/**
* Return a new index with the given manifests
* @param manifests The manifests
* @return The index
*/
public Index withManifests(List<ManifestDescriptor> manifests) {
return new Index(
schemaVersion, mediaType, artifactType, manifests, annotations, subject, descriptor, registry, null);
}

/**
* Return a new index with the given subject
* @param subject The subject
Expand Down
80 changes: 80 additions & 0 deletions src/test/java/land/oras/OCILayoutTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Set;
import land.oras.exception.OrasException;
import land.oras.utils.Const;
import land.oras.utils.SupportedAlgorithm;
Expand Down Expand Up @@ -780,6 +781,85 @@ void testShouldCopyArtifactFromRegistryIntoOciLayout() throws IOException {
assertBlobAbsent(layoutPath, SupportedAlgorithm.SHA256.digest(file2));
}

@Test
void testShouldCopyIndexWithPlatformFilterFromRegistryIntoOciLayout() throws IOException {

Registry registry = Registry.Builder.builder()
.defaults("myuser", "mypass")
.withInsecure(true)
.build();

Path ociLayoutPath = layoutPath.resolve("testShouldCopyIndexWithPlatformFilterFromRegistryIntoOciLayout");
OCILayout ociLayout =
OCILayout.Builder.builder().defaults(ociLayoutPath).build();
LayoutRef layoutRef = LayoutRef.parse("%s:latest".formatted(ociLayoutPath.toString()));

ContainerRef containerRef =
ContainerRef.parse("%s/library/platform-filter-source".formatted(this.registry.getRegistry()));

// Push two manifests with different content, one per platform
Path fileAmd64 = blobDir.resolve("platform-filter-amd64.txt");
Path fileArm64 = blobDir.resolve("platform-filter-arm64.txt");
Files.writeString(fileAmd64, "content-amd64");
Files.writeString(fileArm64, "content-arm64");

Manifest manifestAmd64 = registry.pushArtifact(containerRef.withTag("amd64"), LocalPath.of(fileAmd64));
Manifest manifestArm64 = registry.pushArtifact(containerRef.withTag("arm64"), LocalPath.of(fileArm64));

assertNotNull(manifestAmd64.getDescriptor());
assertNotNull(manifestArm64.getDescriptor());

// Build a multi-platform index and push it
ManifestDescriptor descAmd64 = manifestAmd64.getDescriptor().withPlatform(Platform.linuxAmd64());
ManifestDescriptor descArm64 = manifestArm64.getDescriptor().withPlatform(Platform.linuxArm64V8());
Index sourceIndex = Index.fromManifests(List.of(descAmd64, descArm64));
registry.pushIndex(containerRef.withTag("latest"), sourceIndex);

// Copy only linux/amd64 into the OCI layout
CopyUtils.copy(
registry,
containerRef.withTag("latest"),
ociLayout,
layoutRef,
CopyUtils.CopyOptions.shallow().withPlatformFilter(Set.of(Platform.linuxAmd64())));

// The layout must be a valid OCI layout
assertOciLayout(ociLayoutPath);

// The OCI layout root index.json has two entries:
// 1. the amd64 manifest blob (pushed individually, no tag)
// 2. the filtered index blob (tagged "latest")
Index ociIndex = Index.fromPath(ociLayoutPath.resolve(Const.OCI_LAYOUT_INDEX));
assertEquals(2, ociIndex.getManifests().size(), "OCI layout index.json must have two entries");

// Find the filtered-index entry by its tag annotation
ManifestDescriptor filteredIndexDescriptor = ociIndex.getManifests().stream()
.filter(d -> d.getAnnotations() != null
&& "latest".equals(d.getAnnotations().get(Const.ANNOTATION_REF)))
.findFirst()
.orElseThrow(() -> new AssertionError("No entry with tag 'latest' found in OCI layout index.json"));
assertBlobExists(ociLayoutPath, filteredIndexDescriptor.getDigest());

// The filtered index blob itself contains only the amd64 manifest descriptor
Index filteredIndex = Index.fromJson(Files.readString(ociLayoutPath
.resolve("blobs")
.resolve("sha256")
.resolve(SupportedAlgorithm.getDigest(filteredIndexDescriptor.getDigest()))));
assertEquals(1, filteredIndex.getManifests().size(), "Filtered index must contain exactly one manifest");
assertEquals(
Platform.linuxAmd64(),
filteredIndex.getManifests().get(0).getPlatform(),
"The single manifest in the filtered index must be linux/amd64");

// The amd64 layer blob and its config must be present
assertBlobExists(ociLayoutPath, SupportedAlgorithm.SHA256.digest(fileAmd64));
assertBlobContent(ociLayoutPath, SupportedAlgorithm.SHA256.digest(fileAmd64), "content-amd64");
assertBlobContent(ociLayoutPath, Config.empty().getDigest(), "{}");

// The arm64 layer blob must be absent — it was filtered out
assertBlobAbsent(ociLayoutPath, SupportedAlgorithm.SHA256.digest(fileArm64));
}

@Test
void testShouldCopyFromOciLayoutIntoOciLayoutRecursive() throws IOException {

Expand Down
93 changes: 93 additions & 0 deletions src/test/java/land/oras/RegistryTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import land.oras.exception.OrasException;
Expand Down Expand Up @@ -2275,4 +2276,96 @@ void shouldPushBlobChunkedFromStreamViaInsecureRegistryConfig(@TempDir Path home
registry.deleteBlob(containerRef.withDigest(expectedDigest));
});
}

@Test
void testShouldCopyIndexWithPlatformFilter() throws IOException {
Registry registry = Registry.Builder.builder()
.defaults("myuser", "mypass")
.withInsecure(true)
.build();

ContainerRef containerSource =
ContainerRef.parse("%s/library/multi-platform-source".formatted(this.registry.getRegistry()));

// Push two manifests with different content
Path fileAmd64 = blobDir.resolve("amd64.txt");
Files.writeString(fileAmd64, "content-amd64");
Path fileArm64 = blobDir.resolve("arm64.txt");
Files.writeString(fileArm64, "content-arm64");

Manifest manifestAmd64 = registry.pushArtifact(containerSource.withTag("amd64"), LocalPath.of(fileAmd64));
Manifest manifestArm64 = registry.pushArtifact(containerSource.withTag("arm64"), LocalPath.of(fileArm64));

assertNotNull(manifestAmd64.getDescriptor());
assertNotNull(manifestArm64.getDescriptor());

// Build a multi-platform index
ManifestDescriptor descAmd64 = manifestAmd64.getDescriptor().withPlatform(Platform.linuxAmd64());
ManifestDescriptor descArm64 = manifestArm64.getDescriptor().withPlatform(Platform.linuxArm64V8());

Index sourceIndex = Index.fromManifests(List.of(descAmd64, descArm64));
registry.pushIndex(containerSource.withTag("latest"), sourceIndex);

// Copy only linux/amd64 to target
ContainerRef containerTarget =
ContainerRef.parse("%s/library/multi-platform-target:latest".formatted(this.registry.getRegistry()));
CopyUtils.copy(
registry,
containerSource.withTag("latest"),
registry,
containerTarget,
CopyUtils.CopyOptions.shallow().withPlatformFilter(Set.of(Platform.linuxAmd64())));

// Verify the target index only contains the amd64 manifest
Index targetIndex = registry.getIndex(containerTarget);
assertEquals(1, targetIndex.getManifests().size(), "Filtered index should contain exactly one manifest");
assertEquals(
Platform.linuxAmd64(),
targetIndex.getManifests().get(0).getPlatform(),
"The single manifest should be linux/amd64");

// The target index digest must differ from the source index digest
assertNotEquals(
sourceIndex.getDescriptor() != null
? sourceIndex.getDescriptor().getDigest()
: null,
targetIndex.getDescriptor() != null
? targetIndex.getDescriptor().getDigest()
: null,
"Partial copy must produce a different index digest");
}

@Test
void testShouldThrowWhenPlatformFilterMatchesNothing() throws IOException {
Registry registry = Registry.Builder.builder()
.defaults("myuser", "mypass")
.withInsecure(true)
.build();

ContainerRef containerSource =
ContainerRef.parse("%s/library/no-match-source".formatted(this.registry.getRegistry()));

// Push a single manifest and build an index with linux/amd64
Path file = blobDir.resolve("no-match.txt");
Files.writeString(file, "no-match");
Manifest manifest = registry.pushArtifact(containerSource.withTag("v1"), LocalPath.of(file));
assertNotNull(manifest.getDescriptor());

Index sourceIndex = Index.fromManifests(List.of(manifest.getDescriptor().withPlatform(Platform.linuxAmd64())));
registry.pushIndex(containerSource.withTag("latest"), sourceIndex);

ContainerRef containerTarget =
ContainerRef.parse("%s/library/no-match-target:latest".formatted(this.registry.getRegistry()));

// Filtering for a platform not present in the index must throw
assertThrows(
OrasException.class,
() -> CopyUtils.copy(
registry,
containerSource.withTag("latest"),
registry,
containerTarget,
CopyUtils.CopyOptions.shallow().withPlatformFilter(Set.of(Platform.linuxArm64V8()))),
"Copy with non-matching platform filter should throw OrasException");
}
}
Loading