DockerEmulatorSupport.java
package com.cloudforge.core.local;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* Shared Docker CLI helpers for local emulator containers.
*/
public final class DockerEmulatorSupport {
/** @deprecated use {@link LocalEmulatorDefaults#DOCKER_NETWORK} */
@Deprecated
public static final String DEFAULT_NETWORK = LocalEmulatorDefaults.DOCKER_NETWORK;
/** @deprecated use {@link LocalEmulatorDefaults#GATEWAY_PORT} */
@Deprecated
public static final int DEFAULT_GATEWAY_PORT = LocalEmulatorDefaults.GATEWAY_PORT;
private static final Duration DEFAULT_COMMAND_TIMEOUT = Duration.ofMinutes(3);
private static final Duration DEFAULT_HEALTH_TIMEOUT = Duration.ofMinutes(2);
private DockerEmulatorSupport() {
}
public static void ensureNetwork(String networkName) throws IOException {
try {
run(List.of("docker", "network", "inspect", networkName), DEFAULT_COMMAND_TIMEOUT);
} catch (IOException ignored) {
run(List.of("docker", "network", "create", networkName), DEFAULT_COMMAND_TIMEOUT);
}
}
/**
* Returns the primary Docker network for a running container, preferring {@code cfc-network}
* or any network whose name contains {@code cfc} (e.g. compose project networks).
*/
public static String primaryContainerNetwork(String containerName) throws IOException {
if (!containerExists(containerName)) {
return null;
}
List<String> networks = capture(List.of(
"docker", "inspect", "-f",
"{{range $k,$v := .NetworkSettings.Networks}}{{$k}}\n{{end}}",
containerName));
if (networks.isEmpty()) {
return null;
}
for (String network : networks) {
if (LocalEmulatorDefaults.DOCKER_NETWORK.equals(network)) {
return network;
}
}
for (String network : networks) {
if (network.contains("cfc")) {
return network;
}
}
return networks.get(0);
}
public static void connectNetwork(String containerName, String networkName) throws IOException {
if (networkName == null || networkName.isBlank()) {
return;
}
try {
run(List.of("docker", "network", "connect", networkName, containerName), DEFAULT_COMMAND_TIMEOUT);
} catch (IOException e) {
String message = e.getMessage() == null ? "" : e.getMessage().toLowerCase();
if (message.contains("already exists") || message.contains("already connected")) {
return;
}
throw e;
}
}
public static boolean containerExists(String containerName) throws IOException {
List<String> output = capture(List.of(
"docker", "ps", "-a", "--filter", "name=^" + containerName + "$",
"--format", "{{.Names}}"));
return output.stream().anyMatch(line -> containerName.equals(line.trim()));
}
public static boolean isContainerRunning(String containerName) throws IOException {
if (!containerExists(containerName)) {
return false;
}
List<String> output = capture(List.of(
"docker", "inspect", "-f", "{{.State.Running}}", containerName));
return !output.isEmpty() && "true".equalsIgnoreCase(output.get(0).trim());
}
public static void stopContainer(String containerName) throws IOException {
if (!isContainerRunning(containerName)) {
return;
}
run(List.of("docker", "stop", containerName), DEFAULT_COMMAND_TIMEOUT);
}
public static void startExistingContainer(String containerName) throws IOException {
run(List.of("docker", "start", containerName), DEFAULT_COMMAND_TIMEOUT);
}
public static void removeContainer(String containerName) throws IOException {
if (!containerExists(containerName)) {
return;
}
stopContainer(containerName);
run(List.of("docker", "rm", containerName), DEFAULT_COMMAND_TIMEOUT);
}
/**
* Removes emulator-owned workload containers left behind when their LocalStack
* control plane has been stopped. Names are filtered in-process rather than
* passed as a Docker wildcard so unrelated containers are never targeted.
*
* @return the removed container names
*/
public static List<String> removeContainersWithPrefix(String prefix) throws IOException {
List<String> names = containerNamesWithPrefix(capture(List.of(
"docker", "ps", "-a", "--format", "{{.Names}}")), prefix);
for (String name : names) {
removeContainer(name);
}
return names;
}
static List<String> containerNamesWithPrefix(List<String> names, String prefix) {
if (prefix == null || prefix.isBlank()) {
return List.of();
}
return names.stream()
.map(String::trim)
.filter(name -> name.startsWith(prefix))
.toList();
}
public static void pullImage(String image) throws IOException {
run(List.of("docker", "pull", image), Duration.ofMinutes(10));
}
public static void runDetached(List<String> dockerRunCommand) throws IOException {
if (dockerRunCommand.isEmpty() || !"run".equals(dockerRunCommand.get(0))) {
throw new IllegalArgumentException("Expected docker run command arguments");
}
List<String> command = new ArrayList<>();
command.add("docker");
command.addAll(dockerRunCommand);
run(command, DEFAULT_COMMAND_TIMEOUT);
}
public static void waitForHealthy(URI healthEndpoint) throws IOException {
waitForHealthy(healthEndpoint, DEFAULT_HEALTH_TIMEOUT);
}
public static void waitForHealthy(URI healthEndpoint, Duration timeout) throws IOException {
Instant deadline = Instant.now().plus(timeout);
while (Instant.now().isBefore(deadline)) {
if (isHttpHealthy(healthEndpoint)) {
return;
}
sleep(Duration.ofMillis(500));
}
throw new IOException("Timed out waiting for healthy emulator at " + healthEndpoint);
}
public static boolean isHttpHealthy(URI uri) {
try {
HttpResponse<Void> response = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(3)).GET().build(),
HttpResponse.BodyHandlers.discarding());
return response.statusCode() >= 200 && response.statusCode() < 300;
} catch (Exception ignored) {
return false;
}
}
public static void run(List<String> command, Duration timeout) throws IOException {
Process process = startOrExplain(command);
String output;
try {
if (!process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
process.destroyForcibly();
throw new IOException("Command timed out: " + String.join(" ", command));
}
output = new String(process.getInputStream().readAllBytes());
if (process.exitValue() != 0) {
throw new IOException(
"Command failed (" + process.exitValue() + "): "
+ String.join(" ", command)
+ (output.isBlank() ? "" : "\n" + output.trim()));
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
process.destroyForcibly();
throw new IOException("Interrupted while running: " + String.join(" ", command), e);
}
}
public static List<String> capture(List<String> command) throws IOException {
Process process = startOrExplain(command);
try {
if (!process.waitFor(DEFAULT_COMMAND_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)) {
process.destroyForcibly();
throw new IOException("Command timed out: " + String.join(" ", command));
}
String output = new String(process.getInputStream().readAllBytes());
if (process.exitValue() != 0) {
return List.of();
}
return output.lines().map(String::trim).filter(line -> !line.isEmpty()).toList();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
process.destroyForcibly();
throw new IOException("Interrupted while running: " + String.join(" ", command), e);
}
}
/**
* {@link ProcessBuilder#start()} throws a raw {@code "Cannot run program \"docker\": ... No
* such file or directory"} {@link IOException} when the {@code docker} binary itself isn't on
* {@code PATH} — exactly what happens when this runs inside CloudForge Manager's own runtime
* container (built from a bare {@code eclipse-temurin} JRE image with no Docker CLI installed
* and no access to the host's Docker socket), as opposed to the CLI (cfc-testing), which runs
* directly on the developer's host where Docker is present. Re-thrown as-is that error reads
* like an internal bug; wrapping it here gives every caller (companion Postgres/edge/emulator
* containers, host-port probing, etc.) the same actionable message instead of duplicating this
* check at each call site.
*/
private static Process startOrExplain(List<String> command) throws IOException {
try {
return new ProcessBuilder(command).redirectErrorStream(true).start();
} catch (IOException e) {
String message = e.getMessage() == null ? "" : e.getMessage();
if (message.contains("No such file or directory") || message.contains("Cannot run program")) {
throw new IOException(
"Docker CLI is not available in this runtime (needed to run: "
+ String.join(" ", command) + "). This operation manages a local Docker "
+ "container for the LocalStack/MiniStack emulator and only works where "
+ "the Docker CLI is on PATH with access to the Docker daemon — e.g. the "
+ "cfc-testing CLI running directly on your host. It cannot run from inside "
+ "CloudForge Manager's own container, which has neither.", e);
}
throw e;
}
}
private static void sleep(Duration duration) throws IOException {
try {
Thread.sleep(duration.toMillis());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted while waiting", e);
}
}
}