Initial commit: Spicy CDK automation framework

Jenkins shared library and CDK constructs for AWS infrastructure.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-11-18 22:21:00 -08:00
commit 68684df471
51 changed files with 15587 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
// Export all constructs
export * from './spicy-vpc';
export * from './spicy-ecs-cluster';
export * from './spicy-ecs-service';
export * from './spicy-alb';

View File

@@ -0,0 +1,304 @@
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as route53Targets from 'aws-cdk-lib/aws-route53-targets';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
/**
* Tags for the ALB
*/
export interface SpicyAlbTags {
owner: string;
product: string;
component: string;
environment: string;
build?: string;
}
/**
* Blue/Green DNS configuration for persistent ALB
*/
export interface PersistentAlbBlueGreenDnsConfig {
/** Active hostname (e.g., api.example.com) */
activeHostname: string;
/** Inactive hostname (e.g., inactive-api.example.com) */
inactiveHostname: string;
/** Route53 hosted zone ID */
hostedZoneId: string;
}
/**
* Properties for the SpicyAlb construct
*/
export interface SpicyAlbProps {
/** The VPC to deploy the ALB into */
readonly vpc: ec2.IVpc;
/** VPC CIDR block (required for internal ALB security group rules) */
readonly vpcCidrBlock?: string;
/** ALB scheme: "internet-facing" or "internal" */
readonly scheme: 'internet-facing' | 'internal';
/** Subnets for the ALB (either subnet objects or subnet IDs) */
readonly subnets: ec2.ISubnet[] | string[];
/** Availability zones (required if subnets are provided as IDs) */
readonly availabilityZones?: string[];
/** SSL certificate ARN for HTTPS listener */
readonly certificateArn?: string;
/** Idle timeout in seconds */
readonly idleTimeout?: number;
/** Additional security groups for the ALB */
readonly additionalSecurityGroups?: ec2.ISecurityGroup[];
/** S3 bucket for ALB access logs */
readonly logsBucket?: s3.IBucket;
/** S3 prefix for ALB access logs */
readonly logsPrefix?: string;
/** Enable HTTP→HTTPS redirect */
readonly redirectHttpToHttps?: boolean;
/** Blue/Green DNS configuration */
readonly blueGreenDns?: PersistentAlbBlueGreenDnsConfig;
/** Required tags */
readonly tags: SpicyAlbTags;
}
/**
* SpicyAlb - A persistent ALB construct for blue/green deployments.
*
* This ALB is designed to be deployed in a separate, long-lived stack
* (like the old bg-common pattern) so the ALB DNS name stays constant
* even when service stacks are deleted and recreated.
*/
export class SpicyAlb extends Construct {
/** The Application Load Balancer */
public readonly loadBalancer: elbv2.ApplicationLoadBalancer;
/** HTTP listener */
public readonly httpListener: elbv2.ApplicationListener;
/** HTTPS listener (if certificate provided) */
public readonly httpsListener?: elbv2.ApplicationListener;
/** ALB security group */
public readonly securityGroup: ec2.SecurityGroup;
/** ALB scheme */
private readonly scheme: 'internet-facing' | 'internal';
constructor(scope: Construct, id: string, props: SpicyAlbProps) {
super(scope, id);
const stack = cdk.Stack.of(this);
// Resolve subnets - handle both subnet objects and subnet IDs
let subnets: ec2.ISubnet[];
if (props.subnets.length > 0 && typeof props.subnets[0] === 'string') {
// Subnet IDs provided - create subnet references
const subnetIds = props.subnets as string[];
const availabilityZones = props.availabilityZones ?? props.vpc.availabilityZones;
subnets = subnetIds.map((subnetId, index) =>
ec2.Subnet.fromSubnetAttributes(this, `ALBSubnet${index}`, {
subnetId,
availabilityZone: availabilityZones[index % availabilityZones.length],
})
);
} else {
// Subnet objects provided
subnets = props.subnets as ec2.ISubnet[];
}
// ALB Security Group
this.securityGroup = new ec2.SecurityGroup(this, 'ALBSecurityGroup', {
vpc: props.vpc,
description: `Security group for persistent ALB ${stack.stackName}`,
allowAllOutbound: true,
});
(this.securityGroup.node.defaultChild as ec2.CfnSecurityGroup).overrideLogicalId('ALBSecurityGroup');
// Add ingress rules based on scheme
if (props.scheme === 'internet-facing') {
this.securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'Allow HTTP from internet');
this.securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443), 'Allow HTTPS from internet');
} else {
// Internal ALB - allow from VPC
const vpcCidrBlock = props.vpcCidrBlock ?? props.vpc.vpcCidrBlock ?? '10.0.0.0/16';
this.securityGroup.addIngressRule(ec2.Peer.ipv4(vpcCidrBlock), ec2.Port.tcp(80), 'Allow HTTP from VPC');
this.securityGroup.addIngressRule(ec2.Peer.ipv4(vpcCidrBlock), ec2.Port.tcp(443), 'Allow HTTPS from VPC');
}
// Add additional security groups if provided
const securityGroups = [this.securityGroup, ...(props.additionalSecurityGroups ?? [])];
// Store scheme
this.scheme = props.scheme;
// Create ALB
this.loadBalancer = new elbv2.ApplicationLoadBalancer(this, 'LoadBalancer', {
vpc: props.vpc,
internetFacing: props.scheme === 'internet-facing',
securityGroup: this.securityGroup,
vpcSubnets: { subnets },
idleTimeout: cdk.Duration.seconds(props.idleTimeout ?? 60),
});
(this.loadBalancer.node.defaultChild as elbv2.CfnLoadBalancer).overrideLogicalId('LoadBalancer');
// Enable access logs if bucket provided
if (props.logsBucket) {
const prefix = props.logsPrefix ?? `${stack.stackName}`;
this.loadBalancer.logAccessLogs(props.logsBucket, prefix);
}
// HTTP Listener
this.httpListener = this.loadBalancer.addListener('HTTPListener', {
port: 80,
protocol: elbv2.ApplicationProtocol.HTTP,
});
// HTTPS Listener (if certificate provided)
if (props.certificateArn) {
this.httpsListener = this.loadBalancer.addListener('HTTPSListener', {
port: 443,
protocol: elbv2.ApplicationProtocol.HTTPS,
certificates: [elbv2.ListenerCertificate.fromArn(props.certificateArn)],
defaultAction: elbv2.ListenerAction.fixedResponse(404, {
contentType: 'text/plain',
messageBody: 'No target groups configured',
}),
});
// HTTP→HTTPS redirect (if enabled, default true)
if (props.redirectHttpToHttps !== false) {
this.httpListener.addAction('RedirectToHTTPS', {
action: elbv2.ListenerAction.redirect({
protocol: 'HTTPS',
port: '443',
permanent: true,
}),
});
}
}
// Blue/Green DNS configuration
if (props.blueGreenDns) {
this.configureBlueGreenDns(props.blueGreenDns);
}
// Apply tags
this.applyTags(props.tags);
// Add outputs
this.addOutputs();
}
/**
* Configure Blue/Green DNS (active and inactive hostnames)
*/
private configureBlueGreenDns(dns: PersistentAlbBlueGreenDnsConfig): void {
// Extract domain from hostname (e.g., "api.example.com" -> "example.com")
const domainParts = dns.activeHostname.split('.');
const zoneName = domainParts.slice(-2).join('.');
const activeRecordName = domainParts.slice(0, -2).join('.') || '@';
const inactiveRecordName = dns.inactiveHostname.split('.').slice(0, -2).join('.') || '@';
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', {
hostedZoneId: dns.hostedZoneId,
zoneName: zoneName,
});
// Active hostname DNS record
new route53.ARecord(this, 'ActiveDnsRecord', {
zone: hostedZone,
recordName: activeRecordName === '@' ? undefined : activeRecordName,
target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(this.loadBalancer)),
});
// Inactive hostname DNS record
new route53.ARecord(this, 'InactiveDnsRecord', {
zone: hostedZone,
recordName: inactiveRecordName === '@' ? undefined : inactiveRecordName,
target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(this.loadBalancer)),
});
}
/**
* Apply tags to all resources
*/
private applyTags(tags: SpicyAlbTags): void {
const stack = cdk.Stack.of(this);
cdk.Tags.of(this).add('Name', stack.stackName);
cdk.Tags.of(this).add('Owner', tags.owner);
cdk.Tags.of(this).add('Product', tags.product);
cdk.Tags.of(this).add('Component', tags.component);
cdk.Tags.of(this).add('Environment', tags.environment);
if (tags.build) {
cdk.Tags.of(this).add('Build', tags.build);
}
}
/**
* Add CloudFormation outputs
*/
private addOutputs(): void {
const stack = cdk.Stack.of(this);
const scheme = this.scheme;
const prefix = scheme === 'internet-facing' ? 'internet-facing' : 'internal';
const albDnsOutput = new cdk.CfnOutput(this, 'LoadBalancerDNSOutput', {
value: this.loadBalancer.loadBalancerDnsName,
description: `The DNS name of the ${scheme} load balancer`,
exportName: `${stack.stackName}-${prefix}-url`,
});
albDnsOutput.overrideLogicalId('LoadBalancerDNS');
const albArnOutput = new cdk.CfnOutput(this, 'LoadBalancerArnOutput', {
value: this.loadBalancer.loadBalancerArn,
description: `The ARN of the ${scheme} load balancer`,
exportName: `${stack.stackName}-${prefix}-arn`,
});
albArnOutput.overrideLogicalId('LoadBalancerArn');
const albHostedZoneOutput = new cdk.CfnOutput(this, 'LoadBalancerHostedZoneIdOutput', {
value: this.loadBalancer.loadBalancerCanonicalHostedZoneId,
description: `The hosted zone ID of the ${scheme} load balancer`,
exportName: `${stack.stackName}-${prefix}-hosted-zone-id`,
});
albHostedZoneOutput.overrideLogicalId('LoadBalancerHostedZoneId');
const httpListenerOutput = new cdk.CfnOutput(this, 'HTTPListenerArnOutput', {
value: this.httpListener.listenerArn,
description: `The ARN of the ${scheme} HTTP listener`,
exportName: `${stack.stackName}-${prefix}-http-listener`,
});
httpListenerOutput.overrideLogicalId('HTTPListenerArn');
if (this.httpsListener) {
const httpsListenerOutput = new cdk.CfnOutput(this, 'HTTPSListenerArnOutput', {
value: this.httpsListener.listenerArn,
description: `The ARN of the ${scheme} HTTPS listener`,
exportName: `${stack.stackName}-${prefix}-https-listener`,
});
httpsListenerOutput.overrideLogicalId('HTTPSListenerArn');
}
const securityGroupOutput = new cdk.CfnOutput(this, 'SecurityGroupIdOutput', {
value: this.securityGroup.securityGroupId,
description: `The security group ID of the ${scheme} load balancer`,
exportName: `${stack.stackName}-${prefix}-security-group`,
});
securityGroupOutput.overrideLogicalId('SecurityGroupId');
}
}

View File

@@ -0,0 +1,970 @@
import * as autoscaling from 'aws-cdk-lib/aws-autoscaling';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
/**
* Configuration for VPC tagging
*/
export interface SpicyEcsClusterTags {
owner: string;
product: string;
component: string;
environment: string;
build?: string;
}
/**
* Scaling configuration for the ECS cluster
*/
export interface ClusterScalingConfig {
/** Minimum number of instances */
minCapacity?: number;
/** Maximum number of instances */
maxCapacity?: number;
/** Target capacity utilization percentage for managed scaling (0-100) */
targetCapacityPercent?: number;
/** Cooldown period in seconds after scale up */
scaleUpCooldown?: number;
/** Cooldown period in seconds after scale down */
scaleDownCooldown?: number;
}
/**
* Spot instance configuration
*/
export interface SpotConfig {
/** Enable Spot instances */
enabled: boolean;
/** Percentage of On-Demand instances to maintain (0-100) */
onDemandPercentage?: number;
/** Spot allocation strategy */
spotAllocationStrategy?: 'lowest-price' | 'capacity-optimized' | 'capacity-optimized-prioritized';
/** Maximum Spot price (leave undefined for on-demand price) */
maxSpotPrice?: string;
}
/**
* Load balancer configuration
*/
export interface LoadBalancerConfig {
/** Create an internet-facing load balancer */
createExternal?: boolean;
/** Create an internal load balancer */
createInternal?: boolean;
/** SSL certificate ARN for HTTPS listeners */
certificateArn?: string;
/** Idle timeout in seconds */
idleTimeout?: number;
/** Enable access logging */
enableAccessLogs?: boolean;
}
/**
* Properties for the SpicyEcsCluster construct
*/
export interface SpicyEcsClusterProps {
/**
* The VPC to deploy the cluster into
*/
readonly vpc: ec2.IVpc;
/**
* VPC CIDR block (required for imported VPCs)
* @default Uses vpc.vpcCidrBlock if available
*/
readonly vpcCidrBlock?: string;
/**
* Subnets for EC2 instances (private subnets recommended)
*/
readonly instanceSubnets?: ec2.SubnetSelection;
/**
* Subnets for external load balancer (public subnets)
*/
readonly externalSubnets?: ec2.SubnetSelection;
/**
* Subnets for internal load balancer (private subnets)
*/
readonly internalSubnets?: ec2.SubnetSelection;
/**
* EC2 instance type for the cluster
* @default m5a.large
*/
readonly instanceType?: ec2.InstanceType;
/**
* Additional instance types for mixed instances policy
* Used with Spot instances for better availability
*/
readonly additionalInstanceTypes?: ec2.InstanceType[];
/**
* EC2 Key Pair name for SSH access
*/
readonly keyName?: string;
/**
* EBS volume size in GB
* @default 100
*/
readonly ebsVolumeSize?: number;
/**
* Enable Container Insights
* @default true
*/
readonly containerInsights?: boolean;
/**
* Scaling configuration
*/
readonly scaling?: ClusterScalingConfig;
/**
* Spot instance configuration
*/
readonly spot?: SpotConfig;
/**
* Load balancer configuration
*/
readonly loadBalancer?: LoadBalancerConfig;
/**
* Additional security groups for EC2 instances
*/
readonly additionalSecurityGroups?: ec2.ISecurityGroup[];
/**
* Enable Fargate capacity providers (adds both FARGATE and FARGATE_SPOT)
* @default false
*/
readonly enableFargate?: boolean;
/**
* Timeout in seconds for draining tasks before termination
* @default 900
*/
readonly drainingTimeout?: number;
/**
* Maximum instance lifetime in seconds (for instance refresh)
* @default 604800 (7 days)
*/
readonly maxInstanceLifetime?: number;
/**
* ELB Account ID for access logs (region-specific)
* See: https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/enable-access-logs.html
*/
readonly elbAccountId?: string;
/**
* Custom S3 bucket name for ALB logs
*/
readonly logsBucketName?: string;
/**
* VPC stack name (for exporting to other stacks)
* Used to export the VPC stack name so other stacks can import VPC details
*/
readonly vpcStackName?: string;
/**
* Required tags for the cluster and resources
*/
readonly tags: SpicyEcsClusterTags;
}
/**
* ELB Account IDs by region for access logging
*/
const ELB_ACCOUNT_IDS: Record<string, string> = {
'us-east-1': '127311923021',
'us-east-2': '033677994240',
'us-west-1': '027434742980',
'us-west-2': '797873946194',
'ca-central-1': '985666609251',
'eu-west-1': '156460612806',
'eu-west-2': '652711504416',
'eu-west-3': '009996457667',
'eu-central-1': '054676820928',
'ap-northeast-1': '582318560864',
'ap-northeast-2': '600734575887',
'ap-southeast-1': '114774131450',
'ap-southeast-2': '783225319266',
'ap-south-1': '718504428378',
'sa-east-1': '507241528517',
};
/**
* SpicyEcsCluster - A production-ready ECS cluster with:
* - EC2 Capacity Provider with managed scaling
* - Optional Fargate capacity providers
* - Mixed instances policy for Spot support
* - Instance draining on termination
* - Optional internal/external ALBs
* - Container Insights
* - Launch Templates with IMDSv2 and gp3 volumes
*/
export class SpicyEcsCluster extends Construct {
/** The ECS cluster */
public readonly cluster: ecs.Cluster;
/** The Auto Scaling Group */
public readonly autoScalingGroup: autoscaling.AutoScalingGroup;
/** EC2 Capacity Provider */
public readonly ec2CapacityProvider: ecs.AsgCapacityProvider;
/** External (internet-facing) load balancer */
public readonly externalLoadBalancer?: elbv2.ApplicationLoadBalancer;
/** Internal load balancer */
public readonly internalLoadBalancer?: elbv2.ApplicationLoadBalancer;
/** External HTTPS listener */
public readonly externalHttpsListener?: elbv2.ApplicationListener;
/** External HTTP listener */
public readonly externalHttpListener?: elbv2.ApplicationListener;
/** Internal HTTPS listener */
public readonly internalHttpsListener?: elbv2.ApplicationListener;
/** Internal HTTP listener */
public readonly internalHttpListener?: elbv2.ApplicationListener;
/** ECS Host security group */
public readonly ecsHostSecurityGroup: ec2.SecurityGroup;
/** Load balancer security group */
public readonly loadBalancerSecurityGroup?: ec2.SecurityGroup;
/** S3 bucket for ALB access logs */
public readonly logsBucket?: s3.Bucket;
constructor(scope: Construct, id: string, props: SpicyEcsClusterProps) {
super(scope, id);
const stack = cdk.Stack.of(this);
const region = stack.region;
// Default values
const instanceType = props.instanceType ?? ec2.InstanceType.of(ec2.InstanceClass.M5A, ec2.InstanceSize.LARGE);
const ebsVolumeSize = props.ebsVolumeSize ?? 100;
const containerInsights = props.containerInsights ?? true;
const drainingTimeout = props.drainingTimeout ?? 900;
const maxInstanceLifetime = props.maxInstanceLifetime ?? 604800;
const scaling = {
minCapacity: props.scaling?.minCapacity ?? 2,
maxCapacity: props.scaling?.maxCapacity ?? 4,
targetCapacityPercent: props.scaling?.targetCapacityPercent ?? 100,
scaleUpCooldown: props.scaling?.scaleUpCooldown ?? 60,
scaleDownCooldown: props.scaling?.scaleDownCooldown ?? 300,
};
const spot = {
enabled: props.spot?.enabled ?? false,
onDemandPercentage: props.spot?.onDemandPercentage ?? 100,
spotAllocationStrategy: props.spot?.spotAllocationStrategy ?? 'capacity-optimized',
};
const loadBalancer = {
createExternal: props.loadBalancer?.createExternal ?? false,
createInternal: props.loadBalancer?.createInternal ?? false,
certificateArn: props.loadBalancer?.certificateArn,
idleTimeout: props.loadBalancer?.idleTimeout ?? 60,
enableAccessLogs: props.loadBalancer?.enableAccessLogs ?? true,
};
// Create ECS Cluster
this.cluster = new ecs.Cluster(this, 'Cluster', {
vpc: props.vpc,
clusterName: stack.stackName,
enableFargateCapacityProviders: false,
});
// Enable Container Insights if requested
if (containerInsights) {
const cfnCluster = this.cluster.node.defaultChild as ecs.CfnCluster;
cfnCluster.configuration = {
executeCommandConfiguration: {
logging: 'DEFAULT',
},
};
cfnCluster.addPropertyOverride('ClusterSettings', [
{
Name: 'containerInsights',
Value: 'enabled',
},
]);
}
(this.cluster.node.defaultChild as ecs.CfnCluster).overrideLogicalId('Cluster');
// Security Groups
this.ecsHostSecurityGroup = new ec2.SecurityGroup(this, 'ECSHostSecurityGroup', {
vpc: props.vpc,
description: 'Security group for ECS container instances',
allowAllOutbound: true,
});
(this.ecsHostSecurityGroup.node.defaultChild as ec2.CfnSecurityGroup).overrideLogicalId('ECSHostSecurityGroup');
// Allow internal VPC traffic
const vpcCidrBlock = props.vpcCidrBlock ?? props.vpc.vpcCidrBlock;
if (vpcCidrBlock) {
this.ecsHostSecurityGroup.addIngressRule(
ec2.Peer.ipv4(vpcCidrBlock),
ec2.Port.allTraffic(),
'Allow internal VPC traffic'
);
}
// IAM Role for EC2 instances
const instanceRole = new iam.Role(this, 'InstanceRole', {
assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonEC2ContainerServiceforEC2Role'),
iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore'),
],
});
(instanceRole.node.defaultChild as iam.CfnRole).overrideLogicalId('InstanceRole');
// Additional permissions for ECS instances
instanceRole.addToPolicy(
new iam.PolicyStatement({
actions: [
'logs:CreateLogGroup',
'logs:CreateLogStream',
'logs:PutLogEvents',
'logs:DescribeLogGroups',
'logs:DescribeLogStreams',
],
resources: ['*'],
})
);
instanceRole.addToPolicy(
new iam.PolicyStatement({
actions: ['ec2:DescribeVolumes', 'ec2:CreateTags'],
resources: ['*'],
})
);
instanceRole.addToPolicy(
new iam.PolicyStatement({
actions: ['ecs:UpdateContainerInstancesState'],
resources: ['*'],
})
);
// User data script
const userData = ec2.UserData.forLinux();
userData.addCommands(
'#!/bin/bash',
'set -e',
'',
'# Install SSM agent and other utilities',
'yum install -y amazon-ssm-agent',
'systemctl enable amazon-ssm-agent',
'systemctl start amazon-ssm-agent',
'',
`# Configure ECS agent`,
`echo "ECS_CLUSTER=${stack.stackName}" >> /etc/ecs/ecs.config`,
'echo "ECS_ENABLE_SPOT_INSTANCE_DRAINING=true" >> /etc/ecs/ecs.config',
'echo "ECS_ENABLE_CONTAINER_METADATA=true" >> /etc/ecs/ecs.config',
'echo \'ECS_AVAILABLE_LOGGING_DRIVERS=["json-file","awslogs","splunk"]\' >> /etc/ecs/ecs.config',
'',
'# Signal success',
`/opt/aws/bin/cfn-signal -e $? --stack ${stack.stackName} --resource AutoScalingGroup --region ${region}`
);
// Create Auto Scaling Group with Launch Template
this.autoScalingGroup = new autoscaling.AutoScalingGroup(this, 'AutoScalingGroup', {
vpc: props.vpc,
vpcSubnets: props.instanceSubnets ?? { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
instanceType: instanceType,
machineImage: ecs.EcsOptimizedImage.amazonLinux2(),
role: instanceRole,
minCapacity: scaling.minCapacity,
maxCapacity: scaling.maxCapacity,
maxInstanceLifetime: cdk.Duration.seconds(maxInstanceLifetime),
healthChecks: autoscaling.HealthChecks.ec2({
gracePeriod: cdk.Duration.minutes(5),
}),
updatePolicy: autoscaling.UpdatePolicy.rollingUpdate({
maxBatchSize: 1,
minInstancesInService: 1,
pauseTime: cdk.Duration.minutes(5),
}),
signals: autoscaling.Signals.waitForMinCapacity({
timeout: cdk.Duration.minutes(15),
}),
userData: userData,
blockDevices: [
{
deviceName: '/dev/xvda',
volume: autoscaling.BlockDeviceVolume.ebs(ebsVolumeSize, {
volumeType: autoscaling.EbsDeviceVolumeType.GP3,
encrypted: true,
}),
},
],
keyPair: props.keyName ? ec2.KeyPair.fromKeyPairName(this, 'KeyPair', props.keyName) : undefined,
securityGroup: this.ecsHostSecurityGroup,
});
(this.autoScalingGroup.node.defaultChild as autoscaling.CfnAutoScalingGroup).overrideLogicalId('AutoScalingGroup');
// Add additional security groups
if (props.additionalSecurityGroups) {
for (const sg of props.additionalSecurityGroups) {
this.autoScalingGroup.addSecurityGroup(sg);
}
}
// Configure Mixed Instances Policy for Spot support
if (spot.enabled) {
const cfnAsg = this.autoScalingGroup.node.defaultChild as autoscaling.CfnAutoScalingGroup;
// Get the launch template from the ASG
const launchTemplate = this.autoScalingGroup.node.findChild('LaunchTemplate') as ec2.LaunchTemplate;
const cfnLaunchTemplate = launchTemplate.node.defaultChild as ec2.CfnLaunchTemplate;
// Build instance type overrides
const instanceTypes = [instanceType, ...(props.additionalInstanceTypes ?? [])];
const overrides = instanceTypes.map((it) => ({
instanceType: it.toString(),
}));
cfnAsg.mixedInstancesPolicy = {
launchTemplate: {
launchTemplateSpecification: {
launchTemplateId: cfnLaunchTemplate.ref,
version: cfnLaunchTemplate.attrLatestVersionNumber,
},
overrides: overrides,
},
instancesDistribution: {
onDemandBaseCapacity: 0,
onDemandPercentageAboveBaseCapacity: spot.onDemandPercentage,
spotAllocationStrategy: spot.spotAllocationStrategy,
spotMaxPrice: props.spot?.maxSpotPrice,
},
};
// Remove the direct launch template reference since we're using mixed instances
cfnAsg.launchTemplate = undefined;
cfnAsg.launchConfigurationName = undefined;
}
// Enable IMDSv2
const asgLaunchTemplate = this.autoScalingGroup.node.tryFindChild('LaunchTemplate');
if (asgLaunchTemplate) {
const cfnLt = asgLaunchTemplate.node.defaultChild as ec2.CfnLaunchTemplate;
cfnLt.addPropertyOverride('LaunchTemplateData.MetadataOptions', {
HttpTokens: 'required',
HttpPutResponseHopLimit: 2,
HttpEndpoint: 'enabled',
});
}
// Create EC2 Capacity Provider with managed scaling
this.ec2CapacityProvider = new ecs.AsgCapacityProvider(this, 'EC2CapacityProvider', {
autoScalingGroup: this.autoScalingGroup,
enableManagedScaling: true,
enableManagedTerminationProtection: true,
targetCapacityPercent: scaling.targetCapacityPercent,
capacityProviderName: `${stack.stackName}-ec2`,
});
// Override capacity provider logical ID (the child is named 'EC2CapacityProvider')
const cfnCapacityProvider = this.ec2CapacityProvider.node.tryFindChild(
'EC2CapacityProvider'
) as ecs.CfnCapacityProvider;
if (cfnCapacityProvider) {
cfnCapacityProvider.overrideLogicalId('EC2CapacityProvider');
}
// Override Launch Template and Instance Profile logical IDs
if (asgLaunchTemplate) {
const cfnLaunchTemplate = asgLaunchTemplate.node.defaultChild as ec2.CfnLaunchTemplate;
cfnLaunchTemplate.overrideLogicalId('LaunchTemplate');
}
const instanceProfile = this.autoScalingGroup.node.tryFindChild('InstanceProfile');
if (instanceProfile) {
(instanceProfile as iam.CfnInstanceProfile).overrideLogicalId('InstanceProfile');
}
// Add EC2 capacity provider to cluster
this.cluster.addAsgCapacityProvider(this.ec2CapacityProvider);
// Add Fargate capacity providers if enabled
if (props.enableFargate) {
this.cluster.enableFargateCapacityProviders();
}
// Instance draining Lambda
this.createDrainingLambda(props, drainingTimeout);
// Load Balancers
if (loadBalancer.createExternal || loadBalancer.createInternal) {
this.loadBalancerSecurityGroup = new ec2.SecurityGroup(this, 'LoadBalancerSecurityGroup', {
vpc: props.vpc,
description: 'Security group for ALBs',
allowAllOutbound: true,
});
(this.loadBalancerSecurityGroup.node.defaultChild as ec2.CfnSecurityGroup).overrideLogicalId(
'LoadBalancerSecurityGroup'
);
// Allow ALB to communicate with ECS hosts
this.ecsHostSecurityGroup.addIngressRule(
this.loadBalancerSecurityGroup,
ec2.Port.allTraffic(),
'Allow traffic from ALB'
);
// Create logs bucket if access logs enabled
if (loadBalancer.enableAccessLogs) {
const elbAccountId = props.elbAccountId ?? ELB_ACCOUNT_IDS[region] ?? ELB_ACCOUNT_IDS['ca-central-1'];
this.logsBucket = new s3.Bucket(this, 'LogsBucket', {
bucketName: props.logsBucketName ?? `${stack.stackName}-${region}-alb-logs`,
encryption: s3.BucketEncryption.S3_MANAGED,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
lifecycleRules: [
{
expiration: cdk.Duration.days(365),
},
],
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
(this.logsBucket.node.defaultChild as s3.CfnBucket).overrideLogicalId('LogsBucket');
this.logsBucket.addToResourcePolicy(
new iam.PolicyStatement({
actions: ['s3:PutObject'],
resources: [`${this.logsBucket.bucketArn}/*`],
principals: [new iam.AccountPrincipal(elbAccountId)],
})
);
}
if (loadBalancer.createExternal) {
this.createExternalLoadBalancer(props, loadBalancer);
}
if (loadBalancer.createInternal) {
this.createInternalLoadBalancer(props, loadBalancer);
}
}
// Apply tags
this.applyTags(props.tags);
// Add outputs
this.addOutputs(props);
}
/**
* Create instance draining Lambda function
*/
private createDrainingLambda(props: SpicyEcsClusterProps, drainingTimeout: number): void {
const stack = cdk.Stack.of(this);
// SNS Topic for ASG lifecycle events
const lifecycleTopic = new sns.Topic(this, 'LifecycleTopic', {
displayName: `${stack.stackName}-lifecycle`,
});
(lifecycleTopic.node.defaultChild as sns.CfnTopic).overrideLogicalId('LifecycleTopic');
// Lambda execution role
const lambdaRole = new iam.Role(this, 'DrainingLambdaRole', {
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')],
});
(lambdaRole.node.defaultChild as iam.CfnRole).overrideLogicalId('DrainingLambdaRole');
lambdaRole.addToPolicy(
new iam.PolicyStatement({
actions: [
'autoscaling:CompleteLifecycleAction',
'autoscaling:RecordLifecycleActionHeartbeat',
'ecs:ListContainerInstances',
'ecs:DescribeContainerInstances',
'ecs:UpdateContainerInstancesState',
'sns:Publish',
],
resources: ['*'],
})
);
// Draining Lambda function
const drainingLambda = new lambda.Function(this, 'DrainingLambda', {
runtime: lambda.Runtime.PYTHON_3_11,
handler: 'index.lambda_handler',
role: lambdaRole,
timeout: cdk.Duration.seconds(60),
memorySize: 128,
description: 'Gracefully drain ECS tasks before instance termination',
logGroup: new logs.LogGroup(this, 'DrainingLambdaLogGroup', {
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY,
}),
code: lambda.Code.fromInline(`
import datetime
import json
import time
import boto3
CLUSTER = '${stack.stackName}'
TIMEOUT = ${drainingTimeout}
REGION = '${stack.region}'
def aws(svc):
return boto3.client(svc, region_name=REGION)
ASG = aws('autoscaling')
ECS = aws('ecs')
SNS = aws('sns')
def lookup_instance(msg):
res = ECS.list_container_instances(cluster=CLUSTER, filter='ec2InstanceId == %s' % (msg['EC2InstanceId']))
if not res['containerInstanceArns']:
return None, None, 0
res = ECS.describe_container_instances(cluster=CLUSTER, containerInstances=res['containerInstanceArns'])
ret = (res['containerInstances'][0]['containerInstanceArn'], res['containerInstances'][0]['status'], res['containerInstances'][0]['runningTasksCount'])
print('Found: %s %s' % (str(ret), msg))
return ret
def can_terminate(msg):
(arn, status, count) = lookup_instance(msg)
if arn is None:
print('Cannot lookup: %s' % (msg))
return True
if status != 'DRAINING':
print('Draining: %s' % (msg))
ECS.update_container_instances_state(cluster=CLUSTER, containerInstances=[arn], status='DRAINING')
return False
if count == 0:
print('Finished draining: %s' % (msg))
return True
now = datetime.datetime.now().timestamp()
if msg['instance_timeout'] < now:
print('Timed out: %s' % (msg))
return True
return False
def lambda_handler(event, context):
msg = json.loads(event['Records'][0]['Sns']['Message'])
if 'instance_timeout' not in msg:
msg['instance_timeout'] = (datetime.datetime.now() + datetime.timedelta(seconds=TIMEOUT)).timestamp()
if 'LifecycleTransition' not in msg.keys() or msg['LifecycleTransition'].find('autoscaling:EC2_INSTANCE_TERMINATING') == -1:
print('Unknown transition: %s' % (msg))
return
if can_terminate(msg):
print('ASG complete: %s' % (msg))
ASG.complete_lifecycle_action(LifecycleHookName=msg['LifecycleHookName'], AutoScalingGroupName=msg['AutoScalingGroupName'], LifecycleActionResult='CONTINUE', InstanceId=msg['EC2InstanceId'])
return
print('Tasks are still running: %s' % (msg))
time.sleep(20)
ASG.record_lifecycle_action_heartbeat(LifecycleHookName=msg['LifecycleHookName'], AutoScalingGroupName=msg['AutoScalingGroupName'], LifecycleActionToken=msg['LifecycleActionToken'], InstanceId=msg['EC2InstanceId'])
SNS.publish(TopicArn=event['Records'][0]['Sns']['TopicArn'], Message=json.dumps(msg), Subject='Retry')
`),
});
(drainingLambda.node.defaultChild as lambda.CfnFunction).overrideLogicalId('DrainingLambda');
// Subscribe Lambda to SNS topic
lifecycleTopic.addSubscription(new subscriptions.LambdaSubscription(drainingLambda));
// Lifecycle hook role
const lifecycleRole = new iam.Role(this, 'LifecycleRole', {
assumedBy: new iam.ServicePrincipal('autoscaling.amazonaws.com'),
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AutoScalingNotificationAccessRole')],
});
(lifecycleRole.node.defaultChild as iam.CfnRole).overrideLogicalId('LifecycleRole');
// Add lifecycle hook
this.autoScalingGroup.addLifecycleHook('TerminationHook', {
lifecycleTransition: autoscaling.LifecycleTransition.INSTANCE_TERMINATING,
notificationTarget: new (class implements autoscaling.ILifecycleHookTarget {
bind(_scope: Construct, _options: autoscaling.BindHookTargetOptions): autoscaling.LifecycleHookTargetConfig {
return {
notificationTargetArn: lifecycleTopic.topicArn,
createdRole: lifecycleRole,
};
}
})(),
defaultResult: autoscaling.DefaultResult.ABANDON,
heartbeatTimeout: cdk.Duration.seconds(120),
});
}
/**
* Create external (internet-facing) load balancer
*/
private createExternalLoadBalancer(
props: SpicyEcsClusterProps,
config: { certificateArn?: string; idleTimeout: number; enableAccessLogs: boolean }
): void {
this.loadBalancerSecurityGroup!.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'Allow HTTP');
this.loadBalancerSecurityGroup!.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443), 'Allow HTTPS');
(this as { externalLoadBalancer: elbv2.ApplicationLoadBalancer }).externalLoadBalancer =
new elbv2.ApplicationLoadBalancer(this, 'ExternalLoadBalancer', {
vpc: props.vpc,
internetFacing: true,
securityGroup: this.loadBalancerSecurityGroup,
vpcSubnets: props.externalSubnets ?? { subnetType: ec2.SubnetType.PUBLIC },
idleTimeout: cdk.Duration.seconds(config.idleTimeout),
});
(this.externalLoadBalancer!.node.defaultChild as elbv2.CfnLoadBalancer).overrideLogicalId('ExternalLoadBalancer');
if (config.enableAccessLogs && this.logsBucket) {
this.externalLoadBalancer!.logAccessLogs(this.logsBucket, 'external');
}
// HTTP Listener
(this as { externalHttpListener: elbv2.ApplicationListener }).externalHttpListener =
this.externalLoadBalancer!.addListener('ExternalHTTP', {
port: 80,
protocol: elbv2.ApplicationProtocol.HTTP,
defaultAction: elbv2.ListenerAction.fixedResponse(404, {
contentType: 'text/plain',
messageBody: 'Not Found',
}),
});
// HTTPS Listener (if certificate provided)
if (config.certificateArn) {
(this as { externalHttpsListener: elbv2.ApplicationListener }).externalHttpsListener =
this.externalLoadBalancer!.addListener('ExternalHTTPS', {
port: 443,
protocol: elbv2.ApplicationProtocol.HTTPS,
certificates: [elbv2.ListenerCertificate.fromArn(config.certificateArn)],
defaultAction: elbv2.ListenerAction.fixedResponse(404, {
contentType: 'text/plain',
messageBody: 'Not Found',
}),
});
}
}
/**
* Create internal load balancer
*/
private createInternalLoadBalancer(
props: SpicyEcsClusterProps,
config: { certificateArn?: string; idleTimeout: number; enableAccessLogs: boolean }
): void {
(this as { internalLoadBalancer: elbv2.ApplicationLoadBalancer }).internalLoadBalancer =
new elbv2.ApplicationLoadBalancer(this, 'InternalLoadBalancer', {
vpc: props.vpc,
internetFacing: false,
securityGroup: this.loadBalancerSecurityGroup,
vpcSubnets: props.internalSubnets ?? { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
idleTimeout: cdk.Duration.seconds(config.idleTimeout),
});
(this.internalLoadBalancer!.node.defaultChild as elbv2.CfnLoadBalancer).overrideLogicalId('InternalLoadBalancer');
if (config.enableAccessLogs && this.logsBucket) {
this.internalLoadBalancer!.logAccessLogs(this.logsBucket, 'internal');
}
// HTTP Listener
(this as { internalHttpListener: elbv2.ApplicationListener }).internalHttpListener =
this.internalLoadBalancer!.addListener('InternalHTTP', {
port: 80,
protocol: elbv2.ApplicationProtocol.HTTP,
defaultAction: elbv2.ListenerAction.fixedResponse(404, {
contentType: 'text/plain',
messageBody: 'Not Found',
}),
});
// HTTPS Listener (if certificate provided)
if (config.certificateArn) {
(this as { internalHttpsListener: elbv2.ApplicationListener }).internalHttpsListener =
this.internalLoadBalancer!.addListener('InternalHTTPS', {
port: 443,
protocol: elbv2.ApplicationProtocol.HTTPS,
certificates: [elbv2.ListenerCertificate.fromArn(config.certificateArn)],
defaultAction: elbv2.ListenerAction.fixedResponse(404, {
contentType: 'text/plain',
messageBody: 'Not Found',
}),
});
}
}
/**
* Apply tags to all resources
*/
private applyTags(tags: SpicyEcsClusterTags): void {
const stack = cdk.Stack.of(this);
cdk.Tags.of(this).add('Name', stack.stackName);
cdk.Tags.of(this).add('Owner', tags.owner);
cdk.Tags.of(this).add('Product', tags.product);
cdk.Tags.of(this).add('Component', tags.component);
cdk.Tags.of(this).add('Environment', tags.environment);
if (tags.build) {
cdk.Tags.of(this).add('Build', tags.build);
}
}
/**
* Add CloudFormation outputs
*/
private addOutputs(props: SpicyEcsClusterProps): void {
const stack = cdk.Stack.of(this);
const clusterNameOutput = new cdk.CfnOutput(this, 'ClusterNameOutput', {
value: this.cluster.clusterName,
description: 'ECS Cluster Name',
exportName: `${stack.stackName}-cluster-name`,
});
clusterNameOutput.overrideLogicalId('ClusterName');
const clusterArnOutput = new cdk.CfnOutput(this, 'ClusterArnOutput', {
value: this.cluster.clusterArn,
description: 'ECS Cluster ARN',
exportName: `${stack.stackName}-cluster-arn`,
});
clusterArnOutput.overrideLogicalId('ClusterArn');
const vpcOutput = new cdk.CfnOutput(this, 'VPCOutput', {
value: props.vpc.vpcId,
description: 'VPC ID',
exportName: `${stack.stackName}-VPC`,
});
vpcOutput.overrideLogicalId('VPC');
// Export VPC stack name if provided (for other stacks to import VPC details)
if (props.vpcStackName) {
const vpcStackNameOutput = new cdk.CfnOutput(this, 'VPCStackNameOutput', {
value: props.vpcStackName,
description: 'VPC Stack Name',
exportName: `${stack.stackName}-VPCStackName`,
});
vpcStackNameOutput.overrideLogicalId('VPCStackName');
}
const sgOutput = new cdk.CfnOutput(this, 'ECSHostSecurityGroupIdOutput', {
value: this.ecsHostSecurityGroup.securityGroupId,
description: 'ECS Host Security Group',
exportName: `${stack.stackName}-ecs-host-security-group`,
});
sgOutput.overrideLogicalId('ECSHostSecurityGroupId');
const asgOutput = new cdk.CfnOutput(this, 'AutoScalingGroupNameOutput', {
value: this.autoScalingGroup.autoScalingGroupName,
description: 'Auto Scaling Group Name',
exportName: `${stack.stackName}-auto-scaling-group`,
});
asgOutput.overrideLogicalId('AutoScalingGroupName');
if (this.externalLoadBalancer) {
const extDnsOutput = new cdk.CfnOutput(this, 'ExternalLoadBalancerDNSOutput', {
value: this.externalLoadBalancer.loadBalancerDnsName,
description: 'External Load Balancer DNS',
exportName: `${stack.stackName}-internet-facing-url`,
});
extDnsOutput.overrideLogicalId('ExternalLoadBalancerDNS');
const extArnOutput = new cdk.CfnOutput(this, 'ExternalLoadBalancerArnOutput', {
value: this.externalLoadBalancer.loadBalancerArn,
description: 'External Load Balancer ARN',
exportName: `${stack.stackName}-internet-facing-arn`,
});
extArnOutput.overrideLogicalId('ExternalLoadBalancerArn');
const extHzOutput = new cdk.CfnOutput(this, 'ExternalLoadBalancerHostedZoneIdOutput', {
value: this.externalLoadBalancer.loadBalancerCanonicalHostedZoneId,
description: 'External Load Balancer Hosted Zone ID',
exportName: `${stack.stackName}-internet-facing-hosted-zone-id`,
});
extHzOutput.overrideLogicalId('ExternalLoadBalancerHostedZoneId');
if (this.externalHttpListener) {
const extHttpOutput = new cdk.CfnOutput(this, 'ExternalHTTPListenerArnOutput', {
value: this.externalHttpListener.listenerArn,
description: 'External HTTP Listener ARN',
exportName: `${stack.stackName}-internet-facing-http-listener`,
});
extHttpOutput.overrideLogicalId('ExternalHTTPListenerArn');
}
if (this.externalHttpsListener) {
const extHttpsOutput = new cdk.CfnOutput(this, 'ExternalHTTPSListenerArnOutput', {
value: this.externalHttpsListener.listenerArn,
description: 'External HTTPS Listener ARN',
exportName: `${stack.stackName}-internet-facing-https-listener`,
});
extHttpsOutput.overrideLogicalId('ExternalHTTPSListenerArn');
}
}
if (this.internalLoadBalancer) {
const intDnsOutput = new cdk.CfnOutput(this, 'InternalLoadBalancerDNSOutput', {
value: this.internalLoadBalancer.loadBalancerDnsName,
description: 'Internal Load Balancer DNS',
exportName: `${stack.stackName}-internal-url`,
});
intDnsOutput.overrideLogicalId('InternalLoadBalancerDNS');
const intArnOutput = new cdk.CfnOutput(this, 'InternalLoadBalancerArnOutput', {
value: this.internalLoadBalancer.loadBalancerArn,
description: 'Internal Load Balancer ARN',
exportName: `${stack.stackName}-internal-arn`,
});
intArnOutput.overrideLogicalId('InternalLoadBalancerArn');
const intHzOutput = new cdk.CfnOutput(this, 'InternalLoadBalancerHostedZoneIdOutput', {
value: this.internalLoadBalancer.loadBalancerCanonicalHostedZoneId,
description: 'Internal Load Balancer Hosted Zone ID',
exportName: `${stack.stackName}-internal-hosted-zone-id`,
});
intHzOutput.overrideLogicalId('InternalLoadBalancerHostedZoneId');
if (this.internalHttpListener) {
const intHttpOutput = new cdk.CfnOutput(this, 'InternalHTTPListenerArnOutput', {
value: this.internalHttpListener.listenerArn,
description: 'Internal HTTP Listener ARN',
exportName: `${stack.stackName}-internal-http-listener`,
});
intHttpOutput.overrideLogicalId('InternalHTTPListenerArn');
}
if (this.internalHttpsListener) {
const intHttpsOutput = new cdk.CfnOutput(this, 'InternalHTTPSListenerArnOutput', {
value: this.internalHttpsListener.listenerArn,
description: 'Internal HTTPS Listener ARN',
exportName: `${stack.stackName}-internal-https-listener`,
});
intHttpsOutput.overrideLogicalId('InternalHTTPSListenerArn');
}
}
if (this.logsBucket) {
const logsOutput = new cdk.CfnOutput(this, 'LogsBucketNameOutput', {
value: this.logsBucket.bucketName,
description: 'ALB Logs S3 Bucket',
exportName: `${stack.stackName}-logs-s3-bucket`,
});
logsOutput.overrideLogicalId('LogsBucketName');
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,677 @@
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
/**
* Subnet CIDR configuration for each availability zone
*/
export interface SubnetCidrConfig {
/** CIDR for the public subnet */
publicCidr: string;
/** CIDR for the primary private subnet */
private1Cidr: string;
/** CIDR for the secondary private subnet (with NACL) */
private2Cidr?: string;
}
/**
* Configuration for VPC tagging
*/
export interface SpicyVpcTags {
owner: string;
product: string;
component: string;
build?: string;
}
/**
* Properties for the SpicyVpc construct
*/
export interface SpicyVpcProps {
/**
* The CIDR block for the VPC
* @default "172.1.0.0/16"
*/
readonly vpcCidr?: string;
/**
* The tenancy of instances launched into the VPC
* @default "default"
*/
readonly vpcTenancy?: 'default' | 'dedicated';
/**
* List of availability zones to use
* If not provided, will use the first N AZs in the region based on numberOfAzs
*/
readonly availabilityZones?: string[];
/**
* Number of availability zones to use (2, 3, or 4)
* @default 4
*/
readonly numberOfAzs?: 2 | 3 | 4;
/**
* Whether to create private subnets
* @default true
*/
readonly createPrivateSubnets?: boolean;
/**
* Whether to create additional private subnets with dedicated NACLs
* Only applies if createPrivateSubnets is true
* @default true
*/
readonly createAdditionalPrivateSubnets?: boolean;
/**
* Subnet CIDR configurations per AZ
* Keys should be 'a', 'b', 'c', 'd' for each AZ
*/
readonly subnetCidrs?: {
a?: SubnetCidrConfig;
b?: SubnetCidrConfig;
c?: SubnetCidrConfig;
d?: SubnetCidrConfig;
};
/**
* Tag to add to public subnets (Key=Value format)
* @default "Network=Public"
*/
readonly publicSubnetTag?: string;
/**
* Tag to add to primary private subnets (Key=Value format)
* @default "Network=Private"
*/
readonly privateSubnetATag?: string;
/**
* Tag to add to secondary private subnets with NACLs (Key=Value format)
* @default "Network=Private"
*/
readonly privateSubnetBTag?: string;
/**
* Required tags for the VPC and resources
*/
readonly tags: SpicyVpcTags;
/**
* Whether to create VPC endpoints for AWS services
* @default true
*/
readonly createVpcEndpoints?: boolean;
}
/**
* Default CIDR configurations matching the original CloudFormation template
*/
const DEFAULT_SUBNET_CIDRS: Required<SpicyVpcProps['subnetCidrs']> = {
a: {
publicCidr: '172.1.128.0/20',
private1Cidr: '172.1.0.0/19',
private2Cidr: '172.1.192.0/21',
},
b: {
publicCidr: '172.1.144.0/20',
private1Cidr: '172.1.32.0/19',
private2Cidr: '172.1.200.0/21',
},
c: {
publicCidr: '172.1.160.0/20',
private1Cidr: '172.1.64.0/19',
private2Cidr: '172.1.208.0/21',
},
d: {
publicCidr: '172.1.176.0/20',
private1Cidr: '172.1.96.0/19',
private2Cidr: '172.1.216.0/21',
},
};
/**
* SpicyVpc - A multi-AZ VPC construct with public and private subnets,
* NAT gateways, and optional VPC endpoints.
*
* This construct mirrors the functionality of the original CloudFormation
* template with explicit logical IDs for idempotent deployments.
*/
export class SpicyVpc extends Construct {
/** The VPC created by this construct */
public readonly vpc: ec2.IVpc;
/** Public subnets indexed by AZ letter (a, b, c, d) */
public readonly publicSubnets: Map<string, ec2.ISubnet> = new Map();
/** Primary private subnets indexed by AZ letter */
public readonly privateSubnets1: Map<string, ec2.ISubnet> = new Map();
/** Secondary private subnets (with NACLs) indexed by AZ letter */
public readonly privateSubnets2: Map<string, ec2.ISubnet> = new Map();
/** NAT Gateway EIPs indexed by AZ letter */
public readonly natEips: Map<string, ec2.CfnEIP> = new Map();
/** S3 Gateway Endpoint */
public readonly s3Endpoint?: ec2.GatewayVpcEndpoint;
constructor(scope: Construct, id: string, props: SpicyVpcProps) {
super(scope, id);
const vpcCidr = props.vpcCidr ?? '172.1.0.0/16';
const numberOfAzs = props.numberOfAzs ?? 4;
const createPrivateSubnets = props.createPrivateSubnets ?? true;
const createAdditionalPrivateSubnets = props.createAdditionalPrivateSubnets ?? true;
const createVpcEndpoints = props.createVpcEndpoints ?? true;
// Merge provided CIDRs with defaults
const subnetCidrs = {
...DEFAULT_SUBNET_CIDRS,
...props.subnetCidrs,
};
// Parse subnet tags
const publicSubnetTagParsed = this.parseTag(props.publicSubnetTag ?? 'Network=Public');
const privateSubnetATagParsed = this.parseTag(props.privateSubnetATag ?? 'Network=Private');
const privateSubnetBTagParsed = this.parseTag(props.privateSubnetBTag ?? 'Network=Private');
// Create the VPC using L1 constructs with explicit logical IDs
const cfnVpc = new ec2.CfnVPC(this, 'VPC', {
cidrBlock: vpcCidr,
enableDnsHostnames: true,
enableDnsSupport: true,
instanceTenancy: props.vpcTenancy ?? 'default',
tags: [
{ key: 'Name', value: cdk.Stack.of(this).stackName },
{ key: 'Owner', value: props.tags.owner },
{ key: 'Product', value: props.tags.product },
{ key: 'Component', value: props.tags.component },
...(props.tags.build ? [{ key: 'Build', value: props.tags.build }] : []),
],
});
((cfnVpc.node.defaultChild as cdk.CfnResource) ?? cfnVpc).overrideLogicalId('VPC');
// Create Internet Gateway
const igw = new ec2.CfnInternetGateway(this, 'InternetGateway', {
tags: [{ key: 'Name', value: cdk.Stack.of(this).stackName }],
});
((igw.node.defaultChild as cdk.CfnResource) ?? igw).overrideLogicalId('InternetGateway');
const vpcGatewayAttachment = new ec2.CfnVPCGatewayAttachment(this, 'VPCGatewayAttachment', {
vpcId: cfnVpc.ref,
internetGatewayId: igw.ref,
});
((vpcGatewayAttachment.node.defaultChild as cdk.CfnResource) ?? vpcGatewayAttachment).overrideLogicalId(
'VPCGatewayAttachment'
);
// DHCP Options
const dhcpOptions = new ec2.CfnDHCPOptions(this, 'DHCPOptions', {
domainName:
cdk.Stack.of(this).region === 'ca-central-1' ? 'ec2.internal' : `${cdk.Stack.of(this).region}.compute.internal`,
domainNameServers: ['AmazonProvidedDNS'],
});
((dhcpOptions.node.defaultChild as cdk.CfnResource) ?? dhcpOptions).overrideLogicalId('DHCPOptions');
const dhcpAssociation = new ec2.CfnVPCDHCPOptionsAssociation(this, 'VPCDHCPOptionsAssociation', {
vpcId: cfnVpc.ref,
dhcpOptionsId: dhcpOptions.ref,
});
((dhcpAssociation.node.defaultChild as cdk.CfnResource) ?? dhcpAssociation).overrideLogicalId(
'VPCDHCPOptionsAssociation'
);
// Public Route Table (shared across all public subnets)
const publicRouteTable = new ec2.CfnRouteTable(this, 'PublicSubnetRouteTable', {
vpcId: cfnVpc.ref,
tags: [
{ key: 'Name', value: 'Public Subnets' },
{ key: 'Network', value: 'Public' },
],
});
((publicRouteTable.node.defaultChild as cdk.CfnResource) ?? publicRouteTable).overrideLogicalId(
'PublicSubnetRouteTable'
);
const publicRoute = new ec2.CfnRoute(this, 'PublicSubnetRoute', {
routeTableId: publicRouteTable.ref,
destinationCidrBlock: '0.0.0.0/0',
gatewayId: igw.ref,
});
((publicRoute.node.defaultChild as cdk.CfnResource) ?? publicRoute).overrideLogicalId('PublicSubnetRoute');
// Get AZ letters based on numberOfAzs
const azLetters = ['a', 'b', 'c', 'd'].slice(0, numberOfAzs);
// Determine availability zones
const availabilityZones =
props.availabilityZones ?? azLetters.map((letter) => `${cdk.Stack.of(this).region}${letter}`);
// Track route tables for VPC endpoints
const privateRouteTables: ec2.CfnRouteTable[] = [];
// Track private subnet 1 route tables for VPC import
const privateSubnet1RouteTables: ec2.CfnRouteTable[] = [];
// Create subnets for each AZ
azLetters.forEach((azLetter, index) => {
const azName = availabilityZones[index];
const azUpper = azLetter.toUpperCase();
const cidrs = subnetCidrs[azLetter as keyof typeof subnetCidrs]!;
// Public Subnet
const publicSubnet = new ec2.CfnSubnet(this, `PublicSubnet${azUpper}`, {
vpcId: cfnVpc.ref,
cidrBlock: cidrs.publicCidr,
availabilityZone: azName,
mapPublicIpOnLaunch: true,
tags: [
{ key: 'Name', value: `Public Subnet ${azUpper}` },
...(publicSubnetTagParsed ? [{ key: publicSubnetTagParsed.key, value: publicSubnetTagParsed.value }] : []),
],
});
((publicSubnet.node.defaultChild as cdk.CfnResource) ?? publicSubnet).overrideLogicalId(`PublicSubnet${azUpper}`);
const publicSubnetRtAssoc = new ec2.CfnSubnetRouteTableAssociation(
this,
`PublicSubnet${azUpper}RouteTableAssociation`,
{
subnetId: publicSubnet.ref,
routeTableId: publicRouteTable.ref,
}
);
((publicSubnetRtAssoc.node.defaultChild as cdk.CfnResource) ?? publicSubnetRtAssoc).overrideLogicalId(
`PublicSubnet${azUpper}RouteTableAssociation`
);
// Store reference
this.publicSubnets.set(
azLetter,
ec2.Subnet.fromSubnetAttributes(this, `PublicSubnet${azUpper}Import`, {
subnetId: publicSubnet.ref,
availabilityZone: azName,
routeTableId: publicRouteTable.ref,
})
);
if (createPrivateSubnets) {
// NAT Gateway EIP
const natEip = new ec2.CfnEIP(this, `NATZone${azUpper}EIP`, {
domain: 'vpc',
});
natEip.addDependency(igw);
((natEip.node.defaultChild as cdk.CfnResource) ?? natEip).overrideLogicalId(`NATZone${azUpper}EIP`);
this.natEips.set(azLetter, natEip);
// NAT Gateway
const natGateway = new ec2.CfnNatGateway(this, `NATGatewayZone${azUpper}`, {
allocationId: natEip.attrAllocationId,
subnetId: publicSubnet.ref,
});
natGateway.addDependency(igw);
((natGateway.node.defaultChild as cdk.CfnResource) ?? natGateway).overrideLogicalId(`NATGatewayZone${azUpper}`);
// Private Subnet 1 Route Table
const privateRouteTable1 = new ec2.CfnRouteTable(this, `PrivateSubnet${azUpper}1RouteTable`, {
vpcId: cfnVpc.ref,
tags: [
{ key: 'Name', value: `Private Subnet ${azUpper}1` },
{ key: 'Network', value: 'Private' },
],
});
((privateRouteTable1.node.defaultChild as cdk.CfnResource) ?? privateRouteTable1).overrideLogicalId(
`PrivateSubnet${azUpper}1RouteTable`
);
privateRouteTables.push(privateRouteTable1);
privateSubnet1RouteTables.push(privateRouteTable1);
const privateRoute1 = new ec2.CfnRoute(this, `PrivateSubnet${azUpper}1Route`, {
routeTableId: privateRouteTable1.ref,
destinationCidrBlock: '0.0.0.0/0',
natGatewayId: natGateway.ref,
});
((privateRoute1.node.defaultChild as cdk.CfnResource) ?? privateRoute1).overrideLogicalId(
`PrivateSubnet${azUpper}1Route`
);
// Private Subnet 1
const privateSubnet1 = new ec2.CfnSubnet(this, `PrivateSubnet${azUpper}1`, {
vpcId: cfnVpc.ref,
cidrBlock: cidrs.private1Cidr,
availabilityZone: azName,
tags: [
{ key: 'Name', value: `Private Subnet ${azUpper}1` },
...(privateSubnetATagParsed
? [
{
key: privateSubnetATagParsed.key,
value: privateSubnetATagParsed.value,
},
]
: []),
],
});
((privateSubnet1.node.defaultChild as cdk.CfnResource) ?? privateSubnet1).overrideLogicalId(
`PrivateSubnet${azUpper}1`
);
const privateSubnet1RtAssoc = new ec2.CfnSubnetRouteTableAssociation(
this,
`PrivateSubnet${azUpper}1RouteTableAssociation`,
{
subnetId: privateSubnet1.ref,
routeTableId: privateRouteTable1.ref,
}
);
((privateSubnet1RtAssoc.node.defaultChild as cdk.CfnResource) ?? privateSubnet1RtAssoc).overrideLogicalId(
`PrivateSubnet${azUpper}1RouteTableAssociation`
);
this.privateSubnets1.set(
azLetter,
ec2.Subnet.fromSubnetAttributes(this, `PrivateSubnet${azUpper}1Import`, {
subnetId: privateSubnet1.ref,
availabilityZone: azName,
routeTableId: privateRouteTable1.ref,
})
);
// Private Subnet 2 (with NACL) - only if enabled
if (createAdditionalPrivateSubnets && cidrs.private2Cidr) {
// Private Subnet 2 Route Table
const privateRouteTable2 = new ec2.CfnRouteTable(this, `PrivateSubnet${azUpper}2RouteTable`, {
vpcId: cfnVpc.ref,
tags: [
{ key: 'Name', value: `Private Subnet ${azUpper}2` },
{ key: 'Network', value: 'Private' },
],
});
((privateRouteTable2.node.defaultChild as cdk.CfnResource) ?? privateRouteTable2).overrideLogicalId(
`PrivateSubnet${azUpper}2RouteTable`
);
privateRouteTables.push(privateRouteTable2);
const privateRoute2 = new ec2.CfnRoute(this, `PrivateSubnet${azUpper}2Route`, {
routeTableId: privateRouteTable2.ref,
destinationCidrBlock: '0.0.0.0/0',
natGatewayId: natGateway.ref,
});
((privateRoute2.node.defaultChild as cdk.CfnResource) ?? privateRoute2).overrideLogicalId(
`PrivateSubnet${azUpper}2Route`
);
// Private Subnet 2
const privateSubnet2 = new ec2.CfnSubnet(this, `PrivateSubnet${azUpper}2`, {
vpcId: cfnVpc.ref,
cidrBlock: cidrs.private2Cidr,
availabilityZone: azName,
tags: [
{ key: 'Name', value: `Private Subnet ${azUpper}2` },
...(privateSubnetBTagParsed
? [
{
key: privateSubnetBTagParsed.key,
value: privateSubnetBTagParsed.value,
},
]
: []),
],
});
((privateSubnet2.node.defaultChild as cdk.CfnResource) ?? privateSubnet2).overrideLogicalId(
`PrivateSubnet${azUpper}2`
);
const privateSubnet2RtAssoc = new ec2.CfnSubnetRouteTableAssociation(
this,
`PrivateSubnet${azUpper}2RouteTableAssociation`,
{
subnetId: privateSubnet2.ref,
routeTableId: privateRouteTable2.ref,
}
);
((privateSubnet2RtAssoc.node.defaultChild as cdk.CfnResource) ?? privateSubnet2RtAssoc).overrideLogicalId(
`PrivateSubnet${azUpper}2RouteTableAssociation`
);
// Network ACL for Private Subnet 2
const nacl = new ec2.CfnNetworkAcl(this, `PrivateSubnet${azUpper}2NetworkACL`, {
vpcId: cfnVpc.ref,
tags: [
{ key: 'Name', value: `NACL Protected subnet ${azUpper}` },
{ key: 'Network', value: 'NACL Protected' },
],
});
((nacl.node.defaultChild as cdk.CfnResource) ?? nacl).overrideLogicalId(`PrivateSubnet${azUpper}2NetworkACL`);
// Allow all inbound
const naclInbound = new ec2.CfnNetworkAclEntry(this, `PrivateSubnet${azUpper}2NetworkACLEntryInbound`, {
networkAclId: nacl.ref,
ruleNumber: 100,
protocol: -1,
ruleAction: 'allow',
egress: false,
cidrBlock: '0.0.0.0/0',
});
((naclInbound.node.defaultChild as cdk.CfnResource) ?? naclInbound).overrideLogicalId(
`PrivateSubnet${azUpper}2NetworkACLEntryInbound`
);
// Allow all outbound
const naclOutbound = new ec2.CfnNetworkAclEntry(this, `PrivateSubnet${azUpper}2NetworkACLEntryOutbound`, {
networkAclId: nacl.ref,
ruleNumber: 100,
protocol: -1,
ruleAction: 'allow',
egress: true,
cidrBlock: '0.0.0.0/0',
});
((naclOutbound.node.defaultChild as cdk.CfnResource) ?? naclOutbound).overrideLogicalId(
`PrivateSubnet${azUpper}2NetworkACLEntryOutbound`
);
const naclAssociation = new ec2.CfnSubnetNetworkAclAssociation(
this,
`PrivateSubnet${azUpper}2NetworkACLAssociation`,
{
subnetId: privateSubnet2.ref,
networkAclId: nacl.ref,
}
);
((naclAssociation.node.defaultChild as cdk.CfnResource) ?? naclAssociation).overrideLogicalId(
`PrivateSubnet${azUpper}2NetworkACLAssociation`
);
this.privateSubnets2.set(
azLetter,
ec2.Subnet.fromSubnetAttributes(this, `PrivateSubnet${azUpper}2Import`, {
subnetId: privateSubnet2.ref,
availabilityZone: azName,
routeTableId: privateRouteTable2.ref,
})
);
}
}
});
// Create VPC from the L1 construct for use with L2 constructs
// Build route table IDs arrays matching the subnet order
const publicSubnetRouteTableIds = Array.from(this.publicSubnets.values()).map(() => publicRouteTable.ref);
const privateSubnetRouteTableIds = privateSubnet1RouteTables.map((rt) => rt.ref);
this.vpc = ec2.Vpc.fromVpcAttributes(this, 'VpcImport', {
vpcId: cfnVpc.ref,
availabilityZones: availabilityZones,
publicSubnetIds: Array.from(this.publicSubnets.values()).map((s) => s.subnetId),
privateSubnetIds: Array.from(this.privateSubnets1.values()).map((s) => s.subnetId),
publicSubnetRouteTableIds: publicSubnetRouteTableIds,
privateSubnetRouteTableIds: privateSubnetRouteTableIds,
});
// S3 VPC Endpoint (Gateway type - free)
if (createPrivateSubnets && createVpcEndpoints) {
const s3Endpoint = new ec2.CfnVPCEndpoint(this, 'S3VPCEndpoint', {
vpcId: cfnVpc.ref,
serviceName: `com.amazonaws.${cdk.Stack.of(this).region}.s3`,
routeTableIds: privateRouteTables.map((rt) => rt.ref),
policyDocument: {
Version: '2012-10-17',
Statement: [
{
Action: '*',
Effect: 'Allow',
Resource: '*',
Principal: '*',
},
],
},
});
((s3Endpoint.node.defaultChild as cdk.CfnResource) ?? s3Endpoint).overrideLogicalId('S3VPCEndpoint');
}
// Add CloudFormation Outputs matching the original template
this.addOutputs(
cfnVpc,
azLetters,
publicRouteTable,
privateRouteTables,
createPrivateSubnets,
createAdditionalPrivateSubnets
);
}
/**
* Parse a tag string in "Key=Value" format
*/
private parseTag(tagString: string): { key: string; value: string } | null {
if (!tagString || !tagString.includes('=')) {
return null;
}
const [key, ...valueParts] = tagString.split('=');
return { key, value: valueParts.join('=') };
}
/**
* Add CloudFormation outputs for cross-stack references
* Output logical IDs match the original CloudFormation template
*/
private addOutputs(
cfnVpc: ec2.CfnVPC,
azLetters: string[],
publicRouteTable: ec2.CfnRouteTable,
privateRouteTables: ec2.CfnRouteTable[],
createPrivateSubnets: boolean,
createAdditionalPrivateSubnets: boolean
): void {
const stack = cdk.Stack.of(this);
// VPC outputs
const vpcIdOutput = new cdk.CfnOutput(this, 'VPCID', {
value: cfnVpc.ref,
description: 'VPC ID',
exportName: `${stack.stackName}-VPCID`,
});
vpcIdOutput.overrideLogicalId('VPCID');
const vpcCidrOutput = new cdk.CfnOutput(this, 'VPCCIDR', {
value: cfnVpc.cidrBlock!,
description: 'VPC CIDR',
exportName: `${stack.stackName}-VPCCIDR`,
});
vpcCidrOutput.overrideLogicalId('VPCCIDR');
// Number of AZs output (for importing by other stacks)
const numberOfAzsOutput = new cdk.CfnOutput(this, 'NumberOfAZs', {
value: azLetters.length.toString(),
description: 'Number of Availability Zones',
exportName: `${stack.stackName}-NumberOfAZs`,
});
numberOfAzsOutput.overrideLogicalId('NumberOfAZs');
// Public Route Table output
const publicRtOutput = new cdk.CfnOutput(this, 'PublicSubnetRouteTableOutput', {
value: publicRouteTable.ref,
description: 'Public Subnet Route Table',
exportName: `${stack.stackName}-PublicSubnetRouteTable`,
});
publicRtOutput.overrideLogicalId('PublicSubnetRouteTableOutput');
// Public subnet outputs
azLetters.forEach((azLetter) => {
const azUpper = azLetter.toUpperCase();
const subnet = this.publicSubnets.get(azLetter);
if (subnet) {
const output = new cdk.CfnOutput(this, `PublicSubnet${azUpper}ID`, {
value: subnet.subnetId,
description: `Public Subnet ${azUpper} ID`,
exportName: `${stack.stackName}-PublicSubnet${azUpper}ID`,
});
output.overrideLogicalId(`PublicSubnet${azUpper}ID`);
}
});
// Private subnet outputs
if (createPrivateSubnets) {
azLetters.forEach((azLetter, index) => {
const azUpper = azLetter.toUpperCase();
// Private Subnet 1
const subnet1 = this.privateSubnets1.get(azLetter);
if (subnet1) {
const output = new cdk.CfnOutput(this, `PrivateSubnet${azUpper}1ID`, {
value: subnet1.subnetId,
description: `Private Subnet 1 ID in Availability Zone ${azUpper}`,
exportName: `${stack.stackName}-PrivateSubnet${azUpper}1ID`,
});
output.overrideLogicalId(`PrivateSubnet${azUpper}1ID`);
}
// NAT EIP Output
const natEip = this.natEips.get(azLetter);
if (natEip) {
const output = new cdk.CfnOutput(this, `NATZone${azUpper}EIPOutput`, {
value: natEip.ref,
description: `NAT ${azUpper} IP address`,
exportName: `${stack.stackName}-NATZone${azUpper}EIP`,
});
output.overrideLogicalId(`NATZone${azUpper}EIPOutput`);
}
// Private Route Table 1 Output
const rtIndex = index * (createAdditionalPrivateSubnets ? 2 : 1);
if (privateRouteTables[rtIndex]) {
const output = new cdk.CfnOutput(this, `PrivateSubnet${azUpper}1RouteTableOutput`, {
value: privateRouteTables[rtIndex].ref,
description: `Private Subnet ${azUpper}1 Route Table`,
exportName: `${stack.stackName}-PrivateSubnet${azUpper}1RouteTable`,
});
output.overrideLogicalId(`PrivateSubnet${azUpper}1RouteTableOutput`);
}
// Private Subnet 2
if (createAdditionalPrivateSubnets) {
const subnet2 = this.privateSubnets2.get(azLetter);
if (subnet2) {
const output = new cdk.CfnOutput(this, `PrivateSubnet${azUpper}2ID`, {
value: subnet2.subnetId,
description: `Private Subnet 2 ID in Availability Zone ${azUpper}`,
exportName: `${stack.stackName}-PrivateSubnet${azUpper}2ID`,
});
output.overrideLogicalId(`PrivateSubnet${azUpper}2ID`);
}
// Private Route Table 2 Output
if (privateRouteTables[rtIndex + 1]) {
const output = new cdk.CfnOutput(this, `PrivateSubnet${azUpper}2RouteTableOutput`, {
value: privateRouteTables[rtIndex + 1].ref,
description: `Private Subnet ${azUpper}2 Route Table`,
exportName: `${stack.stackName}-PrivateSubnet${azUpper}2RouteTable`,
});
output.overrideLogicalId(`PrivateSubnet${azUpper}2RouteTableOutput`);
}
}
});
}
}
}