Skip to content

Commit b6460ea

Browse files
committed
Merge pull request #47292 from hojooo
* pr/47292: Polish 'Correctly handle platform specific buildpack builds' Correctly handle platform specific buildpack builds Closes gh-47292
2 parents b6d9c48 + 39f3d1c commit b6460ea

File tree

13 files changed

+434
-51
lines changed

13 files changed

+434
-51
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 & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -103,16 +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-
ImageFetcher imageFetcher = new ImageFetcher(domain, getBuilderAuthHeader(), pullPolicy,
107-
request.getImagePlatform());
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-
Image runImage = imageFetcher.fetchImage(ImageType.RUNNER, request.getRunImage());
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);
117+
}
112118
assertStackIdsMatch(runImage, builderImage);
113119
BuildOwner buildOwner = BuildOwner.fromEnv(builderImage.getConfig().getEnv());
114120
BuildpackLayersMetadata buildpackLayersMetadata = BuildpackLayersMetadata.fromImage(builderImage);
115-
Buildpacks buildpacks = getBuildpacks(request, imageFetcher, builderMetadata, buildpackLayersMetadata);
121+
Buildpacks buildpacks = getBuildpacks(request, imageFetcher, platform, builderMetadata,
122+
buildpackLayersMetadata);
116123
EphemeralBuilder ephemeralBuilder = new EphemeralBuilder(buildOwner, builderImage, request.getName(),
117124
builderMetadata, request.getCreator(), request.getEnv(), buildpacks);
118125
executeLifecycle(request, ephemeralBuilder);
@@ -156,9 +163,9 @@ private void assertStackIdsMatch(Image runImage, Image builderImage) {
156163
}
157164
}
158165

159-
private Buildpacks getBuildpacks(BuildRequest request, ImageFetcher imageFetcher, BuilderMetadata builderMetadata,
160-
BuildpackLayersMetadata buildpackLayersMetadata) {
161-
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,
162169
buildpackLayersMetadata);
163170
return BuildpackResolvers.resolveAll(resolverContext, request.getBuildpacks());
164171
}
@@ -227,51 +234,74 @@ private class ImageFetcher {
227234

228235
private final PullPolicy pullPolicy;
229236

230-
private ImagePlatform defaultPlatform;
231-
232-
ImageFetcher(String domain, String authHeader, PullPolicy pullPolicy, ImagePlatform platform) {
237+
ImageFetcher(String domain, String authHeader, PullPolicy pullPolicy) {
233238
this.domain = domain;
234239
this.authHeader = authHeader;
235240
this.pullPolicy = pullPolicy;
236-
this.defaultPlatform = platform;
237241
}
238242

239-
Image fetchImage(ImageType type, ImageReference reference) throws IOException {
243+
Image fetchImage(ImageType type, ImageReference reference, ImagePlatform platform) throws IOException {
240244
Assert.notNull(type, "Type must not be null");
241245
Assert.notNull(reference, "Reference must not be null");
242246
Assert.state(this.authHeader == null || reference.getDomain().equals(this.domain),
243247
() -> String.format("%s '%s' must be pulled from the '%s' authenticated registry",
244248
StringUtils.capitalize(type.getDescription()), reference, this.domain));
245249
if (this.pullPolicy == PullPolicy.ALWAYS) {
246-
return checkPlatformMismatch(pullImage(reference, type), reference);
250+
return pullImageAndCheckForPlatformMismatch(type, reference, platform);
247251
}
248252
try {
249-
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);
250255
}
251256
catch (DockerEngineException ex) {
252257
if (this.pullPolicy == PullPolicy.IF_NOT_PRESENT && ex.getStatusCode() == 404) {
253-
return checkPlatformMismatch(pullImage(reference, type), reference);
258+
return pullImageAndCheckForPlatformMismatch(type, reference, platform);
259+
}
260+
throw ex;
261+
}
262+
}
263+
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);
254276
}
255277
throw ex;
256278
}
257279
}
258280

259-
private Image pullImage(ImageReference reference, ImageType imageType) throws IOException {
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 {
260293
TotalProgressPullListener listener = new TotalProgressPullListener(
261-
Builder.this.log.pullingImage(reference, this.defaultPlatform, imageType));
262-
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);
263296
Builder.this.log.pulledImage(image, imageType);
264-
if (this.defaultPlatform == null) {
265-
this.defaultPlatform = ImagePlatform.from(image);
266-
}
267297
return image;
268298
}
269299

270-
private Image checkPlatformMismatch(Image image, ImageReference imageReference) {
271-
if (this.defaultPlatform != null) {
272-
ImagePlatform imagePlatform = ImagePlatform.from(image);
273-
if (!imagePlatform.equals(this.defaultPlatform)) {
274-
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);
275305
}
276306
}
277307
return image;
@@ -282,9 +312,9 @@ private Image checkPlatformMismatch(Image image, ImageReference imageReference)
282312
private static final class PlatformMismatchException extends RuntimeException {
283313

284314
private PlatformMismatchException(ImageReference imageReference, ImagePlatform requestedPlatform,
285-
ImagePlatform actualPlatform) {
315+
ImagePlatform actualPlatform, Throwable cause) {
286316
super("Image platform mismatch detected. The configured platform '%s' is not supported by the image '%s'. Requested platform '%s' but got '%s'"
287-
.formatted(requestedPlatform, imageReference, requestedPlatform, actualPlatform));
317+
.formatted(requestedPlatform, imageReference, requestedPlatform, actualPlatform), cause);
288318
}
289319

290320
}
@@ -296,13 +326,16 @@ private class BuilderResolverContext implements BuildpackResolverContext {
296326

297327
private final ImageFetcher imageFetcher;
298328

329+
private final ImagePlatform platform;
330+
299331
private final BuilderMetadata builderMetadata;
300332

301333
private final BuildpackLayersMetadata buildpackLayersMetadata;
302334

303-
BuilderResolverContext(ImageFetcher imageFetcher, BuilderMetadata builderMetadata,
335+
BuilderResolverContext(ImageFetcher imageFetcher, ImagePlatform platform, BuilderMetadata builderMetadata,
304336
BuildpackLayersMetadata buildpackLayersMetadata) {
305337
this.imageFetcher = imageFetcher;
338+
this.platform = platform;
306339
this.builderMetadata = builderMetadata;
307340
this.buildpackLayersMetadata = buildpackLayersMetadata;
308341
}
@@ -319,7 +352,7 @@ public BuildpackLayersMetadata getBuildpackLayersMetadata() {
319352

320353
@Override
321354
public Image fetchImage(ImageReference reference, ImageType imageType) throws IOException {
322-
return this.imageFetcher.fetchImage(imageType, reference);
355+
return this.imageFetcher.fetchImage(imageType, reference, this.platform);
323356
}
324357

325358
@Override

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

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ public class DockerApi {
6868

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

71+
static final ApiVersion PLATFORM_INSPECT_API_VERSION = ApiVersion.of(1, 49);
72+
7173
static final ApiVersion UNKNOWN_API_VERSION = ApiVersion.of(0, 0);
7274

7375
static final String API_VERSION_HEADER_NAME = "API-Version";
@@ -235,7 +237,7 @@ public Image pull(ImageReference reference, ImagePlatform platform,
235237
listener.onUpdate(event);
236238
});
237239
}
238-
return inspect((platform != null) ? PLATFORM_API_VERSION : API_VERSION, reference);
240+
return inspect(reference, platform);
239241
}
240242
finally {
241243
listener.onFinish();
@@ -362,17 +364,36 @@ public void remove(ImageReference reference, boolean force) throws IOException {
362364
* @throws IOException on IO error
363365
*/
364366
public Image inspect(ImageReference reference) throws IOException {
365-
return inspect(API_VERSION, reference);
367+
return inspect(reference, null);
366368
}
367369

368-
private Image inspect(ApiVersion apiVersion, ImageReference reference) 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)
369382
Assert.notNull(reference, "Reference must not be null");
370-
URI imageUri = buildUrl(apiVersion, "/images/" + reference + "/json");
371-
try (Response response = http().get(imageUri)) {
383+
URI inspectUrl = inspectUrl(reference, platform);
384+
try (Response response = http().get(inspectUrl)) {
372385
return Image.of(response.getContent());
373386
}
374387
}
375388

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+
376397
public void tag(ImageReference sourceReference, ImageReference targetReference) throws IOException {
377398
Assert.notNull(sourceReference, "SourceReference must not be null");
378399
Assert.notNull(targetReference, "TargetReference must not be null");

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@
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

2829
import org.springframework.boot.buildpack.platform.json.MappedObject;
30+
import org.springframework.util.CollectionUtils;
2931
import org.springframework.util.StringUtils;
3032

3133
/**
@@ -51,6 +53,8 @@ public class Image extends MappedObject {
5153

5254
private final String created;
5355

56+
private final Descriptor descriptor;
57+
5458
Image(JsonNode node) {
5559
super(node, MethodHandles.lookup());
5660
this.digests = childrenAt("/RepoDigests", JsonNode::asText);
@@ -60,6 +64,9 @@ public class Image extends MappedObject {
6064
this.architecture = valueAt("/Architecture", String.class);
6165
this.variant = valueAt("/Variant", String.class);
6266
this.created = valueAt("/Created", String.class);
67+
JsonNode descriptorNode = getNode().path("Descriptor");
68+
this.descriptor = (descriptorNode.isMissingNode() || descriptorNode.isNull()) ? null
69+
: new Descriptor(descriptorNode);
6370
}
6471

6572
private List<LayerId> extractLayers(String[] layers) {
@@ -125,6 +132,34 @@ public String getCreated() {
125132
return this.created;
126133
}
127134

135+
/**
136+
* Return the descriptor for this image as reported by Docker Engine inspect.
137+
* @return the image descriptor or {@code null}
138+
*/
139+
public Descriptor getDescriptor() {
140+
return this.descriptor;
141+
}
142+
143+
/**
144+
* Return the primary digest of the image or {@code null}. Checks the
145+
* {@code Descriptor.digest} first, falling back to {@code RepoDigest}.
146+
* @return the primary digest or {@code null}
147+
* @since 3.4.12
148+
*/
149+
public String getPrimaryDigest() {
150+
if (this.descriptor != null && StringUtils.hasText(this.descriptor.getDigest())) {
151+
return this.descriptor.getDigest();
152+
}
153+
if (!CollectionUtils.isEmpty(this.digests)) {
154+
try {
155+
return ImageReference.of(this.digests.get(0)).getDigest();
156+
}
157+
catch (RuntimeException ex) {
158+
}
159+
}
160+
return null;
161+
}
162+
128163
/**
129164
* Create a new {@link Image} instance from the specified JSON content.
130165
* @param content the JSON content
@@ -135,4 +170,24 @@ public static Image of(InputStream content) throws IOException {
135170
return of(content, Image::new);
136171
}
137172

173+
/**
174+
* Descriptor details as reported in the {@code Docker inspect} response.
175+
*
176+
* @since 3.4.12
177+
*/
178+
public final class Descriptor extends MappedObject {
179+
180+
private final String digest;
181+
182+
Descriptor(JsonNode node) {
183+
super(node, MethodHandles.lookup());
184+
this.digest = Objects.requireNonNull(valueAt("/digest", String.class));
185+
}
186+
187+
public String getDigest() {
188+
return this.digest;
189+
}
190+
191+
}
192+
138193
}

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.util.Objects;
2020

2121
import org.springframework.util.Assert;
22+
import org.springframework.util.StringUtils;
2223

2324
/**
2425
* A platform specification for a Docker image.
@@ -99,4 +100,24 @@ public static ImagePlatform from(Image image) {
99100
return new ImagePlatform(image.getOs(), image.getArchitecture(), image.getVariant());
100101
}
101102

103+
/**
104+
* Return a JSON-encoded representation of this platform.
105+
* @return the JSON string
106+
*/
107+
public String toJson() {
108+
StringBuilder json = new StringBuilder("{");
109+
json.append(jsonPair("os", this.os));
110+
if (StringUtils.hasText(this.architecture)) {
111+
json.append(",").append(jsonPair("architecture", this.architecture));
112+
}
113+
if (StringUtils.hasText(this.variant)) {
114+
json.append(",").append(jsonPair("variant", this.variant));
115+
}
116+
return json.append("}").toString();
117+
}
118+
119+
private String jsonPair(String name, String value) {
120+
return "\"%s\":\"%s\"".formatted(name, value);
121+
}
122+
102123
}

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,6 @@ public static ImageReference of(String value) {
261261
path = path.substring(0, tagSplit) + remainder;
262262
}
263263
}
264-
265264
Assert.isTrue(isLowerCase(path) && matchesPathRegex(path),
266265
() -> "Unable to parse image reference \"" + value + "\". "
267266
+ "Image reference must be in the form '[domainHost:port/][path/]name[:tag][@digest]', "

0 commit comments

Comments
 (0)