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.