CloudForgeSynthesizer.java

package com.cloudforgeci.api.deploy;

import com.cloudforge.core.config.DeploymentConfig;
import com.cloudforge.core.enums.ComplianceMode;
import com.cloudforge.core.enums.IAMProfile;
import com.cloudforge.core.enums.SecurityProfile;
import com.cloudforge.core.iam.IAMProfileMapper;
import com.cloudforge.core.interfaces.ApplicationSpec;
import com.cloudforgeci.api.compute.ApplicationLoader;
import com.cloudforgeci.api.core.DeploymentContext;
import com.cloudforgeci.api.core.rules.ComplianceFindingsCollector;
import com.cloudforgeci.api.core.rules.NagReportReader;
import com.cloudforgeci.api.core.rules.NagReportReader.ComplianceFinding;
import com.cloudforgeci.api.deploy.aws.AwsDirectDeployer;
import com.cloudforgeci.api.launch.ApplicationEc2Stack;
import com.cloudforgeci.api.launch.ApplicationFargateStack;
import com.cloudforgeci.api.launch.MarketplaceVariant;
import software.amazon.awscdk.App;
import software.amazon.awscdk.Environment;
import software.amazon.awscdk.StackProps;
import software.amazon.awscdk.cxapi.CloudAssembly;
import software.amazon.awscdk.cxapi.CloudFormationStackArtifact;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.route53.Route53Client;
import software.amazon.awssdk.services.route53.model.HostedZone;
import software.amazon.awssdk.services.route53.model.ListHostedZonesByNameRequest;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;

/**
 * In-process CDK synthesis — produces the canonical CloudFormation template
 * {@link CloudForgeDeployment} (specifically {@code AwsDirectDeployer}, but the shape is target-
 * agnostic) needs, without shelling out to the {@code cdk} CLI.
 *
 * <p>Follows the same recipe as {@code cfc-testing}'s {@code InteractiveDeployer} "synthesize
 * only" / "export template" options: build a CDK {@code App} with {@code cfc} context set from
 * {@link DeploymentConfig#toContextMap()}, construct the {@code com.cloudforgeci.api.launch} stack
 * for {@link DeploymentConfig#runtime}, and call {@code app.synth()}. It lives here so that callers
 * without an interactive CLI, such as CloudForge Manager, need not depend on the sample project.</p>
 *
 * <p><b>Account resolution:</b> {@link DeploymentConfig#account} wins when set; otherwise
 * {@code CDK_DEFAULT_ACCOUNT}; otherwise the template's {@code Environment} is account-agnostic
 * and CloudFormation resolves {@code AWS::AccountId} in whichever account it is deployed to.</p>
 *
 * <p><b>Availability-zone resolution:</b> {@code VpcFactory.maxAzs(2)} reads
 * {@code Stack.availabilityZones}, which CDK resolves via {@code Fn::GetAZs} (a
 * CloudFormation-time token) only while account+region are both unresolved — true for the
 * account-agnostic template above. The moment an explicit account is set, CDK instead routes AZ
 * resolution through its {@code availability-zones} *synth-time* context provider — and since
 * this deploy path never shells out to the {@code cdk} CLI to satisfy that lookup, CDK would
 * silently return its built-in dummy values ({@code dummy1a}/{@code dummy1b}) baked straight into
 * the template, which then fails at CloudFormation. Whenever an account is pinned, {@link
 * #synthesize} seeds that context key itself first — see {@link #seedAvailabilityZoneContext}.</p>
 *
 * <p><b>Hosted-zone resolution:</b> the same exposure, a different context provider —
 * {@code DomainFactory}'s {@code HostedZone.fromLookup} for an SSL+custom-domain deploy has no
 * CloudFormation-intrinsic fallback the way AZs do, so it always resolves through CDK's
 * synth-time {@code hosted-zone} context provider once a domain is set, and would otherwise
 * silently bake in CDK's dummy zone id. See {@link #seedHostedZoneContext}.</p>
 */
public final class CloudForgeSynthesizer {

    /**
     * Serializes every {@link #synthesize} call process-wide. aws-cdk-lib is backed by a single
     * jsii Node.js kernel process per JVM, and its Java/jsii channel is not thread-safe:
     * overlapping {@code app.synth()} calls (or one interrupted mid-call) can corrupt kernel state,
     * causing later, unrelated synthesis calls in the same JVM to fail with NPEs
     * ({@code Node.getId()} or {@code Tags.of(...)} returning null). Synthesis is not a hot path, so
     * a single static lock is cheap and makes concurrent callers (such as a job pool running
     * several deploys) safe.
     */
    private static final Object SYNTH_LOCK = new Object();

    // The conventional account id callers set to signal "this is a local-emulator (MiniStack/
    // LocalStack) deploy, not real AWS" -- the same value AwsDirectDeployer.resolveAccountId() and
    // LocalStackDeployer already use, reused here (see seedHostedZoneContext) so a local deploy
    // with a domain configured never makes a real network call to actual AWS Route53.
    private static final String LOCAL_EMULATOR_ACCOUNT = "000000000000";

    private CloudForgeSynthesizer() {
    }

    /**
     * @param stackName the synthesized stack's name (== {@code config.stackName})
     * @param templateFile absolute path to the synthesized CloudFormation template JSON —
     *     what {@link DeploymentRequest#canonicalTemplate()} expects
     * @param assemblyDirectory the cloud assembly directory ({@code outputDirectory}, resolved)
     */
    public record Result(String stackName, Path templateFile, Path assemblyDirectory) {
    }

    /** {@link #synthesizeAdvisoryDryRun}'s result — every finding cdk-nag and the requested
     *  {@code FrameworkRules} implementations produced, with nothing filtered out the way {@link
     *  #synthesize}'s own ENFORCE-mode check only reads error-level cdk-nag lines.
     *
     *  @param error non-null only when {@code app.synth()} itself failed to complete (e.g. an
     *      "always load" cross-framework validator that hard-blocks regardless of {@code
     *      complianceMode} — not every validator in this codebase honors ADVISORY the way the
     *      framework-specific ones do). {@code frameworkFindings} may still be partially populated
     *      in that case (whatever ran before the failing validator), but {@code nagFindings} is
     *      always empty, since cdk-nag's own report files never get written unless synthesis
     *      actually finished. */
    public record ComplianceCheckResult(
        String stackName, List<ComplianceFinding> nagFindings, List<ComplianceFinding> frameworkFindings,
        String error) {
    }

    /**
     * Resolves {@code config.applicationSpec} via {@link ApplicationLoader} when not already set
     * (mutates {@code config} — matches {@code CloudForgeDeployment.deploy()}'s existing
     * convention of resolving/preparing the config it's handed rather than requiring the caller
     * to have already done so).
     *
     * @throws IllegalArgumentException when applicationId/stackName/runtime are missing, or no
     *     {@code ApplicationSpec} is registered for {@code config.applicationId}
     * @throws IOException when {@code app.synth()} fails, or the output directory can't be created
     */
    public static Result synthesize(DeploymentConfig config, Path outputDirectory) throws IOException {
        return synthesize(config, outputDirectory, null);
    }

    /**
     * Same as {@link #synthesize}, but produces the AWS Marketplace CloudFormation listing variant
     * of CloudForge Manager -- see {@code MarketplaceParameterSupport}'s own javadoc for what that
     * changes (real {@code AdminEmail}/{@code LicenseKey} CloudFormation parameters instead of
     * requiring them via {@code --context}/cdk.json, meaningless to a buyer who never runs the CDK
     * CLI). This is the one and only place Marketplace mode is ever turned on -- deliberately not a
     * {@code DeploymentConfig} field or a CLI flag on the regular {@code deploy} command, so it
     * can't be set by hand-editing a context file; only whatever builds the actual Marketplace
     * listing artifact calls this method.
     *
     * @throws IllegalArgumentException when {@code config.applicationId} isn't {@code
     *     cloudforge-manager} -- every other application ignores Marketplace mode entirely (see
     *     {@code MarketplaceParameterSupport}), so synthesizing one through this entry point would
     *     silently produce a template indistinguishable from {@link #synthesize}'s own output.
     */
    public static Result synthesizeForMarketplace(DeploymentConfig config, Path outputDirectory) throws IOException {
        return synthesizeForMarketplace(config, outputDirectory, MarketplaceVariant.SSO);
    }

    /**
     * Same as {@link #synthesizeForMarketplace(DeploymentConfig, Path)}, for an explicit {@link
     * MarketplaceVariant}: {@code SSO} (Cognito, admin email and domain prefix required) or {@code
     * TOKEN} (no Cognito, first administrator created with the one-time setup token).
     */
    public static Result synthesizeForMarketplace(
            DeploymentConfig config, Path outputDirectory, MarketplaceVariant variant) throws IOException {
        Objects.requireNonNull(config, "config");
        Objects.requireNonNull(variant, "variant");
        if (!"cloudforge-manager".equals(config.applicationId)) {
            throw new IllegalArgumentException(
                "synthesizeForMarketplace only applies to cloudforge-manager, got applicationId="
                    + config.applicationId);
        }
        return synthesize(config, outputDirectory, variant);
    }

    private static Result synthesize(DeploymentConfig config, Path outputDirectory, MarketplaceVariant marketplaceVariant)
            throws IOException {
        Objects.requireNonNull(config, "config");
        Objects.requireNonNull(outputDirectory, "outputDirectory");
        if (config.stackName == null || config.stackName.isBlank()) {
            throw new IllegalArgumentException("config.stackName is required to synthesize");
        }
        if (config.runtime == null) {
            throw new IllegalArgumentException("config.runtime is required to synthesize");
        }
        if (config.applicationSpec == null) {
            if (config.applicationId == null || config.applicationId.isBlank()) {
                throw new IllegalArgumentException("config.applicationId is required to synthesize");
            }
            config.applicationSpec = ApplicationLoader.findById(config.applicationId)
                .orElseThrow(() -> new IllegalArgumentException(
                    "No ApplicationSpec registered for applicationId=" + config.applicationId));
        }
        ApplicationSpec applicationSpec = config.applicationSpec;

        Files.createDirectories(outputDirectory);

        Map<String, Object> cfcContext = config.toContextMap();
        IAMProfile iamProfile = IAMProfileMapper.mapFromSecurity(config.securityProfile);

        // Everything from here on talks to the jsii-backed CDK objects (App/Stack/DeploymentContext
        // .from/app.synth()) — see SYNTH_LOCK's javadoc for why this whole section is serialized.
        synchronized (SYNTH_LOCK) {
            App app = App.Builder.create()
                .analyticsReporting(false)
                .autoSynth(false)
                .treeMetadata(false)
                .outdir(outputDirectory.toAbsolutePath().toString())
                .build();
            app.getNode().setContext("cfc", cfcContext);
            // Sibling top-level key, deliberately outside "cfc" -- see
            // DeploymentConfig#complianceFrameworksRawOverride's own javadoc for why it can't be
            // folded into the "cfc" map like every other field here.
            if (config.complianceFrameworksRawOverride != null && !config.complianceFrameworksRawOverride.isBlank()) {
                app.getNode().setContext("complianceFrameworksRawOverride", config.complianceFrameworksRawOverride);
            }

            DeploymentContext cfc = DeploymentContext.from(app);

            String region = cfc.region() != null && !cfc.region().isBlank()
                ? cfc.region()
                : System.getenv().getOrDefault("CDK_DEFAULT_REGION", "us-east-1");
            String account = config.account != null && !config.account.isBlank()
                ? config.account
                : System.getenv("CDK_DEFAULT_ACCOUNT");

            Environment.Builder envBuilder = Environment.builder().region(region);
            if (account != null && !account.isBlank()) {
                envBuilder.account(account);
                seedAvailabilityZoneContext(app, account, region, config.availabilityZones);
                // DomainFactory calls HostedZone.fromLookup (a context-provider lookup with the
                // same dummy-value fallback as availability zones) only when a domain is set and
                // the zone is not being created. See seedHostedZoneContext.
                if (config.domain != null && !config.domain.isBlank()
                        && !Boolean.TRUE.equals(config.createZone)) {
                    seedHostedZoneContext(app, account, region, config.domain);
                }
            }
            StackProps props = StackProps.builder().env(envBuilder.build()).build();

            switch (config.runtime) {
                case FARGATE -> new ApplicationFargateStack(
                    app, config.stackName, props, config.securityProfile, iamProfile, applicationSpec,
                    marketplaceVariant);
                case EC2 -> new ApplicationEc2Stack(
                    app, config.stackName, props, config.securityProfile, iamProfile, applicationSpec,
                    marketplaceVariant);
            }

            CloudAssembly assembly;
            try {
                assembly = app.synth();
            } catch (RuntimeException e) {
                throw new IOException("CDK synthesis failed for " + config.stackName + ": " + e.getMessage(), e);
            }

            // ComplianceMode.ENFORCE: read cdk-nag's report files after synthesis (see
            // NagReportReader for why) and fail on errors. ADVISORY leaves findings uninspected;
            // SecurityRules.applyCdkNagValidation decides which packs run.
            // A null complianceMode resolves to the profile default, as in DeploymentContext
            // (ENFORCE for PRODUCTION, ADVISORY otherwise).
            ComplianceMode effectiveComplianceMode = config.complianceMode != null
                ? config.complianceMode
                : ComplianceMode.defaultForProfile(config.securityProfile);
            if (effectiveComplianceMode == ComplianceMode.ENFORCE) {
                List<ComplianceFinding> errors = NagReportReader.readErrors(
                    Path.of(assembly.getDirectory()), config.stackName);
                if (!errors.isEmpty()) {
                    throw new ComplianceViolationException(config.stackName, errors);
                }
            }

            CloudFormationStackArtifact artifact = assembly.getStackByName(config.stackName);
            Path templateFile = Path.of(assembly.getDirectory()).resolve(artifact.getTemplateFile());

            // A Marketplace-uploaded template file has no later deploy-time rewrite step to rely
            // on (unlike AwsDirectDeployer's own in-memory copy, rewritten right before every
            // CreateStack/CreateChangeSet call) -- the file itself must already be self-contained,
            // so the buyer's own (never-bootstrapped) account doesn't reject BootstrapVersion's
            // unresolvable SSM reference before creating a single resource.
            if (marketplaceVariant != null) {
                String rewritten = AwsDirectDeployer.resolveCdkBootstrapParameters(Files.readString(templateFile));
                Files.writeString(templateFile, rewritten);
            }

            return new Result(config.stackName, templateFile, Path.of(assembly.getDirectory()));
        }
    }

    /**
     * A synth-only "what would this report" pass — never deploys anything, never writes to
     * CloudFormation, and never throws over a compliance finding the way {@link #synthesize} can
     * in {@code ComplianceMode.ENFORCE}. Built for an on-demand compliance check against an
     * already-deployed stack's reconstructed configuration (CloudForge Manager's Compliance tab):
     * the caller sets {@code config.complianceFrameworks} to whichever framework(s) the user
     * picked, and this returns every cdk-nag and framework-rule finding for them, instead of only
     * the subset that would block a real deploy.
     *
     * <p>{@code config.complianceMode} is forced to {@code ADVISORY} for the duration of this call
     * (and restored after) regardless of what the caller set it to — this method's entire point is
     * "report, don't block". Framework-rule findings only reach a caller at all via {@link
     * ComplianceFindingsCollector} — see its own javadoc for why {@code Node.addValidation} itself
     * can't carry them out of ADVISORY mode.</p>
     *
     * <p>{@code config.securityProfile} and {@code config.auditManagerEnabled} are likewise forced
     * (to {@code PRODUCTION} and {@code true}, both restored after) — {@code
     * SecurityRules#install}'s own gates only ever apply cdk-nag for a {@code PRODUCTION} profile
     * and only ever install any {@code FrameworkRules} implementation at all when {@code
     * auditManagerEnabled} is set, regardless of which frameworks were requested. Without forcing
     * both, a check against a real DEV/STAGING stack's own reconstructed profile would silently
     * come back with zero findings from either engine — not "this stack is compliant", just "these
     * checks never ran" — which would actively mislead the very user this preview exists for. The
     * whole point of an advisory check is "would this pass production-grade scrutiny", independent
     * of whatever the target stack's own real profile happens to be.</p>
     *
     * <p>Never throws — not every validator in this codebase honors {@code ComplianceMode}
     * (several "always load" cross-framework validators hard-block on their own {@code
     * SecurityProfile} gate regardless of mode; see {@code ComplianceCheckResult#error}'s own
     * javadoc). A caller building a UI around this (CloudForge Manager's Compliance tab) can
     * always render SOMETHING rather than handling a thrown exception on top of the findings
     * lists it otherwise expects.</p>
     *
     * @param outputDirectory a throwaway cloud-assembly directory — the caller owns its lifecycle
     *     (create before, delete after); nothing here is meant to persist
     */
    public static ComplianceCheckResult synthesizeAdvisoryDryRun(DeploymentConfig config, Path outputDirectory) {
        Objects.requireNonNull(config, "config");
        ComplianceMode originalMode = config.complianceMode;
        var originalProfile = config.securityProfile;
        Boolean originalAuditManagerEnabled = config.auditManagerEnabled;
        config.complianceMode = ComplianceMode.ADVISORY;
        config.securityProfile = SecurityProfile.PRODUCTION;
        config.auditManagerEnabled = true;
        ComplianceFindingsCollector.start();
        try {
            Result result = synthesize(config, outputDirectory);
            List<ComplianceFinding> nagFindings =
                NagReportReader.readAll(result.assemblyDirectory(), result.stackName());
            List<ComplianceFinding> frameworkFindings = ComplianceFindingsCollector.drain();
            return new ComplianceCheckResult(result.stackName(), nagFindings, frameworkFindings, null);
        } catch (IOException e) {
            List<ComplianceFinding> frameworkFindings = ComplianceFindingsCollector.drain();
            return new ComplianceCheckResult(config.stackName, List.of(), frameworkFindings, e.getMessage());
        } finally {
            config.complianceMode = originalMode;
            config.securityProfile = originalProfile;
            config.auditManagerEnabled = originalAuditManagerEnabled;
            // Idempotent if the try block already drained -- guards the case synthesize() itself
            // threw before that line ran, which would otherwise leave a stale collector on this
            // thread for whatever the next synth call happens to be.
            ComplianceFindingsCollector.drain();
        }
    }

    /**
     * Seeds CDK's {@code availability-zones} synth-time context provider so pinning {@code
     * Environment.account} (see the class javadoc) never falls through to CDK's dummy-AZ
     * fallback. Key format ({@code availability-zones:account=<account>:region=<region>}) matches
     * CDK's own {@code ContextProvider.AVAILABILITY_ZONE_PROVIDER} key construction; the value is
     * the plain list of full zone names the VPC construct expects.
     *
     * <p>Suffixes come from {@link DeploymentConfig#availabilityZones} when the caller populated
     * it (region-relative — "a" means whichever zone the target region calls "a"); defaults to
     * {@code ["a", "b"]} otherwise, since every commercial AWS region has at least two AZs with
     * those conventional suffixes. A caller-supplied entry that's already a full zone name (e.g.
     * "us-east-1a", the conventional way anyone would write one by hand) is used as-is rather
     * than getting the region prepended a second time.</p>
     *
     * <p>Always seeds at least two zones, padding with conventional suffixes when the caller
     * supplies fewer. {@link com.cloudforgeci.api.network.VpcFactory} hardcodes {@code .maxAzs(2)}
     * for every deployment, but CDK silently caps subnet creation to however many AZ names are
     * seeded here rather than erroring at synth time — a single-AZ caller (e.g. a wizard answer
     * that opts out of "Multi-AZ") would otherwise produce a VPC CloudFormation only discovers is
     * broken when it tries to create the load balancer.</p>
     */
    private static void seedAvailabilityZoneContext(App app, String account, String region, String[] suffixes) {
        List<String> resolvedSuffixes = suffixes != null && suffixes.length > 0
            ? Arrays.asList(suffixes)
            : List.of("a", "b");
        List<String> zones = new ArrayList<>(resolvedSuffixes.stream()
            .map(suffix -> {
                String trimmed = suffix.trim().toLowerCase(Locale.ROOT);
                return trimmed.startsWith(region) ? trimmed : region + trimmed;
            })
            .toList());
        for (String fallbackSuffix : List.of("a", "b", "c", "d")) {
            if (zones.size() >= 2) break;
            String candidate = region + fallbackSuffix;
            if (!zones.contains(candidate)) {
                zones.add(candidate);
            }
        }
        app.getNode().setContext(
            "availability-zones:account=" + account + ":region=" + region, zones);
    }

    /**
     * Seeds CDK's {@code hosted-zone} synth-time context provider. {@link DomainFactory}'s
     * {@code HostedZone.fromLookup} (SSL with a custom domain) has the same dummy-value fallback
     * that {@link #seedAvailabilityZoneContext} addresses for AZs: without seeding, the template
     * contains a literal {@code "DUMMY"} hosted zone id and CloudFormation fails with
     * "No hosted zone found with ID: DUMMY".
     *
     * <p>Unlike AZs, the zone id cannot be derived from account/region, so this makes one
     * {@code ListHostedZonesByName} call (Route53 is global; account/region appear in the key only
     * because CDK's context key format requires them). The entry maps
     * {@code hosted-zone:account=<id>:domainName=<domain>:privateZone=false:region=<region>} to
     * {@code {"Id": "/hostedzone/<ZONEID>", "Name": "<domain>."}}.
     *
     * @throws IllegalArgumentException when no hosted zone for this exact domain exists in the
     *     account being deployed into — the same failure CloudFormation would eventually report,
     *     just surfaced immediately instead of after minutes of provisioning other resources first
     */
    private static void seedHostedZoneContext(App app, String account, String region, String domain) {
        String zoneName = domain.endsWith(".") ? domain : domain + ".";
        if (LOCAL_EMULATOR_ACCOUNT.equals(account)) {
            // A local-emulator deploy (MiniStack/LocalStack) has no real Route53 zone to look up
            // -- callers signal that by using this well-known test account id (the same
            // convention AwsDirectDeployer.resolveAccountId() and LocalStackDeployer already use
            // for a local target), so seed a dummy value the same way seedAvailabilityZoneContext
            // does for AZs, rather than making a real, always-403 call to actual AWS Route53.
            app.getNode().setContext(
                "hosted-zone:account=" + account + ":domainName=" + domain + ":privateZone=false:region=" + region,
                Map.of("Id", "/hostedzone/LOCALEMULATORZONE", "Name", zoneName));
            return;
        }
        try (Route53Client route53 = Route53Client.builder().region(Region.AWS_GLOBAL).build()) {
            List<HostedZone> zones = route53.listHostedZonesByName(
                ListHostedZonesByNameRequest.builder().dnsName(zoneName).maxItems("1").build())
                .hostedZones();
            if (zones.isEmpty() || !zones.getFirst().name().equals(zoneName)) {
                throw new IllegalArgumentException(
                    "No Route53 hosted zone found for domain \"" + domain + "\" in account " + account
                        + " -- create one first, or set createZone to have this deploy create it.");
            }
            app.getNode().setContext(
                "hosted-zone:account=" + account + ":domainName=" + domain + ":privateZone=false:region=" + region,
                Map.of("Id", zones.getFirst().id(), "Name", zoneName));
        }
    }
}