diff --git a/packages/@aws-cdk/aws-ec2-alpha/README.md b/packages/@aws-cdk/aws-ec2-alpha/README.md index b37359349caa6..1e36a38022f80 100644 --- a/packages/@aws-cdk/aws-ec2-alpha/README.md +++ b/packages/@aws-cdk/aws-ec2-alpha/README.md @@ -41,6 +41,7 @@ new VpcV2(this, 'Vpc', { `SubnetV2` is a re-write of the [`ec2.Subnet`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.Subnet.html) construct. This new construct can be used to add subnets to a `VpcV2` instance: +Note: When defining a subnet with `SubnetV2`, CDK automatically creates a new route table, unless a route table is explicitly provided as an input to the construct. ```ts @@ -66,11 +67,19 @@ By default `VpcV2` uses `10.0.0.0/16` as the primary CIDR if none is defined. Additional CIDRs can be adding to the VPC via the `secondaryAddressBlocks` prop. The following example illustrates the different options of defining the address blocks: +Note: There’s currently an issue with IPAM pool deletion that may affect the `cdk --destroy` command. This is because IPAM takes time to detect when the IP address pool has been deallocated after the VPC is deleted. The current workaround is to wait until the IP address is fully deallocated from the pool before retrying the deletion. Below command can be used to check allocations for a pool using CLI + +```shell +aws ec2 get-ipam-pool-allocations --ipam-pool-id +``` + +Ref: https://docs.aws.amazon.com/cli/latest/reference/ec2/get-ipam-pool-allocations.html + ```ts const stack = new Stack(); const ipam = new Ipam(this, 'Ipam', { - operatingRegion: ['us-west-1'] + operatingRegions: ['us-west-1'] }); const ipamPublicPool = ipam.publicScope.addPool('PublicPoolA', { addressFamily: AddressFamily.IP_V6, @@ -527,6 +536,7 @@ For more information, see [Enable VPC internet access using internet gateways](h You can add an internet gateway to a VPC using `addInternetGateway` method. By default, this method creates a route in all Public Subnets with outbound destination set to `0.0.0.0` for IPv4 and `::0` for IPv6 enabled VPC. Instead of using the default settings, you can configure a custom destination range by providing an optional input `destination` to the method. +In addition to the custom IP range, you can also choose to filter subnets where default routes should be created. The code example below shows how to add an internet gateway with a custom outbound destination IP range: @@ -546,6 +556,34 @@ myVpc.addInternetGateway({ }); ``` +The following code examples demonstrates how to add an internet gateway with a custom outbound destination IP range for specific subnets: + +```ts +const stack = new Stack(); +const myVpc = new VpcV2(this, 'Vpc'); + +const mySubnet = new SubnetV2(this, 'Subnet', { + vpc: myVpc, + availabilityZone: 'eu-west-2a', + ipv4CidrBlock: new IpCidr('10.0.0.0/24'), + subnetType: SubnetType.PUBLIC }); + +myVpc.addInternetGateway({ + ipv4Destination: '192.168.0.0/16', + subnets: [mySubnet], +}); +``` + +```ts +const stack = new Stack(); +const myVpc = new VpcV2(this, 'Vpc'); + +myVpc.addInternetGateway({ + ipv4Destination: '192.168.0.0/16', + subnets: [{subnetType: SubnetType.PRIVATE_WITH_EGRESS}], +}); +``` + ## Importing an existing VPC You can import an existing VPC and its subnets using the `VpcV2.fromVpcV2Attributes()` method or an individual subnet using `SubnetV2.fromSubnetV2Attributes()` method. diff --git a/packages/@aws-cdk/aws-ec2-alpha/jest.config.js b/packages/@aws-cdk/aws-ec2-alpha/jest.config.js index 4995208baf512..18ed6f0effe60 100644 --- a/packages/@aws-cdk/aws-ec2-alpha/jest.config.js +++ b/packages/@aws-cdk/aws-ec2-alpha/jest.config.js @@ -1,10 +1,4 @@ const baseConfig = require('@aws-cdk/cdk-build-tools/config/jest.config'); module.exports = { ...baseConfig, - coverageThreshold: { - global: { - statements: 75, - branches: 63, - }, - }, };; diff --git a/packages/@aws-cdk/aws-ec2-alpha/lib/ipam.ts b/packages/@aws-cdk/aws-ec2-alpha/lib/ipam.ts index f7c47e0932a05..4c10b25e6eb53 100644 --- a/packages/@aws-cdk/aws-ec2-alpha/lib/ipam.ts +++ b/packages/@aws-cdk/aws-ec2-alpha/lib/ipam.ts @@ -62,7 +62,7 @@ export interface IpamProps { * * @default - Stack.region if defined in the stack */ - readonly operatingRegion?: string[]; + readonly operatingRegions?: string[]; /** * Name of IPAM that can be used for tagging resource @@ -511,11 +511,11 @@ export class Ipam extends Resource { if (props?.ipamName) { Tags.of(this).add(NAME_TAG, props.ipamName); } - if (!props?.operatingRegion && !Stack.of(this).region) { + if (props?.operatingRegions && (props.operatingRegions.length === 0)) { throw new Error('Please provide at least one operating region'); } - this.operatingRegions = props?.operatingRegion ?? [Stack.of(this).region]; + this.operatingRegions = props?.operatingRegions ?? [Stack.of(this).region]; this.ipamName = props?.ipamName; this._ipam = new CfnIPAM(this, 'Ipam', { diff --git a/packages/@aws-cdk/aws-ec2-alpha/lib/route.ts b/packages/@aws-cdk/aws-ec2-alpha/lib/route.ts index 3a95a0d9d8264..4dcd9e7588ccc 100644 --- a/packages/@aws-cdk/aws-ec2-alpha/lib/route.ts +++ b/packages/@aws-cdk/aws-ec2-alpha/lib/route.ts @@ -605,12 +605,8 @@ export class RouteTargetType { readonly endpoint?: IVpcEndpoint; constructor(props: RouteTargetProps) { - if ((props.gateway && props.endpoint) || (!props.gateway && !props.endpoint)) { - throw new Error('Exactly one of `gateway` or `endpoint` must be specified.'); - } else { - this.gateway = props.gateway; - this.endpoint = props.endpoint; - } + this.gateway = props.gateway; + this.endpoint = props.endpoint; } } @@ -729,6 +725,10 @@ export class Route extends Resource implements IRouteV2 { if (this.target.gateway?.routerType === RouterType.EGRESS_ONLY_INTERNET_GATEWAY && isDestinationIpv4) { throw new Error('Egress only internet gateway does not support IPv4 routing'); } + + if ((props.target.gateway && props.target.endpoint) || (!props.target.gateway && !props.target.endpoint)) { + throw new Error('Exactly one of `gateway` or `endpoint` must be specified.'); + } this.targetRouterType = this.target.gateway ? this.target.gateway.routerType : RouterType.VPC_ENDPOINT; // Gateway generates route automatically via its RouteTable, thus we don't need to generate the resource for it if (!(this.target.endpoint instanceof GatewayVpcEndpoint)) { diff --git a/packages/@aws-cdk/aws-ec2-alpha/lib/vpc-v2-base.ts b/packages/@aws-cdk/aws-ec2-alpha/lib/vpc-v2-base.ts index c90778e9b08d7..440b945693a22 100644 --- a/packages/@aws-cdk/aws-ec2-alpha/lib/vpc-v2-base.ts +++ b/packages/@aws-cdk/aws-ec2-alpha/lib/vpc-v2-base.ts @@ -60,6 +60,13 @@ export interface InternetGatewayOptions{ * @default - provisioned without a resource name */ readonly internetGatewayName?: string; + + /** + * List of subnets where route to IGW will be added + * + * @default - route created for all subnets with Type `SubnetType.Public` + */ + readonly subnets?: SubnetSelection[]; } /** @@ -437,9 +444,14 @@ export abstract class VpcV2Base extends Resource implements IVpcV2 { } if (options?.subnets) { + // Use Set to ensure unique subnets + const processedSubnets = new Set(); const subnets = flatten(options.subnets.map(s => this.selectSubnets(s).subnets)); subnets.forEach((subnet) => { - this.createEgressRoute(subnet, egw, options.destination); + if (!processedSubnets.has(subnet.node.id)) { + this.createEgressRoute(subnet, egw, options.destination); + processedSubnets.add(subnet.node.id); + } }); } } @@ -476,9 +488,23 @@ export abstract class VpcV2Base extends Resource implements IVpcV2 { this._internetConnectivityEstablished.add(igw); this._internetGatewayId = igw.routerTargetId; - // If there are no public subnets defined, no default route will be added - if (this.publicSubnets) { - this.publicSubnets.forEach( (s) => this.addDefaultInternetRoute(s, igw, options)); + // Add routes for subnets defined as an input + if (options?.subnets) { + // Use Set to ensure unique subnets + const processedSubnets = new Set(); + const subnets = flatten(options.subnets.map(s => this.selectSubnets(s).subnets)); + subnets.forEach((subnet) => { + if (!processedSubnets.has(subnet.node.id)) { + if (!this.publicSubnets.includes(subnet)) { + Annotations.of(this).addWarningV2('InternetGatewayWarning', + `Subnet ${subnet.node.id} is not a public subnet. Internet Gateway should be added only to public subnets.`); + } + this.addDefaultInternetRoute(subnet, igw, options); + processedSubnets.add(subnet.node.id); + } + }); // If there are no input subnets defined, default route will be added to all public subnets + } else if (!options?.subnets && this.publicSubnets) { + this.publicSubnets.forEach((publicSubnets) => this.addDefaultInternetRoute(publicSubnets, igw, options)); } } @@ -488,10 +514,6 @@ export abstract class VpcV2Base extends Resource implements IVpcV2 { */ private addDefaultInternetRoute(subnet: ISubnetV2, igw: InternetGateway, options?: InternetGatewayOptions): void { - if (subnet.subnetType !== SubnetType.PUBLIC) { - throw new Error('No public subnets defined to add route for internet gateway'); - } - // Add default route to IGW for IPv6 if (subnet.ipv6CidrBlock) { new Route(this, `${subnet.node.id}-DefaultIPv6Route`, { diff --git a/packages/@aws-cdk/aws-ec2-alpha/test/integ.ipam.ts b/packages/@aws-cdk/aws-ec2-alpha/test/integ.ipam.ts index e13177bc9d731..25d94b656c666 100644 --- a/packages/@aws-cdk/aws-ec2-alpha/test/integ.ipam.ts +++ b/packages/@aws-cdk/aws-ec2-alpha/test/integ.ipam.ts @@ -23,7 +23,7 @@ const app = new cdk.App(); const stack = new cdk.Stack(app, 'aws-cdk-vpcv2-alpha-integ-ipam'); const ipam = new Ipam(stack, 'IpamTest', { - operatingRegion: ['us-west-2'], + operatingRegions: ['us-west-2'], }); /** Test Ipam Pool Ipv4 */ diff --git a/packages/@aws-cdk/aws-ec2-alpha/test/integ.vpc-v2-tagging.ts b/packages/@aws-cdk/aws-ec2-alpha/test/integ.vpc-v2-tagging.ts index ccd1bf3a23053..c1d5af9d82440 100644 --- a/packages/@aws-cdk/aws-ec2-alpha/test/integ.vpc-v2-tagging.ts +++ b/packages/@aws-cdk/aws-ec2-alpha/test/integ.vpc-v2-tagging.ts @@ -53,7 +53,7 @@ const natgw = vpc.addNatGateway({ natgw.node.addDependency(vpnGateway); const ipam = new Ipam(stack, 'IpamIntegTest', { - operatingRegion: ['us-west-2'], + operatingRegions: ['us-west-2'], ipamName: 'CDKIpamTestTag', }); diff --git a/packages/@aws-cdk/aws-ec2-alpha/test/ipam.test.ts b/packages/@aws-cdk/aws-ec2-alpha/test/ipam.test.ts index 68a4343b9be2a..d417568c59dbe 100644 --- a/packages/@aws-cdk/aws-ec2-alpha/test/ipam.test.ts +++ b/packages/@aws-cdk/aws-ec2-alpha/test/ipam.test.ts @@ -18,7 +18,7 @@ describe('IPAM Test', () => { env: envUSA, }); ipam = new Ipam(stack, 'Ipam', { - operatingRegion: ['us-west-2'], + operatingRegions: ['us-west-2'], }); }); @@ -82,7 +82,7 @@ describe('IPAM Test', () => { test('Creates IPAM CIDR pool under public scope for IPv6', () => { // Create IPAM resources const ipamIpv6 = new Ipam(stack, 'TestIpam', { - operatingRegion: ['us-west-2'], + operatingRegions: ['us-west-2'], }); const poolOptions: vpc.PoolOptions = { addressFamily: AddressFamily.IP_V6, @@ -116,7 +116,7 @@ describe('IPAM Test', () => { test('Get region from stack env', () => { // Create IPAM resources const ipamRegion = new Ipam(stack, 'TestIpam', { - operatingRegion: ['us-west-2'], + operatingRegions: ['us-west-2'], }); const poolOptions: vpc.PoolOptions = { addressFamily: AddressFamily.IP_V6, @@ -155,4 +155,109 @@ describe('IPAM Test', () => { ); }); + test('IPAM throws error if awsService is not provided for IPv6 address', () => { + // Create IPAM resources + const ipamRegion = new Ipam(stack, 'TestIpam', { + operatingRegions: ['us-west-2'], + }); + const poolOptions: vpc.PoolOptions = { + addressFamily: AddressFamily.IP_V6, + publicIpSource: IpamPoolPublicIpSource.AMAZON, + locale: 'us-west-2', + }; + expect(() => ipamRegion.publicScope.addPool('TestPool', poolOptions)).toThrow('awsService is required when addressFamily is set to ipv6'); + }); + + test('IPAM throws error if operating region is provided as an empty array', () => { + const app = new cdk.App(); + const stack_new = new cdk.Stack(app, 'TestStack'); + expect(() => new Ipam(stack_new, 'TestIpam', { + operatingRegions: [], + })).toThrow('Please provide at least one operating region'); + }); + + test('IPAM infers region from provided operating region correctly', () => { + const app = new cdk.App(); + const stack_new = new cdk.Stack(app, 'TestStack'); + new Ipam(stack_new, 'TestIpam', { + operatingRegions: ['us-west-2'], + }); + Template.fromStack(stack_new).hasResourceProperties( + 'AWS::EC2::IPAM', { + OperatingRegions: [ + { + RegionName: 'us-west-2', + }, + ], + }, + ); + }); + + test('IPAM infers region from stack if not provided under IPAM class object', () => { + const app = new cdk.App(); + const stack_new = new cdk.Stack(app, 'TestStack', { + env: { + region: 'us-west-2', + }, + }); + new Ipam(stack_new, 'TestIpam', {}); + Template.fromStack(stack_new).hasResourceProperties( + 'AWS::EC2::IPAM', { + OperatingRegions: [ + { + RegionName: 'us-west-2', + }, + ], + }, + ); + }); + + test('IPAM refers to stack region token', () => { + const app = new cdk.App(); + const stack_new = new cdk.Stack(app, 'TestStack'); + new Ipam(stack_new, 'TestIpam', {}); + Template.fromStack(stack_new).hasResourceProperties( + 'AWS::EC2::IPAM', { + OperatingRegions: [ + { + RegionName: { + Ref: 'AWS::Region', + }, + }, + ], + }, + ); + }); + + test('IPAM throws error if locale(region) of pool is not one of the operating regions', () => { + const ipamRegion = new Ipam(stack, 'TestIpam', { + operatingRegions: ['us-west-2'], + }); + const poolOptions: vpc.PoolOptions = { + addressFamily: AddressFamily.IP_V6, + awsService: vpc.AwsServiceName.EC2, + publicIpSource: IpamPoolPublicIpSource.AMAZON, + locale: 'us-west-1', // Incorrect Region + }; + expect(() => ipamRegion.publicScope.addPool('TestPool', poolOptions)).toThrow("The provided locale 'us-west-1' is not in the operating regions."); + }); + + test('IPAM handles operating regions correctly', () => { + const new_app = new cdk.App(); + const testStack = new cdk.Stack(new_app, 'TestStack', { + env: { + region: 'us-west-1', + }, + }); + new Ipam(testStack, 'TestIpamNew', {}); + Template.fromStack(testStack).hasResourceProperties( + 'AWS::EC2::IPAM', { + OperatingRegions: [ + { + RegionName: 'us-west-1', + }, + ], + }, + ); + }); });// End Test diff --git a/packages/@aws-cdk/aws-ec2-alpha/test/route.test.ts b/packages/@aws-cdk/aws-ec2-alpha/test/route.test.ts index cdc68419ff9d7..b7215c8db2bdb 100644 --- a/packages/@aws-cdk/aws-ec2-alpha/test/route.test.ts +++ b/packages/@aws-cdk/aws-ec2-alpha/test/route.test.ts @@ -3,7 +3,7 @@ import * as vpc from '../lib/vpc-v2'; import * as subnet from '../lib/subnet-v2'; import { CfnEIP, GatewayVpcEndpoint, GatewayVpcEndpointAwsService, SubnetType, VpnConnectionType } from 'aws-cdk-lib/aws-ec2'; import * as route from '../lib/route'; -import { Template } from 'aws-cdk-lib/assertions'; +import { Match, Template } from 'aws-cdk-lib/assertions'; describe('EC2 Routing', () => { let stack: cdk.Stack; @@ -405,6 +405,32 @@ describe('EC2 Routing', () => { }); }); + test('NatGW throws error if both VPC and allocationID is not provided', () => { + expect(() => new route.NatGateway(stack, 'TestNATGW', { + subnet: mySubnet, + connectivityType: route.NatConnectivityType.PUBLIC, + maxDrainDuration: cdk.Duration.seconds(2001), + })).toThrow('Either provide vpc or allocationId'); + }); + + test('NatGW does not create EIP or use allocationId in case of private NAT gateway', () => { + // WHEN + new route.NatGateway(stack, 'NGW', { + vpc: myVpc, + subnet: mySubnet, + connectivityType: route.NatConnectivityType.PRIVATE, + }); + + // THEN + Template.fromStack(stack).resourceCountIs('AWS::EC2::EIP', 0); + Template.fromStack(stack).hasResourceProperties('AWS::EC2::NatGateway', { + ConnectivityType: 'private', + }); + Template.fromStack(stack).hasResourceProperties('AWS::EC2::NatGateway', Match.not({ + AllocationId: Match.anyValue(), + })); + }); + test('Route to DynamoDB Endpoint', () => { const dynamodb = new GatewayVpcEndpoint(stack, 'TestDB', { vpc: myVpc, @@ -512,6 +538,40 @@ describe('EC2 Routing', () => { }, }); }); + + test('Route throws error if no target is specified', () => { + expect(() => { + routeTable.addRoute('testRoute', '0.0.0.0', {}); + }).toThrow('Exactly one of `gateway` or `endpoint` must be specified.'); + }); + + test('Route throws error if both endpoint and gateway is specified as target', () => { + const eigw = new route.EgressOnlyInternetGateway(stack, 'TestEIGW', { + vpc: myVpc, + }); + + const dynamodb = new GatewayVpcEndpoint(stack, 'TestDB', { + vpc: myVpc, + service: GatewayVpcEndpointAwsService.DYNAMODB, + }); + expect(() => { + routeTable.addRoute('testRoute', '::/0', { + gateway: eigw, + endpoint: dynamodb, + }); + }).toThrow('Exactly one of `gateway` or `endpoint` must be specified.'); + }); + + test('EIGW throws error in case destination is set to an IPv4 address', () => { + const eigw = new route.EgressOnlyInternetGateway(stack, 'TestEIGW', { + vpc: myVpc, + }); + expect(() => { + routeTable.addRoute('testRoute', '0.0.0.0', { + gateway: eigw, + }); + }).toThrow('Egress only internet gateway does not support IPv4 routing'); + }); }); describe('VPCPeeringConnection', () => { @@ -523,6 +583,7 @@ describe('VPCPeeringConnection', () => { let vpcA: vpc.VpcV2; let vpcB: vpc.VpcV2; let vpcC: vpc.VpcV2; + let vpcD: vpc.VpcV2; beforeEach(() => { const app = new cdk.App({ @@ -545,7 +606,29 @@ describe('VPCPeeringConnection', () => { vpcC = new vpc.VpcV2(stackC, 'VpcC', { primaryAddressBlock: vpc.IpAddresses.ipv4('10.1.0.0/16'), }); + // Same Account VPC + vpcD = new vpc.VpcV2(stackC, 'VpcD', { + primaryAddressBlock: vpc.IpAddresses.ipv4('10.3.0.0/16'), + }); + }); + + test('Creates a same account VPC peering connection', () => { + // Create VPC peering connection between vpcC and vpcD in same account + new route.VPCPeeringConnection(stackC, 'TestPeeringConnection', { + requestorVpc: vpcC, + acceptorVpc: vpcD, + }); + const template = Template.fromStack(stackC); + template.hasResourceProperties('AWS::EC2::VPCPeeringConnection', { + VpcId: { + 'Fn::GetAtt': ['VpcC211819BA', 'VpcId'], + }, + PeerVpcId: { + 'Fn::GetAtt': ['VpcD66D5BFD0', 'VpcId'], + }, + PeerRegion: 'us-west-2', + }); }); test('Creates a cross account VPC peering connection', () => { @@ -628,15 +711,35 @@ describe('VPCPeeringConnection', () => { }); test('CIDR block overlap with primary CIDR block should throw error', () => { - const vpcD = new vpc.VpcV2(stackA, 'VpcD', { + const testVpc = new vpc.VpcV2(stackA, 'TestVpc', { primaryAddressBlock: vpc.IpAddresses.ipv4('10.0.0.0/16'), }); - expect(() => { new route.VPCPeeringConnection(stackA, 'TestPeering', { requestorVpc: vpcA, - acceptorVpc: vpcD, + acceptorVpc: testVpc, }); }).toThrow(/CIDR block should not overlap with each other for establishing a peering connection/); }); + + test('Can create route for VPC peering connection as a target', () => { + const peering = new route.VPCPeeringConnection(stackC, 'TestPeering', { + requestorVpc: vpcC, + acceptorVpc: vpcD, + }); + + const routeTable = new route.RouteTable(stackC, 'TestRouteTable', { + vpc: vpcD, + }); + + routeTable.addRoute('TestRoute', '172.16.0.0/16', { + gateway: peering, + }); + const template = Template.fromStack(stackC); + template.hasResourceProperties('AWS::EC2::Route', { + VpcPeeringConnectionId: { 'Fn::GetAtt': ['TestPeeringVPCPeeringConnection0E2D1596', 'Id'] }, + RouteTableId: { 'Fn::GetAtt': ['TestRouteTableC34C2E1C', 'RouteTableId'] }, + DestinationCidrBlock: '172.16.0.0/16', + }); + }); }); diff --git a/packages/@aws-cdk/aws-ec2-alpha/test/util.ts b/packages/@aws-cdk/aws-ec2-alpha/test/subnet-test-util.ts similarity index 85% rename from packages/@aws-cdk/aws-ec2-alpha/test/util.ts rename to packages/@aws-cdk/aws-ec2-alpha/test/subnet-test-util.ts index 3af7b6db70bd1..4e2e5f1a8c760 100644 --- a/packages/@aws-cdk/aws-ec2-alpha/test/util.ts +++ b/packages/@aws-cdk/aws-ec2-alpha/test/subnet-test-util.ts @@ -13,9 +13,10 @@ export function createTestSubnet( subnetType: SubnetType; addressFamily?: AddressFamily; ipv6Cidr?: subnet.IpCidr; + assignIpv6AddressOnCreation?: boolean; }, ): subnet.SubnetV2 { - const { vpcV2, availabilityZone, cidrBlock, subnetType, ipv6Cidr } = config; + const { vpcV2, availabilityZone, cidrBlock, subnetType, ipv6Cidr, assignIpv6AddressOnCreation } = config; return new subnet.SubnetV2(stack, 'TestSubnet', { vpc: vpcV2, @@ -23,5 +24,6 @@ export function createTestSubnet( ipv4CidrBlock: cidrBlock, subnetType, ipv6CidrBlock: ipv6Cidr, + assignIpv6AddressOnCreation, }); } diff --git a/packages/@aws-cdk/aws-ec2-alpha/test/subnet-v2.test.ts b/packages/@aws-cdk/aws-ec2-alpha/test/subnet-v2.test.ts index a78c1c3806a1a..cb9a40369a98f 100644 --- a/packages/@aws-cdk/aws-ec2-alpha/test/subnet-v2.test.ts +++ b/packages/@aws-cdk/aws-ec2-alpha/test/subnet-v2.test.ts @@ -4,7 +4,7 @@ import * as vpc from '../lib/vpc-v2'; import * as subnet from '../lib/subnet-v2'; import { NetworkAcl, SubnetType } from 'aws-cdk-lib/aws-ec2'; import { AddressFamily, AwsServiceName, Ipam, IpamPoolPublicIpSource } from '../lib/ipam'; -import { createTestSubnet } from './util'; +import { createTestSubnet } from './subnet-test-util'; /** * Test suite for the SubnetV2 class. @@ -169,7 +169,7 @@ describe('Subnet V2 with custom IP and routing', () => { test('Create Subnet with IPv6 if it is Ipam Ipv6 is enabled on VPC', () => { const ipam = new Ipam(stack, 'TestIpam', { - operatingRegion: ['us-west-1'], + operatingRegions: ['us-west-1'], }); const pool = ipam.publicScope.addPool('PublicPool0', { addressFamily: AddressFamily.IP_V6, @@ -244,7 +244,7 @@ describe('Subnet V2 with custom IP and routing', () => { test('Should throw error if overlapping CIDR block(IPv6) for the subnet', () => { const ipam = new Ipam(stack, 'TestIpam', { - operatingRegion: ['us-west-1'], + operatingRegions: ['us-west-1'], }); const pool = ipam.publicScope.addPool('PublicPool0', { addressFamily: AddressFamily.IP_V6, @@ -322,4 +322,85 @@ describe('Subnet V2 with custom IP and routing', () => { expect(Template.fromStack(stack).hasResource('AWS::EC2::SubnetNetworkAclAssociation', {})); }); + + test('Subnet Creation throws error if assignIPv6 set to true with no IPv6 CIDR', () => { + const testVpc = new vpc.VpcV2(stack, 'TestVPC', { + primaryAddressBlock: vpc.IpAddresses.ipv4('10.1.0.0/16'), + secondaryAddressBlocks: [vpc.IpAddresses.amazonProvidedIpv6( + { cidrBlockName: 'SecondaryAddress' })], + }); + + const subnetConfig = { + vpcV2: testVpc, + availabilityZone: 'us-east-1a', + cidrBlock: new subnet.IpCidr('10.1.0.0/24'), + subnetType: SubnetType.PUBLIC, + assignIpv6AddressOnCreation: true, + }; + expect(() => createTestSubnet(stack, subnetConfig)).toThrow('IPv6 CIDR block is required when assigning IPv6 address on creation'); + }); + + test('Subnet field assignIpv6AddressOnCreation is set to true with IPv6 CIDR as an input', () => { + const testVpc = new vpc.VpcV2(stack, 'TestVPC', { + primaryAddressBlock: vpc.IpAddresses.ipv4('10.1.0.0/16'), + secondaryAddressBlocks: [vpc.IpAddresses.amazonProvidedIpv6( + { cidrBlockName: 'SecondaryAddress' })], + }); + + const subnetConfig = { + vpcV2: testVpc, + availabilityZone: 'us-east-1a', + cidrBlock: new subnet.IpCidr('10.1.0.0/24'), + ipv6Cidr: new subnet.IpCidr('2001:db8:1::/64'), + subnetType: SubnetType.PUBLIC, + assignIpv6AddressOnCreation: true, + }; + createTestSubnet(stack, subnetConfig); + Template.fromStack(stack).hasResourceProperties('AWS::EC2::Subnet', { + AssignIpv6AddressOnCreation: true, + Ipv6CidrBlock: '2001:db8:1::/64', + }); + }); + + test('Subnet field assignIpv6AddressOnCreation is set to false by default with IPv6 CIDR as an input', () => { + const testVpc = new vpc.VpcV2(stack, 'TestVPC', { + primaryAddressBlock: vpc.IpAddresses.ipv4('10.1.0.0/16'), + secondaryAddressBlocks: [vpc.IpAddresses.amazonProvidedIpv6( + { cidrBlockName: 'SecondaryAddress' })], + }); + + const subnetConfig = { + vpcV2: testVpc, + availabilityZone: 'us-east-1a', + cidrBlock: new subnet.IpCidr('10.1.0.0/24'), + ipv6Cidr: new subnet.IpCidr('2001:db8:1::/64'), + subnetType: SubnetType.PUBLIC, + }; + createTestSubnet(stack, subnetConfig); + Template.fromStack(stack).hasResourceProperties('AWS::EC2::Subnet', { + AssignIpv6AddressOnCreation: false, + Ipv6CidrBlock: '2001:db8:1::/64', + }); + }); + + test('Subnet creates custom route Table if not provided as an input', () => { + const testVpc = new vpc.VpcV2(stack, 'TestVPC', { + primaryAddressBlock: vpc.IpAddresses.ipv4('10.1.0.0/16'), + }); + // Subnet with no route Table as an input + const subnetConfig = { + vpcV2: testVpc, + availabilityZone: 'us-east-1a', + cidrBlock: new subnet.IpCidr('10.1.0.0/24'), + subnetType: SubnetType.PUBLIC, + }; + createTestSubnet(stack, subnetConfig); + Template.fromStack(stack).hasResource('AWS::EC2::RouteTable', 1); + Template.fromStack(stack).hasResourceProperties('AWS::EC2::SubnetRouteTableAssociation', { + SubnetId: { + Ref: 'TestSubnet2A4BE4CA', + }, + RouteTableId: { 'Fn::GetAtt': ['TestSubnetRouteTable5AF4379E', 'RouteTableId'] }, + }); + }); }); diff --git a/packages/@aws-cdk/aws-ec2-alpha/test/util.test.ts b/packages/@aws-cdk/aws-ec2-alpha/test/util.test.ts index 56764458f3044..24ccc90ece598 100644 --- a/packages/@aws-cdk/aws-ec2-alpha/test/util.test.ts +++ b/packages/@aws-cdk/aws-ec2-alpha/test/util.test.ts @@ -1,4 +1,4 @@ -import { CidrBlock, CidrBlockIpv6 } from '../lib/util'; +import { CidrBlock, CidrBlockIpv6, NetworkUtils } from '../lib/util'; describe('Tests for the CidrBlock.rangesOverlap method to check if IPv4 ranges overlap', () =>{ test('Should return false for non-overlapping IP ranges', () => { @@ -45,4 +45,29 @@ describe('Tests for the CidrBlock.rangesOverlap method to check if IPv4 ranges o const testCidr = new CidrBlockIpv6('2001:db8::/32'); expect(testCidr.rangesOverlap('2001:db8::1/64', '2001:db8::1/60')).toBe(true); }); + + test('valid IPv4 addresses return true', () => { + const validIps = [ + '192.168.1.1', + '10.0.0.0', + '172.16.254.1', + '0.0.0.0', + '255.255.255.255', + ]; + + validIps.forEach(ip => { + expect(NetworkUtils.validIp(ip)).toBe(true); + }); + }); + + test('invalid IP addresses return false', () => { + const invalidIps = [ + '256.1.2.3', // octet > 255 + '1.2.3.256', // octet > 255 + '1.2.3.4.5', + ]; + invalidIps.forEach(ip => { + expect(NetworkUtils.validIp(ip)).toBe(false); + }); + }); }); diff --git a/packages/@aws-cdk/aws-ec2-alpha/test/vpc-add-method.test.ts b/packages/@aws-cdk/aws-ec2-alpha/test/vpc-add-method.test.ts index 353b6029969e3..2f2463fa0d006 100644 --- a/packages/@aws-cdk/aws-ec2-alpha/test/vpc-add-method.test.ts +++ b/packages/@aws-cdk/aws-ec2-alpha/test/vpc-add-method.test.ts @@ -9,6 +9,9 @@ describe('Vpc V2 with full control', () => { let stack: cdk.Stack; let myVpc: vpc.VpcV2; let mySubnet: SubnetV2; + let routeTable1: route.RouteTable; + let testSubnet1: SubnetV2; + let testSubnet2: SubnetV2; beforeEach(() => { const app = new cdk.App({ @@ -25,10 +28,30 @@ describe('Vpc V2 with full control', () => { }); mySubnet = new SubnetV2(stack, 'TestSubnet', { vpc: myVpc, - ipv4CidrBlock: new IpCidr('10.1.0.0/24'), + ipv4CidrBlock: new IpCidr('10.1.1.0/24'), availabilityZone: 'ap-south-1b', subnetType: SubnetType.PUBLIC, - ipv6CidrBlock: new IpCidr('2001:db8::/48'), + ipv6CidrBlock: new IpCidr('2001:db8:0::/64'), + }); + routeTable1 = new route.RouteTable(stack, 'PrivateSubnetRouteTable1', { + vpc: myVpc, + }); + testSubnet1 = new SubnetV2(stack, 'TestSubnet1', { + vpc: myVpc, + ipv4CidrBlock: new IpCidr('10.1.2.0/24'), + ipv6CidrBlock: new IpCidr('2001:db8:1::/64'), + routeTable: routeTable1, + availabilityZone: 'ap-south-1a', + subnetType: SubnetType.PRIVATE_WITH_EGRESS, + }); + + testSubnet2 = new SubnetV2(stack, 'TestSubnet2', { + vpc: myVpc, + ipv4CidrBlock: new IpCidr('10.1.3.0/24'), + ipv6CidrBlock: new IpCidr('2001:db8:2::/64'), + routeTable: routeTable1, + availabilityZone: 'ap-south-1b', + subnetType: SubnetType.PRIVATE_WITH_EGRESS, }); }); test('Method to add a new Egress-Only IGW', () => { @@ -305,6 +328,86 @@ describe('Vpc V2 with full control', () => { }); }); + test('addInternetGateway throws error if subnet type provided is not available', () => { + expect(() => { + myVpc.addInternetGateway({ + subnets: [{ subnetType: SubnetType.PRIVATE_ISOLATED }], + }); + }).toThrow("There are no 'Isolated' subnet groups in this VPC. Available types: Deprecated_Private_NAT,Private,Deprecated_Private,Public"); + }); + + test('addInternetGateway adds route for all selected subnets if given as an input', () => { + myVpc.addInternetGateway({ + subnets: [{ subnetType: SubnetType.PRIVATE_WITH_EGRESS }], + }); + Template.fromStack(stack).hasResourceProperties('AWS::EC2::Route', { + GatewayId: { + 'Fn::GetAtt': ['TestVpcInternetGatewayIGW4C825874', 'InternetGatewayId'], + }, + RouteTableId: { // Private Subnet RouteTable + 'Fn::GetAtt': ['PrivateSubnetRouteTable1RouteTable8DA475CB', 'RouteTableId'], + }, + }); + // Should be created only for Private Egress Subnet and not for Public Subnet + Template.fromStack(stack).hasResource('AWS::EC2::Route', 1); + }); + + test('addInternetGateway adds route for multiple subnets when filtered by subnet IDs', () => { + // Add Internet Gateway with multiple subnet IDs + myVpc.addInternetGateway({ + subnets: [testSubnet1, testSubnet2], + }); + + // Verify routes are created for both subnets + Template.fromStack(stack).hasResourceProperties('AWS::EC2::Route', { + GatewayId: { + 'Fn::GetAtt': ['TestVpcInternetGatewayIGW4C825874', 'InternetGatewayId'], + }, + RouteTableId: { + 'Fn::GetAtt': ['PrivateSubnetRouteTable1RouteTable8DA475CB', 'RouteTableId'], + }, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::EC2::Route', { + GatewayId: { + 'Fn::GetAtt': ['TestVpcInternetGatewayIGW4C825874', 'InternetGatewayId'], + }, + RouteTableId: { + 'Fn::GetAtt': ['PrivateSubnetRouteTable1RouteTable8DA475CB', 'RouteTableId'], + }, + }); + // Verify two routes are created + Template.fromStack(stack).hasResource('AWS::EC2::Route', 2); + }); + + test('addEgressInternetGateway adds route for multiple subnets when filtered by subnet IDs', () => { + // Add Internet Gateway with multiple subnet IDs + myVpc.addEgressOnlyInternetGateway({ + subnets: [testSubnet1, testSubnet2], + }); + + // Verify routes are created for both subnets + Template.fromStack(stack).hasResourceProperties('AWS::EC2::Route', { + EgressOnlyInternetGatewayId: { + 'Fn::GetAtt': ['TestVpcEgressOnlyGWEIGW5A79987F', 'Id'], + }, + RouteTableId: { + 'Fn::GetAtt': ['PrivateSubnetRouteTable1RouteTable8DA475CB', 'RouteTableId'], + }, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::EC2::Route', { + EgressOnlyInternetGatewayId: { + 'Fn::GetAtt': ['TestVpcEgressOnlyGWEIGW5A79987F', 'Id'], + }, + RouteTableId: { + 'Fn::GetAtt': ['PrivateSubnetRouteTable1RouteTable8DA475CB', 'RouteTableId'], + }, + }); + // Verify two routes are created + Template.fromStack(stack).hasResource('AWS::EC2::Route', 2); + }); + test('Throws error if there is already an IGW attached', () => { myVpc.addInternetGateway(); expect(() => { @@ -388,7 +491,7 @@ describe('Vpc V2 with full control', () => { type: VpnConnectionType.IPSEC_1, vpnRoutePropagation: [{ subnetType: SubnetType.PRIVATE_ISOLATED }], }); - }).toThrow("There are no 'Isolated' subnet groups in this VPC. Available types: Public"); + }).toThrow("There are no 'Isolated' subnet groups in this VPC. Available types: Deprecated_Private_NAT,Private,Deprecated_Private,Public"); }); test('Throws error when VPN GW is already enabled', () => { diff --git a/packages/@aws-cdk/aws-ec2-alpha/test/vpc-tagging.test.ts b/packages/@aws-cdk/aws-ec2-alpha/test/vpc-tagging.test.ts index 1d094d972f61e..940f9e78992a5 100644 --- a/packages/@aws-cdk/aws-ec2-alpha/test/vpc-tagging.test.ts +++ b/packages/@aws-cdk/aws-ec2-alpha/test/vpc-tagging.test.ts @@ -276,7 +276,7 @@ describe('Vpc V2 with full control', () => { test('Adds tag to IPAM and IPAM Scope and Pool', () => { const ipam = new Ipam(stack, 'TestIpam', { ipamName: 'TestIpam', - operatingRegion: ['us-west-1'], + operatingRegions: ['us-west-1'], }); ipam.addScope(stack, 'TestScope', { diff --git a/packages/@aws-cdk/aws-ec2-alpha/test/vpc-v2.test.ts b/packages/@aws-cdk/aws-ec2-alpha/test/vpc-v2.test.ts index cff3e13139e7f..f4ec206cbb888 100644 --- a/packages/@aws-cdk/aws-ec2-alpha/test/vpc-v2.test.ts +++ b/packages/@aws-cdk/aws-ec2-alpha/test/vpc-v2.test.ts @@ -124,7 +124,7 @@ describe('Vpc V2 with full control', () => { test('VPC Primary IP from Ipv4 Ipam', () => { const ipam = new Ipam(stack, 'TestIpam', { - operatingRegion: ['us-west-1'], + operatingRegions: ['us-west-1'], }); const pool = ipam.privateScope.addPool('PrivatePool0', { @@ -178,7 +178,7 @@ describe('Vpc V2 with full control', () => { test('VPC Secondary IP from Ipv6 Ipam', () => { const ipam = new Ipam(stack, 'TestIpam', { - operatingRegion: ['us-west-1'], + operatingRegions: ['us-west-1'], }); const pool = ipam.publicScope.addPool('PublicPool0', { @@ -305,4 +305,66 @@ describe('Vpc V2 with full control', () => { const template = Template.fromStack(stack); template.resourceCountIs('AWS::EC2::VPCCidrBlock', 2); }); + + test('Set field `useIpv6` to true if secondary address is defined using IPAM IPv6', () => { + const ipv6Vpc = new vpc.VpcV2(stack, 'TestVpc', { + primaryAddressBlock: vpc.IpAddresses.ipv4('10.1.0.0/16'), + secondaryAddressBlocks: [vpc.IpAddresses.ipv6Ipam({ + ipamPool: new Ipam(stack, 'TestIpam', { + operatingRegions: ['us-west-1'], + }).publicScope.addPool('PublicPool0', { + addressFamily: AddressFamily.IP_V6, + awsService: AwsServiceName.EC2, + publicIpSource: IpamPoolPublicIpSource.AMAZON, + locale: 'us-west-1', + }), + netmaskLength: 64, + cidrBlockName: 'IPv6Ipam', + })], + }, + ); + expect(ipv6Vpc.useIpv6).toBe(true); + }); + + test('Set field `useIpv6` to true if secondary address is defined using Amazon Provided IPv6', () => { + const ipv6Vpc = new vpc.VpcV2(stack, 'TestVpc', { + primaryAddressBlock: vpc.IpAddresses.ipv4('10.1.0.0/16'), + secondaryAddressBlocks: [vpc.IpAddresses.amazonProvidedIpv6({ + cidrBlockName: 'Ipv6Amazon', + })], + }, + ); + expect(ipv6Vpc.useIpv6).toBe(true); + }); + + test('Set field `useIpv6` to true if secondary address is defined using BYOIP IPv6', () => { + const ipv6Vpc = new vpc.VpcV2(stack, 'TestVpc', { + primaryAddressBlock: vpc.IpAddresses.ipv4('10.1.0.0/16'), + secondaryAddressBlocks: [vpc.IpAddresses.ipv6ByoipPool({ + ipv6PoolId: 'test-Byoip-pool', + ipv6CidrBlock: '2001:db8::/32', + cidrBlockName: 'BYOIP', + })], + }, + ); + expect(ipv6Vpc.useIpv6).toBe(true); + }); + + test('Set field `useIpv6` to false if no IPv6 address is attached to VPC', () => { + const testVpc = new vpc.VpcV2(stack, 'TestVpc', { + primaryAddressBlock: vpc.IpAddresses.ipv4('10.1.0.0/16'), + secondaryAddressBlocks: [vpc.IpAddresses.ipv4('10.2.0.0/16', { + cidrBlockName: 'SecondaryAddress', + })], + }, + ); + expect(testVpc.useIpv6).toBe(false); + }); + + test('VPC throws error is secondary CIDR block name is not provided', () => { + expect(() => new vpc.VpcV2(stack, 'TestVPC', { + primaryAddressBlock: vpc.IpAddresses.ipv4('10.1.0.0/16'), + secondaryAddressBlocks: [vpc.IpAddresses.ipv4('10.2.0.0/16')], + })).toThrow('Cidr Block Name is required to create secondary IP address'); + }); }); diff --git a/packages/@aws-cdk/aws-ec2-alpha/test/vpcv2-import.test.ts b/packages/@aws-cdk/aws-ec2-alpha/test/vpcv2-import.test.ts index 92ea4ec670bb4..3731130a5c980 100644 --- a/packages/@aws-cdk/aws-ec2-alpha/test/vpcv2-import.test.ts +++ b/packages/@aws-cdk/aws-ec2-alpha/test/vpcv2-import.test.ts @@ -214,4 +214,100 @@ describe('Vpc V2 with full control', () => { SubnetId: 'mockSubnetId', }); }); + + test('Import method fromVpcV2Attributes correctly categorizes subnets by type', () => { + const vpc = VpcV2.fromVpcV2Attributes(stack, 'ImportedVPC', { + vpcId: 'vpc-123456', + vpcCidrBlock: '10.0.0.0/16', + subnets: [ + { + subnetId: 'subnet-private1', + availabilityZone: 'us-east-1a', + ipv4CidrBlock: '10.0.1.0/24', + routeTableId: 'rt-private1', + subnetType: SubnetType.PRIVATE_WITH_EGRESS, + subnetName: 'Private1', + }, + { + subnetId: 'subnet-private2', + availabilityZone: 'us-east-1b', + ipv4CidrBlock: '10.0.2.0/24', + routeTableId: 'rt-private2', + subnetType: SubnetType.PRIVATE_WITH_NAT, + subnetName: 'Private2', + }, + { + subnetId: 'subnet-public1', + availabilityZone: 'us-east-1a', + ipv4CidrBlock: '10.0.4.0/24', + routeTableId: 'rt-public1', + subnetType: SubnetType.PUBLIC, + subnetName: 'Public1', + }, + { + subnetId: 'subnet-isolated1', + availabilityZone: 'us-east-1b', + ipv4CidrBlock: '10.0.5.0/24', + routeTableId: 'rt-isolated1', + subnetType: SubnetType.PRIVATE_ISOLATED, + subnetName: 'Isolated1', + }, + ], + }); + + // Verify private subnets + expect(vpc.privateSubnets.length).toBe(2); + + // Verify public subnets + expect(vpc.publicSubnets.length).toBe(1); + + // Verify isolated subnets + expect(vpc.isolatedSubnets.length).toBe(1); + }); + + test('Import method fromVpcV2Attributes use default names for subnets if not set under field `subnetName` while importing', () => { + const vpc = VpcV2.fromVpcV2Attributes(stack, 'ImportedVPC', { + vpcId: 'vpc-123456', + vpcCidrBlock: '10.0.0.0/16', + subnets: [ + { + subnetId: 'subnet-private1', + availabilityZone: 'us-east-1a', + ipv4CidrBlock: '10.0.1.0/24', + routeTableId: 'rt-private1', + subnetType: SubnetType.PRIVATE_WITH_EGRESS, + }, + { + subnetId: 'subnet-public1', + availabilityZone: 'us-east-1a', + ipv4CidrBlock: '10.0.2.0/24', + routeTableId: 'rt-public1', + subnetType: SubnetType.PUBLIC, + }, + { + subnetId: 'subnet-isolated1', + availabilityZone: 'us-east-1a', + ipv4CidrBlock: '10.0.3.0/24', + routeTableId: 'rt-isolated1', + subnetType: SubnetType.PRIVATE_ISOLATED, + }, + ], + }); + + // Verify default names are used + expect(vpc.privateSubnets[0].node.id).toBe('ImportedPrivateSubnet'); + expect(vpc.publicSubnets[0].node.id).toBe('ImportedPublicSubnet'); + expect(vpc.isolatedSubnets[0].node.id).toBe('ImportedIsolatedSubnet'); + }); + + test('handles undefined subnets', () => { + const vpc = VpcV2.fromVpcV2Attributes(stack, 'ImportedVPC', { + vpcId: 'vpc-123456', + vpcCidrBlock: '10.0.0.0/16', + }); + + expect(vpc.privateSubnets).toHaveLength(0); + expect(vpc.publicSubnets).toHaveLength(0); + expect(vpc.isolatedSubnets).toHaveLength(0); + }); });