packages/@aws-cdk/toolkit-lib/lib/context-providers/vpcs.ts (307 lines of code) (raw):

import type { VpcContextQuery } from '@aws-cdk/cloud-assembly-schema'; import { type VpcContextResponse, type VpcSubnetGroup, VpcSubnetGroupType } from '@aws-cdk/cx-api'; import type { Filter, RouteTable, Tag, Vpc } from '@aws-sdk/client-ec2'; import type { IContextProviderMessages } from '.'; import { initContextProviderSdk } from '../api/aws-auth/private'; import type { IEC2Client, SdkProvider } from '../api/aws-auth/private'; import type { ContextProviderPlugin } from '../api/plugin'; import { ContextProviderError } from '../api/toolkit-error'; export class VpcNetworkContextProviderPlugin implements ContextProviderPlugin { constructor(private readonly aws: SdkProvider, private readonly io: IContextProviderMessages) { } public async getValue(args: VpcContextQuery) { const ec2 = (await initContextProviderSdk(this.aws, args)).ec2(); const vpcId = await this.findVpc(ec2, args); return this.readVpcProps(ec2, vpcId, args); } private async findVpc(ec2: IEC2Client, args: VpcContextQuery): Promise<Vpc> { // Build request filter (map { Name -> Value } to list of [{ Name, Values }]) const filters: Filter[] = Object.entries(args.filter).map(([tag, value]) => ({ Name: tag, Values: [value] })); await this.io.debug(`Listing VPCs in ${args.account}:${args.region}`); const response = await ec2.describeVpcs({ Filters: filters }); const vpcs = response.Vpcs || []; if (vpcs.length === 0) { throw new ContextProviderError(`Could not find any VPCs matching ${JSON.stringify(args)}`); } if (vpcs.length > 1) { throw new ContextProviderError(`Found ${vpcs.length} VPCs matching ${JSON.stringify(args)}; please narrow the search criteria`); } return vpcs[0]; } private async readVpcProps(ec2: IEC2Client, vpc: Vpc, args: VpcContextQuery): Promise<VpcContextResponse> { const vpcId = vpc.VpcId!; await this.io.debug(`Describing VPC ${vpcId}`); const filters = { Filters: [{ Name: 'vpc-id', Values: [vpcId] }] }; const subnetsResponse = await ec2.describeSubnets(filters); const listedSubnets = subnetsResponse.Subnets || []; const routeTablesResponse = await ec2.describeRouteTables(filters); const routeTables = new RouteTables(routeTablesResponse.RouteTables || []); // Now comes our job to separate these subnets out into AZs and subnet groups (Public, Private, Isolated) // We have the following attributes to go on: // - Type tag, we tag subnets with their type. In absence of this tag, we // determine the subnet must be Public if either: // a) it has the property MapPublicIpOnLaunch // b) it has a route to an Internet Gateway // If both of the above is false but the subnet has a route to a NAT Gateway // and the destination CIDR block is "0.0.0.0/0", we assume it to be a Private subnet. // Anything else is considered Isolated. // - Name tag, we tag subnets with their subnet group name. In absence of this tag, // we use the type as the name. const azs = Array.from(new Set<string>(listedSubnets.map((s) => s.AvailabilityZone!))); azs.sort(); const subnets: Subnet[] = listedSubnets.map((subnet) => { let type = getTag('aws-cdk:subnet-type', subnet.Tags); if (type === undefined && subnet.MapPublicIpOnLaunch) { type = SubnetType.Public; } if (type === undefined && routeTables.hasRouteToIgw(subnet.SubnetId)) { type = SubnetType.Public; } if (type === undefined && routeTables.hasRouteToNatGateway(subnet.SubnetId)) { type = SubnetType.Private; } if (type === undefined && routeTables.hasRouteToTransitGateway(subnet.SubnetId)) { type = SubnetType.Private; } if (type === undefined) { type = SubnetType.Isolated; } if (!isValidSubnetType(type)) { // eslint-disable-next-line max-len throw new ContextProviderError( `Subnet ${subnet.SubnetArn} has invalid subnet type ${type} (must be ${SubnetType.Public}, ${SubnetType.Private} or ${SubnetType.Isolated})`, ); } if (args.subnetGroupNameTag && !getTag(args.subnetGroupNameTag, subnet.Tags)) { throw new ContextProviderError( `Invalid subnetGroupNameTag: Subnet ${subnet.SubnetArn} does not have an associated tag with Key='${args.subnetGroupNameTag}'`, ); } const name = getTag(args.subnetGroupNameTag || 'aws-cdk:subnet-name', subnet.Tags) || type; const routeTableId = routeTables.routeTableIdForSubnetId(subnet.SubnetId); if (!routeTableId) { throw new ContextProviderError( `Subnet ${subnet.SubnetArn} does not have an associated route table (and there is no "main" table)`, ); } return { az: subnet.AvailabilityZone!, cidr: subnet.CidrBlock!, type, name, subnetId: subnet.SubnetId!, routeTableId, }; }); let grouped: SubnetGroups; let assymetricSubnetGroups: VpcSubnetGroup[] | undefined; if (args.returnAsymmetricSubnets) { grouped = { azs: [], groups: [] }; assymetricSubnetGroups = groupAsymmetricSubnets(subnets); } else { grouped = groupSubnets(subnets); assymetricSubnetGroups = undefined; } // Find attached+available VPN gateway for this VPC const vpnGatewayResponse = (args.returnVpnGateways ?? true) ? await ec2.describeVpnGateways({ Filters: [ { Name: 'attachment.vpc-id', Values: [vpcId], }, { Name: 'attachment.state', Values: ['attached'], }, { Name: 'state', Values: ['available'], }, ], }) : undefined; const vpnGatewayId = vpnGatewayResponse?.VpnGateways?.length === 1 ? vpnGatewayResponse.VpnGateways[0].VpnGatewayId : undefined; return { vpcId, vpcCidrBlock: vpc.CidrBlock!, ownerAccountId: vpc.OwnerId, availabilityZones: grouped.azs, isolatedSubnetIds: collapse( flatMap(findGroups(SubnetType.Isolated, grouped), (group) => group.subnets.map((s) => s.subnetId)), ), isolatedSubnetNames: collapse( flatMap(findGroups(SubnetType.Isolated, grouped), (group) => (group.name ? [group.name] : [])), ), isolatedSubnetRouteTableIds: collapse( flatMap(findGroups(SubnetType.Isolated, grouped), (group) => group.subnets.map((s) => s.routeTableId)), ), privateSubnetIds: collapse( flatMap(findGroups(SubnetType.Private, grouped), (group) => group.subnets.map((s) => s.subnetId)), ), privateSubnetNames: collapse( flatMap(findGroups(SubnetType.Private, grouped), (group) => (group.name ? [group.name] : [])), ), privateSubnetRouteTableIds: collapse( flatMap(findGroups(SubnetType.Private, grouped), (group) => group.subnets.map((s) => s.routeTableId)), ), publicSubnetIds: collapse( flatMap(findGroups(SubnetType.Public, grouped), (group) => group.subnets.map((s) => s.subnetId)), ), publicSubnetNames: collapse( flatMap(findGroups(SubnetType.Public, grouped), (group) => (group.name ? [group.name] : [])), ), publicSubnetRouteTableIds: collapse( flatMap(findGroups(SubnetType.Public, grouped), (group) => group.subnets.map((s) => s.routeTableId)), ), vpnGatewayId, subnetGroups: assymetricSubnetGroups, }; } } class RouteTables { public readonly mainRouteTable?: RouteTable; constructor(private readonly tables: RouteTable[]) { this.mainRouteTable = this.tables.find( (table) => !!table.Associations && table.Associations.some((assoc) => !!assoc.Main), ); } public routeTableIdForSubnetId(subnetId: string | undefined): string | undefined { const table = this.tableForSubnet(subnetId); return (table && table.RouteTableId) || (this.mainRouteTable && this.mainRouteTable.RouteTableId); } /** * Whether the given subnet has a route to a NAT Gateway */ public hasRouteToNatGateway(subnetId: string | undefined): boolean { const table = this.tableForSubnet(subnetId) || this.mainRouteTable; return ( !!table && !!table.Routes && table.Routes.some((route) => !!route.NatGatewayId && route.DestinationCidrBlock === '0.0.0.0/0') ); } /** * Whether the given subnet has a route to a Transit Gateway */ public hasRouteToTransitGateway(subnetId: string | undefined): boolean { const table = this.tableForSubnet(subnetId) || this.mainRouteTable; return ( !!table && !!table.Routes && table.Routes.some((route) => !!route.TransitGatewayId && route.DestinationCidrBlock === '0.0.0.0/0') ); } /** * Whether the given subnet has a route to an IGW */ public hasRouteToIgw(subnetId: string | undefined): boolean { const table = this.tableForSubnet(subnetId) || this.mainRouteTable; return ( !!table && !!table.Routes && table.Routes.some((route) => !!route.GatewayId && route.GatewayId.startsWith('igw-')) ); } public tableForSubnet(subnetId: string | undefined) { return this.tables.find( (table) => !!table.Associations && table.Associations.some((assoc) => assoc.SubnetId === subnetId), ); } } /** * Return the value of a tag from a set of tags */ function getTag(name: string, tags?: Tag[]): string | undefined { for (const tag of tags || []) { if (tag.Key === name) { return tag.Value; } } return undefined; } /** * Group subnets of the same type together, and order by AZ */ function groupSubnets(subnets: Subnet[]): SubnetGroups { const grouping: { [key: string]: Subnet[] } = {}; for (const subnet of subnets) { const key = [subnet.type, subnet.name].toString(); if (!(key in grouping)) { grouping[key] = []; } grouping[key].push(subnet); } const groups = Object.values(grouping).map((sns) => { sns.sort((a: Subnet, b: Subnet) => a.az.localeCompare(b.az)); return { type: sns[0].type, name: sns[0].name, subnets: sns, }; }); const azs = groups[0].subnets.map((s) => s.az); for (const group of groups) { const groupAZs = group.subnets.map((s) => s.az); if (!arraysEqual(groupAZs, azs)) { throw new ContextProviderError(`Not all subnets in VPC have the same AZs: ${groupAZs} vs ${azs}`); } } return { azs, groups }; } function groupAsymmetricSubnets(subnets: Subnet[]): VpcSubnetGroup[] { const grouping: { [key: string]: Subnet[] } = {}; for (const subnet of subnets) { const key = [subnet.type, subnet.name].toString(); if (!(key in grouping)) { grouping[key] = []; } grouping[key].push(subnet); } return Object.values(grouping).map((subnetArray) => { subnetArray.sort((subnet1: Subnet, subnet2: Subnet) => subnet1.az.localeCompare(subnet2.az)); return { name: subnetArray[0].name, type: subnetTypeToVpcSubnetType(subnetArray[0].type), subnets: subnetArray.map((subnet) => ({ subnetId: subnet.subnetId, cidr: subnet.cidr, availabilityZone: subnet.az, routeTableId: subnet.routeTableId, })), }; }); } function subnetTypeToVpcSubnetType(type: SubnetType): VpcSubnetGroupType { switch (type) { case SubnetType.Isolated: return VpcSubnetGroupType.ISOLATED; case SubnetType.Private: return VpcSubnetGroupType.PRIVATE; case SubnetType.Public: return VpcSubnetGroupType.PUBLIC; } } enum SubnetType { Public = 'Public', Private = 'Private', Isolated = 'Isolated', } function isValidSubnetType(val: string): val is SubnetType { return val === SubnetType.Public || val === SubnetType.Private || val === SubnetType.Isolated; } interface Subnet { az: string; cidr: string; type: SubnetType; name: string; routeTableId: string; subnetId: string; } interface SubnetGroup { type: SubnetType; name: string; subnets: Subnet[]; } interface SubnetGroups { azs: string[]; groups: SubnetGroup[]; } function arraysEqual(as: string[], bs: string[]): boolean { if (as.length !== bs.length) { return false; } for (let i = 0; i < as.length; i++) { if (as[i] !== bs[i]) { return false; } } return true; } function findGroups(type: SubnetType, groups: SubnetGroups): SubnetGroup[] { return groups.groups.filter((g) => g.type === type); } function flatMap<T, U>(xs: T[], fn: (x: T) => U[]): U[] { const ret = new Array<U>(); for (const x of xs) { ret.push(...fn(x)); } return ret; } function collapse<T>(xs: T[]): T[] | undefined { if (xs.length > 0) { return xs; } return undefined; }