CloudForgeDeployment.java

package com.cloudforgeci.api.deploy;

import com.cloudforge.core.config.DeploymentContextPreparer;
import com.cloudforge.core.local.DeploymentTarget;
import com.cloudforge.core.local.EmulatorEdgeLifecycle;
import com.cloudforge.core.local.LocalDeployResult;
import com.cloudforge.core.local.LocalDeploymentPipelineResult;
import com.cloudforge.core.local.LocalDeploymentRequest;
import com.cloudforge.core.local.LocalSameApplicationStackReplacer;
import com.cloudforge.core.local.TemplateAdaptationResult;
import com.cloudforgeci.api.deploy.aws.AwsDirectDeployer;
import com.cloudforgeci.api.deploy.aws.AwsStackDeployResult;
import com.cloudforgeci.localstack.LocalStackDeployer;
import com.cloudforgeci.localstack.LocalStackDeploymentPipeline;
import com.cloudforgeci.ministack.MiniStackDeployer;
import com.cloudforgeci.ministack.MiniStackDeploymentPipeline;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * Central deploy façade for MiniStack, LocalStack, and (as of the {@code deploy:create}/
 * {@code deploy:catalog} work) direct-to-AWS.
 *
 * <p>The AWS case is deliberately the simplest of the three: no template adaptation (real ALB/
 * Route53 need no local port-remapping), no {@link LocalSameApplicationStackReplacer} (auto-
 * deleting a caller's other AWS stacks on redeploy is a MiniStack/LocalStack local-resource
 * convenience, not a safe default against real infrastructure — {@link DeployOptions
 * #replaceSameApplication()} is ignored for this target), no emulator-edge reconciliation. See
 * {@link AwsDirectDeployer} for the actual CloudFormation mechanics and the CloudForge stack
 * tagging every AWS deploy applies.</p>
 */
public final class CloudForgeDeployment {

    private CloudForgeDeployment() {
    }

    public static DeploymentResult deploy(DeploymentRequest request) throws IOException {
        if (request.config().applicationSpec != null) {
            DeploymentContextPreparer.prepare(
                request.config(),
                request.config().applicationSpec,
                request.target().configKey());
        }
        LocalDeployPreflight.PreflightOutcome preflight = LocalDeployPreflight.run(request);
        preflight.throwIfBlocked();
        if (preflight.ran() && !preflight.formattedWarnings().isBlank()) {
            System.out.println("\n" + preflight.formattedWarnings());
        }
        return switch (request.target()) {
            case MINISTACK -> deployMiniStack(request, preflight);
            case LOCALSTACK -> deployLocalStack(request, preflight);
            case AWS -> deployAws(request, preflight);
        };
    }

    private static DeploymentResult deployMiniStack(
            DeploymentRequest request,
            LocalDeployPreflight.PreflightOutcome preflight) throws IOException {
        LocalDeploymentRequest localRequest = LocalDeploymentRequest.forTarget(
            DeploymentTarget.MINISTACK,
            request.config().stackName,
            request.canonicalTemplate(),
            request.outputDirectory());

        String endpoint = MiniStackDeployer.resolveEndpoint();
        LocalSameApplicationStackReplacer.Result replaceResult = null;
        if (request.options().replaceSameApplication() && request.mode() == DeployMode.DEPLOY) {
            try (MiniStackDeployer deployer = new MiniStackDeployer()) {
                replaceResult = deployer.replaceSameApplicationStacks(
                    request.config().applicationId,
                    localRequest.localStackName(),
                    request.options().catalogDirectory());
            }
        }

        TemplateAdaptationResult adaptation = null;
        LocalDeployResult deployment = null;
        Map<String, String> outputs = Map.of();

        switch (request.mode()) {
            case DRY_RUN -> adaptation = MiniStackDeploymentPipeline.adapt(localRequest);
            case DEPLOY -> {
                LocalDeploymentPipelineResult pipelineResult =
                    MiniStackDeploymentPipeline.deploy(localRequest);
                adaptation = pipelineResult.adaptation();
                deployment = pipelineResult.deployment();
                outputs = deployment.outputs();
            }
            case VERIFY -> {
                try (MiniStackDeployer deployer = new MiniStackDeployer()) {
                    outputs = deployer.verifyDeployment(localRequest.localStackName());
                }
            }
        }

        return finalizeResult(
            request,
            preflight,
            localRequest.localStackName(),
            endpoint,
            localRequest.adaptationReport(),
            adaptation,
            deployment,
            replaceResult,
            outputs);
    }

    private static DeploymentResult deployLocalStack(
            DeploymentRequest request,
            LocalDeployPreflight.PreflightOutcome preflight) throws IOException {
        LocalDeploymentRequest localRequest = LocalDeploymentRequest.forTarget(
            DeploymentTarget.LOCALSTACK,
            request.config().stackName,
            request.canonicalTemplate(),
            request.outputDirectory());

        String endpoint = LocalStackDeployer.resolveEndpoint();
        LocalSameApplicationStackReplacer.Result replaceResult = null;
        if (request.options().replaceSameApplication() && request.mode() == DeployMode.DEPLOY) {
            try (LocalStackDeployer deployer = new LocalStackDeployer()) {
                replaceResult = deployer.replaceSameApplicationStacks(
                    request.config().applicationId,
                    localRequest.localStackName(),
                    request.options().catalogDirectory());
            }
        }

        TemplateAdaptationResult adaptation = null;
        LocalDeployResult deployment = null;
        Map<String, String> outputs = Map.of();

        switch (request.mode()) {
            case DRY_RUN -> adaptation = LocalStackDeploymentPipeline.adapt(localRequest);
            case DEPLOY -> {
                LocalDeploymentPipelineResult pipelineResult =
                    LocalStackDeploymentPipeline.deploy(localRequest);
                adaptation = pipelineResult.adaptation();
                deployment = pipelineResult.deployment();
                outputs = deployment.outputs();
            }
            case VERIFY -> {
                try (LocalStackDeployer deployer = new LocalStackDeployer()) {
                    outputs = deployer.verifyDeployment(localRequest.localStackName());
                }
            }
        }

        return finalizeResult(
            request,
            preflight,
            localRequest.localStackName(),
            endpoint,
            localRequest.adaptationReport(),
            adaptation,
            deployment,
            replaceResult,
            outputs);
    }

    private static DeploymentResult deployAws(
            DeploymentRequest request,
            LocalDeployPreflight.PreflightOutcome preflight) throws IOException {
        String stackName = request.config().stackName;
        List<String> messages = new ArrayList<>();
        if (preflight != null && preflight.ran()) {
            messages.addAll(preflight.warningMessages());
        }
        Map<String, String> outputs = Map.of();

        try (AwsDirectDeployer deployer = new AwsDirectDeployer(
                request.config(), request.target(), request.options().credentialsOverride())) {
            switch (request.mode()) {
                case DRY_RUN -> {
                    // No adaptation pipeline for AWS — the canonical template deploys as-is.
                    // Nothing to do beyond preflight (already run above).
                }
                case DEPLOY -> {
                    AwsStackDeployResult result = deployer.deploy(stackName, request.canonicalTemplate());
                    outputs = result.outputs();
                    messages.add(result.noOp()
                        ? "No changes for " + stackName
                        : (result.created() ? "Created " : "Updated ") + stackName
                            + " (" + result.changeSummaries().size() + " resource changes)");
                }
                case VERIFY -> outputs = deployer.verifyDeployment(stackName);
            }
        }

        List<java.nio.file.Path> catalogPaths = List.of();
        if (request.mode() == DeployMode.DEPLOY && request.options().persistCatalog()) {
            try {
                catalogPaths = DeploymentContextCatalog.persist(
                    request.config(),
                    request.options().catalogDirectory(),
                    request.options().managerVolumeRoots());
            } catch (IOException | RuntimeException e) {
                // Catalog persistence is best-effort bookkeeping, not the deploy itself — must
                // never abort the caller. Broadened beyond IOException after a real
                // NullPointerException from DeploymentContextCatalog escaped this same shape of
                // catch elsewhere and took down an entire deploy; see that fix's javadoc.
                messages.add("Could not write deployment-contexts catalog: " + e.getMessage());
            }
        }

        return new DeploymentResult(
            request.target(),
            request.mode(),
            stackName,
            request.config().region,
            null,
            null,
            null,
            null,
            outputs,
            catalogPaths,
            messages,
            false);
    }

    private static DeploymentResult finalizeResult(
            DeploymentRequest request,
            LocalDeployPreflight.PreflightOutcome preflight,
            String localStackName,
            String endpoint,
            java.nio.file.Path adaptationReport,
            TemplateAdaptationResult adaptation,
            LocalDeployResult deployment,
            LocalSameApplicationStackReplacer.Result replaceResult,
            Map<String, String> outputs) throws IOException {
        List<String> messages = new ArrayList<>();
        if (preflight != null && preflight.ran()) {
            messages.addAll(preflight.warningMessages());
        }
        List<java.nio.file.Path> catalogPaths = List.of();
        boolean historyRecorded = false;

        if (request.mode() == DeployMode.DEPLOY && request.options().persistCatalog()) {
            try {
                catalogPaths = DeploymentContextCatalog.persist(
                    request.config(),
                    request.options().catalogDirectory(),
                    request.options().managerVolumeRoots());
            } catch (IOException | RuntimeException e) {
                // Must never abort the deploy — reconcileEmulatorEdge below depends on reaching
                // this point. Broadened beyond IOException: a real NullPointerException from
                // DeploymentContextCatalog.registerKnownStack (a null-parent Path — fixed there
                // now, but this catch stays broad as a second line of defense) escaped an
                // IOException-only catch here, silently skipping emulator-edge reconciliation for
                // every local deploy afterward in that run — the new stack's route never got
                // added to nginx, surfacing as "CloudForge application route not found" in a
                // browser hitting its hostname. Discovered via a real LocalStack deploy, not a
                // test — nothing in the test suite exercises this catalog-persist call chain
                // against a real filesystem layout where catalogDirectory is a bare relative path.
                messages.add("Could not write deployment-contexts catalog: " + e.getMessage());
            }
        }

        if (request.mode() == DeployMode.DEPLOY) {
            reconcileEmulatorEdge(messages);
        }

        return new DeploymentResult(
            request.target(),
            request.mode(),
            localStackName,
            endpoint,
            adaptationReport,
            adaptation,
            deployment,
            replaceResult,
            outputs,
            catalogPaths,
            messages,
            historyRecorded);
    }

    /**
     * Keep {@code *.cloudforge.localhost} Host routes in sync after a local deploy publishes
     * new ECS host ports. No-op when edge companions are disabled.
     */
    private static void reconcileEmulatorEdge(List<String> messages) {
        if (!EmulatorEdgeLifecycle.autostartEnabled()) {
            return;
        }
        try {
            EmulatorEdgeLifecycle.ensureRunningAndReconcile();
        } catch (Exception e) {
            messages.add("Emulator edge reconcile skipped: " + e.getMessage());
        }
    }
}