diff --git a/examples/deadline/All-In-AWS-Infrastructure-Basic/python/package/config.py b/examples/deadline/All-In-AWS-Infrastructure-Basic/python/package/config.py index 6b0b7b645..808a3387c 100644 --- a/examples/deadline/All-In-AWS-Infrastructure-Basic/python/package/config.py +++ b/examples/deadline/All-In-AWS-Infrastructure-Basic/python/package/config.py @@ -32,7 +32,7 @@ def __init__(self): # to pin to. Some examples of pinned version values are "10", "10.1", or "10.1.12" self.deadline_version: Optional[str] = None - # A map of regions to Deadline Client Linux AMIs.As an example, the Linux Deadline 10.1.12.1 AMI ID + # A map of regions to Deadline Client Linux AMIs. As an example, the Linux Deadline 10.1.12.1 AMI ID # from us-west-2 is filled in. It can be used as-is, added to, or replaced. Ideally the version here # 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-039f0c1faba28b015'} diff --git a/examples/deadline/All-In-AWS-Infrastructure-SEP/README.md b/examples/deadline/All-In-AWS-Infrastructure-SEP/README.md index 95d36f397..b9a3a4da6 100644 --- a/examples/deadline/All-In-AWS-Infrastructure-SEP/README.md +++ b/examples/deadline/All-In-AWS-Infrastructure-SEP/README.md @@ -10,7 +10,7 @@ _**Note:** This application is an illustrative example to showcase some of the c ## Architecture -This sample application deploys a basic Deadline Render farm that is configured to use Deadlines [Spot Event Plugin](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html). +This sample application deploys a basic Deadline Render farm that is configured to use Deadline's [Spot Event Plugin](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html). ### Components @@ -22,15 +22,15 @@ The Repository component contains the database and file system that store persis #### Render Queue -The Render Queue component contains the fleet of [Deadline Remote Connection Server](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/remote-connection-server.html) instances behind an [Application Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html). This acts as the central service for Deadline applications and is the only component that interacts with the Repository. When comparing this component to the "All in AWS Infrastructure - Basic" example it has been granted additional permissions in order to use the Spot Event Plugin. +The Render Queue component contains the fleet of [Deadline Remote Connection Server](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/remote-connection-server.html) instances behind an [Application Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html). This acts as the central service for Deadline applications and is the only component that interacts with the Repository. When comparing this component to the "All in AWS Infrastructure - Basic" example, it has been granted additional permissions in order to use the Spot Event Plugin. #### Spot Event Plugin Configurations -The Spot Event plugin requires additional Roles for both Deadline's Resource Tracker and the Spot Workers that are created and a Security Group to allow your Spot workers the ability to access the Render Queue. +Spot Event Plugin Configuration Setup component generates and saves the [Spot Fleet Request Configurations](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html#spot-fleet-request-configurations). The Spot Workers that are created will be configured to connect to the Render Queue. The Spot Event Plugin requires additional Role for Deadline's Resource Tracker. ## Prerequisites -- The Spot Fleet Configuration requires an [Amazon Machine Image (AMI)](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/AMIs.html) with the Deadline Worker application installed. This AMI must have Deadline Installed and should be configured to connect to your repository. For additional information on setting up your AMI please see the [Spot Event Plugin Documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html). +- The Spot Fleet Configuration requires an [Amazon Machine Image (AMI)](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/AMIs.html) with the Deadline Worker application installed. This AMI must have Deadline Installed and should be configured to connect to your repository. For additional information on setting up your AMI please see the [Spot Event Plugin Documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html). - You have setup and configured the AWS CLI - Your AWS account already has CDK bootstrapped in the desired region by running `cdk bootstrap` - You must have NodeJS installed on your system diff --git a/examples/deadline/All-In-AWS-Infrastructure-SEP/python/README.md b/examples/deadline/All-In-AWS-Infrastructure-SEP/python/README.md index 33b893d03..2b0253cfb 100644 --- a/examples/deadline/All-In-AWS-Infrastructure-SEP/python/README.md +++ b/examples/deadline/All-In-AWS-Infrastructure-SEP/python/README.md @@ -13,11 +13,13 @@ These instructions assume that your working directory is `examples/deadline/All- --- 1. This sample app on the `mainline` branch may contain features that have not yet been officially released, and may not be available in the `aws-rfdk` package installed through pip from PyPI. To work from an example of the latest release, please switch to the `release` branch. If you would like to try out unreleased features, you can stay on `mainline` and follow the instructions for building, packing, and installing the `aws-rfdk` from your local repository. + 2. Install the dependencies of the sample app: ```bash pip install -r requirements.txt ``` + 3. If working on the `release` branch, this step can be skipped. If working on `mainline`, navigate to the base directory where the build and packaging scripts are, then run them and install the result over top of the `aws-rfdk` version that was installed in the previous step: ```bash # Navigate to the root directory of the RFDK repository @@ -32,33 +34,54 @@ These instructions assume that your working directory is `examples/deadline/All- popd pip install ../../../../dist/python/aws-rfdk-.tar.gz ``` -3. Stage the Docker recipes for `RenderQueue`: + +4. Change the value in the `deadline_client_linux_ami_map` variable in `package/config.py` to include the region + AMI ID mapping of your EC2 AMI(s) with Deadline Worker. You can use the following AWS CLI query to find AMI ID's: + ```bash + aws --region ec2 describe-images \ + --owners 357466774442 \ + --filters "Name=name,Values=*Worker*" "Name=name,Values=**" \ + --query 'Images[*].[ImageId, Name]' \ + --output text + ``` + + And enter it into this section of `package/config.py`: + ```python + # For example, in the us-west-2 region + self.deadline_client_linux_ami_map: Mapping[str, str] = { + 'us-west-2': '' + } + ``` + +5. Stage the Docker recipes for `RenderQueue`: ```bash # Set this value to the version of RFDK your application targets RFDK_VERSION= - # Set this value to the version of AWS Thinkbox Deadline you'd like to deploy to your farm. Deadline 10.1.9 and up are supported. + # Set this value to the version of AWS Thinkbox Deadline you'd like to deploy to your farm. Deadline 10.1.12 and up are supported. RFDK_DEADLINE_VERSION= npx --package=aws-rfdk@${RFDK_VERSION} stage-deadline --output stage ${RFDK_DEADLINE_VERSION} ``` -4. Deploy all the stacks in the sample app: + +6. Deploy all the stacks in the sample app: ```bash cdk deploy "*" ``` -5. Connect to your Render Farm and open up the Deadline Monitor. +7. You can now [connect to the farm](https://docs.aws.amazon.com/rfdk/latest/guide/connecting-to-render-farm.html) and [submit rendering jobs](https://docs.aws.amazon.com/rfdk/latest/guide/first-rfdk-app.html#_optional_submit_a_job_to_the_render_farm). + + **Note:** In order for the Spot Event Plugin to create a Spot Fleet Request you need to: + * Create the Deadline Group associated with the Spot Fleet Request Configuration. See [Deadline Documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/pools-and-groups.html). + * Create the Deadline Pools to which the fleet Workers are added. See [Deadline Documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/pools-and-groups.html). + * Submit the job with the assigned Deadline Group and Deadline Pool. See [Deadline Documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/job-submitting.html#submitting-jobs). + + **Note:** Disable 'Allow Workers to Perform House Cleaning If Pulse is not Running' in the 'Configure Repository Options' when using Spot Event Plugin. See [Deadline Documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html#prerequisites). -6. Configure the Spot event plugin by following the directions in the [Spot Event Plugin documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html) with the following considerations: +8. Once you are finished with the sample app, you can tear it down by running: - Use the default security credentials by using turning "Use Local Credentials" to False and leaving both "Access Key ID" and "Secret Access Key" blank. - Ensure that the Region your Spot workers will be launched in is the same region as your CDK application. - When Creating your Spot Fleet Requests, set the IAM instance profile to "DeadlineSpotWorkerRole" and set the security group to "DeadlineSpotSecurityGroup". - Configure your instances to connect to the Render Queue by either creating your AMI after launching your app and preconfiguring the AMI or by setting up a userdata in the Spot Fleet Request. (see the Spot Event Plugin documentation for additional information on configuring this connection.) - -7. Once you are finished with the sample app, you can tear it down by running: + **Note:** Any resources created by the Spot Event Plugin will not be deleted with `cdk destroy`. Make sure that all such resources (e.g. Spot Fleet Request or Fleet Instances) are cleaned up, before destroying the stacks. Disable the Spot Event Plugin by setting 'state' property to 'SpotEventPluginState.DISABLED' or via Deadline Monitor, ensure you shutdown all Pulse instances and then terminate any Spot Fleet Requests in the AWS EC2 Instance Console. ```bash cdk destroy "*" 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 d0f6eb611..b921967a1 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 @@ -7,14 +7,27 @@ from aws_cdk.core import ( App, - Environment + Environment, +) + +from aws_cdk.aws_ec2 import ( + MachineImage, ) from .lib import ( sep_stack, ) +from .config import config + def main(): + # ------------------------------ + # Validate Config Values + # ------------------------------ + + if 'region' in config.deadline_client_linux_ami_map: + raise ValueError('Deadline Client Linux AMI map is required but was not specified.') + # ------------------------------ # Application # ------------------------------ @@ -28,11 +41,10 @@ def main(): account=os.environ.get('CDK_DEPLOY_ACCOUNT', os.environ.get('CDK_DEFAULT_ACCOUNT')), region=os.environ.get('CDK_DEPLOY_REGION', os.environ.get('CDK_DEFAULT_REGION')) ) - # ------------------------------ - # Service Tier - # ------------------------------ + 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), ) 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 new file mode 100644 index 000000000..af52afa66 --- /dev/null +++ b/examples/deadline/All-In-AWS-Infrastructure-SEP/python/package/config.py @@ -0,0 +1,23 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from typing import ( + List, + Mapping, + Optional, +) + +class AppConfig: + """ + Configuration values for the sample app. + + TODO: Fill these in with your own values. + """ + def __init__(self): + # A map of regions to Deadline Client Linux AMIs. As an example, the Linux Deadline 10.1.12.1 AMI ID + # from us-west-2 is filled in. It can be used as-is, added to, or replaced. Ideally the version here + # 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-039f0c1faba28b015'} + + +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 3c9ed816b..ce7d52187 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 @@ -1,50 +1,75 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -import typing +from typing import ( + Optional, +) from dataclasses import dataclass - from aws_cdk.core import ( Construct, Duration, + RemovalPolicy, Stack, - StackProps + StackProps, + Tags, ) from aws_cdk.aws_ec2 import ( + IMachineImage, + InstanceClass, + InstanceSize, + InstanceType, SecurityGroup, Vpc, ) from aws_cdk.aws_iam import ( ManagedPolicy, Role, - ServicePrincipal + ServicePrincipal, +) +from aws_cdk.aws_elasticloadbalancingv2 import ( + ApplicationProtocol, +) +from aws_cdk.aws_route53 import ( + PrivateHostedZone, ) from aws_rfdk.deadline import ( + ConfigureSpotEventPlugin, RenderQueue, + RenderQueueExternalTLSProps, + RenderQueueHostNameProps, + RenderQueueTrafficEncryptionProps, Repository, + RepositoryRemovalPolicies, + SpotEventPluginFleet, + SpotEventPluginSettings, Stage, ThinkboxDockerRecipes, ) +from aws_rfdk import ( + DistinguishedName, + X509CertificatePem, +) @dataclass class SEPStackProps(StackProps): """ - Properties for ServiceTier + Properties for SEPStack """ # The path to the directory where the staged Deadline Docker recipes are. docker_recipes_stage_path: str + # The IMachineImage to use for Workers (needs Deadline Client installed). + worker_machine_image: IMachineImage class SEPStack(Stack): """ - The service tier contains all "business-logic" constructs - (e.g. Render Queue, UBL Licensing/License Forwarder, etc.). + This stack contains all the constructs required to set the Spot Event Plugin Configuration. """ def __init__(self, scope: Construct, stack_id: str, *, props: SEPStackProps, **kwargs): """ - Initialize a new instance of ServiceTier + Initialize a new instance of SEPStack :param scope: The scope of this construct. :param stack_id: The ID of this construct. :param props: The properties for this construct. @@ -56,13 +81,13 @@ def __init__(self, scope: Construct, stack_id: str, *, props: SEPStackProps, **k vpc = Vpc( self, 'Vpc', - max_azs=2 + max_azs=2, ) recipes = ThinkboxDockerRecipes( self, 'Image', - stage=Stage.from_directory(props.docker_recipes_stage_path) + stage=Stage.from_directory(props.docker_recipes_stage_path), ) repository = Repository( @@ -70,51 +95,71 @@ def __init__(self, scope: Construct, stack_id: str, *, props: SEPStackProps, **k 'Repository', vpc=vpc, version=recipes.version, - repository_installation_timeout=Duration.minutes(20) + repository_installation_timeout=Duration.minutes(20), + # TODO - Evaluate deletion protection for your own needs. These properties are set to RemovalPolicy.DESTROY + # to cleanly remove everything when this stack is destroyed. If you would like to ensure + # that these resources are not accidentally deleted, you should set these properties to RemovalPolicy.RETAIN + # or just remove the removal_policy parameter. + removal_policy=RepositoryRemovalPolicies( + database=RemovalPolicy.DESTROY, + filesystem=RemovalPolicy.DESTROY, + ), + ) + + host = 'renderqueue' + zone_name = 'deadline-test.internal' + + # Internal DNS zone for the VPC. + dns_zone = PrivateHostedZone( + self, + 'DnsZone', + vpc=vpc, + zone_name=zone_name, + ) + + ca_cert = X509CertificatePem( + self, + 'RootCA', + subject=DistinguishedName( + cn='SampleRootCA', + ), + ) + + server_cert = X509CertificatePem( + self, + 'RQCert', + subject=DistinguishedName( + cn=f'{host}.{dns_zone.zone_name}', + o='RFDK-Sample', + ou='RenderQueueExternal', + ), + signing_certificate=ca_cert, ) render_queue = RenderQueue( self, 'RenderQueue', - vpc=props.vpc, + vpc=vpc, version=recipes.version, images=recipes.render_queue_images, repository=repository, # TODO - Evaluate deletion protection for your own needs. This is set to false to # cleanly remove everything when this stack is destroyed. If you would like to ensure # that this resource is not accidentally deleted, you should set this to true. - deletion_protection=False - ) - # Adds the following IAM managed Policies to the Render Queue so it has the necessary permissions - # to run the Spot Event Plugin and launch a Resource Tracker: - # * AWSThinkboxDeadlineSpotEventPluginAdminPolicy - # * AWSThinkboxDeadlineResourceTrackerAdminPolicy - render_queue.add_sep_policies() - - # Create the security group that you will assign to your workers - worker_security_group = SecurityGroup( - self, - 'SpotSecurityGroup', - vpc=props.vpc, - allow_all_outbound=True, - security_group_name='DeadlineSpotSecurityGroup', - ) - worker_security_group.connections.allow_to_default_port( - render_queue.endpoint - ) - - # Create the IAM Role for the Spot Event Plugins workers. - # Note: This Role MUST have a roleName that begins with "DeadlineSpot" - # Note: If you already have a worker IAM role in your account you can remove this code. - worker_iam_role = Role( - self, - 'SpotWorkerRole', - assumed_by=ServicePrincipal('ec2.amazonaws.com'), - managed_policies= [ManagedPolicy.from_aws_managed_policy_name('AWSThinkboxDeadlineSpotEventPluginWorkerPolicy')], - role_name= 'DeadlineSpotWorkerRole', + deletion_protection=False, + hostname=RenderQueueHostNameProps( + hostname=host, + zone=dns_zone, + ), + traffic_encryption=RenderQueueTrafficEncryptionProps( + external_tls=RenderQueueExternalTLSProps( + rfdk_certificate=server_cert, + ), + internal_protocol=ApplicationProtocol.HTTPS, + ), ) - # Creates the Resource Tracker Access role. This role is required to exist in your account so the resource tracker will work properly + # 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, @@ -124,3 +169,28 @@ def __init__(self, scope: Construct, stack_id: str, *, props: SEPStackProps, **k role_name= 'DeadlineResourceTrackerAccessRole', ) + fleet = SpotEventPluginFleet( + self, + 'SpotEventPluginFleet', + vpc=vpc, + render_queue=render_queue, + deadline_groups=['group_name'], + instance_types=[InstanceType.of(InstanceClass.BURSTABLE3, InstanceSize.LARGE)], + worker_machine_image=props.worker_machine_image, + max_capacity=1, + ) + + # Optional: Add additional tags to both spot fleet request and spot instances. + Tags.of(fleet).add('name', 'SEPtest') + + ConfigureSpotEventPlugin( + self, + 'ConfigureSpotEventPlugin', + vpc=vpc, + render_queue=render_queue, + spot_fleets=[fleet], + configuration=SpotEventPluginSettings( + enable_resource_tracker=True, + ), + ) + diff --git a/examples/deadline/All-In-AWS-Infrastructure-SEP/ts/README.md b/examples/deadline/All-In-AWS-Infrastructure-SEP/ts/README.md index 8c3427be9..e58b4b246 100644 --- a/examples/deadline/All-In-AWS-Infrastructure-SEP/ts/README.md +++ b/examples/deadline/All-In-AWS-Infrastructure-SEP/ts/README.md @@ -12,17 +12,38 @@ These instructions assume that your working directory is `examples/deadline/All- --- 1. This sample app on the `mainline` branch may contain features that have not yet been officially released, and may not be available in the `aws-rfdk` package installed through npm from npmjs. To work from an example of the latest release, please switch to the `release` branch. If you would like to try out unreleased features, you can stay on `mainline` and follow the instructions for building and using the `aws-rfdk` from your local repository. + 2. Install the dependencies of the sample app: ``` yarn install ``` -3. Modify the `deadline_ver` field in the `config` block of `package.json` as desired (Deadline 10.1.9 and up are supported), then stage the Docker recipes for `RenderQueue`: +3. Modify the `deadline_ver` field in the `config` block of `package.json` as desired (Deadline 10.1.12 and up are supported), then stage the Docker recipes for `RenderQueue`: ``` yarn stage ``` -4. Build the `aws-rfdk` package, and then build the sample app. There is some magic in the way yarn workspaces and lerna packages work that will link the built `aws-rfdk` from the base directory as the dependency to be used in the example's directory: + +4. Change the value of the `deadlineClientLinuxAmiMap` variable in `bin/config.ts` to include the region + AMI ID mapping of your EC2 AMI(s) with Deadline Worker. You can use the following AWS CLI query to find AMI ID's: + ``` + aws --region ec2 describe-images \ + --owners 357466774442 \ + --filters "Name=name,Values=*Worker*" "Name=name,Values=**" \ + --query 'Images[*].[ImageId, Name]' \ + --output text + ``` + + And enter it into this section of `bin/config.ts`: + ```ts + // For example, in the us-west-2 region + public readonly deadlineClientLinuxAmiMap: Record = { + ['us-west-2']: '', + // ... + }; + ``` + +5. Build the `aws-rfdk` package, and then build the sample app. There is some magic in the way yarn workspaces and lerna packages work that will link the built `aws-rfdk` from the base directory as the dependency to be used in the example's directory: + ```bash # Navigate to the root directory of the RFDK repository (assumes you started in the example's directory) pushd ../../../.. @@ -35,23 +56,26 @@ These instructions assume that your working directory is `examples/deadline/All- # Run the example's build yarn build ``` -5. Deploy all the stacks in the sample app: + +6. Deploy all the stacks in the sample app: ``` cdk deploy ``` -6. Connect to your Render Farm and open up the Deadline Monitor. +7. You can now [connect to the farm](https://docs.aws.amazon.com/rfdk/latest/guide/connecting-to-render-farm.html) and [submit rendering jobs](https://docs.aws.amazon.com/rfdk/latest/guide/first-rfdk-app.html#_optional_submit_a_job_to_the_render_farm). -7. Configure the Spot event plugin by following the directions in the [Spot Event Plugin documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html) with the following considerations: + **Note:** In order for the Spot Event Plugin to create a Spot Fleet Request you need to: + * Create the Deadline Group associated with the Spot Fleet Request Configuration. See [Deadline Documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/pools-and-groups.html). + * Create the Deadline Pools to which the fleet Workers are added. See [Deadline Documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/pools-and-groups.html). + * Submit the job with the assigned Deadline Group and Deadline Pool. See [Deadline Documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/job-submitting.html#submitting-jobs). - Use the default security credentials by using turning "Use Local Credentials" to False and leaving both "Access Key ID" and "Secret Access Key" blank. - Ensure that the Region your Spot workers will be launched in is the same region as your CDK application. - When Creating your Spot Fleet Requests, set the IAM instance profile to "DeadlineSpotWorkerRole" and set the security group to "DeadlineSpotSecurityGroup". - Configure your instances to connect to the Render Queue by either creating your AMI after launching your app and preconfiguring the AMI or by setting up a userdata in the Spot Fleet Request. (see the Spot Event Plugin documentation for additional information on configuring this connection.) + **Note:** Disable 'Allow Workers to Perform House Cleaning If Pulse is not Running' in the 'Configure Repository Options' when using Spot Event Plugin. See [Deadline Documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html#prerequisites). 8. Once you are finished with the sample app, you can tear it down by running: + **Note:** Any resources created by the Spot Event Plugin will not be deleted with `cdk destroy`. Make sure that all such resources (e.g. Spot Fleet Request or Fleet Instances) are cleaned up, before destroying the stacks. Disable the Spot Event Plugin by setting 'state' property to 'SpotEventPluginState.DISABLED' or via Deadline Monitor, ensure you shutdown all Pulse instances and then terminate any Spot Fleet Requests in the AWS EC2 Instance Console. + ``` cdk destroy ``` 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 682d5da6b..4e724423a 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 @@ -7,8 +7,18 @@ import 'source-map-support/register'; import * as path from 'path'; import * as pkg from '../package.json'; +import { MachineImage } from '@aws-cdk/aws-ec2'; import * as cdk from '@aws-cdk/core'; import { SEPStack } from '../lib/sep-stack'; +import { config } from './config'; + +// ------------------------------ // +// --- Validate Config Values --- // +// ------------------------------ // + +if (config.deadlineClientLinuxAmiMap === {['region']: 'ami-id'}) { + throw new Error('Deadline Client Linux AMI map is required but was not specified.'); +} // ------------------- // // --- Application --- // @@ -25,4 +35,5 @@ const app = new cdk.App(); 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), }); 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 new file mode 100644 index 000000000..f0a05b062 --- /dev/null +++ b/examples/deadline/All-In-AWS-Infrastructure-SEP/ts/bin/config.ts @@ -0,0 +1,21 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import 'source-map-support/register'; + +/** + * Configuration values for the sample app. + * + * TODO: Fill these in with your own values. + */ +class AppConfig { + /** + * A map of regions to Deadline Client Linux AMIs. As an example, the Linux Deadline 10.1.12.1 AMI ID from us-west-2 + * is filled in. It can be used as-is, added to, or replaced. + */ + public readonly deadlineClientLinuxAmiMap: Record = {['us-west-2']: 'ami-039f0c1faba28b015'}; +} + +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 8d1862cca..4d553a9df 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 @@ -4,26 +4,36 @@ */ import { - SecurityGroup, + IMachineImage, + InstanceClass, + InstanceSize, + InstanceType, Vpc, } from '@aws-cdk/aws-ec2'; import { Construct, Duration, + RemovalPolicy, Stack, - StackProps + StackProps, + Tags, } from '@aws-cdk/core'; +import { ApplicationProtocol } from '@aws-cdk/aws-elasticloadbalancingv2'; import { ManagedPolicy, Role, - ServicePrincipal + ServicePrincipal, } from '@aws-cdk/aws-iam'; +import { PrivateHostedZone } from '@aws-cdk/aws-route53'; import { + ConfigureSpotEventPlugin, RenderQueue, Repository, + SpotEventPluginFleet, Stage, ThinkboxDockerRecipes, } from 'aws-rfdk/deadline'; +import { X509CertificatePem } from 'aws-rfdk'; /** * Properties for {@link SEPStack}. @@ -34,12 +44,17 @@ export interface SEPStackProps extends StackProps { * The path to the directory where the staged Deadline Docker recipes are. */ readonly dockerRecipesStagePath: string; + + /** + * The {@link IMachineImage} to use for Workers (needs Deadline Client installed). + */ + readonly workerMachineImage: IMachineImage; } export class SEPStack extends Stack { /** - * Initializes a new instance of {@link NetworkTier}. + * Initializes a new instance of SEPStack. * @param scope The scope of this construct. * @param id The ID of this construct. * @param props The stack properties. @@ -57,8 +72,47 @@ export class SEPStack extends Stack { vpc, version: recipes.version, repositoryInstallationTimeout: Duration.minutes(20), + // TODO - Evaluate deletion protection for your own needs. These properties are set to RemovalPolicy.DESTROY + // to cleanly remove everything when this stack is destroyed. If you would like to ensure + // that these resources are not accidentally deleted, you should set these properties to RemovalPolicy.RETAIN + // or just remove the removalPolicy parameter. + removalPolicy: { + database: RemovalPolicy.DESTROY, + filesystem: RemovalPolicy.DESTROY, + }, + }); + + const host = 'renderqueue'; + const zoneName = 'deadline-test.internal'; + + const hostname = { + zone: new PrivateHostedZone(this, 'DnsZone', { + vpc, + zoneName: zoneName, + }), + hostname: host, + }; + + const caCert = new X509CertificatePem(this, 'RootCA', { + subject: { + cn: 'SampleRootCA', + }, }); + const trafficEncryption = { + externalTLS: { + rfdkCertificate: new X509CertificatePem(this, 'RQCert', { + subject: { + cn: `${host}.${zoneName}`, + o: 'RFDK-Sample', + ou: 'RenderQueueExternal', + }, + signingCertificate: caCert, + }), + internalProtocol: ApplicationProtocol.HTTPS, + }, + }; + const renderQueue = new RenderQueue(this, 'RenderQueue', { vpc, version: recipes.version, @@ -68,41 +122,45 @@ export class SEPStack extends Stack { // cleanly remove everything when this stack is destroyed. If you would like to ensure // that this resource is not accidentally deleted, you should set this to true. deletionProtection: false, + hostname, + trafficEncryption, }); - // Adds the following IAM managed Policies to the Render Queue so it has the necessary permissions - // to run the Spot Event Plugin and launch a Resource Tracker: - // * AWSThinkboxDeadlineSpotEventPluginAdminPolicy - // * AWSThinkboxDeadlineResourceTrackerAdminPolicy - renderQueue.addSEPPolicies(); - - // Create the security group that you will assign to your workers - const workerSecurityGroup = new SecurityGroup(this, 'SpotSecurityGroup', { - vpc, - allowAllOutbound: true, - securityGroupName: 'DeadlineSpotSecurityGroup', - }); - workerSecurityGroup.connections.allowToDefaultPort(renderQueue.endpoint); - - // Create the IAM Role for the Spot Event Plugins workers. - // Note: This Role MUST have a roleName that begins with "DeadlineSpot" - // Note if you already have a worker IAM role in your account you can remove this code. - new Role( this, 'SpotWorkerRole', { - assumedBy: new ServicePrincipal('ec2.amazonaws.com'), - managedPolicies: [ - ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineSpotEventPluginWorkerPolicy'), - ], - roleName: 'DeadlineSpotWorkerRole', - }); - - // Creates the Resource Tracker Access role. This role is required to exist in your account so the resource tracker will work properly + // 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', { + new Role(this, 'ResourceTrackerRole', { assumedBy: new ServicePrincipal('lambda.amazonaws.com'), managedPolicies: [ ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineResourceTrackerAccessPolicy'), ], roleName: 'DeadlineResourceTrackerAccessRole', }); + + const fleet = new SpotEventPluginFleet(this, 'SpotEventPluginFleet', { + vpc, + renderQueue, + deadlineGroups: [ + 'group_name', + ], + instanceTypes: [ + InstanceType.of(InstanceClass.T3, InstanceSize.LARGE), + ], + workerMachineImage: props.workerMachineImage, + maxCapacity: 1, + }); + + // Optional: Add additional tags to both spot fleet request and spot instances. + Tags.of(fleet).add('name', 'SEPtest'); + + new ConfigureSpotEventPlugin(this, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueue, + spotFleets: [ + fleet, + ], + configuration: { + enableResourceTracker: true, + }, + }); } } diff --git a/packages/aws-rfdk/lib/core/lib/runtime-info.ts b/packages/aws-rfdk/lib/core/lib/runtime-info.ts index 302d20512..d1e80df79 100644 --- a/packages/aws-rfdk/lib/core/lib/runtime-info.ts +++ b/packages/aws-rfdk/lib/core/lib/runtime-info.ts @@ -15,6 +15,31 @@ import { */ const TAG_NAME = 'aws-rfdk'; +interface TagFields { + /** + * The name of the tag + */ + readonly name: string; + + /** + * The value of the tag + */ + readonly value: string; +} + +/** + * Returns the fields to be used for tagging AWS resources for a given construct + * + * @param scope The construct instance whose underlying resources should be tagged + */ +export function tagFields(scope: T): TagFields { + const className = scope.constructor.name; + return { + name: TAG_NAME, + value: `${RFDK_VERSION}:${className}`, + }; +} + /** * Function that reads in the version of RFDK from the `package.json` file. */ @@ -34,8 +59,6 @@ export const RFDK_VERSION = getVersion(); * @param scope A construct instance to tag */ export function tagConstruct(scope: T) { - // The constructor property is a reference to the "function" used to create - const className = scope.constructor.name; - const value = `${RFDK_VERSION}:${className}`; - Tags.of(scope).add(TAG_NAME, value); + const fields = tagFields(scope); + Tags.of(scope).add(fields.name, fields.value); } diff --git a/packages/aws-rfdk/lib/deadline/README.md b/packages/aws-rfdk/lib/deadline/README.md index 03f747e9b..fef549380 100644 --- a/packages/aws-rfdk/lib/deadline/README.md +++ b/packages/aws-rfdk/lib/deadline/README.md @@ -8,10 +8,12 @@ import * as deadline from 'aws-rfdk/deadline'; --- -_**Note:** RFDK constructs currently support Deadline 10.1.9 and later._ +_**Note:** RFDK constructs currently support Deadline 10.1.9 and later, unless otherwise stated._ --- - +- [Configure Spot Event Plugin](#configure-spot-event-plugin) (supports Deadline 10.1.12 and later) + - [Saving Spot Event Plugin Options](#saving-spot-event-plugin-options) + - [Saving Spot Fleet Request Configurations](#saving-spot-fleet-request-configurations) - [Render Queue](#render-queue) - [Docker Container Images](#render-queue-docker-container-images) - [Encryption](#render-queue-encryption) @@ -19,6 +21,8 @@ _**Note:** RFDK constructs currently support Deadline 10.1.9 and later._ - [Deletion Protection](#render-queue-deletion-protection) - [Repository](#repository) - [Configuring Deadline Client Connections](#configuring-deadline-client-connections) +- [Spot Event Plugin Fleet](#spot-event-plugin-fleet) (supports Deadline 10.1.12 and later) + - [Changing Default Options](#changing-default-options) - [Stage](#stage) - [Staging Docker Recipes](#staging-docker-recipes) - [ThinkboxDockerImages](#thinkbox-docker-images) @@ -30,6 +34,70 @@ _**Note:** RFDK constructs currently support Deadline 10.1.9 and later._ - [Health Monitoring](#worker-fleet-health-monitoring) - [Custom Worker Instance Startup](#custom-worker-instance-startup) +## Configure Spot Event Plugin + +The [Spot Event Plugin](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html) can scale cloud-based EC2 Spot instances dynamically based on the queued Jobs and Tasks in the Deadline Database. It associates a Spot Fleet Request with named Deadline Worker Groups, allowing multiple Spot Fleets with different hardware and software specifications to be launched for different types of Jobs based on their Group assignment. + +The `ConfigureSpotEventPlugin` construct has two main responsibilities: +- Construct a [Spot Fleet Request](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/spot-fleet-requests.html) configuration from the list of [Spot Event Plugin Fleets](#spot-event-plugin-fleet). +- Modify and save the options of the Spot Event Plugin itself. See [Deadline Documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html#event-plugin-configuration-options). + +--- + +**Note:** This construct will configure the Spot Event Plugin, but the Spot Fleet Requests will not be created unless you: +- Create the Deadline Group associated with the Spot Fleet Request Configuration. See [Deadline Documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/pools-and-groups.html). +- Create the Deadline Pools to which the fleet Workers are added. See [Deadline Documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/pools-and-groups.html). +- Submit the job with the assigned Deadline Group and Deadline Pool. See [Deadline Documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/job-submitting.html#submitting-jobs). + +--- + +--- + +_**Note:** Disable 'Allow Workers to Perform House Cleaning If Pulse is not Running' in the 'Configure Repository Options' when using Spot Event Plugin. See [Deadline Documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html#prerequisites)._ + +--- + +--- + +_**Note:** Any resources created by the Spot Event Plugin will not be deleted with `cdk destroy`. Make sure that all such resources (e.g. Spot Fleet Request or Fleet Instances) are cleaned up, before destroying the stacks. Disable the Spot Event Plugin by setting 'state' property to 'SpotEventPluginState.DISABLED' or via Deadline Monitor, ensure you shutdown all Pulse instances and then terminate any Spot Fleet Requests in the AWS EC2 Instance Console._ + +--- + +### Saving Spot Event Plugin Options + +To set the Spot Event Plugin options use `configuration` property of the `ConfigureSpotEventPlugin` construct: + +```ts +const vpc = new Vpc(/* ... */); +const renderQueue = new RenderQueue(stack, 'RenderQueue', /* ... */); + +const spotEventPluginConfig = new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueue, + configuration: { + enableResourceTracker: true, + }, +}); +``` + +This property is optional, so if you don't provide it, the default Spot Event Plugin Options will be used. + +### Saving Spot Fleet Request Configurations + +Use the `spotFleets` property to construct the Spot Fleet Request Configurations from a given [Spot Event Plugin Fleet](#spot-event-plugin-fleet): + +```ts +const fleet = new SpotEventPluginFleet(stack, 'SpotEventPluginFleet', /* ... */); + +const spotEventPluginConfig = new ConfigureSpotEventPlugin(this, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueue, + spotFleets: [ + fleet, + ], +}); +``` + ## Render Queue The `RenderQueue` is the central service of a Deadline render farm. It consists of the following components: @@ -158,6 +226,95 @@ repository.configureClientInstance({ }); ``` +## Spot Event Plugin Fleet + +This construct represents a Spot Fleet launched by the [Deadline's Spot Event Plugin](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html) from the [Spot Fleet Request Configuration](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html#spot-fleet-request-configurations). The construct itself doesn't create a Spot Fleet Request, but creates all the required resources to be used in the Spot Fleet Request Configuration. + +This construct is expected to be used as an input to the [ConfigureSpotEventPlugin](#configure-spot-event-plugin) construct. `ConfigureSpotEventPlugin` construct will generate a Spot Fleet Request Configuration from each provided `SpotEventPluginFleet` and will set these configurations to the Spot Event Plugin. + +_**Note:** You will have to create the groups manually using Deadline before submitting jobs. See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/pools-and-groups.html + +```ts +const vpc = new Vpc(/* ... */); +const renderQueue = new RenderQueue(stack, 'RenderQueue', /* ... */); + +const fleet = new SpotEventPluginFleet(this, 'SpotEventPluginFleet', { + vpc, + renderQueue, + deadlineGroups: [ + 'group_name1', + 'group_name2', + ], + instanceTypes: [InstanceType.of(InstanceClass.T3, InstanceSize.LARGE)], + workerMachineImage: new GenericLinuxImage(/* ... */), + maxCapacity: 1, +}); +``` + +### Changing Default Options + +Here are a few examples of how you set some additional properties of the `SpotEventPluginFleet`: + +#### Setting Allocation Strategy + +Use `allocationStrategy` property to change the default allocation strategy of the Spot Fleet Request: + +```ts +const fleet = new SpotEventPluginFleet(this, 'SpotEventPluginFleet', { + vpc, + renderQueue, + deadlineGroups: [ + 'group_name', + ], + instanceTypes: [InstanceType.of(InstanceClass.T3, InstanceSize.LARGE)], + workerMachineImage: new GenericLinuxImage(/* ... */), + maxCapacity: 1, + allocationStrategy: SpotFleetAllocationStrategy.CAPACITY_OPTIMIZED, +}); +``` + +#### Adding Deadline Pools + +You can add the Workers to Deadline's Pools providing a list of pools as following: + +_**Note:** You will have to create the pools manually using Deadline before submitting jobs. See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/pools-and-groups.html + +```ts +const fleet = new SpotEventPluginFleet(this, 'SpotEventPluginFleet', { + vpc, + renderQueue, + deadlineGroups: [ + 'group_name', + ], + instanceTypes: [InstanceType.of(InstanceClass.T3, InstanceSize.LARGE)], + workerMachineImage: new GenericLinuxImage(/* ... */), + maxCapacity: 1, + deadlinePools: [ + 'pool1', + 'pool2', + ], +}); +``` + +#### Setting the End Date And Time + +By default, the Spot Fleet Request will be valid until you cancel it. +You can set the end date and time until the Spot Fleet request is valid using `validUntil` property: + +```ts +const fleet = new SpotEventPluginFleet(this, 'SpotEventPluginFleet', { + vpc, + renderQueue, + deadlineGroups: [ + 'group_name', + ], + instanceTypes: [InstanceType.of(InstanceClass.T3, InstanceSize.LARGE)], + workerMachineImage: new GenericLinuxImage(/* ... */), + maxCapacity: 1, + validUntil: Expiration.atDate(new Date(2022, 11, 17)), +}); +``` + ## Stage A stage is a directory that conforms to a [conventional structure](../../docs/DockerImageRecipes.md#stage-directory-convention) that RFDK requires to deploy Deadline. This directory contains the Docker image recipes that RFDK uses to build Docker images. 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 new file mode 100644 index 000000000..96acf7563 --- /dev/null +++ b/packages/aws-rfdk/lib/deadline/lib/configure-spot-event-plugin.ts @@ -0,0 +1,649 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'path'; + +import { + BlockDevice, + BlockDeviceVolume, +} from '@aws-cdk/aws-autoscaling'; +import { + IVpc, + SubnetSelection, + SubnetType, +} from '@aws-cdk/aws-ec2'; +import { + Role, + Policy, + PolicyStatement, +} from '@aws-cdk/aws-iam'; +import { + Code, + Function as LambdaFunction, + Runtime, +} from '@aws-cdk/aws-lambda'; +import { RetentionDays } from '@aws-cdk/aws-logs'; +import { + Construct, + CustomResource, + Duration, + Fn, + IResolvable, + Lazy, + Stack, +} from '@aws-cdk/core'; +import { + PluginSettings, + SEPConfiguratorResourceProps, + LaunchSpecification, + SpotFleetRequestConfiguration, + SpotFleetRequestProps, + SpotFleetSecurityGroupId, + SpotFleetTagSpecification, + BlockDeviceMappingProperty, + BlockDeviceProperty, +} from '../../lambdas/nodejs/configure-spot-event-plugin'; +import { IRenderQueue, RenderQueue } from './render-queue'; +import { SpotEventPluginFleet } from './spot-event-plugin-fleet'; +import { + SpotFleetRequestType, + SpotFleetResourceType, +} from './spot-event-plugin-fleet-ref'; +import { Version } from './version'; + +/** + * How the event plug-in should respond to events. + */ +export enum SpotEventPluginState { + /** + * The Render Queue, all jobs and Workers will trigger the events for this plugin. + */ + GLOBAL_ENABLED = 'Global Enabled', + + /** + * No events are triggered for the plugin. + */ + DISABLED = 'Disabled', +} + +/** + * Logging verbosity levels for the Spot Event Plugin. + */ +export enum SpotEventPluginLoggingLevel { + /** + * Standard logging level. + */ + STANDARD = 'Standard', + + /** + * Detailed logging about the inner workings of the Spot Event Plugin. + */ + VERBOSE = 'Verbose', + + /** + * All Verbose logs plus additional information on AWS API calls that are used. + */ + DEBUG = 'Debug', + + /** + * No logging enabled. + */ + OFF = 'Off', +} + +/** + * How the Spot Event Plugin should handle Pre Job Tasks. + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/job-scripts.html + */ +export enum SpotEventPluginPreJobTaskMode { + /** + * Only start 1 Spot instance for the pre job task and ignore any other tasks for that job until the pre job task is completed. + */ + CONSERVATIVE = 'Conservative', + + /** + * Do not take the pre job task into account when calculating target capacity. + */ + IGNORE = 'Ignore', + + /** + * Treat the pre job task like a regular job queued task. + */ + NORMAL = 'Normal', +} + +/** + * The Worker Extra Info column to be used to display AWS Instance Status + * if the instance has been marked to be stopped or terminated by EC2 or Spot Event Plugin. + * See "AWS Instance Status" option at https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html#event-plugin-configuration-options + * and https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/worker-config.html#extra-info + */ +export enum SpotEventPluginDisplayInstanceStatus { + DISABLED = 'Disabled', + EXTRA_INFO_0 = 'ExtraInfo0', + EXTRA_INFO_1 = 'ExtraInfo0', + EXTRA_INFO_2 = 'ExtraInfo0', + EXTRA_INFO_3 = 'ExtraInfo0', + EXTRA_INFO_4 = 'ExtraInfo0', + EXTRA_INFO_5 = 'ExtraInfo0', + EXTRA_INFO_6 = 'ExtraInfo0', + EXTRA_INFO_7 = 'ExtraInfo0', + EXTRA_INFO_8 = 'ExtraInfo0', + EXTRA_INFO_9 = 'ExtraInfo0', +} + +/** + * The settings of the Spot Event Plugin. + * For more details see https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html#event-plugin-configuration-options + */ +export interface SpotEventPluginSettings { + /** + * How the event plug-in should respond to events. + * + * @default SpotEventPluginState.GLOBAL_ENABLED + */ + readonly state?: SpotEventPluginState; + + /** + * Determines whether the Deadline Resource Tracker should be used. + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/resource-tracker-overview.html + * + * @default true + */ + readonly enableResourceTracker?: boolean; + + /** + * Spot Event Plugin logging level. + * Note that Spot Event Plugin adds output to the logs of the render queue and the Workers. + * + * @default SpotEventPluginLoggingLevel.STANDARD + */ + readonly loggingLevel?: SpotEventPluginLoggingLevel; + + /** + * The AWS region in which to start the spot fleet request. + * + * @default The region of the Render Queue if it is available; otherwise the region of the current stack. + */ + readonly region?: string; + + /** + * The length of time that an AWS Worker will wait in a non-rendering state before it is shutdown. + * Should evenly divide into minutes. + * + * @default Duration.minutes(10) + */ + readonly idleShutdown?: Duration; + + /** + * Determines if Deadline Spot Event Plugin terminated AWS Workers will be deleted from the Workers Panel on the next House Cleaning cycle. + * + * @default false + */ + readonly deleteSEPTerminatedWorkers?: boolean; + + /** + * Determines if EC2 Spot interrupted AWS Workers will be deleted from the Workers Panel on the next House Cleaning cycle. + * + * @default false + */ + readonly deleteEC2SpotInterruptedWorkers?: boolean; + + /** + * Determines if any active instances greater than the target capacity for each group will be terminated. + * Workers may be terminated even while rendering. + * + * @default false + */ + readonly strictHardCap?: boolean; + + /** + * The Spot Event Plugin will request this maximum number of instances per House Cleaning cycle. + * + * @default 50 + */ + readonly maximumInstancesStartedPerCycle?: number; + + /** + * Determines how the Spot Event Plugin should handle Pre Job Tasks. + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/job-scripts.html + * + * @default SpotEventPluginPreJobTaskMode.CONSERVATIVE + */ + readonly preJobTaskMode?: SpotEventPluginPreJobTaskMode; + + /** + * The Worker Extra Info column to be used to display AWS Instance Status + * if the instance has been marked to be stopped or terminated by EC2 or Spot Event Plugin. + * All timestamps are displayed in UTC format. + * + * @default SpotEventPluginDisplayInstanceStatus.DISABLED + */ + readonly awsInstanceStatus?: SpotEventPluginDisplayInstanceStatus; +} + +/** + * Input properties for ConfigureSpotEventPlugin. + */ +export interface ConfigureSpotEventPluginProps { + /** + * The VPC in which to create the network endpoint for the lambda function that is + * created by this construct. + */ + readonly vpc: IVpc; + + /** + * The RenderQueue that Worker fleet should connect to. + */ + readonly renderQueue: IRenderQueue; + + /** + * Where within the VPC to place the lambda function's endpoint. + * + * @default The instance is placed within a Private subnet. + */ + readonly vpcSubnets?: SubnetSelection; + + /** + * The array of Spot Event Plugin spot fleets used to generate the mapping between groups and spot fleet requests. + * + * @default Spot Fleet Request Configurations will not be updated. + */ + readonly spotFleets?: SpotEventPluginFleet[]; + + /** + * The Spot Event Plugin settings. + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html#event-plugin-configuration-options + * + * @default Default values of SpotEventPluginSettings will be set. + */ + readonly configuration?: SpotEventPluginSettings; +} + +/** + * This construct configures the Deadline Spot Event Plugin to deploy and auto-scale one or more spot fleets. + * + * For example, to configure the Spot Event Plugin with one spot fleet: + * + * ```ts + * import { App, Stack, Vpc } from '@aws-rfdk/core'; + * import { InstanceClass, InstanceSize, InstanceType } from '@aws-cdk/aws-ec2'; + * import { AwsThinkboxEulaAcceptance, ConfigureSpotEventPlugin, RenderQueue, Repository, SpotEventPluginFleet, ThinkboxDockerImages, VersionQuery } from '@aws-rfdk/deadline'; + * const app = new App(); + * const stack = new Stack(app, 'Stack'); + * const vpc = new Vpc(stack, 'Vpc'); + * const version = new VersionQuery(stack, 'Version', { + * version: '10.1.12', + * }); + * const images = new ThinkboxDockerImages(stack, 'Image', { + * version, + * // Change this to AwsThinkboxEulaAcceptance.USER_ACCEPTS_AWS_THINKBOX_EULA to accept the terms + * // of the AWS Thinkbox End User License Agreement + * userAwsThinkboxEulaAcceptance: AwsThinkboxEulaAcceptance.USER_REJECTS_AWS_THINKBOX_EULA, + * }); + * const repository = new Repository(stack, 'Repository', { + * vpc, + * version, + * }); + * const renderQueue = new RenderQueue(stack, 'RenderQueue', { + * vpc, + * images: images.forRenderQueue(), + * repository: repository, + * }); + * + * const fleet = new SpotEventPluginFleet(this, 'SpotEventPluginFleet', { + * vpc, + * renderQueue, + * deadlineGroups: ['group_name'], + * instanceTypes: [InstanceType.of(InstanceClass.T3, InstanceSize.LARGE)], + * workerMachineImage: new GenericLinuxImage({'us-west-2': 'ami-039f0c1faba28b015'}), + * naxCapacity: 1, + * }); + * + * const spotEventPluginConfig = new ConfigureSpotEventPlugin(this, 'ConfigureSpotEventPlugin', { + * vpc, + * renderQueue: renderQueue, + * spotFleets: [fleet], + * configuration: { + * enableResourceTracker: true, + * }, + * }); + * ``` + * + * To provide this functionality, this construct will create an AWS Lambda function that is granted the ability + * to connect to the render queue. This lambda is run automatically when you deploy or update the stack containing this construct. + * Logs for all AWS Lambdas are automatically recorded in Amazon CloudWatch. + * + * This construct will configure the Spot Event Plugin, but the Spot Fleet Requests will not be created unless you: + * - Create the Deadline Group associated with the Spot Fleet Request Configuration. See [Deadline Documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/pools-and-groups.html). + * - Create the Deadline Pools to which the fleet Workers are added. See [Deadline Documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/pools-and-groups.html). + * - Submit the job with the assigned Deadline Group and Deadline Pool. See [Deadline Documentation](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/job-submitting.html#submitting-jobs). + * + * Important: Disable 'Allow Workers to Perform House Cleaning If Pulse is not Running' in the 'Configure Repository Options' + * when using Spot Event Plugin. + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html#prerequisites + * + * Important: Any resources created by the Spot Event Plugin will not be deleted with 'cdk destroy'. + * Make sure that all such resources (e.g. Spot Fleet Request or Fleet Instances) are cleaned up, before destroying the stacks. + * Disable the Spot Event Plugin by setting 'state' property to 'SpotEventPluginState.DISABLED' or via Deadline Monitor, + * ensure you shutdown all Pulse instances and then terminate any Spot Fleet Requests in the AWS EC2 Instance Console. + * + * Note that this construct adds additional policies to the Render Queue's role + * required to run the Spot Event Plugin and launch a Resource Tracker: + * - AWSThinkboxDeadlineSpotEventPluginAdminPolicy + * - AWSThinkboxDeadlineResourceTrackerAdminPolicy + * - A policy to pass a fleet and instance role + * - A policy to create tags for spot fleet requests + * + * Resources Deployed + * ------------------------ + * - An AWS Lambda that is used to connect to the render queue, and save Spot Event Plugin configurations. + * - 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. + * + * Security Considerations + * ------------------------ + * - The AWS Lambda that is deployed through this construct will be created from a deployment package + * that is uploaded to your CDK bootstrap bucket during deployment. You must limit write access to + * your CDK bootstrap bucket to prevent an attacker from modifying the actions performed by this Lambda. + * We strongly recommend that you either enable Amazon S3 server access logging on your CDK bootstrap bucket, + * or enable AWS CloudTrail on your account to assist in post-incident analysis of compromised production + * environments. + * - The AWS Lambda function that is created by this resource has access to both the certificates used to connect to the render queue, + * and the render queue port. An attacker that can find a way to modify and execute this lambda could use it to + * execute any requets against the render queue. You should not grant any additional actors/principals the ability to modify + * or execute this Lambda. + */ +export class ConfigureSpotEventPlugin extends Construct { + + /** + * Only one Spot Event Plugin Configuration is allowed per render queue / repository. + */ + private static uniqueRenderQueues: Set = new Set(); + + constructor(scope: Construct, id: string, props: ConfigureSpotEventPluginProps) { + super(scope, id); + + if (ConfigureSpotEventPlugin.uniqueRenderQueues.has(props.renderQueue)) { + throw new Error('Only one ConfigureSpotEventPlugin construct is allowed per render queue.'); + } + else { + ConfigureSpotEventPlugin.uniqueRenderQueues.add(props.renderQueue); + } + + if (props.renderQueue instanceof RenderQueue) { + // We do not check the patch version, so it's set to 0. + const minimumVersion: Version = new Version([10, 1, 12, 0]); + + if (props.renderQueue.version.isLessThan(minimumVersion)) { + throw new Error(`Minimum supported Deadline version for ${this.constructor.name} is ` + + `${minimumVersion.versionString}. ` + + `Received: ${props.renderQueue.version.versionString}.`); + } + + if (props.spotFleets && props.spotFleets.length !== 0) { + // Always add Resource Tracker admin policy, even if props.configuration?.enableResourceTracker is false. + // This improves usability, as customers won't need to add this policy manually, if they + // enable Resource Tracker later in the Spot Event Plugin configuration (e.g., using Deadline Monitor and not RFDK). + props.renderQueue.addSEPPolicies(true); + + const fleetRoles = props.spotFleets.map(sf => sf.fleetRole.roleArn); + const fleetInstanceRoles = props.spotFleets.map(sf => sf.fleetInstanceRole.roleArn); + new Policy(this, 'SpotEventPluginPolicy', { + statements: [ + new PolicyStatement({ + actions: [ + 'iam:PassRole', + ], + resources: [...fleetRoles, ...fleetInstanceRoles], + conditions: { + StringLike: { + 'iam:PassedToService': 'ec2.amazonaws.com', + }, + }, + }), + new PolicyStatement({ + actions: [ + 'ec2:CreateTags', + ], + resources: ['arn:aws:ec2:*:*:spot-fleet-request/*'], + }), + ], + roles: [ + props.renderQueue.grantPrincipal as Role, + ], + }); + } + } + else { + throw new Error('The provided render queue is not an instance of RenderQueue class. Some functionality is not supported.'); + } + + const region = Construct.isConstruct(props.renderQueue) ? Stack.of(props.renderQueue).region : Stack.of(this).region; + + const configurator = new LambdaFunction(this, 'Configurator', { + vpc: props.vpc, + vpcSubnets: props.vpcSubnets ?? { subnetType: SubnetType.PRIVATE }, + description: `Used by a ConfigureSpotEventPlugin ${this.node.addr} to perform configuration of Deadline Spot Event Plugin`, + code: Code.fromAsset(path.join(__dirname, '..', '..', 'lambdas', 'nodejs'), { + }), + environment: { + DEBUG: 'false', + }, + runtime: Runtime.NODEJS_12_X, + handler: 'configure-spot-event-plugin.configureSEP', + timeout: Duration.minutes(2), + logRetention: RetentionDays.ONE_WEEK, + }); + + configurator.connections.allowToDefaultPort(props.renderQueue); + props.renderQueue.certChain?.grantRead(configurator.grantPrincipal); + + const pluginConfig: PluginSettings = { + AWSInstanceStatus: props.configuration?.awsInstanceStatus ?? SpotEventPluginDisplayInstanceStatus.DISABLED, + DeleteInterruptedSlaves: props.configuration?.deleteEC2SpotInterruptedWorkers ?? false, + DeleteTerminatedSlaves: props.configuration?.deleteSEPTerminatedWorkers ?? false, + IdleShutdown: props.configuration?.idleShutdown?.toMinutes({integral: true}) ?? 10, + Logging: props.configuration?.loggingLevel ?? SpotEventPluginLoggingLevel.STANDARD, + PreJobTaskMode: props.configuration?.preJobTaskMode ?? SpotEventPluginPreJobTaskMode.CONSERVATIVE, + Region: props.configuration?.region ?? region, + ResourceTracker: props.configuration?.enableResourceTracker ?? true, + StaggerInstances: props.configuration?.maximumInstancesStartedPerCycle ?? 50, + State: props.configuration?.state ?? SpotEventPluginState.GLOBAL_ENABLED, + StrictHardCap: props.configuration?.strictHardCap ?? false, + }; + const spotFleetRequestConfigs = this.mergeSpotFleetRequestConfigs(props.spotFleets); + + const properties: SEPConfiguratorResourceProps = { + connection: { + hostname: props.renderQueue.endpoint.hostname, + port: props.renderQueue.endpoint.portAsString(), + protocol: props.renderQueue.endpoint.applicationProtocol, + caCertificateArn: props.renderQueue.certChain?.secretArn, + }, + spotFleetRequestConfigurations: spotFleetRequestConfigs, + spotPluginConfigurations: pluginConfig, + }; + + const resource = new CustomResource(this, 'Default', { + serviceToken: configurator.functionArn, + resourceType: 'Custom::RFDK_ConfigureSpotEventPlugin', + properties, + }); + + // Prevents a race during a stack-update. + resource.node.addDependency(configurator.role!); + + // We need to add this dependency to avoid failures while deleting a Custom Resource: + // 'Custom Resource failed to stabilize in expected time. If you are using the Python cfn-response module, + // you may need to update your Lambda function code so that CloudFormation can attach the updated version.'. + // This happens, because Route Table Associations are deleted before the Custom Resource and we + // don't get a response from 'doDelete()'. + // Ideally, we would only want to add dependency on 'internetConnectivityEstablished' as shown below, + // but it seems that CDK misses dependencies on Route Table Associations in that case: + // const { internetConnectivityEstablished } = props.vpc.selectSubnets(props.vpcSubnets); + // resource.node.addDependency(internetConnectivityEstablished); + resource.node.addDependency(props.vpc); + + // /* istanbul ignore next */ + // Add a dependency on the render queue to ensure that + // it is running before we try to send requests to it. + resource.node.addDependency(props.renderQueue); + + this.node.defaultChild = resource; + } + + private tagSpecifications(fleet: SpotEventPluginFleet, resourceType: SpotFleetResourceType): IResolvable { + return Lazy.any({ + produce: () => { + if (fleet.tags.hasTags()) { + const tagSpecification: SpotFleetTagSpecification = { + ResourceType: resourceType, + Tags: fleet.tags.renderTags(), + }; + return [tagSpecification]; + } + return undefined; + }, + }); + } + + /** + * Construct Spot Fleet Configurations from the provided fleet. + * Each congiguration 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, + 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. + TargetCapacity: fleet.maxCapacity, + TerminateInstancesWithExpiration: true, + // In order to work with Deadline, Spot Fleets Requests must be set to maintain. + Type: SpotFleetRequestType.MAINTAIN, + ValidUntil: fleet.validUntil?.date.toISOString(), + // Need to convert from IResolvable to bypass TypeScript + TagSpecifications: (spotFleetRequestTagsToken as unknown) as SpotFleetTagSpecification[], + }; + + const spotFleetRequestConfigurations = fleet.deadlineGroups.map(group => { + const spotFleetRequestConfiguration: SpotFleetRequestConfiguration = { + [group]: spotFleetRequestProps, + }; + return spotFleetRequestConfiguration; + }); + + 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; + } + + const fullSpotFleetRequestConfiguration: SpotFleetRequestConfiguration = {}; + spotFleets.map(fleet => { + const spotFleetRequestConfigurations = this.generateSpotFleetRequestConfig(fleet); + spotFleetRequestConfigurations.map(configuration => { + for (const [key, value] of Object.entries(configuration)) { + if (key in fullSpotFleetRequestConfiguration) { + throw new Error(`Bad Group Name: ${key}. Group names in Spot Fleet Request Configurations should be unique.`); + } + fullSpotFleetRequestConfiguration[key] = value; + } + }); + }); + + return fullSpotFleetRequestConfiguration; + } +} diff --git a/packages/aws-rfdk/lib/deadline/lib/index.ts b/packages/aws-rfdk/lib/deadline/lib/index.ts index 8d45c2d40..4766ae4b3 100644 --- a/packages/aws-rfdk/lib/deadline/lib/index.ts +++ b/packages/aws-rfdk/lib/deadline/lib/index.ts @@ -3,17 +3,20 @@ * SPDX-License-Identifier: Apache-2.0 */ +export * from './configure-spot-event-plugin'; export * from './database-connection'; -export * from './usage-based-licensing'; export * from './host-ref'; -export * from './repository'; -export * from './worker-fleet'; export * from './render-queue'; export * from './render-queue-ref'; +export * from './repository'; +export * from './spot-event-plugin-fleet'; +export * from './spot-event-plugin-fleet-ref'; export * from './stage'; export * from './thinkbox-docker-images'; export * from './thinkbox-docker-recipes'; +export * from './usage-based-licensing'; export * from './version'; export * from './version-query'; export * from './version-ref'; export * from './worker-configuration'; +export * from './worker-fleet'; diff --git a/packages/aws-rfdk/lib/deadline/lib/render-queue.ts b/packages/aws-rfdk/lib/deadline/lib/render-queue.ts index 590e25d25..a2ac28997 100644 --- a/packages/aws-rfdk/lib/deadline/lib/render-queue.ts +++ b/packages/aws-rfdk/lib/deadline/lib/render-queue.ts @@ -75,10 +75,12 @@ import { import { tagConstruct, } from '../../core/lib/runtime-info'; - import { RenderQueueConnection, } from './rq-connection'; +import { + WaitForStableService, +} from './wait-for-stable-service'; /** * Interface for Deadline Render Queue. @@ -201,6 +203,26 @@ export class RenderQueue extends RenderQueueBase implements IGrantable { */ public readonly asg: AutoScalingGroup; + /** + * The version of Deadline that the RenderQueue uses + */ + public readonly version: IVersion; + + /** + * The secret containing the cert chain for external connections. + */ + public readonly certChain?: ISecret; + + /** + * Whether SEP policies have been added + */ + private haveAddedSEPPolicies: boolean = false; + + /** + * Whether Resource Tracker policies have been added + */ + private haveAddedResourceTrackerPolicies: boolean = false; + /** * The log group where the RCS container will log to */ @@ -221,11 +243,6 @@ export class RenderQueue extends RenderQueueBase implements IGrantable { */ private readonly rqConnection: RenderQueueConnection; - /** - * The secret containing the cert chain for external connections. - */ - private readonly certChain?: ISecret; - /** * The listener on the ALB that is redirecting traffic to the RCS. */ @@ -237,9 +254,9 @@ export class RenderQueue extends RenderQueueBase implements IGrantable { private readonly taskDefinition: Ec2TaskDefinition; /** - * The version of Deadline installed in the container images + * Depend on this to ensure that ECS Service is stable. */ - private readonly version?: IVersion; + private ecsServiceStabilized: WaitForStableService; constructor(scope: Construct, id: string, props: RenderQueueProps) { super(scope, id); @@ -278,6 +295,8 @@ export class RenderQueue extends RenderQueueBase implements IGrantable { externalProtocol = ApplicationProtocol.HTTP; } + this.version = props.version; + const internalProtocol = props.trafficEncryption?.internalProtocol ?? ApplicationProtocol.HTTPS; if (externalProtocol === ApplicationProtocol.HTTPS && !props.hostname) { @@ -435,7 +454,7 @@ export class RenderQueue extends RenderQueueBase implements IGrantable { }); this.endpoint = new ConnectableApplicationEndpoint({ - address: this.pattern.loadBalancer.loadBalancerDnsName, + address: loadBalancerFQDN ?? this.pattern.loadBalancer.loadBalancerDnsName, port: externalPortNumber, connections: this.connections, protocol: externalProtocol, @@ -452,6 +471,10 @@ export class RenderQueue extends RenderQueueBase implements IGrantable { }); } + this.ecsServiceStabilized = new WaitForStableService(this, 'WaitForStableService', { + service: this.pattern.service, + }); + this.node.defaultChild = taskDefinition; // Tag deployed resources with RFDK meta-data @@ -491,19 +514,25 @@ export class RenderQueue extends RenderQueueBase implements IGrantable { } /** - * Adds AWS Managed Policies to the Render Queue so it is able to control Deadlines Spot Event Plugin. + * Adds AWS Managed Policies to the Render Queue so it is able to control Deadline's Spot Event Plugin. * * See: https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html for additonal information. * - * @param includeResourceTracker Whether or not the Resource tracker admin policy should also be addd (Default: True) + * @param includeResourceTracker Whether or not the Resource tracker admin policy should also be added (Default: True) */ - public addSEPPolicies( includeResourceTracker: boolean = true): void { - const sepPolicy = ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineSpotEventPluginAdminPolicy'); - this.taskDefinition.taskRole.addManagedPolicy(sepPolicy); + public addSEPPolicies(includeResourceTracker: boolean = true): void { + if (!this.haveAddedSEPPolicies) { + const sepPolicy = ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineSpotEventPluginAdminPolicy'); + this.taskDefinition.taskRole.addManagedPolicy(sepPolicy); + this.haveAddedSEPPolicies = true; + } - if (includeResourceTracker) { - const rtPolicy = ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineResourceTrackerAdminPolicy'); - this.taskDefinition.taskRole.addManagedPolicy(rtPolicy); + if (!this.haveAddedResourceTrackerPolicies) { + if (includeResourceTracker) { + const rtPolicy = ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineResourceTrackerAdminPolicy'); + this.taskDefinition.taskRole.addManagedPolicy(rtPolicy); + this.haveAddedResourceTrackerPolicies = true; + } } } @@ -521,6 +550,8 @@ export class RenderQueue extends RenderQueueBase implements IGrantable { // ex: cycles that involve the security group of the RenderQueue & child. child.node.addDependency(this.listener); child.node.addDependency(this.taskDefinition); + child.node.addDependency(this.pattern.service); + child.node.addDependency(this.ecsServiceStabilized); } /** diff --git a/packages/aws-rfdk/lib/deadline/lib/spot-event-plugin-fleet-ref.ts b/packages/aws-rfdk/lib/deadline/lib/spot-event-plugin-fleet-ref.ts new file mode 100644 index 000000000..dd51bca22 --- /dev/null +++ b/packages/aws-rfdk/lib/deadline/lib/spot-event-plugin-fleet-ref.ts @@ -0,0 +1,52 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The allocation strategy for the Spot Instances in your Spot Fleet + * determines how it fulfills your Spot Fleet request from the possible + * Spot Instance pools represented by its launch specifications. + * See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-fleet-configuration-strategies.html#ec2-fleet-allocation-strategy + */ +export enum SpotFleetAllocationStrategy { + /** + * Spot Fleet launches instances from the Spot Instance pools with the lowest price. + */ + LOWEST_PRICE = 'lowestPrice', + /** + * Spot Fleet launches instances from all the Spot Instance pools that you specify. + */ + DIVERSIFIED = 'diversified', + /** + * Spot Fleet launches instances from Spot Instance pools with optimal capacity for the number of instances that are launching. + */ + CAPACITY_OPTIMIZED = 'capacityOptimized', +} + +/** + * Resource types that presently support tag on create. + */ +export enum SpotFleetResourceType { + /** + * EC2 Instances. + */ + INSTANCE = 'instance', + + /** + * Spot fleet requests. + */ + SPOT_FLEET_REQUEST = 'spot-fleet-request', +} + +/** + * The type of request. Indicates whether the Spot Fleet only requests the target capacity or also attempts to maintain it. + * Only 'maintain' is currently supported. + */ +export enum SpotFleetRequestType { + /** + * The Spot Fleet maintains the target capacity. + * The Spot Fleet places the required requests to meet capacity and automatically replenishes any interrupted instances. + */ + MAINTAIN = 'maintain', +} 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 new file mode 100644 index 000000000..f8eea75d6 --- /dev/null +++ b/packages/aws-rfdk/lib/deadline/lib/spot-event-plugin-fleet.ts @@ -0,0 +1,575 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BlockDevice, +} from '@aws-cdk/aws-autoscaling'; +import { + Connections, + EbsDeviceVolumeType, + IConnectable, + IMachineImage, + InstanceType, + ISecurityGroup, + IVpc, + OperatingSystemType, + Port, + SecurityGroup, + SelectedSubnets, + SubnetSelection, + SubnetType, + UserData, +} from '@aws-cdk/aws-ec2'; +import { + CfnInstanceProfile, + IGrantable, + IPrincipal, + IRole, + ManagedPolicy, + Role, + ServicePrincipal, +} from '@aws-cdk/aws-iam'; +import { + Annotations, + Construct, + Expiration, + Stack, + TagManager, + TagType, +} from '@aws-cdk/core'; +import { + IScriptHost, + LogGroupFactoryProps, +} from '../../core'; +import { + tagConstruct, +} from '../../core/lib/runtime-info'; +import { + IRenderQueue, +} from './render-queue'; +import { + SpotFleetAllocationStrategy, +} from './spot-event-plugin-fleet-ref'; +import { + IInstanceUserDataProvider, + WorkerInstanceConfiguration, +} from './worker-configuration'; + +/** + * Properties for the Spot Event Plugin Worker Fleet. + */ +export interface SpotEventPluginFleetProps { + /** + * VPC to launch the Worker fleet in. + */ + readonly vpc: IVpc; + + /** + * The RenderQueue that Worker fleet should connect to. + */ + readonly renderQueue: IRenderQueue; + + /** + * The AMI of the Deadline Worker to launch. + */ + readonly workerMachineImage: IMachineImage; + + /** + * The the maximum capacity that the Spot Fleet can grow to. + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html#spot-fleet-requests + */ + readonly maxCapacity: number; + + /** + * Types of instances to launch. + */ + readonly instanceTypes: InstanceType[]; + + /** + * Deadline groups these workers need to be assigned to. + * + * Note that you will have to create the groups manually using Deadline before submitting jobs. + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/pools-and-groups.html + * + * Also, note that the Spot Fleet configuration does not allow using wildcards as part of the Group name + * as described here https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html#wildcards + */ + readonly deadlineGroups: string[]; + + /** + * Deadline pools these workers need to be assigned to. + * + * Note that you will have to create the pools manually using Deadline before submitting jobs. + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/pools-and-groups.html + * + * @default - Workers are not assigned to any pool. + */ + readonly deadlinePools?: string[]; + + /** + * Deadline region these workers needs to be assigned to. + * Note that this is not an AWS region but a Deadline region used for path mapping. + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/cross-platform.html#regions + * + * @default - Worker is not assigned to any Deadline region. + */ + readonly deadlineRegion?: string; + + /** + * An IAM role for the spot fleet. + * + * The role must be assumable by the service principal `spotfleet.amazonaws.com` + * and have AmazonEC2SpotFleetTaggingRole policy attached + * + * ```ts + * const role = new iam.Role(this, 'FleetRole', { + * assumedBy: new iam.ServicePrincipal('spotfleet.amazonaws.com'), + * managedPolicies: [ + * ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonEC2SpotFleetTaggingRole'), + * ], + * }); + * ``` + * + * @default - A role will automatically be created. + */ + readonly fleetRole?: IRole; + + /** + * An IAM role to associate with the instance profile assigned to its resources. + * Create this role on the same stack with the SpotEventPluginFleet to avoid circular dependencies. + * + * The role must be assumable by the service principal `ec2.amazonaws.com` and + * have AWSThinkboxDeadlineSpotEventPluginWorkerPolicy policy attached: + * + * ```ts + * const role = new iam.Role(this, 'MyRole', { + * assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'), + * managedPolicies: [ + * ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineSpotEventPluginWorkerPolicy'), + * ], + * }); + * ``` + * + * @default - A role will automatically be created. + */ + readonly fleetInstanceRole?: IRole; + + /** + * Name of SSH keypair to grant access to instances. + * + * @default - No SSH access will be possible. + */ + readonly keyName?: string; + + /** + * Security Groups to assign to this fleet. + * + * @default - A new security group will be created automatically. + */ + readonly securityGroups?: ISecurityGroup[]; + + /** + * User data that instances use when starting up. + * + * @default - User data will be created automatically. + */ + readonly userData?: UserData; + + /** + * The Block devices that will be attached to your workers. + * + * @default - The default devices of the provided ami will be used. + */ + readonly blockDevices?: BlockDevice[]; + + /** + * Indicates how to allocate the target Spot Instance capacity + * across the Spot Instance pools specified by the Spot Fleet request. + * + * @default - SpotFleetAllocationStrategy.LOWEST_PRICE. + */ + readonly allocationStrategy?: SpotFleetAllocationStrategy; + + /** + * Where to place the instance within the VPC. + * + * @default - Private subnets. + */ + readonly vpcSubnets?: SubnetSelection; + + /** + * The end date and time of the request. + * After the end date and time, no new Spot Instance requests are placed or able to fulfill the request. + * + * @default - the Spot Fleet request remains until you cancel it. + */ + readonly validUntil?: Expiration; + + /** + * Properties for setting up the Deadline Worker's LogGroup + * @default - LogGroup will be created with all properties' default values and a prefix of "/renderfarm/". + */ + readonly logGroupProps?: LogGroupFactoryProps; + + /** + * An optional provider of user data commands to be injected at various points during the Worker configuration lifecycle. + * You can provide a subclass of InstanceUserDataProvider with the methods overridden as desired. + * + * @default: Not used. + */ + readonly userDataProvider?: IInstanceUserDataProvider; +} + +/** + * Interface for Spot Event Plugin Worker Fleet. + */ +export interface ISpotEventPluginFleet extends IConnectable, IScriptHost, IGrantable { + /** + * Allow access to the Worker's remote command listener port (configured as a part of the + * WorkerConfiguration) for an IConnectable that is either in this stack, or in a stack that + * depends on this stack. If this stack depends on the other stack, use allowRemoteControlTo(). + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/remote-control.html + * + * Common uses are: + * + * Adding a SecurityGroup: + * `workerFleet.allowRemoteControlFrom(securityGroup)` + * + * Adding a CIDR: + * `workerFleet.allowRemoteControlFrom(Peer.ipv4('10.0.0.0/24'))` + */ + allowRemoteControlFrom(other: IConnectable): void; + + /** + * Allow access to the Worker's remote command listener port (configured as a part of the + * WorkerConfiguration) for an IConnectable that is either in this stack, or in a stack that this + * stack depends on. If the other stack depends on this stack, use allowRemoteControlFrom(). + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/remote-control.html + * + * Common uses are: + * + * Adding a SecurityGroup: + * `workerFleet.allowRemoteControlTo(securityGroup)` + * + * Adding a CIDR: + * `workerFleet.allowRemoteControlTo(Peer.ipv4('10.0.0.0/24'))` + */ + allowRemoteControlTo(other: IConnectable): void; +} + +/** + * This construct reperesents a fleet from the Spot Fleet Request created by the Spot Event Plugin. + * This fleet is intended to be used as input for the {@link @aws-rfdk/deadline#ConfigureSpotEventPlugin} construct. + * + * The construct itself doesn't create the Spot Fleet Request, but deploys all the resources + * required for the Spot Fleet Request and generates the Spot Fleet Configuration setting: + * a one to one mapping between a Deadline Group and Spot Fleet Request Configurations. + * + * Resources Deployed + * ------------------------ + * - An Instance Role, corresponding IAM Policy and an Instance Profile. + * - A Fleet Role and corresponding IAM Policy. + * - 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. + * + * Security Considerations + * ------------------------ + * - The instances deployed by this construct download and run scripts from your CDK bootstrap bucket when that instance + * is launched. You must limit write access to your CDK bootstrap bucket to prevent an attacker from modifying the actions + * performed by these scripts. We strongly recommend that you either enable Amazon S3 server access logging on your CDK + * bootstrap bucket, or enable AWS CloudTrail on your account to assist in post-incident analysis of compromised production + * environments. + * - The data that is stored on your Worker's local EBS volume can include temporary working files from the applications + * that are rendering your jobs and tasks. That data can be sensitive or privileged, so we recommend that you encrypt + * the data volumes of these instances using either the provided option or by using an encrypted AMI as your source. + * - The software on the AMI that is being used by this construct may pose a security risk. We recommend that you adopt a + * patching strategy to keep this software current with the latest security patches. Please see + * https://docs.aws.amazon.com/rfdk/latest/guide/patching-software.html for more information. + */ +export class SpotEventPluginFleet extends Construct implements ISpotEventPluginFleet { + /** + * Default prefix for a LogGroup if one isn't provided in the props. + */ + private static readonly DEFAULT_LOG_GROUP_PREFIX: string = '/renderfarm/'; + + /** + * This is the current maximum for number of workers that can be started on a single host. Currently the + * only thing using this limit is the configuration of the listener ports. More than 8 workers can be started, + * but only the first 8 will have their ports opened in the workers' security group. + */ + private static readonly MAX_WORKERS_PER_HOST = 8; + + /** + * The security groups/rules used to allow network connections. + */ + public readonly connections: Connections; + + /** + * The principal to grant permissions to. Granting permissions to this principal will grant + * those permissions to the spot instance role. + */ + public readonly grantPrincipal: IPrincipal; + + /** + * The port workers listen on to share their logs. + */ + public readonly remoteControlPorts: Port; + + /** + * Security Groups assigned to this fleet. + */ + public readonly securityGroups: ISecurityGroup[]; + + /** + * The user data that instances use when starting up. + */ + public readonly userData: UserData; + + /** + * The operating system of the script host. + */ + public readonly osType: OperatingSystemType; + + /** + * An IAM role associated with the instance profile assigned to its resources. + */ + public readonly fleetInstanceRole: IRole; + + /** + * The IAM instance profile that fleet instance role is associated to. + */ + public readonly instanceProfile: CfnInstanceProfile; + + /** + * An IAM role that grants the Spot Fleet the permission to request, launch, terminate, and tag instances on your behalf. + */ + public readonly fleetRole: IRole; + + /** + * An id of the Worker AMI. + */ + public readonly imageId: string; + + /** + * The tags to apply during creation of instances and of the Spot Fleet Request. + */ + public readonly tags: TagManager; + + /** + * Subnets where the instance will be placed within the VPC. + */ + public readonly subnets: SelectedSubnets; + + /** + * Types of instances to launch. + */ + public readonly instanceTypes: InstanceType[]; + + /** + * Indicates how to allocate the target Spot Instance capacity + * across the Spot Instance pools specified by the Spot Fleet request. + */ + public readonly allocationStrategy: SpotFleetAllocationStrategy; + + /** + * The the maximum capacity that the Spot Fleet can grow to. + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html#spot-fleet-requests + */ + public readonly maxCapacity: number; + + /** + * Deadline groups the workers need to be assigned to. + * + * @default - Workers are not assigned to any group + */ + public readonly deadlineGroups: string[]; + + /** + * Name of SSH keypair to grant access to instances. + * + * @default - No SSH access will be possible. + */ + public readonly keyName?: string; + + /** + * The end date and time of the request. + * After the end date and time, no new Spot Instance requests are placed or able to fulfill the request. + * + * @default - the Spot Fleet request remains until you cancel it. + */ + public readonly validUntil?: Expiration; + + /** + * The Block devices that will be attached to your workers. + * + * @default - The default devices of the provided ami will be used. + */ + public readonly blockDevices?: BlockDevice[]; + + constructor(scope: Construct, id: string, props: SpotEventPluginFleetProps) { + super(scope, id); + + this.validateProps(props); + + this.securityGroups = props.securityGroups ?? [ new SecurityGroup(this, 'SpotFleetSecurityGroup', { vpc: props.vpc }) ]; + this.connections = new Connections({ securityGroups: this.securityGroups }); + this.connections.allowToDefaultPort(props.renderQueue.endpoint); + + this.fleetInstanceRole = props.fleetInstanceRole ?? new Role(this, 'SpotFleetInstanceRole', { + assumedBy: new ServicePrincipal('ec2.amazonaws.com'), + managedPolicies: [ + ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineSpotEventPluginWorkerPolicy'), + ], + description: `Spot Fleet instance role for ${id} in region ${Stack.of(scope).region}`, + }); + + this.instanceProfile = new CfnInstanceProfile(this, 'InstanceProfile', { + roles: [this.fleetInstanceRole.roleName], + }); + + this.grantPrincipal = this.fleetInstanceRole; + + this.fleetRole = props.fleetRole ?? new Role(this, 'SpotFleetRole', { + assumedBy: new ServicePrincipal('spotfleet.amazonaws.com'), + managedPolicies: [ + ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonEC2SpotFleetTaggingRole'), + ], + description: `Spot Fleet role for ${id} in region ${Stack.of(scope).region}`, + }); + + this.blockDevices = props.blockDevices; + this.subnets = props.vpc.selectSubnets(props.vpcSubnets ?? { subnetType: SubnetType.PRIVATE }); + this.instanceTypes = props.instanceTypes; + this.allocationStrategy = props.allocationStrategy ?? SpotFleetAllocationStrategy.LOWEST_PRICE; + this.maxCapacity = props.maxCapacity; + this.validUntil = props.validUntil; + this.keyName = props.keyName; + this.deadlineGroups = props.deadlineGroups; + + const imageConfig = props.workerMachineImage.getImage(this); + this.osType = imageConfig.osType; + this.userData = props.userData ?? imageConfig.userData; + this.imageId = imageConfig.imageId; + + const workerConfig = new WorkerInstanceConfiguration(this, id, { + worker: this, + cloudwatchLogSettings: { + logGroupPrefix: SpotEventPluginFleet.DEFAULT_LOG_GROUP_PREFIX, + ...props.logGroupProps, + }, + renderQueue: props.renderQueue, + workerSettings: { + groups: props.deadlineGroups, + pools: props.deadlinePools, + region: props.deadlineRegion, + }, + userDataProvider: props.userDataProvider, + }); + + this.remoteControlPorts = Port.tcpRange( + workerConfig.listenerPort, + workerConfig.listenerPort + SpotEventPluginFleet.MAX_WORKERS_PER_HOST, + ); + + this.tags = new TagManager(TagType.KEY_VALUE, 'RFDK::SpotEventPluginFleet'); + + // Tag deployed resources with RFDK meta-data + tagConstruct(this); + } + + /** + * @inheritdoc + */ + public allowRemoteControlFrom(other: IConnectable): void { + this.connections.allowFrom(other.connections, this.remoteControlPorts, 'Worker remote command listening port'); + } + + /** + * @inheritdoc + */ + public allowRemoteControlTo(other: IConnectable): void { + other.connections.allowTo(this.connections, this.remoteControlPorts, 'Worker remote command listening port'); + } + + private validateProps(props: SpotEventPluginFleetProps): void { + this.validateFleetInstanceRole(props.fleetInstanceRole); + this.validateInstanceTypes(props.instanceTypes); + this.validateSubnets(props.vpc, props.vpcSubnets); + this.validateGroups('deadlineGroups', props.deadlineGroups); + this.validateRegion('deadlineRegion', props.deadlineRegion); + this.validateBlockDevices(props.blockDevices); + } + + private validateFleetInstanceRole(fleetInstanceRole?: IRole): void { + if (fleetInstanceRole) { + if (Stack.of(fleetInstanceRole) !== Stack.of(this)) { + throw new Error(`Fleet instance role should be created on the same stack as ${this.constructor.name} to avoid circular dependencies.`); + } + } + } + + private validateInstanceTypes(array: InstanceType[]): void { + if (array.length === 0) { + throw new Error('At least one instance type is required for a Spot Fleet Request Configuration'); + } + } + + private validateSubnets(vpc: IVpc, vpcSubnets?: SubnetSelection) { + const { subnets } = vpc.selectSubnets(vpcSubnets); + if (subnets.length === 0) { + Annotations.of(this).addError(`Did not find any subnets matching '${JSON.stringify(vpcSubnets)}', please use a different selection.`); + } + } + + private validateGroups(property: string, array: string[]): void { + const regex: RegExp = /^(?!none$)[a-zA-Z0-9-_]+$/i; + if (array.length === 0) { + throw new Error('At least one Deadline Group is required for a Spot Fleet Request Configuration'); + } + array.forEach(value => { + if (!regex.test(value)) { + throw new Error(`Invalid value: ${value} for property '${property}'. Valid characters are A-Z, a-z, 0-9, - and _. Also, group 'none' is reserved as the default group.`); + } + }); + } + + private validateRegion(property: string, region?: string): void { + const regex: RegExp = /^(?!none$|all$|unrecognized$)[a-zA-Z0-9-_]+$/i; + if (region && !regex.test(region)) { + throw new Error(`Invalid value: ${region} for property '${property}'. Valid characters are A-Z, a-z, 0-9, - and _. ‘All’, ‘none’ and ‘unrecognized’ are reserved names that cannot be used.`); + } + } + + private validateBlockDevices(blockDevices?: BlockDevice[]): void { + if (blockDevices === undefined) { + Annotations.of(this).addWarning(`The spot-fleet ${this.node.id} is being created without being provided any block devices so the Source AMI's devices will be used. ` + + 'Workers can have access to sensitive data so it is recommended to either explicitly encrypt the devices on the worker fleet or to ensure the source AMI\'s Drives are encrypted.'); + } else { + blockDevices.forEach(device => { + if (device.volume.ebsDevice === undefined) { + // Suppressed or Ephemeral Block Device + return; + } + + const { iops, volumeType } = device.volume.ebsDevice; + if (!iops) { + if (volumeType === EbsDeviceVolumeType.IO1) { + throw new Error('iops property is required with volumeType: EbsDeviceVolumeType.IO1'); + } + } else if (volumeType !== EbsDeviceVolumeType.IO1) { + Annotations.of(this).addWarning('iops will be ignored without volumeType: EbsDeviceVolumeType.IO1'); + } + + // encrypted is not exposed as part of ebsDeviceProps so we need to confirm it exists then access it via []. + // eslint-disable-next-line dot-notation + if ( ('encrypted' in device.volume.ebsDevice === false) || ('encrypted' in device.volume.ebsDevice && !device.volume.ebsDevice['encrypted'] ) ) { + Annotations.of(this).addWarning(`The BlockDevice "${device.deviceName}" on the spot-fleet ${this.node.id} is not encrypted. ` + + 'Workers can have access to sensitive data so it is recommended to encrypt the devices on the worker fleet.'); + } + }); + } + } +} diff --git a/packages/aws-rfdk/lib/deadline/lib/version-query.ts b/packages/aws-rfdk/lib/deadline/lib/version-query.ts index afa570c81..5ff619255 100644 --- a/packages/aws-rfdk/lib/deadline/lib/version-query.ts +++ b/packages/aws-rfdk/lib/deadline/lib/version-query.ts @@ -26,7 +26,9 @@ import { IVersionProviderResourceProperties, } from '../../lambdas/nodejs/version-provider'; +import { Version } from './version'; import { + IReleaseVersion, IVersion, PlatformInstallers, } from './version-ref'; @@ -67,10 +69,20 @@ abstract class VersionQueryBase extends Construct implements IVersion { */ readonly abstract linuxInstallers: PlatformInstallers; + /** + * @inheritdoc + */ + readonly abstract versionString: string; + /** * @inheritdoc */ abstract linuxFullVersionString(): string; + + /** + * @inheritdoc + */ + abstract isLessThan(other: Version): boolean; } /** @@ -91,6 +103,16 @@ abstract class VersionQueryBase extends Construct implements IVersion { * constructs which version of Deadline you want them to use, and be configured for. */ export class VersionQuery extends VersionQueryBase { + /** + * Regular expression for valid version query expressions + */ + private static readonly EXPRESSION_REGEX = /^(?:(\d+)(?:\.(\d+)(?:\.(\d+)(?:\.(\d+))?)?)?)?$/; + + /** + * The expression used as input to the `VersionQuery` + */ + readonly expression?: string; + /** * @inheritdoc */ @@ -111,9 +133,35 @@ export class VersionQuery extends VersionQueryBase { */ readonly linuxInstallers: PlatformInstallers; + /** + * Custom resource that provides the resolved Deadline version components and installer URIs + */ + private readonly deadlineResource: CustomResource; + + /** + * The pinned numeric version components extracted from the VersionQuery expression. + */ + private readonly pinnedVersionComponents: number[]; + constructor(scope: Construct, id: string, props?: VersionQueryProps) { super(scope, id); + this.expression = props?.version; + + const match = (props?.version ?? '').match(VersionQuery.EXPRESSION_REGEX); + if (match === null) { + throw new Error(`Invalid version expression "${props!.version}`); + } + this.pinnedVersionComponents = ( + match + // First capture group is the entire matched string, so slice it off + .slice(1) + // Capture groups that are not matched return as undefined, so we filter them out + .filter(component => component) + // Parse strings to numbers + .map(component => Number(component)) + ); + const lambdaCode = Code.fromAsset(join(__dirname, '..', '..', 'lambdas', 'nodejs')); const lambdaFunc = new SingletonFunction(this, 'VersionProviderFunction', { @@ -133,25 +181,44 @@ export class VersionQuery extends VersionQueryBase { forceRun: this.forceRun(props?.version), }; - const deadlineResource = new CustomResource(this, 'DeadlineResource', { + this.deadlineResource = new CustomResource(this, 'DeadlineResource', { serviceToken: lambdaFunc.functionArn, properties: deadlineProperties, resourceType: 'Custom::RFDK_DEADLINE_INSTALLERS', }); - this.majorVersion = Token.asNumber(deadlineResource.getAtt('MajorVersion')); - this.minorVersion = Token.asNumber(deadlineResource.getAtt('MinorVersion')); - this.releaseVersion = Token.asNumber(deadlineResource.getAtt('ReleaseVersion')); + this.majorVersion = this.versionComponent({ + expressionIndex: 0, + customResourceAttribute: 'MajorVersion', + }); + this.minorVersion = this.versionComponent({ + expressionIndex: 1, + customResourceAttribute: 'MinorVersion', + }); + this.releaseVersion = this.versionComponent({ + expressionIndex: 2, + customResourceAttribute: 'ReleaseVersion', + }); this.linuxInstallers = { - patchVersion: Token.asNumber(deadlineResource.getAtt('LinuxPatchVersion')), + patchVersion: Token.asNumber(this.deadlineResource.getAtt('LinuxPatchVersion')), repository: { - objectKey: Token.asString(deadlineResource.getAtt('LinuxRepositoryInstaller')), - s3Bucket: Bucket.fromBucketName(scope, 'InstallerBucket', Token.asString(deadlineResource.getAtt('S3Bucket'))), + objectKey: this.deadlineResource.getAttString('LinuxRepositoryInstaller'), + s3Bucket: Bucket.fromBucketName(scope, 'InstallerBucket', this.deadlineResource.getAttString('S3Bucket')), }, }; } + private versionComponent(args: { + expressionIndex: number, + customResourceAttribute: string + }) { + const { expressionIndex, customResourceAttribute } = args; + return this.pinnedVersionComponents.length > expressionIndex + ? this.pinnedVersionComponents[expressionIndex] + : Token.asNumber(this.deadlineResource.getAtt(customResourceAttribute)); + } + public linuxFullVersionString(): string { const major = Token.isUnresolved(this.majorVersion) ? Token.asString(this.majorVersion) : this.majorVersion.toString(); const minor = Token.isUnresolved(this.minorVersion) ? Token.asString(this.minorVersion) : this.minorVersion.toString(); @@ -163,6 +230,40 @@ export class VersionQuery extends VersionQueryBase { return `${major}.${minor}.${release}.${patch}`; } + public isLessThan(other: Version): boolean { + if (other.patchVersion !== 0) { + throw new Error('Cannot compare a VersionQuery to a fully-qualified version with a non-zero patch number'); + } + + // We compare each component from highest order to lowest + const componentGetters: Array<(version: IReleaseVersion) => number> = [ + v => v.majorVersion, + v => v.minorVersion, + v => v.releaseVersion, + ]; + + for (const componentGetter of componentGetters) { + const thisComponent = componentGetter(this); + const otherComponent = componentGetter(other); + + if (Token.isUnresolved(thisComponent)) { + // Unresolved components are unpinned. These will resolve to the latest and are not less than any provided + // version + return false; + } else { + const componentDiff = thisComponent - otherComponent; + if (componentDiff !== 0) { + // If the components are different, return whether this component is smaller than the other component + return componentDiff < 0; + } + } + } + + // If we've exited the loop naturally, it means all version components are pinned and equal. This means the version + // is not less than the other, they are the same + return false; + } + /** * Check if we have a full version in the supplied version string. If we don't, we want to make sure the Lambda * that fetches the full version number and the installers for it is always run. This allows for Deadline updates @@ -183,4 +284,8 @@ export class VersionQuery extends VersionQueryBase { } return true; } + + public get versionString(): string { + return this.expression ?? '(latest)'; + } } diff --git a/packages/aws-rfdk/lib/deadline/lib/version-ref.ts b/packages/aws-rfdk/lib/deadline/lib/version-ref.ts index 730df8ba1..c3ddc2a39 100644 --- a/packages/aws-rfdk/lib/deadline/lib/version-ref.ts +++ b/packages/aws-rfdk/lib/deadline/lib/version-ref.ts @@ -4,6 +4,7 @@ */ import { IBucket } from '@aws-cdk/aws-s3'; +import { Version } from './version'; /** * This interface represents a deadline installer object stored on @@ -62,6 +63,20 @@ export interface IReleaseVersion { * The release version number. */ readonly releaseVersion: number; + + /** + * A string representation of the version using the best available information at synthesis-time. + * + * This value is not guaranteed to be resolved, and is intended for output to CDK users. + */ + readonly versionString: string; + + /** + * Returns whether this version is less than another version + * + * @param other Other version to be compared + */ + isLessThan(other: Version): boolean; } /** diff --git a/packages/aws-rfdk/lib/deadline/lib/version.ts b/packages/aws-rfdk/lib/deadline/lib/version.ts index dfab0d18e..24e7e8389 100644 --- a/packages/aws-rfdk/lib/deadline/lib/version.ts +++ b/packages/aws-rfdk/lib/deadline/lib/version.ts @@ -145,6 +145,13 @@ export class Version implements IPatchVersion { return this.components.join('.'); } + /** + * @inheritdoc + */ + public get versionString(): string { + return this.toString(); + } + /** * This method compares 2 versions. * diff --git a/packages/aws-rfdk/lib/deadline/lib/wait-for-stable-service.ts b/packages/aws-rfdk/lib/deadline/lib/wait-for-stable-service.ts new file mode 100644 index 000000000..95ac95a5b --- /dev/null +++ b/packages/aws-rfdk/lib/deadline/lib/wait-for-stable-service.ts @@ -0,0 +1,105 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomBytes } from 'crypto'; +import * as path from 'path'; + +import { Ec2Service } from '@aws-cdk/aws-ecs'; +import { + Effect, + ManagedPolicy, + PolicyDocument, + PolicyStatement, + Role, + ServicePrincipal, +} from '@aws-cdk/aws-iam'; +import { + Code, + Function as LambdaFunction, + Runtime, +} from '@aws-cdk/aws-lambda'; +import { RetentionDays } from '@aws-cdk/aws-logs'; +import { + Construct, + CustomResource, + Duration, +} from '@aws-cdk/core'; +import { WaitForStableServiceResourceProps } from '../../lambdas/nodejs/wait-for-stable-service'; + +/** + * Input properties for WaitForStableService. + */ +export interface WaitForStableServiceProps { + /** + * A service to wait for. + */ + readonly service: Ec2Service; +} + +/** + * Depend on this construct to wait until the ECS Service becomes stable. + * See https://docs.aws.amazon.com/cli/latest/reference/ecs/wait/services-stable.html. + */ +export class WaitForStableService extends Construct { + constructor(scope: Construct, id: string, props: WaitForStableServiceProps) { + super(scope, id); + + const lambdaRole = new Role(this, 'ECSWaitLambdaRole', { + assumedBy: new ServicePrincipal('lambda.amazonaws.com'), + managedPolicies: [ + ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'), + ], + inlinePolicies: { + describeServices: new PolicyDocument({ + statements: [ + new PolicyStatement({ + actions: [ + 'ecs:DescribeServices', + ], + effect: Effect.ALLOW, + resources: [props.service.cluster.clusterArn, props.service.serviceArn], + }), + ], + }), + }, + }); + + const waitingFunction = new LambdaFunction(this, 'ECSWait', { + role: lambdaRole, + description: `Used by a WaitForStableService ${this.node.addr} to wait until ECS service becomes stable`, + code: Code.fromAsset(path.join(__dirname, '..', '..', 'lambdas', 'nodejs'), { + }), + environment: { + DEBUG: 'false', + }, + runtime: Runtime.NODEJS_12_X, + handler: 'wait-for-stable-service.wait', + timeout: Duration.minutes(15), + logRetention: RetentionDays.ONE_WEEK, + }); + + const properties: WaitForStableServiceResourceProps = { + cluster: props.service.cluster.clusterArn, + services: [props.service.serviceArn], + forceRun: this.forceRun(), + }; + + const resource = new CustomResource(this, 'Default', { + serviceToken: waitingFunction.functionArn, + resourceType: 'Custom::RFDK_WaitForStableService', + properties, + }); + + // Prevents a race during a stack-update. + resource.node.addDependency(lambdaRole); + resource.node.addDependency(props.service); + + this.node.defaultChild = resource; + } + + private forceRun(): string { + return randomBytes(32).toString('base64').slice(0, 32); + } +} 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 new file mode 100644 index 000000000..b7a513edb --- /dev/null +++ b/packages/aws-rfdk/lib/deadline/test/configure-spot-event-plugin.test.ts @@ -0,0 +1,963 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + expect as cdkExpect, + countResources, + haveResourceLike, + objectLike, + arrayWith, + countResourcesLike, + ABSENT, +} from '@aws-cdk/assert'; +import { + BlockDeviceVolume, EbsDeviceVolumeType, +} from '@aws-cdk/aws-autoscaling'; +import { + GenericWindowsImage, + InstanceClass, + InstanceSize, + InstanceType, + Vpc, +} from '@aws-cdk/aws-ec2'; +import { + ContainerImage, +} from '@aws-cdk/aws-ecs'; +import { ManagedPolicy } from '@aws-cdk/aws-iam'; +import { PrivateHostedZone } from '@aws-cdk/aws-route53'; +import { + App, + Duration, + Expiration, + Fn, + Stack, +} from '@aws-cdk/core'; +import { X509CertificatePem } from '../../core'; +import { tagFields } from '../../core/lib/runtime-info'; +import { + ConfigureSpotEventPlugin, + IRenderQueue, + IVersion, + RenderQueue, + Repository, + SpotEventPluginDisplayInstanceStatus, + SpotEventPluginLoggingLevel, + SpotEventPluginPreJobTaskMode, + SpotEventPluginSettings, + SpotEventPluginState, + SpotFleetResourceType, + VersionQuery, +} from '../lib'; +import { + SpotEventPluginFleet, +} from '../lib/spot-event-plugin-fleet'; + +describe('ConfigureSpotEventPlugin', () => { + let stack: Stack; + let vpc: Vpc; + let region: string; + let renderQueue: IRenderQueue; + let version: IVersion; + let app: App; + let fleet: SpotEventPluginFleet; + let groupName: string; + const workerMachineImage = new GenericWindowsImage({ + 'us-east-1': 'ami-any', + }); + + beforeEach(() => { + region = 'us-east-1'; + app = new App(); + stack = new Stack(app, 'stack', { + env: { + region, + }, + }); + vpc = new Vpc(stack, 'Vpc'); + + version = new VersionQuery(stack, 'Version'); + + renderQueue = new RenderQueue(stack, 'RQ', { + vpc, + images: { remoteConnectionServer: ContainerImage.fromAsset(__dirname) }, + repository: new Repository(stack, 'Repository', { + vpc, + version, + }), + version, + }); + + groupName = 'group_name1'; + + fleet = new SpotEventPluginFleet(stack, 'SpotFleet', { + vpc, + renderQueue: renderQueue, + deadlineGroups: [ + groupName, + ], + instanceTypes: [ + InstanceType.of(InstanceClass.T2, InstanceSize.SMALL), + ], + workerMachineImage, + maxCapacity: 1, + }); + }); + + describe('creates a custom resource', () => { + test('with default spot event plugin properties', () => { + // WHEN + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueue, + spotFleets: [ + fleet, + ], + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('Custom::RFDK_ConfigureSpotEventPlugin', objectLike({ + spotPluginConfigurations: objectLike({ + AWSInstanceStatus: 'Disabled', + DeleteInterruptedSlaves: false, + DeleteTerminatedSlaves: false, + IdleShutdown: 10, + Logging: 'Standard', + PreJobTaskMode: 'Conservative', + Region: Stack.of(renderQueue).region, + ResourceTracker: true, + StaggerInstances: 50, + State: 'Global Enabled', + StrictHardCap: false, + }), + }))); + }); + + test('with custom spot event plugin properties', () => { + // GIVEN + const configuration: SpotEventPluginSettings = { + awsInstanceStatus: SpotEventPluginDisplayInstanceStatus.EXTRA_INFO_0, + deleteEC2SpotInterruptedWorkers: true, + deleteSEPTerminatedWorkers: true, + idleShutdown: Duration.minutes(20), + loggingLevel: SpotEventPluginLoggingLevel.VERBOSE, + preJobTaskMode: SpotEventPluginPreJobTaskMode.NORMAL, + region: 'us-west-2', + enableResourceTracker: false, + maximumInstancesStartedPerCycle: 10, + state: SpotEventPluginState.DISABLED, + strictHardCap: true, + }; + + // WHEN + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueue, + spotFleets: [ + fleet, + ], + configuration, + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('Custom::RFDK_ConfigureSpotEventPlugin', objectLike({ + spotPluginConfigurations: objectLike({ + AWSInstanceStatus: 'ExtraInfo0', + DeleteInterruptedSlaves: true, + DeleteTerminatedSlaves: true, + IdleShutdown: 20, + Logging: 'Verbose', + PreJobTaskMode: 'Normal', + Region: 'us-west-2', + ResourceTracker: false, + StaggerInstances: 10, + State: 'Disabled', + StrictHardCap: true, + }), + }))); + }); + + test('without spot fleets', () => { + // WHEN + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueue, + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('Custom::RFDK_ConfigureSpotEventPlugin', { + spotFleetRequestConfigurations: ABSENT, + })); + }); + + test('provides RQ connection parameters to custom resource', () => { + // WHEN + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueue, + spotFleets: [ + fleet, + ], + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('Custom::RFDK_ConfigureSpotEventPlugin', objectLike({ + connection: objectLike({ + hostname: stack.resolve(renderQueue.endpoint.hostname), + port: stack.resolve(renderQueue.endpoint.portAsString()), + protocol: stack.resolve(renderQueue.endpoint.applicationProtocol.toString()), + }), + }))); + }); + + test('with default spot fleet request configuration', () => { + // WHEN + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueue, + spotFleets: [ + fleet, + ], + }); + const rfdkTag = tagFields(fleet); + + // THEN + cdkExpect(stack).to(haveResourceLike('Custom::RFDK_ConfigureSpotEventPlugin', objectLike({ + spotFleetRequestConfigurations: objectLike({ + [groupName]: objectLike({ + AllocationStrategy: fleet.allocationStrategy.toString(), + IamFleetRole: stack.resolve(fleet.fleetRole.roleArn), + LaunchSpecifications: arrayWith( + objectLike({ + IamInstanceProfile: { + Arn: stack.resolve(fleet.instanceProfile.attrArn), + }, + 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', () => { + // WHEN + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueue, + spotFleets: [ + fleet, + ], + }); + + // THEN + cdkExpect(stack).to(countResourcesLike('AWS::IAM::Role', 1, { + ManagedPolicyArns: arrayWith( + stack.resolve(ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineSpotEventPluginAdminPolicy').managedPolicyArn), + stack.resolve(ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineResourceTrackerAdminPolicy').managedPolicyArn), + ), + })); + + cdkExpect(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'iam:PassRole', + Condition: { + StringLike: { + 'iam:PassedToService': 'ec2.amazonaws.com', + }, + }, + Effect: 'Allow', + Resource: [ + stack.resolve(fleet.fleetRole.roleArn), + stack.resolve(fleet.fleetInstanceRole.roleArn), + ], + }, + { + Action: 'ec2:CreateTags', + Effect: 'Allow', + Resource: 'arn:aws:ec2:*:*:spot-fleet-request/*', + }, + ], + }, + Roles: [{ + Ref: 'RQRCSTaskTaskRole00DC9B43', + }], + })); + }); + + test('adds resource tracker policy even if rt disabled', () => { + // WHEN + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueue, + spotFleets: [ + fleet, + ], + configuration: { + enableResourceTracker: false, + }, + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::IAM::Role', { + ManagedPolicyArns: arrayWith( + stack.resolve(ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineResourceTrackerAdminPolicy').managedPolicyArn), + ), + })); + }); + + test.each([ + undefined, + [], + ])('without spot fleet', (noFleets: any) => { + // WHEN + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueue, + spotFleets: noFleets, + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('Custom::RFDK_ConfigureSpotEventPlugin', objectLike({ + spotFleetRequestConfigurations: ABSENT, + }))); + + cdkExpect(stack).notTo(haveResourceLike('AWS::IAM::Role', { + ManagedPolicyArns: arrayWith( + stack.resolve(ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineSpotEventPluginAdminPolicy').managedPolicyArn), + stack.resolve(ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineResourceTrackerAdminPolicy').managedPolicyArn), + ), + })); + + cdkExpect(stack).notTo(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'iam:PassRole', + Condition: { + StringLike: { + 'iam:PassedToService': 'ec2.amazonaws.com', + }, + }, + Effect: 'Allow', + Resource: [ + stack.resolve(fleet.fleetRole.roleArn), + stack.resolve(fleet.fleetInstanceRole.roleArn), + ], + }, + { + Action: 'ec2:CreateTags', + Effect: 'Allow', + Resource: 'arn:aws:ec2:*:*:spot-fleet-request/*', + }, + ], + }, + Roles: [{ + Ref: 'RQRCSTaskTaskRole00DC9B43', + }], + })); + }); + + test('fleet with validUntil', () => { + // GIVEN + const validUntil = Expiration.atDate(new Date(2022, 11, 17)); + const fleetWithCustomProps = new SpotEventPluginFleet(stack, 'SpotEventPluginFleet', { + vpc, + renderQueue, + deadlineGroups: [ + groupName, + ], + instanceTypes: [ + InstanceType.of(InstanceClass.T3, InstanceSize.LARGE), + ], + workerMachineImage, + maxCapacity: 1, + validUntil, + }); + + // WHEN + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueue, + spotFleets: [ + fleetWithCustomProps, + ], + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('Custom::RFDK_ConfigureSpotEventPlugin', objectLike({ + spotFleetRequestConfigurations: objectLike({ + [groupName]: objectLike({ + ValidUntil: validUntil.date.toISOString(), + }), + }), + }))); + }); + + 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', () => { + // GIVEN + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueue, + spotFleets: [ + fleet, + ], + }); + + // WHEN + function createConfigureSpotEventPlugin() { + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin2', { + vpc, + renderQueue: renderQueue, + spotFleets: [ + fleet, + ], + }); + } + + // THEN + expect(createConfigureSpotEventPlugin).toThrowError(/Only one ConfigureSpotEventPlugin construct is allowed per render queue./); + }); + + test('can create multiple objects with different render queues', () => { + // GIVEN + const renderQueue2 = new RenderQueue(stack, 'RQ2', { + vpc, + images: { remoteConnectionServer: ContainerImage.fromAsset(__dirname) }, + repository: new Repository(stack, 'Repository2', { + vpc, + version, + }), + version, + }); + + // WHEN + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueue, + spotFleets: [ + fleet, + ], + }); + + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin2', { + vpc, + renderQueue: renderQueue2, + spotFleets: [ + fleet, + ], + }); + + // THEN + cdkExpect(stack).to(countResources('Custom::RFDK_ConfigureSpotEventPlugin', 2)); + }); + + test('throws with not supported render queue', () => { + // GIVEN + const invalidRenderQueue = { + }; + + // WHEN + function createConfigureSpotEventPlugin() { + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin2', { + vpc, + renderQueue: invalidRenderQueue as IRenderQueue, + spotFleets: [ + fleet, + ], + }); + } + + // THEN + expect(createConfigureSpotEventPlugin).toThrowError(/The provided render queue is not an instance of RenderQueue class. Some functionality is not supported./); + }); + + test('tagSpecifications returns undefined if fleet does not have tags', () => { + // GIVEN + const mockFleet = { + tags: { + hasTags: jest.fn().mockReturnValue(false), + }, + }; + const mockedFleet = (mockFleet as unknown) as SpotEventPluginFleet; + const config = new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueue, + spotFleets: [ + fleet, + ], + }); + + // WHEN + // eslint-disable-next-line dot-notation + const result = stack.resolve(config['tagSpecifications'](mockedFleet, SpotFleetResourceType.INSTANCE)); + + // THEN + expect(result).toBeUndefined(); + }); + + describe('with TLS', () => { + let renderQueueWithTls: IRenderQueue; + let caCert: X509CertificatePem; + + beforeEach(() => { + const host = 'renderqueue'; + const zoneName = 'deadline-test.internal'; + + caCert = new X509CertificatePem(stack, 'RootCA', { + subject: { + cn: 'SampleRootCA', + }, + }); + + renderQueueWithTls = new RenderQueue(stack, 'RQ with TLS', { + vpc, + images: { remoteConnectionServer: ContainerImage.fromAsset(__dirname) }, + repository: new Repository(stack, 'Repository2', { + vpc, + version, + }), + version, + hostname: { + zone: new PrivateHostedZone(stack, 'DnsZone', { + vpc, + zoneName: zoneName, + }), + hostname: host, + }, + trafficEncryption: { + externalTLS: { + rfdkCertificate: new X509CertificatePem(stack, 'RQCert', { + subject: { + cn: `${host}.${zoneName}`, + }, + signingCertificate: caCert, + }), + }, + }, + }); + }); + + test('Lambda role can get the ca secret', () => { + // WHEN + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueueWithTls, + spotFleets: [ + fleet, + ], + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: [ + 'secretsmanager:GetSecretValue', + 'secretsmanager:DescribeSecret', + ], + Effect: 'Allow', + Resource: stack.resolve((renderQueueWithTls as RenderQueue).certChain!.secretArn), + }, + ], + }, + Roles: [ + { + Ref: 'ConfigureSpotEventPluginConfiguratorServiceRole341B4735', + }, + ], + })); + }); + + test('creates a custom resource with connection', () => { + // WHEN + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueueWithTls, + spotFleets: [ + fleet, + ], + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('Custom::RFDK_ConfigureSpotEventPlugin', objectLike({ + connection: objectLike({ + hostname: stack.resolve(renderQueueWithTls.endpoint.hostname), + port: stack.resolve(renderQueueWithTls.endpoint.portAsString()), + protocol: stack.resolve(renderQueueWithTls.endpoint.applicationProtocol.toString()), + caCertificateArn: stack.resolve((renderQueueWithTls as RenderQueue).certChain!.secretArn), + }), + }))); + }); + }); + + test('throws with the same group name', () => { + // WHEN + function createConfigureSpotEventPlugin() { + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueue, + spotFleets: [ + fleet, + fleet, + ], + }); + } + + // THEN + expect(createConfigureSpotEventPlugin).toThrowError(`Bad Group Name: ${groupName}. Group names in Spot Fleet Request Configurations should be unique.`); + }); + + test('uses selected subnets', () => { + // WHEN + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + vpc, + vpcSubnets: { subnets: [ vpc.privateSubnets[0] ] }, + renderQueue: renderQueue, + spotFleets: [ + fleet, + ], + }); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::Lambda::Function', { + Handler: 'configure-spot-event-plugin.configureSEP', + VpcConfig: { + SubnetIds: [ + stack.resolve(vpc.privateSubnets[0].subnetId), + ], + }, + })); + }); + + describe('throws with wrong deadline version', () => { + test.each([ + ['10.1.9'], + ['10.1.10'], + ])('%s', (versionString: string) => { + // GIVEN + const newStack = new Stack(app, 'NewStack'); + version = new VersionQuery(newStack, 'OldVersion', { + version: versionString, + }); + + renderQueue = new RenderQueue(newStack, 'OldRenderQueue', { + vpc, + images: { remoteConnectionServer: ContainerImage.fromAsset(__dirname) }, + repository: new Repository(newStack, 'Repository', { + vpc, + version, + }), + version, + }); + + // WHEN + function createConfigureSpotEventPlugin() { + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueue, + spotFleets: [ + fleet, + ], + }); + } + + // THEN + expect(createConfigureSpotEventPlugin).toThrowError(`Minimum supported Deadline version for ConfigureSpotEventPlugin is 10.1.12.0. Received: ${versionString}.`); + }); + }); + + test('does not throw with min deadline version', () => { + // GIVEN + const versionString = '10.1.12'; + const newStack = new Stack(app, 'NewStack'); + version = new VersionQuery(newStack, 'OldVersion', { + version: versionString, + }); + + renderQueue = new RenderQueue(newStack, 'OldRenderQueue', { + vpc, + images: { remoteConnectionServer: ContainerImage.fromAsset(__dirname) }, + repository: new Repository(newStack, 'Repository', { + vpc, + version, + }), + version, + }); + + // WHEN + function createConfigureSpotEventPlugin() { + new ConfigureSpotEventPlugin(newStack, 'ConfigureSpotEventPlugin', { + vpc, + renderQueue: renderQueue, + spotFleets: [ + fleet, + ], + }); + } + + // THEN + expect(createConfigureSpotEventPlugin).not.toThrow(); + }); +}); diff --git a/packages/aws-rfdk/lib/deadline/test/render-queue.test.ts b/packages/aws-rfdk/lib/deadline/test/render-queue.test.ts index 947998c3d..aabee299c 100644 --- a/packages/aws-rfdk/lib/deadline/test/render-queue.test.ts +++ b/packages/aws-rfdk/lib/deadline/test/render-queue.test.ts @@ -204,6 +204,8 @@ describe('RenderQueue', () => { DependsOn: arrayWith( 'RenderQueueCommonLBPublicListener935F5635', 'RenderQueueCommonRCSTask2A4D5EA5', + 'RenderQueueCommonAlbEc2ServicePatternService42BEFF4C', + 'RenderQueueCommonWaitForStableServiceDB53E266', ), }, ResourcePart.CompleteDefinition)); }); @@ -1025,14 +1027,7 @@ describe('RenderQueue', () => { }, ], }, - '" --render-queue "http://', - { - 'Fn::GetAtt': [ - 'RenderQueueLB235D35F4', - 'DNSName', - ], - }, - ':8080" \n' + + `" --render-queue "http://renderqueue.${ZONE_NAME}:8080" \n` + 'rm -f "/tmp/', { 'Fn::Select': [ @@ -1203,14 +1198,7 @@ describe('RenderQueue', () => { }, ], }, - '" --render-queue "http://', - { - 'Fn::GetAtt': [ - 'RenderQueueLB235D35F4', - 'DNSName', - ], - }, - ':8080" 2>&1\n' + + `" --render-queue "http://renderqueue.${ZONE_NAME}:8080" 2>&1\n` + 'Remove-Item -Path "C:/temp/', { 'Fn::Select': [ @@ -1454,14 +1442,7 @@ describe('RenderQueue', () => { }, ], }, - '" --render-queue "https://', - { - 'Fn::GetAtt': [ - 'RenderQueueLB235D35F4', - 'DNSName', - ], - }, - `:4433" --tls-ca "${CA_ARN}"\n` + + `" --render-queue "https://renderqueue.${ZONE_NAME}:4433" --tls-ca "${CA_ARN}"\n` + 'rm -f "/tmp/', { 'Fn::Select': [ @@ -1625,14 +1606,7 @@ describe('RenderQueue', () => { }, ], }, - '" --render-queue "https://', - { - 'Fn::GetAtt': [ - 'RenderQueueLB235D35F4', - 'DNSName', - ], - }, - `:4433" --tls-ca \"${CA_ARN}\" 2>&1\n` + + `" --render-queue "https://renderqueue.${ZONE_NAME}:4433" --tls-ca \"${CA_ARN}\" 2>&1\n` + 'Remove-Item -Path "C:/temp/', { 'Fn::Select': [ @@ -2190,9 +2164,9 @@ describe('RenderQueue', () => { resourceTypeCounts: { 'AWS::ECS::Cluster': 1, 'AWS::EC2::SecurityGroup': 2, - 'AWS::IAM::Role': 7, + 'AWS::IAM::Role': 8, 'AWS::AutoScaling::AutoScalingGroup': 1, - 'AWS::Lambda::Function': 3, + 'AWS::Lambda::Function': 4, 'AWS::SNS::Topic': 1, 'AWS::ECS::TaskDefinition': 1, 'AWS::DynamoDB::Table': 2, @@ -2206,7 +2180,10 @@ describe('RenderQueue', () => { describe('SEP Policies', () => { test('with resource tracker', () => { + // WHEN renderQueueCommon.addSEPPolicies(); + + // THEN expectCDK(stack).to(countResourcesLike('AWS::IAM::Role', 1, { ManagedPolicyArns: arrayWith( { @@ -2238,7 +2215,10 @@ describe('RenderQueue', () => { }); test('no resource tracker', () => { + // WHEN renderQueueCommon.addSEPPolicies(false); + + // THEN expectCDK(stack).to(haveResourceLike('AWS::IAM::Role', { ManagedPolicyArns: arrayWith( { @@ -2272,7 +2252,15 @@ describe('RenderQueue', () => { ), })); }); + }); + test('creates WaitForStableService by default', () => { + // THEN + expectCDK(stack).to(haveResourceLike('Custom::RFDK_WaitForStableService', { + cluster: stack.resolve(renderQueueCommon.cluster.clusterArn), + // eslint-disable-next-line dot-notation + services: [stack.resolve(renderQueueCommon['pattern'].service.serviceArn)], + })); }); describe('Security Groups', () => { diff --git a/packages/aws-rfdk/lib/deadline/test/repository.test.ts b/packages/aws-rfdk/lib/deadline/test/repository.test.ts index 61ab4b280..533b7433b 100644 --- a/packages/aws-rfdk/lib/deadline/test/repository.test.ts +++ b/packages/aws-rfdk/lib/deadline/test/repository.test.ts @@ -53,6 +53,7 @@ import { IVersion, Repository, VersionQuery, + Version, } from '../lib'; import { REPO_DC_ASSET, @@ -74,19 +75,22 @@ beforeEach(() => { app = new App(); stack = new Stack(app, 'Stack'); vpc = new Vpc(stack, 'VPC'); - version = { - majorVersion: 10, - minorVersion: 1, - releaseVersion: 9, - linuxInstallers: { - patchVersion: 2, + + class MockVersion extends Version implements IVersion { + readonly linuxInstallers = { + patchVersion: 0, repository: { objectKey: 'testInstaller', s3Bucket: new Bucket(stack, 'LinuxInstallerBucket'), }, - }, - linuxFullVersionString: () => '10.1.9.2', - }; + } + + public linuxFullVersionString() { + return this.toString(); + } + } + + version = new MockVersion([10,1,9,2]); }); test('can create two repositories', () => { @@ -1147,4 +1151,4 @@ test('validates VersionQuery is not in a different stack', () => { // THEN expect(synth).toThrow('A VersionQuery can not be supplied from a different stack'); -}); \ No newline at end of file +}); 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 new file mode 100644 index 000000000..e8e96bfff --- /dev/null +++ b/packages/aws-rfdk/lib/deadline/test/spot-event-plugin-fleet.test.ts @@ -0,0 +1,1301 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable no-console */ + +/* eslint-disable dot-notation */ + +import { + arrayWith, + countResources, + countResourcesLike, + expect as expectCDK, + haveResource, + haveResourceLike, + objectLike, +} from '@aws-cdk/assert'; +import { + BlockDeviceVolume, + EbsDeviceVolumeType, +} from '@aws-cdk/aws-autoscaling'; +import { + GenericLinuxImage, + InstanceClass, + InstanceSize, + InstanceType, + IVpc, + Peer, + SecurityGroup, + SubnetSelection, + SubnetType, + Vpc, +} from '@aws-cdk/aws-ec2'; +import { + AssetImage, + ContainerImage, +} from '@aws-cdk/aws-ecs'; +import { + ManagedPolicy, + Role, + ServicePrincipal, +} from '@aws-cdk/aws-iam'; +import { ArtifactMetadataEntryType } from '@aws-cdk/cloud-assembly-schema'; +import { + App, + CfnElement, + Stack, + Tags, +} from '@aws-cdk/core'; +import { tagFields } from '../../core/lib/runtime-info'; +import { + escapeTokenRegex, +} from '../../core/test/token-regex-helpers'; +import { + IHost, + InstanceUserDataProvider, + IRenderQueue, + RenderQueue, + Repository, + VersionQuery, + SpotEventPluginFleet, + SpotFleetAllocationStrategy, +} from '../lib'; + +let app: App; +let stack: Stack; +let spotFleetStack: Stack; +let vpc: IVpc; +let renderQueue: IRenderQueue; +let rcsImage: AssetImage; + +const groupName = 'group_name'; +const deadlineGroups = [ + groupName, +]; +const workerMachineImage = new GenericLinuxImage({ + 'us-east-1': 'ami-any', +}); +const instanceTypes = [ + InstanceType.of(InstanceClass.T2, InstanceSize.SMALL), +]; +const maxCapacity = 1; + +describe('SpotEventPluginFleet', () => { + beforeEach(() => { + app = new App(); + stack = new Stack(app, 'infraStack', { + env: { + region: 'us-east-1', + }, + }); + vpc = new Vpc(stack, 'VPC'); + rcsImage = ContainerImage.fromAsset(__dirname); + const version = new VersionQuery(stack, 'VersionQuery'); + renderQueue = new RenderQueue(stack, 'RQ', { + vpc, + images: { remoteConnectionServer: rcsImage }, + repository: new Repository(stack, 'Repository', { + vpc, + version, + }), + version, + }); + spotFleetStack = new Stack(app, 'SpotFleetStack', { + env: { + region: 'us-east-1', + }, + }); + }); + + describe('created with default values', () => { + test('creates a security group', () => { + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + + // THEN + expect(fleet.securityGroups).toBeDefined(); + expect(fleet.securityGroups.length).toBe(1); + expectCDK(spotFleetStack).to(countResources('AWS::EC2::SecurityGroup', 1)); + }); + + test('allows connection to the render queue', () => { + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + + // THEN + expectCDK(spotFleetStack).to(haveResourceLike('AWS::EC2::SecurityGroupIngress', { + IpProtocol: 'tcp', + ToPort: parseInt(renderQueue.endpoint.portAsString(), 10), + SourceSecurityGroupId: spotFleetStack.resolve(fleet.connections.securityGroups[0].securityGroupId), + })); + }); + + test('creates a spot fleet instance role', () => { + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + + // THEN + expect(fleet.fleetInstanceRole).toBeDefined(); + expectCDK(spotFleetStack).to(haveResourceLike('AWS::IAM::Role', { + AssumeRolePolicyDocument: objectLike({ + Statement: [objectLike({ + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + Service: 'ec2.amazonaws.com', + }, + })], + }), + ManagedPolicyArns: arrayWith( + spotFleetStack.resolve(ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineSpotEventPluginWorkerPolicy').managedPolicyArn), + ), + })); + }); + + test('creates an instance profile', () => { + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + + // THEN + expect(fleet.instanceProfile).toBeDefined(); + expectCDK(spotFleetStack).to(haveResourceLike('AWS::IAM::InstanceProfile', { + Roles: arrayWith({ + Ref: spotFleetStack.getLogicalId(fleet.fleetInstanceRole.node.defaultChild as CfnElement), + }), + })); + }); + + test('creates a spot fleet role', () => { + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + + // THEN + expect(fleet.fleetRole).toBeDefined(); + expectCDK(spotFleetStack).to(haveResourceLike('AWS::IAM::Role', { + AssumeRolePolicyDocument: objectLike({ + Statement: [objectLike({ + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + Service: 'spotfleet.amazonaws.com', + }, + })], + }), + ManagedPolicyArns: arrayWith( + spotFleetStack.resolve(ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonEC2SpotFleetTaggingRole').managedPolicyArn), + ), + })); + }); + + test('adds group names to user data', () => { + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + const renderedUserData = fleet.userData.render(); + + // THEN + expect(fleet.userData).toBeDefined(); + expect(renderedUserData).toMatch(groupName); + }); + + test('adds RFDK tags', () => { + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + const rfdkTag = tagFields(fleet); + + // THEN + expect(fleet.tags).toBeDefined(); + expectCDK(spotFleetStack).to(haveResourceLike('AWS::EC2::SecurityGroup', { + Tags: arrayWith( + objectLike({ + Key: rfdkTag.name, + Value: rfdkTag.value, + }), + ), + })); + }); + + test('uses default LogGroup prefix %s', () => { + // GIVEN + const id = 'SpotFleet'; + + // WHEN + new SpotEventPluginFleet(stack, id, { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + + expectCDK(stack).to(haveResource('Custom::LogRetention', { + RetentionInDays: 3, + LogGroupName: '/renderfarm/' + id, + })); + }); + + test('sets default allocation strategy', () => { + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + + // THEN + expect(fleet.allocationStrategy).toEqual(SpotFleetAllocationStrategy.LOWEST_PRICE); + }); + + test('does not set valid until property', () => { + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + + // THEN + expect(fleet.validUntil).toBeUndefined(); + }); + + test('does not set valid block devices', () => { + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + + // THEN + expect(fleet.blockDevices).toBeUndefined(); + }); + + test('does not set ssh key', () => { + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + + // THEN + expect(fleet.keyName).toBeUndefined(); + }); + }); + + describe('created with custom values', () => { + test('uses provided required properties', () => { + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + const imageConfig = workerMachineImage.getImage(fleet); + + // THEN + expect(fleet.deadlineGroups).toBe(deadlineGroups); + expect(fleet.instanceTypes).toBe(instanceTypes); + expect(fleet.imageId).toBe(imageConfig.imageId); + expect(fleet.osType).toBe(imageConfig.osType); + expect(fleet.maxCapacity).toBe(maxCapacity); + }); + + test('uses provided security group', () => { + // GIVEN + const sg = SecurityGroup.fromSecurityGroupId(stack, 'SG', 'sg-123456789', { + allowAllOutbound: false, + }); + + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + securityGroups: [ + sg, + ], + }); + + // THEN + expectCDK(spotFleetStack).notTo(haveResource('AWS::EC2::SecurityGroup')); + expect(fleet.securityGroups.length).toBe(1); + expect(fleet.securityGroups).toContainEqual(sg); + }); + + test('uses multiple provided security groups', () => { + // GIVEN + const sg1 = SecurityGroup.fromSecurityGroupId(stack, 'SG1', 'sg-123456789', { + allowAllOutbound: false, + }); + const sg2 = SecurityGroup.fromSecurityGroupId(stack, 'SG2', 'sg-987654321', { + allowAllOutbound: false, + }); + + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + securityGroups: [ + sg1, + sg2, + ], + }); + + // THEN + expectCDK(spotFleetStack).notTo(haveResource('AWS::EC2::SecurityGroup')); + expect(fleet.securityGroups.length).toBe(2); + expect(fleet.securityGroups).toContainEqual(sg1); + expect(fleet.securityGroups).toContainEqual(sg2); + }); + + test('adds to provided user data', () => { + // GIVEN + const originalCommands = 'original commands'; + const originalUserData = workerMachineImage.getImage(spotFleetStack).userData; + originalUserData.addCommands(originalCommands); + const renderedOriginalUser = originalUserData.render(); + + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + userData: originalUserData, + }); + const renderedUserData = fleet.userData.render(); + + // THEN + expect(fleet.userData).toBe(originalUserData); + expect(renderedUserData).toMatch(new RegExp(escapeTokenRegex(originalCommands))); + expect(renderedUserData).not.toEqual(renderedOriginalUser); + }); + + test('uses provided spot fleet instance role from the same stack', () => { + // GIVEN + const spotFleetInstanceRole = new Role(spotFleetStack, 'SpotFleetInstanceRole', { + assumedBy: new ServicePrincipal('ec2.amazonaws.com'), + managedPolicies: [ + ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineSpotEventPluginWorkerPolicy'), + ], + }); + + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + fleetInstanceRole: spotFleetInstanceRole, + }); + + // THEN + expect(fleet.fleetInstanceRole).toBe(spotFleetInstanceRole); + expectCDK(spotFleetStack).to(countResourcesLike('AWS::IAM::Role', 1, { + AssumeRolePolicyDocument: objectLike({ + Statement: [objectLike({ + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + Service: 'ec2.amazonaws.com', + }, + })], + }), + ManagedPolicyArns: arrayWith( + spotFleetStack.resolve(ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineSpotEventPluginWorkerPolicy').managedPolicyArn), + ), + })); + }); + + test('throws if provided spot fleet instance role is not from the same stack', () => { + // GIVEN + const otherStack = new Stack(app, 'OtherStack', { + env: { region: 'us-east-1' }, + }); + const spotFleetInstanceRole = new Role(otherStack, 'SpotFleetInstanceRole', { + assumedBy: new ServicePrincipal('ec2.amazonaws.com'), + managedPolicies: [ + ManagedPolicy.fromAwsManagedPolicyName('AWSThinkboxDeadlineSpotEventPluginWorkerPolicy'), + ], + }); + + // WHEN + function createSpotEventPluginFleet() { + new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + fleetInstanceRole: spotFleetInstanceRole, + }); + } + + // THEN + expect(createSpotEventPluginFleet).toThrowError('Fleet instance role should be created on the same stack as SpotEventPluginFleet to avoid circular dependencies.'); + }); + + test('uses provided spot fleet role', () => { + // GIVEN + const fleetRole = new Role(stack, 'FleetRole', { + assumedBy: new ServicePrincipal('spotfleet.amazonaws.com'), + managedPolicies: [ + ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonEC2SpotFleetTaggingRole'), + ], + }); + + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + fleetRole: fleetRole, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + + // THEN + expect(fleet.fleetRole).toBe(fleetRole); + expectCDK(spotFleetStack).notTo(haveResourceLike('AWS::IAM::Role', { + AssumeRolePolicyDocument: objectLike({ + Statement: [objectLike({ + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + Service: 'spotfleet.amazonaws.com', + }, + })], + }), + ManagedPolicyArns: arrayWith( + stack.resolve(ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonEC2SpotFleetTaggingRole').managedPolicyArn), + ), + })); + }); + + test('tags resources deployed by CDK', () => { + // GIVEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + const tagName = 'name'; + const tagValue = 'tagValue'; + + // WHEN + Tags.of(fleet).add(tagName, tagValue); + + // THEN + expectCDK(spotFleetStack).to(haveResourceLike('AWS::EC2::SecurityGroup', { + Tags: arrayWith( + objectLike({ + Key: tagName, + Value: tagValue, + }), + ), + })); + }); + + test('uses provided subnets', () => { + // GIVEN + const privateSubnets: SubnetSelection = { + subnetType: SubnetType.PRIVATE, + }; + + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + vpcSubnets: privateSubnets, + }); + const expectedSubnetId = stack.resolve(vpc.privateSubnets[0].subnetId); + + // THEN + expect(stack.resolve(fleet.subnets.subnetIds)).toContainEqual(expectedSubnetId); + }); + + test('uses provided allocation strategy', () => { + // GIVEN + const allocationStartegy = SpotFleetAllocationStrategy.CAPACITY_OPTIMIZED; + + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + allocationStrategy: allocationStartegy, + }); + + // THEN + expect(fleet.allocationStrategy).toEqual(allocationStartegy); + }); + + test('adds deadline region to user data', () => { + // GIVEN + const deadlineRegion = 'someregion'; + + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + deadlineRegion: deadlineRegion, + }); + const renderedUserData = fleet.userData.render(); + + // THEN + expect(renderedUserData).toMatch(deadlineRegion); + }); + + test('adds deadline pools to user data', () => { + // GIVEN + const pool1 = 'pool1'; + const pool2 = 'pool2'; + + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + deadlinePools: [ + pool1, + pool2, + ], + }); + const renderedUserData = fleet.userData.render(); + + // THEN + expect(renderedUserData).toMatch(pool1); + expect(renderedUserData).toMatch(pool2); + }); + + test('uses provided ssh key name', () => { + // GIVEN + const keyName = 'test-key-name'; + + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + keyName: keyName, + }); + + // THEN + expect(fleet.keyName).toEqual(keyName); + }); + + test('UserData is added by UserDataProvider', () => { + // WHEN + class UserDataProvider extends InstanceUserDataProvider { + preCloudWatchAgent(host: IHost): void { + host.userData.addCommands('echo preCloudWatchAgent'); + } + preRenderQueueConfiguration(host: IHost): void { + host.userData.addCommands('echo preRenderQueueConfiguration'); + } + preWorkerConfiguration(host: IHost): void { + host.userData.addCommands('echo preWorkerConfiguration'); + } + postWorkerLaunch(host: IHost): void { + host.userData.addCommands('echo postWorkerLaunch'); + } + } + + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + userDataProvider: new UserDataProvider(spotFleetStack, 'UserDataProvider'), + }); + + const userData = fleet.userData.render(); + + // THEN + expect(userData).toContain('echo preCloudWatchAgent'); + expect(userData).toContain('echo preRenderQueueConfiguration'); + expect(userData).toContain('echo preWorkerConfiguration'); + expect(userData).toContain('echo postWorkerLaunch'); + }); + + test.each([ + 'test-prefix/', + '', + ])('uses custom LogGroup prefix %s', (testPrefix: string) => { + // GIVEN + const id = 'SpotFleet'; + const logGroupProps = { + logGroupPrefix: testPrefix, + }; + + // WHEN + new SpotEventPluginFleet(stack, id, { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + logGroupProps: logGroupProps, + }); + + // THEN + expectCDK(stack).to(haveResource('Custom::LogRetention', { + RetentionInDays: 3, + LogGroupName: testPrefix + id, + })); + }); + }); + + describe('allowing remote control', () => { + test('from CIDR', () => { + // GIVEN + const fromPort = 56032; + const maxWorkersPerHost = 8; + + // WHEN + const fleet = new SpotEventPluginFleet(stack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + + fleet.allowRemoteControlFrom(Peer.ipv4('127.0.0.1/24')); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::SecurityGroup', { + SecurityGroupEgress: [{ CidrIp: '0.0.0.0/0' }], + SecurityGroupIngress: [ + { + CidrIp: '127.0.0.1/24', + Description: 'Worker remote command listening port', + FromPort: fromPort, + IpProtocol: 'tcp', + ToPort: fromPort + maxWorkersPerHost, + }, + ], + })); + }); + + test('to CIDR', () => { + // GIVEN + const fromPort = 56032; + const maxWorkersPerHost = 8; + + // WHEN + const fleet = new SpotEventPluginFleet(stack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + + fleet.allowRemoteControlTo(Peer.ipv4('127.0.0.1/24')); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::SecurityGroup', { + SecurityGroupEgress: [{ CidrIp: '0.0.0.0/0' }], + SecurityGroupIngress: [ + { + CidrIp: '127.0.0.1/24', + Description: 'Worker remote command listening port', + FromPort: fromPort, + IpProtocol: 'tcp', + ToPort: fromPort + maxWorkersPerHost, + }, + ], + })); + }); + + test('from SecurityGroup', () => { + // GIVEN + const fromPort = 56032; + const maxWorkersPerHost = 8; + + // WHEN + const fleet = new SpotEventPluginFleet(stack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + const securityGroup = SecurityGroup.fromSecurityGroupId(stack, 'SG', 'sg-123456789'); + + fleet.allowRemoteControlFrom(securityGroup); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::SecurityGroupIngress', { + FromPort: fromPort, + IpProtocol: 'tcp', + SourceSecurityGroupId: 'sg-123456789', + ToPort: fromPort + maxWorkersPerHost, + })); + }); + + test('to SecurityGroup', () => { + // GIVEN + const fromPort = 56032; + const maxWorkersPerHost = 8; + + // WHEN + const fleet = new SpotEventPluginFleet(stack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + const securityGroup = SecurityGroup.fromSecurityGroupId(stack, 'SG', 'sg-123456789'); + + fleet.allowRemoteControlTo(securityGroup); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::SecurityGroupIngress', { + FromPort: fromPort, + IpProtocol: 'tcp', + SourceSecurityGroupId: 'sg-123456789', + ToPort: fromPort + maxWorkersPerHost, + })); + }); + + test('from other stack', () => { + // GIVEN + const fromPort = 56032; + const maxWorkersPerHost = 8; + const otherStack = new Stack(app, 'otherStack', { + env: { region: 'us-east-1' }, + }); + + // WHEN + const fleet = new SpotEventPluginFleet(stack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + const securityGroup = SecurityGroup.fromSecurityGroupId(otherStack, 'SG', 'sg-123456789'); + + fleet.allowRemoteControlFrom(securityGroup); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::EC2::SecurityGroupIngress', { + FromPort: fromPort, + IpProtocol: 'tcp', + SourceSecurityGroupId: 'sg-123456789', + ToPort: fromPort + maxWorkersPerHost, + })); + }); + + test('to other stack', () => { + // GIVEN + const fromPort = 56032; + const maxWorkersPerHost = 8; + const otherStack = new Stack(app, 'otherStack', { + env: { region: 'us-east-1' }, + }); + + // WHEN + const fleet = new SpotEventPluginFleet(stack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + const securityGroup = SecurityGroup.fromSecurityGroupId(otherStack, 'SG', 'sg-123456789'); + + fleet.allowRemoteControlTo(securityGroup); + + // THEN + expectCDK(otherStack).to(haveResourceLike('AWS::EC2::SecurityGroupIngress', { + FromPort: fromPort, + IpProtocol: 'tcp', + SourceSecurityGroupId: 'sg-123456789', + ToPort: fromPort + maxWorkersPerHost, + })); + }); + }); + + describe('validation with', () => { + describe('instance types', () => { + test('throws with empty', () => { + // GIVEN + const emptyInstanceTypes: InstanceType[] = []; + + // WHEN + function createSpotEventPluginFleet() { + new SpotEventPluginFleet(stack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes: emptyInstanceTypes, + workerMachineImage, + maxCapacity, + }); + } + + // THEN + expect(createSpotEventPluginFleet).toThrowError(/At least one instance type is required for a Spot Fleet Request Configuration/); + }); + + test('passes with at least one', () => { + // GIVEN + const oneInstanceType = [ InstanceType.of(InstanceClass.T2, InstanceSize.SMALL) ]; + + // WHEN + function createSpotEventPluginFleet() { + new SpotEventPluginFleet(stack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes: oneInstanceType, + workerMachineImage, + maxCapacity, + }); + } + + // THEN + expect(createSpotEventPluginFleet).not.toThrowError(); + }); + }); + + describe('subnets', () => { + test('error if no subnets provided', () => { + // GIVEN + const invalidSubnets = { + subnetType: SubnetType.PRIVATE, + availabilityZones: ['dummy zone'], + }; + + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + vpcSubnets: invalidSubnets, + }); + + // THEN + expect(fleet.node.metadata[0].type).toMatch(ArtifactMetadataEntryType.ERROR); + expect(fleet.node.metadata[0].data).toMatch(/Did not find any subnets matching/); + }); + }); + + describe('groups', () => { + test('throws with empty', () => { + // GIVEN + const emptyDeadlineGroups: string[] = []; + + // WHEN + function createSpotEventPluginFleet() { + new SpotEventPluginFleet(stack, 'SpotFleet', { + vpc, + renderQueue, + instanceTypes, + workerMachineImage, + maxCapacity, + deadlineGroups: emptyDeadlineGroups, + }); + } + + // THEN + expect(createSpotEventPluginFleet).toThrowError(/At least one Deadline Group is required for a Spot Fleet Request Configuration/); + }); + + test.each([ + 'none', + 'with space', + 'group_*', // with wildcard + ])('throws with %s', (invalidGroupName: string) => { + // WHEN + function createSpotEventPluginFleet() { + new SpotEventPluginFleet(stack, 'SpotFleet', { + vpc, + renderQueue, + instanceTypes, + workerMachineImage, + maxCapacity, + deadlineGroups: [invalidGroupName], + }); + } + + // THEN + expect(createSpotEventPluginFleet).toThrowError(/Invalid value: .+ for property 'deadlineGroups'/); + }); + + test('passes with valid group name', () => { + // WHEN + function createSpotEventPluginFleet() { + new SpotEventPluginFleet(stack, 'SpotFleet', { + vpc, + renderQueue, + instanceTypes, + workerMachineImage, + maxCapacity, + deadlineGroups: [groupName], + }); + } + + // THEN + expect(createSpotEventPluginFleet).not.toThrowError(); + }); + }); + + describe('region', () => { + test.each([ + 'none', // region as 'none' + 'all', // region as 'all' + 'unrecognized', // region as 'unrecognized' + 'none@123', // region with invalid characters + 'None', // region with case-insensitive name + ])('throws with %s', (deadlineRegion: string) => { + // WHEN + function createSpotEventPluginFleet() { + new SpotEventPluginFleet(stack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + deadlineRegion: deadlineRegion, + }); + } + + // THEN + expect(createSpotEventPluginFleet).toThrowError(/Invalid value: .+ for property 'deadlineRegion'/); + }); + + test('passes with reserved name as substring', () => { + // GIVEN + const deadlineRegion = 'none123'; + + // WHEN + function createSpotEventPluginFleet() { + new SpotEventPluginFleet(stack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + deadlineRegion: deadlineRegion, + }); + } + + // THEN + expect(createSpotEventPluginFleet).not.toThrowError(); + }); + }); + + describe('Block Device Tests', () => { + test('Warning if no BlockDevices provided', () => { + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + + // THEN + expect(fleet.node.metadata[0].type).toMatch(ArtifactMetadataEntryType.WARN); + expect(fleet.node.metadata[0].data).toMatch('being created without being provided any block devices so the Source AMI\'s devices will be used. Workers can have access to sensitive data so it is recommended to either explicitly encrypt the devices on the worker fleet or to ensure the source AMI\'s Drives are encrypted.'); + }); + + test('No Warnings if Encrypted BlockDevices Provided', () => { + const VOLUME_SIZE = 50; + + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + blockDevices: [ { + deviceName: '/dev/xvda', + volume: BlockDeviceVolume.ebs( VOLUME_SIZE, {encrypted: true}), + }], + }); + + //THEN + expect(fleet.node.metadata).toHaveLength(0); + }); + + test('Warnings if non-Encrypted BlockDevices Provided', () => { + const VOLUME_SIZE = 50; + const DEVICE_NAME = '/dev/xvda'; + const id = 'SpotFleet'; + + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, id, { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + blockDevices: [ { + deviceName: DEVICE_NAME, + volume: BlockDeviceVolume.ebs( VOLUME_SIZE, {encrypted: false}), + }], + }); + + //THEN + expect(fleet.node.metadata[0].type).toMatch(ArtifactMetadataEntryType.WARN); + expect(fleet.node.metadata[0].data).toMatch(`The BlockDevice \"${DEVICE_NAME}\" on the spot-fleet ${id} is not encrypted. Workers can have access to sensitive data so it is recommended to encrypt the devices on the worker fleet.`); + }); + + test('Warnings for BlockDevices without encryption specified', () => { + const VOLUME_SIZE = 50; + const DEVICE_NAME = '/dev/xvda'; + const id = 'SpotFleet'; + + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, id, { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + blockDevices: [ { + deviceName: DEVICE_NAME, + volume: BlockDeviceVolume.ebs( VOLUME_SIZE ), + }], + }); + + //THEN + expect(fleet.node.metadata[0].type).toMatch(ArtifactMetadataEntryType.WARN); + expect(fleet.node.metadata[0].data).toMatch(`The BlockDevice \"${DEVICE_NAME}\" on the spot-fleet ${id} is not encrypted. Workers can have access to sensitive data so it is recommended to encrypt the devices on the worker fleet.`); + }); + + test('No warnings for Ephemeral blockDeviceVolumes', () => { + const DEVICE_NAME = '/dev/xvda'; + + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + blockDevices: [ { + deviceName: DEVICE_NAME, + volume: BlockDeviceVolume.ephemeral( 0 ), + }], + }); + + //THEN + expect(fleet.node.metadata).toHaveLength(0); + }); + + test('No warnings for Suppressed blockDeviceVolumes', () => { + const DEVICE_NAME = '/dev/xvda'; + + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + blockDevices: [ { + deviceName: DEVICE_NAME, + volume: BlockDeviceVolume.noDevice(), + }], + }); + + //THEN + expect(fleet.node.metadata).toHaveLength(0); + }); + + test('throws if block devices without iops and wrong volume type', () => { + // GIVEN + const deviceName = '/dev/xvda'; + const volumeSize = 50; + const volumeType = EbsDeviceVolumeType.IO1; + + // WHEN + function createSpotEventPluginFleet() { + return new SpotEventPluginFleet(stack, 'SpotEventPluginFleet', { + vpc, + renderQueue, + deadlineGroups: [ + groupName, + ], + instanceTypes: [ + InstanceType.of(InstanceClass.T3, InstanceSize.LARGE), + ], + workerMachineImage, + maxCapacity: 1, + blockDevices: [{ + deviceName, + volume: BlockDeviceVolume.ebs(volumeSize, { + volumeType, + }), + }], + }); + } + + // THEN + expect(createSpotEventPluginFleet).toThrowError(/iops property is required with volumeType: EbsDeviceVolumeType.IO1/); + }); + + test('warning if block devices with iops and wrong volume type', () => { + // GIVEN + const deviceName = '/dev/xvda'; + const volumeSize = 50; + const iops = 100; + const volumeType = EbsDeviceVolumeType.STANDARD; + + // WHEN + const fleet = new SpotEventPluginFleet(stack, 'SpotEventPluginFleet', { + vpc, + renderQueue, + deadlineGroups: [ + groupName, + ], + instanceTypes: [ + InstanceType.of(InstanceClass.T3, InstanceSize.LARGE), + ], + workerMachineImage, + maxCapacity: 1, + blockDevices: [{ + deviceName, + volume: BlockDeviceVolume.ebs(volumeSize, { + iops, + volumeType, + }), + }], + }); + + // THEN + expect(fleet.node.metadata[0].type).toMatch(ArtifactMetadataEntryType.WARN); + expect(fleet.node.metadata[0].data).toMatch('iops will be ignored without volumeType: EbsDeviceVolumeType.IO1'); + }); + }); + }); +}); diff --git a/packages/aws-rfdk/lib/deadline/test/wait-for-stable-service.test.ts b/packages/aws-rfdk/lib/deadline/test/wait-for-stable-service.test.ts new file mode 100644 index 000000000..f1d0b0a87 --- /dev/null +++ b/packages/aws-rfdk/lib/deadline/test/wait-for-stable-service.test.ts @@ -0,0 +1,122 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + arrayWith, + countResourcesLike, + expect as cdkExpect, + haveResourceLike, + objectLike, +} from '@aws-cdk/assert'; +import { + InstanceClass, + InstanceSize, + InstanceType, + SubnetType, +} from '@aws-cdk/aws-ec2'; +import { + Cluster, + ContainerImage, + Ec2Service, + Ec2TaskDefinition, +} from '@aws-cdk/aws-ecs'; +import { + ManagedPolicy, +} from '@aws-cdk/aws-iam'; +import { + App, + Stack, +} from '@aws-cdk/core'; +import { + WaitForStableService, +} from '../lib/wait-for-stable-service'; + +describe('WaitForStableService', () => { + let app: App; + let stack: Stack; + let isolatedStack: Stack; + let cluster: Cluster; + let taskDefinition: Ec2TaskDefinition; + let service: Ec2Service; + + beforeEach(() => { + app = new App(); + stack = new Stack(app, 'Stack'); + isolatedStack = new Stack(app, 'IsolatedStack'); + cluster = new Cluster(stack, 'Cluster'); + cluster.addCapacity('ASG', { + vpcSubnets: { subnetType: SubnetType.PRIVATE }, + instanceType: InstanceType.of(InstanceClass.C5, InstanceSize.LARGE), + minCapacity: 1, + maxCapacity: 1, + }); + taskDefinition = new Ec2TaskDefinition(stack, 'RCSTask'); + taskDefinition.addContainer('Test', { + image: ContainerImage.fromAsset(__dirname), + memoryLimitMiB: 7500, + }); + service = new Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + }); + }); + + test('creates a custom resource', () => { + // WHEN + new WaitForStableService(isolatedStack, 'WaitForStableService', { + service, + }); + + // THEN + cdkExpect(isolatedStack).to(haveResourceLike('Custom::RFDK_WaitForStableService', { + cluster: isolatedStack.resolve(cluster.clusterArn), + services: [isolatedStack.resolve(service.serviceArn)], + })); + }); + + test('creates lambda correctly', () => { + // WHEN + new WaitForStableService(isolatedStack, 'WaitForStableService', { + service, + }); + + cdkExpect(isolatedStack).to(countResourcesLike('AWS::Lambda::Function', 1, { + Handler: 'wait-for-stable-service.wait', + Environment: { + Variables: { + DEBUG: 'false', + }, + }, + Runtime: 'nodejs12.x', + Timeout: 900, + })); + }); + + test('adds policies to the lambda role', () => { + // WHEN + new WaitForStableService(isolatedStack, 'WaitForStableService', { + service, + }); + + // THEN + cdkExpect(isolatedStack).to(haveResourceLike('AWS::IAM::Role', { + ManagedPolicyArns: arrayWith( + isolatedStack.resolve(ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole').managedPolicyArn), + ), + Policies: [{ + PolicyDocument: objectLike({ + Statement: [{ + Action: 'ecs:DescribeServices', + Effect: 'Allow', + Resource: arrayWith( + isolatedStack.resolve(cluster.clusterArn), + isolatedStack.resolve(service.serviceArn), + ), + }], + }), + }], + })); + }); +}); 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 new file mode 100644 index 000000000..7ebff8b17 --- /dev/null +++ b/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/conversion.ts @@ -0,0 +1,257 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BlockDeviceMappingProperty, + BlockDeviceProperty, + PluginSettings, + SpotFleetInstanceProfile, + SpotFleetRequestConfiguration, + LaunchSpecification, + SpotFleetRequestProps, + SpotFleetSecurityGroupId, + SpotFleetTagSpecification, +} from './types'; + +/** + * Convert the configuration we received from ConfigureSpotEventPlugin construct to the fromat expected by the Spot Event Plugin. + * boolean and number properties get converted into strings when passed to the Lambda, + * so we need to restore the original types. + */ +export function convertSpotFleetRequestConfiguration(spotFleetRequestConfigs: SpotFleetRequestConfiguration): SpotFleetRequestConfiguration { + const convertedSpotFleetRequestConfigs: SpotFleetRequestConfiguration = {}; + + for (const [group_name, sfrConfigs] of Object.entries(spotFleetRequestConfigs)) { + const convertedSpotFleetRequestProps: SpotFleetRequestProps = { + AllocationStrategy: validateString(sfrConfigs.AllocationStrategy, `${group_name}.AllocationStrategy`), + IamFleetRole: validateString(sfrConfigs.IamFleetRole, `${group_name}.IamFleetRole`), + LaunchSpecifications: convertLaunchSpecifications(sfrConfigs.LaunchSpecifications, `${group_name}.LaunchSpecifications`), + ReplaceUnhealthyInstances: convertToBoolean(sfrConfigs.ReplaceUnhealthyInstances, `${group_name}.ReplaceUnhealthyInstances`), + TargetCapacity: convertToInt(sfrConfigs.TargetCapacity, `${group_name}.TargetCapacity`), + TerminateInstancesWithExpiration: convertToBoolean(sfrConfigs.TerminateInstancesWithExpiration, `${group_name}.TerminateInstancesWithExpiration`), + Type: validateString(sfrConfigs.Type, `${group_name}.Type`), + ValidUntil: validateStringOptional(sfrConfigs.ValidUntil, `${group_name}.ValidUntil`), + TagSpecifications: convertTagSpecifications(sfrConfigs.TagSpecifications, `${group_name}.TagSpecifications`), + }; + convertedSpotFleetRequestConfigs[group_name] = convertedSpotFleetRequestProps; + } + return convertedSpotFleetRequestConfigs; +} + +/** + * Convert the configuration we received from ConfigureSpotEventPlugin construct to the fromat expected by the Spot Event Plugin. + * boolean and number properties get converted into strings when passed to the Lambda, + * so we need to restore the original types. + */ +export function convertSpotEventPluginSettings(pluginOptions: PluginSettings): PluginSettings { + return { + AWSInstanceStatus: validateString(pluginOptions.AWSInstanceStatus, 'AWSInstanceStatus'), + DeleteInterruptedSlaves: convertToBoolean(pluginOptions.DeleteInterruptedSlaves, 'DeleteInterruptedSlaves'), + DeleteTerminatedSlaves: convertToBoolean(pluginOptions.DeleteTerminatedSlaves, 'DeleteTerminatedSlaves'), + IdleShutdown: convertToInt(pluginOptions.IdleShutdown, 'IdleShutdown'), + Logging: validateString(pluginOptions.Logging, 'Logging'), + PreJobTaskMode: validateString(pluginOptions.PreJobTaskMode, 'PreJobTaskMode'), + Region: validateString(pluginOptions.Region, 'Region'), + ResourceTracker: convertToBoolean(pluginOptions.ResourceTracker, 'ResourceTracker'), + StaggerInstances: convertToInt(pluginOptions.StaggerInstances, 'StaggerInstances'), + State: validateString(pluginOptions.State, 'State'), + StrictHardCap: convertToBoolean(pluginOptions.StrictHardCap, 'StrictHardCap'), + }; +} + +export function validateArray(input: any, propertyName: string): void { + if (!input || !Array.isArray(input) || input.length === 0) { + throw new Error(`${propertyName} should be an array with at least one element.`); + } +} + +export function validateProperty(isValid: (input: any) => boolean, property: any, propertyName: string): void { + if (!isValid(property)) { + throw new Error(`${propertyName} type is not valid.`); + } +} + +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 + if (!tagSpecification.ResourceType || typeof(tagSpecification.ResourceType) !== 'string') { return false; } + if (!tagSpecification.Tags || !Array.isArray(tagSpecification.Tags)) { return false; } + for (let element of tagSpecification.Tags) { + if (!element || typeof(element) !== 'object') { return false; }; + if (!element.Key || typeof(element.Key) !== 'string') { return false; } + if (!element.Value || typeof(element.Value) !== 'string') { return false; } + } + return true; +} + +export function convertTagSpecifications(tagSpecifications: SpotFleetTagSpecification[], propertyName: string): SpotFleetTagSpecification[] { + validateArray(tagSpecifications, propertyName); + + const convertedTagSpecifications: SpotFleetTagSpecification[] = tagSpecifications.map(tagSpecification => { + validateProperty(isValidTagSpecification, tagSpecification, propertyName); + const convertedTagSpecification: SpotFleetTagSpecification = { + ResourceType: validateString(tagSpecification.ResourceType, `${propertyName}.ResourceType`), + Tags: tagSpecification.Tags, + }; + return convertedTagSpecification; + }); + + 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 convertInstanceProfile(instanceProfile: SpotFleetInstanceProfile, propertyName: string): SpotFleetInstanceProfile { + validateProperty(isValidInstanceProfile, instanceProfile, propertyName); + const convertedInstanceProfile: SpotFleetInstanceProfile = { + Arn: validateString(instanceProfile.Arn, `${propertyName}.Arn`), + }; + return convertedInstanceProfile; +} + +export function convertLaunchSpecifications(launchSpecifications: LaunchSpecification[], propertyName: string): LaunchSpecification[] { + validateArray(launchSpecifications, 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; + + 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); + }); + return convertedLaunchSpecifications; +} + +export function convertToInt(value: any, propertyName: string): number { + if (typeof(value) === 'number') { + if (Number.isInteger(value)) { + return value; + } + } + + if (typeof(value) === 'string') { + const result = Number.parseFloat(value); + if (Number.isInteger(result)) { + return result; + } + } + + 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; + } + + if (typeof(value) === 'string') { + if (value === 'true') { return true; } + if (value === 'false') { return false; } + } + + 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; + } + + throw new Error(`The value of ${propertyName} should be a string. Received: ${value} of type ${typeof(value)}`); +} + +export function validateStringOptional(value: any, propertyName: string): string | undefined { + if (value === undefined) { + return undefined; + } + return validateString(value, propertyName); +} diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/handler.ts b/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/handler.ts new file mode 100644 index 000000000..2a1dd7eeb --- /dev/null +++ b/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/handler.ts @@ -0,0 +1,149 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import { SecretsManager } from 'aws-sdk'; +import { LambdaContext } from '../lib/aws-lambda'; +import { SpotEventPluginClient } from '../lib/configure-spot-event-plugin'; +import { CfnRequestEvent, SimpleCustomResource } from '../lib/custom-resource'; +import { DeadlineClient } from '../lib/deadline-client'; +import { + isArn as isSecretArn, + readCertificateData, +} from '../lib/secrets-manager'; +import { + convertSpotFleetRequestConfiguration, + convertSpotEventPluginSettings, +} from './conversion'; +import { + ConnectionOptions, + PluginSettings, + SEPConfiguratorResourceProps, +} from './types'; + +/** + * A custom resource used to save Spot Event Plugin server data and configurations. + */ +export class SEPConfiguratorResource extends SimpleCustomResource { + protected readonly secretsManagerClient: SecretsManager; + + constructor(secretsManagerClient: SecretsManager) { + super(); + this.secretsManagerClient = secretsManagerClient; + } + + /** + * @inheritdoc + */ + public validateInput(data: object): boolean { + return this.implementsSEPConfiguratorResourceProps(data); + } + + /** + * @inheritdoc + */ + public async doCreate(_physicalId: string, resourceProperties: SEPConfiguratorResourceProps): Promise { + const spotEventPluginClient = await this.spotEventPluginClient(resourceProperties.connection); + + if (resourceProperties.spotFleetRequestConfigurations) { + const convertedSpotFleetRequestConfigs = convertSpotFleetRequestConfiguration(resourceProperties.spotFleetRequestConfigurations); + const stringConfigs = JSON.stringify(convertedSpotFleetRequestConfigs); + const response = await spotEventPluginClient.saveServerData(stringConfigs); + if (!response) { + throw new Error('Failed to save spot fleet request with configuration'); + } + } + if (resourceProperties.spotPluginConfigurations) { + const convertedSpotPluginConfigs = convertSpotEventPluginSettings(resourceProperties.spotPluginConfigurations); + const pluginSettings = this.toKeyValueArray(convertedSpotPluginConfigs); + const securitySettings = this.securitySettings(); + const response = await spotEventPluginClient.configureSpotEventPlugin([...pluginSettings, ...securitySettings]); + if (!response) { + throw new Error('Failed to save Spot Event Plugin Configurations'); + } + } + return undefined; + } + + /** + * @inheritdoc + */ + public async doDelete(_physicalId: string, _resourceProperties: SEPConfiguratorResourceProps): Promise { + // Nothing to do -- we don't modify anything. + return; + } + + private implementsSEPConfiguratorResourceProps(value: any): value is SEPConfiguratorResourceProps { + if (!value || typeof(value) !== 'object' || Array.isArray(value)) { return false; } + if (!this.implementsConnectionOptions(value.connection)) { return false; } + return true; + } + + private implementsConnectionOptions(value: any): value is ConnectionOptions { + if (!value || typeof(value) !== 'object' || Array.isArray(value)) { return false; } + if (!value.hostname || typeof(value.hostname) !== 'string') { return false; } + if (!value.port || typeof(value.port) !== 'string') { return false; } + const portNum = Number.parseInt(value.port, 10); + if (Number.isNaN(portNum) || portNum < 1 || portNum > 65535) { return false; } + if (!value.protocol || typeof(value.protocol) !== 'string') { return false; } + if (value.protocol !== 'HTTP' && value.protocol !== 'HTTPS') { return false; } + if (!this.isSecretArnOrUndefined(value.caCertificateArn)) { return false; } + return true; + } + + private isSecretArnOrUndefined(value: any): boolean { + if (value) { + if (typeof(value) !== 'string' || !isSecretArn(value)) { return false; } + } + return true; + } + + private async spotEventPluginClient(connection: ConnectionOptions): Promise { + return new SpotEventPluginClient(new DeadlineClient({ + host: connection.hostname, + port: Number.parseInt(connection.port, 10), + protocol: connection.protocol, + tls: { + ca: connection.caCertificateArn ? await readCertificateData(connection.caCertificateArn, this.secretsManagerClient) : undefined, + }, + })); + } + + private toKeyValueArray(input: PluginSettings): Array<{ Key: string, Value: any }> { + const configs: Array<{ Key: string, Value: any }> = []; + for (const [key, value] of Object.entries(input)) { + if (value === undefined) { + throw new Error(`Value for ${key} should be defined.`); + } + configs.push({ + Key: key, + Value: value, + }); + } + return configs; + } + + private securitySettings(): Array<{ Key: string, Value: any }> { + return [ + { + Key: 'UseLocalCredentials', + Value: true, + }, + { + Key: 'NamedProfile', + Value: '', + }, + ]; + } +} + +/** + * The lambda handler that is used to log in to MongoDB and perform some configuration actions. + */ +/* istanbul ignore next */ +export async function configureSEP(event: CfnRequestEvent, context: LambdaContext): Promise { + const handler = new SEPConfiguratorResource(new SecretsManager()); + return await handler.handler(event, context); +} diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/index.ts b/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/index.ts new file mode 100644 index 000000000..19c785b5f --- /dev/null +++ b/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './handler'; +export * from './types'; 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 new file mode 100644 index 000000000..ab55d892c --- /dev/null +++ b/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/test/conversion.test.ts @@ -0,0 +1,531 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + convertSpotEventPluginSettings, + convertToBoolean, + convertToBooleanOptional, + convertToInt, + convertToIntOptional, + isValidDeviceMapping, + isValidInstanceProfile, + isValidSecurityGroup, + isValidTagSpecification, + validateArray, + validateProperty, + validateString, + validateStringOptional, +} from '../conversion'; +import { + PluginSettings, + SpotFleetSecurityGroupId, + BlockDeviceMappingProperty, + SpotFleetInstanceProfile, +} from '../types'; + +describe('convertSpotEventPluginSettings()', () => { + test('does not convert properties with correct types', () => { + // GIVEN + const defaultPluginConfig = { + AWSInstanceStatus: 'Disabled', + DeleteInterruptedSlaves: false, + DeleteTerminatedSlaves: false, + IdleShutdown: 10, + Logging: 'Standard', + PreJobTaskMode: 'Conservative', + Region: 'eu-west-1', + ResourceTracker: true, + StaggerInstances: 50, + State: 'Disabled', + StrictHardCap: false, + }; + + const defaultConvertedPluginConfig = { + AWSInstanceStatus: 'Disabled', + DeleteInterruptedSlaves: false, + DeleteTerminatedSlaves: false, + IdleShutdown: 10, + Logging: 'Standard', + PreJobTaskMode: 'Conservative', + Region: 'eu-west-1', + ResourceTracker: true, + StaggerInstances: 50, + State: 'Disabled', + StrictHardCap: false, + }; + + // WHEN + const returnValue = convertSpotEventPluginSettings(defaultPluginConfig); + + // THEN + expect(returnValue).toEqual(defaultConvertedPluginConfig); + }); + + test('converts properties of type string', () => { + // GIVEN + const defaultPluginConfig = { + AWSInstanceStatus: 'Disabled', + DeleteInterruptedSlaves: 'false', + DeleteTerminatedSlaves: 'false', + IdleShutdown: '10', + Logging: 'Standard', + PreJobTaskMode: 'Conservative', + Region: 'eu-west-1', + ResourceTracker: 'true', + StaggerInstances: '50', + State: 'Disabled', + StrictHardCap: 'false', + }; + + const defaultConvertedPluginConfig = { + AWSInstanceStatus: 'Disabled', + DeleteInterruptedSlaves: false, + DeleteTerminatedSlaves: false, + IdleShutdown: 10, + Logging: 'Standard', + PreJobTaskMode: 'Conservative', + Region: 'eu-west-1', + ResourceTracker: true, + StaggerInstances: 50, + State: 'Disabled', + StrictHardCap: false, + }; + + // WHEN + // Need this trick so TS allows to pass config with string properties. + const config = (defaultPluginConfig as unknown) as PluginSettings; + const returnValue = convertSpotEventPluginSettings(config); + + // THEN + expect(returnValue).toEqual(defaultConvertedPluginConfig); + }); +}); + +describe('convertToInt()', () => { + test.each<[any, number]>([ + ['10', 10], + [10, 10], + ])('correctly converts %p to %p', (input: any, expected: number) => { + // WHEN + const returnValue = convertToInt(input, 'propertyName'); + + // THEN + expect(returnValue).toBe(expected); + }); + + test.each([ + 10.6, + [], + {}, + 'string', + undefined, + ])('throws an error with %p', input => { + // WHEN + const propertyName = 'propertyName'; + function callingConvertToInt() { + convertToInt(input, propertyName); + } + + // THEN + expect(callingConvertToInt).toThrowError(`The value of ${propertyName} should be an integer. Received: ${input}`); + }); +}); + +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], + ['true', true], + [false, false], + ['false', false], + ])('correctly converts %p to %p', (input: any, expected: boolean) => { + // WHEN + const returnValue = convertToBoolean(input, 'property'); + + // THEN + expect(returnValue).toBe(expected); + }); + + test.each([ + 10.6, + [], + {}, + 'string', + undefined, + ])('throws an error with %p', input => { + // WHEN + const propertyName = 'propertyName'; + function callingConvertToBoolean() { + convertToBoolean(input, propertyName); + } + + // THEN + expect(callingConvertToBoolean).toThrowError(`The value of ${propertyName} should be a boolean. Received: ${input}`); + }); +}); + +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'], + ['10', '10'], + ['true', 'true'], + ])('correctly converts %p to %p', (input: any, expected: string) => { + // WHEN + const returnValue = validateString(input, 'propertyName'); + + // THEN + expect(returnValue).toBe(expected); + }); + + test.each([ + 10, + [], + {}, + undefined, + ])('throws an error with %p', input => { + // WHEN + const propertyName = 'propertyName'; + function callingValidateString() { + validateString(input, propertyName); + } + + // THEN + expect(callingValidateString).toThrowError(`The value of ${propertyName} should be a string. Received: ${input} of type ${typeof(input)}`); + }); +}); + +describe('validateStringOptional()', () => { + test.each<[any, string | undefined]>([ + ['string', 'string'], + ['10', '10'], + ['true', 'true'], + [undefined, undefined], + ])('correctly converts %p to %p', (input: any, expected: string | undefined) => { + // WHEN + const returnValue = validateStringOptional(input, 'propertyName'); + + // THEN + expect(returnValue).toBe(expected); + }); + + test.each([ + 10, + [], + {}, + ])('throws an error with %p', input => { + // WHEN + const propertyName = 'propertyName'; + function callingValidateStringOptional() { + validateStringOptional(input, propertyName); + } + + // THEN + expect(callingValidateStringOptional).toThrowError(`The value of ${propertyName} should be a string. Received: ${input} of type ${typeof(input)}`); + }); +}); + +describe('validateArray', () => { + test.each([ + undefined, + {}, + [], + ])('throws with invalid input %p', (invalidInput: any) => { + // WHEN + const propertyName = 'propertyName'; + function callingValidateArray() { + validateArray(invalidInput, propertyName); + } + + // THEN + expect(callingValidateArray).toThrowError(`${propertyName} should be an array with at least one element.`); + }); + + test('passes with not empty array', () => { + // GIVEN + const nonEmptyArray = ['value']; + + // WHEN + function callingValidateArray() { + validateArray(nonEmptyArray, 'propertyName'); + } + + // THEN + expect(callingValidateArray).not.toThrowError(); + }); +}); + +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 = { + ResourceType: 'type', + Tags: [{ + Key: 'key', + Value: 'value', + }], + }; + + // Invalid tag specifications + const noResourceType = { + }; + const resourceTypeNotSting = { + ResourceType: 10, + }; + const noTags = { + ResourceType: 'type', + }; + const tagsNotArray = { + ResourceType: 'type', + Tags: '', + }; + const tagElementUndefined = { + ResourceType: 'type', + Tags: [undefined], + }; + const tagElementWrongType = { + ResourceType: 'type', + Tags: [''], + }; + const tagElementNoKey = { + ResourceType: 'type', + Tags: [{ + }], + }; + const tagElementKeyNotString = { + ResourceType: 'type', + Tags: [{ + Key: 10, + }], + }; + const tagElementNoValue = { + ResourceType: 'type', + Tags: [{ + Key: 'key', + }], + }; + const tagElementValueNotString = { + ResourceType: 'type', + Tags: [{ + Key: 'key', + Value: 10, + }], + }; + + test.each([ + undefined, + [], + '', + noResourceType, + resourceTypeNotSting, + noTags, + tagsNotArray, + tagElementUndefined, + tagElementWrongType, + tagElementNoKey, + tagElementKeyNotString, + tagElementNoValue, + tagElementValueNotString, + ])('returns false with invalid input %p', (invalidInput: any) => { + // WHEN + const result = isValidTagSpecification(invalidInput); + + // THEN + expect(result).toBeFalsy(); + }); + + test('returns true with a valid input', () => { + // WHEN + const result = isValidTagSpecification(validTagSpecification); + + // THEN + expect(result).toBeTruthy(); + }); +}); + +describe('isValidDeviceMapping', () => { + test.each([ + undefined, + [], + '', + ])('returns false with invalid input %p', (invalidInput: any) => { + // WHEN + const result = isValidDeviceMapping(invalidInput); + + // THEN + expect(result).toBeFalsy(); + }); + + test('returns true with a valid input', () => { + // GIVEN + const anyObject = {} as unknown; + + // WHEN + const result = isValidDeviceMapping(anyObject as BlockDeviceMappingProperty); + + // THEN + expect(result).toBeTruthy(); + }); +}); + +describe('isValidInstanceProfile', () => { + // Valid instance profiles + const validInstanceProfile: SpotFleetInstanceProfile = { + Arn: 'arn', + }; + + // Invalid instance profiles + const noArn = { + }; + const arnNotString = { + Arn: 10, + }; + + test.each([ + undefined, + [], + '', + noArn, + arnNotString, + ])('returns false with invalid input %p', (invalidInput: any) => { + // WHEN + const result = isValidInstanceProfile(invalidInput); + + // THEN + expect(result).toBeFalsy(); + }); + + test('returns true with a valid input', () => { + // WHEN + const result = isValidInstanceProfile(validInstanceProfile); + + // THEN + expect(result).toBeTruthy(); + }); +}); + +describe('validateProperty', () => { + test('throws with invalid input', () => { + // WHEN + const propertyName = 'propertyName'; + function returnFalse(_input: any) { + return false; + } + function callingValidateProperty() { + validateProperty(returnFalse, 'anyValue', propertyName); + } + + // THEN + expect(callingValidateProperty).toThrowError(`${propertyName} type is not valid.`); + }); + + test('passes with a valid input', () => { + // WHEN + function returnTrue(_input: any) { + return true; + } + function callingValidateProperty() { + validateProperty(returnTrue, 'anyValue', 'propertyName'); + } + + // THEN + expect(callingValidateProperty).not.toThrowError(); + }); +}); \ No newline at end of file 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 new file mode 100644 index 000000000..7ed0de251 --- /dev/null +++ b/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/test/handler.test.ts @@ -0,0 +1,721 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + InstanceClass, + InstanceSize, + InstanceType, +} from '@aws-cdk/aws-ec2'; +import { Expiration } from '@aws-cdk/core'; +import * as AWS from 'aws-sdk'; +import { + SpotEventPluginDisplayInstanceStatus, + SpotEventPluginLoggingLevel, + SpotEventPluginPreJobTaskMode, + SpotEventPluginState, + SpotFleetAllocationStrategy, + SpotFleetRequestType, + SpotFleetResourceType, +} from '../../../../deadline'; +import { SEPConfiguratorResource } from '../handler'; +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 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', + }, + 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', + }, + ], + }], + }; + + 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 allConfigs: SEPConfiguratorResourceProps = { + spotPluginConfigurations: validSpotEventPluginConfig, + connection: validConnection, + spotFleetRequestConfigurations: validSpotFleetRequestConfig, + }; + + const noConfigs: SEPConfiguratorResourceProps = { + connection: validConnection, + }; + + describe('doCreate', () => { + let handler: SEPConfiguratorResource; + let mockSpotEventPluginClient: { saveServerData: jest.Mock; configureSpotEventPlugin: jest.Mock; }; + + beforeEach(() => { + mockSpotEventPluginClient = { + saveServerData: jest.fn(), + configureSpotEventPlugin: jest.fn(), + }; + + handler = new SEPConfiguratorResource(new AWS.SecretsManager()); + + jest.requireMock('../../lib/secrets-manager/read-certificate').readCertificateData.mockReturnValue(Promise.resolve('BEGIN CERTIFICATE')); + + async function returnSpotEventPluginClient(_v1: any): Promise { + return mockSpotEventPluginClient; + } + // eslint-disable-next-line dot-notation + handler['spotEventPluginClient'] = jest.fn( (a) => returnSpotEventPluginClient(a) ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('with no configs', async () => { + // GIVEN + async function returnTrue(_v1: any): Promise { + return true; + } + const mockSaveServerData = jest.fn( (a) => returnTrue(a) ); + mockSpotEventPluginClient.saveServerData = mockSaveServerData; + const mockConfigureSpotEventPlugin = jest.fn( (a) => returnTrue(a) ); + mockSpotEventPluginClient.configureSpotEventPlugin = mockConfigureSpotEventPlugin; + + // WHEN + const result = await handler.doCreate('physicalId', noConfigs); + + // THEN + expect(result).toBeUndefined(); + expect(mockSaveServerData.mock.calls.length).toBe(0); + expect(mockConfigureSpotEventPlugin.mock.calls.length).toBe(0); + }); + + test('save spot fleet request configs', async () => { + // GIVEN + async function returnTrue(_v1: any): Promise { + return true; + } + const mockSaveServerData = jest.fn( (a) => returnTrue(a) ); + mockSpotEventPluginClient.saveServerData = mockSaveServerData; + + // WHEN + const result = await handler.doCreate('physicalId', noPluginConfigs); + + // 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 + async function returnTrue(_v1: any): Promise { + return true; + } + 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 + async function returnTrue(_v1: any): Promise { + return true; + } + 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); + }); + + test('save spot event plugin configs', async () => { + // GIVEN + async function returnTrue(_v1: any): Promise { + return true; + } + const mockConfigureSpotEventPlugin = jest.fn( (a) => returnTrue(a) ); + mockSpotEventPluginClient.configureSpotEventPlugin = mockConfigureSpotEventPlugin; + + const configs: { Key: string, Value: any }[] = []; + for (const [key, value] of Object.entries(validConvertedPluginConfig)) { + configs.push({ + Key: key, + Value: value, + }); + } + + const securitySettings = [{ + Key: 'UseLocalCredentials', + Value: true, + }, + { + Key: 'NamedProfile', + Value: '', + }]; + + // WHEN + const result = await handler.doCreate('physicalId', noFleetRequestConfigs); + + // THEN + expect(result).toBeUndefined(); + expect(mockConfigureSpotEventPlugin.mock.calls.length).toBe(1); + expect(mockConfigureSpotEventPlugin.mock.calls[0][0]).toEqual([...configs, ...securitySettings]); + }); + + test('save both configs', async () => { + // GIVEN + async function returnTrue(_v1: any): Promise { + return true; + } + const mockSaveServerData = jest.fn( (a) => returnTrue(a) ); + mockSpotEventPluginClient.saveServerData = mockSaveServerData; + + const mockConfigureSpotEventPlugin = jest.fn( (a) => returnTrue(a) ); + mockSpotEventPluginClient.configureSpotEventPlugin = mockConfigureSpotEventPlugin; + + const configs: { Key: string, Value: any }[] = []; + for (const [key, value] of Object.entries(validConvertedPluginConfig)) { + configs.push({ + Key: key, + Value: value, + }); + } + + const securitySettings = [{ + Key: 'UseLocalCredentials', + Value: true, + }, + { + Key: 'NamedProfile', + Value: '', + }]; + + // WHEN + const result = await handler.doCreate('physicalId', allConfigs); + + // THEN + expect(result).toBeUndefined(); + expect(mockSaveServerData.mock.calls.length).toBe(1); + expect(mockSaveServerData.mock.calls[0][0]).toEqual(JSON.stringify(validConvertedSpotFleetRequestConfig)); + + expect(mockConfigureSpotEventPlugin.mock.calls.length).toBe(1); + expect(mockConfigureSpotEventPlugin.mock.calls[0][0]).toEqual([...configs, ...securitySettings]); + }); + + test('throw when cannot save spot fleet request configs', async () => { + // GIVEN + async function returnFalse(_v1: any): Promise { + return false; + } + const mockSaveServerData = jest.fn( (a) => returnFalse(a) ); + mockSpotEventPluginClient.saveServerData = mockSaveServerData; + + // WHEN + const promise = handler.doCreate('physicalId', noPluginConfigs); + + // THEN + await expect(promise) + .rejects + .toThrowError(/Failed to save spot fleet request with configuration/); + }); + + test('throw when cannot save spot event plugin configs', async () => { + // GIVEN + async function returnFalse(_v1: any): Promise { + return false; + } + const mockConfigureSpotEventPlugin = jest.fn( (a) => returnFalse(a) ); + mockSpotEventPluginClient.configureSpotEventPlugin = mockConfigureSpotEventPlugin; + + // WHEN + const promise = handler.doCreate('physicalId', noFleetRequestConfigs); + + // THEN + await expect(promise) + .rejects + .toThrowError(/Failed to save Spot Event Plugin Configurations/); + }); + }); + + test('doDelete does not do anything', async () => { + // GIVEN + const handler = new SEPConfiguratorResource(new AWS.SecretsManager()); + + // WHEN + const promise = await handler.doDelete('physicalId', noConfigs); + + // THEN + await expect(promise).toBeUndefined(); + }); + + describe('.validateInput()', () => { + describe('should return true', () => { + test.each([ + allConfigs, + noPluginConfigs, + noFleetRequestConfigs, + noConfigs, + ])('with valid input', async (input: any) => { + // WHEN + const handler = new SEPConfiguratorResource(new AWS.SecretsManager()); + const returnValue = handler.validateInput(input); + + // THEN + expect(returnValue).toBeTruthy(); + }); + }); + + // Invalid connection + const noProtocolConnection = { + hostname: 'internal-hostname.us-east-1.elb.amazonaws.com', + port: '4433', + }; + const noHostnameConnection = { + protocol: 'HTTPS', + port: '4433', + }; + const noPortConnection = { + hostname: 'internal-hostname.us-east-1.elb.amazonaws.com', + protocol: 'HTTPS', + caCertificateArn: secretArn, + }; + const invalidHostnameConnection = { + hostname: 10, + protocol: 'HTTPS', + port: '4433', + }; + const invalidProtocolConnection = { + hostname: 'internal-hostname.us-east-1.elb.amazonaws.com', + protocol: 'TCP', + port: '4433', + }; + const invalidProtocolTypeConnection = { + hostname: 'internal-hostname.us-east-1.elb.amazonaws.com', + protocol: ['HTTPS'], + port: '4433', + }; + const invalidPortTypeConnection = { + hostname: 'internal-hostname.us-east-1.elb.amazonaws.com', + protocol: 'HTTPS', + port: 4433, + }; + const invalidPortRange1Connection = { + hostname: 'internal-hostname.us-east-1.elb.amazonaws.com', + protocol: 'HTTPS', + port: '-1', + }; + const invalidPortRange2Connection = { + hostname: 'internal-hostname.us-east-1.elb.amazonaws.com', + protocol: 'HTTPS', + port: '65536', + }; + const invalidPortRange3Connection = { + hostname: 'internal-hostname.us-east-1.elb.amazonaws.com', + protocol: 'HTTPS', + port: Number.NaN.toString(), + }; + const invalidCaCertConnection = { + hostname: 'internal-hostname.us-east-1.elb.amazonaws.com', + protocol: 'HTTPS', + port: '4433', + caCertificateArn: 'notArn', + }; + + describe('should return false if', () => { + test.each([ + noProtocolConnection, + noHostnameConnection, + noPortConnection, + invalidCaCertConnection, + invalidHostnameConnection, + invalidProtocolConnection, + invalidProtocolTypeConnection, + invalidPortTypeConnection, + invalidPortRange1Connection, + invalidPortRange2Connection, + invalidPortRange3Connection, + undefined, + [], + ])('invalid connection', (invalidConnection: any) => { + // GIVEN + const input = { + spotPluginConfigurations: validSpotEventPluginConfig, + connection: invalidConnection, + spotFleetRequestConfigurations: validSpotFleetRequestConfig, + }; + + // WHEN + const handler = new SEPConfiguratorResource(new AWS.SecretsManager()); + const returnValue = handler.validateInput(input); + + // THEN + expect(returnValue).toBeFalsy(); + }); + + test.each([ + undefined, + [], + '', + ])('{input=%s}', (input) => { + // WHEN + const handler = new SEPConfiguratorResource(new AWS.SecretsManager()); + const returnValue = handler.validateInput(input); + + // THEN + expect(returnValue).toBeFalsy(); + }); + }); + }); + + describe('.isSecretArnOrUndefined()', () => { + describe('should return true if', () => { + test.each([ + secretArn, + undefined, + ])('{input=%s}', async (input: string | undefined) => { + // WHEN + const handler = new SEPConfiguratorResource(new AWS.SecretsManager()); + + // eslint-disable-next-line dot-notation + const returnValue = handler['isSecretArnOrUndefined'](input); + expect(returnValue).toBeTruthy(); + }); + }); + + describe('should return false if', () => { + test.each([ + 'any string', + 10, + [], + ])('{input=%s}', async (input: any) => { + // WHEN + const handler = new SEPConfiguratorResource(new AWS.SecretsManager()); + + // eslint-disable-next-line dot-notation + const returnValue = handler['isSecretArnOrUndefined'](input); + expect(returnValue).toBeFalsy(); + }); + }); + }); + + describe('.spotEventPluginClient()', () => { + test('creates a valid object with http', async () => { + // GIVEN + const validHTTPConnection: ConnectionOptions = { + hostname: 'internal-hostname.com', + protocol: 'HTTP', + port: '8080', + }; + + // WHEN + const handler = new SEPConfiguratorResource(new AWS.SecretsManager()); + // eslint-disable-next-line dot-notation + const result = await handler['spotEventPluginClient'](validHTTPConnection); + + expect(result).toBeDefined(); + }); + + test('creates a valid object with https', async () => { + // GIVEN + const validHTTPSConnection: ConnectionOptions = { + hostname: 'internal-hostname.com', + protocol: 'HTTP', + port: '8080', + caCertificateArn: secretArn, + }; + + // WHEN + const handler = new SEPConfiguratorResource(new AWS.SecretsManager()); + + jest.requireMock('../../lib/secrets-manager/read-certificate').readCertificateData.mockReturnValue(Promise.resolve('BEGIN CERTIFICATE')); + + // eslint-disable-next-line dot-notation + const result = await handler['spotEventPluginClient'](validHTTPSConnection); + + expect(result).toBeDefined(); + }); + }); + + describe('.toKeyValueArray()', () => { + test('converts to array of key value pairs', () => { + // GIVEN + const pluginConfig = { + AWSInstanceStatus: 'Disabled', + } as unknown; + const expectedResult = { + Key: 'AWSInstanceStatus', + Value: 'Disabled', + }; + + // WHEN + const handler = new SEPConfiguratorResource(new AWS.SecretsManager()); + // eslint-disable-next-line dot-notation + const returnValue = handler['toKeyValueArray'](pluginConfig as PluginSettings); + + // THEN + expect(returnValue).toContainEqual(expectedResult); + }); + + test('throws with undefined values', () => { + // GIVEN + const pluginConfig = { + AWSInstanceStatus: undefined, + } as unknown; + + // WHEN + const handler = new SEPConfiguratorResource(new AWS.SecretsManager()); + function toKeyValueArray() { + // eslint-disable-next-line dot-notation + handler['toKeyValueArray'](pluginConfig as PluginSettings); + } + + // THEN + expect(toKeyValueArray).toThrowError(); + }); + }); +}); 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 new file mode 100644 index 000000000..8887f0287 --- /dev/null +++ b/packages/aws-rfdk/lib/lambdas/nodejs/configure-spot-event-plugin/types.ts @@ -0,0 +1,362 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The input to the SEPConfiguratorResource + */ +export interface SEPConfiguratorResourceProps { + /** + * Info for connecting to the Render Queue. + */ + readonly connection: ConnectionOptions; + + /** + * The Spot Fleet Request Configurations. + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html#example-spot-fleet-request-configurations + */ + readonly spotFleetRequestConfigurations?: SpotFleetRequestConfiguration; + + /** + * The Spot Event Plugin settings. + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html#event-plugin-configuration-options + */ + readonly spotPluginConfigurations?: PluginSettings; +} + +/** + * Values required for establishing a connection to a TLS-enabled Render Queue. + */ +export interface ConnectionOptions { + /** + * Fully qualified domain name of the Render Queue. + */ + readonly hostname: string; + + /** + * Port on the Render Queue to connect to. + */ + readonly port: string; + + /** + * Protocol used to connect to the Render Queue. + * Allowed values: 'HTTP' and 'HTTPS'. + */ + readonly protocol: string; + + /** + * The ARN of the CA certificate stored in the SecretsManager. + */ + readonly caCertificateArn?: string; +} + +/** + * Interface for communication between Lambda and ConfigureSpotEventPlugin construct. + * The properties correspond to SpotEventPluginSettings from '../../../deadline/lib/configure-spot-event-plugin', + * but the types and names may differ. + */ +export interface PluginSettings { + /** + * The Worker Extra Info column to be used to display AWS Instance Status + */ + readonly AWSInstanceStatus: string; + + /** + * Determines if EC2 Spot interrupted AWS Workers will be deleted from the Workers Panel on the next House Cleaning cycle. + */ + readonly DeleteInterruptedSlaves: boolean; + + /** + * Determines if Deadline Spot Event Plugin terminated AWS Workers will be deleted from the Workers Panel on the next House Cleaning cycle. + */ + readonly DeleteTerminatedSlaves: boolean; + + /** + * The number of minutes an AWS Worker will wait in a non-rendering state before it is shutdown. + */ + readonly IdleShutdown: number; + + /** + * Spot Event Plugin logging level. + */ + readonly Logging: string; + + /** + * Determines how the Spot Event Plugin should handle Pre Job Tasks. + */ + readonly PreJobTaskMode: string; + + /** + * The AWS region in which to start the spot fleet request. + */ + readonly Region: string; + + /** + * Determines whether the Deadline Resource Tracker should be used. + */ + readonly ResourceTracker: boolean; + + /** + * The Spot Event Plugin will request this maximum number of instances per House Cleaning cycle. + */ + readonly StaggerInstances: number; + + /** + * How the event plug-in should respond to events. + */ + readonly State: string; + + /** + * Determines if any active instances greater than the target capacity for each group will be terminated. + */ + readonly StrictHardCap: boolean; +} + +/** + * The interface representing the Spot Fleet Request Configurations (see https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/event-spot.html#spot-fleet-request-configurations). + * Used for communication between Lambda and ConfigureSpotEventPlugin construct. + */ +export interface SpotFleetRequestConfiguration { + [groupName: string]: SpotFleetRequestProps; +} + +/** + * Represents a single Spot Fleet Request Configuration that will be mapped to a Deadline Group. + */ +export interface SpotFleetRequestProps { + /** + * Indicates how to allocate the target Spot Instance capacity + * across the Spot Instance pools specified by the Spot Fleet request. + */ + readonly AllocationStrategy: string; + + /** + * An ARN of the Spot Fleet IAM role. + */ + readonly IamFleetRole: string; + + /** + * The launch specifications for the Spot Fleet request. + */ + readonly LaunchSpecifications: LaunchSpecification[]; + + /** + * Indicates whether Spot Fleet should replace unhealthy instances. + */ + readonly ReplaceUnhealthyInstances: boolean; + + /** + * In order to work with Deadline, the 'Target Capacity' of the Spot fleet Request is + * the maximum number of Workers that Deadline will start. + */ + readonly TargetCapacity: number; + + /** + * Indicates whether running Spot Instances are terminated when the Spot Fleet request expires. + */ + readonly TerminateInstancesWithExpiration: boolean; + + /** + * The type of request. Indicates whether the Spot Fleet only requests the target capacity or also attempts to maintain it. + */ + readonly Type: string; + + /** + * The tags to apply to the Spot Fleet Request during creation. + */ + readonly TagSpecifications: SpotFleetTagSpecification[]; + + /** + * The end date and time of the request, in UTC format. + * After the end date and time, no new Spot Instance requests are placed or able to fulfill the request. + * + * @default - the Spot Fleet request remains until you cancel it. + */ + readonly ValidUntil?: string; +} + +/** + * Describes the launch specification for one or more Spot Instances. + */ +export interface LaunchSpecification +{ + /** + * One or more block devices that are mapped to the Spot Instances. + * + * @default - Property not used. + */ + readonly BlockDeviceMappings?: BlockDeviceMappingProperty[]; + + /** + * The IAM instance profile. + */ + readonly IamInstanceProfile: SpotFleetInstanceProfile; + + /** + * The ID of the AMI. + */ + readonly ImageId: string; + + /** + * One or more security groups. + */ + readonly SecurityGroups: SpotFleetSecurityGroupId[]; + + /** + * The IDs of the subnets in which to launch the instances. + * To specify multiple subnets, separate them using commas. + * + * @default - Property not used. + */ + readonly SubnetId?: string; + + /** + * The tags to apply to the instance during creation. + */ + readonly TagSpecifications: SpotFleetTagSpecification[]; + + /** + * The Base64-encoded user data that instances use when starting up. + */ + readonly UserData: string; + + /** + * The instance type. + */ + readonly InstanceType: string; + + /** + * The name of the key pair. + * + * @default - Property not used. + */ + readonly KeyName?: string; +} + +/** + * Describes a block device mapping. + * The following interface represents a CfnLaunchConfiguration.BlockDeviceMappingProperty interface. +*/ +export interface BlockDeviceMappingProperty { + /** + * The device name (for example, /dev/sdh or xvdh ). + */ + readonly DeviceName: string; + + /** + * Parameters used to automatically set up EBS volumes when the instance is launched. + * + * @default - Property not used. + */ + readonly Ebs?: BlockDeviceProperty; + + /** + * To omit the device from the block device mapping, specify an empty string. + * + * @default - Property not used. + */ + readonly NoDevice?: string; + + /** + * The virtual device name (ephemeral N). + * + * @default - Property not used. + */ + readonly VirtualName?: string; +} + +/** + * Parameters used to automatically set up EBS volumes when the instance is launched. + * The following interface represents a CfnLaunchConfiguration.BlockDeviceProperty intreface. +*/ +export interface BlockDeviceProperty { + /** + * Indicates whether the EBS volume is deleted on instance termination. + * + * @default - Property not used. + */ + readonly DeleteOnTermination?: boolean; + + /** + * Indicates whether the encryption state of an EBS volume is changed while being restored from a backing snapshot. + * + * @default - Property not used. + */ + readonly Encrypted?: boolean; + + /** + * The number of I/O operations per second (IOPS). + * + * @default - Property not used. + */ + readonly Iops?: number; + + /** + * The ID of the snapshot. + * + * @default - Property not used. + */ + readonly SnapshotId?: string; + + /** + * The size of the volume, in GiBs. + * + * @default - Property not used. + */ + readonly VolumeSize?: number; + + /** + * The volume type. + * + * @default - Property not used. + */ + readonly VolumeType?: string; +} + +/** + * The IAM instance profile. + */ +export interface SpotFleetInstanceProfile { + /** + * The Amazon Resource Name (ARN) of the instance profile. + */ + readonly Arn: string; +} + +/** + * Describes a security group. + */ +export interface SpotFleetSecurityGroupId { + readonly GroupId: string; +} + +/** + * The tags to apply to a resource when the resource is being created. + */ +export interface SpotFleetTagSpecification { + /** + * The type of resource to tag. + */ + readonly ResourceType: string; + + /** + * The tags to apply to the resource. + */ + readonly Tags: SpotFleetTag[]; +} + +/** + * Describes a tag. + */ +export interface SpotFleetTag { + /** + * The key of the tag. + */ + readonly Key: string; + + /** + * The value of the tag. + */ + readonly Value: string; +} diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/lib/configure-spot-event-plugin/index.ts b/packages/aws-rfdk/lib/lambdas/nodejs/lib/configure-spot-event-plugin/index.ts new file mode 100644 index 000000000..9bb97d038 --- /dev/null +++ b/packages/aws-rfdk/lib/lambdas/nodejs/lib/configure-spot-event-plugin/index.ts @@ -0,0 +1,6 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './spot-event-plugin-client'; diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/lib/configure-spot-event-plugin/spot-event-plugin-client.ts b/packages/aws-rfdk/lib/lambdas/nodejs/lib/configure-spot-event-plugin/spot-event-plugin-client.ts new file mode 100644 index 000000000..65e18f8d3 --- /dev/null +++ b/packages/aws-rfdk/lib/lambdas/nodejs/lib/configure-spot-event-plugin/spot-event-plugin-client.ts @@ -0,0 +1,133 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable no-console */ + +import { + DeadlineClient, + Response, +} from '../deadline-client'; + +/** + * A single entry of the server data received from describeServerData request. + */ +interface DescribedServerData { + readonly ID: string, + readonly ConcurrencyToken: string, +} + +/** + * A response from describeServerData request. + */ +interface DescribeServerDataResponse { + readonly ServerData: DescribedServerData[]; +} + +/** + * Provides a simple interface to send requests to the Render Queue API related to the Deadline Spot Event Plugin. + */ +export class SpotEventPluginClient { + private static readonly EVENT_PLUGIN_ID: string = 'event.plugin.spot'; + + private readonly deadlineClient: DeadlineClient; + + constructor(client: DeadlineClient) { + this.deadlineClient = client; + } + + public async saveServerData(config: string): Promise { + console.log('Saving server data configuration:'); + console.log(config); + + try { + // Get the concurrency token required to save server data + const concurrencyToken = await this.concurrencyToken(); + await this.deadlineClient.PostRequest('/rcs/v1/putServerData', { + ServerData: [ + { + ID: SpotEventPluginClient.EVENT_PLUGIN_ID, + ServerDataDictionary: { + Config: config, + }, + ConcurrencyToken: concurrencyToken, + }, + ], + }, + { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }); + return true; + } catch(e) { + console.error(`Failed to save server data. Reason: ${e}`); + return false; + } + } + + public async configureSpotEventPlugin(configs: Array<{ Key: string, Value: any }>): Promise { + console.log('Saving plugin configuration:'); + console.log(configs); + + try { + await this.deadlineClient.PostRequest('/db/plugins/event/config/save', { + ID: 'spot', + DebugLogging: false, + DlInit: configs, + Icon: null, + Limits: [], + Meta: [], + Name: 'Spot', + PluginEnabled: 1, + }, + { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }); + return true; + } catch(e) { + console.error(`Failed to save plugin configuration. Reason: ${e}`); + return false; + } + } + + private async describeServerData(): Promise { + return await this.deadlineClient.PostRequest('/rcs/v1/describeServerData', { + ServerDataIds: [ + SpotEventPluginClient.EVENT_PLUGIN_ID, + ], + }, + { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }); + } + + /** + * Requests a concurrencyToken required to save spot fleet request configuration. + * If data already exists under the ID, an existing ConcurrencyToken has to be used. + * First obtain the token and then save the data with the same ConcurrencyToken. + * If there is no data under the ID, then real token is not required, + * but the ConcurrencyToken property still has to be set. + * NOTE: + * saveServerData() will have a ConcurrencyToken in its response but we do not use it, + * instead we always call this function to get a latest token. + */ + private async concurrencyToken(): Promise { + const response = await this.describeServerData(); + + const describedData: DescribeServerDataResponse = response.data; + + if (!describedData.ServerData || !Array.isArray(describedData.ServerData)) { + throw new Error(`Failed to receive a ConcurrencyToken. Invalid response: ${describedData}.`); + } + + const found = describedData.ServerData.find(element => element.ID === SpotEventPluginClient.EVENT_PLUGIN_ID); + return found?.ConcurrencyToken ?? ''; + } + +} 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 new file mode 100644 index 000000000..99cf5f17f --- /dev/null +++ b/packages/aws-rfdk/lib/lambdas/nodejs/lib/configure-spot-event-plugin/test/spot-event-plugin-client.test.ts @@ -0,0 +1,241 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IncomingMessage } from 'http'; +import { Socket } from 'net'; +import { DeadlineClient, Response } from '../../deadline-client'; +import { SpotEventPluginClient } from '../spot-event-plugin-client'; + +describe('SpotEventPluginClient', () => { + let spotEventPluginClient: SpotEventPluginClient; + let describeDataResponse: Response; + let successfulResponse: Response; + let consoleLogMock: jest.SpyInstance; + let consoleErrorMock: jest.SpyInstance; + + beforeEach(() => { + consoleLogMock = jest.spyOn(console, 'log').mockReturnValue(undefined); + consoleErrorMock = jest.spyOn(console, 'error').mockReturnValue(undefined); + + describeDataResponse = { + data: { + ServerData: [{ + ID: 'event.plugin.spot', + ConcurrencyToken: 'token', + }], + }, + fullResponse: new IncomingMessage(new Socket()), + }; + successfulResponse = { + data: {}, + fullResponse: new IncomingMessage(new Socket()), + }; + + spotEventPluginClient = new SpotEventPluginClient(new DeadlineClient({ + host: 'test', + port: 0, + protocol: 'HTTP', + })); + // eslint-disable-next-line dot-notation + spotEventPluginClient['deadlineClient'].PostRequest = jest.fn(); + // eslint-disable-next-line dot-notation + spotEventPluginClient['deadlineClient'].GetRequest = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('successful saveServerData', async () => { + // GIVEN + const configuration = 'configuration'; + const mockSuccessfulPostRequest = jest.fn( (_a) => Promise.resolve(successfulResponse) ); + + // WHEN + // eslint-disable-next-line dot-notation + spotEventPluginClient['concurrencyToken'] = jest.fn().mockResolvedValue('token'); + // eslint-disable-next-line dot-notation + spotEventPluginClient['deadlineClient'].PostRequest = mockSuccessfulPostRequest; + const result = await spotEventPluginClient.saveServerData(configuration); + + // THEN + expect(result).toBeTruthy(); + // eslint-disable-next-line dot-notation + expect(spotEventPluginClient['deadlineClient'].PostRequest).toBeCalledTimes(1); + expect(consoleLogMock.mock.calls.length).toBe(2); + expect(consoleLogMock.mock.calls[0][0]).toMatch(/Saving server data configuration:/); + expect(consoleLogMock.mock.calls[1][0]).toMatch(configuration); + }); + + test('failed saveServerData on post request', async () => { + // GIVEN + const statusMessage = 'error message'; + + // WHEN + // eslint-disable-next-line dot-notation + spotEventPluginClient['concurrencyToken'] = jest.fn().mockResolvedValue('token'); + // eslint-disable-next-line dot-notation + spotEventPluginClient['deadlineClient'].PostRequest = jest.fn().mockRejectedValue(statusMessage); + const result = await spotEventPluginClient.saveServerData('configuration'); + + // THEN + expect(result).toBeFalsy(); + expect(consoleErrorMock.mock.calls.length).toBe(1); + expect(consoleErrorMock.mock.calls[0][0]).toMatch(`Failed to save server data. Reason: ${statusMessage}`); + }); + + test('failed saveServerData on concurrency token', async () => { + // GIVEN + const statusMessage = 'error message'; + + // WHEN + // eslint-disable-next-line dot-notation + spotEventPluginClient['concurrencyToken'] = jest.fn().mockRejectedValue(statusMessage); + const result = await spotEventPluginClient.saveServerData('configuration'); + + // THEN + expect(result).toBeFalsy(); + expect(consoleErrorMock.mock.calls.length).toBe(1); + expect(consoleErrorMock.mock.calls[0][0]).toMatch(`Failed to save server data. Reason: ${statusMessage}`); + }); + + test('successful configureSpotEventPlugin', async () => { + // GIVEN + const configs = [ + { + Key: 'testkey', + Value: 'testValue', + }, + { + Key: 'testkey2', + Value: 'testValue2', + }, + ]; + const mockConfigureSpotEventPlugin = jest.fn( (_a) => Promise.resolve(successfulResponse) ); + + // WHEN + // eslint-disable-next-line dot-notation + spotEventPluginClient['deadlineClient'].PostRequest = mockConfigureSpotEventPlugin; + const result = await spotEventPluginClient.configureSpotEventPlugin(configs); + + // THEN + expect(result).toBeTruthy(); + // eslint-disable-next-line dot-notation + expect(spotEventPluginClient['deadlineClient'].PostRequest).toBeCalledTimes(1); + expect(consoleLogMock.mock.calls.length).toBe(2); + expect(consoleLogMock.mock.calls[0][0]).toMatch(/Saving plugin configuration:/); + expect(consoleLogMock.mock.calls[1][0]).toEqual(configs); + }); + + test('failed configureSpotEventPlugin', async () => { + // GIVEN + const configs = [ + { + Key: 'testkey', + Value: 'testValue', + }, + { + Key: 'testkey2', + Value: 'testValue2', + }, + ]; + const statusMessage = 'error message'; + + // eslint-disable-next-line dot-notation + spotEventPluginClient['deadlineClient'].PostRequest = jest.fn().mockRejectedValue(statusMessage); + const result = await spotEventPluginClient.configureSpotEventPlugin(configs); + + // THEN + expect(result).toBeFalsy(); + expect(consoleErrorMock.mock.calls.length).toBe(1); + expect(consoleErrorMock.mock.calls[0][0]).toMatch(`Failed to save plugin configuration. Reason: ${statusMessage}`); + }); + + test('valid concurrency token', async () => { + // GIVEN + const concurrencyToken = 'TOKEN'; + const validResponse = { + data: { + ServerData: [{ + ID: 'event.plugin.spot', + ConcurrencyToken: concurrencyToken, + }], + }, + }; + + // WHEN + // eslint-disable-next-line dot-notation + spotEventPluginClient['describeServerData'] = jest.fn().mockResolvedValue(validResponse); + // eslint-disable-next-line dot-notation + const result = await spotEventPluginClient['concurrencyToken'](); + + // THEN + expect(result).toBe(concurrencyToken); + }); + + test('returns empty token if no event plugin id entry', async () => { + // GIVEN + const noSpotEventOluginResponse = { + data: { + ServerData: [{ + ID: 'NOT.event.plugin.spot', + ConcurrencyToken: 'token', + }], + }, + }; + + // WHEN + // eslint-disable-next-line dot-notation + spotEventPluginClient['describeServerData'] = jest.fn().mockResolvedValue(noSpotEventOluginResponse); + // eslint-disable-next-line dot-notation + const result = await spotEventPluginClient['concurrencyToken'](); + + // THEN + expect(result).toBe(''); + }); + + test('throws if invalid server data', async () => { + // GIVEN + const invalidDescribeDataResponse = { + data: { + NotServerData: {}, + }, + }; + + // WHEN + // eslint-disable-next-line dot-notation + spotEventPluginClient['describeServerData'] = jest.fn().mockResolvedValue(invalidDescribeDataResponse); + // eslint-disable-next-line dot-notation + const promise = spotEventPluginClient['concurrencyToken'](); + + // THEN + await expect(promise).rejects.toThrowError(`Failed to receive a ConcurrencyToken. Invalid response: ${invalidDescribeDataResponse.data}.`); + }); + + test('successful describeServerData', async () => { + // WHEN + // eslint-disable-next-line dot-notation + spotEventPluginClient['deadlineClient'].PostRequest = jest.fn().mockResolvedValue(describeDataResponse); + // eslint-disable-next-line dot-notation + const result = await spotEventPluginClient['describeServerData'](); + + // THEN + expect(result).toEqual(describeDataResponse); + }); + + test('failed describeServerData', async () => { + // GIVEN + const statusMessage = 'error message'; + + // WHEN + // eslint-disable-next-line dot-notation + spotEventPluginClient['deadlineClient'].PostRequest = jest.fn().mockRejectedValue(statusMessage); + // eslint-disable-next-line dot-notation + const promise = spotEventPluginClient['describeServerData'](); + + // THEN + await expect(promise).rejects.toEqual(statusMessage); + }); +}); diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/lib/custom-resource/simple-resource.ts b/packages/aws-rfdk/lib/lambdas/nodejs/lib/custom-resource/simple-resource.ts index 30d18ebf9..027fbdc25 100644 --- a/packages/aws-rfdk/lib/lambdas/nodejs/lib/custom-resource/simple-resource.ts +++ b/packages/aws-rfdk/lib/lambdas/nodejs/lib/custom-resource/simple-resource.ts @@ -97,7 +97,7 @@ export abstract class SimpleCustomResource { const oldResourceProperties: object = event.OldResourceProperties ?? {}; const oldPhysicalId: string = calculateSha256Hash(oldResourceProperties); if (oldPhysicalId !== physicalId) { - console.log('Doing Create -- ResoureceProperties differ.'); + console.log('Doing Create -- ResourceProperties differ.'); cfnData = await this.doCreate(physicalId, resourceProperties); console.debug(`Update data: ${JSON.stringify(cfnData)}`); } diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/lib/deadline-client/deadline-client.ts b/packages/aws-rfdk/lib/lambdas/nodejs/lib/deadline-client/deadline-client.ts new file mode 100644 index 000000000..7725b7185 --- /dev/null +++ b/packages/aws-rfdk/lib/lambdas/nodejs/lib/deadline-client/deadline-client.ts @@ -0,0 +1,187 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as http from 'http'; +import * as https from 'https'; + +/** + * Properties for setting up an {@link TLSProps}. + */ +export interface TLSProps { + /** + * The content of the CA certificate. + */ + readonly ca?: string; + + /** + * The content of the PFX certificate. + */ + readonly pfx?: string; + + /** + * The shared passphrase used for a single private key and/or a PFX. + */ + readonly passphrase?: string; +} + +/** + * Properties for setting up an {@link DeadlineClient}. + */ +export interface DeadlineClientProps { + /** + * The IP address or DNS name of the Render Queue. + */ + readonly host: string; + + /** + * The port number address of the Render Queue. + */ + readonly port: number; + + /** + * The protocol to use when connecting to the Render Queue. + * Supported values: HTTP, HTTPS + */ + readonly protocol: string; + + /** + * The certificate, private key, and root CA certificate if SSL/TLS is used. + */ + readonly tls?: TLSProps; +} + +/** + * Properties for setting up an {@link RequestOptions}. + */ +interface RequestOptions { + /** + * The IP address or DNS name of the Render Queue. + */ + readonly host: string; + + /** + * The port Render Queue is listening to. + */ + readonly port: number; + + /** + * The agent used for TLS connection. + */ + agent?: https.Agent; +} + +/** + * The response returned from the requests. + */ +export interface Response { + /** + * The data of the response to a request. + */ + readonly data: any; + /** + * The full response obtained from the POST and GET requests. + */ + readonly fullResponse: http.IncomingMessage; +} + +/** + * Implements a simple client that supports HTTP/HTTPS GET and POST requests. + * It is intended to be used within Custom Resources that need to send the requests to the Render Queue. + */ +export class DeadlineClient { + public readonly requestOptions: RequestOptions; + private protocol: typeof http | typeof https; + + public constructor(props: DeadlineClientProps) { + this.requestOptions = { + host: props.host, + port: props.port, + }; + + if (props.protocol === 'HTTPS') { + this.protocol = https; + + this.requestOptions.agent = new https.Agent({ + pfx: props.tls?.pfx, + passphrase: props.tls?.passphrase, + ca: props.tls?.ca, + }); + } + else { + this.protocol = http; + } + } + + /** + * Perform an HTTP GET request. + * + * @param path The resource to request for. + * @param requestOptions Other request options, including headers, timeout, etc. + */ + public async GetRequest(path: string, requestOptions?: https.RequestOptions): Promise { + const options = this.FillRequestOptions(path, 'GET', requestOptions); + return this.performRequest(options); + } + + /** + * Perform an HTTP POST request. + * + * @param path The resource to request for. + * @param data The data (body) of the request that contains the information to be sent. + * @param requestOptions Other request options, including headers, timeout, etc. + */ + public async PostRequest(path: string, data?: any, requestOptions?: https.RequestOptions): Promise { + const options = this.FillRequestOptions(path, 'POST', requestOptions); + return this.performRequest(options, data ? JSON.stringify(data) : undefined); + } + + private FillRequestOptions(path: string, method: string, requestOptions?: https.RequestOptions): https.RequestOptions { + const options: https.RequestOptions = { + ...requestOptions, + port: this.requestOptions.port, + host: this.requestOptions.host, + agent: this.requestOptions.agent, + path: path, + method: method, + }; + + return options; + } + + private async performRequest(options: https.RequestOptions, data?: string): Promise { + return new Promise((resolve, reject) => { + try { + const req = this.protocol.request(options, response => { + const { statusCode } = response; + if (!statusCode || statusCode >= 300) { + return reject(response.statusMessage); + } + else { + const chunks: any = []; + response.on('data', (chunk) => { + chunks.push(chunk); + }); + response.on('end', () => { + const stringData = Buffer.concat(chunks).toString(); + const result: Response = { + data: JSON.parse(stringData), + fullResponse: response, + }; + return resolve(result); + }); + } + }); + + req.on('error', reject); + if (data) { + req.write(data); + } + req.end(); + } catch (e) { + reject(e); + } + }); + } +} diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/lib/deadline-client/index.ts b/packages/aws-rfdk/lib/lambdas/nodejs/lib/deadline-client/index.ts new file mode 100644 index 000000000..b6d749b76 --- /dev/null +++ b/packages/aws-rfdk/lib/lambdas/nodejs/lib/deadline-client/index.ts @@ -0,0 +1,6 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './deadline-client'; diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/lib/deadline-client/test/deadline-client.test.ts b/packages/aws-rfdk/lib/lambdas/nodejs/lib/deadline-client/test/deadline-client.test.ts new file mode 100644 index 000000000..2bda04be8 --- /dev/null +++ b/packages/aws-rfdk/lib/lambdas/nodejs/lib/deadline-client/test/deadline-client.test.ts @@ -0,0 +1,404 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable dot-notation */ + +import { EventEmitter } from 'events'; +import { DeadlineClient } from '../deadline-client'; + +jest.mock('http'); +jest.mock('https'); + +describe('DeadlineClient', () => { + let deadlineClient: DeadlineClient; + + class MockResponse extends EventEmitter { + public statusCode: number; + public statusMessage: string = 'status message'; + + public constructor(statusCode: number) { + super(); + this.statusCode = statusCode; + } + } + + class MockRequest extends EventEmitter { + public end() {} + public write(_data: string) {} + } + let request: MockRequest; + let response: MockResponse; + + /** + * Mock implementation of the request + * + * @param _url The URL of the request + * @param callback The callback to call when a response is available + */ + function httpRequestMock(_url: string, callback: (_request: any) => void) { + if (callback) { + callback(response); + } + return request; + } + + describe('successful responses', () => { + beforeEach(() => { + request = new MockRequest(); + jest.requireMock('http').request.mockReset(); + jest.requireMock('https').request.mockReset(); + (jest.requireMock('https').Agent as jest.Mock).mockClear(); + + response = new MockResponse(200); + }); + + test('successful http get request', async () => { + // GIVEN + jest.requireMock('http').request.mockImplementation(httpRequestMock); + + deadlineClient = new DeadlineClient({ + host: 'hostname', + port: 8080, + protocol: 'HTTP', + }); + + const responseData = { + test: true, + }; + + // WHEN + const promise = deadlineClient.GetRequest('/get/version/test'); + response.emit('data', Buffer.from(JSON.stringify(responseData), 'utf8')); + response.emit('end'); + const result = await promise; + + // THEN + // should make an HTTP request + expect(jest.requireMock('http').request) + .toBeCalledWith( + { + agent: undefined, + method: 'GET', + port: 8080, + host: 'hostname', + path: '/get/version/test', + }, + expect.any(Function), + ); + + expect(result.data).toEqual(responseData); + }); + + test('successful http get request with options', async () => { + // GIVEN + jest.requireMock('http').request.mockImplementation(httpRequestMock); + + deadlineClient = new DeadlineClient({ + host: 'hostname', + port: 8080, + protocol: 'HTTP', + }); + + const responseData = { + test: true, + }; + + // WHEN + const promise = deadlineClient.GetRequest('/get/version/test', { + headers: { + 'Content-Type': 'application/json', + }, + }); + response.emit('data', Buffer.from(JSON.stringify(responseData), 'utf8')); + response.emit('end'); + const result = await promise; + + // THEN + // should make an HTTP request + expect(jest.requireMock('http').request) + .toBeCalledWith( + { + agent: undefined, + headers: { + 'Content-Type': 'application/json', + }, + method: 'GET', + port: 8080, + host: 'hostname', + path: '/get/version/test', + }, + expect.any(Function), + ); + + expect(result.data).toEqual(responseData); + }); + + test('successful https get request', async () => { + // GIVEN + jest.requireMock('https').request.mockImplementation(httpRequestMock); + + deadlineClient = new DeadlineClient({ + host: 'hostname', + port: 4433, + protocol: 'HTTPS', + }); + + const responseData = { + test: true, + }; + + // WHEN + const promise = deadlineClient.GetRequest('/get/version/test'); + response.emit('data', Buffer.from(JSON.stringify(responseData), 'utf8')); + response.emit('end'); + const result = await promise; + + // THEN + const agentMock = jest.requireMock('https').Agent as jest.Mock; + expect(agentMock).toHaveBeenCalledTimes(1); + expect(agentMock).toBeCalledWith(expect.not.objectContaining({ ca: expect.any(String) })); + expect(agentMock).toBeCalledWith(expect.not.objectContaining({ pfx: expect.any(String) })); + expect(agentMock).toBeCalledWith(expect.not.objectContaining({ passphrase: expect.any(String) })); + + // should make an HTTPS request + expect(jest.requireMock('https').request) + .toBeCalledWith( + { + agent: agentMock.mock.instances[0], + method: 'GET', + port: 4433, + host: 'hostname', + path: '/get/version/test', + }, + expect.any(Function), + ); + + expect(result.data).toEqual(responseData); + }); + + test('successful https get request with tls', async () => { + // GIVEN + jest.requireMock('https').request.mockImplementation(httpRequestMock); + + deadlineClient = new DeadlineClient({ + host: 'hostname', + port: 4433, + protocol: 'HTTPS', + tls: { + ca: 'cacontent', + pfx: 'pfxcontent', + passphrase: 'passphrasecontent', + }, + }); + + const responseData = { + test: true, + }; + + // WHEN + const promise = deadlineClient.GetRequest('/get/version/test'); + response.emit('data', Buffer.from(JSON.stringify(responseData), 'utf8')); + response.emit('end'); + const result = await promise; + + // THEN + const agentMock = jest.requireMock('https').Agent as jest.Mock; + expect(agentMock).toHaveBeenCalledTimes(1); + expect(agentMock).toBeCalledWith( + expect.objectContaining({ + ca: 'cacontent', + pfx: 'pfxcontent', + passphrase: 'passphrasecontent', + }), + ); + // should make an HTTPS request + expect(jest.requireMock('https').request) + .toBeCalledWith( + { + agent: agentMock.mock.instances[0], + method: 'GET', + port: 4433, + host: 'hostname', + path: '/get/version/test', + }, + expect.any(Function), + ); + + expect(result.data).toEqual(responseData); + }); + + test('successful http post request', async () => { + // GIVEN + jest.requireMock('http').request.mockImplementation(httpRequestMock); + + deadlineClient = new DeadlineClient({ + host: 'hostname', + port: 8080, + protocol: 'HTTP', + }); + + const responseData = { + test: true, + }; + + // WHEN + const promise = deadlineClient.PostRequest('/save/version/test', 'anydata'); + response.emit('data', Buffer.from(JSON.stringify(responseData), 'utf8')); + response.emit('end'); + const result = await promise; + + // THEN + // should make an HTTP request + expect(jest.requireMock('http').request) + .toBeCalledWith( + { + agent: undefined, + method: 'POST', + port: 8080, + host: 'hostname', + path: '/save/version/test', + }, + expect.any(Function), + ); + + expect(result.data).toEqual(responseData); + }); + + test('successful https post request', async () => { + // GIVEN + jest.requireMock('https').request.mockImplementation(httpRequestMock); + + deadlineClient = new DeadlineClient({ + host: 'hostname', + port: 4433, + protocol: 'HTTPS', + }); + + const responseData = { + test: true, + }; + + // WHEN + const promise = deadlineClient.PostRequest('/save/version/test', 'anydata'); + response.emit('data', Buffer.from(JSON.stringify(responseData), 'utf8')); + response.emit('end'); + const result = await promise; + + // THEN + const agentMock = jest.requireMock('https').Agent as jest.Mock; + expect(agentMock).toHaveBeenCalledTimes(1); + expect(agentMock).toBeCalledWith(expect.not.objectContaining({ ca: expect.any(String) })); + expect(agentMock).toBeCalledWith(expect.not.objectContaining({ pfx: expect.any(String) })); + expect(agentMock).toBeCalledWith(expect.not.objectContaining({ passphrase: expect.any(String) })); + + // should make an HTTP request + expect(jest.requireMock('https').request) + .toBeCalledWith( + { + agent: agentMock.mock.instances[0], + method: 'POST', + port: 4433, + host: 'hostname', + path: '/save/version/test', + }, + expect.any(Function), + ); + + expect(result.data).toEqual(responseData); + }); + + test('successful https post request with tls', async () => { + // GIVEN + jest.requireMock('https').request.mockImplementation(httpRequestMock); + + deadlineClient = new DeadlineClient({ + host: 'hostname', + port: 4433, + protocol: 'HTTPS', + tls: { + ca: 'cacontent', + pfx: 'pfxcontent', + passphrase: 'passphrasecontent', + }, + }); + + const responseData = { + test: true, + }; + + // WHEN + const promise = deadlineClient.PostRequest('/save/version/test', 'anydata'); + response.emit('data', Buffer.from(JSON.stringify(responseData), 'utf8')); + response.emit('end'); + const result = await promise; + + // THEN + const agentMock = jest.requireMock('https').Agent as jest.Mock; + expect(agentMock).toHaveBeenCalledTimes(1); + expect(agentMock).toBeCalledWith( + expect.objectContaining({ + ca: 'cacontent', + pfx: 'pfxcontent', + passphrase: 'passphrasecontent', + }), + ); + // should make an HTTPS request + expect(jest.requireMock('https').request) + .toBeCalledWith( + { + agent: agentMock.mock.instances[0], + method: 'POST', + port: 4433, + host: 'hostname', + path: '/save/version/test', + }, + expect.any(Function), + ); + + expect(result.data).toEqual(responseData); + }); + }); + + describe('failed responses', () => { + beforeEach(() => { + request = new MockRequest(); + jest.requireMock('http').request.mockImplementation(httpRequestMock); + jest.requireMock('https').request.mockImplementation(httpRequestMock); + + response = new MockResponse(400); + }); + + afterEach(() => { + jest.requireMock('http').request.mockReset(); + jest.requireMock('https').request.mockReset(); + }); + + test.each([ + ['HTTP', 'GET'], + ['HTTP', 'POST'], + ['HTTPS', 'GET'], + ['HTTPS', 'POST'], + ])('with %p %p', async (protocol: string, requestType: string) => { + // GIVEN + deadlineClient = new DeadlineClient({ + host: 'hostname', + port: 0, + protocol: protocol, + }); + + // WHEN + function performRequest() { + if (requestType === 'GET') { return deadlineClient.GetRequest('anypath'); } + return deadlineClient.PostRequest('anypath', 'anydata'); + } + const promise = performRequest(); + + // THEN + await expect(promise) + .rejects + .toEqual(response.statusMessage); + }); + }); +}); diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/lib/secrets-manager/index.ts b/packages/aws-rfdk/lib/lambdas/nodejs/lib/secrets-manager/index.ts index b3d5a54c7..1964cbfb2 100644 --- a/packages/aws-rfdk/lib/lambdas/nodejs/lib/secrets-manager/index.ts +++ b/packages/aws-rfdk/lib/lambdas/nodejs/lib/secrets-manager/index.ts @@ -3,5 +3,6 @@ * SPDX-License-Identifier: Apache-2.0 */ +export * from './read-certificate'; export * from './secret'; export * from './validation'; diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/lib/secrets-manager/read-certificate.ts b/packages/aws-rfdk/lib/lambdas/nodejs/lib/secrets-manager/read-certificate.ts new file mode 100644 index 000000000..7e26b5c3c --- /dev/null +++ b/packages/aws-rfdk/lib/lambdas/nodejs/lib/secrets-manager/read-certificate.ts @@ -0,0 +1,21 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import { SecretsManager } from 'aws-sdk'; +import { Secret } from './secret'; + +/** + * Retrieve certificate data from the Secret with the given ARN. + * @param arn ARN of the Secret containing the certificate + * @param client An instance of the SecretsManager class + */ +export async function readCertificateData(arn: string, client: SecretsManager): Promise { + const data = await Secret.fromArn(arn, client).getValue(); + if (Buffer.isBuffer(data) || !/BEGIN CERTIFICATE/.test(data as string)) { + throw new Error(`Certificate Secret (${arn}) must contain a Certificate in PEM format.`); + } + return data as string; +} diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/lib/secrets-manager/test/read-certificate.test.ts b/packages/aws-rfdk/lib/lambdas/nodejs/lib/secrets-manager/test/read-certificate.test.ts new file mode 100644 index 000000000..d31a5bc22 --- /dev/null +++ b/packages/aws-rfdk/lib/lambdas/nodejs/lib/secrets-manager/test/read-certificate.test.ts @@ -0,0 +1,76 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as AWS from 'aws-sdk'; +import { mock, restore, setSDKInstance } from 'aws-sdk-mock'; +import { readCertificateData } from '../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('readCertificateData', () => { + beforeEach(() => { + setSDKInstance(AWS); + }); + + afterEach(() => { + restore('SecretsManager'); + }); + + test('success', async () => { + // GIVEN + const certData = 'BEGIN CERTIFICATE'; + const secretContents = { + SecretString: certData, + }; + const mockGetSecret = jest.fn( (request) => successRequestMock(request, secretContents) ); + mock('SecretsManager', 'getSecretValue', mockGetSecret); + const client = new AWS.SecretsManager(); + + // WHEN + const data = await readCertificateData(secretArn, client); + + // THEN + expect(data).toStrictEqual(certData); + }); + + test('not a certificate', async () => { + // GIVEN + const certData = 'NOT A CERTIFICATE'; + const secretContents = { + SecretString: certData, + }; + const mockGetSecret = jest.fn( (request) => successRequestMock(request, secretContents) ); + mock('SecretsManager', 'getSecretValue', mockGetSecret); + const client = new AWS.SecretsManager(); + + // WHEN + const promise = readCertificateData(secretArn, client); + + // THEN + await expect(promise).rejects.toThrowError(/must contain a Certificate in PEM format/); + }); + + test('binary data', async () => { + // GIVEN + const certData = Buffer.from('BEGIN CERTIFICATE', 'utf-8'); + const secretContents = { + SecretBinary: certData, + }; + const mockGetSecret = jest.fn( (request) => successRequestMock(request, secretContents) ); + mock('SecretsManager', 'getSecretValue', mockGetSecret); + const client = new AWS.SecretsManager(); + + // WHEN + const promise = readCertificateData(secretArn, client); + + // THEN + await expect(promise).rejects.toThrowError(/must contain a Certificate in PEM format/); + }); +}); diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/mongodb/handler.ts b/packages/aws-rfdk/lib/lambdas/nodejs/mongodb/handler.ts index 184bf9696..129d44eea 100644 --- a/packages/aws-rfdk/lib/lambdas/nodejs/mongodb/handler.ts +++ b/packages/aws-rfdk/lib/lambdas/nodejs/mongodb/handler.ts @@ -16,6 +16,7 @@ import { writeAsciiFile, } from '../lib/filesystem'; import { + readCertificateData, Secret, } from '../lib/secrets-manager'; import { @@ -130,11 +131,7 @@ export class MongoDbConfigure extends SimpleCustomResource { * @param certificateArn */ protected async readCertificateData(certificateArn: string): Promise { - const data = await Secret.fromArn(certificateArn, this.secretsManagerClient).getValue(); - if (Buffer.isBuffer(data) || !/BEGIN CERTIFICATE/.test(data as string)) { - throw new Error(`CA Certificate Secret (${certificateArn}) must contain a Certificate in PEM format.`); - } - return data as string; + return await readCertificateData(certificateArn, this.secretsManagerClient); } /** diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/mongodb/test/handler.test.ts b/packages/aws-rfdk/lib/lambdas/nodejs/mongodb/test/handler.test.ts index 4e918c371..93bffb161 100644 --- a/packages/aws-rfdk/lib/lambdas/nodejs/mongodb/test/handler.test.ts +++ b/packages/aws-rfdk/lib/lambdas/nodejs/mongodb/test/handler.test.ts @@ -10,6 +10,8 @@ import { mock, restore, setSDKInstance } from 'aws-sdk-mock'; import { MongoDbConfigure } from '../handler'; +jest.mock('../../lib/secrets-manager/read-certificate'); + const secretArn: string = 'arn:aws:secretsmanager:us-west-1:1234567890:secret:SecretPath/Cert'; // @ts-ignore @@ -18,22 +20,10 @@ async function successRequestMock(request: { [key: string]: string}, returnValue } describe('readCertificateData', () => { - beforeEach(() => { - setSDKInstance(AWS); - }); - - afterEach(() => { - restore('SecretsManager'); - }); - test('success', async () => { // GIVEN const certData = 'BEGIN CERTIFICATE'; - const secretContents = { - SecretString: certData, - }; - const mockGetSecret = jest.fn( (request) => successRequestMock(request, secretContents) ); - mock('SecretsManager', 'getSecretValue', mockGetSecret); + jest.requireMock('../../lib/secrets-manager/read-certificate').readCertificateData.mockReturnValue(Promise.resolve(certData)); const handler = new MongoDbConfigure(new AWS.SecretsManager()); // WHEN @@ -44,29 +34,11 @@ describe('readCertificateData', () => { expect(data).toStrictEqual(certData); }); - test('not a certificate', async () => { + test('failure', async () => { // GIVEN - const certData = 'NOT A CERTIFICATE'; - const secretContents = { - SecretString: certData, - }; - const mockGetSecret = jest.fn( (request) => successRequestMock(request, secretContents) ); - mock('SecretsManager', 'getSecretValue', mockGetSecret); - const handler = new MongoDbConfigure(new AWS.SecretsManager()); - - // THEN - // tslint:disable-next-line: no-string-literal - await expect(handler['readCertificateData'](secretArn)).rejects.toThrowError(/must contain a Certificate in PEM format/); - }); - - test('binary data', async () => { - // GIVEN - const certData = Buffer.from('BEGIN CERTIFICATE', 'utf-8'); - const secretContents = { - SecretBinary: certData, - }; - const mockGetSecret = jest.fn( (request) => successRequestMock(request, secretContents) ); - mock('SecretsManager', 'getSecretValue', mockGetSecret); + jest.requireMock('../../lib/secrets-manager/read-certificate').readCertificateData.mockImplementation(() => { + throw new Error('must contain a Certificate in PEM format'); + }); const handler = new MongoDbConfigure(new AWS.SecretsManager()); // THEN diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/wait-for-stable-service/handler.ts b/packages/aws-rfdk/lib/lambdas/nodejs/wait-for-stable-service/handler.ts new file mode 100644 index 000000000..7ca68b801 --- /dev/null +++ b/packages/aws-rfdk/lib/lambdas/nodejs/wait-for-stable-service/handler.ts @@ -0,0 +1,97 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable no-console */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import { ECS } from 'aws-sdk'; +import { LambdaContext } from '../lib/aws-lambda'; +import { + CfnRequestEvent, + SimpleCustomResource, +} from '../lib/custom-resource'; + +export interface WaitForStableServiceResourceProps { + /** + * The short name or full Amazon Resource Name (ARN) of the cluster that hosts the service to describe. + */ + readonly cluster: string, + /** + * A list of services to describe. You may specify up to 10 services to describe in a single operation. + */ + readonly services: string[], + + /** + * A random string that forces the Lambda to run again and check if ECS is stable. + */ + readonly forceRun?: string; +}; + +/** + * A custom resource used to save Spot Event Plugin server data and configurations. + */ +export class WaitForStableServiceResource extends SimpleCustomResource { + protected readonly ecsClient: ECS; + + constructor(ecsClient: ECS) { + super(); + this.ecsClient = ecsClient; + } + + /** + * @inheritdoc + */ + public validateInput(data: object): boolean { + return this.implementsWaitForStableServiceResourceProps(data); + } + + /** + * @inheritdoc + */ + public async doCreate(_physicalId: string, resourceProperties: WaitForStableServiceResourceProps): Promise { + const options = { + services: resourceProperties.services, + cluster: resourceProperties.cluster, + }; + + try { + console.log(`Waiting for ECS services to stabilize. Cluster: ${resourceProperties.cluster}. Services: ${resourceProperties.services}`); + await this.ecsClient.waitFor('servicesStable', options).promise(); + console.log('Finished waiting. ECS services are stable.'); + } catch (e) { + throw new Error(`ECS services failed to stabilize in expected time: ${e.code} -- ${e.message}`); + } + + return undefined; + } + + /** + * @inheritdoc + */ + public async doDelete(_physicalId: string, _resourceProperties: WaitForStableServiceResourceProps): Promise { + // Nothing to do -- we don't modify anything. + return; + } + + private implementsWaitForStableServiceResourceProps(value: any): value is WaitForStableServiceResourceProps { + if (!value || typeof(value) !== 'object' || Array.isArray(value)) { return false; } + if (!value.cluster || typeof(value.cluster) !== 'string') { return false; } + if (!value.services || !Array.isArray(value.services)) { return false; } + for (let service of value.services) { + if (typeof(service) !== 'string') { return false; } + } + if (value.forceRun && typeof(value.forceRun) !== 'string') { return false; } + return true; + } +} + +/** + * The lambda handler that is used to log in to MongoDB and perform some configuration actions. + */ +/* istanbul ignore next */ +export async function wait(event: CfnRequestEvent, context: LambdaContext): Promise { + const handler = new WaitForStableServiceResource(new ECS()); + return await handler.handler(event, context); +} diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/wait-for-stable-service/index.ts b/packages/aws-rfdk/lib/lambdas/nodejs/wait-for-stable-service/index.ts new file mode 100644 index 000000000..761a8c9dd --- /dev/null +++ b/packages/aws-rfdk/lib/lambdas/nodejs/wait-for-stable-service/index.ts @@ -0,0 +1,6 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './handler'; diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/wait-for-stable-service/test/handler.test.ts b/packages/aws-rfdk/lib/lambdas/nodejs/wait-for-stable-service/test/handler.test.ts new file mode 100644 index 000000000..2d7a4e08f --- /dev/null +++ b/packages/aws-rfdk/lib/lambdas/nodejs/wait-for-stable-service/test/handler.test.ts @@ -0,0 +1,145 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable no-console */ + +import * as AWS from 'aws-sdk'; +import { mock, restore, setSDKInstance } from 'aws-sdk-mock'; +import { + WaitForStableServiceResource, + WaitForStableServiceResourceProps, +} from '../handler'; + +describe('WaitForStableServiceResource', () => { + describe('doCreate', () => { + let consoleLogMock: jest.SpyInstance; + + beforeEach(() => { + setSDKInstance(AWS); + AWS.config.region = 'us-east-1'; + consoleLogMock = jest.spyOn(console, 'log').mockReturnValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + restore('ECS'); + }); + + test('success', async () => { + // GIVEN + const props: WaitForStableServiceResourceProps = { + cluster: 'clusterArn', + services: ['serviceArn'], + }; + + mock('ECS', 'waitFor', (_state: 'servicesStable', _params: any, callback: Function) => { + callback(null, { status: 'ready' }); + }); + const handler = new WaitForStableServiceResource(new AWS.ECS()); + + // WHEN + const result = await handler.doCreate('physicalId', props); + + // THEN + expect(result).toBeUndefined(); + expect(consoleLogMock.mock.calls.length).toBe(2); + expect(consoleLogMock.mock.calls[0][0]).toStrictEqual(`Waiting for ECS services to stabilize. Cluster: ${props.cluster}. Services: ${props.services[0]}`); + expect(consoleLogMock.mock.calls[1][0]).toStrictEqual('Finished waiting. ECS services are stable.'); + }); + + test('failure', async () => { + // GIVEN + const props: WaitForStableServiceResourceProps = { + cluster: 'clusterArn', + services: ['serviceArn'], + }; + + mock('ECS', 'waitFor', (_state: 'servicesStable', _params: any, callback: Function) => { + callback({ code: 'errorcode', message: 'not stable' }, null); + }); + const handler = new WaitForStableServiceResource(new AWS.ECS()); + + // WHEN + const promise = handler.doCreate('physicalId', props); + + // THEN + await expect(promise).rejects.toThrowError(/ECS services failed to stabilize in expected time:/); + }); + }); + + test('doDelete does not do anything', async () => { + // GIVEN + const props: WaitForStableServiceResourceProps = { + cluster: 'clusterArn', + services: ['serviceArn'], + }; + const handler = new WaitForStableServiceResource(new AWS.ECS()); + + // WHEN + const promise = await handler.doDelete('physicalId', props); + + // THEN + await expect(promise).toBeUndefined(); + }); + + describe('.validateInput()', () => { + test('returns true with valid input', async () => { + // GIVEN + const validInput: WaitForStableServiceResourceProps = { + cluster: 'clusterArn', + services: ['serviceArn'], + forceRun: '', + }; + // WHEN + const handler = new WaitForStableServiceResource(new AWS.ECS()); + const returnValue = handler.validateInput(validInput); + + // THEN + expect(returnValue).toBeTruthy(); + }); + + const noCluster = { + services: [''], + }; + const clusterNotString = { + services: [''], + cluster: 10, + }; + const noServices = { + cluster: '', + }; + const servicesNotArray = { + cluster: '', + services: '', + }; + const servicesNotArrayOfStrings = { + cluster: '', + services: [10], + }; + const forceRunNotString = { + cluster: '', + services: [''], + forceRun: true, + }; + + test.each([ + [], + '', + noCluster, + clusterNotString, + noServices, + servicesNotArray, + servicesNotArrayOfStrings, + forceRunNotString, + ])('returns false with invalid input %p', async (invalidInput: any) => { + // WHEN + const handler = new WaitForStableServiceResource(new AWS.ECS()); + const returnValue = handler.validateInput(invalidInput); + + // THEN + expect(returnValue).toBeFalsy(); + }); + }); +});