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; /** Secrets from Secrets Manager (key = env var name, value = secret ARN or ARN:key) */ secrets?: Record; /** 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 ?? '/', 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 = {}; 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" should hit active service // 5. Test routing: curl -H "Host: inactive-api.example.com" 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 { // DNS records are NOT created here. They belong to the persistent ALB stack (spicy-alb.ts), // which owns the Route53 A records pointing to the load balancer. Creating them here would // cause CloudFormation conflicts when both blue and green service stacks attempt to manage // the same DNS records. // // Traffic routing between blue/green services is controlled entirely by ALB listener rule // priorities — not by DNS changes. Both hostnames always resolve to the same ALB. } /** * 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 } }