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:
2025-11-18 22:21:00 -08:00
commit 68684df471
51 changed files with 15587 additions and 0 deletions

View 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';

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

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

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

View 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;
}
}