FargateFactory.java
package com.cloudforgeci.api.compute;
import com.cloudforgeci.api.core.annotation.BaseFactory;
import com.cloudforgeci.api.storage.ContainerFactory;
import com.cloudforge.core.annotation.DeploymentContext;
import com.cloudforge.core.annotation.SystemContext;
import com.cloudforge.core.enums.AuthMode;
import com.cloudforge.core.enums.NetworkMode;
import com.cloudforge.core.enums.SecurityProfile;
import com.cloudforge.core.interfaces.ApplicationSpec;
import software.amazon.awscdk.CfnOutput;
import software.amazon.awscdk.RemovalPolicy;
import software.amazon.awscdk.services.ec2.Peer;
import software.amazon.awscdk.services.ec2.Port;
import software.amazon.awscdk.services.ec2.SecurityGroup;
import software.amazon.awscdk.services.ec2.SubnetSelection;
import software.amazon.awscdk.services.ec2.SubnetType;
import software.amazon.awscdk.services.ecs.*;
import software.amazon.awscdk.services.efs.AccessPoint;
import software.amazon.awscdk.services.iam.Role;
import software.constructs.Construct;
import io.github.cdklabs.cdknag.NagSuppressions;
import io.github.cdklabs.cdknag.NagPackSuppression;
import java.util.List;
import java.util.logging.Logger;
// Removed static imports - now using ApplicationSpec
/**
* Factory for creating Fargate-based Jenkins compute infrastructure.
*
* <p>This factory creates and configures AWS Fargate services for Jenkins deployments,
* providing a serverless container-based approach. It respects network mode configuration
* to place tasks in appropriate subnets and handles EFS integration for persistent storage.</p>
*
* <p><strong>Key Features:</strong></p>
* <ul>
* <li>Fargate task definitions with Jenkins container</li>
* <li>ECS cluster and service configuration</li>
* <li>EFS access point integration for persistent storage</li>
* <li>IAM roles for task execution and EFS access</li>
* <li>Network mode awareness (public vs private subnets)</li>
* <li>Security group configuration</li>
* </ul>
*
* <p><strong>Network Configuration:</strong></p>
* <ul>
* <li><strong>public-no-nat:</strong> Tasks get public IPs and use public subnets</li>
* <li><strong>private-with-nat:</strong> Tasks use private subnets with NAT gateway</li>
* </ul>
*
* <p><strong>Example Usage:</strong></p>
* <pre>{@code
* FargateFactory factory = new FargateFactory(scope, "JenkinsFargate");
* factory.create();
*
* // Access created resources
* FargateService service = ctx.fargateService.get().orElseThrow();
* FargateTaskDefinition taskDef = ctx.fargateTaskDef.get().orElseThrow();
* }</pre>
*
* @author CloudForgeCI
* @since 1.0.0
* @see DeploymentContext
* @see SystemContext
* @see ContainerFactory
* @see com.cloudforgeci.api.core.DeploymentContext#networkMode()
*/
public class FargateFactory extends BaseFactory {
private static final Logger LOG = Logger.getLogger(FargateFactory.class.getName());
@DeploymentContext("bastionCidr")
private String bastionCidr;
@DeploymentContext("cpu")
private Integer cpu;
@DeploymentContext("memory")
private Integer memory;
@DeploymentContext("minInstanceCapacity")
private Integer minInstanceCapacity;
@DeploymentContext("maxInstanceCapacity")
private Integer maxInstanceCapacity;
@DeploymentContext("cpuTargetUtilization")
private Integer cpuTargetUtilization;
@DeploymentContext("networkMode")
private NetworkMode networkMode;
@DeploymentContext("healthCheckGracePeriod")
private Integer healthCheckGracePeriod;
@com.cloudforge.core.annotation.SystemContext("fargateExecutionRole")
private Role fargateExecutionRole;
@com.cloudforge.core.annotation.SystemContext("fargateTaskRole")
private Role fargateTaskRole;
@com.cloudforge.core.annotation.SystemContext("applicationSpec")
private com.cloudforge.core.interfaces.ApplicationSpec applicationSpec;
@com.cloudforge.core.annotation.SystemContext("efs")
private software.amazon.awscdk.services.efs.FileSystem efs;
@com.cloudforge.core.annotation.SystemContext("vpc")
private software.amazon.awscdk.services.ec2.Vpc vpc;
@com.cloudforge.core.annotation.SystemContext("albSg")
private SecurityGroup albSg;
@SystemContext("efsSg")
private SecurityGroup efsSg;
@SystemContext("security")
private SecurityProfile security;
@com.cloudforge.core.annotation.SystemContext("ap")
private AccessPoint ap;
@DeploymentContext("authMode")
private AuthMode authMode;
@DeploymentContext("stackName")
private String stackName;
@DeploymentContext("region")
private String region;
@DeploymentContext("containerImage")
private String containerImage;
// ========== Optional Port Configuration ==========
// These flags control which optional ports are exposed in security groups
// Ports are NOT exposed by default - must be explicitly enabled
@DeploymentContext("enableAgents")
private Boolean enableAgents;
@DeploymentContext("enableSsh")
private Boolean enableSsh;
@DeploymentContext("enableSmtp")
private Boolean enableSmtp;
@DeploymentContext("enableSmtps")
private Boolean enableSmtps;
@DeploymentContext("enableClustering")
private Boolean enableClustering;
@DeploymentContext("enableDockerRegistry")
private Boolean enableDockerRegistry;
@DeploymentContext("enableMetrics")
private Boolean enableMetrics;
@DeploymentContext("enableNotary")
private Boolean enableNotary;
@DeploymentContext("enableTrivy")
private Boolean enableTrivy;
@DeploymentContext("enableSentinel")
private Boolean enableSentinel;
@DeploymentContext("enableCluster")
private Boolean enableCluster;
/**
* Creates a new FargateFactory instance.
*
* @param scope The CDK construct scope
* @param id Unique identifier for the Fargate factory
*/
public FargateFactory(Construct scope, String id) {
super(scope, id);
// All fields are automatically injected by BaseFactory
}
@Override
public void create() {
// Set scaling configuration in SystemContext slots so topology can wire auto-scaling
// Priority: DeploymentContext > SecurityProfileConfiguration > default
Integer effectiveMinCapacity = minInstanceCapacity;
Integer effectiveMaxCapacity = maxInstanceCapacity;
if (effectiveMinCapacity == null && config != null) {
effectiveMinCapacity = config.getMinInstanceCount();
LOG.info("Min instance capacity inherited from security profile: " + effectiveMinCapacity);
}
if (effectiveMaxCapacity == null && config != null) {
effectiveMaxCapacity = config.getMaxInstanceCount();
LOG.info("Max instance capacity inherited from security profile: " + effectiveMaxCapacity);
}
if (effectiveMinCapacity != null) {
ctx.minInstanceCapacity.set(effectiveMinCapacity);
}
if (effectiveMaxCapacity != null) {
ctx.maxInstanceCapacity.set(effectiveMaxCapacity);
}
if (cpuTargetUtilization != null) {
ctx.cpuTargetUtilization.set(cpuTargetUtilization);
}
// Use IAM roles from SystemContext created by IAM configuration system
// These roles are security-profile aware and provide consistent permissions
if (fargateExecutionRole == null) {
throw new IllegalStateException("Fargate execution role not found - IAM configuration should have created it");
}
if (fargateTaskRole == null) {
throw new IllegalStateException("Fargate task role not found - IAM configuration should have created it");
}
// Check if ECS Exec should be enabled based on bastionCidr configuration
boolean enableEcsExec = bastionCidr != null && !bastionCidr.isBlank();
FargateTaskDefinition taskDef = FargateTaskDefinition.Builder.create(this, "Task")
.cpu(cpu)
.memoryLimitMiB(memory)
.executionRole(fargateExecutionRole)
.taskRole(fargateTaskRole)
.build();
// Add CDK-nag suppressions for standard ECS patterns
NagSuppressions.addResourceSuppressions(taskDef, List.of(
NagPackSuppression.builder()
.id("AwsSolutions-ECS2")
.reason("Environment variables are used for non-sensitive container configuration. " +
"Sensitive values like secrets use AWS Secrets Manager references.")
.build()
), true);
// Validate that EFS and Access Point are available (created by EfsFactory)
if (efs == null) {
throw new IllegalStateException("EFS not available - EfsFactory should have created it");
}
if (ap == null) {
throw new IllegalStateException("EFS Access Point not available - EfsFactory should have created it");
}
if (vpc == null) {
throw new IllegalStateException("VPC not available");
}
// Enable Container Insights for PRODUCTION/STAGING profiles (PCI-DSS compliance)
boolean enableContainerInsights = security != SecurityProfile.DEV;
Cluster cluster = Cluster.Builder.create(this, "Cluster")
.vpc(vpc)
.containerInsights(enableContainerInsights)
.build();
cluster.applyRemovalPolicy(RemovalPolicy.DESTROY);
// Check if egress should be restricted to VPC CIDR only (only for private subnets)
boolean restrictEgress = config != null && config.isRestrictSecurityGroupEgressEnabled()
&& networkMode != NetworkMode.PUBLIC;
SecurityGroup serviceSg = SecurityGroup.Builder.create(this, getNode().getId() + "SvcSg")
.vpc(vpc)
.description("Fargate Service Security Group")
.allowAllOutbound(!restrictEgress)
.build();
// If egress is restricted, add explicit egress rule for VPC CIDR only
if (restrictEgress) {
serviceSg.addEgressRule(
Peer.ipv4(vpc.getVpcCidrBlock()),
Port.allTraffic(),
"Allow egress to VPC CIDR only"
);
}
ctx.fargateServiceSg.set(serviceSg);
// Determine subnet type and public IP assignment based on network mode
boolean assignPublicIp = networkMode == NetworkMode.PUBLIC;
SubnetType subnetType = assignPublicIp ? SubnetType.PUBLIC : SubnetType.PRIVATE_WITH_EGRESS;
// Enable ECS Exec only if bastionCidr is configured (indicates remote access needed)
FargateService service = FargateService.Builder.create(this, "Service")
.cluster(cluster)
.securityGroups(List.of(serviceSg))
.taskDefinition(taskDef)
.desiredCount(effectiveMinCapacity != null ? effectiveMinCapacity : 1)
.assignPublicIp(assignPublicIp)
.vpcSubnets(SubnetSelection.builder().subnetType(subnetType).build())
.enableExecuteCommand(enableEcsExec) // Enable ECS Exec for shell access when bastionCidr is set
.enableEcsManagedTags(true) // Helps CloudFormation track and clean up ENIs on stack deletion
.circuitBreaker(DeploymentCircuitBreaker.builder()
.enable(true)
.rollback(true)
.build()) // Prevents stuck deployments and enables automatic rollback
.build();
// Set health check grace period (critical for slow-starting apps like GitLab)
// Must be set on the underlying CfnService after FargateService creation
// Priority: deployment context > application spec > default (300)
int defaultGracePeriod = applicationSpec != null ? applicationSpec.defaultHealthCheckGracePeriod() : 300;
int gracePeriodSeconds = healthCheckGracePeriod != null ? healthCheckGracePeriod : defaultGracePeriod;
CfnService cfnService = (CfnService) service.getNode().getDefaultChild();
cfnService.setHealthCheckGracePeriodSeconds(gracePeriodSeconds);
// Add dependency on Cognito client secret Custom Resource if it exists
// This ensures the secret is created in Secrets Manager BEFORE ECS tries to pull it
ctx.cognitoClientSecretResourceInternal.get().ifPresent(secretResource -> {
service.getNode().addDependency(secretResource);
});
// Set task definition in context first (needed by ContainerFactory)
// Note: EFS permissions are handled by IAMRules based on security profile
ctx.fargateTaskDef.set(taskDef);
// Add EFS volume to task definition (needed by ContainerFactory)
String volumeName = applicationSpec != null ? applicationSpec.volumeName() : "jenkinsHome";
ctx.fargateTaskDef.get().orElseThrow().addVolume(Volume.builder()
.name(volumeName)
.efsVolumeConfiguration(EfsVolumeConfiguration.builder()
.fileSystemId(efs.getFileSystemId())
.transitEncryption("ENABLED")
.authorizationConfig(AuthorizationConfig.builder()
.accessPointId(ap.getAccessPointId())
.iam("ENABLED")
.build())
.build())
.build());
// Create container (now that task definition and volume are available)
// Get container image from ApplicationSpec or use default
String image = applicationSpec != null ? applicationSpec.defaultContainerImage() : "jenkins/jenkins:lts";
// Allow DeploymentContext.containerImage to override the tag portion (after ':')
if (this.containerImage != null && !this.containerImage.isBlank()) {
int colonIndex = image.lastIndexOf(':');
String baseImage = colonIndex > 0 ? image.substring(0, colonIndex) : image;
image = baseImage + ":" + this.containerImage;
}
ContainerFactory containerFactory = new ContainerFactory(this, getNode().getId() + "Container", ContainerImage.fromRegistry(image));
containerFactory.create();
// Now set the service in context after container is created
ctx.fargateService.set(service);
// Configure security group rules (migrated from JenkinsBootstrap)
configureSecurityGroupRules(serviceSg);
// Create CloudFormation output for application URL (migrated from JenkinsBootstrap)
createApplicationUrlOutput();
// Note: Auto-scaling is handled by JenkinsServiceTopologyConfiguration
// to avoid conflicts with duplicate auto-scaling configuration
}
/**
* Configure security group rules for Fargate service.
* Allows EFS access from Fargate and HTTP traffic from ALB.
*
* <p>This logic was migrated from JenkinsBootstrap to consolidate
* Fargate-specific configuration in one place.</p>
*/
private void configureSecurityGroupRules(SecurityGroup serviceSg) {
// NFS traffic (Fargate -> EFS) is handled by security profile configurations
// (DevSecurityConfiguration, StagingSecurityConfiguration, ProductionSecurityConfiguration)
// Allow HTTP traffic from ALB to Fargate service
if (albSg != null) {
int appPort = applicationSpec != null ? applicationSpec.applicationPort() : 8080;
serviceSg.addIngressRule(albSg, Port.tcp(appPort), "HTTP_from_ALB", false);
}
// Allow database traffic from Fargate service to RDS (for applications with external database)
ctx.dbSecurityGroup.get().ifPresent(dbSg -> {
ctx.dbConnection.get().ifPresent(dbConn -> {
int dbPort = dbConn.port();
dbSg.addIngressRule(serviceSg, Port.tcp(dbPort), "Database_from_Fargate_service", false);
LOG.info("Added security group rule: Fargate -> RDS on port " + dbPort);
});
});
// Add security group rules for optional inbound ports
// These are NOT exposed by default - must be explicitly enabled via deployment config
if (applicationSpec != null) {
for (ApplicationSpec.OptionalPort optionalPort : applicationSpec.optionalPorts()) {
// Only add ingress rules for inbound ports that are enabled
if (optionalPort.inbound() && isOptionalPortEnabled(optionalPort.configKey())) {
Port port = optionalPort.protocol().equals("udp")
? Port.udp(optionalPort.port())
: Port.tcp(optionalPort.port());
// Allow from anywhere for optional service ports (e.g., SSH, JNLP agents)
serviceSg.addIngressRule(
software.amazon.awscdk.services.ec2.Peer.anyIpv4(),
port,
optionalPort.service().replace(" ", "_") + "_inbound",
false
);
LOG.info(" ✅ Added security group rule for optional port: " +
optionalPort.port() + "/" + optionalPort.protocol() +
" (" + optionalPort.service() + ")");
}
}
}
}
/**
* Check if an optional port is enabled based on the config key.
*/
private boolean isOptionalPortEnabled(String configKey) {
return switch (configKey) {
case "enableAgents" -> Boolean.TRUE.equals(enableAgents);
case "enableSsh" -> Boolean.TRUE.equals(enableSsh);
case "enableSmtp" -> Boolean.TRUE.equals(enableSmtp);
case "enableSmtps" -> Boolean.TRUE.equals(enableSmtps);
case "enableClustering" -> Boolean.TRUE.equals(enableClustering);
case "enableDockerRegistry" -> Boolean.TRUE.equals(enableDockerRegistry);
case "enableMetrics" -> Boolean.TRUE.equals(enableMetrics);
case "enableNotary" -> Boolean.TRUE.equals(enableNotary);
case "enableTrivy" -> Boolean.TRUE.equals(enableTrivy);
case "enableSentinel" -> Boolean.TRUE.equals(enableSentinel);
case "enableCluster" -> Boolean.TRUE.equals(enableCluster);
default -> {
LOG.warning("Unknown optional port config key: " + configKey);
yield false;
}
};
}
/**
* Create CloudFormation output for application URL.
* Uses applicationId from ApplicationSpec to create generic output.
*
* <p>This logic was migrated from JenkinsBootstrap and made generic
* to support any application type.</p>
*/
private void createApplicationUrlOutput() {
// Only create output if ALB is available
if (ctx.alb.get().isEmpty()) {
return;
}
String appId = applicationSpec != null ? applicationSpec.applicationId() : "application";
String outputId = appId.substring(0, 1).toUpperCase() + appId.substring(1) + "Url";
String description = appId.substring(0, 1).toUpperCase() + appId.substring(1) + " URL (ALB DNS)";
CfnOutput.Builder.create(this, outputId)
.description(description)
.value("http://" + ctx.alb.get().get().getLoadBalancerDnsName())
.build();
}
}