import * as ec2 from 'aws-cdk-lib/aws-ec2'; import * as cdk from 'aws-cdk-lib/core'; import { Construct } from 'constructs'; /** * Subnet CIDR configuration for each availability zone */ export interface SubnetCidrConfig { /** CIDR for the public subnet */ publicCidr: string; /** CIDR for the primary private subnet */ private1Cidr: string; /** CIDR for the secondary private subnet (with NACL) */ private2Cidr?: string; } /** * Configuration for VPC tagging */ export interface SpicyVpcTags { owner: string; product: string; component: string; build?: string; } /** * Properties for the SpicyVpc construct */ export interface SpicyVpcProps { /** * The CIDR block for the VPC * @default "172.1.0.0/16" */ readonly vpcCidr?: string; /** * The tenancy of instances launched into the VPC * @default "default" */ readonly vpcTenancy?: 'default' | 'dedicated'; /** * List of availability zones to use * If not provided, will use the first N AZs in the region based on numberOfAzs */ readonly availabilityZones?: string[]; /** * Number of availability zones to use (2, 3, or 4) * @default 4 */ readonly numberOfAzs?: 2 | 3 | 4; /** * Whether to create private subnets * @default true */ readonly createPrivateSubnets?: boolean; /** * Whether to create additional private subnets with dedicated NACLs * Only applies if createPrivateSubnets is true * @default true */ readonly createAdditionalPrivateSubnets?: boolean; /** * Subnet CIDR configurations per AZ * Keys should be 'a', 'b', 'c', 'd' for each AZ */ readonly subnetCidrs?: { a?: SubnetCidrConfig; b?: SubnetCidrConfig; c?: SubnetCidrConfig; d?: SubnetCidrConfig; }; /** * Tag to add to public subnets (Key=Value format) * @default "Network=Public" */ readonly publicSubnetTag?: string; /** * Tag to add to primary private subnets (Key=Value format) * @default "Network=Private" */ readonly privateSubnetATag?: string; /** * Tag to add to secondary private subnets with NACLs (Key=Value format) * @default "Network=Private" */ readonly privateSubnetBTag?: string; /** * Required tags for the VPC and resources */ readonly tags: SpicyVpcTags; /** * Whether to create VPC endpoints for AWS services * @default true */ readonly createVpcEndpoints?: boolean; } /** * Default CIDR configurations matching the original CloudFormation template */ const DEFAULT_SUBNET_CIDRS: Required = { a: { publicCidr: '172.1.128.0/20', private1Cidr: '172.1.0.0/19', private2Cidr: '172.1.192.0/21', }, b: { publicCidr: '172.1.144.0/20', private1Cidr: '172.1.32.0/19', private2Cidr: '172.1.200.0/21', }, c: { publicCidr: '172.1.160.0/20', private1Cidr: '172.1.64.0/19', private2Cidr: '172.1.208.0/21', }, d: { publicCidr: '172.1.176.0/20', private1Cidr: '172.1.96.0/19', private2Cidr: '172.1.216.0/21', }, }; /** * SpicyVpc - A multi-AZ VPC construct with public and private subnets, * NAT gateways, and optional VPC endpoints. * * This construct mirrors the functionality of the original CloudFormation * template with explicit logical IDs for idempotent deployments. */ export class SpicyVpc extends Construct { /** The VPC created by this construct */ public readonly vpc: ec2.IVpc; /** Public subnets indexed by AZ letter (a, b, c, d) */ public readonly publicSubnets: Map = new Map(); /** Primary private subnets indexed by AZ letter */ public readonly privateSubnets1: Map = new Map(); /** Secondary private subnets (with NACLs) indexed by AZ letter */ public readonly privateSubnets2: Map = new Map(); /** NAT Gateway EIPs indexed by AZ letter */ public readonly natEips: Map = new Map(); /** S3 Gateway Endpoint */ public readonly s3Endpoint?: ec2.GatewayVpcEndpoint; constructor(scope: Construct, id: string, props: SpicyVpcProps) { super(scope, id); const vpcCidr = props.vpcCidr ?? '172.1.0.0/16'; const numberOfAzs = props.numberOfAzs ?? 4; const createPrivateSubnets = props.createPrivateSubnets ?? true; const createAdditionalPrivateSubnets = props.createAdditionalPrivateSubnets ?? true; const createVpcEndpoints = props.createVpcEndpoints ?? true; // Merge provided CIDRs with defaults const subnetCidrs = { ...DEFAULT_SUBNET_CIDRS, ...props.subnetCidrs, }; // Parse subnet tags const publicSubnetTagParsed = this.parseTag(props.publicSubnetTag ?? 'Network=Public'); const privateSubnetATagParsed = this.parseTag(props.privateSubnetATag ?? 'Network=Private'); const privateSubnetBTagParsed = this.parseTag(props.privateSubnetBTag ?? 'Network=Private'); // Create the VPC using L1 constructs with explicit logical IDs const cfnVpc = new ec2.CfnVPC(this, 'VPC', { cidrBlock: vpcCidr, enableDnsHostnames: true, enableDnsSupport: true, instanceTenancy: props.vpcTenancy ?? 'default', tags: [ { key: 'Name', value: cdk.Stack.of(this).stackName }, { key: 'Owner', value: props.tags.owner }, { key: 'Product', value: props.tags.product }, { key: 'Component', value: props.tags.component }, ...(props.tags.build ? [{ key: 'Build', value: props.tags.build }] : []), ], }); ((cfnVpc.node.defaultChild as cdk.CfnResource) ?? cfnVpc).overrideLogicalId('VPC'); // Create Internet Gateway const igw = new ec2.CfnInternetGateway(this, 'InternetGateway', { tags: [{ key: 'Name', value: cdk.Stack.of(this).stackName }], }); ((igw.node.defaultChild as cdk.CfnResource) ?? igw).overrideLogicalId('InternetGateway'); const vpcGatewayAttachment = new ec2.CfnVPCGatewayAttachment(this, 'VPCGatewayAttachment', { vpcId: cfnVpc.ref, internetGatewayId: igw.ref, }); ((vpcGatewayAttachment.node.defaultChild as cdk.CfnResource) ?? vpcGatewayAttachment).overrideLogicalId( 'VPCGatewayAttachment' ); // DHCP Options const dhcpOptions = new ec2.CfnDHCPOptions(this, 'DHCPOptions', { domainName: cdk.Stack.of(this).region === 'ca-central-1' ? 'ec2.internal' : `${cdk.Stack.of(this).region}.compute.internal`, domainNameServers: ['AmazonProvidedDNS'], }); ((dhcpOptions.node.defaultChild as cdk.CfnResource) ?? dhcpOptions).overrideLogicalId('DHCPOptions'); const dhcpAssociation = new ec2.CfnVPCDHCPOptionsAssociation(this, 'VPCDHCPOptionsAssociation', { vpcId: cfnVpc.ref, dhcpOptionsId: dhcpOptions.ref, }); ((dhcpAssociation.node.defaultChild as cdk.CfnResource) ?? dhcpAssociation).overrideLogicalId( 'VPCDHCPOptionsAssociation' ); // Public Route Table (shared across all public subnets) const publicRouteTable = new ec2.CfnRouteTable(this, 'PublicSubnetRouteTable', { vpcId: cfnVpc.ref, tags: [ { key: 'Name', value: 'Public Subnets' }, { key: 'Network', value: 'Public' }, ], }); ((publicRouteTable.node.defaultChild as cdk.CfnResource) ?? publicRouteTable).overrideLogicalId( 'PublicSubnetRouteTable' ); const publicRoute = new ec2.CfnRoute(this, 'PublicSubnetRoute', { routeTableId: publicRouteTable.ref, destinationCidrBlock: '0.0.0.0/0', gatewayId: igw.ref, }); ((publicRoute.node.defaultChild as cdk.CfnResource) ?? publicRoute).overrideLogicalId('PublicSubnetRoute'); // Get AZ letters based on numberOfAzs const azLetters = ['a', 'b', 'c', 'd'].slice(0, numberOfAzs); // Determine availability zones const availabilityZones = props.availabilityZones ?? azLetters.map((letter) => `${cdk.Stack.of(this).region}${letter}`); // Track route tables for VPC endpoints const privateRouteTables: ec2.CfnRouteTable[] = []; // Track private subnet 1 route tables for VPC import const privateSubnet1RouteTables: ec2.CfnRouteTable[] = []; // Create subnets for each AZ azLetters.forEach((azLetter, index) => { const azName = availabilityZones[index]; const azUpper = azLetter.toUpperCase(); const cidrs = subnetCidrs[azLetter as keyof typeof subnetCidrs]!; // Public Subnet const publicSubnet = new ec2.CfnSubnet(this, `PublicSubnet${azUpper}`, { vpcId: cfnVpc.ref, cidrBlock: cidrs.publicCidr, availabilityZone: azName, mapPublicIpOnLaunch: true, tags: [ { key: 'Name', value: `Public Subnet ${azUpper}` }, ...(publicSubnetTagParsed ? [{ key: publicSubnetTagParsed.key, value: publicSubnetTagParsed.value }] : []), ], }); ((publicSubnet.node.defaultChild as cdk.CfnResource) ?? publicSubnet).overrideLogicalId(`PublicSubnet${azUpper}`); const publicSubnetRtAssoc = new ec2.CfnSubnetRouteTableAssociation( this, `PublicSubnet${azUpper}RouteTableAssociation`, { subnetId: publicSubnet.ref, routeTableId: publicRouteTable.ref, } ); ((publicSubnetRtAssoc.node.defaultChild as cdk.CfnResource) ?? publicSubnetRtAssoc).overrideLogicalId( `PublicSubnet${azUpper}RouteTableAssociation` ); // Store reference this.publicSubnets.set( azLetter, ec2.Subnet.fromSubnetAttributes(this, `PublicSubnet${azUpper}Import`, { subnetId: publicSubnet.ref, availabilityZone: azName, routeTableId: publicRouteTable.ref, }) ); if (createPrivateSubnets) { // NAT Gateway EIP const natEip = new ec2.CfnEIP(this, `NATZone${azUpper}EIP`, { domain: 'vpc', }); natEip.addDependency(igw); ((natEip.node.defaultChild as cdk.CfnResource) ?? natEip).overrideLogicalId(`NATZone${azUpper}EIP`); this.natEips.set(azLetter, natEip); // NAT Gateway const natGateway = new ec2.CfnNatGateway(this, `NATGatewayZone${azUpper}`, { allocationId: natEip.attrAllocationId, subnetId: publicSubnet.ref, }); natGateway.addDependency(igw); ((natGateway.node.defaultChild as cdk.CfnResource) ?? natGateway).overrideLogicalId(`NATGatewayZone${azUpper}`); // Private Subnet 1 Route Table const privateRouteTable1 = new ec2.CfnRouteTable(this, `PrivateSubnet${azUpper}1RouteTable`, { vpcId: cfnVpc.ref, tags: [ { key: 'Name', value: `Private Subnet ${azUpper}1` }, { key: 'Network', value: 'Private' }, ], }); ((privateRouteTable1.node.defaultChild as cdk.CfnResource) ?? privateRouteTable1).overrideLogicalId( `PrivateSubnet${azUpper}1RouteTable` ); privateRouteTables.push(privateRouteTable1); privateSubnet1RouteTables.push(privateRouteTable1); const privateRoute1 = new ec2.CfnRoute(this, `PrivateSubnet${azUpper}1Route`, { routeTableId: privateRouteTable1.ref, destinationCidrBlock: '0.0.0.0/0', natGatewayId: natGateway.ref, }); ((privateRoute1.node.defaultChild as cdk.CfnResource) ?? privateRoute1).overrideLogicalId( `PrivateSubnet${azUpper}1Route` ); // Private Subnet 1 const privateSubnet1 = new ec2.CfnSubnet(this, `PrivateSubnet${azUpper}1`, { vpcId: cfnVpc.ref, cidrBlock: cidrs.private1Cidr, availabilityZone: azName, tags: [ { key: 'Name', value: `Private Subnet ${azUpper}1` }, ...(privateSubnetATagParsed ? [ { key: privateSubnetATagParsed.key, value: privateSubnetATagParsed.value, }, ] : []), ], }); ((privateSubnet1.node.defaultChild as cdk.CfnResource) ?? privateSubnet1).overrideLogicalId( `PrivateSubnet${azUpper}1` ); const privateSubnet1RtAssoc = new ec2.CfnSubnetRouteTableAssociation( this, `PrivateSubnet${azUpper}1RouteTableAssociation`, { subnetId: privateSubnet1.ref, routeTableId: privateRouteTable1.ref, } ); ((privateSubnet1RtAssoc.node.defaultChild as cdk.CfnResource) ?? privateSubnet1RtAssoc).overrideLogicalId( `PrivateSubnet${azUpper}1RouteTableAssociation` ); this.privateSubnets1.set( azLetter, ec2.Subnet.fromSubnetAttributes(this, `PrivateSubnet${azUpper}1Import`, { subnetId: privateSubnet1.ref, availabilityZone: azName, routeTableId: privateRouteTable1.ref, }) ); // Private Subnet 2 (with NACL) - only if enabled if (createAdditionalPrivateSubnets && cidrs.private2Cidr) { // Private Subnet 2 Route Table const privateRouteTable2 = new ec2.CfnRouteTable(this, `PrivateSubnet${azUpper}2RouteTable`, { vpcId: cfnVpc.ref, tags: [ { key: 'Name', value: `Private Subnet ${azUpper}2` }, { key: 'Network', value: 'Private' }, ], }); ((privateRouteTable2.node.defaultChild as cdk.CfnResource) ?? privateRouteTable2).overrideLogicalId( `PrivateSubnet${azUpper}2RouteTable` ); privateRouteTables.push(privateRouteTable2); const privateRoute2 = new ec2.CfnRoute(this, `PrivateSubnet${azUpper}2Route`, { routeTableId: privateRouteTable2.ref, destinationCidrBlock: '0.0.0.0/0', natGatewayId: natGateway.ref, }); ((privateRoute2.node.defaultChild as cdk.CfnResource) ?? privateRoute2).overrideLogicalId( `PrivateSubnet${azUpper}2Route` ); // Private Subnet 2 const privateSubnet2 = new ec2.CfnSubnet(this, `PrivateSubnet${azUpper}2`, { vpcId: cfnVpc.ref, cidrBlock: cidrs.private2Cidr, availabilityZone: azName, tags: [ { key: 'Name', value: `Private Subnet ${azUpper}2` }, ...(privateSubnetBTagParsed ? [ { key: privateSubnetBTagParsed.key, value: privateSubnetBTagParsed.value, }, ] : []), ], }); ((privateSubnet2.node.defaultChild as cdk.CfnResource) ?? privateSubnet2).overrideLogicalId( `PrivateSubnet${azUpper}2` ); const privateSubnet2RtAssoc = new ec2.CfnSubnetRouteTableAssociation( this, `PrivateSubnet${azUpper}2RouteTableAssociation`, { subnetId: privateSubnet2.ref, routeTableId: privateRouteTable2.ref, } ); ((privateSubnet2RtAssoc.node.defaultChild as cdk.CfnResource) ?? privateSubnet2RtAssoc).overrideLogicalId( `PrivateSubnet${azUpper}2RouteTableAssociation` ); // Network ACL for Private Subnet 2 const nacl = new ec2.CfnNetworkAcl(this, `PrivateSubnet${azUpper}2NetworkACL`, { vpcId: cfnVpc.ref, tags: [ { key: 'Name', value: `NACL Protected subnet ${azUpper}` }, { key: 'Network', value: 'NACL Protected' }, ], }); ((nacl.node.defaultChild as cdk.CfnResource) ?? nacl).overrideLogicalId(`PrivateSubnet${azUpper}2NetworkACL`); // Allow all inbound const naclInbound = new ec2.CfnNetworkAclEntry(this, `PrivateSubnet${azUpper}2NetworkACLEntryInbound`, { networkAclId: nacl.ref, ruleNumber: 100, protocol: -1, ruleAction: 'allow', egress: false, cidrBlock: '0.0.0.0/0', }); ((naclInbound.node.defaultChild as cdk.CfnResource) ?? naclInbound).overrideLogicalId( `PrivateSubnet${azUpper}2NetworkACLEntryInbound` ); // Allow all outbound const naclOutbound = new ec2.CfnNetworkAclEntry(this, `PrivateSubnet${azUpper}2NetworkACLEntryOutbound`, { networkAclId: nacl.ref, ruleNumber: 100, protocol: -1, ruleAction: 'allow', egress: true, cidrBlock: '0.0.0.0/0', }); ((naclOutbound.node.defaultChild as cdk.CfnResource) ?? naclOutbound).overrideLogicalId( `PrivateSubnet${azUpper}2NetworkACLEntryOutbound` ); const naclAssociation = new ec2.CfnSubnetNetworkAclAssociation( this, `PrivateSubnet${azUpper}2NetworkACLAssociation`, { subnetId: privateSubnet2.ref, networkAclId: nacl.ref, } ); ((naclAssociation.node.defaultChild as cdk.CfnResource) ?? naclAssociation).overrideLogicalId( `PrivateSubnet${azUpper}2NetworkACLAssociation` ); this.privateSubnets2.set( azLetter, ec2.Subnet.fromSubnetAttributes(this, `PrivateSubnet${azUpper}2Import`, { subnetId: privateSubnet2.ref, availabilityZone: azName, routeTableId: privateRouteTable2.ref, }) ); } } }); // Create VPC from the L1 construct for use with L2 constructs // Build route table IDs arrays matching the subnet order const publicSubnetRouteTableIds = Array.from(this.publicSubnets.values()).map(() => publicRouteTable.ref); const privateSubnetRouteTableIds = privateSubnet1RouteTables.map((rt) => rt.ref); this.vpc = ec2.Vpc.fromVpcAttributes(this, 'VpcImport', { vpcId: cfnVpc.ref, availabilityZones: availabilityZones, publicSubnetIds: Array.from(this.publicSubnets.values()).map((s) => s.subnetId), privateSubnetIds: Array.from(this.privateSubnets1.values()).map((s) => s.subnetId), publicSubnetRouteTableIds: publicSubnetRouteTableIds, privateSubnetRouteTableIds: privateSubnetRouteTableIds, }); // S3 VPC Endpoint (Gateway type - free) if (createPrivateSubnets && createVpcEndpoints) { const s3Endpoint = new ec2.CfnVPCEndpoint(this, 'S3VPCEndpoint', { vpcId: cfnVpc.ref, serviceName: `com.amazonaws.${cdk.Stack.of(this).region}.s3`, routeTableIds: privateRouteTables.map((rt) => rt.ref), policyDocument: { Version: '2012-10-17', Statement: [ { Action: '*', Effect: 'Allow', Resource: '*', Principal: '*', }, ], }, }); ((s3Endpoint.node.defaultChild as cdk.CfnResource) ?? s3Endpoint).overrideLogicalId('S3VPCEndpoint'); } // Add CloudFormation Outputs matching the original template this.addOutputs( cfnVpc, azLetters, publicRouteTable, privateRouteTables, createPrivateSubnets, createAdditionalPrivateSubnets ); } /** * Parse a tag string in "Key=Value" format */ private parseTag(tagString: string): { key: string; value: string } | null { if (!tagString || !tagString.includes('=')) { return null; } const [key, ...valueParts] = tagString.split('='); return { key, value: valueParts.join('=') }; } /** * Add CloudFormation outputs for cross-stack references * Output logical IDs match the original CloudFormation template */ private addOutputs( cfnVpc: ec2.CfnVPC, azLetters: string[], publicRouteTable: ec2.CfnRouteTable, privateRouteTables: ec2.CfnRouteTable[], createPrivateSubnets: boolean, createAdditionalPrivateSubnets: boolean ): void { const stack = cdk.Stack.of(this); // VPC outputs const vpcIdOutput = new cdk.CfnOutput(this, 'VPCID', { value: cfnVpc.ref, description: 'VPC ID', exportName: `${stack.stackName}-VPCID`, }); vpcIdOutput.overrideLogicalId('VPCID'); const vpcCidrOutput = new cdk.CfnOutput(this, 'VPCCIDR', { value: cfnVpc.cidrBlock!, description: 'VPC CIDR', exportName: `${stack.stackName}-VPCCIDR`, }); vpcCidrOutput.overrideLogicalId('VPCCIDR'); // Number of AZs output (for importing by other stacks) const numberOfAzsOutput = new cdk.CfnOutput(this, 'NumberOfAZs', { value: azLetters.length.toString(), description: 'Number of Availability Zones', exportName: `${stack.stackName}-NumberOfAZs`, }); numberOfAzsOutput.overrideLogicalId('NumberOfAZs'); // Public Route Table output const publicRtOutput = new cdk.CfnOutput(this, 'PublicSubnetRouteTableOutput', { value: publicRouteTable.ref, description: 'Public Subnet Route Table', exportName: `${stack.stackName}-PublicSubnetRouteTable`, }); publicRtOutput.overrideLogicalId('PublicSubnetRouteTableOutput'); // Public subnet outputs azLetters.forEach((azLetter) => { const azUpper = azLetter.toUpperCase(); const subnet = this.publicSubnets.get(azLetter); if (subnet) { const output = new cdk.CfnOutput(this, `PublicSubnet${azUpper}ID`, { value: subnet.subnetId, description: `Public Subnet ${azUpper} ID`, exportName: `${stack.stackName}-PublicSubnet${azUpper}ID`, }); output.overrideLogicalId(`PublicSubnet${azUpper}ID`); } }); // Private subnet outputs if (createPrivateSubnets) { azLetters.forEach((azLetter, index) => { const azUpper = azLetter.toUpperCase(); // Private Subnet 1 const subnet1 = this.privateSubnets1.get(azLetter); if (subnet1) { const output = new cdk.CfnOutput(this, `PrivateSubnet${azUpper}1ID`, { value: subnet1.subnetId, description: `Private Subnet 1 ID in Availability Zone ${azUpper}`, exportName: `${stack.stackName}-PrivateSubnet${azUpper}1ID`, }); output.overrideLogicalId(`PrivateSubnet${azUpper}1ID`); } // NAT EIP Output const natEip = this.natEips.get(azLetter); if (natEip) { const output = new cdk.CfnOutput(this, `NATZone${azUpper}EIPOutput`, { value: natEip.ref, description: `NAT ${azUpper} IP address`, exportName: `${stack.stackName}-NATZone${azUpper}EIP`, }); output.overrideLogicalId(`NATZone${azUpper}EIPOutput`); } // Private Route Table 1 Output const rtIndex = index * (createAdditionalPrivateSubnets ? 2 : 1); if (privateRouteTables[rtIndex]) { const output = new cdk.CfnOutput(this, `PrivateSubnet${azUpper}1RouteTableOutput`, { value: privateRouteTables[rtIndex].ref, description: `Private Subnet ${azUpper}1 Route Table`, exportName: `${stack.stackName}-PrivateSubnet${azUpper}1RouteTable`, }); output.overrideLogicalId(`PrivateSubnet${azUpper}1RouteTableOutput`); } // Private Subnet 2 if (createAdditionalPrivateSubnets) { const subnet2 = this.privateSubnets2.get(azLetter); if (subnet2) { const output = new cdk.CfnOutput(this, `PrivateSubnet${azUpper}2ID`, { value: subnet2.subnetId, description: `Private Subnet 2 ID in Availability Zone ${azUpper}`, exportName: `${stack.stackName}-PrivateSubnet${azUpper}2ID`, }); output.overrideLogicalId(`PrivateSubnet${azUpper}2ID`); } // Private Route Table 2 Output if (privateRouteTables[rtIndex + 1]) { const output = new cdk.CfnOutput(this, `PrivateSubnet${azUpper}2RouteTableOutput`, { value: privateRouteTables[rtIndex + 1].ref, description: `Private Subnet ${azUpper}2 Route Table`, exportName: `${stack.stackName}-PrivateSubnet${azUpper}2RouteTable`, }); output.overrideLogicalId(`PrivateSubnet${azUpper}2RouteTableOutput`); } } }); } } }