Jenkins shared library and CDK constructs for AWS infrastructure. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
211 lines
7.7 KiB
TypeScript
211 lines
7.7 KiB
TypeScript
import * as ec2 from 'aws-cdk-lib/aws-ec2';
|
|
import * as s3 from 'aws-cdk-lib/aws-s3';
|
|
import * as cdk from 'aws-cdk-lib/core';
|
|
import { Construct } from 'constructs';
|
|
import { PersistentAlbBlueGreenDnsConfig, SpicyAlb, SpicyAlbTags } from '../constructs/spicy-alb';
|
|
|
|
/**
|
|
* Props for SpicyAlbStack
|
|
*/
|
|
export interface SpicyAlbStackProps extends Omit<cdk.StackProps, 'tags'> {
|
|
/** VPC ID */
|
|
readonly vpcId: string;
|
|
|
|
/** VPC CIDR block */
|
|
readonly vpcCidrBlock?: string;
|
|
|
|
/** Number of availability zones (2-4) */
|
|
readonly numberOfAzs: number;
|
|
|
|
/** Availability zones */
|
|
readonly availabilityZones: string[];
|
|
|
|
/** Subnet IDs for the ALB */
|
|
readonly subnetIds: string[];
|
|
|
|
/** ALB scheme: "internet-facing" or "internal" */
|
|
readonly scheme: 'internet-facing' | 'internal';
|
|
|
|
/** SSL certificate ARN for HTTPS listener */
|
|
readonly certificateArn?: string;
|
|
|
|
/** Idle timeout in seconds */
|
|
readonly idleTimeout?: number;
|
|
|
|
/** S3 bucket name for ALB access logs */
|
|
readonly logsBucketName?: string;
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/**
|
|
* Stack for deploying a persistent ALB (bg-common pattern)
|
|
*
|
|
* This stack should be deployed once and kept running. The ALB DNS name
|
|
* will remain constant even when service stacks are deleted and recreated.
|
|
*/
|
|
export class SpicyAlbStack extends cdk.Stack {
|
|
public readonly alb: SpicyAlb;
|
|
|
|
constructor(scope: Construct, id: string, props: SpicyAlbStackProps) {
|
|
const { tags, ...stackProps } = props;
|
|
super(scope, id, stackProps);
|
|
|
|
// Import VPC (using fromVpcAttributes to avoid requiring AWS credentials for synth)
|
|
// For ALB, we need to provide subnet IDs - use the subnets we're deploying the ALB to
|
|
// For internal ALB, these are typically private subnets; for internet-facing, they're public
|
|
const vpc = ec2.Vpc.fromVpcAttributes(this, 'ImportedVpc', {
|
|
vpcId: props.vpcId,
|
|
availabilityZones: props.availabilityZones,
|
|
// Provide subnet IDs based on scheme (internal = private, internet-facing = public)
|
|
// We provide them in both arrays to satisfy CDK's requirements, but only the relevant ones are used
|
|
...(props.scheme === 'internal' ? { privateSubnetIds: props.subnetIds } : { publicSubnetIds: props.subnetIds }),
|
|
});
|
|
|
|
// Create subnet references
|
|
const subnets = props.subnetIds.map((subnetId, index) =>
|
|
ec2.Subnet.fromSubnetAttributes(this, `Subnet${index}`, {
|
|
subnetId,
|
|
availabilityZone: props.availabilityZones[index % props.availabilityZones.length],
|
|
})
|
|
);
|
|
|
|
// Create logs bucket if specified
|
|
let logsBucket: s3.IBucket | undefined;
|
|
if (props.logsBucketName) {
|
|
logsBucket = s3.Bucket.fromBucketName(this, 'LogsBucket', props.logsBucketName);
|
|
}
|
|
|
|
// Create ALB construct
|
|
this.alb = new SpicyAlb(this, 'ALB', {
|
|
vpc,
|
|
vpcCidrBlock: props.vpcCidrBlock,
|
|
scheme: props.scheme,
|
|
subnets,
|
|
availabilityZones: props.availabilityZones,
|
|
certificateArn: props.certificateArn,
|
|
idleTimeout: props.idleTimeout,
|
|
logsBucket,
|
|
logsPrefix: props.logsPrefix,
|
|
redirectHttpToHttps: props.redirectHttpToHttps,
|
|
blueGreenDns: props.blueGreenDns,
|
|
tags: props.tags,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create stack from CDK context
|
|
* Only requires clusterStackName - VPC stack name imported from cluster export, then all VPC details imported from VPC stack
|
|
*/
|
|
static fromContext(scope: Construct, id: string, stackProps?: cdk.StackProps): SpicyAlbStack {
|
|
const app = scope.node.root as cdk.App;
|
|
if (!app) {
|
|
throw new Error('SpicyAlbStack.fromContext must be called from within a CDK App');
|
|
}
|
|
|
|
// Required: clusterStackName (VPC stack name imported from cluster export, then VPC details imported from VPC stack)
|
|
const clusterStackName = app.node.tryGetContext('clusterStackName') || app.node.tryGetContext('clusterName');
|
|
if (!clusterStackName) {
|
|
throw new Error(
|
|
'clusterStackName is required. Provide clusterStackName to import VPC stack name from cluster export, then import VPC details from VPC stack.'
|
|
);
|
|
}
|
|
|
|
// Import VPC stack name from cluster stack (cluster exports: ${clusterStackName}-VPCStackName)
|
|
const vpcStackName = cdk.Fn.importValue(`${clusterStackName}-VPCStackName`).toString();
|
|
|
|
// Import VPC ID from cluster stack (cluster exports: ${clusterStackName}-VPC)
|
|
const vpcId = cdk.Fn.importValue(`${clusterStackName}-VPC`);
|
|
|
|
// Import all VPC details from VPC stack exports
|
|
const vpcCidrBlock = cdk.Fn.importValue(`${vpcStackName}-VPCCIDR`).toString();
|
|
const numberOfAzsRaw = app.node.tryGetContext('numberOfAzs');
|
|
const numberOfAzs = numberOfAzsRaw ? parseInt(numberOfAzsRaw, 10) : NaN;
|
|
if (!numberOfAzs || Number.isNaN(numberOfAzs)) {
|
|
throw new Error('numberOfAzs is required in context (2-4) to import VPC subnets for ALB.');
|
|
}
|
|
const azs = ['A', 'B', 'C', 'D'].slice(0, Math.min(Math.max(numberOfAzs, 1), 4));
|
|
|
|
// Import subnets based on scheme (public for internet-facing, private for internal)
|
|
const scheme = app.node.tryGetContext('scheme') ?? 'internet-facing';
|
|
const subnetIds = azs.map((az) => {
|
|
if (scheme === 'internet-facing') {
|
|
return cdk.Fn.importValue(`${vpcStackName}-PublicSubnet${az}ID`).toString();
|
|
} else {
|
|
return cdk.Fn.importValue(`${vpcStackName}-PrivateSubnet${az}1ID`).toString();
|
|
}
|
|
});
|
|
|
|
// Derive availability zones from region
|
|
const region = cdk.Stack.of(scope).region;
|
|
const availabilityZones = azs.map((az) => `${region}${az.toLowerCase()}`);
|
|
const certificateArn = app.node.tryGetContext('certificateArn');
|
|
const idleTimeout = app.node.tryGetContext('idleTimeout')
|
|
? parseInt(app.node.tryGetContext('idleTimeout'))
|
|
: undefined;
|
|
const logsBucketName = app.node.tryGetContext('logsBucketName');
|
|
const logsPrefix = app.node.tryGetContext('logsPrefix');
|
|
const redirectHttpToHttps = app.node.tryGetContext('redirectHttpToHttps') !== 'false';
|
|
|
|
// Blue/Green DNS
|
|
const activeHostname = app.node.tryGetContext('activeHostname');
|
|
const inactiveHostname = app.node.tryGetContext('inactiveHostname');
|
|
const bgHostedZoneId = app.node.tryGetContext('bgHostedZoneId');
|
|
|
|
let blueGreenDns: PersistentAlbBlueGreenDnsConfig | undefined;
|
|
if (activeHostname && inactiveHostname && bgHostedZoneId) {
|
|
blueGreenDns = {
|
|
activeHostname,
|
|
inactiveHostname,
|
|
hostedZoneId: bgHostedZoneId,
|
|
};
|
|
}
|
|
|
|
// Tags
|
|
const ownerTag = app.node.tryGetContext('ownerTag');
|
|
const productTag = app.node.tryGetContext('productTag');
|
|
const componentTag = app.node.tryGetContext('componentTag');
|
|
const environmentTag = app.node.tryGetContext('environmentTag') ?? app.node.tryGetContext('environment');
|
|
const buildTag = app.node.tryGetContext('buildTag') ?? app.node.tryGetContext('build');
|
|
|
|
if (!ownerTag || !productTag || !componentTag) {
|
|
throw new Error('ownerTag, productTag, and componentTag context are required');
|
|
}
|
|
|
|
const tags: SpicyAlbTags = {
|
|
owner: ownerTag,
|
|
product: productTag,
|
|
component: componentTag,
|
|
environment: environmentTag ?? 'dev',
|
|
build: buildTag,
|
|
};
|
|
|
|
return new SpicyAlbStack(scope, id, {
|
|
...stackProps,
|
|
vpcId: vpcId.toString(),
|
|
vpcCidrBlock,
|
|
numberOfAzs,
|
|
availabilityZones,
|
|
subnetIds,
|
|
scheme: scheme as 'internet-facing' | 'internal',
|
|
certificateArn,
|
|
idleTimeout,
|
|
logsBucketName,
|
|
logsPrefix,
|
|
redirectHttpToHttps,
|
|
blueGreenDns,
|
|
tags: tags,
|
|
});
|
|
}
|
|
}
|