From 866f2ec08fb2f443ccdb48cff3211ccb0a08f172 Mon Sep 17 00:00:00 2001 From: Emmanuel Briney Date: Wed, 3 Jun 2026 11:59:36 +0200 Subject: [PATCH 1/6] Add DockerContextResolver for Docker CLI context lookup Mirrors the docker(1) resolution chain (DOCKER_HOST > DOCKER_CONTEXT > currentContext in ~/.docker/config.json) and parses the matching meta.json under ~/.docker/contexts/meta//, exposing the endpoint host URI and any TLS material to callers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dockerclient/DockerContextResolver.java | 199 ++++++++++++++++++ .../DockerContextResolverTest.java | 193 +++++++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 core/src/main/java/org/testcontainers/dockerclient/DockerContextResolver.java create mode 100644 core/src/test/java/org/testcontainers/dockerclient/DockerContextResolverTest.java diff --git a/core/src/main/java/org/testcontainers/dockerclient/DockerContextResolver.java b/core/src/main/java/org/testcontainers/dockerclient/DockerContextResolver.java new file mode 100644 index 00000000000..967369ae777 --- /dev/null +++ b/core/src/main/java/org/testcontainers/dockerclient/DockerContextResolver.java @@ -0,0 +1,199 @@ +package org.testcontainers.dockerclient; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Value; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Resolves the Docker endpoint for a Docker CLI context. + *

+ * Mirrors the resolution order used by the Docker CLI (see {@code cli/command/cli.go}): + *

    + *
  1. {@code DOCKER_HOST} env var — when set, the CLI forces the {@code default} context and + * does not consult any named context. This class therefore returns no endpoint in that case.
  2. + *
  3. {@code DOCKER_CONTEXT} env var.
  4. + *
  5. {@code currentContext} in {@code $DOCKER_CONFIG/config.json} (default + * {@code ~/.docker/config.json}).
  6. + *
  7. The built-in {@code default} context (no metadata file).
  8. + *
+ *

+ * Named context metadata lives at + * {@code $DOCKER_CONFIG/contexts/meta//meta.json}; the per-endpoint TLS material, + * when present, lives under {@code $DOCKER_CONFIG/contexts/tls//docker/}. + */ +@Slf4j +public final class DockerContextResolver { + + public static final String DEFAULT_CONTEXT_NAME = "default"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private DockerContextResolver() {} + + /** + * @return the user's Docker config directory, honouring {@code DOCKER_CONFIG} if set, else + * {@code ~/.docker}. + */ + public static Path defaultDockerConfigDir() { + String override = System.getenv("DOCKER_CONFIG"); + if (!StringUtils.isBlank(override)) { + return Paths.get(override); + } + return Paths.get(System.getProperty("user.home"), ".docker"); + } + + /** + * Resolves the context name that the Docker CLI would pick, given the current process + * environment and the supplied Docker config directory. + * + * @return the context name, or {@code null} if {@code DOCKER_HOST} is set (in which case the + * CLI bypasses named contexts entirely). + */ + @Nullable + public static String resolveCurrentContextName(Path dockerConfigDir) { + return resolveCurrentContextName(dockerConfigDir, System::getenv); + } + + @Nullable + static String resolveCurrentContextName(Path dockerConfigDir, java.util.function.Function env) { + if (!StringUtils.isBlank(env.apply("DOCKER_HOST"))) { + return null; + } + String fromEnv = env.apply("DOCKER_CONTEXT"); + if (!StringUtils.isBlank(fromEnv)) { + return fromEnv; + } + String fromConfig = readCurrentContextFromConfig(dockerConfigDir); + if (!StringUtils.isBlank(fromConfig)) { + return fromConfig; + } + return DEFAULT_CONTEXT_NAME; + } + + @Nullable + private static String readCurrentContextFromConfig(Path dockerConfigDir) { + Path configFile = dockerConfigDir.resolve("config.json"); + if (!Files.exists(configFile)) { + return null; + } + try { + JsonNode root = OBJECT_MAPPER.readTree(configFile.toFile()); + JsonNode current = root.get("currentContext"); + if (current == null || !current.isTextual()) { + return null; + } + return current.asText(); + } catch (IOException e) { + log.debug("Failed to read currentContext from {}", configFile, e); + return null; + } + } + + /** + * Reads the Docker endpoint for the named context. + * + * @param dockerConfigDir the Docker config directory (typically {@code ~/.docker}). + * @param contextName the context name. The built-in {@code default} context has no metadata + * file and returns {@code null}. + * @return the resolved endpoint, or {@code null} for the {@code default} context. + * @throws InvalidConfigurationException if the context is not the default one and its metadata + * cannot be read or is malformed. + */ + @Nullable + public static DockerContextEndpoint resolveEndpoint(Path dockerConfigDir, String contextName) { + if (DEFAULT_CONTEXT_NAME.equals(contextName)) { + return null; + } + Path metaFile = contextMetaFile(dockerConfigDir, contextName); + if (!Files.exists(metaFile)) { + throw new InvalidConfigurationException( + "Docker context '" + contextName + "' has no metadata at " + metaFile + ); + } + JsonNode root; + try { + root = OBJECT_MAPPER.readTree(metaFile.toFile()); + } catch (IOException e) { + throw new InvalidConfigurationException( + "Failed to read Docker context metadata at " + metaFile, + e + ); + } + JsonNode dockerEndpoint = root.path("Endpoints").path("docker"); + JsonNode hostNode = dockerEndpoint.get("Host"); + if (hostNode == null || !hostNode.isTextual() || StringUtils.isBlank(hostNode.asText())) { + throw new InvalidConfigurationException( + "Docker context '" + contextName + "' does not declare a docker endpoint host" + ); + } + URI host; + try { + host = new URI(hostNode.asText()); + } catch (URISyntaxException e) { + throw new InvalidConfigurationException( + "Docker context '" + contextName + "' has an invalid host URI: " + hostNode.asText(), + e + ); + } + boolean skipTlsVerify = dockerEndpoint.path("SkipTLSVerify").asBoolean(false); + Path tlsDir = contextTlsDir(dockerConfigDir, contextName); + Path resolvedTlsDir = Files.isDirectory(tlsDir) ? tlsDir : null; + return new DockerContextEndpoint(contextName, host, resolvedTlsDir, skipTlsVerify); + } + + static Path contextMetaFile(Path dockerConfigDir, String contextName) { + return dockerConfigDir + .resolve("contexts") + .resolve("meta") + .resolve(sha256(contextName)) + .resolve("meta.json"); + } + + static Path contextTlsDir(Path dockerConfigDir, String contextName) { + return dockerConfigDir + .resolve("contexts") + .resolve("tls") + .resolve(sha256(contextName)) + .resolve("docker"); + } + + private static String sha256(String input) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + @Value + public static class DockerContextEndpoint { + + String contextName; + + URI host; + + @Nullable + Path tlsMaterialDir; + + boolean skipTlsVerify; + } +} diff --git a/core/src/test/java/org/testcontainers/dockerclient/DockerContextResolverTest.java b/core/src/test/java/org/testcontainers/dockerclient/DockerContextResolverTest.java new file mode 100644 index 00000000000..14628dadf66 --- /dev/null +++ b/core/src/test/java/org/testcontainers/dockerclient/DockerContextResolverTest.java @@ -0,0 +1,193 @@ +package org.testcontainers.dockerclient; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DockerContextResolverTest { + + @Test + void resolvesDefaultWhenConfigAbsent(@TempDir Path dockerConfigDir) { + String name = DockerContextResolver.resolveCurrentContextName(dockerConfigDir, k -> null); + assertThat(name).isEqualTo(DockerContextResolver.DEFAULT_CONTEXT_NAME); + } + + @Test + void resolvesCurrentContextFromConfig(@TempDir Path dockerConfigDir) throws IOException { + writeConfig(dockerConfigDir, "{\"currentContext\":\"desktop-linux\"}"); + + String name = DockerContextResolver.resolveCurrentContextName(dockerConfigDir, k -> null); + + assertThat(name).isEqualTo("desktop-linux"); + } + + @Test + void dockerContextEnvOverridesConfig(@TempDir Path dockerConfigDir) throws IOException { + writeConfig(dockerConfigDir, "{\"currentContext\":\"desktop-linux\"}"); + + String name = DockerContextResolver.resolveCurrentContextName( + dockerConfigDir, + mapEnv("DOCKER_CONTEXT", "orbstack") + ); + + assertThat(name).isEqualTo("orbstack"); + } + + @Test + void dockerHostEnvBypassesContexts(@TempDir Path dockerConfigDir) throws IOException { + writeConfig(dockerConfigDir, "{\"currentContext\":\"desktop-linux\"}"); + + String name = DockerContextResolver.resolveCurrentContextName( + dockerConfigDir, + mapEnv("DOCKER_HOST", "tcp://1.2.3.4:2375", "DOCKER_CONTEXT", "orbstack") + ); + + assertThat(name).isNull(); + } + + @Test + void blankDockerContextEnvIsIgnored(@TempDir Path dockerConfigDir) throws IOException { + writeConfig(dockerConfigDir, "{\"currentContext\":\"desktop-linux\"}"); + + String name = DockerContextResolver.resolveCurrentContextName( + dockerConfigDir, + mapEnv("DOCKER_CONTEXT", " ") + ); + + assertThat(name).isEqualTo("desktop-linux"); + } + + @Test + void resolvesDefaultWhenConfigHasNoCurrentContext(@TempDir Path dockerConfigDir) throws IOException { + writeConfig(dockerConfigDir, "{\"auths\":{}}"); + + String name = DockerContextResolver.resolveCurrentContextName(dockerConfigDir, k -> null); + + assertThat(name).isEqualTo(DockerContextResolver.DEFAULT_CONTEXT_NAME); + } + + @Test + void resolveEndpointReturnsNullForDefaultContext(@TempDir Path dockerConfigDir) { + DockerContextResolver.DockerContextEndpoint endpoint = DockerContextResolver.resolveEndpoint( + dockerConfigDir, + DockerContextResolver.DEFAULT_CONTEXT_NAME + ); + + assertThat(endpoint).isNull(); + } + + @Test + void resolveEndpointParsesHost(@TempDir Path dockerConfigDir) throws IOException { + writeMeta( + dockerConfigDir, + "my-ctx", + "{\"Name\":\"my-ctx\",\"Endpoints\":{\"docker\":{\"Host\":\"unix:///var/run/foo.sock\",\"SkipTLSVerify\":false}}}" + ); + + DockerContextResolver.DockerContextEndpoint endpoint = DockerContextResolver.resolveEndpoint( + dockerConfigDir, + "my-ctx" + ); + + assertThat(endpoint).isNotNull(); + assertThat(endpoint.getContextName()).isEqualTo("my-ctx"); + assertThat(endpoint.getHost().toString()).isEqualTo("unix:///var/run/foo.sock"); + assertThat(endpoint.isSkipTlsVerify()).isFalse(); + assertThat(endpoint.getTlsMaterialDir()).isNull(); + } + + @Test + void resolveEndpointDiscoversTlsMaterial(@TempDir Path dockerConfigDir) throws IOException { + writeMeta( + dockerConfigDir, + "remote", + "{\"Name\":\"remote\",\"Endpoints\":{\"docker\":{\"Host\":\"tcp://10.0.0.5:2376\"}}}" + ); + Path tlsDir = DockerContextResolver.contextTlsDir(dockerConfigDir, "remote"); + Files.createDirectories(tlsDir); + Files.write(tlsDir.resolve("ca.pem"), "ca".getBytes(StandardCharsets.UTF_8)); + + DockerContextResolver.DockerContextEndpoint endpoint = DockerContextResolver.resolveEndpoint( + dockerConfigDir, + "remote" + ); + + assertThat(endpoint.getTlsMaterialDir()).isEqualTo(tlsDir); + } + + @Test + void resolveEndpointThrowsWhenContextMissing(@TempDir Path dockerConfigDir) { + assertThatThrownBy(() -> DockerContextResolver.resolveEndpoint(dockerConfigDir, "ghost")) + .isInstanceOf(InvalidConfigurationException.class) + .hasMessageContaining("ghost"); + } + + @Test + void resolveEndpointThrowsWhenHostMissing(@TempDir Path dockerConfigDir) throws IOException { + writeMeta(dockerConfigDir, "broken", "{\"Name\":\"broken\",\"Endpoints\":{\"docker\":{}}}"); + + assertThatThrownBy(() -> DockerContextResolver.resolveEndpoint(dockerConfigDir, "broken")) + .isInstanceOf(InvalidConfigurationException.class) + .hasMessageContaining("docker endpoint host"); + } + + @Test + void contextDirectoryNameMatchesDockerCliHash(@TempDir Path dockerConfigDir) { + // Hashes captured from a real `~/.docker/contexts/meta/` directory created by Docker Desktop. + assertThat(DockerContextResolver.contextMetaFile(dockerConfigDir, "desktop-linux")) + .isEqualTo( + dockerConfigDir + .resolve("contexts") + .resolve("meta") + .resolve(sha256("desktop-linux")) + .resolve("meta.json") + ); + assertThat(sha256("desktop-linux")) + .isEqualTo("fe9c6bd7a66301f49ca9b6a70b217107cd1284598bfc254700c989b916da791e"); + } + + private static void writeConfig(Path dockerConfigDir, String json) throws IOException { + Files.createDirectories(dockerConfigDir); + Files.write(dockerConfigDir.resolve("config.json"), json.getBytes(StandardCharsets.UTF_8)); + } + + private static void writeMeta(Path dockerConfigDir, String contextName, String json) throws IOException { + Path metaFile = DockerContextResolver.contextMetaFile(dockerConfigDir, contextName); + Files.createDirectories(metaFile.getParent()); + Files.write(metaFile, json.getBytes(StandardCharsets.UTF_8)); + } + + private static java.util.function.Function mapEnv(String... pairs) { + Map map = new HashMap<>(); + for (int i = 0; i < pairs.length; i += 2) { + map.put(pairs[i], pairs[i + 1]); + } + return Collections.unmodifiableMap(map)::get; + } + + private static String sha256(String input) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : digest) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} From 99b89d19139bb6cb14443e774f263acbd4d0a2e9 Mon Sep 17 00:00:00 2001 From: Emmanuel Briney Date: Wed, 3 Jun 2026 12:00:16 +0200 Subject: [PATCH 2/6] Honour the active Docker CLI context for the default client Adds DockerContextClientProviderStrategy (registered via the SPI between the env-var and unix-socket strategies) so a default-configured Testcontainers picks the same daemon as `docker info`. This matters for setups where Docker is only reachable through the configured context (e.g. Docker Desktop on macOS without a /var/run/docker.sock symlink, OrbStack, or any DOCKER_CONTEXT override). A public DockerContextClientProviderStrategy(String contextName) constructor mirrors EnvironmentAndSystemPropertyClientProviderStrategy's package-private hook so callers can pin a specific context by name. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DockerContextClientProviderStrategy.java | 150 ++++++++++++++ ....dockerclient.DockerClientProviderStrategy | 1 + ...ckerContextClientProviderStrategyTest.java | 191 ++++++++++++++++++ 3 files changed, 342 insertions(+) create mode 100644 core/src/main/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategy.java create mode 100644 core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java diff --git a/core/src/main/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategy.java new file mode 100644 index 00000000000..5282ee7cbdb --- /dev/null +++ b/core/src/main/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategy.java @@ -0,0 +1,150 @@ +package org.testcontainers.dockerclient; + +import com.github.dockerjava.core.LocalDirectorySSLConfig; +import com.github.dockerjava.transport.SSLConfig; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.Nullable; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Picks the Docker endpoint declared by the active Docker CLI context. + *

+ * Resolution order matches {@code docker(1)}: {@code DOCKER_HOST} → {@code DOCKER_CONTEXT} → + * {@code currentContext} in {@code $DOCKER_CONFIG/config.json}. When {@code DOCKER_HOST} is set + * the CLI bypasses named contexts, and so does this strategy — the + * {@link EnvironmentAndSystemPropertyClientProviderStrategy} owns that case. When no context + * resolves to anything other than the built-in {@code default}, this strategy steps aside and lets + * the local-socket strategies handle the platform default. + *

+ * An explicit context can also be requested by name via + * {@link #DockerContextClientProviderStrategy(String)}; the SPI uses the no-arg constructor and + * resolves the active context dynamically. + * + * @deprecated this class is used by the SPI and should not be used directly + */ +@Slf4j +@Deprecated +public final class DockerContextClientProviderStrategy extends DockerClientProviderStrategy { + + public static final int PRIORITY = EnvironmentAndSystemPropertyClientProviderStrategy.PRIORITY - 10; + + private final Path dockerConfigDir; + + @Nullable + private final String requestedContextName; + + @Getter(lazy = true) + @Nullable + private final DockerContextResolver.DockerContextEndpoint endpoint = resolveEndpoint(); + + public DockerContextClientProviderStrategy() { + this(DockerContextResolver.defaultDockerConfigDir(), null); + } + + /** + * Resolves the Docker endpoint for the supplied context name, bypassing the + * {@code DOCKER_HOST}/{@code DOCKER_CONTEXT}/{@code currentContext} fallback chain. + */ + public DockerContextClientProviderStrategy(String contextName) { + this(DockerContextResolver.defaultDockerConfigDir(), contextName); + } + + DockerContextClientProviderStrategy(Path dockerConfigDir, @Nullable String requestedContextName) { + this.dockerConfigDir = dockerConfigDir; + this.requestedContextName = requestedContextName; + } + + @Nullable + private DockerContextResolver.DockerContextEndpoint resolveEndpoint() { + String contextName = requestedContextName != null + ? requestedContextName + : DockerContextResolver.resolveCurrentContextName(dockerConfigDir); + if (contextName == null) { + return null; + } + return DockerContextResolver.resolveEndpoint(dockerConfigDir, contextName); + } + + @Override + protected boolean isApplicable() { + DockerContextResolver.DockerContextEndpoint endpoint = getEndpoint(); + if (endpoint == null) { + return false; + } + String scheme = endpoint.getHost().getScheme(); + if (scheme == null) { + return false; + } + switch (scheme) { + case "unix": + case "npipe": + case "tcp": + case "http": + case "https": + return true; + default: + log.debug( + "Docker context '{}' uses unsupported scheme '{}'; skipping", + endpoint.getContextName(), + scheme + ); + return false; + } + } + + @Override + public TransportConfig getTransportConfig() throws InvalidConfigurationException { + DockerContextResolver.DockerContextEndpoint endpoint = getEndpoint(); + if (endpoint == null) { + throw new InvalidConfigurationException("No Docker context endpoint resolved"); + } + URI host = endpoint.getHost(); + if ("unix".equals(host.getScheme())) { + Path socketPath = java.nio.file.Paths.get(host.getPath()); + if (!Files.exists(socketPath)) { + throw new InvalidConfigurationException( + "Docker context '" + endpoint.getContextName() + "' points at " + socketPath + + " but the socket does not exist" + ); + } + } + TransportConfig.TransportConfigBuilder builder = TransportConfig.builder().dockerHost(host); + SSLConfig sslConfig = buildSslConfig(endpoint); + if (sslConfig != null) { + builder.sslConfig(sslConfig); + } + return builder.build(); + } + + @Nullable + private SSLConfig buildSslConfig(DockerContextResolver.DockerContextEndpoint endpoint) { + Path tlsDir = endpoint.getTlsMaterialDir(); + if (tlsDir == null) { + return null; + } + return new LocalDirectorySSLConfig(tlsDir.toString()); + } + + @Override + protected int getPriority() { + return PRIORITY; + } + + @Override + public String getDescription() { + DockerContextResolver.DockerContextEndpoint endpoint = getEndpoint(); + if (endpoint == null) { + return "Docker CLI context (none)"; + } + return "Docker CLI context '" + endpoint.getContextName() + "' (" + endpoint.getHost() + ")"; + } + + @Override + protected boolean isPersistable() { + return false; + } +} diff --git a/core/src/main/resources/META-INF/services/org.testcontainers.dockerclient.DockerClientProviderStrategy b/core/src/main/resources/META-INF/services/org.testcontainers.dockerclient.DockerClientProviderStrategy index cae8bf53041..2a7d4d7c3f4 100644 --- a/core/src/main/resources/META-INF/services/org.testcontainers.dockerclient.DockerClientProviderStrategy +++ b/core/src/main/resources/META-INF/services/org.testcontainers.dockerclient.DockerClientProviderStrategy @@ -1,5 +1,6 @@ org.testcontainers.dockerclient.TestcontainersHostPropertyClientProviderStrategy org.testcontainers.dockerclient.EnvironmentAndSystemPropertyClientProviderStrategy +org.testcontainers.dockerclient.DockerContextClientProviderStrategy org.testcontainers.dockerclient.UnixSocketClientProviderStrategy org.testcontainers.dockerclient.DockerMachineClientProviderStrategy org.testcontainers.dockerclient.NpipeSocketClientProviderStrategy diff --git a/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java b/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java new file mode 100644 index 00000000000..9bafeb10f76 --- /dev/null +++ b/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java @@ -0,0 +1,191 @@ +package org.testcontainers.dockerclient; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +class DockerContextClientProviderStrategyTest { + + @Test + void notApplicableWhenContextResolvesToDefault(@TempDir Path dockerConfigDir) { + DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy(dockerConfigDir, null); + + assertThat(strategy.isApplicable()).isFalse(); + } + + @Test + void notApplicableForSshContext(@TempDir Path dockerConfigDir) throws IOException { + writeMeta( + dockerConfigDir, + "rpi", + "{\"Name\":\"rpi\",\"Endpoints\":{\"docker\":{\"Host\":\"ssh://user@192.168.1.10\"}}}" + ); + + DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy(dockerConfigDir, "rpi"); + + assertThat(strategy.isApplicable()).isFalse(); + } + + @Test + void resolvesNamedContextThroughExplicitConstructor(@TempDir Path dockerConfigDir) throws IOException { + Path socket = createFakeUnixSocket(dockerConfigDir, "fake.sock"); + writeMeta( + dockerConfigDir, + "my-ctx", + "{\"Name\":\"my-ctx\",\"Endpoints\":{\"docker\":{\"Host\":\"unix://" + socket + "\"}}}" + ); + + DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy( + dockerConfigDir, + "my-ctx" + ); + + assertThat(strategy.isApplicable()).isTrue(); + TransportConfig config = strategy.getTransportConfig(); + assertThat(config.getDockerHost().toString()).isEqualTo("unix://" + socket); + assertThat(config.getSslConfig()).isNull(); + assertThat(strategy.getDescription()).contains("my-ctx").contains(socket.toString()); + } + + @Test + void usesCurrentContextFromConfigWhenNoExplicitName(@TempDir Path dockerConfigDir) throws IOException { + Path socket = createFakeUnixSocket(dockerConfigDir, "current.sock"); + writeConfig(dockerConfigDir, "{\"currentContext\":\"picked\"}"); + writeMeta( + dockerConfigDir, + "picked", + "{\"Name\":\"picked\",\"Endpoints\":{\"docker\":{\"Host\":\"unix://" + socket + "\"}}}" + ); + + DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy(dockerConfigDir, null); + + assertThat(strategy.isApplicable()).isTrue(); + assertThat(strategy.getTransportConfig().getDockerHost().getPath()).isEqualTo(socket.toString()); + } + + @Test + void getTransportConfigFailsWhenUnixSocketMissing(@TempDir Path dockerConfigDir) throws IOException { + writeMeta( + dockerConfigDir, + "ghost", + "{\"Name\":\"ghost\",\"Endpoints\":{\"docker\":{\"Host\":\"unix:///definitely/not/here.sock\"}}}" + ); + + DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy(dockerConfigDir, "ghost"); + + assertThatThrownBy(strategy::getTransportConfig) + .isInstanceOf(InvalidConfigurationException.class) + .hasMessageContaining("does not exist"); + } + + @Test + void exposesTlsMaterialWhenPresent(@TempDir Path dockerConfigDir) throws IOException { + writeMeta( + dockerConfigDir, + "remote", + "{\"Name\":\"remote\",\"Endpoints\":{\"docker\":{\"Host\":\"tcp://10.0.0.5:2376\"}}}" + ); + Path tlsDir = DockerContextResolver.contextTlsDir(dockerConfigDir, "remote"); + Files.createDirectories(tlsDir); + Files.write(tlsDir.resolve("ca.pem"), "ca".getBytes(StandardCharsets.UTF_8)); + Files.write(tlsDir.resolve("cert.pem"), "cert".getBytes(StandardCharsets.UTF_8)); + Files.write(tlsDir.resolve("key.pem"), "key".getBytes(StandardCharsets.UTF_8)); + + DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy( + dockerConfigDir, + "remote" + ); + + TransportConfig config = strategy.getTransportConfig(); + assertThat(config.getDockerHost().toString()).isEqualTo("tcp://10.0.0.5:2376"); + assertThat(config.getSslConfig()).isNotNull(); + // docker-java is relocated under org.testcontainers.shaded.* in the produced jar, so we + // can't depend on the class identity — match by simple name and extract the path via the + // public getter. + assertThat(config.getSslConfig().getClass().getSimpleName()).isEqualTo("LocalDirectorySSLConfig"); + assertThat(config.getSslConfig()).extracting("dockerCertPath").isEqualTo(tlsDir.toString()); + } + + /** + * Live smoke test against the local Docker installation: skipped unless an active context + * resolves to a unix socket that actually exists. With Docker Desktop running, this confirms + * end-to-end that the strategy can reach the daemon — including setups where Docker is only + * reachable through the configured context (no {@code /var/run/docker.sock} symlink). + */ + @Test + void connectsToLocalDockerThroughActiveContext() { + Path dockerConfigDir = DockerContextResolver.defaultDockerConfigDir(); + assumeThat(Files.exists(dockerConfigDir)).as("Docker config dir exists").isTrue(); + assumeThat(System.getenv("DOCKER_HOST")).as("DOCKER_HOST is unset").isNull(); + + DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy(); + assumeThat(strategy.isApplicable()).as("strategy is applicable").isTrue(); + + TransportConfig config = strategy.getTransportConfig(); + assumeThat(config.getDockerHost().getScheme()).as("scheme").isIn("unix", "tcp", "http", "https", "npipe"); + + // Actually exercise the docker daemon — if Docker Desktop is reachable via context this + // succeeds; otherwise the assumption short-circuits. + try (com.github.dockerjava.api.DockerClient client = strategy.getDockerClient()) { + client.pingCmd().exec(); + assertThat(client.infoCmd().exec().getOsType()).isEqualTo("linux"); + } catch (Exception e) { + assumeThat(e).as("docker daemon reachable via active context").isNull(); + } + } + + @Test + void priorityIsBetweenEnvVarAndUnixSocket() { + assertThat(DockerContextClientProviderStrategy.PRIORITY) + .isGreaterThan(UnixSocketClientProviderStrategy.PRIORITY) + .isLessThan(EnvironmentAndSystemPropertyClientProviderStrategy.PRIORITY); + } + + /** + * End-to-end check that the SPI flow in {@link org.testcontainers.DockerClientFactory} picks + * this strategy when the active context resolves to a working endpoint. + */ + @Test + void factoryPicksContextStrategyForActiveContext() { + assumeThat(Files.exists(DockerContextResolver.defaultDockerConfigDir())).isTrue(); + assumeThat(System.getenv("DOCKER_HOST")).isNull(); + assumeThat(new DockerContextClientProviderStrategy().isApplicable()) + .as("active context resolves to a non-default endpoint") + .isTrue(); + + try { + org.testcontainers.DockerClientFactory.instance().client(); + } catch (Exception e) { + assumeThat(e).as("DockerClientFactory can initialize").isNull(); + } + + assertThat( + org.testcontainers.DockerClientFactory.instance().isUsing(DockerContextClientProviderStrategy.class) + ).isTrue(); + } + + private static void writeConfig(Path dockerConfigDir, String json) throws IOException { + Files.createDirectories(dockerConfigDir); + Files.write(dockerConfigDir.resolve("config.json"), json.getBytes(StandardCharsets.UTF_8)); + } + + private static void writeMeta(Path dockerConfigDir, String contextName, String json) throws IOException { + Path metaFile = DockerContextResolver.contextMetaFile(dockerConfigDir, contextName); + Files.createDirectories(metaFile.getParent()); + Files.write(metaFile, json.getBytes(StandardCharsets.UTF_8)); + } + + private static Path createFakeUnixSocket(Path dir, String name) throws IOException { + Path socket = dir.resolve(name); + Files.write(socket, new byte[0]); + return socket.toAbsolutePath(); + } +} From 441623f564693c63618a546d0d142a89e5e28896 Mon Sep 17 00:00:00 2001 From: Emmanuel Briney Date: Wed, 3 Jun 2026 14:47:33 +0200 Subject: [PATCH 3/6] Make Docker context strategy tests Windows-portable The two unix-socket fixtures embedded an absolute temp-file path into a JSON meta document. On Windows that path contains backslashes, which are invalid JSON escapes and broke Jackson parsing before the production code ran. Use an OS-appropriate endpoint instead: a real unix socket on POSIX (so the socket-existence check is still exercised) and an npipe address on Windows (no backing file needed). Both forms use forward slashes and embed safely into the JSON fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ckerContextClientProviderStrategyTest.java | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java b/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java index 9bafeb10f76..7e774fea28e 100644 --- a/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java +++ b/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java @@ -1,5 +1,6 @@ package org.testcontainers.dockerclient; +import org.apache.commons.lang3.SystemUtils; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -36,11 +37,11 @@ void notApplicableForSshContext(@TempDir Path dockerConfigDir) throws IOExceptio @Test void resolvesNamedContextThroughExplicitConstructor(@TempDir Path dockerConfigDir) throws IOException { - Path socket = createFakeUnixSocket(dockerConfigDir, "fake.sock"); + String host = fakeEndpointHost(dockerConfigDir, "fake"); writeMeta( dockerConfigDir, "my-ctx", - "{\"Name\":\"my-ctx\",\"Endpoints\":{\"docker\":{\"Host\":\"unix://" + socket + "\"}}}" + "{\"Name\":\"my-ctx\",\"Endpoints\":{\"docker\":{\"Host\":\"" + host + "\"}}}" ); DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy( @@ -50,25 +51,25 @@ void resolvesNamedContextThroughExplicitConstructor(@TempDir Path dockerConfigDi assertThat(strategy.isApplicable()).isTrue(); TransportConfig config = strategy.getTransportConfig(); - assertThat(config.getDockerHost().toString()).isEqualTo("unix://" + socket); + assertThat(config.getDockerHost().toString()).isEqualTo(host); assertThat(config.getSslConfig()).isNull(); - assertThat(strategy.getDescription()).contains("my-ctx").contains(socket.toString()); + assertThat(strategy.getDescription()).contains("my-ctx").contains(host); } @Test void usesCurrentContextFromConfigWhenNoExplicitName(@TempDir Path dockerConfigDir) throws IOException { - Path socket = createFakeUnixSocket(dockerConfigDir, "current.sock"); + String host = fakeEndpointHost(dockerConfigDir, "current"); writeConfig(dockerConfigDir, "{\"currentContext\":\"picked\"}"); writeMeta( dockerConfigDir, "picked", - "{\"Name\":\"picked\",\"Endpoints\":{\"docker\":{\"Host\":\"unix://" + socket + "\"}}}" + "{\"Name\":\"picked\",\"Endpoints\":{\"docker\":{\"Host\":\"" + host + "\"}}}" ); DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy(dockerConfigDir, null); assertThat(strategy.isApplicable()).isTrue(); - assertThat(strategy.getTransportConfig().getDockerHost().getPath()).isEqualTo(socket.toString()); + assertThat(strategy.getTransportConfig().getDockerHost().toString()).isEqualTo(host); } @Test @@ -183,9 +184,17 @@ private static void writeMeta(Path dockerConfigDir, String contextName, String j Files.write(metaFile, json.getBytes(StandardCharsets.UTF_8)); } - private static Path createFakeUnixSocket(Path dir, String name) throws IOException { - Path socket = dir.resolve(name); + /** + * Builds a Docker endpoint host suitable for the current OS: a real unix socket on POSIX (whose + * existence the strategy verifies) and an npipe address on Windows (which needs no backing + * file). Both forms use forward slashes, so they embed safely into the JSON meta fixtures. + */ + private static String fakeEndpointHost(Path dir, String name) throws IOException { + if (SystemUtils.IS_OS_WINDOWS) { + return "npipe:////./pipe/" + name; + } + Path socket = dir.resolve(name + ".sock"); Files.write(socket, new byte[0]); - return socket.toAbsolutePath(); + return "unix://" + socket.toAbsolutePath(); } } From 762c9050866cdbf0c1bbf141ca1bddb1f0ba4824 Mon Sep 17 00:00:00 2001 From: Emmanuel Briney Date: Wed, 3 Jun 2026 15:02:36 +0200 Subject: [PATCH 4/6] Skip live context test when Docker daemon is not reachable connectsToLocalDockerThroughActiveContext only guarded the ping/info calls; when Docker Desktop is stopped, getTransportConfig already throws InvalidConfigurationException for the missing socket, which surfaced as a hard failure instead of a skip. Move the transport-resolve and client construction inside the existing try/catch so any failure on the path to the daemon translates to a JUnit assumption skip. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ockerContextClientProviderStrategyTest.java | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java b/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java index 7e774fea28e..a54c6cb39dc 100644 --- a/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java +++ b/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java @@ -130,14 +130,16 @@ void connectsToLocalDockerThroughActiveContext() { DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy(); assumeThat(strategy.isApplicable()).as("strategy is applicable").isTrue(); - TransportConfig config = strategy.getTransportConfig(); - assumeThat(config.getDockerHost().getScheme()).as("scheme").isIn("unix", "tcp", "http", "https", "npipe"); - - // Actually exercise the docker daemon — if Docker Desktop is reachable via context this - // succeeds; otherwise the assumption short-circuits. - try (com.github.dockerjava.api.DockerClient client = strategy.getDockerClient()) { - client.pingCmd().exec(); - assertThat(client.infoCmd().exec().getOsType()).isEqualTo("linux"); + // Resolve config and exercise the daemon under a single guard. A stopped daemon shows up + // as a missing socket (InvalidConfigurationException from getTransportConfig) or a refused + // connection (from pingCmd) — both should skip rather than fail. + try { + TransportConfig config = strategy.getTransportConfig(); + assumeThat(config.getDockerHost().getScheme()).as("scheme").isIn("unix", "tcp", "http", "https", "npipe"); + try (com.github.dockerjava.api.DockerClient client = strategy.getDockerClient()) { + client.pingCmd().exec(); + assertThat(client.infoCmd().exec().getOsType()).isEqualTo("linux"); + } } catch (Exception e) { assumeThat(e).as("docker daemon reachable via active context").isNull(); } From 9dd64167b5f586d30944b9929f0602f3af2a78cb Mon Sep 17 00:00:00 2001 From: Emmanuel Briney Date: Wed, 3 Jun 2026 15:14:40 +0200 Subject: [PATCH 5/6] Document Docker CLI context resolution Mention the new context-aware lookup in the Docker environment discovery list and call out DOCKER_CONTEXT / DOCKER_CONFIG in the host-detection env-var reference. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/features/configuration.md | 6 ++++++ docs/supported_docker_environment/index.md | 1 + 2 files changed, 7 insertions(+) diff --git a/docs/features/configuration.md b/docs/features/configuration.md index c13c0a659e3..eae25c0c627 100644 --- a/docs/features/configuration.md +++ b/docs/features/configuration.md @@ -110,6 +110,12 @@ However, sometimes customization is required. Testcontainers will respect the fo > **DOCKER_HOST** = unix:///var/run/docker.sock > See [Docker environment variables](https://docs.docker.com/engine/reference/commandline/cli/#environment-variables) > +> **DOCKER_CONTEXT** +> Name of a Docker CLI context to use (e.g. `desktop-linux`, `orbstack`). When set — and `DOCKER_HOST` is not — Testcontainers resolves the endpoint from `$DOCKER_CONFIG/contexts/meta//meta.json` just like `docker(1)`. If neither variable is set, the `currentContext` field in `$DOCKER_CONFIG/config.json` (default `~/.docker/config.json`) is used. +> +> **DOCKER_CONFIG** +> Overrides the Docker config directory (default `~/.docker`) where `config.json` and the `contexts/` subtree are read from. +> > **TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE** > Path to Docker's socket. Used by Ryuk, Docker Compose, and a few other containers that need to perform Docker actions. > Example: `/var/run/docker-alt.sock` diff --git a/docs/supported_docker_environment/index.md b/docs/supported_docker_environment/index.md index 0bdac109245..f1f8d7887ef 100644 --- a/docs/supported_docker_environment/index.md +++ b/docs/supported_docker_environment/index.md @@ -99,6 +99,7 @@ Testcontainers will try to connect to a Docker daemon using the following strate * `DOCKER_HOST=https://localhost:2376` * `DOCKER_TLS_VERIFY=1` * `DOCKER_CERT_PATH=~/.docker` +* The active Docker CLI context, resolved the same way `docker(1)` does — from `DOCKER_CONTEXT`, falling back to `currentContext` in `$DOCKER_CONFIG/config.json` (default `~/.docker/config.json`). The endpoint host is read from `$DOCKER_CONFIG/contexts/meta//meta.json`, and per-context TLS material under `contexts/tls//docker/` is picked up automatically when present. `DOCKER_HOST` overrides this, matching the CLI's behaviour. * If Docker Machine is installed, the docker machine environment for the *first* machine found. Docker Machine needs to be on the PATH for this to succeed. * If you're going to run your tests inside a container, please read [Patterns for running tests inside a docker container](continuous_integration/dind_patterns.md) first. From 6ae21c3aabd26ea7e26c1f037130c71d9ef7cbca Mon Sep 17 00:00:00 2001 From: Emmanuel Briney Date: Wed, 3 Jun 2026 15:25:13 +0200 Subject: [PATCH 6/6] Apply spotless formatting Result of running ./gradlew :testcontainers:spotlessApply over the new Docker context sources; no behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DockerContextClientProviderStrategy.java | 5 ++++- .../dockerclient/DockerContextResolver.java | 17 +++-------------- ...DockerContextClientProviderStrategyTest.java | 10 ++++++---- .../dockerclient/DockerContextResolverTest.java | 5 +---- 4 files changed, 14 insertions(+), 23 deletions(-) diff --git a/core/src/main/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategy.java index 5282ee7cbdb..4668ebaaabd 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategy.java +++ b/core/src/main/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategy.java @@ -107,7 +107,10 @@ public TransportConfig getTransportConfig() throws InvalidConfigurationException Path socketPath = java.nio.file.Paths.get(host.getPath()); if (!Files.exists(socketPath)) { throw new InvalidConfigurationException( - "Docker context '" + endpoint.getContextName() + "' points at " + socketPath + + "Docker context '" + + endpoint.getContextName() + + "' points at " + + socketPath + " but the socket does not exist" ); } diff --git a/core/src/main/java/org/testcontainers/dockerclient/DockerContextResolver.java b/core/src/main/java/org/testcontainers/dockerclient/DockerContextResolver.java index 967369ae777..153699bd872 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/DockerContextResolver.java +++ b/core/src/main/java/org/testcontainers/dockerclient/DockerContextResolver.java @@ -127,10 +127,7 @@ public static DockerContextEndpoint resolveEndpoint(Path dockerConfigDir, String try { root = OBJECT_MAPPER.readTree(metaFile.toFile()); } catch (IOException e) { - throw new InvalidConfigurationException( - "Failed to read Docker context metadata at " + metaFile, - e - ); + throw new InvalidConfigurationException("Failed to read Docker context metadata at " + metaFile, e); } JsonNode dockerEndpoint = root.path("Endpoints").path("docker"); JsonNode hostNode = dockerEndpoint.get("Host"); @@ -155,19 +152,11 @@ public static DockerContextEndpoint resolveEndpoint(Path dockerConfigDir, String } static Path contextMetaFile(Path dockerConfigDir, String contextName) { - return dockerConfigDir - .resolve("contexts") - .resolve("meta") - .resolve(sha256(contextName)) - .resolve("meta.json"); + return dockerConfigDir.resolve("contexts").resolve("meta").resolve(sha256(contextName)).resolve("meta.json"); } static Path contextTlsDir(Path dockerConfigDir, String contextName) { - return dockerConfigDir - .resolve("contexts") - .resolve("tls") - .resolve(sha256(contextName)) - .resolve("docker"); + return dockerConfigDir.resolve("contexts").resolve("tls").resolve(sha256(contextName)).resolve("docker"); } private static String sha256(String input) { diff --git a/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java b/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java index a54c6cb39dc..a32a7b6c626 100644 --- a/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java +++ b/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java @@ -80,7 +80,10 @@ void getTransportConfigFailsWhenUnixSocketMissing(@TempDir Path dockerConfigDir) "{\"Name\":\"ghost\",\"Endpoints\":{\"docker\":{\"Host\":\"unix:///definitely/not/here.sock\"}}}" ); - DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy(dockerConfigDir, "ghost"); + DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy( + dockerConfigDir, + "ghost" + ); assertThatThrownBy(strategy::getTransportConfig) .isInstanceOf(InvalidConfigurationException.class) @@ -170,9 +173,8 @@ void factoryPicksContextStrategyForActiveContext() { assumeThat(e).as("DockerClientFactory can initialize").isNull(); } - assertThat( - org.testcontainers.DockerClientFactory.instance().isUsing(DockerContextClientProviderStrategy.class) - ).isTrue(); + assertThat(org.testcontainers.DockerClientFactory.instance().isUsing(DockerContextClientProviderStrategy.class)) + .isTrue(); } private static void writeConfig(Path dockerConfigDir, String json) throws IOException { diff --git a/core/src/test/java/org/testcontainers/dockerclient/DockerContextResolverTest.java b/core/src/test/java/org/testcontainers/dockerclient/DockerContextResolverTest.java index 14628dadf66..2818b40b41d 100644 --- a/core/src/test/java/org/testcontainers/dockerclient/DockerContextResolverTest.java +++ b/core/src/test/java/org/testcontainers/dockerclient/DockerContextResolverTest.java @@ -61,10 +61,7 @@ void dockerHostEnvBypassesContexts(@TempDir Path dockerConfigDir) throws IOExcep void blankDockerContextEnvIsIgnored(@TempDir Path dockerConfigDir) throws IOException { writeConfig(dockerConfigDir, "{\"currentContext\":\"desktop-linux\"}"); - String name = DockerContextResolver.resolveCurrentContextName( - dockerConfigDir, - mapEnv("DOCKER_CONTEXT", " ") - ); + String name = DockerContextResolver.resolveCurrentContextName(dockerConfigDir, mapEnv("DOCKER_CONTEXT", " ")); assertThat(name).isEqualTo("desktop-linux"); }