- Fix HTTP listener in spicy-alb.ts missing default action when no certificate is provided, which would cause CDK synth to fail - Auto-import numberOfAzs from VPC stack exports (NumberOfAZs) in cluster, service, and ALB stacks when not provided via context - Fix CDK_SYNTH_EXAMPLES.md ALB examples using raw vpcId/subnetIds that don't match the actual fromContext() implementation (requires clusterName) - Fix docs overstating "only clusterName required" to list actual required params - Remove package-lock.json and add to .gitignore (project uses pnpm) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
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.'
|
|
);
|
|
}
|
|
// numberOfAzs: use context value if provided, otherwise auto-import from VPC stack export
|
|
const numberOfAzsRaw = app.node.tryGetContext('numberOfAzs');
|
|
let numberOfAzs: number;
|
|
if (numberOfAzsRaw) {
|
|
numberOfAzs = parseInt(numberOfAzsRaw, 10);
|
|
} else {
|
|
// Auto-import from VPC stack export
|
|
const imported = cdk.Fn.importValue(`${vpcStackName}-NumberOfAZs`).toString();
|
|
numberOfAzs = parseInt(imported, 10);
|
|
}
|
|
if (!numberOfAzs || Number.isNaN(numberOfAzs)) {
|
|
throw new Error('numberOfAzs is required (2-4). Provide via context or ensure VPC stack exports NumberOfAZs.');
|
|
}
|
|
|
|
// 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,
|
|
});
|
|
}
|
|
}
|