diff --git a/examples/deadline/All-In-AWS-Infrastructure-SEP/python/package/app.py b/examples/deadline/All-In-AWS-Infrastructure-SEP/python/package/app.py index b921967a1..194e70c45 100644 --- a/examples/deadline/All-In-AWS-Infrastructure-SEP/python/package/app.py +++ b/examples/deadline/All-In-AWS-Infrastructure-SEP/python/package/app.py @@ -45,6 +45,7 @@ def main(): sep_props = sep_stack.SEPStackProps( docker_recipes_stage_path=os.path.join(os.path.dirname(os.path.realpath(__file__)), os.pardir, 'stage'), worker_machine_image=MachineImage.generic_linux(config.deadline_client_linux_ami_map), + create_resource_tracker_role=config.create_resource_tracker_role, ) service = sep_stack.SEPStack(app, 'SEPStack', props=sep_props, env=env) diff --git a/examples/deadline/All-In-AWS-Infrastructure-SEP/python/package/config.py b/examples/deadline/All-In-AWS-Infrastructure-SEP/python/package/config.py index 8e6e8e49d..21102770a 100644 --- a/examples/deadline/All-In-AWS-Infrastructure-SEP/python/package/config.py +++ b/examples/deadline/All-In-AWS-Infrastructure-SEP/python/package/config.py @@ -19,5 +19,13 @@ def __init__(self): # should match the one used for staging the render queue and usage based licensing recipes. self.deadline_client_linux_ami_map: Mapping[str, str] = {'us-west-2': 'ami-04ae356533dc07fb5'} + # Whether the DeadlineResourceTrackerAccessRole IAM role required by Deadline's Resource Tracker should be created in this CDK app. + # + # If you have previously used this same AWS account with either Deadline's AWS Portal feature or Spot Event Plugin and had used the + # Deadline Resource Tracker, then you likely have this IAM role in your account already unless you have removed it. + # + # Note: Deadline's Resource Tracker only supports being used by a single Deadline Repository per AWS account. + self.create_resource_tracker_role: bool = True + config: AppConfig = AppConfig() diff --git a/examples/deadline/All-In-AWS-Infrastructure-SEP/python/package/lib/sep_stack.py b/examples/deadline/All-In-AWS-Infrastructure-SEP/python/package/lib/sep_stack.py index ce7d52187..3732ef590 100644 --- a/examples/deadline/All-In-AWS-Infrastructure-SEP/python/package/lib/sep_stack.py +++ b/examples/deadline/All-In-AWS-Infrastructure-SEP/python/package/lib/sep_stack.py @@ -60,6 +60,8 @@ class SEPStackProps(StackProps): docker_recipes_stage_path: str # The IMachineImage to use for Workers (needs Deadline Client installed). worker_machine_image: IMachineImage + # Whether the DeadlineResourceTrackerAccessRole IAM role required by Deadline's Resource Tracker should be created in this CDK app. + create_resource_tracker_role: bool class SEPStack(Stack): @@ -159,15 +161,15 @@ def __init__(self, scope: Construct, stack_id: str, *, props: SEPStackProps, **k ), ) - # Creates the Resource Tracker Access role. This role is required to exist in your account so the resource tracker will work properly - # Note: If you already have a Resource Tracker IAM role in your account you can remove this code. - Role( - self, - 'ResourceTrackerRole', - assumed_by=ServicePrincipal('lambda.amazonaws.com'), - managed_policies= [ManagedPolicy.from_aws_managed_policy_name('AWSThinkboxDeadlineResourceTrackerAccessPolicy')], - role_name= 'DeadlineResourceTrackerAccessRole', - ) + if props.create_resource_tracker_role: + # Creates the Resource Tracker Access role. This role is required to exist in your account so the resource tracker will work properly + Role( + self, + 'ResourceTrackerRole', + assumed_by=ServicePrincipal('lambda.amazonaws.com'), + managed_policies= [ManagedPolicy.from_aws_managed_policy_name('AWSThinkboxDeadlineResourceTrackerAccessPolicy')], + role_name= 'DeadlineResourceTrackerAccessRole', + ) fleet = SpotEventPluginFleet( self, diff --git a/examples/deadline/All-In-AWS-Infrastructure-SEP/ts/bin/app.ts b/examples/deadline/All-In-AWS-Infrastructure-SEP/ts/bin/app.ts index 4e724423a..d66c05a2f 100644 --- a/examples/deadline/All-In-AWS-Infrastructure-SEP/ts/bin/app.ts +++ b/examples/deadline/All-In-AWS-Infrastructure-SEP/ts/bin/app.ts @@ -36,4 +36,5 @@ new SEPStack(app, 'SEPStack', { env, dockerRecipesStagePath: path.join(__dirname, '..', pkg.config.stage_path), // Stage directory in config is relative, make it absolute workerMachineImage: MachineImage.genericLinux(config.deadlineClientLinuxAmiMap), + createResourceTrackerRole: config.createResourceTrackerRole, }); diff --git a/examples/deadline/All-In-AWS-Infrastructure-SEP/ts/bin/config.ts b/examples/deadline/All-In-AWS-Infrastructure-SEP/ts/bin/config.ts index 8d496a8b8..0106dd1f5 100644 --- a/examples/deadline/All-In-AWS-Infrastructure-SEP/ts/bin/config.ts +++ b/examples/deadline/All-In-AWS-Infrastructure-SEP/ts/bin/config.ts @@ -16,6 +16,16 @@ class AppConfig { * is filled in. It can be used as-is, added to, or replaced. */ public readonly deadlineClientLinuxAmiMap: Record = {['us-west-2']: 'ami-04ae356533dc07fb5'}; + + /** + * Whether the DeadlineResourceTrackerAccessRole IAM role required by Deadline's Resource Tracker should be created in this CDK app. + * + * If you have previously used this same AWS account with either Deadline's AWS Portal feature or Spot Event Plugin and had used the + * Deadline Resource Tracker, then you likely have this IAM role in your account already unless you have removed it. + * + * Note: Deadline's Resource Tracker only supports being used by a single Deadline Repository per AWS account. + */ + public readonly createResourceTrackerRole: boolean = true; } export const config = new AppConfig(); diff --git a/examples/deadline/All-In-AWS-Infrastructure-SEP/ts/lib/sep-stack.ts b/examples/deadline/All-In-AWS-Infrastructure-SEP/ts/lib/sep-stack.ts index 4d553a9df..9464a4998 100644 --- a/examples/deadline/All-In-AWS-Infrastructure-SEP/ts/lib/sep-stack.ts +++ b/examples/deadline/All-In-AWS-Infrastructure-SEP/ts/lib/sep-stack.ts @@ -49,6 +49,11 @@ export interface SEPStackProps extends StackProps { * The {@link IMachineImage} to use for Workers (needs Deadline Client installed). */ readonly workerMachineImage: IMachineImage; + + /** + * Whether the DeadlineResourceTracker stack and supporting resources already exist or not. + */ + readonly createResourceTrackerRole: boolean; } export class SEPStack extends Stack { @@ -126,15 +131,16 @@ export class SEPStack extends Stack { trafficEncryption, }); - // Creates the Resource Tracker Access role. This role is required to exist in your account so the resource tracker will work properly - // Note: If you already have a Resource Tracker IAM role in your account you can remove this code. - new Role(this, 'ResourceTrackerRole', { - assumedBy: new ServicePrincipal('lambda.amazonaws.com'), - managedPolicies: [ - ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineResourceTrackerAccessPolicy'), - ], - roleName: 'DeadlineResourceTrackerAccessRole', - }); + if (props.createResourceTrackerRole) { + // Creates the Resource Tracker Access role. This role is required to exist in your account so the resource tracker will work properly + new Role(this, 'ResourceTrackerRole', { + assumedBy: new ServicePrincipal('lambda.amazonaws.com'), + managedPolicies: [ + ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineResourceTrackerAccessPolicy'), + ], + roleName: 'DeadlineResourceTrackerAccessRole', + }); + } const fleet = new SpotEventPluginFleet(this, 'SpotEventPluginFleet', { vpc, @@ -154,7 +160,7 @@ export class SEPStack extends Stack { new ConfigureSpotEventPlugin(this, 'ConfigureSpotEventPlugin', { vpc, - renderQueue: renderQueue, + renderQueue, spotFleets: [ fleet, ], diff --git a/packages/aws-rfdk/docs/diagrams/deadline/ConfigureSpotEventPlugin.svg b/packages/aws-rfdk/docs/diagrams/deadline/ConfigureSpotEventPlugin.svg index f38f83695..019a1b243 100644 --- a/packages/aws-rfdk/docs/diagrams/deadline/ConfigureSpotEventPlugin.svg +++ b/packages/aws-rfdk/docs/diagrams/deadline/ConfigureSpotEventPlugin.svg @@ -1,3 +1,3 @@ -
VPC
VPC
Private subnet
Private subnet
SpotEventPluginFleet
SpotEventPluginFleet
Amazon
Simple Storage
Service (S3)
Amazon...
CDK bootstrap
bucket
CDK boo...
Security group
Amazon
CloudWatch
Amazon...
SSM parameter
CloudWatch Agent
config file
SSM pa...
RenderQueue
RenderQueue
UsageBasedLicensing
UsageBasedLicensing
TCP
ports 1024-65535
TCP...
userdata
scripts
userd...
TLS
CA certificate
TLS...
Security groupSecurity group
uses
uses
fetch and use
fetch and use
uses
uses
Deadline Repository
configuration
Lambda function
Deadline...
Security group


HTTP(S)
port 8080/4433


HTTP(S)...
Elastic Network Interface
Elastic...
Application Load
Balancer
Applic...
Amazon Elastic Cloud
Compute (EC2)
Amazon E...
Spot Fleet
Spot Fleet
2. uses
2. uses
Spot Event Plugin configuration
Spot Event Plu...
2. sends
2. sends
2. sends
2. sends
Deadline Worker
Dead...
Repository
Repository
1. generates
1. generates
4. deploy and scale
4. deploy and scale
Log Group
Log Gr...
stream logs
stream logs
CloudWatch Agent
CloudWatch Age...
3. persists
3. persists
accesses
accesses
DocumentDB
Document...
AWS
Systems Manager
AWS...
AWS Lambda
AWS Lambda
Amazon
Machine Image
Amazon...
IAM Role
IAM Ro...
grants
grants
read access
read access
IAM Policy
IAM P...
AWS
Identity and Access
Management (IAM)
AWS...
Lambda
function
code
Lambd...
IAM Role
IAM Role
read access
read access
fetch/exec
fetch/exec
uses
uses
assigned
assigned
fetch/use
fetch/use
references
references
ConfigureSpotEventPlugin
ConfigureSpotEventPlugin
Viewer does not support full SVG 1.1
\ No newline at end of file +
VPC
VPC
Private subnet
Private subnet
SpotEventPluginFleet
SpotEventPluginFleet
Amazon
Simple Storage
Service (S3)
Amazon...
CDK bootstrap
bucket
CDK boo...
Security group
RenderQueue
RenderQueue
UsageBasedLicensing
UsageBasedLicensing
TCP
ports 1024-65535
TCP...
TLS
CA certificate
TLS...
Security groupSecurity group
uses
uses
fetch and use
fetch and use
uses
uses
Deadline Repository
configuration
Lambda function
Deadline...
Security group


HTTP(S)
port 8080/4433


HTTP(S)...
Elastic Network Interface
Elastic...
Application Load
Balancer
Applic...
Amazon Elastic Cloud
Compute (EC2)
Amazon E...
Spot Fleet
Spot Fleet
2. uses
2. uses
references
references
Spot Event Plugin configuration
Spot Event P...
2. sends
2. sends
2. sends
2. sends
Deadline Worker
Dead...
Repository
Repository
1. generates
1. generates
4. deploy and scale
4. deploy and scale
CloudWatch Agent
CloudWatch Age...
3. persists
3. persists
accesses
accesses
DocumentDB
Document...
AWS Lambda
AWS Lamb...
uses
uses
IAM Role
IAM Role
grants
grants
read access
read access
IAM
Policy
IAM...
AWS
Identity and Access
Management (IAM)
AWS...
Lambda
function
code
Lambd...
references
references
uses
uses
Launch Template Configs
Launch Tem...
Launch
Template
Launch...
read
access
read...
ConfigureSpotEventPlugin
ConfigureSpotEventPlugin
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/packages/aws-rfdk/docs/diagrams/deadline/SpotEventPluginFleet.svg b/packages/aws-rfdk/docs/diagrams/deadline/SpotEventPluginFleet.svg index bd344e3da..b149ef863 100644 --- a/packages/aws-rfdk/docs/diagrams/deadline/SpotEventPluginFleet.svg +++ b/packages/aws-rfdk/docs/diagrams/deadline/SpotEventPluginFleet.svg @@ -1,3 +1,3 @@ -
VPC
VPC
Private subnet
Private subnet
Amazon
Simple Storage
Service (S3)
Amazon...
CDK bootstrap
bucket
CDK b...
Security group
Amazon CloudWatch
Amazon C...
SSM parameter
CloudWatch Agent
config file
SSM...
RenderQueue
RenderQueue
read access
read access
grants
grants
IAM Role
IAM Role
UsageBasedLicensing
UsageBasedLicensing
TCP
ports 1024-65535
TCP...
read access
read access
read access
read access
write access
write access
IAM Policy
IAM...
userdata
scripts
user...
TLS
CA certificate
TLS...
references
references
ConfigureSpotEventPlugin
ConfigureSpotEventPlugin
Log Group
Log Gr...
Security group
HTTP(S)
port 8080/4433
HTTP(S)...
Security group
AMI
AMI
references
references
SpotEventPluginFleet
SpotEventPluginFleet
Viewer does not support full SVG 1.1
\ No newline at end of file +
VPC
VPC
Private subnet
Private subnet
Amazon
Simple Storage
Service (S3)
Amazon...
CDK bootstrap
bucket
CDK b...
Security group
Amazon CloudWatch
Amazon C...
SSM parameter
CloudWatch Agent
config file
SSM...
RenderQueue
RenderQueue
read access
read access
grants
grants
IAM Role
IAM Role
UsageBasedLicensing
UsageBasedLicensing
TCP
ports 1024-65535
TCP...
read access
read access
read access
read access
write access
write access
IAM Policy
IAM...
userdata
scripts
user...
TLS
CA certificate
TLS...
references
references
ConfigureSpotEventPlugin
ConfigureSpotEventPlugin
Log Group
Log Gr...
Security group
HTTP(S)
port 8080/4433
HTTP(S)...
Security group
AMI
AMI
Amazon EC2
Amazon E...
references
references
references
references
references
references
Launch Template Configs
Launch Te...
Launch Template
Launch T...
SpotEventPluginFleet
SpotEventPluginFleet
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/packages/aws-rfdk/docs/upgrade/index.md b/packages/aws-rfdk/docs/upgrade/index.md index b0f132aa2..e58fa0fba 100644 --- a/packages/aws-rfdk/docs/upgrade/index.md +++ b/packages/aws-rfdk/docs/upgrade/index.md @@ -7,3 +7,4 @@ upgrading to (or beyond) a version listed below, you should consult the the link * [`0.27.x`](./upgrading-0.27.md) * [`0.37.x`](./upgrading-0.37.md) * [`0.38.x`](./upgrading-0.38.md) +* [`0.39.x`](./upgrading-0.39.md) diff --git a/packages/aws-rfdk/docs/upgrade/upgrading-0.39.md b/packages/aws-rfdk/docs/upgrade/upgrading-0.39.md new file mode 100644 index 000000000..571121f19 --- /dev/null +++ b/packages/aws-rfdk/docs/upgrade/upgrading-0.39.md @@ -0,0 +1,13 @@ +# Upgrading to RFDK v0.39.x or Newer + +Starting in RFDK v0.39.0, the `SpotEventPluginFleet` construct now creates an [EC2 Launch Template](https://docs.aws.amazon.com/autoscaling/ec2/userguide/LaunchTemplates.html) +instead of using Launch Specifications. This change will reconfigure the Spot Event Plugin settings in Deadline to use a new Spot Fleet Request configuration. If you have active +Spot Fleet Requests created by the Spot Event Plugin, upgrading to RFDK v0.39.x and redeploying your render farm will orphan those Spot Fleet Requests. Therefore, we highly +recommend following these instructions to upgrade to RFDK v0.39.x: + +1. Disable the Spot Event Plugin in Deadline. Refer to the [Spot Event Plugin "State" option in Deadline](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot-configuration-options.html) +for more information. +2. Cancel any Spot Fleet Requests created by the Spot Event Plugin, which you can do by following these [instructions](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/work-with-spot-fleets.html#cancel-spot-fleet). +3. Upgrade to RFDK v0.39.x and redeploy your render farm. +4. Once the deployment is complete, re-enable the Spot Event Plugin in Deadline. Refer to the [Spot Event Plugin "State" option in Deadline](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot-configuration-options.html) +for more information. diff --git a/packages/aws-rfdk/lib/deadline/lib/configure-spot-event-plugin.ts b/packages/aws-rfdk/lib/deadline/lib/configure-spot-event-plugin.ts index bb0c8e885..8c0b8a82b 100644 --- a/packages/aws-rfdk/lib/deadline/lib/configure-spot-event-plugin.ts +++ b/packages/aws-rfdk/lib/deadline/lib/configure-spot-event-plugin.ts @@ -5,10 +5,6 @@ import * as path from 'path'; -import { - BlockDevice, - BlockDeviceVolume, -} from '@aws-cdk/aws-autoscaling'; import { IVpc, SubnetSelection, @@ -30,7 +26,6 @@ import { Construct, CustomResource, Duration, - Fn, IResolvable, Lazy, Stack, @@ -39,13 +34,9 @@ import { import { PluginSettings, SEPConfiguratorResourceProps, - LaunchSpecification, SpotFleetRequestConfiguration, SpotFleetRequestProps, - SpotFleetSecurityGroupId, SpotFleetTagSpecification, - BlockDeviceMappingProperty, - BlockDeviceProperty, } from '../../lambdas/nodejs/configure-spot-event-plugin'; import { IRenderQueue, @@ -157,6 +148,12 @@ export interface SpotEventPluginSettings { /** * Determines whether the Deadline Resource Tracker should be used. + * + * In addition to this property, the Spot Instances deployed by the Spot Event Plugin must also be configured to be tracked by the Resource Tracker using the + * [`trackInstancesWithResourceTracker`](https://docs.aws.amazon.com/rfdk/api/latest/docs/aws-rfdk.deadline.SpotEventPluginFleet.html#trackinstanceswithresourcetracker) + * property of the `SpotEventPluginFleet` construct, which is `true` by default. You can set that property to `false` for fleets that you would like to opt out of the + * Resource Tracker. + * * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/resource-tracker-overview.html * * @default true @@ -344,6 +341,9 @@ export interface ConfigureSpotEventPluginProps { * - A policy to pass a fleet and instance role * - A policy to create tags for spot fleet requests * + * The Spot Fleet Requests that this construct configures Deadline to create will always use the latest version of the + * corresponding EC2 Launch Template that was created for them. + * * ![architecture diagram](/diagrams/deadline/ConfigureSpotEventPlugin.svg) * * Resources Deployed @@ -352,6 +352,7 @@ export interface ConfigureSpotEventPluginProps { * - A CloudFormation Custom Resource that triggers execution of the Lambda on stack deployment, update, and deletion. * - An Amazon CloudWatch log group that records history of the AWS Lambda's execution. * - An IAM Policy attached to Render Queue's Role. + * - EC2 Launch Templates for each Spot Event Plugin fleet. * * Security Considerations * ------------------------ @@ -418,7 +419,10 @@ export class ConfigureSpotEventPlugin extends Construct { actions: [ 'ec2:CreateTags', ], - resources: ['arn:aws:ec2:*:*:spot-fleet-request/*'], + resources: [ + 'arn:aws:ec2:*:*:spot-fleet-request/*', + 'arn:aws:ec2:*:*:volume/*', + ], }), ], roles: [ @@ -545,54 +549,15 @@ export class ConfigureSpotEventPlugin extends Construct { /** * Construct Spot Fleet Configurations from the provided fleet. - * Each congiguration is a mapping between one Deadline Group and one Spot Fleet Request Configuration. + * Each configuration is a mapping between one Deadline Group and one Spot Fleet Request Configuration. */ private generateSpotFleetRequestConfig(fleet: SpotEventPluginFleet): SpotFleetRequestConfiguration[] { - const securityGroupsToken = Lazy.any({ produce: () => { - return fleet.securityGroups.map(sg => { - const securityGroupId: SpotFleetSecurityGroupId = { - GroupId: sg.securityGroupId, - }; - return securityGroupId; - }); - }}); - - const userDataToken = Lazy.string({ produce: () => Fn.base64(fleet.userData.render()) }); - - const blockDeviceMappings = (fleet.blockDevices !== undefined ? - this.synthesizeBlockDeviceMappings(fleet.blockDevices) : undefined); - - const { subnetIds } = fleet.subnets; - const subnetId = subnetIds.join(','); - - const instanceTagsToken = this.tagSpecifications(fleet, SpotFleetResourceType.INSTANCE); const spotFleetRequestTagsToken = this.tagSpecifications(fleet, SpotFleetResourceType.SPOT_FLEET_REQUEST); - const launchSpecifications: LaunchSpecification[] = []; - - fleet.instanceTypes.map(instanceType => { - const launchSpecification: LaunchSpecification = { - BlockDeviceMappings: blockDeviceMappings, - IamInstanceProfile: { - Arn: fleet.instanceProfile.attrArn, - }, - ImageId: fleet.imageId, - KeyName: fleet.keyName, - // Need to convert from IResolvable to bypass TypeScript - SecurityGroups: (securityGroupsToken as unknown) as SpotFleetSecurityGroupId[], - SubnetId: subnetId, - // Need to convert from IResolvable to bypass TypeScript - TagSpecifications: (instanceTagsToken as unknown) as SpotFleetTagSpecification[], - UserData: userDataToken, - InstanceType: instanceType.toString(), - }; - launchSpecifications.push(launchSpecification); - }); - const spotFleetRequestProps: SpotFleetRequestProps = { AllocationStrategy: fleet.allocationStrategy, IamFleetRole: fleet.fleetRole.roleArn, - LaunchSpecifications: launchSpecifications, + LaunchTemplateConfigs: fleet._launchTemplateConfigs, ReplaceUnhealthyInstances: true, // In order to work with Deadline, the 'Target Capacity' of the Spot fleet Request is // the maximum number of Workers that Deadline will start. @@ -615,49 +580,6 @@ export class ConfigureSpotEventPlugin extends Construct { return spotFleetRequestConfigurations; } - /** - * Synthesize an array of block device mappings from a list of block devices - * - * @param blockDevices list of block devices - */ - private synthesizeBlockDeviceMappings(blockDevices: BlockDevice[]): BlockDeviceMappingProperty[] { - return blockDevices.map(({ deviceName, volume, mappingEnabled }) => { - const { virtualName, ebsDevice: ebs } = volume; - - if (volume === BlockDeviceVolume._NO_DEVICE || mappingEnabled === false) { - return { - DeviceName: deviceName, - // To omit the device from the block device mapping, specify an empty string. - // See https://docs.aws.amazon.com/cli/latest/reference/ec2/request-spot-fleet.html - NoDevice: '', - }; - } - - let Ebs: BlockDeviceProperty | undefined; - - if (ebs) { - const { iops, volumeType, volumeSize, snapshotId, deleteOnTermination } = ebs; - - Ebs = { - DeleteOnTermination: deleteOnTermination, - Iops: iops, - SnapshotId: snapshotId, - VolumeSize: volumeSize, - VolumeType: volumeType, - // encrypted is not exposed as part of ebsDeviceProps so we need to access it via []. - // eslint-disable-next-line dot-notation - Encrypted: 'encrypted' in ebs ? ebs['encrypted'] : undefined, - }; - } - - return { - DeviceName: deviceName, - Ebs, - VirtualName: virtualName, - }; - }); - } - private mergeSpotFleetRequestConfigs(spotFleets?: SpotEventPluginFleet[]): SpotFleetRequestConfiguration | undefined { if (!spotFleets || spotFleets.length === 0) { return undefined; diff --git a/packages/aws-rfdk/lib/deadline/lib/spot-event-plugin-fleet.ts b/packages/aws-rfdk/lib/deadline/lib/spot-event-plugin-fleet.ts index e28bc4a4c..dfca00494 100644 --- a/packages/aws-rfdk/lib/deadline/lib/spot-event-plugin-fleet.ts +++ b/packages/aws-rfdk/lib/deadline/lib/spot-event-plugin-fleet.ts @@ -14,6 +14,8 @@ import { InstanceType, ISecurityGroup, IVpc, + LaunchTemplate, + LaunchTemplateSpecialVersions, OperatingSystemType, Port, SecurityGroup, @@ -37,6 +39,7 @@ import { Expiration, Stack, TagManager, + Tags, TagType, } from '@aws-cdk/core'; import { @@ -46,6 +49,9 @@ import { import { tagConstruct, } from '../../core/lib/runtime-info'; +import { + LaunchTemplateConfig, +} from '../../lambdas/nodejs/configure-spot-event-plugin'; import { IRenderQueue, } from './render-queue'; @@ -214,6 +220,17 @@ export interface SpotEventPluginFleetProps { * @default: Not used. */ readonly userDataProvider?: IInstanceUserDataProvider; + + /** + * Whether the instances in the Spot Fleet should be tracked by Deadline Resource Tracker. + * + * In addition to this property, the Spot Event Plugin must also be configured to use the Resource tracker by using the + * [`enableResourceTracker`](https://docs.aws.amazon.com/rfdk/api/latest/docs/aws-rfdk.deadline.SpotEventPluginSettings.html#enableresourcetracker) + * property of the `ConfigureSpotEventPlugin` construct, which is `true` by default. + * + * @default true + */ + readonly trackInstancesWithResourceTracker?: boolean; } /** @@ -270,6 +287,7 @@ export interface ISpotEventPluginFleet extends IConnectable, IScriptHost, IGrant * - An Amazon CloudWatch log group that contains the Deadline Worker, Deadline Launcher, and instance-startup logs for the instances * in the fleet. * - A security Group if security groups are not provided. + * - An EC2 Launch Template for the Spot Fleet. * * Security Considerations * ------------------------ @@ -351,9 +369,9 @@ export class SpotEventPluginFleet extends Construct implements ISpotEventPluginF public readonly fleetRole: IRole; /** - * An id of the Worker AMI. + * The Worker AMI. */ - public readonly imageId: string; + public readonly machineImage: IMachineImage; /** * The tags to apply during creation of instances and of the Spot Fleet Request. @@ -418,6 +436,16 @@ export class SpotEventPluginFleet extends Construct implements ISpotEventPluginF */ public readonly blockDevices?: BlockDevice[]; + /** + * The Launch Template for this Spot Fleet. This launch template does not specify an instance type or subnet. + */ + public readonly launchTemplate: LaunchTemplate; + + /** + * @internal + */ + public readonly _launchTemplateConfigs: LaunchTemplateConfig[]; + constructor(scope: Construct, id: string, props: SpotEventPluginFleetProps) { super(scope, id); @@ -464,7 +492,7 @@ export class SpotEventPluginFleet extends Construct implements ISpotEventPluginF const imageConfig = props.workerMachineImage.getImage(this); this.osType = imageConfig.osType; this.userData = props.userData ?? imageConfig.userData; - this.imageId = imageConfig.imageId; + this.machineImage = props.workerMachineImage; const workerConfig = new WorkerInstanceConfiguration(this, id, { worker: this, @@ -490,6 +518,9 @@ export class SpotEventPluginFleet extends Construct implements ISpotEventPluginF // Tag deployed resources with RFDK meta-data tagConstruct(this); + + this.launchTemplate = this.createLaunchTemplate(props.trackInstancesWithResourceTracker ?? true); + this._launchTemplateConfigs = this.createLaunchTemplateConfigs(); } /** @@ -506,6 +537,47 @@ export class SpotEventPluginFleet extends Construct implements ISpotEventPluginF other.connections.allowTo(this.connections, this.remoteControlPorts, 'Worker remote command listening port'); } + private createLaunchTemplate(resourceTrackerEnabled: boolean): LaunchTemplate { + const launchTemplate = new LaunchTemplate(this, 'LaunchTemplate', { + blockDevices: this.blockDevices, + role: this.fleetInstanceRole, + machineImage: this.machineImage, + keyName: this.keyName, + securityGroup: this.securityGroups[0], + userData: this.userData, + }); + if (this.securityGroups.length > 1) { + launchTemplate.connections.addSecurityGroup(...this.securityGroups.slice(1)); + } + + Tags.of(launchTemplate).add(resourceTrackerEnabled ? 'DeadlineTrackedAWSResource' : 'DeadlineResourceTracker', 'SpotEventPlugin'); + + return launchTemplate; + } + + private createLaunchTemplateConfigs(): LaunchTemplateConfig[] { + const launchTemplateConfigs: LaunchTemplateConfig[] = []; + + // Create a launch template config for each instance type + subnet pair + this.instanceTypes.forEach(instanceType => { + this.subnets.subnetIds.forEach(subnetId => { + launchTemplateConfigs.push({ + LaunchTemplateSpecification: { + Version: LaunchTemplateSpecialVersions.LATEST_VERSION, + LaunchTemplateId: this.launchTemplate.launchTemplateId, + LaunchTemplateName: this.launchTemplate.launchTemplateName, + }, + Overrides: [{ + InstanceType: instanceType.toString(), + SubnetId: subnetId, + }], + }); + }); + }); + + return launchTemplateConfigs; + } + private validateProps(props: SpotEventPluginFleetProps): void { this.validateFleetInstanceRole(props.fleetInstanceRole); this.validateInstanceTypes(props.instanceTypes); diff --git a/packages/aws-rfdk/lib/deadline/test/configure-spot-event-plugin.test.ts b/packages/aws-rfdk/lib/deadline/test/configure-spot-event-plugin.test.ts index 4ae3f4ef1..5aa83d9ce 100644 --- a/packages/aws-rfdk/lib/deadline/test/configure-spot-event-plugin.test.ts +++ b/packages/aws-rfdk/lib/deadline/test/configure-spot-event-plugin.test.ts @@ -12,9 +12,6 @@ import { countResourcesLike, ABSENT, } from '@aws-cdk/assert'; -import { - BlockDeviceVolume, EbsDeviceVolumeType, -} from '@aws-cdk/aws-autoscaling'; import { GenericWindowsImage, InstanceClass, @@ -32,7 +29,6 @@ import { App, Duration, Expiration, - Fn, Stack, } from '@aws-cdk/core'; import { X509CertificatePem } from '../../core'; @@ -226,56 +222,33 @@ describe('ConfigureSpotEventPlugin', () => { const rfdkTag = tagFields(fleet); // THEN - cdkExpect(stack).to(haveResourceLike('Custom::RFDK_ConfigureSpotEventPlugin', objectLike({ - spotFleetRequestConfigurations: objectLike({ - [groupName]: objectLike({ - AllocationStrategy: fleet.allocationStrategy.toString(), + cdkExpect(stack).to(haveResourceLike('Custom::RFDK_ConfigureSpotEventPlugin', { + spotFleetRequestConfigurations: { + [groupName]: { + AllocationStrategy: 'lowestPrice', IamFleetRole: stack.resolve(fleet.fleetRole.roleArn), - LaunchSpecifications: arrayWith( - objectLike({ - IamInstanceProfile: { - Arn: stack.resolve(fleet.instanceProfile.attrArn), + LaunchTemplateConfigs: [ + { + LaunchTemplateSpecification: { + Version: '$Latest', + LaunchTemplateId: stack.resolve(fleet.launchTemplate.launchTemplateId), }, - ImageId: fleet.imageId, - SecurityGroups: arrayWith( - objectLike({ - GroupId: stack.resolve(fleet.securityGroups[0].securityGroupId), - }), - ), - SubnetId: stack.resolve(Fn.join('', [vpc.privateSubnets[0].subnetId, ',', vpc.privateSubnets[1].subnetId])), - TagSpecifications: arrayWith( - objectLike({ - ResourceType: 'instance', - Tags: arrayWith( - objectLike({ - Key: rfdkTag.name, - Value: rfdkTag.value, - }), - ), - }), - ), - UserData: stack.resolve(Fn.base64(fleet.userData.render())), - InstanceType: fleet.instanceTypes[0].toString(), - }), - ), - ReplaceUnhealthyInstances: true, - TargetCapacity: fleet.maxCapacity, - TerminateInstancesWithExpiration: true, - Type: 'maintain', + }, + ], TagSpecifications: arrayWith( objectLike({ ResourceType: 'spot-fleet-request', Tags: arrayWith( - objectLike({ + { Key: rfdkTag.name, Value: rfdkTag.value, - }), + }, ), }), ), - }), - }), - }))); + }, + }, + })); }); test('adds policies to the render queue', () => { @@ -315,7 +288,10 @@ describe('ConfigureSpotEventPlugin', () => { { Action: 'ec2:CreateTags', Effect: 'Allow', - Resource: 'arn:aws:ec2:*:*:spot-fleet-request/*', + Resource: [ + 'arn:aws:ec2:*:*:spot-fleet-request/*', + 'arn:aws:ec2:*:*:volume/*', + ], }, ], }, @@ -433,228 +409,6 @@ describe('ConfigureSpotEventPlugin', () => { }), }))); }); - - test('fleet with block devices', () => { - // GIVEN - const deviceName = '/dev/xvda'; - const volumeSize = 50; - const encrypted = true; - const deleteOnTermination = true; - const iops = 100; - const volumeType = EbsDeviceVolumeType.STANDARD; - - const fleetWithCustomProps = new SpotEventPluginFleet(stack, 'SpotEventPluginFleet', { - vpc, - renderQueue, - deadlineGroups: [ - groupName, - ], - instanceTypes: [ - InstanceType.of(InstanceClass.T3, InstanceSize.LARGE), - ], - workerMachineImage, - maxCapacity: 1, - blockDevices: [{ - deviceName, - volume: BlockDeviceVolume.ebs(volumeSize, { - encrypted, - deleteOnTermination, - iops, - volumeType, - }), - }], - }); - - // WHEN - new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { - vpc, - renderQueue: renderQueue, - spotFleets: [ - fleetWithCustomProps, - ], - }); - - // THEN - cdkExpect(stack).to(haveResourceLike('Custom::RFDK_ConfigureSpotEventPlugin', objectLike({ - spotFleetRequestConfigurations: objectLike({ - [groupName]: objectLike({ - LaunchSpecifications: arrayWith(objectLike({ - BlockDeviceMappings: arrayWith(objectLike({ - DeviceName: deviceName, - Ebs: objectLike({ - DeleteOnTermination: deleteOnTermination, - Iops: iops, - VolumeSize: volumeSize, - VolumeType: volumeType, - Encrypted: encrypted, - }), - })), - })), - }), - }), - }))); - }); - - test('fleet with block devices with custom volume', () => { - // GIVEN - const deviceName = '/dev/xvda'; - const virtualName = 'name'; - const snapshotId = 'snapshotId'; - const volumeSize = 50; - const deleteOnTermination = true; - const iops = 100; - const volumeType = EbsDeviceVolumeType.STANDARD; - - const fleetWithCustomProps = new SpotEventPluginFleet(stack, 'SpotEventPluginFleet', { - vpc, - renderQueue, - deadlineGroups: [ - groupName, - ], - instanceTypes: [ - InstanceType.of(InstanceClass.T3, InstanceSize.LARGE), - ], - workerMachineImage, - maxCapacity: 1, - blockDevices: [{ - deviceName: deviceName, - volume: { - ebsDevice: { - deleteOnTermination, - iops, - volumeSize, - volumeType, - snapshotId, - }, - virtualName, - }, - }], - }); - - // WHEN - new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { - vpc, - renderQueue: renderQueue, - spotFleets: [ - fleetWithCustomProps, - ], - }); - - // THEN - cdkExpect(stack).to(haveResourceLike('Custom::RFDK_ConfigureSpotEventPlugin', objectLike({ - spotFleetRequestConfigurations: objectLike({ - [groupName]: objectLike({ - LaunchSpecifications: arrayWith(objectLike({ - BlockDeviceMappings: arrayWith(objectLike({ - DeviceName: deviceName, - Ebs: objectLike({ - SnapshotId: snapshotId, - DeleteOnTermination: deleteOnTermination, - Iops: iops, - VolumeSize: volumeSize, - VolumeType: volumeType, - Encrypted: ABSENT, - }), - VirtualName: virtualName, - })), - })), - }), - }), - }))); - }); - - test('fleet with block devices with no device', () => { - // GIVEN - const deviceName = '/dev/xvda'; - const volume = BlockDeviceVolume.noDevice(); - - const fleetWithCustomProps = new SpotEventPluginFleet(stack, 'SpotEventPluginFleet', { - vpc, - renderQueue, - deadlineGroups: [ - groupName, - ], - instanceTypes: [ - InstanceType.of(InstanceClass.T3, InstanceSize.LARGE), - ], - workerMachineImage, - maxCapacity: 1, - blockDevices: [{ - deviceName: deviceName, - volume, - }], - }); - - // WHEN - new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { - vpc, - renderQueue: renderQueue, - spotFleets: [ - fleetWithCustomProps, - ], - }); - - // THEN - cdkExpect(stack).to(haveResourceLike('Custom::RFDK_ConfigureSpotEventPlugin', objectLike({ - spotFleetRequestConfigurations: objectLike({ - [groupName]: objectLike({ - LaunchSpecifications: arrayWith(objectLike({ - BlockDeviceMappings: arrayWith(objectLike({ - DeviceName: deviceName, - NoDevice: '', - })), - })), - }), - }), - }))); - }); - - test('fleet with deprecated mappingEnabled', () => { - // GIVEN - const deviceName = '/dev/xvda'; - const mappingEnabled = false; - - const fleetWithCustomProps = new SpotEventPluginFleet(stack, 'SpotEventPluginFleet', { - vpc, - renderQueue, - deadlineGroups: [ - groupName, - ], - instanceTypes: [ - InstanceType.of(InstanceClass.T3, InstanceSize.LARGE), - ], - workerMachineImage, - maxCapacity: 1, - blockDevices: [{ - deviceName: deviceName, - volume: BlockDeviceVolume.ebs(50), - mappingEnabled, - }], - }); - - // WHEN - new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { - vpc, - renderQueue: renderQueue, - spotFleets: [ - fleetWithCustomProps, - ], - }); - - // THEN - cdkExpect(stack).to(haveResourceLike('Custom::RFDK_ConfigureSpotEventPlugin', objectLike({ - spotFleetRequestConfigurations: objectLike({ - [groupName]: objectLike({ - LaunchSpecifications: arrayWith(objectLike({ - BlockDeviceMappings: arrayWith(objectLike({ - DeviceName: deviceName, - NoDevice: '', - })), - })), - }), - }), - }))); - }); }); test('only one object allowed per render queue', () => { @@ -860,12 +614,20 @@ describe('ConfigureSpotEventPlugin', () => { test('throws with the same group name', () => { // WHEN function createConfigureSpotEventPlugin() { + const duplicateFleet = new SpotEventPluginFleet(stack, 'DuplicateSpotFleet', { + vpc, + renderQueue, + workerMachineImage: fleet.machineImage, + instanceTypes: fleet.instanceTypes, + maxCapacity: fleet.maxCapacity, + deadlineGroups: fleet.deadlineGroups, + }); new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { vpc, renderQueue: renderQueue, spotFleets: [ fleet, - fleet, + duplicateFleet, ], }); } diff --git a/packages/aws-rfdk/lib/deadline/test/spot-event-plugin-fleet.test.ts b/packages/aws-rfdk/lib/deadline/test/spot-event-plugin-fleet.test.ts index f4d2fb2a3..886381bb0 100644 --- a/packages/aws-rfdk/lib/deadline/test/spot-event-plugin-fleet.test.ts +++ b/packages/aws-rfdk/lib/deadline/test/spot-event-plugin-fleet.test.ts @@ -52,6 +52,7 @@ import { tagFields } from '../../core/lib/runtime-info'; import { escapeTokenRegex, } from '../../core/test/token-regex-helpers'; +import { LaunchTemplateConfig } from '../../lambdas/nodejs/configure-spot-event-plugin'; import { IHost, InstanceUserDataProvider, @@ -61,6 +62,7 @@ import { VersionQuery, SpotEventPluginFleet, SpotFleetAllocationStrategy, + SpotFleetResourceType, } from '../lib'; let app: App; @@ -347,19 +349,133 @@ describe('SpotEventPluginFleet', () => { expect(fleet.keyName).toBeUndefined(); }); - test('.defaultSubnets is true', () => { + test('creates launch template configs for each instance type', () => { // WHEN + const moreInstanceTypes: InstanceType[] = [ + new InstanceType('t2.small'), + new InstanceType('c5.large'), + ]; const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { vpc, renderQueue, deadlineGroups, + workerMachineImage, + maxCapacity, + instanceTypes: moreInstanceTypes, + }); + + // THEN + expect(fleet._launchTemplateConfigs.length).toBeGreaterThanOrEqual(moreInstanceTypes.length); + moreInstanceTypes.forEach(instanceType => { + expect(fleet._launchTemplateConfigs.some(ltc => { + return (ltc as LaunchTemplateConfig).Overrides.some(override => override.InstanceType === instanceType.toString()); + })).toBeTruthy(); + }); + }); + + test('creates launch template configs for each subnet id', () => { + // WHEN + const subnets = vpc.selectSubnets({ subnetType: SubnetType.PRIVATE }); + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + instanceTypes, + deadlineGroups, + workerMachineImage, + maxCapacity, + vpcSubnets: subnets, + }); + + // THEN + expect(fleet._launchTemplateConfigs.length).toBeGreaterThanOrEqual(subnets.subnets.length); + subnets.subnetIds.forEach(subnetId => { + expect(fleet._launchTemplateConfigs.some(ltc => { + return (ltc as LaunchTemplateConfig).Overrides.some(override => override.SubnetId === subnetId); + })).toBeTruthy(); + }); + }); + + test('add tag indicating resource tracker is enabled', () => { + // WHEN + new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, instanceTypes, + deadlineGroups, workerMachineImage, maxCapacity, }); // THEN - expect(fleet.defaultSubnets).toBeTruthy(); + expectCDK(spotFleetStack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: objectLike({ + TagSpecifications: arrayWith({ + ResourceType: 'instance', + Tags: arrayWith({ + Key: 'DeadlineTrackedAWSResource', + Value: 'SpotEventPlugin', + }), + }), + }), + })); + }); + + test('adds multiple fleet security groups to launch template', () => { + // GIVEN + const securityGroups = [ + new SecurityGroup(stack, 'NewFleetSecurityGroup1', { vpc }), + new SecurityGroup(stack, 'NewFleetSecurityGroup2', { vpc }), + ]; + + // WHEN + new SpotEventPluginFleet(spotFleetStack, 'SpotFleet2', { + vpc, + renderQueue, + deadlineGroups: ['group2'], + instanceTypes: [new InstanceType('t2.micro')], + workerMachineImage, + maxCapacity: 1, + securityGroups, + }); + + // THEN + expectCDK(spotFleetStack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: objectLike({ + SecurityGroupIds: securityGroups.map(sg => spotFleetStack.resolve(sg.securityGroupId)), + }), + })); + }); + + test('adds fleet tags to launch template', () => { + // GIVEN + const tag = { + key: 'mykey', + value: 'myvalue', + }; + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + instanceTypes, + deadlineGroups, + workerMachineImage, + maxCapacity, + }); + + // WHEN + Tags.of(fleet).add(tag.key, tag.value); + + // THEN + expectCDK(spotFleetStack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: objectLike({ + TagSpecifications: arrayWith({ + ResourceType: SpotFleetResourceType.INSTANCE.toString(), + Tags: arrayWith({ + Key: tag.key, + Value: tag.value, + }), + }), + }), + })); }); }); @@ -379,7 +495,6 @@ describe('SpotEventPluginFleet', () => { // THEN expect(fleet.deadlineGroups).toStrictEqual(deadlineGroups.map(group => group.toLocaleLowerCase())); expect(fleet.instanceTypes).toBe(instanceTypes); - expect(fleet.imageId).toBe(imageConfig.imageId); expect(fleet.osType).toBe(imageConfig.osType); expect(fleet.maxCapacity).toBe(maxCapacity); }); @@ -785,6 +900,32 @@ describe('SpotEventPluginFleet', () => { LogGroupName: testPrefix + id, })); }); + + test('adds tag indicating resource tracker is not enabled', () => { + // WHEN + new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + instanceTypes, + deadlineGroups, + workerMachineImage, + maxCapacity, + trackInstancesWithResourceTracker: false, + }); + + // THEN + expectCDK(spotFleetStack).to(haveResourceLike('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: objectLike({ + TagSpecifications: arrayWith({ + ResourceType: 'instance', + Tags: arrayWith({ + Key: 'DeadlineResourceTracker', + Value: 'SpotEventPlugin', + }), + }), + }), + })); + }); }); describe('allowing remote control', () => { diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/conversion.ts b/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/conversion.ts index 7ebff8b17..ecf33e1f1 100644 --- a/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/conversion.ts +++ b/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/conversion.ts @@ -4,15 +4,13 @@ */ import { - BlockDeviceMappingProperty, - BlockDeviceProperty, PluginSettings, - SpotFleetInstanceProfile, SpotFleetRequestConfiguration, - LaunchSpecification, SpotFleetRequestProps, - SpotFleetSecurityGroupId, SpotFleetTagSpecification, + LaunchTemplateConfig, + LaunchTemplateSpecification, + LaunchTemplateOverrides, } from './types'; /** @@ -27,7 +25,9 @@ export function convertSpotFleetRequestConfiguration(spotFleetRequestConfigs: Sp const convertedSpotFleetRequestProps: SpotFleetRequestProps = { AllocationStrategy: validateString(sfrConfigs.AllocationStrategy, `${group_name}.AllocationStrategy`), IamFleetRole: validateString(sfrConfigs.IamFleetRole, `${group_name}.IamFleetRole`), - LaunchSpecifications: convertLaunchSpecifications(sfrConfigs.LaunchSpecifications, `${group_name}.LaunchSpecifications`), + // Empty array needed for compatibility with SEP since it expects an array for the LaunchSpecifications property + LaunchSpecifications: [], + LaunchTemplateConfigs: sfrConfigs.LaunchTemplateConfigs ? validateLaunchTemplateConfigs(sfrConfigs.LaunchTemplateConfigs, `${group_name}.LaunchTemplateConfigs`) : undefined, ReplaceUnhealthyInstances: convertToBoolean(sfrConfigs.ReplaceUnhealthyInstances, `${group_name}.ReplaceUnhealthyInstances`), TargetCapacity: convertToInt(sfrConfigs.TargetCapacity, `${group_name}.TargetCapacity`), TerminateInstancesWithExpiration: convertToBoolean(sfrConfigs.TerminateInstancesWithExpiration, `${group_name}.TerminateInstancesWithExpiration`), @@ -73,27 +73,6 @@ export function validateProperty(isValid: (input: any) => boolean, property: any } } -export function isValidSecurityGroup(securityGroup: SpotFleetSecurityGroupId): boolean { - if (!securityGroup || typeof(securityGroup) !== 'object' || Array.isArray(securityGroup)) { return false; } - // We also verify groupId with validateString later - if (!securityGroup.GroupId || typeof(securityGroup.GroupId) !== 'string') { return false; } - return true; -} - -export function convertSecurityGroups(securityGroups: SpotFleetSecurityGroupId[], propertyName: string): SpotFleetSecurityGroupId[] { - validateArray(securityGroups, propertyName); - - const convertedSecurityGroups: SpotFleetSecurityGroupId[] = securityGroups.map(securityGroup => { - validateProperty(isValidSecurityGroup, securityGroup, propertyName); - const convertedSecurityGroup: SpotFleetSecurityGroupId = { - GroupId: validateString(securityGroup.GroupId, `${propertyName}.GroupId`), - }; - return convertedSecurityGroup; - }); - - return convertedSecurityGroups; -} - export function isValidTagSpecification(tagSpecification: SpotFleetTagSpecification): boolean { if (!tagSpecification || typeof(tagSpecification) !== 'object' || Array.isArray(tagSpecification)) { return false; } // We also verify resourceType with validateString later @@ -122,79 +101,36 @@ export function convertTagSpecifications(tagSpecifications: SpotFleetTagSpecific return convertedTagSpecifications; } -export function isValidDeviceMapping(deviceMapping: BlockDeviceMappingProperty): boolean { - if (!deviceMapping || typeof(deviceMapping) !== 'object' || Array.isArray(deviceMapping)) { return false; } - // We validate the rest properties when convert them. - return true; -} - -export function convertEbs(ebs: BlockDeviceProperty, propertyName: string): BlockDeviceProperty { - const convertedEbs: BlockDeviceProperty = { - DeleteOnTermination: convertToBooleanOptional(ebs.DeleteOnTermination, `${propertyName}.DeleteOnTermination`), - Encrypted: convertToBooleanOptional(ebs.Encrypted, `${propertyName}.Encrypted`), - Iops: convertToIntOptional(ebs.Iops, `${propertyName}.Iops`), - SnapshotId: validateStringOptional(ebs.SnapshotId, `${propertyName}.SnapshotId`), - VolumeSize: convertToIntOptional(ebs.VolumeSize, `${propertyName}.VolumeSize`), - VolumeType: validateStringOptional(ebs.VolumeType, `${propertyName}.VolumeType`), - }; - return convertedEbs; -} - -export function convertBlockDeviceMapping(blockDeviceMappings: BlockDeviceMappingProperty[], propertyName: string): BlockDeviceMappingProperty[] { - validateArray(blockDeviceMappings, propertyName); - const convertedBlockDeviceMappings: BlockDeviceMappingProperty[] = blockDeviceMappings.map(deviceMapping => { - validateProperty(isValidDeviceMapping, deviceMapping, propertyName); - - const convertedDeviceMapping: BlockDeviceMappingProperty = { - DeviceName: validateString(deviceMapping.DeviceName, `${propertyName}.DeviceName`), - Ebs: deviceMapping.Ebs ? convertEbs(deviceMapping.Ebs, `${propertyName}.Ebs`) : undefined, - NoDevice: validateStringOptional(deviceMapping.NoDevice, `${propertyName}.NoDevice`), - VirtualName: validateStringOptional(deviceMapping.VirtualName, `${propertyName}.VirtualName`), - }; - return convertedDeviceMapping; - }); - return convertedBlockDeviceMappings; -} - -export function isValidInstanceProfile(instanceProfile: SpotFleetInstanceProfile): boolean { - if (!instanceProfile || typeof(instanceProfile) !== 'object' || Array.isArray(instanceProfile)) { return false; } - // We also verify arn with validateString later - if (!instanceProfile.Arn || typeof(instanceProfile.Arn) !== 'string') { return false; } - return true; +export function validateLaunchTemplateSpecification(launchTemplateSpecification: LaunchTemplateSpecification, propertyName: string): void { + const id = validateStringOptional(launchTemplateSpecification.LaunchTemplateId, `${propertyName}.LaunchTemplateId`); + const name = validateStringOptional(launchTemplateSpecification.LaunchTemplateName, `${propertyName}.LaunchTemplateName`); + if ((id === undefined && name === undefined) || (id !== undefined && name !== undefined)) { + throw new Error(`Exactly one of ${propertyName}.LaunchTemplateId or ${propertyName}.LaunchTemplateName must be specified, but got: ${id} and ${name} respectively`); + } + validateString(launchTemplateSpecification.Version, `${propertyName}.Version`); } -export function convertInstanceProfile(instanceProfile: SpotFleetInstanceProfile, propertyName: string): SpotFleetInstanceProfile { - validateProperty(isValidInstanceProfile, instanceProfile, propertyName); - const convertedInstanceProfile: SpotFleetInstanceProfile = { - Arn: validateString(instanceProfile.Arn, `${propertyName}.Arn`), - }; - return convertedInstanceProfile; +export function validateLaunchTemplateOverrides(launchTemplateOverrides: LaunchTemplateOverrides, propertyName: string) { + validateStringOptional(launchTemplateOverrides.AvailabilityZone, `${propertyName}.AvailabilityZone`); + validateStringOptional(launchTemplateOverrides.InstanceType, `${propertyName}.InstanceType`); + validateStringOptional(launchTemplateOverrides.SpotPrice, `${propertyName}.SpotPrice`); + validateStringOptional(launchTemplateOverrides.SubnetId, `${propertyName}.SubnetId`); + validateProperty(num => num === undefined || typeof num === 'number', launchTemplateOverrides.WeightedCapacity, `${propertyName}.WeightedCapacity`); } -export function convertLaunchSpecifications(launchSpecifications: LaunchSpecification[], propertyName: string): LaunchSpecification[] { - validateArray(launchSpecifications, propertyName); +export function validateLaunchTemplateConfigs(launchTemplateConfigs: LaunchTemplateConfig[], propertyName: string): LaunchTemplateConfig[] { + validateArray(launchTemplateConfigs, propertyName); - const convertedLaunchSpecifications: LaunchSpecification[] = []; - launchSpecifications.map(launchSpecification => { - const SecurityGroups = convertSecurityGroups(launchSpecification.SecurityGroups, `${propertyName}.SecurityGroups`); - const TagSpecifications = convertTagSpecifications(launchSpecification.TagSpecifications, `${propertyName}.TagSpecifications`); - const BlockDeviceMappings = launchSpecification.BlockDeviceMappings ? - convertBlockDeviceMapping(launchSpecification.BlockDeviceMappings, `${propertyName}.BlockDeviceMappings`) : undefined; + launchTemplateConfigs.forEach((ltc, i) => { + const ltcPropertyName = `${propertyName}[${i}]`; + validateProperty(input => typeof input === 'object' && !Array.isArray(input), ltc.LaunchTemplateSpecification, `${ltcPropertyName}.LaunchTemplateSpecification`); + validateLaunchTemplateSpecification(ltc.LaunchTemplateSpecification, `${ltcPropertyName}.LaunchTemplateSpecification`); - const convertedLaunchSpecification: LaunchSpecification = { - BlockDeviceMappings, - IamInstanceProfile: convertInstanceProfile(launchSpecification.IamInstanceProfile, `${propertyName}.IamInstanceProfile`), - ImageId: validateString(launchSpecification.ImageId, `${propertyName}.ImageId`), - KeyName: validateStringOptional(launchSpecification.KeyName, `${propertyName}.KeyName`), - SecurityGroups, - SubnetId: validateStringOptional(launchSpecification.SubnetId, `${propertyName}.SubnetId`), - TagSpecifications, - UserData: validateString(launchSpecification.UserData, `${propertyName}.UserData`), - InstanceType: validateString(launchSpecification.InstanceType, `${propertyName}.InstanceType`), - }; - convertedLaunchSpecifications.push(convertedLaunchSpecification); + validateProperty(input => Array.isArray(input), ltc.Overrides, `${ltcPropertyName}.Overrides`); + ltc.Overrides.forEach((override, idx) => validateLaunchTemplateOverrides(override, `${ltcPropertyName}.Overrides[${idx}]`)); }); - return convertedLaunchSpecifications; + + return launchTemplateConfigs; } export function convertToInt(value: any, propertyName: string): number { @@ -214,13 +150,6 @@ export function convertToInt(value: any, propertyName: string): number { throw new Error(`The value of ${propertyName} should be an integer. Received: ${value} of type ${typeof(value)}`); } -export function convertToIntOptional(value: any, propertyName: string): number | undefined { - if (value === undefined) { - return undefined; - } - return convertToInt(value, propertyName); -} - export function convertToBoolean(value: any, propertyName: string): boolean { if (typeof(value) === 'boolean') { return value; @@ -234,13 +163,6 @@ export function convertToBoolean(value: any, propertyName: string): boolean { throw new Error(`The value of ${propertyName} should be a boolean. Received: ${value} of type ${typeof(value)}`); } -export function convertToBooleanOptional(value: any, propertyName: string): boolean | undefined { - if (value === undefined) { - return undefined; - } - return convertToBoolean(value, propertyName); -} - export function validateString(value: any, propertyName: string): string { if (typeof(value) === 'string') { return value; diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/test/conversion.test.ts b/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/test/conversion.test.ts index ab55d892c..c72399811 100644 --- a/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/test/conversion.test.ts +++ b/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/test/conversion.test.ts @@ -6,25 +6,25 @@ import { convertSpotEventPluginSettings, convertToBoolean, - convertToBooleanOptional, convertToInt, - convertToIntOptional, - isValidDeviceMapping, - isValidInstanceProfile, - isValidSecurityGroup, isValidTagSpecification, validateArray, + validateLaunchTemplateConfigs, + validateLaunchTemplateOverrides, + validateLaunchTemplateSpecification, validateProperty, validateString, validateStringOptional, } from '../conversion'; import { + LaunchTemplateConfig, + LaunchTemplateOverrides, + LaunchTemplateSpecification, PluginSettings, - SpotFleetSecurityGroupId, - BlockDeviceMappingProperty, - SpotFleetInstanceProfile, } from '../types'; +const propertyName = 'propertyName'; + describe('convertSpotEventPluginSettings()', () => { test('does not convert properties with correct types', () => { // GIVEN @@ -123,7 +123,6 @@ describe('convertToInt()', () => { undefined, ])('throws an error with %p', input => { // WHEN - const propertyName = 'propertyName'; function callingConvertToInt() { convertToInt(input, propertyName); } @@ -133,36 +132,6 @@ describe('convertToInt()', () => { }); }); -describe('convertToIntOptional()', () => { - test.each<[any, number | undefined]>([ - ['10', 10], - [10, 10], - [undefined, undefined], - ])('correctly converts %p to %p', (input: any, expected: number | undefined) => { - // WHEN - const returnValue = convertToIntOptional(input, 'propertyName'); - - // THEN - expect(returnValue).toBe(expected); - }); - - test.each([ - 10.6, - [], - {}, - 'string', - ])('throws an error with %p', input => { - // WHEN - const propertyName = 'propertyName'; - function callingConvertToIntOptional() { - convertToIntOptional(input, propertyName); - } - - // THEN - expect(callingConvertToIntOptional).toThrowError(`The value of ${propertyName} should be an integer. Received: ${input}`); - }); -}); - describe('convertToBoolean()', () => { test.each<[any, boolean]>([ [true, true], @@ -185,7 +154,6 @@ describe('convertToBoolean()', () => { undefined, ])('throws an error with %p', input => { // WHEN - const propertyName = 'propertyName'; function callingConvertToBoolean() { convertToBoolean(input, propertyName); } @@ -195,38 +163,6 @@ describe('convertToBoolean()', () => { }); }); -describe('convertToBooleanOptional()', () => { - test.each<[any, boolean | undefined]>([ - [true, true], - ['true', true], - [false, false], - ['false', false], - [undefined, undefined], - ])('correctly converts %p to %p', (input: any, expected: boolean | undefined) => { - // WHEN - const returnValue = convertToBooleanOptional(input, 'property'); - - // THEN - expect(returnValue).toBe(expected); - }); - - test.each([ - 10.6, - [], - {}, - 'string', - ])('throws an error with %p', input => { - // WHEN - const propertyName = 'propertyName'; - function callingConvertToBooleanOptional() { - convertToBooleanOptional(input, propertyName); - } - - // THEN - expect(callingConvertToBooleanOptional).toThrowError(`The value of ${propertyName} should be a boolean. Received: ${input}`); - }); -}); - describe('validateString()', () => { test.each<[any, string]>([ ['string', 'string'], @@ -247,7 +183,6 @@ describe('validateString()', () => { undefined, ])('throws an error with %p', input => { // WHEN - const propertyName = 'propertyName'; function callingValidateString() { validateString(input, propertyName); } @@ -277,7 +212,6 @@ describe('validateStringOptional()', () => { {}, ])('throws an error with %p', input => { // WHEN - const propertyName = 'propertyName'; function callingValidateStringOptional() { validateStringOptional(input, propertyName); } @@ -294,7 +228,6 @@ describe('validateArray', () => { [], ])('throws with invalid input %p', (invalidInput: any) => { // WHEN - const propertyName = 'propertyName'; function callingValidateArray() { validateArray(invalidInput, propertyName); } @@ -317,42 +250,6 @@ describe('validateArray', () => { }); }); -describe('isValidSecurityGroup', () => { - // Valid security groups - const validSecurityGroup: SpotFleetSecurityGroupId = { - GroupId: 'groupId', - }; - - // Invalid security groups - const groupIdNotString = { - GroupId: 10, - }; - const noGroupId = { - }; - - test.each([ - undefined, - [], - '', - groupIdNotString, - noGroupId, - ])('returns false with invalid input %p', (invalidInput: any) => { - // WHEN - const result = isValidSecurityGroup(invalidInput); - - // THEN - expect(result).toBeFalsy(); - }); - - test('returns true with a valid input', () => { - // WHEN - const result = isValidSecurityGroup(validSecurityGroup); - - // THEN - expect(result).toBeTruthy(); - }); -}); - describe('isValidTagSpecification', () => { // Valid tag specifications const validTagSpecification = { @@ -440,92 +337,333 @@ describe('isValidTagSpecification', () => { }); }); -describe('isValidDeviceMapping', () => { - test.each([ - undefined, - [], - '', - ])('returns false with invalid input %p', (invalidInput: any) => { +describe('validateProperty', () => { + test('throws with invalid input', () => { // WHEN - const result = isValidDeviceMapping(invalidInput); + function returnFalse(_input: any) { + return false; + } + function callingValidateProperty() { + validateProperty(returnFalse, 'anyValue', propertyName); + } // THEN - expect(result).toBeFalsy(); + expect(callingValidateProperty).toThrowError(`${propertyName} type is not valid.`); }); - test('returns true with a valid input', () => { + test('passes with a valid input', () => { + // WHEN + function returnTrue(_input: any) { + return true; + } + function callingValidateProperty() { + validateProperty(returnTrue, 'anyValue', 'propertyName'); + } + + // THEN + expect(callingValidateProperty).not.toThrowError(); + }); +}); + +describe('validateLaunchTemplateSpecification', () => { + test('accepts launch template specification with id', () => { // GIVEN - const anyObject = {} as unknown; + const spec: LaunchTemplateSpecification = { + Version: '1', + LaunchTemplateId: 'id', + }; // WHEN - const result = isValidDeviceMapping(anyObject as BlockDeviceMappingProperty); + expect(() => validateLaunchTemplateSpecification(spec, propertyName)) - // THEN - expect(result).toBeTruthy(); + // THEN + .not.toThrow(); + }); + + test('accepts launch template specification with name', () => { + // GIVEN + const spec: LaunchTemplateSpecification = { + Version: '1', + LaunchTemplateName: 'name', + }; + + // WHEN + expect(() => validateLaunchTemplateSpecification(spec, propertyName)) + + // THEN + .not.toThrow(); + }); + + test('throws if both id and name are specified', () => { + // GIVEN + const id = 'id'; + const name = 'name'; + const spec: LaunchTemplateSpecification = { + Version: '1', + LaunchTemplateId: id, + LaunchTemplateName: name, + }; + + // WHEN + expect(() => validateLaunchTemplateSpecification(spec, propertyName)) + + // THEN + .toThrowError(`Exactly one of ${propertyName}.LaunchTemplateId or ${propertyName}.LaunchTemplateName must be specified, but got: ${id} and ${name} respectively`); + }); + + test('throws if neither id or name are specified', () => { + // GIVEN + const spec: LaunchTemplateSpecification = { + Version: '1', + }; + + // WHEN + expect(() => validateLaunchTemplateSpecification(spec, propertyName)) + + // THEN + .toThrowError(`Exactly one of ${propertyName}.LaunchTemplateId or ${propertyName}.LaunchTemplateName must be specified, but got: ${undefined} and ${undefined} respectively`); + }); + + test('throws if id is invalid', () => { + // GIVEN + const invalidValue = 123; + const spec: LaunchTemplateSpecification = { + Version: '1', + // @ts-ignore + LaunchTemplateId: invalidValue, + }; + + // WHEN + expect(() => validateLaunchTemplateSpecification(spec, propertyName)) + + // THEN + .toThrowError(new RegExp(`The value of ${propertyName}.LaunchTemplateId should be a string. Received: ${invalidValue} of type ${typeof(invalidValue)}`)); + }); + + test('throws if name is invalid', () => { + // GIVEN + const invalidValue = 123; + const spec: LaunchTemplateSpecification = { + Version: '1', + // @ts-ignore + LaunchTemplateName: invalidValue, + }; + + // WHEN + expect(() => validateLaunchTemplateSpecification(spec, propertyName)) + + // THEN + .toThrowError(new RegExp(`The value of ${propertyName}.LaunchTemplateName should be a string. Received: ${invalidValue} of type ${typeof(invalidValue)}`)); + }); + + test('throws if version is invalid', () => { + // GIVEN + const invalidValue = 123; + const spec: LaunchTemplateSpecification = { + LaunchTemplateId: '', + // @ts-ignore + Version: invalidValue, + }; + + // WHEN + expect(() => validateLaunchTemplateSpecification(spec, propertyName)) + + // THEN + .toThrowError(`The value of ${propertyName}.Version should be a string. Received: ${invalidValue} of type ${typeof(invalidValue)}`); }); }); -describe('isValidInstanceProfile', () => { - // Valid instance profiles - const validInstanceProfile: SpotFleetInstanceProfile = { - Arn: 'arn', - }; +describe('validateLaunchTemplateOverrides', () => { + test('accepts valid overrides', () => { + // GIVEN + const overrides: LaunchTemplateOverrides = { + AvailabilityZone: 'AvailabilityZone', + InstanceType: 'InstanceType', + SpotPrice: 'SpotPrice', + SubnetId: 'SubnetId', + WeightedCapacity: 123, + }; - // Invalid instance profiles - const noArn = { - }; - const arnNotString = { - Arn: 10, - }; + // WHEN + expect(() => validateLaunchTemplateOverrides(overrides, propertyName)) + + // THEN + .not.toThrow(); + }); + + test('throws if AvailabilityZone is invalid', () => { + // GIVEN + const invalidValue = 123; + const overrides: LaunchTemplateOverrides = { + // @ts-ignore + AvailabilityZone: invalidValue, + }; - test.each([ - undefined, - [], - '', - noArn, - arnNotString, - ])('returns false with invalid input %p', (invalidInput: any) => { // WHEN - const result = isValidInstanceProfile(invalidInput); + expect(() => validateLaunchTemplateOverrides(overrides, propertyName)) - // THEN - expect(result).toBeFalsy(); + // THEN + .toThrowError(new RegExp(`The value of ${propertyName}.AvailabilityZone should be a string. Received: ${invalidValue} of type ${typeof(invalidValue)}`)); }); - test('returns true with a valid input', () => { + test('throws if InstanceType is invalid', () => { + // GIVEN + const invalidValue = 123; + const overrides: LaunchTemplateOverrides = { + // @ts-ignore + InstanceType: invalidValue, + }; + // WHEN - const result = isValidInstanceProfile(validInstanceProfile); + expect(() => validateLaunchTemplateOverrides(overrides, propertyName)) - // THEN - expect(result).toBeTruthy(); + // THEN + .toThrowError(new RegExp(`The value of ${propertyName}.InstanceType should be a string. Received: ${invalidValue} of type ${typeof(invalidValue)}`)); + }); + + test('throws if SpotPrice is invalid', () => { + // GIVEN + const invalidValue = 123; + const overrides: LaunchTemplateOverrides = { + // @ts-ignore + SpotPrice: invalidValue, + }; + + // WHEN + expect(() => validateLaunchTemplateOverrides(overrides, propertyName)) + + // THEN + .toThrowError(new RegExp(`The value of ${propertyName}.SpotPrice should be a string. Received: ${invalidValue} of type ${typeof(invalidValue)}`)); + }); + + test('throws if SubnetId is invalid', () => { + // GIVEN + const invalidValue = 123; + const overrides: LaunchTemplateOverrides = { + // @ts-ignore + SubnetId: invalidValue, + }; + + // WHEN + expect(() => validateLaunchTemplateOverrides(overrides, propertyName)) + + // THEN + .toThrowError(new RegExp(`The value of ${propertyName}.SubnetId should be a string. Received: ${invalidValue} of type ${typeof(invalidValue)}`)); + }); + + test('throws if WeightedCapacity is invalid', () => { + // GIVEN + const invalidValue = 'WeightedCapacity'; + const overrides: LaunchTemplateOverrides = { + // @ts-ignore + WeightedCapacity: invalidValue, + }; + + // WHEN + expect(() => validateLaunchTemplateOverrides(overrides, propertyName)) + + // THEN + .toThrowError(`${propertyName}.WeightedCapacity type is not valid.`); }); }); -describe('validateProperty', () => { - test('throws with invalid input', () => { +describe('validateLaunchTemplateConfigs', () => { + const LaunchTemplateSpec: LaunchTemplateSpecification = { + Version: '1', + LaunchTemplateId: 'id', + }; + const Overrides: LaunchTemplateOverrides[] = []; + + test('accepts valid LaunchTemplateConfig', () => { + // GIVEN + const config: LaunchTemplateConfig = { + LaunchTemplateSpecification: LaunchTemplateSpec, + Overrides, + }; + // WHEN - const propertyName = 'propertyName'; - function returnFalse(_input: any) { - return false; - } - function callingValidateProperty() { - validateProperty(returnFalse, 'anyValue', propertyName); - } + expect(() => validateLaunchTemplateConfigs([config], propertyName)) - // THEN - expect(callingValidateProperty).toThrowError(`${propertyName} type is not valid.`); + // THEN + .not.toThrow(); }); - test('passes with a valid input', () => { + test('throws when not given an array of LaunchTemplateConfigs', () => { // WHEN - function returnTrue(_input: any) { - return true; - } - function callingValidateProperty() { - validateProperty(returnTrue, 'anyValue', 'propertyName'); - } + expect(() => { + // @ts-ignore + validateLaunchTemplateConfigs({}, propertyName); + }) - // THEN - expect(callingValidateProperty).not.toThrowError(); + // THEN + .toThrowError(`${propertyName} should be an array with at least one element.`); + }); + + test('throws when LaunchTemplateSpecification is the wrong type', () => { + // GIVEN + const invalidValue = 123; + const config: LaunchTemplateConfig = { + // @ts-ignore + LaunchTemplateSpecification: invalidValue, + Overrides, + }; + + // WHEN + expect(() => validateLaunchTemplateConfigs([config], propertyName)) + + // THEN + .toThrowError(`${propertyName}[0].LaunchTemplateSpecification type is not valid.`); + }); + + test('throws when Version is invalid', () => { + // GIVEN + const invalidValue = 123; + const config: LaunchTemplateConfig = { + LaunchTemplateSpecification: { + LaunchTemplateId: '', + // @ts-ignore + Version: invalidValue, + }, + Overrides, + }; + + // WHEN + expect(() => validateLaunchTemplateConfigs([config], propertyName)) + + // THEN + .toThrowError(`The value of ${propertyName}[0].LaunchTemplateSpecification.Version should be a string. Received: ${invalidValue} of type ${typeof(invalidValue)}`); }); -}); \ No newline at end of file + + test('throws when Overrides is not an array', () => { + // GIVEN + const config: LaunchTemplateConfig = { + LaunchTemplateSpecification: LaunchTemplateSpec, + // @ts-ignore + Overrides: 123, + }; + + // WHEN + expect(() => validateLaunchTemplateConfigs([config], propertyName)) + + // THEN + .toThrowError(`${propertyName}[0].Overrides type is not valid.`); + }); + + test('throws when a LaunchTemplateOverride is invalid', () => { + // GIVEN + const invalidValue = 123; + const config: LaunchTemplateConfig = { + LaunchTemplateSpecification: LaunchTemplateSpec, + Overrides: [{ + // @ts-ignore + AvailabilityZone: invalidValue, + }], + }; + + // WHEN + expect(() => validateLaunchTemplateConfigs([config], propertyName)) + + // THEN + .toThrowError(`The value of ${propertyName}[0].Overrides[0].AvailabilityZone should be a string. Received: ${invalidValue} of type ${typeof(invalidValue)}`); + }); +}); diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/test/handler.test.ts b/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/test/handler.test.ts index 65adeb63b..b67b5e4d7 100644 --- a/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/test/handler.test.ts +++ b/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/test/handler.test.ts @@ -4,11 +4,13 @@ */ import { - InstanceClass, - InstanceSize, - InstanceType, + LaunchTemplate, } from '@aws-cdk/aws-ec2'; -import { Expiration } from '@aws-cdk/core'; +import { + App, + Expiration, + Stack, +} from '@aws-cdk/core'; import * as AWS from 'aws-sdk'; import { SpotEventPluginDisplayInstanceStatus, @@ -24,209 +26,79 @@ import { ConnectionOptions, SEPConfiguratorResourceProps, PluginSettings, - SpotFleetRequestConfiguration, - LaunchSpecification, - SpotFleetRequestProps, } from '../types'; jest.mock('../../lib/secrets-manager/read-certificate'); const secretArn: string = 'arn:aws:secretsmanager:us-west-1:1234567890:secret:SecretPath/Cert'; -// @ts-ignore -async function successRequestMock(request: { [key: string]: string}, returnValue: any): Promise<{ [key: string]: any }> { - return returnValue; -} - describe('SEPConfiguratorResource', () => { - const validConnection: ConnectionOptions = { - hostname: 'internal-hostname.com', - protocol: 'HTTPS', - port: '4433', - caCertificateArn: secretArn, - }; - - const validLaunchSpecification: LaunchSpecification = { - IamInstanceProfile: { - Arn: 'iamInstanceProfileArn', - }, - ImageId: 'any-ami', - InstanceType: InstanceType.of(InstanceClass.T2, InstanceSize.SMALL).toString(), - SecurityGroups: [{ - GroupId: 'sg-id', - }], - TagSpecifications: [{ - ResourceType: SpotFleetResourceType.INSTANCE, - Tags: [ - { - Key: 'name', - Value: 'test', - }, - ], - }], - UserData: 'userdata', - KeyName: 'keyname', - SubnetId: 'subnet-id', - BlockDeviceMappings: [{ - DeviceName: 'device', - NoDevice: '', - VirtualName: 'virtualname', - Ebs: { - DeleteOnTermination: true, - Encrypted: true, - Iops: 10, - SnapshotId: 'snapshot-id', - VolumeSize: 10, - VolumeType: 'volume-type', + const deadlineGroup = 'group_name'; + const deadlinePool = 'pool_name'; + + let app: App; + let stack: Stack; + let validSepConfiguration: SEPConfiguratorResourceProps; + + beforeEach(() => { + app = new App(); + stack = new Stack(app, 'Stack'); + const launchTemplate = new LaunchTemplate(stack, 'LaunchTemplate'); + + validSepConfiguration = { + spotPluginConfigurations: { + AWSInstanceStatus: SpotEventPluginDisplayInstanceStatus.DISABLED, + DeleteInterruptedSlaves: true, + DeleteTerminatedSlaves: true, + IdleShutdown: 20, + Logging: SpotEventPluginLoggingLevel.STANDARD, + PreJobTaskMode: SpotEventPluginPreJobTaskMode.CONSERVATIVE, + Region: 'us-west-2', + ResourceTracker: true, + StaggerInstances: 50, + State: SpotEventPluginState.GLOBAL_ENABLED, + StrictHardCap: true, }, - }], - }; - - const validSpotFleetRequestProps: SpotFleetRequestProps = { - AllocationStrategy: SpotFleetAllocationStrategy.CAPACITY_OPTIMIZED, - IamFleetRole: 'roleArn', - LaunchSpecifications: [validLaunchSpecification], - ReplaceUnhealthyInstances: true, - TargetCapacity: 1, - TerminateInstancesWithExpiration: true, - Type: SpotFleetRequestType.MAINTAIN, - TagSpecifications: [{ - ResourceType: SpotFleetResourceType.SPOT_FLEET_REQUEST, - Tags: [ - { - Key: 'name', - Value: 'test', - }, - ], - }], - ValidUntil: Expiration.atDate(new Date(2022, 11, 17)).date.toISOString(), - }; - - const validConvertedLaunchSpecifications = { - BlockDeviceMappings: [{ - DeviceName: 'device', - Ebs: { - DeleteOnTermination: true, - Encrypted: true, - Iops: 10, - SnapshotId: 'snapshot-id', - VolumeSize: 10, - VolumeType: 'volume-type', + connection: { + hostname: 'internal-hostname.com', + protocol: 'HTTPS', + port: '4433', + caCertificateArn: secretArn, }, - NoDevice: '', - VirtualName: 'virtualname', - }], - IamInstanceProfile: { - Arn: 'iamInstanceProfileArn', - }, - ImageId: 'any-ami', - KeyName: 'keyname', - SecurityGroups: [{ - GroupId: 'sg-id', - }], - SubnetId: 'subnet-id', - TagSpecifications: [{ - ResourceType: 'instance', - Tags: [ - { - Key: 'name', - Value: 'test', - }, - ], - }], - UserData: 'userdata', - InstanceType: 't2.small', - }; - - const validConvertedSpotFleetRequestProps = { - AllocationStrategy: 'capacityOptimized', - IamFleetRole: 'roleArn', - LaunchSpecifications: [validConvertedLaunchSpecifications], - ReplaceUnhealthyInstances: true, - TargetCapacity: 1, - TerminateInstancesWithExpiration: true, - Type: 'maintain', - ValidUntil: Expiration.atDate(new Date(2022, 11, 17)).date.toISOString(), - TagSpecifications: [{ - ResourceType: 'spot-fleet-request', - Tags: [ - { - Key: 'name', - Value: 'test', + spotFleetRequestConfigurations: { + [deadlineGroup]: { + AllocationStrategy: SpotFleetAllocationStrategy.CAPACITY_OPTIMIZED, + IamFleetRole: 'roleArn', + // Explicitly provide empty array for testing comparisons since we inject this for compatibility with SEP + LaunchSpecifications: [], + LaunchTemplateConfigs: [{ + LaunchTemplateSpecification: { + LaunchTemplateId: launchTemplate.launchTemplateId, + LaunchTemplateName: launchTemplate.launchTemplateName, + Version: launchTemplate.versionNumber, + }, + Overrides: [], + }], + ReplaceUnhealthyInstances: true, + TargetCapacity: 1, + TerminateInstancesWithExpiration: true, + Type: SpotFleetRequestType.MAINTAIN, + ValidUntil: Expiration.atDate(new Date(2022, 11, 17)).date.toISOString(), + TagSpecifications: [{ + ResourceType: SpotFleetResourceType.SPOT_FLEET_REQUEST, + Tags: [ + { + Key: 'name', + Value: 'test', + }, + ], + }], }, - ], - }], - }; - - const validSpotFleetRequestConfig: SpotFleetRequestConfiguration = { - group_name1: validSpotFleetRequestProps, - }; - - const validConvertedSpotFleetRequestConfig = { - group_name1: validConvertedSpotFleetRequestProps, - }; - - const validSpotEventPluginConfig: PluginSettings = { - AWSInstanceStatus: SpotEventPluginDisplayInstanceStatus.DISABLED, - DeleteInterruptedSlaves: true, - DeleteTerminatedSlaves: true, - IdleShutdown: 20, - Logging: SpotEventPluginLoggingLevel.STANDARD, - PreJobTaskMode: SpotEventPluginPreJobTaskMode.CONSERVATIVE, - Region: 'us-west-2', - ResourceTracker: true, - StaggerInstances: 50, - State: SpotEventPluginState.GLOBAL_ENABLED, - StrictHardCap: true, - }; - - const validConvertedPluginConfig = { - AWSInstanceStatus: 'Disabled', - DeleteInterruptedSlaves: true, - DeleteTerminatedSlaves: true, - IdleShutdown: 20, - Logging: 'Standard', - PreJobTaskMode: 'Conservative', - Region: 'us-west-2', - ResourceTracker: true, - StaggerInstances: 50, - State: 'Global Enabled', - StrictHardCap: true, - }; - - // Valid configurations - const noPluginConfigs: SEPConfiguratorResourceProps = { - connection: validConnection, - spotFleetRequestConfigurations: validSpotFleetRequestConfig, - }; - - const noFleetRequestConfigs: SEPConfiguratorResourceProps = { - spotPluginConfigurations: validSpotEventPluginConfig, - connection: validConnection, - }; - - const deadlineGroups = ['group_name']; - const deadlinePools = ['pool_name']; - - const allConfigs: SEPConfiguratorResourceProps = { - spotPluginConfigurations: validSpotEventPluginConfig, - connection: validConnection, - spotFleetRequestConfigurations: validSpotFleetRequestConfig, - deadlineGroups, - deadlinePools, - }; - - const noConfigs: SEPConfiguratorResourceProps = { - connection: validConnection, - }; - - async function returnTrue(_v1: any): Promise { - return true; - } - - async function returnFalse(_v1: any): Promise { - return false; - } + }, + deadlineGroups: [deadlineGroup], + deadlinePools: [deadlinePool], + }; + }); describe('doCreate', () => { let handler: SEPConfiguratorResource; @@ -239,10 +111,10 @@ describe('SEPConfiguratorResource', () => { beforeEach(() => { mockSpotEventPluginClient = { - saveServerData: jest.fn( (a) => returnTrue(a) ), - configureSpotEventPlugin: jest.fn( (a) => returnTrue(a) ), - addGroups: jest.fn( (a) => returnTrue(a) ), - addPools: jest.fn( (a) => returnTrue(a) ), + saveServerData: jest.fn( (_a) => Promise.resolve(true) ), + configureSpotEventPlugin: jest.fn( (_a) => Promise.resolve(true) ), + addGroups: jest.fn( (_a) => Promise.resolve(true) ), + addPools: jest.fn( (_a) => Promise.resolve(true) ), }; handler = new SEPConfiguratorResource(new AWS.SecretsManager()); @@ -260,15 +132,17 @@ describe('SEPConfiguratorResource', () => { jest.clearAllMocks(); }); - test('with no configs', async () => { + test('does not save server data when no configurations are provided', async () => { // GIVEN - const mockSaveServerData = jest.fn( (a) => returnTrue(a) ); + const mockSaveServerData = jest.fn( (_a) => Promise.resolve(true) ); mockSpotEventPluginClient.saveServerData = mockSaveServerData; - const mockConfigureSpotEventPlugin = jest.fn( (a) => returnTrue(a) ); + const mockConfigureSpotEventPlugin = jest.fn( (_a) => Promise.resolve(true) ); mockSpotEventPluginClient.configureSpotEventPlugin = mockConfigureSpotEventPlugin; // WHEN - const result = await handler.doCreate('physicalId', noConfigs); + const result = await handler.doCreate('physicalId', { + connection: validSepConfiguration.connection, + }); // THEN expect(result).toBeUndefined(); @@ -278,116 +152,28 @@ describe('SEPConfiguratorResource', () => { test('save spot fleet request configs', async () => { // GIVEN - const mockSaveServerData = jest.fn( (a) => returnTrue(a) ); + const mockSaveServerData = jest.fn( (_a) => Promise.resolve(true) ); mockSpotEventPluginClient.saveServerData = mockSaveServerData; // WHEN - const result = await handler.doCreate('physicalId', noPluginConfigs); + const result = await handler.doCreate('physicalId', { + ...validSepConfiguration, + spotPluginConfigurations: undefined, + }); // THEN expect(result).toBeUndefined(); expect(mockSaveServerData.mock.calls.length).toBe(1); - const calledWithString = mockSaveServerData.mock.calls[0][0]; - const calledWithObject = JSON.parse(calledWithString); - - expect(calledWithObject).toEqual(validConvertedSpotFleetRequestConfig); - }); - - test('save spot fleet request configs without BlockDeviceMappings', async () => { - // GIVEN - const mockSaveServerData = jest.fn( (a) => returnTrue(a) ); - mockSpotEventPluginClient.saveServerData = mockSaveServerData; - - const noEbs = { - ...noPluginConfigs, - spotFleetRequestConfigurations: { - ...validSpotFleetRequestConfig, - group_name1: { - ...validSpotFleetRequestProps, - LaunchSpecifications: [ - { - ...validLaunchSpecification, - BlockDeviceMappings: undefined, - }, - ], - }, - }, - }; - const convertedNoEbs = { - ...validConvertedSpotFleetRequestConfig, - group_name1: { - ...validConvertedSpotFleetRequestProps, - LaunchSpecifications: [ - { - ...validConvertedLaunchSpecifications, - BlockDeviceMappings: undefined, - }, - ], - }, - }; - - // WHEN - await handler.doCreate('physicalId', noEbs); - const calledWithString = mockSaveServerData.mock.calls[0][0]; - const calledWithObject = JSON.parse(calledWithString); - - // THEN - expect(calledWithObject).toEqual(convertedNoEbs); - }); - - test('save spot fleet request configs without Ebs', async () => { - // GIVEN - const mockSaveServerData = jest.fn( (a) => returnTrue(a) ); - mockSpotEventPluginClient.saveServerData = mockSaveServerData; - - const blockDevicesNoEbs = [{ - DeviceName: 'device', - }]; - - const noEbs = { - ...noPluginConfigs, - spotFleetRequestConfigurations: { - ...validSpotFleetRequestConfig, - group_name1: { - ...validSpotFleetRequestProps, - LaunchSpecifications: [ - { - ...validLaunchSpecification, - BlockDeviceMappings: blockDevicesNoEbs, - }, - ], - }, - }, - }; - const convertedNoEbs = { - ...validConvertedSpotFleetRequestConfig, - group_name1: { - ...validConvertedSpotFleetRequestProps, - LaunchSpecifications: [ - { - ...validConvertedLaunchSpecifications, - BlockDeviceMappings: blockDevicesNoEbs, - }, - ], - }, - }; - - // WHEN - await handler.doCreate('physicalId', noEbs); - const calledWithString = mockSaveServerData.mock.calls[0][0]; - const calledWithObject = JSON.parse(calledWithString); - - // THEN - expect(calledWithObject).toEqual(convertedNoEbs); + expect(mockSaveServerData).toBeCalledWith(JSON.stringify(validSepConfiguration.spotFleetRequestConfigurations)); }); test('save spot event plugin configs', async () => { // GIVEN - const mockConfigureSpotEventPlugin = jest.fn( (a) => returnTrue(a) ); + const mockConfigureSpotEventPlugin = jest.fn( (_a) => Promise.resolve(true) ); mockSpotEventPluginClient.configureSpotEventPlugin = mockConfigureSpotEventPlugin; const configs: { Key: string, Value: any }[] = []; - for (const [key, value] of Object.entries(validConvertedPluginConfig)) { + for (const [key, value] of Object.entries(validSepConfiguration.spotPluginConfigurations as any)) { configs.push({ Key: key, Value: value, @@ -404,7 +190,10 @@ describe('SEPConfiguratorResource', () => { }]; // WHEN - const result = await handler.doCreate('physicalId', noFleetRequestConfigs); + const result = await handler.doCreate('physicalId', { + ...validSepConfiguration, + spotFleetRequestConfigurations: undefined, + }); // THEN expect(result).toBeUndefined(); @@ -414,25 +203,25 @@ describe('SEPConfiguratorResource', () => { test('save server data', async () => { // GIVEN - const mockSaveServerData = jest.fn( (a) => returnTrue(a) ); + const mockSaveServerData = jest.fn( (_a) => Promise.resolve(true) ); mockSpotEventPluginClient.saveServerData = mockSaveServerData; // WHEN - const result = await handler.doCreate('physicalId', allConfigs); + const result = await handler.doCreate('physicalId', validSepConfiguration); // THEN expect(result).toBeUndefined(); expect(mockSaveServerData.mock.calls.length).toBe(1); - expect(mockSaveServerData.mock.calls[0][0]).toEqual(JSON.stringify(validConvertedSpotFleetRequestConfig)); + expect(mockSaveServerData.mock.calls[0][0]).toEqual(JSON.stringify(validSepConfiguration.spotFleetRequestConfigurations)); }); test('configure spot event plugin', async () => { // GIVEN - const mockConfigureSpotEventPlugin = jest.fn( (a) => returnTrue(a) ); + const mockConfigureSpotEventPlugin = jest.fn( (_a) => Promise.resolve(true) ); mockSpotEventPluginClient.configureSpotEventPlugin = mockConfigureSpotEventPlugin; const configs: { Key: string, Value: any }[] = []; - for (const [key, value] of Object.entries(validConvertedPluginConfig)) { + for (const [key, value] of Object.entries(validSepConfiguration.spotPluginConfigurations as any)) { configs.push({ Key: key, Value: value, @@ -449,7 +238,7 @@ describe('SEPConfiguratorResource', () => { }]; // WHEN - await handler.doCreate('physicalId', allConfigs); + await handler.doCreate('physicalId', validSepConfiguration); // THEN expect(mockConfigureSpotEventPlugin.mock.calls.length).toBe(1); @@ -458,63 +247,63 @@ describe('SEPConfiguratorResource', () => { test('create groups', async () => { // GIVEN - const mockAddGroups = jest.fn( (a) => returnTrue(a) ); + const mockAddGroups = jest.fn( (_a) => Promise.resolve(true) ); mockSpotEventPluginClient.addGroups = mockAddGroups; // WHEN - await handler.doCreate('physicalId', allConfigs); + await handler.doCreate('physicalId', validSepConfiguration); // THEN expect(mockAddGroups.mock.calls.length).toBe(1); - expect(mockAddGroups).toHaveBeenCalledWith(deadlineGroups); + expect(mockAddGroups).toHaveBeenCalledWith([deadlineGroup]); }); test('create pools', async () => { // GIVEN - const mockAddPools = jest.fn( (a) => returnTrue(a) ); + const mockAddPools = jest.fn( (_a) => Promise.resolve(true) ); mockSpotEventPluginClient.addPools = mockAddPools; // WHEN - await handler.doCreate('physicalId', allConfigs); + await handler.doCreate('physicalId', validSepConfiguration); // THEN expect(mockAddPools.mock.calls.length).toBe(1); - expect(mockAddPools).toHaveBeenCalledWith(deadlinePools); + expect(mockAddPools).toHaveBeenCalledWith([deadlinePool]); }); test('throw when cannot add groups', async () => { // GIVEN - mockSpotEventPluginClient.addGroups = jest.fn( (a) => returnFalse(a) ); + mockSpotEventPluginClient.addGroups = jest.fn( (_a) => Promise.resolve(false) ); // WHEN - const promise = handler.doCreate('physicalId', allConfigs); + const promise = handler.doCreate('physicalId', validSepConfiguration); // THEN await expect(promise) .rejects - .toThrowError(`Failed to add Deadline group(s) ${allConfigs.deadlineGroups}`); + .toThrowError(`Failed to add Deadline group(s) ${validSepConfiguration.deadlineGroups}`); }); test('throw when cannot add pools', async () => { // GIVEN - mockSpotEventPluginClient.addPools = jest.fn( (a) => returnFalse(a) ); + mockSpotEventPluginClient.addPools = jest.fn( (_a) => Promise.resolve(false) ); // WHEN - const promise = handler.doCreate('physicalId', allConfigs); + const promise = handler.doCreate('physicalId', validSepConfiguration); // THEN await expect(promise) .rejects - .toThrowError(`Failed to add Deadline pool(s) ${allConfigs.deadlinePools}`); + .toThrowError(`Failed to add Deadline pool(s) ${validSepConfiguration.deadlinePools}`); }); test('throw when cannot save spot fleet request configs', async () => { // GIVEN - const mockSaveServerData = jest.fn( (a) => returnFalse(a) ); + const mockSaveServerData = jest.fn( (_a) => Promise.resolve(false) ); mockSpotEventPluginClient.saveServerData = mockSaveServerData; // WHEN - const promise = handler.doCreate('physicalId', noPluginConfigs); + const promise = handler.doCreate('physicalId', validSepConfiguration); // THEN await expect(promise) @@ -524,11 +313,11 @@ describe('SEPConfiguratorResource', () => { test('throw when cannot save spot event plugin configs', async () => { // GIVEN - const mockConfigureSpotEventPlugin = jest.fn( (a) => returnFalse(a) ); + const mockConfigureSpotEventPlugin = jest.fn( (_a) => Promise.resolve(false) ); mockSpotEventPluginClient.configureSpotEventPlugin = mockConfigureSpotEventPlugin; // WHEN - const promise = handler.doCreate('physicalId', noFleetRequestConfigs); + const promise = handler.doCreate('physicalId', validSepConfiguration); // THEN await expect(promise) @@ -542,7 +331,9 @@ describe('SEPConfiguratorResource', () => { const handler = new SEPConfiguratorResource(new AWS.SecretsManager()); // WHEN - const promise = await handler.doDelete('physicalId', noConfigs); + const promise = await handler.doDelete('physicalId', { + connection: validSepConfiguration.connection, + }); // THEN await expect(promise).toBeUndefined(); @@ -550,12 +341,54 @@ describe('SEPConfiguratorResource', () => { describe('.validateInput()', () => { describe('should return true', () => { - test.each([ - allConfigs, - noPluginConfigs, - noFleetRequestConfigs, - noConfigs, - ])('with valid input', async (input: any) => { + test('with valid input', async () => { + // GIVEN + const input = validSepConfiguration; + + // WHEN + const handler = new SEPConfiguratorResource(new AWS.SecretsManager()); + const returnValue = handler.validateInput(input); + + // THEN + expect(returnValue).toBeTruthy(); + }); + + test('without spotPluginConfigurations', async () => { + // GIVEN + const input: SEPConfiguratorResourceProps = { + ...validSepConfiguration, + spotPluginConfigurations: undefined, + }; + + // WHEN + const handler = new SEPConfiguratorResource(new AWS.SecretsManager()); + const returnValue = handler.validateInput(input); + + // THEN + expect(returnValue).toBeTruthy(); + }); + + test('without spotFleetRequestConfigurations', async () => { + // GIVEN + const input: SEPConfiguratorResourceProps = { + ...validSepConfiguration, + spotFleetRequestConfigurations: undefined, + }; + + // WHEN + const handler = new SEPConfiguratorResource(new AWS.SecretsManager()); + const returnValue = handler.validateInput(input); + + // THEN + expect(returnValue).toBeTruthy(); + }); + + test('with only connection', async () => { + // GIVEN + const input: SEPConfiguratorResourceProps = { + connection: validSepConfiguration.connection, + }; + // WHEN const handler = new SEPConfiguratorResource(new AWS.SecretsManager()); const returnValue = handler.validateInput(input); @@ -639,9 +472,9 @@ describe('SEPConfiguratorResource', () => { ])('invalid connection', (invalidConnection: any) => { // GIVEN const input = { - spotPluginConfigurations: validSpotEventPluginConfig, + spotPluginConfigurations: validSepConfiguration.spotPluginConfigurations, connection: invalidConnection, - spotFleetRequestConfigurations: validSpotFleetRequestConfig, + spotFleetRequestConfigurations: validSepConfiguration.spotFleetRequestConfigurations, }; // WHEN diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/types.ts b/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/types.ts index c08f1d822..0cf694ea5 100644 --- a/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/types.ts +++ b/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/types.ts @@ -148,8 +148,15 @@ export interface SpotFleetRequestProps { /** * The launch specifications for the Spot Fleet request. + * + * @deprecated This property is ignored. Use `LaunchTemplateConfigs` instead. + */ + readonly LaunchSpecifications?: any[]; + + /** + * The launch templates for the Spot Fleet request. */ - readonly LaunchSpecifications: LaunchSpecification[]; + readonly LaunchTemplateConfigs?: LaunchTemplateConfig[]; /** * Indicates whether Spot Fleet should replace unhealthy instances. @@ -187,61 +194,67 @@ export interface SpotFleetRequestProps { } /** - * Describes the launch specification for one or more Spot Instances. + * Interface for `FleetLaunchTemplateSpecification` used in the `RequestSpotFleet` API invoked by SEP. + * @see https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_RequestSpotFleet.html + * @see https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_FleetLaunchTemplateSpecification.html */ -export interface LaunchSpecification -{ +export interface LaunchTemplateSpecification { /** - * One or more block devices that are mapped to the Spot Instances. - * - * @default - Property not used. + * The ID of the launch template. This is mutually exclusive with `LaunchTemplateName`. */ - readonly BlockDeviceMappings?: BlockDeviceMappingProperty[]; - + readonly LaunchTemplateId?: string; /** - * The IAM instance profile. + * The name of the launch template. This is mutually exclusive with `LaunchTemplateId`. */ - readonly IamInstanceProfile: SpotFleetInstanceProfile; - + readonly LaunchTemplateName?: string; /** - * The ID of the AMI. + * The version of the launch template to use. */ - readonly ImageId: string; + readonly Version: string; +} +/** + * Interface for `LaunchTemplateOverrides` used in the `RequestSpotFleet` API invoked by SEP. + * @see https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_RequestSpotFleet.html + * @see https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_LaunchTemplateOverrides.html + */ +export interface LaunchTemplateOverrides { /** - * One or more security groups. + * The Availability Zone in which to launch the instances. */ - readonly SecurityGroups: SpotFleetSecurityGroupId[]; - + readonly AvailabilityZone?: string, /** - * The IDs of the subnets in which to launch the instances. - * To specify multiple subnets, separate them using commas. - * - * @default - Property not used. + * The instance type. */ - readonly SubnetId?: string; - + readonly InstanceType?: string, /** - * The tags to apply to the instance during creation. + * The maximum price per unit hour you are willing to pay for a Spot Instance. */ - readonly TagSpecifications: SpotFleetTagSpecification[]; - + readonly SpotPrice?: string, /** - * The Base64-encoded user data that instances use when starting up. + * The ID of the subnet in which to launch the instances. */ - readonly UserData: string; - + readonly SubnetId?: string, /** - * The instance type. + * The number of units provided by the specified instance type. */ - readonly InstanceType: string; + readonly WeightedCapacity?: number, +} +/** + * Interface for `LaunchTemplateConfig` used in the `RequestSpotFleet` API invoked by SEP. + * @see https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_RequestSpotFleet.html + * @see https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_LaunchTemplateConfig.html + */ +export interface LaunchTemplateConfig { /** - * The name of the key pair. - * - * @default - Property not used. + * The launch template. + */ + readonly LaunchTemplateSpecification: LaunchTemplateSpecification; + /** + * Any parameters that you specify override the same parameters in the launch template. */ - readonly KeyName?: string; + readonly Overrides: LaunchTemplateOverrides[]; } /** diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/lib/configure-spot-event-plugin/test/spot-event-plugin-client.test.ts b/packages/aws-rfdk/lib/lambdas/nodejs/lib/configure-spot-event-plugin/test/spot-event-plugin-client.test.ts index 111b8f851..989247796 100644 --- a/packages/aws-rfdk/lib/lambdas/nodejs/lib/configure-spot-event-plugin/test/spot-event-plugin-client.test.ts +++ b/packages/aws-rfdk/lib/lambdas/nodejs/lib/configure-spot-event-plugin/test/spot-event-plugin-client.test.ts @@ -439,4 +439,36 @@ describe('SpotEventPluginClient', () => { ); } }); + + test('does not add groups when groups were not retrieved successfully', async () => { + // GIVEN + // eslint-disable-next-line dot-notation + spotEventPluginClient['deadlineClient'].GetRequest = jest.fn().mockResolvedValue(undefined); + + // WHEN + const added = await spotEventPluginClient.addGroups(['group']); + + // THEN + // eslint-disable-next-line dot-notation + expect(spotEventPluginClient['deadlineClient'].GetRequest).toBeCalledTimes(1); + // eslint-disable-next-line dot-notation + expect(spotEventPluginClient['deadlineClient'].PostRequest).not.toBeCalled(); + expect(added).toBe(false); + }); + + test('does not add pools when pools were not retrieved successfully', async () => { + // GIVEN + // eslint-disable-next-line dot-notation + spotEventPluginClient['deadlineClient'].GetRequest = jest.fn().mockResolvedValue(undefined); + + // WHEN + const added = await spotEventPluginClient.addPools(['pool']); + + // THEN + // eslint-disable-next-line dot-notation + expect(spotEventPluginClient['deadlineClient'].GetRequest).toBeCalledTimes(1); + // eslint-disable-next-line dot-notation + expect(spotEventPluginClient['deadlineClient'].PostRequest).not.toBeCalled(); + expect(added).toBe(false); + }); });