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:
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user