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>
This commit is contained in:
304
resources/lib/constructs/spicy-alb.ts
Normal file
304
resources/lib/constructs/spicy-alb.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user