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:
5
resources/lib/constructs/index.ts
Normal file
5
resources/lib/constructs/index.ts
Normal 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';
|
||||
304
resources/lib/constructs/spicy-alb.ts
Normal file
304
resources/lib/constructs/spicy-alb.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
970
resources/lib/constructs/spicy-ecs-cluster.ts
Normal file
970
resources/lib/constructs/spicy-ecs-cluster.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
1025
resources/lib/constructs/spicy-ecs-service.ts
Normal file
1025
resources/lib/constructs/spicy-ecs-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
677
resources/lib/constructs/spicy-vpc.ts
Normal file
677
resources/lib/constructs/spicy-vpc.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
5
resources/lib/index.ts
Normal file
5
resources/lib/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Main entry point for spicy-automation library
|
||||
// Export all constructs and stacks for use as a library
|
||||
|
||||
export * from './constructs';
|
||||
export * from './stacks';
|
||||
6
resources/lib/stacks/index.ts
Normal file
6
resources/lib/stacks/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Export all stacks
|
||||
export * from './spicy-vpc-stack';
|
||||
export * from './spicy-ecs-cluster-stack';
|
||||
export * from './spicy-ecs-service-stack';
|
||||
export * from './spicy-alb-stack';
|
||||
|
||||
210
resources/lib/stacks/spicy-alb-stack.ts
Normal file
210
resources/lib/stacks/spicy-alb-stack.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import * as ec2 from 'aws-cdk-lib/aws-ec2';
|
||||
import * as s3 from 'aws-cdk-lib/aws-s3';
|
||||
import * as cdk from 'aws-cdk-lib/core';
|
||||
import { Construct } from 'constructs';
|
||||
import { PersistentAlbBlueGreenDnsConfig, SpicyAlb, SpicyAlbTags } from '../constructs/spicy-alb';
|
||||
|
||||
/**
|
||||
* Props for SpicyAlbStack
|
||||
*/
|
||||
export interface SpicyAlbStackProps extends Omit<cdk.StackProps, 'tags'> {
|
||||
/** VPC ID */
|
||||
readonly vpcId: string;
|
||||
|
||||
/** VPC CIDR block */
|
||||
readonly vpcCidrBlock?: string;
|
||||
|
||||
/** Number of availability zones (2-4) */
|
||||
readonly numberOfAzs: number;
|
||||
|
||||
/** Availability zones */
|
||||
readonly availabilityZones: string[];
|
||||
|
||||
/** Subnet IDs for the ALB */
|
||||
readonly subnetIds: string[];
|
||||
|
||||
/** ALB scheme: "internet-facing" or "internal" */
|
||||
readonly scheme: 'internet-facing' | 'internal';
|
||||
|
||||
/** SSL certificate ARN for HTTPS listener */
|
||||
readonly certificateArn?: string;
|
||||
|
||||
/** Idle timeout in seconds */
|
||||
readonly idleTimeout?: number;
|
||||
|
||||
/** S3 bucket name for ALB access logs */
|
||||
readonly logsBucketName?: string;
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stack for deploying a persistent ALB (bg-common pattern)
|
||||
*
|
||||
* This stack should be deployed once and kept running. The ALB DNS name
|
||||
* will remain constant even when service stacks are deleted and recreated.
|
||||
*/
|
||||
export class SpicyAlbStack extends cdk.Stack {
|
||||
public readonly alb: SpicyAlb;
|
||||
|
||||
constructor(scope: Construct, id: string, props: SpicyAlbStackProps) {
|
||||
const { tags, ...stackProps } = props;
|
||||
super(scope, id, stackProps);
|
||||
|
||||
// Import VPC (using fromVpcAttributes to avoid requiring AWS credentials for synth)
|
||||
// For ALB, we need to provide subnet IDs - use the subnets we're deploying the ALB to
|
||||
// For internal ALB, these are typically private subnets; for internet-facing, they're public
|
||||
const vpc = ec2.Vpc.fromVpcAttributes(this, 'ImportedVpc', {
|
||||
vpcId: props.vpcId,
|
||||
availabilityZones: props.availabilityZones,
|
||||
// Provide subnet IDs based on scheme (internal = private, internet-facing = public)
|
||||
// We provide them in both arrays to satisfy CDK's requirements, but only the relevant ones are used
|
||||
...(props.scheme === 'internal' ? { privateSubnetIds: props.subnetIds } : { publicSubnetIds: props.subnetIds }),
|
||||
});
|
||||
|
||||
// Create subnet references
|
||||
const subnets = props.subnetIds.map((subnetId, index) =>
|
||||
ec2.Subnet.fromSubnetAttributes(this, `Subnet${index}`, {
|
||||
subnetId,
|
||||
availabilityZone: props.availabilityZones[index % props.availabilityZones.length],
|
||||
})
|
||||
);
|
||||
|
||||
// Create logs bucket if specified
|
||||
let logsBucket: s3.IBucket | undefined;
|
||||
if (props.logsBucketName) {
|
||||
logsBucket = s3.Bucket.fromBucketName(this, 'LogsBucket', props.logsBucketName);
|
||||
}
|
||||
|
||||
// Create ALB construct
|
||||
this.alb = new SpicyAlb(this, 'ALB', {
|
||||
vpc,
|
||||
vpcCidrBlock: props.vpcCidrBlock,
|
||||
scheme: props.scheme,
|
||||
subnets,
|
||||
availabilityZones: props.availabilityZones,
|
||||
certificateArn: props.certificateArn,
|
||||
idleTimeout: props.idleTimeout,
|
||||
logsBucket,
|
||||
logsPrefix: props.logsPrefix,
|
||||
redirectHttpToHttps: props.redirectHttpToHttps,
|
||||
blueGreenDns: props.blueGreenDns,
|
||||
tags: props.tags,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create stack from CDK context
|
||||
* Only requires clusterStackName - VPC stack name imported from cluster export, then all VPC details imported from VPC stack
|
||||
*/
|
||||
static fromContext(scope: Construct, id: string, stackProps?: cdk.StackProps): SpicyAlbStack {
|
||||
const app = scope.node.root as cdk.App;
|
||||
if (!app) {
|
||||
throw new Error('SpicyAlbStack.fromContext must be called from within a CDK App');
|
||||
}
|
||||
|
||||
// Required: clusterStackName (VPC stack name imported from cluster export, then VPC details imported from VPC stack)
|
||||
const clusterStackName = app.node.tryGetContext('clusterStackName') || app.node.tryGetContext('clusterName');
|
||||
if (!clusterStackName) {
|
||||
throw new Error(
|
||||
'clusterStackName is required. Provide clusterStackName to import VPC stack name from cluster export, then import VPC details from VPC stack.'
|
||||
);
|
||||
}
|
||||
|
||||
// Import VPC stack name from cluster stack (cluster exports: ${clusterStackName}-VPCStackName)
|
||||
const vpcStackName = cdk.Fn.importValue(`${clusterStackName}-VPCStackName`).toString();
|
||||
|
||||
// Import VPC ID from cluster stack (cluster exports: ${clusterStackName}-VPC)
|
||||
const vpcId = cdk.Fn.importValue(`${clusterStackName}-VPC`);
|
||||
|
||||
// Import all VPC details from VPC stack exports
|
||||
const vpcCidrBlock = cdk.Fn.importValue(`${vpcStackName}-VPCCIDR`).toString();
|
||||
const numberOfAzsRaw = app.node.tryGetContext('numberOfAzs');
|
||||
const numberOfAzs = numberOfAzsRaw ? parseInt(numberOfAzsRaw, 10) : NaN;
|
||||
if (!numberOfAzs || Number.isNaN(numberOfAzs)) {
|
||||
throw new Error('numberOfAzs is required in context (2-4) to import VPC subnets for ALB.');
|
||||
}
|
||||
const azs = ['A', 'B', 'C', 'D'].slice(0, Math.min(Math.max(numberOfAzs, 1), 4));
|
||||
|
||||
// Import subnets based on scheme (public for internet-facing, private for internal)
|
||||
const scheme = app.node.tryGetContext('scheme') ?? 'internet-facing';
|
||||
const subnetIds = azs.map((az) => {
|
||||
if (scheme === 'internet-facing') {
|
||||
return cdk.Fn.importValue(`${vpcStackName}-PublicSubnet${az}ID`).toString();
|
||||
} else {
|
||||
return cdk.Fn.importValue(`${vpcStackName}-PrivateSubnet${az}1ID`).toString();
|
||||
}
|
||||
});
|
||||
|
||||
// Derive availability zones from region
|
||||
const region = cdk.Stack.of(scope).region;
|
||||
const availabilityZones = azs.map((az) => `${region}${az.toLowerCase()}`);
|
||||
const certificateArn = app.node.tryGetContext('certificateArn');
|
||||
const idleTimeout = app.node.tryGetContext('idleTimeout')
|
||||
? parseInt(app.node.tryGetContext('idleTimeout'))
|
||||
: undefined;
|
||||
const logsBucketName = app.node.tryGetContext('logsBucketName');
|
||||
const logsPrefix = app.node.tryGetContext('logsPrefix');
|
||||
const redirectHttpToHttps = app.node.tryGetContext('redirectHttpToHttps') !== 'false';
|
||||
|
||||
// Blue/Green DNS
|
||||
const activeHostname = app.node.tryGetContext('activeHostname');
|
||||
const inactiveHostname = app.node.tryGetContext('inactiveHostname');
|
||||
const bgHostedZoneId = app.node.tryGetContext('bgHostedZoneId');
|
||||
|
||||
let blueGreenDns: PersistentAlbBlueGreenDnsConfig | undefined;
|
||||
if (activeHostname && inactiveHostname && bgHostedZoneId) {
|
||||
blueGreenDns = {
|
||||
activeHostname,
|
||||
inactiveHostname,
|
||||
hostedZoneId: bgHostedZoneId,
|
||||
};
|
||||
}
|
||||
|
||||
// Tags
|
||||
const ownerTag = app.node.tryGetContext('ownerTag');
|
||||
const productTag = app.node.tryGetContext('productTag');
|
||||
const componentTag = app.node.tryGetContext('componentTag');
|
||||
const environmentTag = app.node.tryGetContext('environmentTag') ?? app.node.tryGetContext('environment');
|
||||
const buildTag = app.node.tryGetContext('buildTag') ?? app.node.tryGetContext('build');
|
||||
|
||||
if (!ownerTag || !productTag || !componentTag) {
|
||||
throw new Error('ownerTag, productTag, and componentTag context are required');
|
||||
}
|
||||
|
||||
const tags: SpicyAlbTags = {
|
||||
owner: ownerTag,
|
||||
product: productTag,
|
||||
component: componentTag,
|
||||
environment: environmentTag ?? 'dev',
|
||||
build: buildTag,
|
||||
};
|
||||
|
||||
return new SpicyAlbStack(scope, id, {
|
||||
...stackProps,
|
||||
vpcId: vpcId.toString(),
|
||||
vpcCidrBlock,
|
||||
numberOfAzs,
|
||||
availabilityZones,
|
||||
subnetIds,
|
||||
scheme: scheme as 'internet-facing' | 'internal',
|
||||
certificateArn,
|
||||
idleTimeout,
|
||||
logsBucketName,
|
||||
logsPrefix,
|
||||
redirectHttpToHttps,
|
||||
blueGreenDns,
|
||||
tags: tags,
|
||||
});
|
||||
}
|
||||
}
|
||||
332
resources/lib/stacks/spicy-ecs-cluster-stack.ts
Normal file
332
resources/lib/stacks/spicy-ecs-cluster-stack.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import * as ec2 from 'aws-cdk-lib/aws-ec2';
|
||||
import * as cdk from 'aws-cdk-lib/core';
|
||||
import { Construct } from 'constructs';
|
||||
import {
|
||||
ClusterScalingConfig,
|
||||
LoadBalancerConfig,
|
||||
SpicyEcsCluster,
|
||||
SpicyEcsClusterTags,
|
||||
SpotConfig,
|
||||
} from '../constructs/spicy-ecs-cluster';
|
||||
|
||||
/**
|
||||
* Props for SpicyEcsClusterStack
|
||||
*/
|
||||
export interface SpicyEcsClusterStackProps extends cdk.StackProps {
|
||||
/**
|
||||
* VPC stack name to import VPC details from (required)
|
||||
* All VPC details (VPC ID, CIDR, subnets, AZs) will be imported from VPC stack exports
|
||||
*/
|
||||
readonly vpcStackName: string;
|
||||
|
||||
/**
|
||||
* Number of availability zones (2-4) used by the VPC
|
||||
* Must match the VPC stack export to correctly import subnet IDs.
|
||||
*/
|
||||
readonly numberOfAzs: number;
|
||||
|
||||
/**
|
||||
* EC2 instance type
|
||||
* @default m5a.large
|
||||
*/
|
||||
readonly instanceType?: string;
|
||||
|
||||
/**
|
||||
* Additional instance types for mixed instances policy
|
||||
*/
|
||||
readonly additionalInstanceTypes?: string[];
|
||||
|
||||
/**
|
||||
* EC2 Key Pair name
|
||||
*/
|
||||
readonly keyName?: string;
|
||||
|
||||
/**
|
||||
* EBS volume size in GB
|
||||
* @default 100
|
||||
*/
|
||||
readonly ebsVolumeSize?: number;
|
||||
|
||||
/**
|
||||
* Enable Container Insights
|
||||
* @default true
|
||||
*/
|
||||
readonly containerInsights?: boolean;
|
||||
|
||||
/**
|
||||
* Minimum cluster size
|
||||
* @default 2
|
||||
*/
|
||||
readonly minClusterSize?: number;
|
||||
|
||||
/**
|
||||
* Maximum cluster size
|
||||
* @default 4
|
||||
*/
|
||||
readonly maxClusterSize?: number;
|
||||
|
||||
/**
|
||||
* Target capacity utilization percentage
|
||||
* @default 100
|
||||
*/
|
||||
readonly targetCapacityPercent?: number;
|
||||
|
||||
/**
|
||||
* Enable Spot instances
|
||||
* @default false
|
||||
*/
|
||||
readonly spotEnabled?: boolean;
|
||||
|
||||
/**
|
||||
* On-Demand percentage when Spot is enabled
|
||||
* @default 100
|
||||
*/
|
||||
readonly onDemandPercentage?: number;
|
||||
|
||||
/**
|
||||
* Spot allocation strategy
|
||||
* @default capacity-optimized
|
||||
*/
|
||||
readonly spotAllocationStrategy?: 'lowest-price' | 'capacity-optimized' | 'capacity-optimized-prioritized';
|
||||
|
||||
/**
|
||||
* Create external (internet-facing) load balancer
|
||||
* @default false
|
||||
*/
|
||||
readonly createExternalLoadBalancer?: boolean;
|
||||
|
||||
/**
|
||||
* Create internal load balancer
|
||||
* @default false
|
||||
*/
|
||||
readonly createInternalLoadBalancer?: boolean;
|
||||
|
||||
/**
|
||||
* SSL certificate ARN for HTTPS
|
||||
*/
|
||||
readonly certificateArn?: string;
|
||||
|
||||
/**
|
||||
* Enable Fargate capacity providers (adds both FARGATE and FARGATE_SPOT)
|
||||
* @default false
|
||||
*/
|
||||
readonly enableFargate?: boolean;
|
||||
|
||||
/**
|
||||
* Draining timeout in seconds
|
||||
* @default 900
|
||||
*/
|
||||
readonly drainingTimeout?: number;
|
||||
|
||||
/**
|
||||
* Max instance lifetime in seconds
|
||||
* @default 604800 (7 days)
|
||||
*/
|
||||
readonly maxInstanceLifetime?: number;
|
||||
|
||||
/**
|
||||
* Required cluster tags
|
||||
*/
|
||||
readonly clusterTags: SpicyEcsClusterTags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stack for deploying a SpicyEcsCluster
|
||||
*/
|
||||
export class SpicyEcsClusterStack extends cdk.Stack {
|
||||
public readonly cluster: SpicyEcsCluster;
|
||||
|
||||
constructor(scope: Construct, id: string, props: SpicyEcsClusterStackProps) {
|
||||
super(scope, id, props);
|
||||
|
||||
// Validate required inputs
|
||||
if (!props.vpcStackName) {
|
||||
throw new Error('vpcStackName is required for ECS Cluster stack.');
|
||||
}
|
||||
|
||||
// Import all VPC details from VPC stack exports
|
||||
// VPC stack exports: ${vpcStackName}-VPCID, ${vpcStackName}-VPCCIDR, ${vpcStackName}-NumberOfAZs, etc.
|
||||
const vpcId = cdk.Fn.importValue(`${props.vpcStackName}-VPCID`);
|
||||
const vpcCidrBlock = cdk.Fn.importValue(`${props.vpcStackName}-VPCCIDR`).toString();
|
||||
if (!props.numberOfAzs || Number.isNaN(props.numberOfAzs)) {
|
||||
throw new Error('numberOfAzs is required and must be a number (2-4) to import subnets.');
|
||||
}
|
||||
const azs = ['A', 'B', 'C', 'D'].slice(0, Math.min(Math.max(props.numberOfAzs, 1), 4));
|
||||
|
||||
// Import private subnet IDs (always needed for ECS instances)
|
||||
const privateSubnetIds = azs.map((az) =>
|
||||
cdk.Fn.importValue(`${props.vpcStackName}-PrivateSubnet${az}1ID`).toString()
|
||||
);
|
||||
|
||||
// Import public subnet IDs if external load balancer is requested
|
||||
let publicSubnetIds: string[] | undefined;
|
||||
if (props.createExternalLoadBalancer) {
|
||||
publicSubnetIds = azs.map((az) => cdk.Fn.importValue(`${props.vpcStackName}-PublicSubnet${az}ID`).toString());
|
||||
}
|
||||
|
||||
// Derive availability zones from region
|
||||
const region = cdk.Stack.of(this).region;
|
||||
const availabilityZones = azs.map((az) => `${region}${az.toLowerCase()}`);
|
||||
|
||||
// Build VPC attributes for importing
|
||||
const vpcAttributes: ec2.VpcAttributes = {
|
||||
vpcId: vpcId.toString(),
|
||||
availabilityZones,
|
||||
privateSubnetIds,
|
||||
...(publicSubnetIds && publicSubnetIds.length > 0 ? { publicSubnetIds } : {}),
|
||||
};
|
||||
|
||||
const vpc = ec2.Vpc.fromVpcAttributes(this, 'ImportedVpc', vpcAttributes);
|
||||
|
||||
// Parse instance types
|
||||
const instanceType = props.instanceType
|
||||
? new ec2.InstanceType(props.instanceType)
|
||||
: ec2.InstanceType.of(ec2.InstanceClass.M5A, ec2.InstanceSize.LARGE);
|
||||
|
||||
const additionalInstanceTypes = props.additionalInstanceTypes?.map((t) => new ec2.InstanceType(t));
|
||||
|
||||
// Build scaling config
|
||||
const scaling: ClusterScalingConfig = {
|
||||
minCapacity: props.minClusterSize ?? 2,
|
||||
maxCapacity: props.maxClusterSize ?? 4,
|
||||
targetCapacityPercent: props.targetCapacityPercent ?? 100,
|
||||
};
|
||||
|
||||
// Build spot config
|
||||
const spot: SpotConfig = {
|
||||
enabled: props.spotEnabled ?? false,
|
||||
onDemandPercentage: props.onDemandPercentage ?? 100,
|
||||
spotAllocationStrategy: props.spotAllocationStrategy ?? 'capacity-optimized',
|
||||
};
|
||||
|
||||
// Build load balancer config
|
||||
const loadBalancer: LoadBalancerConfig = {
|
||||
createExternal: props.createExternalLoadBalancer ?? false,
|
||||
createInternal: props.createInternalLoadBalancer ?? false,
|
||||
certificateArn: props.certificateArn,
|
||||
};
|
||||
|
||||
// Create the cluster
|
||||
this.cluster = new SpicyEcsCluster(this, 'EcsCluster', {
|
||||
vpc,
|
||||
vpcCidrBlock,
|
||||
vpcStackName: props.vpcStackName,
|
||||
instanceType,
|
||||
additionalInstanceTypes,
|
||||
keyName: props.keyName,
|
||||
ebsVolumeSize: props.ebsVolumeSize,
|
||||
containerInsights: props.containerInsights,
|
||||
scaling,
|
||||
spot,
|
||||
loadBalancer,
|
||||
enableFargate: props.enableFargate,
|
||||
drainingTimeout: props.drainingTimeout,
|
||||
maxInstanceLifetime: props.maxInstanceLifetime,
|
||||
tags: props.clusterTags,
|
||||
instanceSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
|
||||
externalSubnets: publicSubnetIds ? { subnetType: ec2.SubnetType.PUBLIC } : undefined,
|
||||
internalSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create stack from CDK context
|
||||
* Only requires vpcStackName - all VPC details are imported from VPC stack exports
|
||||
*/
|
||||
static fromContext(scope: Construct, id: string, stackProps?: cdk.StackProps): SpicyEcsClusterStack {
|
||||
const app = scope.node.root as cdk.App;
|
||||
|
||||
// Required: VPC stack name (all VPC details imported from exports)
|
||||
const vpcStackName = app.node.tryGetContext('vpcStackName');
|
||||
if (!vpcStackName) {
|
||||
throw new Error(
|
||||
'vpcStackName is required. Provide vpcStackName to import all VPC details from VPC stack exports.'
|
||||
);
|
||||
}
|
||||
const numberOfAzsRaw = app.node.tryGetContext('numberOfAzs');
|
||||
const numberOfAzs = numberOfAzsRaw ? parseInt(numberOfAzsRaw, 10) : NaN;
|
||||
if (!numberOfAzs || Number.isNaN(numberOfAzs)) {
|
||||
throw new Error('numberOfAzs is required in context (2-4) to import subnets from the VPC stack.');
|
||||
}
|
||||
|
||||
// Tags
|
||||
const tags: SpicyEcsClusterTags = {
|
||||
owner: app.node.tryGetContext('ownerTag') ?? 'Unknown',
|
||||
product: app.node.tryGetContext('productTag') ?? 'Unknown',
|
||||
component: app.node.tryGetContext('componentTag') ?? 'ecs-cluster',
|
||||
environment: app.node.tryGetContext('environment') ?? 'dev',
|
||||
build: app.node.tryGetContext('build'),
|
||||
};
|
||||
|
||||
// Optional context values
|
||||
const instanceType = app.node.tryGetContext('instanceType');
|
||||
const additionalInstanceTypes = app.node.tryGetContext('additionalInstanceTypes')?.split(',');
|
||||
const keyName = app.node.tryGetContext('keyName');
|
||||
const ebsVolumeSize = app.node.tryGetContext('ebsVolumeSize')
|
||||
? parseInt(app.node.tryGetContext('ebsVolumeSize'))
|
||||
: undefined;
|
||||
const containerInsights = app.node.tryGetContext('containerInsights') !== 'false';
|
||||
|
||||
// Scaling
|
||||
const minClusterSize = app.node.tryGetContext('minClusterSize')
|
||||
? parseInt(app.node.tryGetContext('minClusterSize'))
|
||||
: undefined;
|
||||
const maxClusterSize = app.node.tryGetContext('maxClusterSize')
|
||||
? parseInt(app.node.tryGetContext('maxClusterSize'))
|
||||
: undefined;
|
||||
const targetCapacityPercent = app.node.tryGetContext('targetCapacityPercent')
|
||||
? parseInt(app.node.tryGetContext('targetCapacityPercent'))
|
||||
: undefined;
|
||||
|
||||
// Spot
|
||||
const spotEnabled = app.node.tryGetContext('spotEnabled') === 'true';
|
||||
const onDemandPercentage = app.node.tryGetContext('onDemandPercentage')
|
||||
? parseInt(app.node.tryGetContext('onDemandPercentage'))
|
||||
: undefined;
|
||||
const spotAllocationStrategy = app.node.tryGetContext('spotAllocationStrategy') as
|
||||
| 'lowest-price'
|
||||
| 'capacity-optimized'
|
||||
| 'capacity-optimized-prioritized'
|
||||
| undefined;
|
||||
|
||||
// Load balancers
|
||||
const createExternalLoadBalancer = app.node.tryGetContext('createExternalLoadBalancer') === 'true';
|
||||
const createInternalLoadBalancer = app.node.tryGetContext('createInternalLoadBalancer') === 'true';
|
||||
const certificateArn = app.node.tryGetContext('certificateArn');
|
||||
|
||||
// Fargate
|
||||
const enableFargate = app.node.tryGetContext('enableFargate') === 'true';
|
||||
|
||||
// Timeouts
|
||||
const drainingTimeout = app.node.tryGetContext('drainingTimeout')
|
||||
? parseInt(app.node.tryGetContext('drainingTimeout'))
|
||||
: undefined;
|
||||
const maxInstanceLifetime = app.node.tryGetContext('maxInstanceLifetime')
|
||||
? parseInt(app.node.tryGetContext('maxInstanceLifetime'))
|
||||
: undefined;
|
||||
|
||||
return new SpicyEcsClusterStack(scope, id, {
|
||||
...stackProps,
|
||||
vpcStackName,
|
||||
instanceType,
|
||||
additionalInstanceTypes,
|
||||
keyName,
|
||||
ebsVolumeSize,
|
||||
containerInsights,
|
||||
minClusterSize,
|
||||
maxClusterSize,
|
||||
targetCapacityPercent,
|
||||
spotEnabled,
|
||||
onDemandPercentage,
|
||||
spotAllocationStrategy,
|
||||
createExternalLoadBalancer,
|
||||
createInternalLoadBalancer,
|
||||
certificateArn,
|
||||
enableFargate,
|
||||
drainingTimeout,
|
||||
maxInstanceLifetime,
|
||||
numberOfAzs,
|
||||
clusterTags: tags,
|
||||
});
|
||||
}
|
||||
}
|
||||
467
resources/lib/stacks/spicy-ecs-service-stack.ts
Normal file
467
resources/lib/stacks/spicy-ecs-service-stack.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
import * as ec2 from 'aws-cdk-lib/aws-ec2';
|
||||
import * as ecs from 'aws-cdk-lib/aws-ecs';
|
||||
import * as cdk from 'aws-cdk-lib/core';
|
||||
import { Construct } from 'constructs';
|
||||
import {
|
||||
CapacityProviderStrategyItem,
|
||||
ContainerConfig,
|
||||
DeploymentConfig,
|
||||
DnsConfig,
|
||||
HealthCheckConfig,
|
||||
LoadBalancerRoutingConfig,
|
||||
ServiceScalingConfig,
|
||||
SpicyEcsService,
|
||||
SpicyEcsServiceTags,
|
||||
} from '../constructs/spicy-ecs-service';
|
||||
|
||||
/**
|
||||
* Props for SpicyEcsServiceStack
|
||||
*/
|
||||
export interface SpicyEcsServiceStackProps extends cdk.StackProps {
|
||||
/** ECS Cluster name to import */
|
||||
readonly clusterName: string;
|
||||
|
||||
/** VPC ID */
|
||||
readonly vpcId: string;
|
||||
|
||||
/** VPC CIDR block */
|
||||
readonly vpcCidrBlock?: string;
|
||||
|
||||
/** Number of availability zones (2-4) */
|
||||
readonly numberOfAzs: number;
|
||||
|
||||
/** Availability zones */
|
||||
readonly availabilityZones: string[];
|
||||
|
||||
/** Private subnet IDs */
|
||||
readonly privateSubnetIds: string[];
|
||||
|
||||
/** Service name */
|
||||
readonly serviceName: string;
|
||||
|
||||
/** Docker image URI */
|
||||
readonly image: string;
|
||||
|
||||
/** Container port */
|
||||
readonly containerPort: number;
|
||||
|
||||
/** CPU units */
|
||||
readonly cpu?: number;
|
||||
|
||||
/** Memory in MiB */
|
||||
readonly memory?: number;
|
||||
|
||||
/** Environment variables (JSON string) */
|
||||
readonly environment?: string;
|
||||
|
||||
/** Secrets (JSON string: { "ENV_VAR": "secret-arn" }) */
|
||||
readonly secrets?: string;
|
||||
|
||||
/** Desired task count */
|
||||
readonly desiredCount?: number;
|
||||
|
||||
/** Capacity provider strategy (JSON string) */
|
||||
readonly capacityProviderStrategy?: string;
|
||||
|
||||
/** Scaling configuration */
|
||||
readonly scaling?: ServiceScalingConfig;
|
||||
|
||||
/** Health check path */
|
||||
readonly healthCheckPath?: string;
|
||||
|
||||
/** Routing configuration */
|
||||
readonly routing?: LoadBalancerRoutingConfig;
|
||||
|
||||
/** External ALB listener ARN (for cluster ALB mode) */
|
||||
readonly externalListenerArn?: string;
|
||||
|
||||
/** Internal ALB listener ARN (for cluster ALB mode) */
|
||||
readonly internalListenerArn?: string;
|
||||
|
||||
/** Public subnet IDs (for individual ALB) */
|
||||
readonly publicSubnetIds?: string[];
|
||||
|
||||
/** Cluster logs bucket name (for individual ALB access logs) */
|
||||
readonly clusterLogsBucketName?: string;
|
||||
|
||||
/** DNS configuration */
|
||||
readonly dns?: DnsConfig;
|
||||
|
||||
/** Deployment configuration */
|
||||
readonly deployment?: DeploymentConfig;
|
||||
|
||||
/** Service tags */
|
||||
readonly serviceTags: SpicyEcsServiceTags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stack for deploying an ECS Service
|
||||
*/
|
||||
export class SpicyEcsServiceStack extends cdk.Stack {
|
||||
public readonly service: SpicyEcsService;
|
||||
|
||||
constructor(scope: Construct, id: string, props: SpicyEcsServiceStackProps) {
|
||||
super(scope, id, props);
|
||||
|
||||
// Import VPC
|
||||
const vpc = ec2.Vpc.fromVpcAttributes(this, 'ImportedVpc', {
|
||||
vpcId: props.vpcId,
|
||||
availabilityZones: props.availabilityZones,
|
||||
privateSubnetIds: props.privateSubnetIds,
|
||||
...(props.publicSubnetIds && { publicSubnetIds: props.publicSubnetIds }),
|
||||
});
|
||||
|
||||
// Import Cluster
|
||||
const cluster = ecs.Cluster.fromClusterAttributes(this, 'ImportedCluster', {
|
||||
clusterName: props.clusterName,
|
||||
vpc,
|
||||
securityGroups: [],
|
||||
});
|
||||
|
||||
// Parse JSON configs
|
||||
const environment = props.environment ? JSON.parse(props.environment) : undefined;
|
||||
const secrets = props.secrets ? JSON.parse(props.secrets) : undefined;
|
||||
const capacityProviderStrategy: CapacityProviderStrategyItem[] | undefined = props.capacityProviderStrategy
|
||||
? JSON.parse(props.capacityProviderStrategy)
|
||||
: undefined;
|
||||
|
||||
// Build container config
|
||||
const container: ContainerConfig = {
|
||||
image: props.image,
|
||||
port: props.containerPort,
|
||||
cpu: props.cpu,
|
||||
memory: props.memory,
|
||||
environment,
|
||||
secrets,
|
||||
};
|
||||
|
||||
// Build health check config
|
||||
const healthCheck: HealthCheckConfig | undefined = props.healthCheckPath
|
||||
? { path: props.healthCheckPath }
|
||||
: undefined;
|
||||
|
||||
// Create the service
|
||||
this.service = new SpicyEcsService(this, 'EcsService', {
|
||||
cluster,
|
||||
vpc,
|
||||
serviceName: props.serviceName,
|
||||
container,
|
||||
capacityProviderStrategy,
|
||||
desiredCount: props.desiredCount,
|
||||
scaling: props.scaling,
|
||||
healthCheck,
|
||||
routing: props.routing,
|
||||
externalListenerArn: props.externalListenerArn,
|
||||
internalListenerArn: props.internalListenerArn,
|
||||
dns: props.dns,
|
||||
deployment: props.deployment,
|
||||
tags: props.serviceTags,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create stack from CDK context
|
||||
* Only requires clusterStackName - VPC stack name imported from cluster export, then all VPC details imported from VPC stack
|
||||
*/
|
||||
static fromContext(scope: Construct, id: string, stackProps?: cdk.StackProps): SpicyEcsServiceStack {
|
||||
const app = scope.node.root as cdk.App;
|
||||
|
||||
// Required: clusterStackName (VPC stack name imported from cluster export, then VPC details imported from VPC stack)
|
||||
const clusterStackName = app.node.tryGetContext('clusterStackName') || app.node.tryGetContext('clusterName');
|
||||
if (!clusterStackName) {
|
||||
throw new Error(
|
||||
'clusterStackName is required. Provide clusterStackName to import VPC stack name from cluster export, then import VPC details from VPC stack.'
|
||||
);
|
||||
}
|
||||
|
||||
// Required service config
|
||||
const serviceName = app.node.tryGetContext('serviceName');
|
||||
const image = app.node.tryGetContext('image');
|
||||
const containerPort = parseInt(app.node.tryGetContext('containerPort') ?? '3000');
|
||||
|
||||
// Import VPC stack name from cluster stack (cluster exports: ${clusterStackName}-VPCStackName)
|
||||
const vpcStackName = cdk.Fn.importValue(`${clusterStackName}-VPCStackName`).toString();
|
||||
|
||||
// Import VPC ID from cluster stack (cluster exports: ${clusterStackName}-VPC)
|
||||
const vpcId = cdk.Fn.importValue(`${clusterStackName}-VPC`);
|
||||
|
||||
// Import all VPC details from VPC stack exports
|
||||
const vpcCidrBlock = cdk.Fn.importValue(`${vpcStackName}-VPCCIDR`).toString();
|
||||
const numberOfAzsRaw = app.node.tryGetContext('numberOfAzs');
|
||||
const numberOfAzs = numberOfAzsRaw ? parseInt(numberOfAzsRaw, 10) : NaN;
|
||||
if (!numberOfAzs || Number.isNaN(numberOfAzs)) {
|
||||
throw new Error('numberOfAzs is required in context (2-4) to import VPC subnets.');
|
||||
}
|
||||
const azs = ['A', 'B', 'C', 'D'].slice(0, Math.min(Math.max(numberOfAzs, 1), 4));
|
||||
|
||||
// Import private subnet IDs from VPC stack
|
||||
const privateSubnetIds = azs.map((az) => cdk.Fn.importValue(`${vpcStackName}-PrivateSubnet${az}1ID`).toString());
|
||||
|
||||
// Derive availability zones from region
|
||||
const region = cdk.Stack.of(scope).region;
|
||||
const availabilityZones = azs.map((az) => `${region}${az.toLowerCase()}`);
|
||||
|
||||
// ALB stack name is derived from service stackName: ${baseStackName}-alb
|
||||
// Remove -blue/-green suffix and add -alb
|
||||
const serviceStackName = app.node.tryGetContext('stackName') || id;
|
||||
const baseStackName = serviceStackName.replace(/-blue$|-green$/, '');
|
||||
const albStackName = `${baseStackName}-alb`;
|
||||
|
||||
// Tags
|
||||
const serviceTags: SpicyEcsServiceTags = {
|
||||
owner: app.node.tryGetContext('ownerTag') ?? 'Unknown',
|
||||
product: app.node.tryGetContext('productTag') ?? 'Unknown',
|
||||
component: app.node.tryGetContext('componentTag') ?? serviceName,
|
||||
environment: app.node.tryGetContext('environment') ?? 'dev',
|
||||
build: app.node.tryGetContext('build'),
|
||||
};
|
||||
|
||||
// Optional container config
|
||||
const cpu = app.node.tryGetContext('cpu') ? parseInt(app.node.tryGetContext('cpu')) : undefined;
|
||||
const memory = app.node.tryGetContext('memory') ? parseInt(app.node.tryGetContext('memory')) : undefined;
|
||||
const environment = app.node.tryGetContext('environment_vars'); // JSON string
|
||||
const secrets = app.node.tryGetContext('secrets'); // JSON string
|
||||
const desiredCount = app.node.tryGetContext('desiredCount')
|
||||
? parseInt(app.node.tryGetContext('desiredCount'))
|
||||
: undefined;
|
||||
|
||||
// Capacity provider strategy (JSON string)
|
||||
const capacityProviderStrategy = app.node.tryGetContext('capacityProviderStrategy');
|
||||
|
||||
// Scaling
|
||||
const minCapacity = app.node.tryGetContext('minCapacity')
|
||||
? parseInt(app.node.tryGetContext('minCapacity'))
|
||||
: undefined;
|
||||
const maxCapacity = app.node.tryGetContext('maxCapacity')
|
||||
? parseInt(app.node.tryGetContext('maxCapacity'))
|
||||
: undefined;
|
||||
const targetCpuUtilization = app.node.tryGetContext('targetCpuUtilization')
|
||||
? parseInt(app.node.tryGetContext('targetCpuUtilization'))
|
||||
: undefined;
|
||||
const targetMemoryUtilization = app.node.tryGetContext('targetMemoryUtilization')
|
||||
? parseInt(app.node.tryGetContext('targetMemoryUtilization'))
|
||||
: undefined;
|
||||
const targetRequestsPerTarget = app.node.tryGetContext('targetRequestsPerTarget')
|
||||
? parseInt(app.node.tryGetContext('targetRequestsPerTarget'))
|
||||
: undefined;
|
||||
|
||||
const scaling: ServiceScalingConfig | undefined =
|
||||
minCapacity && maxCapacity
|
||||
? {
|
||||
minCapacity,
|
||||
maxCapacity,
|
||||
targetCpuUtilization,
|
||||
targetMemoryUtilization,
|
||||
targetRequestsPerTarget,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Health check
|
||||
const healthCheckPath = app.node.tryGetContext('healthCheckPath');
|
||||
|
||||
// Routing - ALB details (resolved by pipeline from cluster or ALB stack)
|
||||
// Pipeline sets: albLoadBalancerArn, albHttpsListenerArn, albHttpListenerArn in context
|
||||
// If not provided in context, fall back to importing from exports (backward compatibility)
|
||||
const useClusterAlb = app.node.tryGetContext('useClusterAlb') !== 'false'; // default true
|
||||
|
||||
let existingAlbArn: string | undefined = app.node.tryGetContext('albLoadBalancerArn');
|
||||
let existingAlbHttpsListenerArn: string | undefined = app.node.tryGetContext('albHttpsListenerArn');
|
||||
let existingAlbHttpListenerArn: string | undefined = app.node.tryGetContext('albHttpListenerArn');
|
||||
|
||||
// If not provided in context, import from exports (backward compatibility)
|
||||
if (!existingAlbArn && !useClusterAlb) {
|
||||
const albScheme = app.node.tryGetContext('albScheme') || 'internet-facing';
|
||||
const prefix = albScheme === 'internet-facing' ? 'internet-facing' : 'internal';
|
||||
existingAlbArn = cdk.Fn.importValue(`${albStackName}-${prefix}-arn`).toString();
|
||||
existingAlbHttpsListenerArn = cdk.Fn.importValue(`${albStackName}-${prefix}-https-listener`).toString();
|
||||
existingAlbHttpListenerArn = cdk.Fn.importValue(`${albStackName}-${prefix}-http-listener`).toString();
|
||||
}
|
||||
|
||||
// ALB listeners (for cluster ALB mode) - import from cluster stack (backward compatibility)
|
||||
// Cluster exports: ${clusterStackName}-internet-facing-https-listener, ${clusterStackName}-internal-https-listener
|
||||
let externalListenerArn = app.node.tryGetContext('externalListenerArn');
|
||||
let internalListenerArn = app.node.tryGetContext('internalListenerArn');
|
||||
|
||||
// Auto-import listener ARNs from cluster stack if clusterStackName provided and listeners not explicitly set
|
||||
// This matches the old pattern: Fn::ImportValue: !Sub "${ClusterName}-internet-facing-https-listener"
|
||||
// Only used if albLoadBalancerArn is not provided (backward compatibility)
|
||||
if (!existingAlbArn && clusterStackName && !externalListenerArn && !internalListenerArn) {
|
||||
const useExternal = app.node.tryGetContext('useExternalALB') === 'true';
|
||||
const useInternal = app.node.tryGetContext('useInternalALB') === 'true';
|
||||
const albScheme =
|
||||
app.node.tryGetContext('albScheme') ||
|
||||
(useExternal ? 'internet-facing' : useInternal ? 'internal' : 'internet-facing');
|
||||
|
||||
// Try HTTPS listener first (most common), fall back to HTTP if needed
|
||||
if (albScheme === 'internet-facing' || useExternal) {
|
||||
externalListenerArn = cdk.Fn.importValue(`${clusterStackName}-internet-facing-https-listener`).toString();
|
||||
} else if (albScheme === 'internal' || useInternal) {
|
||||
internalListenerArn = cdk.Fn.importValue(`${clusterStackName}-internal-https-listener`).toString();
|
||||
}
|
||||
}
|
||||
const hostHeader = app.node.tryGetContext('hostHeader');
|
||||
const pathPatterns = app.node.tryGetContext('pathPatterns')?.split(',');
|
||||
const priority = app.node.tryGetContext('priority') ? parseInt(app.node.tryGetContext('priority')) : undefined;
|
||||
const useExternal = app.node.tryGetContext('useExternalALB') === 'true';
|
||||
const useInternal = app.node.tryGetContext('useInternalALB') === 'true';
|
||||
|
||||
let routing: LoadBalancerRoutingConfig | undefined;
|
||||
|
||||
// Use unified ALB parameters if provided (from pipeline resolveAlbDetails)
|
||||
// Otherwise fall back to cluster ALB mode (backward compatibility)
|
||||
const useBgCommonAlb = existingAlbArn != null;
|
||||
|
||||
if (useBgCommonAlb) {
|
||||
// ALB details are provided by pipeline (resolved from cluster or ALB stack)
|
||||
// Pipeline sets: albLoadBalancerArn, albHttpsListenerArn, albHttpListenerArn
|
||||
if (!hostHeader && (!pathPatterns || pathPatterns.length === 0)) {
|
||||
throw new Error('When using ALB, either hostHeader or pathPatterns must be provided for routing');
|
||||
}
|
||||
// Use unified ALB parameters (from cluster or ALB stack, resolved by pipeline)
|
||||
routing = {
|
||||
existingALB: existingAlbArn!, // ALB ARN (from cluster or ALB stack)
|
||||
httpsListenerArn: existingAlbHttpsListenerArn, // HTTPS listener ARN (if certificate provided)
|
||||
httpListenerArn: existingAlbHttpListenerArn, // HTTP listener ARN (if no certificate or for redirect)
|
||||
hostHeader,
|
||||
pathPatterns,
|
||||
priority,
|
||||
stickiness: app.node.tryGetContext('stickiness') !== 'false',
|
||||
stickinessDuration: app.node.tryGetContext('stickinessDuration')
|
||||
? parseInt(app.node.tryGetContext('stickinessDuration'))
|
||||
: undefined,
|
||||
deregistrationDelay: app.node.tryGetContext('deregistrationDelay')
|
||||
? parseInt(app.node.tryGetContext('deregistrationDelay'))
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// Blue/Green DNS configuration (for bg-common ALB)
|
||||
// Note: Both DNS records are created and point to the same ALB.
|
||||
// Routing is controlled by listener rule priorities, not DNS changes.
|
||||
// The isActive flag is informational - both records are always created.
|
||||
const activeHostname = app.node.tryGetContext('activeHostname');
|
||||
const inactiveHostname = app.node.tryGetContext('inactiveHostname');
|
||||
const bgHostedZoneId = app.node.tryGetContext('bgHostedZoneId') || app.node.tryGetContext('hostedZoneId');
|
||||
const isActive = app.node.tryGetContext('isActive') !== 'false'; // default true
|
||||
|
||||
if (activeHostname && inactiveHostname && bgHostedZoneId) {
|
||||
routing.blueGreenDns = {
|
||||
activeHostname,
|
||||
inactiveHostname,
|
||||
hostedZoneId: bgHostedZoneId,
|
||||
isActive, // Informational only - both DNS records are always created
|
||||
};
|
||||
}
|
||||
} else if (hostHeader || pathPatterns) {
|
||||
// Cluster ALB mode (existing behavior)
|
||||
// Validate required parameters for cluster ALB
|
||||
// Note: externalListenerArn/internalListenerArn were declared above and may have been imported if clusterStackName was provided
|
||||
|
||||
if (!externalListenerArn && !internalListenerArn) {
|
||||
throw new Error('When using cluster ALB, either externalListenerArn or internalListenerArn must be provided');
|
||||
}
|
||||
if (externalListenerArn && internalListenerArn) {
|
||||
throw new Error(
|
||||
'Cannot use both externalListenerArn and internalListenerArn. Choose either external OR internal, not both'
|
||||
);
|
||||
}
|
||||
if (useExternal && useInternal) {
|
||||
throw new Error(
|
||||
'Cannot use both useExternalALB and useInternalALB. Choose either external OR internal, not both'
|
||||
);
|
||||
}
|
||||
if (useExternal && !externalListenerArn) {
|
||||
throw new Error('useExternalALB=true requires externalListenerArn to be provided');
|
||||
}
|
||||
if (useInternal && !internalListenerArn) {
|
||||
throw new Error('useInternalALB=true requires internalListenerArn to be provided');
|
||||
}
|
||||
// Auto-detect if not explicitly set
|
||||
const detectedExternal = externalListenerArn ? true : false;
|
||||
const detectedInternal = internalListenerArn ? true : false;
|
||||
|
||||
// Use explicit flags if set, otherwise auto-detect from listener ARNs
|
||||
routing = {
|
||||
external: useExternal || (detectedExternal && !useInternal),
|
||||
internal: useInternal || (detectedInternal && !useExternal),
|
||||
hostHeader,
|
||||
pathPatterns,
|
||||
priority: priority ?? 100,
|
||||
stickiness: app.node.tryGetContext('stickiness') === 'true',
|
||||
stickinessDuration: app.node.tryGetContext('stickinessDuration')
|
||||
? parseInt(app.node.tryGetContext('stickinessDuration'))
|
||||
: undefined,
|
||||
deregistrationDelay: app.node.tryGetContext('deregistrationDelay')
|
||||
? parseInt(app.node.tryGetContext('deregistrationDelay'))
|
||||
: undefined,
|
||||
};
|
||||
} else {
|
||||
// No routing configured - this is valid for services without ALB (e.g., workers, pub/sub)
|
||||
// No validation needed - service can run without ALB
|
||||
}
|
||||
|
||||
// DNS (for cluster ALB mode)
|
||||
const hostedZoneId = app.node.tryGetContext('hostedZoneId');
|
||||
const zoneName = app.node.tryGetContext('zoneName');
|
||||
const recordName = app.node.tryGetContext('recordName');
|
||||
|
||||
const dns: DnsConfig | undefined =
|
||||
hostedZoneId && zoneName && recordName
|
||||
? {
|
||||
hostedZoneId,
|
||||
zoneName,
|
||||
recordName,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Deployment
|
||||
const circuitBreaker = app.node.tryGetContext('circuitBreaker') !== 'false';
|
||||
const enableExecuteCommand = app.node.tryGetContext('enableExecuteCommand') !== 'false';
|
||||
|
||||
const deployment: DeploymentConfig = {
|
||||
circuitBreaker,
|
||||
enableExecuteCommand,
|
||||
};
|
||||
|
||||
// Public subnet IDs (for individual ALB)
|
||||
const publicSubnetIds = app.node.tryGetContext('publicSubnetIds')?.split(',');
|
||||
|
||||
// Auto-import logs bucket from cluster stack if not explicitly provided
|
||||
// This matches the old pattern: Fn::ImportValue: !Sub "${ClusterName}-logs-s3-bucket"
|
||||
// Cluster exports: ${clusterStackName}-logs-s3-bucket
|
||||
let clusterLogsBucketName = app.node.tryGetContext('clusterLogsBucketName');
|
||||
if (!clusterLogsBucketName && clusterStackName) {
|
||||
clusterLogsBucketName = cdk.Fn.importValue(`${clusterStackName}-logs-s3-bucket`).toString();
|
||||
}
|
||||
// Note: clusterLogsBucketName is optional (only needed for bg-common ALB access logs)
|
||||
// We don't fail if it's missing - it's only used if provided
|
||||
|
||||
return new SpicyEcsServiceStack(scope, id, {
|
||||
...stackProps,
|
||||
clusterName: clusterStackName,
|
||||
vpcId: vpcId.toString(),
|
||||
vpcCidrBlock,
|
||||
numberOfAzs,
|
||||
availabilityZones,
|
||||
privateSubnetIds,
|
||||
serviceName,
|
||||
image,
|
||||
containerPort,
|
||||
cpu,
|
||||
memory,
|
||||
environment,
|
||||
secrets,
|
||||
desiredCount,
|
||||
capacityProviderStrategy,
|
||||
scaling,
|
||||
healthCheckPath,
|
||||
routing,
|
||||
externalListenerArn,
|
||||
internalListenerArn,
|
||||
publicSubnetIds,
|
||||
clusterLogsBucketName,
|
||||
dns,
|
||||
deployment,
|
||||
serviceTags,
|
||||
});
|
||||
}
|
||||
}
|
||||
164
resources/lib/stacks/spicy-vpc-stack.ts
Normal file
164
resources/lib/stacks/spicy-vpc-stack.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import * as cdk from 'aws-cdk-lib/core';
|
||||
import { Construct } from 'constructs';
|
||||
import { SpicyVpc, SpicyVpcProps, SubnetCidrConfig } from '../constructs/spicy-vpc';
|
||||
|
||||
/**
|
||||
* Props for SpicyVpcStack
|
||||
* Extends SpicyVpcProps but makes tags optional since we can derive from context
|
||||
*/
|
||||
export interface SpicyVpcStackProps extends cdk.StackProps {
|
||||
/**
|
||||
* VPC configuration - all SpicyVpcProps except tags (derived from stack props)
|
||||
*/
|
||||
readonly vpcConfig?: Omit<SpicyVpcProps, 'tags'>;
|
||||
|
||||
/**
|
||||
* Owner tag
|
||||
*/
|
||||
readonly ownerTag: string;
|
||||
|
||||
/**
|
||||
* Product tag
|
||||
*/
|
||||
readonly productTag: string;
|
||||
|
||||
/**
|
||||
* Component tag
|
||||
*/
|
||||
readonly componentTag: string;
|
||||
|
||||
/**
|
||||
* Build identifier (e.g., git SHA)
|
||||
*/
|
||||
readonly buildTag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SpicyVpcStack - A CloudFormation stack that creates a Spicy VPC
|
||||
*
|
||||
* This stack can be deployed via CDK CLI or from Jenkins pipelines.
|
||||
* Configuration can be provided via:
|
||||
* 1. Direct props
|
||||
* 2. CDK context (cdk deploy -c key=value)
|
||||
* 3. Environment variables
|
||||
*/
|
||||
export class SpicyVpcStack extends cdk.Stack {
|
||||
/** The VPC construct */
|
||||
public readonly spicyVpc: SpicyVpc;
|
||||
|
||||
constructor(scope: Construct, id: string, props: SpicyVpcStackProps) {
|
||||
super(scope, id, props);
|
||||
|
||||
// Create the VPC
|
||||
this.spicyVpc = new SpicyVpc(this, 'SpicyVpc', {
|
||||
...props.vpcConfig,
|
||||
tags: {
|
||||
owner: props.ownerTag,
|
||||
product: props.productTag,
|
||||
component: props.componentTag,
|
||||
build: props.buildTag,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a SpicyVpcStack from CDK context values
|
||||
* This is useful when deploying from Jenkins where context is passed via -c flags
|
||||
*/
|
||||
static fromContext(scope: Construct, id: string, stackProps?: cdk.StackProps): SpicyVpcStack {
|
||||
const app = scope.node.root as cdk.App;
|
||||
|
||||
// Required context values
|
||||
const ownerTag = app.node.tryGetContext('ownerTag') ?? 'SpicyTeam';
|
||||
const productTag = app.node.tryGetContext('productTag') ?? 'spicy';
|
||||
const componentTag = app.node.tryGetContext('componentTag') ?? 'spicy-VPC';
|
||||
const buildTag = app.node.tryGetContext('build');
|
||||
|
||||
// Optional VPC configuration from context
|
||||
const vpcCidr = app.node.tryGetContext('vpcCidr') as string | undefined;
|
||||
const vpcTenancy = app.node.tryGetContext('vpcTenancy') as 'default' | 'dedicated' | undefined;
|
||||
const numberOfAzs = app.node.tryGetContext('numberOfAzs') as string | undefined;
|
||||
const availabilityZones = app.node.tryGetContext('availabilityZones') as string | undefined;
|
||||
const createPrivateSubnets = app.node.tryGetContext('createPrivateSubnets') as string | undefined;
|
||||
const createAdditionalPrivateSubnets = app.node.tryGetContext('createAdditionalPrivateSubnets') as
|
||||
| string
|
||||
| undefined;
|
||||
const publicSubnetTag = app.node.tryGetContext('publicSubnetTag') as string | undefined;
|
||||
const privateSubnetATag = app.node.tryGetContext('privateSubnetATag') as string | undefined;
|
||||
const privateSubnetBTag = app.node.tryGetContext('privateSubnetBTag') as string | undefined;
|
||||
|
||||
// Parse subnet CIDRs from context
|
||||
const subnetCidrs = SpicyVpcStack.parseSubnetCidrsFromContext(app);
|
||||
|
||||
// Build VPC config using a mutable object then cast
|
||||
const vpcConfigBuilder: {
|
||||
vpcCidr?: string;
|
||||
vpcTenancy?: 'default' | 'dedicated';
|
||||
numberOfAzs?: 2 | 3 | 4;
|
||||
availabilityZones?: string[];
|
||||
createPrivateSubnets?: boolean;
|
||||
createAdditionalPrivateSubnets?: boolean;
|
||||
publicSubnetTag?: string;
|
||||
privateSubnetATag?: string;
|
||||
privateSubnetBTag?: string;
|
||||
subnetCidrs?: SpicyVpcProps['subnetCidrs'];
|
||||
} = {};
|
||||
|
||||
if (vpcCidr) vpcConfigBuilder.vpcCidr = vpcCidr;
|
||||
if (vpcTenancy) vpcConfigBuilder.vpcTenancy = vpcTenancy;
|
||||
if (numberOfAzs) vpcConfigBuilder.numberOfAzs = parseInt(numberOfAzs, 10) as 2 | 3 | 4;
|
||||
if (availabilityZones) {
|
||||
vpcConfigBuilder.availabilityZones = availabilityZones.split(',');
|
||||
}
|
||||
if (createPrivateSubnets !== undefined) {
|
||||
vpcConfigBuilder.createPrivateSubnets = createPrivateSubnets === 'true';
|
||||
}
|
||||
if (createAdditionalPrivateSubnets !== undefined) {
|
||||
vpcConfigBuilder.createAdditionalPrivateSubnets = createAdditionalPrivateSubnets === 'true';
|
||||
}
|
||||
if (publicSubnetTag) vpcConfigBuilder.publicSubnetTag = publicSubnetTag;
|
||||
if (privateSubnetATag) vpcConfigBuilder.privateSubnetATag = privateSubnetATag;
|
||||
if (privateSubnetBTag) vpcConfigBuilder.privateSubnetBTag = privateSubnetBTag;
|
||||
if (subnetCidrs && Object.keys(subnetCidrs).length > 0) {
|
||||
vpcConfigBuilder.subnetCidrs = subnetCidrs;
|
||||
}
|
||||
|
||||
return new SpicyVpcStack(scope, id, {
|
||||
...stackProps,
|
||||
ownerTag,
|
||||
productTag,
|
||||
componentTag,
|
||||
buildTag,
|
||||
vpcConfig: vpcConfigBuilder,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse subnet CIDR configurations from CDK context
|
||||
*/
|
||||
private static parseSubnetCidrsFromContext(app: cdk.App): SpicyVpcProps['subnetCidrs'] {
|
||||
const cidrs: SpicyVpcProps['subnetCidrs'] = {};
|
||||
|
||||
const azLetters = ['a', 'b', 'c', 'd'] as const;
|
||||
|
||||
for (const az of azLetters) {
|
||||
const azUpper = az.toUpperCase();
|
||||
const publicCidr = app.node.tryGetContext(`publicSubnet${azUpper}Cidr`);
|
||||
const private1Cidr = app.node.tryGetContext(`privateSubnet${azUpper}1Cidr`);
|
||||
const private2Cidr = app.node.tryGetContext(`privateSubnet${azUpper}2Cidr`);
|
||||
|
||||
if (publicCidr || private1Cidr || private2Cidr) {
|
||||
const config: SubnetCidrConfig = {
|
||||
publicCidr: publicCidr ?? '',
|
||||
private1Cidr: private1Cidr ?? '',
|
||||
};
|
||||
if (private2Cidr) {
|
||||
config.private2Cidr = private2Cidr;
|
||||
}
|
||||
cidrs[az] = config;
|
||||
}
|
||||
}
|
||||
|
||||
return cidrs;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user