From 2eefc831faf1900811f9b49ba4e21ef47e0e2989 Mon Sep 17 00:00:00 2001 From: David Tavoularis Date: Fri, 17 Jul 2026 07:32:14 +0200 Subject: [PATCH] feat(wslc): add WSL Containers (wslc://) transport, lifecycle integration and auto-detection WSL Containers (wslc) runs dockerd inside a lightweight VM that exposes no Windows named pipe or TCP port; the only host-visible channel is the stdio bridge `wslc system session run docker system dial-stdio`. This adds first-class wslc support to docker-java. Transport (docker-java-transport, -transport-httpclient5): - WslcSocket: a Socket whose streams are the wslc dial-stdio child process (mirrors NamedPipeSocket; java.util.logging only, no new dependency). - ApacheDockerHttpClientImpl: a `wslc` scheme case plus a WslcSocket branch. Hijacked exec/attach/log streams work unchanged at the HttpClient5 socket level. Lifecycle integration (docker-java-core): - wslc's Windows integration (127.0.0.1 port relay and Windows-path bind mounts) is wired by the wslc control plane only when a container is created/started through the native `wslc` CLI, not over the Docker API on the same dockerd. - WslcLifecycleDockerHttpClient decorates a Docker-API client and reconciles just the affected calls with the wslc CLI, delegating everything else unchanged: POST /containers/create -> wslc create, POST /containers/{id}/start -> wslc start (wires relay+bind), POST /networks/create -> wslc network create, and GET /containers/{id}/json with NetworkSettings.Ports overridden from `wslc list` (the start relay's real host port differs from the daemon's recorded one). - Activated transparently in DockerClientImpl.getInstance only when the host scheme is wslc, so unix/npipe/tcp are unaffected and no extra dependency is needed. Consumers that shade docker-java-core (e.g. Testcontainers) pick it up after a plain rebuild. Auto-detection (DefaultDockerClientConfig): - when DOCKER_HOST is unset, fall back to wslc://localhost on Windows only if the //./pipe/docker_engine pipe is absent and wslc is available (`wslc version` exit 0, WSLC_EXECUTABLE-overridable), so Docker Desktop/Podman keep winning. Tests run without a daemon or wslc CLI: WslcSocketTest (executable resolution and connect failure) and WslcLifecycleDockerHttpClientTest (request pass-through, inspect fallback when wslc is unavailable, create-failure handling). Refs: #2658 Signed-off-by: David Tavoularis Co-authored-by: Claude --- .../core/DefaultDockerClientConfig.java | 62 +- .../dockerjava/core/DockerClientImpl.java | 13 +- .../core/WslcLifecycleDockerHttpClient.java | 541 ++++++++++++++++++ .../ApacheDockerHttpClientImpl.java | 4 + .../dockerjava/transport/WslcSocket.java | 119 ++++ .../WslcLifecycleDockerHttpClientTest.java | 129 +++++ .../dockerjava/transport/WslcSocketTest.java | 50 ++ 7 files changed, 916 insertions(+), 2 deletions(-) create mode 100644 docker-java-core/src/main/java/com/github/dockerjava/core/WslcLifecycleDockerHttpClient.java create mode 100644 docker-java-transport/src/main/java/com/github/dockerjava/transport/WslcSocket.java create mode 100644 docker-java/src/test/java/com/github/dockerjava/core/WslcLifecycleDockerHttpClientTest.java create mode 100644 docker-java/src/test/java/com/github/dockerjava/transport/WslcSocketTest.java 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(); + } + } +}