From 9b387cbe77a7b2ae88a2f4ecce6bb5d293c248c2 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Wed, 12 Nov 2025 19:57:36 -0800 Subject: [PATCH] Support recent Docker installs by raising the API version when possible Update `DockerApi` so that the URL uses version `v1.50` whenever possible. Prior to this commit, `v1.24` was often used which breaks recent Docker installs due to the dropping of API version v1.43 and below. If the actual API version running is less than `v1.50`, but greater than the minimum required for the API call, it will be used instead. This hopefully means that older versions of Docker will continue to work as they did previously. Fixes gh-48050 --- .../buildpack/platform/build/ApiVersions.java | 1 - .../buildpack/platform/docker/DockerApi.java | 67 +++++++---- .../platform/docker/type/ApiVersion.java | 11 +- .../platform/docker/DockerApiTests.java | 106 ++++++++++++------ .../platform/docker/type/ApiVersionTests.java | 11 ++ 5 files changed, 138 insertions(+), 58 deletions(-) diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/ApiVersions.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/ApiVersions.java index fcd9458c8c2..c9805e34070 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/ApiVersions.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/ApiVersions.java @@ -69,7 +69,6 @@ final class ApiVersions { if (obj == null || getClass() != obj.getClass()) { return false; } - ApiVersions other = (ApiVersions) obj; return Arrays.equals(this.apiVersions, other.apiVersions); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java index 23e7516bd70..e0ba96e7039 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java @@ -64,14 +64,10 @@ public class DockerApi { private static final List FORCE_PARAMS = Collections.unmodifiableList(Arrays.asList("force", "1")); - static final ApiVersion API_VERSION = ApiVersion.of(1, 24); - - 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 ApiVersion PREFERRED_API_VERSION = ApiVersion.of(1, 50); + static final String API_VERSION_HEADER_NAME = "API-Version"; private final HttpTransport http; @@ -127,17 +123,30 @@ public class DockerApi { } private URI buildUrl(String path, Collection params) { - return buildUrl(API_VERSION, path, (params != null) ? params.toArray() : null); + return buildUrl(Feature.BASELINE, path, (params != null) ? params.toArray() : null); } private URI buildUrl(String path, Object... params) { - return buildUrl(API_VERSION, path, params); + return buildUrl(Feature.BASELINE, path, params); } - private URI buildUrl(ApiVersion apiVersion, String path, Object... params) { - verifyApiVersion(apiVersion); + URI buildUrl(Feature feature, String path, Object... params) { + ApiVersion version = getApiVersion(); + if (version.equals(UNKNOWN_API_VERSION) || (version.compareTo(PREFERRED_API_VERSION) >= 0 + && version.compareTo(feature.minimumVersion()) >= 0)) { + return buildVersionedUrl(PREFERRED_API_VERSION, path, params); + } + if (version.compareTo(feature.minimumVersion()) >= 0) { + return buildVersionedUrl(version, path, params); + } + throw new IllegalStateException( + "Docker API version must be at least %s to support this feature, but current API version is %s" + .formatted(feature.minimumVersion(), version)); + } + + private URI buildVersionedUrl(ApiVersion version, String path, Object[] params) { try { - URIBuilder builder = new URIBuilder("/v" + apiVersion + path); + URIBuilder builder = new URIBuilder("/v" + version + path); if (params != null) { int param = 0; while (param < params.length) { @@ -151,13 +160,6 @@ public class DockerApi { } } - private void verifyApiVersion(ApiVersion minimumVersion) { - ApiVersion actualVersion = getApiVersion(); - Assert.state(actualVersion.equals(UNKNOWN_API_VERSION) || actualVersion.supports(minimumVersion), - () -> "Docker API version must be at least " + minimumVersion - + " to support this feature, but current API version is " + actualVersion); - } - private ApiVersion getApiVersion() { ApiVersion apiVersion = this.apiVersion; if (this.apiVersion == null) { @@ -226,7 +228,7 @@ public class DockerApi { Assert.notNull(reference, "Reference must not be null"); Assert.notNull(listener, "Listener must not be null"); URI createUri = (platform != null) - ? buildUrl(PLATFORM_API_VERSION, "/images/create", "fromImage", reference, "platform", platform) + ? buildUrl(Feature.PLATFORM, "/images/create", "fromImage", reference, "platform", platform) : buildUrl("/images/create", "fromImage", reference); DigestCaptureUpdateListener digestCapture = new DigestCaptureUpdateListener(); listener.onStart(); @@ -388,8 +390,8 @@ public class DockerApi { private URI inspectUrl(ImageReference reference, 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()); + if (platform != null && getApiVersion().supports(Feature.PLATFORM_INSPECT.minimumVersion())) { + return buildUrl(Feature.PLATFORM_INSPECT, path, "platform", platform.toJson()); } return buildUrl(path); } @@ -435,8 +437,7 @@ public class DockerApi { } private ContainerReference createContainer(ContainerConfig config, ImagePlatform platform) throws IOException { - URI createUri = (platform != null) - ? buildUrl(PLATFORM_API_VERSION, "/containers/create", "platform", platform) + URI createUri = (platform != null) ? buildUrl(Feature.PLATFORM, "/containers/create", "platform", platform) : buildUrl("/containers/create"); try (Response response = http().post(createUri, "application/json", config::writeTo)) { return ContainerReference @@ -634,4 +635,24 @@ public class DockerApi { } + enum Feature { + + BASELINE(ApiVersion.of(1, 24)), + + PLATFORM(ApiVersion.of(1, 41)), + + PLATFORM_INSPECT(ApiVersion.of(1, 49)); + + private final ApiVersion minimumVersion; + + Feature(ApiVersion minimumVersion) { + this.minimumVersion = minimumVersion; + } + + ApiVersion minimumVersion() { + return this.minimumVersion; + } + + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ApiVersion.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ApiVersion.java index e45509d71bf..0db86dac147 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ApiVersion.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ApiVersion.java @@ -16,6 +16,7 @@ package org.springframework.boot.buildpack.platform.docker.type; +import java.util.Comparator; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -28,10 +29,13 @@ import org.springframework.util.Assert; * @author Scott Frederick * @since 3.4.0 */ -public final class ApiVersion { +public final class ApiVersion implements Comparable { private static final Pattern PATTERN = Pattern.compile("^v?(\\d+)\\.(\\d*)$"); + private static final Comparator COMPARATOR = Comparator.comparing(ApiVersion::getMajor) + .thenComparing(ApiVersion::getMinor); + private final int major; private final int minor; @@ -135,4 +139,9 @@ public final class ApiVersion { return new ApiVersion(major, minor); } + @Override + public int compareTo(ApiVersion other) { + return COMPARATOR.compare(this, other); + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java index 85d72df00b3..443cbd77d61 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java @@ -45,6 +45,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.boot.buildpack.platform.docker.DockerApi.ContainerApi; +import org.springframework.boot.buildpack.platform.docker.DockerApi.Feature; import org.springframework.boot.buildpack.platform.docker.DockerApi.ImageApi; import org.springframework.boot.buildpack.platform.docker.DockerApi.SystemApi; import org.springframework.boot.buildpack.platform.docker.DockerApi.VolumeApi; @@ -90,20 +91,14 @@ import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) class DockerApiTests { - private static final String API_URL = "/v" + DockerApi.API_VERSION; + private static final String API_URL = "/v" + DockerApi.PREFERRED_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 = "/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 = "/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"); @@ -176,6 +171,52 @@ class DockerApiTests { assertThat(api).isNotNull(); } + @Test + void buildUrlWhenUnknownVersionUsesPreferredVersion() throws Exception { + setVersion("0.0"); + assertThat(this.dockerApi.buildUrl(Feature.BASELINE, "/test")) + .isEqualTo(URI.create("/v" + DockerApi.PREFERRED_API_VERSION + "/test")); + } + + @Test + void buildUrlWhenVersionIsGreaterThanPreferredUsesPreferred() throws Exception { + setVersion("1000.0"); + assertThat(this.dockerApi.buildUrl(Feature.BASELINE, "/test")) + .isEqualTo(URI.create("/v" + DockerApi.PREFERRED_API_VERSION + "/test")); + } + + @Test + void buildUrlWhenVersionIsEqualToPreferredUsesPreferred() throws Exception { + setVersion(DockerApi.PREFERRED_API_VERSION.toString()); + assertThat(this.dockerApi.buildUrl(Feature.BASELINE, "/test")) + .isEqualTo(URI.create("/v" + DockerApi.PREFERRED_API_VERSION + "/test")); + } + + @Test + void buildUrlWhenVersionIsLessThanPreferredAndGreaterThanMinimumUsesVersionVersion() throws Exception { + setVersion("1.48"); + assertThat(this.dockerApi.buildUrl(Feature.BASELINE, "/test")).isEqualTo(URI.create("/v1.48/test")); + } + + @Test + void buildUrlWhenVersionIsLessThanPreferredAndEqualToMinimumUsesVersionVersion() throws Exception { + setVersion(Feature.BASELINE.minimumVersion().toString()); + assertThat(this.dockerApi.buildUrl(Feature.BASELINE, "/test")).isEqualTo(URI.create("/v1.24/test")); + } + + @Test + void buildUrlWhenVersionIsLessThanMinimumThrowsException() throws Exception { + setVersion("1.23"); + assertThatIllegalStateException().isThrownBy(() -> this.dockerApi.buildUrl(Feature.BASELINE, "/test")) + .withMessage("Docker API version must be at least 1.24 " + + "to support this feature, but current API version is 1.23"); + } + + private void setVersion(String version) throws IOException, URISyntaxException { + given(http().head(eq(new URI(PING_URL)))) + .willReturn(responseWithHeaders(new BasicHeader(DockerApi.API_VERSION_HEADER_NAME, version))); + } + @Nested class ImageDockerApiTests { @@ -244,12 +285,11 @@ class DockerApiTests { @Test void pullWithPlatformPullsImageAndProducesEvents() throws Exception { ImageReference reference = ImageReference.of("gcr.io/paketo-buildpacks/builder:base"); - URI createUri = new URI(PLATFORM_IMAGES_URL - + "/create?fromImage=gcr.io%2Fpaketo-buildpacks%2Fbuilder%3Abase&platform=linux%2Farm64%2Fv1"); - URI imageUri = new URI(PLATFORM_INSPECT_IMAGES_URL + "/gcr.io/paketo-buildpacks/builder:base/json?platform=" + URI createUri = new URI( + "/v1.49/images/create?fromImage=gcr.io%2Fpaketo-buildpacks%2Fbuilder%3Abase&platform=linux%2Farm64%2Fv1"); + URI imageUri = new URI("/v1.49/images/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.49"))); + setVersion("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, LINUX_ARM64_PLATFORM, this.pullListener); @@ -264,8 +304,7 @@ class DockerApiTests { void pullWithPlatformAndInsufficientApiVersionThrowsException() throws Exception { ImageReference reference = ImageReference.of("gcr.io/paketo-buildpacks/builder:base"); ImagePlatform platform = ImagePlatform.of("linux/arm64/v1"); - given(http().head(eq(new URI(PING_URL)))).willReturn( - responseWithHeaders(new BasicHeader(DockerApi.API_VERSION_HEADER_NAME, DockerApi.API_VERSION))); + setVersion("1.24"); assertThatIllegalStateException().isThrownBy(() -> this.api.pull(reference, platform, this.pullListener)) .withMessageContaining("must be at least 1.41") .withMessageContaining("current API version is 1.24"); @@ -403,10 +442,9 @@ class DockerApiTests { @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))); + URI imageUri = new URI("/v1.49/images/docker.io/paketobuildpacks/builder:base/json?platform=" + + ENCODED_LINUX_ARM64_PLATFORM_JSON); + setVersion("1.49"); given(http().get(imageUri)).willReturn(responseOf("type/image-platform.json")); Image image = this.api.inspect(reference, LINUX_ARM64_PLATFORM); assertThat(image.getArchitecture()).isEqualTo("arm64"); @@ -416,9 +454,8 @@ class DockerApiTests { @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))); + URI imageUri = new URI("/v1.48/images/docker.io/paketobuildpacks/builder:base/json"); + setVersion("1.48"); given(http().get(imageUri)).willReturn(responseOf("type/image.json")); Image image = this.api.inspect(reference, LINUX_ARM64_PLATFORM); assertThat(image.getArchitecture()).isEqualTo("amd64"); @@ -619,23 +656,27 @@ class DockerApiTests { @Test void createWithPlatformCreatesContainer() throws Exception { - createWithPlatform("1.41"); + ImageReference imageReference = ImageReference.of("ubuntu:bionic"); + ContainerConfig config = ContainerConfig.of(imageReference, (update) -> update.withCommand("/bin/bash")); + ImagePlatform platform = ImagePlatform.of("linux/arm64/v1"); + setVersion("1.41"); + URI createUri = new URI("/v1.41/containers/create?platform=linux%2Farm64%2Fv1"); + given(http().post(eq(createUri), eq("application/json"), any())) + .willReturn(responseOf("create-container-response.json")); + ContainerReference containerReference = this.api.create(config, platform); + assertThat(containerReference).hasToString("e90e34656806"); + then(http()).should().post(any(), any(), this.writer.capture()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + this.writer.getValue().accept(out); + assertThat(out.toByteArray()).hasSize(config.toString().length()); } @Test void createWithPlatformAndUnknownApiVersionAttemptsCreate() throws Exception { - createWithPlatform(null); - } - - private void createWithPlatform(String apiVersion) throws IOException, URISyntaxException { ImageReference imageReference = ImageReference.of("ubuntu:bionic"); ContainerConfig config = ContainerConfig.of(imageReference, (update) -> update.withCommand("/bin/bash")); ImagePlatform platform = ImagePlatform.of("linux/arm64/v1"); - if (apiVersion != null) { - given(http().head(eq(new URI(PING_URL)))) - .willReturn(responseWithHeaders(new BasicHeader(DockerApi.API_VERSION_HEADER_NAME, apiVersion))); - } - URI createUri = new URI(PLATFORM_CONTAINERS_URL + "/create?platform=linux%2Farm64%2Fv1"); + URI createUri = new URI(CONTAINERS_URL + "/create?platform=linux%2Farm64%2Fv1"); given(http().post(eq(createUri), eq("application/json"), any())) .willReturn(responseOf("create-container-response.json")); ContainerReference containerReference = this.api.create(config, platform); @@ -651,8 +692,7 @@ class DockerApiTests { ImageReference imageReference = ImageReference.of("ubuntu:bionic"); ContainerConfig config = ContainerConfig.of(imageReference, (update) -> update.withCommand("/bin/bash")); ImagePlatform platform = ImagePlatform.of("linux/arm64/v1"); - given(http().head(eq(new URI(PING_URL)))) - .willReturn(responseWithHeaders(new BasicHeader(DockerApi.API_VERSION_HEADER_NAME, "1.24"))); + setVersion("1.24"); assertThatIllegalStateException().isThrownBy(() -> this.api.create(config, platform)) .withMessageContaining("must be at least 1.41") .withMessageContaining("current API version is 1.24"); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ApiVersionTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ApiVersionTests.java index 591e0cb9e30..c485e239357 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ApiVersionTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ApiVersionTests.java @@ -110,6 +110,17 @@ class ApiVersionTests { assertThat(v12a).isEqualTo(v12a).isEqualTo(v12b).isNotEqualTo(v13); } + @Test + void compareTo() { + assertThat(ApiVersion.of(0, 0).compareTo(ApiVersion.of(0, 0))).isZero(); + assertThat(ApiVersion.of(0, 1).compareTo(ApiVersion.of(0, 1))).isZero(); + assertThat(ApiVersion.of(1, 0).compareTo(ApiVersion.of(1, 0))).isZero(); + assertThat(ApiVersion.of(0, 0).compareTo(ApiVersion.of(0, 1))).isLessThan(0); + assertThat(ApiVersion.of(0, 1).compareTo(ApiVersion.of(0, 0))).isGreaterThan(0); + assertThat(ApiVersion.of(1, 0).compareTo(ApiVersion.of(0, 1))).isGreaterThan(0); + assertThat(ApiVersion.of(0, 1).compareTo(ApiVersion.of(1, 0))).isLessThan(0); + } + private boolean supports(String v1, String v2) { return ApiVersion.parse(v1).supports(ApiVersion.parse(v2)); }