import * as ec2 from 'aws-cdk-lib/aws-ec2'; import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2'; import * as route53 from 'aws-cdk-lib/aws-route53'; import * as route53Targets from 'aws-cdk-lib/aws-route53-targets'; import * as s3 from 'aws-cdk-lib/aws-s3'; import * as cdk from 'aws-cdk-lib/core'; import { Construct } from 'constructs'; /** * Tags for the ALB */ export interface SpicyAlbTags { owner: string; product: string; component: string; environment: string; build?: string; } /** * Blue/Green DNS configuration for persistent ALB */ export interface PersistentAlbBlueGreenDnsConfig { /** 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; } /** * Properties for the SpicyAlb construct */ export interface SpicyAlbProps { /** The VPC to deploy the ALB into */ readonly vpc: ec2.IVpc; /** VPC CIDR block (required for internal ALB security group rules) */ readonly vpcCidrBlock?: string; /** ALB scheme: "internet-facing" or "internal" */ readonly scheme: 'internet-facing' | 'internal'; /** Subnets for the ALB (either subnet objects or subnet IDs) */ readonly subnets: ec2.ISubnet[] | string[]; /** Availability zones (required if subnets are provided as IDs) */ readonly availabilityZones?: string[]; /** SSL certificate ARN for HTTPS listener */ readonly certificateArn?: string; /** Idle timeout in seconds */ readonly idleTimeout?: number; /** Additional security groups for the ALB */ readonly additionalSecurityGroups?: ec2.ISecurityGroup[]; /** S3 bucket for ALB access logs */ readonly logsBucket?: s3.IBucket; /** 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; } /** * SpicyAlb - A persistent ALB construct for blue/green deployments. * * This ALB is designed to be deployed in a separate, long-lived stack * (like the old bg-common pattern) so the ALB DNS name stays constant * even when service stacks are deleted and recreated. */ export class SpicyAlb extends Construct { /** The Application Load Balancer */ public readonly loadBalancer: elbv2.ApplicationLoadBalancer; /** HTTP listener */ public readonly httpListener: elbv2.ApplicationListener; /** HTTPS listener (if certificate provided) */ public readonly httpsListener?: elbv2.ApplicationListener; /** ALB security group */ public readonly securityGroup: ec2.SecurityGroup; /** ALB scheme */ private readonly scheme: 'internet-facing' | 'internal'; constructor(scope: Construct, id: string, props: SpicyAlbProps) { super(scope, id); const stack = cdk.Stack.of(this); // Resolve subnets - handle both subnet objects and subnet IDs let subnets: ec2.ISubnet[]; if (props.subnets.length > 0 && typeof props.subnets[0] === 'string') { // Subnet IDs provided - create subnet references const subnetIds = props.subnets as string[]; const availabilityZones = props.availabilityZones ?? props.vpc.availabilityZones; subnets = subnetIds.map((subnetId, index) => ec2.Subnet.fromSubnetAttributes(this, `ALBSubnet${index}`, { subnetId, availabilityZone: availabilityZones[index % availabilityZones.length], }) ); } else { // Subnet objects provided subnets = props.subnets as ec2.ISubnet[]; } // ALB Security Group this.securityGroup = new ec2.SecurityGroup(this, 'ALBSecurityGroup', { vpc: props.vpc, description: `Security group for persistent ALB ${stack.stackName}`, allowAllOutbound: true, }); (this.securityGroup.node.defaultChild as ec2.CfnSecurityGroup).overrideLogicalId('ALBSecurityGroup'); // Add ingress rules based on scheme if (props.scheme === 'internet-facing') { this.securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'Allow HTTP from internet'); this.securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443), 'Allow HTTPS from internet'); } else { // Internal ALB - allow from VPC const vpcCidrBlock = props.vpcCidrBlock ?? props.vpc.vpcCidrBlock ?? '10.0.0.0/16'; this.securityGroup.addIngressRule(ec2.Peer.ipv4(vpcCidrBlock), ec2.Port.tcp(80), 'Allow HTTP from VPC'); this.securityGroup.addIngressRule(ec2.Peer.ipv4(vpcCidrBlock), ec2.Port.tcp(443), 'Allow HTTPS from VPC'); } // Add additional security groups if provided const securityGroups = [this.securityGroup, ...(props.additionalSecurityGroups ?? [])]; // Store scheme this.scheme = props.scheme; // Create ALB this.loadBalancer = new elbv2.ApplicationLoadBalancer(this, 'LoadBalancer', { vpc: props.vpc, internetFacing: props.scheme === 'internet-facing', securityGroup: this.securityGroup, vpcSubnets: { subnets }, idleTimeout: cdk.Duration.seconds(props.idleTimeout ?? 60), }); (this.loadBalancer.node.defaultChild as elbv2.CfnLoadBalancer).overrideLogicalId('LoadBalancer'); // Enable access logs if bucket provided if (props.logsBucket) { const prefix = props.logsPrefix ?? `${stack.stackName}`; this.loadBalancer.logAccessLogs(props.logsBucket, prefix); } // HTTP Listener this.httpListener = this.loadBalancer.addListener('HTTPListener', { port: 80, protocol: elbv2.ApplicationProtocol.HTTP, }); // HTTPS Listener (if certificate provided) if (props.certificateArn) { this.httpsListener = this.loadBalancer.addListener('HTTPSListener', { port: 443, protocol: elbv2.ApplicationProtocol.HTTPS, certificates: [elbv2.ListenerCertificate.fromArn(props.certificateArn)], defaultAction: elbv2.ListenerAction.fixedResponse(404, { contentType: 'text/plain', messageBody: 'No target groups configured', }), }); // HTTP→HTTPS redirect (if enabled, default true) if (props.redirectHttpToHttps !== false) { this.httpListener.addAction('RedirectToHTTPS', { action: elbv2.ListenerAction.redirect({ protocol: 'HTTPS', port: '443', permanent: true, }), }); } } // Blue/Green DNS configuration if (props.blueGreenDns) { this.configureBlueGreenDns(props.blueGreenDns); } // Apply tags this.applyTags(props.tags); // Add outputs this.addOutputs(); } /** * Configure Blue/Green DNS (active and inactive hostnames) */ private configureBlueGreenDns(dns: PersistentAlbBlueGreenDnsConfig): 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 new route53.ARecord(this, 'ActiveDnsRecord', { zone: hostedZone, recordName: activeRecordName === '@' ? undefined : activeRecordName, target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(this.loadBalancer)), }); // Inactive hostname DNS record new route53.ARecord(this, 'InactiveDnsRecord', { zone: hostedZone, recordName: inactiveRecordName === '@' ? undefined : inactiveRecordName, target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(this.loadBalancer)), }); } /** * Apply tags to all resources */ private applyTags(tags: SpicyAlbTags): 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(): void { const stack = cdk.Stack.of(this); const scheme = this.scheme; const prefix = scheme === 'internet-facing' ? 'internet-facing' : 'internal'; const albDnsOutput = new cdk.CfnOutput(this, 'LoadBalancerDNSOutput', { value: this.loadBalancer.loadBalancerDnsName, description: `The DNS name of the ${scheme} load balancer`, exportName: `${stack.stackName}-${prefix}-url`, }); albDnsOutput.overrideLogicalId('LoadBalancerDNS'); const albArnOutput = new cdk.CfnOutput(this, 'LoadBalancerArnOutput', { value: this.loadBalancer.loadBalancerArn, description: `The ARN of the ${scheme} load balancer`, exportName: `${stack.stackName}-${prefix}-arn`, }); albArnOutput.overrideLogicalId('LoadBalancerArn'); const albHostedZoneOutput = new cdk.CfnOutput(this, 'LoadBalancerHostedZoneIdOutput', { value: this.loadBalancer.loadBalancerCanonicalHostedZoneId, description: `The hosted zone ID of the ${scheme} load balancer`, exportName: `${stack.stackName}-${prefix}-hosted-zone-id`, }); albHostedZoneOutput.overrideLogicalId('LoadBalancerHostedZoneId'); const httpListenerOutput = new cdk.CfnOutput(this, 'HTTPListenerArnOutput', { value: this.httpListener.listenerArn, description: `The ARN of the ${scheme} HTTP listener`, exportName: `${stack.stackName}-${prefix}-http-listener`, }); httpListenerOutput.overrideLogicalId('HTTPListenerArn'); if (this.httpsListener) { const httpsListenerOutput = new cdk.CfnOutput(this, 'HTTPSListenerArnOutput', { value: this.httpsListener.listenerArn, description: `The ARN of the ${scheme} HTTPS listener`, exportName: `${stack.stackName}-${prefix}-https-listener`, }); httpsListenerOutput.overrideLogicalId('HTTPSListenerArn'); } const securityGroupOutput = new cdk.CfnOutput(this, 'SecurityGroupIdOutput', { value: this.securityGroup.securityGroupId, description: `The security group ID of the ${scheme} load balancer`, exportName: `${stack.stackName}-${prefix}-security-group`, }); securityGroupOutput.overrideLogicalId('SecurityGroupId'); } }