diff --git a/packages/@aws-cdk/aws-ec2/README.md b/packages/@aws-cdk/aws-ec2/README.md index bf26132b35a07..27dcabd8b69cd 100644 --- a/packages/@aws-cdk/aws-ec2/README.md +++ b/packages/@aws-cdk/aws-ec2/README.md @@ -163,6 +163,8 @@ Which subnets are selected is evaluated as follows: in the given availability zones will be returned. * `onePerAz`: per availability zone, a maximum of one subnet will be returned (Useful for resource types that do not allow creating two ENIs in the same availability zone). +* `subnetFilters`: additional filtering on subnets using any number of user-provided filters which + extend the SubnetFilter class. ### Using NAT instances diff --git a/packages/@aws-cdk/aws-ec2/lib/index.ts b/packages/@aws-cdk/aws-ec2/lib/index.ts index ff7f7131d53e4..ca25a02f3f8d1 100644 --- a/packages/@aws-cdk/aws-ec2/lib/index.ts +++ b/packages/@aws-cdk/aws-ec2/lib/index.ts @@ -10,6 +10,7 @@ export * from './network-acl'; export * from './network-acl-types'; export * from './port'; export * from './security-group'; +export * from './subnet'; export * from './peer'; export * from './volume'; export * from './vpc'; diff --git a/packages/@aws-cdk/aws-ec2/lib/subnet.ts b/packages/@aws-cdk/aws-ec2/lib/subnet.ts new file mode 100644 index 0000000000000..204c81957c929 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/lib/subnet.ts @@ -0,0 +1,115 @@ +import { CidrBlock, NetworkUtils } from './network-util'; +import { ISubnet } from './vpc'; + +/** + * Contains logic which chooses a set of subnets from a larger list, in conjunction + * with SubnetSelection, to determine where to place AWS resources such as VPC + * endpoints, EC2 instances, etc. + */ +export abstract class SubnetFilter { + + /** + * Chooses subnets which are in one of the given availability zones. + */ + public static availabilityZones(availabilityZones: string[]): SubnetFilter { + return new AvailabilityZoneSubnetFilter(availabilityZones); + } + + /** + * Chooses subnets such that there is at most one per availability zone. + */ + public static onePerAz(): SubnetFilter { + return new OnePerAZSubnetFilter(); + } + + /** + * Chooses subnets which contain any of the specified IP addresses. + */ + public static containsIpAddresses(ipv4addrs: string[]): SubnetFilter { + return new ContainsIpAddressesSubnetFilter(ipv4addrs); + } + + /** + * Executes the subnet filtering logic, returning a filtered set of subnets. + */ + public selectSubnets(_subnets: ISubnet[]): ISubnet[] { + throw new Error('Cannot select subnets with an abstract SubnetFilter. `selectSubnets` needs to be implmemented.'); + } +} + +/** + * Chooses subnets which are in one of the given availability zones. + */ +class AvailabilityZoneSubnetFilter extends SubnetFilter { + + private readonly availabilityZones: string[]; + + constructor(availabilityZones: string[]) { + super(); + this.availabilityZones = availabilityZones; + } + + /** + * Executes the subnet filtering logic. + */ + public selectSubnets(subnets: ISubnet[]): ISubnet[] { + return subnets.filter(s => this.availabilityZones.includes(s.availabilityZone)); + } +} + +/** + * Chooses subnets such that there is at most one per availability zone. + */ +class OnePerAZSubnetFilter extends SubnetFilter { + + constructor() { + super(); + } + + /** + * Executes the subnet filtering logic. + */ + public selectSubnets(subnets: ISubnet[]): ISubnet[] { + return this.retainOnePerAz(subnets); + } + + private retainOnePerAz(subnets: ISubnet[]): ISubnet[] { + const azsSeen = new Set(); + return subnets.filter(subnet => { + if (azsSeen.has(subnet.availabilityZone)) { return false; } + azsSeen.add(subnet.availabilityZone); + return true; + }); + } +} + +/** + * Chooses subnets which contain any of the specified IP addresses. + */ +class ContainsIpAddressesSubnetFilter extends SubnetFilter { + + private readonly ipAddresses: string[]; + + constructor(ipAddresses: string[]) { + super(); + this.ipAddresses = ipAddresses; + } + + /** + * Executes the subnet filtering logic. + */ + public selectSubnets(subnets: ISubnet[]): ISubnet[] { + return this.retainByIp(subnets, this.ipAddresses); + } + + private retainByIp(subnets: ISubnet[], ips: string[]): ISubnet[] { + const cidrBlockObjs = ips.map(ip => { + const ipNum = NetworkUtils.ipToNum(ip); + return new CidrBlock(ipNum, 32); + }); + return subnets.filter(s => { + const subnetCidrBlock = new CidrBlock(s.ipv4CidrBlock); + return cidrBlockObjs.some(cidr => subnetCidrBlock.containsCidr(cidr)); + }); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ec2/lib/vpc.ts b/packages/@aws-cdk/aws-ec2/lib/vpc.ts index 63118badb5977..9b7943a7d36e6 100644 --- a/packages/@aws-cdk/aws-ec2/lib/vpc.ts +++ b/packages/@aws-cdk/aws-ec2/lib/vpc.ts @@ -11,6 +11,7 @@ import { import { NatProvider } from './nat'; import { INetworkAcl, NetworkAcl, SubnetNetworkAclAssociation } from './network-acl'; import { NetworkBuilder } from './network-util'; +import { SubnetFilter } from './subnet'; import { allRouteTableIds, defaultSubnetName, flatten, ImportSubnetGroup, subnetGroupNameFromConstructId, subnetId } from './util'; import { GatewayVpcEndpoint, GatewayVpcEndpointAwsService, GatewayVpcEndpointOptions, InterfaceVpcEndpoint, InterfaceVpcEndpointOptions } from './vpc-endpoint'; import { FlowLog, FlowLogOptions, FlowLogResourceType } from './vpc-flow-logs'; @@ -36,6 +37,11 @@ export interface ISubnet extends IResource { */ readonly internetConnectivityEstablished: IDependable; + /** + * The IPv4 CIDR block for this subnet + */ + readonly ipv4CidrBlock: string; + /** * The route table for this subnet */ @@ -236,6 +242,13 @@ export interface SubnetSelection { */ readonly onePerAz?: boolean; + /** + * List of provided subnet filters. + * + * @default - none + */ + readonly subnetFilters?: SubnetFilter[]; + /** * Explicitly select individual subnets * @@ -460,17 +473,21 @@ abstract class VpcBase extends Resource implements IVpc { subnets = this.selectSubnetObjectsByType(type); } - if (selection.availabilityZones !== undefined) { // Filter by AZs, if specified - subnets = retainByAZ(subnets, selection.availabilityZones); - } - - if (!!selection.onePerAz && subnets.length > 0) { // Ensure one per AZ if specified - subnets = retainOnePerAz(subnets); - } + // Apply all the filters + subnets = this.applySubnetFilters(subnets, selection.subnetFilters ?? []); return subnets; } + private applySubnetFilters(subnets: ISubnet[], filters: SubnetFilter[]): ISubnet[] { + let filtered = subnets; + // Apply each filter in sequence + for (const filter of filters) { + filtered = filter.selectSubnets(filtered); + } + return filtered; + } + private selectSubnetObjectsByName(groupName: string) { const allSubnets = [...this.publicSubnets, ...this.privateSubnets, ...this.isolatedSubnets]; const subnets = allSubnets.filter(s => subnetGroupNameFromConstructId(s) === groupName); @@ -510,9 +527,12 @@ abstract class VpcBase extends Resource implements IVpc { * PUBLIC (in that order) that has any subnets. */ private reifySelectionDefaults(placement: SubnetSelection): SubnetSelection { + if (placement.subnetName !== undefined) { if (placement.subnetGroupName !== undefined) { throw new Error('Please use only \'subnetGroupName\' (\'subnetName\' is deprecated and has the same behavior)'); + } else { + Annotations.of(this).addWarning('Usage of \'subnetName\' in SubnetSelection is deprecated, use \'subnetGroupName\' instead'); } placement = { ...placement, subnetGroupName: placement.subnetName }; } @@ -525,42 +545,27 @@ abstract class VpcBase extends Resource implements IVpc { if (placement.subnetType === undefined && placement.subnetGroupName === undefined && placement.subnets === undefined) { // Return default subnet type based on subnets that actually exist - if (this.privateSubnets.length > 0) { - return { - subnetType: SubnetType.PRIVATE, - onePerAz: placement.onePerAz, - availabilityZones: placement.availabilityZones, - }; - } - if (this.isolatedSubnets.length > 0) { - return { - subnetType: SubnetType.ISOLATED, - onePerAz: placement.onePerAz, - availabilityZones: placement.availabilityZones, - }; - } - return { - subnetType: SubnetType.PUBLIC, - onePerAz: placement.onePerAz, - availabilityZones: placement.availabilityZones, - }; + let subnetType = this.privateSubnets.length ? SubnetType.PRIVATE : this.isolatedSubnets.length ? SubnetType.ISOLATED : SubnetType.PUBLIC; + placement = { ...placement, subnetType: subnetType }; } - return placement; - } -} + // Establish which subnet filters are going to be used + let subnetFilters = placement.subnetFilters ?? []; -function retainByAZ(subnets: ISubnet[], azs: string[]): ISubnet[] { - return subnets.filter(s => azs.includes(s.availabilityZone)); -} + // Backwards compatibility with existing `availabilityZones` and `onePerAz` functionality + if (placement.availabilityZones !== undefined) { // Filter by AZs, if specified + subnetFilters.push(SubnetFilter.availabilityZones(placement.availabilityZones)); + } + if (!!placement.onePerAz) { // Ensure one per AZ if specified + subnetFilters.push(SubnetFilter.onePerAz()); + } + + // Overwrite the provided placement filters and remove the availabilityZones and onePerAz properties + placement = { ...placement, subnetFilters: subnetFilters, availabilityZones: undefined, onePerAz: undefined }; + const { availabilityZones, onePerAz, ...rest } = placement; -function retainOnePerAz(subnets: ISubnet[]): ISubnet[] { - const azsSeen = new Set(); - return subnets.filter(subnet => { - if (azsSeen.has(subnet.availabilityZone)) { return false; } - azsSeen.add(subnet.availabilityZone); - return true; - }); + return rest; + } } /** @@ -654,6 +659,7 @@ export interface VpcAttributes { } export interface SubnetAttributes { + /** * The Availability Zone the subnet is located in * @@ -662,9 +668,11 @@ export interface SubnetAttributes { readonly availabilityZone?: string; /** - * The subnetId for this particular subnet + * The IPv4 CIDR block associated with the subnet + * + * @default - No CIDR information, cannot use CIDR filter features */ - readonly subnetId: string; + readonly ipv4CidrBlock?: string; /** * The ID of the route table for this particular subnet @@ -672,6 +680,11 @@ export interface SubnetAttributes { * @default - No route table information, cannot create VPC endpoints */ readonly routeTableId?: string; + + /** + * The subnetId for this particular subnet + */ + readonly subnetId: string; } /** @@ -1442,6 +1455,11 @@ export class Subnet extends Resource implements ISubnet { */ public readonly availabilityZone: string; + /** + * @attribute + */ + public readonly ipv4CidrBlock: string; + /** * The subnetId for this particular subnet */ @@ -1491,6 +1509,7 @@ export class Subnet extends Resource implements ISubnet { Tags.of(this).add(NAME_TAG, this.node.path); this.availabilityZone = props.availabilityZone; + this.ipv4CidrBlock = props.cidrBlock; const subnet = new CfnSubnet(this, 'Subnet', { vpcId: props.vpcId, cidrBlock: props.cidrBlock, @@ -1890,6 +1909,7 @@ class ImportedSubnet extends Resource implements ISubnet, IPublicSubnet, IPrivat public readonly subnetId: string; public readonly routeTable: IRouteTable; private readonly _availabilityZone?: string; + private readonly _ipv4CidrBlock?: string; constructor(scope: Construct, id: string, attrs: SubnetAttributes) { super(scope, id); @@ -1902,6 +1922,7 @@ class ImportedSubnet extends Resource implements ISubnet, IPublicSubnet, IPrivat Annotations.of(this).addWarning(`No routeTableId was provided to the subnet ${ref}. Attempting to read its .routeTable.routeTableId will return null/undefined. (More info: https://github.com/aws/aws-cdk/pull/3171)`); } + this._ipv4CidrBlock = attrs.ipv4CidrBlock; this._availabilityZone = attrs.availabilityZone; this.subnetId = attrs.subnetId; this.routeTable = { @@ -1913,11 +1934,19 @@ class ImportedSubnet extends Resource implements ISubnet, IPublicSubnet, IPrivat public get availabilityZone(): string { if (!this._availabilityZone) { // eslint-disable-next-line max-len - throw new Error("You cannot reference a Subnet's availability zone if it was not supplied. Add the availabilityZone when importing using Subnet.fromSubnetAttributes()"); + throw new Error('You cannot reference a Subnet\'s availability zone if it was not supplied. Add the availabilityZone when importing using Subnet.fromSubnetAttributes()'); } return this._availabilityZone; } + public get ipv4CidrBlock(): string { + if (!this._ipv4CidrBlock) { + // tslint:disable-next-line: max-line-length + throw new Error('You cannot reference an imported Subnet\'s IPv4 CIDR if it was not supplied. Add the ipv4CidrBlock when importing using Subnet.fromSubnetAttributes()'); + } + return this._ipv4CidrBlock; + } + public associateNetworkAcl(id: string, networkAcl: INetworkAcl): void { const scope = Construct.isConstruct(networkAcl) ? networkAcl : this; const other = Construct.isConstruct(networkAcl) ? this : networkAcl; diff --git a/packages/@aws-cdk/aws-ec2/test/vpc.test.ts b/packages/@aws-cdk/aws-ec2/test/vpc.test.ts index 87752fef51bdb..9163a210738f8 100644 --- a/packages/@aws-cdk/aws-ec2/test/vpc.test.ts +++ b/packages/@aws-cdk/aws-ec2/test/vpc.test.ts @@ -2,9 +2,9 @@ import { countResources, expect, haveResource, haveResourceLike, isSuperObject, import { CfnOutput, Lazy, Stack, Tags } from '@aws-cdk/core'; import { nodeunitShim, Test } from 'nodeunit-shim'; import { - AclCidr, AclTraffic, CfnSubnet, CfnVPC, DefaultInstanceTenancy, GenericLinuxImage, InstanceType, InterfaceVpcEndpoint, - InterfaceVpcEndpointService, NatProvider, NetworkAcl, NetworkAclEntry, Peer, Port, PrivateSubnet, PublicSubnet, - RouterType, Subnet, SubnetType, TrafficDirection, Vpc, + AclCidr, AclTraffic, BastionHostLinux, CfnSubnet, CfnVPC, SubnetFilter, DefaultInstanceTenancy, GenericLinuxImage, + InstanceType, InterfaceVpcEndpoint, InterfaceVpcEndpointService, NatProvider, NetworkAcl, NetworkAclEntry, Peer, Port, PrivateSubnet, + PublicSubnet, RouterType, Subnet, SubnetType, TrafficDirection, Vpc, } from '../lib'; nodeunitShim({ @@ -1381,6 +1381,74 @@ nodeunitShim({ })); test.done(); }, + + 'can filter by single IP address'(test: Test) { + // GIVEN + const stack = getTestStack(); + + // IP space is split into 6 pieces, one public/one private per AZ + const vpc = new Vpc(stack, 'VPC', { + cidr: '10.0.0.0/16', + maxAzs: 3, + }); + + // WHEN + // We want to place this bastion host in the same subnet as this IPv4 + // address. + new BastionHostLinux(stack, 'Bastion', { + vpc, + subnetSelection: { + subnetFilters: [SubnetFilter.containsIpAddresses(['10.0.160.0'])], + }, + }); + + // THEN + // 10.0.160.0/19 is the third subnet, sequentially, if you split + // 10.0.0.0/16 into 6 pieces + expect(stack).to(haveResource('AWS::EC2::Instance', { + SubnetId: { + Ref: 'VPCPrivateSubnet3Subnet3EDCD457', + }, + })); + test.done(); + }, + + 'can filter by multiple IP addresses'(test: Test) { + // GIVEN + const stack = getTestStack(); + + // IP space is split into 6 pieces, one public/one private per AZ + const vpc = new Vpc(stack, 'VPC', { + cidr: '10.0.0.0/16', + maxAzs: 3, + }); + + // WHEN + // We want to place this endpoint in the same subnets as these IPv4 + // address. + // WHEN + new InterfaceVpcEndpoint(stack, 'VPC Endpoint', { + vpc, + service: new InterfaceVpcEndpointService('com.amazonaws.vpce.us-east-1.vpce-svc-uuddlrlrbastrtsvc', 443), + subnets: { + subnetFilters: [SubnetFilter.containsIpAddresses(['10.0.96.0', '10.0.160.0'])], + }, + }); + + // THEN + expect(stack).to(haveResource('AWS::EC2::VPCEndpoint', { + ServiceName: 'com.amazonaws.vpce.us-east-1.vpce-svc-uuddlrlrbastrtsvc', + SubnetIds: [ + { + Ref: 'VPCPrivateSubnet1Subnet8BCA10E0', + }, + { + Ref: 'VPCPrivateSubnet3Subnet3EDCD457', + }, + ], + })); + test.done(); + }, }, });