From 4514a3b7af9cf1a1ec981b106b57dd938ed0a55d Mon Sep 17 00:00:00 2001 From: Daniel Neilson <53624638+ddneilson@users.noreply.github.com> Date: Fri, 5 Feb 2021 11:19:45 -0600 Subject: [PATCH] feat(ec2): can define Launch Templates (not use them yet) (#12385) This provides an initial implementation of a level 2 construct for EC2 Launch Templates. It is a start that is intended to help get the ball rolling on implementation of Launch Template support within the CDK. It is a step towards resolving https://github.com/aws/aws-cdk/issues/6734 Launch Templates have value even without the integrations into Instance and AutoScalingGroup being implemented yet. Thus, the intention with this PR is to solely implement the L2 for LaunchTemplate. Future work in a separate PR would be required to implement its integration into Instance, AutoScalingGroup, and others. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- 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 | 665 +++++++++++++++++ .../@aws-cdk/aws-ec2/lib/private/ebs-util.ts | 42 ++ packages/@aws-cdk/aws-ec2/lib/volume.ts | 35 +- .../aws-ec2/test/launch-template.test.ts | 685 ++++++++++++++++++ 7 files changed, 1419 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 6ab25bfb78b3f..1a442e104e4b0 100644 --- a/packages/@aws-cdk/aws-ec2/README.md +++ b/packages/@aws-cdk/aws-ec2/README.md @@ -999,3 +999,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, 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..3b5b39f9b6370 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/lib/launch-template.ts @@ -0,0 +1,665 @@ +import * as iam from '@aws-cdk/aws-iam'; + +import { + Annotations, + Duration, + Expiration, + Fn, + IResource, + Lazy, + Resource, + TagManager, + TagType, + 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, MachineImageConfig, OperatingSystemType } 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; +}; + +/** + * 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; no UserData is created if a machineImage is not provided + */ + 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 - No 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; +} + +/** + * 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 + + public readonly versionNumber: string; + public readonly launchTemplateId?: string; + 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; + + /** + * The type of OS the instance is running. + * + * @attribute + */ + public readonly osType?: OperatingSystemType; + + /** + * IAM Role assumed by instances that are launched from this template. + * + * @attribute + */ + public readonly role?: iam.IRole; + + /** + * UserData executed by instances that are launched from this template. + * + * @attribute + */ + public readonly userData?: UserData; + + // ============================================= + // Private/protected data members + + /** + * Principal to grant permissions to. + * @internal + */ + protected readonly _grantPrincipal?: iam.IPrincipal; + + /** + * Allows specifying security group connections for the instance. + * @internal + */ + protected readonly _connections?: Connections; + + /** + * TagManager for tagging support. + */ + protected readonly tags: TagManager; + + // ============================================= + + 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; + this._grantPrincipal = this.role; + const iamProfile: iam.CfnInstanceProfile | undefined = this.role ? new iam.CfnInstanceProfile(this, 'Profile', { + roles: [this.role!.roleName], + }) : undefined; + + if (props.securityGroup) { + this._connections = new Connections({ securityGroups: [props.securityGroup] }); + } + const securityGroupsToken = Lazy.list({ + produce: () => { + 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; + }, + }); + + const imageConfig: MachineImageConfig | undefined = props.machineImage?.getImage(this); + if (imageConfig) { + this.osType = imageConfig.osType; + } + + 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.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: { + 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: iamProfile !== undefined ? { + arn: iamProfile.getAtt('Arn').toString(), + } : undefined, + imageId: imageConfig?.imageId, + instanceType: props?.instanceType?.toString(), + instanceInitiatedShutdownBehavior: props?.instanceInitiatedShutdownBehavior, + instanceMarketOptions: marketOptions, + keyName: props?.keyName, + monitoring: props?.detailedMonitoring !== undefined ? { + enabled: props.detailedMonitoring, + } : undefined, + securityGroupIds: securityGroupsToken, + tagSpecifications: tagsToken, + 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, + + // 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, + + // 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')); + } + + /** + * 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('LaunchTemplate can only be used as IConnectable if a securityGroup is provided when contructing it.'); + } + 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._grantPrincipal) { + throw new Error('LaunchTemplate can only be used as IGrantable if a role is provided when constructing it.'); + } + return this._grantPrincipal; + } +} 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..dc91f6d795011 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/lib/private/ebs-util.ts @@ -0,0 +1,42 @@ +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, {}); +} + +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; + }); +} diff --git a/packages/@aws-cdk/aws-ec2/lib/volume.ts b/packages/@aws-cdk/aws-ec2/lib/volume.ts index 65d84b30eed55..36fb86faa2bac 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..882cd69ed282f --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/launch-template.test.ts @@ -0,0 +1,685 @@ +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, + Tags, +} from '@aws-cdk/core'; +import { + AmazonLinuxImage, + BlockDevice, + BlockDeviceVolume, + CpuCredits, + EbsDeviceVolumeType, + InstanceInitiatedShutdownBehavior, + InstanceType, + LaunchTemplate, + OperatingSystemType, + 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 + // 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: { + 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(); + expect(() => { template.connections; }).toThrow(); + expect(template.osType).toBeUndefined(); + expect(template.role).toBeUndefined(); + expect(template.userData).toBeUndefined(); + }); + + 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 + const template = new LaunchTemplate(stack, 'Template', { + machineImage: new AmazonLinuxImage(), + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + ImageId: { + Ref: stringLike('SsmParameterValueawsserviceamiamazonlinuxlatestamznami*Parameter'), + }, + }, + })); + expect(template.osType).toBe(OperatingSystemType.LINUX); + expect(template.userData).toBeUndefined(); + }); + + test('Given machineImage (Windows)', () => { + // WHEN + const template = 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'), + }, + }, + })); + expect(template.osType).toBe(OperatingSystemType.WINDOWS); + expect(template.userData).toBeUndefined(); + }); + + test('Given userData', () => { + // GIVEN + const userData = UserData.forLinux(); + userData.addCommands('echo Test'); + + // WHEN + const template = new LaunchTemplate(stack, 'Template', { + userData, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + UserData: { + 'Fn::Base64': '#!/bin/bash\necho Test', + }, + }, + })); + expect(template.userData).toBeDefined(); + }); + + test('Given role', () => { + // GIVEN + const role = new Role(stack, 'TestRole', { + assumedBy: new ServicePrincipal('ec2.amazonaws.com'), + }); + + // WHEN + const template = new LaunchTemplate(stack, 'Template', { + role, + }); + + // THEN + expectCDK(stack).to(countResources('AWS::IAM::Role', 1)); + expectCDK(stack).to(haveResourceLike('AWS::IAM::InstanceProfile', { + Roles: [ + { + Ref: 'TestRole6C9272DF', + }, + ], + })); + expectCDK(stack).to(haveResource('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: { + 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(); + expect(template.grantPrincipal).toBeDefined(); + }); + + 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).toBeDefined(); + 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', () => { + 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', + }, + }, + }, + })); + }); +});