LocalEmulatorHostPortProbe.java
package com.cloudforge.core.local;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Detects host ports already used by local emulator ECS tasks (Docker) or stack outputs (CFN).
*/
public final class LocalEmulatorHostPortProbe {
private static final Pattern DOCKER_HOST_PORT =
Pattern.compile("0\\.0\\.0\\.0:(\\d+)->");
private static final Pattern STACK_MEMBER =
Pattern.compile("<member>(.*?)</member>", Pattern.DOTALL);
private static final Pattern STACK_NAME =
Pattern.compile("<StackName>([^<]+)</StackName>");
private static final Pattern STACK_STATUS =
Pattern.compile("<StackStatus>([^<]+)</StackStatus>");
private static final Pattern OUTPUT_PAIR =
Pattern.compile(
"<OutputKey>(MiniStackApplicationUrl|LocalStackApplicationUrl)</OutputKey>\\s*"
+ "<OutputValue>http://localhost:(\\d+)[^<]*</OutputValue>");
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
private static final String DOCKER_EXECUTABLE = resolveExecutable("docker");
private LocalEmulatorHostPortProbe() {
}
/**
* Resolves {@code name} to an absolute path by searching {@code PATH} — running an
* executable by its bare name lets whatever comes first on PATH win, which on a compromised
* PATH could be an attacker-controlled binary instead of the real {@code docker}. Falls back
* to the bare name if not found anywhere on PATH: probeDocker() below already treats a failed
* launch as "no Docker available" rather than a hard error, so this preserves that behavior
* in environments without Docker at all (e.g. most CI runners).
*/
private static String resolveExecutable(String name) {
String path = System.getenv("PATH");
if (path != null) {
for (String dir : path.split(java.io.File.pathSeparator)) {
java.io.File candidate = new java.io.File(dir, name);
if (candidate.isFile() && candidate.canExecute()) {
return candidate.getAbsolutePath();
}
}
}
return name;
}
public static List<LocalHostPortOccupant> probe(URI endpoint, DeploymentTarget target)
throws IOException {
Map<Integer, LocalHostPortOccupant> byPort = new LinkedHashMap<>();
merge(byPort, probeDocker(target));
merge(byPort, probeCloudFormationBestEffort(endpoint, target));
return List.copyOf(byPort.values());
}
private static List<LocalHostPortOccupant> probeCloudFormationBestEffort(
URI endpoint, DeploymentTarget target) {
try {
return probeCloudFormation(endpoint, target);
} catch (IOException e) {
// Same best-effort reasoning as probeDocker()'s missing-binary case above: no local
// CloudFormation emulator reachable at `endpoint` (connection refused, timed out, or
// an error response) just means there's nothing there to report port occupancy for —
// e.g. a CI runner or a fresh dev machine with no MiniStack/LocalStack emulator
// started yet — not a reason to hard-fail an otherwise-valid preflight check.
return List.of();
}
}
static List<LocalHostPortOccupant> probeDocker(DeploymentTarget target) throws IOException {
String prefix = switch (target) {
case MINISTACK -> "ministack-ecs-";
case LOCALSTACK -> "ls-ecs-";
case AWS -> throw new IllegalArgumentException("AWS is not a local emulator target");
};
ProcessBuilder builder = new ProcessBuilder(
DOCKER_EXECUTABLE, "ps", "--format", "{{.Names}}\t{{.Ports}}");
builder.redirectErrorStream(true);
Process process;
try {
process = builder.start();
} catch (IOException e) {
// This is a best-effort port-conflict warning, not a hard requirement -- a caller
// running where the `docker` binary itself isn't on PATH (e.g. Manager's own
// container, deploying to LocalStack via deploy:create -- it has no Docker CLI or
// socket access, by design of its own image) shouldn't have this checked exception
// propagate up and hard-fail the entire deploy attempt, when CloudFormation-side port
// info from probeCloudFormation() below is still fully available on its own.
// "Cannot run program" is exactly what ProcessBuilder#start throws for a missing
// executable (as opposed to the command running and failing, which still throws IOException
// during output reading below and should keep propagating — only absence is swallowed here).
return List.of();
}
List<LocalHostPortOccupant> occupants = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (!line.startsWith(prefix)) {
continue;
}
String name = line.substring(0, line.indexOf('\t'));
String ports = line.substring(line.indexOf('\t') + 1);
Matcher matcher = DOCKER_HOST_PORT.matcher(ports);
while (matcher.find()) {
int port = Integer.parseInt(matcher.group(1));
String stackHint = inferStackNameFromContainer(name, target);
occupants.add(new LocalHostPortOccupant(
stackHint,
port,
"running container " + name));
}
}
int exit = process.waitFor();
if (exit != 0 && occupants.isEmpty()) {
return List.of();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("docker ps interrupted", e);
}
return occupants;
}
static List<LocalHostPortOccupant> probeCloudFormation(URI endpoint, DeploymentTarget target)
throws IOException {
String suffix = switch (target) {
case MINISTACK -> "-ministack";
case LOCALSTACK -> "-localstack";
case AWS -> throw new IllegalArgumentException("AWS is not a local emulator target");
};
String listXml = cloudFormationRequest(endpoint, "ListStacks");
List<String> stackNames = new ArrayList<>();
Matcher memberMatcher = STACK_MEMBER.matcher(listXml);
while (memberMatcher.find()) {
String member = memberMatcher.group(1);
Matcher nameMatcher = STACK_NAME.matcher(member);
Matcher statusMatcher = STACK_STATUS.matcher(member);
if (!nameMatcher.find() || !statusMatcher.find()) {
continue;
}
String name = nameMatcher.group(1);
String status = statusMatcher.group(1);
if ("CREATE_COMPLETE".equals(status) && name.endsWith(suffix)) {
stackNames.add(name);
}
}
List<LocalHostPortOccupant> occupants = new ArrayList<>();
for (String stackName : stackNames) {
String describeXml = cloudFormationRequest(
endpoint,
"DescribeStacks&StackName=" + urlEncode(stackName));
Matcher outputMatcher = OUTPUT_PAIR.matcher(describeXml);
while (outputMatcher.find()) {
int port = Integer.parseInt(outputMatcher.group(2));
occupants.add(new LocalHostPortOccupant(
stackName,
port,
"stack output " + outputMatcher.group(1)));
}
}
return occupants;
}
static String inferStackNameFromContainer(String containerName, DeploymentTarget target) {
String marker = "FargateContainer";
int end = containerName.indexOf(marker);
if (end < 0) {
return containerName;
}
String body = containerName.substring(0, end);
String prefix = switch (target) {
case MINISTACK -> "ministack-ecs-";
case LOCALSTACK -> "ls-ecs-";
case AWS -> throw new IllegalArgumentException("AWS is not a local emulator target");
};
if (!body.startsWith(prefix)) {
return containerName;
}
String remainder = body.substring(prefix.length());
int dash = remainder.indexOf('-');
if (dash == 8 && isHex8(remainder.substring(0, 8))) {
remainder = remainder.substring(dash + 1);
}
String suffix = switch (target) {
case MINISTACK -> "-ministack";
case LOCALSTACK -> "-localstack";
case AWS -> throw new IllegalArgumentException("AWS is not a local emulator target");
};
return remainder + suffix;
}
private static boolean isHex8(String segment) {
if (segment.length() != 8) {
return false;
}
for (int i = 0; i < 8; i++) {
if (!isHex(segment.charAt(i))) {
return false;
}
}
return true;
}
private static boolean isHex(char ch) {
return (ch >= '0' && ch <= '9')
|| (ch >= 'a' && ch <= 'f')
|| (ch >= 'A' && ch <= 'F');
}
private static void merge(
Map<Integer, LocalHostPortOccupant> byPort,
List<LocalHostPortOccupant> additions) {
for (LocalHostPortOccupant occupant : additions) {
byPort.putIfAbsent(occupant.hostPort(), occupant);
}
}
private static String cloudFormationRequest(URI endpoint, String query) throws IOException {
URI uri = URI.create(endpoint.toString().replaceAll("/$", "")
+ "/?Action=" + query + "&Version=2010-05-15");
HttpRequest request = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(5))
.GET()
.build();
try {
HttpResponse<String> response = HTTP.send(
request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() >= 400) {
throw new IOException("CloudFormation probe failed: HTTP " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("CloudFormation probe interrupted", e);
}
}
private static String urlEncode(String value) {
return java.net.URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}