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, }); } }