diff --git a/packages/@aws-cdk/aws-ec2/README.md b/packages/@aws-cdk/aws-ec2/README.md index ac1907d197f47..58d2731f62f34 100644 --- a/packages/@aws-cdk/aws-ec2/README.md +++ b/packages/@aws-cdk/aws-ec2/README.md @@ -86,6 +86,20 @@ itself to 2 Availability Zones. Therefore, to get the VPC to spread over 3 or more availability zones, you must specify the environment where the stack will be deployed. +### Using NAT instances + +By default, the `Vpc` construct will create NAT *gateways* for you, which +are managed by AWS. If you would prefer to use your own managed NAT +*instances* instead, specify a different value for the `natGatewayProvider` +property, as follows: + +[using NAT instances](test/integ.nat-instances.lit.ts) + +The construct will automatically search for the most recent NAT gateway AMI. +If you prefer to use a custom AMI, pass a `GenericLinuxImage` instance +for the instance's `machineImage` parameter and configure the right AMI ID +for the regions you want to deploy to. + ### Advanced Subnet Configuration If the default VPC configuration (public and private subnets spanning the @@ -341,19 +355,19 @@ fleet.connections.allowDefaultPortTo(rdsDatabase, 'Fleet can access database'); AMIs control the OS that gets launched when you start your EC2 instance. The EC2 library contains constructs to select the AMI you want to use. -Depending on the type of AMI, you select it a different way. - -The latest version of Amazon Linux and Microsoft Windows images are -selectable by instantiating one of these classes: +Depending on the type of AMI, you select it a different way. Here are some +examples of things you might want to use: [example of creating images](test/example.images.lit.ts) -> NOTE: The Amazon Linux images selected will be cached in your `cdk.json`, so that your -> AutoScalingGroups don't automatically change out from under you when you're making unrelated -> changes. To update to the latest version of Amazon Linux, remove the cache entry from the `context` -> section of your `cdk.json`. +> NOTE: The AMIs selected by `AmazonLinuxImage` or `LookupImage` will be cached in +> `cdk.context.json`, so that your AutoScalingGroup instances aren't replaced while +> you are making unrelated changes to your CDK app. > -> We will add command-line options to make this step easier in the future. +> To query for the latest AMI again, remove the relevant cache entry from +> `cdk.context.json`, or use the `cdk context` command. For more information, see +> [Runtime Context](https://docs.aws.amazon.com/cdk/latest/guide/context.html) in the CDK +> developer guide. ## VPN connections to a VPC diff --git a/packages/@aws-cdk/aws-ec2/lib/index.ts b/packages/@aws-cdk/aws-ec2/lib/index.ts index 3516e97586b9c..af52216f61464 100644 --- a/packages/@aws-cdk/aws-ec2/lib/index.ts +++ b/packages/@aws-cdk/aws-ec2/lib/index.ts @@ -3,6 +3,7 @@ export * from './connections'; export * from './instance-types'; export * from './instance'; export * from './machine-image'; +export * from './nat'; export * from './network-acl'; export * from './network-acl-types'; export * from './port'; diff --git a/packages/@aws-cdk/aws-ec2/lib/machine-image.ts b/packages/@aws-cdk/aws-ec2/lib/machine-image.ts index fe076034fe9f4..ae757033c78f7 100644 --- a/packages/@aws-cdk/aws-ec2/lib/machine-image.ts +++ b/packages/@aws-cdk/aws-ec2/lib/machine-image.ts @@ -1,5 +1,6 @@ import ssm = require('@aws-cdk/aws-ssm'); -import { Construct, Stack, Token } from '@aws-cdk/core'; +import { Construct, ContextProvider, Stack, Token } from '@aws-cdk/core'; +import cxapi = require('@aws-cdk/cx-api'); import { UserData } from './user-data'; import { WindowsVersion } from './windows-versions'; @@ -230,7 +231,7 @@ export interface GenericLinuxImageProps { /** * Initial user data * - * @default - Empty UserData for Windows machines + * @default - Empty UserData for Linux machines */ readonly userData?: UserData; } @@ -311,3 +312,90 @@ export enum OperatingSystemType { LINUX, WINDOWS, } + +/** + * A machine image whose AMI ID will be searched using DescribeImages. + * + * The most recent, available, launchable image matching the given filter + * criteria will be used. Looking up AMIs may take a long time; specify + * as many filter criteria as possible to narrow down the search. + * + * The AMI selected will be cached in `cdk.context.json` and the same value + * will be used on future runs. To refresh the AMI lookup, you will have to + * evict the value from the cache using the `cdk context` command. See + * https://docs.aws.amazon.com/cdk/latest/guide/context.html for more information. + */ +export class LookupMachineImage implements IMachineImage { + constructor(private readonly props: LookupMachineImageProps) { + } + + public getImage(scope: Construct): MachineImageConfig { + // Need to know 'windows' or not before doing the query to return the right + // osType for the dummy value, so might as well add it to the filter. + const filters: Record = { + 'name': [this.props.name], + 'state': ['available'], + 'image-type': ['machine'], + 'platform': this.props.windows ? ['windows'] : undefined, + }; + Object.assign(filters, this.props.filters); + + const value = ContextProvider.getValue(scope, { + provider: cxapi.AMI_PROVIDER, + props: { + owners: this.props.owners, + filters, + } as cxapi.AmiContextQuery, + dummyValue: 'ami-1234', + }).value as cxapi.AmiContextResponse; + + if (typeof value !== 'string') { + throw new Error(`Response to AMI lookup invalid, got: ${value}`); + } + + return { + imageId: value, + osType: this.props.windows ? OperatingSystemType.WINDOWS : OperatingSystemType.LINUX, + userData: this.props.userData + }; + } +} + +/** + * Properties for looking up an image + */ +export interface LookupMachineImageProps { + /** + * Name of the image (may contain wildcards) + */ + readonly name: string; + + /** + * Owner account IDs or aliases + * + * @default - All owners + */ + readonly owners?: string[]; + + /** + * Additional filters on the AMI + * + * @see https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeImages.html + * @default - No additional filters + */ + readonly filters?: {[key: string]: string[]}; + + /** + * Look for Windows images + * + * @default false + */ + readonly windows?: boolean; + + /** + * Custom userdata for this image + * + * @default - Empty user data appropriate for the platform type + */ + readonly userData?: UserData; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ec2/lib/nat.ts b/packages/@aws-cdk/aws-ec2/lib/nat.ts new file mode 100644 index 0000000000000..fc0a4fa358bc1 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/lib/nat.ts @@ -0,0 +1,218 @@ +import iam = require('@aws-cdk/aws-iam'); +import { Instance } from './instance'; +import { InstanceType } from './instance-types'; +import { IMachineImage, LookupMachineImage } from "./machine-image"; +import { Port } from './port'; +import { SecurityGroup } from './security-group'; +import { PrivateSubnet, PublicSubnet, RouterType, Vpc } from './vpc'; + +/** + * NAT providers + * + * Determines what type of NAT provider to create, either NAT gateways or NAT + * instance. + * + * @experimental + */ +export abstract class NatProvider { + /** + * Use NAT Gateways to provide NAT services for your VPC + * + * NAT gateways are managed by AWS. + * + * @see https://docs.aws.amazon.com/vpc/latest/userguide/vpc-nat-gateway.html + */ + public static gateway(): NatProvider { + return new NatGateway(); + } + + /** + * Use NAT instances to provide NAT services for your VPC + * + * NAT instances are managed by you, but in return allow more configuration. + * + * Be aware that instances created using this provider will not be + * automatically replaced if they are stopped for any reason. You should implement + * your own NatProvider based on AutoScaling groups if you need that. + * + * @see https://docs.aws.amazon.com/vpc/latest/userguide/VPC_NAT_Instance.html + */ + public static instance(props: NatInstanceProps): NatProvider { + return new NatInstance(props); + } + + /** + * Called by the VPC to configure NAT + */ + public abstract configureNat(options: ConfigureNatOptions): void; +} + +/** + * Options passed by the VPC when NAT needs to be configured + * + * @experimental + */ +export interface ConfigureNatOptions { + /** + * The VPC we're configuring NAT for + */ + readonly vpc: Vpc; + + /** + * The public subnets where the NAT providers need to be placed + */ + readonly natSubnets: PublicSubnet[]; + + /** + * The private subnets that need to route through the NAT providers. + * + * There may be more private subnets than public subnets with NAT providers. + */ + readonly privateSubnets: PrivateSubnet[]; +} + +/** + * Properties for a NAT instance + * + * @experimental + */ +export interface NatInstanceProps { + /** + * The machine image (AMI) to use + * + * By default, will do an AMI lookup for the latest NAT instance image. + * + * If you have a specific AMI ID you want to use, pass a `GenericLinuxImage`. For example: + * + * ```ts + * NatProvider.instance({ + * instanceType: new InstanceType('t3.micro'), + * machineImage: new GenericLinuxImage({ + * 'us-east-2': 'ami-0f9c61b5a562a16af' + * }) + * }) + * ``` + * + * @default - Latest NAT instance image + */ + readonly machineImage?: IMachineImage; + + /** + * Instance type of the NAT instance + */ + readonly instanceType: InstanceType; + + /** + * Name of SSH keypair to grant access to instance + * + * @default - No SSH access will be possible. + */ + readonly keyName?: string; +} + +class NatGateway extends NatProvider { + public configureNat(options: ConfigureNatOptions) { + // Create the NAT gateways + const gatewayIds = new PrefSet(); + for (const sub of options.natSubnets) { + const gateway = sub.addNatGateway(); + gatewayIds.add(sub.availabilityZone, gateway.ref); + } + + // Add routes to them in the private subnets + for (const sub of options.privateSubnets) { + sub.addRoute('DefaultRoute', { + routerType: RouterType.NAT_GATEWAY, + routerId: gatewayIds.pick(sub.availabilityZone), + enablesInternetConnectivity: true, + }); + } + } +} + +class NatInstance extends NatProvider { + constructor(private readonly props: NatInstanceProps) { + super(); + } + + public configureNat(options: ConfigureNatOptions) { + // Create the NAT instances. They can share a security group and a Role. + const instances = new PrefSet(); + const machineImage = this.props.machineImage || new NatInstanceImage(); + const sg = new SecurityGroup(options.vpc, 'NatSecurityGroup', { + vpc: options.vpc, + description: 'Security Group for NAT instances', + }); + sg.connections.allowFromAnyIpv4(Port.allTcp()); + + // FIXME: Ideally, NAT instances don't have a role at all, but + // 'Instance' does not allow that right now. + const role = new iam.Role(options.vpc, 'NatRole', { + assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com') + }); + + for (const sub of options.natSubnets) { + const natInstance = new Instance(sub, 'NatInstance', { + instanceType: this.props.instanceType, + machineImage, + sourceDestCheck: false, // Required for NAT + vpc: options.vpc, + vpcSubnets: { subnets: [sub] }, + securityGroup: sg, + role, + keyName: this.props.keyName + }); + // NAT instance routes all traffic, both ways + instances.add(sub.availabilityZone, natInstance); + } + + // Add routes to them in the private subnets + for (const sub of options.privateSubnets) { + sub.addRoute('DefaultRoute', { + routerType: RouterType.INSTANCE, + routerId: instances.pick(sub.availabilityZone).instanceId, + enablesInternetConnectivity: true, + }); + } + } +} + +/** + * Preferential set + * + * Picks the value with the given key if available, otherwise distributes + * evenly among the available options. + */ +class PrefSet { + private readonly map: Record = {}; + private readonly vals = new Array(); + private next: number = 0; + + public add(pref: string, value: A) { + this.map[pref] = value; + this.vals.push(value); + } + + public pick(pref: string): A { + if (this.vals.length === 0) { + throw new Error('Cannot pick, set is empty'); + } + + if (pref in this.map) { return this.map[pref]; } + return this.vals[this.next++ % this.vals.length]; + } +} + +/** + * Machine image representing the latest NAT instance image + * + * @experimental + */ +export class NatInstanceImage extends LookupMachineImage { + constructor() { + super({ + name: 'amzn-ami-vpc-nat-*', + owners: ['amazon'], + }); + } +} \ 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 db3a590d171b4..a2d508b444183 100644 --- a/packages/@aws-cdk/aws-ec2/lib/vpc.ts +++ b/packages/@aws-cdk/aws-ec2/lib/vpc.ts @@ -4,6 +4,7 @@ import cxapi = require('@aws-cdk/cx-api'); import { CfnEIP, CfnInternetGateway, CfnNatGateway, CfnRoute, CfnRouteTable, CfnSubnet, CfnSubnetRouteTableAssociation, CfnVPC, CfnVPCGatewayAttachment, CfnVPNGateway, CfnVPNGatewayRoutePropagation } from './ec2.generated'; +import { NatProvider } from './nat'; import { INetworkAcl, NetworkAcl, SubnetNetworkAclAssociation } from './network-acl'; import { NetworkBuilder } from './network-util'; import { allRouteTableIds, defaultSubnetName, ImportSubnetGroup, subnetGroupNameFromConstructId, subnetId } from './util'; @@ -291,6 +292,8 @@ abstract class VpcBase extends Resource implements IVpc { /** * Dependencies for NAT connectivity + * + * @deprecated - This value is no longer used. */ protected readonly natDependencies = new Array(); @@ -606,18 +609,21 @@ export interface VpcProps { readonly maxAzs?: number; /** - * The number of NAT Gateways to create. + * The number of NAT Gateways/Instances to create. + * + * The type of NAT gateway or instance will be determined by the + * `natGatewayProvider` parameter. * * You can set this number lower than the number of Availability Zones in your - * VPC in order to save on NAT gateway cost. Be aware you may be charged for + * VPC in order to save on NAT cost. Be aware you may be charged for * cross-AZ data traffic instead. * - * @default - One NAT gateway per Availability Zone + * @default - One NAT gateway/instance per Availability Zone */ readonly natGateways?: number; /** - * Configures the subnets which will have NAT Gateways + * Configures the subnets which will have NAT Gateways/Instances * * You can pick a specific group of subnets by specifying the group name; * the picked subnets must be public subnets. @@ -628,6 +634,17 @@ export interface VpcProps { */ readonly natGatewaySubnets?: SubnetSelection; + /** + * What type of NAT provider to use + * + * Select between NAT gateways or NAT instances. NAT gateways + * may not be available in all AWS regions. + * + * @default NatProvider.gateway() + * @experimental + */ + readonly natGatewayProvider?: NatProvider; + /** * Configure the subnets to build for each AZ * @@ -938,11 +955,6 @@ export class Vpc extends VpcBase { */ private networkBuilder: NetworkBuilder; - /** - * Mapping of NatGateway by AZ - */ - private natGatewayByAZ: { [az: string]: string } = {}; - /** * Subnet configurations for this VPC */ @@ -1028,17 +1040,8 @@ export class Vpc extends VpcBase { // if gateways are needed create them if (natGatewayCount > 0) { - this.createNatGateways(natGatewayCount, natGatewayPlacement); - - (this.privateSubnets as PrivateSubnet[]).forEach((privateSubnet, i) => { - let ngwId = this.natGatewayByAZ[privateSubnet.availabilityZone]; - if (ngwId === undefined) { - const ngwArray = Array.from(Object.values(this.natGatewayByAZ)); - // round robin the available NatGW since one is not in your AZ - ngwId = ngwArray[i % ngwArray.length]; - } - privateSubnet.addDefaultNatRoute(ngwId); - }); + const provider = props.natGatewayProvider || NatProvider.gateway(); + this.createNatGateways(provider, natGatewayCount, natGatewayPlacement); } } @@ -1115,20 +1118,19 @@ export class Vpc extends VpcBase { }); } - private createNatGateways(natCount: number, placement: SubnetSelection): void { - let natSubnets: PublicSubnet[] = this.selectSubnetObjects(placement) as PublicSubnet[]; + private createNatGateways(provider: NatProvider, natCount: number, placement: SubnetSelection): void { + const natSubnets: PublicSubnet[] = this.selectSubnetObjects(placement) as PublicSubnet[]; for (const sub of natSubnets) { if (this.publicSubnets.indexOf(sub) === -1) { throw new Error(`natGatewayPlacement ${placement} contains non public subnet ${sub}`); } } - natSubnets = natSubnets.slice(0, natCount); - for (const sub of natSubnets) { - const gateway = sub.addNatGateway(); - this.natGatewayByAZ[sub.availabilityZone] = gateway.ref; - this.natDependencies.push(gateway); - } + provider.configureNat({ + vpc: this, + natSubnets: natSubnets.slice(0, natCount), + privateSubnets: this.privateSubnets as PrivateSubnet[] + }); } /** @@ -1376,12 +1378,31 @@ export class Subnet extends Resource implements ISubnet { * @param natGatewayId The ID of the NAT gateway */ public addDefaultNatRoute(natGatewayId: string) { - const route = new CfnRoute(this, `DefaultRoute`, { + this.addRoute('DefaultRoute', { + routerType: RouterType.NAT_GATEWAY, + routerId: natGatewayId, + enablesInternetConnectivity: true, + }); + } + + /** + * Adds an entry to this subnets route table + */ + public addRoute(id: string, options: AddRouteOptions) { + if (options.destinationCidrBlock && options.destinationIpv6CidrBlock) { + throw new Error(`Cannot specify both 'destinationCidrBlock' and 'destinationIpv6CidrBlock'`); + } + + const route = new CfnRoute(this, id, { routeTableId: this.routeTable.routeTableId, - destinationCidrBlock: '0.0.0.0/0', - natGatewayId + destinationCidrBlock: options.destinationCidrBlock || (options.destinationIpv6CidrBlock === undefined ? '0.0.0.0/0' : undefined), + destinationIpv6CidrBlock: options.destinationCidrBlock, + [routerTypeToPropName(options.routerType)]: options.routerId, }); - this._internetConnectivityEstablished.add(route); + + if (options.enablesInternetConnectivity) { + this._internetConnectivityEstablished.add(route); + } } public associateNetworkAcl(id: string, networkAcl: INetworkAcl) { @@ -1396,12 +1417,100 @@ export class Subnet extends Resource implements ISubnet { } } +/** + * Options for adding a new route to a subnet + */ +export interface AddRouteOptions { + /** + * IPv4 range this route applies to + * + * @default '0.0.0.0/0' + */ + readonly destinationCidrBlock?: string; + + /** + * IPv6 range this route applies to + * + * @default - Uses IPv6 + */ + readonly destinationIpv6CidrBlock?: string; + + /** + * What type of router to route this traffic to + */ + readonly routerType: RouterType; + + /** + * The ID of the router + * + * Can be an instance ID, gateway ID, etc, depending on the router type. + */ + readonly routerId: string; + + /** + * Whether this route will enable internet connectivity + * + * If true, this route will be added before any AWS resources that depend + * on internet connectivity in the VPC will be created. + * + * @default false + */ + readonly enablesInternetConnectivity?: boolean; +} + +/** + * Type of router used in route + */ +export enum RouterType { + /** + * Egress-only Internet Gateway + */ + EGRESS_ONLY_INTERNET_GATEWAY = 'EgressOnlyInternetGateway', + + /** + * Internet Gateway + */ + GATEWAY = 'Gateway', + + /** + * Instance + */ + INSTANCE = 'Instance', + + /** + * NAT Gateway + */ + NAT_GATEWAY = 'NatGateway', + + /** + * Network Interface + */ + NETWORK_INTERFACE = 'NetworkInterface', + + /** + * VPC peering connection + */ + VPC_PEERING_CONNECTION = 'VpcPeeringConnection', +} + +function routerTypeToPropName(routerType: RouterType) { + return ({ + [RouterType.EGRESS_ONLY_INTERNET_GATEWAY]: 'egressOnlyInternetGatewayId', + [RouterType.GATEWAY]: 'gatewayId', + [RouterType.INSTANCE]: 'instanceId', + [RouterType.NAT_GATEWAY]: 'natGatewayId', + [RouterType.NETWORK_INTERFACE]: 'networkInterfaceId', + [RouterType.VPC_PEERING_CONNECTION]: 'vpcPeeringConnectionId', + })[routerType]; +} + // tslint:disable-next-line:no-empty-interface export interface PublicSubnetProps extends SubnetProps { } export interface IPublicSubnet extends ISubnet { } + export interface PublicSubnetAttributes extends SubnetAttributes { } /** diff --git a/packages/@aws-cdk/aws-ec2/test/example.images.lit.ts b/packages/@aws-cdk/aws-ec2/test/example.images.lit.ts index 6214a61627fb3..80a318c8225f8 100644 --- a/packages/@aws-cdk/aws-ec2/test/example.images.lit.ts +++ b/packages/@aws-cdk/aws-ec2/test/example.images.lit.ts @@ -1,9 +1,7 @@ import ec2 = require("../lib"); +import { LookupMachineImage } from "../lib"; /// !show -// Pick a Windows edition to use -const windows = new ec2.WindowsImage(ec2.WindowsVersion.WINDOWS_SERVER_2019_ENGLISH_FULL_BASE); - // Pick the right Amazon Linux edition. All arguments shown are optional // and will default to these values when omitted. const amznLinux = new ec2.AmazonLinuxImage({ @@ -13,6 +11,17 @@ const amznLinux = new ec2.AmazonLinuxImage({ storage: ec2.AmazonLinuxStorage.GENERAL_PURPOSE, }); +// Pick a Windows edition to use +const windows = new ec2.WindowsImage(ec2.WindowsVersion.WINDOWS_SERVER_2019_ENGLISH_FULL_BASE); + +// Look up the most recent image matching a set of AMI filters. +// In this case, look up the NAT instance AMI, by using a wildcard +// in the 'name' field: +const natAmi = new LookupMachineImage({ + name: 'amzn-ami-vpc-nat-*', + owners: ['amazon'], +}); + // For other custom (Linux) images, instantiate a `GenericLinuxImage` with // a map giving the AMI to in for each region: @@ -36,3 +45,4 @@ Array.isArray(windows); Array.isArray(amznLinux); Array.isArray(linux); Array.isArray(genericWindows); +Array.isArray(natAmi); diff --git a/packages/@aws-cdk/aws-ec2/test/integ.nat-instances.lit.expected.json b/packages/@aws-cdk/aws-ec2/test/integ.nat-instances.lit.expected.json new file mode 100644 index 0000000000000..62344c06aa839 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/integ.nat-instances.lit.expected.json @@ -0,0 +1,576 @@ +{ + "Resources": { + "MyVpcF9F0CA6F": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16", + "EnableDnsHostnames": true, + "EnableDnsSupport": true, + "InstanceTenancy": "default", + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-vpc-nat-instances/MyVpc" + } + ] + } + }, + "MyVpcPublicSubnet1SubnetF6608456": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/19", + "VpcId": { + "Ref": "MyVpcF9F0CA6F" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-vpc-nat-instances/MyVpc/PublicSubnet1" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + } + ] + } + }, + "MyVpcPublicSubnet1RouteTableC46AB2F4": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "MyVpcF9F0CA6F" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-vpc-nat-instances/MyVpc/PublicSubnet1" + } + ] + } + }, + "MyVpcPublicSubnet1RouteTableAssociation2ECEE1CB": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "MyVpcPublicSubnet1RouteTableC46AB2F4" + }, + "SubnetId": { + "Ref": "MyVpcPublicSubnet1SubnetF6608456" + } + } + }, + "MyVpcPublicSubnet1DefaultRoute95FDF9EB": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "MyVpcPublicSubnet1RouteTableC46AB2F4" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "MyVpcIGW5C4A4F63" + } + }, + "DependsOn": [ + "MyVpcVPCGW488ACE0D" + ] + }, + "MyVpcPublicSubnet1NatInstanceInstanceProfile2FD934CB": { + "Type": "AWS::IAM::InstanceProfile", + "Properties": { + "Roles": [ + { + "Ref": "MyVpcNatRoleF1616EE9" + } + ] + } + }, + "MyVpcPublicSubnet1NatInstance8E94E5F7": { + "Type": "AWS::EC2::Instance", + "Properties": { + "AvailabilityZone": "test-region-1a", + "IamInstanceProfile": { + "Ref": "MyVpcPublicSubnet1NatInstanceInstanceProfile2FD934CB" + }, + "ImageId": "ami-1234", + "InstanceType": "t3.small", + "SecurityGroupIds": [ + { + "Fn::GetAtt": [ + "MyVpcNatSecurityGroupAA76397E", + "GroupId" + ] + } + ], + "SourceDestCheck": false, + "SubnetId": { + "Ref": "MyVpcPublicSubnet1SubnetF6608456" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-vpc-nat-instances/MyVpc/PublicSubnet1/NatInstance" + } + ], + "UserData": { + "Fn::Base64": "#!/bin/bash" + } + }, + "DependsOn": [ + "MyVpcNatRoleF1616EE9" + ] + }, + "MyVpcPublicSubnet2Subnet492B6BFB": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.32.0/19", + "VpcId": { + "Ref": "MyVpcF9F0CA6F" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-vpc-nat-instances/MyVpc/PublicSubnet2" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + } + ] + } + }, + "MyVpcPublicSubnet2RouteTable1DF17386": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "MyVpcF9F0CA6F" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-vpc-nat-instances/MyVpc/PublicSubnet2" + } + ] + } + }, + "MyVpcPublicSubnet2RouteTableAssociation227DE78D": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "MyVpcPublicSubnet2RouteTable1DF17386" + }, + "SubnetId": { + "Ref": "MyVpcPublicSubnet2Subnet492B6BFB" + } + } + }, + "MyVpcPublicSubnet2DefaultRoute052936F6": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "MyVpcPublicSubnet2RouteTable1DF17386" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "MyVpcIGW5C4A4F63" + } + }, + "DependsOn": [ + "MyVpcVPCGW488ACE0D" + ] + }, + "MyVpcPublicSubnet2NatInstanceInstanceProfile5AB09EF6": { + "Type": "AWS::IAM::InstanceProfile", + "Properties": { + "Roles": [ + { + "Ref": "MyVpcNatRoleF1616EE9" + } + ] + } + }, + "MyVpcPublicSubnet2NatInstance04BCE4E3": { + "Type": "AWS::EC2::Instance", + "Properties": { + "AvailabilityZone": "test-region-1b", + "IamInstanceProfile": { + "Ref": "MyVpcPublicSubnet2NatInstanceInstanceProfile5AB09EF6" + }, + "ImageId": "ami-1234", + "InstanceType": "t3.small", + "SecurityGroupIds": [ + { + "Fn::GetAtt": [ + "MyVpcNatSecurityGroupAA76397E", + "GroupId" + ] + } + ], + "SourceDestCheck": false, + "SubnetId": { + "Ref": "MyVpcPublicSubnet2Subnet492B6BFB" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-vpc-nat-instances/MyVpc/PublicSubnet2/NatInstance" + } + ], + "UserData": { + "Fn::Base64": "#!/bin/bash" + } + }, + "DependsOn": [ + "MyVpcNatRoleF1616EE9" + ] + }, + "MyVpcPublicSubnet3Subnet57EEE236": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.64.0/19", + "VpcId": { + "Ref": "MyVpcF9F0CA6F" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-vpc-nat-instances/MyVpc/PublicSubnet3" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + } + ] + } + }, + "MyVpcPublicSubnet3RouteTable15028F08": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "MyVpcF9F0CA6F" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-vpc-nat-instances/MyVpc/PublicSubnet3" + } + ] + } + }, + "MyVpcPublicSubnet3RouteTableAssociation5C27DDA4": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "MyVpcPublicSubnet3RouteTable15028F08" + }, + "SubnetId": { + "Ref": "MyVpcPublicSubnet3Subnet57EEE236" + } + } + }, + "MyVpcPublicSubnet3DefaultRoute3A83AB36": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "MyVpcPublicSubnet3RouteTable15028F08" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "MyVpcIGW5C4A4F63" + } + }, + "DependsOn": [ + "MyVpcVPCGW488ACE0D" + ] + }, + "MyVpcPrivateSubnet1Subnet5057CF7E": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.96.0/19", + "VpcId": { + "Ref": "MyVpcF9F0CA6F" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-vpc-nat-instances/MyVpc/PrivateSubnet1" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + } + ] + } + }, + "MyVpcPrivateSubnet1RouteTable8819E6E2": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "MyVpcF9F0CA6F" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-vpc-nat-instances/MyVpc/PrivateSubnet1" + } + ] + } + }, + "MyVpcPrivateSubnet1RouteTableAssociation56D38C7E": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "MyVpcPrivateSubnet1RouteTable8819E6E2" + }, + "SubnetId": { + "Ref": "MyVpcPrivateSubnet1Subnet5057CF7E" + } + } + }, + "MyVpcPrivateSubnet1DefaultRouteA8CDE2FA": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "MyVpcPrivateSubnet1RouteTable8819E6E2" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "InstanceId": { + "Ref": "MyVpcPublicSubnet1NatInstance8E94E5F7" + } + } + }, + "MyVpcPrivateSubnet2Subnet0040C983": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.128.0/19", + "VpcId": { + "Ref": "MyVpcF9F0CA6F" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-vpc-nat-instances/MyVpc/PrivateSubnet2" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + } + ] + } + }, + "MyVpcPrivateSubnet2RouteTableCEDCEECE": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "MyVpcF9F0CA6F" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-vpc-nat-instances/MyVpc/PrivateSubnet2" + } + ] + } + }, + "MyVpcPrivateSubnet2RouteTableAssociation86A610DA": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "MyVpcPrivateSubnet2RouteTableCEDCEECE" + }, + "SubnetId": { + "Ref": "MyVpcPrivateSubnet2Subnet0040C983" + } + } + }, + "MyVpcPrivateSubnet2DefaultRoute9CE96294": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "MyVpcPrivateSubnet2RouteTableCEDCEECE" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "InstanceId": { + "Ref": "MyVpcPublicSubnet2NatInstance04BCE4E3" + } + } + }, + "MyVpcPrivateSubnet3Subnet772D6AD7": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.160.0/19", + "VpcId": { + "Ref": "MyVpcF9F0CA6F" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-vpc-nat-instances/MyVpc/PrivateSubnet3" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + } + ] + } + }, + "MyVpcPrivateSubnet3RouteTableB790927C": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "MyVpcF9F0CA6F" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-vpc-nat-instances/MyVpc/PrivateSubnet3" + } + ] + } + }, + "MyVpcPrivateSubnet3RouteTableAssociationD951741C": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "MyVpcPrivateSubnet3RouteTableB790927C" + }, + "SubnetId": { + "Ref": "MyVpcPrivateSubnet3Subnet772D6AD7" + } + } + }, + "MyVpcPrivateSubnet3DefaultRouteEC11C0C5": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "MyVpcPrivateSubnet3RouteTableB790927C" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "InstanceId": { + "Ref": "MyVpcPublicSubnet1NatInstance8E94E5F7" + } + } + }, + "MyVpcIGW5C4A4F63": { + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-vpc-nat-instances/MyVpc" + } + ] + } + }, + "MyVpcVPCGW488ACE0D": { + "Type": "AWS::EC2::VPCGatewayAttachment", + "Properties": { + "VpcId": { + "Ref": "MyVpcF9F0CA6F" + }, + "InternetGatewayId": { + "Ref": "MyVpcIGW5C4A4F63" + } + } + }, + "MyVpcNatSecurityGroupAA76397E": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "Security Group for NAT instances", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "SecurityGroupIngress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "from 0.0.0.0/0:ALL PORTS", + "FromPort": 0, + "IpProtocol": "tcp", + "ToPort": 65535 + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-vpc-nat-instances/MyVpc" + } + ], + "VpcId": { + "Ref": "MyVpcF9F0CA6F" + } + } + }, + "MyVpcNatRoleF1616EE9": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "ec2.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + } + } + } + ], + "Version": "2012-10-17" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-vpc-nat-instances/MyVpc" + } + ] + } + } + } +} diff --git a/packages/@aws-cdk/aws-ec2/test/integ.nat-instances.lit.ts b/packages/@aws-cdk/aws-ec2/test/integ.nat-instances.lit.ts new file mode 100644 index 0000000000000..af676d5a53931 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/integ.nat-instances.lit.ts @@ -0,0 +1,31 @@ +import cdk = require('@aws-cdk/core'); +import ec2 = require('../lib'); + +class NatInstanceStack extends cdk.Stack { + constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + /// !show + // Configure the `natGatewayProvider` when defining a Vpc + const vpc = new ec2.Vpc(this, 'MyVpc', { + natGatewayProvider: ec2.NatProvider.instance({ + instanceType: new ec2.InstanceType('t3.small') + }), + + // The 'natGateways' parameter now controls the number of NAT instances + natGateways: 2, + }); + /// !hide + + Array.isArray(vpc); + } +} + +const app = new cdk.App(); +new NatInstanceStack(app, 'aws-cdk-vpc-nat-instances', { + env: { + account: process.env.CDK_INTEG_ACCOUNT || process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_INTEG_REGION || process.env.CDK_DEFAULT_REGION, + } +}); +app.synth(); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ec2/test/test.machine-image.ts b/packages/@aws-cdk/aws-ec2/test/test.machine-image.ts index 85d3d9dfac8b7..5f6b57f8335e8 100644 --- a/packages/@aws-cdk/aws-ec2/test/test.machine-image.ts +++ b/packages/@aws-cdk/aws-ec2/test/test.machine-image.ts @@ -1,6 +1,7 @@ -import { Stack } from '@aws-cdk/core'; +import { App, Stack } from '@aws-cdk/core'; import { Test } from 'nodeunit'; import ec2 = require('../lib'); +import { LookupMachineImage } from '../lib'; export = { 'can make and use a Windows image'(test: Test) { @@ -43,4 +44,36 @@ export = { test.done(); }, + + 'LookupMachineImage default search'(test: Test) { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'Stack', { + env: { account: '1234', region: 'testregion' } + }); + + // WHEN + new LookupMachineImage({ name: 'bla*', owners: ['amazon'] }).getImage(stack); + + // THEN + const missing = app.synth().manifest.missing || []; + test.deepEqual(missing, [ + { + key: 'ami:account=1234:filters.image-type.0=machine:filters.name.0=bla*:filters.state.0=available:owners.0=amazon:region=testregion', + props: { + account: '1234', + region: 'testregion', + owners: [ 'amazon' ], + filters: { + 'name': [ 'bla*' ], + 'state': [ 'available' ], + 'image-type': [ 'machine' ] + } + }, + provider: 'ami' + } + ]); + + test.done(); + } }; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ec2/test/test.vpc.ts b/packages/@aws-cdk/aws-ec2/test/test.vpc.ts index 28d2f3017ae9c..72c8c9ad74eeb 100644 --- a/packages/@aws-cdk/aws-ec2/test/test.vpc.ts +++ b/packages/@aws-cdk/aws-ec2/test/test.vpc.ts @@ -1,8 +1,8 @@ import { countResources, expect, haveResource, haveResourceLike, isSuperObject, MatchStyle } from '@aws-cdk/assert'; import { CfnOutput, Lazy, Stack, Tag } from '@aws-cdk/core'; import { Test } from 'nodeunit'; -import { AclCidr, AclTraffic, CfnSubnet, CfnVPC, DefaultInstanceTenancy, NetworkAcl, NetworkAclEntry, - PrivateSubnet, Subnet, SubnetType, TrafficDirection, Vpc } from '../lib'; +import { AclCidr, AclTraffic, CfnSubnet, CfnVPC, DefaultInstanceTenancy, GenericLinuxImage, InstanceType, + NatProvider, NetworkAcl, NetworkAclEntry, PrivateSubnet, Subnet, SubnetType, TrafficDirection, Vpc } from '../lib'; export = { "When creating a VPC": { @@ -620,6 +620,59 @@ export = { }, }, + 'NAT instances': { + 'Can configure NAT instances instead of NAT gateways'(test: Test) { + // GIVEN + const stack = getTestStack(); + + // WHEN + new Vpc(stack, 'TheVPC', { + natGatewayProvider: NatProvider.instance({ + instanceType: new InstanceType('q86.mega'), + machineImage: new GenericLinuxImage({ + 'us-east-1': 'ami-1' + }) + }) + }); + + // THEN + expect(stack).to(countResources('AWS::EC2::Instance', 3)); + expect(stack).to(haveResource('AWS::EC2::Instance', { + ImageId: "ami-1", + InstanceType: "q86.mega", + SourceDestCheck: false, + })); + expect(stack).to(haveResource('AWS::EC2::Route', { + RouteTableId: { Ref: "TheVPCPrivateSubnet1RouteTableF6513BC2" }, + DestinationCidrBlock: "0.0.0.0/0", + InstanceId: { Ref: "TheVPCPublicSubnet1NatInstanceCC514192" } + })); + + test.done(); + }, + + 'natGateways controls amount of NAT instances'(test: Test) { + // GIVEN + const stack = getTestStack(); + + // WHEN + new Vpc(stack, 'TheVPC', { + natGatewayProvider: NatProvider.instance({ + instanceType: new InstanceType('q86.mega'), + machineImage: new GenericLinuxImage({ + 'us-east-1': 'ami-1' + }) + }), + natGateways: 1 + }); + + // THEN + expect(stack).to(countResources('AWS::EC2::Instance', 1)); + + test.done(); + }, + }, + 'Network ACL association': { 'by default uses default ACL reference'(test: Test) { // GIVEN diff --git a/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts b/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts index eb0355e831f0a..01779b2413ce0 100644 --- a/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts +++ b/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts @@ -215,7 +215,9 @@ export class CloudAssemblyBuilder { * @param missing Missing context information. */ public addMissing(missing: MissingContext) { - this.missing.push(missing); + if (this.missing.every(m => m.key !== missing.key)) { + this.missing.push(missing); + } } /** diff --git a/packages/@aws-cdk/cx-api/lib/context/ami.ts b/packages/@aws-cdk/cx-api/lib/context/ami.ts new file mode 100644 index 0000000000000..c71be19f7b064 --- /dev/null +++ b/packages/@aws-cdk/cx-api/lib/context/ami.ts @@ -0,0 +1,23 @@ +export const AMI_PROVIDER = "ami"; + +/** + * Query to AMI context provider + */ +export interface AmiContextQuery { + /** + * Owners to DescribeImages call + * + * @default - All owners + */ + readonly owners?: string[]; + + /** + * Filters to DescribeImages call + */ + readonly filters: {[key: string]: string[]}; +} + +/** + * Returns just an AMI ID + */ +export type AmiContextResponse = string; \ No newline at end of file diff --git a/packages/@aws-cdk/cx-api/lib/index.ts b/packages/@aws-cdk/cx-api/lib/index.ts index 7dbe4040dc3f8..0b15109012855 100644 --- a/packages/@aws-cdk/cx-api/lib/index.ts +++ b/packages/@aws-cdk/cx-api/lib/index.ts @@ -2,6 +2,7 @@ export * from './cxapi'; export * from './context/hosted-zone'; export * from './context/vpc'; export * from './context/ssm-parameter'; +export * from './context/ami'; export * from './context/availability-zones'; export * from './cloud-artifact'; export * from './cloudformation-artifact'; diff --git a/packages/@aws-cdk/cx-api/lib/versioning.ts b/packages/@aws-cdk/cx-api/lib/versioning.ts index 17dc186fae9a4..090798c17c55a 100644 --- a/packages/@aws-cdk/cx-api/lib/versioning.ts +++ b/packages/@aws-cdk/cx-api/lib/versioning.ts @@ -65,7 +65,9 @@ export function upgradeAssemblyManifest(manifest: AssemblyManifest): AssemblyMan } if (manifest.version === '1.10.0') { - // backwards-compatible changes to the VPC provider + // Two changes: + // * Backwards-compatible changes to the VPC provider + // * Added AMI context provider: old assemblies won't reference it. manifest = justUpgradeVersion(manifest, '1.16.0'); } diff --git a/packages/@aws-cdk/cx-api/test/cloud-assembly-builder.test.ts b/packages/@aws-cdk/cx-api/test/cloud-assembly-builder.test.ts index c69de189bfa79..ff4b01c0ff624 100644 --- a/packages/@aws-cdk/cx-api/test/cloud-assembly-builder.test.ts +++ b/packages/@aws-cdk/cx-api/test/cloud-assembly-builder.test.ts @@ -110,3 +110,15 @@ test('cloud assembly builder', () => { test('outdir must be a directory', () => { expect(() => new CloudAssemblyBuilder(__filename)).toThrow('must be a directory'); }); + +test('duplicate missing values with the same key are only reported once', () => { + const outdir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloud-assembly-builder-tests')); + const session = new CloudAssemblyBuilder(outdir); + + session.addMissing({ key: 'foo', provider: 'context-provider', props: { } }); + session.addMissing({ key: 'foo', provider: 'context-provider', props: { } }); + + const assembly = session.buildAssembly(); + + expect(assembly.manifest.missing!.length).toEqual(1); +}); \ No newline at end of file diff --git a/packages/aws-cdk/lib/context-providers/ami.ts b/packages/aws-cdk/lib/context-providers/ami.ts new file mode 100644 index 0000000000000..b9c7f0caae2d2 --- /dev/null +++ b/packages/aws-cdk/lib/context-providers/ami.ts @@ -0,0 +1,54 @@ +import cxapi = require('@aws-cdk/cx-api'); +import { ISDK, Mode } from '../api'; +import { debug, print } from '../logging'; +import { ContextProviderPlugin } from './provider'; + +/** + * Plugin to search AMIs for the current account + */ +export class AmiContextProviderPlugin implements ContextProviderPlugin { + constructor(private readonly aws: ISDK) { + } + + public async getValue(args: cxapi.AmiContextQuery & { region: string, account: string }) { + const region = args.region; + const account = args.account; + + // Normally we'd do this only as 'debug', but searching AMIs typically takes dozens + // of seconds, so be little more verbose about it so users know what is going on. + print(`Searching for AMI in ${account}:${region}`); + debug(`AMI search parameters: ${JSON.stringify(args)}`); + + const ec2 = await this.aws.ec2(account, region, Mode.ForReading); + const response = await ec2.describeImages({ + Owners: args.owners, + Filters: Object.entries(args.filters).map(([key, values]) => ({ + Name: key, + Values: values + })) + }).promise(); + + const images = [...response.Images || []].filter(i => i.ImageId !== undefined); + + if (images.length === 0) { + throw new Error(`No AMI found that matched the search criteria`); + } + + // Return the most recent one + // Note: Date.parse() is not going to respect the timezone of the string, + // but since we only care about the relative values that is okay. + images.sort(descending(i => Date.parse(i.CreationDate || '1970'))); + + debug(`Selected image '${images[0].ImageId}' created at '${images[0].CreationDate}'`); + return images[0].ImageId!; + } +} + +/** + * Make a comparator that sorts in descending order given a sort key extractor + */ +function descending(valueOf: (x: A) => number) { + return (a: A, b: A) => { + return valueOf(b) - valueOf(a); + }; +} diff --git a/packages/aws-cdk/lib/context-providers/index.ts b/packages/aws-cdk/lib/context-providers/index.ts index 708ad476d0e89..84c68a2a2097f 100644 --- a/packages/aws-cdk/lib/context-providers/index.ts +++ b/packages/aws-cdk/lib/context-providers/index.ts @@ -2,6 +2,7 @@ import cxapi = require('@aws-cdk/cx-api'); import { ISDK } from '../api/util/sdk'; import { debug } from '../logging'; import { Context, TRANSIENT_CONTEXT_KEY } from '../settings'; +import { AmiContextProviderPlugin } from './ami'; import { AZContextProviderPlugin } from './availability-zones'; import { HostedZoneContextProviderPlugin } from './hosted-zones'; import { ContextProviderPlugin } from './provider'; @@ -56,4 +57,5 @@ const availableContextProviders: ProviderMap = { [cxapi.SSM_PARAMETER_PROVIDER]: SSMContextProviderPlugin, [cxapi.HOSTED_ZONE_PROVIDER]: HostedZoneContextProviderPlugin, [cxapi.VPC_PROVIDER]: VpcNetworkContextProviderPlugin, + [cxapi.AMI_PROVIDER]: AmiContextProviderPlugin, }; diff --git a/packages/aws-cdk/test/context-providers/test.amis.ts b/packages/aws-cdk/test/context-providers/test.amis.ts new file mode 100644 index 0000000000000..4c1ec906d5626 --- /dev/null +++ b/packages/aws-cdk/test/context-providers/test.amis.ts @@ -0,0 +1,83 @@ +import aws = require('aws-sdk'); +import AWS = require('aws-sdk-mock'); +import nodeunit = require('nodeunit'); +import { ISDK } from '../../lib/api'; +import { AmiContextProviderPlugin } from '../../lib/context-providers/ami'; + +AWS.setSDKInstance(aws); + +const mockSDK: ISDK = { + defaultAccount: () => Promise.resolve('123456789012'), + defaultRegion: () => Promise.resolve('bermuda-triangle-1337'), + cloudFormation: () => { throw new Error('Not Mocked'); }, + ec2: () => Promise.resolve(new aws.EC2()), + ecr: () => { throw new Error('Not Mocked'); }, + route53: () => { throw new Error('Not Mocked'); }, + s3: () => { throw new Error('Not Mocked'); }, + ssm: () => { throw new Error('Not Mocked'); }, +}; + +type AwsCallback = (err: Error | null, val: T) => void; + +export = nodeunit.testCase({ + async 'calls DescribeImages on the request'(test: nodeunit.Test) { + // GIVEN + let request: aws.EC2.DescribeImagesRequest; + AWS.mock('EC2', 'describeImages', (params: aws.EC2.DescribeImagesRequest, cb: AwsCallback) => { + request = params; + return cb(null, { Images: [{ ImageId: 'ami-1234' }] }); + }); + + // WHEN + await new AmiContextProviderPlugin(mockSDK).getValue({ + account: '1234', + region: 'asdf', + owners: ['some-owner'], + filters: { + 'some-filter': ['filtered'] + } + }); + + // THEN + test.deepEqual(request!, { + Owners: ['some-owner'], + Filters: [ + { + Name: 'some-filter', + Values: ['filtered'], + } + ] + } as aws.EC2.DescribeImagesRequest); + + AWS.restore(); + test.done(); + }, + async 'returns the most recent AMI matching the criteria'(test: nodeunit.Test) { + // GIVEN + AWS.mock('EC2', 'describeImages', (_: aws.EC2.DescribeImagesRequest, cb: AwsCallback) => { + return cb(null, { Images: [ + { + ImageId: 'ami-1234', + CreationDate: "2016-06-22T08:39:59.000Z", + }, + { + ImageId: 'ami-5678', + CreationDate: "2019-06-22T08:39:59.000Z", + } + ]}); + }); + + // WHEN + const result = await new AmiContextProviderPlugin(mockSDK).getValue({ + account: '1234', + region: 'asdf', + filters: {} + }); + + // THEN + test.equals(result, 'ami-5678'); + + AWS.restore(); + test.done(); + } +}); \ No newline at end of file diff --git a/tools/cdk-integ-tools/lib/integ-helpers.ts b/tools/cdk-integ-tools/lib/integ-helpers.ts index 2d53c07814a86..b368172fb9473 100644 --- a/tools/cdk-integ-tools/lib/integ-helpers.ts +++ b/tools/cdk-integ-tools/lib/integ-helpers.ts @@ -183,6 +183,8 @@ export const DEFAULT_SYNTH_OPTIONS = { "ssm:account=12345678:parameterName=/aws/service/ami-amazon-linux-latest/amzn-ami-hvm-x86_64-gp2:region=test-region": "ami-1234", "ssm:account=12345678:parameterName=/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2:region=test-region": "ami-1234", "ssm:account=12345678:parameterName=/aws/service/ecs/optimized-ami/amazon-linux/recommended:region=test-region": "{\"image_id\": \"ami-1234\"}", + // tslint:disable-next-line:max-line-length + "ami:account=12345678:filters.image-type.0=machine:filters.name.0=amzn-ami-vpc-nat-*:filters.state.0=available:owners.0=amazon:region=test-region": "ami-1234", "vpc-provider:account=12345678:filter.isDefault=true:region=test-region:returnAsymmetricSubnets=true": { vpcId: "vpc-60900905", subnetGroups: [