From 557f93fb283167bdfefc4d16886adb47b431b928 Mon Sep 17 00:00:00 2001 From: Grady Barrett Date: Mon, 1 Jun 2020 13:51:20 -0700 Subject: [PATCH 1/2] feat(ec2): enhance lookup subnet with ip count A subnet's AvailableIpAddressCount is made available during vpc lookup. This change just exposes it, if available, for use later down the road for purposes such as filtering subnets during Subnet selection. closes #8310 --- packages/@aws-cdk/aws-ec2/lib/vpc.ts | 15 +++++++++++++++ packages/@aws-cdk/cx-api/lib/context/vpc.ts | 7 +++++++ packages/aws-cdk/lib/context-providers/vpcs.ts | 3 +++ 3 files changed, 25 insertions(+) diff --git a/packages/@aws-cdk/aws-ec2/lib/vpc.ts b/packages/@aws-cdk/aws-ec2/lib/vpc.ts index 25a3473e2d3f3..b18512581b621 100644 --- a/packages/@aws-cdk/aws-ec2/lib/vpc.ts +++ b/packages/@aws-cdk/aws-ec2/lib/vpc.ts @@ -38,6 +38,11 @@ export interface ISubnet extends IResource { */ readonly routeTable: IRouteTable; + /** + * Count of remaining IP addresses in the subnet + */ + readonly availableIpAddressCount?: number; + /** * Associate a Network ACL with this subnet * @@ -665,6 +670,13 @@ export interface SubnetAttributes { * @default - No route table information, cannot create VPC endpoints */ readonly routeTableId?: string; + + /** + * Count of remaining available IP addresses in the subnet + * + * @default - No available ip address count available + */ + readonly availableIpAddressCount?: number; } /** @@ -1800,6 +1812,7 @@ class LookedUpVpc extends VpcBase { availabilityZone: vpcSubnet.availabilityZone, subnetId: vpcSubnet.subnetId, routeTableId: vpcSubnet.routeTableId, + availableIpAddressCount: vpcSubnet.availableIpAddressCount!, })); } return ret; @@ -1850,6 +1863,7 @@ class ImportedSubnet extends Resource implements ISubnet, IPublicSubnet, IPrivat public readonly internetConnectivityEstablished: IDependable = new ConcreteDependable(); public readonly subnetId: string; public readonly routeTable: IRouteTable; + public readonly availableIpAddressCount?: number; private readonly _availabilityZone?: string; constructor(scope: Construct, id: string, attrs: SubnetAttributes) { @@ -1869,6 +1883,7 @@ class ImportedSubnet extends Resource implements ISubnet, IPublicSubnet, IPrivat // Forcing routeTableId to pretend non-null to maintain backwards-compatibility. See https://github.com/aws/aws-cdk/pull/3171 routeTableId: attrs.routeTableId!, }; + this.availableIpAddressCount = attrs.availableIpAddressCount!; } public get availabilityZone(): string { diff --git a/packages/@aws-cdk/cx-api/lib/context/vpc.ts b/packages/@aws-cdk/cx-api/lib/context/vpc.ts index 8d7bbf34f28ea..ed55a4e68d994 100644 --- a/packages/@aws-cdk/cx-api/lib/context/vpc.ts +++ b/packages/@aws-cdk/cx-api/lib/context/vpc.ts @@ -36,6 +36,13 @@ export interface VpcSubnet { * @default - CIDR information not available */ readonly cidr?: string; + + /** + * Count of IP addresses still available in the subnet. + * + * @default - Available IP address count not available + */ + readonly availableIpAddressCount?: number; } /** diff --git a/packages/aws-cdk/lib/context-providers/vpcs.ts b/packages/aws-cdk/lib/context-providers/vpcs.ts index a2b3dec92eedf..0eb878ee8b753 100644 --- a/packages/aws-cdk/lib/context-providers/vpcs.ts +++ b/packages/aws-cdk/lib/context-providers/vpcs.ts @@ -88,6 +88,7 @@ export class VpcNetworkContextProviderPlugin implements ContextProviderPlugin { name, subnetId: subnet.SubnetId!, routeTableId, + availableIpAddressCount: subnet.AvailableIpAddressCount!, }; }); @@ -232,6 +233,7 @@ function groupAsymmetricSubnets(subnets: Subnet[]): cxapi.VpcSubnetGroup[] { cidr: subnet.cidr, availabilityZone: subnet.az, routeTableId: subnet.routeTableId, + availableIpAddressCount: subnet.availableIpAddressCount!, })), }; }); @@ -264,6 +266,7 @@ interface Subnet { name: string; routeTableId: string; subnetId: string; + availableIpAddressCount: number; } interface SubnetGroup { From b500fc92d4dea08ed5be1766d35bdaed12c38484 Mon Sep 17 00:00:00 2001 From: Grady Barrett Date: Mon, 1 Jun 2020 13:55:17 -0700 Subject: [PATCH 2/2] feat(ec2): allow filtering ip exhausted subnets When providing SubnetSelection it's useful to ignore Subnets in a selected subnet group that have 0 available IPs, since we generally wouldn't want to try to deploy to those subnets. For example if we have a subnet group with 4 subnets, 3 of which have sufficient IP space: this is the case in our organization. closes #8310 --- packages/@aws-cdk/aws-ec2/lib/vpc.ts | 31 +++- .../aws-ec2/test/test.vpc.from-lookup.ts | 145 +++++++++++++++++- packages/@aws-cdk/aws-ec2/test/test.vpc.ts | 24 +++ 3 files changed, 196 insertions(+), 4 deletions(-) diff --git a/packages/@aws-cdk/aws-ec2/lib/vpc.ts b/packages/@aws-cdk/aws-ec2/lib/vpc.ts index b18512581b621..f7798dd2eb690 100644 --- a/packages/@aws-cdk/aws-ec2/lib/vpc.ts +++ b/packages/@aws-cdk/aws-ec2/lib/vpc.ts @@ -249,6 +249,16 @@ export interface SubnetSelection { * @default - Use all subnets in a selected group (all private subnets by default) */ readonly subnets?: ISubnet[] + + /** + * Filter out Subnets with 0 available IP addresses + * + * Usful to avoid deployments to IP exhausted subnets that are a part of the selected + * subnet group. + * + * @default false + */ + readonly filterOutIpExhausted?: boolean; } /** @@ -469,6 +479,10 @@ abstract class VpcBase extends Resource implements IVpc { subnets = retainOnePerAz(subnets); } + if (selection.filterOutIpExhausted) { // Filter out IP exhausted + subnets = retainOnlyIpAvailable(subnets); + } + return subnets; } @@ -530,18 +544,21 @@ abstract class VpcBase extends Resource implements IVpc { return { subnetType: SubnetType.PRIVATE, onePerAz: placement.onePerAz, - availabilityZones: placement.availabilityZones}; + availabilityZones: placement.availabilityZones, + filterOutIpExhausted: placement.filterOutIpExhausted }; } if (this.isolatedSubnets.length > 0) { return { subnetType: SubnetType.ISOLATED, onePerAz: placement.onePerAz, - availabilityZones: placement.availabilityZones }; + availabilityZones: placement.availabilityZones, + filterOutIpExhausted: placement.filterOutIpExhausted }; } return { subnetType: SubnetType.PUBLIC, onePerAz: placement.onePerAz, - availabilityZones: placement.availabilityZones }; + availabilityZones: placement.availabilityZones, + filterOutIpExhausted: placement.filterOutIpExhausted }; } return placement; @@ -561,6 +578,14 @@ function retainOnePerAz(subnets: ISubnet[]): ISubnet[] { }); } +function retainOnlyIpAvailable(subnets: ISubnet[]): ISubnet[] { + return subnets.filter(subnet => { + if (subnet.availableIpAddressCount! === 0) { return false; } // Be optimistic if no count defined for backwards compat. + + return true; + }); +} + /** * Properties that reference an external Vpc */ diff --git a/packages/@aws-cdk/aws-ec2/test/test.vpc.from-lookup.ts b/packages/@aws-cdk/aws-ec2/test/test.vpc.from-lookup.ts index 2cf0fac4a9e33..0019bef8f052c 100644 --- a/packages/@aws-cdk/aws-ec2/test/test.vpc.from-lookup.ts +++ b/packages/@aws-cdk/aws-ec2/test/test.vpc.from-lookup.ts @@ -2,7 +2,7 @@ import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import { Construct, ContextProvider, GetContextValueOptions, GetContextValueResult, Lazy, Stack } from '@aws-cdk/core'; import * as cxapi from '@aws-cdk/cx-api'; import { Test } from 'nodeunit'; -import { GenericLinuxImage, Instance, InstanceType, SubnetType, Vpc } from '../lib'; +import { GenericLinuxImage, Instance, InstanceType, ISubnet, SubnetType, Vpc } from '../lib'; export = { 'Vpc.fromLookup()': { @@ -104,6 +104,149 @@ export = { test.done(); }, + 'populates subnet available ip count info'(test: Test) { + const previous = mockVpcContextProviderWith(test, { + vpcId: 'vpc-1234', + subnetGroups: [ + { + name: 'Public', + type: cxapi.VpcSubnetGroupType.PUBLIC, + subnets: [ + { + subnetId: 'pub-exh-sub-in-us-east-1a', + availabilityZone: 'us-east-1a', + routeTableId: 'rt-123', + availableIpAddressCount: 0, + }, + { + subnetId: 'pub-sub-in-us-east-1b', + availabilityZone: 'us-east-1b', + routeTableId: 'rt-123', + }, + ], + }, + { + name: 'Private', + type: cxapi.VpcSubnetGroupType.PRIVATE, + subnets: [ + { + subnetId: 'pri-sub-1-in-us-east-1c', + availabilityZone: 'us-east-1c', + routeTableId: 'rt-123', + }, + { + subnetId: 'pri-exh-sub-2-in-us-east-1c', + availabilityZone: 'us-east-1c', + routeTableId: 'rt-123', + availableIpAddressCount: 0, + }, + { + subnetId: 'pri-ip-avail-sub-1-in-us-east-1d', + availabilityZone: 'us-east-1d', + routeTableId: 'rt-123', + availableIpAddressCount: 10, + }, + { + subnetId: 'pri-sub-2-in-us-east-1d', + availabilityZone: 'us-east-1d', + routeTableId: 'rt-123', + }, + ], + }, + ], + }, options => { + test.deepEqual(options.filter, { + isDefault: 'true', + }); + + test.equal(options.subnetGroupNameTag, undefined); + }); + + const stack = new Stack(); + const vpc = Vpc.fromLookup(stack, 'Vpc', { + isDefault: true, + }); + + test.deepEqual(vpc.availabilityZones, ['us-east-1a', 'us-east-1b', 'us-east-1c', 'us-east-1d']); + test.equal(vpc.publicSubnets.length, 2); + test.equal(vpc.privateSubnets.length, 4); + test.equal(vpc.isolatedSubnets.length, 0); + + const filterSubnets = (subnets: ISubnet[], filterExpression: ((x: ISubnet) => boolean)) => subnets.filter(subnet => filterExpression(subnet)); + const ipExhaustedSubnet = (subnet: ISubnet) => subnet.availableIpAddressCount! === 0; + const explicitlyIpAvailableSubnet = (subnet: ISubnet) => subnet.availableIpAddressCount! > 0; + const implicitlyIpAvailableSubnet = (subnet: ISubnet) => !ipExhaustedSubnet(subnet) && !explicitlyIpAvailableSubnet(subnet); + + test.strictEqual(filterSubnets(vpc.publicSubnets, ipExhaustedSubnet).length, 1); + test.strictEqual(filterSubnets(vpc.publicSubnets, explicitlyIpAvailableSubnet).length, 0); + test.strictEqual(filterSubnets(vpc.publicSubnets, implicitlyIpAvailableSubnet).length, 1); + test.strictEqual(filterSubnets(vpc.privateSubnets, ipExhaustedSubnet).length, 1); + test.strictEqual(filterSubnets(vpc.privateSubnets, explicitlyIpAvailableSubnet).length, 1); + test.strictEqual(filterSubnets(vpc.privateSubnets, implicitlyIpAvailableSubnet).length, 2); + + restoreContextProvider(previous); + test.done(); + }, + + 'filters out IP exhausted subnets'(test: Test) { + const exhaustedSubnetId: string = 'pri-exh-sub-2-in-us-east-1c'; + + const previous = mockVpcContextProviderWith(test, { + vpcId: 'vpc-1234', + subnetGroups: [ + { + name: 'Private', + type: cxapi.VpcSubnetGroupType.PRIVATE, + subnets: [ + { + subnetId: 'pri-sub-1-in-us-east-1c', + availabilityZone: 'us-east-1c', + routeTableId: 'rt-123', + }, + { + subnetId: exhaustedSubnetId, + availabilityZone: 'us-east-1c', + routeTableId: 'rt-123', + availableIpAddressCount: 0, + }, + { + subnetId: 'pri-ip-avail-sub-1-in-us-east-1d', + availabilityZone: 'us-east-1d', + routeTableId: 'rt-123', + availableIpAddressCount: 10, + }, + { + subnetId: 'pri-sub-2-in-us-east-1d', + availabilityZone: 'us-east-1d', + routeTableId: 'rt-123', + }, + ], + }, + ], + }, options => { + test.deepEqual(options.filter, { + isDefault: 'true', + }); + + test.equal(options.subnetGroupNameTag, undefined); + }); + + const stack = new Stack(); + const vpc = Vpc.fromLookup(stack, 'Vpc', { + isDefault: true, + }); + + const { subnetIds } = vpc.selectSubnets({ filterOutIpExhausted: true }); + + test.equal(vpc.privateSubnets.length, 4); + + test.equal(subnetIds.length, 3); + test.ok(!subnetIds.includes(exhaustedSubnetId)); + + restoreContextProvider(previous); + test.done(); + }, + 'selectSubnets onePerAz works on imported VPC'(test: Test) { const previous = mockVpcContextProviderWith(test, { vpcId: 'vpc-1234', diff --git a/packages/@aws-cdk/aws-ec2/test/test.vpc.ts b/packages/@aws-cdk/aws-ec2/test/test.vpc.ts index 94df845e9fab8..723ca8c4d9809 100644 --- a/packages/@aws-cdk/aws-ec2/test/test.vpc.ts +++ b/packages/@aws-cdk/aws-ec2/test/test.vpc.ts @@ -1079,6 +1079,30 @@ export = { test.done(); }, + 'select non-lookup subnets with ip exhaustion filter'(test: Test) { + const isExpectedSubnet = (expectedSubnetIds: string[]) => (subnetId: string) => expectedSubnetIds.includes(subnetId); + + // GIVEN + const stack = getTestStack(); + const vpc = new Vpc(stack, 'VpcNetwork', { + maxAzs: 1, + subnetConfiguration: [ + {name: 'lb', subnetType: SubnetType.PUBLIC }, + {name: 'app', subnetType: SubnetType.PRIVATE }, + {name: 'db', subnetType: SubnetType.PRIVATE }, + ], + }); + const privateSubnets = vpc.privateSubnets.map(subnet => subnet.subnetId); + + // WHEN + const { subnetIds } = vpc.selectSubnets({ filterOutIpExhausted: true }); + + // THEN + test.deepEqual(subnetIds.length, 2); + test.ok(subnetIds.every(isExpectedSubnet(privateSubnets))); + test.done(); + }, + 'select explicitly defined subnets'(test: Test) { // GIVEN const stack = getTestStack();