Files
spicy-automation/resources/lib/constructs/spicy-ecs-service.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

1026 lines
35 KiB
TypeScript

import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as route53Targets from 'aws-cdk-lib/aws-route53-targets';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
/**
* Container definition for the service
*/
export interface ContainerConfig {
/** Docker image URI (e.g., nexus.kodeniks.com/docker-hosted/my-app:latest) */
image: string;
/** Container port */
port: number;
/** CPU units (256 = 0.25 vCPU) */
cpu?: number;
/** Memory in MiB */
memory?: number;
/** Memory reservation (soft limit) in MiB */
memoryReservation?: number;
/** Environment variables */
environment?: Record<string, string>;
/** Secrets from Secrets Manager (key = env var name, value = secret ARN or ARN:key) */
secrets?: Record<string, string>;
/** Health check command */
healthCheck?: {
command: string[];
interval?: number;
timeout?: number;
retries?: number;
startPeriod?: number;
};
/** Container command override */
command?: string[];
/** Entry point override */
entryPoint?: string[];
}
/**
* Capacity provider strategy for mixed EC2/Fargate deployments
*/
export interface CapacityProviderStrategyItem {
/** Capacity provider name (e.g., 'FARGATE', 'FARGATE_SPOT', or EC2 provider name) */
capacityProvider: string;
/** Base count - minimum tasks on this provider */
base?: number;
/** Weight for distributing tasks above base */
weight: number;
}
/**
* Auto-scaling configuration
*/
export interface ServiceScalingConfig {
/** Minimum number of tasks */
minCapacity: number;
/** Maximum number of tasks */
maxCapacity: number;
/** Target CPU utilization percentage (0-100) */
targetCpuUtilization?: number;
/** Target memory utilization percentage (0-100) */
targetMemoryUtilization?: number;
/** Target requests per task per minute */
targetRequestsPerTarget?: number;
/** Scale-in cooldown in seconds */
scaleInCooldown?: number;
/** Scale-out cooldown in seconds */
scaleOutCooldown?: number;
}
/**
* Health check configuration for ALB target group
*/
export interface HealthCheckConfig {
/** Health check path */
path: string;
/** Health check interval in seconds */
interval?: number;
/** Health check timeout in seconds */
timeout?: number;
/** Healthy threshold count */
healthyThresholdCount?: number;
/** Unhealthy threshold count */
unhealthyThresholdCount?: number;
/** HTTP codes to consider healthy */
healthyHttpCodes?: string;
}
/**
* Blue/Green DNS configuration
*
* Note: Both DNS records are created and point to the same ALB.
* The isActive flag is informational only - routing is controlled by ALB listener rule priorities.
*/
export interface BlueGreenDnsConfig {
/** Active hostname (e.g., api.example.com) */
activeHostname: string;
/** Inactive hostname (e.g., inactive-api.example.com) */
inactiveHostname: string;
/** Route53 hosted zone ID */
hostedZoneId: string;
/**
* Whether this is the active service (informational only)
*
* Note: This flag doesn't affect DNS record creation - both records are always created.
* Actual routing is controlled by ALB listener rule priorities:
* - Active service: Lower priority (e.g., 100) for active hostname
* - Inactive service: Higher priority (e.g., 200) for inactive hostname
*/
isActive?: boolean;
}
/**
* Load balancer routing configuration
*/
export interface LoadBalancerRoutingConfig {
/**
* Reference the bg-common ALB (from persistent ALB stack)
*
* **Recommended approach**: Deploy ALB separately using SpicyAlbStack (or spicyALB pipeline function), then reference it here.
* This allows the ALB to persist across service stack deletions, keeping DNS stable.
*
* Can be provided as:
* - ALB ARN string (will be imported)
* - ALB object (from SpicyAlb construct)
*
* If not provided, uses cluster ALB (if externalListenerArn/internalListenerArn are set).
*/
existingALB?: elbv2.IApplicationLoadBalancer | string;
/** Use cluster's external (internet-facing) ALB */
external?: boolean;
/** Use cluster's internal ALB */
internal?: boolean;
/** Host header for routing (e.g., api.example.com) */
hostHeader?: string;
/** Path patterns for routing (e.g., /api/*) */
pathPatterns?: string[];
/** Listener rule priority (lower = higher priority) */
priority?: number;
/** Enable session stickiness (default: true for individual ALB, false for cluster ALB) */
stickiness?: boolean;
/** Stickiness duration in seconds */
stickinessDuration?: number;
/** Deregistration delay in seconds */
deregistrationDelay?: number;
/** Blue/Green DNS configuration (for individual ALB) */
blueGreenDns?: BlueGreenDnsConfig;
/**
* HTTPS listener ARN for bg-common ALB (required when using existingALB with certificate)
* Only one listener type is needed since bg-common ALB is either external OR internal (not both)
*/
httpsListenerArn?: string;
/**
* HTTP listener ARN for bg-common ALB (required when using existingALB without certificate)
* Only one listener type is needed since bg-common ALB is either external OR internal (not both)
*/
httpListenerArn?: string;
}
/**
* Route53 DNS configuration
*/
export interface DnsConfig {
/** Hosted zone ID */
hostedZoneId: string;
/** Hosted zone name (e.g., example.com) */
zoneName: string;
/** Record name (e.g., api for api.example.com) */
recordName: string;
}
/**
* Deployment configuration
*/
export interface DeploymentConfig {
/** Minimum healthy percent during deployment */
minHealthyPercent?: number;
/** Maximum percent during deployment */
maxPercent?: number;
/** Enable circuit breaker with rollback */
circuitBreaker?: boolean;
/** Enable ECS Exec for debugging */
enableExecuteCommand?: boolean;
}
/**
* Tags for the service
*/
export interface SpicyEcsServiceTags {
owner: string;
product: string;
component: string;
environment: string;
build?: string;
}
/**
* Properties for SpicyEcsService construct
*/
export interface SpicyEcsServiceProps {
/** ECS Cluster to deploy to */
readonly cluster: ecs.ICluster;
/** VPC for networking */
readonly vpc: ec2.IVpc;
/** Service name */
readonly serviceName: string;
/** Container configuration */
readonly container: ContainerConfig;
/**
* Capacity provider strategy for mixed deployments
* If not specified, uses cluster default
*/
readonly capacityProviderStrategy?: CapacityProviderStrategyItem[];
/**
* Desired number of tasks
* @default 2
*/
readonly desiredCount?: number;
/** Auto-scaling configuration */
readonly scaling?: ServiceScalingConfig;
/** Health check configuration */
readonly healthCheck?: HealthCheckConfig;
/** Load balancer routing */
readonly routing?: LoadBalancerRoutingConfig;
/** External ALB listener ARN (from cluster) */
readonly externalListenerArn?: string;
/** Internal ALB listener ARN (from cluster) */
readonly internalListenerArn?: string;
/** External ALB (for target group VPC) */
readonly externalLoadBalancer?: elbv2.IApplicationLoadBalancer;
/** Internal ALB (for target group VPC) */
readonly internalLoadBalancer?: elbv2.IApplicationLoadBalancer;
/** Route53 DNS configuration */
readonly dns?: DnsConfig;
/** Deployment configuration */
readonly deployment?: DeploymentConfig;
/** Subnets for tasks (for awsvpc network mode) */
readonly taskSubnets?: ec2.SubnetSelection;
/** Security groups for tasks */
readonly securityGroups?: ec2.ISecurityGroup[];
/** Additional IAM policies for task role */
readonly taskRolePolicies?: iam.PolicyStatement[];
/** Required tags */
readonly tags: SpicyEcsServiceTags;
}
/**
* SpicyEcsService - ECS Service with:
* - Mixed capacity provider strategy (EC2 + Fargate burst)
* - Auto-scaling on CPU/Memory/Requests
* - ALB integration with host/path routing
* - Optional Route53 DNS
* - Deployment circuit breaker
* - ECS Exec support
*/
export class SpicyEcsService extends Construct {
/** The ECS service */
public readonly service: ecs.BaseService;
/** The task definition */
public readonly taskDefinition: ecs.TaskDefinition;
/** The target group */
public targetGroup?: elbv2.ApplicationTargetGroup;
/** ALB reference (bg-common ALB when using existingALB) */
public alb?: elbv2.IApplicationLoadBalancer;
/** ALB HTTP listener (when using bg-common ALB) */
public httpListener?: elbv2.ApplicationListener;
/** ALB HTTPS listener (when using bg-common ALB) */
public httpsListener?: elbv2.ApplicationListener;
/** CloudWatch log group */
public readonly logGroup: logs.LogGroup;
/** Task role */
public readonly taskRole: iam.Role;
/** Task execution role */
public readonly executionRole: iam.Role;
constructor(scope: Construct, id: string, props: SpicyEcsServiceProps) {
super(scope, id);
const stack = cdk.Stack.of(this);
// Defaults
const desiredCount = props.desiredCount ?? 2;
const container = props.container;
const cpu = container.cpu ?? 256;
const memory = container.memory ?? 512;
const memoryReservation = container.memoryReservation ?? memory;
const deployment = {
minHealthyPercent: props.deployment?.minHealthyPercent ?? 50,
maxPercent: props.deployment?.maxPercent ?? 200,
circuitBreaker: props.deployment?.circuitBreaker ?? true,
enableExecuteCommand: props.deployment?.enableExecuteCommand ?? true,
};
const healthCheck = {
path: props.healthCheck?.path ?? '/health',
interval: props.healthCheck?.interval ?? 15,
timeout: props.healthCheck?.timeout ?? 14,
healthyThresholdCount: props.healthCheck?.healthyThresholdCount ?? 2,
unhealthyThresholdCount: props.healthCheck?.unhealthyThresholdCount ?? 7,
healthyHttpCodes: props.healthCheck?.healthyHttpCodes ?? '200-299',
};
// Determine if we're using Fargate (for network mode)
const usesFargate = Boolean(
props.capacityProviderStrategy?.some(
(s) => s.capacityProvider === 'FARGATE' || s.capacityProvider === 'FARGATE_SPOT'
)
);
// CloudWatch Log Group
this.logGroup = new logs.LogGroup(this, 'LogGroup', {
logGroupName: `/ecs/${props.serviceName}`,
retention: logs.RetentionDays.ONE_MONTH,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
(this.logGroup.node.defaultChild as logs.CfnLogGroup).overrideLogicalId('LogGroup');
// Task Execution Role (for pulling images, logging)
this.executionRole = new iam.Role(this, 'ExecutionRole', {
assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonECSTaskExecutionRolePolicy')],
});
(this.executionRole.node.defaultChild as iam.CfnRole).overrideLogicalId('ExecutionRole');
// Allow pulling from Nexus (private registry)
this.executionRole.addToPolicy(
new iam.PolicyStatement({
actions: ['ecr:GetAuthorizationToken'],
resources: ['*'],
})
);
// Task Role (for application permissions)
this.taskRole = new iam.Role(this, 'TaskRole', {
assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
});
(this.taskRole.node.defaultChild as iam.CfnRole).overrideLogicalId('TaskRole');
// Add custom policies
if (props.taskRolePolicies) {
for (const policy of props.taskRolePolicies) {
this.taskRole.addToPolicy(policy);
}
}
// ECS Exec permissions
if (deployment.enableExecuteCommand) {
this.taskRole.addToPolicy(
new iam.PolicyStatement({
actions: [
'ssmmessages:CreateControlChannel',
'ssmmessages:CreateDataChannel',
'ssmmessages:OpenControlChannel',
'ssmmessages:OpenDataChannel',
],
resources: ['*'],
})
);
}
// Task Definition
// Use EC2 compatibility for mixed strategies, Fargate-only if no EC2
const compatibility = usesFargate ? ecs.Compatibility.EC2_AND_FARGATE : ecs.Compatibility.EC2;
this.taskDefinition = new ecs.TaskDefinition(this, 'TaskDefinition', {
compatibility,
networkMode: usesFargate ? ecs.NetworkMode.AWS_VPC : ecs.NetworkMode.BRIDGE,
cpu: usesFargate ? String(cpu) : undefined,
memoryMiB: usesFargate ? String(memory) : undefined,
executionRole: this.executionRole,
taskRole: this.taskRole,
family: props.serviceName,
});
(this.taskDefinition.node.defaultChild as ecs.CfnTaskDefinition).overrideLogicalId('TaskDefinition');
// Build secrets
const secrets: Record<string, ecs.Secret> = {};
if (container.secrets) {
for (const [envName, secretArn] of Object.entries(container.secrets)) {
// Handle ARN:key format
const [arn, key] =
secretArn.includes(':') && !secretArn.startsWith('arn:') ? [secretArn, undefined] : secretArn.split('::');
const secret = secretsmanager.Secret.fromSecretCompleteArn(this, `Secret-${envName}`, arn);
secrets[envName] = key ? ecs.Secret.fromSecretsManager(secret, key) : ecs.Secret.fromSecretsManager(secret);
// Grant read access
secret.grantRead(this.executionRole);
}
}
// Container Definition
const containerDef = this.taskDefinition.addContainer('Container', {
containerName: props.serviceName,
image: ecs.ContainerImage.fromRegistry(container.image),
cpu: usesFargate ? undefined : cpu,
memoryLimitMiB: usesFargate ? undefined : memory,
memoryReservationMiB: usesFargate ? undefined : memoryReservation,
environment: container.environment,
secrets,
logging: ecs.LogDriver.awsLogs({
logGroup: this.logGroup,
streamPrefix: props.serviceName,
}),
command: container.command,
entryPoint: container.entryPoint,
healthCheck: container.healthCheck
? {
command: container.healthCheck.command,
interval: cdk.Duration.seconds(container.healthCheck.interval ?? 30),
timeout: cdk.Duration.seconds(container.healthCheck.timeout ?? 5),
retries: container.healthCheck.retries ?? 3,
startPeriod: cdk.Duration.seconds(container.healthCheck.startPeriod ?? 60),
}
: undefined,
essential: true,
});
// Port mapping
containerDef.addPortMappings({
containerPort: container.port,
protocol: ecs.Protocol.TCP,
});
// Security Group for tasks (awsvpc mode)
let taskSecurityGroup: ec2.SecurityGroup | undefined;
if (usesFargate) {
taskSecurityGroup = new ec2.SecurityGroup(this, 'TaskSecurityGroup', {
vpc: props.vpc,
description: `Security group for ${props.serviceName} tasks`,
allowAllOutbound: true,
});
(taskSecurityGroup.node.defaultChild as ec2.CfnSecurityGroup).overrideLogicalId('TaskSecurityGroup');
}
// Determine if we're using bg-common ALB or cluster ALB
// bg-common stack is always created, so it's either:
// 1. Use bg-common ALB (via existingALB)
// 2. Use cluster ALB
const existingALB = props.routing?.existingALB;
// Use bg-common ALB if provided (persistent ALB from separate stack)
if (existingALB) {
// Validate required listener ARN for bg-common ALB
// bg-common ALB has only one scheme (either external OR internal, not both)
// But it can have both HTTP and HTTPS listeners (like the old template)
const httpsListenerArn = props.routing?.httpsListenerArn;
const httpListenerArn = props.routing?.httpListenerArn;
if (!httpsListenerArn && !httpListenerArn) {
throw new Error(
'When using bg-common ALB (existingALB), either httpsListenerArn or httpListenerArn must be provided in routing config'
);
}
// Import ALB if ARN string provided, otherwise use object directly
const alb =
typeof existingALB === 'string'
? elbv2.ApplicationLoadBalancer.fromApplicationLoadBalancerAttributes(this, 'ExistingALB', {
loadBalancerArn: existingALB,
loadBalancerDnsName: '', // Not needed for routing
securityGroupId: 'sg-placeholder', // Placeholder - not used for routing
})
: existingALB;
this.useExistingALB(props, alb, healthCheck, usesFargate, container.port, taskSecurityGroup);
} else if (props.routing && (props.externalListenerArn || props.internalListenerArn)) {
// Use cluster ALB
// Validate that only one listener is used (external OR internal, not both)
if (props.externalListenerArn && props.internalListenerArn) {
throw new Error(
'Cannot use both externalListenerArn and internalListenerArn. Choose either external OR internal, not both'
);
}
if (props.routing.external && props.routing.internal) {
throw new Error(
'Cannot use both routing.external and routing.internal. Choose either external OR internal, not both'
);
}
// Validate that routing config matches listener ARNs
if (props.routing.external && !props.externalListenerArn) {
throw new Error('routing.external=true requires externalListenerArn to be provided');
}
if (props.routing.internal && !props.internalListenerArn) {
throw new Error('routing.internal=true requires internalListenerArn to be provided');
}
this.createClusterALBRouting(props, healthCheck, usesFargate, container.port, taskSecurityGroup);
} else if (props.routing) {
// Routing config provided but no ALB/listener ARNs - this is an error
throw new Error(
'Routing configuration provided but no ALB found. Either provide existingALB (for bg-common ALB) or externalListenerArn/internalListenerArn (for cluster ALB)'
);
}
// If no routing config, service runs without ALB (valid for workers, pub/sub, etc.)
// Allow traffic from ALB to tasks
if (taskSecurityGroup) {
if (existingALB) {
// Allow from bg-common ALB
taskSecurityGroup.addIngressRule(
ec2.Peer.ipv4(props.vpc.vpcCidrBlock),
ec2.Port.tcp(container.port),
'Allow traffic from bg-common ALB'
);
} else if (props.externalLoadBalancer || props.internalLoadBalancer) {
// Allow from cluster ALB
taskSecurityGroup.addIngressRule(
ec2.Peer.ipv4(props.vpc.vpcCidrBlock),
ec2.Port.tcp(container.port),
'Allow traffic from VPC (ALB)'
);
}
}
// Create Service
const serviceProps: ecs.Ec2ServiceProps | ecs.FargateServiceProps = {
cluster: props.cluster,
taskDefinition: this.taskDefinition,
desiredCount,
serviceName: props.serviceName,
minHealthyPercent: deployment.minHealthyPercent,
maxHealthyPercent: deployment.maxPercent,
circuitBreaker: deployment.circuitBreaker ? { rollback: true } : undefined,
enableExecuteCommand: deployment.enableExecuteCommand,
capacityProviderStrategies: props.capacityProviderStrategy?.map((s) => ({
capacityProvider: s.capacityProvider,
base: s.base,
weight: s.weight,
})),
};
// Create appropriate service type
if (usesFargate) {
this.service = new ecs.FargateService(this, 'Service', {
...serviceProps,
assignPublicIp: false,
vpcSubnets: props.taskSubnets ?? { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
securityGroups: taskSecurityGroup ? [taskSecurityGroup, ...(props.securityGroups ?? [])] : props.securityGroups,
platformVersion: ecs.FargatePlatformVersion.LATEST,
} as ecs.FargateServiceProps);
} else {
this.service = new ecs.Ec2Service(this, 'Service', {
...serviceProps,
placementStrategies: [
ecs.PlacementStrategy.spreadAcrossInstances(),
ecs.PlacementStrategy.spreadAcross(ecs.BuiltInAttributes.AVAILABILITY_ZONE),
],
} as ecs.Ec2ServiceProps);
}
(this.service.node.defaultChild as ecs.CfnService).overrideLogicalId('Service');
// Attach to target group
if (this.targetGroup) {
this.service.attachToApplicationTargetGroup(this.targetGroup);
}
// Auto-scaling
if (props.scaling) {
this.configureAutoScaling(props.scaling);
}
// Route53 DNS (for cluster ALB mode only - bg-common ALB handles DNS in useExistingALB)
// Note: For bg-common ALB with blue/green, DNS is configured in useExistingALB() via blueGreenDns
if (props.dns && !existingALB && (props.externalLoadBalancer || props.internalLoadBalancer)) {
this.configureDns(props.dns, props.externalLoadBalancer ?? props.internalLoadBalancer!);
}
// Apply tags
this.applyTags(props.tags);
// Outputs
this.addOutputs(props);
}
/**
* Use existing ALB (from persistent ALB stack)
*/
private useExistingALB(
props: SpicyEcsServiceProps,
existingALB: elbv2.IApplicationLoadBalancer,
healthCheck: {
path: string;
interval: number;
timeout: number;
healthyThresholdCount: number;
unhealthyThresholdCount: number;
healthyHttpCodes: string;
},
usesFargate: boolean,
containerPort: number,
taskSecurityGroup: ec2.SecurityGroup | undefined
): void {
const routing = props.routing!;
// Store reference to existing ALB
this.alb = existingALB;
// Create Target Group
// Default stickiness to true for individual ALB (matches legacy)
const stickinessEnabled = routing.stickiness ?? true;
this.targetGroup = new elbv2.ApplicationTargetGroup(this, 'TargetGroup', {
vpc: props.vpc,
port: containerPort,
protocol: elbv2.ApplicationProtocol.HTTP,
targetType: usesFargate ? elbv2.TargetType.IP : elbv2.TargetType.INSTANCE,
healthCheck: {
path: healthCheck.path,
interval: cdk.Duration.seconds(healthCheck.interval),
timeout: cdk.Duration.seconds(healthCheck.timeout),
healthyThresholdCount: healthCheck.healthyThresholdCount,
unhealthyThresholdCount: healthCheck.unhealthyThresholdCount,
healthyHttpCodes: healthCheck.healthyHttpCodes,
},
deregistrationDelay: cdk.Duration.seconds(routing.deregistrationDelay ?? 60),
stickinessCookieDuration: stickinessEnabled
? cdk.Duration.seconds(routing.stickinessDuration ?? 86400)
: undefined,
});
(this.targetGroup.node.defaultChild as elbv2.CfnTargetGroup).overrideLogicalId('TargetGroup');
// Get listeners from existing ALB
// bg-common ALB can have both HTTP and HTTPS listeners (like the old template)
const httpsListenerArn = routing.httpsListenerArn;
const httpListenerArn = routing.httpListenerArn;
if (!httpsListenerArn && !httpListenerArn) {
throw new Error(
'When using existingALB, either httpsListenerArn or httpListenerArn must be provided in routing config'
);
}
// Build conditions for listener rule
const conditions: elbv2.ListenerCondition[] = [];
if (routing.hostHeader) {
conditions.push(elbv2.ListenerCondition.hostHeaders([routing.hostHeader]));
}
if (routing.pathPatterns && routing.pathPatterns.length > 0) {
conditions.push(elbv2.ListenerCondition.pathPatterns(routing.pathPatterns));
}
if (conditions.length === 0) {
conditions.push(elbv2.ListenerCondition.pathPatterns(['/*']));
}
// Create listener references and add rules (like the old template - rules on both HTTP and HTTPS)
const placeholderSG = ec2.SecurityGroup.fromSecurityGroupId(this, 'ExistingListenerSG', 'sg-placeholder', {
allowAllOutbound: true,
});
// HTTPS listener rule (if HTTPS listener provided)
if (httpsListenerArn) {
const httpsListener = elbv2.ApplicationListener.fromApplicationListenerAttributes(this, 'ExistingHTTPSListener', {
listenerArn: httpsListenerArn,
securityGroup: placeholderSG,
});
new elbv2.ApplicationListenerRule(this, 'HTTPSListenerRule', {
listener: httpsListener,
priority: routing.priority ?? 100,
conditions,
targetGroups: [this.targetGroup],
});
}
// HTTP listener rule (if HTTP listener provided)
if (httpListenerArn) {
const httpListener = elbv2.ApplicationListener.fromApplicationListenerAttributes(this, 'ExistingHTTPListener', {
listenerArn: httpListenerArn,
securityGroup: placeholderSG,
});
new elbv2.ApplicationListenerRule(this, 'HTTPListenerRule', {
listener: httpListener,
priority: routing.priority ?? 100,
conditions,
targetGroups: [this.targetGroup],
});
}
// Configure Blue/Green DNS if provided (for bg-common ALB)
// Note: Both active and inactive DNS records are created and point to the same ALB.
// Routing is controlled by ALB listener rule priorities, not DNS changes.
// The isActive flag in BlueGreenDnsConfig is informational - both records are always created.
// Actual routing is determined by which service stack has the lower priority listener rule.
//
// Testing: To verify blue/green DNS:
// 1. Check both DNS records exist: `dig api.example.com` and `dig inactive-api.example.com`
// 2. Both should resolve to the same ALB DNS name
// 3. Verify listener rules: Active service should have priority 100, inactive should have priority 200
// 4. Test routing: curl -H "Host: api.example.com" <ALB_DNS> should hit active service
// 5. Test routing: curl -H "Host: inactive-api.example.com" <ALB_DNS> should hit inactive service
if (routing.blueGreenDns) {
this.configureBlueGreenDns(routing.blueGreenDns, existingALB);
}
}
/**
* Create cluster ALB routing (existing behavior)
*/
private createClusterALBRouting(
props: SpicyEcsServiceProps,
healthCheck: {
path: string;
interval: number;
timeout: number;
healthyThresholdCount: number;
unhealthyThresholdCount: number;
healthyHttpCodes: string;
},
usesFargate: boolean,
containerPort: number,
taskSecurityGroup: ec2.SecurityGroup | undefined
): void {
const routing = props.routing!;
// Create Target Group
// Default stickiness to false for cluster ALB (existing behavior)
const stickinessEnabled = routing.stickiness ?? false;
this.targetGroup = new elbv2.ApplicationTargetGroup(this, 'TargetGroup', {
vpc: props.vpc,
port: containerPort,
protocol: elbv2.ApplicationProtocol.HTTP,
targetType: usesFargate ? elbv2.TargetType.IP : elbv2.TargetType.INSTANCE,
healthCheck: {
path: healthCheck.path,
interval: cdk.Duration.seconds(healthCheck.interval),
timeout: cdk.Duration.seconds(healthCheck.timeout),
healthyThresholdCount: healthCheck.healthyThresholdCount,
unhealthyThresholdCount: healthCheck.unhealthyThresholdCount,
healthyHttpCodes: healthCheck.healthyHttpCodes,
},
deregistrationDelay: cdk.Duration.seconds(routing.deregistrationDelay ?? 60),
stickinessCookieDuration: stickinessEnabled
? cdk.Duration.seconds(routing.stickinessDuration ?? 86400)
: undefined,
});
(this.targetGroup.node.defaultChild as elbv2.CfnTargetGroup).overrideLogicalId('TargetGroup');
// Add listener rules
if (props.externalListenerArn && routing.external) {
this.addListenerRule('External', props.externalListenerArn, routing);
}
if (props.internalListenerArn && routing.internal) {
this.addListenerRule('Internal', props.internalListenerArn, routing);
}
}
/**
* Configure Blue/Green DNS (active and inactive hostnames)
*
* IMPORTANT: Both DNS records are created and point to the SAME ALB.
* Traffic routing is controlled by ALB listener rule priorities, NOT by DNS changes.
*
* How it works:
* 1. Both active and inactive hostnames resolve to the same ALB DNS name
* 2. ALB listener rules use hostname conditions to route traffic
* 3. The active service has a lower priority rule (e.g., 100) for the active hostname
* 4. The inactive service has a higher priority rule (e.g., 200) for the inactive hostname
* 5. When swapping, only the listener rule priorities are updated (via CDK deployment)
* 6. DNS records never change - they always point to the same ALB
*
* The isActive flag in BlueGreenDnsConfig is informational only - both records are always created.
* The actual routing is determined by which service stack has the lower priority listener rule.
*/
private configureBlueGreenDns(dns: BlueGreenDnsConfig, loadBalancer: elbv2.IApplicationLoadBalancer): void {
// Extract domain from hostname (e.g., "api.example.com" -> "example.com")
const domainParts = dns.activeHostname.split('.');
const zoneName = domainParts.slice(-2).join('.');
const activeRecordName = domainParts.slice(0, -2).join('.') || '@';
const inactiveRecordName = dns.inactiveHostname.split('.').slice(0, -2).join('.') || '@';
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', {
hostedZoneId: dns.hostedZoneId,
zoneName: zoneName,
});
// Active hostname DNS record - always points to the ALB
// Note: This record is created by both blue and green stacks, but routing is via listener priorities
new route53.ARecord(this, 'ActiveDnsRecord', {
zone: hostedZone,
recordName: activeRecordName === '@' ? undefined : activeRecordName,
target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(loadBalancer)),
});
// Inactive hostname DNS record - always points to the ALB
// Note: This record is created by both blue and green stacks, but routing is via listener priorities
new route53.ARecord(this, 'InactiveDnsRecord', {
zone: hostedZone,
recordName: inactiveRecordName === '@' ? undefined : inactiveRecordName,
target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(loadBalancer)),
});
}
/**
* Add listener rule for ALB routing
*/
private addListenerRule(name: string, listenerArn: string, routing: LoadBalancerRoutingConfig): void {
const conditions: elbv2.ListenerCondition[] = [];
if (routing.hostHeader) {
conditions.push(elbv2.ListenerCondition.hostHeaders([routing.hostHeader]));
}
if (routing.pathPatterns && routing.pathPatterns.length > 0) {
conditions.push(elbv2.ListenerCondition.pathPatterns(routing.pathPatterns));
}
// If no conditions, use a catch-all path
if (conditions.length === 0) {
conditions.push(elbv2.ListenerCondition.pathPatterns(['/*']));
}
const placeholderSG = ec2.SecurityGroup.fromSecurityGroupId(this, `${name}ListenerSG`, 'sg-placeholder', {
allowAllOutbound: true,
});
const listener = elbv2.ApplicationListener.fromApplicationListenerAttributes(this, `${name}Listener`, {
listenerArn,
securityGroup: placeholderSG,
});
new elbv2.ApplicationListenerRule(this, `${name}ListenerRule`, {
listener,
priority: routing.priority ?? 100,
conditions,
targetGroups: [this.targetGroup!],
});
// HTTP redirect rule (if external)
if (name === 'External' && routing.hostHeader) {
// Note: HTTP redirect should be handled at the ALB level, not per-service
// The cluster ALB already has a default HTTP→HTTPS redirect
}
}
/**
* Configure auto-scaling
*/
private configureAutoScaling(scaling: ServiceScalingConfig): void {
const scalableTarget = this.service.autoScaleTaskCount({
minCapacity: scaling.minCapacity,
maxCapacity: scaling.maxCapacity,
});
const cooldown = {
scaleInCooldown: cdk.Duration.seconds(scaling.scaleInCooldown ?? 300),
scaleOutCooldown: cdk.Duration.seconds(scaling.scaleOutCooldown ?? 60),
};
// CPU utilization scaling
if (scaling.targetCpuUtilization) {
scalableTarget.scaleOnCpuUtilization('CpuScaling', {
targetUtilizationPercent: scaling.targetCpuUtilization,
...cooldown,
});
}
// Memory utilization scaling
if (scaling.targetMemoryUtilization) {
scalableTarget.scaleOnMemoryUtilization('MemoryScaling', {
targetUtilizationPercent: scaling.targetMemoryUtilization,
...cooldown,
});
}
// Request count scaling (requires target group)
if (scaling.targetRequestsPerTarget && this.targetGroup) {
scalableTarget.scaleOnRequestCount('RequestScaling', {
requestsPerTarget: scaling.targetRequestsPerTarget,
targetGroup: this.targetGroup,
...cooldown,
});
}
}
/**
* Configure Route53 DNS
*/
private configureDns(dns: DnsConfig, loadBalancer: elbv2.IApplicationLoadBalancer): void {
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', {
hostedZoneId: dns.hostedZoneId,
zoneName: dns.zoneName,
});
new route53.ARecord(this, 'DnsRecord', {
zone: hostedZone,
recordName: dns.recordName,
target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(loadBalancer)),
});
}
/**
* Apply tags
*/
private applyTags(tags: SpicyEcsServiceTags): void {
const stack = cdk.Stack.of(this);
cdk.Tags.of(this).add('Name', stack.stackName);
cdk.Tags.of(this).add('Owner', tags.owner);
cdk.Tags.of(this).add('Product', tags.product);
cdk.Tags.of(this).add('Component', tags.component);
cdk.Tags.of(this).add('Environment', tags.environment);
if (tags.build) {
cdk.Tags.of(this).add('Build', tags.build);
}
}
/**
* Add CloudFormation outputs
*/
private addOutputs(props: SpicyEcsServiceProps): void {
const stack = cdk.Stack.of(this);
const serviceNameOutput = new cdk.CfnOutput(this, 'ServiceNameOutput', {
value: props.serviceName,
description: 'ECS Service Name',
exportName: `${stack.stackName}-service-name`,
});
serviceNameOutput.overrideLogicalId('ServiceName');
const serviceArnOutput = new cdk.CfnOutput(this, 'ServiceArnOutput', {
value: this.service.serviceArn,
description: 'ECS Service ARN',
exportName: `${stack.stackName}-service-arn`,
});
serviceArnOutput.overrideLogicalId('ServiceArn');
const taskDefOutput = new cdk.CfnOutput(this, 'TaskDefinitionArnOutput', {
value: this.taskDefinition.taskDefinitionArn,
description: 'Task Definition ARN',
exportName: `${stack.stackName}-task-definition-arn`,
});
taskDefOutput.overrideLogicalId('TaskDefinitionArn');
const logGroupOutput = new cdk.CfnOutput(this, 'LogGroupNameOutput', {
value: this.logGroup.logGroupName,
description: 'CloudWatch Log Group',
exportName: `${stack.stackName}-log-group`,
});
logGroupOutput.overrideLogicalId('LogGroupName');
if (this.targetGroup) {
const targetGroupOutput = new cdk.CfnOutput(this, 'TargetGroupArnOutput', {
value: this.targetGroup.targetGroupArn,
description: 'Target Group ARN',
exportName: `${stack.stackName}-target-group-arn`,
});
targetGroupOutput.overrideLogicalId('TargetGroupArn');
}
// Note: When using existing ALB (bg-common pattern), outputs come from ALB stack, not service stack
}
}