CloudForgeSynthesizer.java
package com.cloudforgeci.api.deploy;
import com.cloudforge.core.config.DeploymentConfig;
import com.cloudforge.core.enums.IAMProfile;
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.launch.ApplicationEc2Stack;
import com.cloudforgeci.api.launch.ApplicationFargateStack;
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 java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
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>Same recipe {@code cfc-testing}'s {@code InteractiveDeployer} already uses for its
* "synthesize only" / "export template" options: build a CDK {@code App} with {@code cfc} context
* set from {@link DeploymentConfig#toContextMap()}, construct the right
* {@code com.cloudforgeci.api.launch} stack for {@link DeploymentConfig#runtime}, call {@code
* app.synth()}. Factored out here (rather than reused from cfc-testing directly) because Manager
* — the first caller with no interactive CLI — cannot depend on the sample/reference repo.</p>
*
* <p><b>Account resolution, stated plainly:</b> {@link DeploymentConfig#account} wins when set;
* otherwise falls back to {@code CDK_DEFAULT_ACCOUNT}; otherwise the synthesized template's
* {@code Environment} is account-agnostic (same fallback {@code InteractiveDeployer}'s "Export
* Template" option already relies on) — CloudFormation resolves {@code AWS::AccountId}
* pseudo-parameters against whichever real account the template is actually deployed into. This
* class stays entirely unaware of *why* a caller might set an explicit account (cross-account
* deploy, multi-region, whatever) — that's the caller's concern.</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>
*/
public final class CloudForgeSynthesizer {
/**
* Serializes every {@link #synthesize} call process-wide. Discovered the hard way: aws-cdk-lib
* is a jsii-wrapped library backed by a single shared Node.js kernel child process per JVM,
* and its Java↔jsii communication channel is not safe for concurrent use from multiple
* threads — two overlapping {@code app.synth()} calls (or one interrupted mid-call) corrupted
* kernel state badly enough that unrelated, previously-passing synthesis calls in the *same*
* JVM started failing with nonsensical NPEs ({@code Node.getId()} returning null,
* {@code Tags.of(...)} returning null) deep inside {@code ApplicationFactory}, for stacks that
* had nothing to do with the call that corrupted things. A single static lock is the
* conservative fix — this is not a hot path (interactive deploys, not a request-per-second
* API), so serializing it costs nothing that matters and removes an entire class of
* hard-to-reproduce cross-request corruption. If a caller (e.g. Manager's async job pool)
* ever runs multiple {@code deploy:create} jobs concurrently, this lock is what makes that
* safe rather than a silent landmine.
*/
private static final Object SYNTH_LOCK = new Object();
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) {
}
/**
* 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 {
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);
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);
}
StackProps props = StackProps.builder().env(envBuilder.build()).build();
switch (config.runtime) {
case FARGATE -> new ApplicationFargateStack(
app, config.stackName, props, config.securityProfile, iamProfile, applicationSpec);
case EC2 -> new ApplicationEc2Stack(
app, config.stackName, props, config.securityProfile, iamProfile, applicationSpec);
}
CloudAssembly assembly;
try {
assembly = app.synth();
} catch (RuntimeException e) {
throw new IOException("CDK synthesis failed for " + config.stackName + ": " + e.getMessage(), e);
}
CloudFormationStackArtifact artifact = assembly.getStackByName(config.stackName);
Path templateFile = Path.of(assembly.getDirectory()).resolve(artifact.getTemplateFile());
return new Result(config.stackName, templateFile, Path.of(assembly.getDirectory()));
}
}
/**
* 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.</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 = resolvedSuffixes.stream()
.map(suffix -> region + suffix.trim().toLowerCase(Locale.ROOT))
.toList();
app.getNode().setContext(
"availability-zones:account=" + account + ":region=" + region, zones);
}
}