diff --git a/src/main/java/land/oras/ContainerRef.java b/src/main/java/land/oras/ContainerRef.java index 7867e03b..0b8d0c7c 100644 --- a/src/main/java/land/oras/ContainerRef.java +++ b/src/main/java/land/oras/ContainerRef.java @@ -465,7 +465,7 @@ public static ContainerRef parse(String name) { * Get the effective registry based on given target * This methods will perform HEAD request to determine the first unqualified search registry that contains the container reference if the reference is unqualified, otherwise return the registry of the reference. * This only works with Manifests and Index but now direct blob access. - * See {@link #forRegistry(String)} so set correct registry when getting blobs outside high level API like {@link Registry#pullArtifact(ContainerRef, Path, boolean)}. + * See {@link #forRegistry(String)} to set correct registry when getting blobs outside high level API like {@link Registry#pullArtifact(ContainerRef, Path, OCI.PullOptions)}. * @param target The target registry * @return The effective registry */ diff --git a/src/main/java/land/oras/CopyUtils.java b/src/main/java/land/oras/CopyUtils.java index 1229a943..4337083e 100644 --- a/src/main/java/land/oras/CopyUtils.java +++ b/src/main/java/land/oras/CopyUtils.java @@ -49,12 +49,17 @@ 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, @Nullable Set platformFilter) { + @OrasModel + public static final class CopyOptions { + + private final boolean includeReferrers; + private final @Nullable Set platformFilter; + + private CopyOptions(boolean includeReferrers, @Nullable Set platformFilter) { + this.includeReferrers = includeReferrers; + this.platformFilter = platformFilter; + } /** * The default copy options with includeReferrers to false @@ -82,6 +87,22 @@ public static CopyOptions deep() { public CopyOptions withPlatformFilter(Set platforms) { return new CopyOptions(includeReferrers, platforms); } + + /** + * Return whether referrers should be included in the copy. + * @return {@code true} if referrers should be included + */ + public boolean includeReferrers() { + return includeReferrers; + } + + /** + * Return the optional platform filter. + * @return The platform filter, or {@code null} if not set + */ + public @Nullable Set platformFilter() { + return platformFilter; + } } /** diff --git a/src/main/java/land/oras/OCI.java b/src/main/java/land/oras/OCI.java index 6f0aa266..25312bd7 100644 --- a/src/main/java/land/oras/OCI.java +++ b/src/main/java/land/oras/OCI.java @@ -58,6 +58,102 @@ public abstract sealed class OCI> permits Registry, OC */ protected static final Logger LOG = LoggerFactory.getLogger(OCI.class); + /** + * Options controlling the behavior of {@link OCI#pushArtifact} operations. + */ + @OrasModel + public static final class PushOptions { + + /** Default chunk size: 5 MiB. */ + public static final long DEFAULT_CHUNK_SIZE = 5L * 1024 * 1024; + + private final boolean chunkedEnabled; + private final long chunkSize; + + private PushOptions(boolean chunkedEnabled, long chunkSize) { + this.chunkedEnabled = chunkedEnabled; + this.chunkSize = chunkSize; + } + + /** + * Default options: single-request upload, no chunking. + * @return The default push options + */ + public static PushOptions defaults() { + return new PushOptions(false, DEFAULT_CHUNK_SIZE); + } + + /** + * Options that enable chunked (PATCH-based) upload with the default chunk size. + * @return Push options with chunked upload enabled + */ + public static PushOptions chunked() { + return new PushOptions(true, DEFAULT_CHUNK_SIZE); + } + + /** + * Options that enable chunked (PATCH-based) upload with a custom chunk size. + * @param chunkSize Maximum number of bytes per chunk. Must be greater than 0. + * @return Push options with chunked upload enabled + */ + public static PushOptions chunked(long chunkSize) { + return new PushOptions(true, chunkSize); + } + + /** + * Return whether chunked upload is enabled. + * @return {@code true} if chunked upload should be used + */ + public boolean isChunked() { + return chunkedEnabled; + } + + /** + * Return the maximum number of bytes per chunk. + * @return The chunk size in bytes + */ + public long chunkSize() { + return chunkSize; + } + } + + /** + * Options controlling the behavior of {@link OCI#pullArtifact} operations. + */ + @OrasModel + public static final class PullOptions { + + private final boolean overwriteEnabled; + + private PullOptions(boolean overwriteEnabled) { + this.overwriteEnabled = overwriteEnabled; + } + + /** + * Default options: do not overwrite existing files. + * @return The default pull options + */ + public static PullOptions defaults() { + return new PullOptions(false); + } + + /** + * Options that allow overwriting existing files when pulling. + * @return Pull options with overwrite enabled + */ + public static PullOptions overwrite() { + return new PullOptions(true); + } + + /** + * Return whether existing files should be overwritten. + * @return {@code true} if existing files should be overwritten + */ + public boolean isOverwrite() { + return overwriteEnabled; + } + } + /** * Default constructor */ @@ -96,6 +192,43 @@ public Manifest pushArtifact(T ref, ArtifactType artifactType, Annotations annot return pushArtifact(ref, artifactType, annotations, Config.empty(), paths); } + /** + * Push an artifact with explicit push options + * @param ref The ref + * @param options The push options + * @param paths The paths + * @return The manifest + */ + public Manifest pushArtifact(T ref, PushOptions options, LocalPath... paths) { + return pushArtifact(ref, ArtifactType.unknown(), Annotations.empty(), Config.empty(), options, paths); + } + + /** + * Push an artifact with explicit push options + * @param ref The ref + * @param artifactType The artifact type + * @param options The push options + * @param paths The paths + * @return The manifest + */ + public Manifest pushArtifact(T ref, ArtifactType artifactType, PushOptions options, LocalPath... paths) { + return pushArtifact(ref, artifactType, Annotations.empty(), Config.empty(), options, paths); + } + + /** + * Push an artifact with explicit push options + * @param ref The ref + * @param artifactType The artifact type + * @param annotations The annotations + * @param options The push options + * @param paths The paths + * @return The manifest + */ + public Manifest pushArtifact( + T ref, ArtifactType artifactType, Annotations annotations, PushOptions options, LocalPath... paths) { + return pushArtifact(ref, artifactType, annotations, Config.empty(), options, paths); + } + /** * Push a blob from file * @param ref The ref @@ -182,7 +315,7 @@ protected List collectLayers(T ref, String contentType, boolean includeAl } /** - * Push layers to the target + * Push layers to the target using default push options * @param ref The ref * @param annotations The annotations for layers (selected by title annotation). * @param withDigest Push with digest @@ -190,10 +323,24 @@ protected List collectLayers(T ref, String contentType, boolean includeAl * @return The layers */ protected final List pushLayers(T ref, Annotations annotations, boolean withDigest, LocalPath... paths) { + return pushLayers(ref, annotations, withDigest, PushOptions.defaults(), paths); + } + + /** + * Push layers to the target + * @param ref The ref + * @param annotations The annotations for layers (selected by title annotation). + * @param withDigest Push with digest + * @param options The push options + * @param paths The paths to the files + * @return The layers + */ + protected final List pushLayers( + T ref, Annotations annotations, boolean withDigest, PushOptions options, LocalPath... paths) { try { return Arrays.stream(paths) .map(p -> CompletableFuture.supplyAsync( - () -> pushLayer(ref, annotations, withDigest, p), getExecutorService())) + () -> pushLayer(ref, annotations, withDigest, p, options), getExecutorService())) .map(CompletableFuture::join) .toList(); } catch (CompletionException e) { @@ -286,17 +433,45 @@ public final Manifest attachArtifact(T ref, ArtifactType artifactType, LocalPath */ public abstract Repositories getRepositories(); + /** + * Push an artifact using default push options + * @param ref The container + * @param artifactType The artifact type. Can be null + * @param annotations The annotations + * @param config The config + * @param paths The paths + * @return The manifest + */ + public Manifest pushArtifact( + T ref, ArtifactType artifactType, Annotations annotations, @Nullable Config config, LocalPath... paths) { + return pushArtifact(ref, artifactType, annotations, config, PushOptions.defaults(), paths); + } + /** * Push an artifact * @param ref The container * @param artifactType The artifact type. Can be null * @param annotations The annotations * @param config The config + * @param options The push options * @param paths The paths * @return The manifest */ public abstract Manifest pushArtifact( - T ref, ArtifactType artifactType, Annotations annotations, @Nullable Config config, LocalPath... paths); + T ref, + ArtifactType artifactType, + Annotations annotations, + @Nullable Config config, + PushOptions options, + LocalPath... paths); + + /** + * Pull an artifact using the given options + * @param ref The reference of the artifact + * @param path The path to save the artifact + * @param options The pull options + */ + public abstract void pullArtifact(T ref, Path path, PullOptions options); /** * Pull an artifact @@ -304,7 +479,9 @@ public abstract Manifest pushArtifact( * @param path The path to save the artifact * @param overwrite Overwrite the artifact if it exists */ - public abstract void pullArtifact(T ref, Path path, boolean overwrite); + public void pullArtifact(T ref, Path path, boolean overwrite) { + pullArtifact(ref, path, overwrite ? PullOptions.overwrite() : PullOptions.defaults()); + } /** * Push a manifest @@ -449,6 +626,10 @@ public Manifest attachArtifact(T ref, ArtifactType artifactType, Annotations ann } protected Layer pushLayer(T ref, Annotations annotations, boolean withDigest, LocalPath path) { + return pushLayer(ref, annotations, withDigest, path, PushOptions.defaults()); + } + + protected Layer pushLayer(T ref, Annotations annotations, boolean withDigest, LocalPath path, PushOptions options) { try { // Create tar.gz archive for directory if (Files.isDirectory(path.getPath())) { @@ -462,57 +643,72 @@ protected Layer pushLayer(T ref, Annotations annotations, boolean withDigest, Lo if (withDigest) { ref = ref.withDigest(ref.getAlgorithm().digest(tempArchive.getPath())); } - try (InputStream is = Files.newInputStream(tempArchive.getPath())) { - String title = path.getPath().isAbsolute() - ? path.getPath().getFileName().toString() - : path.getPath().toString(); - - // We store the filename, based on directory name if we don't auto unpack - if (!autoUnpack) { - title = "%s.%s".formatted(title, compression.getFileExtension()); - } - LOG.debug("Uploading directory as archive with title: {}", title); - - Map layerAnnotations = annotations.hasFileAnnotations(title) - ? annotations.getFileAnnotations(title) - : new LinkedHashMap<>(Map.of(Const.ANNOTATION_TITLE, title)); - - // Add oras digest/unpack - // For example zip can be packed application/zip but never unpacked by the runtime - // This is convenience method to pack zip layer as directories - if (compression.isAutoUnpack()) { - layerAnnotations.put( - Const.ANNOTATION_ORAS_CONTENT_DIGEST, - ref.getAlgorithm().digest(tempSource.getPath())); - layerAnnotations.put(Const.ANNOTATION_ORAS_UNPACK, "true"); - } else { - layerAnnotations.put(Const.ANNOTATION_ORAS_UNPACK, "false"); - } - Layer layer = - pushBlob(ref, is).withMediaType(path.getMediaType()).withAnnotations(layerAnnotations); - LOG.info("Uploaded directory: {}", layer.getDigest()); - Files.delete(tempArchive.getPath()); - return layer; + String title = path.getPath().isAbsolute() + ? path.getPath().getFileName().toString() + : path.getPath().toString(); + + // We store the filename, based on directory name if we don't auto unpack + if (!autoUnpack) { + title = "%s.%s".formatted(title, compression.getFileExtension()); + } + LOG.debug("Uploading directory as archive with title: {}", title); + + Map layerAnnotations = annotations.hasFileAnnotations(title) + ? annotations.getFileAnnotations(title) + : new LinkedHashMap<>(Map.of(Const.ANNOTATION_TITLE, title)); + + // Add oras digest/unpack + // For example zip can be packed application/zip but never unpacked by the runtime + // This is convenience method to pack zip layer as directories + if (compression.isAutoUnpack()) { + layerAnnotations.put( + Const.ANNOTATION_ORAS_CONTENT_DIGEST, + ref.getAlgorithm().digest(tempSource.getPath())); + layerAnnotations.put(Const.ANNOTATION_ORAS_UNPACK, "true"); + } else { + layerAnnotations.put(Const.ANNOTATION_ORAS_UNPACK, "false"); } + + Layer layer = doPushBlob(ref, tempArchive.getPath(), options) + .withMediaType(path.getMediaType()) + .withAnnotations(layerAnnotations); + LOG.info("Uploaded directory: {}", layer.getDigest()); + Files.delete(tempArchive.getPath()); + return layer; } else { - try (InputStream is = Files.newInputStream(path.getPath())) { - if (withDigest) { - ref = ref.withDigest(ref.getAlgorithm().digest(path.getPath())); - } - String title = path.getPath().getFileName().toString(); - Map layerAnnotations = annotations.hasFileAnnotations(title) - ? annotations.getFileAnnotations(title) - : Map.of(Const.ANNOTATION_TITLE, title); - - Layer layer = - pushBlob(ref, is).withMediaType(path.getMediaType()).withAnnotations(layerAnnotations); - LOG.info("Uploaded: {}", layer.getDigest()); - return layer; + if (withDigest) { + ref = ref.withDigest(ref.getAlgorithm().digest(path.getPath())); } + String title = path.getPath().getFileName().toString(); + Map layerAnnotations = annotations.hasFileAnnotations(title) + ? annotations.getFileAnnotations(title) + : Map.of(Const.ANNOTATION_TITLE, title); + + Layer layer = doPushBlob(ref, path.getPath(), options) + .withMediaType(path.getMediaType()) + .withAnnotations(layerAnnotations); + LOG.info("Uploaded: {}", layer.getDigest()); + return layer; } } catch (IOException e) { throw new OrasException("Failed to push artifact", e); } } + + /** + * Push a blob from a file path, respecting the given push options. + * Subclasses may override to support chunked upload. + * @param ref The ref + * @param blob The blob file + * @param options The push options + * @return The layer + */ + protected Layer doPushBlob(T ref, Path blob, PushOptions options) { + try (InputStream is = Files.newInputStream(blob)) { + return pushBlob(ref, is); + } catch (IOException e) { + throw new OrasException("Failed to push blob", e); + } + } } diff --git a/src/main/java/land/oras/OCILayout.java b/src/main/java/land/oras/OCILayout.java index 9fec8fb7..51c8ee28 100644 --- a/src/main/java/land/oras/OCILayout.java +++ b/src/main/java/land/oras/OCILayout.java @@ -36,6 +36,8 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Supplier; +import land.oras.OCI.PullOptions; +import land.oras.OCI.PushOptions; import land.oras.exception.OrasException; import land.oras.utils.ArchiveUtils; import land.oras.utils.Const; @@ -132,6 +134,7 @@ public Manifest pushArtifact( ArtifactType artifactType, Annotations annotations, @Nullable Config config, + PushOptions options, LocalPath... paths) { Manifest manifest = Manifest.empty().withArtifactType(artifactType); @@ -146,7 +149,7 @@ public Manifest pushArtifact( } // Push layers - List layers = pushLayers(ref, annotations, true, paths); + List layers = pushLayers(ref, annotations, true, options, paths); // Push the config like any other blob Config configToPush = config != null ? config : Config.empty(); @@ -162,7 +165,7 @@ public Manifest pushArtifact( } @Override - public void pullArtifact(LayoutRef ref, Path path, boolean overwrite) { + public void pullArtifact(LayoutRef ref, Path path, PullOptions options) { if (ref.getTag() == null) { throw new OrasException("Tag is required to pull artifact from layout"); } @@ -186,7 +189,11 @@ public void pullArtifact(LayoutRef ref, Path path, boolean overwrite) { throw new OrasException("Refusing to pull layer: title annotation is not withing folder '%s'" .formatted(layer.getAnnotations().get(Const.ANNOTATION_TITLE))); } - Files.copy(blobPath, targetPath); + if (options.isOverwrite()) { + Files.copy(blobPath, targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } else { + Files.copy(blobPath, targetPath); + } } catch (IOException e) { throw new OrasException("Failed to copy blob", e); } diff --git a/src/main/java/land/oras/Registry.java b/src/main/java/land/oras/Registry.java index a178abd8..a72c2c71 100644 --- a/src/main/java/land/oras/Registry.java +++ b/src/main/java/land/oras/Registry.java @@ -41,6 +41,8 @@ import java.util.concurrent.Executors; import java.util.function.BiFunction; import java.util.function.Supplier; +import land.oras.OCI.PullOptions; +import land.oras.OCI.PushOptions; import land.oras.auth.AuthProvider; import land.oras.auth.AuthStoreAuthenticationProvider; import land.oras.auth.BearerTokenProvider; @@ -573,21 +575,21 @@ public void deleteBlob(ContainerRef containerRef) { } @Override - public void pullArtifact(ContainerRef containerRef, Path path, boolean overwrite) { + public void pullArtifact(ContainerRef containerRef, Path path, PullOptions options) { withMirrorFallback(containerRef, (reg, ref) -> { - reg.pullArtifactDirect(ref, path, overwrite); + reg.pullArtifactDirect(ref, path, options); return null; }); } - private void pullArtifactDirect(ContainerRef containerRef, Path path, boolean overwrite) { + private void pullArtifactDirect(ContainerRef containerRef, Path path, PullOptions options) { ContainerRef ref = containerRef.forRegistry(this).checkBlocked(this); if (ref.isInsecure(this) && !this.isInsecure()) { - asInsecure().pullArtifactDirect(containerRef, path, overwrite); + asInsecure().pullArtifactDirect(containerRef, path, options); return; } if (!ref.isInsecure(this) && this.isInsecure()) { - asSecure().pullArtifactDirect(containerRef, path, overwrite); + asSecure().pullArtifactDirect(containerRef, path, options); return; } // Only collect layer that are files @@ -606,7 +608,7 @@ private void pullArtifactDirect(ContainerRef containerRef, Path path, boolean ov CompletableFuture.allOf(layers.stream() .filter(layer -> layer.getAnnotations().containsKey(Const.ANNOTATION_TITLE)) .map(layer -> CompletableFuture.runAsync( - () -> pullLayer(ref, layer, path, overwrite), getExecutorService())) + () -> pullLayer(ref, layer, path, options.isOverwrite()), getExecutorService())) .toArray(CompletableFuture[]::new)) .join(); } @@ -617,6 +619,7 @@ public Manifest pushArtifact( ArtifactType artifactType, Annotations annotations, @Nullable Config config, + PushOptions options, LocalPath... paths) { Manifest manifest = Manifest.empty().withArtifactType(artifactType); Map manifestAnnotations = new HashMap<>(annotations.manifestAnnotations()); @@ -638,7 +641,7 @@ public Manifest pushArtifact( ContainerRef resolvedRef = containerRef.forRegistry(this).forRegistry(resolvedRegistry); // Push layers - List layers = pushLayers(resolvedRef, annotations, false, paths); + List layers = pushLayers(resolvedRef, annotations, false, options, paths); // Add layer and config manifest = manifest.withLayers(layers).withConfig(pushedConfig); @@ -651,6 +654,14 @@ public Manifest pushArtifact( return manifest; } + @Override + protected Layer doPushBlob(ContainerRef ref, Path blob, PushOptions options) { + if (options.isChunked()) { + return pushBlobChunked(ref, blob, options.chunkSize()); + } + return super.doPushBlob(ref, blob, options); + } + @Override public Layer pushBlob(ContainerRef containerRef, Path blob, Map annotations) { String digest = containerRef.getAlgorithm().digest(blob); diff --git a/src/test/java/land/oras/ClassAnnotationsTest.java b/src/test/java/land/oras/ClassAnnotationsTest.java index 6bbb1fe9..cc3a1112 100644 --- a/src/test/java/land/oras/ClassAnnotationsTest.java +++ b/src/test/java/land/oras/ClassAnnotationsTest.java @@ -49,12 +49,13 @@ void shouldHaveAnnotationOnModel() { .loadClasses()); // Check number of classes - assertEquals(28, modelClasses.size()); + assertEquals(31, modelClasses.size()); // Check classes assertTrue(modelClasses.contains(Annotations.class)); assertTrue(modelClasses.contains(ArtifactType.class)); assertTrue(modelClasses.contains(Config.class)); + assertTrue(modelClasses.contains(CopyUtils.CopyOptions.class)); assertTrue(modelClasses.contains(Descriptor.class)); assertTrue(modelClasses.contains(Describable.class)); assertTrue(modelClasses.contains(Error.class)); @@ -64,6 +65,8 @@ void shouldHaveAnnotationOnModel() { assertTrue(modelClasses.contains(Manifest.class)); assertTrue(modelClasses.contains(ManifestDescriptor.class)); assertTrue(modelClasses.contains(OCILayout.class)); + assertTrue(modelClasses.contains(OCI.PullOptions.class)); + assertTrue(modelClasses.contains(OCI.PushOptions.class)); assertTrue(modelClasses.contains(Repositories.class)); assertTrue(modelClasses.contains(Subject.class)); assertTrue(modelClasses.contains(Tags.class)); diff --git a/src/test/java/land/oras/RegistryTest.java b/src/test/java/land/oras/RegistryTest.java index f6d55180..68e6d3a5 100644 --- a/src/test/java/land/oras/RegistryTest.java +++ b/src/test/java/land/oras/RegistryTest.java @@ -2368,4 +2368,77 @@ void testShouldThrowWhenPlatformFilterMatchesNothing() throws IOException { CopyUtils.CopyOptions.shallow().withPlatformFilter(Set.of(Platform.linuxArm64V8()))), "Copy with non-matching platform filter should throw OrasException"); } + + @Test + void shouldPushArtifactWithChunkedOptions() throws IOException { + Registry registry = Registry.Builder.builder() + .defaults("myuser", "mypass") + .withInsecure(true) + .build(); + ContainerRef containerRef = + ContainerRef.parse("%s/library/artifact-push-options-chunked".formatted(this.registry.getRegistry())); + + Path file = blobDir.resolve("chunked-artifact.txt"); + Files.writeString(file, "hello chunked artifact"); + + // PushOptions.chunked() — default chunk size + OCI.PushOptions options = OCI.PushOptions.chunked(); + assertTrue(options.isChunked()); + assertEquals(OCI.PushOptions.DEFAULT_CHUNK_SIZE, options.chunkSize()); + + Manifest manifest = registry.pushArtifact(containerRef, options, LocalPath.of(file)); + assertEquals(1, manifest.getLayers().size()); + assertNotNull(manifest.getAnnotations().get(Const.ANNOTATION_CREATED)); + + registry.pullArtifact(containerRef, artifactDir, true); + assertEquals("hello chunked artifact", Files.readString(artifactDir.resolve("chunked-artifact.txt"))); + } + + @Test + void shouldPushArtifactWithChunkedOptionsCustomChunkSizeAndArtifactType() throws IOException { + Registry registry = Registry.Builder.builder() + .defaults("myuser", "mypass") + .withInsecure(true) + .build(); + ContainerRef containerRef = ContainerRef.parse( + "%s/library/artifact-push-options-chunked-type".formatted(this.registry.getRegistry())); + + Path file = blobDir.resolve("chunked-artifact-type.txt"); + Files.writeString(file, "chunked with artifact type"); + + // PushOptions.chunked(long) — custom chunk size + OCI.PushOptions options = OCI.PushOptions.chunked(8L); + assertTrue(options.isChunked()); + assertEquals(8L, options.chunkSize()); + + Manifest manifest = registry.pushArtifact( + containerRef, ArtifactType.from("application/vnd.test+type"), options, LocalPath.of(file)); + assertEquals(1, manifest.getLayers().size()); + assertEquals("application/vnd.test+type", manifest.getArtifactType().getMediaType()); + } + + @Test + void shouldPushArtifactWithChunkedOptionsAndAnnotations() throws IOException { + Registry registry = Registry.Builder.builder() + .defaults("myuser", "mypass") + .withInsecure(true) + .build(); + ContainerRef containerRef = ContainerRef.parse( + "%s/library/artifact-push-options-chunked-annotations".formatted(this.registry.getRegistry())); + + Path file = blobDir.resolve("chunked-artifact-annotations.txt"); + Files.writeString(file, "chunked with annotations"); + + Annotations annotations = Annotations.ofManifest(Map.of("custom-key", "custom-value")); + + // PushOptions.chunked(long) via the artifactType+annotations overload + Manifest manifest = registry.pushArtifact( + containerRef, + ArtifactType.from("application/vnd.test+type"), + annotations, + OCI.PushOptions.chunked(16L), + LocalPath.of(file)); + assertEquals(1, manifest.getLayers().size()); + assertEquals("custom-value", manifest.getAnnotations().get("custom-key")); + } }