diff --git a/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java b/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java index 4e4aab001106..c6d77782b0cc 100644 --- a/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java +++ b/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java @@ -105,14 +105,22 @@ public void build(BuildRequest request) throws DockerEngineException, IOExceptio Assert.notNull(request, "'request' must not be null"); this.log.start(request); validateBindings(request.getBindings()); - PullPolicy pullPolicy = request.getPullPolicy(); - ImageFetcher imageFetcher = new ImageFetcher(this.dockerConfiguration.builderRegistryAuthentication(), - pullPolicy, request.getImagePlatform()); + PullPolicy pullPolicy = request.getPullPolicy(); + ImagePlatform requestedPlatform = request.getImagePlatform(); + ImageFetcher imageFetcher = new ImageFetcher(this.dockerConfiguration.builderRegistryAuthentication(), + pullPolicy, requestedPlatform); Image builderImage = imageFetcher.fetchImage(ImageType.BUILDER, request.getBuilder()); BuilderMetadata builderMetadata = BuilderMetadata.fromImage(builderImage); request = withRunImageIfNeeded(request, builderMetadata); - Assert.state(request.getRunImage() != null, "'request.getRunImage()' must not be null"); - Image runImage = imageFetcher.fetchImage(ImageType.RUNNER, request.getRunImage()); + ImageReference imageReference = request.getRunImage(); + Assert.state(imageReference != null, "'imageReference' must not be null"); + Image runImage = imageFetcher.fetchImage(ImageType.RUNNER, imageReference); + String digest = this.docker.image().resolveManifestDigest(imageReference, requestedPlatform); + if (StringUtils.hasText(digest)) { + imageReference = imageReference.withDigest(digest); + runImage = imageFetcher.fetchImage(ImageType.RUNNER, imageReference); + } + request = request.withRunImage(imageReference); assertStackIdsMatch(runImage, builderImage); BuildOwner buildOwner = BuildOwner.fromEnv(builderImage.getConfig().getEnv()); BuildpackLayersMetadata buildpackLayersMetadata = BuildpackLayersMetadata.fromImage(builderImage); @@ -355,8 +363,21 @@ public Image fetchImage(ImageReference reference, ImageType imageType) throws IO @Override public void exportImageLayers(ImageReference reference, IOBiConsumer exports) throws IOException { - Builder.this.docker.image().exportLayers(reference, exports); - } + try { + ImageReference pinned = reference; + String digest = Builder.this.docker.image().resolveManifestDigest(reference, + this.imageFetcher.defaultPlatform); + if (org.springframework.util.StringUtils.hasText(digest)) { + pinned = pinned.withDigest(digest); + } + if (!pinned.equals(reference)) { + Builder.this.docker.image().exportLayers(pinned, null, exports); + } + } + catch (Exception ex) { + Builder.this.docker.image().exportLayers(reference, this.imageFetcher.defaultPlatform, exports); + } + } } diff --git a/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java b/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java index a08789a94e95..ae23d7440de0 100644 --- a/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java +++ b/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java @@ -68,6 +68,10 @@ public class DockerApi { static final ApiVersion PLATFORM_API_VERSION = ApiVersion.of(1, 41); + static final ApiVersion EXPORT_PLATFORM_API_VERSION = ApiVersion.of(1, 48); + + static final ApiVersion INSPECT_PLATFORM_API_VERSION = ApiVersion.of(1, 49); + static final ApiVersion UNKNOWN_API_VERSION = ApiVersion.of(0, 0); static final String API_VERSION_HEADER_NAME = "API-Version"; @@ -239,7 +243,10 @@ public Image pull(ImageReference reference, @Nullable ImagePlatform platform, listener.onUpdate(event); }); } - return inspect((platform != null) ? PLATFORM_API_VERSION : API_VERSION, reference); + if (platform != null) { + return inspect(INSPECT_PLATFORM_API_VERSION, reference, platform); + } + return inspect(API_VERSION, reference); } finally { listener.onFinish(); @@ -311,9 +318,26 @@ public void load(ImageArchive archive, UpdateListener list */ public void exportLayers(ImageReference reference, IOBiConsumer exports) throws IOException { + exportLayers(reference, null, exports); + } + + /** + * Export the layers of an image as {@link TarArchive TarArchives}. + * @param reference the reference to export + * @param platform the platform (os/architecture/variant) of the image to export + * @param exports a consumer to receive the layers (contents can only be accessed + * during the callback) + * @throws IOException on IO error + */ + public void exportLayers(ImageReference reference, @Nullable ImagePlatform platform, + IOBiConsumer exports) throws IOException { Assert.notNull(reference, "'reference' must not be null"); Assert.notNull(exports, "'exports' must not be null"); URI uri = buildUrl("/images/" + reference + "/get"); + if (platform != null) { + uri = buildUrl(EXPORT_PLATFORM_API_VERSION, "/images/" + reference + "/get", "platform", + platform.toJson()); + } try (Response response = http().get(uri)) { try (ExportedImageTar exportedImageTar = new ExportedImageTar(reference, response.getContent())) { exportedImageTar.exportLayers(exports); @@ -321,6 +345,32 @@ public void exportLayers(ImageReference reference, IOBiConsumer repoDigests = image.getDigests(); + String digest = repoDigests.isEmpty() ? "" : repoDigests.get(0); + return digest.substring(digest.indexOf('@') + 1); + } + /** * Remove a specific image. * @param reference the reference the remove @@ -345,8 +395,15 @@ public Image inspect(ImageReference reference) throws IOException { } private Image inspect(ApiVersion apiVersion, ImageReference reference) throws IOException { + return inspect(apiVersion, reference, null); + } + + private Image inspect(ApiVersion apiVersion, ImageReference reference, @Nullable ImagePlatform platform) + throws IOException { Assert.notNull(reference, "'reference' must not be null"); - URI imageUri = buildUrl(apiVersion, "/images/" + reference + "/json"); + URI imageUri = (platform != null) + ? buildUrl(apiVersion, "/images/" + reference + "/json", "platform", platform.toJson()) + : buildUrl(apiVersion, "/images/" + reference + "/json"); try (Response response = http().get(imageUri)) { return Image.of(response.getContent()); } diff --git a/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/Image.java b/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/Image.java index 0ad0c67d5d77..947133c94e45 100644 --- a/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/Image.java +++ b/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/Image.java @@ -22,6 +22,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Objects; import org.jspecify.annotations.Nullable; import tools.jackson.databind.JsonNode; @@ -52,6 +53,8 @@ public class Image extends MappedObject { private final @Nullable String created; + private final @Nullable Descriptor descriptor; + Image(JsonNode node) { super(node, MethodHandles.lookup()); this.digests = childrenAt("/RepoDigests", JsonNode::asString); @@ -61,6 +64,8 @@ public class Image extends MappedObject { this.architecture = valueAt("/Architecture", String.class); this.variant = valueAt("/Variant", String.class); this.created = valueAt("/Created", String.class); + JsonNode descriptorNode = getNode().path("Descriptor"); + this.descriptor = (descriptorNode.isMissingNode() || descriptorNode.isNull()) ? null : new Descriptor(descriptorNode); } private List extractLayers(String @Nullable [] layers) { @@ -126,6 +131,46 @@ public String getOs() { return this.created; } + /** + * Return the descriptor for this image as reported by Docker Engine inspect. + * @return the image descriptor or {@code null} + */ + public @Nullable Descriptor getDescriptor() { + return this.descriptor; + } + + /** + * Descriptor details as reported by the Docker Engine inspect response. + */ + public static final class Descriptor extends MappedObject { + + private final @Nullable String mediaType; + + private final String digest; + + private final @Nullable Long size; + + Descriptor(JsonNode node) { + super(node, MethodHandles.lookup()); + this.mediaType = valueAt("/mediaType", String.class); + this.digest = Objects.requireNonNull(valueAt("/digest", String.class)); + this.size = valueAt("/size", Long.class); + } + + public @Nullable String getMediaType() { + return this.mediaType; + } + + public String getDigest() { + return this.digest; + } + + public @Nullable Long getSize() { + return this.size; + } + + } + /** * Create a new {@link Image} instance from the specified JSON content. * @param content the JSON content diff --git a/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ImagePlatform.java b/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ImagePlatform.java index 75bfbfbcaafb..16eb2115bbe6 100644 --- a/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ImagePlatform.java +++ b/buildpack/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ImagePlatform.java @@ -101,4 +101,22 @@ public static ImagePlatform from(Image image) { return new ImagePlatform(image.getOs(), image.getArchitecture(), image.getVariant()); } + /** + * Return a JSON-encoded representation of this platform for use with Docker Engine + * API 1.48+ endpoints that require the platform parameter in JSON format + * (e.g., image inspect and export operations). + * @return a JSON object in the form {@code {"os":"...","architecture":"...","variant":"..."}} + */ + public String toJson() { + StringBuilder json = new StringBuilder("{"); + json.append("\"os\":\"").append(this.os).append("\""); + if (this.architecture != null && !this.architecture.isEmpty()) { + json.append(",\"architecture\":\"").append(this.architecture).append("\""); + } + if (this.variant != null && !this.variant.isEmpty()) { + json.append(",\"variant\":\"").append(this.variant).append("\""); + } + json.append("}"); + return json.toString(); + } } diff --git a/buildpack/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java b/buildpack/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java index c06270461f26..2a32fe7c02ff 100644 --- a/buildpack/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java +++ b/buildpack/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java @@ -91,12 +91,16 @@ class DockerApiTests { private static final String PLATFORM_API_URL = "/v" + DockerApi.PLATFORM_API_VERSION; + private static final String INSPECT_PLATFORM_API_URL = "/v" + DockerApi.INSPECT_PLATFORM_API_VERSION; + public static final String PING_URL = "/_ping"; private static final String IMAGES_URL = API_URL + "/images"; private static final String PLATFORM_IMAGES_URL = PLATFORM_API_URL + "/images"; + private static final String INSPECT_PLATFORM_IMAGES_URL = INSPECT_PLATFORM_API_URL + "/images"; + private static final String CONTAINERS_URL = API_URL + "/containers"; private static final String PLATFORM_CONTAINERS_URL = PLATFORM_API_URL + "/containers"; @@ -239,9 +243,10 @@ void pullWithPlatformPullsImageAndProducesEvents() throws Exception { ImagePlatform platform = ImagePlatform.of("linux/arm64/v1"); URI createUri = new URI(PLATFORM_IMAGES_URL + "/create?fromImage=gcr.io%2Fpaketo-buildpacks%2Fbuilder%3Abase&platform=linux%2Farm64%2Fv1"); - URI imageUri = new URI(PLATFORM_IMAGES_URL + "/gcr.io/paketo-buildpacks/builder:base/json"); + URI imageUri = new URI(INSPECT_PLATFORM_IMAGES_URL + + "/gcr.io/paketo-buildpacks/builder:base/json?platform=%7B%22os%22%3A%22linux%22%2C%22architecture%22%3A%22arm64%22%2C%22variant%22%3A%22v1%22%7D"); given(http().head(eq(new URI(PING_URL)))) - .willReturn(responseWithHeaders(new BasicHeader(DockerApi.API_VERSION_HEADER_NAME, "1.41"))); + .willReturn(responseWithHeaders(new BasicHeader(DockerApi.API_VERSION_HEADER_NAME, "1.49"))); given(http().post(eq(createUri), isNull())).willReturn(responseOf("pull-stream.json")); given(http().get(imageUri)).willReturn(responseOf("type/image.json")); Image image = this.api.pull(reference, platform, this.pullListener);