Skip to content

Commit a25bcfc

Browse files
committed
Merge branch '3.5.x'
Closes gh-48099
2 parents d161aaf + 25a5d43 commit a25bcfc

File tree

12 files changed

+436
-51
lines changed

12 files changed

+436
-51
lines changed

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

Lines changed: 68 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -106,17 +106,25 @@ public void build(BuildRequest request) throws DockerEngineException, IOExceptio
106106
this.log.start(request);
107107
validateBindings(request.getBindings());
108108
PullPolicy pullPolicy = request.getPullPolicy();
109-
ImageFetcher imageFetcher = new ImageFetcher(this.dockerConfiguration.builderRegistryAuthentication(),
110-
pullPolicy, request.getImagePlatform());
111-
Image builderImage = imageFetcher.fetchImage(ImageType.BUILDER, request.getBuilder());
109+
ImagePlatform platform = request.getImagePlatform();
110+
boolean specifiedPlatform = request.getImagePlatform() != null;
111+
DockerRegistryAuthentication registryAuthentication = this.dockerConfiguration.builderRegistryAuthentication();
112+
ImageFetcher imageFetcher = new ImageFetcher(registryAuthentication, pullPolicy);
113+
Image builderImage = imageFetcher.fetchImage(ImageType.BUILDER, request.getBuilder(), platform);
112114
BuilderMetadata builderMetadata = BuilderMetadata.fromImage(builderImage);
113115
request = withRunImageIfNeeded(request, builderMetadata);
114116
Assert.state(request.getRunImage() != null, "'request.getRunImage()' must not be null");
115-
Image runImage = imageFetcher.fetchImage(ImageType.RUNNER, request.getRunImage());
117+
platform = (platform != null) ? platform : ImagePlatform.from(builderImage);
118+
Image runImage = imageFetcher.fetchImage(ImageType.RUNNER, request.getRunImage(), platform);
119+
if (specifiedPlatform && runImage.getPrimaryDigest() != null) {
120+
request = request.withRunImage(request.getRunImage().withDigest(runImage.getPrimaryDigest()));
121+
runImage = imageFetcher.fetchImage(ImageType.RUNNER, request.getRunImage(), platform);
122+
}
116123
assertStackIdsMatch(runImage, builderImage);
117124
BuildOwner buildOwner = BuildOwner.fromEnv(builderImage.getConfig().getEnv());
118125
BuildpackLayersMetadata buildpackLayersMetadata = BuildpackLayersMetadata.fromImage(builderImage);
119-
Buildpacks buildpacks = getBuildpacks(request, imageFetcher, builderMetadata, buildpackLayersMetadata);
126+
Buildpacks buildpacks = getBuildpacks(request, imageFetcher, platform, builderMetadata,
127+
buildpackLayersMetadata);
120128
EphemeralBuilder ephemeralBuilder = new EphemeralBuilder(buildOwner, builderImage, request.getName(),
121129
builderMetadata, request.getCreator(), request.getEnv(), buildpacks);
122130
executeLifecycle(request, ephemeralBuilder);
@@ -160,9 +168,9 @@ private void assertStackIdsMatch(Image runImage, Image builderImage) {
160168
}
161169
}
162170

163-
private Buildpacks getBuildpacks(BuildRequest request, ImageFetcher imageFetcher, BuilderMetadata builderMetadata,
164-
BuildpackLayersMetadata buildpackLayersMetadata) {
165-
BuildpackResolverContext resolverContext = new BuilderResolverContext(imageFetcher, builderMetadata,
171+
private Buildpacks getBuildpacks(BuildRequest request, ImageFetcher imageFetcher, ImagePlatform platform,
172+
BuilderMetadata builderMetadata, BuildpackLayersMetadata buildpackLayersMetadata) {
173+
BuildpackResolverContext resolverContext = new BuilderResolverContext(imageFetcher, platform, builderMetadata,
166174
buildpackLayersMetadata);
167175
return BuildpackResolvers.resolveAll(resolverContext, request.getBuildpacks());
168176
}
@@ -225,49 +233,74 @@ private class ImageFetcher {
225233

226234
private final PullPolicy pullPolicy;
227235

228-
private @Nullable ImagePlatform defaultPlatform;
229-
230-
ImageFetcher(@Nullable DockerRegistryAuthentication registryAuthentication, PullPolicy pullPolicy,
231-
@Nullable ImagePlatform platform) {
236+
ImageFetcher(@Nullable DockerRegistryAuthentication registryAuthentication, PullPolicy pullPolicy) {
232237
this.registryAuthentication = registryAuthentication;
233238
this.pullPolicy = pullPolicy;
234-
this.defaultPlatform = platform;
235239
}
236240

237-
Image fetchImage(ImageType type, ImageReference reference) throws IOException {
241+
Image fetchImage(ImageType type, ImageReference reference, @Nullable ImagePlatform platform)
242+
throws IOException {
238243
Assert.notNull(type, "'type' must not be null");
239244
Assert.notNull(reference, "'reference' must not be null");
240245
if (this.pullPolicy == PullPolicy.ALWAYS) {
241-
return checkPlatformMismatch(pullImage(reference, type), reference);
246+
return pullImageAndCheckForPlatformMismatch(type, reference, platform);
242247
}
243248
try {
244-
return checkPlatformMismatch(Builder.this.docker.image().inspect(reference), reference);
249+
Image image = Builder.this.docker.image().inspect(reference, platform);
250+
return checkPlatformMismatch(image, reference, platform);
245251
}
246252
catch (DockerEngineException ex) {
247253
if (this.pullPolicy == PullPolicy.IF_NOT_PRESENT && ex.getStatusCode() == 404) {
248-
return checkPlatformMismatch(pullImage(reference, type), reference);
254+
return pullImageAndCheckForPlatformMismatch(type, reference, platform);
249255
}
250256
throw ex;
251257
}
252258
}
253259

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

266-
private Image checkPlatformMismatch(Image image, ImageReference imageReference) {
267-
if (this.defaultPlatform != null) {
268-
ImagePlatform imagePlatform = ImagePlatform.from(image);
269-
if (!imagePlatform.equals(this.defaultPlatform)) {
270-
throw new PlatformMismatchException(imageReference, this.defaultPlatform, imagePlatform);
298+
private Image checkPlatformMismatch(Image image, ImageReference reference,
299+
@Nullable ImagePlatform requestedPlatform) {
300+
if (requestedPlatform != null) {
301+
ImagePlatform actualPlatform = ImagePlatform.from(image);
302+
if (!actualPlatform.equals(requestedPlatform)) {
303+
throw new PlatformMismatchException(reference, requestedPlatform, actualPlatform, null);
271304
}
272305
}
273306
return image;
@@ -278,9 +311,9 @@ private Image checkPlatformMismatch(Image image, ImageReference imageReference)
278311
private static final class PlatformMismatchException extends RuntimeException {
279312

280313
private PlatformMismatchException(ImageReference imageReference, ImagePlatform requestedPlatform,
281-
ImagePlatform actualPlatform) {
314+
ImagePlatform actualPlatform, @Nullable Throwable cause) {
282315
super("Image platform mismatch detected. The configured platform '%s' is not supported by the image '%s'. Requested platform '%s' but got '%s'"
283-
.formatted(requestedPlatform, imageReference, requestedPlatform, actualPlatform));
316+
.formatted(requestedPlatform, imageReference, requestedPlatform, actualPlatform), cause);
284317
}
285318

286319
}
@@ -326,13 +359,16 @@ private class BuilderResolverContext implements BuildpackResolverContext {
326359

327360
private final ImageFetcher imageFetcher;
328361

362+
private final ImagePlatform platform;
363+
329364
private final BuilderMetadata builderMetadata;
330365

331366
private final BuildpackLayersMetadata buildpackLayersMetadata;
332367

333-
BuilderResolverContext(ImageFetcher imageFetcher, BuilderMetadata builderMetadata,
368+
BuilderResolverContext(ImageFetcher imageFetcher, ImagePlatform platform, BuilderMetadata builderMetadata,
334369
BuildpackLayersMetadata buildpackLayersMetadata) {
335370
this.imageFetcher = imageFetcher;
371+
this.platform = platform;
336372
this.builderMetadata = builderMetadata;
337373
this.buildpackLayersMetadata = buildpackLayersMetadata;
338374
}
@@ -349,7 +385,7 @@ public BuildpackLayersMetadata getBuildpackLayersMetadata() {
349385

350386
@Override
351387
public Image fetchImage(ImageReference reference, ImageType imageType) throws IOException {
352-
return this.imageFetcher.fetchImage(imageType, reference);
388+
return this.imageFetcher.fetchImage(imageType, reference, this.platform);
353389
}
354390

355391
@Override

buildpack/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
@@ -66,6 +66,8 @@ public class DockerApi {
6666

6767
static final ApiVersion PLATFORM_API_VERSION = ApiVersion.of(1, 41);
6868

69+
static final ApiVersion PLATFORM_INSPECT_API_VERSION = ApiVersion.of(1, 49);
70+
6971
static final ApiVersion UNKNOWN_API_VERSION = ApiVersion.of(0, 0);
7072

7173
static final String API_VERSION_HEADER_NAME = "API-Version";
@@ -237,7 +239,7 @@ public Image pull(ImageReference reference, @Nullable ImagePlatform platform,
237239
listener.onUpdate(event);
238240
});
239241
}
240-
return inspect((platform != null) ? PLATFORM_API_VERSION : API_VERSION, reference);
242+
return inspect(reference, platform);
241243
}
242244
finally {
243245
listener.onFinish();
@@ -339,17 +341,36 @@ public void remove(ImageReference reference, boolean force) throws IOException {
339341
* @throws IOException on IO error
340342
*/
341343
public Image inspect(ImageReference reference) throws IOException {
342-
return inspect(API_VERSION, reference);
344+
return inspect(reference, null);
343345
}
344346

345-
private Image inspect(ApiVersion apiVersion, ImageReference reference) throws IOException {
347+
/**
348+
* Inspect an image.
349+
* @param reference the image reference
350+
* @param platform the platform (os/architecture/variant) of the image to inspect.
351+
* Ignored on older versions of Docker.
352+
* @return the image from the local repository
353+
* @throws IOException on IO error
354+
* @since 3.4.12
355+
*/
356+
public Image inspect(ImageReference reference, @Nullable ImagePlatform platform) throws IOException {
357+
// The Docker documentation is incomplete but platform parameters
358+
// are supported since 1.49 (see https://github.com/moby/moby/pull/49586)
346359
Assert.notNull(reference, "'reference' must not be null");
347-
URI imageUri = buildUrl(apiVersion, "/images/" + reference + "/json");
348-
try (Response response = http().get(imageUri)) {
360+
URI inspectUrl = inspectUrl(reference, platform);
361+
try (Response response = http().get(inspectUrl)) {
349362
return Image.of(response.getContent());
350363
}
351364
}
352365

366+
private URI inspectUrl(ImageReference reference, @Nullable ImagePlatform platform) {
367+
String path = "/images/" + reference + "/json";
368+
if (platform != null && getApiVersion().supports(PLATFORM_INSPECT_API_VERSION)) {
369+
return buildUrl(PLATFORM_INSPECT_API_VERSION, path, "platform", platform.toJson());
370+
}
371+
return buildUrl(path);
372+
}
373+
353374
public void tag(ImageReference sourceReference, ImageReference targetReference) throws IOException {
354375
Assert.notNull(sourceReference, "'sourceReference' must not be null");
355376
Assert.notNull(targetReference, "'targetReference' must not be null");

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import org.springframework.boot.buildpack.platform.docker.type.Image;
2424
import org.springframework.util.Assert;
25+
import org.springframework.util.StringUtils;
2526

2627
/**
2728
* A platform specification for a Docker image.
@@ -102,4 +103,24 @@ public static ImagePlatform from(Image image) {
102103
return new ImagePlatform(image.getOs(), image.getArchitecture(), image.getVariant());
103104
}
104105

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

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

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

2627
import org.jspecify.annotations.Nullable;
2728
import tools.jackson.databind.JsonNode;
2829

2930
import org.springframework.boot.buildpack.platform.json.MappedObject;
31+
import org.springframework.util.CollectionUtils;
3032
import org.springframework.util.StringUtils;
3133

3234
/**
@@ -52,6 +54,8 @@ public class Image extends MappedObject {
5254

5355
private final @Nullable String created;
5456

57+
private final @Nullable Descriptor descriptor;
58+
5559
Image(JsonNode node) {
5660
super(node, MethodHandles.lookup());
5761
this.digests = childrenAt("/RepoDigests", JsonNode::asString);
@@ -61,6 +65,9 @@ public class Image extends MappedObject {
6165
this.architecture = valueAt("/Architecture", String.class);
6266
this.variant = valueAt("/Variant", String.class);
6367
this.created = valueAt("/Created", String.class);
68+
JsonNode descriptorNode = getNode().path("Descriptor");
69+
this.descriptor = (descriptorNode.isMissingNode() || descriptorNode.isNull()) ? null
70+
: new Descriptor(descriptorNode);
6471
}
6572

6673
private List<LayerId> extractLayers(String @Nullable [] layers) {
@@ -126,6 +133,35 @@ public String getOs() {
126133
return this.created;
127134
}
128135

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

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

0 commit comments

Comments
 (0)