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 command = new ArrayList<>(); + command.add(Paths.get(System.getProperty("java.home"), "bin", executable).toString()); + command.addAll(Arrays.asList(args)); + return discarding(new ProcessBuilder(command)); + } + + /** + * A command that outlives any timeout these tests use, so the timeout and interrupt paths are + * reached deterministically rather than by racing process startup. + */ + private static ProcessBuilder sleeper() { + return discarding( + SystemUtils.IS_OS_WINDOWS + ? new ProcessBuilder("cmd", "/c", "ping", "-n", "10", "127.0.0.1") + : new ProcessBuilder("sleep", "5") + ); + } + + private static ProcessBuilder discarding(ProcessBuilder builder) { + return builder.redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.DISCARD); + } +}