DefaultEmulatorEdgeRuntime.java
package com.cloudforge.core.local;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.logging.Logger;
/**
* Dockerized nginx edge for {@code *.cloudforge.localhost} Host routing.
*/
public final class DefaultEmulatorEdgeRuntime implements EmulatorEdgeRuntime {
private static final Logger LOG = Logger.getLogger(DefaultEmulatorEdgeRuntime.class.getName());
private static final Pattern HOST_PUBLISH =
Pattern.compile("(\\d+)->(\\d+)/tcp");
private static final Pattern JENKINS_OPTS_PREFIX =
Pattern.compile("--prefix=([^\\s]+)");
private static final Map<Integer, String> CONTAINER_PORT_TO_HOST = Map.ofEntries(
Map.entry(1958, LocalEmulatorDefaults.HOST_MANAGER),
Map.entry(80, "gitlab.cloudforge.localhost"),
Map.entry(8080, "jenkins.cloudforge.localhost"),
Map.entry(3000, "grafana.cloudforge.localhost"),
Map.entry(8065, "mattermost.cloudforge.localhost"),
Map.entry(9090, "prometheus.cloudforge.localhost"),
Map.entry(8200, "vault.cloudforge.localhost"),
Map.entry(8081, "nexus.cloudforge.localhost"),
Map.entry(9000, "sonarqube.cloudforge.localhost"),
Map.entry(6379, "redis.cloudforge.localhost"),
Map.entry(5432, "postgres.cloudforge.localhost"));
// Gateway :4566, StackPort :8888, and nginx status are applied in expandPlatformRoutes.
private final Path workingDirectory;
public DefaultEmulatorEdgeRuntime() {
this(Path.of("").toAbsolutePath().normalize());
}
public DefaultEmulatorEdgeRuntime(Path workingDirectory) {
this.workingDirectory = workingDirectory;
}
@Override
public String containerName() {
return LocalEmulatorDefaults.EMULATOR_EDGE_CONTAINER;
}
@Override
public URI browserUrl() {
return URI.create("http://127.0.0.1:" + hostPort() + "/");
}
@Override
public void start() throws IOException {
DockerEmulatorSupport.ensureNetwork(LocalEmulatorDefaults.DOCKER_NETWORK);
Path runtimeDir = prepareRuntimeDir();
Path nginxConf = ensureNginxConf(runtimeDir);
Path confDir = runtimeDir.resolve("conf.d");
Files.createDirectories(confDir);
if (!Files.isRegularFile(confDir.resolve("cloudforge-apps.conf"))) {
Files.writeString(confDir.resolve("cloudforge-apps.conf"),
"# Generated by EmulatorEdge reconcile\n", StandardCharsets.UTF_8);
}
reconcileRoutes(confDir);
if (DockerEmulatorSupport.isContainerRunning(containerName())) {
reload();
System.out.println("Emulator edge already running: " + browserUrl());
return;
}
if (DockerEmulatorSupport.containerExists(containerName())) {
DockerEmulatorSupport.startExistingContainer(containerName());
} else {
DockerEmulatorSupport.runDetached(dockerCreateArgs(nginxConf, confDir));
}
DockerEmulatorSupport.waitForHealthy(browserUrl());
System.out.println("Emulator edge started: " + browserUrl());
System.out.println(" Hostnames: docs/guides/LOCAL_EMULATOR_HOSTS.md "
+ "(./scripts/setup-cloudforge-local-hosts.sh)");
System.out.println(" StackPort (simulated console): http://127.0.0.1:"
+ LocalEmulatorDefaults.STACKPORT_HOST_PORT);
}
@Override
public void stop() throws IOException {
if (DockerEmulatorSupport.containerExists(containerName())) {
DockerEmulatorSupport.removeContainer(containerName());
System.out.println("Emulator edge stopped");
}
}
@Override
public void rebuild() throws IOException {
stop();
DockerEmulatorSupport.pullImage(LocalEmulatorDefaults.EMULATOR_EDGE_IMAGE);
start();
System.out.println("Emulator edge rebuilt");
}
@Override
public void reload() throws IOException {
if (!isRunning()) {
return;
}
DockerEmulatorSupport.run(
List.of("docker", "exec", containerName(), "nginx", "-t"),
java.time.Duration.ofSeconds(30));
DockerEmulatorSupport.run(
List.of("docker", "exec", containerName(), "nginx", "-s", "reload"),
java.time.Duration.ofSeconds(30));
System.out.println("Emulator edge reloaded");
}
@Override
public Map<String, Integer> reconcile() throws IOException {
Path runtimeDir = prepareRuntimeDir();
Path confDir = runtimeDir.resolve("conf.d");
Files.createDirectories(confDir);
Map<String, Integer> routes = reconcileRoutes(confDir);
if (isRunning()) {
// Avoid double "reloaded" log from reload() during reconcile spam
DockerEmulatorSupport.run(
List.of("docker", "exec", containerName(), "nginx", "-t"),
java.time.Duration.ofSeconds(30));
DockerEmulatorSupport.run(
List.of("docker", "exec", containerName(), "nginx", "-s", "reload"),
java.time.Duration.ofSeconds(30));
}
System.out.println("Emulator edge reconciled " + routes.size() + " vhost(s)");
routes.forEach((host, port) ->
System.out.println(" http://" + host + "/ → host.docker.internal:" + port));
return routes;
}
@Override
public boolean isRunning() throws IOException {
return DockerEmulatorSupport.isContainerRunning(containerName());
}
@Override
public boolean isHealthy() {
return DockerEmulatorSupport.isHttpHealthy(browserUrl());
}
/**
* Parses {@code docker ps} lines of the form {@code name\tports}.
* Skips StackPort / edge containers so their internal :8080 does not steal Jenkins.
*/
static Map<String, Integer> parseDockerPortPublishes(List<String> nameAndPortLines) {
return parseDockerPortPublishes(nameAndPortLines, Map.of());
}
/**
* @param edgeHostnameOverrides container name → {@code CFC_LOCALSTACK_EDGE_HOSTNAME} (see
* {@code LocalStackTemplateAdapter}), when that container was
* deployed with a {@code subdomain}. Takes priority over {@link
* #hostnameForContainer} so two instances of the same application
* (e.g. two Jenkins stacks, {@code jenkins1}/{@code jenkins2}) get
* distinct vhosts instead of colliding on one shared hostname.
*/
static Map<String, Integer> parseDockerPortPublishes(
List<String> nameAndPortLines, Map<String, String> edgeHostnameOverrides) {
Map<String, Integer> routes = new LinkedHashMap<>();
Map<String, String> overrides = edgeHostnameOverrides == null ? Map.of() : edgeHostnameOverrides;
for (String line : nameAndPortLines) {
if (line == null || line.isBlank()) {
continue;
}
String name;
String ports;
int tab = line.indexOf('\t');
if (tab >= 0) {
name = line.substring(0, tab).trim();
ports = line.substring(tab + 1);
} else {
name = "";
ports = line;
}
if (isCompanionContainer(name)) {
if (isStackPortContainer(name)) {
Matcher matcher = HOST_PUBLISH.matcher(ports);
while (matcher.find()) {
int hostPort = parsePort(matcher.group(1));
int containerPort = parsePort(matcher.group(2));
if (hostPort < 0 || containerPort < 0) {
continue;
}
if (containerPort == LocalEmulatorDefaults.STACKPORT_CONTAINER_PORT
|| hostPort == LocalEmulatorDefaults.STACKPORT_HOST_PORT) {
routes.putIfAbsent(LocalEmulatorDefaults.HOST_STACKPORT, hostPort);
}
}
}
continue;
}
if (name.equals(LocalEmulatorDefaults.LOCALSTACK_CONTAINER)
|| name.equals(LocalEmulatorDefaults.MINISTACK_CONTAINER)) {
Matcher matcher = HOST_PUBLISH.matcher(ports);
while (matcher.find()) {
int hostPort = parsePort(matcher.group(1));
int containerPort = parsePort(matcher.group(2));
if (hostPort < 0 || containerPort < 0) {
continue;
}
if (containerPort == LocalEmulatorDefaults.GATEWAY_PORT
|| hostPort == LocalEmulatorDefaults.GATEWAY_PORT) {
routes.put(LocalEmulatorDefaults.HOST_EMULATOR, hostPort);
if (name.equals(LocalEmulatorDefaults.LOCALSTACK_CONTAINER)) {
routes.put(LocalEmulatorDefaults.HOST_LOCALSTACK, hostPort);
} else {
routes.put(LocalEmulatorDefaults.HOST_MINISTACK, hostPort);
}
}
}
continue;
}
Matcher matcher = HOST_PUBLISH.matcher(ports);
while (matcher.find()) {
int hostPort = parsePort(matcher.group(1));
int containerPort = parsePort(matcher.group(2));
if (hostPort < 0 || containerPort < 0) {
continue;
}
String override = overrides.get(name);
String hostname = (override != null && !override.isBlank())
? override : hostnameForContainer(name, containerPort);
if (hostname != null) {
routes.merge(hostname, hostPort, (existing, candidate) ->
preferredHostPort(hostname, existing, candidate));
}
}
}
return routes;
}
/**
* Resolves ports shared by multiple applications from the LocalStack ECS container name.
* A fixed port-only map would otherwise route Metabase to Grafana (both use 3000),
* or Drone to GitLab (both use 80).
*/
static String hostnameForContainer(String containerName, int containerPort) {
String name = containerName == null ? "" : containerName.toLowerCase(Locale.ROOT);
if (containerPort == 3000) {
if (name.contains("metabase")) {
return "metabase.cloudforge.localhost";
}
if (name.contains("gitea")) {
return "gitea.cloudforge.localhost";
}
if (name.contains("grafana")) {
return "grafana.cloudforge.localhost";
}
}
if (containerPort == 80) {
if (name.contains("drone")) {
return "drone.cloudforge.localhost";
}
if (name.contains("joomla")) {
return "joomla.cloudforge.localhost";
}
if (name.contains("gitlab")) {
return "gitlab.cloudforge.localhost";
}
// The rest of the CMS/e-commerce catalog (ApplicationSpec#applicationPort) also
// listens on 80 — without its own case here each one silently fell through to
// CONTAINER_PORT_TO_HOST's gitlab default below. "woocommerce" is checked before
// "wordpress" purely for readability; WooCommerceApplicationSpec's own stack naming
// never contains "wordpress" so the order doesn't actually matter.
if (name.contains("wordpress")) {
return "wordpress.cloudforge.localhost";
}
if (name.contains("woocommerce")) {
return "woocommerce.cloudforge.localhost";
}
if (name.contains("drupal")) {
return "drupal.cloudforge.localhost";
}
if (name.contains("magento")) {
return "magento.cloudforge.localhost";
}
if (name.contains("prestashop")) {
return "prestashop.cloudforge.localhost";
}
if (name.contains("opencart")) {
return "opencart.cloudforge.localhost";
}
if (name.contains("bagisto")) {
return "bagisto.cloudforge.localhost";
}
if (name.contains("sylius")) {
return "sylius.cloudforge.localhost";
}
if (name.contains("concretecms")) {
return "concretecms.cloudforge.localhost";
}
if (name.contains("octobercms")) {
return "octobercms.cloudforge.localhost";
}
if (name.contains("typo3")) {
return "typo3.cloudforge.localhost";
}
if (name.contains("mediawiki")) {
return "mediawiki.cloudforge.localhost";
}
if (name.contains("moodle")) {
return "moodle.cloudforge.localhost";
}
if (name.contains("suitecrm")) {
return "suitecrm.cloudforge.localhost";
}
if (name.contains("mybb")) {
return "mybb.cloudforge.localhost";
}
if (name.contains("flarum")) {
return "flarum.cloudforge.localhost";
}
if (name.contains("dolphinuna")) {
return "dolphinuna.cloudforge.localhost";
}
}
return CONTAINER_PORT_TO_HOST.get(containerPort);
}
static int preferredHostPort(String hostname, int existing, int candidate) {
if (LocalEmulatorDefaults.HOST_MANAGER.equals(hostname)) {
// Use the conventional Manager port when ECS briefly has an old dynamic
// task and a replacement task at the same time. This makes the route
// independent of Docker's container listing order.
if (existing == 1958 || candidate == 1958) {
return 1958;
}
}
return Math.min(existing, candidate);
}
static boolean isCompanionContainer(String name) {
return isStackPortContainer(name)
|| name.equals(LocalEmulatorDefaults.EMULATOR_EDGE_CONTAINER);
}
static boolean isStackPortContainer(String name) {
return name.equals(LocalEmulatorDefaults.MINISTACK_STACKPORT_CONTAINER)
|| name.equals(LocalEmulatorDefaults.LOCALSTACK_STACKPORT_CONTAINER)
|| name.endsWith("-stackport");
}
static String renderNginxConf(Map<String, Integer> routes) {
return renderNginxConf(routes, Map.of());
}
/**
* @param pathPrefixes optional LocalStack ALB path prefixes per hostname
* (e.g. {@code /_aws/elb/cfc-xyz}) for applications that serve beneath one
*/
static String renderNginxConf(Map<String, Integer> routes, Map<String, String> pathPrefixes) {
StringBuilder sb = new StringBuilder();
sb.append("# Generated by DefaultEmulatorEdgeRuntime.reconcile\n\n");
sb.append(renderNginxStatusServer(routes));
if (routes.isEmpty()) {
sb.append("# No matching host ports yet — deploy an app, then reconcile.\n");
return sb.toString();
}
List<String> hosts = new ArrayList<>(routes.keySet());
hosts.sort(String::compareTo);
for (String hostname : hosts) {
if (LocalEmulatorDefaults.HOST_NGINX.equals(hostname)) {
continue;
}
int hostPort = routes.get(hostname);
boolean gateway = isGatewayHostname(hostname);
String pathPrefix = normalizePathPrefix(
pathPrefixes == null ? null : pathPrefixes.get(hostname));
// CloudForge Manager is a root-served Angular SPA. Its LocalStack ALB metadata is
// useful for the public URL but must not turn the friendly edge route into a
// prefix-rewritten (or /login-redirected) route like Jenkins.
if (LocalEmulatorDefaults.HOST_MANAGER.equals(hostname)) {
pathPrefix = null;
}
sb.append("server {\n")
.append(" listen 80;\n")
.append(" server_name ").append(serverNames(hostname)).append(";\n\n")
// Browsers request /favicon.ico; LocalStack treats unknown paths as S3 → 403/404.
.append(" location = /favicon.ico {\n")
.append(" access_log off;\n")
.append(" return 204;\n")
.append(" }\n\n");
if (gateway && LocalEmulatorDefaults.HOST_LOCALSTACK.equals(hostname)) {
appendCognitoHostedLoginStyle(sb, hostPort);
}
// Do not reserve gateway root. LocalStack's Cognito hosted-login page uses
// its browser origin as the AWS SDK endpoint and POSTs to /; an HTML landing
// response here is parsed as JSON and prevents application OIDC sign-in.
if (pathPrefix != null) {
// Pass-through when the client already uses the LocalStack ELB path.
sb.append(" location ^~ ").append(pathPrefix).append("/ {\n");
appendProxyHeaders(sb, gateway);
appendProxyPass(sb, hostPort);
appendPrefixResponseRewrites(sb, pathPrefix);
sb.append(" }\n\n");
// Jenkins-specific: an anonymous request to bare "/" under the ELB prefix gets a
// 403 + meta-refresh instead of a clean redirect, so /login is preferred instead.
// This block used to run for every path-prefixed app regardless of hostname —
// harmless while Jenkins was the only one with a real vhost, but once the rest of
// the CMS catalog (WordPress etc., see hostnameForContainer) got their own vhosts
// too, they inherited a forced "/" → "/login" redirect to a route that doesn't
// exist for them, breaking their real root path. "/login" itself is also Jenkins-
// specific (its actual login URL) — not a generic app convention.
if (hostname.contains("jenkins")) {
sb.append(" location = / {\n")
.append(" return 302 /login;\n")
.append(" }\n\n");
}
}
sb.append(" location / {\n");
appendProxyHeaders(sb, gateway);
if (pathPrefix != null && hostname.contains("jenkins")) {
// Jenkins's own --prefix flag makes it expect every request already carrying
// the ALB path prefix, so a bare "/" needs it injected before proxying.
sb.append(" rewrite ^/(.*)$ ").append(pathPrefix).append("/$1 break;\n");
appendProxyPass(sb, hostPort);
appendPrefixResponseRewrites(sb, pathPrefix);
} else if (pathPrefix != null) {
// Everything else (WordPress etc.) expects to be hit at its own root — it fakes
// the ALB-prefix illusion itself, purely for asset-URL generation (e.g.
// WordPress's WORDPRESS_CONFIG_EXTRA env var), not for request routing. Forcing
// the prefix into the request path here turned a valid "/" into a literal
// sub-path the app's own webserver can't resolve to a real file (a genuine 404,
// not proxied at all) — same root cause as the /login redirect above, this
// block used to run unconditionally too. Response-side rewrites still apply: the
// backend's own redirects/asset links legitimately carry that prefix regardless
// of whether it's injected into the request, and still need translating back to
// the friendly hostname's root.
appendProxyPass(sb, hostPort);
appendPrefixResponseRewrites(sb, pathPrefix);
} else {
appendProxyPass(sb, hostPort);
}
sb.append(" }\n")
.append("}\n\n");
}
return sb.toString();
}
private static void appendProxyPass(StringBuilder sb, int hostPort) {
sb.append(" proxy_pass http://host.docker.internal:")
.append(hostPort).append(";\n");
}
/** Apply CloudForge styling to LocalStack's otherwise unbranded Cognito hosted-login page. */
private static void appendCognitoHostedLoginStyle(StringBuilder sb, int hostPort) {
sb.append(" location ^~ /_aws/cognito-idp/login {\n");
appendProxyHeaders(sb, true);
sb.append(" proxy_set_header Accept-Encoding \"\";\n")
.append(" sub_filter_once on;\n")
.append(" sub_filter \"</head>\" ")
.append(nginxQuotedHtml("<style>" + cognitoHostedLoginCss() + "</style></head>"))
.append(";\n");
appendProxyPass(sb, hostPort);
sb.append(" }\n\n");
}
private static String cognitoHostedLoginCss() {
return "html,body{min-height:100%;background:#f3f6f8!important;color:#1f2933;font-family:Inter,"
+ "ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif}"
+ ".wrapper{width:min(28rem,calc(100% - 2rem));gap:.8rem}.wrapper:before{content:\"CloudForge Identity\";"
+ "color:#273b4a;font-weight:700;font-size:1.15rem;letter-spacing:-.02em}.box{background:#fff;"
+ "border:1px solid #dbe3e8;border-radius:12px!important;padding:1.75rem!important;"
+ "box-shadow:0 14px 34px rgba(31,41,51,.12)!important}.box h2{margin-top:0;color:#273b4a;"
+ "font-size:1.3rem;font-weight:650}.box input{width:100%;height:2.5rem;border:1px solid #c8d3da;"
+ "border-radius:6px;padding:.45rem .65rem;box-shadow:none}.box input:focus{outline:2px solid "
+ "rgba(31,123,164,.24);border-color:#1f7ba4}.box .btn-primary{background:#1f7ba4;border-color:#1f7ba4;"
+ "border-radius:6px;font-weight:600;padding:.55rem .85rem}.box .btn-primary:hover{background:#17688b;"
+ "border-color:#17688b}.box a{color:#1f7ba4}.box table{width:100%}";
}
/**
* Keep browsers on {@code http://jenkins.cloudforge.localhost/...} while Jetty still sees
* the ELB prefix.
*/
private static void appendPrefixResponseRewrites(StringBuilder sb, String pathPrefix) {
sb.append(" proxy_redirect ").append(pathPrefix).append("/ /;\n");
sb.append(" proxy_redirect https://localhost.localstack.cloud:")
.append(LocalEmulatorDefaults.GATEWAY_PORT).append(pathPrefix).append("/ /;\n");
sb.append(" proxy_redirect http://localhost.localstack.cloud:")
.append(LocalEmulatorDefaults.GATEWAY_PORT).append(pathPrefix).append("/ /;\n");
// Jenkins embeds the prefix in HTML (data-rooturl, resURL, meta refresh).
sb.append(" proxy_set_header Accept-Encoding \"\";\n");
sb.append(" sub_filter_types application/javascript text/css;\n");
sb.append(" sub_filter_once off;\n");
sb.append(" sub_filter \"").append(pathPrefix).append("\" \"\";\n");
}
private static void appendProxyHeaders(StringBuilder sb, boolean gateway) {
sb.append(" proxy_http_version 1.1;\n")
// Gateway must not forward custom Host (LocalStack S3 virtual-host → 403).
.append(" proxy_set_header Host ")
.append(gateway ? "localhost" : "$host")
.append(";\n")
.append(" proxy_set_header X-Real-IP $remote_addr;\n")
.append(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n")
.append(" proxy_set_header X-Forwarded-Proto $scheme;\n")
.append(" proxy_set_header Connection \"\";\n");
}
/** Visible for tests — {@code CFC_LOCALSTACK_ALB_PREFIX} or {@code JENKINS_OPTS --prefix=}. */
static String extractAlbPathPrefix(Iterable<String> envLines) {
if (envLines == null) {
return null;
}
String fromOpts = null;
for (String line : envLines) {
if (line == null || line.isBlank()) {
continue;
}
if (line.startsWith("CFC_LOCALSTACK_ALB_PREFIX=")) {
return normalizePathPrefix(line.substring("CFC_LOCALSTACK_ALB_PREFIX=".length()));
}
if (line.startsWith("JENKINS_OPTS=") && fromOpts == null) {
Matcher m = JENKINS_OPTS_PREFIX.matcher(line);
if (m.find()) {
fromOpts = normalizePathPrefix(m.group(1));
}
}
}
return fromOpts;
}
static String normalizePathPrefix(String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
String p = raw.trim();
if (!p.startsWith("/")) {
p = "/" + p;
}
while (p.endsWith("/") && p.length() > 1) {
p = p.substring(0, p.length() - 1);
}
return p;
}
/**
* Maps hostname → LocalStack ALB path prefix using container env for published host ports.
*/
static Map<String, String> pathPrefixesForPublishedPorts(
Map<String, Integer> routes,
Map<Integer, String> hostPortToPrefix) {
Map<String, String> result = new LinkedHashMap<>();
if (routes == null || hostPortToPrefix == null || hostPortToPrefix.isEmpty()) {
return result;
}
for (Map.Entry<String, Integer> entry : routes.entrySet()) {
String prefix = hostPortToPrefix.get(entry.getValue());
if (prefix != null && !prefix.isBlank()) {
result.put(entry.getKey(), prefix);
}
}
return result;
}
/**
* @param pathPrefixByHostPort hostPort → LocalStack ALB path prefix (unchanged behavior)
* @param edgeHostnameByContainerName container name → {@code CFC_LOCALSTACK_EDGE_HOSTNAME},
* for containers deployed with a {@code subdomain}
*/
record ContainerOverrides(
Map<Integer, String> pathPrefixByHostPort,
Map<String, String> edgeHostnameByContainerName) {
}
/**
* From {@code docker ps} lines, collect per-container overrides via a single {@code docker
* inspect} pass each: ALB path prefix (hostPort-keyed, as before) and the edge hostname
* derived from that stack's {@code subdomain} (container-name-keyed — see {@link
* #parseDockerPortPublishes(List, Map)}).
*/
ContainerOverrides inspectContainerOverrides(List<String> nameAndPortLines)
throws IOException {
Map<Integer, String> byHostPort = new LinkedHashMap<>();
Map<String, String> edgeHostnameByContainerName = new LinkedHashMap<>();
for (String line : nameAndPortLines) {
if (line == null || line.isBlank()) {
continue;
}
int tab = line.indexOf('\t');
if (tab < 0) {
continue;
}
String name = line.substring(0, tab).trim();
String ports = line.substring(tab + 1);
if (name.isBlank() || isCompanionContainer(name)
|| name.equals(LocalEmulatorDefaults.LOCALSTACK_CONTAINER)
|| name.equals(LocalEmulatorDefaults.MINISTACK_CONTAINER)) {
continue;
}
boolean mapsAppPort = false;
Matcher matcher = HOST_PUBLISH.matcher(ports);
List<Integer> hostPorts = new ArrayList<>();
while (matcher.find()) {
int hostPort = parsePort(matcher.group(1));
int containerPort = parsePort(matcher.group(2));
if (hostPort < 0 || containerPort < 0) {
continue;
}
if (CONTAINER_PORT_TO_HOST.containsKey(containerPort)) {
mapsAppPort = true;
hostPorts.add(hostPort);
}
}
if (!mapsAppPort) {
continue;
}
List<String> env = inspectContainerEnv(name);
String prefix = extractAlbPathPrefix(env);
if (prefix != null) {
for (Integer hostPort : hostPorts) {
byHostPort.putIfAbsent(hostPort, prefix);
}
}
String edgeHostname = extractEdgeHostname(env);
if (edgeHostname != null) {
edgeHostnameByContainerName.put(name, edgeHostname);
}
}
return new ContainerOverrides(byHostPort, edgeHostnameByContainerName);
}
/** Visible for tests — {@code CFC_LOCALSTACK_EDGE_HOSTNAME} (see LocalStackTemplateAdapter). */
static String extractEdgeHostname(Iterable<String> envLines) {
if (envLines == null) {
return null;
}
for (String line : envLines) {
if (line != null && line.startsWith("CFC_LOCALSTACK_EDGE_HOSTNAME=")) {
String value = line.substring("CFC_LOCALSTACK_EDGE_HOSTNAME=".length()).trim();
return value.isBlank() ? null : value;
}
}
return null;
}
private static List<String> inspectContainerEnv(String containerName) throws IOException {
List<String> lines = DockerEmulatorSupport.capture(List.of(
"docker", "inspect",
"--format", "{{range .Config.Env}}{{println .}}{{end}}",
containerName));
return lines == null ? List.of() : lines;
}
static boolean isGatewayHostname(String hostname) {
return LocalEmulatorDefaults.HOST_LOCALSTACK.equals(hostname)
|| LocalEmulatorDefaults.HOST_MINISTACK.equals(hostname)
|| LocalEmulatorDefaults.HOST_EMULATOR.equals(hostname);
}
/**
* HTML landing for emulator gateway hostnames. LocalStack/MiniStack have no AWS Console UI —
* StackPort is the resource browser.
*/
static String renderGatewayLandingHtml(String emulatorLabel, String healthPath) {
return "<!doctype html><html><head><meta charset=\"utf-8\">"
+ "<title>" + emulatorLabel + " — CloudForge</title>"
+ "<style>body{font:16px/1.45 system-ui,sans-serif;max-width:40rem;margin:2rem auto;padding:0 1rem}"
+ "a{color:#06c}code{background:#f4f4f4;padding:.1rem .3rem;border-radius:3px}</style>"
+ "</head><body>"
+ "<h1>" + emulatorLabel + "</h1>"
+ "<p>Gateway is up on port <code>4566</code>. "
+ emulatorLabel + " has <strong>no browser console</strong> — use StackPort to browse "
+ "CloudFormation, ECS, RDS, and other resources.</p>"
+ "<ul>"
+ "<li><a href=\"http://" + LocalEmulatorDefaults.HOST_STACKPORT + "/\">StackPort</a> "
+ "(simulated AWS console)</li>"
+ "<li><a href=\"http://" + LocalEmulatorDefaults.HOST_MANAGER + "/\">CloudForge Manager</a></li>"
+ "<li><a href=\"http://" + LocalEmulatorDefaults.HOST_NGINX + "/\">nginx edge status</a></li>"
+ "<li><a href=\"" + healthPath + "\">Raw health JSON</a> <code>" + healthPath + "</code></li>"
+ "</ul>"
+ "<p>SDK / CLI endpoint: <code>http://127.0.0.1:4566</code></p>"
+ "</body></html>";
}
/** Quote a string for nginx {@code return 200 "...";} (escape \\, ", and newlines). */
static String nginxQuotedHtml(String html) {
return "\""
+ html.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "")
+ "\"";
}
/** Emits one canonical CloudForge local hostname per virtual host. */
static String serverNames(String hostname) {
return hostname;
}
/**
* Operator status is available only at {@code nginx.cloudforge.localhost}. Unknown application
* hostnames deliberately receive a terse 404 rather than an inventory of local routes.
*/
static String renderNginxStatusServer(Map<String, Integer> routes) {
// ASCII-only body: text/plain without charset shows mojibake for UTF-8 dashes/arrows.
StringBuilder body = new StringBuilder("CloudForge emulator edge (nginx)\\n\\n");
body.append("Use Host-based URLs (http:// - bare names may open a search):\\n");
body.append(" http://").append(LocalEmulatorDefaults.HOST_LOCALSTACK).append("/\\n");
body.append(" http://").append(LocalEmulatorDefaults.HOST_MINISTACK).append("/\\n");
body.append(" http://").append(LocalEmulatorDefaults.HOST_STACKPORT).append("/\\n");
body.append(" http://").append(LocalEmulatorDefaults.HOST_MANAGER).append("/\\n");
body.append(" http://").append(LocalEmulatorDefaults.HOST_NGINX).append("/\\n\\n");
body.append("Set up hosts: ./scripts/setup-cloudforge-local-hosts.sh\\n");
body.append("Reconcile: mvn -f cfc-testing cloudforge:emulator-edge-reconcile\\n\\n");
if (routes.isEmpty()) {
body.append("No app vhosts yet - deploy an app, then reconcile.\\n");
} else {
body.append("Active proxy routes:\\n");
List<String> hosts = new ArrayList<>(routes.keySet());
hosts.sort(String::compareTo);
for (String hostname : hosts) {
body.append(" http://").append(hostname).append("/ -> :")
.append(routes.get(hostname)).append("\\n");
}
}
return "server {\n"
+ " listen 80 default_server;\n"
+ " server_name _;\n"
+ " access_log /var/log/nginx/access.log combined;\n"
// Without this, the 404 response has no default_type and nginx serves it as
// application/octet-stream, which browsers download instead of rendering.
+ " default_type text/plain;\n"
+ " location = /favicon.ico { access_log off; return 204; }\n"
+ " location / { return 404 \"CloudForge application route not found\\n\"; }\n"
+ "}\n\n"
+ "server {\n"
+ " listen 80;\n"
+ " server_name " + serverNames(LocalEmulatorDefaults.HOST_NGINX) + ";\n"
+ " charset utf-8;\n\n"
+ " location = /favicon.ico { access_log off; return 204; }\n\n"
+ " location / {\n"
+ " default_type text/plain;\n"
+ " return 200 \"" + body + "\";\n"
+ " }\n"
+ "}\n\n";
}
private Map<String, Integer> reconcileRoutes(Path confDir) throws IOException {
List<String> portLines = DockerEmulatorSupport.capture(List.of(
"docker", "ps", "--format", "{{.Names}}\t{{.Ports}}"));
ContainerOverrides overrides = inspectContainerOverrides(portLines);
Map<String, Integer> routes =
parseDockerPortPublishes(portLines, overrides.edgeHostnameByContainerName());
expandPlatformRoutes(routes);
for (Map.Entry<Integer, String> entry : CONTAINER_PORT_TO_HOST.entrySet()) {
int port = entry.getKey();
String hostname = entry.getValue();
if (!routes.containsKey(hostname) && isLocalPortOpen(port)) {
routes.put(hostname, port);
}
}
Map<String, String> pathPrefixes = pathPrefixesForPublishedPorts(
routes, overrides.pathPrefixByHostPort());
Files.writeString(
confDir.resolve("cloudforge-apps.conf"),
renderNginxConf(routes, pathPrefixes),
StandardCharsets.UTF_8);
return routes;
}
/**
* Consistent platform names: localstack / ministack / emulator / stackport on live ports.
*/
static void expandPlatformRoutes(Map<String, Integer> routes) throws IOException {
boolean localstackUp = DockerEmulatorSupport.isContainerRunning(
LocalEmulatorDefaults.LOCALSTACK_CONTAINER);
boolean ministackUp = DockerEmulatorSupport.isContainerRunning(
LocalEmulatorDefaults.MINISTACK_CONTAINER);
if (localstackUp || ministackUp || isLocalPortOpen(LocalEmulatorDefaults.GATEWAY_PORT)) {
int gateway = LocalEmulatorDefaults.GATEWAY_PORT;
routes.put(LocalEmulatorDefaults.HOST_EMULATOR, gateway);
if (localstackUp) {
routes.put(LocalEmulatorDefaults.HOST_LOCALSTACK, gateway);
}
if (ministackUp) {
routes.put(LocalEmulatorDefaults.HOST_MINISTACK, gateway);
}
// Port open but neither container name matched — still expose both names
if (!localstackUp && !ministackUp) {
routes.putIfAbsent(LocalEmulatorDefaults.HOST_LOCALSTACK, gateway);
routes.putIfAbsent(LocalEmulatorDefaults.HOST_MINISTACK, gateway);
}
}
if (!routes.containsKey(LocalEmulatorDefaults.HOST_STACKPORT)
&& isLocalPortOpen(LocalEmulatorDefaults.STACKPORT_HOST_PORT)) {
routes.put(
LocalEmulatorDefaults.HOST_STACKPORT,
LocalEmulatorDefaults.STACKPORT_HOST_PORT);
}
}
private List<String> dockerCreateArgs(Path nginxConf, Path confDir) {
return List.of(
"run", "-d",
"--name", containerName(),
"--network", LocalEmulatorDefaults.DOCKER_NETWORK,
"-p", hostPort() + ":" + LocalEmulatorDefaults.EMULATOR_EDGE_CONTAINER_PORT,
"--add-host", "host.docker.internal:host-gateway",
"-v", nginxConf.toAbsolutePath() + ":/etc/nginx/nginx.conf:ro",
"-v", confDir.toAbsolutePath() + ":/etc/nginx/conf.d:ro",
LocalEmulatorDefaults.EMULATOR_EDGE_IMAGE);
}
private Path prepareRuntimeDir() throws IOException {
Path dir = LocalEmulatorPaths.emulatorEdgeDir(workingDirectory);
Files.createDirectories(dir.resolve("conf.d"));
return dir;
}
private Path ensureNginxConf(Path runtimeDir) throws IOException {
Path fromRepo = LocalEmulatorPaths.emulatorEdgeNginxConf(workingDirectory);
Path target = runtimeDir.resolve("nginx.conf");
if (Files.isRegularFile(fromRepo) && !fromRepo.equals(target)) {
Files.copy(fromRepo, target, StandardCopyOption.REPLACE_EXISTING);
return target;
}
if (Files.isRegularFile(target)) {
return target;
}
try (InputStream in = DefaultEmulatorEdgeRuntime.class.getResourceAsStream(
"/emulator-edge/nginx.conf")) {
if (in == null) {
throw new IOException(
"nginx.conf not found under docker/emulator-edge or classpath emulator-edge/");
}
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
return target;
}
private static int hostPort() {
String raw = System.getenv(LocalEmulatorDefaults.EMULATOR_EDGE_HTTP_PORT_ENV);
if (raw == null || raw.isBlank()) {
return LocalEmulatorDefaults.EMULATOR_EDGE_HOST_PORT;
}
try {
return Integer.parseInt(raw.trim());
} catch (NumberFormatException e) {
LOG.warning(LocalEmulatorDefaults.EMULATOR_EDGE_HTTP_PORT_ENV + "='" + raw
+ "' is not a valid port number, falling back to the default");
return LocalEmulatorDefaults.EMULATOR_EDGE_HOST_PORT;
}
}
/**
* Parses a {@code docker ps} port-mapping capture group (matched against {@link
* #HOST_PUBLISH}'s {@code \d+}, so overwhelmingly numeric already) — returns -1 on the rare
* malformed/overflow case instead of throwing, since one bad entry in `docker ps` output
* shouldn't abort edge route reconciliation for every other container.
*/
private static int parsePort(String raw) {
try {
return Integer.parseInt(raw);
} catch (NumberFormatException e) {
return -1;
}
}
private static boolean isLocalPortOpen(int port) {
try (java.net.Socket socket = new java.net.Socket()) {
socket.connect(new java.net.InetSocketAddress("127.0.0.1", port), 200);
return true;
} catch (Exception ignored) {
return false;
}
}
}