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 { /** 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, }); } }