Files
spicy-automation/resources/lib/stacks/spicy-alb-stack.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

219 lines
8.0 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();
// numberOfAzs: use context value if provided, otherwise auto-import from VPC stack export
const numberOfAzsRaw = app.node.tryGetContext('numberOfAzs');
let numberOfAzs: number;
if (numberOfAzsRaw) {
numberOfAzs = parseInt(numberOfAzsRaw, 10);
} else {
const imported = cdk.Fn.importValue(`${vpcStackName}-NumberOfAZs`).toString();
numberOfAzs = parseInt(imported, 10);
}
if (!numberOfAzs || Number.isNaN(numberOfAzs)) {
throw new Error('numberOfAzs is required (2-4). Provide via context or ensure VPC stack exports NumberOfAZs.');
}
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,
});
}
}