- 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>
310 lines
11 KiB
TypeScript
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');
|
|
}
|
|
}
|