LocalHostPortConflictChecker.java

package com.cloudforge.core.local;

import com.cloudforge.core.interfaces.ApplicationSpec;
import com.fasterxml.jackson.databind.JsonNode;

import java.io.IOException;
import java.net.URI;
import java.util.List;

/**
 * Blocks local emulator deploys when the required {@code localhost} port is already in use.
 */
public final class LocalHostPortConflictChecker {

    private LocalHostPortConflictChecker() {
    }

    public static void validate(
            DeploymentTarget target,
            URI endpoint,
            String deployingLocalStackName,
            ApplicationSpec spec,
            JsonNode template,
            List<PreflightViolation> violations) throws IOException {
        validateAgainstOccupants(
            target,
            deployingLocalStackName,
            spec,
            template,
            LocalEmulatorHostPortProbe.probe(endpoint, target),
            violations);
    }

    public static void validateAgainstOccupants(
            DeploymentTarget target,
            String deployingLocalStackName,
            ApplicationSpec spec,
            JsonNode template,
            List<LocalHostPortOccupant> occupants,
            List<PreflightViolation> violations) {
        int requiredPort = LocalHostPortPolicy.resolveRequiredHostPort(spec, template);
        if (requiredPort <= 0) {
            return;
        }

        // LocalStack ECS publishes each task on an allocated host port, even when
        // multiple task definitions expose the same container port. Unlike
        // MiniStack, a container port is therefore not an exclusive host-port
        // reservation and must not block an otherwise valid deployment.
        if (target == DeploymentTarget.LOCALSTACK) {
            return;
        }

        if (LocalHostPortPolicy.isPrivilegedHostPort(requiredPort)) {
            violations.add(new PreflightViolation(
                PreflightSeverity.WARN,
                "PRIVILEGED_HOST_PORT",
                "Application binds host port " + requiredPort
                    + " (<1024). Binding may require elevated privileges outside Docker.",
                "Prefer apps on ports >=1024 on MiniStack/LocalStack, or ensure Docker can bind the port."));
        }

        for (LocalHostPortOccupant occupant : occupants) {
            if (occupant.hostPort() != requiredPort) {
                continue;
            }
            if (sameStack(deployingLocalStackName, occupant.stackName())) {
                continue;
            }
            violations.add(new PreflightViolation(
                PreflightSeverity.BLOCK,
                "HOST_PORT_CONFLICT",
                "Host port " + requiredPort + " is already used by "
                    + occupant.stackName() + " (" + occupant.detail() + "). "
                    + "MiniStack maps one app per localhost port.",
                LocalHostPortPolicy.portConflictSuggestion(target)));
            return;
        }
    }

    private static boolean sameStack(String deployingLocalStackName, String occupantStackName) {
        if (deployingLocalStackName == null || deployingLocalStackName.isBlank()
                || occupantStackName == null || occupantStackName.isBlank()) {
            return false;
        }
        return deployingLocalStackName.equals(occupantStackName)
            || deployingLocalStackName.equalsIgnoreCase(occupantStackName);
    }
}