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;
        }
    }
}