diff --git a/docker-java-core/src/main/java/com/github/dockerjava/core/DefaultDockerClientConfig.java b/docker-java-core/src/main/java/com/github/dockerjava/core/DefaultDockerClientConfig.java
index dad75b360..2addaaeb5 100644
--- a/docker-java-core/src/main/java/com/github/dockerjava/core/DefaultDockerClientConfig.java
+++ b/docker-java-core/src/main/java/com/github/dockerjava/core/DefaultDockerClientConfig.java
@@ -28,6 +28,7 @@
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
+import java.util.concurrent.TimeUnit;
import static org.apache.commons.lang3.BooleanUtils.isTrue;
@@ -68,6 +69,11 @@ public class DefaultDockerClientConfig implements Serializable, DockerClientConf
static final String WINDOWS_DEFAULT_DOCKER_HOST = "npipe:////./pipe/docker_engine";
+ static final String WSLC_DEFAULT_DOCKER_HOST = "wslc://localhost";
+
+ // wslc availability is constant per machine; probe at most once per JVM.
+ private static volatile Boolean wslcAvailable;
+
static {
CONFIG_KEYS.add(DOCKER_HOST);
CONFIG_KEYS.add(DOCKER_TLS_VERIFY);
@@ -115,6 +121,60 @@ private URI checkDockerHostScheme(URI dockerHost) {
return dockerHost;
}
+ /**
+ * Default DOCKER_HOST when none is configured. On Windows a real Docker/Podman named pipe is
+ * preferred; only when that pipe is absent and the WSL Containers (wslc) CLI is available does it
+ * fall back to {@code wslc://localhost}, so existing Docker Desktop / Podman setups keep winning.
+ */
+ private static String defaultDockerHost() {
+ if (!SystemUtils.IS_OS_WINDOWS) {
+ return DEFAULT_DOCKER_HOST;
+ }
+ if (!new File("//./pipe/docker_engine").exists() && isWslcAvailable()) {
+ return WSLC_DEFAULT_DOCKER_HOST;
+ }
+ return WINDOWS_DEFAULT_DOCKER_HOST;
+ }
+
+ private static boolean isWslcAvailable() {
+ Boolean cached = wslcAvailable;
+ if (cached == null) {
+ cached = probeWslc();
+ wslcAvailable = cached;
+ }
+ return cached;
+ }
+
+ // 'wslc version' is a cheap metadata call that does not start the container VM. The executable
+ // can be overridden with the WSLC_EXECUTABLE environment variable.
+ private static boolean probeWslc() {
+ String executable = System.getenv("WSLC_EXECUTABLE");
+ if (executable == null || executable.trim().isEmpty()) {
+ executable = "wslc.exe";
+ }
+ Process process = null;
+ try {
+ process = new ProcessBuilder(executable, "version")
+ .redirectErrorStream(true)
+ .redirectOutput(ProcessBuilder.Redirect.to(new File("NUL")))
+ .start();
+ if (!process.waitFor(10, TimeUnit.SECONDS)) {
+ process.destroyForcibly();
+ return false;
+ }
+ return process.exitValue() == 0;
+ } catch (IOException | InterruptedException | RuntimeException e) {
+ if (e instanceof InterruptedException) {
+ Thread.currentThread().interrupt();
+ }
+ return false;
+ } finally {
+ if (process != null && process.isAlive()) {
+ process.destroyForcibly();
+ }
+ }
+ }
+
private static Properties loadIncludedDockerProperties(Properties systemProperties) {
Properties p = new Properties();
p.putAll(DEFAULT_PROPERTIES);
@@ -485,7 +545,7 @@ public DefaultDockerClientConfig build() {
URI dockerHostUri = dockerHost != null
? dockerHost
- : URI.create(SystemUtils.IS_OS_WINDOWS ? WINDOWS_DEFAULT_DOCKER_HOST : DEFAULT_DOCKER_HOST);
+ : URI.create(defaultDockerHost());
return new DefaultDockerClientConfig(dockerHostUri, dockerConfigFile, dockerConfig, apiVersion, registryUrl, registryUsername,
registryPassword, registryEmail, sslConfig);
diff --git a/docker-java-core/src/main/java/com/github/dockerjava/core/DockerClientImpl.java b/docker-java-core/src/main/java/com/github/dockerjava/core/DockerClientImpl.java
index a1ddc2897..db7370792 100644
--- a/docker-java-core/src/main/java/com/github/dockerjava/core/DockerClientImpl.java
+++ b/docker-java-core/src/main/java/com/github/dockerjava/core/DockerClientImpl.java
@@ -211,8 +211,19 @@ public static DockerClientImpl getInstance(DockerClientConfig dockerClientConfig
}
public static DockerClient getInstance(DockerClientConfig dockerClientConfig, DockerHttpClient dockerHttpClient) {
+ DockerHttpClient httpClient = dockerHttpClient;
+ // WSL Containers (wslc): the Docker Engine API reaches the daemon over the dial-stdio bridge,
+ // but the Windows port relay and host bind mounts are only wired when a container is created
+ // and started through the `wslc` CLI. Transparently route just those lifecycle calls through
+ // wslc so that published ports are reachable from Windows and host directories can be
+ // bind-mounted. Doing it here means every docker-java consumer (including Testcontainers, which
+ // shades this class) gets the behaviour without any extra dependency or client-side wiring.
+ if (dockerClientConfig.getDockerHost() != null
+ && "wslc".equals(dockerClientConfig.getDockerHost().getScheme())) {
+ httpClient = new WslcLifecycleDockerHttpClient(httpClient, null);
+ }
return new DockerClientImpl(dockerClientConfig)
- .withHttpClient(dockerHttpClient);
+ .withHttpClient(httpClient);
}
/**
diff --git a/docker-java-core/src/main/java/com/github/dockerjava/core/WslcLifecycleDockerHttpClient.java b/docker-java-core/src/main/java/com/github/dockerjava/core/WslcLifecycleDockerHttpClient.java
new file mode 100644
index 000000000..9a91791f8
--- /dev/null
+++ b/docker-java-core/src/main/java/com/github/dockerjava/core/WslcLifecycleDockerHttpClient.java
@@ -0,0 +1,541 @@
+package com.github.dockerjava.core;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.github.dockerjava.transport.DockerHttpClient;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A {@link DockerHttpClient} decorator that makes container port publishing, host bind mounts and
+ * user-defined networks actually work on WSL Containers (wslc).
+ *
+ * On wslc the Windows integration (VirtioNet port relay to {@code 127.0.0.1} and VirtioFs host-path
+ * bind mounts) is wired by the wslc control plane only when a container is created and
+ * started through the {@code wslc} CLI. A container created/started through the Docker Engine API
+ * (the dial-stdio bridge) reaches the very same dockerd, but the relay/bind are never established.
+ *
+ * This decorator therefore intercepts the four calls that must be reconciled with the wslc control
+ * plane, translating each to the equivalent {@code wslc} command, and delegates everything else
+ * (exec, logs, wait, stop, pull, build, ...) unchanged to the wrapped Docker-API client, which talks
+ * to the same daemon:
+ *
+ * - {@code POST /containers/create} → {@code wslc create}
+ * - {@code POST /containers/{id}/start} → {@code wslc start} (this wires the relay/bind)
+ * - {@code POST /networks/create} → {@code wslc network create} (so the network is visible
+ * to {@code wslc create --network})
+ * - {@code GET /containers/{id}/json} → the daemon's inspect with {@code NetworkSettings.Ports}
+ * overridden from {@code wslc list} (the relay's real host port)
+ *
+ * Because it sits at the {@link DockerHttpClient} layer, docker-java (and Testcontainers on top of it)
+ * needs no other change.
+ */
+public class WslcLifecycleDockerHttpClient implements DockerHttpClient {
+
+ private static final java.util.logging.Logger LOGGER =
+ java.util.logging.Logger.getLogger(WslcLifecycleDockerHttpClient.class.getName());
+
+ private static final Pattern CREATE = Pattern.compile(".*/containers/create$");
+ private static final Pattern START = Pattern.compile(".*/containers/([^/]+)/start$");
+ private static final Pattern NETWORK_CREATE = Pattern.compile(".*/networks/create$");
+ private static final Pattern INSPECT = Pattern.compile(".*/containers/([^/]+)/json$");
+
+ private final DockerHttpClient delegate;
+ private final String wslc;
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ public WslcLifecycleDockerHttpClient(DockerHttpClient delegate, String wslcExecutable) {
+ this.delegate = delegate;
+ this.wslc = (wslcExecutable == null || wslcExecutable.isEmpty()) ? "wslc.exe" : wslcExecutable;
+ }
+
+ @Override
+ public Response execute(Request request) {
+ String path = request.path();
+ String noQuery = path.contains("?") ? path.substring(0, path.indexOf('?')) : path;
+ if ("POST".equals(request.method())) {
+ if (CREATE.matcher(noQuery).matches()) {
+ return handleCreate(request);
+ }
+ if (NETWORK_CREATE.matcher(noQuery).matches()) {
+ return handleNetworkCreate(request);
+ }
+ Matcher m = START.matcher(noQuery);
+ if (m.matches()) {
+ return handleStart(urlDecode(m.group(1)));
+ }
+ }
+ if ("GET".equals(request.method()) && INSPECT.matcher(noQuery).matches()) {
+ return handleInspect(request);
+ }
+ return delegate.execute(request);
+ }
+
+ // ---- POST /containers/create -> wslc create ----
+ private Response handleCreate(Request request) {
+ try {
+ JsonNode body = mapper.readTree(readBody(request));
+ JsonNode hostConfig = body.path("HostConfig");
+ List cmd = new ArrayList<>();
+ cmd.add(wslc);
+ cmd.add("create");
+
+ String name = queryParam(request.path(), "name");
+ addOpt(cmd, "--name", name);
+ addOpt(cmd, "-h", text(body, "Hostname"));
+ addOpt(cmd, "--domainname", text(body, "Domainname"));
+ addOpt(cmd, "-u", text(body, "User"));
+ addOpt(cmd, "-w", text(body, "WorkingDir"));
+ addOpt(cmd, "--stop-signal", text(body, "StopSignal"));
+
+ for (JsonNode env : body.path("Env")) {
+ add(cmd, "-e", env.asText());
+ }
+ for (Iterator> it = body.path("Labels").fields(); it.hasNext(); ) {
+ Map.Entry e = it.next();
+ add(cmd, "-l", e.getKey() + "=" + e.getValue().asText());
+ }
+ // -p host:container from HostConfig.PortBindings {"80/tcp":[{"HostPort":"18086"}]}
+ for (Iterator> it = hostConfig.path("PortBindings").fields(); it.hasNext(); ) {
+ Map.Entry e = it.next();
+ String containerPort = e.getKey().split("/")[0];
+ String hostPort = e.getValue().isArray() && e.getValue().size() > 0
+ ? e.getValue().get(0).path("HostPort").asText("") : "";
+ add(cmd, "-p", hostPort.isEmpty() ? containerPort : hostPort + ":" + containerPort);
+ }
+ if (hostConfig.path("PublishAllPorts").asBoolean(false)) {
+ cmd.add("-P");
+ }
+ // -v host:container[:mode] from HostConfig.Binds ["C:\\dir:/mnt/x:rw"]
+ for (JsonNode bind : hostConfig.path("Binds")) {
+ add(cmd, "-v", bind.asText());
+ }
+ for (Iterator> it = hostConfig.path("Tmpfs").fields(); it.hasNext(); ) {
+ add(cmd, "--tmpfs", it.next().getKey());
+ }
+ for (JsonNode dns : hostConfig.path("Dns")) {
+ add(cmd, "--dns", dns.asText());
+ }
+ for (JsonNode u : hostConfig.path("Ulimits")) {
+ add(cmd, "--ulimit", u.path("Name").asText() + "=" + u.path("Soft").asLong() + ":" + u.path("Hard").asLong());
+ }
+ long memory = hostConfig.path("Memory").asLong(0);
+ if (memory > 0) {
+ add(cmd, "-m", Long.toString(memory));
+ }
+ long nanoCpus = hostConfig.path("NanoCpus").asLong(0);
+ if (nanoCpus > 0) {
+ add(cmd, "--cpus", String.valueOf(nanoCpus / 1_000_000_000.0));
+ }
+ long shmSize = hostConfig.path("ShmSize").asLong(0);
+ if (shmSize > 0) {
+ add(cmd, "--shm-size", Long.toString(shmSize));
+ }
+
+ // Network: prefer the explicit NetworkingConfig endpoint (carries aliases), else NetworkMode.
+ String network = null;
+ JsonNode endpoints = body.path("NetworkingConfig").path("EndpointsConfig");
+ if (endpoints.isObject() && endpoints.size() > 0) {
+ network = endpoints.fieldNames().next();
+ for (JsonNode alias : endpoints.path(network).path("Aliases")) {
+ add(cmd, "--network-alias", alias.asText());
+ }
+ }
+ if (network == null) {
+ String nm = hostConfig.path("NetworkMode").asText("");
+ if (!nm.isEmpty() && !"default".equals(nm)) {
+ network = nm;
+ }
+ }
+ // `wslc create --network` resolves a network by name, not by id, but Testcontainers passes
+ // the network id; translate id -> name via a Docker-API network inspect over the bridge.
+ addOpt(cmd, "--network", resolveNetworkName(network));
+
+ // Entrypoint override: --entrypoint takes the executable; the rest become leading args.
+ List tailArgs = new ArrayList<>();
+ JsonNode entrypoint = body.path("Entrypoint");
+ if (entrypoint.isArray() && entrypoint.size() > 0) {
+ add(cmd, "--entrypoint", entrypoint.get(0).asText());
+ for (int i = 1; i < entrypoint.size(); i++) {
+ tailArgs.add(entrypoint.get(i).asText());
+ }
+ }
+
+ cmd.add(body.path("Image").asText());
+ for (JsonNode arg : body.path("Cmd")) {
+ tailArgs.add(arg.asText());
+ }
+ cmd.addAll(tailArgs);
+
+ // ExtraHosts (--add-host) has no wslc equivalent; surface it rather than silently dropping.
+ if (hostConfig.path("ExtraHosts").isArray() && hostConfig.path("ExtraHosts").size() > 0) {
+ LOGGER.warning("wslc has no --add-host; ExtraHosts ignored: " + hostConfig.path("ExtraHosts"));
+ }
+
+ Exec r = run(cmd);
+ if (r.exit != 0) {
+ LOGGER.warning("wslc create failed (exit " + r.exit + "): " + r.err.trim());
+ return json(500, "{\"message\":\"wslc create failed: " + escape(r.err) + "\"}");
+ }
+ ObjectNode resp = mapper.createObjectNode();
+ resp.put("Id", r.out.trim());
+ resp.set("Warnings", mapper.createArrayNode());
+ return json(201, mapper.writeValueAsString(resp));
+ } catch (Exception ex) {
+ LOGGER.log(java.util.logging.Level.WARNING, "wslc create translation failed", ex);
+ return json(500, "{\"message\":\"" + escape(String.valueOf(ex.getMessage())) + "\"}");
+ }
+ }
+
+ // Resolve a docker network reference (id or name) to its name, which is what `wslc create
+ // --network` expects. Returns the input unchanged if it is null or cannot be inspected.
+ private String resolveNetworkName(String ref) {
+ if (ref == null || ref.isEmpty()) {
+ return ref;
+ }
+ try {
+ Request req = Request.builder()
+ .method(Request.Method.GET)
+ .path("/networks/" + ref)
+ .headers(java.util.Collections.emptyMap())
+ .build();
+ try (Response resp = delegate.execute(req)) {
+ if (resp.getStatusCode() / 100 == 2) {
+ String name = mapper.readTree(resp.getBody()).path("Name").asText("");
+ if (!name.isEmpty()) {
+ return name;
+ }
+ }
+ }
+ } catch (Exception e) {
+ LOGGER.fine("network name resolution failed for '" + ref + "', using it as-is: " + e.getMessage());
+ }
+ return ref;
+ }
+
+ private static String text(JsonNode node, String field) {
+ String v = node.path(field).asText("");
+ return v.isEmpty() ? null : v;
+ }
+
+ private static void addOpt(List cmd, String flag, String value) {
+ if (value != null && !value.isEmpty()) {
+ cmd.add(flag);
+ cmd.add(value);
+ }
+ }
+
+ private static void add(List cmd, String flag, String value) {
+ cmd.add(flag);
+ cmd.add(value);
+ }
+
+ // ---- POST /networks/create -> wslc network create ----
+ // wslc create --network only sees networks registered through the wslc control plane; a network
+ // created via the Docker API (as Testcontainers does) is invisible to it even on the same daemon.
+ // So route network creation through wslc too.
+ private Response handleNetworkCreate(Request request) {
+ try {
+ JsonNode body = mapper.readTree(readBody(request));
+ List cmd = new ArrayList<>();
+ cmd.add(wslc);
+ cmd.add("network");
+ cmd.add("create");
+ String driver = body.path("Driver").asText("");
+ if (!driver.isEmpty() && !"default".equals(driver)) {
+ add(cmd, "--driver", driver);
+ }
+ if (body.path("Internal").asBoolean(false)) {
+ cmd.add("--internal");
+ }
+ for (Iterator> it = body.path("Options").fields(); it.hasNext(); ) {
+ Map.Entry e = it.next();
+ add(cmd, "-o", e.getKey() + "=" + e.getValue().asText());
+ }
+ for (Iterator> it = body.path("Labels").fields(); it.hasNext(); ) {
+ Map.Entry e = it.next();
+ add(cmd, "-l", e.getKey() + "=" + e.getValue().asText());
+ }
+ JsonNode ipam = body.path("IPAM").path("Config");
+ if (ipam.isArray() && ipam.size() > 0) {
+ addOpt(cmd, "--subnet", text(ipam.get(0), "Subnet"));
+ addOpt(cmd, "--gateway", text(ipam.get(0), "Gateway"));
+ }
+ cmd.add(body.path("Name").asText());
+
+ Exec r = run(cmd);
+ if (r.exit != 0) {
+ LOGGER.warning("wslc network create failed (exit " + r.exit + "): " + r.err.trim());
+ return json(500, "{\"message\":\"wslc network create failed: " + escape(r.err) + "\"}");
+ }
+ ObjectNode resp = mapper.createObjectNode();
+ resp.put("Id", r.out.trim());
+ resp.put("Warning", "");
+ return json(201, mapper.writeValueAsString(resp));
+ } catch (Exception ex) {
+ LOGGER.log(java.util.logging.Level.WARNING, "wslc network create translation failed", ex);
+ return json(500, "{\"message\":\"" + escape(String.valueOf(ex.getMessage())) + "\"}");
+ }
+ }
+
+ // ---- POST /containers/{id}/start -> wslc start (this is what wires the relay/bind) ----
+ private Response handleStart(String id) {
+ List cmd = new ArrayList<>();
+ cmd.add(wslc);
+ cmd.add("start");
+ cmd.add(id);
+ Exec r = run(cmd);
+ if (r.exit != 0) {
+ LOGGER.warning("wslc start failed for " + id + " (exit " + r.exit + "): " + r.err.trim());
+ return json(500, "{\"message\":\"wslc start failed: " + escape(r.err) + "\"}");
+ }
+ return json(204, "");
+ }
+
+ // ---- GET /containers/{id}/json -> inject the wslc relay port mapping ----
+ // wslc publishes ports through its control plane (visible via `wslc list`), but the Docker-API
+ // inspect leaves NetworkSettings.Ports empty. Testcontainers reads the mapped host port from
+ // there, so fill it in from `wslc list --format json` when the daemon left it blank.
+ private Response handleInspect(Request request) {
+ Response real = delegate.execute(request);
+ int status = real.getStatusCode();
+ Map> headers = real.getHeaders();
+ byte[] body;
+ try {
+ body = readAll(real.getBody());
+ } catch (Exception e) {
+ return real;
+ } finally {
+ real.close();
+ }
+ if (status / 100 != 2) {
+ return rawResponse(status, headers, body);
+ }
+ try {
+ JsonNode root = mapper.readTree(body);
+ if (root instanceof ObjectNode) {
+ ObjectNode obj = (ObjectNode) root;
+ // Always override with the wslc ground truth: for a random published port, `wslc create`
+ // records one host port in dockerd's inspect but `wslc start`'s relay binds a different
+ // one, so the daemon's value is wrong (not just missing). `wslc list` has the real port.
+ ObjectNode injected = wslcPorts(obj.path("Id").asText(""));
+ if (injected != null && injected.size() > 0) {
+ JsonNode ns = obj.path("NetworkSettings");
+ ObjectNode nsObj = ns.isObject() ? (ObjectNode) ns : obj.putObject("NetworkSettings");
+ nsObj.set("Ports", injected);
+ return rawResponse(status, headers, mapper.writeValueAsBytes(obj));
+ }
+ }
+ } catch (Exception e) {
+ LOGGER.fine("inspect port override failed, returning the daemon's original body: " + e.getMessage());
+ }
+ return rawResponse(status, headers, body);
+ }
+
+ // Build a Docker-API NetworkSettings.Ports node for the given container id from `wslc list`.
+ private ObjectNode wslcPorts(String id) {
+ if (id == null || id.isEmpty()) {
+ return null;
+ }
+ try {
+ Exec r = run(java.util.Arrays.asList(wslc, "list", "--format", "json", "--no-trunc", "--all"));
+ if (r.exit != 0) {
+ LOGGER.fine("wslc list failed (exit " + r.exit + "), leaving inspect ports unchanged: " + r.err.trim());
+ return null;
+ }
+ for (JsonNode row : mapper.readTree(r.out)) {
+ String rid = row.path("Id").asText("");
+ if (rid.isEmpty() || !(rid.startsWith(id) || id.startsWith(rid))) {
+ continue;
+ }
+ JsonNode portList = row.path("Ports");
+ if (!portList.isArray() || portList.size() == 0) {
+ return null;
+ }
+ ObjectNode ports = mapper.createObjectNode();
+ for (JsonNode p : portList) {
+ String proto = p.path("Protocol").asInt(6) == 17 ? "udp" : "tcp";
+ String key = p.path("ContainerPort").asInt() + "/" + proto;
+ ObjectNode binding = mapper.createObjectNode();
+ binding.put("HostIp", p.path("BindingAddress").asText("127.0.0.1"));
+ binding.put("HostPort", String.valueOf(p.path("HostPort").asInt()));
+ ArrayNode arr = ports.has(key) ? (ArrayNode) ports.get(key) : ports.putArray(key);
+ arr.add(binding);
+ }
+ return ports;
+ }
+ } catch (Exception e) {
+ LOGGER.fine("could not derive wslc port mapping for " + id + ": " + e.getMessage());
+ }
+ return null;
+ }
+
+ private static byte[] readAll(InputStream in) throws IOException {
+ if (in == null) {
+ return new byte[0];
+ }
+ java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
+ byte[] buf = new byte[8192];
+ int n;
+ while ((n = in.read(buf)) != -1) {
+ bos.write(buf, 0, n);
+ }
+ return bos.toByteArray();
+ }
+
+ private static Response rawResponse(int status, Map> headers, byte[] body) {
+ return new Response() {
+ @Override public int getStatusCode() {
+ return status;
+ }
+ @Override public Map> getHeaders() {
+ return headers;
+ }
+ @Override public InputStream getBody() {
+ return new ByteArrayInputStream(body);
+ }
+ @Override public void close() {
+ }
+ };
+ }
+
+ // ---------- helpers ----------
+ private static String queryParam(String path, String key) {
+ int q = path.indexOf('?');
+ if (q < 0) {
+ return null;
+ }
+ for (String pair : path.substring(q + 1).split("&")) {
+ int eq = pair.indexOf('=');
+ if (eq > 0 && pair.substring(0, eq).equals(key)) {
+ return urlDecode(pair.substring(eq + 1));
+ }
+ }
+ return null;
+ }
+
+ private static String urlDecode(String s) {
+ try {
+ return URLDecoder.decode(s, "UTF-8");
+ } catch (Exception e) {
+ return s;
+ }
+ }
+
+ private static byte[] readBody(Request request) throws IOException {
+ if (request.bodyBytes() != null) {
+ return request.bodyBytes();
+ }
+ InputStream in = request.body();
+ if (in == null) {
+ return new byte[0];
+ }
+ java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
+ byte[] buf = new byte[4096];
+ int n;
+ while ((n = in.read(buf)) != -1) {
+ bos.write(buf, 0, n);
+ }
+ return bos.toByteArray();
+ }
+
+ private static final class Exec {
+ final int exit;
+ final String out;
+ final String err;
+ Exec(int exit, String out, String err) {
+ this.exit = exit;
+ this.out = out;
+ this.err = err;
+ }
+ }
+
+ private static Exec run(List command) {
+ Process p = null;
+ try {
+ p = new ProcessBuilder(command).start();
+ // Drain stdout and stderr concurrently: reading one fully before the other would risk a
+ // deadlock if the process fills the still-unread pipe's buffer while we block on the other.
+ final Process proc = p;
+ final StringBuilder errBuf = new StringBuilder();
+ Thread errDrain = new Thread(() -> {
+ try {
+ errBuf.append(drain(proc.getErrorStream()));
+ } catch (IOException ignored) {
+ // process gone; nothing useful to add
+ }
+ }, "wslc-cmd-stderr");
+ errDrain.setDaemon(true);
+ errDrain.start();
+ String out = drain(p.getInputStream());
+ if (!p.waitFor(120, TimeUnit.SECONDS)) {
+ p.destroyForcibly();
+ errDrain.join(1000);
+ return new Exec(-1, out, "timed out after 120s");
+ }
+ errDrain.join(1000);
+ return new Exec(p.exitValue(), out, errBuf.toString());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ if (p != null) {
+ p.destroyForcibly();
+ }
+ return new Exec(-1, "", "interrupted");
+ } catch (Exception e) {
+ if (p != null) {
+ p.destroyForcibly();
+ }
+ return new Exec(-1, "", String.valueOf(e.getMessage()));
+ }
+ }
+
+ private static String drain(InputStream in) throws IOException {
+ java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
+ byte[] buf = new byte[4096];
+ int n;
+ while ((n = in.read(buf)) != -1) {
+ bos.write(buf, 0, n);
+ }
+ return new String(bos.toByteArray(), StandardCharsets.UTF_8);
+ }
+
+ private static String escape(String s) {
+ return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ").trim();
+ }
+
+ private static Response json(int status, String body) {
+ byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+ return new Response() {
+ @Override public int getStatusCode() {
+ return status;
+ }
+ @Override public Map> getHeaders() {
+ return Collections.singletonMap("Content-Type", Collections.singletonList("application/json"));
+ }
+ @Override public InputStream getBody() {
+ return new ByteArrayInputStream(bytes);
+ }
+ @Override public void close() {
+ }
+ };
+ }
+
+ @Override
+ public void close() throws IOException {
+ delegate.close();
+ }
+}
diff --git a/docker-java-transport-httpclient5/src/main/java/com/github/dockerjava/httpclient5/ApacheDockerHttpClientImpl.java b/docker-java-transport-httpclient5/src/main/java/com/github/dockerjava/httpclient5/ApacheDockerHttpClientImpl.java
index c97a2bc45..8de5e6686 100644
--- a/docker-java-transport-httpclient5/src/main/java/com/github/dockerjava/httpclient5/ApacheDockerHttpClientImpl.java
+++ b/docker-java-transport-httpclient5/src/main/java/com/github/dockerjava/httpclient5/ApacheDockerHttpClientImpl.java
@@ -4,6 +4,7 @@
import com.github.dockerjava.transport.NamedPipeSocket;
import com.github.dockerjava.transport.SSLConfig;
import com.github.dockerjava.transport.UnixSocket;
+import com.github.dockerjava.transport.WslcSocket;
import org.apache.hc.client5.http.SystemDefaultDnsResolver;
import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase;
@@ -74,6 +75,7 @@ protected ApacheDockerHttpClientImpl(
switch (dockerHost.getScheme()) {
case "unix":
case "npipe":
+ case "wslc":
pathPrefix = "";
host = new HttpHost(dockerHost.getScheme(), "localhost", 2375);
break;
@@ -151,6 +153,8 @@ private HttpClientConnectionOperator createConnectionOperator(
return UnixSocket.get(dockerHostPath);
} else if ("npipe".equalsIgnoreCase(dockerHostScheme)) {
return new NamedPipeSocket(dockerHostPath);
+ } else if ("wslc".equalsIgnoreCase(dockerHostScheme)) {
+ return new WslcSocket(dockerHostPath);
} else {
return socksProxy == null ? new Socket() : new Socket(socksProxy);
}
diff --git a/docker-java-transport/src/main/java/com/github/dockerjava/transport/WslcSocket.java b/docker-java-transport/src/main/java/com/github/dockerjava/transport/WslcSocket.java
new file mode 100644
index 000000000..9fbd86a51
--- /dev/null
+++ b/docker-java-transport/src/main/java/com/github/dockerjava/transport/WslcSocket.java
@@ -0,0 +1,119 @@
+package com.github.dockerjava.transport;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.Socket;
+import java.net.SocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * A {@link Socket} whose streams are backed by a child process that bridges stdin/stdout to the
+ * Docker daemon socket inside a WSL Containers (wslc) virtual machine, via
+ * {@code wslc system session run docker system dial-stdio}.
+ *
+ * wslc exposes neither a Windows named pipe nor a TCP port on the host, so this stdio bridge (the
+ * same mechanism used by {@code DOCKER_HOST=ssh://}) is the only host-visible channel to the daemon.
+ * Supplying a {@link Socket} is sufficient: Apache HttpClient5 — including the hijacked
+ * exec/attach/log streams — drives it like any other socket.
+ *
+ * The executable is taken from the {@code wslc://} URL path if present (e.g. {@code wslc:///wslc.exe}),
+ * otherwise from the {@code WSLC_EXECUTABLE} environment variable, otherwise {@code wslc.exe}.
+ */
+public class WslcSocket extends Socket {
+
+ // java.util.logging keeps this transport module dependency-free (it uses no slf4j).
+ private static final Logger LOGGER = Logger.getLogger(WslcSocket.class.getName());
+
+ private final String executable;
+
+ private volatile Process process;
+
+ public WslcSocket(String dockerHostPath) {
+ this.executable = resolveExecutable(dockerHostPath);
+ }
+
+ static String resolveExecutable(String dockerHostPath) {
+ if (dockerHostPath == null || dockerHostPath.trim().isEmpty() || "/".equals(dockerHostPath)) {
+ String fromEnv = System.getenv("WSLC_EXECUTABLE");
+ return fromEnv != null && !fromEnv.trim().isEmpty() ? fromEnv : "wslc.exe";
+ }
+ // A leading slash comes from a URL like wslc:///wslc.exe -- strip it for the executable name.
+ return dockerHostPath.startsWith("/") ? dockerHostPath.substring(1) : dockerHostPath;
+ }
+
+ @Override
+ public void connect(SocketAddress endpoint) throws IOException {
+ connect(endpoint, 0);
+ }
+
+ @Override
+ public void connect(SocketAddress endpoint, int timeout) throws IOException {
+ // The connect timeout is not applied to the bridge spawn: ProcessBuilder.start() returns
+ // immediately and the daemon stream has no socket-level read timeout (see class javadoc).
+ List command = new ArrayList<>();
+ command.add(executable);
+ command.add("system");
+ command.add("session");
+ command.add("run");
+ command.add("docker");
+ command.add("system");
+ command.add("dial-stdio");
+
+ ProcessBuilder processBuilder = new ProcessBuilder(command);
+ processBuilder.redirectErrorStream(false);
+ try {
+ process = processBuilder.start();
+ } catch (IOException e) {
+ throw new IOException("Failed to start wslc Docker bridge " + command + ": " + e.getMessage(), e);
+ }
+
+ // Drain the bridge's stderr so a full pipe buffer can never block the daemon stream, and log
+ // it at FINE so connection failures (e.g. dial-stdio cannot reach the daemon) are diagnosable.
+ final Process startedProcess = process;
+ Thread stderrDrain = new Thread(() -> {
+ byte[] buffer = new byte[1024];
+ try (InputStream stderr = startedProcess.getErrorStream()) {
+ int read;
+ while ((read = stderr.read(buffer)) != -1) {
+ if (read > 0 && LOGGER.isLoggable(Level.FINE)) {
+ LOGGER.fine("wslc bridge stderr: " + new String(buffer, 0, read, StandardCharsets.UTF_8).trim());
+ }
+ }
+ } catch (IOException ignored) {
+ // process gone
+ }
+ }, "wslc-bridge-stderr");
+ stderrDrain.setDaemon(true);
+ stderrDrain.start();
+ }
+
+ @Override
+ public InputStream getInputStream() throws IOException {
+ if (process == null) {
+ throw new IOException("Socket is not connected");
+ }
+ // stdout of the bridge process = bytes coming back from the Docker daemon socket
+ return process.getInputStream();
+ }
+
+ @Override
+ public OutputStream getOutputStream() throws IOException {
+ if (process == null) {
+ throw new IOException("Socket is not connected");
+ }
+ // stdin of the bridge process = bytes written to the Docker daemon socket
+ return process.getOutputStream();
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (process != null) {
+ process.destroyForcibly();
+ }
+ }
+}
diff --git a/docker-java/src/test/java/com/github/dockerjava/core/WslcLifecycleDockerHttpClientTest.java b/docker-java/src/test/java/com/github/dockerjava/core/WslcLifecycleDockerHttpClientTest.java
new file mode 100644
index 000000000..3d2eb0497
--- /dev/null
+++ b/docker-java/src/test/java/com/github/dockerjava/core/WslcLifecycleDockerHttpClientTest.java
@@ -0,0 +1,129 @@
+package com.github.dockerjava.core;
+
+import com.github.dockerjava.transport.DockerHttpClient;
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Unit tests for {@link WslcLifecycleDockerHttpClient} that exercise its request routing and
+ * fallback behaviour without a real daemon or a real {@code wslc} CLI. Lifecycle calls are forced
+ * to fail deterministically by pointing the decorator at a non-existent executable, which is enough
+ * to assert that non-lifecycle traffic is delegated untouched and that failures degrade gracefully.
+ */
+public class WslcLifecycleDockerHttpClientTest {
+
+ private static final String MISSING_WSLC = "no-such-wslc-binary-xyz";
+
+ /** A recording delegate that returns a fixed response and captures the last request it saw. */
+ private static final class RecordingDelegate implements DockerHttpClient {
+ final AtomicReference last = new AtomicReference<>();
+ private final int status;
+ private final byte[] body;
+
+ RecordingDelegate(int status, String body) {
+ this.status = status;
+ this.body = body.getBytes(StandardCharsets.UTF_8);
+ }
+
+ @Override
+ public Response execute(Request request) {
+ last.set(request);
+ return new Response() {
+ @Override public int getStatusCode() {
+ return status;
+ }
+ @Override public Map> getHeaders() {
+ return Collections.emptyMap();
+ }
+ @Override public InputStream getBody() {
+ return new ByteArrayInputStream(body);
+ }
+ @Override public void close() {
+ }
+ };
+ }
+
+ @Override
+ public void close() {
+ }
+ }
+
+ private static String read(DockerHttpClient.Response response) throws Exception {
+ try (InputStream in = response.getBody()) {
+ java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
+ byte[] buf = new byte[1024];
+ int n;
+ while ((n = in.read(buf)) != -1) {
+ bos.write(buf, 0, n);
+ }
+ return new String(bos.toByteArray(), StandardCharsets.UTF_8);
+ }
+ }
+
+ @Test
+ public void nonLifecycleRequestIsDelegatedUnchanged() throws Exception {
+ RecordingDelegate delegate = new RecordingDelegate(200, "[{\"Id\":\"img\"}]");
+ WslcLifecycleDockerHttpClient client = new WslcLifecycleDockerHttpClient(delegate, MISSING_WSLC);
+
+ DockerHttpClient.Request req = DockerHttpClient.Request.builder()
+ .method(DockerHttpClient.Request.Method.GET)
+ .path("/images/json")
+ .headers(Collections.emptyMap())
+ .build();
+ DockerHttpClient.Response resp = client.execute(req);
+
+ assertEquals(200, resp.getStatusCode());
+ assertEquals("[{\"Id\":\"img\"}]", read(resp));
+ assertNotNull("delegate should have been invoked", delegate.last.get());
+ assertEquals("/images/json", delegate.last.get().path());
+ }
+
+ @Test
+ public void inspectFallsBackToDaemonBodyWhenWslcUnavailable() throws Exception {
+ String inspect = "{\"Id\":\"abc123\",\"NetworkSettings\":{\"Ports\":{}}}";
+ RecordingDelegate delegate = new RecordingDelegate(200, inspect);
+ WslcLifecycleDockerHttpClient client = new WslcLifecycleDockerHttpClient(delegate, MISSING_WSLC);
+
+ DockerHttpClient.Request req = DockerHttpClient.Request.builder()
+ .method(DockerHttpClient.Request.Method.GET)
+ .path("/containers/abc123/json")
+ .headers(Collections.emptyMap())
+ .build();
+ DockerHttpClient.Response resp = client.execute(req);
+
+ // wslc list cannot run, so the port override is skipped and the daemon's body is returned as-is.
+ assertEquals(200, resp.getStatusCode());
+ assertEquals(inspect, read(resp));
+ }
+
+ @Test
+ public void createReturns500WhenWslcCannotRun() throws Exception {
+ RecordingDelegate delegate = new RecordingDelegate(201, "{}");
+ WslcLifecycleDockerHttpClient client = new WslcLifecycleDockerHttpClient(delegate, MISSING_WSLC);
+
+ DockerHttpClient.Request req = DockerHttpClient.Request.builder()
+ .method(DockerHttpClient.Request.Method.POST)
+ .path("/containers/create?name=demo")
+ .headers(Collections.emptyMap())
+ .bodyBytes("{\"Image\":\"busybox\"}".getBytes(StandardCharsets.UTF_8))
+ .build();
+ DockerHttpClient.Response resp = client.execute(req);
+
+ // The create is routed to `wslc create`; with no wslc on PATH it fails and surfaces as a 500,
+ // and it must NOT have been forwarded to the daemon delegate.
+ assertEquals(500, resp.getStatusCode());
+ assertTrue(read(resp).contains("wslc create failed"));
+ assertEquals("delegate must not receive the create", null, delegate.last.get());
+ }
+}
diff --git a/docker-java/src/test/java/com/github/dockerjava/transport/WslcSocketTest.java b/docker-java/src/test/java/com/github/dockerjava/transport/WslcSocketTest.java
new file mode 100644
index 000000000..0d2b02d53
--- /dev/null
+++ b/docker-java/src/test/java/com/github/dockerjava/transport/WslcSocketTest.java
@@ -0,0 +1,50 @@
+package com.github.dockerjava.transport;
+
+import org.junit.Test;
+
+import java.io.IOException;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class WslcSocketTest {
+
+ @Test
+ public void resolveExecutableDefaultsConsistentlyForEmptyOrRootPath() {
+ // wslc:// -> null/empty path, wslc:/// -> "/" path: all resolve identically (to WSLC_EXECUTABLE
+ // if set, otherwise "wslc.exe"). Assert consistency unconditionally and the default only when
+ // the environment does not override it, so the test does not depend on WSLC_EXECUTABLE.
+ String fromNull = WslcSocket.resolveExecutable(null);
+ assertEquals(fromNull, WslcSocket.resolveExecutable(""));
+ assertEquals(fromNull, WslcSocket.resolveExecutable("/"));
+ if (System.getenv("WSLC_EXECUTABLE") == null) {
+ assertEquals("wslc.exe", fromNull);
+ }
+ }
+
+ @Test
+ public void resolveExecutableStripsLeadingSlashFromUrlPath() {
+ // wslc:///wslc.exe -> path "/wslc.exe"
+ assertEquals("wslc.exe", WslcSocket.resolveExecutable("/wslc.exe"));
+ }
+
+ @Test
+ public void resolveExecutableKeepsAbsoluteWindowsPath() {
+ assertEquals("C:\\Program Files\\WSL\\wslc.exe",
+ WslcSocket.resolveExecutable("C:\\Program Files\\WSL\\wslc.exe"));
+ }
+
+ @Test
+ public void connectWithMissingExecutableThrowsIoException() throws Exception {
+ WslcSocket socket = new WslcSocket("no-such-wslc-binary-xyz");
+ try {
+ socket.connect(null, 0);
+ fail("expected IOException for a missing bridge executable");
+ } catch (IOException e) {
+ assertTrue(e.getMessage(), e.getMessage().contains("no-such-wslc-binary-xyz"));
+ } finally {
+ socket.close();
+ }
+ }
+}