Files
spicy-automation/resources/lib/stacks/spicy-ecs-cluster-stack.ts
Ryan Wilson 68684df471 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>
2025-11-18 22:21:00 -08:00

333 lines
10 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.'
);
}
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,
});
}
}