Files
spicy-automation/resources/lib/constructs/spicy-alb.ts
Ryan Wilson fa1e865f50 Fix ALB listener default action, auto-import numberOfAzs, and correct docs
- Fix HTTP listener in spicy-alb.ts missing default action when no certificate
  is provided, which would cause CDK synth to fail
- Auto-import numberOfAzs from VPC stack exports (NumberOfAZs) in cluster,
  service, and ALB stacks when not provided via context
- Fix CDK_SYNTH_EXAMPLES.md ALB examples using raw vpcId/subnetIds that don't
  match the actual fromContext() implementation (requires clusterName)
- Fix docs overstating "only clusterName required" to list actual required params
- Remove package-lock.json and add to .gitignore (project uses pnpm)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:18:18 -08:00

310 lines
11 KiB
TypeScript

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);
}
// 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 Listener - redirect to HTTPS if certificate provided, otherwise serve as primary
if (props.certificateArn && props.redirectHttpToHttps !== false) {
this.httpListener = this.loadBalancer.addListener('HTTPListener', {
port: 80,
protocol: elbv2.ApplicationProtocol.HTTP,
defaultAction: elbv2.ListenerAction.redirect({
protocol: 'HTTPS',
port: '443',
permanent: true,
}),
});
} else {
this.httpListener = this.loadBalancer.addListener('HTTPListener', {
port: 80,
protocol: elbv2.ApplicationProtocol.HTTP,
defaultAction: elbv2.ListenerAction.fixedResponse(404, {
contentType: 'text/plain',
messageBody: 'No target groups configured',
}),
});
}
// 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');
}
}