diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java index 367e52d616..abe6bf2a69 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,6 @@ package org.springframework.boot.buildpack.platform.build; import java.io.IOException; -import java.nio.file.Path; import java.util.List; import java.util.function.Consumer; @@ -33,6 +32,7 @@ import org.springframework.boot.buildpack.platform.docker.transport.DockerEngine import org.springframework.boot.buildpack.platform.docker.type.Image; import org.springframework.boot.buildpack.platform.docker.type.ImageReference; import org.springframework.boot.buildpack.platform.io.IOBiConsumer; +import org.springframework.boot.buildpack.platform.io.TarArchive; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -273,8 +273,9 @@ public class Builder { } @Override - public void exportImageLayers(ImageReference reference, IOBiConsumer exports) throws IOException { - Builder.this.docker.image().exportLayerFiles(reference, exports); + public void exportImageLayers(ImageReference reference, IOBiConsumer exports) + throws IOException { + Builder.this.docker.image().exportLayers(reference, exports); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildpackResolverContext.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildpackResolverContext.java index fd8e59feff..4357ca8b38 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildpackResolverContext.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/BuildpackResolverContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,12 +17,12 @@ package org.springframework.boot.buildpack.platform.build; import java.io.IOException; -import java.nio.file.Path; import java.util.List; import org.springframework.boot.buildpack.platform.docker.type.Image; import org.springframework.boot.buildpack.platform.docker.type.ImageReference; import org.springframework.boot.buildpack.platform.io.IOBiConsumer; +import org.springframework.boot.buildpack.platform.io.TarArchive; /** * Context passed to a {@link BuildpackResolver}. @@ -52,6 +52,6 @@ interface BuildpackResolverContext { * during the callback) * @throws IOException on IO error */ - void exportImageLayers(ImageReference reference, IOBiConsumer exports) throws IOException; + void exportImageLayers(ImageReference reference, IOBiConsumer exports) throws IOException; } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/ImageBuildpack.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/ImageBuildpack.java index 827f4a8da0..7bb824edaf 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/ImageBuildpack.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/ImageBuildpack.java @@ -36,6 +36,7 @@ import org.springframework.boot.buildpack.platform.docker.type.ImageReference; import org.springframework.boot.buildpack.platform.docker.type.Layer; import org.springframework.boot.buildpack.platform.docker.type.LayerId; import org.springframework.boot.buildpack.platform.io.IOConsumer; +import org.springframework.boot.buildpack.platform.io.TarArchive; import org.springframework.util.StreamUtils; /** @@ -115,31 +116,31 @@ final class ImageBuildpack implements Buildpack { ExportedLayers(BuildpackResolverContext context, ImageReference imageReference) throws IOException { List layerFiles = new ArrayList<>(); - context.exportImageLayers(imageReference, (name, path) -> layerFiles.add(copyToTemp(path))); + context.exportImageLayers(imageReference, + (name, tarArchive) -> layerFiles.add(createLayerFile(tarArchive))); this.layerFiles = Collections.unmodifiableList(layerFiles); } - private Path copyToTemp(Path path) throws IOException { - Path outputPath = Files.createTempFile("create-builder-scratch-", null); - try (OutputStream out = Files.newOutputStream(outputPath)) { - copyLayerTar(path, out); + private Path createLayerFile(TarArchive tarArchive) throws IOException { + Path sourceTarFile = Files.createTempFile("create-builder-scratch-source-", null); + try (OutputStream out = Files.newOutputStream(sourceTarFile)) { + tarArchive.writeTo(out); } - return outputPath; - } - - private void copyLayerTar(Path path, OutputStream out) throws IOException { - try (TarArchiveInputStream tarIn = new TarArchiveInputStream(Files.newInputStream(path)); - TarArchiveOutputStream tarOut = new TarArchiveOutputStream(out)) { - tarOut.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); - TarArchiveEntry entry = tarIn.getNextEntry(); - while (entry != null) { - tarOut.putArchiveEntry(entry); - StreamUtils.copy(tarIn, tarOut); - tarOut.closeArchiveEntry(); - entry = tarIn.getNextEntry(); + Path layerFile = Files.createTempFile("create-builder-scratch-", null); + try (TarArchiveOutputStream out = new TarArchiveOutputStream(Files.newOutputStream(layerFile))) { + try (TarArchiveInputStream in = new TarArchiveInputStream(Files.newInputStream(sourceTarFile))) { + out.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); + TarArchiveEntry entry = in.getNextEntry(); + while (entry != null) { + out.putArchiveEntry(entry); + StreamUtils.copy(in, out); + out.closeArchiveEntry(); + entry = in.getNextEntry(); + } + out.finish(); } - tarOut.finish(); } + return layerFile; } void apply(IOConsumer layers) throws IOException { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java index bbe0765f93..6e7263af88 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java @@ -16,16 +16,10 @@ package org.springframework.boot.buildpack.platform.docker; -import java.io.BufferedReader; -import java.io.ByteArrayInputStream; -import java.io.FileInputStream; import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; import java.io.OutputStream; import java.net.URI; import java.net.URISyntaxException; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; @@ -33,10 +27,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Objects; -import java.util.stream.Collectors; -import org.apache.commons.compress.archivers.tar.TarArchiveEntry; -import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; import org.apache.hc.core5.net.URIBuilder; import org.springframework.boot.buildpack.platform.docker.configuration.DockerConfiguration.DockerHostConfiguration; @@ -48,7 +39,6 @@ import org.springframework.boot.buildpack.platform.docker.type.ContainerReferenc import org.springframework.boot.buildpack.platform.docker.type.ContainerStatus; import org.springframework.boot.buildpack.platform.docker.type.Image; import org.springframework.boot.buildpack.platform.docker.type.ImageArchive; -import org.springframework.boot.buildpack.platform.docker.type.ImageArchiveManifest; import org.springframework.boot.buildpack.platform.docker.type.ImageReference; import org.springframework.boot.buildpack.platform.docker.type.VolumeName; import org.springframework.boot.buildpack.platform.io.IOBiConsumer; @@ -56,7 +46,6 @@ import org.springframework.boot.buildpack.platform.io.TarArchive; import org.springframework.boot.buildpack.platform.json.JsonStream; import org.springframework.boot.buildpack.platform.json.SharedObjectMapper; import org.springframework.util.Assert; -import org.springframework.util.StreamUtils; import org.springframework.util.StringUtils; /** @@ -263,7 +252,35 @@ public class DockerApi { } /** - * Export the layers of an image as {@link TarArchive}s. + * Export the layers of an image as paths to layer tar files. + * @param reference the reference to export + * @param exports a consumer to receive the layer tar file paths (file can only be + * accessed during the callback) + * @throws IOException on IO error + * @since 2.7.10 + * @deprecated since 3.2.6 for removal in 3.5.0 in favor of + * {@link #exportLayers(ImageReference, IOBiConsumer)} + */ + @Deprecated(since = "3.2.6", forRemoval = true) + public void exportLayerFiles(ImageReference reference, IOBiConsumer exports) throws IOException { + Assert.notNull(reference, "Reference must not be null"); + Assert.notNull(exports, "Exports must not be null"); + exportLayers(reference, (name, archive) -> { + Path path = Files.createTempFile("docker-export-layer-files-", null); + try { + try (OutputStream out = Files.newOutputStream(path)) { + archive.writeTo(out); + exports.accept(name, path); + } + } + finally { + Files.delete(path); + } + }); + } + + /** + * Export the layers of an image as {@link TarArchive TarArchives}. * @param reference the reference to export * @param exports a consumer to receive the layers (contents can only be accessed * during the callback) @@ -271,41 +288,14 @@ public class DockerApi { */ public void exportLayers(ImageReference reference, IOBiConsumer exports) throws IOException { - exportLayerFiles(reference, (name, path) -> { - try (InputStream in = Files.newInputStream(path)) { - TarArchive archive = (out) -> StreamUtils.copy(in, out); - exports.accept(name, archive); - } - }); - } - - /** - * Export the layers of an image as paths to layer tar files. - * @param reference the reference to export - * @param exports a consumer to receive the layer tar file paths (file can only be - * accessed during the callback) - * @throws IOException on IO error - * @since 2.7.10 - */ - public void exportLayerFiles(ImageReference reference, IOBiConsumer exports) throws IOException { Assert.notNull(reference, "Reference must not be null"); Assert.notNull(exports, "Exports must not be null"); - URI saveUri = buildUrl("/images/" + reference + "/get"); - Response response = http().get(saveUri); - Path exportFile = copyToTemp(response.getContent()); - ImageArchiveManifest manifest = getManifest(reference, exportFile); - try (TarArchiveInputStream tar = new TarArchiveInputStream(new FileInputStream(exportFile.toFile()))) { - TarArchiveEntry entry = tar.getNextEntry(); - while (entry != null) { - if (manifestContainsLayerEntry(manifest, entry.getName())) { - Path layerFile = copyToTemp(tar); - exports.accept(entry.getName(), layerFile); - Files.delete(layerFile); - } - entry = tar.getNextEntry(); + URI uri = buildUrl("/images/" + reference + "/get"); + try (Response response = http().get(uri)) { + try (ExportedImageTar exportedImageTar = new ExportedImageTar(reference, response.getContent())) { + exportedImageTar.exportLayers(exports); } } - Files.delete(exportFile); } /** @@ -345,37 +335,6 @@ public class DockerApi { http().post(uri).close(); } - private ImageArchiveManifest getManifest(ImageReference reference, Path exportFile) throws IOException { - try (TarArchiveInputStream tar = new TarArchiveInputStream(new FileInputStream(exportFile.toFile()))) { - TarArchiveEntry entry = tar.getNextEntry(); - while (entry != null) { - if (entry.getName().equals("manifest.json")) { - return readManifest(tar); - } - entry = tar.getNextEntry(); - } - } - throw new IllegalArgumentException("Manifest not found in image " + reference); - } - - private ImageArchiveManifest readManifest(TarArchiveInputStream tar) throws IOException { - String manifestContent = new BufferedReader(new InputStreamReader(tar, StandardCharsets.UTF_8)).lines() - .collect(Collectors.joining()); - return ImageArchiveManifest.of(new ByteArrayInputStream(manifestContent.getBytes(StandardCharsets.UTF_8))); - } - - private Path copyToTemp(InputStream in) throws IOException { - Path path = Files.createTempFile("create-builder-scratch-", null); - try (OutputStream out = Files.newOutputStream(path)) { - StreamUtils.copy(in, out); - } - return path; - } - - private boolean manifestContainsLayerEntry(ImageArchiveManifest manifest, String layerId) { - return manifest.getEntries().stream().anyMatch((content) -> content.getLayers().contains(layerId)); - } - } /** @@ -458,8 +417,9 @@ public class DockerApi { public ContainerStatus wait(ContainerReference reference) throws IOException { Assert.notNull(reference, "Reference must not be null"); URI uri = buildUrl("/containers/" + reference + "/wait"); - Response response = http().post(uri); - return ContainerStatus.of(response.getContent()); + try (Response response = http().post(uri)) { + return ContainerStatus.of(response.getContent()); + } } /** diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ExportedImageTar.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ExportedImageTar.java new file mode 100644 index 0000000000..c563b3734f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ExportedImageTar.java @@ -0,0 +1,261 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.buildpack.platform.docker; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; + +import org.springframework.boot.buildpack.platform.docker.type.BlobReference; +import org.springframework.boot.buildpack.platform.docker.type.ImageArchiveIndex; +import org.springframework.boot.buildpack.platform.docker.type.ImageArchiveManifest; +import org.springframework.boot.buildpack.platform.docker.type.ImageReference; +import org.springframework.boot.buildpack.platform.docker.type.Manifest; +import org.springframework.boot.buildpack.platform.docker.type.ManifestList; +import org.springframework.boot.buildpack.platform.io.IOBiConsumer; +import org.springframework.boot.buildpack.platform.io.TarArchive; +import org.springframework.boot.buildpack.platform.io.TarArchive.Compression; +import org.springframework.util.Assert; +import org.springframework.util.function.ThrowingFunction; + +/** + * Internal helper class used by the {@link DockerApi} to extract layers from an exported + * image tar. + * + * @author Phillip Webb + * @author Moritz Halbritter + * @author Scott Frederick + */ +class ExportedImageTar implements Closeable { + + private final Path tarFile; + + private final LayerArchiveFactory layerArchiveFactory; + + ExportedImageTar(ImageReference reference, InputStream inputStream) throws IOException { + this.tarFile = Files.createTempFile("docker-layers-", null); + Files.copy(inputStream, this.tarFile, StandardCopyOption.REPLACE_EXISTING); + this.layerArchiveFactory = LayerArchiveFactory.create(reference, this.tarFile); + } + + void exportLayers(IOBiConsumer exports) throws IOException { + try (TarArchiveInputStream tar = openTar(this.tarFile)) { + TarArchiveEntry entry = tar.getNextEntry(); + while (entry != null) { + TarArchive layerArchive = this.layerArchiveFactory.getLayerArchive(tar, entry); + if (layerArchive != null) { + exports.accept(entry.getName(), layerArchive); + } + entry = tar.getNextEntry(); + } + } + } + + private static TarArchiveInputStream openTar(Path path) throws IOException { + return new TarArchiveInputStream(Files.newInputStream(path)); + } + + @Override + public void close() throws IOException { + Files.delete(this.tarFile); + } + + /** + * Factory class used to create a {@link TarArchiveEntry} for layer. + */ + private abstract static class LayerArchiveFactory { + + /** + * Create a new {@link TarArchive} if the given entry represents a layer. + * @param tar the tar input stream + * @param entry the candidate entry + * @return a new {@link TarArchive} instance or {@code null} if this entry is not + * a layer. + */ + abstract TarArchive getLayerArchive(TarArchiveInputStream tar, TarArchiveEntry entry); + + /** + * Create a new {@link LayerArchiveFactory} for the given tar file using either + * the {@code index.json} or {@code manifest.json} to detect layers. + * @param reference the image that was referenced + * @param tarFile the source tar file + * @return a new {@link LayerArchiveFactory} instance + * @throws IOException on IO error + */ + static LayerArchiveFactory create(ImageReference reference, Path tarFile) throws IOException { + try (TarArchiveInputStream tar = openTar(tarFile)) { + ImageArchiveIndex index = null; + ImageArchiveManifest manifest = null; + TarArchiveEntry entry = tar.getNextEntry(); + while (entry != null) { + if ("index.json".equals(entry.getName())) { + index = ImageArchiveIndex.of(tar); + break; + } + if ("manifest.json".equals(entry.getName())) { + manifest = ImageArchiveManifest.of(tar); + } + entry = tar.getNextEntry(); + } + Assert.state(index != null || manifest != null, + "Exported image '%s' does not contain 'index.json' or 'manifest.json'".formatted(reference)); + return (index != null) ? new IndexLayerArchiveFactory(tarFile, index) + : new ManifestLayerArchiveFactory(tarFile, manifest); + } + } + + } + + /** + * {@link LayerArchiveFactory} backed by the more recent {@code index.json} file. + */ + private static class IndexLayerArchiveFactory extends LayerArchiveFactory { + + private final Map layerMediaTypes; + + IndexLayerArchiveFactory(Path tarFile, ImageArchiveIndex index) throws IOException { + Set manifestDigests = getDigests(index, this::isManifest); + List manifestLists = getManifestLists(tarFile, getDigests(index, this::isManifestList)); + List manifests = getManifests(tarFile, manifestDigests, manifestLists); + this.layerMediaTypes = manifests.stream() + .flatMap((manifest) -> manifest.getLayers().stream()) + .collect(Collectors.toMap(this::getEntryName, BlobReference::getMediaType)); + } + + private Set getDigests(ImageArchiveIndex index, Predicate predicate) { + return index.getManifests() + .stream() + .filter(predicate) + .map(BlobReference::getDigest) + .collect(Collectors.toUnmodifiableSet()); + } + + private List getManifestLists(Path tarFile, Set digests) throws IOException { + return getDigestMatches(tarFile, digests, ManifestList::of); + } + + private List getManifests(Path tarFile, Set manifestDigests, List manifestLists) + throws IOException { + Set digests = new HashSet<>(manifestDigests); + manifestLists.stream() + .flatMap(ManifestList::streamManifests) + .filter(this::isManifest) + .map(BlobReference::getDigest) + .forEach(digests::add); + return getDigestMatches(tarFile, digests, Manifest::of); + } + + private List getDigestMatches(Path tarFile, Set digests, + ThrowingFunction factory) throws IOException { + if (digests.isEmpty()) { + return Collections.emptyList(); + } + Set names = digests.stream().map(this::getEntryName).collect(Collectors.toUnmodifiableSet()); + List result = new ArrayList<>(); + try (TarArchiveInputStream tar = openTar(tarFile)) { + TarArchiveEntry entry = tar.getNextEntry(); + while (entry != null) { + if (names.contains(entry.getName())) { + result.add(factory.apply(tar)); + } + entry = tar.getNextEntry(); + } + } + return Collections.unmodifiableList(result); + } + + private boolean isManifest(BlobReference reference) { + return isJsonWithPrefix(reference.getMediaType(), "application/vnd.oci.image.manifest.v") + || isJsonWithPrefix(reference.getMediaType(), "application/vnd.docker.distribution.manifest.v"); + } + + private boolean isManifestList(BlobReference reference) { + return isJsonWithPrefix(reference.getMediaType(), "application/vnd.docker.distribution.manifest.list.v"); + } + + private boolean isJsonWithPrefix(String mediaType, String prefix) { + return mediaType.startsWith(prefix) && mediaType.endsWith("+json"); + } + + private String getEntryName(BlobReference reference) { + return getEntryName(reference.getDigest()); + } + + private String getEntryName(String digest) { + return "blobs/" + digest.replace(':', '/'); + } + + @Override + TarArchive getLayerArchive(TarArchiveInputStream tar, TarArchiveEntry entry) { + String mediaType = this.layerMediaTypes.get(entry.getName()); + if (mediaType == null) { + return null; + } + return TarArchive.fromInputStream(tar, getCompression(mediaType)); + } + + private Compression getCompression(String mediaType) { + if (mediaType.endsWith(".tar.gzip")) { + return Compression.GZIP; + } + if (mediaType.endsWith(".tar.zstd")) { + return Compression.ZSTD; + } + return Compression.NONE; + } + + } + + /** + * {@link LayerArchiveFactory} backed by the legacy {@code manifest.json} file. + */ + private static class ManifestLayerArchiveFactory extends LayerArchiveFactory { + + private Set layers; + + ManifestLayerArchiveFactory(Path tarFile, ImageArchiveManifest manifest) { + this.layers = manifest.getEntries() + .stream() + .flatMap((entry) -> entry.getLayers().stream()) + .collect(Collectors.toUnmodifiableSet()); + } + + @Override + TarArchive getLayerArchive(TarArchiveInputStream tar, TarArchiveEntry entry) { + if (!this.layers.contains(entry.getName())) { + return null; + } + return TarArchive.fromInputStream(tar, Compression.NONE); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/BlobReference.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/BlobReference.java new file mode 100644 index 0000000000..ed2b1a2848 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/BlobReference.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.buildpack.platform.docker.type; + +import java.lang.invoke.MethodHandles; + +import com.fasterxml.jackson.databind.JsonNode; + +import org.springframework.boot.buildpack.platform.json.MappedObject; + +/** + * A reference to a blob by its digest. + * + * @author Phillip Webb + * @since 3.2.6 + */ +public class BlobReference extends MappedObject { + + private final String digest; + + private final String mediaType; + + BlobReference(JsonNode node) { + super(node, MethodHandles.lookup()); + this.digest = valueAt("/digest", String.class); + this.mediaType = valueAt("/mediaType", String.class); + } + + /** + * Return the digest of the blob. + * @return the blob digest + */ + public String getDigest() { + return this.digest; + } + + /** + * Return the media type of the blob. + * @return the blob media type + */ + public String getMediaType() { + return this.mediaType; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/Image.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/Image.java index 24a488bd4a..dd224c8dab 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/Image.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/Image.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,7 +19,6 @@ package org.springframework.boot.buildpack.platform.docker.type; import java.io.IOException; import java.io.InputStream; import java.lang.invoke.MethodHandles; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -48,22 +47,13 @@ public class Image extends MappedObject { Image(JsonNode node) { super(node, MethodHandles.lookup()); - this.digests = getDigests(getNode().at("/RepoDigests")); + this.digests = childrenAt("/RepoDigests", JsonNode::asText); this.config = new ImageConfig(getNode().at("/Config")); this.layers = extractLayers(valueAt("/RootFS/Layers", String[].class)); this.os = valueAt("/Os", String.class); this.created = valueAt("/Created", String.class); } - private List getDigests(JsonNode node) { - if (node.isEmpty()) { - return Collections.emptyList(); - } - List digests = new ArrayList<>(); - node.forEach((child) -> digests.add(child.asText())); - return Collections.unmodifiableList(digests); - } - private List extractLayers(String[] layers) { if (layers == null) { return Collections.emptyList(); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveIndex.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveIndex.java new file mode 100644 index 0000000000..df117703c5 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveIndex.java @@ -0,0 +1,67 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.buildpack.platform.docker.type; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.invoke.MethodHandles; +import java.util.List; + +import com.fasterxml.jackson.databind.JsonNode; + +import org.springframework.boot.buildpack.platform.json.MappedObject; + +/** + * Image archive index information as provided by {@code index.json}. + * + * @author Phillip Webb + * @since 3.2.6 + * @see OCI Image Index + * Specification + */ +public class ImageArchiveIndex extends MappedObject { + + private final Integer schemaVersion; + + private final List manifests; + + protected ImageArchiveIndex(JsonNode node) { + super(node, MethodHandles.lookup()); + this.schemaVersion = valueAt("/schemaVersion", Integer.class); + this.manifests = childrenAt("/manifests", BlobReference::new); + } + + public Integer getSchemaVersion() { + return this.schemaVersion; + } + + public List getManifests() { + return this.manifests; + } + + /** + * Create an {@link ImageArchiveIndex} from the provided JSON input stream. + * @param content the JSON input stream + * @return a new {@link ImageArchiveIndex} instance + * @throws IOException on IO error + */ + public static ImageArchiveIndex of(InputStream content) throws IOException { + return of(content, ImageArchiveIndex::new); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveManifest.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveManifest.java index 9c8c1e8cce..1f5f70bdf9 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveManifest.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveManifest.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,7 +19,6 @@ package org.springframework.boot.buildpack.platform.docker.type; import java.io.IOException; import java.io.InputStream; import java.lang.invoke.MethodHandles; -import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -28,18 +27,18 @@ import com.fasterxml.jackson.databind.JsonNode; import org.springframework.boot.buildpack.platform.json.MappedObject; /** - * Image archive manifest information. + * Image archive manifest information as provided by {@code manifest.json}. * * @author Scott Frederick * @since 2.7.10 */ public class ImageArchiveManifest extends MappedObject { - private final List entries = new ArrayList<>(); + private final List entries; protected ImageArchiveManifest(JsonNode node) { super(node, MethodHandles.lookup()); - getNode().elements().forEachRemaining((element) -> this.entries.add(ManifestEntry.of(element))); + this.entries = childrenAt(null, ManifestEntry::new); } /** @@ -77,10 +76,6 @@ public class ImageArchiveManifest extends MappedObject { return this.layers; } - static ManifestEntry of(JsonNode node) { - return new ManifestEntry(node); - } - @SuppressWarnings("unchecked") private List extractLayers() { List layers = valueAt("/Layers", List.class); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/Manifest.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/Manifest.java new file mode 100644 index 0000000000..d3d9e50b25 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/Manifest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.buildpack.platform.docker.type; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.invoke.MethodHandles; +import java.util.List; + +import com.fasterxml.jackson.databind.JsonNode; + +import org.springframework.boot.buildpack.platform.json.MappedObject; + +/** + * A manifest as defined in {@code application/vnd.docker.distribution.manifest} or + * {@code application/vnd.oci.image.manifest} files. + * + * @author Phillip Webb + * @since 3.2.6 + * @see OCI + * Image Manifest Specification + */ +public class Manifest extends MappedObject { + + private final Integer schemaVersion; + + private final String mediaType; + + private final List layers; + + protected Manifest(JsonNode node) { + super(node, MethodHandles.lookup()); + this.schemaVersion = valueAt("/schemaVersion", Integer.class); + this.mediaType = valueAt("/mediaType", String.class); + this.layers = childrenAt("/layers", BlobReference::new); + } + + public Integer getSchemaVersion() { + return this.schemaVersion; + } + + public String getMediaType() { + return this.mediaType; + } + + public List getLayers() { + return this.layers; + } + + /** + * Create an {@link Manifest} from the provided JSON input stream. + * @param content the JSON input stream + * @return a new {@link Manifest} instance + * @throws IOException on IO error + */ + public static Manifest of(InputStream content) throws IOException { + return of(content, Manifest::new); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ManifestList.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ManifestList.java new file mode 100644 index 0000000000..5d5fbcb64f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ManifestList.java @@ -0,0 +1,79 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.buildpack.platform.docker.type; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.invoke.MethodHandles; +import java.util.List; +import java.util.stream.Stream; + +import com.fasterxml.jackson.databind.JsonNode; + +import org.springframework.boot.buildpack.platform.json.MappedObject; + +/** + * A distribution manifest list as defined in + * {@code application/vnd.docker.distribution.manifest.list} files. + * + * @author Phillip Webb + * @since 3.2.6 + * @see OCI + * Image Manifest Specification + */ +public class ManifestList extends MappedObject { + + private final Integer schemaVersion; + + private final String mediaType; + + private final List manifests; + + protected ManifestList(JsonNode node) { + super(node, MethodHandles.lookup()); + this.schemaVersion = valueAt("/schemaVersion", Integer.class); + this.mediaType = valueAt("/mediaType", String.class); + this.manifests = childrenAt("/manifests", BlobReference::new); + } + + public Integer getSchemaVersion() { + return this.schemaVersion; + } + + public String getMediaType() { + return this.mediaType; + } + + public Stream streamManifests() { + return getManifests().stream(); + } + + public List getManifests() { + return this.manifests; + } + + /** + * Create an {@link ManifestList} from the provided JSON input stream. + * @param content the JSON input stream + * @return a new {@link ManifestList} instance + * @throws IOException on IO error + */ + public static ManifestList of(InputStream content) throws IOException { + return of(content, ManifestList::new); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/io/TarArchive.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/io/TarArchive.java index 70a8e7ca97..c5d6e99ba1 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/io/TarArchive.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/io/TarArchive.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,10 +18,15 @@ package org.springframework.boot.buildpack.platform.io; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.time.Instant; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.util.zip.GZIPInputStream; + +import org.springframework.util.StreamUtils; +import org.springframework.util.function.ThrowingFunction; /** * A TAR archive that can be written to an output stream. @@ -45,6 +50,15 @@ public interface TarArchive { */ void writeTo(OutputStream outputStream) throws IOException; + /** + * Return the compression being used with the tar archive. + * @return the used compression + * @since 3.2.6 + */ + default Compression getCompression() { + return Compression.NONE; + } + /** * Factory method to create a new {@link TarArchive} instance with a specific layout. * @param layout the TAR layout @@ -68,4 +82,68 @@ public interface TarArchive { return new ZipFileTarArchive(zip, owner); } + /** + * Factory method to adapt a ZIP file to {@link TarArchive}. Assumes that + * {@link #writeTo(OutputStream)} will only be called once. + * @param inputStream the source input stream + * @param compression the compression used + * @return a new {@link TarArchive} instance + * @since 3.2.6 + */ + static TarArchive fromInputStream(InputStream inputStream, Compression compression) { + return new TarArchive() { + + @Override + public void writeTo(OutputStream outputStream) throws IOException { + StreamUtils.copy(compression.uncompress(inputStream), outputStream); + } + + @Override + public Compression getCompression() { + return compression; + } + + }; + } + + /** + * Compression type applied to the archive. + * + * @since 3.2.6 + */ + enum Compression { + + /** + * The tar file is not compressed. + */ + NONE((inputStream) -> inputStream), + + /** + * The tar file is compressed using gzip. + */ + GZIP(GZIPInputStream::new), + + /** + * The tar file is compressed using zstd. + */ + ZSTD("zstd compression is not supported"); + + private final ThrowingFunction uncompressor; + + Compression(String uncompressError) { + this((inputStream) -> { + throw new IllegalStateException(uncompressError); + }); + } + + Compression(ThrowingFunction wrapper) { + this.uncompressor = wrapper; + } + + InputStream uncompress(InputStream inputStream) { + return this.uncompressor.apply(inputStream); + } + + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/json/MappedObject.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/json/MappedObject.java index 1e51ef5e4b..08a16a206a 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/json/MappedObject.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/json/MappedObject.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,12 +23,16 @@ import java.lang.invoke.MethodHandles.Lookup; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.function.Function; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.util.Assert; +import org.springframework.util.StreamUtils; /** * Base class for mapped JSON objects. @@ -71,6 +75,25 @@ public class MappedObject { return valueAt(this, this.node, this.lookup, expression, type); } + /** + * Get children at the given JSON path expression by constructing them using the given + * factory. + * @param the child type + * @param expression the JSON path expression + * @param factory factory used to create the child + * @return a list of children + * @since 3.2.6 + */ + protected List childrenAt(String expression, Function factory) { + JsonNode node = (expression != null) ? this.node.at(expression) : this.node; + if (node.isEmpty()) { + return Collections.emptyList(); + } + List children = new ArrayList<>(); + node.elements().forEachRemaining((childNode) -> children.add(factory.apply(childNode))); + return Collections.unmodifiableList(children); + } + @SuppressWarnings("unchecked") protected static T getRoot(Object proxy) { MappedInvocationHandler handler = (MappedInvocationHandler) Proxy.getInvocationHandler(proxy); @@ -128,7 +151,7 @@ public class MappedObject { */ protected static T of(InputStream content, Function factory) throws IOException { - return of(content, ObjectMapper::readTree, factory); + return of(StreamUtils.nonClosing(content), ObjectMapper::readTree, factory); } /** diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/ImageBuildpackTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/ImageBuildpackTests.java index 7978de12eb..2974be103a 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/ImageBuildpackTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/ImageBuildpackTests.java @@ -19,10 +19,10 @@ package org.springframework.boot.buildpack.platform.build; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.nio.file.Files; -import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Random; @@ -37,6 +37,8 @@ import org.mockito.invocation.InvocationOnMock; import org.springframework.boot.buildpack.platform.docker.type.Image; import org.springframework.boot.buildpack.platform.docker.type.ImageReference; import org.springframework.boot.buildpack.platform.io.IOBiConsumer; +import org.springframework.boot.buildpack.platform.io.TarArchive; +import org.springframework.boot.buildpack.platform.io.TarArchive.Compression; import org.springframework.boot.buildpack.platform.json.AbstractJsonTests; import static org.assertj.core.api.Assertions.assertThat; @@ -176,7 +178,7 @@ class ImageBuildpackTests extends AbstractJsonTests { private Object withMockLayers(InvocationOnMock invocation) { try { - IOBiConsumer consumer = invocation.getArgument(1); + IOBiConsumer consumer = invocation.getArgument(1); File tarFile = File.createTempFile("create-builder-test-", null); FileOutputStream out = new FileOutputStream(tarFile); try (TarArchiveOutputStream tarOut = new TarArchiveOutputStream(out)) { @@ -189,7 +191,7 @@ class ImageBuildpackTests extends AbstractJsonTests { writeTarEntry(tarOut, "/cnb/buildpacks/example_buildpack/0.0.1/" + this.longFilePath); tarOut.finish(); } - consumer.accept("test", tarFile.toPath()); + consumer.accept("test", TarArchive.fromInputStream(new FileInputStream(tarFile), Compression.NONE)); Files.delete(tarFile.toPath()); } catch (IOException ex) { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java index f751ada70c..4f1dce08ac 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java @@ -313,12 +313,14 @@ class DockerApiTests { } @Test + @SuppressWarnings("removal") void exportLayersWhenReferenceIsNullThrowsException() { assertThatIllegalArgumentException().isThrownBy(() -> this.api.exportLayerFiles(null, (name, archive) -> { })).withMessage("Reference must not be null"); } @Test + @SuppressWarnings("removal") void exportLayersWhenExportsIsNullThrowsException() { ImageReference reference = ImageReference.of("gcr.io/paketo-buildpacks/builder:base"); assertThatIllegalArgumentException().isThrownBy(() -> this.api.exportLayerFiles(reference, null)) @@ -393,14 +395,15 @@ class DockerApiTests { } @Test + @SuppressWarnings("removal") void exportLayersWithNoManifestThrowsException() throws Exception { ImageReference reference = ImageReference.of("gcr.io/paketo-buildpacks/builder:base"); URI exportUri = new URI(IMAGES_URL + "/gcr.io/paketo-buildpacks/builder:base/get"); given(DockerApiTests.this.http.get(exportUri)).willReturn(responseOf("export-no-manifest.tar")); - assertThatIllegalArgumentException() - .isThrownBy(() -> this.api.exportLayerFiles(reference, (name, archive) -> { - })) - .withMessageContaining("Manifest not found in image " + reference); + String expectedMessage = "Exported image '%s' does not contain 'index.json' or 'manifest.json'" + .formatted(reference); + assertThatIllegalStateException().isThrownBy(() -> this.api.exportLayerFiles(reference, (name, archive) -> { + })).withMessageContaining(expectedMessage); } @Test diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ExportedImageTarTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ExportedImageTarTests.java new file mode 100644 index 0000000000..253ed2cb03 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ExportedImageTarTests.java @@ -0,0 +1,53 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.buildpack.platform.docker; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.boot.buildpack.platform.docker.type.ImageReference; +import org.springframework.boot.buildpack.platform.io.TarArchive.Compression; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ExportedImageTar}. + * + * @author Phillip Webb + * @author Scott Frederick + */ +class ExportedImageTarTests { + + @ParameterizedTest + @ValueSource(strings = { "export-docker-desktop.tar", "export-docker-desktop-containerd.tar", + "export-docker-desktop-containerd-manifest-list.tar", "export-docker-engine.tar", "export-podman.tar" }) + void test(String tarFile) throws Exception { + ImageReference reference = ImageReference.of("test:latest"); + try (ExportedImageTar exportedImageTar = new ExportedImageTar(reference, + getClass().getResourceAsStream(tarFile))) { + Compression expectedCompression = (!tarFile.contains("containerd")) ? Compression.NONE : Compression.GZIP; + String expectedName = (expectedCompression != Compression.GZIP) + ? "5caae51697b248b905dca1a4160864b0e1a15c300981736555cdce6567e8d477" + : "f0f1fd1bdc71ac6a4dc99cea5f5e45c86c5ec26fe4d1daceeb78207303606429"; + exportedImageTar.exportLayers((name, tarArchive) -> { + assertThat(name).contains(expectedName); + assertThat(tarArchive.getCompression()).isEqualTo(expectedCompression); + }); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveIndexTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveIndexTests.java new file mode 100644 index 0000000000..87117ddd0f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveIndexTests.java @@ -0,0 +1,50 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.buildpack.platform.docker.type; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.buildpack.platform.json.AbstractJsonTests; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ImageArchiveIndex}. + * + * @author Phillip Webb + */ +class ImageArchiveIndexTests extends AbstractJsonTests { + + @Test + void loadJson() throws IOException { + String content = getContentAsString("image-archive-index.json"); + ImageArchiveIndex index = getIndex(content); + assertThat(index.getSchemaVersion()).isEqualTo(2); + assertThat(index.getManifests()).hasSize(1); + BlobReference manifest = index.getManifests().get(0); + assertThat(manifest.getMediaType()).isEqualTo("application/vnd.docker.distribution.manifest.list.v2+json"); + assertThat(manifest.getDigest()) + .isEqualTo("sha256:3bbe02431d8e5124ffe816ec27bf6508b50edd1d10218be1a03e799a186b9004"); + } + + private ImageArchiveIndex getIndex(String content) throws IOException { + return new ImageArchiveIndex(getObjectMapper().readTree(content)); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveManifestTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveManifestTests.java index 8f0eaccd84..76fd174cb1 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveManifestTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageArchiveManifestTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,7 +36,8 @@ class ImageArchiveManifestTests extends AbstractJsonTests { @Test void getLayersReturnsLayers() throws Exception { - ImageArchiveManifest manifest = getManifest(); + String content = getContentAsString("image-archive-manifest.json"); + ImageArchiveManifest manifest = getManifest(content); List expectedLayers = new ArrayList<>(); for (int blankLayersCount = 0; blankLayersCount < 46; blankLayersCount++) { expectedLayers.add("blank_" + blankLayersCount); @@ -50,7 +51,7 @@ class ImageArchiveManifestTests extends AbstractJsonTests { @Test void getLayersWithNoLayersReturnsEmptyList() throws Exception { String content = "[{\"Layers\": []}]"; - ImageArchiveManifest manifest = new ImageArchiveManifest(getObjectMapper().readTree(content)); + ImageArchiveManifest manifest = getManifest(content); assertThat(manifest.getEntries()).hasSize(1); assertThat(manifest.getEntries().get(0).getLayers()).isEmpty(); } @@ -58,12 +59,12 @@ class ImageArchiveManifestTests extends AbstractJsonTests { @Test void getLayersWithEmptyManifestReturnsEmptyList() throws Exception { String content = "[]"; - ImageArchiveManifest manifest = new ImageArchiveManifest(getObjectMapper().readTree(content)); + ImageArchiveManifest manifest = getManifest(content); assertThat(manifest.getEntries()).isEmpty(); } - private ImageArchiveManifest getManifest() throws IOException { - return new ImageArchiveManifest(getObjectMapper().readTree(getContent("image-archive-manifest.json"))); + private ImageArchiveManifest getManifest(String content) throws IOException { + return new ImageArchiveManifest(getObjectMapper().readTree(content)); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ManifestListTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ManifestListTests.java new file mode 100644 index 0000000000..148b9cd935 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ManifestListTests.java @@ -0,0 +1,47 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.buildpack.platform.docker.type; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.buildpack.platform.json.AbstractJsonTests; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ManifestList}. + * + * @author Phillip Webb + */ +class ManifestListTests extends AbstractJsonTests { + + @Test + void loadJsonFromDistributionManifestList() throws IOException { + String content = getContentAsString("distribution-manifest-list.json"); + ManifestList manifestList = getManifestList(content); + assertThat(manifestList.getSchemaVersion()).isEqualTo(2); + assertThat(manifestList.getMediaType()).isEqualTo("application/vnd.docker.distribution.manifest.list.v2+json"); + assertThat(manifestList.getManifests()).hasSize(2); + } + + private ManifestList getManifestList(String content) throws IOException { + return new ManifestList(getObjectMapper().readTree(content)); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ManifestTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ManifestTests.java new file mode 100644 index 0000000000..ac24305d0e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ManifestTests.java @@ -0,0 +1,56 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.buildpack.platform.docker.type; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.buildpack.platform.json.AbstractJsonTests; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link Manifest}. + * + * @author Phillip Webb + */ +class ManifestTests extends AbstractJsonTests { + + @Test + void loadJsonFromDistributionManifest() throws IOException { + String content = getContentAsString("distribution-manifest.json"); + Manifest manifestList = getManifest(content); + assertThat(manifestList.getSchemaVersion()).isEqualTo(2); + assertThat(manifestList.getMediaType()).isEqualTo("application/vnd.docker.distribution.manifest.v2+json"); + assertThat(manifestList.getLayers()).hasSize(1); + } + + @Test + void loadJsonFromImageManifest() throws IOException { + String content = getContentAsString("image-manifest.json"); + Manifest manifestList = getManifest(content); + assertThat(manifestList.getSchemaVersion()).isEqualTo(2); + assertThat(manifestList.getMediaType()).isEqualTo("application/vnd.oci.image.manifest.v1+json"); + assertThat(manifestList.getLayers()).hasSize(1); + } + + private Manifest getManifest(String content) throws IOException { + return new Manifest(getObjectMapper().readTree(content)); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/export-docker-desktop-containerd-manifest-list.tar b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/export-docker-desktop-containerd-manifest-list.tar new file mode 100644 index 0000000000..09b9e04564 Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/export-docker-desktop-containerd-manifest-list.tar differ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/export-docker-desktop-containerd.tar b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/export-docker-desktop-containerd.tar new file mode 100644 index 0000000000..473e17c077 Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/export-docker-desktop-containerd.tar differ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/export-docker-desktop.tar b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/export-docker-desktop.tar new file mode 100644 index 0000000000..61ffcd40b3 Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/export-docker-desktop.tar differ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/export-docker-engine.tar b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/export-docker-engine.tar new file mode 100644 index 0000000000..2ae031ee47 Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/export-docker-engine.tar differ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/export-podman.tar b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/export-podman.tar new file mode 100644 index 0000000000..d6f6b08134 Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/export-podman.tar differ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/type/distribution-manifest-list.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/type/distribution-manifest-list.json new file mode 100644 index 0000000000..d1e4f67643 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/type/distribution-manifest-list.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json", + "manifests": [ + { + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "size": 428, + "digest": "sha256:6dba064234a3aa60f7da2e0f1f8b86dccb7df2841136f577b08bd6a89004cb23", + "platform": { + "architecture": "amd64", + "os": "linux" + } + }, + { + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "size": 428, + "digest": "sha256:c036aba2c51a86a7a338f60af4730df725c2abff1b8b565d753896fd9533dfad", + "platform": { + "architecture": "arm64", + "os": "linux" + } + } + ] +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/type/distribution-manifest.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/type/distribution-manifest.json new file mode 100644 index 0000000000..0d41d2593f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/type/distribution-manifest.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "config": { + "mediaType": "application/vnd.docker.container.image.v1+json", + "size": 1175, + "digest": "sha256:b2160a0f9037918d3ca2270fb90f656f425760b337a5ed3813c3a48c09825065" + }, + "layers": [ + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 4872935, + "digest": "sha256:13ac7da0441b95b1960de1b87ed2c1ef129026cc69b926ffbe734a7dcc4fa40c" + } + ] +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/type/image-archive-index.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/type/image-archive-index.json new file mode 100644 index 0000000000..4369b7ce05 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/type/image-archive-index.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 2, + "manifests": [ + { + "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json", + "digest": "sha256:3bbe02431d8e5124ffe816ec27bf6508b50edd1d10218be1a03e799a186b9004", + "size": 529, + "annotations": { + "containerd.io/distribution.source.gcr.io": "paketo-buildpacks/adoptium", + "io.containerd.image.name": "gcr.io/paketo-buildpacks/adoptium:latest", + "org.opencontainers.image.ref.name": "latest" + } + } + ] +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/type/image-manifest.json b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/type/image-manifest.json new file mode 100644 index 0000000000..5a91f5d567 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/resources/org/springframework/boot/buildpack/platform/docker/type/image-manifest.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:ee382dc5c080aa6af5ea716041eaa4442c9d461520388627dfe51709c679043e", + "size": 849, + "platform": { + "architecture": "amd64", + "os": "linux" + } + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar", + "digest": "sha256:5caae51697b248b905dca1a4160864b0e1a15c300981736555cdce6567e8d477", + "size": 6656 + } + ] +} \ No newline at end of file