Skip to content

Commit 913c434

Browse files
hojooophilwebb
authored andcommitted
Correctly handle platform specific buildpack builds
Prior to this commit, performing a build on a ARM Mac with the default configuration and then building it again with the image platform set to `linux/amd64` results in an "Image platform mismatch detected" failure. This is due to the fact that `docker inspect` returns JSON for the default platform, regardless of the fact that another architecture has been pulled. To solve the issue, the `inspect` API call on Docker 1.49+ can now accept a platform query parameter which when specified returns platform specific JSON. At the time of this commit, the Docker API documentation hasn't been updated, despite PR moby/moby#49586 being merged. In addition to using the correct inspect JSON, we also need to pin the run image we use to a specific digest. Without doing this, buildpacks revert back to the default platform image and "content digest not found" errors are thrown (similar to https://github.com/buildpacks/docs/issues/818). See gh-47292 Signed-off-by: hojooo <[email protected]>
1 parent b6d9c48 commit 913c434

File tree

5 files changed

+148
-8
lines changed

5 files changed

+148
-8
lines changed

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/Builder.java

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,19 @@ public void build(BuildRequest request) throws DockerEngineException, IOExceptio
103103
validateBindings(request.getBindings());
104104
String domain = request.getBuilder().getDomain();
105105
PullPolicy pullPolicy = request.getPullPolicy();
106-
ImageFetcher imageFetcher = new ImageFetcher(domain, getBuilderAuthHeader(), pullPolicy,
107-
request.getImagePlatform());
106+
ImagePlatform requestedPlatform = request.getImagePlatform();
107+
ImageFetcher imageFetcher = new ImageFetcher(domain, getBuilderAuthHeader(), pullPolicy, requestedPlatform);
108108
Image builderImage = imageFetcher.fetchImage(ImageType.BUILDER, request.getBuilder());
109109
BuilderMetadata builderMetadata = BuilderMetadata.fromImage(builderImage);
110110
request = withRunImageIfNeeded(request, builderMetadata);
111-
Image runImage = imageFetcher.fetchImage(ImageType.RUNNER, request.getRunImage());
111+
ImageReference imageReference = request.getRunImage();
112+
Image runImage = imageFetcher.fetchImage(ImageType.RUNNER, imageReference);
113+
String digest = this.docker.image().resolveManifestDigest(imageReference, requestedPlatform);
114+
if (StringUtils.hasText(digest)) {
115+
imageReference = imageReference.withDigest(digest);
116+
runImage = imageFetcher.fetchImage(ImageType.RUNNER, imageReference);
117+
}
118+
request = request.withRunImage(imageReference);
112119
assertStackIdsMatch(runImage, builderImage);
113120
BuildOwner buildOwner = BuildOwner.fromEnv(builderImage.getConfig().getEnv());
114121
BuildpackLayersMetadata buildpackLayersMetadata = BuildpackLayersMetadata.fromImage(builderImage);
@@ -325,7 +332,15 @@ public Image fetchImage(ImageReference reference, ImageType imageType) throws IO
325332
@Override
326333
public void exportImageLayers(ImageReference reference, IOBiConsumer<String, TarArchive> exports)
327334
throws IOException {
328-
Builder.this.docker.image().exportLayers(reference, exports);
335+
String digest = Builder.this.docker.image()
336+
.resolveManifestDigest(reference, this.imageFetcher.defaultPlatform);
337+
if (StringUtils.hasText(digest)) {
338+
ImageReference pinned = reference.withDigest(digest);
339+
Builder.this.docker.image().exportLayers(pinned, null, exports);
340+
}
341+
else {
342+
Builder.this.docker.image().exportLayers(reference, this.imageFetcher.defaultPlatform, exports);
343+
}
329344
}
330345

331346
}

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ public class DockerApi {
6868

6969
static final ApiVersion PLATFORM_API_VERSION = ApiVersion.of(1, 41);
7070

71+
static final ApiVersion EXPORT_PLATFORM_API_VERSION = ApiVersion.of(1, 48);
72+
73+
static final ApiVersion INSPECT_PLATFORM_API_VERSION = ApiVersion.of(1, 49);
74+
7175
static final ApiVersion UNKNOWN_API_VERSION = ApiVersion.of(0, 0);
7276

7377
static final String API_VERSION_HEADER_NAME = "API-Version";
@@ -235,7 +239,10 @@ public Image pull(ImageReference reference, ImagePlatform platform,
235239
listener.onUpdate(event);
236240
});
237241
}
238-
return inspect((platform != null) ? PLATFORM_API_VERSION : API_VERSION, reference);
242+
if (platform != null) {
243+
return inspect(INSPECT_PLATFORM_API_VERSION, reference, platform);
244+
}
245+
return inspect(API_VERSION, reference);
239246
}
240247
finally {
241248
listener.onFinish();
@@ -332,16 +339,58 @@ public void exportLayerFiles(ImageReference reference, IOBiConsumer<String, Path
332339
*/
333340
public void exportLayers(ImageReference reference, IOBiConsumer<String, TarArchive> exports)
334341
throws IOException {
342+
exportLayers(reference, null, exports);
343+
}
344+
345+
/**
346+
* Export the layers of an image as {@link TarArchive TarArchives}.
347+
* @param reference the reference to export
348+
* @param platform the platform (os/architecture/variant) of the image to export
349+
* @param exports a consumer to receive the layers (contents can only be accessed
350+
* during the callback)
351+
* @throws IOException on IO error
352+
*/
353+
public void exportLayers(ImageReference reference, ImagePlatform platform,
354+
IOBiConsumer<String, TarArchive> exports) throws IOException {
335355
Assert.notNull(reference, "Reference must not be null");
336356
Assert.notNull(exports, "Exports must not be null");
337357
URI uri = buildUrl("/images/" + reference + "/get");
358+
if (platform != null) {
359+
uri = buildUrl(EXPORT_PLATFORM_API_VERSION, "/images/" + reference + "/get", "platform",
360+
platform.toJson());
361+
}
338362
try (Response response = http().get(uri)) {
339363
try (ExportedImageTar exportedImageTar = new ExportedImageTar(reference, response.getContent())) {
340364
exportedImageTar.exportLayers(exports);
341365
}
342366
}
343367
}
344368

369+
/**
370+
* Resolve an image manifest digest via Docker inspect. If {@code platform} is
371+
* provided, performs a platform-aware inspect. Preference order:
372+
* {@code Descriptor.digest} then first {@code RepoDigest}. Returns an empty
373+
* string if no digest can be determined.
374+
* @param reference image reference
375+
* @param platform desired platform
376+
* @return resolved digest or empty string
377+
* @throws IOException on IO error
378+
*/
379+
public String resolveManifestDigest(ImageReference reference, ImagePlatform platform) throws IOException {
380+
Assert.notNull(reference, "'reference' must not be null");
381+
Image image = inspect(API_VERSION, reference);
382+
if (platform != null) {
383+
image = inspect(INSPECT_PLATFORM_API_VERSION, reference, platform);
384+
}
385+
Image.Descriptor descriptor = image.getDescriptor();
386+
if (descriptor != null && StringUtils.hasText(descriptor.getDigest())) {
387+
return descriptor.getDigest();
388+
}
389+
List<String> repoDigests = image.getDigests();
390+
String digest = repoDigests.isEmpty() ? "" : repoDigests.get(0);
391+
return digest.substring(digest.indexOf('@') + 1);
392+
}
393+
345394
/**
346395
* Remove a specific image.
347396
* @param reference the reference the remove
@@ -366,8 +415,15 @@ public Image inspect(ImageReference reference) throws IOException {
366415
}
367416

368417
private Image inspect(ApiVersion apiVersion, ImageReference reference) throws IOException {
418+
return inspect(apiVersion, reference, null);
419+
}
420+
421+
private Image inspect(ApiVersion apiVersion, ImageReference reference, ImagePlatform platform)
422+
throws IOException {
369423
Assert.notNull(reference, "Reference must not be null");
370-
URI imageUri = buildUrl(apiVersion, "/images/" + reference + "/json");
424+
URI imageUri = (platform != null)
425+
? buildUrl(apiVersion, "/images/" + reference + "/json", "platform", platform.toJson())
426+
: buildUrl(apiVersion, "/images/" + reference + "/json");
371427
try (Response response = http().get(imageUri)) {
372428
return Image.of(response.getContent());
373429
}

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/Image.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.util.Arrays;
2323
import java.util.Collections;
2424
import java.util.List;
25+
import java.util.Objects;
2526

2627
import com.fasterxml.jackson.databind.JsonNode;
2728

@@ -51,6 +52,8 @@ public class Image extends MappedObject {
5152

5253
private final String created;
5354

55+
private final Descriptor descriptor;
56+
5457
Image(JsonNode node) {
5558
super(node, MethodHandles.lookup());
5659
this.digests = childrenAt("/RepoDigests", JsonNode::asText);
@@ -60,6 +63,9 @@ public class Image extends MappedObject {
6063
this.architecture = valueAt("/Architecture", String.class);
6164
this.variant = valueAt("/Variant", String.class);
6265
this.created = valueAt("/Created", String.class);
66+
JsonNode descriptorNode = getNode().path("Descriptor");
67+
this.descriptor = (descriptorNode.isMissingNode() || descriptorNode.isNull()) ? null
68+
: new Descriptor(descriptorNode);
6369
}
6470

6571
private List<LayerId> extractLayers(String[] layers) {
@@ -125,6 +131,46 @@ public String getCreated() {
125131
return this.created;
126132
}
127133

134+
/**
135+
* Return the descriptor for this image as reported by Docker Engine inspect.
136+
* @return the image descriptor or {@code null}
137+
*/
138+
public Descriptor getDescriptor() {
139+
return this.descriptor;
140+
}
141+
142+
/**
143+
* Descriptor details as reported by the Docker Engine inspect response.
144+
*/
145+
public static final class Descriptor extends MappedObject {
146+
147+
private final String mediaType;
148+
149+
private final String digest;
150+
151+
private final Long size;
152+
153+
Descriptor(JsonNode node) {
154+
super(node, MethodHandles.lookup());
155+
this.mediaType = valueAt("/mediaType", String.class);
156+
this.digest = Objects.requireNonNull(valueAt("/digest", String.class));
157+
this.size = valueAt("/size", Long.class);
158+
}
159+
160+
public String getMediaType() {
161+
return this.mediaType;
162+
}
163+
164+
public String getDigest() {
165+
return this.digest;
166+
}
167+
168+
public Long getSize() {
169+
return this.size;
170+
}
171+
172+
}
173+
128174
/**
129175
* Create a new {@link Image} instance from the specified JSON content.
130176
* @param content the JSON content

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ImagePlatform.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,22 @@ public static ImagePlatform from(Image image) {
9999
return new ImagePlatform(image.getOs(), image.getArchitecture(), image.getVariant());
100100
}
101101

102+
/**
103+
* Return a JSON-encoded representation of this platform for use with Docker Engine
104+
* API 1.48+ endpoints that require the platform parameter in JSON format
105+
* (e.g., image inspect and export operations).
106+
* @return a JSON object in the form {@code {"os":"...","architecture":"...","variant":"..."}}
107+
*/
108+
public String toJson() {
109+
StringBuilder json = new StringBuilder("{");
110+
json.append("\"os\":\"").append(this.os).append("\"");
111+
if (this.architecture != null && !this.architecture.isEmpty()) {
112+
json.append(",\"architecture\":\"").append(this.architecture).append("\"");
113+
}
114+
if (this.variant != null && !this.variant.isEmpty()) {
115+
json.append(",\"variant\":\"").append(this.variant).append("\"");
116+
}
117+
json.append("}");
118+
return json.toString();
119+
}
102120
}

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,16 @@ class DockerApiTests {
9292

9393
private static final String PLATFORM_API_URL = "/v" + DockerApi.PLATFORM_API_VERSION;
9494

95+
private static final String INSPECT_PLATFORM_API_URL = "/v" + DockerApi.INSPECT_PLATFORM_API_VERSION;
96+
9597
public static final String PING_URL = "/_ping";
9698

9799
private static final String IMAGES_URL = API_URL + "/images";
98100

99101
private static final String PLATFORM_IMAGES_URL = PLATFORM_API_URL + "/images";
100102

103+
private static final String INSPECT_PLATFORM_IMAGES_URL = INSPECT_PLATFORM_API_URL + "/images";
104+
101105
private static final String CONTAINERS_URL = API_URL + "/containers";
102106

103107
private static final String PLATFORM_CONTAINERS_URL = PLATFORM_API_URL + "/containers";
@@ -240,9 +244,10 @@ void pullWithPlatformPullsImageAndProducesEvents() throws Exception {
240244
ImagePlatform platform = ImagePlatform.of("linux/arm64/v1");
241245
URI createUri = new URI(PLATFORM_IMAGES_URL
242246
+ "/create?fromImage=gcr.io%2Fpaketo-buildpacks%2Fbuilder%3Abase&platform=linux%2Farm64%2Fv1");
243-
URI imageUri = new URI(PLATFORM_IMAGES_URL + "/gcr.io/paketo-buildpacks/builder:base/json");
247+
URI imageUri = new URI(INSPECT_PLATFORM_IMAGES_URL
248+
+ "/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");
244249
given(http().head(eq(new URI(PING_URL))))
245-
.willReturn(responseWithHeaders(new BasicHeader(DockerApi.API_VERSION_HEADER_NAME, "1.41")));
250+
.willReturn(responseWithHeaders(new BasicHeader(DockerApi.API_VERSION_HEADER_NAME, "1.49")));
246251
given(http().post(eq(createUri), isNull())).willReturn(responseOf("pull-stream.json"));
247252
given(http().get(imageUri)).willReturn(responseOf("type/image.json"));
248253
Image image = this.api.pull(reference, platform, this.pullListener);

0 commit comments

Comments
 (0)