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..4668ebaaabd --- /dev/null +++ b/core/src/main/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategy.java @@ -0,0 +1,153 @@ +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/java/org/testcontainers/dockerclient/DockerContextResolver.java b/core/src/main/java/org/testcontainers/dockerclient/DockerContextResolver.java new file mode 100644 index 00000000000..153699bd872 --- /dev/null +++ b/core/src/main/java/org/testcontainers/dockerclient/DockerContextResolver.java @@ -0,0 +1,188 @@ +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/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..a32a7b6c626 --- /dev/null +++ b/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java @@ -0,0 +1,204 @@ +package org.testcontainers.dockerclient; + +import org.apache.commons.lang3.SystemUtils; +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 { + String host = fakeEndpointHost(dockerConfigDir, "fake"); + writeMeta( + dockerConfigDir, + "my-ctx", + "{\"Name\":\"my-ctx\",\"Endpoints\":{\"docker\":{\"Host\":\"" + host + "\"}}}" + ); + + DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy( + dockerConfigDir, + "my-ctx" + ); + + assertThat(strategy.isApplicable()).isTrue(); + TransportConfig config = strategy.getTransportConfig(); + assertThat(config.getDockerHost().toString()).isEqualTo(host); + assertThat(config.getSslConfig()).isNull(); + assertThat(strategy.getDescription()).contains("my-ctx").contains(host); + } + + @Test + void usesCurrentContextFromConfigWhenNoExplicitName(@TempDir Path dockerConfigDir) throws IOException { + String host = fakeEndpointHost(dockerConfigDir, "current"); + writeConfig(dockerConfigDir, "{\"currentContext\":\"picked\"}"); + writeMeta( + dockerConfigDir, + "picked", + "{\"Name\":\"picked\",\"Endpoints\":{\"docker\":{\"Host\":\"" + host + "\"}}}" + ); + + DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy(dockerConfigDir, null); + + assertThat(strategy.isApplicable()).isTrue(); + assertThat(strategy.getTransportConfig().getDockerHost().toString()).isEqualTo(host); + } + + @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(); + + // 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(); + } + } + + @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)); + } + + /** + * 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 "unix://" + socket.toAbsolutePath(); + } +} 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..2818b40b41d --- /dev/null +++ b/core/src/test/java/org/testcontainers/dockerclient/DockerContextResolverTest.java @@ -0,0 +1,190 @@ +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); + } + } +} 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.