From 5f7718b1d23cff6c9415da9e8a847b0eab9744d7 Mon Sep 17 00:00:00 2001 From: Daniel Neilson Date: Mon, 4 Jan 2021 15:34:33 +0000 Subject: [PATCH 1/7] feat(ec2): implementation of L2 construct for Launch Templates This provides an initial implementation of a level 2 construct for EC2 Launch Templates. --- packages/@aws-cdk/aws-ec2/README.md | 21 + packages/@aws-cdk/aws-ec2/lib/index.ts | 1 + packages/@aws-cdk/aws-ec2/lib/instance.ts | 5 +- .../@aws-cdk/aws-ec2/lib/launch-template.ts | 688 ++++++++++++++++ .../@aws-cdk/aws-ec2/lib/private/ebs-util.ts | 38 + packages/@aws-cdk/aws-ec2/lib/volume.ts | 35 +- .../aws-ec2/test/launch-template.test.ts | 750 ++++++++++++++++++ 7 files changed, 1503 insertions(+), 35 deletions(-) create mode 100644 packages/@aws-cdk/aws-ec2/lib/launch-template.ts create mode 100644 packages/@aws-cdk/aws-ec2/lib/private/ebs-util.ts create mode 100644 packages/@aws-cdk/aws-ec2/test/launch-template.test.ts diff --git a/packages/@aws-cdk/aws-ec2/README.md b/packages/@aws-cdk/aws-ec2/README.md index b8a583d1a2ea5..66131ecfa7338 100644 --- a/packages/@aws-cdk/aws-ec2/README.md +++ b/packages/@aws-cdk/aws-ec2/README.md @@ -964,3 +964,24 @@ const subnet = Subnet.fromSubnetAttributes(this, 'SubnetFromAttributes', { // Supply only subnet id const subnet = Subnet.fromSubnetId(this, 'SubnetFromId', 's-1234'); ``` + +## Launch Templates + +A Launch Template is a standardized template that contains the configuration information to launch an instance. +They can be used when launching instances on their own, through Amazon EC2 Auto Scaling, EC2 Fleet, and Spot Fleet. +Launch templates enable you to store launch parameters so that you do not have to specify them every time you launch +an instance. For information on Launch Templates please see the +[official documentation](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-launch-templates.html). + +The following demonstrates how to create a launch template with an Amazon Machine Image, IAM Role, and security group. + +```ts +const vpc = new ec2.Vpc(...); +// ... +const template = new ec2.LaunchTemplate(this, 'LaunchTemplate', { + machineImage: new ec2.AmazonMachineImage(), + securityGroup: new ec2.SecurityGroup(this, 'LaunchTemplateSG', { + vpc: vpc, + }), +}); +``` diff --git a/packages/@aws-cdk/aws-ec2/lib/index.ts b/packages/@aws-cdk/aws-ec2/lib/index.ts index ca25a02f3f8d1..9f70a4320060d 100644 --- a/packages/@aws-cdk/aws-ec2/lib/index.ts +++ b/packages/@aws-cdk/aws-ec2/lib/index.ts @@ -4,6 +4,7 @@ export * from './cfn-init'; export * from './cfn-init-elements'; export * from './instance-types'; export * from './instance'; +export * from './launch-template'; export * from './machine-image'; export * from './nat'; export * from './network-acl'; diff --git a/packages/@aws-cdk/aws-ec2/lib/instance.ts b/packages/@aws-cdk/aws-ec2/lib/instance.ts index 22c4fa7cf880f..82fbb22bea4e3 100644 --- a/packages/@aws-cdk/aws-ec2/lib/instance.ts +++ b/packages/@aws-cdk/aws-ec2/lib/instance.ts @@ -8,9 +8,10 @@ import { Connections, IConnectable } from './connections'; import { CfnInstance } from './ec2.generated'; import { InstanceType } from './instance-types'; import { IMachineImage, OperatingSystemType } from './machine-image'; +import { instanceBlockDeviceMappings } from './private/ebs-util'; import { ISecurityGroup, SecurityGroup } from './security-group'; import { UserData } from './user-data'; -import { BlockDevice, synthesizeBlockDeviceMappings } from './volume'; +import { BlockDevice } from './volume'; import { IVpc, Subnet, SubnetSelection } from './vpc'; /** @@ -362,7 +363,7 @@ export class Instance extends Resource implements IInstance { subnetId: subnet.subnetId, availabilityZone: subnet.availabilityZone, sourceDestCheck: props.sourceDestCheck, - blockDeviceMappings: props.blockDevices !== undefined ? synthesizeBlockDeviceMappings(this, props.blockDevices) : undefined, + blockDeviceMappings: props.blockDevices !== undefined ? instanceBlockDeviceMappings(this, props.blockDevices) : undefined, privateIpAddress: props.privateIpAddress, }); this.instance.node.addDependency(this.role); diff --git a/packages/@aws-cdk/aws-ec2/lib/launch-template.ts b/packages/@aws-cdk/aws-ec2/lib/launch-template.ts new file mode 100644 index 0000000000000..db3b9d903eff2 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/lib/launch-template.ts @@ -0,0 +1,688 @@ +import * as iam from '@aws-cdk/aws-iam'; + +import { + Annotations, + CfnTag, + Duration, + Expiration, + Fn, + IResource, + Lazy, + Resource, + Tags, + Token, +} from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { Connections, IConnectable } from './connections'; +import { CfnLaunchTemplate } from './ec2.generated'; +import { InstanceType } from './instance-types'; +import { IMachineImage } from './machine-image'; +import { launchTemplateBlockDeviceMappings } from './private/ebs-util'; +import { ISecurityGroup } from './security-group'; +import { UserData } from './user-data'; +import { BlockDevice } from './volume'; + +/** + * Name tag constant + */ +const NAME_TAG: string = 'Name'; + +/** + * Provides the options for specifying the CPU credit type for burstable EC2 instance types (T2, T3, T3a, etc). + * + * @see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/burstable-performance-instances-how-to.html + */ +// dev-note: This could be used in the Instance L2 +export enum CpuCredits { + /** + * Standard bursting mode. + * @see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/burstable-performance-instances-standard-mode.html + */ + STANDARD = 'standard', + + /** + * Unlimited bursting mode. + * @see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/burstable-performance-instances-unlimited-mode.html + */ + UNLIMITED = 'unlimited', +}; + +/** + * Provides the options for specifying the instance initiated shutdown behavior. + * + * @see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/terminating-instances.html#Using_ChangingInstanceInitiatedShutdownBehavior + */ +// dev-note: This could be used in the Instance L2 +export enum InstanceInitiatedShutdownBehavior { + /** + * The instance will stop when it initiates a shutdown. + */ + STOP = 'stop', + + /** + * The instance will be terminated when it initiates a shutdown. + */ + TERMINATE = 'terminate', +}; + +/** + * Interface for LaunchTemplate-like objects. + */ +export interface ILaunchTemplate extends IResource { + /** + * The version number of this launch template to use + * + * @attribute + */ + readonly versionNumber: string; + + /** + * The identifier of the Launch Template + * + * Exactly one of `launchTemplateId` and `launchTemplateName` will be set. + * + * @attribute + */ + readonly launchTemplateId?: string; + + /** + * The name of the Launch Template + * + * Exactly one of `launchTemplateId` and `launchTemplateName` will be set. + * + * @attribute + */ + readonly launchTemplateName?: string; +} + +/** + * Provides the options for the types of interruption for spot instances. + */ +// dev-note: This could be used in a SpotFleet L2 if one gets developed. +export enum SpotInstanceInterruption { + /** + * The instance will stop when interrupted. + */ + STOP = 'stop', + + /** + * The instance will be terminated when interrupted. + */ + TERMINATE = 'terminate', + + /** + * The instance will hibernate when interrupted. + */ + HIBERNATE = 'hibernate', +} + +/** + * The Spot Instance request type. + * + * @see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/spot-requests.html + */ +export enum SpotRequestType { + /** + * A one-time Spot Instance request remains active until Amazon EC2 launches the Spot Instance, + * the request expires, or you cancel the request. If the Spot price exceeds your maximum price + * or capacity is not available, your Spot Instance is terminated and the Spot Instance request + * is closed. + */ + ONE_TIME = 'one-time', + + /** + * A persistent Spot Instance request remains active until it expires or you cancel it, even if + * the request is fulfilled. If the Spot price exceeds your maximum price or capacity is not available, + * your Spot Instance is interrupted. After your instance is interrupted, when your maximum price exceeds + * the Spot price or capacity becomes available again, the Spot Instance is started if stopped or resumed + * if hibernated. + */ + PERSISTENT = 'persistent', +} + +/** + * Interface for the Spot market instance options provided in a LaunchTemplate. + */ +export interface LaunchTemplateSpotOptions { + /** + * Spot Instances with a defined duration (also known as Spot blocks) are designed not to be interrupted and will run continuously for the duration you select. + * You can use a duration of 1, 2, 3, 4, 5, or 6 hours. + * + * @see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/spot-requests.html#fixed-duration-spot-instances + * + * @default Requested spot instances do not have a pre-defined duration. + */ + readonly blockDuration?: Duration; + + /** + * The behavior when a Spot Instance is interrupted. + * + * @default Spot instances will terminate when interrupted. + */ + readonly interruptionBehavior?: SpotInstanceInterruption; + + /** + * Maximum hourly price you're willing to pay for each Spot instance. The value is given + * in dollars. ex: 0.01 for 1 cent per hour, or 0.001 for one-tenth of a cent per hour. + * + * @default Maximum hourly price will default to the on-demand price for the instance type. + */ + readonly maxPrice?: number; + + /** + * The Spot Instance request type. + * + * If you are using Spot Instances with an Auto Scaling group, use one-time requests, as the + * Amazon EC2 Auto Scaling service handles requesting new Spot Instances whenever the group is + * below its desired capacity. + * + * @default One-time spot request. + */ + readonly requestType?: SpotRequestType; + + /** + * The end date of the request. For a one-time request, the request remains active until all instances + * launch, the request is canceled, or this date is reached. If the request is persistent, it remains + * active until it is canceled or this date and time is reached. + * + * @default The default end date is 7 days from the current date. + */ + readonly validUntil?: Expiration; +}; + +/** + * The types of resource tags that can be set on a launch template. + */ +export enum LaunchTemplateTagResourceType { + // dev-note: Full list at https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-launchtemplate-tagspecification.html#cfn-ec2-launchtemplate-tagspecification-resourcetype + // These are the only ones from the list that presently support tag on create. + + /** + * EC2 Instances. + */ + INSTANCE = 'instance', + + /** + * EBS Volumes. + */ + VOLUME = 'volume', +} + +/** + * Interface for defining the tag specification of a LaunchTemplate. + */ +export interface LaunchTemplateTagSpecification { + /** + * The types of resources that are tagged with the given tags. Note that there are + * restrictions regarding which resources can be tagged when created. See the following + * for additional information. + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-launchtemplate-tagspecification.html#cfn-ec2-launchtemplate-tagspecification-resourcetype + */ + readonly resourceTypes: LaunchTemplateTagResourceType[]; + + /** + * The tags to apply to the resources. + */ + readonly tags: CfnTag[]; +} + +/** + * Properties of a LaunchTemplate. + */ +export interface LaunchTemplateProps { + /** + * Name for this launch template. + * + * @default Automatically generated name + */ + readonly launchTemplateName?: string; + + /** + * Type of instance to launch. + * + * @default - This Launch Template does not specify a default Instance Type. + */ + readonly instanceType?: InstanceType; + + /** + * The AMI that will be used by instances. + * + * @default - This Launch Template does not specify a default AMI. + */ + readonly machineImage?: IMachineImage; + + /** + * The AMI that will be used by instances. + * + * @default - This Launch Template creates a UserData based on the type of provided machineImage. + */ + readonly userData?: UserData; + + /** + * An IAM role to associate with the instance profile that is used by instances. + * + * The role must be assumable by the service principal `ec2.amazonaws.com`: + * + * @example + * const role = new iam.Role(this, 'MyRole', { + * assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com') + * }); + * + * @default - A new Role is created. + */ + readonly role?: iam.IRole; + + /** + * Specifies how block devices are exposed to the instance. You can specify virtual devices and EBS volumes. + * + * Each instance that is launched has an associated root device volume, + * either an Amazon EBS volume or an instance store volume. + * You can use block device mappings to specify additional EBS volumes or + * instance store volumes to attach to an instance when it is launched. + * + * @see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/block-device-mapping-concepts.html + * + * @default - Uses the block device mapping of the AMI + */ + readonly blockDevices?: BlockDevice[]; + + /** + * CPU credit type for burstable EC2 instance types. + * + * @see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/burstable-performance-instances.html + * + * @default - No credit type is specified in the Launch Template. + */ + readonly cpuCredits?: CpuCredits; + + /** + * If you set this parameter to true, you cannot terminate the instances launched with this launch template + * using the Amazon EC2 console, CLI, or API; otherwise, you can. + * + * @default - The API termination setting is not specified in the Launch Template. + */ + readonly disableApiTermination?: boolean; + + /** + * Indicates whether the instances are optimized for Amazon EBS I/O. This optimization provides dedicated throughput + * to Amazon EBS and an optimized configuration stack to provide optimal Amazon EBS I/O performance. This optimization + * isn't available with all instance types. Additional usage charges apply when using an EBS-optimized instance. + * + * @default - EBS optimization is not specified in the launch template. + */ + readonly ebsOptimized?: boolean; + + /** + * If this parameter is set to true, the instance is enabled for AWS Nitro Enclaves; otherwise, it is not enabled for AWS Nitro Enclaves. + * + * @default - Enablement of Nitro enclaves is not specified in the launch template; defaulting to false. + */ + readonly nitroEnclaveEnabled?: boolean; + + /** + * If you set this parameter to true, the instance is enabled for hibernation. + * + * @default - Hibernation configuration is not specified in the launch template; defaulting to false. + */ + readonly hibernationConfigured?: boolean; + + /** + * Indicates whether an instance stops or terminates when you initiate shutdown from the instance (using the operating system command for system shutdown). + * + * @see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/terminating-instances.html#Using_ChangingInstanceInitiatedShutdownBehavior + * + * @default - Shutdown behavior is not specified in the launch template; defaults to STOP. + */ + readonly instanceInitiatedShutdownBehavior?: InstanceInitiatedShutdownBehavior; + + /** + * If this property is defined, then the Launch Template's InstanceMarketOptions will be + * set to use Spot instances, and the options for the Spot instances will be as defined. + * + * @default - Instance launched with this template will not be spot instances. + */ + readonly spotOptions?: LaunchTemplateSpotOptions; + + /** + * Name of SSH keypair to grant access to instance + * + * @default - No SSH access will be possible. + */ + readonly keyName?: string; + + /** + * If set to true, then detailed monitoring will be enabled on instances created with this + * launch template. + * + * @see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html + * + * @default False - Detailed monitoring is disabled. + */ + readonly detailedMonitoring?: boolean; + + /** + * Security group to assign to instances created with the launch template. + * + * @default No security group is assigned. + */ + readonly securityGroup?: ISecurityGroup; + + /** + * The tags to apply to the resources during launch. The specified tags are applied to all resources of the given + * types launch. + * Note that not every resource supports tagging during creation. See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-launchtemplate-tagspecification.html#cfn-ec2-launchtemplate-tagspecification-resourcetype + * for additional information on the restrictions. + * + * @default No tags are specified. + */ + readonly tagSpecifications?: LaunchTemplateTagSpecification[]; +} + +/** + * A class that provides convenient access to special version tokens for LaunchTemplate + * versions. + */ +export class LaunchTemplateSpecialVersions { + /** + * The special value that denotes that users of a Launch Template should + * reference the LATEST version of the template. + */ + public static readonly LATEST_VERSION: string = '$Latest'; + + /** + * The special value that denotes that users of a Launch Template should + * reference the DEFAULT version of the template. + */ + public static readonly DEFAULT_VERSION: string = '$Default'; +} + +/** + * Attributes for an imported LaunchTemplate. + */ +export interface LaunchTemplateAttributes { + /** + * The version number of this launch template to use + * + * @default Version: "$Default" + */ + readonly versionNumber?: string; + + /** + * The identifier of the Launch Template + * + * Exactly one of `launchTemplateId` and `launchTemplateName` may be set. + * + * @default None + */ + readonly launchTemplateId?: string; + + /** + * The name of the Launch Template + * + * Exactly one of `launchTemplateId` and `launchTemplateName` may be set. + * + * @default None + */ + readonly launchTemplateName?: string; +} + +/** + * This represents an EC2 LaunchTemplate. + * + * @see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-launch-templates.html + */ +export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGrantable, IConnectable { + /** + * Import an existing LaunchTemplate. + */ + public static fromLaunchTemplateAttributes(scope: Construct, id: string, attrs: LaunchTemplateAttributes): ILaunchTemplate { + const haveId = Boolean(attrs.launchTemplateId); + const haveName = Boolean(attrs.launchTemplateName); + if (haveId == haveName) { + throw new Error('LaunchTemplate.fromLaunchTemplateAttributes() requires exactly one of launchTemplateId or launchTemplateName be provided.'); + } + + class Import extends Resource implements ILaunchTemplate { + public readonly versionNumber = attrs.versionNumber ?? LaunchTemplateSpecialVersions.DEFAULT_VERSION; + public readonly launchTemplateId? = attrs.launchTemplateId; + public readonly launchTemplateName? = attrs.launchTemplateName; + } + return new Import(scope, id); + } + + // ============================================ + // Members for ILaunchTemplate interface + /** + * @inheritdoc + */ + public readonly versionNumber: string; + + /** + * @inheritdoc + */ + public readonly launchTemplateId?: string; + + /** + * @inheritdoc + */ + public readonly launchTemplateName?: string; + + // ============================================= + // Data members + + /** + * The default version for the launch template. + * + * @attribute + */ + public readonly defaultVersionNumber: string; + + /** + * The latest version of the launch template. + * + * @attribute + */ + public readonly latestVersionNumber: string; + + /** + * IAM Role assumed by instances that are launched from this template. + */ + public readonly role: iam.IRole; + + /** + * Principal to grant permissions to. + */ + public readonly grantPrincipal: iam.IPrincipal; + + /** + * Allows specifying security group connections for the instance. + * + * Note: If the LaunchTemplate was not created with a SecurityGroup, then this connections + * object will be empty and operations on it will do nothing. + */ + public readonly connections: Connections; + + /** + * UserData executed by instances that are launched from this template. + */ + public readonly userData?: UserData; + + + protected tagSpecifications: LaunchTemplateTagSpecification[]; + + // ============================================= + + constructor(scope: Construct, id: string, props?: LaunchTemplateProps) { + super(scope, id); + + // Basic validation of the provided spot block duration + const spotDuration = props?.spotOptions?.blockDuration?.toHours({ integral: true }); + if (spotDuration !== undefined && (spotDuration < 1 || spotDuration > 6)) { + // See: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/spot-requests.html#fixed-duration-spot-instances + Annotations.of(this).addError('Spot block duration must be exactly 1, 2, 3, 4, 5, or 6 hours.'); + } + + this.role = props?.role ?? new iam.Role(this, 'Role', { + assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'), + }); + this.grantPrincipal = this.role; + const iamProfile = new iam.CfnInstanceProfile(this, 'Profile', { + roles: [this.role.roleName], + }); + + if (props?.securityGroup) { + this.connections = new Connections({ securityGroups: [props.securityGroup] }); + } else { + // Note: LaunchTemplates are not associated with a VPC, so we cannot create a + // SecurityGroup by default. So, we preserve the IConnectable interface by + // creating a Connections object but using it when empty will just result in no-ops. + this.connections = new Connections({ securityGroups: [] }); + } + const securityGroupsToken = Lazy.list({ + produce: () => { + if (this.connections.securityGroups.length > 0) { + return this.connections.securityGroups.map(sg => sg.securityGroupId); + } + return undefined; + }, + }); + + this.userData = props?.userData; + if (!this.userData !== undefined && props?.machineImage !== undefined) { + this.userData = props?.machineImage.getImage(this).userData; + } + const userDataToken = this.userData ? + Lazy.string({ produce: () => Fn.base64(this.userData!.render()) }) : + undefined; + + let marketOptions: any = undefined; + if (props?.spotOptions) { + marketOptions = { + marketType: 'spot', + spotOptions: { + blockDurationMinutes: spotDuration !== undefined ? spotDuration * 60 : undefined, + instanceInterruptionBehavior: props.spotOptions.interruptionBehavior, + maxPrice: props.spotOptions.maxPrice?.toString(), + spotInstanceType: props.spotOptions.requestType, + validUntil: props.spotOptions.validUntil?.date.toUTCString(), + }, + }; + // Remove SpotOptions if there are none. + if (Object.keys(marketOptions.spotOptions).filter(k => marketOptions.spotOptions[k]).length == 0) { + marketOptions.spotOptions = undefined; + } + } + + this.tagSpecifications = props?.tagSpecifications ?? []; + const tagSpecificationsToken = Lazy.any({ produce: () => this.convertTagSpecList(this.tagSpecifications) }); + + const resource = new CfnLaunchTemplate(this, 'Resource', { + launchTemplateName: props?.launchTemplateName, + launchTemplateData: { + blockDeviceMappings: props?.blockDevices !== undefined ? launchTemplateBlockDeviceMappings(this, props.blockDevices) : undefined, + creditSpecification: props?.cpuCredits !== undefined ? { + cpuCredits: props.cpuCredits, + } : undefined, + disableApiTermination: props?.disableApiTermination, + ebsOptimized: props?.ebsOptimized, + enclaveOptions: props?.nitroEnclaveEnabled !== undefined ? { + enabled: props.nitroEnclaveEnabled, + } : undefined, + hibernationOptions: props?.hibernationConfigured !== undefined ? { + configured: props.hibernationConfigured, + } : undefined, + iamInstanceProfile: { + arn: iamProfile.getAtt('Arn').toString(), + }, + imageId: props?.machineImage?.getImage(this).imageId, + instanceType: props?.instanceType?.toString(), + instanceInitiatedShutdownBehavior: props?.instanceInitiatedShutdownBehavior, + instanceMarketOptions: marketOptions, + keyName: props?.keyName, + monitoring: props?.detailedMonitoring !== undefined ? { + enabled: props.detailedMonitoring, + } : undefined, + securityGroupIds: securityGroupsToken, + tagSpecifications: tagSpecificationsToken, + userData: userDataToken, + + // Fields not yet implemented: + // ========================== + // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-launchtemplate-launchtemplatedata-capacityreservationspecification.html + // Will require creating an L2 for AWS::EC2::CapacityReservation + // capacityReservationSpecification: undefined, + + // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-launchtemplate-launchtemplatedata-cpuoptions.html + // cpuOptions: undefined, + + // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-launchtemplate-elasticgpuspecification.html + // elasticGpuSpecifications: undefined, + + // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-launchtemplate-launchtemplatedata.html#cfn-ec2-launchtemplate-launchtemplatedata-elasticinferenceaccelerators + // elasticInferenceAccelerators: undefined, + + // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-launchtemplate-launchtemplatedata.html#cfn-ec2-launchtemplate-launchtemplatedata-kernelid + // kernelId: undefined, + // ramDiskId: undefined, + + // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-launchtemplate-launchtemplatedata.html#cfn-ec2-launchtemplate-launchtemplatedata-licensespecifications + // Also not implemented in Instance L2 + // licenseSpecifications: undefined, + + // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-launchtemplate-launchtemplatedata.html#cfn-ec2-launchtemplate-launchtemplatedata-metadataoptions + // metadataOptions: undefined, + + // CDK has no abstraction for Network Interfaces yet. + // networkInterfaces: undefined, + + // CDK has no abstraction for Placement yet. + // placement: undefined, + + }, + }); + + Tags.of(this).add(NAME_TAG, this.node.path); + + this.defaultVersionNumber = resource.attrDefaultVersionNumber; + this.latestVersionNumber = resource.attrLatestVersionNumber; + this.launchTemplateId = resource.ref; + this.versionNumber = Token.asString(resource.getAtt('LatestVersionNumber')); + } + + /** + * Adds a given tag specification to this launch template. + */ + public addTagSpecification(specification: LaunchTemplateTagSpecification) { + this.tagSpecifications.push(specification); + } + + /** + * Convert the tag specification from the input form into the form required of the + * Launch Template L1 construct. + */ + protected convertTagSpecList(tagSpecifications?: LaunchTemplateTagSpecification[]): CfnLaunchTemplate.TagSpecificationProperty[] | undefined { + if (tagSpecifications === undefined) { + return undefined; + } + + let tagMapping: { [key: string]: any } = {}; + tagSpecifications.forEach(spec => { + spec.resourceTypes.forEach(res => { + tagMapping[res] = tagMapping[res] ?? []; + tagMapping[res] = tagMapping[res].concat(spec.tags); + }); + }); + + let result = Object.keys(tagMapping) + .filter(res => tagMapping[res].length > 0) + .map(resourceType => { + return { + resourceType: resourceType, + tags: tagMapping[resourceType], + }; + }); + + return result.length > 0 ? result : undefined; + } +} diff --git a/packages/@aws-cdk/aws-ec2/lib/private/ebs-util.ts b/packages/@aws-cdk/aws-ec2/lib/private/ebs-util.ts new file mode 100644 index 0000000000000..3fb055e9516e1 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/lib/private/ebs-util.ts @@ -0,0 +1,38 @@ +import { Annotations, Construct } from '@aws-cdk/core'; +import { CfnInstance, CfnLaunchTemplate } from '../ec2.generated'; +import { BlockDevice, EbsDeviceVolumeType } from '../volume'; + +export function instanceBlockDeviceMappings(construct: Construct, blockDevices: BlockDevice[]): CfnInstance.BlockDeviceMappingProperty[] { + return synthesizeBlockDeviceMappings(construct, blockDevices, {}); +} + +export function launchTemplateBlockDeviceMappings(construct: Construct, blockDevices: BlockDevice[]): CfnLaunchTemplate.BlockDeviceMappingProperty[] { + return synthesizeBlockDeviceMappings(construct, blockDevices, ''); +} + +/** + * Synthesize an array of block device mappings from a list of block device + * + * @param construct the instance/asg construct, used to host any warning + * @param blockDevices list of block devices + */ +function synthesizeBlockDeviceMappings(construct: Construct, blockDevices: BlockDevice[], noDeviceValue: NDT): RT[] { + return blockDevices.map(({ deviceName, volume, mappingEnabled }): RT => { + const { virtualName, ebsDevice: ebs } = volume; + + if (ebs) { + const { iops, volumeType } = ebs; + + if (!iops) { + if (volumeType === EbsDeviceVolumeType.IO1) { + throw new Error('iops property is required with volumeType: EbsDeviceVolumeType.IO1'); + } + } else if (volumeType !== EbsDeviceVolumeType.IO1) { + Annotations.of(construct).addWarning('iops will be ignored without volumeType: EbsDeviceVolumeType.IO1'); + } + } + + const noDevice = mappingEnabled === false ? noDeviceValue : undefined; + return { deviceName, ebs, virtualName, noDevice } as any; + }); +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ec2/lib/volume.ts b/packages/@aws-cdk/aws-ec2/lib/volume.ts index 3f9c8f3cac0dd..427e2f3375a2c 100644 --- a/packages/@aws-cdk/aws-ec2/lib/volume.ts +++ b/packages/@aws-cdk/aws-ec2/lib/volume.ts @@ -2,9 +2,9 @@ import * as crypto from 'crypto'; import { AccountRootPrincipal, Grant, IGrantable } from '@aws-cdk/aws-iam'; import { IKey, ViaServicePrincipal } from '@aws-cdk/aws-kms'; -import { Annotations, IResource, Resource, Size, SizeRoundingBehavior, Stack, Token, Tags, Names } from '@aws-cdk/core'; +import { IResource, Resource, Size, SizeRoundingBehavior, Stack, Token, Tags, Names } from '@aws-cdk/core'; import { Construct } from 'constructs'; -import { CfnInstance, CfnVolume } from './ec2.generated'; +import { CfnVolume } from './ec2.generated'; import { IInstance } from './instance'; // v2 - keep this import as a separate section to reduce merge conflict when forward merging with the v2 branch. @@ -164,37 +164,6 @@ export class BlockDeviceVolume { } } -/** - * Synthesize an array of block device mappings from a list of block device - * - * @param construct the instance/asg construct, used to host any warning - * @param blockDevices list of block devices - */ -export function synthesizeBlockDeviceMappings(construct: Construct, blockDevices: BlockDevice[]): CfnInstance.BlockDeviceMappingProperty[] { - return blockDevices.map(({ deviceName, volume, mappingEnabled }) => { - const { virtualName, ebsDevice: ebs } = volume; - - if (ebs) { - const { iops, volumeType } = ebs; - - if (!iops) { - if (volumeType === EbsDeviceVolumeType.IO1) { - throw new Error('iops property is required with volumeType: EbsDeviceVolumeType.IO1'); - } - } else if (volumeType !== EbsDeviceVolumeType.IO1) { - Annotations.of(construct).addWarning('iops will be ignored without volumeType: EbsDeviceVolumeType.IO1'); - } - } - - return { - deviceName, - ebs, - virtualName, - noDevice: mappingEnabled === false ? {} : undefined, - }; - }); -} - /** * Supported EBS volume types for blockDevices */ diff --git a/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts b/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts new file mode 100644 index 0000000000000..9e09d44808d05 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts @@ -0,0 +1,750 @@ +import { + countResources, + expect as expectCDK, + haveResource, + haveResourceLike, + stringLike, +} from '@aws-cdk/assert'; +import { + CfnInstanceProfile, + Role, + ServicePrincipal, +} from '@aws-cdk/aws-iam'; +import { + App, + Duration, + Expiration, + Stack, +} from '@aws-cdk/core'; +import { + AmazonLinuxImage, + BlockDevice, + BlockDeviceVolume, + CpuCredits, + EbsDeviceVolumeType, + InstanceInitiatedShutdownBehavior, + InstanceType, + LaunchTemplate, + LaunchTemplateTagResourceType, + SecurityGroup, + SpotInstanceInterruption, + SpotRequestType, + UserData, + Vpc, + WindowsImage, + WindowsVersion, +} from '../lib'; + +/* eslint-disable jest/expect-expect */ + +describe('LaunchTemplate', () => { + let app: App; + let stack: Stack; + + beforeEach(() => { + app = new App(); + stack = new Stack(app); + }); + + test('Empty props', () => { + // WHEN + const template = new LaunchTemplate(stack, 'Template'); + + // THEN + expectCDK(stack).to(haveResource('AWS::IAM::Role')); + expectCDK(stack).to(haveResourceLike('AWS::IAM::InstanceProfile', { + Roles: [ + { + Ref: 'TemplateRole17D80861', + }, + ], + })); + // Note: The following is intentionally a haveResource instead of haveResourceLike + // to ensure that only the bare minimum of properties have values when no properties + // are given to a LaunchTemplate. + expectCDK(stack).to(haveResource('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + IamInstanceProfile: { + Arn: stack.resolve((template.node.findChild('Profile') as CfnInstanceProfile).getAtt('Arn')), + }, + }, + })); + expect(template.connections.securityGroups).toHaveLength(0); + }); + + test('Import from attributes with name', () => { + // WHEN + const template = LaunchTemplate.fromLaunchTemplateAttributes(stack, 'Template', { + launchTemplateName: 'TestName', + versionNumber: 'TestVersion', + }); + + // THEN + expect(template.launchTemplateId).toBeUndefined(); + expect(template.launchTemplateName).toBe('TestName'); + expect(template.versionNumber).toBe('TestVersion'); + }); + + test('Import from attributes with id', () => { + // WHEN + const template = LaunchTemplate.fromLaunchTemplateAttributes(stack, 'Template', { + launchTemplateId: 'TestId', + versionNumber: 'TestVersion', + }); + + // THEN + expect(template.launchTemplateId).toBe('TestId'); + expect(template.launchTemplateName).toBeUndefined(); + expect(template.versionNumber).toBe('TestVersion'); + }); + + test('Import from attributes fails with name and id', () => { + expect(() => { + LaunchTemplate.fromLaunchTemplateAttributes(stack, 'Template', { + launchTemplateName: 'TestName', + launchTemplateId: 'TestId', + versionNumber: 'TestVersion', + }); + }).toThrow(); + }); + + test('Given name', () => { + // WHEN + new LaunchTemplate(stack, 'Template', { + launchTemplateName: 'LTName', + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateName: 'LTName', + })); + }); + + test('Given instanceType', () => { + // WHEN + new LaunchTemplate(stack, 'Template', { + instanceType: new InstanceType('tt.test'), + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + InstanceType: 'tt.test', + }, + })); + }); + + test('Given machineImage (Linux)', () => { + // WHEN + new LaunchTemplate(stack, 'Template', { + machineImage: new AmazonLinuxImage(), + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + ImageId: { + Ref: stringLike('SsmParameterValueawsserviceamiamazonlinuxlatestamznami*Parameter'), + }, + UserData: { + 'Fn::Base64': '#!/bin/bash', + }, + }, + })); + }); + + test('Given machineImage (Windows)', () => { + // WHEN + new LaunchTemplate(stack, 'Template', { + machineImage: new WindowsImage(WindowsVersion.WINDOWS_SERVER_2019_ENGLISH_FULL_BASE), + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + ImageId: { + Ref: stringLike('SsmParameterValueawsserviceamiwindowslatestWindowsServer2019EnglishFullBase*Parameter'), + }, + UserData: { + 'Fn::Base64': '', + }, + }, + })); + }); + + test('Given userData', () => { + // GIVEN + const userData = UserData.forLinux(); + userData.addCommands('echo Test'); + + // WHEN + new LaunchTemplate(stack, 'Template', { + userData, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + UserData: { + 'Fn::Base64': '#!/bin/bash\necho Test', + }, + }, + })); + }); + + test('Given role', () => { + // GIVEN + const role = new Role(stack, 'TestRole', { + assumedBy: new ServicePrincipal('ec2.amazonaws.com'), + }); + + // WHEN + new LaunchTemplate(stack, 'Template', { + role, + }); + + // THEN + expectCDK(stack).to(countResources('AWS::IAM::Role', 1)); + expectCDK(stack).to(haveResourceLike('AWS::IAM::InstanceProfile', { + Roles: [ + { + Ref: 'TestRole6C9272DF', + }, + ], + })); + }); + + test('Given blockDeviceMapping', () => { + // GIVEN + const blockDevices: BlockDevice[] = [ + { + deviceName: 'ebs', + mappingEnabled: true, + volume: BlockDeviceVolume.ebs(15, { + deleteOnTermination: true, + encrypted: true, + volumeType: EbsDeviceVolumeType.IO1, + iops: 5000, + }), + }, { + deviceName: 'ebs-snapshot', + mappingEnabled: false, + volume: BlockDeviceVolume.ebsFromSnapshot('snapshot-id', { + volumeSize: 500, + deleteOnTermination: false, + volumeType: EbsDeviceVolumeType.SC1, + }), + }, { + deviceName: 'ephemeral', + volume: BlockDeviceVolume.ephemeral(0), + }, + ]; + + // WHEN + new LaunchTemplate(stack, 'Template', { + blockDevices, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + BlockDeviceMappings: [ + { + DeviceName: 'ebs', + Ebs: { + DeleteOnTermination: true, + Encrypted: true, + Iops: 5000, + VolumeSize: 15, + VolumeType: 'io1', + }, + }, + { + DeviceName: 'ebs-snapshot', + Ebs: { + DeleteOnTermination: false, + SnapshotId: 'snapshot-id', + VolumeSize: 500, + VolumeType: 'sc1', + }, + NoDevice: '', + }, + { + DeviceName: 'ephemeral', + VirtualName: 'ephemeral0', + }, + ], + }, + })); + }); + + test.each([ + [CpuCredits.STANDARD, 'standard'], + [CpuCredits.UNLIMITED, 'unlimited'], + ])('Given cpuCredits %p', (given: CpuCredits, expected: string) => { + // WHEN + new LaunchTemplate(stack, 'Template', { + cpuCredits: given, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + CreditSpecification: { + CpuCredits: expected, + }, + }, + })); + }); + + test.each([ + [true, true], + [false, false], + ])('Given disableApiTermination %p', (given: boolean, expected: boolean) => { + // WHEN + new LaunchTemplate(stack, 'Template', { + disableApiTermination: given, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + DisableApiTermination: expected, + }, + })); + }); + + test.each([ + [true, true], + [false, false], + ])('Given ebsOptimized %p', (given: boolean, expected: boolean) => { + // WHEN + new LaunchTemplate(stack, 'Template', { + ebsOptimized: given, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + EbsOptimized: expected, + }, + })); + }); + + test.each([ + [true, true], + [false, false], + ])('Given nitroEnclaveEnabled %p', (given: boolean, expected: boolean) => { + // WHEN + new LaunchTemplate(stack, 'Template', { + nitroEnclaveEnabled: given, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + EnclaveOptions: { + Enabled: expected, + }, + }, + })); + }); + + test.each([ + [InstanceInitiatedShutdownBehavior.STOP, 'stop'], + [InstanceInitiatedShutdownBehavior.TERMINATE, 'terminate'], + ])('Given instanceInitiatedShutdownBehavior %p', (given: InstanceInitiatedShutdownBehavior, expected: string) => { + // WHEN + new LaunchTemplate(stack, 'Template', { + instanceInitiatedShutdownBehavior: given, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + InstanceInitiatedShutdownBehavior: expected, + }, + })); + }); + + test('Given keyName', () => { + // WHEN + new LaunchTemplate(stack, 'Template', { + keyName: 'TestKeyname', + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + KeyName: 'TestKeyname', + }, + })); + }); + + test.each([ + [true, true], + [false, false], + ])('Given detailedMonitoring %p', (given: boolean, expected: boolean) => { + // WHEN + new LaunchTemplate(stack, 'Template', { + detailedMonitoring: given, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + Monitoring: { + Enabled: expected, + }, + }, + })); + }); + + test('Given securityGroup', () => { + // GIVEN + const vpc = new Vpc(stack, 'VPC'); + const sg = new SecurityGroup(stack, 'SG', { vpc }); + + // WHEN + const template = new LaunchTemplate(stack, 'Template', { + securityGroup: sg, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + SecurityGroupIds: [ + { + 'Fn::GetAtt': [ + 'SGADB53937', + 'GroupId', + ], + }, + ], + }, + })); + expect(template.connections.securityGroups).toHaveLength(1); + expect(template.connections.securityGroups[0]).toBe(sg); + }); + + test('Given empty tagSpecification resourceTypes', () => { + // WHEN + const template = new LaunchTemplate(stack, 'Template', { + tagSpecifications: [ + { + resourceTypes: [], + tags: [ + { + key: 'TestTag', + value: 'TestValue', + }, + ], + }, + ], + }); + + // THEN + expectCDK(stack).to(haveResource('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + IamInstanceProfile: { + Arn: stack.resolve((template.node.findChild('Profile') as CfnInstanceProfile).getAtt('Arn')), + }, + }, + })); + }); + + test('Given empty tagSpecification tags', () => { + // WHEN + const template = new LaunchTemplate(stack, 'Template', { + tagSpecifications: [ + { + resourceTypes: [ + LaunchTemplateTagResourceType.INSTANCE, + ], + tags: [], + }, + ], + }); + + // THEN + expectCDK(stack).to(haveResource('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + IamInstanceProfile: { + Arn: stack.resolve((template.node.findChild('Profile') as CfnInstanceProfile).getAtt('Arn')), + }, + }, + })); + }); + + test('Given tagSpecification', () => { + // WHEN + new LaunchTemplate(stack, 'Template', { + tagSpecifications: [ + { + resourceTypes: [ + LaunchTemplateTagResourceType.INSTANCE, + ], + tags: [ + { + key: 'InstanceKey', + value: 'InstanceValue', + }, + ], + }, + { // empty one to make sure we don't end up with invalid entries + resourceTypes: [ + LaunchTemplateTagResourceType.INSTANCE, + ], + tags: [], + }, + { + resourceTypes: [ + LaunchTemplateTagResourceType.INSTANCE, + LaunchTemplateTagResourceType.VOLUME, + ], + tags: [ + { + key: 'SharedKey', + value: 'SharedValue', + }, + ], + }, + ], + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + TagSpecifications: [ + { + ResourceType: 'instance', + Tags: [ + { + Key: 'InstanceKey', + Value: 'InstanceValue', + }, + { + Key: 'SharedKey', + Value: 'SharedValue', + }, + ], + }, + { + ResourceType: 'volume', + Tags: [ + { + Key: 'SharedKey', + Value: 'SharedValue', + }, + ], + }, + ], + }, + })); + }); + + test('Adding tagSpecification', () => { + // GIVEN + const template = new LaunchTemplate(stack, 'Template'); + + // WHEN + template.addTagSpecification({ + resourceTypes: [ + LaunchTemplateTagResourceType.INSTANCE, + ], + tags: [ + { + key: 'InstanceKey', + value: 'InstanceValue', + }, + ], + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + TagSpecifications: [ + { + ResourceType: 'instance', + Tags: [ + { + Key: 'InstanceKey', + Value: 'InstanceValue', + }, + ], + }, + ], + }, + })); + }); +}); + +describe('LaunchTemplate marketOptions', () => { + let app: App; + let stack: Stack; + + beforeEach(() => { + app = new App(); + stack = new Stack(app); + }); + + test('given spotOptions', () => { + // WHEN + new LaunchTemplate(stack, 'Template', { + spotOptions: {}, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + InstanceMarketOptions: { + MarketType: 'spot', + }, + }, + })); + }); + + test.each([ + [0, 1], + [1, 0], + [6, 0], + [7, 1], + ])('for range duration errors: %p', (duration: number, expectedErrors: number) => { + // WHEN + const template = new LaunchTemplate(stack, 'Template', { + spotOptions: { + blockDuration: Duration.hours(duration), + }, + }); + + // THEN + expect(template.node.metadata).toHaveLength(expectedErrors); + }); + + test('for bad duration', () => { + expect(() => { + new LaunchTemplate(stack, 'Template', { + spotOptions: { + // Duration must be an integral number of hours. + blockDuration: Duration.minutes(61), + }, + }); + }).toThrow(); + }); + + test('given blockDuration', () => { + // WHEN + new LaunchTemplate(stack, 'Template', { + spotOptions: { + blockDuration: Duration.hours(1), + }, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + InstanceMarketOptions: { + MarketType: 'spot', + SpotOptions: { + BlockDurationMinutes: 60, + }, + }, + }, + })); + }); + + test.each([ + [SpotInstanceInterruption.STOP, 'stop'], + [SpotInstanceInterruption.TERMINATE, 'terminate'], + [SpotInstanceInterruption.HIBERNATE, 'hibernate'], + ])('given interruptionBehavior %p', (given: SpotInstanceInterruption, expected: string) => { + // WHEN + new LaunchTemplate(stack, 'Template', { + spotOptions: { + interruptionBehavior: given, + }, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + InstanceMarketOptions: { + MarketType: 'spot', + SpotOptions: { + InstanceInterruptionBehavior: expected, + }, + }, + }, + })); + }); + + test.each([ + [0.001, '0.001'], + [1, '1'], + [2.5, '2.5'], + ])('given maxPrice %p', (given: number, expected: string) => { + // WHEN + new LaunchTemplate(stack, 'Template', { + spotOptions: { + maxPrice: given, + }, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + InstanceMarketOptions: { + MarketType: 'spot', + SpotOptions: { + MaxPrice: expected, + }, + }, + }, + })); + }); + + test.each([ + [SpotRequestType.ONE_TIME, 'one-time'], + [SpotRequestType.PERSISTENT, 'persistent'], + ])('given requestType %p', (given: SpotRequestType, expected: string) => { + // WHEN + new LaunchTemplate(stack, 'Template', { + spotOptions: { + requestType: given, + }, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + InstanceMarketOptions: { + MarketType: 'spot', + SpotOptions: { + SpotInstanceType: expected, + }, + }, + }, + })); + }); + + test('given validUntil', () => { + // WHEN + new LaunchTemplate(stack, 'Template', { + spotOptions: { + validUntil: Expiration.atTimestamp(0), + }, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + InstanceMarketOptions: { + MarketType: 'spot', + SpotOptions: { + ValidUntil: 'Thu, 01 Jan 1970 00:00:00 GMT', + }, + }, + }, + })); + }); +}); \ No newline at end of file From ab789c743f0219a174aaf3bbed8f018e310bcb6f Mon Sep 17 00:00:00 2001 From: Daniel Neilson Date: Wed, 6 Jan 2021 20:50:29 +0000 Subject: [PATCH 2/7] Add missing newlines at end of files --- packages/@aws-cdk/aws-ec2/lib/private/ebs-util.ts | 2 +- packages/@aws-cdk/aws-ec2/test/launch-template.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@aws-cdk/aws-ec2/lib/private/ebs-util.ts b/packages/@aws-cdk/aws-ec2/lib/private/ebs-util.ts index 3fb055e9516e1..526335d85add7 100644 --- a/packages/@aws-cdk/aws-ec2/lib/private/ebs-util.ts +++ b/packages/@aws-cdk/aws-ec2/lib/private/ebs-util.ts @@ -35,4 +35,4 @@ function synthesizeBlockDeviceMappings(construct: Construct, blockDevic const noDevice = mappingEnabled === false ? noDeviceValue : undefined; return { deviceName, ebs, virtualName, noDevice } as any; }); -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts b/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts index 9e09d44808d05..6af93d169d513 100644 --- a/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts +++ b/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts @@ -747,4 +747,4 @@ describe('LaunchTemplate marketOptions', () => { }, })); }); -}); \ No newline at end of file +}); From 5bf9ac9bd626ffc309ce7f767e1fb59cdc5370ec Mon Sep 17 00:00:00 2001 From: Daniel Neilson Date: Tue, 2 Feb 2021 17:01:54 +0000 Subject: [PATCH 3/7] Make all props optional --- .../@aws-cdk/aws-ec2/lib/launch-template.ts | 167 +++++++++++++----- .../aws-ec2/test/launch-template.test.ts | 67 ++++--- 2 files changed, 150 insertions(+), 84 deletions(-) diff --git a/packages/@aws-cdk/aws-ec2/lib/launch-template.ts b/packages/@aws-cdk/aws-ec2/lib/launch-template.ts index db3b9d903eff2..62f30def289d1 100644 --- a/packages/@aws-cdk/aws-ec2/lib/launch-template.ts +++ b/packages/@aws-cdk/aws-ec2/lib/launch-template.ts @@ -16,7 +16,7 @@ import { Construct } from 'constructs'; import { Connections, IConnectable } from './connections'; import { CfnLaunchTemplate } from './ec2.generated'; import { InstanceType } from './instance-types'; -import { IMachineImage } from './machine-image'; +import { IMachineImage, MachineImageConfig, OperatingSystemType } from './machine-image'; import { launchTemplateBlockDeviceMappings } from './private/ebs-util'; import { ISecurityGroup } from './security-group'; import { UserData } from './user-data'; @@ -255,7 +255,8 @@ export interface LaunchTemplateProps { /** * The AMI that will be used by instances. * - * @default - This Launch Template creates a UserData based on the type of provided machineImage. + * @default - This Launch Template creates a UserData based on the type of provided + * machineImage; no UserData is created if a machineImage is not provided */ readonly userData?: UserData; @@ -269,7 +270,7 @@ export interface LaunchTemplateProps { * assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com') * }); * - * @default - A new Role is created. + * @default - No new role is created. */ readonly role?: iam.IRole; @@ -453,19 +454,9 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr // ============================================ // Members for ILaunchTemplate interface - /** - * @inheritdoc - */ - public readonly versionNumber: string; - /** - * @inheritdoc - */ + public readonly versionNumber: string; public readonly launchTemplateId?: string; - - /** - * @inheritdoc - */ public readonly launchTemplateName?: string; // ============================================= @@ -485,35 +476,44 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr */ public readonly latestVersionNumber: string; + // ============================================= + // Private/protected data members + + /** + * The type of OS the instance is running. + * @internal + */ + protected readonly _osType?: OperatingSystemType; + /** * IAM Role assumed by instances that are launched from this template. + * @internal */ - public readonly role: iam.IRole; + protected readonly _role?: iam.IRole; /** * Principal to grant permissions to. + * @internal */ - public readonly grantPrincipal: iam.IPrincipal; + protected readonly _grantPrincipal?: iam.IPrincipal; /** * Allows specifying security group connections for the instance. - * - * Note: If the LaunchTemplate was not created with a SecurityGroup, then this connections - * object will be empty and operations on it will do nothing. + * @internal */ - public readonly connections: Connections; + protected readonly _connections?: Connections; /** * UserData executed by instances that are launched from this template. + * @internal */ - public readonly userData?: UserData; - + protected readonly _userData?: UserData; protected tagSpecifications: LaunchTemplateTagSpecification[]; // ============================================= - constructor(scope: Construct, id: string, props?: LaunchTemplateProps) { + constructor(scope: Construct, id: string, props: LaunchTemplateProps = {}) { super(scope, id); // Basic validation of the provided spot block duration @@ -523,38 +523,48 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr Annotations.of(this).addError('Spot block duration must be exactly 1, 2, 3, 4, 5, or 6 hours.'); } - this.role = props?.role ?? new iam.Role(this, 'Role', { - assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'), - }); - this.grantPrincipal = this.role; - const iamProfile = new iam.CfnInstanceProfile(this, 'Profile', { - roles: [this.role.roleName], + this._role = props.role; + this._grantPrincipal = this._role; + const iamProfile: iam.CfnInstanceProfile | undefined = this._role ? new iam.CfnInstanceProfile(this, 'Profile', { + roles: [this._role!.roleName], + }) : undefined; + const iamProfileToken = Lazy.string({ + produce: () => { + if (iamProfile) { + return iamProfile.getAtt('Arn').toString(); + } + return undefined; + }, }); - if (props?.securityGroup) { - this.connections = new Connections({ securityGroups: [props.securityGroup] }); - } else { - // Note: LaunchTemplates are not associated with a VPC, so we cannot create a - // SecurityGroup by default. So, we preserve the IConnectable interface by - // creating a Connections object but using it when empty will just result in no-ops. - this.connections = new Connections({ securityGroups: [] }); + if (props.securityGroup) { + this._connections = new Connections({ securityGroups: [props.securityGroup] }); } const securityGroupsToken = Lazy.list({ produce: () => { - if (this.connections.securityGroups.length > 0) { - return this.connections.securityGroups.map(sg => sg.securityGroupId); + if (this._connections && this._connections.securityGroups.length > 0) { + return this._connections.securityGroups.map(sg => sg.securityGroupId); + } + return undefined; + }, + }); + + if (props.userData) { + this._userData = props.userData; + } + const userDataToken = Lazy.string({ + produce: () => { + if (this._userData) { + return Fn.base64(this._userData.render()); } return undefined; }, }); - this.userData = props?.userData; - if (!this.userData !== undefined && props?.machineImage !== undefined) { - this.userData = props?.machineImage.getImage(this).userData; + const imageConfig: MachineImageConfig | undefined = props.machineImage?.getImage(this); + if (imageConfig) { + this._osType = imageConfig.osType; } - const userDataToken = this.userData ? - Lazy.string({ produce: () => Fn.base64(this.userData!.render()) }) : - undefined; let marketOptions: any = undefined; if (props?.spotOptions) { @@ -592,10 +602,10 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr hibernationOptions: props?.hibernationConfigured !== undefined ? { configured: props.hibernationConfigured, } : undefined, - iamInstanceProfile: { - arn: iamProfile.getAtt('Arn').toString(), - }, - imageId: props?.machineImage?.getImage(this).imageId, + iamInstanceProfile: iamProfile !== undefined ? { + arn: iamProfileToken, + } : undefined, + imageId: imageConfig?.imageId, instanceType: props?.instanceType?.toString(), instanceInitiatedShutdownBehavior: props?.instanceInitiatedShutdownBehavior, instanceMarketOptions: marketOptions, @@ -657,6 +667,67 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr this.tagSpecifications.push(specification); } + /** + * Allows specifying security group connections for the instance. + * + * @note Only available if you provide a securityGroup when constructing the LaunchTemplate. + */ + public get connections(): Connections { + if (!this._connections) { + throw new Error('connections not available on LaunchTemplate. You must provide a securityGroup when constructing the LaunchTemplate to make it available.'); + } + return this._connections; + } + + /** + * Principal to grant permissions to. + * + * @note Only available if you provide a role when constructing the LaunchTemplate. + */ + public get grantPrincipal(): iam.IPrincipal { + if (!this._role) { + throw new Error('grantPrincipal not available on LaunchTemplate. You must provide a role when constructing the LaunchTemplate to make it available.'); + } + return this._role; + } + + /** + * The type of OS on which the instance is running. + * + * @note Only available if you provide a machineImage when constructing the LaunchTemplate + */ + public get osType(): OperatingSystemType { + // Explicitly check for undef. osType is an enum, and can have the value 0 which messes with attempts at "if (!osType)" + if (this._osType === undefined) { + throw new Error('osType not available on LaunchTemplate. You must provide a machineImage when constructing the LaunchTemplate to make it available.'); + } + return this._osType; + } + + /** + * IAM Role assumed by instances that are launched from this template. + * + * @note Only available if you provide a role when constructing the LaunchTemplate. + */ + public get role(): iam.IRole { + if (!this._role) { + throw new Error('role not available on LaunchTemplate. You must provide a role when constructing the LaunchTemplate to make it available.'); + } + return this._role; + } + + /** + * UserData executed by instances that are launched from this template. + * + * @note Only available if you provide a machineImage or UserData when constructing the LaunchTemplate. + */ + public get userData(): UserData { + if (!this._userData) { + throw new Error('userData not available on LaunchTemplate. You must provide a userData or machineImage when constructing the LaunchTemplate to make it available.'); + } + return this._userData; + } + /** * Convert the tag specification from the input form into the form required of the * Launch Template L1 construct. diff --git a/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts b/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts index 6af93d169d513..95fc0da9be7da 100644 --- a/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts +++ b/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts @@ -26,6 +26,7 @@ import { InstanceType, LaunchTemplate, LaunchTemplateTagResourceType, + OperatingSystemType, SecurityGroup, SpotInstanceInterruption, SpotRequestType, @@ -51,25 +52,18 @@ describe('LaunchTemplate', () => { const template = new LaunchTemplate(stack, 'Template'); // THEN - expectCDK(stack).to(haveResource('AWS::IAM::Role')); - expectCDK(stack).to(haveResourceLike('AWS::IAM::InstanceProfile', { - Roles: [ - { - Ref: 'TemplateRole17D80861', - }, - ], - })); // Note: The following is intentionally a haveResource instead of haveResourceLike // to ensure that only the bare minimum of properties have values when no properties // are given to a LaunchTemplate. expectCDK(stack).to(haveResource('AWS::EC2::LaunchTemplate', { - LaunchTemplateData: { - IamInstanceProfile: { - Arn: stack.resolve((template.node.findChild('Profile') as CfnInstanceProfile).getAtt('Arn')), - }, - }, + LaunchTemplateData: {}, })); - expect(template.connections.securityGroups).toHaveLength(0); + expectCDK(stack).notTo(haveResource('AWS::IAM::InstanceProfile')); + expect(() => { template.grantPrincipal; }).toThrow(); + expect(() => { template.connections; }).toThrow(); + expect(() => { template.osType; }).toThrow(); + expect(() => { template.role; }).toThrow(); + expect(() => { template.userData; }).toThrow(); }); test('Import from attributes with name', () => { @@ -136,7 +130,7 @@ describe('LaunchTemplate', () => { test('Given machineImage (Linux)', () => { // WHEN - new LaunchTemplate(stack, 'Template', { + const template = new LaunchTemplate(stack, 'Template', { machineImage: new AmazonLinuxImage(), }); @@ -146,16 +140,15 @@ describe('LaunchTemplate', () => { ImageId: { Ref: stringLike('SsmParameterValueawsserviceamiamazonlinuxlatestamznami*Parameter'), }, - UserData: { - 'Fn::Base64': '#!/bin/bash', - }, }, })); + expect(template.osType).toBe(OperatingSystemType.LINUX); + expect(() => { template.userData; }).toThrow(); }); test('Given machineImage (Windows)', () => { // WHEN - new LaunchTemplate(stack, 'Template', { + const template = new LaunchTemplate(stack, 'Template', { machineImage: new WindowsImage(WindowsVersion.WINDOWS_SERVER_2019_ENGLISH_FULL_BASE), }); @@ -165,11 +158,10 @@ describe('LaunchTemplate', () => { ImageId: { Ref: stringLike('SsmParameterValueawsserviceamiwindowslatestWindowsServer2019EnglishFullBase*Parameter'), }, - UserData: { - 'Fn::Base64': '', - }, }, })); + expect(template.osType).toBe(OperatingSystemType.WINDOWS); + expect(() => { template.userData; }).toThrow(); }); test('Given userData', () => { @@ -178,7 +170,7 @@ describe('LaunchTemplate', () => { userData.addCommands('echo Test'); // WHEN - new LaunchTemplate(stack, 'Template', { + const template = new LaunchTemplate(stack, 'Template', { userData, }); @@ -190,6 +182,7 @@ describe('LaunchTemplate', () => { }, }, })); + expect(template.userData).toBeDefined(); }); test('Given role', () => { @@ -199,7 +192,7 @@ describe('LaunchTemplate', () => { }); // WHEN - new LaunchTemplate(stack, 'Template', { + const template = new LaunchTemplate(stack, 'Template', { role, }); @@ -212,6 +205,15 @@ describe('LaunchTemplate', () => { }, ], })); + expectCDK(stack).to(haveResource('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + IamInstanceProfile: { + Arn: stack.resolve((template.node.findChild('Profile') as CfnInstanceProfile).getAtt('Arn')), + }, + }, + })); + expect(template.role).toBeDefined(); + expect(template.grantPrincipal).toBeDefined(); }); test('Given blockDeviceMapping', () => { @@ -423,13 +425,14 @@ describe('LaunchTemplate', () => { ], }, })); + expect(template.connections).toBeDefined(); expect(template.connections.securityGroups).toHaveLength(1); expect(template.connections.securityGroups[0]).toBe(sg); }); test('Given empty tagSpecification resourceTypes', () => { // WHEN - const template = new LaunchTemplate(stack, 'Template', { + new LaunchTemplate(stack, 'Template', { tagSpecifications: [ { resourceTypes: [], @@ -445,17 +448,13 @@ describe('LaunchTemplate', () => { // THEN expectCDK(stack).to(haveResource('AWS::EC2::LaunchTemplate', { - LaunchTemplateData: { - IamInstanceProfile: { - Arn: stack.resolve((template.node.findChild('Profile') as CfnInstanceProfile).getAtt('Arn')), - }, - }, + LaunchTemplateData: {}, })); }); test('Given empty tagSpecification tags', () => { // WHEN - const template = new LaunchTemplate(stack, 'Template', { + new LaunchTemplate(stack, 'Template', { tagSpecifications: [ { resourceTypes: [ @@ -468,11 +467,7 @@ describe('LaunchTemplate', () => { // THEN expectCDK(stack).to(haveResource('AWS::EC2::LaunchTemplate', { - LaunchTemplateData: { - IamInstanceProfile: { - Arn: stack.resolve((template.node.findChild('Profile') as CfnInstanceProfile).getAtt('Arn')), - }, - }, + LaunchTemplateData: {}, })); }); From 737e2c4f9704d06deef6c98468ec0edb7177e2b4 Mon Sep 17 00:00:00 2001 From: Daniel Neilson Date: Tue, 2 Feb 2021 18:11:12 +0000 Subject: [PATCH 4/7] Remove ad-hoc tagging support --- .../@aws-cdk/aws-ec2/lib/launch-template.ts | 94 +----------- .../aws-ec2/test/launch-template.test.ts | 144 ------------------ 2 files changed, 4 insertions(+), 234 deletions(-) diff --git a/packages/@aws-cdk/aws-ec2/lib/launch-template.ts b/packages/@aws-cdk/aws-ec2/lib/launch-template.ts index 62f30def289d1..f9983a31b4832 100644 --- a/packages/@aws-cdk/aws-ec2/lib/launch-template.ts +++ b/packages/@aws-cdk/aws-ec2/lib/launch-template.ts @@ -2,7 +2,6 @@ import * as iam from '@aws-cdk/aws-iam'; import { Annotations, - CfnTag, Duration, Expiration, Fn, @@ -190,43 +189,6 @@ export interface LaunchTemplateSpotOptions { readonly validUntil?: Expiration; }; -/** - * The types of resource tags that can be set on a launch template. - */ -export enum LaunchTemplateTagResourceType { - // dev-note: Full list at https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-launchtemplate-tagspecification.html#cfn-ec2-launchtemplate-tagspecification-resourcetype - // These are the only ones from the list that presently support tag on create. - - /** - * EC2 Instances. - */ - INSTANCE = 'instance', - - /** - * EBS Volumes. - */ - VOLUME = 'volume', -} - -/** - * Interface for defining the tag specification of a LaunchTemplate. - */ -export interface LaunchTemplateTagSpecification { - /** - * The types of resources that are tagged with the given tags. Note that there are - * restrictions regarding which resources can be tagged when created. See the following - * for additional information. - * - * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-launchtemplate-tagspecification.html#cfn-ec2-launchtemplate-tagspecification-resourcetype - */ - readonly resourceTypes: LaunchTemplateTagResourceType[]; - - /** - * The tags to apply to the resources. - */ - readonly tags: CfnTag[]; -} - /** * Properties of a LaunchTemplate. */ @@ -368,16 +330,6 @@ export interface LaunchTemplateProps { * @default No security group is assigned. */ readonly securityGroup?: ISecurityGroup; - - /** - * The tags to apply to the resources during launch. The specified tags are applied to all resources of the given - * types launch. - * Note that not every resource supports tagging during creation. See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-launchtemplate-tagspecification.html#cfn-ec2-launchtemplate-tagspecification-resourcetype - * for additional information on the restrictions. - * - * @default No tags are specified. - */ - readonly tagSpecifications?: LaunchTemplateTagSpecification[]; } /** @@ -509,8 +461,6 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr */ protected readonly _userData?: UserData; - protected tagSpecifications: LaunchTemplateTagSpecification[]; - // ============================================= constructor(scope: Construct, id: string, props: LaunchTemplateProps = {}) { @@ -584,9 +534,6 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr } } - this.tagSpecifications = props?.tagSpecifications ?? []; - const tagSpecificationsToken = Lazy.any({ produce: () => this.convertTagSpecList(this.tagSpecifications) }); - const resource = new CfnLaunchTemplate(this, 'Resource', { launchTemplateName: props?.launchTemplateName, launchTemplateData: { @@ -614,7 +561,6 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr enabled: props.detailedMonitoring, } : undefined, securityGroupIds: securityGroupsToken, - tagSpecifications: tagSpecificationsToken, userData: userDataToken, // Fields not yet implemented: @@ -643,6 +589,10 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-launchtemplate-launchtemplatedata.html#cfn-ec2-launchtemplate-launchtemplatedata-metadataoptions // metadataOptions: undefined, + // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-launchtemplate-launchtemplatedata.html#cfn-ec2-launchtemplate-launchtemplatedata-tagspecifications + // Should be implemented via the Tagging aspect in CDK core. Complication will be that this tagging interface is very unique to LaunchTemplates. + // tagSpecification: undefined + // CDK has no abstraction for Network Interfaces yet. // networkInterfaces: undefined, @@ -660,13 +610,6 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr this.versionNumber = Token.asString(resource.getAtt('LatestVersionNumber')); } - /** - * Adds a given tag specification to this launch template. - */ - public addTagSpecification(specification: LaunchTemplateTagSpecification) { - this.tagSpecifications.push(specification); - } - /** * Allows specifying security group connections for the instance. * @@ -727,33 +670,4 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr } return this._userData; } - - /** - * Convert the tag specification from the input form into the form required of the - * Launch Template L1 construct. - */ - protected convertTagSpecList(tagSpecifications?: LaunchTemplateTagSpecification[]): CfnLaunchTemplate.TagSpecificationProperty[] | undefined { - if (tagSpecifications === undefined) { - return undefined; - } - - let tagMapping: { [key: string]: any } = {}; - tagSpecifications.forEach(spec => { - spec.resourceTypes.forEach(res => { - tagMapping[res] = tagMapping[res] ?? []; - tagMapping[res] = tagMapping[res].concat(spec.tags); - }); - }); - - let result = Object.keys(tagMapping) - .filter(res => tagMapping[res].length > 0) - .map(resourceType => { - return { - resourceType: resourceType, - tags: tagMapping[resourceType], - }; - }); - - return result.length > 0 ? result : undefined; - } } diff --git a/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts b/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts index 95fc0da9be7da..d5ddcd1ef6615 100644 --- a/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts +++ b/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts @@ -25,7 +25,6 @@ import { InstanceInitiatedShutdownBehavior, InstanceType, LaunchTemplate, - LaunchTemplateTagResourceType, OperatingSystemType, SecurityGroup, SpotInstanceInterruption, @@ -429,149 +428,6 @@ describe('LaunchTemplate', () => { expect(template.connections.securityGroups).toHaveLength(1); expect(template.connections.securityGroups[0]).toBe(sg); }); - - test('Given empty tagSpecification resourceTypes', () => { - // WHEN - new LaunchTemplate(stack, 'Template', { - tagSpecifications: [ - { - resourceTypes: [], - tags: [ - { - key: 'TestTag', - value: 'TestValue', - }, - ], - }, - ], - }); - - // THEN - expectCDK(stack).to(haveResource('AWS::EC2::LaunchTemplate', { - LaunchTemplateData: {}, - })); - }); - - test('Given empty tagSpecification tags', () => { - // WHEN - new LaunchTemplate(stack, 'Template', { - tagSpecifications: [ - { - resourceTypes: [ - LaunchTemplateTagResourceType.INSTANCE, - ], - tags: [], - }, - ], - }); - - // THEN - expectCDK(stack).to(haveResource('AWS::EC2::LaunchTemplate', { - LaunchTemplateData: {}, - })); - }); - - test('Given tagSpecification', () => { - // WHEN - new LaunchTemplate(stack, 'Template', { - tagSpecifications: [ - { - resourceTypes: [ - LaunchTemplateTagResourceType.INSTANCE, - ], - tags: [ - { - key: 'InstanceKey', - value: 'InstanceValue', - }, - ], - }, - { // empty one to make sure we don't end up with invalid entries - resourceTypes: [ - LaunchTemplateTagResourceType.INSTANCE, - ], - tags: [], - }, - { - resourceTypes: [ - LaunchTemplateTagResourceType.INSTANCE, - LaunchTemplateTagResourceType.VOLUME, - ], - tags: [ - { - key: 'SharedKey', - value: 'SharedValue', - }, - ], - }, - ], - }); - - // THEN - expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { - LaunchTemplateData: { - TagSpecifications: [ - { - ResourceType: 'instance', - Tags: [ - { - Key: 'InstanceKey', - Value: 'InstanceValue', - }, - { - Key: 'SharedKey', - Value: 'SharedValue', - }, - ], - }, - { - ResourceType: 'volume', - Tags: [ - { - Key: 'SharedKey', - Value: 'SharedValue', - }, - ], - }, - ], - }, - })); - }); - - test('Adding tagSpecification', () => { - // GIVEN - const template = new LaunchTemplate(stack, 'Template'); - - // WHEN - template.addTagSpecification({ - resourceTypes: [ - LaunchTemplateTagResourceType.INSTANCE, - ], - tags: [ - { - key: 'InstanceKey', - value: 'InstanceValue', - }, - ], - }); - - // THEN - expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { - LaunchTemplateData: { - TagSpecifications: [ - { - ResourceType: 'instance', - Tags: [ - { - Key: 'InstanceKey', - Value: 'InstanceValue', - }, - ], - }, - ], - }, - })); - }); }); describe('LaunchTemplate marketOptions', () => { From db2c3f87c0fc6d9ae271f480b24efe5f80be69ff Mon Sep 17 00:00:00 2001 From: Daniel Neilson Date: Tue, 2 Feb 2021 19:01:12 +0000 Subject: [PATCH 5/7] Add hack for tagging aspect support --- .../@aws-cdk/aws-ec2/lib/launch-template.ts | 34 ++++++++ .../aws-ec2/test/launch-template.test.ts | 86 ++++++++++++++++++- 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/packages/@aws-cdk/aws-ec2/lib/launch-template.ts b/packages/@aws-cdk/aws-ec2/lib/launch-template.ts index f9983a31b4832..579353182051c 100644 --- a/packages/@aws-cdk/aws-ec2/lib/launch-template.ts +++ b/packages/@aws-cdk/aws-ec2/lib/launch-template.ts @@ -8,6 +8,8 @@ import { IResource, Lazy, Resource, + TagManager, + TagType, Tags, Token, } from '@aws-cdk/core'; @@ -428,6 +430,11 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr */ public readonly latestVersionNumber: string; + /** + * TagManager for tagging support. + */ + protected readonly tags: TagManager; + // ============================================= // Private/protected data members @@ -534,6 +541,32 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr } } + this.tags = new TagManager(TagType.KEY_VALUE, 'AWS::EC2::LaunchTemplate'); + const tagsToken = Lazy.any({ + produce: () => { + if (this.tags.hasTags()) { + const renderedTags = this.tags.renderTags(); + const lowerCaseRenderedTags = renderedTags.map( (tag: { [key: string]: string}) => { + return { + key: tag.Key, + value: tag.Value, + }; + }); + return [ + { + resourceType: 'instance', + tags: lowerCaseRenderedTags, + }, + { + resourceType: 'volume', + tags: lowerCaseRenderedTags, + }, + ]; + } + return undefined; + }, + }); + const resource = new CfnLaunchTemplate(this, 'Resource', { launchTemplateName: props?.launchTemplateName, launchTemplateData: { @@ -561,6 +594,7 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr enabled: props.detailedMonitoring, } : undefined, securityGroupIds: securityGroupsToken, + tagSpecifications: tagsToken, userData: userDataToken, // Fields not yet implemented: diff --git a/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts b/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts index d5ddcd1ef6615..a72ab1776f797 100644 --- a/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts +++ b/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts @@ -15,6 +15,7 @@ import { Duration, Expiration, Stack, + Tags, } from '@aws-cdk/core'; import { AmazonLinuxImage, @@ -55,7 +56,28 @@ describe('LaunchTemplate', () => { // to ensure that only the bare minimum of properties have values when no properties // are given to a LaunchTemplate. expectCDK(stack).to(haveResource('AWS::EC2::LaunchTemplate', { - LaunchTemplateData: {}, + LaunchTemplateData: { + TagSpecifications: [ + { + ResourceType: 'instance', + Tags: [ + { + Key: 'Name', + Value: 'Default/Template', + }, + ], + }, + { + ResourceType: 'volume', + Tags: [ + { + Key: 'Name', + Value: 'Default/Template', + }, + ], + }, + ], + }, })); expectCDK(stack).notTo(haveResource('AWS::IAM::InstanceProfile')); expect(() => { template.grantPrincipal; }).toThrow(); @@ -209,6 +231,26 @@ describe('LaunchTemplate', () => { IamInstanceProfile: { Arn: stack.resolve((template.node.findChild('Profile') as CfnInstanceProfile).getAtt('Arn')), }, + TagSpecifications: [ + { + ResourceType: 'instance', + Tags: [ + { + Key: 'Name', + Value: 'Default/Template', + }, + ], + }, + { + ResourceType: 'volume', + Tags: [ + { + Key: 'Name', + Value: 'Default/Template', + }, + ], + }, + ], }, })); expect(template.role).toBeDefined(); @@ -428,6 +470,48 @@ describe('LaunchTemplate', () => { expect(template.connections.securityGroups).toHaveLength(1); expect(template.connections.securityGroups[0]).toBe(sg); }); + + test('Adding tags', () => { + // GIVEN + const template = new LaunchTemplate(stack, 'Template'); + + // WHEN + Tags.of(template).add('TestKey', 'TestValue'); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + TagSpecifications: [ + { + ResourceType: 'instance', + Tags: [ + { + Key: 'Name', + Value: 'Default/Template', + }, + { + Key: 'TestKey', + Value: 'TestValue', + }, + ], + }, + { + ResourceType: 'volume', + Tags: [ + { + Key: 'Name', + Value: 'Default/Template', + }, + { + Key: 'TestKey', + Value: 'TestValue', + }, + ], + }, + ], + }, + })); + }); }); describe('LaunchTemplate marketOptions', () => { From 62486b91bdd2cfa3b61f24b421882901765ce063 Mon Sep 17 00:00:00 2001 From: Daniel Neilson Date: Fri, 5 Feb 2021 05:04:56 +0000 Subject: [PATCH 6/7] Apply suggested changes --- packages/@aws-cdk/aws-ec2/README.md | 2 +- .../@aws-cdk/aws-ec2/lib/launch-template.ts | 102 ++++++------------ .../aws-ec2/test/launch-template.test.ts | 10 +- 3 files changed, 36 insertions(+), 78 deletions(-) diff --git a/packages/@aws-cdk/aws-ec2/README.md b/packages/@aws-cdk/aws-ec2/README.md index c341bf6281964..1a442e104e4b0 100644 --- a/packages/@aws-cdk/aws-ec2/README.md +++ b/packages/@aws-cdk/aws-ec2/README.md @@ -1008,7 +1008,7 @@ Launch templates enable you to store launch parameters so that you do not have t an instance. For information on Launch Templates please see the [official documentation](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-launch-templates.html). -The following demonstrates how to create a launch template with an Amazon Machine Image, IAM Role, and security group. +The following demonstrates how to create a launch template with an Amazon Machine Image, and security group. ```ts const vpc = new ec2.Vpc(...); diff --git a/packages/@aws-cdk/aws-ec2/lib/launch-template.ts b/packages/@aws-cdk/aws-ec2/lib/launch-template.ts index 579353182051c..3b5b39f9b6370 100644 --- a/packages/@aws-cdk/aws-ec2/lib/launch-template.ts +++ b/packages/@aws-cdk/aws-ec2/lib/launch-template.ts @@ -431,24 +431,28 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr public readonly latestVersionNumber: string; /** - * TagManager for tagging support. + * The type of OS the instance is running. + * + * @attribute */ - protected readonly tags: TagManager; - - // ============================================= - // Private/protected data members + public readonly osType?: OperatingSystemType; /** - * The type of OS the instance is running. - * @internal + * IAM Role assumed by instances that are launched from this template. + * + * @attribute */ - protected readonly _osType?: OperatingSystemType; + public readonly role?: iam.IRole; /** - * IAM Role assumed by instances that are launched from this template. - * @internal + * UserData executed by instances that are launched from this template. + * + * @attribute */ - protected readonly _role?: iam.IRole; + public readonly userData?: UserData; + + // ============================================= + // Private/protected data members /** * Principal to grant permissions to. @@ -463,10 +467,9 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr protected readonly _connections?: Connections; /** - * UserData executed by instances that are launched from this template. - * @internal + * TagManager for tagging support. */ - protected readonly _userData?: UserData; + protected readonly tags: TagManager; // ============================================= @@ -480,19 +483,11 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr Annotations.of(this).addError('Spot block duration must be exactly 1, 2, 3, 4, 5, or 6 hours.'); } - this._role = props.role; - this._grantPrincipal = this._role; - const iamProfile: iam.CfnInstanceProfile | undefined = this._role ? new iam.CfnInstanceProfile(this, 'Profile', { - roles: [this._role!.roleName], + this.role = props.role; + this._grantPrincipal = this.role; + const iamProfile: iam.CfnInstanceProfile | undefined = this.role ? new iam.CfnInstanceProfile(this, 'Profile', { + roles: [this.role!.roleName], }) : undefined; - const iamProfileToken = Lazy.string({ - produce: () => { - if (iamProfile) { - return iamProfile.getAtt('Arn').toString(); - } - return undefined; - }, - }); if (props.securityGroup) { this._connections = new Connections({ securityGroups: [props.securityGroup] }); @@ -507,12 +502,12 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr }); if (props.userData) { - this._userData = props.userData; + this.userData = props.userData; } const userDataToken = Lazy.string({ produce: () => { - if (this._userData) { - return Fn.base64(this._userData.render()); + if (this.userData) { + return Fn.base64(this.userData.render()); } return undefined; }, @@ -520,7 +515,7 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr const imageConfig: MachineImageConfig | undefined = props.machineImage?.getImage(this); if (imageConfig) { - this._osType = imageConfig.osType; + this.osType = imageConfig.osType; } let marketOptions: any = undefined; @@ -583,7 +578,7 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr configured: props.hibernationConfigured, } : undefined, iamInstanceProfile: iamProfile !== undefined ? { - arn: iamProfileToken, + arn: iamProfile.getAtt('Arn').toString(), } : undefined, imageId: imageConfig?.imageId, instanceType: props?.instanceType?.toString(), @@ -651,7 +646,7 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr */ public get connections(): Connections { if (!this._connections) { - throw new Error('connections not available on LaunchTemplate. You must provide a securityGroup when constructing the LaunchTemplate to make it available.'); + throw new Error('LaunchTemplate can only be used as IConnectable if a securityGroup is provided when contructing it.'); } return this._connections; } @@ -662,46 +657,9 @@ export class LaunchTemplate extends Resource implements ILaunchTemplate, iam.IGr * @note Only available if you provide a role when constructing the LaunchTemplate. */ public get grantPrincipal(): iam.IPrincipal { - if (!this._role) { - throw new Error('grantPrincipal not available on LaunchTemplate. You must provide a role when constructing the LaunchTemplate to make it available.'); - } - return this._role; - } - - /** - * The type of OS on which the instance is running. - * - * @note Only available if you provide a machineImage when constructing the LaunchTemplate - */ - public get osType(): OperatingSystemType { - // Explicitly check for undef. osType is an enum, and can have the value 0 which messes with attempts at "if (!osType)" - if (this._osType === undefined) { - throw new Error('osType not available on LaunchTemplate. You must provide a machineImage when constructing the LaunchTemplate to make it available.'); - } - return this._osType; - } - - /** - * IAM Role assumed by instances that are launched from this template. - * - * @note Only available if you provide a role when constructing the LaunchTemplate. - */ - public get role(): iam.IRole { - if (!this._role) { - throw new Error('role not available on LaunchTemplate. You must provide a role when constructing the LaunchTemplate to make it available.'); - } - return this._role; - } - - /** - * UserData executed by instances that are launched from this template. - * - * @note Only available if you provide a machineImage or UserData when constructing the LaunchTemplate. - */ - public get userData(): UserData { - if (!this._userData) { - throw new Error('userData not available on LaunchTemplate. You must provide a userData or machineImage when constructing the LaunchTemplate to make it available.'); + if (!this._grantPrincipal) { + throw new Error('LaunchTemplate can only be used as IGrantable if a role is provided when constructing it.'); } - return this._userData; + return this._grantPrincipal; } } diff --git a/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts b/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts index a72ab1776f797..882cd69ed282f 100644 --- a/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts +++ b/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts @@ -82,9 +82,9 @@ describe('LaunchTemplate', () => { expectCDK(stack).notTo(haveResource('AWS::IAM::InstanceProfile')); expect(() => { template.grantPrincipal; }).toThrow(); expect(() => { template.connections; }).toThrow(); - expect(() => { template.osType; }).toThrow(); - expect(() => { template.role; }).toThrow(); - expect(() => { template.userData; }).toThrow(); + expect(template.osType).toBeUndefined(); + expect(template.role).toBeUndefined(); + expect(template.userData).toBeUndefined(); }); test('Import from attributes with name', () => { @@ -164,7 +164,7 @@ describe('LaunchTemplate', () => { }, })); expect(template.osType).toBe(OperatingSystemType.LINUX); - expect(() => { template.userData; }).toThrow(); + expect(template.userData).toBeUndefined(); }); test('Given machineImage (Windows)', () => { @@ -182,7 +182,7 @@ describe('LaunchTemplate', () => { }, })); expect(template.osType).toBe(OperatingSystemType.WINDOWS); - expect(() => { template.userData; }).toThrow(); + expect(template.userData).toBeUndefined(); }); test('Given userData', () => { From 80c8b7f546ea583cb9f844b6ca6a15886b1df1b5 Mon Sep 17 00:00:00 2001 From: Daniel Neilson Date: Fri, 5 Feb 2021 16:41:29 +0000 Subject: [PATCH 7/7] Fix linter error --- packages/@aws-cdk/aws-ec2/lib/private/ebs-util.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/@aws-cdk/aws-ec2/lib/private/ebs-util.ts b/packages/@aws-cdk/aws-ec2/lib/private/ebs-util.ts index 526335d85add7..dc91f6d795011 100644 --- a/packages/@aws-cdk/aws-ec2/lib/private/ebs-util.ts +++ b/packages/@aws-cdk/aws-ec2/lib/private/ebs-util.ts @@ -1,7 +1,11 @@ -import { Annotations, Construct } from '@aws-cdk/core'; +import { Annotations } from '@aws-cdk/core'; import { CfnInstance, CfnLaunchTemplate } from '../ec2.generated'; import { BlockDevice, EbsDeviceVolumeType } from '../volume'; +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct } from '@aws-cdk/core'; + export function instanceBlockDeviceMappings(construct: Construct, blockDevices: BlockDevice[]): CfnInstance.BlockDeviceMappingProperty[] { return synthesizeBlockDeviceMappings(construct, blockDevices, {}); }