diff --git a/src/main/java/land/oras/Index.java b/src/main/java/land/oras/Index.java index f7f73406..7be10bc4 100644 --- a/src/main/java/land/oras/Index.java +++ b/src/main/java/land/oras/Index.java @@ -21,7 +21,10 @@ package land.oras; import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.LinkedList; import java.util.List; +import java.util.Map; import land.oras.utils.Const; import land.oras.utils.JsonUtils; @@ -92,6 +95,44 @@ public List getManifests() { return manifests; } + /** + * Return a new index with new manifest added to index + * @param manifest The manifest + * @return The index + */ + public Index withNewManifests(ManifestDescriptor manifest) { + List newManifests = new LinkedList<>(); + for (ManifestDescriptor descriptor : manifests) { + + // Ignore same digest + if (descriptor.getDigest().equals(manifest.getDigest())) { + continue; + } + + // Move previous ref + if (descriptor.getAnnotations() != null + && descriptor.getAnnotations().containsKey(Const.ANNOTATION_REF) + && manifest.getAnnotations() != null + && manifest.getAnnotations().containsKey(Const.ANNOTATION_REF) + && descriptor + .getAnnotations() + .get(Const.ANNOTATION_REF) + .equals(manifest.getAnnotations().get(Const.ANNOTATION_REF))) { + Map newAnnotations = new LinkedHashMap<>(descriptor.getAnnotations()); + newAnnotations.remove(Const.ANNOTATION_REF); + if (newAnnotations.isEmpty()) { + newAnnotations = null; + } + newManifests.add(ManifestDescriptor.fromJson( + descriptor.withAnnotations(newAnnotations).toJson())); + continue; + } + newManifests.add(ManifestDescriptor.fromJson(descriptor.toJson())); + } + newManifests.add(manifest); + return new Index(schemaVersion, mediaType, artifactType, newManifests, descriptor, json); + } + /** * Get the descriptor * @return The descriptor diff --git a/src/main/java/land/oras/ManifestDescriptor.java b/src/main/java/land/oras/ManifestDescriptor.java index 130342db..ccf03a91 100644 --- a/src/main/java/land/oras/ManifestDescriptor.java +++ b/src/main/java/land/oras/ManifestDescriptor.java @@ -108,7 +108,7 @@ public Map getPlatform() { * Get the annotations * @return The annotations */ - public Map getAnnotations() { + public @Nullable Map getAnnotations() { return annotations; } @@ -137,6 +137,15 @@ public Subject toSubject() { return Subject.of(mediaType, digest, size); } + /** + * Create a manifest descriptor with the given annotations + * @param annotations The annotations + * @return The subject + */ + public ManifestDescriptor withAnnotations(@Nullable Map annotations) { + return new ManifestDescriptor(artifactType, mediaType, digest, size, platform, annotations); + } + /** * Create a manifest descriptor * @param mediaType The media type diff --git a/src/main/java/land/oras/OCI.java b/src/main/java/land/oras/OCI.java index 750b4770..0f968eb4 100644 --- a/src/main/java/land/oras/OCI.java +++ b/src/main/java/land/oras/OCI.java @@ -119,6 +119,14 @@ public abstract Manifest pushArtifact( */ public abstract void pullArtifact(T ref, Path path, boolean overwrite); + /** + * Push a manifest + * @param ref The ref + * @param manifest The manifest + * @return The location + */ + public abstract Manifest pushManifest(T ref, Manifest manifest); + /** * Get the blob for the given digest. Not be suitable for large blobs * @param ref The ref diff --git a/src/main/java/land/oras/OCILayout.java b/src/main/java/land/oras/OCILayout.java index df1fdf4f..af464db2 100644 --- a/src/main/java/land/oras/OCILayout.java +++ b/src/main/java/land/oras/OCILayout.java @@ -85,8 +85,41 @@ public void pullArtifact(LayoutRef ref, Path path, boolean overwrite) { } @Override - public byte[] getBlob(LayoutRef containerRef) { - try (InputStream is = fetchBlob(containerRef)) { + public Manifest pushManifest(LayoutRef layoutRef, Manifest manifest) { + + // For portability each layer should have at least one entry + if (manifest.getLayers().isEmpty()) { + Config config = manifest.getConfig(); + Layer configLayer = Layer.fromJson(config.toJson()); + manifest = manifest.withLayers(List.of(configLayer)); + } + + // Create the manifest descriptor with ref if tag is present + byte[] manifestData = manifest.toJson().getBytes(); + String manifestDigest = + SupportedAlgorithm.getDefault().digest(manifest.toJson().getBytes()); + ManifestDescriptor manifestDescriptor = + ManifestDescriptor.of(Const.DEFAULT_MANIFEST_MEDIA_TYPE, manifestDigest, manifestData.length); + if (layoutRef.getTag() != null) { + manifestDescriptor = manifestDescriptor.withAnnotations(Map.of(Const.ANNOTATION_REF, layoutRef.getTag())); + } + manifest = manifest.withDescriptor(manifestDescriptor); + + Index index = Index.fromPath(getIndexPath()).withNewManifests(manifestDescriptor); + + // Write blobs + try { + writeManifest(manifest); + writeIndex(index); + } catch (IOException e) { + throw new OrasException("Failed to write manifest", e); + } + return manifest; + } + + @Override + public byte[] getBlob(LayoutRef layoutRef) { + try (InputStream is = fetchBlob(layoutRef)) { return is.readAllBytes(); } catch (IOException e) { throw new OrasException("Failed to get blob", e); diff --git a/src/main/java/land/oras/Registry.java b/src/main/java/land/oras/Registry.java index d9bc516f..cfd61de5 100644 --- a/src/main/java/land/oras/Registry.java +++ b/src/main/java/land/oras/Registry.java @@ -176,12 +176,7 @@ public void deleteManifest(ContainerRef containerRef) { handleError(response); } - /** - * Push a manifest - * @param containerRef The container - * @param manifest The manifest - * @return The location - */ + @Override public Manifest pushManifest(ContainerRef containerRef, Manifest manifest) { Map annotations = manifest.getAnnotations(); diff --git a/src/test/java/land/oras/IndexTest.java b/src/test/java/land/oras/IndexTest.java index 9013d8c1..161f6617 100644 --- a/src/test/java/land/oras/IndexTest.java +++ b/src/test/java/land/oras/IndexTest.java @@ -23,6 +23,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import java.util.List; +import java.util.Map; +import land.oras.utils.Const; +import land.oras.utils.SupportedAlgorithm; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; @@ -47,4 +51,106 @@ void shouldReadAndWriteIndex() { assertEquals(json, index.toJson()); index.toJson(); } + + @Test + void shouldAddManifest() { + Index index = Index.fromManifests(List.of()); + index = index.withNewManifests(Manifest.empty().getDescriptor()); + assertEquals(1, index.getManifests().size()); + + Manifest newManifest = Manifest.empty().withAnnotations(Map.of("foo", "bar")); + String digest = + SupportedAlgorithm.getDefault().digest(newManifest.toJson().getBytes()); + int size = newManifest.toJson().getBytes().length; + ManifestDescriptor descriptor = ManifestDescriptor.of(Const.DEFAULT_MANIFEST_MEDIA_TYPE, digest, size); + newManifest.withDescriptor(descriptor); + index = index.withNewManifests(descriptor); + assertEquals(2, index.getManifests().size()); + } + + @Test + void shouldNotAddIfSameDigest() { + Index index = Index.fromManifests(List.of()); + index = index.withNewManifests(Manifest.empty().getDescriptor()); + assertEquals(1, index.getManifests().size()); + index = index.withNewManifests(Manifest.empty().getDescriptor()); + assertEquals(1, index.getManifests().size()); + index = index.withNewManifests(Manifest.empty().getDescriptor()); + assertEquals(1, index.getManifests().size()); + } + + @Test + void shouldMoveRefAndSetNullAnnotations() { + Index index = Index.fromManifests(List.of()); + index = index.withNewManifests( + Manifest.empty().getDescriptor().withAnnotations(Map.of(Const.ANNOTATION_REF, "latest"))); + assertEquals(1, index.getManifests().size()); + index = index.withNewManifests(ManifestDescriptor.of(Const.DEFAULT_MANIFEST_MEDIA_TYPE, "sha256:123", 123) + .withAnnotations(Map.of(Const.ANNOTATION_REF, "latest"))); + assertEquals(2, index.getManifests().size()); + + // Ensure 1st descriptor has null annotations + assertNull(index.getManifests().get(0).getAnnotations()); + + // Ensure 2nd descriptor has ref + assertEquals("latest", index.getManifests().get(1).getAnnotations().get(Const.ANNOTATION_REF)); + } + + @Test + void shouldNotMoveRefIfDifferent() { + Index index = Index.fromManifests(List.of()); + index = index.withNewManifests( + Manifest.empty().getDescriptor().withAnnotations(Map.of(Const.ANNOTATION_REF, "latest"))); + assertEquals(1, index.getManifests().size()); + index = index.withNewManifests(ManifestDescriptor.of(Const.DEFAULT_MANIFEST_MEDIA_TYPE, "sha256:123", 123) + .withAnnotations(Map.of(Const.ANNOTATION_REF, "stable"))); + assertEquals(2, index.getManifests().size()); + + // No change + assertEquals("latest", index.getManifests().get(0).getAnnotations().get(Const.ANNOTATION_REF)); + + // Added ref + assertEquals("stable", index.getManifests().get(1).getAnnotations().get(Const.ANNOTATION_REF)); + } + + @Test + void shouldKeepExistingAnnotation() { + Index index = Index.fromManifests(List.of()); + index = index.withNewManifests( + Manifest.empty().getDescriptor().withAnnotations(Map.of(Const.ANNOTATION_REF, "latest", "foo", "bar"))); + assertEquals(1, index.getManifests().size()); + index = index.withNewManifests(ManifestDescriptor.of(Const.DEFAULT_MANIFEST_MEDIA_TYPE, "sha256:123", 123) + .withAnnotations(Map.of(Const.ANNOTATION_REF, "latest"))); + assertEquals(2, index.getManifests().size()); + + // One annotation + assertEquals(1, index.getManifests().get(0).getAnnotations().size()); + assertEquals("bar", index.getManifests().get(0).getAnnotations().get("foo")); + + // Added ref + assertEquals("latest", index.getManifests().get(1).getAnnotations().get(Const.ANNOTATION_REF)); + + // Add one more + index = index.withNewManifests(ManifestDescriptor.of(Const.DEFAULT_MANIFEST_MEDIA_TYPE, "sha256:532", 123) + .withAnnotations(Map.of("test", "hello"))); + assertEquals(3, index.getManifests().size()); + assertEquals(1, index.getManifests().get(0).getAnnotations().size()); + assertEquals("bar", index.getManifests().get(0).getAnnotations().get("foo")); + assertEquals(1, index.getManifests().get(1).getAnnotations().size()); + assertEquals("latest", index.getManifests().get(1).getAnnotations().get(Const.ANNOTATION_REF)); + assertEquals(1, index.getManifests().get(2).getAnnotations().size()); + assertEquals("hello", index.getManifests().get(2).getAnnotations().get("test")); + + // With null annotations + index = index.withNewManifests(ManifestDescriptor.of(Const.DEFAULT_MANIFEST_MEDIA_TYPE, "sha256:789", 123) + .withAnnotations(null)); + assertEquals(4, index.getManifests().size()); + assertEquals(1, index.getManifests().get(0).getAnnotations().size()); + assertEquals("bar", index.getManifests().get(0).getAnnotations().get("foo")); + assertEquals(1, index.getManifests().get(1).getAnnotations().size()); + assertEquals("latest", index.getManifests().get(1).getAnnotations().get(Const.ANNOTATION_REF)); + assertEquals(1, index.getManifests().get(2).getAnnotations().size()); + assertEquals("hello", index.getManifests().get(2).getAnnotations().get("test")); + assertNull(index.getManifests().get(3).getAnnotations()); + } } diff --git a/src/test/java/land/oras/ManifestDescriptorTest.java b/src/test/java/land/oras/ManifestDescriptorTest.java index 2b74960e..0718b126 100644 --- a/src/test/java/land/oras/ManifestDescriptorTest.java +++ b/src/test/java/land/oras/ManifestDescriptorTest.java @@ -22,10 +22,20 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import java.util.Map; +import land.oras.utils.Const; import org.junit.jupiter.api.Test; public class ManifestDescriptorTest { + @Test + void shouldSetAnnotations() { + Manifest manifest = Manifest.empty(); + ManifestDescriptor descriptor = manifest.getDescriptor(); + descriptor = descriptor.withAnnotations(Map.of(Const.ANNOTATION_REF, "latest")); + assertEquals("latest", descriptor.getAnnotations().get(Const.ANNOTATION_REF)); + } + @Test void shouldReadFromJson() { ManifestDescriptor descriptor = ManifestDescriptor.fromJson(descriptor()); diff --git a/src/test/java/land/oras/OCILayoutTest.java b/src/test/java/land/oras/OCILayoutTest.java index 8bf92afe..1ee7c909 100644 --- a/src/test/java/land/oras/OCILayoutTest.java +++ b/src/test/java/land/oras/OCILayoutTest.java @@ -20,16 +20,14 @@ package land.oras; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Map; import land.oras.exception.OrasException; import land.oras.utils.Const; import land.oras.utils.DigestUtils; @@ -59,6 +57,97 @@ public class OCILayoutTest { @Container private final ZotContainer registry = new ZotContainer().withStartupAttempts(3); + @Test + void shouldPushEmptyManifest() { + Path path = layoutPath.resolve("shouldPushManifest"); + LayoutRef layoutRef = LayoutRef.parse("%s".formatted(path.toString())); + OCILayout ociLayout = OCILayout.Builder.builder().defaults(path).build(); + Manifest manifest = Manifest.empty().withConfig(Config.empty()); + manifest = ociLayout.pushManifest(layoutRef, manifest); + + // Assertion + assertOciLayout(path); + assertIndex(path, manifest); + assertBlobExists(path, manifest.getDescriptor().getDigest()); + assertEquals(425, manifest.getDescriptor().getSize()); + + // One element in the index + Index index = JsonUtils.fromJson(path.resolve(Const.OCI_LAYOUT_INDEX), Index.class); + assertEquals(1, index.getManifests().size()); + + // Ensure one layer for compatibility + assertEquals(1, manifest.getLayers().size(), "Should have at least one layer"); + assertLayerExists(path, manifest.getLayers().get(0)); + assertEquals("e30=", manifest.getLayers().get(0).getData()); + + // Copy again + manifest = ociLayout.pushManifest(layoutRef, manifest); + assertEquals(425, manifest.getDescriptor().getSize()); + + // Same manifest + index = JsonUtils.fromJson(path.resolve(Const.OCI_LAYOUT_INDEX), Index.class); + assertEquals(1, index.getManifests().size()); + + // Add an other manifest with different digest + Manifest manifest2 = Manifest.empty().withConfig(Config.empty()).withAnnotations(Map.of("foo", "bar")); + ociLayout.pushManifest(layoutRef, manifest2); + + // Two elements in the index + index = JsonUtils.fromJson(path.resolve(Const.OCI_LAYOUT_INDEX), Index.class); + assertEquals(2, index.getManifests().size()); + + // None have any annotations + index.getManifests().forEach(m -> assertNull(m.getAnnotations())); + } + + @Test + void shouldPushEmptyManifestWithRef() { + Path path = layoutPath.resolve("shouldPushManifest"); + LayoutRef layoutRef = LayoutRef.parse("%s:latest".formatted(path.toString())); + OCILayout ociLayout = OCILayout.Builder.builder().defaults(path).build(); + Manifest manifest = Manifest.empty().withConfig(Config.empty()); + manifest = ociLayout.pushManifest(layoutRef, manifest); + + // Assertion + assertOciLayout(path); + assertIndex(path, manifest); + assertBlobExists(path, manifest.getDescriptor().getDigest()); + assertEquals(425, manifest.getDescriptor().getSize()); + + // One element in the index + Index index = JsonUtils.fromJson(path.resolve(Const.OCI_LAYOUT_INDEX), Index.class); + assertEquals(1, index.getManifests().size()); + + // Check latest tag + assertEquals("latest", index.getManifests().get(0).getAnnotations().get(Const.ANNOTATION_REF)); + + // Ensure one layer for compatibility + assertEquals(1, manifest.getLayers().size(), "Should have at least one layer"); + assertLayerExists(path, manifest.getLayers().get(0)); + assertEquals("e30=", manifest.getLayers().get(0).getData()); + + // Copy again + manifest = ociLayout.pushManifest(layoutRef, manifest); + assertEquals(425, manifest.getDescriptor().getSize()); + + index = JsonUtils.fromJson(path.resolve(Const.OCI_LAYOUT_INDEX), Index.class); + assertEquals(1, index.getManifests().size()); + + // Add an other manifest with different digest + Manifest manifest2 = Manifest.empty().withConfig(Config.empty()).withAnnotations(Map.of("foo", "bar")); + ociLayout.pushManifest(layoutRef, manifest2); + + // Two elements in the index + index = JsonUtils.fromJson(path.resolve(Const.OCI_LAYOUT_INDEX), Index.class); + assertEquals(2, index.getManifests().size()); + + // Ensure manifest1 doesn't have any annotations + assertNull(index.getManifests().get(0).getAnnotations()); + + // Ref was moved to manifest2 + assertEquals("latest", index.getManifests().get(1).getAnnotations().get(Const.ANNOTATION_REF)); + } + @Test void shouldEnforceTagWhenPullArtifact() throws IOException { LayoutRef layoutRef = LayoutRef.parse("src/test/resources/oci/artifact"); @@ -460,12 +549,14 @@ private void assertIndex(Path ociLayoutPath, Manifest manifest) { } private void assertLayerExists(Path ociLayoutPath, Layer layer) { - assertTrue( - Files.exists(ociLayoutPath - .resolve("blobs") - .resolve("sha256") - .resolve(SupportedAlgorithm.getDigest(layer.getDigest()))), - "Expect layer to exist"); + if (layer.getData() == null) { + assertTrue( + Files.exists(ociLayoutPath + .resolve("blobs") + .resolve("sha256") + .resolve(SupportedAlgorithm.getDigest(layer.getDigest()))), + "Expect layer to exist"); + } } private void assertBlobExists(Path ociLayoutPath, String digest) {