diff --git a/core/build.gradle b/core/build.gradle index 66f9fe7dcc0..237507478ec 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -80,6 +80,10 @@ dependencies { shaded 'org.awaitility:awaitility:4.3.0' + // TODO(wslc): bump both coordinates below to the first docker-java release that carries the + // wslc:// transport (docker-java/docker-java#2658, implemented in docker-java/docker-java#2659). + // Until then WslcSocketClientProviderStrategy compiles and is discovered, but the URI it + // resolves to cannot be opened at runtime -- which is why the pull request adding it is a draft. api platform('com.github.docker-java:docker-java-bom:3.7.1') shaded platform('com.github.docker-java:docker-java-bom:3.7.1') diff --git a/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java index 7b0aaafc169..da0635da11d 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java +++ b/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java @@ -200,6 +200,12 @@ protected boolean test() { }; socketAddress = new InetSocketAddress("localhost", 2375); break; + case "wslc": + // wslc publishes neither a socket file nor a port: the daemon is reached over a + // stdio bridge, so there is nothing here to connect() to. Reachability is + // established by the infoCmd() ping in tryOutStrategy instead. + log.debug("wslc transport has no connectable endpoint, deferring to the daemon ping"); + return true; default: log.warn("Unknown DOCKER_HOST scheme {}, skipping the strategy test...", dockerHost.getScheme()); return true; @@ -475,6 +481,11 @@ static String resolveDockerHostIpAddress(DockerClient client, URI dockerHost, bo }); } return "localhost"; + case "wslc": + // The wslc control plane relays published container ports onto 127.0.0.1 on the + // Windows host, so the daemon is always addressable there. Without this case the + // default below returns null and ContainerState.getHost() has no address to give. + return "localhost"; default: return null; } diff --git a/core/src/main/java/org/testcontainers/dockerclient/WslcSocketClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/WslcSocketClientProviderStrategy.java new file mode 100644 index 00000000000..f00ed76465b --- /dev/null +++ b/core/src/main/java/org/testcontainers/dockerclient/WslcSocketClientProviderStrategy.java @@ -0,0 +1,137 @@ +package org.testcontainers.dockerclient; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.SystemUtils; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.util.concurrent.TimeUnit; + +/** + * Auto-detects a WSL Containers (wslc) Docker daemon when no {@code DOCKER_HOST} is configured and no + * real Docker/Podman endpoint is available. wslc exposes neither a Windows named pipe nor a TCP port; + * its daemon is reached through a stdio bridge (see the {@code wslc://} docker-java transport). + *
+ * The priority is below {@link NpipeSocketClientProviderStrategy} so an existing Docker Desktop or + * Podman named pipe always wins; wslc is only used as a fallback, and only on Windows when the + * {@code wslc} CLI is actually present. + * + * @deprecated this class is used by the SPI and should not be used directly + */ +@Slf4j +@Deprecated +public final class WslcSocketClientProviderStrategy extends DockerClientProviderStrategy { + + private static final String SOCKET_LOCATION = "wslc://localhost"; + + public static final int PRIORITY = NpipeSocketClientProviderStrategy.PRIORITY - 10; + + static final String WSLC_EXECUTABLE_ENV = "WSLC_EXECUTABLE"; + + static final String DEFAULT_EXECUTABLE = "wslc.exe"; + + static final long PROBE_TIMEOUT_MS = 10_000; + + @Override + public TransportConfig getTransportConfig() { + return TransportConfig.builder().dockerHost(URI.create(SOCKET_LOCATION)).build(); + } + + @Override + protected boolean isApplicable() { + return applies(SystemUtils.IS_OS_WINDOWS); + } + + @Override + public String getDescription() { + return "WSL Containers (wslc) session (" + SOCKET_LOCATION + ")"; + } + + @Override + protected int getPriority() { + return PRIORITY; + } + + /** + * Never remembered in {@code ~/.testcontainers.properties}. A persisted strategy is loaded ahead + * of the priority-sorted ones by {@code getFirstValidStrategy}, so recording this one would let + * wslc keep winning on later runs even once a Docker Desktop or Podman named pipe is available — + * defeating the priority that is supposed to keep it a fallback. + */ + @Override + protected boolean isPersistable() { + return false; + } + + /** + * Split from {@link #isApplicable()} so the Windows gate can be exercised on any platform: the + * OS cannot be varied at runtime. + */ + static boolean applies(boolean windows) { + return windows && isWslcAvailable(); + } + + private static boolean isWslcAvailable() { + String executable = resolveExecutable(System.getenv(WSLC_EXECUTABLE_ENV)); + return probe(probeCommand(executable, SystemUtils.IS_OS_WINDOWS), PROBE_TIMEOUT_MS); + } + + /** + * @return {@link #DEFAULT_EXECUTABLE} unless {@code configured} names something; a blank + * {@code WSLC_EXECUTABLE} is treated as unset rather than passed on to fail as a + * missing executable + */ + static String resolveExecutable(String configured) { + return StringUtils.isBlank(configured) ? DEFAULT_EXECUTABLE : configured; + } + + /** + * {@code wslc version} is metadata only: unlike most subcommands it does not start the container + * VM, which is what makes it usable on the strategy-discovery path. + *
+ * Both streams are discarded, and merged first, so the child can never block on a pipe nobody is
+ * draining. {@code Redirect.DISCARD} would say this more directly but is Java 9+, and core main
+ * sources compile at {@code release 8}.
+ */
+ static ProcessBuilder probeCommand(String executable, boolean windows) {
+ return new ProcessBuilder(executable, "version")
+ .redirectErrorStream(true)
+ .redirectOutput(ProcessBuilder.Redirect.to(new File(windows ? "NUL" : "/dev/null")));
+ }
+
+ /**
+ * @return true only if the command ran to completion within {@code timeoutMillis} and exited 0.
+ * Every other outcome is a reason not to claim this strategy, and is logged at debug so
+ * a half-installed wslc does not fail silently.
+ */
+ static boolean probe(ProcessBuilder builder, long timeoutMillis) {
+ Process process = null;
+ try {
+ process = builder.start();
+ if (!process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS)) {
+ log.debug("wslc probe {} did not finish within {}ms", builder.command(), timeoutMillis);
+ return false;
+ }
+ int exitValue = process.exitValue();
+ if (exitValue != 0) {
+ log.debug("wslc probe {} exited with {}", builder.command(), exitValue);
+ return false;
+ }
+ return true;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ log.debug("wslc probe {} was interrupted", builder.command(), e);
+ return false;
+ } catch (IOException | RuntimeException e) {
+ log.debug("wslc probe {} could not be run", builder.command(), e);
+ return false;
+ } finally {
+ if (process != null) {
+ // A no-op once the process has exited, so this needs no isAlive() guard.
+ process.destroyForcibly();
+ }
+ }
+ }
+}
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..0e73ba2245e 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
@@ -3,5 +3,6 @@ org.testcontainers.dockerclient.EnvironmentAndSystemPropertyClientProviderStrate
org.testcontainers.dockerclient.UnixSocketClientProviderStrategy
org.testcontainers.dockerclient.DockerMachineClientProviderStrategy
org.testcontainers.dockerclient.NpipeSocketClientProviderStrategy
+org.testcontainers.dockerclient.WslcSocketClientProviderStrategy
org.testcontainers.dockerclient.RootlessDockerClientProviderStrategy
org.testcontainers.dockerclient.DockerDesktopClientProviderStrategy
diff --git a/core/src/test/java/org/testcontainers/dockerclient/DockerClientConfigUtilsTest.java b/core/src/test/java/org/testcontainers/dockerclient/DockerClientConfigUtilsTest.java
index f3bc15d2c22..adc6ffab0b4 100644
--- a/core/src/test/java/org/testcontainers/dockerclient/DockerClientConfigUtilsTest.java
+++ b/core/src/test/java/org/testcontainers/dockerclient/DockerClientConfigUtilsTest.java
@@ -46,6 +46,16 @@ void getDockerHostIpAddressShouldReturnDockerHostIpWhenTcpUri() {
assertThat(actual).isEqualTo("12.23.34.45");
}
+ @Test
+ void getDockerHostIpAddressShouldReturnLocalhostWhenWslcUri() {
+ String actual = DockerClientProviderStrategy.resolveDockerHostIpAddress(
+ client,
+ URI.create("wslc://localhost"),
+ true
+ );
+ assertThat(actual).isEqualTo("localhost");
+ }
+
@Test
void getDockerHostIpAddressShouldReturnNullWhenUnsupportedUriScheme() {
String actual = DockerClientProviderStrategy.resolveDockerHostIpAddress(
diff --git a/core/src/test/java/org/testcontainers/dockerclient/WslcSocketClientProviderStrategyTest.java b/core/src/test/java/org/testcontainers/dockerclient/WslcSocketClientProviderStrategyTest.java
new file mode 100644
index 00000000000..f20e4a92fe4
--- /dev/null
+++ b/core/src/test/java/org/testcontainers/dockerclient/WslcSocketClientProviderStrategyTest.java
@@ -0,0 +1,166 @@
+package org.testcontainers.dockerclient;
+
+import org.apache.commons.lang3.SystemUtils;
+import org.junit.jupiter.api.Test;
+
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+import static org.assertj.core.api.Assumptions.assumeThat;
+
+class WslcSocketClientProviderStrategyTest {
+
+ private final WslcSocketClientProviderStrategy strategy = new WslcSocketClientProviderStrategy();
+
+ @Test
+ void resolvesToTheWslcDockerHost() {
+ assertThat(strategy.getTransportConfig().getDockerHost()).hasToString("wslc://localhost");
+ }
+
+ @Test
+ void describesItselfWithTheEndpoint() {
+ assertThat(strategy.getDescription()).contains("wslc://localhost");
+ }
+
+ @Test
+ void sitsBelowTheNamedPipeStrategySoDockerDesktopAlwaysWins() {
+ assertThat(strategy.getPriority())
+ .isEqualTo(WslcSocketClientProviderStrategy.PRIORITY)
+ .isLessThan(NpipeSocketClientProviderStrategy.PRIORITY);
+ }
+
+ @Test
+ void neverAppliesOffWindows() {
+ assertThat(WslcSocketClientProviderStrategy.applies(false)).isFalse();
+ }
+
+ @Test
+ void probesTheEnvironmentWhenOnWindows() {
+ // The outcome depends on whether wslc is installed on the machine running the build, so this
+ // pins only what is environment-independent: the probe is reached and completes.
+ assertThatNoException().isThrownBy(() -> WslcSocketClientProviderStrategy.applies(true));
+ }
+
+ @Test
+ void isApplicableCompletesOnAnyPlatform() {
+ assertThatNoException().isThrownBy(strategy::isApplicable);
+ }
+
+ @Test
+ void isNotApplicableOffWindowsEvenIfSomethingCalledWslcExists() {
+ assumeThat(SystemUtils.IS_OS_WINDOWS).isFalse();
+ assertThat(strategy.isApplicable()).isFalse();
+ }
+
+ @Test
+ void isNotPersistedSoALaterRunCannotSkipTheNamedPipeStrategy() {
+ // A persisted strategy is loaded ahead of the priority-sorted ones, which would let wslc
+ // keep winning after a Docker Desktop or Podman pipe becomes available.
+ assertThat(strategy.isPersistable()).isFalse();
+ }
+
+ @Test
+ void skipsTheSocketProbeBecauseThereIsNoConnectableEndpoint() {
+ // A stdio bridge has neither a socket file nor a port to connect() to, so test() must defer
+ // to the daemon ping in tryOutStrategy rather than warn about an unrecognised scheme.
+ assertThat(strategy.test()).isTrue();
+ }
+
+ @Test
+ void defaultsTheExecutableWhenTheEnvironmentDoesNotNameOne() {
+ assertThat(WslcSocketClientProviderStrategy.resolveExecutable(null))
+ .isEqualTo(WslcSocketClientProviderStrategy.DEFAULT_EXECUTABLE);
+ assertThat(WslcSocketClientProviderStrategy.resolveExecutable(" "))
+ .isEqualTo(WslcSocketClientProviderStrategy.DEFAULT_EXECUTABLE);
+ }
+
+ @Test
+ void honoursAConfiguredExecutable() {
+ assertThat(WslcSocketClientProviderStrategy.resolveExecutable("C:\\tools\\wslc.exe"))
+ .isEqualTo("C:\\tools\\wslc.exe");
+ }
+
+ @Test
+ void asksWslcOnlyForItsVersion() {
+ assertThat(WslcSocketClientProviderStrategy.probeCommand("wslc.exe", true).command())
+ .containsExactly("wslc.exe", "version");
+ }
+
+ @Test
+ void discardsProbeOutputToThePlatformNullDevice() {
+ ProcessBuilder onWindows = WslcSocketClientProviderStrategy.probeCommand("wslc.exe", true);
+ ProcessBuilder elsewhere = WslcSocketClientProviderStrategy.probeCommand("wslc", false);
+
+ assertThat(onWindows.redirectOutput().file()).hasName("NUL");
+ assertThat(elsewhere.redirectOutput().file()).hasName("null");
+ assertThat(onWindows.redirectErrorStream()).isTrue();
+ }
+
+ @Test
+ void probeSucceedsWhenTheCommandExitsZero() {
+ assertThat(WslcSocketClientProviderStrategy.probe(java("-version"), 30_000)).isTrue();
+ }
+
+ @Test
+ void probeFailsWhenTheCommandExitsNonZero() {
+ assertThat(WslcSocketClientProviderStrategy.probe(java("-XXdefinitelyNotAValidFlag"), 30_000)).isFalse();
+ }
+
+ @Test
+ void probeFailsWhenTheExecutableCannotBeRun() {
+ ProcessBuilder missing = new ProcessBuilder("wslc-does-not-exist-" + UUID.randomUUID());
+
+ assertThat(WslcSocketClientProviderStrategy.probe(missing, 30_000)).isFalse();
+ }
+
+ @Test
+ void probeFailsWhenTheCommandOutlivesTheTimeout() {
+ assertThat(WslcSocketClientProviderStrategy.probe(sleeper(), 200)).isFalse();
+ }
+
+ @Test
+ void probeFailsAndRestoresTheInterruptFlagWhenInterrupted() {
+ Thread.currentThread().interrupt();
+ try {
+ assertThat(WslcSocketClientProviderStrategy.probe(sleeper(), 30_000)).isFalse();
+ assertThat(Thread.currentThread().isInterrupted())
+ .as("interrupt flag restored rather than swallowed")
+ .isTrue();
+ } finally {
+ // Clear it so the rest of the suite is unaffected.
+ Thread.interrupted();
+ }
+ }
+
+ /**
+ * The JVM running the build, which exists on every platform the suite runs on.
+ */
+ private static ProcessBuilder java(String... args) {
+ String executable = SystemUtils.IS_OS_WINDOWS ? "java.exe" : "java";
+ List