Skip to content

Commit 39f3d1c

Browse files
committed
Polish 'Correctly handle platform specific buildpack builds'
See gh-47292
1 parent 913c434 commit 39f3d1c

File tree

13 files changed

+403
-160
lines changed

13 files changed

+403
-160
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: 64 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -103,23 +103,23 @@ public void build(BuildRequest request) throws DockerEngineException, IOExceptio
103103
validateBindings(request.getBindings());
104104
String domain = request.getBuilder().getDomain();
105105
PullPolicy pullPolicy = request.getPullPolicy();
106-
ImagePlatform requestedPlatform = request.getImagePlatform();
107-
ImageFetcher imageFetcher = new ImageFetcher(domain, getBuilderAuthHeader(), pullPolicy, requestedPlatform);
108-
Image builderImage = imageFetcher.fetchImage(ImageType.BUILDER, request.getBuilder());
106+
ImagePlatform platform = request.getImagePlatform();
107+
boolean specifiedPlatform = request.getImagePlatform() != null;
108+
ImageFetcher imageFetcher = new ImageFetcher(domain, getBuilderAuthHeader(), pullPolicy);
109+
Image builderImage = imageFetcher.fetchImage(ImageType.BUILDER, request.getBuilder(), platform);
109110
BuilderMetadata builderMetadata = BuilderMetadata.fromImage(builderImage);
110111
request = withRunImageIfNeeded(request, builderMetadata);
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);
112+
platform = (platform != null) ? platform : ImagePlatform.from(builderImage);
113+
Image runImage = imageFetcher.fetchImage(ImageType.RUNNER, request.getRunImage(), platform);
114+
if (specifiedPlatform && runImage.getPrimaryDigest() != null) {
115+
request = request.withRunImage(request.getRunImage().withDigest(runImage.getPrimaryDigest()));
116+
runImage = imageFetcher.fetchImage(ImageType.RUNNER, request.getRunImage(), platform);
117117
}
118-
request = request.withRunImage(imageReference);
119118
assertStackIdsMatch(runImage, builderImage);
120119
BuildOwner buildOwner = BuildOwner.fromEnv(builderImage.getConfig().getEnv());
121120
BuildpackLayersMetadata buildpackLayersMetadata = BuildpackLayersMetadata.fromImage(builderImage);
122-
Buildpacks buildpacks = getBuildpacks(request, imageFetcher, builderMetadata, buildpackLayersMetadata);
121+
Buildpacks buildpacks = getBuildpacks(request, imageFetcher, platform, builderMetadata,
122+
buildpackLayersMetadata);
123123
EphemeralBuilder ephemeralBuilder = new EphemeralBuilder(buildOwner, builderImage, request.getName(),
124124
builderMetadata, request.getCreator(), request.getEnv(), buildpacks);
125125
executeLifecycle(request, ephemeralBuilder);
@@ -163,9 +163,9 @@ private void assertStackIdsMatch(Image runImage, Image builderImage) {
163163
}
164164
}
165165

166-
private Buildpacks getBuildpacks(BuildRequest request, ImageFetcher imageFetcher, BuilderMetadata builderMetadata,
167-
BuildpackLayersMetadata buildpackLayersMetadata) {
168-
BuildpackResolverContext resolverContext = new BuilderResolverContext(imageFetcher, builderMetadata,
166+
private Buildpacks getBuildpacks(BuildRequest request, ImageFetcher imageFetcher, ImagePlatform platform,
167+
BuilderMetadata builderMetadata, BuildpackLayersMetadata buildpackLayersMetadata) {
168+
BuildpackResolverContext resolverContext = new BuilderResolverContext(imageFetcher, platform, builderMetadata,
169169
buildpackLayersMetadata);
170170
return BuildpackResolvers.resolveAll(resolverContext, request.getBuildpacks());
171171
}
@@ -234,51 +234,74 @@ private class ImageFetcher {
234234

235235
private final PullPolicy pullPolicy;
236236

237-
private ImagePlatform defaultPlatform;
238-
239-
ImageFetcher(String domain, String authHeader, PullPolicy pullPolicy, ImagePlatform platform) {
237+
ImageFetcher(String domain, String authHeader, PullPolicy pullPolicy) {
240238
this.domain = domain;
241239
this.authHeader = authHeader;
242240
this.pullPolicy = pullPolicy;
243-
this.defaultPlatform = platform;
244241
}
245242

246-
Image fetchImage(ImageType type, ImageReference reference) throws IOException {
243+
Image fetchImage(ImageType type, ImageReference reference, ImagePlatform platform) throws IOException {
247244
Assert.notNull(type, "Type must not be null");
248245
Assert.notNull(reference, "Reference must not be null");
249246
Assert.state(this.authHeader == null || reference.getDomain().equals(this.domain),
250247
() -> String.format("%s '%s' must be pulled from the '%s' authenticated registry",
251248
StringUtils.capitalize(type.getDescription()), reference, this.domain));
252249
if (this.pullPolicy == PullPolicy.ALWAYS) {
253-
return checkPlatformMismatch(pullImage(reference, type), reference);
250+
return pullImageAndCheckForPlatformMismatch(type, reference, platform);
254251
}
255252
try {
256-
return checkPlatformMismatch(Builder.this.docker.image().inspect(reference), reference);
253+
Image image = Builder.this.docker.image().inspect(reference, platform);
254+
return checkPlatformMismatch(image, reference, platform);
257255
}
258256
catch (DockerEngineException ex) {
259257
if (this.pullPolicy == PullPolicy.IF_NOT_PRESENT && ex.getStatusCode() == 404) {
260-
return checkPlatformMismatch(pullImage(reference, type), reference);
258+
return pullImageAndCheckForPlatformMismatch(type, reference, platform);
261259
}
262260
throw ex;
263261
}
264262
}
265263

266-
private Image pullImage(ImageReference reference, ImageType imageType) throws IOException {
264+
private Image pullImageAndCheckForPlatformMismatch(ImageType type, ImageReference reference,
265+
ImagePlatform platform) throws IOException {
266+
try {
267+
Image image = pullImage(reference, type, platform);
268+
return checkPlatformMismatch(image, reference, platform);
269+
}
270+
catch (DockerEngineException ex) {
271+
// Try to throw our own exception for consistent log output. Matching
272+
// on the message is a little brittle, but it doesn't matter too much
273+
// if it fails as the original exception is still enough to stop the build
274+
if (platform != null && ex.getMessage().contains("does not provide the specified platform")) {
275+
throwAsPlatformMismatchException(type, reference, platform, ex);
276+
}
277+
throw ex;
278+
}
279+
}
280+
281+
private void throwAsPlatformMismatchException(ImageType type, ImageReference reference, ImagePlatform platform,
282+
Throwable cause) throws IOException {
283+
try {
284+
Image image = pullImage(reference, type, null);
285+
throw new PlatformMismatchException(reference, platform, ImagePlatform.from(image), cause);
286+
}
287+
catch (DockerEngineException ex) {
288+
}
289+
}
290+
291+
private Image pullImage(ImageReference reference, ImageType imageType, ImagePlatform platform)
292+
throws IOException {
267293
TotalProgressPullListener listener = new TotalProgressPullListener(
268-
Builder.this.log.pullingImage(reference, this.defaultPlatform, imageType));
269-
Image image = Builder.this.docker.image().pull(reference, this.defaultPlatform, listener, this.authHeader);
294+
Builder.this.log.pullingImage(reference, platform, imageType));
295+
Image image = Builder.this.docker.image().pull(reference, platform, listener, this.authHeader);
270296
Builder.this.log.pulledImage(image, imageType);
271-
if (this.defaultPlatform == null) {
272-
this.defaultPlatform = ImagePlatform.from(image);
273-
}
274297
return image;
275298
}
276299

277-
private Image checkPlatformMismatch(Image image, ImageReference imageReference) {
278-
if (this.defaultPlatform != null) {
279-
ImagePlatform imagePlatform = ImagePlatform.from(image);
280-
if (!imagePlatform.equals(this.defaultPlatform)) {
281-
throw new PlatformMismatchException(imageReference, this.defaultPlatform, imagePlatform);
300+
private Image checkPlatformMismatch(Image image, ImageReference reference, ImagePlatform requestedPlatform) {
301+
if (requestedPlatform != null) {
302+
ImagePlatform actualPlatform = ImagePlatform.from(image);
303+
if (!actualPlatform.equals(requestedPlatform)) {
304+
throw new PlatformMismatchException(reference, requestedPlatform, actualPlatform, null);
282305
}
283306
}
284307
return image;
@@ -289,9 +312,9 @@ private Image checkPlatformMismatch(Image image, ImageReference imageReference)
289312
private static final class PlatformMismatchException extends RuntimeException {
290313

291314
private PlatformMismatchException(ImageReference imageReference, ImagePlatform requestedPlatform,
292-
ImagePlatform actualPlatform) {
315+
ImagePlatform actualPlatform, Throwable cause) {
293316
super("Image platform mismatch detected. The configured platform '%s' is not supported by the image '%s'. Requested platform '%s' but got '%s'"
294-
.formatted(requestedPlatform, imageReference, requestedPlatform, actualPlatform));
317+
.formatted(requestedPlatform, imageReference, requestedPlatform, actualPlatform), cause);
295318
}
296319

297320
}
@@ -303,13 +326,16 @@ private class BuilderResolverContext implements BuildpackResolverContext {
303326

304327
private final ImageFetcher imageFetcher;
305328

329+
private final ImagePlatform platform;
330+
306331
private final BuilderMetadata builderMetadata;
307332

308333
private final BuildpackLayersMetadata buildpackLayersMetadata;
309334

310-
BuilderResolverContext(ImageFetcher imageFetcher, BuilderMetadata builderMetadata,
335+
BuilderResolverContext(ImageFetcher imageFetcher, ImagePlatform platform, BuilderMetadata builderMetadata,
311336
BuildpackLayersMetadata buildpackLayersMetadata) {
312337
this.imageFetcher = imageFetcher;
338+
this.platform = platform;
313339
this.builderMetadata = builderMetadata;
314340
this.buildpackLayersMetadata = buildpackLayersMetadata;
315341
}
@@ -326,21 +352,13 @@ public BuildpackLayersMetadata getBuildpackLayersMetadata() {
326352

327353
@Override
328354
public Image fetchImage(ImageReference reference, ImageType imageType) throws IOException {
329-
return this.imageFetcher.fetchImage(imageType, reference);
355+
return this.imageFetcher.fetchImage(imageType, reference, this.platform);
330356
}
331357

332358
@Override
333359
public void exportImageLayers(ImageReference reference, IOBiConsumer<String, TarArchive> exports)
334360
throws IOException {
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-
}
361+
Builder.this.docker.image().exportLayers(reference, exports);
344362
}
345363

346364
}

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

Lines changed: 25 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,7 @@ 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);
71+
static final ApiVersion PLATFORM_INSPECT_API_VERSION = ApiVersion.of(1, 49);
7472

7573
static final ApiVersion UNKNOWN_API_VERSION = ApiVersion.of(0, 0);
7674

@@ -239,10 +237,7 @@ public Image pull(ImageReference reference, ImagePlatform platform,
239237
listener.onUpdate(event);
240238
});
241239
}
242-
if (platform != null) {
243-
return inspect(INSPECT_PLATFORM_API_VERSION, reference, platform);
244-
}
245-
return inspect(API_VERSION, reference);
240+
return inspect(reference, platform);
246241
}
247242
finally {
248243
listener.onFinish();
@@ -339,58 +334,16 @@ public void exportLayerFiles(ImageReference reference, IOBiConsumer<String, Path
339334
*/
340335
public void exportLayers(ImageReference reference, IOBiConsumer<String, TarArchive> exports)
341336
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 {
355337
Assert.notNull(reference, "Reference must not be null");
356338
Assert.notNull(exports, "Exports must not be null");
357339
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-
}
362340
try (Response response = http().get(uri)) {
363341
try (ExportedImageTar exportedImageTar = new ExportedImageTar(reference, response.getContent())) {
364342
exportedImageTar.exportLayers(exports);
365343
}
366344
}
367345
}
368346

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-
394347
/**
395348
* Remove a specific image.
396349
* @param reference the reference the remove
@@ -411,24 +364,36 @@ public void remove(ImageReference reference, boolean force) throws IOException {
411364
* @throws IOException on IO error
412365
*/
413366
public Image inspect(ImageReference reference) throws IOException {
414-
return inspect(API_VERSION, reference);
415-
}
416-
417-
private Image inspect(ApiVersion apiVersion, ImageReference reference) throws IOException {
418-
return inspect(apiVersion, reference, null);
367+
return inspect(reference, null);
419368
}
420369

421-
private Image inspect(ApiVersion apiVersion, ImageReference reference, ImagePlatform platform)
422-
throws IOException {
370+
/**
371+
* Inspect an image.
372+
* @param reference the image reference
373+
* @param platform the platform (os/architecture/variant) of the image to inspect.
374+
* Ignored on older versions of Docker.
375+
* @return the image from the local repository
376+
* @throws IOException on IO error
377+
* @since 3.4.12
378+
*/
379+
public Image inspect(ImageReference reference, ImagePlatform platform) throws IOException {
380+
// The Docker documentation is incomplete but platform parameters
381+
// are supported since 1.49 (see https://github.com/moby/moby/pull/49586)
423382
Assert.notNull(reference, "Reference must not be null");
424-
URI imageUri = (platform != null)
425-
? buildUrl(apiVersion, "/images/" + reference + "/json", "platform", platform.toJson())
426-
: buildUrl(apiVersion, "/images/" + reference + "/json");
427-
try (Response response = http().get(imageUri)) {
383+
URI inspectUrl = inspectUrl(reference, platform);
384+
try (Response response = http().get(inspectUrl)) {
428385
return Image.of(response.getContent());
429386
}
430387
}
431388

389+
private URI inspectUrl(ImageReference reference, ImagePlatform platform) {
390+
String path = "/images/" + reference + "/json";
391+
if (platform != null && getApiVersion().supports(PLATFORM_INSPECT_API_VERSION)) {
392+
return buildUrl(PLATFORM_INSPECT_API_VERSION, path, "platform", platform.toJson());
393+
}
394+
return buildUrl(path);
395+
}
396+
432397
public void tag(ImageReference sourceReference, ImageReference targetReference) throws IOException {
433398
Assert.notNull(sourceReference, "SourceReference must not be null");
434399
Assert.notNull(targetReference, "TargetReference must not be null");

0 commit comments

Comments
 (0)