From d00b73692b59603ac9f1ba2fa7c8bb794ff4840e Mon Sep 17 00:00:00 2001 From: John Kelly Date: Wed, 20 May 2026 13:48:21 -0500 Subject: [PATCH 1/2] feat: opt-in API version auto-negotiation Adds DefaultDockerClientConfig.Builder.withApiVersionAutoNegotiation(boolean) (also exposed via DOCKER_API_VERSION_AUTO_NEGOTIATION env var and the api.version.auto.negotiation property). When enabled and no explicit api version is set, DockerClientImpl.getInstance(config, httpClient) queries the daemon's /version endpoint and pins the client to min(daemon ApiVersion, latest version known to docker-java). If that result is below the daemon's MinAPIVersion, the daemon's minimum is used and a WARN is logged - matching the behaviour of moby/client.NegotiateAPIVersion. Default is off; existing callers see no change. The new isApiVersionAutoNegotiationEnabled() method on DockerClientConfig is a default-returning-false interface method, which japicmp already tolerates via the project's METHOD_NEW_DEFAULT override. Fixes #2547 --- .../dockerjava/core/ApiVersionNegotiator.java | 140 ++++++++++++++++++ .../core/DefaultDockerClientConfig.java | 62 +++++++- .../dockerjava/core/DockerClientConfig.java | 13 ++ .../dockerjava/core/DockerClientImpl.java | 15 +- .../core/NegotiatedDockerClientConfig.java | 85 +++++++++++ .../core/ApiVersionNegotiatorTest.java | 82 ++++++++++ .../core/DefaultDockerClientConfigTest.java | 67 +++++++++ 7 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 docker-java-core/src/main/java/com/github/dockerjava/core/ApiVersionNegotiator.java create mode 100644 docker-java-core/src/main/java/com/github/dockerjava/core/NegotiatedDockerClientConfig.java create mode 100644 docker-java/src/test/java/com/github/dockerjava/core/ApiVersionNegotiatorTest.java diff --git a/docker-java-core/src/main/java/com/github/dockerjava/core/ApiVersionNegotiator.java b/docker-java-core/src/main/java/com/github/dockerjava/core/ApiVersionNegotiator.java new file mode 100644 index 000000000..c6368ede6 --- /dev/null +++ b/docker-java-core/src/main/java/com/github/dockerjava/core/ApiVersionNegotiator.java @@ -0,0 +1,140 @@ +package com.github.dockerjava.core; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.dockerjava.api.exception.DockerClientException; +import com.github.dockerjava.api.model.Version; +import com.github.dockerjava.transport.DockerHttpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Objects; + +/** + * Performs one-shot Docker Remote API version negotiation against a running daemon. + *

+ * Mirrors the behaviour of {@code moby/client.NegotiateAPIVersion}: queries {@code GET /version}, + * reads the daemon's {@code ApiVersion} and {@code MinAPIVersion}, and returns + * {@code min(daemon ApiVersion, latest version known to docker-java)}. If that result is below the + * daemon's {@code MinAPIVersion}, the daemon's minimum is used instead and a warning is logged — + * matching {@code moby}'s behaviour. + *

+ * Callers normally don't invoke this directly: enable + * {@link DefaultDockerClientConfig.Builder#withApiVersionAutoNegotiation(boolean)} and the + * {@link DockerClientImpl#getInstance(DockerClientConfig, DockerHttpClient)} factory will run + * negotiation automatically. The class is exposed as a public utility for callers that want to + * negotiate without going through {@code DockerClientImpl} (for example, when constructing + * commands manually for tests). + */ +public final class ApiVersionNegotiator { + + private static final Logger LOGGER = LoggerFactory.getLogger(ApiVersionNegotiator.class); + + private ApiVersionNegotiator() { + } + + /** + * Calls {@code GET /version} via {@code httpClient}, parses the response with {@code objectMapper}, + * and returns the negotiated {@link RemoteApiVersion}. + * + * @throws DockerClientException if the daemon is unreachable, returns a non-2xx status, or returns + * a {@code /version} payload without a parseable {@code ApiVersion}. + */ + @Nonnull + public static RemoteApiVersion negotiate(@Nonnull DockerHttpClient httpClient, @Nonnull ObjectMapper objectMapper) { + Objects.requireNonNull(httpClient, "httpClient was not specified"); + Objects.requireNonNull(objectMapper, "objectMapper was not specified"); + + Version daemonVersion = fetchDaemonVersion(httpClient, objectMapper); + + RemoteApiVersion daemonApi = parseOrThrow(daemonVersion.getApiVersion(), "ApiVersion"); + RemoteApiVersion clientMax = latestSupported(); + + RemoteApiVersion negotiated = daemonApi.isGreater(clientMax) ? clientMax : daemonApi; + + String minApiVersionString = daemonVersion.getMinAPIVersion(); + if (minApiVersionString != null && !minApiVersionString.isEmpty()) { + RemoteApiVersion daemonMin = RemoteApiVersion.parseConfigWithDefault(minApiVersionString); + if (daemonMin != RemoteApiVersion.UNKNOWN_VERSION && daemonMin.isGreater(negotiated)) { + LOGGER.warn( + "Negotiated API version {} is below the daemon's minimum supported version {}. " + + "Pinning to the daemon's minimum; some docker-java features may not work as expected.", + negotiated.getVersion(), daemonMin.getVersion()); + negotiated = daemonMin; + } + } + + LOGGER.debug("API version auto-negotiation result: daemon={} min={} client-max={} -> {}", + daemonApi.getVersion(), + minApiVersionString, + clientMax.getVersion(), + negotiated.getVersion()); + + return negotiated; + } + + private static Version fetchDaemonVersion(DockerHttpClient httpClient, ObjectMapper objectMapper) { + DockerHttpClient.Request request = DockerHttpClient.Request.builder() + .method(DockerHttpClient.Request.Method.GET) + .path("/version") + .build(); + + try (DockerHttpClient.Response response = httpClient.execute(request); + InputStream body = response.getBody()) { + int status = response.getStatusCode(); + if (status < 200 || status >= 300) { + throw new DockerClientException( + "Failed to negotiate Docker API version: GET /version returned HTTP " + status); + } + return objectMapper.readValue(body, Version.class); + } catch (IOException e) { + throw new DockerClientException("Failed to negotiate Docker API version", e); + } + } + + private static RemoteApiVersion parseOrThrow(String version, String field) { + if (version == null || version.isEmpty()) { + throw new DockerClientException("Daemon /version response did not include a " + field); + } + RemoteApiVersion parsed = RemoteApiVersion.parseConfigWithDefault(version); + if (parsed == RemoteApiVersion.UNKNOWN_VERSION) { + throw new DockerClientException("Daemon /version returned unparseable " + field + ": " + version); + } + return parsed; + } + + /** + * The highest {@code VERSION_1_X} constant declared on {@link RemoteApiVersion}. Discovered by + * reflection so adding new constants does not require touching this class. + */ + static RemoteApiVersion latestSupported() { + RemoteApiVersion highest = null; + for (Field field : RemoteApiVersion.class.getDeclaredFields()) { + if (!Modifier.isStatic(field.getModifiers()) || !Modifier.isPublic(field.getModifiers())) { + continue; + } + if (!RemoteApiVersion.class.equals(field.getType())) { + continue; + } + try { + RemoteApiVersion candidate = (RemoteApiVersion) field.get(null); + if (candidate == null || candidate == RemoteApiVersion.UNKNOWN_VERSION) { + continue; + } + if (highest == null || candidate.isGreater(highest)) { + highest = candidate; + } + } catch (IllegalAccessException e) { + throw new IllegalStateException("Unable to read " + field.getName(), e); + } + } + if (highest == null) { + throw new IllegalStateException("No VERSION_1_X constants found on RemoteApiVersion"); + } + return highest; + } +} diff --git a/docker-java-core/src/main/java/com/github/dockerjava/core/DefaultDockerClientConfig.java b/docker-java-core/src/main/java/com/github/dockerjava/core/DefaultDockerClientConfig.java index dad75b360..ca5b0bdc7 100644 --- a/docker-java-core/src/main/java/com/github/dockerjava/core/DefaultDockerClientConfig.java +++ b/docker-java-core/src/main/java/com/github/dockerjava/core/DefaultDockerClientConfig.java @@ -50,6 +50,16 @@ public class DefaultDockerClientConfig implements Serializable, DockerClientConf public static final String API_VERSION = "api.version"; + public static final String API_VERSION_AUTO_NEGOTIATION = "api.version.auto.negotiation"; + + /** + * Environment variable counterpart of {@link #API_VERSION_AUTO_NEGOTIATION}. + *

+ * Accepted truthy values are {@code true} (case-insensitive) and {@code 1}; anything else + * (or unset) is treated as {@code false}. + */ + public static final String DOCKER_API_VERSION_AUTO_NEGOTIATION = "DOCKER_API_VERSION_AUTO_NEGOTIATION"; + public static final String REGISTRY_USERNAME = "registry.username"; public static final String REGISTRY_PASSWORD = "registry.password"; @@ -74,6 +84,8 @@ public class DefaultDockerClientConfig implements Serializable, DockerClientConf CONFIG_KEYS.add(DOCKER_CONFIG); CONFIG_KEYS.add(DOCKER_CERT_PATH); CONFIG_KEYS.add(API_VERSION); + CONFIG_KEYS.add(API_VERSION_AUTO_NEGOTIATION); + CONFIG_KEYS.add(DOCKER_API_VERSION_AUTO_NEGOTIATION); CONFIG_KEYS.add(REGISTRY_USERNAME); CONFIG_KEYS.add(REGISTRY_PASSWORD); CONFIG_KEYS.add(REGISTRY_EMAIL); @@ -92,15 +104,25 @@ public class DefaultDockerClientConfig implements Serializable, DockerClientConf private final RemoteApiVersion apiVersion; + private final boolean apiVersionAutoNegotiation; + private final DockerConfigFile dockerConfig; DefaultDockerClientConfig(URI dockerHost, DockerConfigFile dockerConfigFile, String dockerConfigPath, String apiVersion, String registryUrl, String registryUsername, String registryPassword, String registryEmail, SSLConfig sslConfig) { + this(dockerHost, dockerConfigFile, dockerConfigPath, apiVersion, registryUrl, registryUsername, registryPassword, + registryEmail, sslConfig, false); + } + + DefaultDockerClientConfig(URI dockerHost, DockerConfigFile dockerConfigFile, String dockerConfigPath, String apiVersion, + String registryUrl, String registryUsername, String registryPassword, String registryEmail, + SSLConfig sslConfig, boolean apiVersionAutoNegotiation) { this.dockerHost = checkDockerHostScheme(dockerHost); this.dockerConfig = dockerConfigFile; this.dockerConfigPath = dockerConfigPath; this.apiVersion = RemoteApiVersion.parseConfigWithDefault(apiVersion); + this.apiVersionAutoNegotiation = apiVersionAutoNegotiation; this.sslConfig = sslConfig; this.registryUsername = registryUsername; this.registryPassword = registryPassword; @@ -247,6 +269,11 @@ public RemoteApiVersion getApiVersion() { return apiVersion; } + @Override + public boolean isApiVersionAutoNegotiationEnabled() { + return apiVersionAutoNegotiation; + } + @Override public String getRegistryUsername() { return registryUsername; @@ -338,6 +365,8 @@ public static class Builder { private Boolean dockerTlsVerify; + private Boolean apiVersionAutoNegotiation; + private SSLConfig customSslConfig = null; /** @@ -351,11 +380,17 @@ public Builder withProperties(Properties p) { withDockerHost(p.getProperty(DOCKER_HOST)); } + String autoNegotiation = p.getProperty(API_VERSION_AUTO_NEGOTIATION); + if (autoNegotiation == null) { + autoNegotiation = p.getProperty(DOCKER_API_VERSION_AUTO_NEGOTIATION); + } + return withDockerTlsVerify(p.getProperty(DOCKER_TLS_VERIFY)) .withDockerContext(p.getProperty(DOCKER_CONTEXT)) .withDockerConfig(p.getProperty(DOCKER_CONFIG)) .withDockerCertPath(p.getProperty(DOCKER_CERT_PATH)) .withApiVersion(p.getProperty(API_VERSION)) + .withApiVersionAutoNegotiation(autoNegotiation) .withRegistryUsername(p.getProperty(REGISTRY_USERNAME)) .withRegistryPassword(p.getProperty(REGISTRY_PASSWORD)) .withRegistryEmail(p.getProperty(REGISTRY_EMAIL)) @@ -381,6 +416,31 @@ public final Builder withApiVersion(String apiVersion) { return this; } + /** + * Opt the client in to one-shot API version auto-negotiation against the daemon's + * {@code /version} endpoint at construction time. See + * {@link DockerClientConfig#isApiVersionAutoNegotiationEnabled()}. + */ + public final Builder withApiVersionAutoNegotiation(boolean apiVersionAutoNegotiation) { + this.apiVersionAutoNegotiation = apiVersionAutoNegotiation; + return this; + } + + /** + * String overload that matches the {@link #withDockerTlsVerify(String)} parsing semantics: + * {@code "true"} (case-insensitive) and {@code "1"} are truthy; anything else (or {@code null}) + * is falsy. Used when wiring the flag in from a {@link Properties} source. + */ + public final Builder withApiVersionAutoNegotiation(String apiVersionAutoNegotiation) { + if (apiVersionAutoNegotiation != null) { + String trimmed = apiVersionAutoNegotiation.trim(); + this.apiVersionAutoNegotiation = "true".equalsIgnoreCase(trimmed) || "1".equals(trimmed); + } else { + this.apiVersionAutoNegotiation = false; + } + return this; + } + public final Builder withRegistryUsername(String registryUsername) { this.registryUsername = registryUsername; return this; @@ -488,7 +548,7 @@ public DefaultDockerClientConfig build() { : URI.create(SystemUtils.IS_OS_WINDOWS ? WINDOWS_DEFAULT_DOCKER_HOST : DEFAULT_DOCKER_HOST); return new DefaultDockerClientConfig(dockerHostUri, dockerConfigFile, dockerConfig, apiVersion, registryUrl, registryUsername, - registryPassword, registryEmail, sslConfig); + registryPassword, registryEmail, sslConfig, isTrue(apiVersionAutoNegotiation)); } private DockerConfigFile readDockerConfig() { diff --git a/docker-java-core/src/main/java/com/github/dockerjava/core/DockerClientConfig.java b/docker-java-core/src/main/java/com/github/dockerjava/core/DockerClientConfig.java index e3961661a..e8570c7c4 100644 --- a/docker-java-core/src/main/java/com/github/dockerjava/core/DockerClientConfig.java +++ b/docker-java-core/src/main/java/com/github/dockerjava/core/DockerClientConfig.java @@ -60,6 +60,19 @@ static ObjectMapper getDefaultObjectMapper() { default ObjectMapper getObjectMapper() { return getDefaultObjectMapper(); } + + /** + * Whether the client should query the daemon's {@code /version} endpoint at construction time + * and pin the API version to {@code min(daemon API version, latest API version supported by docker-java)}. + *

+ * Mirrors {@code moby/client.NegotiateAPIVersion}. + *

+ * Default {@code false}: callers that have not opted in keep the existing behaviour + * (explicit {@code withApiVersion(...)} or {@link RemoteApiVersion#UNKNOWN_VERSION}). + */ + default boolean isApiVersionAutoNegotiationEnabled() { + return false; + } } enum DefaultObjectMapperHolder { diff --git a/docker-java-core/src/main/java/com/github/dockerjava/core/DockerClientImpl.java b/docker-java-core/src/main/java/com/github/dockerjava/core/DockerClientImpl.java index a1ddc2897..c40376ea3 100644 --- a/docker-java-core/src/main/java/com/github/dockerjava/core/DockerClientImpl.java +++ b/docker-java-core/src/main/java/com/github/dockerjava/core/DockerClientImpl.java @@ -211,10 +211,23 @@ public static DockerClientImpl getInstance(DockerClientConfig dockerClientConfig } public static DockerClient getInstance(DockerClientConfig dockerClientConfig, DockerHttpClient dockerHttpClient) { - return new DockerClientImpl(dockerClientConfig) + DockerClientConfig effectiveConfig = maybeNegotiateApiVersion(dockerClientConfig, dockerHttpClient); + return new DockerClientImpl(effectiveConfig) .withHttpClient(dockerHttpClient); } + private static DockerClientConfig maybeNegotiateApiVersion(DockerClientConfig config, DockerHttpClient httpClient) { + if (!config.isApiVersionAutoNegotiationEnabled()) { + return config; + } + if (config.getApiVersion() != RemoteApiVersion.UNKNOWN_VERSION) { + // Explicit withApiVersion(...) wins, same precedence the Docker CLI uses. + return config; + } + RemoteApiVersion negotiated = ApiVersionNegotiator.negotiate(httpClient, config.getObjectMapper()); + return new NegotiatedDockerClientConfig(config, negotiated); + } + /** * * @deprecated use {@link #getInstance(DockerClientConfig, DockerHttpClient)} diff --git a/docker-java-core/src/main/java/com/github/dockerjava/core/NegotiatedDockerClientConfig.java b/docker-java-core/src/main/java/com/github/dockerjava/core/NegotiatedDockerClientConfig.java new file mode 100644 index 000000000..d2964f124 --- /dev/null +++ b/docker-java-core/src/main/java/com/github/dockerjava/core/NegotiatedDockerClientConfig.java @@ -0,0 +1,85 @@ +package com.github.dockerjava.core; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.dockerjava.api.model.AuthConfig; +import com.github.dockerjava.api.model.AuthConfigurations; + +import java.net.URI; +import java.util.Objects; + +/** + * Decorates a {@link DockerClientConfig} and overrides only {@link #getApiVersion()} with a value + * produced by {@link ApiVersionNegotiator}. All other accessors delegate to the original config. + *

+ * Used by {@link DockerClientImpl#getInstance(DockerClientConfig, com.github.dockerjava.transport.DockerHttpClient)} + * when {@link DockerClientConfig#isApiVersionAutoNegotiationEnabled() auto-negotiation} is enabled. + * Keeping the negotiated version in a wrapper avoids mutating the immutable + * {@link DefaultDockerClientConfig}. + */ +final class NegotiatedDockerClientConfig implements DockerClientConfig { + + private final DockerClientConfig delegate; + + private final RemoteApiVersion negotiatedApiVersion; + + NegotiatedDockerClientConfig(DockerClientConfig delegate, RemoteApiVersion negotiatedApiVersion) { + this.delegate = Objects.requireNonNull(delegate, "delegate config was not specified"); + this.negotiatedApiVersion = Objects.requireNonNull(negotiatedApiVersion, "negotiatedApiVersion was not specified"); + } + + @Override + public URI getDockerHost() { + return delegate.getDockerHost(); + } + + @Override + public RemoteApiVersion getApiVersion() { + return negotiatedApiVersion; + } + + @Override + public String getRegistryUsername() { + return delegate.getRegistryUsername(); + } + + @Override + public String getRegistryPassword() { + return delegate.getRegistryPassword(); + } + + @Override + public String getRegistryEmail() { + return delegate.getRegistryEmail(); + } + + @Override + public String getRegistryUrl() { + return delegate.getRegistryUrl(); + } + + @Override + public AuthConfig effectiveAuthConfig(String imageName) { + return delegate.effectiveAuthConfig(imageName); + } + + @Override + public AuthConfigurations getAuthConfigurations() { + return delegate.getAuthConfigurations(); + } + + @Override + public SSLConfig getSSLConfig() { + return delegate.getSSLConfig(); + } + + @Override + public ObjectMapper getObjectMapper() { + return delegate.getObjectMapper(); + } + + @Override + public boolean isApiVersionAutoNegotiationEnabled() { + // Negotiation has already happened; report as resolved so downstream consumers don't re-negotiate. + return false; + } +} diff --git a/docker-java/src/test/java/com/github/dockerjava/core/ApiVersionNegotiatorTest.java b/docker-java/src/test/java/com/github/dockerjava/core/ApiVersionNegotiatorTest.java new file mode 100644 index 000000000..caaabbec0 --- /dev/null +++ b/docker-java/src/test/java/com/github/dockerjava/core/ApiVersionNegotiatorTest.java @@ -0,0 +1,82 @@ +package com.github.dockerjava.core; + +import com.github.dockerjava.api.exception.DockerClientException; +import com.github.dockerjava.transport.DockerHttpClient; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ApiVersionNegotiatorTest { + + @Test + public void daemonNewerThanClient_pinsToClientMax() { + // Daemon advertises a fictional 1.99; client max comes from RemoteApiVersion (1.44 today). + DockerHttpClient httpClient = httpClientReturning(200, + "{\"ApiVersion\":\"1.99\",\"MinAPIVersion\":\"1.12\"}"); + + RemoteApiVersion negotiated = ApiVersionNegotiator.negotiate(httpClient, DockerClientConfig.getDefaultObjectMapper()); + + assertThat(negotiated, equalTo(ApiVersionNegotiator.latestSupported())); + } + + @Test + public void daemonOlderThanClient_pinsToDaemon() { + DockerHttpClient httpClient = httpClientReturning(200, + "{\"ApiVersion\":\"1.30\",\"MinAPIVersion\":\"1.12\"}"); + + RemoteApiVersion negotiated = ApiVersionNegotiator.negotiate(httpClient, DockerClientConfig.getDefaultObjectMapper()); + + assertThat(negotiated, equalTo(RemoteApiVersion.VERSION_1_30)); + } + + @Test + public void daemonMinAboveClientMax_pinsToDaemonMin() { + // Synthetic daemon that requires 1.99 minimum and advertises 2.10. Both are higher than + // anything declared in RemoteApiVersion, so the client max is below the daemon min and we + // should pin to the daemon minimum. + DockerHttpClient httpClient = httpClientReturning(200, + "{\"ApiVersion\":\"2.10\",\"MinAPIVersion\":\"1.99\"}"); + + RemoteApiVersion negotiated = ApiVersionNegotiator.negotiate(httpClient, DockerClientConfig.getDefaultObjectMapper()); + + assertThat(negotiated, equalTo(RemoteApiVersion.parseConfig("1.99"))); + } + + @Test + public void nonSuccessStatus_throws() { + DockerHttpClient httpClient = httpClientReturning(500, "{}"); + + DockerClientException ex = assertThrows(DockerClientException.class, + () -> ApiVersionNegotiator.negotiate(httpClient, DockerClientConfig.getDefaultObjectMapper())); + assertThat(ex.getMessage().contains("500"), is(true)); + } + + @Test + public void missingApiVersion_throws() { + DockerHttpClient httpClient = httpClientReturning(200, "{\"MinAPIVersion\":\"1.12\"}"); + + DockerClientException ex = assertThrows(DockerClientException.class, + () -> ApiVersionNegotiator.negotiate(httpClient, DockerClientConfig.getDefaultObjectMapper())); + assertThat(ex.getMessage().contains("ApiVersion"), is(true)); + } + + private static DockerHttpClient httpClientReturning(int status, String body) { + DockerHttpClient httpClient = mock(DockerHttpClient.class); + DockerHttpClient.Response response = mock(DockerHttpClient.Response.class); + when(response.getStatusCode()).thenReturn(status); + when(response.getBody()).thenReturn(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8))); + when(response.getHeaders()).thenReturn(Collections.emptyMap()); + when(httpClient.execute(any(DockerHttpClient.Request.class))).thenReturn(response); + return httpClient; + } +} diff --git a/docker-java/src/test/java/com/github/dockerjava/core/DefaultDockerClientConfigTest.java b/docker-java/src/test/java/com/github/dockerjava/core/DefaultDockerClientConfigTest.java index 6c7787caf..131cf87a2 100644 --- a/docker-java/src/test/java/com/github/dockerjava/core/DefaultDockerClientConfigTest.java +++ b/docker-java/src/test/java/com/github/dockerjava/core/DefaultDockerClientConfigTest.java @@ -271,6 +271,73 @@ public void withDockerTlsVerify() throws Exception { assertThat((Boolean) field.get(builder), is(true)); } + @Test + public void withApiVersionAutoNegotiation_defaultsToFalse() { + DefaultDockerClientConfig config = DefaultDockerClientConfig + .createDefaultConfigBuilder(Collections.emptyMap(), new Properties()) + .build(); + + assertThat(config.isApiVersionAutoNegotiationEnabled(), is(false)); + } + + @Test + public void withApiVersionAutoNegotiation_booleanSetter() { + DefaultDockerClientConfig config = DefaultDockerClientConfig + .createDefaultConfigBuilder(Collections.emptyMap(), new Properties()) + .withApiVersionAutoNegotiation(true) + .build(); + + assertThat(config.isApiVersionAutoNegotiationEnabled(), is(true)); + } + + @Test + public void withApiVersionAutoNegotiation_parsesStringTruthyValues() throws Exception { + DefaultDockerClientConfig.Builder builder = new DefaultDockerClientConfig.Builder(); + Field field = builder.getClass().getDeclaredField("apiVersionAutoNegotiation"); + field.setAccessible(true); + + builder.withApiVersionAutoNegotiation(""); + assertThat((Boolean) field.get(builder), is(false)); + + builder.withApiVersionAutoNegotiation("true"); + assertThat((Boolean) field.get(builder), is(true)); + + builder.withApiVersionAutoNegotiation("TRUE"); + assertThat((Boolean) field.get(builder), is(true)); + + builder.withApiVersionAutoNegotiation("1"); + assertThat((Boolean) field.get(builder), is(true)); + + builder.withApiVersionAutoNegotiation("false"); + assertThat((Boolean) field.get(builder), is(false)); + + builder.withApiVersionAutoNegotiation("0"); + assertThat((Boolean) field.get(builder), is(false)); + + builder.withApiVersionAutoNegotiation((String) null); + assertThat((Boolean) field.get(builder), is(false)); + } + + @Test + public void apiVersionAutoNegotiation_fromEnvVar() { + Map env = new HashMap<>(); + env.put(DefaultDockerClientConfig.DOCKER_API_VERSION_AUTO_NEGOTIATION, "true"); + + DefaultDockerClientConfig config = buildConfig(env, new Properties()); + + assertThat(config.isApiVersionAutoNegotiationEnabled(), is(true)); + } + + @Test + public void apiVersionAutoNegotiation_fromSystemProperty() { + Properties systemProperties = new Properties(); + systemProperties.put(DefaultDockerClientConfig.API_VERSION_AUTO_NEGOTIATION, "1"); + + DefaultDockerClientConfig config = buildConfig(Collections.emptyMap(), systemProperties); + + assertThat(config.isApiVersionAutoNegotiationEnabled(), is(true)); + } + @Test public void dockerHostSetExplicitlyOnSetter() { DefaultDockerClientConfig.Builder builder = DefaultDockerClientConfig.createDefaultConfigBuilder(Collections.emptyMap(), new Properties()); From 19f529b1e0d323966f38499cd3144c8a66fc9ed3 Mon Sep 17 00:00:00 2001 From: John Kelly Date: Wed, 20 May 2026 13:53:30 -0500 Subject: [PATCH 2/2] docs: document API version auto-negotiation flag --- docs/getting_started.md | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/getting_started.md b/docs/getting_started.md index 7781e38ec..42f8e3c12 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -44,7 +44,11 @@ There are a couple of configuration items, all of which have sensible defaults: * `DOCKER_TLS_VERIFY` enable/disable TLS verification (switch between `http` and `https` protocol) * `DOCKER_CERT_PATH` Path to the certificates needed for TLS verification * `DOCKER_CONFIG` Path for additional docker configuration files (like `.dockercfg`) -* `api.version` The API version, e.g. `1.23`. +* `api.version` The API version, e.g. `1.23`. Leave unset to use the daemon's default, or enable + `api.version.auto.negotiation` to have the client pick the highest mutually-supported version at startup. +* `api.version.auto.negotiation` When `true` (or `1`), the client calls `GET /version` once at construction + and pins itself to `min(daemon's API version, latest version supported by docker-java)`. Default `false`. + An explicit `api.version` always wins over auto-negotiation. * `registry.url` Your registry's address. * `registry.username` Your registry username (required to push containers). * `registry.password` Your registry password. @@ -59,6 +63,7 @@ There are three ways to configure, in descending order of precedence: DOCKER_CERT_PATH=/home/user/.docker/certs DOCKER_CONFIG=/home/user/.docker api.version=1.23 + api.version.auto.negotiation=false registry.url=https://index.docker.io/v1/ registry.username=dockeruser registry.password=ilovedocker @@ -74,6 +79,7 @@ There are three ways to configure, in descending order of precedence: export DOCKER_TLS_VERIFY=1 export DOCKER_CERT_PATH=/home/user/.docker/certs export DOCKER_CONFIG=/home/user/.docker + export DOCKER_API_VERSION_AUTO_NEGOTIATION=1 ##### File System @@ -83,6 +89,30 @@ In `$HOME/.docker-java.properties` In the class path at `/docker-java.properties` +### API version auto-negotiation + +By default, `docker-java` either talks to the daemon using whatever API version is configured +(`api.version` / `withApiVersion(...)`), or — if none is configured — lets the daemon choose. The client +itself doesn't know what the daemon supports. + +Opting in to auto-negotiation makes the client query `GET /version` once at construction time and pin +itself to `min(daemon's reported API version, latest version known to docker-java)`. This is the same +behaviour `moby/client.NegotiateAPIVersion` provides in the Go client. + +```java +DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder() + .withApiVersionAutoNegotiation(true) + .build(); + +DockerClient client = DockerClientImpl.getInstance(config, httpClient); +``` + +Equivalent env var: `DOCKER_API_VERSION_AUTO_NEGOTIATION=1`. Equivalent property: `api.version.auto.negotiation=true`. + +Precedence: an explicit `withApiVersion(...)` (or `api.version` property / env var) always wins, even when +auto-negotiation is enabled. If the daemon's reported minimum is higher than the latest version known to +docker-java, the daemon minimum is used and a warning is logged. + ### Jackson Should you need to customize the Jackson's `ObjectMapper` used by `docker-java`, you can create your own `DockerClientConfig` and override `DockerClientConfig#getObjectMapper()`.