Merge branch '3.5.x'

Closes gh-48099
This commit is contained in:
Phillip Webb
2025-11-12 14:22:17 -08:00
12 changed files with 438 additions and 53 deletions
@@ -106,17 +106,25 @@ public class Builder {
this.log.start(request);
validateBindings(request.getBindings());
PullPolicy pullPolicy = request.getPullPolicy();
ImageFetcher imageFetcher = new ImageFetcher(this.dockerConfiguration.builderRegistryAuthentication(),
pullPolicy, request.getImagePlatform());
Image builderImage = imageFetcher.fetchImage(ImageType.BUILDER, request.getBuilder());
ImagePlatform platform = request.getImagePlatform();
boolean specifiedPlatform = request.getImagePlatform() != null;
DockerRegistryAuthentication registryAuthentication = this.dockerConfiguration.builderRegistryAuthentication();
ImageFetcher imageFetcher = new ImageFetcher(registryAuthentication, pullPolicy);
Image builderImage = imageFetcher.fetchImage(ImageType.BUILDER, request.getBuilder(), platform);
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());
platform = (platform != null) ? platform : ImagePlatform.from(builderImage);
Image runImage = imageFetcher.fetchImage(ImageType.RUNNER, request.getRunImage(), platform);
if (specifiedPlatform && runImage.getPrimaryDigest() != null) {
request = request.withRunImage(request.getRunImage().withDigest(runImage.getPrimaryDigest()));
runImage = imageFetcher.fetchImage(ImageType.RUNNER, request.getRunImage(), platform);
}
assertStackIdsMatch(runImage, builderImage);
BuildOwner buildOwner = BuildOwner.fromEnv(builderImage.getConfig().getEnv());
BuildpackLayersMetadata buildpackLayersMetadata = BuildpackLayersMetadata.fromImage(builderImage);
Buildpacks buildpacks = getBuildpacks(request, imageFetcher, builderMetadata, buildpackLayersMetadata);
Buildpacks buildpacks = getBuildpacks(request, imageFetcher, platform, builderMetadata,
buildpackLayersMetadata);
EphemeralBuilder ephemeralBuilder = new EphemeralBuilder(buildOwner, builderImage, request.getName(),
builderMetadata, request.getCreator(), request.getEnv(), buildpacks);
executeLifecycle(request, ephemeralBuilder);
@@ -160,9 +168,9 @@ public class Builder {
}
}
private Buildpacks getBuildpacks(BuildRequest request, ImageFetcher imageFetcher, BuilderMetadata builderMetadata,
BuildpackLayersMetadata buildpackLayersMetadata) {
BuildpackResolverContext resolverContext = new BuilderResolverContext(imageFetcher, builderMetadata,
private Buildpacks getBuildpacks(BuildRequest request, ImageFetcher imageFetcher, ImagePlatform platform,
BuilderMetadata builderMetadata, BuildpackLayersMetadata buildpackLayersMetadata) {
BuildpackResolverContext resolverContext = new BuilderResolverContext(imageFetcher, platform, builderMetadata,
buildpackLayersMetadata);
return BuildpackResolvers.resolveAll(resolverContext, request.getBuildpacks());
}
@@ -225,49 +233,74 @@ public class Builder {
private final PullPolicy pullPolicy;
private @Nullable ImagePlatform defaultPlatform;
ImageFetcher(@Nullable DockerRegistryAuthentication registryAuthentication, PullPolicy pullPolicy,
@Nullable ImagePlatform platform) {
ImageFetcher(@Nullable DockerRegistryAuthentication registryAuthentication, PullPolicy pullPolicy) {
this.registryAuthentication = registryAuthentication;
this.pullPolicy = pullPolicy;
this.defaultPlatform = platform;
}
Image fetchImage(ImageType type, ImageReference reference) throws IOException {
Image fetchImage(ImageType type, ImageReference reference, @Nullable ImagePlatform platform)
throws IOException {
Assert.notNull(type, "'type' must not be null");
Assert.notNull(reference, "'reference' must not be null");
if (this.pullPolicy == PullPolicy.ALWAYS) {
return checkPlatformMismatch(pullImage(reference, type), reference);
return pullImageAndCheckForPlatformMismatch(type, reference, platform);
}
try {
return checkPlatformMismatch(Builder.this.docker.image().inspect(reference), reference);
Image image = Builder.this.docker.image().inspect(reference, platform);
return checkPlatformMismatch(image, reference, platform);
}
catch (DockerEngineException ex) {
if (this.pullPolicy == PullPolicy.IF_NOT_PRESENT && ex.getStatusCode() == 404) {
return checkPlatformMismatch(pullImage(reference, type), reference);
return pullImageAndCheckForPlatformMismatch(type, reference, platform);
}
throw ex;
}
}
private Image pullImage(ImageReference reference, ImageType imageType) throws IOException {
TotalProgressPullListener listener = new TotalProgressPullListener(
Builder.this.log.pullingImage(reference, this.defaultPlatform, imageType));
String authHeader = authHeader(this.registryAuthentication, reference);
Image image = Builder.this.docker.image().pull(reference, this.defaultPlatform, listener, authHeader);
Builder.this.log.pulledImage(image, imageType);
if (this.defaultPlatform == null) {
this.defaultPlatform = ImagePlatform.from(image);
private Image pullImageAndCheckForPlatformMismatch(ImageType type, ImageReference reference,
@Nullable ImagePlatform platform) throws IOException {
try {
Image image = pullImage(reference, type, platform);
return checkPlatformMismatch(image, reference, platform);
}
catch (DockerEngineException ex) {
// Try to throw our own exception for consistent log output. Matching
// on the message is a little brittle, but it doesn't matter too much
// if it fails as the original exception is still enough to stop the build
if (platform != null && ex.getMessage() != null
&& ex.getMessage().contains("does not provide the specified platform")) {
throwAsPlatformMismatchException(type, reference, platform, ex);
}
throw ex;
}
}
private void throwAsPlatformMismatchException(ImageType type, ImageReference reference, ImagePlatform platform,
@Nullable Throwable cause) throws IOException {
try {
Image image = pullImage(reference, type, null);
throw new PlatformMismatchException(reference, platform, ImagePlatform.from(image), cause);
}
catch (DockerEngineException ex) {
}
}
private Image pullImage(ImageReference reference, ImageType imageType, @Nullable ImagePlatform platform)
throws IOException {
TotalProgressPullListener listener = new TotalProgressPullListener(
Builder.this.log.pullingImage(reference, platform, imageType));
String authHeader = authHeader(this.registryAuthentication, reference);
Image image = Builder.this.docker.image().pull(reference, platform, listener, authHeader);
Builder.this.log.pulledImage(image, imageType);
return image;
}
private Image checkPlatformMismatch(Image image, ImageReference imageReference) {
if (this.defaultPlatform != null) {
ImagePlatform imagePlatform = ImagePlatform.from(image);
if (!imagePlatform.equals(this.defaultPlatform)) {
throw new PlatformMismatchException(imageReference, this.defaultPlatform, imagePlatform);
private Image checkPlatformMismatch(Image image, ImageReference reference,
@Nullable ImagePlatform requestedPlatform) {
if (requestedPlatform != null) {
ImagePlatform actualPlatform = ImagePlatform.from(image);
if (!actualPlatform.equals(requestedPlatform)) {
throw new PlatformMismatchException(reference, requestedPlatform, actualPlatform, null);
}
}
return image;
@@ -278,9 +311,9 @@ public class Builder {
private static final class PlatformMismatchException extends RuntimeException {
private PlatformMismatchException(ImageReference imageReference, ImagePlatform requestedPlatform,
ImagePlatform actualPlatform) {
ImagePlatform actualPlatform, @Nullable Throwable cause) {
super("Image platform mismatch detected. The configured platform '%s' is not supported by the image '%s'. Requested platform '%s' but got '%s'"
.formatted(requestedPlatform, imageReference, requestedPlatform, actualPlatform));
.formatted(requestedPlatform, imageReference, requestedPlatform, actualPlatform), cause);
}
}
@@ -326,13 +359,16 @@ public class Builder {
private final ImageFetcher imageFetcher;
private final ImagePlatform platform;
private final BuilderMetadata builderMetadata;
private final BuildpackLayersMetadata buildpackLayersMetadata;
BuilderResolverContext(ImageFetcher imageFetcher, BuilderMetadata builderMetadata,
BuilderResolverContext(ImageFetcher imageFetcher, ImagePlatform platform, BuilderMetadata builderMetadata,
BuildpackLayersMetadata buildpackLayersMetadata) {
this.imageFetcher = imageFetcher;
this.platform = platform;
this.builderMetadata = builderMetadata;
this.buildpackLayersMetadata = buildpackLayersMetadata;
}
@@ -349,7 +385,7 @@ public class Builder {
@Override
public Image fetchImage(ImageReference reference, ImageType imageType) throws IOException {
return this.imageFetcher.fetchImage(imageType, reference);
return this.imageFetcher.fetchImage(imageType, reference, this.platform);
}
@Override
@@ -66,6 +66,8 @@ public class DockerApi {
static final ApiVersion PLATFORM_API_VERSION = ApiVersion.of(1, 41);
static final ApiVersion PLATFORM_INSPECT_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";
@@ -237,7 +239,7 @@ public class DockerApi {
listener.onUpdate(event);
});
}
return inspect((platform != null) ? PLATFORM_API_VERSION : API_VERSION, reference);
return inspect(reference, platform);
}
finally {
listener.onFinish();
@@ -339,17 +341,36 @@ public class DockerApi {
* @throws IOException on IO error
*/
public Image inspect(ImageReference reference) throws IOException {
return inspect(API_VERSION, reference);
return inspect(reference, null);
}
private Image inspect(ApiVersion apiVersion, ImageReference reference) throws IOException {
/**
* Inspect an image.
* @param reference the image reference
* @param platform the platform (os/architecture/variant) of the image to inspect.
* Ignored on older versions of Docker.
* @return the image from the local repository
* @throws IOException on IO error
* @since 3.4.12
*/
public Image inspect(ImageReference reference, @Nullable ImagePlatform platform) throws IOException {
// The Docker documentation is incomplete but platform parameters
// are supported since 1.49 (see https://github.com/moby/moby/pull/49586)
Assert.notNull(reference, "'reference' must not be null");
URI imageUri = buildUrl(apiVersion, "/images/" + reference + "/json");
try (Response response = http().get(imageUri)) {
URI inspectUrl = inspectUrl(reference, platform);
try (Response response = http().get(inspectUrl)) {
return Image.of(response.getContent());
}
}
private URI inspectUrl(ImageReference reference, @Nullable ImagePlatform platform) {
String path = "/images/" + reference + "/json";
if (platform != null && getApiVersion().supports(PLATFORM_INSPECT_API_VERSION)) {
return buildUrl(PLATFORM_INSPECT_API_VERSION, path, "platform", platform.toJson());
}
return buildUrl(path);
}
public void tag(ImageReference sourceReference, ImageReference targetReference) throws IOException {
Assert.notNull(sourceReference, "'sourceReference' must not be null");
Assert.notNull(targetReference, "'targetReference' must not be null");
@@ -22,6 +22,7 @@ import org.jspecify.annotations.Nullable;
import org.springframework.boot.buildpack.platform.docker.type.Image;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* A platform specification for a Docker image.
@@ -102,4 +103,24 @@ public class ImagePlatform {
return new ImagePlatform(image.getOs(), image.getArchitecture(), image.getVariant());
}
/**
* Return a JSON-encoded representation of this platform.
* @return the JSON string
*/
public String toJson() {
StringBuilder json = new StringBuilder("{");
json.append(jsonPair("os", this.os));
if (StringUtils.hasText(this.architecture)) {
json.append(",").append(jsonPair("architecture", this.architecture));
}
if (StringUtils.hasText(this.variant)) {
json.append(",").append(jsonPair("variant", this.variant));
}
return json.append("}").toString();
}
private String jsonPair(String name, String value) {
return "\"%s\":\"%s\"".formatted(name, value);
}
}
@@ -22,11 +22,13 @@ import java.lang.invoke.MethodHandles;
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;
import org.springframework.boot.buildpack.platform.json.MappedObject;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
/**
@@ -52,6 +54,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 +65,9 @@ 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<LayerId> extractLayers(String @Nullable [] layers) {
@@ -126,6 +133,35 @@ public class Image extends MappedObject {
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;
}
/**
* Return the primary digest of the image or {@code null}. Checks the
* {@code Descriptor.digest} first, falling back to {@code RepoDigest}.
* @return the primary digest or {@code null}
* @since 3.4.12
*/
public @Nullable String getPrimaryDigest() {
if (this.descriptor != null && StringUtils.hasText(this.descriptor.getDigest())) {
return this.descriptor.getDigest();
}
if (!CollectionUtils.isEmpty(this.digests)) {
try {
String digest = this.digests.get(0);
return (digest != null) ? ImageReference.of(digest).getDigest() : null;
}
catch (RuntimeException ex) {
}
}
return null;
}
/**
* Create a new {@link Image} instance from the specified JSON content.
* @param content the JSON content
@@ -136,4 +172,24 @@ public class Image extends MappedObject {
return of(content, Image::new);
}
/**
* Descriptor details as reported in the {@code Docker inspect} response.
*
* @since 3.4.12
*/
public final class Descriptor extends MappedObject {
private final String digest;
Descriptor(JsonNode node) {
super(node, MethodHandles.lookup());
this.digest = Objects.requireNonNull(valueAt("/digest", String.class));
}
public String getDigest() {
return this.digest;
}
}
}
@@ -78,6 +78,9 @@ class BuilderTests {
private static final ImageReference BASE_CNB = ImageReference.of("docker.io/cloudfoundry/run:base-cnb");
private static final ImageReference PLATFORM_CNB = ImageReference
.of("docker.io/cloudfoundry/run@sha256:fb5ecb90a42b2067a859aab23fc1f5e9d9c2589d07ba285608879e7baa415aad");
@Test
@SuppressWarnings("NullAway") // Test null check
void createWhenLogIsNullThrowsException() {
@@ -278,8 +281,8 @@ class BuilderTests {
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(BASE_CNB), eq(ImagePlatform.from(builderImage)), any(), isNull()))
.willAnswer(withPulledImage(runImage));
given(docker.image().inspect(eq(DEFAULT_BUILDER))).willReturn(builderImage);
given(docker.image().inspect(eq(BASE_CNB))).willReturn(runImage);
given(docker.image().inspect(eq(DEFAULT_BUILDER), any())).willReturn(builderImage);
given(docker.image().inspect(eq(BASE_CNB), any())).willReturn(runImage);
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest().withPullPolicy(PullPolicy.NEVER);
builder.build(request);
@@ -291,7 +294,7 @@ class BuilderTests {
assertThat(tag).isNotNull();
then(docker.image()).should().remove(tag, true);
then(docker.image()).should(never()).pull(any(), any(), any());
then(docker.image()).should(times(2)).inspect(any());
then(docker.image()).should(times(2)).inspect(any(), any());
}
@Test
@@ -330,11 +333,11 @@ class BuilderTests {
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(BASE_CNB), eq(ImagePlatform.from(builderImage)), any(), isNull()))
.willAnswer(withPulledImage(runImage));
given(docker.image().inspect(eq(DEFAULT_BUILDER)))
given(docker.image().inspect(eq(DEFAULT_BUILDER), any()))
.willThrow(new TestDockerEngineException("docker://localhost/", new URI("example"), 404, "NOT FOUND", null,
null, null))
.willReturn(builderImage);
given(docker.image().inspect(eq(BASE_CNB)))
given(docker.image().inspect(eq(BASE_CNB), any()))
.willThrow(new TestDockerEngineException("docker://localhost/", new URI("example"), 404, "NOT FOUND", null,
null, null))
.willReturn(runImage);
@@ -348,7 +351,7 @@ class BuilderTests {
ImageReference tag = archive.getValue().getTag();
assertThat(tag).isNotNull();
then(docker.image()).should().remove(tag, true);
then(docker.image()).should(times(2)).inspect(any());
then(docker.image()).should(times(2)).inspect(any(), any());
then(docker.image()).should(times(2)).pull(any(), any(), any(), isNull());
}
@@ -425,6 +428,8 @@ class BuilderTests {
given(docker.image().pull(eq(DEFAULT_BUILDER), eq(platform), any(), isNull()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(BASE_CNB), eq(platform), any(), isNull())).willAnswer(withPulledImage(runImage));
given(docker.image().pull(eq(PLATFORM_CNB), eq(platform), any(), isNull()))
.willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker, null);
BuildRequest request = getTestRequest().withImagePlatform("linux/arm64/v1");
builder.build(request);
@@ -433,6 +438,7 @@ class BuilderTests {
ArgumentCaptor<ImageArchive> archive = ArgumentCaptor.forClass(ImageArchive.class);
then(docker.image()).should().pull(eq(DEFAULT_BUILDER), eq(platform), any(), isNull());
then(docker.image()).should().pull(eq(BASE_CNB), eq(platform), any(), isNull());
then(docker.image()).should().pull(eq(PLATFORM_CNB), eq(platform), any(), isNull());
then(docker.image()).should().load(archive.capture(), any());
ImageReference tag = archive.getValue().getTag();
assertThat(tag).isNotNull();
@@ -23,6 +23,8 @@ import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
@@ -88,20 +90,25 @@ class DockerApiTests {
private static final String API_URL = "/v" + DockerApi.API_VERSION;
private static final String PLATFORM_API_URL = "/v" + DockerApi.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 PLATFORM_IMAGES_URL = "/v" + DockerApi.PLATFORM_API_VERSION + "/images";
private static final String PLATFORM_INSPECT_IMAGES_URL = "/v" + DockerApi.PLATFORM_INSPECT_API_VERSION + "/images";
private static final String CONTAINERS_URL = API_URL + "/containers";
private static final String PLATFORM_CONTAINERS_URL = PLATFORM_API_URL + "/containers";
private static final String PLATFORM_CONTAINERS_URL = "/v" + DockerApi.PLATFORM_API_VERSION + "/containers";
private static final String VOLUMES_URL = API_URL + "/volumes";
private static final ImagePlatform LINUX_ARM64_PLATFORM = ImagePlatform.of("linux/arm64/v1");
private static final String ENCODED_LINUX_ARM64_PLATFORM_JSON = URLEncoder.encode(LINUX_ARM64_PLATFORM.toJson(),
StandardCharsets.UTF_8);
@Mock
@SuppressWarnings("NullAway.Init")
private HttpTransport http;
@@ -242,15 +249,15 @@ class DockerApiTests {
@Test
void pullWithPlatformPullsImageAndProducesEvents() throws Exception {
ImageReference reference = ImageReference.of("gcr.io/paketo-buildpacks/builder:base");
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(PLATFORM_INSPECT_IMAGES_URL + "/gcr.io/paketo-buildpacks/builder:base/json?platform="
+ ENCODED_LINUX_ARM64_PLATFORM_JSON);
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);
Image image = this.api.pull(reference, LINUX_ARM64_PLATFORM, this.pullListener);
assertThat(image.getLayers()).hasSize(46);
InOrder ordered = inOrder(this.pullListener);
ordered.verify(this.pullListener).onStart();
@@ -400,6 +407,32 @@ class DockerApiTests {
URI imageUri = new URI(IMAGES_URL + "/docker.io/paketobuildpacks/builder:base/json");
given(http().get(imageUri)).willReturn(responseOf("type/image.json"));
Image image = this.api.inspect(reference);
assertThat(image.getArchitecture()).isEqualTo("amd64");
assertThat(image.getLayers()).hasSize(46);
}
@Test
void inspectWithPlatformWhenSupportedVersionInspectImage() throws Exception {
ImageReference reference = ImageReference.of("docker.io/paketobuildpacks/builder:base");
URI imageUri = new URI(PLATFORM_INSPECT_IMAGES_URL
+ "/docker.io/paketobuildpacks/builder:base/json?platform=" + ENCODED_LINUX_ARM64_PLATFORM_JSON);
given(http().head(eq(new URI(PING_URL)))).willReturn(responseWithHeaders(
new BasicHeader(DockerApi.API_VERSION_HEADER_NAME, DockerApi.PLATFORM_INSPECT_API_VERSION)));
given(http().get(imageUri)).willReturn(responseOf("type/image-platform.json"));
Image image = this.api.inspect(reference, LINUX_ARM64_PLATFORM);
assertThat(image.getArchitecture()).isEqualTo("arm64");
assertThat(image.getLayers()).hasSize(2);
}
@Test
void inspectWithPlatformWhenOldVersionInspectImage() throws Exception {
ImageReference reference = ImageReference.of("docker.io/paketobuildpacks/builder:base");
URI imageUri = new URI(IMAGES_URL + "/docker.io/paketobuildpacks/builder:base/json");
given(http().head(eq(new URI(PING_URL)))).willReturn(responseWithHeaders(
new BasicHeader(DockerApi.API_VERSION_HEADER_NAME, DockerApi.PLATFORM_API_VERSION)));
given(http().get(imageUri)).willReturn(responseOf("type/image.json"));
Image image = this.api.inspect(reference, LINUX_ARM64_PLATFORM);
assertThat(image.getArchitecture()).isEqualTo("amd64");
assertThat(image.getLayers()).hasSize(46);
}
@@ -64,6 +64,20 @@ class ImagePlatformTests extends AbstractJsonTests {
assertThat(platform.toString()).isEqualTo("linux/amd64/v1");
}
@Test
void toJsonString() {
ImagePlatform platform = ImagePlatform.of("linux/amd64/v1");
assertThat(platform.toJson()).isEqualTo("""
{"os":"linux","architecture":"amd64","variant":"v1"}""");
}
@Test
void toJsonStringWhenOnlyOs() {
ImagePlatform platform = ImagePlatform.of("linux");
assertThat(platform.toJson()).isEqualTo("""
{"os":"linux"}""");
}
private Image getImage() throws IOException {
return Image.of(getContent("type/image.json"));
}
@@ -23,6 +23,7 @@ import java.util.Map;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.Test;
import org.springframework.boot.buildpack.platform.docker.type.Image.Descriptor;
import org.springframework.boot.buildpack.platform.json.AbstractJsonTests;
import static org.assertj.core.api.Assertions.assertThat;
@@ -98,6 +99,35 @@ class ImageTests extends AbstractJsonTests {
assertThat(image.getCreated()).isEqualTo("2019-10-30T19:34:56.296666503Z");
}
@Test
void getDescriptorReturnsDescriptor() throws Exception {
Image image = getImage();
Descriptor descriptor = image.getDescriptor();
assertThat(descriptor).isNotNull();
assertThat(descriptor.getDigest())
.isEqualTo("sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96");
}
@Test
void getPrimaryDigestWhenHasDescriptor() throws Exception {
Image image = getImage();
assertThat(image.getPrimaryDigest())
.isEqualTo("sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96");
}
@Test
void getPrimaryDigestWhenNoDescriptor() throws Exception {
Image image = Image.of(getContent("image-no-descriptor.json"));
assertThat(image.getPrimaryDigest())
.isEqualTo("sha256:21635a6b4880772f3fabbf8b660907fa38636558cf787cc26f1779fc4b4e2cba");
}
@Test
void getPrimaryDigestWhenNoDigest() throws Exception {
Image image = Image.of(getContent("image-no-digest.json"));
assertThat(image.getPrimaryDigest()).isNull();
}
private Image getImage() throws IOException {
return Image.of(getContent("image.json"));
}
@@ -0,0 +1,25 @@
{
"Id": "sha256:21635a6b4880772f3fabbf8b660907fa38636558cf787cc26f1779fc4b4e2cba",
"RepoTags": [
"ghcr.io/spring-io/spring-boot-cnb-test-builder:0.0.1"
],
"RepoDigests": [
"ghcr.io/spring-io/spring-boot-cnb-test-builder@sha256:21635a6b4880772f3fabbf8b660907fa38636558cf787cc26f1779fc4b4e2cba"
],
"Parent": "",
"Comment": "",
"DockerVersion": "",
"Author": "",
"Config": null,
"Architecture": "",
"Os": "",
"Size": 166797518,
"GraphDriver": {
"Data": null,
"Name": "overlayfs"
},
"RootFS": {},
"Metadata": {
"LastTagTime": "2025-04-10T22:41:27.520294922Z"
}
}
@@ -0,0 +1,22 @@
{
"Id": "sha256:21635a6b4880772f3fabbf8b660907fa38636558cf787cc26f1779fc4b4e2cba",
"RepoTags": [
"ghcr.io/spring-io/spring-boot-cnb-test-builder:0.0.1"
],
"Parent": "",
"Comment": "",
"DockerVersion": "",
"Author": "",
"Config": null,
"Architecture": "",
"Os": "",
"Size": 166797518,
"GraphDriver": {
"Data": null,
"Name": "overlayfs"
},
"RootFS": {},
"Metadata": {
"LastTagTime": "2025-04-10T22:41:27.520294922Z"
}
}
@@ -1,5 +1,27 @@
{
"Id": "sha256:9b450bffdb05bcf660d464d0bfdf344ee6ca38e9b8de4f408c8080b0c9319349",
"Descriptor": {
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96",
"size": 424,
"urls": [
"https://example.com"
],
"annotations": {
"com.docker.official-images.bashbrew.arch": "amd64",
"org.opencontainers.image.version": "24.04"
},
"data": null,
"platform": {
"architecture": "arm",
"os": "windows",
"os.version": "10.0.19041.1165",
"os.features": [
],
"variant": "v7"
},
"artifactType": null
},
"RepoTags": [
"paketo-buildpacks/cnb:latest"
],