Files
spicy-automation/resources/lib/constructs/spicy-vpc.ts
Ryan Wilson 2e02b02023 Add dual-stack IPv6 VPC with egress optimization and VPC endpoints
- Add Amazon-provided IPv6 /56 CIDR block with auto-carved /64 per subnet
- Add Egress-Only Internet Gateway for free IPv6 outbound from private subnets
- Add IPv6 routes: public subnets via IGW, private subnets via EOIGW
- Add IPv6 NACL entries for subnet tier 2
- Add DynamoDB gateway endpoint (free, alongside existing S3)
- Add 6 interface endpoints: ECR, ECR Docker, CloudWatch Logs, STS,
  Secrets Manager, SSM with shared security group
- Add enableIpv6 prop (default true) and interfaceEndpoints config
- Update VPC stack with context params for all new features
- Include design doc and implementation plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:18:18 -08:00

941 lines
34 KiB
TypeScript

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;
}
/**
* Configuration for VPC Interface Endpoints
*/
export interface VpcInterfaceEndpointConfig {
/** ECR API endpoint (for image metadata) @default true */
readonly ecr?: boolean;
/** ECR Docker endpoint (for image layer pulls) @default true */
readonly ecrDocker?: boolean;
/** CloudWatch Logs endpoint @default true */
readonly cloudwatchLogs?: boolean;
/** STS endpoint (for IAM role assumption) @default true */
readonly sts?: boolean;
/** Secrets Manager endpoint @default true */
readonly secretsManager?: boolean;
/** SSM endpoint (for Parameter Store, Session Manager) @default true */
readonly ssm?: boolean;
}
/**
* 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;
/**
* Enable IPv6 dual-stack with Egress-Only Internet Gateway.
* Adds Amazon-provided IPv6 CIDR, assigns /64 to each subnet,
* and routes private subnet IPv6 egress through EOIGW (free, outbound-only).
* @default true
*/
readonly enableIpv6?: boolean;
/**
* Configuration for VPC Interface Endpoints.
* Only applies when createVpcEndpoints is true.
* Each endpoint costs ~$0.01/hr but eliminates NAT data processing costs for that service.
* @default All endpoints enabled (ECR, ECR Docker, CloudWatch Logs, STS, Secrets Manager, SSM)
*/
readonly interfaceEndpoints?: VpcInterfaceEndpointConfig;
}
/**
* Default CIDR configurations matching the original CloudFormation template
*/
const DEFAULT_SUBNET_CIDRS: Required<SpicyVpcProps['subnetCidrs']> = {
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<string, ec2.ISubnet> = new Map();
/** Primary private subnets indexed by AZ letter */
public readonly privateSubnets1: Map<string, ec2.ISubnet> = new Map();
/** Secondary private subnets (with NACLs) indexed by AZ letter */
public readonly privateSubnets2: Map<string, ec2.ISubnet> = new Map();
/** NAT Gateway EIPs indexed by AZ letter */
public readonly natEips: Map<string, ec2.CfnEIP> = new Map();
/** S3 Gateway Endpoint */
public readonly s3Endpoint?: ec2.GatewayVpcEndpoint;
/** Egress-Only Internet Gateway (IPv6 outbound from private subnets) */
public readonly egressOnlyInternetGateway?: ec2.CfnEgressOnlyInternetGateway;
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'
);
// IPv6 Dual-Stack Configuration
const enableIpv6 = props.enableIpv6 ?? true;
let ipv6CidrBlock: ec2.CfnVPCCidrBlock | undefined;
let egressOnlyIgw: ec2.CfnEgressOnlyInternetGateway | undefined;
if (enableIpv6) {
// Add Amazon-provided IPv6 /56 CIDR block to the VPC
ipv6CidrBlock = new ec2.CfnVPCCidrBlock(this, 'IPv6CidrBlock', {
vpcId: cfnVpc.ref,
amazonProvidedIpv6CidrBlock: true,
});
((ipv6CidrBlock.node.defaultChild as cdk.CfnResource) ?? ipv6CidrBlock).overrideLogicalId('IPv6CidrBlock');
// Egress-Only Internet Gateway (outbound IPv6 only, drops unsolicited inbound)
egressOnlyIgw = new ec2.CfnEgressOnlyInternetGateway(this, 'EgressOnlyInternetGateway', {
vpcId: cfnVpc.ref,
});
((egressOnlyIgw.node.defaultChild as cdk.CfnResource) ?? egressOnlyIgw).overrideLogicalId(
'EgressOnlyInternetGateway'
);
this.egressOnlyInternetGateway = egressOnlyIgw;
}
// 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');
// Public subnet IPv6 route via IGW
if (enableIpv6) {
const publicIpv6Route = new ec2.CfnRoute(this, 'PublicSubnetIpv6Route', {
routeTableId: publicRouteTable.ref,
destinationIpv6CidrBlock: '::/0',
gatewayId: igw.ref,
});
((publicIpv6Route.node.defaultChild as cdk.CfnResource) ?? publicIpv6Route).overrideLogicalId(
'PublicSubnetIpv6Route'
);
}
// 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}`);
// Assign IPv6 /64 to public subnet
if (enableIpv6 && ipv6CidrBlock) {
const publicIpv6Cidr = cdk.Fn.select(
index,
cdk.Fn.cidr(cdk.Fn.select(0, cfnVpc.attrIpv6CidrBlocks), 256, '64')
);
publicSubnet.ipv6CidrBlock = publicIpv6Cidr;
publicSubnet.assignIpv6AddressOnCreation = true;
publicSubnet.addDependency(ipv6CidrBlock);
}
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`
);
// Assign IPv6 /64 to private subnet 1 (offset by numberOfAzs to avoid public subnet range)
if (enableIpv6 && ipv6CidrBlock && egressOnlyIgw) {
const private1Ipv6Cidr = cdk.Fn.select(
numberOfAzs + index,
cdk.Fn.cidr(cdk.Fn.select(0, cfnVpc.attrIpv6CidrBlocks), 256, '64')
);
privateSubnet1.ipv6CidrBlock = private1Ipv6Cidr;
privateSubnet1.assignIpv6AddressOnCreation = true;
privateSubnet1.addDependency(ipv6CidrBlock);
// IPv6 egress route via Egress-Only IGW
const private1Ipv6Route = new ec2.CfnRoute(this, `PrivateSubnet${azUpper}1Ipv6Route`, {
routeTableId: privateRouteTable1.ref,
destinationIpv6CidrBlock: '::/0',
egressOnlyInternetGatewayId: egressOnlyIgw.ref,
});
((private1Ipv6Route.node.defaultChild as cdk.CfnResource) ?? private1Ipv6Route).overrideLogicalId(
`PrivateSubnet${azUpper}1Ipv6Route`
);
}
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`
);
// Assign IPv6 /64 to private subnet 2 (offset by 2*numberOfAzs)
if (enableIpv6 && ipv6CidrBlock && egressOnlyIgw) {
const private2Ipv6Cidr = cdk.Fn.select(
2 * numberOfAzs + index,
cdk.Fn.cidr(cdk.Fn.select(0, cfnVpc.attrIpv6CidrBlocks), 256, '64')
);
privateSubnet2.ipv6CidrBlock = private2Ipv6Cidr;
privateSubnet2.assignIpv6AddressOnCreation = true;
privateSubnet2.addDependency(ipv6CidrBlock);
// IPv6 egress route via Egress-Only IGW
const private2Ipv6Route = new ec2.CfnRoute(this, `PrivateSubnet${azUpper}2Ipv6Route`, {
routeTableId: privateRouteTable2.ref,
destinationIpv6CidrBlock: '::/0',
egressOnlyInternetGatewayId: egressOnlyIgw.ref,
});
((private2Ipv6Route.node.defaultChild as cdk.CfnResource) ?? private2Ipv6Route).overrideLogicalId(
`PrivateSubnet${azUpper}2Ipv6Route`
);
}
// 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`
);
// IPv6 NACL entries
if (enableIpv6) {
const naclInboundIpv6 = new ec2.CfnNetworkAclEntry(
this,
`PrivateSubnet${azUpper}2NetworkACLEntryInboundIpv6`,
{
networkAclId: nacl.ref,
ruleNumber: 101,
protocol: -1,
ruleAction: 'allow',
egress: false,
ipv6CidrBlock: '::/0',
}
);
((naclInboundIpv6.node.defaultChild as cdk.CfnResource) ?? naclInboundIpv6).overrideLogicalId(
`PrivateSubnet${azUpper}2NetworkACLEntryInboundIpv6`
);
const naclOutboundIpv6 = new ec2.CfnNetworkAclEntry(
this,
`PrivateSubnet${azUpper}2NetworkACLEntryOutboundIpv6`,
{
networkAclId: nacl.ref,
ruleNumber: 101,
protocol: -1,
ruleAction: 'allow',
egress: true,
ipv6CidrBlock: '::/0',
}
);
((naclOutboundIpv6.node.defaultChild as cdk.CfnResource) ?? naclOutboundIpv6).overrideLogicalId(
`PrivateSubnet${azUpper}2NetworkACLEntryOutboundIpv6`
);
}
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');
// DynamoDB VPC Endpoint (Gateway type - free)
const dynamoDbEndpoint = new ec2.CfnVPCEndpoint(this, 'DynamoDBVPCEndpoint', {
vpcId: cfnVpc.ref,
serviceName: `com.amazonaws.${cdk.Stack.of(this).region}.dynamodb`,
routeTableIds: privateRouteTables.map((rt) => rt.ref),
policyDocument: {
Version: '2012-10-17',
Statement: [
{
Action: '*',
Effect: 'Allow',
Resource: '*',
Principal: '*',
},
],
},
});
((dynamoDbEndpoint.node.defaultChild as cdk.CfnResource) ?? dynamoDbEndpoint).overrideLogicalId(
'DynamoDBVPCEndpoint'
);
// Interface VPC Endpoints (cost ~$0.01/hr each, but eliminate NAT data processing)
const endpointConfig = props.interfaceEndpoints ?? {};
// Security group for all interface endpoints
const endpointSecurityGroup = new ec2.CfnSecurityGroup(this, 'VpcEndpointSecurityGroup', {
vpcId: cfnVpc.ref,
groupDescription: 'Security group for VPC Interface Endpoints',
securityGroupIngress: [
{
ipProtocol: 'tcp',
fromPort: 443,
toPort: 443,
cidrIp: vpcCidr,
description: 'Allow HTTPS from VPC IPv4',
},
...(enableIpv6
? [
{
ipProtocol: 'tcp',
fromPort: 443,
toPort: 443,
cidrIpv6: '::/0',
description: 'Allow HTTPS from VPC IPv6',
},
]
: []),
],
});
((endpointSecurityGroup.node.defaultChild as cdk.CfnResource) ?? endpointSecurityGroup).overrideLogicalId(
'VpcEndpointSecurityGroup'
);
const privateSubnetIds = Array.from(this.privateSubnets1.values()).map((s) => s.subnetId);
const region = cdk.Stack.of(this).region;
const interfaceEndpointServices: { id: string; service: string; enabled: boolean }[] = [
{ id: 'ECRApiEndpoint', service: `com.amazonaws.${region}.ecr.api`, enabled: endpointConfig.ecr ?? true },
{
id: 'ECRDockerEndpoint',
service: `com.amazonaws.${region}.ecr.dkr`,
enabled: endpointConfig.ecrDocker ?? true,
},
{
id: 'CloudWatchLogsEndpoint',
service: `com.amazonaws.${region}.logs`,
enabled: endpointConfig.cloudwatchLogs ?? true,
},
{ id: 'STSEndpoint', service: `com.amazonaws.${region}.sts`, enabled: endpointConfig.sts ?? true },
{
id: 'SecretsManagerEndpoint',
service: `com.amazonaws.${region}.secretsmanager`,
enabled: endpointConfig.secretsManager ?? true,
},
{ id: 'SSMEndpoint', service: `com.amazonaws.${region}.ssm`, enabled: endpointConfig.ssm ?? true },
];
for (const ep of interfaceEndpointServices) {
if (!ep.enabled) continue;
const endpoint = new ec2.CfnVPCEndpoint(this, ep.id, {
vpcId: cfnVpc.ref,
serviceName: ep.service,
vpcEndpointType: 'Interface',
privateDnsEnabled: true,
subnetIds: privateSubnetIds,
securityGroupIds: [endpointSecurityGroup.ref],
});
((endpoint.node.defaultChild as cdk.CfnResource) ?? endpoint).overrideLogicalId(ep.id);
}
}
// Add CloudFormation Outputs matching the original template
this.addOutputs(
cfnVpc,
azLetters,
publicRouteTable,
privateRouteTables,
createPrivateSubnets,
createAdditionalPrivateSubnets,
enableIpv6
);
}
/**
* 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,
enableIpv6: 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`);
}
}
});
}
// IPv6 outputs
if (enableIpv6 && this.egressOnlyInternetGateway) {
const eoigwOutput = new cdk.CfnOutput(this, 'EgressOnlyInternetGatewayId', {
value: this.egressOnlyInternetGateway.ref,
description: 'Egress-Only Internet Gateway ID',
exportName: `${stack.stackName}-EgressOnlyIGW`,
});
eoigwOutput.overrideLogicalId('EgressOnlyInternetGatewayId');
}
}
}