diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 78e8460..24a12ee 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -21,7 +21,7 @@ assignees: '' - [ ] Region: [e.g. us-east-1] - [ ] Was the solution modified from the version published on this repository? - [ ] If the answer to the previous question was yes, are the changes available on GitHub? -- [ ] Have you checked your [service quotas](https://docs.aws.amazon.com/general/latest/gr/aws_service_limits.html) for the sevices this solution uses? +- [ ] Have you checked your [service quotas](https://docs.aws.amazon.com/general/latest/gr/aws_service_limits.html) for the services this solution uses? - [ ] Were there any errors in the CloudWatch Logs? **Screenshots** diff --git a/CHANGELOG.md b/CHANGELOG.md index c326a20..374949e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,45 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.0.2] - 2022-03-31 +## [3.0.0] - 2022-08-24 + +⚠ BREAKING CHANGES +Version 3.0.0 does not support upgrading from previous versions. ### Added +- Merge [#71](https://github.com/aws-solutions/distributed-load-testing-on-aws/pull/71) by [@pyranja](https://github.com/pyranja) +- Multi-region load test support + - Template for secondary regions + - Menu option for region management + - Region selection in test creation + - Results viewable by region +- Real time data on UI for running tests +- Upload bzt log, as well as jmeter log, out, and err logs to S3 +- Link to S3 test results in the test details +- Logging for failed tasks + +### Changed + +- History moved to separate table +- History view moved to modal rather than separate link +- Updated to CDK V2 + +### Fixed + +- Bug fix for long running containers by adding timeout to sockets +- Bug fix port issues by handing SIGTERM properly +- Bug fix for graceful failure when leader task fails +- Bug fix for tasks which launched but failed to provision + +### Removed + +- Sleep between runTask API calls + +## [2.0.2] - 2022-03-31 + +### Added + - Enabled encryption in transit for the logging S3 bucket. ## [2.0.1] - 2021-12-13 @@ -109,9 +144,9 @@ Version 2.0.0 does not support upgrading from previous versions. - JMeter input file support and plugins support - JMeter input files should be zipped with the JMeter script file. - Add `jetty-*.jar` files to the Amazon ECR to support JMeter HTTP/2 plugin: - - https://github.com/Blazemeter/jmeter-http2-plugin - - https://stackoverflow.com/questions/62714281/http-2-request-with-jmeter-fails-with-nullsession-jetty-alpn - - https://webtide.com/jetty-alpn-java-8u252/ + - + - + - - See the latest `jetty-*.jar` files in the [Maven repository](https://mvnrepository.com/): - [jetty-alpn-client](https://mvnrepository.com/artifact/org.eclipse.jetty/jetty-alpn-client) - [jetty-alpn-openjdk8-client](https://mvnrepository.com/artifact/org.eclipse.jetty/jetty-alpn-openjdk8-client) diff --git a/NOTICE.txt b/NOTICE.txt index c7e0400..aa41ea6 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -6,16 +6,26 @@ THIRD PARTY COMPONENTS ********************** This software includes third party software subject to the following copyrights: -@fortawesome/fontawesome-svg-core under the Massachusetts Institute of Technology (MIT) license -@fortawesome/free-brands-svg-icons under the Creative Commons Attribution 4.0 International license and the Massachusetts Institute of Technology (MIT) license -@fortawesome/free-solid-svg-icons under the Creative Commons Attribution 4.0 International license and the Massachusetts Institute of Technology (MIT) license -@fortawesome/react-fontawesome under the Massachusetts Institute of Technology (MIT) license -aws-sdk under the Apache License Version 2.0 +@aws-amplify/pubsub under the Apache License 2.0 +@aws-amplify/ui-components under the Apache License 2.0 +@aws-amplify/ui-react under the Apache License 2.0 +@aws-cdk/assert under the Apache License 2.0 +@aws-solutions-constructs/aws-cloudfront-s3 under the Apache License 2.0 +@types/jest under the Massachusetts Institute of Technology (MIT) License +@types/node under the Massachusetts Institute of Technology (MIT) License aws-amplify under the Apache License Version 2.0 +aws-cdk under the Apache License Version 2.0 +aws-cdk-lib under the Apache License 2.0 +aws-sdk under the Apache License Version 2.0 axios under the Massachusetts Institute of Technology (MIT) license axios-mock-adapter under the Massachusetts Institute of Technology (MIT) license bootstrap under the Massachusetts Institute of Technology (MIT) license +bootstrap-icons under the Massachusetts Institute of Technology (MIT) license brace under the Massachusetts Institute of Technology (MIT) license +chart.js under the Massachusetts Institute of Technology (MIT) license +chartjs-adapter-moment under the Massachusetts Institute of Technology (MIT) license +constructs under the Apache License 2.0 +false under the Apache License Version 2.0 jest under the Massachusetts Institute of Technology (MIT) license jetty-alpn-client under the Apache License Version 2.0 and the Eclipse Public License 1.0 jetty-alpn-openjdk8-client under the Apache License Version 2.0 and the Eclipse Public License 1.0 @@ -23,15 +33,18 @@ jetty-client under the Apache License Version 2.0 and the Eclipse Public License jetty-http under the Apache License Version 2.0 and the Eclipse Public License 1.0 jetty-io under the Apache License Version 2.0 and the Eclipse Public License 1.0 jetty-util under the Apache License Version 2.0 and the Eclipse Public License 1.0 +js-yaml under the Massachusetts Institute of Technology (MIT) license moment under the Massachusetts Institute of Technology (MIT) license react under the Massachusetts Institute of Technology (MIT) license react-ace under the Massachusetts Institute of Technology (MIT) license react-dom under the Massachusetts Institute of Technology (MIT) license react-router-dom under the Massachusetts Institute of Technology (MIT) license -react-router-hash-link under the Massachusetts Institute of Technology (MIT) license react-scripts under the Massachusetts Institute of Technology (MIT) license reactstrap under the Massachusetts Institute of Technology (MIT) license nanoid under the Massachusetts Institute of Technology (MIT) license taurus under the Apache License Version 2.0 +ts-jest under the Massachusetts Institute of Technology (MIT) License +ts-node under the Massachusetts Institute of Technology (MIT) License +typescript under the Apache License 2.0 uuid under the Massachusetts Institute of Technology (MIT) license xml-js under the Massachusetts Institute of Technology (MIT) license diff --git a/README.md b/README.md index a0318f9..8630bf0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Distributed Load Testing on AWS -The Distributed Load Testing Solution leverages managed, highly available and highly scalable AWS services to effortlessly create and simulate thousands of connected users generating a selected amount of transactions per second. As a result, developers can understand the behavior of their applications at scale and at load to identify any bottleneck problems before they deploy to Production. +The Distributed Load Testing Solution leverages managed, highly available and highly scalable AWS services to effortlessly create and simulate thousands of connected users generating a selected amount of transactions per second, originating from up to 5 simultaneous AWS regions. As a result, developers can understand the behavior of their applications at scale and at load to identify any bottleneck problems before they deploy to Production. ## On this Page @@ -15,7 +15,7 @@ The Distributed Load Testing Solution leverages managed, highly available and hi ## Deployment -The solution is deployed using a CloudFormation template with a lambda backed custom resource. For details on deploying the solution please see the details on the solution home page: [Distributed Load Testing](https://aws.amazon.com/solutions/implementations/distributed-load-testing-on-aws/) +The solution is deployed using a CloudFormation template with a lambda backed custom resource. To simulate users from regions other than the region the solution is initially deployed in, a regional template must be deployed within the other desired regions. For details on deploying the solution please see the details on the solution implementation guide: [Distributed Load Testing](https://docs.aws.amazon.com/solutions/latest/distributed-load-testing-on-aws/deployment.html) ## Source Code @@ -26,11 +26,22 @@ A NodeJS Lambda function for the API microservices. Integrated with Amazon API G ReactJS Single page application to provide a GUI to the solutions. Authenticated through Amazon Cognito this dashboard allows users to Create tests and view the final results. **source/custom-resource**
-A NodeJS Lambda function used as a CloudFormation custom resource for configuring Amazon S3 bucket notifications and to send anonymous metrics. +A NodeJS Lambda function used as a CloudFormation custom resource for sending anonymous metrics, configuration for regional testing infrastructure, and iot configuration. + +**source/infrastructure**
+A Typescript [AWS Cloud Development Kit (AWS CDK)](https://aws.amazon.com/cdk/) [v2](https://docs.aws.amazon.com/cdk/v2/guide/home.html) package that defines the infrastructure resources to run the Distributed Load Testing on AWS solution. + +It also uses the [AWS Solutions Constructs](https://aws.amazon.com/solutions/constructs/) [aws-cloudfront-s3](https://docs.aws.amazon.com/solutions/latest/constructs/aws-cloudfront-s3.html) package to define the CloudFront distribution and the S3 bucket that stores the content that makes up the UI. + +**source/real-time-data-publisher**
+A NodeJS Lambda function used to publish the real time load test data to an IoT topic. **source/results-parser**
A NodeJS Lambda function used to write the xml output from the docker images to Amazon DynamoDB and generate the final results for each test. +**source/solution-utils**
+A NodeJS package that contains commonly used functionality that is imported by other packages in this solution. + **source/task-canceler**
A NodeJS Lambda function used to stop tasks for a test that has been cancelled. @@ -48,11 +59,12 @@ To make changes to the solution, download or clone this repository, update the s ### Prerequisites -* Node.js 14.x or later +- Node.js 14.x or later +- S3 bucket that includes the AWS region as a suffix in the name. For example, `my-bucket-us-east-1`. The bucket and CloudFormation stack must be in the same region. The solution's CloudFormation template will expect the source code to be located in a bucket matching that name. ### Running unit tests for customization -* Clone the repository and make the desired code changes. +- Clone the repository and make the desired code changes. ```bash git clone https://github.com/aws-solutions/distributed-load-testing-on-aws.git @@ -60,7 +72,7 @@ cd distributed-load-testing-on-aws export BASE_DIRECTORY=$PWD ``` -* Run unit tests to make sure the updates pass the tests. +- Run unit tests to make sure the updates pass the tests. ```bash cd $BASE_DIRECTORY/deployment @@ -70,35 +82,34 @@ chmod +x ./run-unit-tests.sh ### Building distributable for customization -* Configure the environment variables. +- Configure the environment variables. ```bash -export DIST_BUCKET_PREFIX=my-bucket-name # bucket where customized code will reside +export REGION=aws-region-code # the AWS region to launch the solution (e.g. us-east-1) +export BUCKET_PREFIX=my-bucket-name # prefix of the bucket name without the region code +export BUCKET_NAME=$BUCKET_PREFIX-$REGION # full bucket name where the code will reside export SOLUTION_NAME=my-solution-name export VERSION=my-version # version number for the customized code -export REGION=aws-region-code # the AWS region to launch the solution (e.g. us-east-1) export PUBLIC_ECR_REGISTRY=public.ecr.aws/awssolutions/distributed-load-testing-on-aws-load-tester # replace with the container registry and image if you want to use a different container image -export PUBLIC_ECR_TAG=v2.0 # replace with the container image tag if you want to use a different container image +export PUBLIC_ECR_TAG=v3.0.0 # replace with the container image tag if you want to use a different container image ``` -> **Note:** When you define `DIST_BUCKET_PREFIX`, a randomized value is recommended. You will need to create an S3 bucket where the name is `-`. The solution's CloudFormation template will expect the source code to be located in a bucket matching that name. - -* Build the distributable. +- Build the distributable. ```bash cd $BASE_DIRECTORY/deployment chmod +x ./build-s3-dist.sh -./build-s3-dist.sh $DIST_BUCKET_PREFIX $SOLUTION_NAME $VERSION +./build-s3-dist.sh $BUCKET_PREFIX $SOLUTION_NAME $VERSION ``` -> **Note**: The _build-s3-dist_ script expects the bucket name as one of its parameters, and this value should not include the region suffix. In addition to that, the version parameter will be used to tag the npm packages, and therefore should be in the [Semantic Versioning format](https://semver.org/spec/v2.0.0.html). +> **Note**: The _build-s3-dist_ script expects the bucket name **without the region suffix** as one of its parameters. -* Deploy the distributable to the Amazon S3 bucket in your account. +- Deploy the distributable to the Amazon S3 bucket in your account. + - Make sure you are uploading the files in `deployment/global-s3-assets` and `deployment/regional-s3-assets` to `$BUCKET_NAME/$SOLUTION_NAME/$VERSION`. - * Make sure you are uploading the distributable to the `-` bucket. - * Get the link of the solution template uploaded to your Amazon S3 bucket. +- Get the link of the solution template uploaded to your Amazon S3 bucket. -* Deploy the solution to your account by launching a new AWS CloudFormation stack using the link of the solution template in Amazon S3. +- Deploy the solution to your account by launching a new AWS CloudFormation stack using the link of the solution template in Amazon S3. ## Creating a custom container build @@ -111,4 +122,4 @@ This solution collects anonymous operational metrics to help AWS improve the qua *** Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
-SPDX-License-Identifier: Apache-2.0 \ No newline at end of file +SPDX-License-Identifier: Apache-2.0 diff --git a/architecture.png b/architecture.png index 98178e8..cea90ff 100644 Binary files a/architecture.png and b/architecture.png differ diff --git a/deployment/build-s3-dist.sh b/deployment/build-s3-dist.sh old mode 100755 new mode 100644 index 9c6ad6b..398d5cb --- a/deployment/build-s3-dist.sh +++ b/deployment/build-s3-dist.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # # This assumes all of the OS-level configuration has been completed and git repo has already been cloned # @@ -27,109 +27,122 @@ set -e # Get reference for all important folders template_dir="$PWD" -template_dist_dir="$template_dir/global-s3-assets" -build_dist_dir="$template_dir/regional-s3-assets" -source_dir="$template_dir/../source" +template_dist_dir="${template_dir}/global-s3-assets" +build_dist_dir="${template_dir}/regional-s3-assets" +source_dir="${template_dir}/../source" echo "------------------------------------------------------------------------------" echo "Rebuild distribution" echo "------------------------------------------------------------------------------" -rm -rf $template_dist_dir -mkdir -p $template_dist_dir -rm -rf $build_dist_dir -mkdir -p $build_dist_dir - -[ -e $template_dist_dir ] && rm -r $template_dist_dir -[ -e $build_dist_dir ] && rm -r $build_dist_dir -mkdir -p $template_dist_dir $build_dist_dir - -echo "------------------------------------------------------------------------------" -echo "CloudFormation Template" -echo "------------------------------------------------------------------------------" - -cd $source_dir/infrastructure +rm -rf ${template_dist_dir} +mkdir -p ${template_dist_dir} +rm -rf ${build_dist_dir} +mkdir -p ${build_dist_dir} + +echo "--------------------------------------------------------------------------------------" +echo "CloudFormation Template generation - for main solution stack and regional deployments" +echo "--------------------------------------------------------------------------------------" +export CODE_BUCKET=$1 +export SOLUTION_NAME=$2 +export CODE_VERSION=$3 +export PUBLIC_ECR_REGISTRY=${PUBLIC_ECR_REGISTRY} +export PUBLIC_ECR_TAG=${PUBLIC_ECR_TAG} + +# Change these +main_cfn_template=${SOLUTION_NAME} +regional_cfn_template=${main_cfn_template}-regional + +declare -A templates=( + [${main_cfn_template}]=${template_dist_dir} + [${regional_cfn_template}]=${build_dist_dir} +) + +cd ${source_dir}/infrastructure +npm run clean npm install -node_modules/aws-cdk/bin/cdk synth --asset-metadata false --path-metadata false > $template_dist_dir/distributed-load-testing-on-aws.template -replace="s/CODE_BUCKET/$1/g" -echo "sed -i -e $replace" -sed -i -e $replace $template_dist_dir/distributed-load-testing-on-aws.template -replace="s/SOLUTION_NAME/$2/g" -echo "sed -i -e $replace" -sed -i -e $replace $template_dist_dir/distributed-load-testing-on-aws.template -replace="s/CODE_VERSION/$3/g" -echo "sed -i -e $replace" -sed -i -e $replace $template_dist_dir/distributed-load-testing-on-aws.template - -echo "***** public ECR registry: $PUBLIC_ECR_REGISTRY" -replace="s|PUBLIC_ECR_REGISTRY|$PUBLIC_ECR_REGISTRY|g" -echo "sed -i -e $replace" -sed -i -e $replace $template_dist_dir/distributed-load-testing-on-aws.template - -echo "***** public ECR tag: $PUBLIC_ECR_TAG" -replace="s/PUBLIC_ECR_TAG/$PUBLIC_ECR_TAG/g" -echo "sed -i -e $replace" -sed -i -e $replace $template_dist_dir/distributed-load-testing-on-aws.template -# remove tmp file for MACs -[ -e $template_dist_dir/distributed-load-testing-on-aws.template-e ] && rm -r $template_dist_dir/distributed-load-testing-on-aws.template-e - -echo "------------------------------------------------------------------------------" -echo "Creating custom-resource deployment package" -echo "------------------------------------------------------------------------------" -cd $source_dir/custom-resource/ -rm -rf node_modules/ -npm install --production -rm package-lock.json -zip -q -r9 ../../deployment/regional-s3-assets/custom-resource.zip * -echo "------------------------------------------------------------------------------" -echo "Creating api-services deployment package" -echo "------------------------------------------------------------------------------" -cd $source_dir/api-services -rm -rf node_modules/ -npm install --production -rm package-lock.json -zip -q -r9 $build_dist_dir/api-services.zip * +for template in "${!templates[@]}"; do + node_modules/aws-cdk/bin/cdk synth --asset-metadata false --path-metadata false -a "npx ts-node --prefer-ts-exts bin/${template}.ts" > ${templates[$template]}/${template}.template + if [ $? -eq 0 ] + then + echo "Build for ${template} succeeded" + else + echo "******************************************************************************" + echo "Build FAILED for ${template}" + echo "******************************************************************************" + exit 1 + fi +done -echo "------------------------------------------------------------------------------" -echo "Creating results-parser deployment package" -echo "------------------------------------------------------------------------------" -cd $source_dir/results-parser -rm -rf node_modules/ +# Setup solution utils package +cd ${source_dir}/solution-utils +rm -rf node_modules npm install --production -rm package-lock.json -zip -q -r9 $build_dist_dir/results-parser.zip * +rm -rf package-lock.json -echo "------------------------------------------------------------------------------" -echo "Creating task-canceler deployment package" -echo "------------------------------------------------------------------------------" -cd $source_dir/task-canceler -rm -rf node_modules/ -npm install --production -rm package-lock.json -zip -q -r9 $build_dist_dir/task-canceler.zip * +# Creating custom resource resources for both stacks +main_stack_custom_resource_files="index.js node_modules lib/*" +regional_stack_custom_resource_files="index.js node_modules lib/cfn lib/metrics lib/config-storage lib/iot" -echo "------------------------------------------------------------------------------" -echo "Creating task-runner deployment package" -echo "------------------------------------------------------------------------------" -cd $source_dir/task-runner -rm -rf node_modules/ -npm install --production -rm package-lock.json -zip -q -r9 $build_dist_dir/task-runner.zip * +declare -a stacks=( + "main" + "regional" +) -echo "------------------------------------------------------------------------------" -echo "Creating task-status-checker deployment package" -echo "------------------------------------------------------------------------------" -cd $source_dir/task-status-checker +cd ${source_dir}/custom-resource rm -rf node_modules/ npm install --production rm package-lock.json -zip -q -r9 $build_dist_dir/task-status-checker.zip * +for stack in "${stacks[@]}"; do + cp ${stack}-index.js index.js + files_to_zip=${stack}_stack_custom_resource_files + zip -q -r ${build_dist_dir}/${stack}-custom-resource.zip ${!files_to_zip} + rm index.js + if [ $? -eq 0 ] + then + echo "Build for ${stack}-custom-resource.zip succeeded" + else + echo "******************************************************************************" + echo "Build FAILED for ${stack}-custom-resource.zip" + echo "******************************************************************************" + exit 1 + fi +done + +# Create lambda packages +declare -a packages=( + "api-services" + "results-parser" + "task-canceler" + "task-runner" + "task-status-checker" + "real-time-data-publisher" +) + +for package in "${packages[@]}"; do + echo "------------------------------------------------------------------------------" + echo "Creating $package deployment package" + echo "------------------------------------------------------------------------------" + cd ${source_dir}/${package} + rm -rf node_modules/ + npm install --production + rm package-lock.json + zip -q -r9 ${build_dist_dir}/${package}.zip * + if [ $? -eq 0 ] + then + echo "Build for ${package} succeeded" + else + echo "******************************************************************************" + echo "Build FAILED for ${package}" + echo "******************************************************************************" + exit 1 + fi +done echo "------------------------------------------------------------------------------" echo "Creating container deployment package" echo "------------------------------------------------------------------------------" -cd $template_dir/ecr/distributed-load-testing-on-aws-load-tester +cd ${template_dir}/ecr/distributed-load-testing-on-aws-load-tester # Downloading jetty 9.4.34.v20201102 curl -O https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-alpn-client/9.4.34.v20201102/jetty-alpn-client-9.4.34.v20201102.jar curl -O https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-alpn-openjdk8-client/9.4.34.v20201102/jetty-alpn-openjdk8-client-9.4.34.v20201102.jar @@ -141,22 +154,22 @@ curl -O https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-util/9.4.34.v2020 echo "------------------------------------------------------------------------------" echo "Building console" echo "------------------------------------------------------------------------------" -cd $source_dir/console +cd ${source_dir}/console [ -e build ] && rm -r build [ -e node_modules ] && rm -rf node_modules npm install npm run build -mkdir $build_dist_dir/console -cp -r ./build/* $build_dist_dir/console/ +mkdir ${build_dist_dir}/console +cp -r ./build/* ${build_dist_dir}/console/ echo "------------------------------------------------------------------------------" echo "Generate console manifest file" echo "------------------------------------------------------------------------------" -cd $build_dist_dir/console +cd ${build_dist_dir}/console manifest=(`find * -type f ! -iname ".DS_Store"`) manifest_json=$(IFS=,;printf "%s" "${manifest[*]}") -echo "[\"$manifest_json\"]" | sed 's/,/","/g' > ./console-manifest.json +echo "[\"${manifest_json}\"]" | sed 's/,/","/g' > ./console-manifest.json echo "------------------------------------------------------------------------------" echo "Build S3 Packaging Complete" -echo "------------------------------------------------------------------------------" +echo "------------------------------------------------------------------------------" \ No newline at end of file diff --git a/deployment/ecr/distributed-load-testing-on-aws-load-tester/Dockerfile b/deployment/ecr/distributed-load-testing-on-aws-load-tester/Dockerfile index 12d8611..6b523e3 100644 --- a/deployment/ecr/distributed-load-testing-on-aws-load-tester/Dockerfile +++ b/deployment/ecr/distributed-load-testing-on-aws-load-tester/Dockerfile @@ -1,8 +1,8 @@ -FROM blazemeter/taurus +FROM blazemeter/taurus:1.16.11 # taurus includes python and pip RUN /usr/bin/python3 -m pip install --upgrade pip RUN pip install --no-cache-dir awscli -RUN apt-get -y install xmlstarlet bc +RUN apt-get -y install xmlstarlet bc procps # Taurus working directory = /bzt-configs ADD ./load-test.sh /bzt-configs/ ADD ./*.jar /bzt-configs/ @@ -10,4 +10,4 @@ ADD ./*.py /bzt-configs/ RUN chmod 755 /bzt-configs/load-test.sh RUN chmod 755 /bzt-configs/ecslistener.py RUN chmod 755 /bzt-configs/ecscontroller.py -ENTRYPOINT ["sh", "-c","./load-test.sh"] +ENTRYPOINT ["./load-test.sh"] diff --git a/deployment/ecr/distributed-load-testing-on-aws-load-tester/ecscontroller.py b/deployment/ecr/distributed-load-testing-on-aws-load-tester/ecscontroller.py index a269e1b..5013f99 100644 --- a/deployment/ecr/distributed-load-testing-on-aws-load-tester/ecscontroller.py +++ b/deployment/ecr/distributed-load-testing-on-aws-load-tester/ecscontroller.py @@ -3,7 +3,7 @@ #!/usr/bin/python from multiprocessing import Pool -from socket import socket, AF_INET, SOCK_STREAM +import socket from functools import partial import sys @@ -14,7 +14,8 @@ def request_socket(ip_host, ip_net): server_name = ip_net + "." + ip_host #Create socket and connect - client_socket = socket(AF_INET, SOCK_STREAM) + client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + client_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) client_socket.connect((server_name, int(server_port))) #message to send diff --git a/deployment/ecr/distributed-load-testing-on-aws-load-tester/ecslistener.py b/deployment/ecr/distributed-load-testing-on-aws-load-tester/ecslistener.py index d08f3d9..9815c79 100644 --- a/deployment/ecr/distributed-load-testing-on-aws-load-tester/ecslistener.py +++ b/deployment/ecr/distributed-load-testing-on-aws-load-tester/ecslistener.py @@ -2,7 +2,9 @@ # SPDX-License-Identifier: Apache-2.0 #!/usr/bin/python -from socket import socket, AF_INET, SOCK_STREAM +import socket +import sys +import signal def recv_message(connection_socket): """receive a message on input socket and return""" @@ -20,18 +22,36 @@ def messaging(server_socket): #Keep listening for message if not start (Limit 5 messages) while(message != "start" and i < 5): - print("message " + i + ": " + message) - #Get message from client - message = recv_message(connection_socket).decode() - i += 1 + print("message " + i + ": " + message) + #Get message from client + message = recv_message(connection_socket).decode() + i += 1 if __name__ == "__main__": + #get timeout + timeout = sys.argv[1] + #get port number port_number = 50000 #create socket - server_socket = socket(AF_INET, SOCK_STREAM) + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + #set reuseaddr option to avoid socket in use error + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + #set timeout + server_socket.settimeout(int(timeout)) + + #handle signal when container is terminated in order to properly close socket + def signal_handler(sig, frame): + print('container terminated, closing socket..') + server_socket.close() + print('socket closed') + sys.exit(143) #128 + 15 + + signal.signal(signal.SIGTERM, signal_handler) + #bind socket server_socket.bind(("", port_number)) @@ -39,9 +59,14 @@ def messaging(server_socket): #listen for incoming connection server_socket.listen(1) - - #connect and receive messaging - messaging(server_socket) + try: + #connect and receive messaging + messaging(server_socket) + except socket.timeout: + print('socket timed out, closing socket and starting test.') + server_socket.close() + exit(0) + #close socket server_socket.close() \ No newline at end of file diff --git a/deployment/ecr/distributed-load-testing-on-aws-load-tester/load-test.sh b/deployment/ecr/distributed-load-testing-on-aws-load-tester/load-test.sh index 62fbd11..f04c173 100644 --- a/deployment/ecr/distributed-load-testing-on-aws-load-tester/load-test.sh +++ b/deployment/ecr/distributed-load-testing-on-aws-load-tester/load-test.sh @@ -2,16 +2,27 @@ # set a uuid for the results xml file name in S3 UUID=$(cat /proc/sys/kernel/random/uuid) - +pypid=0 echo "S3_BUCKET:: ${S3_BUCKET}" echo "TEST_ID:: ${TEST_ID}" echo "TEST_TYPE:: ${TEST_TYPE}" echo "FILE_TYPE:: ${FILE_TYPE}" echo "PREFIX:: ${PREFIX}" -echo "UUID ${UUID}" +echo "UUID:: ${UUID}" +echo "LIVE_DATA_ENABLED:: ${LIVE_DATA_ENABLED}" + +sigterm_handler() { + if [ $pypid -ne 0 ]; then + echo "container received SIGTERM." + kill -15 $pypid + wait $pypid + exit 143 #128 + 15 + fi +} +trap 'sigterm_handler' SIGTERM echo "Download test scenario" -aws s3 cp s3://$S3_BUCKET/test-scenarios/$TEST_ID.json test.json +aws s3 cp s3://$S3_BUCKET/test-scenarios/$TEST_ID-$AWS_REGION.json test.json # download JMeter jmx file if [ "$TEST_TYPE" != "simple" ]; then @@ -53,15 +64,17 @@ if [ "$TEST_TYPE" != "simple" ]; then fi #Download python script - if [ -z "$IPNETWORK" ]; then - python3 $SCRIPT + python3 -u $SCRIPT $TIMEOUT & + pypid=$! + wait $pypid + pypid=0 else - python3 $SCRIPT $IPNETWORK $IPHOSTS + python3 -u $SCRIPT $IPNETWORK $IPHOSTS fi echo "Running test" -stdbuf -i0 -o0 -e0 bzt test.json -o modules.console.disable=true | stdbuf -i0 -o0 -e0 tee -a result.tmp | sed -u -e "s|^|$TEST_ID |" +stdbuf -i0 -o0 -e0 bzt test.json -o modules.console.disable=true | stdbuf -i0 -o0 -e0 tee -a result.tmp | sed -u -e "s|^|$TEST_ID $LIVE_DATA_ENABLED |" CALCULATED_DURATION=`cat result.tmp | grep -m1 "Test duration" | awk -F ' ' '{ print $5 }' | awk -F ':' '{ print ($1 * 3600) + ($2 * 60) + $3 }'` # upload custom results to S3 if any @@ -78,7 +91,7 @@ if [ "$TEST_TYPE" != "simple" ]; then echo "Files to upload as results" cat results.txt - + files=(`cat results.txt`) for f in "${files[@]}"; do p="s3://$S3_BUCKET/results/$TEST_ID/JMeter_Result/$PREFIX/$UUID/$f" @@ -100,8 +113,12 @@ if [ -f /tmp/artifacts/results.xml ]; then xmlstarlet ed -L -u /FinalStatus/TestDuration -v $CALCULATED_DURATION /tmp/artifacts/results.xml fi - echo "Uploading results" - aws s3 cp /tmp/artifacts/results.xml s3://$S3_BUCKET/results/${TEST_ID}/${PREFIX}-${UUID}.xml + echo "Uploading results, bzt log, and JMeter log, out, and err files" + aws s3 cp /tmp/artifacts/results.xml s3://$S3_BUCKET/results/${TEST_ID}/${PREFIX}-${UUID}-${AWS_REGION}.xml + aws s3 cp /tmp/artifacts/bzt.log s3://$S3_BUCKET/results/${TEST_ID}/bzt-${PREFIX}-${UUID}-${AWS_REGION}.log + aws s3 cp /tmp/artifacts/jmeter.log s3://$S3_BUCKET/results/${TEST_ID}/jmeter-${PREFIX}-${UUID}-${AWS_REGION}.log + aws s3 cp /tmp/artifacts/jmeter.out s3://$S3_BUCKET/results/${TEST_ID}/jmeter-${PREFIX}-${UUID}-${AWS_REGION}.out + aws s3 cp /tmp/artifacts/jmeter.err s3://$S3_BUCKET/results/${TEST_ID}/jmeter-${PREFIX}-${UUID}-${AWS_REGION}.err else - echo "There might be an error happened while the test." + echo "An error occurred while the test was running." fi \ No newline at end of file diff --git a/deployment/run-unit-tests.sh b/deployment/run-unit-tests.sh old mode 100755 new mode 100644 index 5b43e90..ea0898e --- a/deployment/run-unit-tests.sh +++ b/deployment/run-unit-tests.sh @@ -46,6 +46,7 @@ run_tests() { # prepare coverage reports prepare_jest_coverage_report $component_name + rm -rf coverage node_modules package-lock.json } # Run unit tests @@ -57,9 +58,11 @@ coverage_reports_top_path=$source_dir/test/coverage-reports # Test packages declare -a packages=( + "solution-utils" "api-services" "custom-resource" "infrastructure" + "real-time-data-publisher" "results-parser" "task-canceler" "task-runner" diff --git a/source/api-services/index.js b/source/api-services/index.js index c6f9ace..5ca97f0 100644 --- a/source/api-services/index.js +++ b/source/api-services/index.js @@ -5,88 +5,103 @@ const scenarios = require('./lib/scenarios/'); const metrics = require('./lib/metrics/'); exports.handler = async (event, context) => { - console.log(JSON.stringify(event, null, 2)); + console.log(JSON.stringify(event, null, 2)); - const resource = event.resource; - const method = event.httpMethod; - const config = JSON.parse(event.body); - const errMsg = `Method: ${method} not supported for this resource: ${resource} `; + const resource = event.resource; + const method = event.httpMethod; + const config = JSON.parse(event.body); + const errMsg = `Method: ${method} not supported for this resource: ${resource} `; - let testId; - let data; - let response = { - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept" - }, - statusCode: 200 - }; + let testId; + let data; + let response = { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept" + }, + statusCode: 200 + }; - try { - switch (resource) { - case '/scenarios': - switch (method) { - case 'GET': - data = await scenarios.listTests(); - break; - case 'POST': - if(config.scheduleStep) - { - const contextValues = { - functionName: context.functionName, - functionArn: context.invokedFunctionArn - } - data = await scenarios.scheduleTest(event, contextValues); - } - else { - data = await scenarios.createTest(config); - } - //sending anonymous metrics (task Count) to aws - if (process.env.SEND_METRIC === 'Yes') { - await metrics.send({ taskCount: config.taskCount, testType: config.testType, fileType: config.fileType }); - } - break; - default: - throw new Error(errMsg); - } - break; - - case '/scenarios/{testId}': - testId = event.pathParameters.testId; - switch (method) { - case 'GET': - data = await scenarios.getTest(testId); - break; - case 'POST': - data = await scenarios.cancelTest(testId); - break; - case 'DELETE': - data = await scenarios.deleteTest(testId, context.functionName); - break; - default: - throw new Error(errMsg); - } - break; - case '/tasks': - switch (method) { - case 'GET': - data = await scenarios.listTasks(); - break; - default: - throw new Error(errMsg); - } - break; - default: - throw new Error(errMsg); + try { + switch (resource) { + case '/regions': { + if (method === 'GET') { + data = { regions: await scenarios.getAllRegionConfigs() }; + data.url = await scenarios.getCFUrl(); + } else { + throw new Error(errMsg); + } + } + break; + case '/scenarios': + switch (method) { + case 'GET': + if (event.queryStringParameters && event.queryStringParameters.op === 'listRegions') { + data = { regions: await scenarios.getAllRegionConfigs() }; + data.url = await scenarios.getCFUrl(); + } else { + data = await scenarios.listTests(); + } + break; + case 'POST': + if (config.scheduleStep) { + const contextValues = { + functionName: context.functionName, + functionArn: context.invokedFunctionArn + }; + data = await scenarios.scheduleTest({ + resource: resource, + httpMethod: method, + body: event.body + }, + contextValues); + } + else { + data = await scenarios.createTest(config); + } + //sending anonymous metrics (task Count) to aws + if (process.env.SEND_METRIC === 'Yes') { + await metrics.send({ testTaskConfigs: config.testTaskConfigs, testType: config.testType, fileType: config.fileType }); + } + break; + default: + throw new Error(errMsg); } + break; - response.body = JSON.stringify(data); - } catch (err) { - console.error(err); - response.body = err.toString(); - response.statusCode = 400; + case '/scenarios/{testId}': + testId = event.pathParameters.testId; + switch (method) { + case 'GET': + data = await scenarios.getTest(testId); + break; + case 'POST': + data = await scenarios.cancelTest(testId); + break; + case 'DELETE': + data = await scenarios.deleteTest(testId, context.functionName); + break; + default: + throw new Error(errMsg); + } + break; + case '/tasks': + if (method === 'GET') { + data = await scenarios.listTasks(); + } else { + throw new Error(errMsg); + } + break; + default: + throw new Error(errMsg); } + response.body = JSON.stringify(data); + } catch (err) { + console.error(err); + response.body = err.toString(); + response.statusCode = 400; + } - console.log(response); - return response; + console.log(response); + return response; }; diff --git a/source/api-services/jest.config.js b/source/api-services/jest.config.js new file mode 100644 index 0000000..7d3eaec --- /dev/null +++ b/source/api-services/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + roots: ['/lib'], + testMatch: ['**/*.spec.js'], + collectCoverageFrom: [ + '**/*.js' + ], + coverageReporters: [ + "text", + "clover", + "json", + ["lcov", { "projectRoot": "../../" }] + ] +}; \ No newline at end of file diff --git a/source/api-services/lib/metrics/index.js b/source/api-services/lib/metrics/index.js index ace0cb8..b80ce31 100644 --- a/source/api-services/lib/metrics/index.js +++ b/source/api-services/lib/metrics/index.js @@ -5,45 +5,45 @@ const axios = require('axios'); const moment = require('moment'); /** - * Sends anonymouse metrics. + * Sends anonymous metrics. * @param {{ taskCount: number, testType: string, fileType: string|undefined }} - the number of containers used for the test, the test type, and the file type */ const send = async (obj) => { - let data; + let data; - try { - const metrics = { - Solution: process.env.SOLUTION_ID, - UUID: process.env.UUID, - TimeStamp: moment().utc().format('YYYY-MM-DD HH:mm:ss.S'), - Version: process.env.VERSION, - Data: { - Type: 'TaskCreate', - TestType: obj.testType, - FileType: obj.fileType || (obj.testType === 'simple' ? 'none' : 'script'), - TaskCount: obj.taskCount - } - }; - const params = { - method: 'post', - port: 443, - url: process.env.METRIC_URL, - headers: { - 'Content-Type': 'application/json' - }, - data: metrics - }; - //Send Metrics & retun status code. - data = await axios(params); - } catch (err) { - //Not returning an error to avoid Metrics affecting the Application - console.log(err); - } - return data.status; + try { + const metrics = { + Solution: process.env.SOLUTION_ID, + UUID: process.env.UUID, + TimeStamp: moment().utc().format('YYYY-MM-DD HH:mm:ss.S'), + Version: process.env.VERSION, + Data: { + Type: 'TaskCreate', + TestType: obj.testType, + FileType: obj.fileType || (obj.testType === 'simple' ? 'none' : 'script'), + TaskCount: obj.taskCount + } + }; + const params = { + method: 'post', + port: 443, + url: process.env.METRIC_URL, + headers: { + 'Content-Type': 'application/json' + }, + data: metrics + }; + //Send Metrics & return status code. + data = await axios(params); + } catch (err) { + //Not returning an error to avoid Metrics affecting the Application + console.log(err); + } + return data.status; }; module.exports = { - send: send + send: send }; diff --git a/source/api-services/lib/metrics/index.spec.js b/source/api-services/lib/metrics/index.spec.js index a223d0c..adaad7f 100644 --- a/source/api-services/lib/metrics/index.spec.js +++ b/source/api-services/lib/metrics/index.spec.js @@ -10,41 +10,41 @@ const _taskCount = 30; describe('#SEND METRICS', () => { - it('should return "200" on a send metrics sucess for simple test', async () => { + it('should return "200" on a send metrics success for simple test', async () => { - let mock = new MockAdapter(axios); - mock.onPost().reply(200, {}); + let mock = new MockAdapter(axios); + mock.onPost().reply(200, {}); - let response = await lambda.send({ taskCount: _taskCount, testType: 'simple' }); - expect(response).toEqual(200); - }); + let response = await lambda.send({ taskCount: _taskCount, testType: 'simple' }); + expect(response).toEqual(200); + }); - it('should return "200" on a send metrics sucess for zip JMeter test', async () => { + it('should return "200" on a send metrics success for zip JMeter test', async () => { - let mock = new MockAdapter(axios); - mock.onPost().reply(200, {}); + let mock = new MockAdapter(axios); + mock.onPost().reply(200, {}); - let response = await lambda.send({ taskCount: _taskCount, testType: 'jmter', fileType: 'zip' }); - expect(response).toEqual(200); - }); + let response = await lambda.send({ taskCount: _taskCount, testType: 'jmter', fileType: 'zip' }); + expect(response).toEqual(200); + }); - it('should return "200" on a send metrics sucess for script JMeter test', async () => { + it('should return "200" on a send metrics success for script JMeter test', async () => { - let mock = new MockAdapter(axios); - mock.onPost().reply(200, {}); + let mock = new MockAdapter(axios); + mock.onPost().reply(200, {}); - let response = await lambda.send({ taskCount: _taskCount, testType: 'jemter' }); - expect(response).toEqual(200); - }); + let response = await lambda.send({ taskCount: _taskCount, testType: 'jemter' }); + expect(response).toEqual(200); + }); - it('should return "Network Error" on connection timedout', async () => { + it('should return "Network Error" on connection timedout', async () => { - let mock = new MockAdapter(axios); - mock.onPut().networkError(); + let mock = new MockAdapter(axios); + mock.onPut().networkError(); - await lambda.send({ taskCount: _taskCount, testType: 'simple' }).catch(err => { - expect(err.toString()).toEqual("TypeError: Cannot read property 'status' of undefined"); - }); - }); + await lambda.send({ taskCount: _taskCount, testType: 'simple' }).catch(err => { + expect(err.toString()).toEqual("TypeError: Cannot read property 'status' of undefined"); + }); + }); }); diff --git a/source/api-services/lib/scenarios/index.js b/source/api-services/lib/scenarios/index.js index 3b8f2b5..7a5eebf 100644 --- a/source/api-services/lib/scenarios/index.js +++ b/source/api-services/lib/scenarios/index.js @@ -3,260 +3,721 @@ const AWS = require('aws-sdk'); const moment = require('moment'); -const { customAlphabet } = require('nanoid'); -const { SOLUTION_ID, VERSION } = process.env; -let options = {}; -if (SOLUTION_ID && VERSION && SOLUTION_ID.trim() && VERSION.trim()) { - options.customUserAgent = `AwsSolution/${SOLUTION_ID}/${VERSION}`; -} +const utils = require('solution-utils'); +const { HISTORY_TABLE, SCENARIOS_TABLE, SCENARIOS_BUCKET, STATE_MACHINE_ARN, TASK_CANCELER_ARN, STACK_ID } = process.env; AWS.config.logger = console; - +let options = {}; +options = utils.getOptions(options); +options.region = process.env.AWS_REGION; const s3 = new AWS.S3(options); const lambda = new AWS.Lambda(options); -options.region = process.env.AWS_REGION; const dynamoDB = new AWS.DynamoDB.DocumentClient(options); const stepFunctions = new AWS.StepFunctions(options); -const ecs = new AWS.ECS(options); -const cloudwatch = new AWS.CloudWatch(options); -const cloudwatchLogs = new AWS.CloudWatchLogs(options); const cloudwatchevents = new AWS.CloudWatchEvents(options); +const cloudformation = new AWS.CloudFormation(options); - -const ALPHA_NUMERIC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; /** - * Generates an unique ID based on the parameter length. - * @param length The length of the unique ID - * @returns The unique ID + * Class to throw errors + * @param {string} code + * @param {string} errMsg */ -function generateUniqueId(length) { - const nanoid = customAlphabet(ALPHA_NUMERIC, length); - return nanoid(); +class ErrorException { + constructor(code, errMsg) { + this.code = code; + this.message = errMsg; + this.status = 400; + } } /** - * @function listTests - * Description: returns a consolidated list of test scenarios + * Get URL for the regional CloudFormation template from the main CloudFormation stack exports + * @returns {string} The S3 URL for the modified regional CloudFormation template */ -const listTests = async () => { - console.log('List tests'); - - try { - const response = { 'Items': [] }; - const params = { - TableName: process.env.SCENARIOS_TABLE, - AttributesToGet: [ - 'testId', - 'testName', - 'testDescription', - 'status', - 'startTime', - 'nextRun', - 'scheduleRecurrence' - ], - }; - do { - const testScenarios = await dynamoDB.scan(params).promise(); - response.Items.push(...testScenarios.Items); - params.ExclusiveStartKey = testScenarios.LastEvaluatedKey; - } while (params.ExclusiveStartKey); - return response; - - } catch (err) { - throw err; +const getCFUrl = async () => { + let exports = []; + let params = {}; + try { + do { + const listExports = await cloudformation.listExports(params).promise(); + exports.push(...listExports.Exports); + params.NextToken = listExports.NextToken; + } while (params.NextToken); + const result = exports.find(entry => entry.ExportingStackId === STACK_ID && entry.Name === 'RegionalCFTemplate'); + return result.Value; + } catch (err) { + console.error(err); + throw err; + } +}; + +/** + * Returns test resource information for all configured regions + * @returns {object} Test infrastructure configuration for every configured region + */ +const getAllRegionConfigs = async () => { + let response = []; + const params = { + TableName: SCENARIOS_TABLE, + Select: "ALL_ATTRIBUTES", + ScanFilter: { + 'testId': { + ComparisonOperator: 'BEGINS_WITH', + AttributeValueList: ["region"] + }, + 'taskCluster': { + ComparisonOperator: 'NE', + AttributeValueList: [""] + } } + }; + try { + do { + const regionConfigs = await dynamoDB.scan(params).promise(); + response.push(...regionConfigs.Items); + params.ExclusiveStartKey = regionConfigs.LastEvaluatedKey; + } while (params.ExclusiveStartKey); + return response; + } catch (err) { + console.error(err); + throw err; + } +}; + +/** + * Returns the dynamoDB entry for a given testId + * @param {string} testId + * @returns {object} Test configuration stored in DynamoDB + */ +const getTestEntry = async (testId) => { + try { + let data; + let params = { + TableName: SCENARIOS_TABLE, + Key: { + testId: testId + } + }; + const response = await dynamoDB.get(params).promise(); + data = response.Item; + return data; + } catch (err) { + console.error(err); + throw err; + } +}; + +/** + * Return test resource information for a given region + * @param {string} testRegion + * @returns {object} Test infrastructure configuration for specified region + */ +const getRegionInfraConfigs = async (testRegion) => { + try { + let regionalParameters = { + TableName: SCENARIOS_TABLE, + Key: { + testId: `region-${testRegion}` + } + }; + const ddbEntry = await dynamoDB.get(regionalParameters).promise(); + if (!('Item' in ddbEntry)) { + const errorMessage = 'The region requested does not have a stored infrastructure configuration.'; + console.error(errorMessage); + throw new ErrorException('InvalidRegionRequest', errorMessage); + } + return ddbEntry.Item; + } catch (err) { + console.error(err); + throw err; + } }; /** - * @function scheduleTest - * Description: Schedules test and returns a consolidated list of test scenarios - * @event {object} test sevent information - * @context {object} the lambda context information + * Retrieves all information needed to run a test, including + * regional testing infrastructure configuration, + * based on testId + * @param {string} testId + * @returns {object} Combined test configuration and test infrastructure configuration for regions to be tested */ -const scheduleTest = async (event, context) => { - try { - let config = JSON.parse(event.body); - const { testId, scheduleDate, scheduleTime } = config; - const [hour, minute] = scheduleTime.split(':'); - let [year, month, day] = scheduleDate.split('-'); - let nextRun = `${year}-${month}-${day} ${hour}:${minute}:00`; - const functionName = context.functionName; - const functionArn = context.functionArn; - let scheduleRecurrence = ""; - - //check if rule exists, delete rule if exists - let rulesResponse = await cloudwatchevents.listRules({ NamePrefix: testId }).promise(); - - for (let rule of rulesResponse.Rules) { - let ruleName = rule.Name; - await cloudwatchevents.removeTargets({ Rule: ruleName, Ids: [ruleName] }).promise(); - await lambda.removePermission({ FunctionName: functionName, StatementId: ruleName }).promise(); - await cloudwatchevents.deleteRule({ Name: ruleName }).promise(); - } +const getTestAndRegionConfigs = async (testId) => { + try { + const testEntry = await getTestEntry(testId); + if (testEntry.testTaskConfigs) { + for (let testRegionSettings of testEntry.testTaskConfigs) { + const regionInfraConfig = await getRegionInfraConfigs(testRegionSettings.region); + Object.assign(testRegionSettings, regionInfraConfig); + } + } + return testEntry; + } catch (err) { + console.error(err); + throw err; + } +}; +/** + * getTestHistoryEntries + * @param {string} testId + * @returns {object} List of all history objects for testId + */ +const getTestHistoryEntries = async (testId) => { + try { + let response = []; + const params = { + TableName: HISTORY_TABLE, + Select: "ALL_ATTRIBUTES", + KeyConditionExpression: '#t = :t', + ExpressionAttributeNames: { + '#t': 'testId' + }, + ExpressionAttributeValues: { + ':t': testId + } + }; + do { + const historyEntries = await dynamoDB.query(params).promise(); + response.push(...historyEntries.Items); + params.ExclusiveStartKey = historyEntries.LastEvaluatedKey; + } while (params.ExclusiveStartKey); + return response; + } catch (err) { + console.error(err); + throw err; + } +}; - if (config.scheduleStep === 'create') { - //Schedule for 1 min prior to account for time it takes to create rule - const createRun = moment([year, parseInt(month, 10) - 1, day, hour, minute]).subtract(1, 'minute').format('YYYY-MM-DD HH:mm:ss'); - let [createDate, createTime] = createRun.split(" "); - const [createHour, createMin] = createTime.split(':'); - const [createYear, createMonth, createDay] = createDate.split('-'); - const cronStart = `cron(${createMin} ${createHour} ${createDay} ${createMonth} ? ${createYear})`; - scheduleRecurrence = config.recurrence; - - //Create rule to create schedule - const createRuleParams = { - Name: `${testId}Create`, - Description: `Create test schedule for: ${testId}`, - ScheduleExpression: cronStart, - State: 'ENABLED' - }; - let ruleArn = await cloudwatchevents.putRule(createRuleParams).promise(); - - //Add permissions to lambda - let permissionParams = { - Action: "lambda:InvokeFunction", - FunctionName: functionName, - Principal: "events.amazonaws.com", - SourceArn: ruleArn.RuleArn, - StatementId: `${testId}Create` - } - await lambda.addPermission(permissionParams).promise(); - - //modify schedule step in input params - config.scheduleStep = "start"; - event.body = JSON.stringify(config); - - //add target - let createTargetParams = { - Rule: `${testId}Create`, - Targets: [{ - Arn: functionArn, - Id: `${testId}Create`, - Input: JSON.stringify(event), - }] - }; - await cloudwatchevents.putTargets(createTargetParams).promise(); - } else { - //create schedule expression - let scheduleString; - if (config.recurrence) { - scheduleRecurrence = config.recurrence; - switch (config.recurrence) { - case "daily": - scheduleString = "rate(1 day)"; - break; - case "weekly": - scheduleString = "rate(7 days)"; - break; - case "biweekly": - scheduleString = "rate(14 days)"; - break; - case "monthly": - scheduleString = `cron(${minute} ${hour} ${day} * ? *)`; - break; - default: throw { - message: `Invalid recurrence value.`, - code: 'InvalidParameter', - status: 400 - }; - } - } else { - scheduleString = `cron(${minute} ${hour} ${day} ${month} ? ${year})`; - } - - //Create rule to run on schedule - const ruleParams = { - Name: `${testId}Scheduled`, - Description: `Scheduled tests for ${testId}`, - ScheduleExpression: scheduleString, - State: 'ENABLED' - }; - let ruleArn = await cloudwatchevents.putRule(ruleParams).promise(); - - //Add permissions to lambda - let permissionParams = { - Action: "lambda:InvokeFunction", - FunctionName: functionName, - Principal: "events.amazonaws.com", - SourceArn: ruleArn.RuleArn, - StatementId: `${testId}Scheduled` - }; - await lambda.addPermission(permissionParams).promise(); - - //remove schedule step in params - delete config.scheduleStep; - event.body = JSON.stringify(config); - - //add target to rule - let targetParams = { - Rule: `${testId}Scheduled`, - Targets: [{ - Arn: functionArn, - Id: `${testId}Scheduled`, - Input: JSON.stringify(event), - }] - }; - await cloudwatchevents.putTargets(targetParams).promise(); - - //Remove rule created during create schedule step - if (config.recurrence) { - let ruleName = `${testId}Create`; - await cloudwatchevents.removeTargets({ Rule: ruleName, Ids: [ruleName] }).promise(); - await lambda.removePermission({ FunctionName: functionName, StatementId: ruleName }).promise(); - await cloudwatchevents.deleteRule({ Name: ruleName }).promise(); - } +/** + * Creates a list of all test scenarios + * @returns {object} All created tests + */ +const listTests = async () => { + console.log('List tests'); + + try { + const response = { 'Items': [] }; + const params = { + TableName: SCENARIOS_TABLE, + AttributesToGet: [ + 'testId', + 'testName', + 'testDescription', + 'status', + 'startTime', + 'nextRun', + 'scheduleRecurrence' + ], + ScanFilter: { + 'testId': { + ComparisonOperator: 'NOT_CONTAINS', + AttributeValueList: ["region"] } + } + }; + do { + const testScenarios = await dynamoDB.scan(params).promise(); + response.Items.push(...testScenarios.Items); + params.ExclusiveStartKey = testScenarios.LastEvaluatedKey; + } while (params.ExclusiveStartKey); + return response; + + } catch (err) { + console.error(err); + throw err; + } +}; - //Update DynamoDB if table was not already updated by "create" schedule step - if (config.scheduleStep || !config.recurrence) { - let params = { - TableName: process.env.SCENARIOS_TABLE, - Key: { - testId: testId - }, - UpdateExpression: 'set #n = :n, #d = :d, #c = :c, #t = :t, #s = :s, #r = :r, #st = :st, #et = :et, #nr = :nr, #sr = :sr, #tt = :tt, #ft = :ft', - ExpressionAttributeNames: { - '#n': 'testName', - '#d': 'testDescription', - '#c': 'taskCount', - '#t': 'testScenario', - '#s': 'status', - '#r': 'results', - '#st': 'startTime', - '#et': 'endTime', - '#nr': 'nextRun', - '#sr': 'scheduleRecurrence', - '#tt': 'testType', - '#ft': 'fileType' - }, - ExpressionAttributeValues: { - ':n': config.testName, - ':d': config.testDescription, - ':c': config.taskCount, - ':t': JSON.stringify(config.testScenario), - ':s': 'scheduled', - ':r': {}, - ':st': "", - ':et': '', - ':nr': nextRun, - ':sr': scheduleRecurrence, - ':tt': config.testType, - ':ft': config.fileType - }, - ReturnValues: 'ALL_NEW' - }; - let data = await dynamoDB.update(params).promise(); - - console.log(`Schedule test complete: ${JSON.stringify(data, null, 2)}`); - - return data.Attributes; - } - else { - console.log(`Succesfully created schedule rule for test: ${testId}`); +/** + * Schedules test and returns a consolidated list of test scenarios + * @param {object} event test event information + * @param {object} context the lambda context information + * @returns A map of attribute values in Dynamodb after scheduled. + */ +const scheduleTest = async (event, context) => { + try { + let config = JSON.parse(event.body); + const { testId, scheduleDate, scheduleTime, showLive } = config; + const [hour, minute] = scheduleTime.split(':'); + let [year, month, day] = scheduleDate.split('-'); + let nextRun = `${year}-${month}-${day} ${hour}:${minute}:00`; + const functionName = context.functionName; + const functionArn = context.functionArn; + let scheduleRecurrence = ""; + + //check if rule exists, delete rule if exists + let rulesResponse = await cloudwatchevents.listRules({ NamePrefix: testId }).promise(); + + for (let rule of rulesResponse.Rules) { + let ruleName = rule.Name; + await cloudwatchevents.removeTargets({ Rule: ruleName, Ids: [ruleName] }).promise(); + await lambda.removePermission({ FunctionName: functionName, StatementId: ruleName }).promise(); + await cloudwatchevents.deleteRule({ Name: ruleName }).promise(); + } + + if (config.scheduleStep === 'create') { + //Schedule for 1 min prior to account for time it takes to create rule + const createRun = moment([year, parseInt(month, 10) - 1, day, hour, minute]).subtract(1, 'minute').format('YYYY-MM-DD HH:mm:ss'); + let [createDate, createTime] = createRun.split(" "); + const [createHour, createMin] = createTime.split(':'); + const [createYear, createMonth, createDay] = createDate.split('-'); + const cronStart = `cron(${createMin} ${createHour} ${createDay} ${createMonth} ? ${createYear})`; + scheduleRecurrence = config.recurrence; + + //Create rule to create schedule + const createRuleParams = { + Name: `${testId}Create`, + Description: `Create test schedule for: ${testId}`, + ScheduleExpression: cronStart, + State: 'ENABLED' + }; + let ruleArn = await cloudwatchevents.putRule(createRuleParams).promise(); + + //Add permissions to lambda + let permissionParams = { + Action: "lambda:InvokeFunction", + FunctionName: functionName, + Principal: "events.amazonaws.com", + SourceArn: ruleArn.RuleArn, + StatementId: `${testId}Create` + }; + await lambda.addPermission(permissionParams).promise(); + + //modify schedule step in input params + config.scheduleStep = "start"; + event.body = JSON.stringify(config); + + //add target + let createTargetParams = { + Rule: `${testId}Create`, + Targets: [{ + Arn: functionArn, + Id: `${testId}Create`, + Input: JSON.stringify(event), + }] + }; + await cloudwatchevents.putTargets(createTargetParams).promise(); + } else { + //create schedule expression + let scheduleString; + if (config.recurrence) { + scheduleRecurrence = config.recurrence; + switch (config.recurrence) { + case "daily": + scheduleString = "rate(1 day)"; + break; + case "weekly": + scheduleString = "rate(7 days)"; + break; + case "biweekly": + scheduleString = "rate(14 days)"; + break; + case "monthly": + scheduleString = `cron(${minute} ${hour} ${day} * ? *)`; + break; + default: throw new ErrorException("InvalidParameter", "Invalid recurrence value."); } - } catch (err) { - throw err; + } else { + scheduleString = `cron(${minute} ${hour} ${day} ${month} ? ${year})`; + } + + //Create rule to run on schedule + const ruleParams = { + Name: `${testId}Scheduled`, + Description: `Scheduled tests for ${testId}`, + ScheduleExpression: scheduleString, + State: 'ENABLED' + }; + let ruleArn = await cloudwatchevents.putRule(ruleParams).promise(); + + //Add permissions to lambda + let permissionParams = { + Action: "lambda:InvokeFunction", + FunctionName: functionName, + Principal: "events.amazonaws.com", + SourceArn: ruleArn.RuleArn, + StatementId: `${testId}Scheduled` + }; + await lambda.addPermission(permissionParams).promise(); + + //remove schedule step in params + delete config.scheduleStep; + event.body = JSON.stringify(config); + + //add target to rule + let targetParams = { + Rule: `${testId}Scheduled`, + Targets: [{ + Arn: functionArn, + Id: `${testId}Scheduled`, + Input: JSON.stringify(event), + }] + }; + await cloudwatchevents.putTargets(targetParams).promise(); + + //Remove rule created during create schedule step + if (config.recurrence) { + let ruleName = `${testId}Create`; + await cloudwatchevents.removeTargets({ Rule: ruleName, Ids: [ruleName] }).promise(); + await lambda.removePermission({ FunctionName: functionName, StatementId: ruleName }).promise(); + await cloudwatchevents.deleteRule({ Name: ruleName }).promise(); + } + } + + //Update DynamoDB if table was not already updated by "create" schedule step + if (config.scheduleStep || !config.recurrence) { + let params = { + TableName: SCENARIOS_TABLE, + Key: { + testId: testId + }, + UpdateExpression: 'set #n = :n, #d = :d, #tc = :tc, #t = :t, #s = :s, #r = :r, #st = :st, #et = :et, #nr = :nr, #sr = :sr, #sl = :sl, #tt = :tt, #ft = :ft', + ExpressionAttributeNames: { + '#n': 'testName', + '#d': 'testDescription', + '#tc': 'testTaskConfigs', + '#t': 'testScenario', + '#s': 'status', + '#r': 'results', + '#st': 'startTime', + '#et': 'endTime', + '#nr': 'nextRun', + '#sr': 'scheduleRecurrence', + '#sl': 'showLive', + '#tt': 'testType', + '#ft': 'fileType' + }, + ExpressionAttributeValues: { + ':n': config.testName, + ':d': config.testDescription, + ':tc': config.testTaskConfigs, + ':t': JSON.stringify(config.testScenario), + ':s': 'scheduled', + ':r': {}, + ':st': "", + ':et': '', + ':nr': nextRun, + ':sr': scheduleRecurrence, + ':sl': showLive, + ':tt': config.testType, + ':ft': config.fileType + }, + ReturnValues: 'ALL_NEW' + }; + let data = await dynamoDB.update(params).promise(); + + console.log(`Schedule test complete: ${JSON.stringify(data, null, 2)}`); + + return data.Attributes; + } + else { + console.log(`Succesfully created schedule rule for test: ${testId}`); } + } catch (err) { + console.error(err); + throw err; + } +}; + +/** + * Sets the file type of the test, if the test is simple HTTP endpoint test, fileType is `none`, + * if there is no fileType, then fileType is `script` + * @param {string} testType + * @param {string} fileType + * @returns {string} fileType + */ +const setFileType = (testType, fileType) => { + // When no fileType, fileType is script. + if (testType === 'simple') { + fileType = 'none'; + } else if (!fileType) { + fileType = 'script'; + } + return fileType; +}; + +/** + * Generates the testId if one does not exist + * @param {string} testId + * @returns generated unique ID + */ +const setTestId = (testId) => { + // When accessing API directly and no testId + return testId || utils.generateUniqueId(10); +}; + +/** + * Sets the next schedule test run + * @param {string} scheduleRecurrence + * @returns next run, scheduleRecurrence + */ +const setNextRun = (scheduleRecurrence = "") => { + let nextRun = ""; + if (scheduleRecurrence) { + switch (scheduleRecurrence) { + case "daily": + nextRun = moment().utc().add(1, 'd').format('YYYY-MM-DD HH:mm:ss'); + break; + case "weekly": + nextRun = moment().utc().add(7, 'd').format('YYYY-MM-DD HH:mm:ss'); + break; + case "biweekly": + nextRun = moment().utc().add(14, 'd').format('YYYY-MM-DD HH:mm:ss'); + break; + case "monthly": + nextRun = moment().utc().add(1, 'M').format('YYYY-MM-DD HH:mm:ss'); + break; + default: throw new ErrorException('InvalidParameter', "Invalid recurrence value."); + } + } + return { nextRun, scheduleRecurrence }; +}; + +/** + * Validates the setting for task count and task concurrency + * @param {object} testTaskConfigs + * @returns testTaskConfigs + */ +const validateTaskCountConcurrency = (testTaskConfigs) => { + // For each regional config, parse the task count and concurrency + for (const regionalTestConfig of testTaskConfigs) { + if (typeof regionalTestConfig.taskCount === 'string') { + regionalTestConfig.taskCount = regionalTestConfig.taskCount.trim(); + } + const taskCount = parseInt(regionalTestConfig.taskCount); + if (isNaN(taskCount) + || parseInt(taskCount) < 1 + || parseInt(taskCount) > 1000) { + throw new ErrorException('InvalidParameter', 'Task count should be positive number between 1 to 1000.'); + } + regionalTestConfig.taskCount = taskCount; + + if (typeof regionalTestConfig.concurrency === 'string') { + regionalTestConfig.concurrency = regionalTestConfig.concurrency.trim(); + } + const concurrency = parseInt(regionalTestConfig.concurrency); + if (isNaN(concurrency) + || parseInt(regionalTestConfig.concurrency) < 1) { + throw new ErrorException('InvalidParameter', 'Concurrency should be positive number'); + } + regionalTestConfig.concurrency = parseInt(concurrency); + } + return testTaskConfigs; +}; + +/** + * Validation that there is a value for a given key + * @param {object} patterns + * @param {string} key + * @throws InvalidParameter if the value of the key is invalid + */ +const validateParameter = (patterns, key) => { + if (patterns.length === 0 || patterns.length % 2 !== 0) { + throw new ErrorException('InvalidParameter', `Invalid ${key} value.`); + } +}; + +/** + * Validation for the execution value + * @param {string} result + * @param {string} key + * @param {number} value + * @param {number} min + * @throws InvalidParameter if the value is not a positive number less than the minimum + * @returns value + */ +const validateNumber = (result, key, value, min) => { + // Number + if (isNaN(value) || parseInt(value) < min) { + throw new ErrorException('InvalidParameter', `${key} should be positive number equal to or greater than ${min}.`); + } + return `${result}${parseInt(value)}`; +}; + +/** + * validateUnit + * For execution values like ramp-up and hold-for, validates the time units + * @param {string} result test result + * @param {string} key test result key + * @param {string} value test result value with time units + * + */ +const validateUnit = (result, key, value) => { + const timeUnits = ['ms', 's', 'm', 'h', 'd']; + // Unit + if (!timeUnits.includes(value)) { + throw new ErrorException('InvalidParameter', `${key} unit should be one of these: ms, s, m, h, d.`); + } + return `${result}${value}`; +}; + +/** + * + * @param {object} testScenario + * @param {string} key + * @returns + */ + +const formatStringKey = (testScenario, key) => { + if (typeof testScenario.execution[0][key] === 'string') { + testScenario.execution[0][key] = testScenario.execution[0][key].replace(/\s/g, ''); + } + return testScenario; +}; + +/** + * Validates if time unit are valid. + * @param {object} testScenario + * @param {string} key Key to validate (ramp-up, hold-for) + * @param {number} min Minimum number for the value + */ +const validateTimeUnit = (testScenario, key, min) => { + const timeRegex = /[a-z]+|[^a-z]+/gi; + testScenario = formatStringKey(testScenario, key); + + if (isNaN(testScenario.execution[0][key])) { + let patterns = testScenario.execution[0][key].match(timeRegex); + validateParameter(patterns, key); + + let result = ''; + for (let i = 0, length = patterns.length; i < length; i++) { + let value = patterns[i]; + if (i % 2 === 0) { + result = validateNumber(result, key, value, min); + } else { + result = validateUnit(result, key, value); + } + } + testScenario.execution[0][key] = result; + } else { + testScenario.execution[0][key] = parseInt(testScenario.execution[0][key]); + if (testScenario.execution[0][key] < min) { + throw new ErrorException('InvalidParameter', `${key} should be positive number equal to or greater than ${min}.`); + } + } + return testScenario; +}; + +/** + * + * @param {object} testTaskConfigs + * @param {object} testScenario + * @param {string} testId + */ +const writeTestScenarioToS3 = async (testTaskConfigs, testScenario, testId) => { + // 1. Write test scenario to S3 for each region + try { + const s3Promises = testTaskConfigs.map(testTaskConfig => { + const testScenarioS3 = testScenario; + testScenarioS3.execution[0].taskCount = testTaskConfig.taskCount; + testScenarioS3.execution[0].concurrency = testTaskConfig.concurrency; + const params = { + Body: JSON.stringify(testScenarioS3), + Bucket: SCENARIOS_BUCKET, + Key: `test-scenarios/${testId}-${testTaskConfig.region}.json` + }; + return s3.putObject(params).promise(); + }); + await Promise.all(s3Promises); + } catch (err) { + console.error(err); + throw err; + } +}; + +/** + * + * @param {object} testTaskConfigs + * @returns the scheduled test config for tasks and regional + * ecs infrastructure configuration in one object + */ +const mergeTestAndInfraConfiguration = async (testTaskConfigs) => { + const regionalTestAndInfraConfiguration = []; + for (const regionalTestConfig of testTaskConfigs) { + const regionalInfraConfiguration = await getRegionInfraConfigs(regionalTestConfig.region); + regionalTestAndInfraConfiguration.push({ + ...regionalTestConfig, + ...regionalInfraConfiguration + }); + } + return regionalTestAndInfraConfiguration; +}; + +/** + * startStepFunctionExecution + * Kicks off the step function state machine for the test run + * @param {object} stepFunctionParams + */ +const startStepFunctionExecution = async (stepFunctionParams) => { + try { + const { regionalTestAndInfraConfiguration, testId, testType, fileType, showLive } = stepFunctionParams; + const prefix = new Date().toISOString().replace('Z', '').split('').reverse().join(''); + await stepFunctions.startExecution({ + stateMachineArn: STATE_MACHINE_ARN, + input: JSON.stringify({ + testTaskConfig: regionalTestAndInfraConfiguration, + testId, + testType, + fileType, + showLive, + prefix + }) + }).promise(); + } catch (err) { + console.error(err); + throw err; + } +}; + +/** + * + * @param {object} updateTestConfigs + * @returns + */ +const updateTestDBEntry = async (updateTestConfigs) => { + try { + const { testId, testName, testDescription, testTaskConfigs, testScenario, startTime, nextRun, scheduleRecurrence, showLive, testType, fileType } = updateTestConfigs; + const params = { + TableName: SCENARIOS_TABLE, + Key: { + testId: testId + }, + UpdateExpression: 'set #n = :n, #d = :d, #tc = :tc, #t = :t, #s = :s, #r = :r, #st = :st, #et = :et, #nr = :nr, #sr = :sr, #sl = :sl, #tt = :tt, #ft = :ft', + ExpressionAttributeNames: { + '#n': 'testName', + '#d': 'testDescription', + '#tc': 'testTaskConfigs', + '#t': 'testScenario', + '#s': 'status', + '#r': 'results', + '#st': 'startTime', + '#et': 'endTime', + '#nr': 'nextRun', + '#sr': 'scheduleRecurrence', + '#sl': 'showLive', + '#tt': 'testType', + '#ft': 'fileType' + }, + ExpressionAttributeValues: { + ':n': testName, + ':d': testDescription, + ':tc': testTaskConfigs, + ':t': JSON.stringify(testScenario), + ':s': 'running', + ':r': {}, + ':st': startTime, + ':et': 'running', + ':nr': nextRun, + ':sr': scheduleRecurrence, + ':sl': showLive, + ':tt': testType, + ':ft': fileType + }, + ReturnValues: 'ALL_NEW' + }; + return dynamoDB.update(params).promise(); + } catch (err) { + console.error(err); + throw err; + } }; /** @@ -265,230 +726,142 @@ const scheduleTest = async (event, context) => { * @config {object} test scenario configuration */ const createTest = async (config) => { - console.log(`Create test: ${JSON.stringify(config, null, 2)}`); + console.log(`Create test: ${JSON.stringify(config, null, 2)}`); - try { - let params; - let data; + try { - const { testName, testDescription, testType } = config; - let { testId, testScenario, taskCount, fileType } = config; + const { testName, testDescription, testType, showLive } = config; + let { testId, testScenario, testTaskConfigs, fileType } = config; - // When no fileType, fileType is script. - if (testType === 'simple') { - fileType = 'none'; - } else if (!fileType) { - fileType = 'script'; - } + fileType = setFileType(testType, fileType); + testId = setTestId(testId); - // When acessing API directly and no testId - if (testId === undefined) { - testId = generateUniqueId(10); - } + const startTime = moment().utc().format('YYYY-MM-DD HH:mm:ss'); + let { nextRun, scheduleRecurrence } = setNextRun(config.recurrence); - const startTime = moment().utc().format('YYYY-MM-DD HH:mm:ss'); - const numRegex = /^\d+$/; - const timeRegex = /[a-z]+|[^a-z]+/gi; - const timeUnits = ['ms', 's', 'm', 'h', 'd']; - let nextRun = ""; - let scheduleRecurrence = ""; - if (config.recurrence) { - scheduleRecurrence = config.recurrence; - switch (config.recurrence) { - case "daily": - nextRun = moment().utc().add(1, 'd').format('YYYY-MM-DD HH:mm:ss'); - break; - case "weekly": - nextRun = moment().utc().add(7, 'd').format('YYYY-MM-DD HH:mm:ss'); - break; - case "biweekly": - nextRun = moment().utc().add(14, 'd').format('YYYY-MM-DD HH:mm:ss'); - break; - case "monthly": - nextRun = moment().utc().add(1, 'M').format('YYYY-MM-DD HH:mm:ss'); - break; - default: throw { - message: `Invalid recurrence value.`, - code: 'InvalidParameter', - status: 400 - }; - } - } + testTaskConfigs = validateTaskCountConcurrency(testTaskConfigs); - /** - * Validates if time unit are valid. - * @param {string} key Key to validate (ramp-up, hold-for) - * @param {number} min Minimum number for the value - */ - const validateTimeUnit = (key, min) => { - if (typeof testScenario.execution[0][key] === 'string') { - testScenario.execution[0][key] = testScenario.execution[0][key].replace(/\s/g, ''); - } - if (!numRegex.test(testScenario.execution[0][key])) { - let patterns = testScenario.execution[0][key].match(timeRegex); - if (patterns.length === 0 || patterns.length % 2 !== 0) { - throw { - message: `Invalid ${key} value.`, - code: 'InvalidParameter', - status: 400 - }; - } - - let result = ''; - for (let i = 0, length = patterns.length; i < length; i++) { - let value = patterns[i]; - if (i % 2 === 0) { - // Number - if (!numRegex.test(value) || parseInt(value) < min) { - throw { - message: `${key} should be positive number equal to or greater than ${min}.`, - code: 'InvalidParameter', - status: 400 - }; - } - result = `${result}${parseInt(value)}`; - } else { - // Unit - if (!timeUnits.includes(value)) { - throw { - message: `${key} unit should be one of these: ms, s, m, h, d.`, - code: 'InvalidParameter', - status: 400 - }; - } - result = `${result}${value}`; - } - } - - testScenario.execution[0][key] = result; - } else { - testScenario.execution[0][key] = parseInt(testScenario.execution[0][key]); - if (testScenario.execution[0][key] < min) { - throw { - message: `${key} should be positive number equal to or greater than ${min}.`, - code: 'InvalidParameter', - status: 400 - }; - } - } - } + // Ramp up + testScenario = validateTimeUnit(testScenario, 'ramp-up', 0); - // Task count - if (typeof taskCount === 'string') { - taskCount = taskCount.trim(); - } - if (!numRegex.test(taskCount) - || parseInt(taskCount) < 1 - || parseInt(taskCount) > 1000) { - throw { - message: 'Task count should be positive number between 1 to 1000.', - code: 'InvalidParameter', - status: 400 - }; - } - taskCount = parseInt(taskCount); + // Hold for + testScenario = validateTimeUnit(testScenario, 'hold-for', 1); - // Concurrency - if (typeof testScenario.execution[0].concurrency === 'string') { - testScenario.execution[0].concurrency = testScenario.execution[0].concurrency.trim(); - } - if (!numRegex.test(testScenario.execution[0].concurrency) - || parseInt(testScenario.execution[0].concurrency) < 1) { - throw { - message: 'Concurrency should be positive number', - code: 'InvalidParameter', - status: 400 - }; - } - testScenario.execution[0].concurrency = parseInt(testScenario.execution[0].concurrency); - - // Ramp up - validateTimeUnit('ramp-up', 0); - - // Hold for - validateTimeUnit('hold-for', 1); - - // Add reporting to Test Scenario so that the end results are export to - // Amazon s3 by each task. - testScenario.reporting = [{ - "module": "final-stats", - "summary": true, - "percentiles": true, - "summary-labels": true, - "test-duration": true, - "dump-xml": "/tmp/artifacts/results.xml" - }]; - - console.log('TEST:: ', JSON.stringify(testScenario, null, 2)); - - // 1. Write test scenario to S3 - params = { - Body: JSON.stringify(testScenario), - Bucket: process.env.SCENARIOS_BUCKET, - Key: `test-scenarios/${testId}.json` - }; - await s3.putObject(params).promise(); - - console.log(`test scenario upoladed to s3: test-scenarios/${testId}.json`); - - // 2. Start Step Functions execution - await stepFunctions.startExecution({ - stateMachineArn: process.env.STATE_MACHINE_ARN, - input: JSON.stringify({ - scenario: { - testId: testId, - taskCount: taskCount, - testType: testType, - fileType: fileType - } - }) - }).promise(); - - // 3. Update DynamoDB. values for history and endTime are used to remove the old data. - params = { - TableName: process.env.SCENARIOS_TABLE, - Key: { - testId: testId - }, - UpdateExpression: 'set #n = :n, #d = :d, #c = :c, #t = :t, #s = :s, #r = :r, #st = :st, #et = :et, #nr = :nr, #sr = :sr, #tt = :tt, #ft = :ft', - ExpressionAttributeNames: { - '#n': 'testName', - '#d': 'testDescription', - '#c': 'taskCount', - '#t': 'testScenario', - '#s': 'status', - '#r': 'results', - '#st': 'startTime', - '#et': 'endTime', - '#nr': 'nextRun', - '#sr': 'scheduleRecurrence', - '#tt': 'testType', - '#ft': 'fileType' - }, - ExpressionAttributeValues: { - ':n': testName, - ':d': testDescription, - ':c': taskCount, - ':t': JSON.stringify(testScenario), - ':s': 'running', - ':r': {}, - ':st': startTime, - ':et': 'running', - ':nr': nextRun, - ':sr': scheduleRecurrence, - ':tt': testType, - ':ft': fileType - }, - ReturnValues: 'ALL_NEW' - }; - data = await dynamoDB.update(params).promise(); + // Add reporting to Test Scenario so that the end results are export to + // Amazon s3 by each task. + testScenario.reporting = [{ + "module": "final-stats", + "summary": true, + "percentiles": true, + "summary-labels": true, + "test-duration": true, + "dump-xml": "/tmp/artifacts/results.xml" + }]; + + console.log('TEST:: ', JSON.stringify(testScenario, null, 2)); - console.log(`Create test complete: ${data}.json`); + // 1. Write test scenario to S3 + await writeTestScenarioToS3(testTaskConfigs, testScenario, testId); - return data.Attributes; - } catch (err) { - throw err; + console.log(`test scenario uploaded to s3: test-scenarios/${testId}.json`); + + // Based on the selected regions for the test, retrieve the test infrastructure configuration + // for each region and create an object for the specific region and add it to the list sent to the step functions + const regionalTestAndInfraConfiguration = await mergeTestAndInfraConfiguration(testTaskConfigs); + + /** + * Start Step Functions execution + */ + const stepFunctionParams = { regionalTestAndInfraConfiguration, testId, testType, fileType, showLive }; + await startStepFunctionExecution(stepFunctionParams); + + // Update DynamoDB values. + const updateDBData = { testId, testName, testDescription, testTaskConfigs, testScenario, startTime, nextRun, scheduleRecurrence, showLive, testType, fileType }; + const data = await updateTestDBEntry(updateDBData); + + console.log(`Create test complete: ${JSON.stringify(data)}`); + + return data.Attributes; + } catch (err) { + console.error(err); + throw err; + } +}; + +/** + * + * @param {object} ecs The client is created in the region in which the tasks are running + * @param {Array} tasks List of tasks + * @param {string} taskCluster Name of ECS cluster + * @param {object} tasksInRegion Object storing the region the tasks are running in + * @returns object with region and tasks in region + */ + +const getRunningTasks = async (ecs, tasks, taskCluster, tasksInRegion) => { + const params = { + cluster: taskCluster, + }; + let describeTasksResponse; + while (tasks.length > 0) { + //get groups of 100 tasks + params.tasks = tasks.splice(0, 100); + describeTasksResponse = await ecs.describeTasks(params).promise(); + //add tasks to returned value for use in UI + tasksInRegion.tasks = tasksInRegion.tasks.concat(describeTasksResponse.tasks); + } + return tasksInRegion; +}; + +/** + * + * @param {object} ecs The client is created in the region in which the tasks are running + * @param {string} taskCluster Name of ECS cluster + * @param {string} testId + * @returns array of task ARNs + */ + +const getListOfTasksInRegion = async (ecs, taskCluster, testId) => { + let params = { + cluster: taskCluster, + startedBy: testId + }; + let tasks = []; + let tasksResponse; + do { + tasksResponse = await ecs.listTasks(params).promise(); + tasks = tasks.concat(tasksResponse.taskArns); + params.nextToken = tasksResponse.nextToken; + + } while (tasksResponse.nextToken); + return tasks; +}; + +/** + * + * @param {object} data + * @param {string} testId + * @returns test run data augmented with tasks running per region, if any + */ +const listTasksPerRegion = async (data, testId) => { + for (const testRegion of data.testTaskConfigs) { + if (testRegion.taskCluster) { + const region = testRegion.region; + let tasksInRegion = { region: region }; + tasksInRegion.tasks = []; + options.region = region; + const ecs = new AWS.ECS(options); + const tasks = await getListOfTasksInRegion(ecs, testRegion.taskCluster, testId); + if (tasks.length !== 0) { + tasksInRegion = await getRunningTasks(ecs, tasks, testRegion.taskCluster, tasksInRegion); + } + data.tasksPerRegion = data.tasksPerRegion.concat(tasksInRegion); + } else { + const errorMessage = new ErrorException('InvalidInfrastructureConfiguration', `There is no ECS test infrastructure configured for region ${testRegion.region}`); + console.log(errorMessage); + throw errorMessage; } + } + return data; }; /** @@ -497,195 +870,298 @@ const createTest = async (config) => { * @testId {string} the unique id of test scenario to return. */ const getTest = async (testId) => { - console.log(`Get test details for testId: ${testId}`); - - try { - let data; - let params = { - TableName: process.env.SCENARIOS_TABLE, - Key: { - testId: testId - } - }; - data = await dynamoDB.get(params).promise(); - data = data.Item; - //convert testScenario back into an object - data.testScenario = JSON.parse(data.testScenario); - - if (data.status === 'running') { - console.log(`testId: ${testId} is still running`); - - //1. Get list of task for testId - data.tasks = []; - params = { - cluster: process.env.TASK_CLUSTER, - startedBy: testId - }; - let tasks = []; - let tasksResponse; - do { - tasksResponse = await ecs.listTasks(params).promise(); - tasks = tasks.concat(tasksResponse.taskArns); - params.nextToken = tasksResponse.nextToken; - - } while (tasksResponse.nextToken); - - //2. describe tasks - if (tasks.length !== 0) { - params = { - cluster: process.env.TASK_CLUSTER, - }; - - let describeTasksResponse; - while (tasks.length > 0) { - //get groups of 100 tasks - params.tasks = tasks.splice(0, 100); - describeTasksResponse = await ecs.describeTasks(params).promise(); - //add tasks to returned value for use in UI - data.tasks = data.tasks.concat(describeTasksResponse.tasks); - } - } - } + console.log(`Get test details for testId: ${testId}`); + + try { + //Retrieve test and regional resource information from DDB + let data = await getTestAndRegionConfigs(testId); + + data.testScenario = JSON.parse(data.testScenario); + + if (data.status === 'running') { + console.log(`testId: ${testId} is still running`); + + // Get the list of tasks for testId for each test region + data.tasksPerRegion = []; + if (data.testTaskConfigs) { + data = await listTasksPerRegion(data, testId); + } else { + const errorMessage = new ErrorException('InvalidConfiguration', 'There are no test task configurations for the test.'); + console.log(errorMessage); + throw errorMessage; + } + } + data.history = await getTestHistoryEntries(testId); + return data; + } catch (err) { + console.error(err); + throw err; + } +}; + +/** + * deleteDDBTestEntry + * Deleting the DDB test entry + * @param {string} testId + */ +const deleteDDBTestEntry = async (testId) => { + try { + const params = { + TableName: SCENARIOS_TABLE, + Key: { + testId: testId + } + }; + await dynamoDB.delete(params).promise(); + } catch (err) { + console.error(err); + throw err; + } +}; + +/** + * getTestHistoryTestRunIds + * @param {string} testId + * @returns list of all history objects for testId + */ +const getTestHistoryTestRunIds = async (testId) => { + try { + let response = []; + const params = { + TableName: HISTORY_TABLE, + KeyConditionExpression: '#t = :t', + ExpressionAttributeNames: { + '#t': 'testId' + }, + ExpressionAttributeValues: { + ':t': testId + } + }; + do { + const testRunIds = await dynamoDB.query(params).promise(); + testRunIds.Items.map(testRunItem => { response.push(testRunItem.testRunId); }); + params.ExclusiveStartKey = testRunIds.LastEvaluatedKey; + } while (params.ExclusiveStartKey); + return response; + } catch (err) { + console.error(err); + throw err; + } +}; + +/** + * The DynamoDB batch write API expected the delete request to have a specific format + * This function creates a list of entries formatted as required for all test run entries + * @param {string} testId + * @param {object} testRunItems + * @returns List of batch DeleteRequest items + */ +const createBatchRequestItems = (testId, testRunItems) => { + return testRunItems.map(testRunItem => ({ DeleteRequest: { Key: { 'testId': testId, 'testRunId': testRunItem } } })); +}; - return data; - } catch (err) { - throw err; +/** + * Batch delete of history test runs + * If there are some unprocessed items, calls itself to run again + * @param {object} deleteItems + */ +const deleteTestHistory = async (deleteItems) => { + try { + const batchRequestItem = {}; + if (!Array.isArray(deleteItems)) { + deleteItems = [deleteItems]; + } + batchRequestItem[HISTORY_TABLE] = deleteItems; + const params = { + RequestItems: batchRequestItem + }; + const response = await dynamoDB.batchWrite(params).promise(); + if (Object.keys(response.UnprocessedItems).length > 0) { + deleteTestHistory(response.UnprocessedItems); } + } catch (err) { + console.error(err); + throw err; + } }; /** - * @function deleteTest - * Description: deletes all data related to a specific testId - * @testId {string} the unique id of test scenario to delete - * @functionName {string} the name of the task runner lambda function + * The DynamoDB batch write API limits batch write items to 25 + * This function parses the formatted delete requests into a max of 25 item chunks + * @param {object} testRuns */ -const deleteTest = async (testId, functionName) => { - console.log(`Delete test, testId: ${testId}`); - try { - //delete metric filter, if no metric filters log error and continue delete - const metrics = ["numVu", "numSucc", "numFail", "avgRt"]; +const parseBatchRequests = async (testRuns) => { + while (testRuns.length > 0) { + await deleteTestHistory(testRuns.splice(0, 25)); + } +}; + +/** + * Deletes the metric filter created for the test run in all configured regions + * @param {string} testId + * @param {object} testAndRegionalInfraConfigs + */ +const deleteMetricFilter = async (testId, testAndRegionalInfraConfigs) => { + //delete metric filter, if no metric filters log error and continue delete + try { + const metrics = ["numVu", "numSucc", "numFail", "avgRt"]; + if (testAndRegionalInfraConfigs.testTaskConfigs) { + for (const regionConfig of testAndRegionalInfraConfigs.testTaskConfigs) { + options.region = regionConfig.region; + const cloudwatch = new AWS.CloudWatch(options); + const cloudwatchLogs = new AWS.CloudWatchLogs(options); for (let metric of metrics) { - let deleteMetricFilterParams = { - filterName: `${process.env.TASK_CLUSTER}-Ecs${metric}-${testId}`, - logGroupName: process.env.ECS_LOG_GROUP - }; - await cloudwatchLogs.deleteMetricFilter(deleteMetricFilterParams).promise(); - } - } catch (err) { - if (err.code === 'ResourceNotFoundException') { - console.error(err); + let deleteMetricFilterParams = { + filterName: `${regionConfig.taskCluster}-Ecs${metric}-${testId}`, + logGroupName: regionConfig.ecsCloudWatchLogGroup + }; + await cloudwatchLogs.deleteMetricFilter(deleteMetricFilterParams).promise(); } - else { - throw err; - } - } - - try { //Delete Dashboard const deleteDashboardParams = { DashboardNames: [`EcsLoadTesting-${testId}`] }; await cloudwatch.deleteDashboards(deleteDashboardParams).promise(); + } + } + } catch (err) { + console.error(err); + throw err; + } +}; - - //Get Rules - let rulesResponse = await cloudwatchevents.listRules({ NamePrefix: testId }).promise(); - //Delete Rule - for (let rule of rulesResponse.Rules) { - let ruleName = rule.Name; - await cloudwatchevents.removeTargets({ Rule: ruleName, Ids: [ruleName] }).promise(); - await lambda.removePermission({ FunctionName: functionName, StatementId: ruleName }).promise(); - await cloudwatchevents.deleteRule({ Name: ruleName }).promise(); - } - - - const params = { - TableName: process.env.SCENARIOS_TABLE, - Key: { - testId: testId - } - }; - await dynamoDB.delete(params).promise(); - - return 'success'; - } catch (err) { - console.error(err); - throw err; +/** + * Deletes all data related to a specific testId + * @param {string} testId the unique id of test scenario to delete + * @param {string} functionName the name of the task runner lambda function + * @returns Success + */ +const deleteTest = async (testId, functionName) => { + console.log(`Delete test, testId: ${testId}`); + // Get test regions then get config info + try { + // Get test and regional test infrastructure configuration + const testAndRegionalInfraConfigs = await getTestAndRegionConfigs(testId); + await deleteMetricFilter(testId, testAndRegionalInfraConfigs); + } catch (err) { + if (err.code === 'ResourceNotFoundException') { + console.error(err); } + else { + throw err; + } + } + + try { + //Get Rules + let rulesResponse = await cloudwatchevents.listRules({ NamePrefix: testId }).promise(); + //Delete Rule + for (let rule of rulesResponse.Rules) { + let ruleName = rule.Name; + await cloudwatchevents.removeTargets({ Rule: ruleName, Ids: [ruleName] }).promise(); + await lambda.removePermission({ FunctionName: functionName, StatementId: ruleName }).promise(); + await cloudwatchevents.deleteRule({ Name: ruleName }).promise(); + } + await deleteDDBTestEntry(testId); + const testRunIds = await getTestHistoryTestRunIds(testId); + const testRuns = createBatchRequestItems(testId, testRunIds); + await parseBatchRequests(testRuns); + return 'success'; + } catch (err) { + console.error(err); + throw err; + } }; /** - * @function cancelTest - * Description: stop all tasks related to a specific testId - * @testId {string} the unique id of test scenario to stop. - * e. + * Stop all tasks related to a specific testId, updates test status in Dynamodb + * @param {string} testId the unique id of test scenario to stop. + * @returns Test cancelling */ const cancelTest = async (testId) => { - console.log(`Cancel test for testId: ${testId}`); + console.log(`Cancel test for testId: ${testId}`); - try { + try { + // Get test and regional infrastructure configuration + const testAndRegionalInfraConfigs = await getTestAndRegionConfigs(testId); + if (testAndRegionalInfraConfigs.testTaskConfigs) { + for (const regionalConfig of testAndRegionalInfraConfigs.testTaskConfigs) { //cancel tasks - let params; - params = { - FunctionName: process.env.TASK_CANCELER_ARN, - InvocationType: "Event", - Payload: JSON.stringify({ - testId: testId - }) - }; - await lambda.invoke(params).promise(); - - //Update the status in the scenarios table. - params = { - TableName: process.env.SCENARIOS_TABLE, - Key: { - testId: testId - }, - UpdateExpression: 'set #s = :s', - ExpressionAttributeNames: { - '#s': 'status' - }, - ExpressionAttributeValues: { - ':s': 'cancelling' - } + const taskCancelerParams = { + FunctionName: TASK_CANCELER_ARN, + InvocationType: "Event", + Payload: JSON.stringify({ + testId: testId, + region: regionalConfig.region, + taskCluster: regionalConfig.taskCluster + }) }; - await dynamoDB.update(params).promise(); - - return 'test cancelling'; - } catch (err) { - throw err; + await lambda.invoke(taskCancelerParams).promise(); + } } + + //Update the status in the scenarios table. + const params = { + TableName: SCENARIOS_TABLE, + Key: { + testId: testId + }, + UpdateExpression: 'set #s = :s', + ExpressionAttributeNames: { + '#s': 'status' + }, + ExpressionAttributeValues: { + ':s': 'cancelling' + } + }; + await dynamoDB.update(params).promise(); + + return 'test cancelling'; + } catch (err) { + console.error(err); + throw err; + } }; /** - * @function listTasks - * Description: returns a list of ecs tasks + * Returns a list of ecs tasks + * @returns A list of task ARNs by test region */ const listTasks = async () => { - console.log('List tasks'); - - try { - //Get list of running tasks - let params = { - cluster: process.env.TASK_CLUSTER - }; + console.log('Collect all running tasks in all regions'); + try { + let regionalTaskArns = []; + const regionalConfigs = await getAllRegionConfigs(); + //Get list of running tasks + for (const regionalConfig of regionalConfigs) { + const regionalTasks = { region: regionalConfig.region }; + options.region = regionalConfig.region; + let params = { + cluster: regionalConfig.taskCluster + }; + const ecs = new AWS.ECS(options); + const taskArns = []; + do { let data = await ecs.listTasks(params).promise(); - let taskArns = data.taskArns; - while (data.nextToken) { - params.nextToken = data.nextToken; - data = await ecs.listTasks(params).promise(); - taskArns.push(data.taskArns); - } - return taskArns; - } catch (err) { - throw err; + taskArns.push(...data.taskArns); + params.nextToken = data.nextToken; + } while (params.nextToken); + regionalTasks.taskArns = taskArns; + regionalTaskArns.push(regionalTasks); } + return regionalTaskArns; + } catch (err) { + console.error(err); + throw err; + } }; module.exports = { - listTests: listTests, - createTest: createTest, - getTest: getTest, - deleteTest: deleteTest, - cancelTest: cancelTest, - listTasks: listTasks, - scheduleTest: scheduleTest + listTests: listTests, + createTest: createTest, + getTest: getTest, + deleteTest: deleteTest, + cancelTest: cancelTest, + listTasks: listTasks, + scheduleTest: scheduleTest, + getAllRegionConfigs: getAllRegionConfigs, + getCFUrl: getCFUrl }; \ No newline at end of file diff --git a/source/api-services/lib/scenarios/index.spec.js b/source/api-services/lib/scenarios/index.spec.js index 5f04c4f..0f80a02 100644 --- a/source/api-services/lib/scenarios/index.spec.js +++ b/source/api-services/lib/scenarios/index.spec.js @@ -10,1336 +10,2236 @@ const mockCloudWatch = jest.fn(); const mockCloudWatchLogs = jest.fn(); const mockCloudWatchEvents = jest.fn(); const mockLambda = jest.fn(); +const mockCloudFormation = jest.fn(); const mockAWS = require('aws-sdk'); mockAWS.S3 = jest.fn(() => ({ - putObject: mockS3 + putObject: mockS3 })); mockAWS.StepFunctions = jest.fn(() => ({ - startExecution: mockStepFunctions -})); -mockAWS.ECS = jest.fn(() => ({ - listTasks: mockEcs, - describeTasks: mockEcs, - stopTask: mockEcs + startExecution: mockStepFunctions })); mockAWS.config = jest.fn(() => ({ - logger: Function + logger: Function })); mockAWS.DynamoDB.DocumentClient = jest.fn(() => ({ - scan: mockDynamoDB, - delete: mockDynamoDB, - update: mockDynamoDB, - get: mockDynamoDB + scan: mockDynamoDB, + delete: mockDynamoDB, + update: mockDynamoDB, + get: mockDynamoDB, + query: mockDynamoDB, + batchWrite: mockDynamoDB })); mockAWS.CloudWatch = jest.fn(() => ({ - deleteDashboards: mockCloudWatch + deleteDashboards: mockCloudWatch })); mockAWS.CloudWatchLogs = jest.fn(() => ({ - deleteMetricFilter: mockCloudWatchLogs + deleteMetricFilter: mockCloudWatchLogs })); mockAWS.CloudWatchEvents = jest.fn(() => ({ - putRule: mockCloudWatchEvents, - putTargets: mockCloudWatchEvents, - removeTargets: mockCloudWatchEvents, - deleteRule: mockCloudWatchEvents, - listRules: mockCloudWatchEvents, + putRule: mockCloudWatchEvents, + putTargets: mockCloudWatchEvents, + removeTargets: mockCloudWatchEvents, + deleteRule: mockCloudWatchEvents, + listRules: mockCloudWatchEvents, })); mockAWS.Lambda = jest.fn(() => ({ - addPermission: mockLambda, - removePermission: mockLambda, - update: mockDynamoDB, - get: mockDynamoDB, - invoke: mockLambda + addPermission: mockLambda, + removePermission: mockLambda, + update: mockDynamoDB, + get: mockDynamoDB, + invoke: mockLambda +})); +mockAWS.CloudFormation = jest.fn(() => ({ + listExports: mockCloudFormation })); Date.now = jest.fn(() => new Date("2017-04-22T02:28:37.000Z")); const testId = '1234'; const listData = { - Items: [ - { testId: '1234' }, - { testId: '5678' } - ] -} -const getData = { - Item: { - testId: '1234', - name: 'mytest', - status: 'running', - testScenario: "{\"name\":\"example\"}" - } -} - -const tasks = { - "taskArns": ["arn:of:task1", "arn:of:task2", "arn:of:task3"] -} + Items: [ + { testId: '1234' }, + { testId: '5678' } + ] +}; + +let getData = { + Item: { + testId: '1234', + name: 'mytest', + status: 'running', + testScenario: "{\"name\":\"example\"}", + testTaskConfigs: [ + { + region: "us-east-1", + concurrency: "5", + taskCount: "5" + } + ] + } +}; +const origData = getData; + +let getDataWithConfigs = { + Item: { + testId: '1234', + name: 'mytest', + status: 'running', + testScenario: "{\"name\":\"example\"}", + testTaskConfigs: [ + { + region: 'us-east-1', + concurrency: '5', + taskCount: '5', + ecsCloudWatchLogGroup: 'testCluster-DLTEcsDLTCloudWatchLogsGroup', + taskCluster: 'testCluster', + taskDefinition: 'arn:aws:ecs:us-east-1:123456789012:task-definition/testTaskDef1:1', + subnetA: 'subnet-456def', + subnetB: 'subnet-123abc', + taskImage: 'test-load-tester-image', + taskSecurityGroup: 'sg-000000' + }, + { + testId: "region-eu-west-1", + concurrency: '5', + taskCount: '5', + ecsCloudWatchLogGroup: "testClusterEU-DLTEcsDLTCloudWatchLogsGroup", + taskCluster: "testClusterEU", + taskDefinition: "arn:aws:ecs:eu-west-1:123456789012:task-definition/testTaskDef2:1", + subnetB: "subnet-abc123", + region: "eu-west-1", + taskImage: "eu-test-load-tester-image", + subnetA: "subnet-def456", + taskSecurityGroup: "sg-111111" + } + ], + } +}; + +let getDataWithNoConfigs = { + Item: { + testId: '1234', + name: 'mytest', + status: 'running', + testScenario: "{\"name\":\"example\"}", + } +}; + +let getDataWithEmptyConfigs = { + Item: { + testId: '1234', + name: 'mytest', + status: 'running', + testScenario: "{\"name\":\"example\"}", + testTaskConfigs: [{}], + } +}; + +const tasks1 = { + "taskArns": ["arn:of:task1", "arn:of:task2", "arn:of:task3"], + "nextToken": true +}; + +const tasks2 = { + "taskArns": ["arn:of:task4", "arn:of:task5", "arn:of:task6"] +}; + +const multiRegionTasksList = [ + { + region: 'us-east-1', + taskArns: [ + "arn:of:task1", "arn:of:task2", "arn:of:task3", "arn:of:task4", "arn:of:task5", "arn:of:task6" + ] + }, { + region: 'eu-west-1', + taskArns: [ + "arn:of:task1", "arn:of:task2", "arn:of:task3", "arn:of:task4", "arn:of:task5", "arn:of:task6" + ] + }]; const updateData = { - Attributes: { testStatus: 'running' } -} + Attributes: { testStatus: 'running' } +}; + const config = { - testName: "mytest", - testDescription: "test", - taskCount: "4", - testScenario: { - execution: [ - { - "concurrency": "10", - "ramp-up": "30s", - "hold-for": "1m" - } - ] - }, - scheduleDate: "2018-02-28", - scheduleTime: "12:30", -} + testName: "mytest", + testDescription: "test", + testTaskConfigs: [ + { + "region": "us-east-1", + "concurrency": "5", + "taskCount": "5" + }, + { + "region": "eu-west-1", + "concurrency": "5", + "taskCount": "5" + } + ], + testScenario: { + execution: [ + { + "ramp-up": "30s", + "hold-for": "1m" + } + ] + }, + scheduleDate: "2018-02-28", + scheduleTime: "12:30", +}; const context = { - functionName: "lambdaFunctionName", - invokedFunctionArn: "arn:of:lambdaFunctionName" -} + functionName: "lambdaFunctionName", + invokedFunctionArn: "arn:of:lambdaFunctionName" +}; const rulesResponse = { - Rules: [ - { - Arn: 'arn:of:rule/123', - Name: '123' - } - ] + Rules: [ + { + Arn: 'arn:of:rule/123', + Name: '123' + } + ] +}; + +const getRegionalConf = { + Item: { + testId: "region-us-east-1", + ecsCloudWatchLogGroup: "testCluster-DLTEcsDLTCloudWatchLogsGroup", + taskCluster: "testCluster", + taskDefinition: "arn:aws:ecs:us-east-1:123456789012:task-definition/testTaskDef1:1", + subnetB: "subnet-123abc", + region: "us-east-1", + taskImage: "test-load-tester-image", + subnetA: "subnet-456def", + taskSecurityGroup: "sg-000000" + } +}; + +const getRegionalConf2 = { + Item: { + testId: "region-eu-west-1", + ecsCloudWatchLogGroup: "testClusterEU-DLTEcsDLTCloudWatchLogsGroup", + taskCluster: "testClusterEU", + taskDefinition: "arn:aws:ecs:eu-west-1:123456789012:task-definition/testTaskDef2:1", + subnetB: "subnet-abc123", + region: "eu-west-1", + taskImage: "eu-test-load-tester-image", + subnetA: "subnet-def456", + taskSecurityGroup: "sg-111111" + } +}; + +const notRegionalConf = { + 'ResponseMetadata': { + 'RequestId': '1234567890ABCDEF' + } +}; + +const getAllRegionalConfs = { + Items: [{ + testId: "region-us-east-1", + ecsCloudWatchLogGroup: "testClusterUS-DLTEcsDLTCloudWatchLogsGroup", + taskCluster: "testClusterUS", + taskDefinition: "arn:aws:ecs:us-east-1:123456789012:task-definition/testTaskDef1:1", + subnetB: "subnet-123abc", + region: "us-east-1", + taskImage: "us-test-load-tester-image", + subnetA: "subnet-456def", + taskSecurityGroup: "sg-000000" + }, + { + testId: "region-eu-west-1", + ecsCloudWatchLogGroup: "testClusterEU-DLTEcsDLTCloudWatchLogsGroup", + taskCluster: "testClusterEU", + taskDefinition: "arn:aws:ecs:eu-west-1:123456789012:task-definition/testTaskDef2:1", + subnetB: "subnet-abc123", + region: "eu-west-1", + taskImage: "eu-test-load-tester-image", + subnetA: "subnet-def456", + taskSecurityGroup: "sg-111111" + } + ] +}; + +const historyEntries = { + "Items": [ + { + "testTaskConfigs": [ + { + "taskCount": 1, + "taskCluster": "testTaskCluster1", + "subnetA": "subnet-aaaaa", + "ecsCloudWatchLogGroup": "testEcsCWG1", + "subnetB": "subnet-bbbbbbd", + "taskImage": "testTaskImage1", + "testId": "testId1", + "taskDefinition": "arn:test:taskGroup/testTaskDef:1", + "completed": 1, + "region": "us-west-2", + "taskSecurityGroup": "sg-111111", + "concurrency": 100 + } + ], + "testType": "simple", + "status": "complete", + "succPercent": "100.00", + "testRunId": "testRunId", + "startTime": "2022-03-26 23:42:14", + "testDescription": "test description", + "testId": "testId", + "endTime": "2022-03-26 23:48:25", + "results": { + "avg_lt": "0.03658", + "p0_0": "0.127", + "p99_0": "0.375", + "stdev_rt": "0.069", + "avg_ct": "0.02612", + "concurrency": "1", + "p99_9": "1.784", + "labels": [ + { + "avg_lt": "0.03658", + "p0_0": "0.127", + "p99_0": "0.375", + "stdev_rt": "0.069", + "avg_ct": "0.02612", + "label": "https://test.url", + "concurrency": "1", + "p99_9": "1.784", + "fail": 0, + "rc": [], + "succ": 967, + "p100_0": "1.784", + "bytes": "5384054559", + "p95_0": "0.244", + "avg_rt": "0.18487", + "throughput": 967, + "p90_0": "0.219", + "testDuration": "0", + "p50_0": "0.181" + } + ], + "fail": 0, + "rc": [], + "succ": 967, + "p100_0": "1.784", + "bytes": "5384054559", + "p95_0": "0.244", + "avg_rt": "0.18487", + "throughput": 967, + "p90_0": "0.219", + "testDuration": "180", + "p50_0": "0.181" + }, + "region": "us-west-2", + "metricS3Location": "testS3Location", + "testScenario": { + "execution": [ + { + "scenario": "testScenario1", + "ramp-up": "0m", + "hold-for": "3m" + } + ], + "reporting": [ + { + "summary": true, + "dump-xml": "testXML/location", + "percentiles": true, + "test-duration": true, + "summary-labels": true, + "module": "final-stats" + } + ], + "scenarios": { + "testScenario": { + "requests": [ + { + "headers": {}, + "method": "GET", + "body": {}, + "url": "https://test.url" + } + ] + } + } + } + } + ] +}; + +const getStackExports = { + Exports: [{ + ExportingStackId: 'arn:of:cloudformation:stack/stackName/abc-def-hij-123', + Name: 'RegionalCFTemplate', + Value: 'https://s3-test-url/prefix/regional.template' + }, + { + ExportingStackId: 'arn:of:cloudformation:stack/notTheStack/xyz-456', + Name: 'NotTheExport', + Value: 'https://s3-test-url/IncorrectURL/wrong.template' + } + ] +}; + +const errorNoStackExports = { + Exports: [{}] +}; + +const noUnprocessedItems = { UnprocessedItems: {} }; +const unprocessedItems = { + UnprocessedItems: + { + testHistoryTable: + [ + { + "DeleteRequest": { + "Key": { + "testId": "1234", "testRunId": "testRunId" + } + } + }] + } }; process.env.SCENARIOS_BUCKET = 'bucket'; +process.env.SCENARIOS_TABLE = 'testScenariosTable'; +process.env.HISTORY_TABLE = 'testHistoryTable'; process.env.STATE_MACHINE_ARN = 'arn:of:state:machine'; process.env.LAMBDA_ARN = 'arn:of:apilambda'; process.env.TASK_CANCELER_ARN = 'arn:of:taskCanceler'; process.env.SOLUTION_ID = 'SO0062'; -process.env.VERSION = '2.0.1'; +process.env.STACK_ID = 'arn:of:cloudformation:stack/stackName/abc-def-hij-123'; +process.env.VERSION = '3.0.0'; const lambda = require('./index.js'); describe('#SCENARIOS API:: ', () => { - beforeEach(() => { - mockS3.mockReset(); - mockDynamoDB.mockReset(); - mockStepFunctions.mockReset(); - mockEcs.mockReset(); - mockCloudWatch.mockReset(); - mockCloudWatchEvents.mockReset(); - mockLambda.mockReset(); - }); - - //Positive tests - it('should return "SUCCESS" when "LISTTESTS" returns success', async () => { - mockDynamoDB.mockImplementation(() => { - return { - promise() { - // scan - return Promise.resolve(listData); - } - }; - }); - - const response = await lambda.listTests(); - expect(response.Items[0].testId).toEqual('1234'); - }); - - it('should return "SUCCESS" when "GETTEST" returns success', async () => { - mockDynamoDB.mockImplementation(() => { - return { - promise() { - // get - return Promise.resolve(getData); - } - }; - }); - mockEcs.mockImplementationOnce(() => { - return { - promise() { - // listTasks - return Promise.resolve(tasks); - } - }; - }); - mockEcs.mockImplementationOnce(() => { - return { - promise() { - //describeTasks - return Promise.resolve({ - tasks: [ - { group: testId }, - { group: testId }, - { group: "notTestId" } - ] - }); - } - } - }); - - - - const response = await lambda.getTest(testId); - expect(response.name).toEqual('mytest'); - }); - - it('should return "SUCCESS" when "listTask" returns success', async () => { - mockEcs.mockImplementationOnce(() => { - tasks.nextToken = "true"; - return { - promise() { - // listTasks - return Promise.resolve(tasks); - } - }; - }); - mockEcs.mockImplementationOnce(() => { - delete tasks.nextToken; - return { - promise() { - // listTasks - return Promise.resolve(tasks); - } - }; - }); - - - const response = await lambda.listTasks(); - expect(response).toEqual(tasks.taskArns); - }); - it('should return "SUCCESS" when "DELETETEST" returns success', async () => { - mockDynamoDB.mockImplementation(() => { - return { - promise() { - // delete - return Promise.resolve(); - } - }; - }); - - mockCloudWatchLogs.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - mockCloudWatch.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(); - } - }; - }); - - mockCloudWatchEvents.mockImplementationOnce(() => { - - return { - promise() { - return Promise.resolve(rulesResponse); - } - } - }); - mockCloudWatchEvents.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - mockLambda.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - - const response = await lambda.deleteTest(testId, context.functionName); - expect(response).toEqual('success'); - }); - it('DELETE should return "SUCCESS" when no metrics are found', async () => { - mockDynamoDB.mockImplementation(() => { - return { - promise() { - // delete - return Promise.resolve(); - } - }; - }); - - mockCloudWatchLogs.mockImplementation(() => { - return { - promise() { - return Promise.reject({ - code: 'ResourceNotFoundException', - statusCode: 400 - }); - } - } - }); - mockCloudWatch.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(); - } - }; - }); - - mockCloudWatchEvents.mockImplementationOnce(() => { - - return { - promise() { - return Promise.resolve(rulesResponse); - } - } - }); - mockCloudWatchEvents.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - mockLambda.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - - const response = await lambda.deleteTest(testId, context.functionName); - expect(response).toEqual('success'); - }); - it('should return "SUCCESS" when "CREATETEST" returns success', async () => { - mockDynamoDB.mockImplementation(() => { - return { - promise() { - // update - return Promise.resolve(updateData); - } - }; - }); - mockS3.mockImplementation(() => { - return { - promise() { - // putObject - return Promise.resolve(); - } - }; - }); - mockStepFunctions.mockImplementation(() => { - return { - promise() { - // startExecution - return Promise.resolve(); - } - }; - }); - - const response = await lambda.createTest(config); - expect(response.testStatus).toEqual('running'); - }); - it('should record proper date when "CREATETEST" with daily recurrence', async () => { - config.recurrence = "daily"; - mockDynamoDB.mockImplementation(() => { - return { - promise() { - // update - return Promise.resolve(updateData); - } - }; - }); - mockS3.mockImplementation(() => { - return { - promise() { - // putObject - return Promise.resolve(); - } - }; - }); - mockStepFunctions.mockImplementation(() => { - return { - promise() { - // startExecution - return Promise.resolve(); - } - }; - }); - - const response = await lambda.createTest(config); - expect(response.testStatus).toEqual('running'); - expect(mockDynamoDB).toHaveBeenCalledWith(expect.objectContaining({ - ExpressionAttributeValues: expect.objectContaining({ - ":nr": "2017-04-23 02:28:37" - }) - })); - //reset config - delete config.recurrence; - }); - it('should record proper date when "CREATETEST" with weekly recurrence', async () => { - config.recurrence = "weekly"; - mockDynamoDB.mockImplementation(() => { - return { - promise() { - // update - return Promise.resolve(updateData); - } - }; - }); - mockS3.mockImplementation(() => { - return { - promise() { - // putObject - return Promise.resolve(); - } - }; - }); - mockStepFunctions.mockImplementation(() => { - return { - promise() { - // startExecution - return Promise.resolve(); - } - }; - }); - - const response = await lambda.createTest(config); - expect(response.testStatus).toEqual('running'); - expect(mockDynamoDB).toHaveBeenCalledWith(expect.objectContaining({ - ExpressionAttributeValues: expect.objectContaining({ - ":nr": "2017-04-29 02:28:37" - }) - })); - //reset config - delete config.recurrence; - }); - it('should record proper date when "CREATETEST" with biweekly recurrence', async () => { - config.recurrence = "biweekly"; - mockDynamoDB.mockImplementation(() => { - return { - promise() { - // update - return Promise.resolve(updateData); - } - }; - }); - mockS3.mockImplementation(() => { - return { - promise() { - // putObject - return Promise.resolve(); - } - }; - }); - mockStepFunctions.mockImplementation(() => { - return { - promise() { - // startExecution - return Promise.resolve(); - } - }; - }); - - const response = await lambda.createTest(config); - expect(response.testStatus).toEqual('running'); - expect(mockDynamoDB).toHaveBeenCalledWith(expect.objectContaining({ - ExpressionAttributeValues: expect.objectContaining({ - ":nr": "2017-05-06 02:28:37" - }) - })); - //reset config - delete config.recurrence; - }); - it('should record proper date when "CREATETEST" with daily recurrence', async () => { - config.recurrence = "monthly"; - mockDynamoDB.mockImplementation(() => { - return { - promise() { - // update - return Promise.resolve(updateData); - } - }; - }); - mockS3.mockImplementation(() => { - return { - promise() { - // putObject - return Promise.resolve(); - } - }; - }); - mockStepFunctions.mockImplementation(() => { - return { - promise() { - // startExecution - return Promise.resolve(); - } - }; - }); - - const response = await lambda.createTest(config); - expect(response.testStatus).toEqual('running'); - expect(mockDynamoDB).toHaveBeenCalledWith(expect.objectContaining({ - ExpressionAttributeValues: expect.objectContaining({ - ":nr": "2017-05-22 02:28:37" - }) - })); - //reset config - delete config.recurrence; - }); - it('should return SUCCESS when "CANCELTEST" finds running tasks and returns success', async () => { - - mockLambda.mockImplementationOnce(() => { - return { - promise() { - //invoke TaskCanceler lambda function - return Promise.resolve(); - } - } - }); - - mockDynamoDB.mockImplementationOnce(() => { - return { - promise() { - Promise.resolve(); - } - } - }); - - const response = await lambda.cancelTest(testId); - expect(response).toEqual("test cancelling"); - - }); - it('should return SUCCESS when "SCHEDULETEST" returns success and scheduleStep is "create"', async () => { - config.scheduleStep = 'create'; - config.recurrence = 'daily'; - eventInput = { body: JSON.stringify(config) }; - - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //listRule - return Promise.resolve({ Rules: [] }); - } - } - }); - - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //putRule - return Promise.resolve(rulesResponse); - } - } - }); - mockLambda.mockImplementationOnce(() => { - return { - promise() { - //putPermission - return Promise.resolve(); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //putTargets - return Promise.resolve(); - } - } - }); - mockDynamoDB.mockImplementation(() => { - let scheduleData = updateData; - scheduleData.Attributes.testStatus = 'scheduled'; - return { - promise() { - // update - return Promise.resolve(scheduleData); - } - }; - }); - - const response = await lambda.scheduleTest(eventInput, context); - expect(response.testStatus).toEqual('scheduled'); - - //reset config - delete config.recurrence; - delete config.scheduleStep; - }); - it('should return SUCCESS and record proper next daily run when "SCHEDULETEST" returns success when scheduleStep is start and recurrence exists', async () => { - config.scheduleStep = 'start'; - config.recurrence = 'daily'; - eventInput = { body: JSON.stringify(config) }; - - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //listRule - return Promise.resolve({ Rules: [] }); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //putRule - return Promise.resolve({ RuleArn: 'arn:of:rule/123' }); - } - } - }); - mockLambda.mockImplementationOnce(() => { - return { - promise() { - //putPermission - return Promise.resolve(); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //putTargets - return Promise.resolve(); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //removeTargets - return Promise.resolve(); - } - } - }); - mockLambda.mockImplementationOnce(() => { - return { - promise() { - //removePermission - return Promise.resolve(); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //removeTargets - return Promise.resolve(); - } - } - }); - mockDynamoDB.mockImplementation(() => { - let scheduleData = updateData; - scheduleData.Attributes.testStatus = 'scheduled'; - return { - promise() { - // update - return Promise.resolve(scheduleData); - } - }; - }); - - const response = await lambda.scheduleTest(eventInput, context); - expect(mockCloudWatchEvents).toHaveBeenNthCalledWith(2, expect.objectContaining({ - "ScheduleExpression": "rate(1 day)" - })); - //reset config - delete config.recurrence; - delete config.scheduleStep; - }); - it('should return SUCCESS and record proper next weekly run when "SCHEDULETEST" returns success withe scheduleStep is start and recurrence exists', async () => { - config.scheduleStep = 'start'; - config.recurrence = 'weekly'; - eventInput = { body: JSON.stringify(config) }; - - - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //listRule - return Promise.resolve(rulesResponse); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //delete target - return Promise.resolve(); - } - } - }); - mockLambda.mockImplementationOnce(() => { - return { - promise() { - //delete permission - return Promise.resolve(); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //delete rule - return Promise.resolve(); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //putRule - return Promise.resolve({ RuleArn: 'arn:of:rule/123' }); - } - } - }); - mockLambda.mockImplementationOnce(() => { - return { - promise() { - //putPermission - return Promise.resolve(); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //putTargets - return Promise.resolve(); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //removeTargets - return Promise.resolve(); - } - } - }); - mockLambda.mockImplementationOnce(() => { - return { - promise() { - //removePermission - return Promise.resolve(); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //removeRule - return Promise.resolve(); - } - } - }); - mockDynamoDB.mockImplementation(() => { - let scheduleData = updateData; - scheduleData.Attributes.testStatus = 'scheduled'; - return { - promise() { - // update - return Promise.resolve(scheduleData); - } - }; - }); - - const response = await lambda.scheduleTest(eventInput, context); - expect(mockCloudWatchEvents).toHaveBeenNthCalledWith(4, expect.objectContaining({ - "ScheduleExpression": "rate(7 days)" - })); - //reset config - delete config.recurrence; - delete config.scheduleStep; - }); - it('should return SUCCESS and record proper next biweekly run when "SCHEDULETEST" returns success withe scheduleStep is start and recurrence exists', async () => { - config.scheduleStep = 'start'; - config.recurrence = 'biweekly'; - eventInput = { body: JSON.stringify(config) }; - - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //listRule - return Promise.resolve({ Rules: [] }); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //putRule - return Promise.resolve({ RuleArn: 'arn:of:rule/123' }); - } - } - }); - mockLambda.mockImplementationOnce(() => { - return { - promise() { - //putPermission - return Promise.resolve(); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //putTargets - return Promise.resolve(); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //removeTargets - return Promise.resolve(); - } - } - }); - mockLambda.mockImplementationOnce(() => { - return { - promise() { - //removePermission - return Promise.resolve(); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //removeTargets - return Promise.resolve(); - } - } - }); - mockDynamoDB.mockImplementation(() => { - let scheduleData = updateData; - scheduleData.Attributes.testStatus = 'scheduled'; - return { - promise() { - // update - return Promise.resolve(scheduleData); - } - }; - }); - - const response = await lambda.scheduleTest(eventInput, context); - expect(mockCloudWatchEvents).toHaveBeenNthCalledWith(2, expect.objectContaining({ - "ScheduleExpression": "rate(14 days)" - })); - //reset config - delete config.recurrence; - delete config.scheduleStep; - }); - it('should return SUCCESS and record proper next monthly run when "SCHEDULETEST" returns success and scheduleStep is start and recurrence exists', async () => { - config.scheduleStep = 'start'; - config.recurrence = 'monthly'; - eventInput = { body: JSON.stringify(config) }; - - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //listRule - return Promise.resolve({ Rules: [] }); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //putRule - return Promise.resolve({ RuleArn: 'arn:of:rule/123' }); - } - } - }); - mockLambda.mockImplementationOnce(() => { - return { - promise() { - //putPermission - return Promise.resolve(); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //putTargets - return Promise.resolve(); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //removeTargets - return Promise.resolve(); - } - } - }); - mockLambda.mockImplementationOnce(() => { - return { - promise() { - //removePermission - return Promise.resolve(); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //removeTargets - return Promise.resolve(); - } - } - }); - mockDynamoDB.mockImplementation(() => { - let scheduleData = updateData; - scheduleData.Attributes.testStatus = 'scheduled'; - return { - promise() { - // update - return Promise.resolve(scheduleData); - } - }; - }); - - const response = await lambda.scheduleTest(eventInput, context); - expect(mockCloudWatchEvents).toHaveBeenNthCalledWith(2, expect.objectContaining({ - "ScheduleExpression": "cron(30 12 28 * ? *)" - })); - //reset config - delete config.recurrence; - delete config.scheduleStep; - }); - it('should return SUCCESS, and records proper nextRun when "SCHEDULETEST" returns success withe scheduleStep is start and no recurrence', async () => { - config.scheduleStep = 'start'; - eventInput = { body: JSON.stringify(config) }; - - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //listRule - return Promise.resolve({ Rules: [] }); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //putRule - return Promise.resolve({ RuleArn: 'arn:of:rule/123' }); - } - } - }); - mockLambda.mockImplementationOnce(() => { - return { - promise() { - //putPermission - return Promise.resolve(); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //putTargets - return Promise.resolve(); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //removeTargets - return Promise.resolve(); - } - } - }); - mockLambda.mockImplementationOnce(() => { - return { - promise() { - //removePermission - return Promise.resolve(); - } - } - }); - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //removeRule - return Promise.resolve(); - } - } - }); - mockDynamoDB.mockImplementationOnce(() => { - let scheduleData = updateData; - scheduleData.Attributes.testStatus = 'scheduled'; - return { - promise() { - // update - return Promise.resolve(scheduleData); - } - }; - }); - - const response = await lambda.scheduleTest(eventInput, context); - expect(response.testStatus).toEqual('scheduled'); - expect(mockDynamoDB).toHaveBeenCalledWith(expect.objectContaining({ - ExpressionAttributeValues: expect.objectContaining({ - ":nr": "2018-02-28 12:30:00" - }) - })); - expect(mockCloudWatchEvents).toHaveBeenNthCalledWith(2, expect.objectContaining({ - "ScheduleExpression": "cron(30 12 28 02 ? 2018)" - })); - delete config.scheduleStep; - }); - //Negative Tests - it('should return "DB ERROR" when "LISTTESTS" fails', async () => { - mockDynamoDB.mockImplementation(() => { - return { - promise() { - // scan - return Promise.reject('DB ERROR'); - } - }; - }); - - try { - await lambda.listTests(); - } catch (error) { - expect(error).toEqual('DB ERROR'); - } - }); - - it('should return "DB ERROR" when "GETTEST" fails', async () => { - mockDynamoDB.mockImplementation(() => { - return { - promise() { - // get - return Promise.reject('DB ERROR'); - } - }; - }); - - try { - await lambda.getTest(testId); - } catch (error) { - expect(error).toEqual('DB ERROR'); - } - }); - - it('should return "DB ERROR" when "DELETETEST" fails', async () => { - mockDynamoDB.mockImplementation(() => { - return { - promise() { - // delete - return Promise.reject('DB ERROR'); - } - }; - }); - - mockCloudWatchLogs.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - - mockCloudWatch.mockImplementationOnce(() => { - return { - promise() { - //delete dashboard - return Promise.resolve(); - } - }; - }); - - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(rulesResponse); - } - } - }); - mockCloudWatchEvents.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - mockLambda.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - - try { - await lambda.deleteTest(testId, context.functionName); - } catch (error) { - expect(error).toEqual('DB ERROR'); - } - }); - it('should return "METRICS ERROR" when "DELETETEST" fails due to deleteMetricFilter error other than ResourceNotFoundException', async () => { - mockDynamoDB.mockImplementation(() => { - return { - promise() { - // delete - return Promise.resolve(); - } - }; - }); - - mockCloudWatchLogs.mockImplementationOnce(() => { - return { - promise() { - //delete metrics - return Promise.reject("METRICS ERROR"); - } - } - }); - - mockCloudWatch.mockImplementationOnce(() => { - return { - promise() { - //delete dashboard - return Promise.resolve(); - } - }; - }); - - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(rulesResponse); - } - } - }); - mockCloudWatchEvents.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - mockLambda.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - - try { - await lambda.deleteTest(testId, context.functionName); - } catch (error) { - expect(error).toEqual('METRICS ERROR'); - } - }); - it('should return "STEP FUNCTIONS ERROR" when "CREATETEST" fails', async () => { - mockS3.mockImplementation(() => { - return { - promise() { - // putObject - return Promise.resolve(); - } - }; - }); - mockStepFunctions.mockImplementation(() => { - return { - promise() { - // startExecution - return Promise.reject('STEP FUNCTIONS ERROR'); - } - }; - }); - - try { - await lambda.createTest(config); - } catch (error) { - expect(error).toEqual('STEP FUNCTIONS ERROR'); - } - }); - - it('should return "DB ERROR" when "CREATETEST" fails', async () => { - mockDynamoDB.mockImplementation(() => { - return { - promise() { - // update - return Promise.reject('DB ERROR'); - } - }; - }); - mockS3.mockImplementation(() => { - return { - promise() { - // putObject - return Promise.resolve(); - } - }; - }); - mockStepFunctions.mockImplementation(() => { - return { - promise() { - // startExecution - return Promise.resolve(); - } - }; - }); - - try { - await lambda.createTest(config); - } catch (error) { - expect(error).toEqual('DB ERROR'); - } - }); - it('should return "InvalidParamater" when "CREATETEST" fails due to task count being less than 1', async () => { - config.taskCount = "0"; - - try { - await lambda.createTest(config); - } catch (error) { - expect(error.code).toEqual('InvalidParameter'); - } - - //reset config - config.taskCount = "4"; - }); - it('should return "InvalidParamater" when "CREATETEST" fails due to concurrency being less 1', async () => { - config.testScenario.execution[0]["concurrency"] = 0; - - try { - await lambda.createTest(config); - } catch (error) { - expect(error.code).toEqual('InvalidParameter'); - } - //reset config - config.testScenario.execution[0]["concurrency"] = "1"; - }); - it('should return "InvalidParamater" when "CREATETEST" fails due to hold-for less than min with no units', async () => { - config.testScenario.execution[0]["hold-for"] = 0; - - try { - await lambda.createTest(config); - } catch (error) { - expect(error.code).toEqual('InvalidParameter'); - } - //reset config - config.testScenario.execution[0]["hold-for"] = "1m"; - }); - it('should return "InvalidParamater" when "CREATETEST" fails due to hold-for less than min with units', async () => { - config.testScenario.execution[0]["hold-for"] = "0 ms"; - - try { - await lambda.createTest(config); - } catch (error) { - expect(error.code).toEqual('InvalidParameter'); - } - - //reset config - config.testScenario.execution[0]["hold-for"] = "1m"; - }); - it('should return "InvalidParamater" when "CREATETEST" fails due to hold-for units being invalid', async () => { - config.testScenario.execution[0]["hold-for"] = "2 seconds"; - - try { - await lambda.createTest(config); - } catch (error) { - expect(error.code).toEqual('InvalidParameter'); - } - //reset config - config.testScenario.execution[0]["hold-for"] = "1m"; - }); - it('should return "InvalidParamater" when "CREATETEST" fails due to hold-for being invalid', async () => { - config.testScenario.execution[0]["hold-for"] = "a"; - config.testType = "simple"; - - try { - await lambda.createTest(config); - } catch (error) { - expect(error.code).toEqual('InvalidParameter'); - } - //reset config - config.testScenario.execution[0]["hold-for"] = "1m"; - delete config.testType - }); - it('should return "InvalidParamater" when "CREATETEST" fails due to recurrence being invalid', async () => { - config.recurrence = "invalid" - try { - await lambda.createTest(config); - } catch (error) { - expect(error.code).toEqual('InvalidParameter'); - } - //reset config - delete config.recurrence; - }); - it('should return InvalidParameter when "SCHEDULETEST" fails due to invalid recurrence', async () => { - config.scheduleStep = 'start'; - config.recurrence = 'invalid'; - eventInput = { body: JSON.stringify(config) }; - - mockCloudWatchEvents.mockImplementationOnce(() => { - return { - promise() { - //listRule - return Promise.resolve({ Rules: [] }); - } - } - }); - - try { - await lambda.scheduleTest(eventInput, context) - } catch (error) { - expect(error.code).toEqual("InvalidParameter"); - } - //reset config - delete config.recurrence - delete config.scheduleStep - }); - it('should return "DB ERROR" when CANCELTEST fails', async () => { - - mockLambda.mockImplementationOnce(() => { - return { - promise() { - //invoke TaskCanceler lambda function - return Promise.resolve(); - } - } - }); - mockDynamoDB.mockImplementation(() => { - return { - promise() { - // update - return Promise.reject('DB ERROR'); - } - }; - }); - - try { - await lambda.cancelTest(testId) - } catch (error) { - expect(error).toEqual("DB ERROR"); - } - }); - it('should return "ECS ERROR" when listTasks fails', async () => { - - mockEcs.mockImplementationOnce(() => { - return { - promise() { - //describtTasks - return Promise.reject("ECS ERROR"); - } - } - }); - - try { - await lambda.listTasks() - } catch (error) { - expect(error).toEqual("ECS ERROR"); - } - }); + beforeEach(() => { + mockS3.mockReset(); + mockDynamoDB.mockReset(); + mockStepFunctions.mockReset(); + mockEcs.mockReset(); + mockCloudWatch.mockReset(); + mockCloudWatchEvents.mockReset(); + mockLambda.mockReset(); + mockCloudFormation.mockReset(); + getData = { ...origData }; + }); + + //Positive tests + it('should return "SUCCESS" when "LISTTESTS" returns success', async () => { + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // scan + return Promise.resolve(listData); + } + }; + }); + + const response = await lambda.listTests(); + expect(response.Items[0].testId).toEqual('1234'); + }); + + it('should return "SUCCESS" when "GETTEST" returns success', async () => { + mockAWS.ECS = jest.fn(() => ({ + listTasks: mockEcs, + describeTasks: mockEcs + })); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getData); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getRegionalConf); + } + }; + }); + mockDynamoDB.mockImplementation(() => { + return { + promise() { + //get history + return Promise.resolve(historyEntries); + } + }; + }); + mockEcs.mockImplementation(() => { + return { + promise() { + // listTasks + return Promise.resolve(tasks2); + } + }; + }); + mockEcs.mockImplementationOnce(() => { + return { + promise() { + //describeTasks + return Promise.resolve({ + tasks: [ + { group: testId }, + { group: testId }, + { group: "notTestId" } + ] + }); + } + }; + }); + + const response = await lambda.getTest(testId); + expect(response.name).toEqual('mytest'); + }); + + it('should return "SUCCESS" when "listTask" returns success', async () => { + mockAWS.ECS = jest.fn(() => ({ + listTasks: mockEcs + })); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getAllRegionalConfs); + } + }; + }); + mockEcs.mockImplementationOnce(() => { + return { + promise() { + // listTasks + return Promise.resolve(tasks1); + } + }; + }); + mockEcs.mockImplementationOnce(() => { + return { + promise() { + // listTasks + return Promise.resolve(tasks2); + } + }; + }); + mockEcs.mockImplementationOnce(() => { + return { + promise() { + // listTasks + return Promise.resolve(tasks1); + } + }; + }); + mockEcs.mockImplementationOnce(() => { + return { + promise() { + // listTasks + return Promise.resolve(tasks2); + } + }; + }); + + const response = await lambda.listTasks(); + expect(response).toEqual(multiRegionTasksList); + }); + + it('should return "SUCCESS" when "DELETETEST" returns success', async () => { + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getData); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(getRegionalConf); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getRegionalConf2); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + //get test run IDs + return Promise.resolve(historyEntries); + } + }; + }); + mockDynamoDB.mockImplementation(() => { + return { + promise() { + // batchWrite + return Promise.resolve(noUnprocessedItems); + } + }; + }); + + mockCloudWatchLogs.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockCloudWatch.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(rulesResponse); + } + }; + }); + mockCloudWatchEvents.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + + const response = await lambda.deleteTest(testId, context.functionName); + expect(response).toEqual('success'); + }); + + it('should return "SUCCESS" when "DELETETEST" has unprocessed entries from "deleteTestHistory', async () => { + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getData); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(getRegionalConf); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getRegionalConf2); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + //get test run IDs + return Promise.resolve(historyEntries); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // delete + return Promise.resolve(unprocessedItems); + } + }; + }); + mockDynamoDB.mockImplementation(() => { + return { + promise() { + // batchWrite + return Promise.resolve(noUnprocessedItems); + } + }; + }); + mockCloudWatchLogs.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockCloudWatch.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(rulesResponse); + } + }; + }); + mockCloudWatchEvents.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + + const response = await lambda.deleteTest(testId, context.functionName); + expect(response).toEqual('success'); + }); + + it('DELETE should return "SUCCESS" when no metrics are found', async () => { + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getData); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getRegionalConf); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // delete + return Promise.resolve(); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + //get test run IDs + return Promise.resolve(historyEntries); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // delete + return Promise.resolve(noUnprocessedItems); + } + }; + }); + mockCloudWatchLogs.mockImplementation(() => { + return { + promise() { + return Promise.reject({ + code: 'ResourceNotFoundException', + statusCode: 400 + }); + } + }; + }); + mockCloudWatch.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(rulesResponse); + } + }; + }); + mockCloudWatchEvents.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + + const response = await lambda.deleteTest(testId, context.functionName); + expect(response).toEqual('success'); + }); + + it('should return "SUCCESS" when "CREATETEST" returns success', async () => { + mockS3.mockImplementation(() => { + return { + promise() { + // putObject + return Promise.resolve(); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve(getRegionalConf); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(getRegionalConf2); + } + }; + }); + mockStepFunctions.mockImplementation(() => { + return { + promise() { + // startExecution + return Promise.resolve(); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve(updateData); + } + }; + }); + const response = await lambda.createTest(config); + expect(response.testStatus).toEqual('running'); + }); + + it('should record proper date when "CREATETEST" with daily recurrence', async () => { + config.recurrence = "daily"; + mockS3.mockImplementation(() => { + return { + promise() { + // putObject + return Promise.resolve(); + } + }; + }); + mockStepFunctions.mockImplementation(() => { + return { + promise() { + // startExecution + return Promise.resolve(); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve(getRegionalConf); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve(getRegionalConf2); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve(updateData); + } + }; + }); + + const response = await lambda.createTest(config); + expect(response.testStatus).toEqual('running'); + expect(mockDynamoDB).toHaveBeenCalledWith(expect.objectContaining({ + ExpressionAttributeValues: expect.objectContaining({ + ":nr": "2017-04-23 02:28:37" + }) + })); + //reset config + delete config.recurrence; + }); + + it('should record proper date when "CREATETEST" with weekly recurrence', async () => { + config.recurrence = "weekly"; + + mockS3.mockImplementation(() => { + return { + promise() { + // putObject + return Promise.resolve(); + } + }; + }); + mockStepFunctions.mockImplementation(() => { + return { + promise() { + // startExecution + return Promise.resolve(); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve(getRegionalConf); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve(getRegionalConf2); + } + }; + }); + mockDynamoDB.mockImplementation(() => { + return { + promise() { + // update + return Promise.resolve(updateData); + } + }; + }); + + const response = await lambda.createTest(config); + expect(response.testStatus).toEqual('running'); + expect(mockDynamoDB).toHaveBeenCalledWith(expect.objectContaining({ + ExpressionAttributeValues: expect.objectContaining({ + ":nr": "2017-04-29 02:28:37" + }) + })); + //reset config + delete config.recurrence; + }); + + it('should record proper date when "CREATETEST" with biweekly recurrence', async () => { + config.recurrence = "biweekly"; + + mockS3.mockImplementation(() => { + return { + promise() { + // putObject + return Promise.resolve(); + } + }; + }); + mockStepFunctions.mockImplementation(() => { + return { + promise() { + // startExecution + return Promise.resolve(); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve(getRegionalConf); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve(getRegionalConf2); + } + }; + }); + mockDynamoDB.mockImplementation(() => { + return { + promise() { + // update + return Promise.resolve(updateData); + } + }; + }); + + const response = await lambda.createTest(config); + expect(response.testStatus).toEqual('running'); + expect(mockDynamoDB).toHaveBeenCalledWith(expect.objectContaining({ + ExpressionAttributeValues: expect.objectContaining({ + ":nr": "2017-05-06 02:28:37" + }) + })); + //reset config + delete config.recurrence; + }); + + it('should record proper date when "CREATETEST" with daily recurrence', async () => { + config.recurrence = "monthly"; + + mockS3.mockImplementation(() => { + return { + promise() { + // putObject + return Promise.resolve(); + } + }; + }); + mockStepFunctions.mockImplementation(() => { + return { + promise() { + // startExecution + return Promise.resolve(); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve(getRegionalConf); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve(getRegionalConf2); + } + }; + }); + mockDynamoDB.mockImplementation(() => { + return { + promise() { + // update + return Promise.resolve(updateData); + } + }; + }); + const response = await lambda.createTest(config); + expect(response.testStatus).toEqual('running'); + expect(mockDynamoDB).toHaveBeenCalledWith(expect.objectContaining({ + ExpressionAttributeValues: expect.objectContaining({ + ":nr": "2017-05-22 02:28:37" + }) + })); + //reset config + delete config.recurrence; + }); + + it('should return SUCCESS when "CANCELTEST" finds running tasks and returns success', async () => { + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getData); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getRegionalConf); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + //invoke TaskCanceler lambda function + return Promise.resolve(); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + Promise.resolve(); + } + }; + }); + + const response = await lambda.cancelTest(testId); + expect(response).toEqual("test cancelling"); + + }); + + it('should return SUCCESS when "SCHEDULETEST" returns success and scheduleStep is "create"', async () => { + config.scheduleStep = 'create'; + config.recurrence = 'daily'; + eventInput = { body: JSON.stringify(config) }; + + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //listRule + return Promise.resolve({ Rules: [] }); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //putRule + return Promise.resolve(rulesResponse); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + //putPermission + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //putTargets + return Promise.resolve(); + } + }; + }); + mockDynamoDB.mockImplementation(() => { + let scheduleData = updateData; + scheduleData.Attributes.testStatus = 'scheduled'; + return { + promise() { + // update + return Promise.resolve(scheduleData); + } + }; + }); + + const response = await lambda.scheduleTest(eventInput, context); + expect(response.testStatus).toEqual('scheduled'); + + //reset config + delete config.recurrence; + delete config.scheduleStep; + }); + + it('should return SUCCESS and record proper next daily run when "SCHEDULETEST" returns success when scheduleStep is start and recurrence exists', async () => { + config.scheduleStep = 'start'; + config.recurrence = 'daily'; + eventInput = { body: JSON.stringify(config) }; + + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //listRule + return Promise.resolve({ Rules: [] }); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //putRule + return Promise.resolve({ RuleArn: 'arn:of:rule/123' }); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + //putPermission + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //putTargets + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //removeTargets + return Promise.resolve(); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + //removePermission + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //removeTargets + return Promise.resolve(); + } + }; + }); + mockDynamoDB.mockImplementation(() => { + let scheduleData = updateData; + scheduleData.Attributes.testStatus = 'scheduled'; + return { + promise() { + // update + return Promise.resolve(scheduleData); + } + }; + }); + + await lambda.scheduleTest(eventInput, context); + expect(mockCloudWatchEvents).toHaveBeenNthCalledWith(2, expect.objectContaining({ + "ScheduleExpression": "rate(1 day)" + })); + //reset config + delete config.recurrence; + delete config.scheduleStep; + }); + + it('should return SUCCESS and record proper next weekly run when "SCHEDULETEST" returns success withe scheduleStep is start and recurrence exists', async () => { + config.scheduleStep = 'start'; + config.recurrence = 'weekly'; + eventInput = { body: JSON.stringify(config) }; + + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //listRule + return Promise.resolve(rulesResponse); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //delete target + return Promise.resolve(); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + //delete permission + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //delete rule + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //putRule + return Promise.resolve({ RuleArn: 'arn:of:rule/123' }); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + //putPermission + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //putTargets + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //removeTargets + return Promise.resolve(); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + //removePermission + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //removeRule + return Promise.resolve(); + } + }; + }); + mockDynamoDB.mockImplementation(() => { + let scheduleData = updateData; + scheduleData.Attributes.testStatus = 'scheduled'; + return { + promise() { + // update + return Promise.resolve(scheduleData); + } + }; + }); + + await lambda.scheduleTest(eventInput, context); + expect(mockCloudWatchEvents).toHaveBeenNthCalledWith(4, expect.objectContaining({ + "ScheduleExpression": "rate(7 days)" + })); + //reset config + delete config.recurrence; + delete config.scheduleStep; + }); + + it('should return SUCCESS and record proper next biweekly run when "SCHEDULETEST" returns success withe scheduleStep is start and recurrence exists', async () => { + config.scheduleStep = 'start'; + config.recurrence = 'biweekly'; + eventInput = { body: JSON.stringify(config) }; + + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //listRule + return Promise.resolve({ Rules: [] }); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //putRule + return Promise.resolve({ RuleArn: 'arn:of:rule/123' }); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + //putPermission + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //putTargets + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //removeTargets + return Promise.resolve(); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + //removePermission + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //removeTargets + return Promise.resolve(); + } + }; + }); + mockDynamoDB.mockImplementation(() => { + let scheduleData = updateData; + scheduleData.Attributes.testStatus = 'scheduled'; + return { + promise() { + // update + return Promise.resolve(scheduleData); + } + }; + }); + + await lambda.scheduleTest(eventInput, context); + expect(mockCloudWatchEvents).toHaveBeenNthCalledWith(2, expect.objectContaining({ + "ScheduleExpression": "rate(14 days)" + })); + //reset config + delete config.recurrence; + delete config.scheduleStep; + }); + + it('should return SUCCESS and record proper next monthly run when "SCHEDULETEST" returns success and scheduleStep is start and recurrence exists', async () => { + config.scheduleStep = 'start'; + config.recurrence = 'monthly'; + eventInput = { body: JSON.stringify(config) }; + + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //listRule + return Promise.resolve({ Rules: [] }); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //putRule + return Promise.resolve({ RuleArn: 'arn:of:rule/123' }); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + //putPermission + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //putTargets + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //removeTargets + return Promise.resolve(); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + //removePermission + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //removeTargets + return Promise.resolve(); + } + }; + }); + mockDynamoDB.mockImplementation(() => { + let scheduleData = updateData; + scheduleData.Attributes.testStatus = 'scheduled'; + return { + promise() { + // update + return Promise.resolve(scheduleData); + } + }; + }); + + await lambda.scheduleTest(eventInput, context); + expect(mockCloudWatchEvents).toHaveBeenNthCalledWith(2, expect.objectContaining({ + "ScheduleExpression": "cron(30 12 28 * ? *)" + })); + //reset config + delete config.recurrence; + delete config.scheduleStep; + }); + + it('should return SUCCESS, and records proper nextRun when "SCHEDULETEST" returns success withe scheduleStep is start and no recurrence', async () => { + config.scheduleStep = 'start'; + eventInput = { body: JSON.stringify(config) }; + + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //listRule + return Promise.resolve({ Rules: [] }); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //putRule + return Promise.resolve({ RuleArn: 'arn:of:rule/123' }); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + //putPermission + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //putTargets + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //removeTargets + return Promise.resolve(); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + //removePermission + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //removeRule + return Promise.resolve(); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + let scheduleData = updateData; + scheduleData.Attributes.testStatus = 'scheduled'; + return { + promise() { + // update + return Promise.resolve(scheduleData); + } + }; + }); + + const response = await lambda.scheduleTest(eventInput, context); + expect(response.testStatus).toEqual('scheduled'); + expect(mockDynamoDB).toHaveBeenCalledWith(expect.objectContaining({ + ExpressionAttributeValues: expect.objectContaining({ + ":nr": "2018-02-28 12:30:00" + }) + })); + expect(mockCloudWatchEvents).toHaveBeenNthCalledWith(2, expect.objectContaining({ + "ScheduleExpression": "cron(30 12 28 02 ? 2018)" + })); + delete config.scheduleStep; + }); + + it('should return "SUCCESS" when "getCFUrl" returns a URL', async () => { + mockCloudFormation.mockImplementation(() => { + return { + promise() { + // scan + return Promise.resolve(getStackExports); + } + }; + }); + + const response = await lambda.getCFUrl(); + expect(response).toEqual('https://s3-test-url/prefix/regional.template'); + }); + + //Negative Tests + it('should return "DB ERROR" when "LISTTESTS" fails', async () => { + mockDynamoDB.mockImplementation(() => { + return { + promise() { + // scan + return Promise.reject('DB ERROR'); + } + }; + }); + + try { + await lambda.listTests(); + } catch (error) { + expect(error).toEqual('DB ERROR'); + } + }); + + it('should return "DB ERROR" when "GETTEST" fails', async () => { + mockDynamoDB.mockImplementation(() => { + return { + promise() { + // get + return Promise.reject('DB ERROR'); + } + }; + }); + + try { + await lambda.getTest(testId); + } catch (error) { + expect(error).toEqual('DB ERROR'); + } + }); + + it('should return "DB ERROR" when "DELETETEST" fails', async () => { + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getData); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getRegionalConf); + } + }; + }); + mockDynamoDB.mockImplementation(() => { + return { + promise() { + // delete + return Promise.reject('DB ERROR'); + } + }; + }); + mockCloudWatchLogs.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockCloudWatch.mockImplementationOnce(() => { + return { + promise() { + //delete dashboard + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(rulesResponse); + } + }; + }); + mockCloudWatchEvents.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + + try { + await lambda.deleteTest(testId, context.functionName); + } catch (error) { + expect(error).toEqual('DB ERROR'); + } + }); + + it('should return "DB ERROR" when "DELETETEST" fails when deleting the test', async () => { + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getData); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getRegionalConf); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(); + } + }; + }); + mockCloudWatchLogs.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockCloudWatch.mockImplementationOnce(() => { + return { + promise() { + //delete dashboard + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(rulesResponse); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockDynamoDB.mockImplementation(() => { + return { + promise() { + return Promise.reject('DDB ERROR - DELETE FAILED'); + } + }; + }); + + try { + await lambda.deleteTest(testId, context.functionName); + } catch (error) { + expect(error).toEqual('DDB ERROR - DELETE FAILED'); + } + }); + + it('should return "METRICS ERROR" when "DELETETEST" fails due to deleteMetricFilter error other than ResourceNotFoundException', async () => { + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getData); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getRegionalConf); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getRegionalConf2); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // delete + return Promise.resolve(); + } + }; + }); + mockCloudWatchLogs.mockImplementationOnce(() => { + return { + promise() { + //delete metrics + return Promise.reject("METRICS ERROR"); + } + }; + }); + mockCloudWatch.mockImplementationOnce(() => { + return { + promise() { + //delete dashboard + return Promise.resolve(); + } + }; + }); + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(rulesResponse); + } + }; + }); + mockCloudWatchEvents.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockLambda.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + + try { + await lambda.deleteTest(testId, context.functionName); + } catch (error) { + expect(error).toEqual('METRICS ERROR'); + } + }); + + it('should return "STEP FUNCTIONS ERROR" when "CREATETEST" fails', async () => { + mockS3.mockImplementation(() => { + return { + promise() { + // putObject + return Promise.resolve(); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getRegionalConf); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getRegionalConf2); + } + }; + }); + mockStepFunctions.mockImplementation(() => { + return { + promise() { + // startExecution + return Promise.reject('STEP FUNCTIONS ERROR'); + } + }; + }); + + try { + await lambda.createTest(config); + } catch (error) { + expect(error).toEqual('STEP FUNCTIONS ERROR'); + } + }); + + it('should return "DB ERROR" when "CREATETEST" fails', async () => { + mockDynamoDB.mockImplementation(() => { + return { + promise() { + // update + return Promise.reject('DB ERROR'); + } + }; + }); + mockS3.mockImplementation(() => { + return { + promise() { + // putObject + return Promise.resolve(); + } + }; + }); + mockStepFunctions.mockImplementation(() => { + return { + promise() { + // startExecution + return Promise.resolve(); + } + }; + }); + + try { + await lambda.createTest(config); + } catch (error) { + expect(error).toEqual('DB ERROR'); + } + }); + + it('should return "InvalidParameter" when "CREATETEST" fails due to task count being less than 1', async () => { + config.testTaskConfigs[0]["taskCount"] = "0"; + try { + await lambda.createTest(config); + } catch (error) { + expect(error.code).toEqual('InvalidParameter'); + } + + //reset config + config.testTaskConfigs[0]["taskCount"] = "5"; + }); + + it('should return "InvalidParameter" when "CREATETEST" fails due to concurrency being less 1', async () => { + config.testTaskConfigs[0]["concurrency"] = 0; + try { + await lambda.createTest(config); + } catch (error) { + expect(error.code).toEqual('InvalidParameter'); + } + //reset config + config.testTaskConfigs[0]["concurrency"] = "5"; + }); + + it('should return "InvalidParameter" when "CREATETEST" fails due to hold-for less than min with no units', async () => { + config.testScenario.execution[0]["hold-for"] = 0; + + try { + await lambda.createTest(config); + } catch (error) { + expect(error.code).toEqual('InvalidParameter'); + } + //reset config + config.testScenario.execution[0]["hold-for"] = "1m"; + }); + + it('should return "InvalidParameter" when "CREATETEST" fails due to hold-for less than min with units', async () => { + config.testScenario.execution[0]["hold-for"] = "0 ms"; + + try { + await lambda.createTest(config); + } catch (error) { + expect(error.code).toEqual('InvalidParameter'); + } + + //reset config + config.testScenario.execution[0]["hold-for"] = "1m"; + }); + + it('should return "InvalidParameter" when "CREATETEST" fails due to hold-for units being invalid', async () => { + config.testScenario.execution[0]["hold-for"] = "2 seconds"; + + try { + await lambda.createTest(config); + } catch (error) { + expect(error.code).toEqual('InvalidParameter'); + } + //reset config + config.testScenario.execution[0]["hold-for"] = "1m"; + }); + + it('should return "InvalidParameter" when "CREATETEST" fails due to hold-for being invalid', async () => { + config.testScenario.execution[0]["hold-for"] = "a"; + config.testType = "simple"; + + try { + await lambda.createTest(config); + } catch (error) { + expect(error.code).toEqual('InvalidParameter'); + } + //reset config + config.testScenario.execution[0]["hold-for"] = "1m"; + delete config.testType; + }); + + it('should return "InvalidParameter" when "CREATETEST" fails due to recurrence being invalid', async () => { + config.recurrence = "invalid"; + + try { + await lambda.createTest(config); + } catch (error) { + expect(error.code).toEqual('InvalidParameter'); + } + //reset config + delete config.recurrence; + }); + + it('should return an exception when "CreateTest" fails to return a regional config', async () => { + mockS3.mockImplementation(() => { + return { + promise() { + // putObject + return Promise.resolve(); + } + }; + }); + + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(notRegionalConf); + } + }; + }); + await lambda.createTest(config).catch(err => { + expect(err.message.toString()).toEqual("The region requested does not have a stored infrastructure configuration."); + }); + }); + + it('should return InvalidParameter when "SCHEDULETEST" fails due to invalid recurrence', async () => { + config.scheduleStep = 'start'; + config.recurrence = 'invalid'; + eventInput = { body: JSON.stringify(config) }; + + mockCloudWatchEvents.mockImplementationOnce(() => { + return { + promise() { + //listRule + return Promise.resolve({ Rules: [] }); + } + }; + }); + + try { + await lambda.scheduleTest(eventInput, context); + } catch (error) { + expect(error.code).toEqual("InvalidParameter"); + } + //reset config + delete config.recurrence; + delete config.scheduleStep; + }); + + it('should return "DB ERROR" when CANCELTEST fails', async () => { + mockLambda.mockImplementationOnce(() => { + return { + promise() { + //invoke TaskCanceler lambda function + return Promise.resolve(); + } + }; + }); + mockDynamoDB.mockImplementation(() => { + return { + promise() { + // update + return Promise.reject('DB ERROR'); + } + }; + }); + + try { + await lambda.cancelTest(testId); + } catch (error) { + expect(error).toEqual("DB ERROR"); + } + }); + + it('should return "ECS ERROR" when listTasks fails', async () => { + mockAWS.ECS = jest.fn(() => ({ + listTasks: mockEcs + })); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getAllRegionalConfs); + } + }; + }); + mockEcs.mockImplementationOnce(() => { + return { + promise() { + //describeTasks + return Promise.reject("ECS ERROR"); + } + }; + }); + + try { + await lambda.listTasks(); + } catch (error) { + expect(error).toEqual("ECS ERROR"); + } + }); +}); + +it('should return "DDB ERROR" when listTasks fails', async () => { + mockAWS.ECS = jest.fn(() => ({ + listTasks: mockEcs + })); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.reject("DDB ERROR"); + } + }; + }); + + try { + await lambda.listTasks(); + } catch (error) { + expect(error).toEqual("DDB ERROR"); + } +}); + +it('should return "DDB ERROR" when retrieveTestEntry fails', async () => { + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.reject('DDB ERROR'); + } + }; + }); + + try { + await lambda.getTest(testId); + } catch (error) { + expect(error).toEqual("DDB ERROR"); + } +}); + +it('should return "DDB ERROR" when retrieveTestRegionConfigs fails', async () => { + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.reject('DDB ERROR'); + } + }; + }); + + try { + await lambda.getTest(testId); + } catch (error) { + expect(error).toEqual("DDB ERROR"); + } +}); + +it('should return "InvalidConfiguration" when no testTaskConfigs are returned', async () => { + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // get + return Promise.resolve(getDataWithNoConfigs); + } + }; + }); + + try { + await lambda.getTest(testId); + } catch (error) { + expect(error.code).toEqual("InvalidConfiguration"); + } +}); + +it('should return "InvalidInfrastructureConfiguration" when an empty testTaskConfigs are returned', async () => { + mockDynamoDB.mockImplementation(() => { + return { + promise() { + // get + return Promise.resolve(getDataWithEmptyConfigs); + } + }; + }); + + try { + await lambda.getTest(testId); + } catch (error) { + expect(error.code).toEqual("InvalidInfrastructureConfiguration"); + } }); + +it('should return an error when no exports returned', async () => { + mockCloudFormation.mockImplementation(() => { + return { + promise() { + return Promise.resolve(errorNoStackExports); + } + }; + }); + + await lambda.getCFUrl(testId).catch(err => { + expect(err.toString()).toContain("TypeError"); + expect(err.toString()).toContain("Value"); + }); +}); + +it('should return "S3 ERROR" when "PUTOBJECT" fails', async () => { + mockS3.mockImplementation(() => { + return { + promise() { + // putObject + return Promise.reject('S3 ERROR'); + } + }; + }); + + try { + await lambda.createTest(config); + } catch (error) { + expect(error).toEqual("S3 ERROR"); + } +}); \ No newline at end of file diff --git a/source/api-services/package.json b/source/api-services/package.json index 3d4cc12..103d1bb 100644 --- a/source/api-services/package.json +++ b/source/api-services/package.json @@ -1,10 +1,13 @@ { "name": "api-services", - "version": "2.0.1", - "engines": { - "node": "^12.x" - }, + "version": "3.0.0", "description": "REST API micro services", + "repository": { + "type": "git", + "url": "na" + }, + "license": "Apache-2.0", + "author": "aws-solution-builders", "main": "na", "scripts": { "clean": "rm -rf node_modules package-lock.json", @@ -13,18 +16,15 @@ "dependencies": { "axios": "^0.21.1", "moment": "^2.27.0", - "nanoid": "^3.1.25" + "solution-utils": "file:../solution-utils" }, "devDependencies": { "aws-sdk": "2.1001.0", "axios-mock-adapter": "1.18.2", "jest": "26.6.3" }, - "repository": { - "type": "git", - "url": "na" + "engines": { + "node": "^14.x" }, - "author": "aws-solution-builders", - "license": "Apache-2.0", "readme": "./README.md" } diff --git a/source/console/package.json b/source/console/package.json index 6257d48..125c88a 100644 --- a/source/console/package.json +++ b/source/console/package.json @@ -1,48 +1,50 @@ -{ - "name": "distributed-load-testing-on-aws-ui", - "version": "2.0.1", - "private": true, - "dependencies": { - "@aws-amplify/ui-react": "^1.0.2", - "@fortawesome/fontawesome-svg-core": "^1.2.18", - "@fortawesome/free-brands-svg-icons": "^5.8.2", - "@fortawesome/free-solid-svg-icons": "^5.8.2", - "@fortawesome/react-fontawesome": "^0.1.4", - "aws-amplify": "^3.3.20", - "bootstrap": "^4.6.0", - "brace": "^0.11.1", - "nanoid": "^3.1.25", - "react": "^17.0.1", - "react-ace": "^9.3.0", - "react-dom": "^17.0.1", - "react-router-dom": "^5.2.0", - "react-router-hash-link": "^2.4.3", - "react-scripts": "^4.0.3", - "reactstrap": "^8.9.0" - }, - "scripts": { - "start": "react-scripts start", - "build": "GENERATE_SOURCEMAP=false INLINE_RUNTIME_CHUNK=false react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject", - "clean": "rm -rf node_modules package-lock.json" - }, - "eslintConfig": { - "extends": "react-app" - }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] - }, - "author": "aws-solution-builders", - "license": "Apache-2.0", - "readme": "./README.md" -} +{ + "name": "distributed-load-testing-on-aws-ui", + "version": "3.0.0", + "private": true, + "license": "Apache-2.0", + "author": "aws-solution-builders", + "scripts": { + "build": "GENERATE_SOURCEMAP=false INLINE_RUNTIME_CHUNK=false react-scripts build", + "clean": "rm -rf node_modules package-lock.json", + "eject": "react-scripts eject", + "start": "react-scripts start", + "test": "react-scripts test" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "eslintConfig": { + "extends": "react-app" + }, + "dependencies": { + "@aws-amplify/pubsub": "4.4.10", + "@aws-amplify/ui-components": "^1.9.6", + "@aws-amplify/ui-react": "^1.0.2", + "aws-amplify": "4.3.31", + "aws-sdk": "^2.1082.0", + "bootstrap": "^4.6.0", + "bootstrap-icons": "^1.8.1", + "brace": "^0.11.1", + "chart.js": "^3.7.1", + "chartjs-adapter-moment": "^1.0.0", + "moment": "^2.29.1", + "react": "^17.0.1", + "react-ace": "^9.3.0", + "react-dom": "^17.0.1", + "react-router-dom": "^5.2.0", + "react-scripts": "^4.0.3", + "reactstrap": "^8.9.0", + "solution-utils": "file:../solution-utils" + }, + "readme": "./README.md" +} diff --git a/source/console/public/aws_config.js b/source/console/public/aws_config.js deleted file mode 100644 index e69de29..0000000 diff --git a/source/console/src/App.js b/source/console/src/App.js index c1d5538..9e90331 100644 --- a/source/console/src/App.js +++ b/source/console/src/App.js @@ -3,8 +3,6 @@ import React from 'react'; import { BrowserRouter as Router, Route, Switch, Link } from "react-router-dom"; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faPlusSquare, faSignOutAlt, faBars, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'; import { Collapse, Navbar, @@ -17,15 +15,49 @@ import { //Amplify import Amplify, { Auth } from 'aws-amplify'; import { withAuthenticator } from '@aws-amplify/ui-react'; +import { AuthState, onAuthUIStateChange } from '@aws-amplify/ui-components'; +import { PubSub, AWSIoTProvider } from '@aws-amplify/pubsub'; +import AWS from 'aws-sdk'; //Components import Dashboard from './Components/Dashboard/Dashboard.js'; import Create from './Components/Create/Create.js'; import Details from './Components/Details/Details.js'; +import RegionalModal from './Components/RegionalModal/RegionalModal.js'; declare var awsConfig; +Amplify.addPluggable(new AWSIoTProvider({ + aws_pubsub_region: awsConfig.aws_project_region, + aws_pubsub_endpoint: 'wss://' + awsConfig.aws_iot_endpoint + '/mqtt' +})); +PubSub.configure(awsConfig); Amplify.configure(awsConfig); +/** + * Need to attach IoT Policy to Identity in order to subscribe. + */ +onAuthUIStateChange(async (nextAuthState) => { + if (nextAuthState === AuthState.SignedIn) { + const credentials = await Auth.currentCredentials(); + const identityId = credentials.identityId; + AWS.config.update({ + region: awsConfig.aws_project_region, + credentials: Auth.essentialCredentials(credentials) + }); + + const params = { + policyName: awsConfig.aws_iot_policy_name, + principal: identityId + }; + + try { + await new AWS.Iot().attachPrincipalPolicy(params).promise(); + } catch (error) { + console.error('Error occurred while attaching principal policy', error); + } + } +}); + class App extends React.Component { constructor(props) { @@ -33,8 +65,10 @@ class App extends React.Component { this.noMatch = this.noMatch.bind(this); this.signOut = this.signOut.bind(this); this.toggleNavbar = this.toggleNavbar.bind(this); + this.toggleRegionalModal = this.toggleRegionalModal.bind(this); this.state = { - collapsed: true + collapsed: true, + regionalModal: false }; } @@ -54,6 +88,12 @@ class App extends React.Component { }); } + toggleRegionalModal() { + this.setState({ + regionalModal: !this.state.regionalModal + }); + } + signOut() { Auth.signOut(); window.location.reload(); @@ -70,25 +110,31 @@ class App extends React.Component { @@ -105,15 +151,15 @@ class App extends React.Component { - ) + ); } } diff --git a/source/console/src/Components/Create/Create.js b/source/console/src/Components/Create/Create.js index 46389d8..d186eb5 100644 --- a/source/console/src/Components/Create/Create.js +++ b/source/console/src/Components/Create/Create.js @@ -6,822 +6,913 @@ import { API, Storage } from 'aws-amplify'; import 'brace'; import AceEditor from 'react-ace'; import { - Col, - Row, - Button, - FormGroup, - Label, - Input, - FormText, - Spinner, - InputGroup, - CustomInput, - Collapse, - Nav, - NavItem, - NavLink, - TabContent, - TabPane, + Col, + Row, + Button, + FormGroup, + Label, + Input, + FormText, + Spinner, + InputGroup, + CustomInput, + Collapse, + Nav, + NavItem, + NavLink, + TabContent, + TabPane, } from 'reactstrap'; -import { customAlphabet } from 'nanoid'; import 'brace/theme/github'; - - -const ALPHA_NUMERIC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' -/** - * Generates an unique ID based on the parameter length. - * @param length The length of the unique ID - * @returns The unique ID - */ -function generateUniqueId(length) { - const nanoid = customAlphabet(ALPHA_NUMERIC, length); - return nanoid(); -} +import { generateUniqueId } from 'solution-utils'; // Upload file size limit const FILE_SIZE_LIMIT = 50 * 1024 * 1024; -// Allowed file extentions +// Allowed file extensions const FILE_EXTENSIONS = ['jmx', 'zip']; class Create extends React.Component { - constructor(props) { - super(props); - if (this.props.location.state && this.props.location.state.data.testId) { - let fileType = ''; - if (this.props.location.state.data.testType && this.props.location.state.data.testType !== 'simple') { - if (this.props.location.state.data.fileType) { - fileType = this.props.location.state.data.fileType; - } else { - fileType = 'script'; - } - } - - this.state = { - isLoading: false, - runningTasks: false, - availableTasks: 1000, - testId: this.props.location.state.data.testId, - file: null, - validFile: false, - chooseNewFile: false, - activeTab: this.props.location.state.data.recurrence ? '2' : '1', - submitLabel: this.props.location.state.data.scheduleDate ? "Schedule" : "Run Now", - formValues: { - testName: this.props.location.state.data.testName, - testDescription: this.props.location.state.data.testDescription, - taskCount: this.props.location.state.data.taskCount, - concurrency: this.props.location.state.data.concurrency, - holdFor: this.props.location.state.data.holdFor.slice(0, -1), - holdForUnits: this.props.location.state.data.holdFor.slice(-1), - rampUp: this.props.location.state.data.rampUp.slice(0, -1), - rampUpUnits: this.props.location.state.data.rampUp.slice(-1), - endpoint: this.props.location.state.data.endpoint, - method: this.props.location.state.data.method, - body: JSON.stringify(this.props.location.state.data.body, null, 2), - headers: JSON.stringify(this.props.location.state.data.headers, null, 2), - testType: this.props.location.state.data.testType ? this.props.location.state.data.testType : 'simple', - fileType: fileType, - onSchedule: this.props.location.state.data.scheduleDate ? '1' : '0', - scheduleDate: this.props.location.state.data.scheduleDate || null, - scheduleTime: this.props.location.state.data.scheduleTime || null, - recurrence: this.props.location.state.data.recurrence || null - } - } + constructor(props) { + super(props); + if (this.props.location.state && this.props.location.state.data.testId) { + let fileType = ''; + if (this.props.location.state.data.testType && this.props.location.state.data.testType !== 'simple') { + if (this.props.location.state.data.fileType) { + fileType = this.props.location.state.data.fileType; } else { - this.state = { - isLoading: false, - runningTasks: false, - availableTasks: 1000, - testId: null, - file: null, - validFile: false, - chooseNewFile: false, - activeTab: '1', - submitLabel: 'Run Now', - formValues: { - testName: '', - testDescription: '', - taskCount: 0, - concurrency: 0, - holdFor: 0, - holdForUnits: 'm', - rampUp: 0, - rampUpUnits: 'm', - endpoint: '', - method: 'GET', - body: '', - headers: '', - testType: 'simple', - fileType: '', - onSchedule: '0', - scheduleDate: null, - scheduleTime: null, - recurrence: null - } - }; + fileType = 'script'; } - - this.form = React.createRef(); - this.handleSubmit = this.handleSubmit.bind(this); - this.handleInputChange = this.handleInputChange.bind(this); - this.setFormValue = this.setFormValue.bind(this); - this.handleBodyPayloadChange = this.handleBodyPayloadChange.bind(this); - this.handleHeadersChange = this.handleHeadersChange.bind(this); - this.handleFileChange = this.handleFileChange.bind(this); - this.handleCheckBox = this.handleCheckBox.bind(this); - this.parseJson = this.parseJson.bind(this); - this.listTasks = this.listTasks.bind(this); - this.toggleTab = this.toggleTab.bind(this); - } - - parseJson(str) { - try { - return JSON.parse(str); - } catch (err) { - return false; + } + + this.state = { + isLoading: false, + runningTasks: false, + availableTasks: 1000, + testId: this.props.location.state.data.testId, + file: null, + validFile: false, + chooseNewFile: false, + activeTab: this.props.location.state.data.recurrence ? '2' : '1', + submitLabel: this.props.location.state.data.scheduleDate ? "Schedule" : "Run Now", + availableRegions: [], + formValues: { + testName: this.props.location.state.data.testName, + testDescription: this.props.location.state.data.testDescription, + testTaskConfigs: this.props.location.state.data.testTaskConfigs, + holdFor: this.props.location.state.data.holdFor.slice(0, -1), + holdForUnits: this.props.location.state.data.holdFor.slice(-1), + rampUp: this.props.location.state.data.rampUp.slice(0, -1), + rampUpUnits: this.props.location.state.data.rampUp.slice(-1), + endpoint: this.props.location.state.data.endpoint, + method: this.props.location.state.data.method, + body: JSON.stringify(this.props.location.state.data.body, null, 2), + headers: JSON.stringify(this.props.location.state.data.headers, null, 2), + testType: this.props.location.state.data.testType ? this.props.location.state.data.testType : 'simple', + fileType: fileType, + onSchedule: this.props.location.state.data.scheduleDate ? '1' : '0', + scheduleDate: this.props.location.state.data.scheduleDate || "", + scheduleTime: this.props.location.state.data.scheduleTime || "", + recurrence: this.props.location.state.data.recurrence || "", + showLive: this.props.location.state.data.showLive || false } + }; + } else { + this.state = { + isLoading: false, + runningTasks: false, + availableTasks: 1000, + testId: null, + file: null, + validFile: false, + chooseNewFile: false, + activeTab: '1', + submitLabel: 'Run Now', + availableRegions: [], + formValues: { + testName: '', + testDescription: '', + testTaskConfigs: [{ concurrency: 0, taskCount: 0, region: '' }], + holdFor: 0, + holdForUnits: 'm', + rampUp: 0, + rampUpUnits: 'm', + endpoint: '', + method: 'GET', + body: '', + headers: '', + testType: 'simple', + fileType: '', + onSchedule: '0', + scheduleDate: '', + scheduleTime: '', + recurrence: '', + showLive: false + } + }; } - handleSubmit = async () => { - const values = this.state.formValues; + this.form = React.createRef(); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleInputChange = this.handleInputChange.bind(this); + this.setFormValue = this.setFormValue.bind(this); + this.handleBodyPayloadChange = this.handleBodyPayloadChange.bind(this); + this.handleHeadersChange = this.handleHeadersChange.bind(this); + this.handleFileChange = this.handleFileChange.bind(this); + this.handleCheckBox = this.handleCheckBox.bind(this); + this.parseJson = this.parseJson.bind(this); + this.listTasks = this.listTasks.bind(this); + this.toggleTab = this.toggleTab.bind(this); + this.listRegions = this.listRegions.bind(this); + } + + parseJson(str) { + try { + return JSON.parse(str); + } catch (err) { + return false; + } + } - if (!this.form.current.reportValidity()) { - this.setState({ isLoading: false }); - return false; - } + handleSubmit = async () => { + const values = this.state.formValues; - const testId = this.state.testId || generateUniqueId(10); - let payload = { - testId, - testName: values.testName, - testDescription: values.testDescription, - taskCount: parseInt(values.taskCount), - testScenario: { - execution: [{ - concurrency: parseInt(values.concurrency), - "ramp-up": String(parseInt(values.rampUp)).concat(values.rampUpUnits), - "hold-for": String(parseInt(values.holdFor)).concat(values.holdForUnits), - scenario: values.testName - }], - scenarios: { - [values.testName]: {} - } - }, - testType: values.testType, - fileType: values.fileType - }; - - if (!!parseInt(values.onSchedule)) { - payload.scheduleDate = values.scheduleDate; - payload.scheduleTime = values.scheduleTime; - payload.scheduleStep = "start"; - if (this.state.activeTab === '2') { - payload.scheduleStep = "create"; - payload.recurrence = values.recurrence; - } - } + if (!this.form.current.reportValidity()) { + this.setState({ isLoading: false }); + return false; + } - if (values.testType === 'simple') { - if (!values.headers) { - values.headers = '{}'; - } - if (!values.body) { - values.body = '{}'; - } - if (!this.parseJson(values.headers.trim())) { - return alert('WARNING: headers text is not valid JSON'); - } - if (!this.parseJson(values.body.trim())) { - return alert('WARNING: body text is not valid JSON'); - } - - payload.testScenario.scenarios[values.testName] = { - requests: [ - { - url: values.endpoint, - method: values.method, - body: this.parseJson(values.body.trim()), - headers: this.parseJson(values.headers.trim()) - } - ] - }; - } else { - payload.testScenario.scenarios[values.testName] = { - script: `${testId}.jmx` - }; - - if (this.state.file) { - try { - const file = this.state.file; - let filename = `${testId}.jmx`; - - if (file.type && file.type.includes('zip')) { - payload.fileType = 'zip'; - filename = `${testId}.zip`; - } else { - payload.fileType = 'script'; - } - - await Storage.put(`test-scenarios/jmeter/${filename}`, file); - console.log('Script uploaded successfully'); - } catch (error) { - console.error('Error', error); - } - } + const testId = this.state.testId || generateUniqueId(10); + let payload = { + testId, + testName: values.testName, + testDescription: values.testDescription, + testTaskConfigs: values.testTaskConfigs, + testScenario: { + execution: [{ + "ramp-up": String(parseInt(values.rampUp)).concat(values.rampUpUnits), + "hold-for": String(parseInt(values.holdFor)).concat(values.holdForUnits), + scenario: values.testName + }], + scenarios: { + [values.testName]: {} } + }, + showLive: values.showLive, + testType: values.testType, + fileType: values.fileType + }; - this.setState({ isLoading: true }); + if (!!parseInt(values.onSchedule)) { + payload.scheduleDate = values.scheduleDate; + payload.scheduleTime = values.scheduleTime; + payload.scheduleStep = "start"; + if (this.state.activeTab === '2') { + payload.scheduleStep = "create"; + payload.recurrence = values.recurrence; + } + } + if (values.testType === 'simple') { + if (!values.headers) { + values.headers = '{}'; + } + if (!values.body) { + values.body = '{}'; + } + if (!this.parseJson(values.headers.trim())) { + return alert('WARNING: headers text is not valid JSON'); + } + if (!this.parseJson(values.body.trim())) { + return alert('WARNING: body text is not valid JSON'); + } + + payload.testScenario.scenarios[values.testName] = { + requests: [ + { + url: values.endpoint, + method: values.method, + body: this.parseJson(values.body.trim()), + headers: this.parseJson(values.headers.trim()) + } + ] + }; + } else { + payload.testScenario.scenarios[values.testName] = { + script: `${testId}.jmx` + }; + + if (this.state.file) { try { - const response = await API.post('dlts', '/scenarios', { body: payload }); - console.log('Scenario created successfully', response.testId); - this.props.history.push({ pathname: `/details/${response.testId}`, state: { testId: response.testId } }); - } catch (err) { - console.error('Failed to create scenario', err); - this.setState({ isLoading: false }); + const file = this.state.file; + let filename = `${testId}.jmx`; + + if (file.type && file.type.includes('zip')) { + payload.fileType = 'zip'; + filename = `${testId}.zip`; + } else { + payload.fileType = 'script'; + } + + await Storage.put(`test-scenarios/jmeter/${filename}`, file); + console.log('Script uploaded successfully'); + } catch (error) { + console.error('Error', error); } + } } - setFormValue(key, value) { - const formValues = this.state.formValues; - formValues[key] = value; - this.setState({ formValues }); - } - - handleInputChange(event) { - const value = event.target.value; - const name = event.target.name; + this.setState({ isLoading: true }); - if (name === 'testType') { - this.setState({ file: null }); - } else if (name === 'onSchedule') { - this.setState({ submitLabel: value === '1' ? 'Schedule' : 'Run Now' }) - } - - this.setFormValue(name, value); + try { + const response = await API.post('dlts', '/scenarios', { body: payload }); + console.log('Scenario created successfully', response.testId); + this.props.history.push({ pathname: `/details/${response.testId}`, state: { testId: response.testId } }); + } catch (err) { + console.error('Failed to create scenario', err); + this.setState({ isLoading: false }); } - - handleBodyPayloadChange(value) { - this.setFormValue('body', value); + }; + + setFormValue(key, value, id) { + const formValues = this.state.formValues; + if (key === 'testTaskConfigs') { + const [subKey, index] = id.split('-'); + formValues[key][parseInt(index)][subKey] = value; + } else { + formValues[key] = value; } - - handleHeadersChange(value) { - this.setFormValue('headers', value); + this.setState({ formValues }); + } + + handleInputChange(event) { + const value = event.target.name === 'showLive' ? event.target.checked : event.target.value; + const name = event.target.name; + const id = event.target.id; + + if (name === 'testType') { + this.setState({ file: null }); + } else if (name === 'onSchedule') { + this.setState({ submitLabel: value === '1' ? 'Schedule' : 'Run Now' }); } - handleFileChange(event) { - const file = event.target.files[0]; - this.setState({ - file: null, - validFile: false - }); - - if (file) { - const { name, size } = file; - const extension = name.split('.').pop(); - - // Limit upload file size - if (size > FILE_SIZE_LIMIT) { - return alert(`WARNING: exceeded file size limit ${FILE_SIZE_LIMIT}`); - } - - // Limit file extension - if (!FILE_EXTENSIONS.includes(extension)) { - return alert(`WARNING: only allows (${FILE_EXTENSIONS.join(',')}) files.`); - } - - this.setState({ - file, - validFile: true - }); - } + this.setFormValue(name, value, id); + } + + handleBodyPayloadChange(value) { + this.setFormValue('body', value); + } + + handleHeadersChange(value) { + this.setFormValue('headers', value); + } + + handleFileChange(event) { + const file = event.target.files[0]; + this.setState({ + file: null, + validFile: false + }); + + if (file) { + const { name, size } = file; + const extension = name.split('.').pop(); + + // Limit upload file size + if (size > FILE_SIZE_LIMIT) { + return alert(`WARNING: exceeded file size limit ${FILE_SIZE_LIMIT}`); + } + + // Limit file extension + if (!FILE_EXTENSIONS.includes(extension)) { + return alert(`WARNING: only allows (${FILE_EXTENSIONS.join(',')}) files.`); + } + + this.setState({ + file, + validFile: true + }); } - - handleCheckBox(event) { - const { checked } = event.target; - if (checked) { - this.setState({ - validFile: false, - file: null - }); - } else { - this.setState({ validFile: true }); - } - this.setState({ chooseNewFile: checked }); + } + + handleCheckBox(event) { + const { checked } = event.target; + if (checked) { + this.setState({ + validFile: false, + file: null + }); + } else { + this.setState({ validFile: true }); } - - listTasks = async () => { - try { - const data = await API.get('dlts', '/tasks'); - if (data.length !== 0) { - this.setState({ runningTasks: true }); - this.setState({ availableTasks: 1000 - data.length }); - } - } catch (err) { - alert(err); - } - }; - - toggleTab(tab) { - if (this.state.activeTab !== tab) { - this.setState({ activeTab: tab }); - } + this.setState({ chooseNewFile: checked }); + } + + listTasks = async () => { + try { + const data = await API.get('dlts', '/tasks'); + if (data.length !== 0) { + this.setState({ runningTasks: true }); + this.setState({ availableTasks: 1000 - data.length }); + } + } catch (err) { + alert(err); + } + }; + + listRegions = async () => { + try { + const regions = []; + const data = await API.get('dlts', '/regions'); + for (const item of data.regions) { + regions.push(item.region); + } + this.setState({ availableRegions: regions }); + } catch (err) { + alert(err); } + }; - async componentDidMount() { - await this.listTasks(); + toggleTab(tab) { + if (this.state.activeTab !== tab) { + this.setState({ activeTab: tab }); } + } - render() { + async componentDidMount() { + await this.listTasks(); + await this.listRegions(); + } - const cancel = () => { - return this.state.testId === null ? - this.props.history.push('/') : - this.props.history.push({ pathname: `/details/${this.state.testId}`, state: { testId: this.state.testId } }) - } + render() { - const heading = ( -
-

{this.state.testId === null ? 'Create' : 'Update'} Load Test

-
- ) - const currentDate = new Date().toISOString().split('T')[0]; - const createTestForm = ( -
- - -
-

General Settings

- - - - - The name of your load test, doesn't have to be unique. - - - - - - - Short description of the test scenario. - - - - - - - Number of docker containers that will be launched in the Fargate cluster to run the - test scenario, max value 1000. ({this.state.availableTasks} available). - - - - - - - - The number of concurrent virtual users generated per task. The recommended limit based on default settings is 200 virtual users. - Concurrency is limited by CPU and Memory. Please see the   - - implementation guide - -  for instructions on how to determine the amount concurrency your test can support. - - - - - - -   - - - - - - - - The time to reach target concurrency. - - - - - - -   - - - - - - - Time to hold target concurrency. - - - - - - - - - - - - - -   - - -   - -   - - - - - The date and time(UTC) to run the test. - - - - - - - -   - -   - -   - - - - The date and time(UTC) to first run the test. - - - - - -   - - - - - - - How often to run the test. - - - - - -
+ const cancel = () => { + return this.state.testId === null ? + this.props.history.push('/') : + this.props.history.push({ pathname: `/details/${this.state.testId}`, state: { testId: this.state.testId } }); + }; + + const heading = ( +
+

{this.state.testId === null ? 'Create' : 'Update'} Load Test

+
+ ); + const currentDate = new Date().toISOString().split('T')[0]; + const maxRegions = Math.min(this.state.availableRegions.length, 5); + const createTestForm = ( +
+ + +
+

General Settings

+ + + + + The name of your load test, doesn't have to be unique. + + + + + + + Short description of the test scenario. + + + + + + + + + + + + + + + {this.state.formValues.testTaskConfigs.map((value, index) => ( + + + + + + - -
-

Scenario

- - - - - - - - { - this.state.formValues.testType === 'simple' && -
- - - - - Target URL to run tests against, supports http and https. i.e. - https://example.com:8080. - - - - - - - - - - - - - The request method, default is GET. - - - - - - - A valid JSON object key-value pair containing headers to include in the requests. - - - - - - - A valid JSON object containing any body text to include in the requests. - - -
- } - { - this.state.formValues.testType !== 'simple' && -
- { - ['zip', 'script'].includes(this.state.formValues.fileType) && - - - - } - { - ((this.state.formValues.testType !== 'simple' && !['zip', 'script'].includes(this.state.formValues.fileType)) || this.state.chooseNewFile) && - - - - - You can choose either a .jmx file or a .zip file. Choose .zip file if you have any files to upload other than a .jmx script file. - - - } -
- } - - -
+ + + + {this.state.availableRegions.map((region) => ( + + ))} + + + {index === this.state.formValues.testTaskConfigs.length - 1 && + + + + + } + +
+ ))} + + + + Task Count: Number of docker containers that will be launched in the Fargate cluster to run the + test scenario, max value 1000. ({this.state.availableTasks} available).
+ Concurrency: The number of concurrent virtual users generated per task. The recommended limit based on default settings is 200 virtual users. + Concurrency is limited by CPU and Memory. Please see the   + + implementation guide + +  for instructions on how to determine the amount concurrency your test can support.
+ Region: The region to launch the given task count and concurrency
+
+
+
+ + + + +   + + + + + + + The time to reach target concurrency. + + + + + + +   + + + + + + + Time to hold target concurrency. + + + + + + Schedule a test or or run now + + + + + + + + +   + + +   + +   + + + + + The date and time(UTC) to run the test. + + + + + + + +   + +   + +   + + + + The date and time(UTC) to first run the test. + + + + + +   + + + + + + + How often to run the test. + + + + + + + + + + + Includes live data while the test is running. +
- ); - - return ( -
-
e.preventDefault()}> - - {heading} - -
- {this.state.isLoading ?
: createTestForm} -
-
+ + +
+

Scenario

+ + + + + + + + { + this.state.formValues.testType === 'simple' && +
+ + + + + Target URL to run tests against, supports http and https. i.e. + https://example.com:8080. + + + + + + + + + + + + The request method, default is GET. + + + + + + + A valid JSON object key-value pair containing headers to include in the requests. + + + + + + + A valid JSON object containing any body text to include in the requests. + + +
+ } + { + this.state.formValues.testType !== 'simple' && +
+ { + ['zip', 'script'].includes(this.state.formValues.fileType) && + + + + } + { + ((this.state.formValues.testType !== 'simple' && !['zip', 'script'].includes(this.state.formValues.fileType)) || this.state.chooseNewFile) && + + + + + You can choose either a .jmx file or a .zip file. Choose .zip file if you have any files to upload other than a .jmx script file. + + + } +
+ } + +
- ) - } + + +
+ ); + + return ( +
+
e.preventDefault()}> + + {heading} + +
+ {this.state.isLoading ?
: createTestForm} +
+
+
+ ); + } } export default Create; diff --git a/source/console/src/Components/Dashboard/Dashboard.js b/source/console/src/Components/Dashboard/Dashboard.js index f26b18f..0fe8bff 100644 --- a/source/console/src/Components/Dashboard/Dashboard.js +++ b/source/console/src/Components/Dashboard/Dashboard.js @@ -4,112 +4,110 @@ import React from 'react'; import { Table, Spinner, Button } from 'reactstrap'; import { Link } from "react-router-dom"; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faArrowAltCircleRight } from '@fortawesome/free-solid-svg-icons'; import { API } from 'aws-amplify'; class Dashboard extends React.Component { - constructor(props) { - super(props); - this.state = { - Items: [], - isLoading: true - } - } - - getItems = async () => { - this.setState({ - Items: [], - isLoading: true - }); + constructor(props) { + super(props); + this.state = { + Items: [], + isLoading: true + }; + } - try { - const data = await API.get('dlts', '/scenarios'); - data.Items.sort((a, b) => { - if (!a.startTime) a.startTime = ''; - if (!b.startTime) b.startTime = ''; - return b.startTime.localeCompare(a.startTime) - }); + getItems = async () => { + this.setState({ + Items: [], + isLoading: true + }); - this.setState({ - Items: data.Items, - isLoading: false - }); - } catch (err) { - alert(err); - } - }; + try { + const data = await API.get('dlts', '/scenarios'); + data.Items.sort((a, b) => { + if (!a.startTime) a.startTime = ''; + if (!b.startTime) b.startTime = ''; + return b.startTime.localeCompare(a.startTime); + }); - componentDidMount() { - this.getItems(); + this.setState({ + Items: data.Items, + isLoading: false + }); + } catch (err) { + alert(err); } + }; - render() { - const { Items } = this.state; + componentDidMount() { + this.getItems(); + } - const welcome = ( -
-

To get started select Create test from the top menu.

-
- ) + render() { + const { Items } = this.state; - const tableBody = ( - - { - Items.map(item => ( - - {item.testName} - {item.testId} - {item.testDescription} - {item.startTime} - {item.status} - {item.nextRun} - {item.scheduleRecurrence} - - - - - - - )) - } - - ) + const welcome = ( +
+

To get started select Create test from the top menu.

+
+ ); - return ( -
-
-

Test Scenarios

- -
-
- - - - - - - - - - - - - - { tableBody } -
NameIdDescriptionLast Run (UTC)StatusNext Run (UTC)RecurrenceDetails
- { - this.state.isLoading && -
- -
- } -
- { !this.state.isLoading && Items.length === 0 && welcome } + const tableBody = ( + + { + Items.map(item => ( + + {item.testName} + {item.testId} + {item.testDescription} + {item.startTime} + {item.status} + {item.nextRun} + {item.scheduleRecurrence} + + + + + + + )) + } + + ); + + return ( +
+
+

Test Scenarios

+ +
+
+ + + + + + + + + + + + + + {tableBody} +
NameIdDescriptionLast Run (UTC)StatusNext Run (UTC)RecurrenceDetails
+ { + this.state.isLoading && +
+
- ) - } + } +
+ {!this.state.isLoading && Items.length === 0 && welcome} +
+ ); + } } export default Dashboard; diff --git a/source/console/src/Components/Details/Details.js b/source/console/src/Components/Details/Details.js index 82612a7..b2d47c1 100644 --- a/source/console/src/Components/Details/Details.js +++ b/source/console/src/Components/Details/Details.js @@ -3,463 +3,351 @@ import React from 'react'; import { Link } from "react-router-dom"; -import { Button, Modal, ModalHeader, ModalBody, ModalFooter, Row, Col, Spinner } from 'reactstrap'; +import { Button, Modal, ModalHeader, ModalBody, ModalFooter, Spinner } from 'reactstrap'; import { API, Storage } from 'aws-amplify'; import Results from '../Results/Results.js'; import Running from '../Running/Running.js'; import History from '../History/History.js'; +import DetailsTable from './DetailsTable'; -import AceEditor from 'react-ace'; import 'brace'; import 'brace/theme/github'; class Details extends React.Component { - constructor(props) { - super(props); - this.state = { - isLoading: true, - runningTasks: false, - deleteModal: false, - cancelModal: false, - testId: props.match.params.testId, - testDuration: 0, - data: { - testName: null, - testDescription: null, - testType: null, - fileType: null, - results: {}, - history: [], - taskCount: null, - concurrency: null, - rampUp: null, - holdFor: null, - endpoint: null, - method: null, - taskArns: [], - testScenario: { - execution: [], - reporting: [], - scenarios: {} - }, - scheduleDate: null, - scheduleTime: null, - recurrence: null - } - } - this.clickedLink = this.clickedLink.bind(this); - this.deleteToggle = this.deleteToggle.bind(this); - this.deleteTest = this.deleteTest.bind(this); - this.cancelToggle = this.cancelToggle.bind(this); - this.cancelTest = this.cancelTest.bind(this); - this.handleStart = this.handleStart.bind(this); - this.handleDownload = this.handleDownload.bind(this); - this.caculateTestDurationSeconds = this.caculateTestDurationSeconds.bind(this); + constructor(props) { + super(props); + this.state = { + isLoading: true, + runningTasks: false, + deleteModal: false, + cancelModal: false, + isCurrentTestRunning: true, + testId: props.match.params.testId, + testDuration: 0, + data: { + completeTasks: {}, + testName: null, + testDescription: null, + testType: null, + fileType: null, + results: {}, + history: [], + testTaskConfigs: [], + rampUp: null, + holdFor: null, + endpoint: null, + method: null, + taskArns: [], + testScenario: { + execution: [], + reporting: [], + scenarios: {} + }, + scheduleDate: null, + scheduleTime: null, + recurrence: null + } + }; + this.deleteToggle = this.deleteToggle.bind(this); + this.deleteTest = this.deleteTest.bind(this); + this.cancelToggle = this.cancelToggle.bind(this); + this.cancelTest = this.cancelTest.bind(this); + this.handleStart = this.handleStart.bind(this); + this.handleDownload = this.handleDownload.bind(this); + this.calculateTestDurationSeconds = this.calculateTestDurationSeconds.bind(this); + this.handleFullTestDataLocation = this.handleFullTestDataLocation.bind(this); + } + + deleteToggle() { + this.setState(prevState => ({ + deleteModal: !prevState.deleteModal + })); + } + + cancelToggle() { + this.setState(prevState => ({ + cancelModal: !prevState.cancelModal + })); + } + + deleteTest = async () => { + const testId = this.state.testId; + try { + await API.del('dlts', `/scenarios/${testId}`); + } catch (err) { + alert(err); } - - - deleteToggle() { - this.setState(prevState => ({ - deleteModal: !prevState.deleteModal - })); + this.props.history.push("../dashboard"); + }; + + cancelTest = async () => { + const testId = this.state.testId; + try { + await API.post('dlts', `/scenarios/${testId}`); + } catch (err) { + alert(err); } - - cancelToggle() { - this.setState(prevState => ({ - cancelModal: !prevState.cancelModal - })); + this.props.history.push("../dashboard"); + }; + + reloadData = async () => { + this.setState({ + isLoading: true, + data: { + results: {}, + history: [], + testTaskConfigs: [], + rampUp: null, + holdFor: null, + endpoint: null, + method: null, + body: {}, + header: {}, + taskArns: [] + } + }); + await this.getTest(); + this.setState({ isLoading: false }); + }; + + // sets common state values to be presented, either current or past tests + setTestData(data) { + data.rampUp = data.testScenario.execution[0]['ramp-up']; + data.holdFor = data.testScenario.execution[0]['hold-for']; + const testDuration = this.calculateTestDurationSeconds([data.rampUp, data.holdFor]); + if (!data.testType || ['', 'simple'].includes(data.testType)) { + data.testType = 'simple'; + data.endpoint = data.testScenario.scenarios[`${data.testName}`].requests[0].url; + data.method = data.testScenario.scenarios[`${data.testName}`].requests[0].method; + data.body = data.testScenario.scenarios[`${data.testName}`].requests[0].body; + data.headers = data.testScenario.scenarios[`${data.testName}`].requests[0].headers; } - deleteTest = async () => { - const testId = this.state.testId; - try { - await API.del('dlts', `/scenarios/${testId}`); - } catch (err) { - alert(err); - } - this.props.history.push("../dashboard"); + this.setState({ data, testDuration }); + this.setState({ isCurrentTestRunning: data.status === 'running' }); + } + + getTest = async () => { + const testId = this.state.testId; + try { + const data = await API.get('dlts', `/scenarios/${testId}`); + if (data.nextRun) { + const [scheduleDate, scheduleTime] = data.nextRun.split(' '); + data.scheduleDate = scheduleDate; + data.scheduleTime = scheduleTime.split(':', 2).join(':'); + data.recurrence = data.scheduleRecurrence; + delete data.nextRun; + } + this.setTestData(data); + this.setState({ isCurrentTestRunning: data.status === 'running' }); + } catch (err) { + console.error(err); + alert(err); } - - cancelTest = async () => { - const testId = this.state.testId; - try { - await API.post('dlts', `/scenarios/${testId}`); - } catch (err) { - alert(err); - } - this.props.history.push("../dashboard"); + }; + + listTasks = async () => { + try { + const data = await API.get('dlts', '/tasks'); + this.setState({ + isLoading: false, + runningTasks: data.length > 0 + }); + } catch (err) { + alert(err); } - - reloadData = async () => { - this.setState({ - isLoading: true, - data: { - results: {}, - history: [], - concurrency: null, - rampUp: null, - holdFor: null, - endpoint: null, - method: null, - body: {}, - header: {}, - taskArns: [] - } - }) - await this.getTest(); - this.setState({ isLoading: false }); + }; + + calculateTestDurationSeconds = (items) => { + let seconds = 0; + + for (let item of items) { + // On the UI, the item format would be always Xm or Xs (X is a number). + if (item.endsWith('m')) { + seconds += parseInt(item.slice(0, item.length - 1)) * 60; + } else { + seconds += parseInt(item.slice(0, item.length - 1)); + } } - // sets newTestId to be the past test ID and calls getHistoricalTest to retrieve values - clickedLink = async (testId) => { - this.getHistoricalTest(testId); - } + return seconds; + }; - // Sets the state with values from a previous run of the test - getHistoricalTest(testId) { - try { - const testRun = this.state.data.history - .find((e) => e.id === testId); - const data = { - ...testRun, - testName: testRun.testScenario.execution[0].scenario, - history: this.state.data.history, - testId: this.state.data.testId, - } - this.setTestData(data); - } catch (err) { - alert(err); - } + componentDidMount = async () => { + if (!this.state.testId) { + this.props.history.push('../'); + } else { + await this.getTest(); + await this.listTasks(); } - - // sets common state values to be presented, either current or past tests - setTestData(data) { - data.concurrency = data.testScenario.execution[0].concurrency; - data.rampUp = data.testScenario.execution[0]['ramp-up']; - data.holdFor = data.testScenario.execution[0]['hold-for']; - const testDuration = this.caculateTestDurationSeconds([data.rampUp, data.holdFor]); - if (!data.testType || ['', 'simple'].includes(data.testType)) { - data.testType = 'simple'; - data.endpoint = data.testScenario.scenarios[`${data.testName}`].requests[0].url; - data.method = data.testScenario.scenarios[`${data.testName}`].requests[0].method; - data.body = data.testScenario.scenarios[`${data.testName}`].requests[0].body; - data.headers = data.testScenario.scenarios[`${data.testName}`].requests[0].headers; - } - this.setState({ data, testDuration }); - } - - getTest = async () => { - const testId = this.state.testId; - try { - const data = await API.get('dlts', `/scenarios/${testId}`); - if (data.nextRun) { - const [scheduleDate, scheduleTime] = data.nextRun.split(' '); - data.scheduleDate = scheduleDate; - data.scheduleTime = scheduleTime.split(':', 2).join(':'); - data.recurrence = data.scheduleRecurrence; - delete data.nextRun; - } - this.setTestData(data); - } catch (err) { - console.error(err); - alert(err); - } - } - - listTasks = async () => { - try { - const data = await API.get('dlts', '/tasks'); - this.setState({ - isLoading: false, - runningTasks: data.length > 0 - }); - } catch (err) { - alert(err); + }; + + async handleStart() { + const testId = this.state.testId; + const { data } = this.state; + let payload = { + testId, + testName: data.testName, + testDescription: data.testDescription, + testTaskConfigs: data.testTaskConfigs, + testScenario: { + execution: [{ + "ramp-up": data.rampUp, + "hold-for": data.holdFor, + scenario: data.testName, + }], + scenarios: { + [data.testName]: {} } + }, + showLive: data.showLive, + testType: data.testType, + scheduleData: data.scheduleDate, + scheduleTime: data.scheduleTime, + recurrence: data.recurrence }; - - caculateTestDurationSeconds = (items) => { - let seconds = 0; - - for (let item of items) { - // On the UI, the item format would be always Xm or Xs (X is a number). - if (item.endsWith('m')) { - seconds += parseInt(item.slice(0, item.length - 1)) * 60; - } else { - seconds += parseInt(item.slice(0, item.length - 1)); - } - } - - return seconds; + const hasEmptyRegion = data.testTaskConfigs.some(taskConfigs => taskConfigs.taskCluster === ""); + if (hasEmptyRegion) { + alert("The test contains a region that may have been deleted, if you wish to run this test, please edit the test to remove the deleted region."); + return; } - - componentDidMount = async () => { - if (!this.state.testId) { - this.props.history.push('../'); - } else { - await this.getTest(); - await this.listTasks(); - } + if (data.testType === 'simple') { + payload.testScenario.scenarios[data.testName] = { + requests: [ + { + url: data.endpoint, + method: data.method, + body: data.body, + headers: data.headers + } + ] + }; + } else { + payload.testScenario.scenarios[data.testName] = { + script: `${testId}.jmx` + }; + payload.fileType = data.fileType; } - async handleStart() { - const testId = this.state.testId; - const { data } = this.state; - let payload = { - testId, - testName: data.testName, - testDescription: data.testDescription, - taskCount: data.taskCount, - testScenario: { - execution: [{ - concurrency: data.concurrency, - "ramp-up": data.rampUp, - "hold-for": data.holdFor, - scenario: data.testName, - }], - scenarios: { - [data.testName]: {} - } - }, - testType: data.testType - }; - - if (data.testType === 'simple') { - payload.testScenario.scenarios[data.testName] = { - requests: [ - { - url: data.endpoint, - method: data.method, - body: data.body, - headers: data.headers - } - ] - }; - } else { - payload.testScenario.scenarios[data.testName] = { - script: `${testId}.jmx` - }; - payload.fileType = data.fileType; - } - - this.setState({ isLoading: true }); + this.setState({ isLoading: true }); - try { - const response = await API.post('dlts', '/scenarios', { body: payload }); - console.log('Scenario started successfully', response.testId); - await this.reloadData(); - } catch (err) { - console.error('Failed to start scenario', err); - this.setState({ isLoading: false }); - } + try { + const response = await API.post('dlts', '/scenarios', { body: payload }); + console.log('Scenario started successfully', response.testId); + await this.reloadData(); + } catch (err) { + console.error('Failed to start scenario', err); + this.setState({ isLoading: false }); } - - async handleDownload() { - try { - const testId = this.state.testId; - const { testType } = this.state.data; - - let filename = this.state.data.fileType === 'zip' ? `${testId}.zip` : `${testId}.jmx` - const url = await Storage.get(`test-scenarios/${testType}/${filename}`, { expires: 10 }); - window.open(url, '_blank'); - } catch (error) { - console.error('Error', error); - } + } + + async handleDownload() { + try { + const testId = this.state.testId; + const { testType } = this.state.data; + let filename = this.state.data.fileType === 'zip' ? `${testId}.zip` : `${testId}.jmx`; + const url = await Storage.get(`test-scenarios/${testType}/${filename}`, { expires: 10 }); + window.open(url, '_blank'); + } catch (error) { + console.error('Error', error); } - - render() { - const { data, testDuration } = this.state; - - const cancelled = ( -
-

Test Results

-

No results available as the test was cancelled.

-
- ) - - const failed = ( -
-

Test Failed

-
-
{JSON.stringify(data.taskError, null, 2) || data.errorReason}
-
-
- ) - - const details = ( -
-
-

Load Test Details

- { - data.status === 'running' ? - [ - , - - ] : - [ - , - - - , - - ] - } -
-
- - - - ID - {data.testId} - - - NAME - {data.testName} - - - DESCRIPTION - {data.testDescription} - - { - (!data.testType || ['', 'simple'].includes(data.testType)) && -
- - ENDPOINT - {data.endpoint} - - - METHOD - {data.method} - - - HEADERS - - - - - - BODY - - - - -
- } - { - data.testType && data.testType !== '' && data.testType !== 'simple' && - - {data.fileType === 'zip' ? 'ZIP' : 'SCRIPT'} - - - } - - - - STATUS - {data.status} - - - STARTED AT - {data.startTime} - - { - data.status === 'complete' && - - ENDED AT - {data.endTime} - - } - { - data.recurrence && data.recurrence !== '' && - - RECURRENCE - {data.recurrence} - - } - - TASK COUNT - {data.taskCount} {data.status === 'complete' && data.completeTasks !== undefined && `(${data.completeTasks} completed)`} - - - - CONCURRENCY - {data.concurrency} - - - RAMP UP - {data.rampUp} - - - HOLD FOR - {data.holdFor} - - -
-
- - {data.status === 'complete' && } - {data.status === 'cancelled' && cancelled} - {data.status === 'failed' && failed} - {data.status === 'running' && } - {data.status !== 'running' && } - -
- ) - - return ( -
- {this.state.isLoading ?
: details} - - Warning - - This will delete the test scenario and all of of the results - - - - - - - - - Warning - - This will stop all running tasks and end the test. - - - - - - - -
- ) + } + + async handleFullTestDataLocation() { + try { + const testId = this.state.testId; + const url = `https://console.aws.amazon.com/s3/buckets/${Storage._config.AWSS3.bucket}?prefix=results/${testId}/`; + window.open(url, '_blank'); + } catch (error) { + console.error('Failed to open S3 location for test run results', error); } + } + + render() { + const { data, testDuration } = this.state; + const cancelled = ( +
+

Test Results

+

No results available as the test was cancelled.

+
+ ); + + const failed = ( +
+

Test Failed

+
+
{JSON.stringify(data.taskError, null, 2) || data.errorReason}
+
+
+ ); + const isCurrentTestFinished = data.results && Object.keys(data.results).length === 0; + const refreshButtonText = isCurrentTestFinished ? 'Refresh' : 'Back to Current Test'; + const details = ( +
+
+

Load Test Details

+ { + this.state.isCurrentTestRunning ? + [ + isCurrentTestFinished && , + + ] : + [ + , + + + , + + ] + } +
+ + + { + data.status === 'complete' && + region !== 'total')} + />} + {data.status === 'cancelled' && cancelled} + {data.status === 'failed' && failed} + {data.status === 'running' && } + {data.history && } + +
+ ); + + return ( +
+ {this.state.isLoading ?
: details} + + Warning + + This will delete the test scenario and all of of the results + + + + + + + + + Warning + + This will stop all running tasks and end the test. + + + + + + + +
+ ); + } } export default Details; diff --git a/source/console/src/Components/Details/DetailsTable.js b/source/console/src/Components/Details/DetailsTable.js new file mode 100644 index 0000000..991e6ca --- /dev/null +++ b/source/console/src/Components/Details/DetailsTable.js @@ -0,0 +1,150 @@ +import React from 'react'; +import { Button, Row, Col } from 'reactstrap'; + +import AceEditor from 'react-ace'; +import 'brace'; +import 'brace/theme/github'; + +class DetailsTable extends React.Component { + + constructor(props) { + super(props); + this.listTestTasks = this.listTestTasks.bind(this); + } + + listTestTasks = (regionEntry) => { + const data = this.props.data; + const testRegion = regionEntry.region; + return

{testRegion} : {regionEntry.taskCount} {data.status === 'complete' && data.completeTasks !== undefined && `(${data.completeTasks[testRegion]} completed)`}

; + }; + + render() { + const data = this.props.data; + return ( +
+ + + + ID + {data.testId} + + + NAME + {data.testName} + + + DESCRIPTION + {data.testDescription} + + { + (!data.testType || ['', 'simple'].includes(data.testType)) && +
+ + ENDPOINT + {data.endpoint} + + + METHOD + {data.method} + + + HEADERS + + + + + + BODY + + + + +
+ } + { + data.status === 'complete' && + + FULL TEST RESULTS + + You must be logged into your AWS account to access the results in S3 + } + { + data.testType && data.testType !== '' && data.testType !== 'simple' && + + {data.fileType === 'zip' ? 'ZIP' : 'SCRIPT'} + + + } + + + + STATUS + {data.status} + + + STARTED AT + {data.startTime} + + { + data.status === 'complete' && + + ENDED AT + {data.endTime} + + } + { + data.recurrence && data.recurrence !== '' && + + RECURRENCE + {data.recurrence} + + } + + TASK COUNT + {data.testTaskConfigs.map((regionEntry) => { return this.listTestTasks(regionEntry); })} + + + + CONCURRENCY + {data.testTaskConfigs.map((regionEntry) => { return

{regionEntry.region} : {regionEntry.concurrency}

; })} +
+ + RAMP UP + {data.rampUp} + + + HOLD FOR + {data.holdFor} + + +
+
+ ); + } +} + +export default DetailsTable; \ No newline at end of file diff --git a/source/console/src/Components/History/History.js b/source/console/src/Components/History/History.js index 436e506..929c789 100644 --- a/source/console/src/Components/History/History.js +++ b/source/console/src/Components/History/History.js @@ -2,66 +2,113 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { Table } from 'reactstrap'; -import { HashLink } from "react-router-hash-link"; +import { Modal, ModalBody, ModalHeader, Table } from 'reactstrap'; +import DetailsTable from '../Details/DetailsTable.js'; +import Results from '../Results/Results.js'; -class Results extends React.Component { - constructor(props) { - super(props); - this.clickedLink = props.clickedLink; - this.state = { - rowIsActive: 0 - } - } +class History extends React.Component { + constructor(props) { + super(props); + this.getHistoricalTest = this.getHistoricalTest.bind(this); + this.state = { + rowIsActive: -1 + }; + } - activeRow(index) { - this.setState({ - rowIsActive: index - }); - } + activeRow(index) { + this.setState({ + rowIsActive: index + }); + } - onClick(id, index) { - this.clickedLink(id); - this.activeRow(index); - } + onClick(index) { + this.activeRow(index); + } - render() { - const history = this.props.data.history || []; - return ( -
-
-

Results History

- - - - - - - - - - - - - { - history.map((i, index) => ( - - - - - - - - - )) - } - -
Run TimeTask CountConcurrencyAverage Response TimeSuccess %
{i.endTime}{i.taskCount}{i.results.concurrency}{i.results.avg_rt}s{i.succPercent}% this.onClick(i.id, index)} to="#TestResults">View details
-
-
- ) + //Sets the state with values from a previous run of the test + getHistoricalTest(testRun) { + try { + let data = { + ...testRun, + testName: testRun.testScenario.execution[0].scenario, + testId: this.props.data.testId, + }; + data.rampUp = data.testScenario.execution[0]['ramp-up']; + data.holdFor = data.testScenario.execution[0]['hold-for']; + if (!data.testType || ['', 'simple'].includes(data.testType)) { + data.testType = 'simple'; + data.endpoint = data.testScenario.scenarios[`${data.testName}`].requests[0].url; + data.method = data.testScenario.scenarios[`${data.testName}`].requests[0].method; + data.body = data.testScenario.scenarios[`${data.testName}`].requests[0].body; + data.headers = data.testScenario.scenarios[`${data.testName}`].requests[0].headers; + } + return data; + } catch (err) { + alert(err); } + } + + render() { + const history = this.props.data.history || []; + return ( +
+
+

Results History

+ + + + + + + + + + + + + { + history.map((i, index) => { + const loadInfo = i.testTaskConfigs.reduce((previous, current) => ( + { + taskCount: previous.taskCount + current.taskCount, + concurrency: previous.concurrency + current.concurrency + } + )); + const data = this.getHistoricalTest(i); + return ( + + + + + + + + { + this.state.rowIsActive === index && + { this.setState({ rowIsActive: -1 }) }}> + { this.setState({ rowIsActive: -1 }) }}> +

Test run from {i.endTime}

+
+ + + region !== 'total')} + > + +
+ } + + ); + }) + } + +
Run TimeTotal Task CountTotal ConcurrencyAverage Response TimeSuccess %
{i.endTime}{loadInfo.taskCount}{loadInfo.concurrency}{i.results.total?.avg_rt}s{i.succPercent}%
this.onClick(index)}>View details
+
+
+ ); + } } -export default Results; +export default History; diff --git a/source/console/src/Components/RegionalModal/RegionalModal.js b/source/console/src/Components/RegionalModal/RegionalModal.js new file mode 100644 index 0000000..3569e44 --- /dev/null +++ b/source/console/src/Components/RegionalModal/RegionalModal.js @@ -0,0 +1,121 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { Modal, ModalHeader, ModalBody, Row, Col, Button, ListGroup, ListGroupItem, Tooltip } from 'reactstrap'; +import { API } from 'aws-amplify'; + +class RegionalModal extends React.Component { + constructor(props) { + super(props); + this.state = { + availableRegions: [], + cfUrl: '', + tooltipOpen: false, + tooltipLanguage: 'Copy URL' + }; + this.listRegions = this.listRegions.bind(this); + this.toggle = this.toggle.bind(this); + } + + toggle() { + let tooltipLanguage; + if (this.state.tooltipOpen === false) { + tooltipLanguage = 'Copy URL'; + } + this.setState({ + tooltipOpen: !this.state.tooltipOpen, + tooltipLanguage: tooltipLanguage + }); + } + + handleCopyClick() { + navigator.clipboard.writeText(this.state.cfUrl); + this.setState({ tooltipLanguage: 'Copied!' }); + } + + listRegions = async () => { + try { + const regions = []; + const data = await API.get('dlts', '/regions'); + for (const item of data.regions) { + regions.push(item.region); + } + this.setState({ availableRegions: regions, cfUrl: data.url }); + } catch (err) { + alert(err); + } + }; + + componentDidMount() { + this.listRegions(); + } + + render() { + return ( + + + Manage Regions + + + + Available Regions + + + + { + this.state.availableRegions.map((region) => ( + + {region} + + )) + } + + + + + + Add A Region + + Add a new region to enable multi-region load testing:  +
    +
  • Copy the link for the regional CloudFormation template   + + {this.state.tooltipLanguage} +
  • +
  • Navigate to the CloudFormation console
  • +
  • Select the appropriate region from the dropdown menu in the upper right hand corner
  • +
  • Paste the link the Amazon S3 URL area
  • +
  • Launch the CloudFormation Stack
  • +
+ + + Regional Deployment CloudFormation Template URL:  + + {this.state.cfUrl} + + +
+ + + + + Delete A Region + + Remove a region:  +
    +
  • Navigate to the CloudFormation console
  • +
  • Select the appropriate region from the dropdown menu in the upper right hand corner
  • +
  • Select the appropriate CloudFormation stack and click the Delete button to delete the regional deployment
  • +
+ +
+ +
+
+ ); + } + +} + +export default RegionalModal; diff --git a/source/console/src/Components/Results/Results.js b/source/console/src/Components/Results/Results.js index 87bb973..c5a33cb 100644 --- a/source/console/src/Components/Results/Results.js +++ b/source/console/src/Components/Results/Results.js @@ -2,334 +2,546 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { Row, Col, Button, Popover, PopoverHeader, PopoverBody, Nav, NavItem, NavLink, TabContent, TabPane } from 'reactstrap'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'; +import { Row, Col, Button, Popover, PopoverHeader, PopoverBody, Nav, NavItem, NavLink, TabContent, TabPane, Tooltip, Input, Modal, ModalHeader, ModalBody } from 'reactstrap'; import { Storage } from 'aws-amplify'; +import AceEditor from 'react-ace'; class Results extends React.Component { - constructor(props) { - super(props); - - this.toggle = this.toggle.bind(this); - this.toggleTab = this.toggleTab.bind(this); - this.showResult = this.showResult.bind(this); - this.caculateBandwidth = this.caculateBandwidth.bind(this); - this.state = { - info: false, - activeTab: 'summary', - metricImage: undefined, - metricImageLocation: undefined - }; + constructor(props) { + super(props); + + this.toggle = this.toggle.bind(this); + this.toggleTab = this.toggleTab.bind(this); + this.showResult = this.showResult.bind(this); + this.calculateBandwidth = this.calculateBandwidth.bind(this); + this.handleRegionChange = this.handleRegionChange.bind(this); + this.getTotalGraphSource = this.getTotalGraphSource.bind(this); + this.tooltipToggle = this.tooltipToggle.bind(this); + this.handleCopyClick = this.handleCopyClick.bind(this); + this.state = { + info: false, + activeTab: 'summary', + metricImage: undefined, + metricImageLocation: undefined, + selectedRegion: this.props.regions[0], + showTotalGraphDirections: false, + tooltipOpen: false, + tooltipLanguage: "Copy Code" + }; + } + + toggle() { + this.setState({ + info: !this.state.info + }); + } + + toggleTab(tab) { + if (this.state.activeTab !== tab) { + this.setState({ activeTab: tab }); } + } - toggle() { - this.setState({ - info: !this.state.info - }); + /** + * calculate the bandwidth. + * @param {number} bandwidth Test total download bytes + * @param {number} duration Test duration seconds + * @return {string} Calculated bandwidth + */ + calculateBandwidth(bandwidth, duration) { + if (isNaN(bandwidth) || isNaN(duration) || duration === 0) { + return '-'; } - toggleTab(tab) { - if (this.state.activeTab !== tab) { - this.setState({ activeTab: tab }); - } + bandwidth = Math.round(bandwidth * 100 / duration / 8) / 100; // Initially, Bps + let units = 'Bps'; + + while (bandwidth > 1024) { + switch (units) { + case 'Bps': + units = 'Kbps'; + break; + case 'Kbps': + units = 'Mbps'; + break; + case 'Mbps': + units = 'Gbps'; + break; + default: + return `${bandwidth} ${units}`; + } + + bandwidth = Math.round(bandwidth * 100 / 1024) / 100; } - /** - * Caculate the bandwidth. - * @param {number} bandwidth Test total download bytes - * @param {number} duration Test duration seconds - * @return {string} Caculcated bandwidth - */ - caculateBandwidth(bandwidth, duration) { - if (isNaN(bandwidth) || isNaN(duration) || duration === 0) { - return '-'; - } + return `${bandwidth} ${units}`; + } - bandwidth = Math.round(bandwidth * 100 / duration / 8) / 100; // Initially, Bps - let units = 'Bps'; - - while (bandwidth > 1024) { - switch (units) { - case 'Bps': - units = 'Kbps'; - break; - case 'Kbps': - units = 'Mbps'; - break; - case 'Mbps': - units = 'Gbps'; - break; - default: - return `${bandwidth} ${units}`; - } - - bandwidth = Math.round(bandwidth * 100 / 1024) / 100; - } + /** + * Retrieve the CloudWatch widget image from S3 + * Store the base64 encoded image string in state + * @param {string} metricS3ImageLocation + */ + retrieveImage = async (metricS3ImageLocation) => { + try { + const image = await Storage.get(metricS3ImageLocation, { contentType: 'data:image/jpeg;base64', download: true }); + const imageBodyText = await image.Body.text(); + this.setState({ metricImage: imageBodyText }); + } catch (error) { + console.error('There was an error trying to retrieve the CloudWatch widget image from S3: ', error); + this.setState({ metricImage: undefined }); + } + } + + /** + * Checks if the variable is undefined + * @param {any} value The input variable + * @returns {boolean} Returns true if the variable is undefined + */ + isUndefined(value) { + return typeof value === 'undefined'; + } - return `${bandwidth} ${units}`; + /* + * Initial load of the CloudWatch widget image depending upon set value returned in `this.props.data` + * Either `this.props.data.metricS3Location` will be populated (if base64 image string is stored in S3) + * or `this.props.data.metricWidgetImage` will be populated (if base64 image string is stored in DynamoDB) + */ + componentDidMount = async () => { + if (!this.isUndefined(this.props.data.results.total.metricS3Location)) { + await this.retrieveImage(this.props.data.results.total.metricS3Location); + this.setState({ metricImageLocation: this.props.data.results.total.metricS3Location }); + } else if (!this.isUndefined(this.props.data.metricWidgetImage)) { + this.setState({ metricImage: this.props.data.metricWidgetImage }); + } else { + console.log("The CloudWatch metric widget could not be retrieved."); + this.setState({ + metricImage: undefined, + metricImageLocation: undefined + }); } + } - /** - * Retrieve the CloudWatch widget image from S3 - * Store the base64 encoded image string in state - * @param {string} metricS3ImageLocation - */ - retrieveImage = async (metricS3ImageLocation) => { - try { - const image = await Storage.get(metricS3ImageLocation, { contentType: 'data:image/jpeg;base64', download: true }); - const imageBodyText = await image.Body.text(); - this.setState({ metricImage: imageBodyText }); - } catch (error) { - console.error('There was an error trying to retrieve the CloudWatch widget image from S3: ', error); - this.setState({ metricImage: undefined }); - } + /* + * Update the CloudWatch widget image in showResult onClick event for View Details + * This includes backwards compatibility for images stored as a string in DynamoDB + * Either `this.props.data.metricWidgetImage` or `this.props.data.metricS3Location` are populated by the solution + * `this.props.data.metricWidgetImage` is from the previous method of storing the base64 encoded image in DynamoBD + * `this.props.data.metricS3Location` is the new method where the image is stored in S3 and the location is stored in DynamoDB + */ + componentDidUpdate = async () => { + const imageLocation = this.props.data.results[this.state.selectedRegion].metricS3Location || undefined; + if (!this.isUndefined(imageLocation) && imageLocation !== this.state.metricImageLocation) { + await this.retrieveImage(imageLocation); + this.setState({ metricImageLocation: imageLocation }); + } else if (this.isUndefined(imageLocation) && !this.isUndefined(this.props.data.metricWidgetImage) && this.props.data.metricWidgetImage !== this.state.metricImage) { + this.setState({ + metricImage: this.props.data.metricWidgetImage, + metricImageLocation: undefined + }); + } else if (this.isUndefined(imageLocation) && this.isUndefined(this.props.data.metricWidgetImage)) { + console.log("The CloudWatch metric widget could not be retrieved."); + this.setState({ + metricImage: undefined, + metricImageLocation: undefined + }); } + } - /** - * Checks if the variable is undefined - * @param {any} value The input variable - * @returns {boolean} Returns true if the variable is undefined - */ - isUndefined(value) { - return typeof value === 'undefined'; + getTotalGraphSource() { + const regions = this.props.regions; + const metricOptions = { + 'avgRt': { + label: 'Avg Response Time', + color: '#FF9900', + }, + 'numVu': { + label: 'Virtual Users', + color: '#1f77b4', + stat: "Sum" + }, + 'numSucc': { + label: 'Successes', + color: '#2CA02C', + stat: "Sum" + }, + 'numFail': { + label: 'Failures', + color: '#D62728', + stat: "Sum" + } } + const metricList = { avgRt: [], numVu: [], numSucc: [], numFail: [] }; + const metrics = regions.map((region, index) => { + const regionalMetrics = []; + for (const metric in metricOptions) { + const id = `${metric}${index}`; + metricList[metric].push(id); + regionalMetrics.push([ + 'distributed-load-testing', + `${this.props.data.testId}-${metric}`, + { + ...metricOptions[metric], + visible: false, + region: region, + id: id + } + ]) + } + return regionalMetrics; + }).flat(); - /* - * Initital load of the CloudWatch widget image depending upon set value returned in `this.props.data` - * Either `this.props.data.metricS3Location` will be populated (if base64 image string is stored in S3) - * or `this.props.data.metricWidgetImage` will be populated (if base64 image string is stored in DynamoDB) - */ - componentDidMount = async () => { - if (!this.isUndefined(this.props.data.metricS3Location)) { - await this.retrieveImage(this.props.data.metricS3Location); - this.setState({ metricImageLocation: this.props.data.metricS3Location }); - } else if (!this.isUndefined(this.props.data.metricWidgetImage)) { - this.setState({ metricImage: this.props.data.metricWidgetImage }); - } else { - console.log("The CloudWatch metric widget could not be retrieved."); - this.setState({ - metricImage: undefined, - metricImageLocation: undefined - }); - } + for (const metric in metricOptions) { + const yAxis = metric === 'avgRt' ? "left" : "right" + const op = metric === 'avgRt' ? "AVG" : "SUM"; + metrics.push({ + ...metricOptions[metric], + yAxis: yAxis, + expression: `${op}([${metricList[metric]}])` + }); } - /* - * Update the CloudWatch widget image in showResult onClick event for View Details - * This includes backwards compatability for images stored as a string in DynamoDB - * Either `this.props.data.metricWidgetImage` or `this.props.data.metricS3Location` are populated by the solution - * `this.props.data.metricWidgetImage` is from the previous method of storing the base64 encoded image in DynamoBD - * `this.props.data.metricS3Location` is the new method where the image is stored in S3 and the location is stored in DynamoDB - */ - componentDidUpdate = async () => { - if (!this.isUndefined(this.props.data.metricS3Location) && this.props.data.metricS3Location !== this.state.metricImageLocation) { - await this.retrieveImage(this.props.data.metricS3Location); - this.setState({ metricImageLocation: this.props.data.metricS3Location }); - } else if (this.isUndefined(this.props.data.metricS3Location) && !this.isUndefined(this.props.data.metricWidgetImage) && this.props.data.metricWidgetImage !== this.state.metricImage) { - this.setState({ - metricImage: this.props.data.metricWidgetImage, - metricImageLocation: undefined - }); - } else if (this.isUndefined(this.props.data.metricS3Location) && this.isUndefined(this.props.data.metricWidgetImage)) { - console.log("The CloudWatch metric widget could not be retrieved."); - this.setState({ - metricImage: undefined, - metricImageLocation: undefined - }); + return JSON.stringify({ + width: 600, + height: 395, + metrics: metrics, + period: 10, + yAxis: { + "left": { + "showUnits": false, + "label": "Seconds" + }, + "right": { + "showUnits": false, + "label": "Total" } + }, + stat: 'Average', + view: 'timeSeries', + start: new Date(this.props.data.startTime).toISOString(), + end: new Date(this.props.data.endTime).toISOString() + }, null, 2); + } + + /** + * Show the result into DIV. + * @param {object} data Result data to show + * @param {number} testDuration Test duration + * @return {JSX.IntrinsicElements} Result DIV from the data + */ + showResult(data, testDuration) { + testDuration = parseInt(testDuration); + const image = this.state.metricImage; + + let errors; + if (data.rc && data.rc.length > 0) { + errors = data.rc.map((err) => + +
+ {err.code}:{err.count} +
+ + ); } - /** - * Show the result into DIV. - * @param {object} data Result data to show - * @param {number} testDuration Test duration - * @return {JSX.IntrinsicElements} Result DIV from the data - */ - showResult(data, testDuration) { - - testDuration = parseInt(testDuration); - const image = this.state.metricImage; - - let errors; - if (data.rc && data.rc.length > 0) { - errors = data.rc.map((err) => - -
- {err.code}:{err.count} -
- - ); - } + if (isNaN(testDuration) || testDuration === 0) { + testDuration = this.props.testDuration; + } - if (isNaN(testDuration) || testDuration === 0) { - testDuration = this.props.testDuration; + return ( +
+ + +
+ Avg Response Time +

{data.avg_rt}s

+
+ + +
+ Avg Latency +

{data.avg_lt}s

+
+ + +
+ Avg Connection Time +

{data.avg_ct}s

+
+ + +
+ Avg Bandwidth +

{this.calculateBandwidth(data.bytes, testDuration)}

+
+ +
+ + +
+ Total Count:{data.throughput} +
+ + +
+ Success Count:{data.succ} +
+ + +
+ Error Count:{data.fail} +
+ + +
+ Requests Per Second:{testDuration > 0 ? Math.round(data.throughput * 100 / testDuration) / 100 : '-'} +
+ +
+ { + errors && + + +

Errors

+ +
} - - return ( -
- - -
- Avg Response Time -

{data.avg_rt}s

-
- - -
- Avg Latency -

{data.avg_lt}s

-
- - -
- Avg Connection Time -

{data.avg_ct}s

-
- - -
- Avg Bandwidth -

{this.caculateBandwidth(data.bytes, testDuration)}

-
- -
- - -
- Total Count:{data.throughput} -
- - -
- Success Count:{data.succ} -
- - -
- Error Count:{data.fail} -
- - -
- Requests Per Second:{testDuration > 0 ? Math.round(data.throughput * 100 / testDuration) / 100 : '-'} -
- -
- { - errors && - - -

Errors

- -
- } - - {errors} - - - -

Percentile Response Time

-
- 100%:{data.p100_0}s -
-
- 99.9%:{data.p99_9}s -
-
- 99%:{data.p99_0}s -
-
- 95%:{data.p95_0}s -
-
- 90%:{data.p90_0}s -
-
- 50%:{data.p50_0}s -
-
- 0%:{data.p0_0}s -
- - { - image && - - avRt - - } -
+ + {errors} + + + +

Percentile Response Time

+
+ 100%:{data.p100_0}s
- ); +
+ 99.9%:{data.p99_9}s +
+
+ 99%:{data.p99_0}s +
+
+ 95%:{data.p95_0}s +
+
+ 90%:{data.p90_0}s +
+
+ 50%:{data.p50_0}s +
+
+ 0%:{data.p0_0}s +
+ + { + ( + this.state.selectedRegion === 'total' && + +

Graphs are not available for aggregated results across multiple regions.

To view graphs you may do one of the following:

+
    +
  1. View graphs by region
  2. +
      +
    • View the graphs by region using the dropdown above
    • +
    +
  3. View a total graph with all regions aggregated
  4. +
      +
    • View the total aggregate graph by using Amazon CloudWatch in the AWS console using the following  + + {this.state.tooltipLanguage} + +
    • +
    • + Navigate to Amazon CloudWatch in the  + + AWS Console + +
    • +
    • + In the left hand menu, click on All Metrics under the Metrics header. +
    • +
    • + Click on the Source tab +
    • +
    • + Paste the copied code into the source text box in the Amazon CloudWatch console. +
    • +
+ + + + + ) || + ( + image && + + avRt + + )} +
+
+ ); + } + + handleRegionChange = async (event) => { + this.setState({ selectedRegion: event.target.value }) + } + + tooltipToggle() { + let tooltipLanguage; + if (this.state.tooltipOpen === false) { + tooltipLanguage = 'Copy Code'; } + this.setState({ + tooltipOpen: !this.state.tooltipOpen, + tooltipLanguage: tooltipLanguage + }); + } - render() { - const results = this.props.data.results || { labels: [], testDuration: 0 }; - const testType = this.props.data.testType || ''; - const { labels, testDuration } = results; - - let labelTabs = []; - let labelContents = []; - - if (labels && labels.length > 0 && !['simple', ''].includes(testType)) { - for (let i = 0, length = labels.length; i < length; i++) { - let label = labels[i].label; - labelTabs.push( - - { this.toggleTab(label) }}>{label} - - ); - - labelContents.push( - - {this.showResult(labels[i], testDuration)} - - ); - } - } + handleCopyClick() { + navigator.clipboard.writeText(this.getTotalGraphSource()); + this.setState({ tooltipLanguage: 'Copied!' }); + } - return ( -
-
-

Test Results

- - - - - - - - - {this.showResult(results, testDuration)} - - {labelContents} - - - -
- - - Results Details - -
  • Avg Response Time (AvgRt): the average response time in seconds for all requests.
  • -
  • Avg Latency (AvgLt): the average latency in seconds for all requests.
  • -
  • Avg Connection Time (AvgCt): the average connection time in seconds for all requests.
  • -
  • Avg Bandwidth: the average bandwidth for all requests.
  • -
  • Total Count: the total number of requests.
  • -
  • Success Count: the total number of success requests.
  • -
  • Error Count: the total number of errors.
  • -
  • Requests Per Second: the average requests per seconds for all requests.
  • -
  • Percentiles: percentile levels for the response time, 0 is also minimum response time, 100 is maximum response time.
  • -
    -
    -
    - ) + render() { + const results = this.props.data.results[this.state.selectedRegion] || { labels: [], testDuration: 0 }; + const testType = this.props.data.testType || ''; + const { labels, testDuration } = results; + + const labelTabs = []; + const labelContents = []; + + if (labels && labels.length > 0 && !['simple', ''].includes(testType)) { + for (let i = 0, length = labels.length; i < length; i++) { + let label = labels[i].label; + labelTabs.push( + + { this.toggleTab(label) }}>{label} + + ); + + labelContents.push( + + {this.showResult(labels[i], testDuration)} + + ); + } } + + return ( +
    +
    + + +

    Test Results

    + + + + + { + this.props.regions.map((key) => ( + + ) + ) + } + { + this.props.regions.length > 1 && + + } + + +
    + + + + + + + + {this.showResult(results, testDuration)} + + {labelContents} + + + +
    + + + Results Details + +
  • Avg Response Time (AvgRt): the average response time in seconds for all requests.
  • +
  • Avg Latency (AvgLt): the average latency in seconds for all requests.
  • +
  • Avg Connection Time (AvgCt): the average connection time in seconds for all requests.
  • +
  • Avg Bandwidth: the average bandwidth for all requests.
  • +
  • Total Count: the total number of requests.
  • +
  • Success Count: the total number of success requests.
  • +
  • Error Count: the total number of errors.
  • +
  • Requests Per Second: the average requests per seconds for all requests.
  • +
  • Percentiles: percentile levels for the response time, 0 is also minimum response time, 100 is maximum response time.
  • +
    +
    +
    + ) + } } export default Results; diff --git a/source/console/src/Components/Running/Running.js b/source/console/src/Components/Running/Running.js index 204e944..f472290 100644 --- a/source/console/src/Components/Running/Running.js +++ b/source/console/src/Components/Running/Running.js @@ -2,90 +2,315 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { Row, Col } from 'reactstrap'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'; +import { Button, Table, Col, Row } from 'reactstrap'; +import { PubSub } from '@aws-amplify/pubsub'; +import Chart from 'chart.js/auto'; +import 'chartjs-adapter-moment'; declare var awsConfig; class Running extends React.Component { + constructor(props) { + super(props); + this._chartRef = React.createRef(); + this._chartRef.current = { + 'avgRt': null, + 'vu': null, + 'succ': null, + 'fail': null + }; + this.handleMessage = this.handleMessage.bind(this); + this.buildChart = this.buildChart.bind(this); + this.setGraphRegions = this.setGraphRegions.bind(this); + this.callbackRef = this.callbackRef.bind(this); + this.iotSubscription = ""; + this.timer = ""; + this.state = { + testMetricData: {}, + charts: [], + pauseChart: false + }; + } - render() { - let provisioning = 0; - let pending = 0; - let running = 0; - let ETA = 0; - - for (let task in this.props.data.tasks) { - // eslint-disable-next-line default-case - switch (this.props.data.tasks[task].lastStatus) { - case 'PROVISIONING': - ++provisioning - break; - case 'PENDING': - ++pending - break; - case 'RUNNING': - ++running - break; - } + handleMessage(data) { + const region = Object.keys(data)[0]; + //allocate max per region based on a total max of 5,000 items + const maxPerRegion = Math.floor(5000 / Object.keys(this.state.testMetricData).length); + const regionMetricData = this.state.testMetricData[region]; + + data[region].forEach(dataPoint => { + const timeIndex = regionMetricData.findIndex(existingItems => existingItems.timestamp === dataPoint.timestamp); + if (timeIndex > -1) { + regionMetricData[timeIndex].count += 1; + regionMetricData[timeIndex].avgRt = (regionMetricData[timeIndex].avgRt + dataPoint.avgRt) / regionMetricData[timeIndex].count; + regionMetricData[timeIndex].vu += dataPoint.vu; + regionMetricData[timeIndex].succ += dataPoint.succ; + regionMetricData[timeIndex].fail = dataPoint.fail; + } else { + dataPoint.count = 1; + regionMetricData.length === maxPerRegion && regionMetricData.shift(); + regionMetricData.push(dataPoint); + } + }); + + if (!this.state.pauseChart && !this.timer) { + this.timer = setTimeout(() => { + this.buildChart() + clearTimeout(this.timer); + this.timer = ""; + }, 1000); + } + + //if more than the maximum, remove the first (oldest) item + regionMetricData.length === maxPerRegion && regionMetricData.shift(); + this.setState(prevState => ({ + testMetricData: { + ...prevState.testMetricData, + [region]: regionMetricData + } + })); + } + + setGraphRegions() { + //initialize regions on chart data object + const testMetricData = this.state.testMetricData; + for (const regionEntry of this.props.data.testTaskConfigs) { + testMetricData[regionEntry.region] = []; + } + this.setState({ testMetricData: { ...testMetricData } }); + } + + componentDidMount() { + try { + //set regions for graph data + this.setGraphRegions(); + //subscribe to iot topic, handle incoming messages + this.iotSubscription = PubSub.subscribe(`dlt/${this.props.testId}`).subscribe({ + next: data => { this.handleMessage(data.value); }, + error: error => console.error(error), + complete: () => console.log('closing connection') + }); + + //build graphs + this.buildChart(); + } catch (err) { + console.error(err); + } + } + + componentWillUnmount() { + this.timer && clearTimeout(this.timer); + this.iotSubscription.unsubscribe(); + } + + buildChart() { + const metricLabels = Object.keys(this._chartRef.current); + + const charts = metricLabels.map((label, i) => { + const chartRef = this._chartRef.current[label].getContext('2d'); + let labelDescription = ""; + switch (label) { + case 'avgRt': + labelDescription = 'Response Time'; + break; + case 'vu': + labelDescription = 'Virtual Users'; + break + case 'succ': + labelDescription = 'Successes'; + break; + case 'fail': + labelDescription = 'Failures'; + break; + default: + labelDescription = 'Unknown'; + break; + } + + const scale = { + x: { + type: 'time', + time: { + unit: 'minute' + }, + title: { + display: true, + text: 'Time' + } + }, + y: { + type: 'linear', + display: true, + position: 'left', + title: { + display: true, + text: labelDescription + }, + min: 0 } - //10 seconds to launch every 10 (or 1 second per task) + 2 minutes to enter running state - let workerLaunchTime = this.props.data.taskCount / 60 + 2; - //another minute to launch leader (may have to wait in step functions) + 2 minutes to enter running state, rounded up - ETA = Math.ceil(workerLaunchTime + 3); + } + const colors = ['blue', '#FF9900', 'red', 'green', 'purple']; + const dataset = Object.keys(this.state.testMetricData).map((region, index) => { return ( -
    -
    -

    Tasks Status:

    - - Details for the running tasks can be viewed in the - Amazon ECS Console - - - - - -
    - Task Count:{this.props.data.tasks.length} of {this.props.data.taskCount} -
    - - -
    - Provisioning Count:{provisioning} -
    - - -
    - Pending Count:{pending} -
    - - -
    - Running Count:{running} -
    - -
    -
    -
    -

    Realtime Metrics

    -

    - The realtime average response time, number of users, success counts, and error counts can be monitored using the - Amazon CloudWatch Metrics Dashboard - -

    -

    Response times will start to populate once the tasks are running, task are launched in batches of 10 - and it can take up to {ETA} minutes for all tasks to be running.

    -
    -
    - ) + { + label: region, + type: "scatter", + data: this.state.testMetricData[region], + parsing: { + yAxisKey: label, + xAxisKey: 'timestamp' + }, + pointRadius: 1, + pointHoverRadius: 1, + borderColor: colors[index], + borderWidth: 2, + fill: false, + yAxisID: 'y' + }); + }); + + const options = { + animation: { + duration: 0 + }, + scales: scale, + legend: { display: true } + }; + + const data = { + datasets: dataset + }; + + if (this.state.charts.length === 0) { + return new Chart(chartRef, { type: 'scatter', data, options }); + } else { + const chart = this.state.charts[i]; + chart.data = data; + chart.update(); + return chart; + } + }); + + this.setState({ charts: charts }); + } + + callbackRef(metricLabel) { + return node => this._chartRef.current[metricLabel] = node; + } + + render() { + let ETA = 0; + + for (let regionSet of this.props.data.tasksPerRegion) { + regionSet.provisioning = 0; + regionSet.pending = 0; + regionSet.running = 0; + for (const task in regionSet.tasks) { + switch (regionSet.tasks[task].lastStatus) { + case 'PROVISIONING': + ++regionSet.provisioning; + break; + case 'PENDING': + ++regionSet.pending; + break; + case 'RUNNING': + ++regionSet.running; + break; + default: + break; + } + } } + const tasksPerRegion = this.props.data.tasksPerRegion; + //10 seconds to launch every 10 (or 1 second per task) + 2 minutes to enter running state + let totalTaskCount = 0; + const testTasks = this.props.data.testTaskConfigs; + testTasks.forEach(entry => { + totalTaskCount += entry.taskCount; + }); + let workerLaunchTime = totalTaskCount / 60 + 2; + //another minute to launch leader (may have to wait in step functions) + 2 minutes to enter running state, rounded up + ETA = Math.ceil(workerLaunchTime + 3); + return ( +
    +
    + {/*
    */} +
    +

    Task Status

    + + + + + + + + + + + + + { + tasksPerRegion.map((i) => ( + + + + + + + + )) + } + +
    RegionTasksRunningPendingProvisioning
    {i.region}{i.tasks.length}{i.running}{i.pending}{i.provisioning}
    +
    + + Details for the running tasks can be viewed in the + Amazon ECS Console + + +
    +
    +

    Realtime Metrics

    + + + +
    + +
    +
    + +
    +
    + +
    + +
    +
    + +
    +
    + +

    + The realtime average response time, number of users, success counts, and error counts can be monitored using the + Amazon CloudWatch Metrics Dashboard + +

    +

    Response times will start to populate once the tasks are running, task are launched in batches of 10 + and it can take up to {ETA} minutes for all tasks to be running.

    +
    +
    + ); + } } diff --git a/source/console/src/index.css b/source/console/src/index.css index 119355f..e29087a 100644 --- a/source/console/src/index.css +++ b/source/console/src/index.css @@ -1,371 +1,479 @@ -body { - margin: 0; - -moz-osx-font-smoothing: grayscale; - /*font-size: 1rem; */ - font-size: 90%; - font-family: "Amazon Ember", "Helvetica", "sans-serif"; - font-weight: 300; - background-color: #F1F1F1; -} - -#logo { - vertical-align: middle; - margin-right: 0.5rem; -} - -/******* Reactstrap Overides ********/ -.bg-dark { - background-color: #31465F!important; -} - -.nav-link { - font-size: 0.9rem; - text-transform: uppercase !important; -} - -.nav-link:hover { -color: #FF9900 !important; -} - -.tab-content { - background-color: #FFF !important; - padding-top: 1rem !important; - border-left: 1px solid #dee2e6; - border-collapse: collapse; - box-shadow: 1px 1px 4px 0 rgba(0,0,0,0.15); -} - - -/******* Div ********/ -.main { - margin: 5rem auto; - padding:1rem; - width: 90%; - max-width: 1280px; -} - -.welcome { - text-align: center; - border-radius: 2px; - padding: 1rem 2rem 1rem 2rem; - margin: 0.7rem; -} - -.box { - min-height: 3.8rem; - border-radius: 2px; - padding: 1rem 2rem 1rem 2rem; - margin-bottom: 1.5rem; - border-collapse: collapse; - box-shadow: 1px 1px 4px 0 rgba(0,0,0,0.15); - background-color: #fff; - overflow:hidden -} - -.footer { - text-align: center; - border-radius: 2px; - padding: 1rem 2rem 1rem 2rem; - margin: 0.7rem; -} - -.create-box { - height: 1050px; -} - -.info>.popover{ - max-width: 80%; -} -.info>.PopoverBody{ - margin: 1rem; -} - -/******* Misc ********/ -h1 { - font-weight: 400; - font-size: 1.4rem; - color:#31465F; - margin: 0; - display: inline-block; -} - -h2 { - font-weight: 400; - font-size: 1.25rem; - color:#31465F; - margin: 1rem 0 1rem 0; - padding-bottom: 1rem; - display: inline-block; -} - -h3 { - font-weight: 400; - font-size: 1.1rem; - color:#31465F; - margin: 1rem 0 1rem 0; - display: inline-block; -} - -a { - color: #31465F; -} - -a:hover { - color: #FF9900; -} - -span { - margin-left: 0.75rem; -} - -.console { - margin-top:1rem; - font-size: 1rem; -} -.note { - padding: 0.5rem; - font-size: 0.9rem; - font-style: italic; -} -.text-link { - color: #00a1c9; - font-weight: 400; -} - -th { - color:#31465F !important; - font-weight: 400 !important; - border-bottom: 1px solid #E1E4EA !important; -} - -td { - border-bottom: 1px solid #E1E4EA !important; -} -.td-center { - text-align: center; -} - -.rowActive { - background-color: #BBD4DD; -} - -.desc { - max-width: 20rem; -} -.complete { - color:#008000; - text-transform: uppercase; -} - -.running { - color: #FF9900; - text-transform: uppercase; -} - -.cancelled { - color: #DC3545; - text-transform: uppercase; -} - -.cancelling { - color: #EE6723; - text-transform: uppercase; -} - -.recurrence { - text-transform: uppercase; -} - -.warning { - text-align: center; - color: #DC3545; - font-weight: 400; - font-size: 1.1rem; -} - -.failed { - color: #DC3545; - text-transform: uppercase; -} - -.scheduled { - color: #53C9ED; - text-transform: uppercase; -} - -.RUNNING { - color: #008000; - text-transform: uppercase; -} - -.PENDING { - color: #FFC46D; - text-transform: uppercase; -} - -.btn-secondary { - background-color:#FF9900 !important; -} - -.btn-secondary:hover, .btn-danger:hover { - background-color:#31465F !important; -} - -.btn-secondary, .btn-danger { - color: #fff; - width: 6rem; - float: right; - text-transform: uppercase; - margin-right: 0.5rem; - font-size: 0.8rem; - font-weight: 400; - text-align: center; - border: 0 !important; -} - -.submit { - margin-bottom: 1rem; - margin-top: 1rem; -} - -.btn-link { - color: #FF9900 !important; - margin-bottom: 0.5rem; -} - -.btn-link:hover { - color:#31465F !important; - text-decoration: none; -} - -.btn-link-custom { - padding: 0; - margin-bottom: 0; - font-size: 90%; - font-family: "Amazon Ember", "Helvetica", "sans-serif"; -} - -.btn.disabled, .btn:disabled { - opacity: .35; - background-color:#31465F !important; -} - -.text-secondary { - color:#FF9900 !important; -} - -.desc { - margin-top: 0.5rem; -} - -.desc ul { - list-style: none; - margin: 0; - padding: 0; -} -.desc li { - margin: 1rem 0; -} - -.form-short { - display: block; - width: 20%; -} - -.input-group-short { - width: 50%; -} - -img { - width: 85%; - display: block; - margin: 0.5rem 0 0 1rem; -} - -.result { - border: 1px solid rgba(0,0,0,.125); - padding: 0.5rem; - margin-bottom: 1rem; - border-left: 0.25rem solid #FF9900; -} - -.result p { - font-size: 1.4rem; - font-weight: 600; - color: #31465F; -} - -.error { - border-left: 0.25rem solid #DC3545; -} - -.detail { - margin: 0.5rem; - padding: 0.5rem; - border-bottom: 1px solid rgba(0,0,0,.125); -} - -.detail-col { - color:#FF9900 !important; -} - -.loading { - position: absolute; - top: 50%; - left: 50%; - text-align: center; -} - -.custom-tab { - cursor: pointer; -} - -.custom-tab.active { - background-color: #FF9900 !important; - border: none; -} - -.custom-tab.active:hover { - color: #FFFFFF !important; -} - -.tab-content { - padding: 5px 5px 5px 5px; -} - -/********** @media *********/ - -@media screen and (max-width: 768px) { - - .navbar-brand { - font-size: 1rem !important; - } - .btn { - width: 3.5rem; - font-size: 0.5rem; - margin:0.1rem; - } - .dashboard thead tr th:nth-child(2), .dashboard tbody tr td:nth-child(2), - .dashboard thead tr th:nth-child(3), .dashboard tbody tr td:nth-child(3) - { - display: none; - } - .main { - margin: 5rem auto; - padding:0.2rem; - width: 100%; - } - .box { - padding:1rem; - } - - img { - width: 98%; - display: block; - margin: 0; - } - .result p { - font-size: 1rem; - font-weight: 600; - } -} +body { + margin: 0; + -moz-osx-font-smoothing: grayscale; + /*font-size: 1rem; */ + font-size: 90%; + font-family: "Amazon Ember", "Helvetica", sans-serif; + font-weight: 300; + background-color: #F1F1F1; +} + +#logo { + vertical-align: middle; + margin-right: 0.5rem; +} + +/******* Reactstrap Overrides ********/ +.bg-dark { + background-color: #31465F !important; +} + +.nav-link { + font-size: 0.9rem; + text-transform: uppercase !important; + cursor: pointer; +} + +.nav-link:hover { + color: #FF9900 !important; +} + +.tab-content { + background-color: #FFF !important; + padding-top: 1rem !important; + padding-left: 5px; + padding-right: 5px; + padding-bottom: 5px; + border-left: 1px solid #dee2e6; + border-collapse: collapse; + box-shadow: 1px 1px 4px 0 rgba(0, 0, 0, 0.15); +} + + +/******* Div ********/ +.main { + margin: 5rem auto; + padding: 1rem; +} + +.welcome { + text-align: center; + border-radius: 2px; + padding: 1rem 2rem 1rem 2rem; + margin: 0.7rem; +} + +.box { + min-height: 3.8rem; + border-radius: 2px; + padding: 1rem 2rem 1rem 2rem; + margin-bottom: 1.5rem; + border-collapse: collapse; + box-shadow: 1px 1px 4px 0 rgba(0, 0, 0, 0.15); + background-color: #fff; + overflow: hidden +} + +.footer { + text-align: center; + border-radius: 2px; + padding: 1rem 2rem 1rem 2rem; + margin: 0.7rem; +} + +.create-box { + height: 100%; +} + +.info>.popover { + max-width: 80%; +} + +.info>.PopoverBody { + margin: 1rem; +} + +/******* Misc ********/ +h1 { + font-weight: 400; + font-size: 1.4rem; + color: #31465F; + margin: 0; + display: inline-block; +} + +h2 { + font-weight: 400; + font-size: 1.25rem; + color: #31465F; + margin: 1rem 0 1rem 0; + padding-bottom: 1rem; + display: inline-block; +} + +h3 { + font-weight: 400; + font-size: 1.1rem; + color: #31465F; + margin: 1rem 0 1rem 0; + display: inline-block; +} + +a { + color: #31465F; +} + +a:hover { + color: #FF9900; +} + +span { + margin-left: 0.75rem; +} + +.console { + margin-top: 1rem; + font-size: 1rem; +} + +.note { + padding: 0.5rem; + font-size: 0.9rem; + font-style: italic; +} + +.text-link { + color: #00a1c9; + font-weight: 400; + cursor: pointer; +} + +.history-link { + width: fit-content; +} + +.history-link:hover { + text-decoration: underline; +} + +th { + color: #31465F !important; + font-weight: 400 !important; + border-bottom: 1px solid #E1E4EA !important; +} + +td { + border-bottom: 1px solid #E1E4EA !important; +} + +.td-center { + text-align: center; +} + +.rowActive { + background-color: #BBD4DD; +} + +.complete { + color: #008000; + text-transform: uppercase; +} + +.running { + color: #FF9900; + text-transform: uppercase; +} + +.cancelled { + color: #DC3545; + text-transform: uppercase; +} + +.cancelling { + color: #EE6723; + text-transform: uppercase; +} + +.recurrence { + text-transform: uppercase; +} + +.warning { + text-align: center; + color: #DC3545; + font-weight: 400; + font-size: 1.1rem; +} + +.failed { + color: #DC3545; + text-transform: uppercase; +} + +.scheduled { + color: #53C9ED; + text-transform: uppercase; +} + +.RUNNING { + color: #008000; + text-transform: uppercase; +} + +.PENDING { + color: #FFC46D; + text-transform: uppercase; +} + +.btn-secondary { + background-color: #FF9900 !important; +} + +.btn-secondary:hover, +.btn-danger:hover { + background-color: #31465F !important; +} + +.btn-secondary, +.btn-danger { + color: #fff; + width: 6rem; + float: right; + text-transform: uppercase; + margin-right: 0.5rem; + font-size: 0.8rem; + font-weight: 400; + text-align: center; + border: 0 !important; +} + +.submit { + margin-bottom: 1rem; + margin-top: 1rem; +} + +.btn-link { + color: #FF9900 !important; + margin-bottom: 0.5rem; +} + +.btn-link:hover { + color: #31465F !important; + text-decoration: none; +} + +.btn-link-custom { + padding: 0; + margin-bottom: 0; + font-size: 90%; + font-family: "Amazon Ember", "Helvetica", sans-serif; +} + +.btn.disabled, +.btn:disabled { + opacity: .35; + background-color: #31465F !important; +} + +.text-secondary { + color: #FF9900 !important; +} + +.desc { + max-width: 20rem; + margin-top: 0.5rem; +} + +.desc ul { + list-style: none; + margin: 0; + padding: 0; +} + +.desc li { + margin: 1rem 0; +} + +.form-short { + display: block; + width: 20%; +} + +.input-group-short { + width: 50%; +} + +img { + width: 85%; + display: block; + margin: 0.5rem 0 0 1rem; +} + +.result { + border: 1px solid rgba(0, 0, 0, .125); + padding: 0.5rem; + margin-bottom: 1rem; + border-left: 0.25rem solid #FF9900; +} + +.result p { + font-size: 1.4rem; + font-weight: 600; + color: #31465F; +} + +.error { + border-left: 0.25rem solid #DC3545; +} + +.detail { + margin: 0.5rem; + padding: 0.5rem; + border-bottom: 1px solid rgba(0, 0, 0, .125); +} + +.detail-col { + color: #FF9900 !important; +} + +.loading { + position: absolute; + top: 50%; + left: 50%; + text-align: center; +} + +.custom-tab { + cursor: pointer; +} + +.custom-tab.active { + background-color: #FF9900 !important; + border: none; +} + +.custom-tab.active:hover { + color: #FFFFFF !important; +} + +.regional-stack-row { + border-style: dashed; + border-width: 1px; + text-align: left; + align-items: center; + padding: 0.5rem; + margin: 0.5rem; +} + +.regional-stack-button, +.total-graph-button { + display: inline; + color: link; + font-size: 0.9rem; + margin: 0 !important; + padding: 0 !important; +} + +.regional-cf-link { + color: #00a1c9; + font-weight: 300; + font-size: 0.9rem; + display: inline; +} + +.available-regions-title { + border-bottom: 1px solid #E1E4EA; + margin: 1.5rem 0.5rem 0.5rem 0.5rem; +} + +.available-regions-list { + margin: 0 0.5rem 0 0.5rem; + flex-wrap: wrap; + justify-content: left; +} + +.available-region-item { + margin: 0.5rem; + border: 0; +} + +.regional-config-input-row { + align-items: center; + margin-top: 0.25rem; + margin-bottom: 0.25rem; +} + +.regional-config-button-row { + flex-wrap: nowrap; +} + +.regional-config-button { + font-size: 1rem; +} + +.icon-large { + font-size: 1.25rem; +} + +.schedule-tab-content { + margin-bottom: 1rem; +} + +.total-graph-directions { + margin: 1rem; + padding: 1rem; + text-align: center; + border: solid 1px; +} + +.total-graph-directions-list { + margin-top: 1rem; + list-style-position: inside; + text-align: left; + margin-left: 7rem; + margin-right: 7rem; +} + +.total-graph-directions-link { + padding: 0; + font-size: inherit; + vertical-align: inherit; +} + +.chart-container-div { + width: 50%; +} + + +/********** @media *********/ + +@media screen and (max-width: 768px) { + + .navbar-brand { + font-size: 1rem !important; + } + + .btn { + width: 3.5rem; + font-size: 0.5rem; + margin: 0.1rem; + } + + .dashboard thead tr th:nth-child(2), + .dashboard tbody tr td:nth-child(2), + .dashboard thead tr th:nth-child(3), + .dashboard tbody tr td:nth-child(3) { + display: none; + } + + .main { + margin: 5rem auto; + padding: 0.2rem; + width: 100%; + } + + .box { + padding: 1rem; + } + + img { + width: 98%; + display: block; + margin: 0; + } + + .result p { + font-size: 1rem; + font-weight: 600; + } +} \ No newline at end of file diff --git a/source/console/src/index.js b/source/console/src/index.js index 122c7c4..d42fd45 100644 --- a/source/console/src/index.js +++ b/source/console/src/index.js @@ -4,6 +4,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import 'bootstrap/dist/css/bootstrap.min.css'; +import 'bootstrap-icons/font/bootstrap-icons.css'; import './index.css'; import App from './App'; diff --git a/source/custom-resource/index.js b/source/custom-resource/index.js deleted file mode 100644 index 93d9f7a..0000000 --- a/source/custom-resource/index.js +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -const uuid = require('uuid'); -const cfn = require('./lib/cfn'); -const metrics = require('./lib/metrics'); -const s3 = require('./lib/s3'); - -exports.handler = async (event, context) => { - console.log(`event: ${JSON.stringify(event,null,2)}`); - - const resource = event.ResourceProperties.Resource; - const config = event.ResourceProperties; - let responseData = {}; - - try { - if (event.RequestType === 'Create') { - switch (resource) { - case ('UUID'): - responseData = { - UUID: uuid.v4() - }; - break; - case ('CopyAssets'): - await s3.copyAssets(config.SrcBucket, config.SrcPath, config.ManifestFile, config.DestBucket); - break; - case ('ConfigFile'): - await s3.configFile(config.AwsExports, config.DestBucket); - break; - case ('AnonymousMetric'): - await metrics.send(config, event.RequestType); - break; - default: - throw Error(resource + ' not defined as a resource'); - } - } else if (event.RequestType === 'Update') { - switch (resource) { - case ('CopyAssets'): - await s3.copyAssets(config.SrcBucket, config.SrcPath, config.ManifestFile, config.DestBucket); - break; - case ('ConfigFile'): - await s3.configFile(config.AwsExports, config.DestBucket); - break; - case ('AnonymousMetric'): - await metrics.send(config, event.RequestType); - break; - default: - break; - } - } else if (event.RequestType === 'Delete') { - await metrics.send(config, event.RequestType); - } - - await cfn.send(event, context, 'SUCCESS', responseData, resource); - } - catch (err) { - console.log(err, err.stack); - cfn.send(event, context, 'FAILED',{},resource); - } -}; diff --git a/source/custom-resource/jest.config.js b/source/custom-resource/jest.config.js new file mode 100644 index 0000000..7d3eaec --- /dev/null +++ b/source/custom-resource/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + roots: ['/lib'], + testMatch: ['**/*.spec.js'], + collectCoverageFrom: [ + '**/*.js' + ], + coverageReporters: [ + "text", + "clover", + "json", + ["lcov", { "projectRoot": "../../" }] + ] +}; \ No newline at end of file diff --git a/source/custom-resource/lib/cfn/index.js b/source/custom-resource/lib/cfn/index.js index f5001e5..7d22750 100644 --- a/source/custom-resource/lib/cfn/index.js +++ b/source/custom-resource/lib/cfn/index.js @@ -4,10 +4,6 @@ const axios = require('axios'); const send = async (event, context, responseStatus, responseData, physicalResourceId) => { - - - let data; - try { const responseBody = JSON.stringify({ Status: responseStatus, @@ -28,15 +24,14 @@ const send = async (event, context, responseStatus, responseData, physicalResour }, data: responseBody }; - data = await axios(params); + await axios(params); } catch (err) { + console.error(`There was an error sending the response to CloudFormation: ${err}`); throw err; } - return data.status; }; - module.exports = { - send: send -}; + send: send +}; \ No newline at end of file diff --git a/source/custom-resource/lib/cfn/index.spec.js b/source/custom-resource/lib/cfn/index.spec.js index 71e8985..1f14be7 100644 --- a/source/custom-resource/lib/cfn/index.spec.js +++ b/source/custom-resource/lib/cfn/index.spec.js @@ -22,23 +22,28 @@ const _context = { const _responseStatus = 'ok'; -const _responseData = { +const _responseData = { test: 'testing' }; -describe('#CFN RESONSE::',() => { +describe('#CFN RESPONSE::', () => { - it('should return "200" on a send cfn response sucess', async () => { + it('should succeed on send cfn', async () => { let mock = new MockAdapter(axios); mock.onPut().reply(200, {}); - lambda.send(_event, _context, _responseStatus, _responseData, (err, res) => { - expect(res.status).toEqual(200); + lambda.send(_event, _context, _responseStatus, _responseData, () => { + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenCalledWith(_event); + expect('responseBody').toBeDefined(); + expect('responseBody').toHaveProperty('Status', 'ok'); + expect('responseBody').toHaveProperty('StackId', 'StackId'); + expect('responseBody').toHaveProperty('Data', { test: 'testing' }); }); }); - it('should return "Network Error" on connection timedout', async () => { + it('should return error on connection timeout', async () => { let mock = new MockAdapter(axios); mock.onPut().networkError(); @@ -47,4 +52,4 @@ describe('#CFN RESONSE::',() => { expect(err.toString()).toEqual("Error: Network Error"); }); }); -}); +}); \ No newline at end of file diff --git a/source/custom-resource/lib/config-storage/index.js b/source/custom-resource/lib/config-storage/index.js new file mode 100644 index 0000000..8f9f3f1 --- /dev/null +++ b/source/custom-resource/lib/config-storage/index.js @@ -0,0 +1,71 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const AWS = require('aws-sdk'); +const utils = require('solution-utils'); +AWS.config.logger = console; +const { MAIN_REGION, DDB_TABLE, S3_BUCKET, AWS_REGION } = process.env; +let options = { + region: MAIN_REGION +}; +options = utils.getOptions(options); +const s3 = new AWS.S3(options); +const dynamoDB = new AWS.DynamoDB.DocumentClient(options); + +/** + * generate the testing-resources config file containing the resource information for the remote region, stored in the scenario bucket. + */ +const testingResourcesConfigFile = async (config) => { + try { + // Write the testing-resources configs to DDB + const ddbParams = { + TableName: DDB_TABLE, + Item: { + 'testId': `region-${AWS_REGION}`, + 'ecsCloudWatchLogGroup': config.ecsCloudWatchLogGroup, + 'region': config.region, + 'subnetA': config.subnetA, + 'subnetB': config.subnetB, + 'taskSecurityGroup': config.taskSecurityGroup, + 'taskCluster': config.taskCluster, + 'taskDefinition': config.taskDefinition, + 'taskImage': config.taskImage + } + }; + await dynamoDB.put(ddbParams).promise(); + console.log("Testing infrastructure configuration stored successfully"); + } catch (err) { + console.error(`There was an error creating the configuration: ${err}`); + throw err; + } + return 'success'; +}; + +const delTestingResourcesConfigFile = async (config) => { + try { + const ddbParams = { + TableName: DDB_TABLE, + Item: { + 'testId': `region-${AWS_REGION}`, + 'ecsCloudWatchLogGroup': "", + 'region': config.region, + 'subnetA': "", + 'subnetB': "", + 'taskSecurityGroup': "", + 'taskCluster': "", + 'taskDefinition': "", + 'taskImage': "" + } + }; + await dynamoDB.put(ddbParams).promise(); + console.log(`Deleted DynamoDB region entry region-${AWS_REGION}`); + } catch (err) { + console.error(`There was an error deleting the configurations: ${err}`); + throw err; + } + return 'success'; +}; +module.exports = { + delTestingResourcesConfigFile: delTestingResourcesConfigFile, + testingResourcesConfigFile: testingResourcesConfigFile +}; \ No newline at end of file diff --git a/source/custom-resource/lib/config-storage/index.spec.js b/source/custom-resource/lib/config-storage/index.spec.js new file mode 100644 index 0000000..979a84b --- /dev/null +++ b/source/custom-resource/lib/config-storage/index.spec.js @@ -0,0 +1,143 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Mock AWS SDK +const mockDynamoDb = { + put: jest.fn(), + delete: jest.fn() +}; +const mockAWS = require('aws-sdk'); + +mockAWS.DynamoDB.DocumentClient = jest.fn(() => ({ + put: mockDynamoDb.put, +})); + +process.env = { + AWS_REGION: 'us-west-2', + DDB_TABLE: 'testDDBTable', + MAIN_REGION: 'us-east-2', + S3_BUCKET: 'tests3bucket', + SOLUTION_ID: 'SO00XX', + VERSION: '3.0.0' +}; + +const lambda = require('./index.js'); + + +const testingResourcesConfig = { + "testId": `region-${process.env.AWS_REGION}`, + "ecsCloudWatchLogGroup": "testCloudWatchGroup", + "region": "us-west-2", + "subnetA": "subnet-testA", + "subnetB": "subnet-testB", + "taskSecurityGroup": "testFargateSG", + "taskDefinition": "testFargateTestDefinition", + "taskImage": "test-load-tester", + "taskCluster": "testCluster" +}; + +const deletedTestingResourcesConfig = { + "testId": `region-${process.env.AWS_REGION}`, + "ecsCloudWatchLogGroup": "", + "region": "us-west-2", + "subnetA": "", + "subnetB": "", + "taskSecurityGroup": "", + "taskDefinition": "", + "taskImage": "", + "taskCluster": "" +}; + +describe('#Write Configs::', () => { + + beforeEach(() => { + mockDynamoDb.put.mockReset(); + }); + + it('should return "success" for writing to S3 and DynamoDB', async () => { + mockDynamoDb.put.mockImplementation(() => { + return { + promise() { + // update DDB entry + return Promise.resolve(); + } + }; + }); + + const response = await lambda.testingResourcesConfigFile(testingResourcesConfig, () => { + expect(options.customUserAgent).toBeDefined(); + expect(options.customUserAgent).toHaveValue('AwsSolution/SO00XX/3.0.0'); + expect(mockDynamoDb.put).toHaveBeenCalledWith({ + "TableName": "testDDBTable", + "Item": expect.objectContaining(testingResourcesConfig) + }); + }); + expect(response).toEqual('success'); + }); + + it('should return "ERROR" on ConfigFile failure', async () => { + mockDynamoDb.put.mockImplementation(() => { + return { + promise() { + // update + return Promise.reject('ERROR'); + } + }; + }); + + try { + await lambda.testingResourcesConfigFile(testingResourcesConfig); + } catch (error) { + expect(error).toEqual('ERROR'); + expect(mockDynamoDb.put).toHaveBeenCalledWith({ + "TableName": "testDDBTable", + "Item": expect.objectContaining(testingResourcesConfig) + }); + } + }); + +}); + +describe('#Delete Configs::', () => { + + beforeEach(() => { + mockDynamoDb.put.mockReset(); + }); + + it('should return "success" for deleting S3 object and DynamoDB item', async () => { + mockDynamoDb.put.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + + const response = await lambda.delTestingResourcesConfigFile(testingResourcesConfig); + expect(response).toEqual('success'); + expect(mockDynamoDb.put).toHaveBeenCalledWith({ + "TableName": "testDDBTable", + "Item": expect.objectContaining(deletedTestingResourcesConfig) + }); + }); + + it('should return "ERROR" on delete failure', async () => { + mockDynamoDb.put.mockImplementation(() => { + return { + promise() { + return Promise.reject('ERROR'); + } + }; + }); + + try { + await lambda.delTestingResourcesConfigFile(testingResourcesConfig); + } catch (error) { + expect(error).toEqual('ERROR'); + expect(mockDynamoDb.put).toHaveBeenCalledWith({ + "TableName": "testDDBTable", + "Item": expect.objectContaining(deletedTestingResourcesConfig) + }); + } + }); +}); \ No newline at end of file diff --git a/source/custom-resource/lib/iot/index.js b/source/custom-resource/lib/iot/index.js new file mode 100644 index 0000000..9ca7863 --- /dev/null +++ b/source/custom-resource/lib/iot/index.js @@ -0,0 +1,46 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const AWS = require('aws-sdk'); +const utils = require('solution-utils'); +AWS.config.logger = console; +let options = { region: process.env.MAIN_REGION }; +options = utils.getOptions(options); +const iot = new AWS.Iot(options); + + +/** + * Get the IoT endpoint + */ +const getIotEndpoint = async () => { + let params = { + endpointType: 'iot:Data-ATS' + }; + const data = await iot.describeEndpoint(params).promise(); + return data.endpointAddress; +}; + +/** + * Detach IoT policy on CloudFormation DELETE. + */ +const detachIotPolicy = async (iotPolicyName) => { + const response = await iot.listTargetsForPolicy({ policyName: iotPolicyName }).promise(); + const targets = response.targets; + + for (let target of targets) { + const params = { + policyName: iotPolicyName, + principal: target + }; + + await iot.detachPrincipalPolicy(params).promise(); + console.log(`${target} is detached from ${iotPolicyName}`); + } + + return 'success'; +}; + +module.exports = { + getIotEndpoint: getIotEndpoint, + detachIotPolicy: detachIotPolicy +}; \ No newline at end of file diff --git a/source/custom-resource/lib/iot/index.spec.js b/source/custom-resource/lib/iot/index.spec.js new file mode 100644 index 0000000..06dfa46 --- /dev/null +++ b/source/custom-resource/lib/iot/index.spec.js @@ -0,0 +1,140 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Mock AWS SDK +const mockIot = jest.fn(); +const mockAWS = require('aws-sdk'); +mockAWS.Iot = jest.fn(() => ({ + describeEndpoint: mockIot, + listTargetsForPolicy: mockIot, + detachPrincipalPolicy: mockIot +})); +mockAWS.config = jest.fn(() => ({ + logger: Function +})); +process.env.SOLUTION_ID = 'SO0062'; +process.env.VERSION = '3.0.0'; +const lambda = require('./index.js'); + +describe('#IOT::', () => { + + beforeEach(() => { + mockIot.mockReset(); + }); + + it('should return endpoint on getIotEndpoint success', async () => { + + mockIot.mockImplementationOnce(() => { + return { + promise() { + //describeEndpoint + return Promise.resolve({ endpointAddress: 'test-endpoint' }); + } + }; + }); + + const response = await lambda.getIotEndpoint(); + expect(response).toEqual('test-endpoint'); + expect(mockIot).toHaveBeenCalledTimes(1); + expect(mockIot).toHaveBeenCalledWith({ + endpointType: 'iot:Data-ATS' + }); + }); + + it('should return "error" on getIotEndpoint error', async () => { + + mockIot.mockImplementationOnce(() => { + return { + promise() { + //describeEndpoint + return Promise.reject("error"); + } + }; + }); + + try { + await lambda.getIotEndpoint(); + } catch (err) { + expect(err).toEqual('error'); + } + }); + + it('should return "success" on detachIotPolicy success', async () => { + + mockIot.mockImplementationOnce(() => { + return { + promise() { + //listTargetsForPolicy + return Promise.resolve({ targets: ["target1"] }); + } + }; + }); + + mockIot.mockImplementationOnce(() => { + return { + promise() { + //detachPrincipalPolicy + return Promise.resolve(); + } + }; + }); + + const response = await lambda.detachIotPolicy("iot-policy"); + expect(response).toEqual('success'); + expect(mockIot).toHaveBeenNthCalledWith(1, { + policyName: 'iot-policy' + }); + expect(mockIot).toHaveBeenNthCalledWith(2, { + policyName: 'iot-policy', + principal: 'target1' + }); + }); + + it('should return "error" on listTargetsForPolicy error', async () => { + + mockIot.mockImplementationOnce(() => { + return { + promise() { + //listTargetsForPolicy + return Promise.reject('error'); + } + }; + }); + + try { + await lambda.detachIotPolicy("iot-policy"); + } + catch (err) { + expect(err).toEqual('error'); + } + }); + + + it('should return "error" on detachPrincipalPolicy error', async () => { + + mockIot.mockImplementationOnce(() => { + return { + promise() { + //listTargetsForPolicy + return Promise.resolve({ targets: ["target1"] }); + } + }; + }); + + mockIot.mockImplementationOnce(() => { + return { + promise() { + //detachPrincipalPolicy + return Promise.reject('error'); + } + }; + }); + + try { + await lambda.detachIotPolicy("iot-policy"); + } + catch (err) { + expect(err).toEqual('error'); + } + }); +}); \ No newline at end of file diff --git a/source/custom-resource/lib/metrics/index.js b/source/custom-resource/lib/metrics/index.js index c6e08a2..e733875 100644 --- a/source/custom-resource/lib/metrics/index.js +++ b/source/custom-resource/lib/metrics/index.js @@ -5,39 +5,35 @@ const axios = require('axios'); const moment = require('moment'); const send = async (config, type) => { - let data; - - try { - const metrics = { - Solution: config.SolutionId, - Version: config.Version, - UUID: config.UUID, - TimeStamp: moment().utc().format('YYYY-MM-DD HH:mm:ss.S'), - Data: { - Type: type, - Region: config.Region, - ExistingVpc: config.existingVPC - } - }; - const params = { - method: 'post', - port: 443, - url: process.env.METRIC_URL, - headers: { - 'Content-Type': 'application/json' - }, - data: metrics - }; - //Send Metrics & retun status code. - data = await axios(params); - } catch (err) { - //Not returning an error to avoid Metrics affecting the Application - console.log(err); - } - return data.status; + try { + const metrics = { + Solution: config.SolutionId, + Version: config.Version, + UUID: config.UUID, + TimeStamp: moment().utc().format('YYYY-MM-DD HH:mm:ss.S'), + Data: { + Type: type, + Region: config.Region, + ExistingVpc: config.existingVPC + } + }; + const params = { + method: 'post', + port: 443, + url: process.env.METRIC_URL, + headers: { + 'Content-Type': 'application/json' + }, + data: metrics + }; + //Send Metrics & return status code. + await axios(params); + } catch (err) { + //Not returning an error to avoid Metrics affecting the Application + console.error(err); + } }; - module.exports = { - send: send -}; + send: send +}; \ No newline at end of file diff --git a/source/custom-resource/lib/metrics/index.spec.js b/source/custom-resource/lib/metrics/index.spec.js index 3d6b057..f303c8a 100644 --- a/source/custom-resource/lib/metrics/index.spec.js +++ b/source/custom-resource/lib/metrics/index.spec.js @@ -7,29 +7,37 @@ const MockAdapter = require('axios-mock-adapter'); const lambda = require('./index.js'); const _config = { - SolutionId: 'SO0021', - UUID: '999-999' - } + SolutionId: 'SO00XX', + Version: 'testVersion', + UUID: '999-999', + Region: 'testRegion', + ExistingVpc: 'testTest' +}; describe('#SEND METRICS', () => { - it('should return "200" on a send metrics sucess', async () => { - - let mock = new MockAdapter(axios); - mock.onPost().reply(200, {}); - - let response = await lambda.send(_config, 'Create'); - expect(response).toEqual(200); - }); - - it('should return "Network Error" on connection timedout', async () => { - - let mock = new MockAdapter(axios); - mock.onPut().networkError(); - - await lambda.send(_config).catch(err => { - expect(err.toString()).toEqual("TypeError: Cannot read property 'status' of undefined"); - }); - }); - -}); + it('Send metrics success', async () => { + const mock = new MockAdapter(axios); + + lambda.send(_config, 'Create', () => { + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenCalledWith(_config); + expect('metrics').toBeDefined(); + expect('metrics').toHaveProperty('Solution', 'SO00XX'); + expect('metrics').toHaveProperty('Version', 'testVersion'); + expect('metrics').toHaveProperty('UUID', '999-999'); + expect('metrics').toHaveProperty('Data.Region', 'testRegion'); + expect('metrics').toHaveProperty('Data.ExistingVpc', 'testTest'); + }); + }); + + it('should return error', async () => { + let mock = new MockAdapter(axios); + mock.onPut().networkError(); + + await lambda.send(_config, 'Create').catch(err => { + expect(mock).toHaveBeenCalledTimes(1); + expect(err.toString()).toEqual("Error: Network Error"); + }); + }); +}); \ No newline at end of file diff --git a/source/custom-resource/lib/s3/index.js b/source/custom-resource/lib/s3/index.js index bb1bd33..3faa062 100644 --- a/source/custom-resource/lib/s3/index.js +++ b/source/custom-resource/lib/s3/index.js @@ -2,65 +2,106 @@ // SPDX-License-Identifier: Apache-2.0 const AWS = require('aws-sdk'); +const utils = require('solution-utils'); AWS.config.logger = console; -const { SOLUTION_ID, VERSION } = process.env; let options = {}; -if (SOLUTION_ID && VERSION && SOLUTION_ID.trim() && VERSION.trim()) { - options.customUserAgent = `AwsSolution/${SOLUTION_ID}/${VERSION}`; -} +options = utils.getOptions(options); const s3 = new AWS.S3(options); +const yaml = require('js-yaml'); + +/** + * Copy regional template from source to destination bucket and modify mappings + */ +const putRegionalTemplate = async (config) => { + try { + //get file from S3 and convert from yaml + const getParams = { + Bucket: config.SrcBucket, + Key: `${config.SrcPath}/distributed-load-testing-on-aws-regional.template` + }; + const data = await s3.getObject(getParams).promise(); + const template = yaml.load(data.Body.toString()); + + //modify template mappings + template.Mappings.Solution.Config.APIServicesLambdaRoleName = config.APIServicesLambdaRoleName; + template.Mappings.Solution.Config.MainStackRegion = config.MainStackRegion; + template.Mappings.Solution.Config.ScenariosS3Bucket = config.DestBucket; + template.Mappings.Solution.Config.ResultsParserRoleName = config.ResultsParserRoleName; + template.Mappings.Solution.Config.ScenariosTable = config.ScenariosTable; + template.Mappings.Solution.Config.TaskRunnerRoleName = config.TaskRunnerRoleName; + template.Mappings.Solution.Config.TaskCancelerRoleName = config.TaskCancelerRoleName; + template.Mappings.Solution.Config.TaskStatusCheckerRoleName = config.TaskStatusCheckerRoleName; + template.Mappings.Solution.Config.Uuid = config.Uuid; + + //convert back to yaml put object in destination bucket + const modifiedTemplate = yaml.dump(template, { lineWidth: -1 }); + const putParams = { + Bucket: config.DestBucket, + Key: 'regional-template/distributed-load-testing-on-aws-regional.template', + Body: modifiedTemplate + }; + await s3.putObject(putParams).promise(); + } catch (err) { + console.error(err); + throw err; + } + return 'success'; +}; /** * Copy Console assets and Container assets from source to destination buckets */ const copyAssets = async (srcBucket, srcPath, manifestFile, destBucket) => { - try { - // get file manifest from s3 - const getParams = { - Bucket: srcBucket, - Key: `${srcPath}/${manifestFile}` - }; + try { + // get file manifest from s3 + const getParams = { + Bucket: srcBucket, + Key: `${srcPath}/${manifestFile}` + }; - const data = await s3.getObject(getParams).promise(); - const manifest = JSON.parse(data.Body); - console.log('Manifest:', JSON.stringify(manifest, null, 2)); + const data = await s3.getObject(getParams).promise(); + const manifest = JSON.parse(data.Body); + console.log('Manifest:', JSON.stringify(manifest, null, 2)); - // Loop through manifest and copy files to the destination bucket - const response = await Promise.all(manifest.map(async (file) => { - let copyParams = { - Bucket: destBucket, - CopySource: `${srcBucket}/${srcPath}/${file}`, - Key: file - }; - return s3.copyObject(copyParams).promise(); - })); - console.log('file copied to s3: ', response); - } catch (err) { - throw err; - } - return 'success'; + // Loop through manifest and copy files to the destination bucket + const response = await Promise.all(manifest.map(async (file) => { + let copyParams = { + Bucket: destBucket, + CopySource: `${srcBucket}/${srcPath}/${file}`, + Key: file + }; + return s3.copyObject(copyParams).promise(); + })); + console.log('file copied to s3: ', response); + } catch (err) { + console.error(err); + throw err; + } + return 'success'; }; /** - * generate the aws exports file containing cognito and API congig details. + * generate the aws exports file containing cognito and API config details. */ const configFile = async (file, destBucket) => { - try { - //write exports file to the console - const params = { - Bucket: destBucket, - Key: 'assets/aws_config.js', - Body: file - }; - console.log(`creating config file: ${JSON.stringify(params)}`); - await s3.putObject(params).promise(); - } catch (err) { - throw err; - } - return 'success'; + try { + //write exports file to the console + const params = { + Bucket: destBucket, + Key: 'assets/aws_config.js', + Body: file + }; + console.log(`creating config file: ${JSON.stringify(params)}`); + await s3.putObject(params).promise(); + } catch (err) { + console.error(err); + throw err; + } + return 'success'; }; module.exports = { - copyAssets: copyAssets, - configFile: configFile + copyAssets: copyAssets, + configFile: configFile, + putRegionalTemplate: putRegionalTemplate }; \ No newline at end of file diff --git a/source/custom-resource/lib/s3/index.spec.js b/source/custom-resource/lib/s3/index.spec.js index fb66e3d..49b526c 100644 --- a/source/custom-resource/lib/s3/index.spec.js +++ b/source/custom-resource/lib/s3/index.spec.js @@ -1,95 +1,183 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +const yaml = require('js-yaml'); // Mock AWS SDK const mockS3 = jest.fn(); const mockAWS = require('aws-sdk'); mockAWS.S3 = jest.fn(() => ({ - getObject: mockS3, - putObject: mockS3, - copyObject: mockS3 + getObject: mockS3, + putObject: mockS3, + copyObject: mockS3 })); mockAWS.config = jest.fn(() => ({ - logger: Function + logger: Function })); process.env.SOLUTION_ID = 'SO0062'; -process.env.VERSION = '2.0.1'; +process.env.VERSION = '3.0.0'; const lambda = require('./index.js'); +//fake template for putRegionalTemplate tests +const template = yaml.dump({ + Mappings: { + Solution: { + Config: { + APIServicesLambdaRoleName: 'PLACEHOLDER', + MainStackRegion: 'PLACEHOLDER', + ScenariosTable: 'PLACEHOLDER', + TaskRunnerRoleName: 'PLACEHOLDER', + TaskCancelerRoleName: 'PLACEHOLDER', + TaskStatusCheckerRoleName: 'PLACEHOLDER', + ScenariosS3Bucket: "PLACEHOLDER", + Uuid: 'PLACEHOLDER' + } + } + } +}); + +//event body for putRegionalTemplate tests +const putRegionalTemplateEventBody = { + APIServicesLambdaRoleName: "test-services-role", + MainStackRegion: "test-region", + ScenariosTable: "test-table", + TaskRunnerRoleName: "test-runner-role", + TaskCancelerRoleName: "test-canceler-role", + TaskStatusCheckerRoleName: "test-checker-role", + DestBucket: "test-bucket", + Uuid: "test-uuid" +}; + describe('#S3::', () => { - beforeEach(() => { - mockS3.mockReset(); - }); - - it('should return "success" on copyAssets sucess', async () => { - - const data = { Body: "[\"console/file1\",\"console/file2\"]" }; - mockS3.mockImplementationOnce(() => { - return { - promise() { - // getObject - return Promise.resolve(data); - } - }; - }).mockImplementation(() => { - return { - promise() { - // copyObject - return Promise.resolve({}); - } - }; - }); - - const response = await lambda.copyAssets('srcBucket', 'srcPath', 'manifestFile', 'destBucket'); - expect(response).toEqual('success'); - }); - - it('should return "ERROR" on copyAssets failure', async () => { - mockS3.mockImplementation(() => { - return { - promise() { - // getObject - return Promise.reject('ERROR'); - } - }; - }); - - try { - await lambda.copyAssets('srcBucket', 'srcPath', 'manifestFile', 'destBucket'); - } catch (error) { - expect(error).toEqual('ERROR'); - } - }); - - it('should return "success" on ConfigFile sucess', async () => { - mockS3.mockImplementation(() => { - return { - promise() { - // putObject - return Promise.resolve(); - } - }; - }); - - const response = await lambda.configFile('file', 'destBucket') - expect(response).toEqual('success'); - }); - - it('should return "ERROR" on ConfigFile failure', async () => { - mockS3.mockImplementation(() => { - return { - promise() { - // putObject - return Promise.reject('ERROR'); - } - }; - }); - - try { - await lambda.configFile('file', 'destBucket'); - } catch (error) { - expect(error).toEqual('ERROR'); - } - }); + beforeEach(() => { + mockS3.mockReset(); + }); + + it('should return "success" on copyAssets success', async () => { + + const data = { Body: "[\"console/file1\",\"console/file2\"]" }; + mockS3.mockImplementationOnce(() => { + return { + promise() { + // getObject + return Promise.resolve(data); + } + }; + }).mockImplementation(() => { + return { + promise() { + // copyObject + return Promise.resolve({}); + } + }; + }); + + const response = await lambda.copyAssets('srcBucket', 'srcPath', 'manifestFile', 'destBucket'); + expect(response).toEqual('success'); + }); + + it('should return "ERROR" on copyAssets failure', async () => { + mockS3.mockImplementation(() => { + return { + promise() { + // getObject + return Promise.reject('ERROR'); + } + }; + }); + + try { + await lambda.copyAssets('srcBucket', 'srcPath', 'manifestFile', 'destBucket'); + } catch (error) { + expect(error).toEqual('ERROR'); + } + }); + + it('should return "success" on ConfigFile success', async () => { + mockS3.mockImplementation(() => { + return { + promise() { + // putObject + return Promise.resolve(); + } + }; + }); + + const response = await lambda.configFile('file', 'destBucket'); + expect(response).toEqual('success'); + }); + + it('should return "ERROR" on ConfigFile failure', async () => { + mockS3.mockImplementation(() => { + return { + promise() { + // putObject + return Promise.reject('ERROR'); + } + }; + }); + + try { + await lambda.configFile('file', 'destBucket'); + } catch (error) { + expect(error).toEqual('ERROR'); + } + }); + + it('should return "SUCCESS" on putRegionalTemplate success', async () => { + mockS3.mockImplementationOnce(() => { + return { + promise() { + // getObject + return Promise.resolve({ Body: template }); + } + }; + }); + + mockS3.mockImplementationOnce(() => { + return { + promise() { + // putObject + return Promise.resolve(); + } + }; + }); + + const response = await lambda.putRegionalTemplate(putRegionalTemplateEventBody); + const expectedTemplate = yaml.dump({ + Mappings: { + Solution: { + Config: { + APIServicesLambdaRoleName: 'test-services-role', + MainStackRegion: 'test-region', + ScenariosTable: 'test-table', + TaskRunnerRoleName: 'test-runner-role', + TaskCancelerRoleName: 'test-canceler-role', + TaskStatusCheckerRoleName: 'test-checker-role', + ScenariosS3Bucket: "test-bucket", + Uuid: 'test-uuid' + } + } + } + }); + expect(mockS3).toHaveBeenNthCalledWith(2, expect.objectContaining({ Body: expectedTemplate })); + expect(response).toEqual('success'); + }); + + it('should return "ERROR" on putRegionalTemplate failure', async () => { + mockS3.mockImplementation(() => { + return { + promise() { + // putObject + return Promise.reject('ERROR'); + } + }; + }); + + try { + await lambda.putRegionalTemplate(putRegionalTemplateEventBody); + } catch (error) { + expect(error).toEqual('ERROR'); + } + }); }); \ No newline at end of file diff --git a/source/custom-resource/main-index.js b/source/custom-resource/main-index.js new file mode 100644 index 0000000..06bd217 --- /dev/null +++ b/source/custom-resource/main-index.js @@ -0,0 +1,76 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const uuid = require('uuid'); +const cfn = require('./lib/cfn'); +const metrics = require('./lib/metrics'); +const s3 = require('./lib/s3'); +const iot = require('./lib/iot'); +const storeConfig = require('./lib/config-storage'); + +exports.handler = async (event, context) => { + console.log(`event: ${JSON.stringify(event, null, 2)}`); + + const resource = event.ResourceProperties.Resource; + const config = event.ResourceProperties; + const requestType = event.RequestType; + let responseData = {}; + + try { + switch (resource) { + case ('TestingResourcesConfigFile'): + if (requestType === 'Delete') { + await storeConfig.delTestingResourcesConfigFile(config.TestingResourcesConfig); + } else { + await storeConfig.testingResourcesConfigFile(config.TestingResourcesConfig); + } + break; + case ('UUID'): + if (requestType === 'Create') { + responseData = { + UUID: uuid.v4(), + SUFFIX: uuid.v4().slice(-10) + } + } + break; + case ('CopyAssets'): + if (requestType !== 'Delete') { + await s3.copyAssets(config.SrcBucket, config.SrcPath, config.ManifestFile, config.DestBucket); + } + break; + case ('ConfigFile'): + if (requestType !== 'Delete') { + await s3.configFile(config.AwsExports, config.DestBucket); + } + break; + case ('PutRegionalTemplate'): + if (requestType !== 'Delete') { + await s3.putRegionalTemplate(config); + } + break; + case ('GetIotEndpoint'): + if (requestType === 'Create') { + const iotEndpoint = await iot.getIotEndpoint(); + responseData = { + IOT_ENDPOINT: iotEndpoint + }; + } + break; + case ('DetachIotPolicy'): + if (requestType === 'Delete') { + await iot.detachIotPolicy(config.IotPolicyName); + } + break; + case ('AnonymousMetric'): + await metrics.send(config, requestType); + break; + default: + throw Error(`${resource} not supported`); + } + await cfn.send(event, context, 'SUCCESS', responseData, resource); + } + catch (err) { + console.log(err, err.stack); + cfn.send(event, context, 'FAILED', {}, resource); + } +}; diff --git a/source/custom-resource/package.json b/source/custom-resource/package.json index 18dbcc7..8e4ca7f 100644 --- a/source/custom-resource/package.json +++ b/source/custom-resource/package.json @@ -1,18 +1,23 @@ { "name": "custom-resource", - "version": "2.0.1", - "engines": { - "node": "^12.x" - }, + "version": "3.0.0", "description": "cfn custom resources for distributed load testing on AWS workflow", + "repository": { + "type": "git", + "url": "https://github.com/aws-solutions/distributed-load-testing-on-aws" + }, + "license": "Apache-2.0", + "author": "aws-solution-builders", "main": "index.js", "scripts": { "clean": "rm -rf node_modules package-lock.json", "test": "jest lib/**/*.spec.js --coverage --silent" }, "dependencies": { - "axios": "^0.21.0", + "axios": "^0.25.0", + "js-yaml": "^4.1.0", "moment": "^2.29.1", + "solution-utils": "file:../solution-utils", "uuid": "^8.3.1" }, "devDependencies": { @@ -20,11 +25,8 @@ "axios-mock-adapter": "1.19.0", "jest": "26.6.3" }, - "repository": { - "type": "git", - "url": "https://github.com/aws-solutions/distributed-load-testing-on-aws" + "engines": { + "node": "^14.x" }, - "author": "aws-solution-builders", - "license": "Apache-2.0", "readme": "./README.md" } diff --git a/source/custom-resource/regional-index.js b/source/custom-resource/regional-index.js new file mode 100644 index 0000000..b7ddfcd --- /dev/null +++ b/source/custom-resource/regional-index.js @@ -0,0 +1,46 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const cfn = require('./lib/cfn'); +const metrics = require('./lib/metrics'); +const storeConfig = require('./lib/config-storage'); +const iot = require('./lib/iot'); + +exports.handler = async (event, context) => { + console.log(`event: ${JSON.stringify(event, null, 2)}`); + + const resource = event.ResourceProperties.Resource; + const config = event.ResourceProperties; + const requestType = event.RequestType; + let responseData = {}; + + try { + switch (resource) { + case ('TestingResourcesConfigFile'): + if (requestType === 'Delete') { + await storeConfig.delTestingResourcesConfigFile(config.TestingResourcesConfig); + } else { + await storeConfig.testingResourcesConfigFile(config.TestingResourcesConfig); + } + break; + case ('GetIotEndpoint'): + if (requestType !== 'Delete') { + const iotEndpoint = await iot.getIotEndpoint(); + responseData = { + IOT_ENDPOINT: iotEndpoint + }; + } + break; + case ('AnonymousMetric'): + await metrics.send(config, requestType); + break; + default: + throw Error(`${resource} not supported`); + } + await cfn.send(event, context, 'SUCCESS', responseData, resource); + } + catch (err) { + console.log(err, err.stack); + await cfn.send(event, context, 'FAILED', {}, resource); + } +}; diff --git a/source/infrastructure/bin/distributed-load-testing-on-aws-regional.ts b/source/infrastructure/bin/distributed-load-testing-on-aws-regional.ts new file mode 100644 index 0000000..a316e6c --- /dev/null +++ b/source/infrastructure/bin/distributed-load-testing-on-aws-regional.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env node +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'source-map-support/register'; +import { App, DefaultStackSynthesizer } from 'aws-cdk-lib'; +import { RegionalInfrastructureDLTStack, RegionalInfrastructureDLTStackProps } from '../lib/distributed-load-testing-on-aws-regional-stack'; + +const getProps = (): RegionalInfrastructureDLTStackProps => { + const { CODE_BUCKET, SOLUTION_NAME, CODE_VERSION, PUBLIC_ECR_REGISTRY, PUBLIC_ECR_TAG } = process.env; + if (typeof CODE_BUCKET !== 'string' || CODE_BUCKET.trim() === '') { + throw new Error('Missing required environment variable: CODE_BUCKET'); + } + + if (typeof SOLUTION_NAME !== 'string' || SOLUTION_NAME.trim() === '') { + throw new Error('Missing required environment variable: SOLUTION_NAME'); + } + + if (typeof CODE_VERSION !== 'string' || CODE_VERSION.trim() === '') { + throw new Error('Missing required environment variable: CODE_VERSION'); + } + + if (typeof PUBLIC_ECR_REGISTRY !== 'string' || PUBLIC_ECR_REGISTRY.trim() === '') { + throw new Error('Missing required environment variable: PUBLIC_ECR_REGISTRY'); + } + + if (typeof PUBLIC_ECR_TAG !== 'string' || PUBLIC_ECR_TAG.trim() === '') { + throw new Error('Missing required environment variable: PUBLIC_ECR_TAG'); + } + + const codeBucket = CODE_BUCKET; + const codeVersion = CODE_VERSION; + const publicECRRegistry = PUBLIC_ECR_REGISTRY; + const publicECRTag = PUBLIC_ECR_TAG; + const stackType = 'regional'; + const solutionId = 'SO0062'; + const solutionName = SOLUTION_NAME; + const description = `(${solutionId}-regional) - Distributed Load Testing on AWS testing resources regional deployment. Version ${codeVersion}`; + const url = 'https://metrics.awssolutionsbuilder.com/generic'; + + return { + codeBucket, + codeVersion, + description, + publicECRRegistry, + publicECRTag, + stackType, + solutionId, + solutionName, + url + }; +}; + +const app = new App(); +new RegionalInfrastructureDLTStack(app, 'RegionalInfrastructureDLTStack', { + synthesizer: new DefaultStackSynthesizer({ + generateBootstrapVersionRule: false + }), + ...getProps() +}); \ No newline at end of file diff --git a/source/infrastructure/bin/distributed-load-testing-on-aws.ts b/source/infrastructure/bin/distributed-load-testing-on-aws.ts old mode 100755 new mode 100644 index 4fd8144..e9547dc --- a/source/infrastructure/bin/distributed-load-testing-on-aws.ts +++ b/source/infrastructure/bin/distributed-load-testing-on-aws.ts @@ -1,12 +1,62 @@ -#!/usr/bin/env node -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import 'source-map-support/register'; -import * as cdk from '@aws-cdk/core'; -import { DLTStack } from '../lib/distributed-load-testing-on-aws-stack'; - -const app = new cdk.App(); -new DLTStack(app, 'DLTStack', { - description: '(SO0062) - Distributed Load Testing on AWS is a reference architecture to perform application load testing at scale. Version CODE_VERSION' -}); +#!/usr/bin/env node +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'source-map-support/register'; +import { App, DefaultStackSynthesizer } from 'aws-cdk-lib'; +import { DLTStack, DLTStackProps } from '../lib/distributed-load-testing-on-aws-stack'; + +const getProps = (): DLTStackProps => { + const { CODE_BUCKET, SOLUTION_NAME, CODE_VERSION, PUBLIC_ECR_REGISTRY, PUBLIC_ECR_TAG } = process.env; + if (typeof CODE_BUCKET !== 'string' || CODE_BUCKET.trim() === '') { + throw new Error('Missing required environment variable: CODE_BUCKET'); + } + + if (typeof SOLUTION_NAME !== 'string' || SOLUTION_NAME.trim() === '') { + throw new Error('Missing required environment variable: SOLUTION_NAME'); + } + + if (typeof CODE_VERSION !== 'string' || CODE_VERSION.trim() === '') { + throw new Error('Missing required environment variable: CODE_VERSION'); + } + + if (typeof PUBLIC_ECR_REGISTRY !== 'string' || PUBLIC_ECR_REGISTRY.trim() === '') { + throw new Error('Missing required environment variable: PUBLIC_ECR_REGISTRY'); + } + + if (typeof PUBLIC_ECR_TAG !== 'string' || PUBLIC_ECR_TAG.trim() === '') { + throw new Error('Missing required environment variable: PUBLIC_ECR_TAG'); + } + + const codeBucket = CODE_BUCKET; + const codeVersion = CODE_VERSION; + const publicECRRegistry = PUBLIC_ECR_REGISTRY; + const publicECRTag = PUBLIC_ECR_TAG; + const stackType = 'main'; + const solutionId = 'SO0062'; + const solutionName = SOLUTION_NAME; + const description = `(${solutionId}) - Distributed Load Testing on AWS is a reference architecture to perform application load testing at scale. Version ${codeVersion}`; + const url = 'https://metrics.awssolutionsbuilder.com/generic'; + + return { + codeBucket, + codeVersion, + description, + publicECRRegistry, + publicECRTag, + stackType, + solutionId, + solutionName, + url + }; +}; + + + +const app = new App(); +new DLTStack(app, 'DLTStack', { + synthesizer: new DefaultStackSynthesizer({ + generateBootstrapVersionRule: false + }), + ...getProps() +}); \ No newline at end of file diff --git a/source/infrastructure/cdk.json b/source/infrastructure/cdk.json index 2bd95fc..8b44ca0 100644 --- a/source/infrastructure/cdk.json +++ b/source/infrastructure/cdk.json @@ -1,12 +1,6 @@ { - "app": "npx ts-node --prefer-ts-exts bin/distributed-load-testing-on-aws.ts", "context": { - "@aws-cdk/core:enableStackNameDuplicates": "true", - "aws-cdk:enableDiffNoFail": "true", - "@aws-cdk/core:stackRelativeExports": "true", - "@aws-cdk/aws-ecr-assets:dockerIgnoreSupport": true, - "@aws-cdk/aws-secretsmanager:parseOwnedSecretName": true, - "@aws-cdk/aws-kms:defaultKeyPolicies": true, - "@aws-cdk/aws-s3:grantWriteWithoutAcl": true + "@aws-cdk/core:stackRelativeExports": false, + "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": false } -} +} \ No newline at end of file diff --git a/source/infrastructure/jest.config.js b/source/infrastructure/jest.config.js index eaabc87..367f583 100644 --- a/source/infrastructure/jest.config.js +++ b/source/infrastructure/jest.config.js @@ -6,6 +6,8 @@ module.exports = { }, coverageReporters: [ "text", - ["lcov", { "projectRoot": "../" }] + "clover", + "json", + ["lcov", { "projectRoot": "../.." }] ] }; diff --git a/source/infrastructure/lib/api.ts b/source/infrastructure/lib/api.ts deleted file mode 100644 index 9cb7698..0000000 --- a/source/infrastructure/lib/api.ts +++ /dev/null @@ -1,370 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { Code, Function as LambdaFunction, Runtime } from '@aws-cdk/aws-lambda'; -import { Aws, CfnResource, Construct, Duration, RemovalPolicy, Stack, Tags } from '@aws-cdk/core'; -import { IBucket } from '@aws-cdk/aws-s3'; -import { LogGroup, RetentionDays } from '@aws-cdk/aws-logs'; -import { Effect, Policy, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from '@aws-cdk/aws-iam'; -import { - AccessLogFormat, - AuthorizationType, - CfnAccount, - ContentHandling, - Deployment, - EndpointType, - Integration, - IntegrationType, - LogGroupLogDestination, - MethodLoggingLevel, - MethodOptions, - PassthroughBehavior, - RequestValidator, - RestApi, - Stage -} from '@aws-cdk/aws-apigateway'; - - -/** - * @interface DLTAPIProps - * DLTAPI props -*/ -export interface DLTAPIProps { - // ECS CloudWatch Log Group - readonly ecsCloudWatchLogGroup: LogGroup; - // CloudWatch Logs Policy - readonly cloudWatchLogsPolicy: Policy; - // DynamoDB policy - readonly dynamoDbPolicy: Policy, - //Task Canceler Invoke Policy - readonly taskCancelerInvokePolicy: Policy; - // Test scenarios S3 bucket - readonly scenariosBucketName: string; - // Test scenarios S3 bucket policy - readonly scenariosS3Policy: Policy; - // Test scenarios DynamoDB table - readonly scenariosTableName: string; - // ECS cluster - readonly ecsCuster: string; - // ECS Task Execution Role ARN - readonly ecsTaskExecutionRoleArn: string; - // Task Runner state function - readonly taskRunnerStepFunctionsArn: string; - // Task canceler ARN - readonly tastCancelerArn: string; - /** - * Solution config properties. - * the metric URL endpoint, send anonymous usage, solution ID, version, source code bucket, and source code prefix - */ - readonly metricsUrl: string; - readonly sendAnonymousUsage: string; - readonly solutionId: string; - readonly solutionVersion: string; - readonly sourceCodeBucket: IBucket; - readonly sourceCodePrefix: string; - // UUID - readonly uuid: string; -} - -/** - * @class - * Distributed Load Testing on AWS API construct - */ -export class DLTAPI extends Construct { - apiId: string; - apiEndpointPath: string; - - constructor(scope: Construct, id: string, props: DLTAPIProps) { - super(scope, id); - - const taskArn = Stack.of(this).formatArn({ service: 'ecs', resource: 'task', sep: '/', resourceName: '*' }); - const taskDefArn = Stack.of(this).formatArn({ service: 'ecs', resource: 'task-definition/' }); - - const dltApiServicesLambdaRole = new Role(this, 'DLTAPIServicesLambdaRole', { - assumedBy: new ServicePrincipal('lambda.amazonaws.com'), - inlinePolicies: { - 'DLTAPIServicesLambdaPolicy': new PolicyDocument({ - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['ecs:ListTasks'], - resources: ['*'] - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'ecs:RunTask', - 'ecs:DescribeTasks' - ], - resources: [ - taskArn, - taskDefArn - ] - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['iam:PassRole'], - resources: [props.ecsTaskExecutionRoleArn] - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['states:StartExecution'], - resources: [props.taskRunnerStepFunctionsArn] - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['logs:DeleteMetricFilter'], - resources: [props.ecsCloudWatchLogGroup.logGroupArn] - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['cloudwatch:DeleteDashboards'], - resources: [`arn:${Aws.PARTITION}:cloudwatch::${Aws.ACCOUNT_ID}:dashboard/EcsLoadTesting*`] - }) - ] - }) - } - }); - dltApiServicesLambdaRole.attachInlinePolicy(props.cloudWatchLogsPolicy); - dltApiServicesLambdaRole.attachInlinePolicy(props.dynamoDbPolicy); - dltApiServicesLambdaRole.attachInlinePolicy(props.scenariosS3Policy); - dltApiServicesLambdaRole.attachInlinePolicy(props.taskCancelerInvokePolicy); - - const ruleSchedArn = Stack.of(this).formatArn({ service: 'events', resource: 'rule', resourceName: '*Scheduled' }); - const ruleCreateArn = Stack.of(this).formatArn({ service: 'events', resource: 'rule', resourceName: '*Create' }); - const ruleListArn = Stack.of(this).formatArn({ service: 'events', resource: 'rule', resourceName: '*' }); - - const lambdaApiEventsPolicy = new Policy(this, 'LambdaApiEventsPolicy', { - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'events:PutTargets', - 'events:PutRule', - 'events:DeleteRule', - 'events:RemoveTargets' - ], - resources: [ - ruleSchedArn, - ruleCreateArn - ] - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'events:ListRules' - ], - resources: [ - ruleListArn - ] - }) - ] - }); - dltApiServicesLambdaRole.attachInlinePolicy(lambdaApiEventsPolicy); - - const apiLambdaRoleResource = dltApiServicesLambdaRole.node.defaultChild as CfnResource; - apiLambdaRoleResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W11', - reason: 'ecs:ListTasks does not support resource level permissions' - }] - }); - - const dltApiServicesLambda = new LambdaFunction(this, 'DLTAPIServicesLambda', { - description: 'API microservices for creating, updating, listing and deleting test scenarios', - code: Code.fromBucket(props.sourceCodeBucket, `${props.sourceCodePrefix}/api-services.zip`), - runtime: Runtime.NODEJS_14_X, - handler: 'index.handler', - timeout: Duration.seconds(120), - environment: { - SCENARIOS_BUCKET: props.scenariosBucketName, - SCENARIOS_TABLE: props.scenariosTableName, - TASK_CLUSTER: props.ecsCuster, - STATE_MACHINE_ARN: props.taskRunnerStepFunctionsArn, - SOLUTION_ID: props.solutionId, - UUID: props.uuid, - VERSION: props.solutionVersion, - SEND_METRIC: props.sendAnonymousUsage, - METRIC_URL: props.metricsUrl, - ECS_LOG_GROUP: props.ecsCloudWatchLogGroup.logGroupName, - TASK_CANCELER_ARN: props.tastCancelerArn - }, - role: dltApiServicesLambdaRole - }); - Tags.of(dltApiServicesLambda).add('SolutionId', props.solutionId); - const apiLambdaResource = dltApiServicesLambda.node.defaultChild as CfnResource; - apiLambdaResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W58', - reason: 'CloudWatchLogsPolicy covers a permission to write CloudWatch logs.' - }, { - id: 'W89', - reason: 'VPC not needed for lambda' - }, { - id: 'W92', - reason: 'Does not run concurrent executions' - }] - }); - - const lambdaApiPermissionPolicy = new Policy(this, 'LambdaApiPermissionPolicy', { - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'lambda:AddPermission', - 'lambda:RemovePermission' - ], - resources: [dltApiServicesLambda.functionArn] - }) - ] - }); - dltApiServicesLambdaRole.attachInlinePolicy(lambdaApiPermissionPolicy); - - const apiLogs = new LogGroup(this, 'APILogs', { - retention: RetentionDays.ONE_YEAR, - removalPolicy: RemovalPolicy.RETAIN - }); - const apiLogsResource = apiLogs.node.defaultChild as CfnResource; - apiLogsResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W84', - reason: 'KMS encryption unnecessary for log group' - }] - }); - - const logsArn = Stack.of(this).formatArn({ service: 'logs', resource: '*' }) - const apiLoggingRole = new Role(this, 'APILoggingRole', { - assumedBy: new ServicePrincipal('apigateway.amazonaws.com'), - inlinePolicies: { - 'apiLoggingPolicy': new PolicyDocument({ - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'logs:CreateLogGroup', - 'logs:CreateLogStream', - 'logs:DescribeLogGroups', - 'logs:DescribeLogStreams', - 'logs:PutLogEvents', - 'logs:GetLogEvents', - 'logs:FilterLogEvent', - ], - resources: [ - logsArn - ] - }) - ] - }) - } - }); - - const api = new RestApi(this, 'DLTApi', { - defaultCorsPreflightOptions: { - allowOrigins: ['*'], - allowHeaders: [ - 'Authorization', - 'Content-Type', - 'X-Amz-Date', - 'X-Amz-Security-Token', - 'X-Api-Key' - ], - allowMethods: [ - 'DELETE', - 'GET', - 'HEAD', - 'OPTIONS', - 'PATCH', - 'POST', - 'PUT' - ], - statusCode: 200 - }, - deploy: true, - deployOptions: { - accessLogDestination: new LogGroupLogDestination(apiLogs), - accessLogFormat: AccessLogFormat.jsonWithStandardFields(), - loggingLevel: MethodLoggingLevel.INFO, - stageName: 'prod', - tracingEnabled: true - }, - description: `Distributed Load Testing API - version ${props.solutionVersion}`, - endpointTypes: [EndpointType.EDGE] - }); - - this.apiId = api.restApiId; - this.apiEndpointPath = api.url.slice(0, -1); - - const apiAccountConfig = new CfnAccount(this, 'ApiAccountConfig', { - cloudWatchRoleArn: apiLoggingRole.roleArn - }); - apiAccountConfig.addDependsOn(api.node.defaultChild as CfnResource); - - const apiAllRequestValidator = new RequestValidator(this, 'APIAllRequestValidator', { - restApi: api, - validateRequestBody: true, - validateRequestParameters: true - }); - - const apiDeployment = api.node.findChild('Deployment') as Deployment; - const apiDeploymentResource = apiDeployment.node.defaultChild as CfnResource; - apiDeploymentResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W68', - reason: 'The solution does not require the usage plan.' - }] - }); - - const apiFindProdResource = api.node.findChild('DeploymentStage.prod') as Stage; - const apiProdResource = apiFindProdResource.node.defaultChild as CfnResource; - apiProdResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W64', - reason: 'The solution does not require the usage plan.' - }] - }); - - const allIntegration = new Integration({ - type: IntegrationType.AWS_PROXY, - integrationHttpMethod: 'POST', - options: { - contentHandling: ContentHandling.CONVERT_TO_TEXT, - integrationResponses: [{ statusCode: '200' }], - passthroughBehavior: PassthroughBehavior.WHEN_NO_MATCH, - }, - uri: `arn:${Aws.PARTITION}:apigateway:${Aws.REGION}:lambda:path/2015-03-31/functions/${dltApiServicesLambda.functionArn}/invocations` - }); - const allMethodOptions: MethodOptions = { - authorizationType: AuthorizationType.IAM, - methodResponses: [{ - statusCode: '200', - responseModels: { - 'application/json': { modelId: 'Empty' } - } - }], - requestValidator: apiAllRequestValidator - }; - - /** Test scenario API - * /scenarios - * /scenarios/{testId} - * /tasks - */ - const scenariosResource = api.root.addResource('scenarios'); - scenariosResource.addMethod('ANY', allIntegration, allMethodOptions); - - const testIds = scenariosResource.addResource('{testId}'); - testIds.addMethod('ANY', allIntegration, allMethodOptions); - - const tasksResource = api.root.addResource('tasks'); - tasksResource.addMethod('ANY', allIntegration, allMethodOptions); - - - const invokeSourceArn = Stack.of(this).formatArn({ service: 'execute-api', resource: api.restApiId, resourceName: '*' }); - dltApiServicesLambda.addPermission('DLTApiInvokePermission', { - action: 'lambda:InvokeFunction', - principal: new ServicePrincipal('apigateway.amazonaws.com'), - sourceArn: invokeSourceArn - }); - - } -} \ No newline at end of file diff --git a/source/infrastructure/lib/auth.ts b/source/infrastructure/lib/auth.ts deleted file mode 100644 index 83bf223..0000000 --- a/source/infrastructure/lib/auth.ts +++ /dev/null @@ -1,172 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { UserPool, CfnUserPool, UserPoolClient, ClientAttributes, CfnIdentityPool, CfnIdentityPoolRoleAttachment, CfnUserPoolUser } from '@aws-cdk/aws-cognito'; -import { Effect, FederatedPrincipal, PolicyDocument, PolicyStatement, Role } from '@aws-cdk/aws-iam'; -import { Aws, Construct, Duration, RemovalPolicy, Stack } from '@aws-cdk/core'; - - -/** - * CognitoAuthConstruct props -* @interface CognitoAuthConstructProps -*/ -export interface CognitoAuthConstructProps { - adminEmail: string; - adminName: string; - apiId: string; - cloudFrontDomainName: string; - scenariosBucketArn: string; -} - -/** - * @class CognitoAuthConstruct - */ -export class CognitoAuthConstruct extends Construct { - cognitoIdentityPoolId: string; - cognitoUserPoolClientId: string; - cognitoUserPoolId: string; - - constructor(scope: Construct, id: string, props: CognitoAuthConstructProps) { - super(scope, id); - - const cognitoUserPool = new UserPool(this, 'DLTUserPool', { - autoVerify: { - email: true - }, - passwordPolicy: { - minLength: 12, - requireLowercase: true, - requireDigits: true, - requireSymbols: true, - requireUppercase: true - }, - removalPolicy: RemovalPolicy.DESTROY, - selfSignUpEnabled: false, - signInAliases: { - email: true, - username: true - }, - standardAttributes: { - email: { - required: true - } - }, - userInvitation: { - emailSubject: 'Welcome to Distributed Load Testing', - emailBody: ` -

    - Please use the credentials below to login to the Distributed Load Testing console. -

    -

    - Username: {username} -

    -

    - Password: {####} -

    -

    - Console: https://${props.cloudFrontDomainName}/ -

    - `, - smsMessage: 'Your username is {username} and temporary password is {####}.' - }, - userPoolName: `${Aws.STACK_NAME}-user-pool` - }); - (cognitoUserPool.node.defaultChild as CfnUserPool).userPoolAddOns = { advancedSecurityMode: 'ENFORCED' }; - this.cognitoUserPoolId = cognitoUserPool.userPoolId; - - const clientWriteAttributes = new ClientAttributes().withStandardAttributes({ - address: true, - email: true, - phoneNumber: true - }); - - const cognitoUserPoolClient = new UserPoolClient(this, 'DLTUserPoolClient', { - userPoolClientName: `${Aws.STACK_NAME}-userpool-client`, - userPool: cognitoUserPool, - generateSecret: false, - writeAttributes: clientWriteAttributes, - refreshTokenValidity: Duration.days(1), - }); - - this.cognitoUserPoolClientId = cognitoUserPoolClient.userPoolClientId; - - const cognitoIdentityPool = new CfnIdentityPool(this, 'DLTIdentityPool', { - allowUnauthenticatedIdentities: false, - cognitoIdentityProviders: [ - { - clientId: this.cognitoUserPoolClientId, - providerName: cognitoUserPool.userPoolProviderName - } - ] - }); - - this.cognitoIdentityPoolId = cognitoIdentityPool.ref; - - const apiProdExecuteArn = Stack.of(this).formatArn({ service: 'execute-api', resource: props.apiId, resourceName: 'prod/*' }); - const cognitoAuthorizedRole = new Role(this, 'DLTCognitoAuthorizedRole', { - assumedBy: new FederatedPrincipal( - 'cognito-identity.amazonaws.com', - { - StringEquals: { 'cognito-identity.amazonaws.com:aud': this.cognitoIdentityPoolId }, - 'ForAnyValue:StringLike': { 'cognito-identity.amazonaws.com:amr': 'authenticated' } - }, - 'sts:AssumeRoleWithWebIdentity' - ), - description: `${Aws.STACK_NAME} Identity Pool authenticated role`, - inlinePolicies: { - 'InvokeApiPolicy': new PolicyDocument({ - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['execute-api:Invoke'], - resources: [ - apiProdExecuteArn - ] - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 's3:PutObject', - 's3:GetObject' - ], - resources: [ - `${props.scenariosBucketArn}/public/*`, - `${props.scenariosBucketArn}/cloudWatchImages/*` - ] - }) - ] - }) - } - }); - - const cognitoUnauthorizedRole = new Role(this, 'DLTCognitoUnauthorizedRole', { - assumedBy: new FederatedPrincipal( - 'cognito-identity.amazonaws.com', - { - StringEquals: { 'cognito-identity.amazonaws.com:aud': this.cognitoIdentityPoolId }, - 'ForAnyValue:StringLike': { 'cognito-identity.amazonaws.com:amr': 'unauthenticated' } - }, - 'sts:AssumeRoleWithWebIdentity' - ) - }); - - new CfnIdentityPoolRoleAttachment(this, 'CognitoAttachRole', { - identityPoolId: this.cognitoIdentityPoolId, - roles: { - unauthenticated: cognitoUnauthorizedRole.roleArn, - authenticated: cognitoAuthorizedRole.roleArn - } - }); - - new CfnUserPoolUser(this, 'CognitoUser', { - desiredDeliveryMediums: ['EMAIL'], - forceAliasCreation: true, - userAttributes: [ - { name: 'email', value: props.adminEmail }, - { name: 'nickname', value: props.adminName }, - { name: 'email_verified', value: 'true' } - ], - username: props.adminName, - userPoolId: this.cognitoUserPoolId - }) - } -} \ No newline at end of file diff --git a/source/infrastructure/lib/back-end/scenarios-storage.ts b/source/infrastructure/lib/back-end/scenarios-storage.ts new file mode 100644 index 0000000..ff1f26d --- /dev/null +++ b/source/infrastructure/lib/back-end/scenarios-storage.ts @@ -0,0 +1,123 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { RemovalPolicy } from 'aws-cdk-lib'; +import { AttributeType, BillingMode, Table, TableEncryption } from 'aws-cdk-lib/aws-dynamodb'; +import { AnyPrincipal, Effect, Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { BlockPublicAccess, Bucket, BucketEncryption, HttpMethods } from 'aws-cdk-lib/aws-s3'; +import { Construct } from 'constructs'; + +export interface ScenarioTestRunnerStorageConstructProps { + // S3 Logs Bucket + readonly s3LogsBucket: Bucket; + // CloudFront domain name + readonly cloudFrontDomainName: string; + // Solution Id + readonly solutionId: string; +} + +/** + * Distributed Load Testing storage construct + * Creates an S3 bucket to store test scenarios and + * a Dynamodb table to store tests and test configuration + */ +export class ScenarioTestRunnerStorageConstruct extends Construct { + public scenariosBucket: Bucket; + public scenariosS3Policy: Policy; + public scenariosTable: Table; + public scenarioDynamoDbPolicy: Policy; + public historyTable: Table; + public historyDynamoDbPolicy: Policy; + + + constructor(scope: Construct, id: string, props: ScenarioTestRunnerStorageConstructProps) { + super(scope, id); + + this.scenariosBucket = new Bucket(this, 'DLTScenariosBucket', { + removalPolicy: RemovalPolicy.RETAIN, + serverAccessLogsBucket: props.s3LogsBucket, + serverAccessLogsPrefix: 'scenarios-bucket-access/', + encryption: BucketEncryption.KMS_MANAGED, + blockPublicAccess: BlockPublicAccess.BLOCK_ALL, + cors: [ + { + allowedMethods: [HttpMethods.GET, HttpMethods.POST, HttpMethods.PUT], + allowedOrigins: [`https://${props.cloudFrontDomainName}`], + allowedHeaders: ['*'], + exposedHeaders: ['ETag'] + } + ] + }); + + this.scenariosBucket.addToResourcePolicy(new PolicyStatement({ + actions: ['s3:*'], + resources: [this.scenariosBucket.bucketArn, `${this.scenariosBucket.bucketArn}/*`], + effect: Effect.DENY, + principals: [new AnyPrincipal], + conditions: { + 'Bool': { + 'aws:SecureTransport': false + } + } + })); + + this.scenariosS3Policy = new Policy(this, 'ScenariosS3Policy', { + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 's3:HeadObject', + 's3:PutObject', + 's3:GetObject', + 's3:ListBucket' + ], + resources: [ + this.scenariosBucket.bucketArn, + `${this.scenariosBucket.bucketArn}/*` + ] + }) + ] + }); + + this.scenariosTable = new Table(this, 'DLTScenariosTable', { + billingMode: BillingMode.PAY_PER_REQUEST, + encryption: TableEncryption.AWS_MANAGED, + partitionKey: { name: 'testId', type: AttributeType.STRING }, + pointInTimeRecovery: true + }); + + this.historyTable = new Table(this, 'DLTHistoryTable', { + billingMode: BillingMode.PAY_PER_REQUEST, + encryption: TableEncryption.AWS_MANAGED, + partitionKey: { name: 'testId', type: AttributeType.STRING }, + sortKey: { name: 'testRunId', type: AttributeType.STRING }, + pointInTimeRecovery: true + }); + + let historyDDBActions = ['dynamodb:BatchWriteItem', 'dynamodb:PutItem', 'dynamodb:Query']; + this.historyDynamoDbPolicy = new Policy(this, 'HistoryDynamoDbPolicy', { + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: historyDDBActions, + resources: [ + this.historyTable.tableArn + ] + }) + ] + }); + + const scenariosDDBActions = ['dynamodb:DeleteItem', 'dynamodb:GetItem', 'dynamodb:PutItem', 'dynamodb:Scan', 'dynamodb:UpdateItem']; + this.scenarioDynamoDbPolicy = new Policy(this, 'ScenarioDynamoDbPolicy', { + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: scenariosDDBActions, + resources: [ + this.scenariosTable.tableArn + ] + }) + ] + }); + } +} \ No newline at end of file diff --git a/source/infrastructure/lib/back-end/step-functions.ts b/source/infrastructure/lib/back-end/step-functions.ts new file mode 100644 index 0000000..1586384 --- /dev/null +++ b/source/infrastructure/lib/back-end/step-functions.ts @@ -0,0 +1,181 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Chain, Choice, Condition, DISCARD, Fail, Pass, LogLevel, Map as SFMap, StateMachine, Succeed, Wait, WaitTime } from 'aws-cdk-lib/aws-stepfunctions'; +import { LambdaInvoke } from 'aws-cdk-lib/aws-stepfunctions-tasks'; +import { Aws, CfnResource, Duration } from 'aws-cdk-lib'; +import { Policy } from 'aws-cdk-lib/aws-iam'; +import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs'; +import { IFunction } from 'aws-cdk-lib/aws-lambda'; +import { Construct } from 'constructs'; + +export interface TaskRunnerStepFunctionConstructProps { + // State machine Lambda functions + readonly taskStatusChecker: IFunction; + readonly taskRunner: IFunction; + readonly resultsParser: IFunction; + readonly taskCanceler: IFunction; + // Solution ID + readonly solutionId: string; + readonly suffix: string; +} + +/** + * Creates the Step function state machine to control the Fargate tasks + */ +export class TaskRunnerStepFunctionConstruct extends Construct { + public taskRunnerStepFunctions: StateMachine; + + constructor(scope: Construct, id: string, props: TaskRunnerStepFunctionConstructProps) { + super(scope, id); + + const stepFunctionsLogGroup = new LogGroup(this, 'StepFunctionsLogGroup', { + retention: RetentionDays.ONE_YEAR, + logGroupName: `/aws/vendedlogs/states/StepFunctionsLogGroup${Aws.STACK_NAME}${props.suffix}` + }); + const stepFunctionsLogGroupResource = stepFunctionsLogGroup.node.defaultChild as CfnResource; + stepFunctionsLogGroupResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W84', + reason: 'KMS encryption unnecessary for log group' + }] + }); + + const done = new Succeed(this, 'Done'); + const mapEnd = new Pass(this, "Map End"); + const parseResult = new LambdaInvoke(this, 'Parse result', { + lambdaFunction: props.resultsParser + }); + parseResult.next(done); + + const checkWorkerStatus = new LambdaInvoke(this, 'Check worker status', { + lambdaFunction: props.taskStatusChecker, + inputPath: '$', + outputPath: '$.Payload' + }); + + const checkTaskStatus = new LambdaInvoke(this, 'Check task status', { + lambdaFunction: props.taskStatusChecker, + inputPath: '$', + outputPath: '$.Payload' + }); + + const waitTask = new Wait(this, 'Wait 1 minute - task status', { + comment: 'Wait 1 minute to check task status again', + time: WaitTime.duration(Duration.seconds(60)) + }); + waitTask.next(checkTaskStatus); + + const allTasksDone = new Choice(this, 'Are all tasks done?'); + allTasksDone.when(Condition.booleanEquals('$.isRunning', false), mapEnd); + allTasksDone.otherwise(waitTask); + + checkTaskStatus.next(allTasksDone); + + const cancelTest = new LambdaInvoke(this, 'Cancel Test', { + lambdaFunction: props.taskCanceler, + inputPath: '$', + resultPath: DISCARD + }); + cancelTest.next(mapEnd); + + const waitWorker = new Wait(this, 'Wait 1 minute - worker status', { + comment: 'Wait 1 minute to check task status again', + time: WaitTime.duration(Duration.seconds(60)) + }); + waitWorker.next(checkWorkerStatus); + + const regionConfigsForTest = new SFMap(this, 'Regions for testing', { + inputPath: '$', + resultPath: DISCARD, + itemsPath: '$.testTaskConfig', + parameters: { + "testTaskConfig.$": "$$.Map.Item.Value", + "testId.$": "$.testId", + "testType.$": "$.testType", + "fileType.$": "$.fileType", + "showLive.$": "$.showLive", + "prefix.$": "$.prefix" + } + }); + + const runWorkers = new LambdaInvoke(this, 'Run workers', { + lambdaFunction: props.taskRunner, + inputPath: '$', + outputPath: '$.Payload' + }); + + const requiresLeader = new Choice(this, 'Requires leader?'); + requiresLeader.when(Condition.booleanEquals('$.isRunning', false), cancelTest); + requiresLeader.when(Condition.isNotPresent('$.taskIds'), waitTask); + requiresLeader.otherwise(waitWorker); + + runWorkers.next(requiresLeader); + + const runLeaderTask = new LambdaInvoke(this, 'Run leader task', { + lambdaFunction: props.taskRunner, + inputPath: '$', + outputPath: '$.Payload' + }); + runLeaderTask.addCatch(cancelTest, { resultPath: '$.error' }); + + runLeaderTask.next(waitTask); + + const allWorkersRunning = new Choice(this, 'Are all workers running?'); + allWorkersRunning.when(Condition.booleanEquals('$.isRunning', false), cancelTest); + allWorkersRunning.when(Condition.numberEqualsJsonPath('$.numTasksRunning', '$.numTasksTotal'), runLeaderTask); + allWorkersRunning.otherwise(waitWorker); + + checkWorkerStatus.next(allWorkersRunning); + + const testIsStillRunning = new Fail(this, 'Test is still running', { + cause: 'The same test is already running.', + error: 'TestAlreadyRunning' + }); + + const noRunningTests = new Choice(this, 'No running tests'); + noRunningTests.when(Condition.booleanEquals('$.isRunning', false), runWorkers); + noRunningTests.otherwise(testIsStillRunning); + + const checkRunningTests = new LambdaInvoke(this, 'Check running tests', { + lambdaFunction: props.taskStatusChecker, + inputPath: '$', + outputPath: '$.Payload' + }); + checkRunningTests.next(noRunningTests); + + const definition = Chain + .start(regionConfigsForTest.iterator(checkRunningTests)) + .next(parseResult); + + this.taskRunnerStepFunctions = new StateMachine(this, 'TaskRunnerStepFunctions', { + definition, + logs: { + destination: stepFunctionsLogGroup, + level: LogLevel.ALL, + includeExecutionData: false + } + }); + const stepFunctionsRoleResource = this.taskRunnerStepFunctions.role.node.defaultChild as CfnResource; + stepFunctionsRoleResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W11', + reason: 'CloudWatch logs actions do not support resource level permissions' + }, { + id: 'W12', + reason: 'CloudWatch logs actions do not support resource level permissions' + }] + }); + const stepFunctionPolicy = this.taskRunnerStepFunctions.role.node.findChild('DefaultPolicy') as Policy; + const policyResource = stepFunctionPolicy.node.defaultChild as CfnResource; + policyResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W12', + reason: 'CloudWatch logs actions do not support resource level permissions' + }, { + id: 'W76', + reason: 'The IAM policy is written for least-privilege access.' + }] + }); + } +} \ No newline at end of file diff --git a/source/infrastructure/lib/back-end/test-task-lambdas.ts b/source/infrastructure/lib/back-end/test-task-lambdas.ts new file mode 100644 index 0000000..576ca97 --- /dev/null +++ b/source/infrastructure/lib/back-end/test-task-lambdas.ts @@ -0,0 +1,359 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ArnFormat, Aws, CfnResource, Duration, Stack } from 'aws-cdk-lib'; +import { Code, Function as LambdaFunction, Runtime } from 'aws-cdk-lib/aws-lambda'; +import { Effect, PolicyStatement, PolicyDocument, Role, ServicePrincipal, Policy } from 'aws-cdk-lib/aws-iam'; +import { IBucket } from 'aws-cdk-lib/aws-s3'; +import { Table } from 'aws-cdk-lib/aws-dynamodb'; +import { LogGroup } from 'aws-cdk-lib/aws-logs'; +import { Construct } from 'constructs'; + +export interface TestRunnerLambdasConstructProps { + readonly cloudWatchLogsPolicy: Policy; + //ECS Task Execution Role ARN + readonly ecsTaskExecutionRoleArn: string; + // ECS CloudWatch LogGroup + readonly ecsCloudWatchLogGroup: LogGroup; + // ECS Cluster + readonly ecsCluster: string; + // ECS Task definition + readonly ecsTaskDefinition: string; + // ECS Security Group + readonly ecsTaskSecurityGroup: string; + // Table storing historical test data + readonly historyTable: Table; + // History DynamoDB table policy + readonly historyDynamoDbPolicy: Policy; + // Scenarios S3 Bucket policy + readonly scenariosS3Policy: Policy; + // Subnet A Id + readonly subnetA: string; + // Subnet B Id + readonly subnetB: string; + // Test scenarios bucket + readonly scenariosBucket: string; + // Test scenarios table + readonly scenariosTable: Table; + // Scenario DynamoDB table policy + readonly scenariosDynamoDbPolicy: Policy; + /** + * Solution config properties. + * the metric URL endpoint, send anonymous usage, solution ID, version, source code bucket, and source code prefix + */ + readonly metricsUrl: string; + readonly sendAnonymousUsage: string; + readonly solutionId: string; + readonly solutionVersion: string; + readonly sourceCodeBucket: IBucket; + readonly sourceCodePrefix: string; + readonly uuid: string; +} + +/** +* Distributed Load Testing on AWS Test Runner Lambdas construct. +* This creates the Results parser, Task Runner, Task Canceler, +* and Task Status Checker +*/ +export class TestRunnerLambdasConstruct extends Construct { + public resultsParser: LambdaFunction; + public taskRunner: LambdaFunction; + public taskCanceler: LambdaFunction; + public taskCancelerInvokePolicy: Policy; + public taskStatusChecker: LambdaFunction; + public realTimeDataPublisher: LambdaFunction; + + constructor(scope: Construct, id: string, props: TestRunnerLambdasConstructProps) { + super(scope, id); + + const ecsLogGroupArn = props.ecsCloudWatchLogGroup.logGroupArn; + const lambdaResultsRole = new Role(this, 'LambdaResultsRole', { + assumedBy: new ServicePrincipal('lambda.amazonaws.com') + }); + const cfnPolicy = new Policy(this, 'LambdaResultsPolicy', { + statements: [ + new PolicyStatement({ + resources: ['*'], + actions: ['cloudwatch:GetMetricWidgetImage'] + }), + new PolicyStatement({ + resources: [ecsLogGroupArn], + actions: ['logs:DeleteMetricFilter'] + }) + ] + }); + + lambdaResultsRole.attachInlinePolicy(cfnPolicy); + lambdaResultsRole.attachInlinePolicy(props.cloudWatchLogsPolicy); + lambdaResultsRole.attachInlinePolicy(props.scenariosDynamoDbPolicy); + lambdaResultsRole.attachInlinePolicy(props.historyDynamoDbPolicy); + lambdaResultsRole.attachInlinePolicy(props.scenariosS3Policy); + + const resultsRoleResource = lambdaResultsRole.node.defaultChild as CfnResource; + resultsRoleResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W12', + reason: 'The action does not support resource level permissions.' + }] + }); + const resultsPolicyResource = cfnPolicy.node.defaultChild as CfnResource; + resultsPolicyResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W12', + reason: 'The action does not support resource level permissions.' + }] + }); + + this.resultsParser = new LambdaFunction(this, 'ResultsParser', { + description: 'Result parser for indexing xml test results to DynamoDB', + handler: 'index.handler', + role: lambdaResultsRole, + code: Code.fromBucket(props.sourceCodeBucket, `${props.sourceCodePrefix}/results-parser.zip`), + runtime: Runtime.NODEJS_14_X, + timeout: Duration.seconds(120), + environment: { + HISTORY_TABLE: props.historyTable.tableName, + METRIC_URL: props.metricsUrl, + SCENARIOS_BUCKET: props.scenariosBucket, + SCENARIOS_TABLE: props.scenariosTable.tableName, + SEND_METRIC: props.sendAnonymousUsage, + SOLUTION_ID: props.solutionId, + UUID: props.uuid, + VERSION: props.solutionVersion + }, + }); + const resultsParserResource = this.resultsParser.node.defaultChild as CfnResource; + resultsParserResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W58', + reason: 'CloudWatchLogsPolicy covers a permission to write CloudWatch logs.' + }, { + id: 'W89', + reason: 'This Lambda function does not require a VPC' + }, { + id: 'W92', + reason: 'Does not run concurrent executions' + }] + }); + + const taskArn = Stack.of(this).formatArn({ service: 'ecs', resource: 'task', resourceName: '*', arnFormat: ArnFormat.SLASH_RESOURCE_NAME }); + const taskDefArn = Stack.of(this).formatArn({ service: 'ecs', resource: 'task-definition', resourceName: '*:*' }); + + const lambdaTaskRole = new Role(this, 'DLTTestLambdaTaskRole', { + assumedBy: new ServicePrincipal('lambda.amazonaws.com'), + inlinePolicies: { + 'TaskLambdaPolicy': new PolicyDocument({ + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['ecs:ListTasks'], + resources: ['*'] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'ecs:RunTask', + 'ecs:DescribeTasks' + ], + resources: [ + taskArn, + taskDefArn + ] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['iam:PassRole'], + resources: [props.ecsTaskExecutionRoleArn] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['logs:PutMetricFilter'], + resources: [ecsLogGroupArn] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['cloudwatch:PutDashboard'], + resources: [ + `arn:${Aws.PARTITION}:cloudwatch::${Aws.ACCOUNT_ID}:dashboard/EcsLoadTesting*` + ] + }) + ] + }) + } + }); + lambdaTaskRole.attachInlinePolicy(props.cloudWatchLogsPolicy); + lambdaTaskRole.attachInlinePolicy(props.scenariosDynamoDbPolicy); + + const lambdaTaskRoleResource = lambdaTaskRole.node.defaultChild as CfnResource; + lambdaTaskRoleResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W11', + reason: 'ecs:ListTasks does not support resource level permissions' + }] + }); + + this.taskRunner = new LambdaFunction(this, 'TaskRunner', { + description: 'Task runner for ECS task definitions', + handler: 'index.handler', + role: lambdaTaskRole, + code: Code.fromBucket(props.sourceCodeBucket, `${props.sourceCodePrefix}/task-runner.zip`), + environment: { + SCENARIOS_BUCKET: props.scenariosBucket, + SCENARIOS_TABLE: props.scenariosTable.tableName, + SOLUTION_ID: props.solutionId, + VERSION: props.solutionVersion + }, + runtime: Runtime.NODEJS_14_X, + timeout: Duration.seconds(900) + }); + const taskRunnerResource = this.taskRunner.node.defaultChild as CfnResource; + taskRunnerResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W58', + reason: 'CloudWatchLogsPolicy covers a permission to write CloudWatch logs.' + }, { + id: 'W89', + reason: 'This Lambda function does not require a VPC' + }, { + id: 'W92', + reason: 'Does not run concurrent executions' + }] + }); + + const taskCancelerRole = new Role(this, 'LambdaTaskCancelerRole', { + assumedBy: new ServicePrincipal('lambda.amazonaws.com'), + inlinePolicies: { + 'TaskCancelerPolicy': new PolicyDocument({ + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['ecs:ListTasks'], + resources: ['*'] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['ecs:StopTask'], + resources: [ + taskArn, + taskDefArn + ] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['dynamodb:UpdateItem'], + resources: [props.scenariosTable.tableArn] + }) + ] + }) + } + }); + taskCancelerRole.attachInlinePolicy(props.cloudWatchLogsPolicy); + + const taskCancelerRoleResource = taskCancelerRole.node.defaultChild as CfnResource; + taskCancelerRoleResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W11', + reason: 'ecs:ListTasks does not support resource level permissions' + }] + }); + + this.taskCanceler = new LambdaFunction(this, 'TaskCanceler', { + description: 'Stops ECS task', + handler: 'index.handler', + role: taskCancelerRole, + code: Code.fromBucket(props.sourceCodeBucket, `${props.sourceCodePrefix}/task-canceler.zip`), + runtime: Runtime.NODEJS_14_X, + timeout: Duration.seconds(300), + environment: { + METRIC_URL: props.metricsUrl, + SOLUTION_ID: props.solutionId, + VERSION: props.solutionVersion, + SCENARIOS_TABLE: props.scenariosTable.tableName + } + }); + const taskCancelerResource = this.taskCanceler.node.defaultChild as CfnResource; + taskCancelerResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W58', + reason: 'CloudWatchLogsPolicy covers a permission to write CloudWatch logs.' + }, { + id: 'W89', + reason: 'This Lambda function does not require a VPC' + }, { + id: 'W92', + reason: 'Does not run concurrent executions' + }] + }); + + this.taskCancelerInvokePolicy = new Policy(this, 'TaskCancelerInvokePolicy', { + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['lambda:InvokeFunction'], + resources: [this.taskCanceler.functionArn] + }) + ] + }); + + const taskStatusCheckerRole = new Role(this, 'TaskStatusRole', { + assumedBy: new ServicePrincipal('lambda.amazonaws.com'), + inlinePolicies: { + 'TaskStatusPolicy': new PolicyDocument({ + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['ecs:ListTasks'], + resources: ['*'] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['ecs:DescribeTasks'], + resources: [ + taskArn + ] + }) + ] + }) + } + }); + taskStatusCheckerRole.attachInlinePolicy(this.taskCancelerInvokePolicy); + taskStatusCheckerRole.attachInlinePolicy(props.cloudWatchLogsPolicy); + taskStatusCheckerRole.attachInlinePolicy(props.scenariosDynamoDbPolicy); + + const taskStatusCheckerRoleResource = taskStatusCheckerRole.node.defaultChild as CfnResource; + taskStatusCheckerRoleResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W11', + reason: 'ecs:ListTasks does not support resource level permissions' + }] + }); + + this.taskStatusChecker = new LambdaFunction(this, 'TaskStatusChecker', { + description: 'Task status checker', + handler: 'index.handler', + role: taskStatusCheckerRole, + code: Code.fromBucket(props.sourceCodeBucket, `${props.sourceCodePrefix}/task-status-checker.zip`), + runtime: Runtime.NODEJS_14_X, + timeout: Duration.seconds(180), + environment: { + SCENARIOS_TABLE: props.scenariosTable.tableName, + TASK_CANCELER_ARN: this.taskCanceler.functionArn, + SOLUTION_ID: props.solutionId, + VERSION: props.solutionVersion + } + }); + const taskStatusCheckerResource = this.taskStatusChecker.node.defaultChild as CfnResource; + taskStatusCheckerResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W58', + reason: 'CloudWatchLogsPolicy covers a permission to write CloudWatch logs.' + }, { + id: 'W89', + reason: 'This Lambda function does not require a VPC' + }, { + id: 'W92', + reason: 'Does not run concurrent executions' + }] + }); + } +} \ No newline at end of file diff --git a/source/infrastructure/lib/common-resources.ts b/source/infrastructure/lib/common-resources.ts deleted file mode 100644 index a4cca5e..0000000 --- a/source/infrastructure/lib/common-resources.ts +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { Aws, CfnCondition, CfnCustomResource, CfnResource, Construct, CustomResource, Duration, RemovalPolicy, Stack, Tags } from '@aws-cdk/core'; -import { BlockPublicAccess, Bucket, BucketAccessControl, BucketEncryption, IBucket } from '@aws-cdk/aws-s3'; -import { AnyPrincipal, Effect, Policy, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from '@aws-cdk/aws-iam'; -import { Code, Function as LambdaFunction, Runtime } from '@aws-cdk/aws-lambda'; - -/** - * CommonResourcesContruct props - * @interface CommonResourcesContructProps - */ -export interface CommonResourcesContructProps { - // ECS Task Execution Role - dltEcsTaskExecutionRole: Role; - // Solution ID - readonly solutionId: string; - //Source Code Bucket - readonly sourceCodeBucket: string; - // Source code prefix - readonly sourceCodePrefix: string; - // Solution Version - readonly solutionVersion: string; - // Send anonymous metrics condition: - readonly sendAnonymousUsageCondition: CfnCondition; - // Metrics URL - readonly metricsUrl: string; - // create VPC resources condition logical ID - readonly existingVpc: string; -} - -/** - * @class - * Distributed Load Testing on AWS common resources construct. - * Creates a CloudWatch logs policy and an S3 bucket to store logs. - */ -export class CommonResourcesContruct extends Construct { - // CloudWatch Logs Policy - public cloudWatchLogsPolicy: Policy; - // Custom Resource Role - public customResourceRole: Role; - //Custom Resource lambda - public customResourceLambda: LambdaFunction; - //S3 Logs Bucket - public s3LogsBucket: Bucket; - // Code S3 Bucket - public sourceBucket: IBucket; - // Generated UUID - public uuid: string; - - constructor(scope: Construct, id: string, props: CommonResourcesContructProps) { - super(scope, id); - - const logGroupResourceArn = Stack.of(this).formatArn({ service: 'logs', resource: 'log-group:', resourceName: 'aws/lambda/*' }) - this.cloudWatchLogsPolicy = new Policy(this, 'CloudWatchLogsPolicy', { - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'logs:CreateLogGroup', - 'logs:CreateLogStream', - 'logs:PutLogEvents' - ], - resources: [ - logGroupResourceArn - ] - }) - ] - }); - - props.dltEcsTaskExecutionRole.attachInlinePolicy(this.cloudWatchLogsPolicy); - - this.s3LogsBucket = new Bucket(this, 'LogsBucket', { - accessControl: BucketAccessControl.LOG_DELIVERY_WRITE, - blockPublicAccess: BlockPublicAccess.BLOCK_ALL, - encryption: BucketEncryption.S3_MANAGED, - removalPolicy: RemovalPolicy.RETAIN - }); - Tags.of(this.s3LogsBucket).add('SolutionId', props.solutionId); - - this.s3LogsBucket.addToResourcePolicy( - new PolicyStatement({ - actions: ['s3:*'], - conditions: { - Bool: { 'aws:SecureTransport': 'false' } - }, - effect: Effect.DENY, - principals: [new AnyPrincipal()], - resources: [this.s3LogsBucket.bucketArn, this.s3LogsBucket.arnForObjects('*')] - }) - ); - - const s3LogsBucketResource = this.s3LogsBucket.node.defaultChild as CfnResource; - s3LogsBucketResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W35', - reason: 'This is the logging bucket, it does not require logging.' - }] - }) - - this.sourceBucket = Bucket.fromBucketName(this, 'SourceCodeBucket', props.sourceCodeBucket); - const sourceBucketArn = this.sourceBucket.arnForObjects('*'); - - const customResourceRole = new Role(this, 'CustomResourceLambdaRole', { - assumedBy: new ServicePrincipal('lambda.amazonaws.com'), - inlinePolicies: { - 'CustomResourcePolicy': new PolicyDocument({ - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['s3:GetObject'], - resources: [ - sourceBucketArn - ] - }) - ] - }) - } - }); - customResourceRole.attachInlinePolicy(this.cloudWatchLogsPolicy); - - this.customResourceLambda = new LambdaFunction(this, 'CustomResourceLambda', { - description: 'CFN Lambda backed custom resource to deploy assets to s3', - handler: 'index.handler', - role: customResourceRole, - code: Code.fromBucket(this.sourceBucket, `${props.sourceCodePrefix}/custom-resource.zip`), - runtime: Runtime.NODEJS_14_X, - timeout: Duration.seconds(120), - environment: { - METRIC_URL: props.metricsUrl, - SOLUTION_ID: props.solutionId, - VERSION: props.solutionVersion - } - }); - - Tags.of(this.customResourceLambda).add('SolutionId', props.solutionId); - const customResource = this.customResourceLambda.node.defaultChild as CfnResource; - customResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W58', - reason: 'CloudWatchLogsPolicy covers a permission to write CloudWatch logs.' - }, { - id: 'W89', - reason: 'VPC not needed for lambda' - }, { - id: 'W92', - reason: 'Does not run concurrent executions' - },] - }) - - const uuidGenerator = new CustomResource(this, 'UUID', { - serviceToken: this.customResourceLambda.functionArn, - resourceType: 'Custom::UUID', - properties: { - Resource: 'UUID' - } - }); - - this.uuid = uuidGenerator.getAtt('UUID').toString(); - - - const sendAnonymousMetrics = new CustomResource(this, 'AnonymousMetric', { - serviceToken: this.customResourceLambda.functionArn, - resourceType: 'Custom::AnonymousMetric', - properties: { - Resource: 'AnonymousMetric', - Region: Aws.REGION, - SolutionId: props.solutionId, - UUID: this.uuid, - VERSION: props.solutionVersion, - existingVPC: props.existingVpc - } - }); - const cfnSendAnonymousMetrics = sendAnonymousMetrics.node.defaultChild as CfnCustomResource; - cfnSendAnonymousMetrics.cfnOptions.condition = props.sendAnonymousUsageCondition; - } -} \ No newline at end of file diff --git a/source/infrastructure/lib/common-resources/common-resources.ts b/source/infrastructure/lib/common-resources/common-resources.ts new file mode 100644 index 0000000..2c2b3c5 --- /dev/null +++ b/source/infrastructure/lib/common-resources/common-resources.ts @@ -0,0 +1,75 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CfnResource, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { BlockPublicAccess, Bucket, BucketAccessControl, BucketEncryption, IBucket } from 'aws-cdk-lib/aws-s3'; +import { AnyPrincipal, Effect, Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { Construct } from 'constructs'; + +export interface CommonResourcesConstructProps { + readonly sourceCodeBucket: string; +} + +/** + * Distributed Load Testing on AWS common resources construct. + * Creates a CloudWatch logs policy and an S3 bucket to store logs. + */ +export class CommonResourcesConstruct extends Construct { + // CloudWatch logs Policy + public cloudWatchLogsPolicy: Policy; + // Code S3 Bucket + public sourceBucket: IBucket; + + + constructor(scope: Construct, id: string, props: CommonResourcesConstructProps) { + super(scope, id); + + const logGroupResourceArn = Stack.of(this).formatArn({ service: 'logs', resource: 'log-group:', resourceName: 'aws/lambda/*' }); + this.cloudWatchLogsPolicy = new Policy(this, 'CloudWatchLogsPolicy', { + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'logs:CreateLogGroup', + 'logs:CreateLogStream', + 'logs:PutLogEvents' + ], + resources: [ + logGroupResourceArn + ] + }) + ] + }); + + this.sourceBucket = Bucket.fromBucketName(this, 'SourceCodeBucket', props.sourceCodeBucket); + } + + public s3LogsBucket(): Bucket { + const logsBucket = new Bucket(this, 'LogsBucket', { + accessControl: BucketAccessControl.LOG_DELIVERY_WRITE, + blockPublicAccess: BlockPublicAccess.BLOCK_ALL, + encryption: BucketEncryption.S3_MANAGED, + removalPolicy: RemovalPolicy.RETAIN + }); + + logsBucket.addToResourcePolicy( + new PolicyStatement({ + actions: ['s3:*'], + conditions: { + Bool: { 'aws:SecureTransport': 'false' } + }, + effect: Effect.DENY, + principals: [new AnyPrincipal()], + resources: [logsBucket.bucketArn, logsBucket.arnForObjects('*')] + })); + + const s3LogsBucketResource = logsBucket.node.defaultChild as CfnResource; + s3LogsBucketResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W35', + reason: 'This is the logging bucket, it does not require logging.' + }] + }); + return logsBucket; + } +} \ No newline at end of file diff --git a/source/infrastructure/lib/console.ts b/source/infrastructure/lib/console.ts deleted file mode 100644 index 38fbcdc..0000000 --- a/source/infrastructure/lib/console.ts +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { CloudFrontToS3 } from '@aws-solutions-constructs/aws-cloudfront-s3'; -import { Effect, PolicyStatement } from '@aws-cdk/aws-iam'; -import { Bucket, IBucket } from "@aws-cdk/aws-s3"; -import { Construct, Tags } from '@aws-cdk/core'; -import { Function as LambdaFunction } from '@aws-cdk/aws-lambda'; - - -/** - * @interface DLTConsoleContructProps - * DLTConsoleContruct props - */ -export interface DLTConsoleContructProps { - // Custom Resource Lambda - customResource: LambdaFunction; - // S3 Logs Bucket - readonly s3LogsBucket: Bucket; - // Solution ID - readonly solutionId: string; -} - -/** - * Distributed Load Testing on AWS console construct - * This creates the S3 bucket and CloudFront distribution - * and Cognito resources for the web front end. - * @class - */ -export class DLTConsoleContruct extends Construct { - public cloudFrontDomainName: string; - public consoleBucketArn: string; - public consoleBucket: IBucket; - - constructor(scope: Construct, id: string, props: DLTConsoleContructProps) { - super(scope, id); - - const dltS3CloudFrontDist = new CloudFrontToS3(this, 'DLTCloudFrontToS3', { - bucketProps: { - serverAccessLogsBucket: props.s3LogsBucket, - serverAccessLogsPrefix: 'console-bucket-access/', - }, - cloudFrontDistributionProps: { - comment: 'Website distribution for the Distributed Load Testing solution', - enableLogging: true, - errorResponses: [ - { httpStatus: 403, responseHttpStatus: 200, responsePagePath: '/index.html' }, - { httpStatus: 404, responseHttpStatus: 200, responsePagePath: '/index.html' } - ], - httpVersion: 'http2', - logBucket: props.s3LogsBucket, - logFilePrefix: 'cloudfront-logs/' - }, - insertHttpSecurityHeaders: false - }); - Tags.of(dltS3CloudFrontDist).add('SolutionId', props.solutionId); - - this.cloudFrontDomainName = dltS3CloudFrontDist.cloudFrontWebDistribution.domainName; - this.consoleBucket = dltS3CloudFrontDist.s3BucketInterface; - - this.consoleBucketArn = dltS3CloudFrontDist.s3BucketInterface.bucketArn; - - props.customResource.addToRolePolicy(new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['s3:PutObject'], - resources: [this.consoleBucketArn, `${this.consoleBucketArn}/*`] - })); - } -} \ No newline at end of file diff --git a/source/infrastructure/lib/custom-resources.ts b/source/infrastructure/lib/custom-resources.ts deleted file mode 100644 index 560f4f5..0000000 --- a/source/infrastructure/lib/custom-resources.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { Aws, Construct, CustomResource } from '@aws-cdk/core'; - - -/** - * CustomResourcesConstruct props - * @interface CustomResourcesConstructProps - */ -export interface CustomResourcesConstructProps { - readonly apiEndpoint: string; - //Custom Resource lambda - readonly customResourceLambda: string; - // Cognito Resources - readonly cognitoIdentityPool: string; - readonly cognitoUserPool: string; - readonly cognitoUserPoolClient: string; - // UI Console S3 bucket - readonly consoleBucketName: string; - // Test scenarios storage - readonly scenariosBucket: string; - // Source code details - readonly sourceCodeBucketName: string; - readonly sourceCodePrefix: string; -} - -/** - * @class - * Distributed Load Testing on AWS Custom Resources Construct. - * It creates a custom resource Lambda function, a solution UUID, and a custom resource to send anonymous usage. - */ -export class CustomResourcesConstruct extends Construct { - - constructor(scope: Construct, id: string, props: CustomResourcesConstructProps) { - super(scope, id); - - new CustomResource(this, 'CopyConsoleFiles', { - serviceToken: props.customResourceLambda, - resourceType: 'Custom::CopyConsoleFiles', - properties: { - Resource: 'CopyAssets', - SrcBucket: props.sourceCodeBucketName, - SrcPath: `${props.sourceCodePrefix}/console`, - ManifestFile: 'console-manifest.json', - DestBucket: props.consoleBucketName - } - }); - - const awsExports = `const awsConfig = { - cw_dashboard: 'https://console.aws.amazon.com/cloudwatch/home?region=${Aws.REGION}#dashboards:name=', - ecs_dashboard: 'https://${Aws.REGION}.console.aws.amazon.com/ecs/home?region=${Aws.REGION}#/clusters/${Aws.STACK_NAME}/tasks', - aws_project_region: '${Aws.REGION}', - aws_cognito_region: '${Aws.REGION}', - aws_cognito_identity_pool_id: '${props.cognitoIdentityPool}', - aws_user_pools_id: '${props.cognitoUserPool}', - aws_user_pools_web_client_id: '${props.cognitoUserPoolClient}', - oauth: {}, - aws_cloud_logic_custom: [ - { - name: 'dlts', - endpoint: '${props.apiEndpoint}', - region: '${Aws.REGION}' - } - ], - aws_user_files_s3_bucket: '${props.scenariosBucket}', - aws_user_files_s3_bucket_region: '${Aws.REGION}' - }`; - - new CustomResource(this, 'ConsoleConfig', { - serviceToken: props.customResourceLambda, - resourceType: 'Custom::CopyConfigFiles', - properties: { - Resource: 'ConfigFile', - DestBucket: props.consoleBucketName, - AwsExports: awsExports - } - }); - } -} \ No newline at end of file diff --git a/source/infrastructure/lib/custom-resources/custom-resources-infra.ts b/source/infrastructure/lib/custom-resources/custom-resources-infra.ts new file mode 100644 index 0000000..f93f2bc --- /dev/null +++ b/source/infrastructure/lib/custom-resources/custom-resources-infra.ts @@ -0,0 +1,140 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ArnFormat, CfnResource, Duration, Stack } from 'aws-cdk-lib'; +import { Effect, Policy, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; +import { Code, Function as LambdaFunction, Runtime } from 'aws-cdk-lib/aws-lambda'; +import { Bucket, IBucket } from 'aws-cdk-lib/aws-s3'; +import { Construct } from 'constructs'; + +export interface CustomResourceInfraConstructProps { + readonly cloudWatchPolicy: Policy; + readonly consoleBucketArn?: string; + readonly mainStackRegion: string; + readonly metricsUrl: string; + readonly scenariosS3Bucket: string; + readonly scenariosTable: string; + readonly solutionId: string; + readonly solutionVersion: string; + readonly sourceCodeBucket: IBucket; + readonly sourceCodePrefix: string; + readonly stackType: string; +} + +/** + * Distributed Load Testing on AWS Custom Resources Construct. + * It creates a custom resource Lambda function. + */ +export class CustomResourceInfraConstruct extends Construct { + + public customResourceArn: string; + + constructor (scope: Construct, id: string, props: CustomResourceInfraConstructProps) { + super(scope, id); + + const sourceBucket = props.sourceCodeBucket; + const sourceBucketArn = sourceBucket.arnForObjects('*'); + + const scenariosBucket = Bucket.fromBucketName(this, 'ScenariosBucket', props.scenariosS3Bucket); + const scenariosBucketObjectArn = scenariosBucket.arnForObjects('*'); + + const scenariosTableArn = Stack.of(this).formatArn({ service: 'dynamodb', region: props.mainStackRegion, resource: 'table', resourceName: props.scenariosTable }); + + const customResourceRole = new Role(this, 'CustomResourceLambdaRole', { + assumedBy: new ServicePrincipal('lambda.amazonaws.com'), + inlinePolicies: { + 'CustomResourcePolicy': new PolicyDocument({ + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['s3:GetObject'], + resources: [ + sourceBucketArn, + ] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 's3:PutObject', + 's3:DeleteObject' + ], + resources: [ + scenariosBucketObjectArn + ] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'dynamodb:PutItem', + 'dynamodb:DeleteItem' + ], + resources: [ + scenariosTableArn + ] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['iot:DescribeEndpoint', 'iot:DetachPrincipalPolicy'], + resources: ['*'] + }), + new PolicyStatement({ + actions: ['iot:ListTargetsForPolicy'], + effect: Effect.ALLOW, + resources: [Stack.of(this).formatArn({ service: 'iot', resource: 'policy', resourceName: '*', arnFormat: ArnFormat.SLASH_RESOURCE_NAME })] + }) + ] + }) + } + }); + + customResourceRole.attachInlinePolicy(props.cloudWatchPolicy); + const cfnCustomResourceRole = customResourceRole.node.defaultChild as CfnResource; + cfnCustomResourceRole.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W11', + reason: 'iot:DescribeEndpoint and iot:DetachPrincipalPolicy cannot specify the resource.' + }] + }); + + const customResourceLambda = new LambdaFunction(this, 'CustomResourceLambda', { + description: 'CFN Lambda backed custom resource to deploy assets to s3', + handler: 'index.handler', + role: customResourceRole, + code: Code.fromBucket(sourceBucket, `${props.sourceCodePrefix}/${props.stackType}-custom-resource.zip`), + runtime: Runtime.NODEJS_14_X, + timeout: Duration.seconds(120), + environment: { + METRIC_URL: props.metricsUrl, + SOLUTION_ID: props.solutionId, + VERSION: props.solutionVersion, + MAIN_REGION: props.mainStackRegion, + DDB_TABLE: props.scenariosTable, + S3_BUCKET: props.scenariosS3Bucket + } + }); + + if (props.stackType === 'main') { + customResourceLambda.addToRolePolicy(new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['s3:PutObject'], + resources: [`${props.consoleBucketArn}`, `${props.consoleBucketArn}/*`] + })); + } + + this.customResourceArn = customResourceLambda.functionArn; + + const customResource = customResourceLambda.node.defaultChild as CfnResource; + customResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W58', + reason: 'CloudWatchLogsPolicy covers a permission to write CloudWatch logs.' + }, { + id: 'W89', + reason: 'VPC not needed for lambda' + }, { + id: 'W92', + reason: 'Does not run concurrent executions' + }] + }); + } +} diff --git a/source/infrastructure/lib/custom-resources/custom-resources.ts b/source/infrastructure/lib/custom-resources/custom-resources.ts new file mode 100644 index 0000000..8c49436 --- /dev/null +++ b/source/infrastructure/lib/custom-resources/custom-resources.ts @@ -0,0 +1,222 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Aws, CfnCondition, CfnCustomResource, CustomResource } from 'aws-cdk-lib'; +import { Construct } from 'constructs'; + +export interface ConsoleConfigProps { + readonly apiEndpoint: string; + readonly cognitoIdentityPool: string; + readonly cognitoUserPool: string; + readonly cognitoUserPoolClient: string; + readonly consoleBucketName: string; + readonly scenariosBucket: string; + readonly sourceCodeBucketName: string; + readonly sourceCodePrefix: string; + readonly iotEndpoint: string; + readonly iotPolicy: string; +} + +export interface CopyConsoleFilesProps { + readonly consoleBucketName: string; + readonly scenariosBucket: string; + readonly sourceCodeBucketName: string; + readonly sourceCodePrefix: string; +} + +export interface SendAnonymousMetricsCRProps { + readonly existingVpc: string; + readonly sendAnonymousUsage: string; + readonly sendAnonymousUsageCondition: CfnCondition; + readonly solutionId: string; + readonly solutionVersion: string; + readonly uuid: string; +} + +export interface TestingResourcesConfigCRProps { + readonly taskCluster: string; + readonly ecsCloudWatchLogGroup: string; + readonly taskSecurityGroup: string; + readonly taskDefinition: string; + readonly subnetA: string; + readonly subnetB: string; + readonly uuid: string; +} + +export interface PutRegionalTemplateProps { + readonly sourceCodeBucketName: string; + readonly regionalTemplatePrefix: string; + readonly mainStackRegion: string; + readonly apiServicesLambdaRoleName: string; + readonly resultsParserRoleName: string; + readonly scenariosBucket: string; + readonly scenariosTable: string; + readonly taskRunnerRoleName: string; + readonly taskCancelerRoleName: string; + readonly taskStatusCheckerRoleName: string; + readonly uuid: string; +} + +export interface DetachIotPrincipalPolicyProps { + readonly iotPolicyName: string; +} + +export interface CustomResourcesConstructProps { + readonly customResourceLambdaArn: string; +} + +/** + * Distributed Load Testing on AWS Custom Resources Construct. + * It creates a custom resource Lambda function, a solution UUID, and a custom resource to send anonymous usage. + */ +export class CustomResourcesConstruct extends Construct { + private customResourceLambdaArn: string; + + constructor (scope: Construct, id: string, props: CustomResourcesConstructProps) { + super(scope, id); + this.customResourceLambdaArn = props.customResourceLambdaArn; + } + + private createCustomResource(id: string, customResourceFunctionArn: string, props?: Record): CustomResource { + return new CustomResource(this, id, { + serviceToken: customResourceFunctionArn, + properties: props + }); + } + + public getIotEndpoint() { + const iotEndpoint = this.createCustomResource( + 'GetIotEndpoint', + this.customResourceLambdaArn, + { + Resource: 'GetIotEndpoint' + }); + return iotEndpoint.getAtt('IOT_ENDPOINT').toString(); + } + + public detachIotPrincipalPolicy(props: DetachIotPrincipalPolicyProps) { + this.createCustomResource( + 'DetachIotPrincipalPolicy', + this.customResourceLambdaArn, + { + Resource: 'DetachIotPolicy', + IotPolicyName: props.iotPolicyName + }); + } + + public putRegionalTemplate(props: PutRegionalTemplateProps) { + this.createCustomResource( + 'PutRegionalTemplate', + this.customResourceLambdaArn, + { + Resource: 'PutRegionalTemplate', + SrcBucket: props.sourceCodeBucketName, + SrcPath: props.regionalTemplatePrefix, + DestBucket: props.scenariosBucket, + APIServicesLambdaRoleName: props.apiServicesLambdaRoleName, + MainStackRegion: props.mainStackRegion, + ResultsParserRoleName: props.resultsParserRoleName, + ScenariosTable: props.scenariosTable, + TaskRunnerRoleName: props.taskRunnerRoleName, + TaskCancelerRoleName: props.taskCancelerRoleName, + TaskStatusCheckerRoleName: props.taskStatusCheckerRoleName, + Uuid: props.uuid + }); + } + + public copyConsoleFiles(props: CopyConsoleFilesProps) { + this.createCustomResource( + 'CopyConsoleFiles', + this.customResourceLambdaArn, + { + DestBucket: props.consoleBucketName, + ManifestFile: 'console-manifest.json', + Resource: 'CopyAssets', + SrcBucket: props.sourceCodeBucketName, + SrcPath: `${props.sourceCodePrefix}/console` + }); + } + + public consoleConfig(props: ConsoleConfigProps) { + const awsExports = `const awsConfig = { + aws_iot_endpoint: '${props.iotEndpoint}', + aws_iot_policy_name: '${props.iotPolicy}', + cw_dashboard: 'https://console.aws.amazon.com/cloudwatch/home?region=${Aws.REGION}#dashboards:name=', + ecs_dashboard: 'https://${Aws.REGION}.console.aws.amazon.com/ecs/home?region=${Aws.REGION}#/clusters/${Aws.STACK_NAME}/tasks', + aws_project_region: '${Aws.REGION}', + aws_cognito_region: '${Aws.REGION}', + aws_cognito_identity_pool_id: '${props.cognitoIdentityPool}', + aws_user_pools_id: '${props.cognitoUserPool}', + aws_user_pools_web_client_id: '${props.cognitoUserPoolClient}', + oauth: {}, + aws_cloud_logic_custom: [ + { + name: 'dlts', + endpoint: '${props.apiEndpoint}', + region: '${Aws.REGION}' + } + ], + aws_user_files_s3_bucket: '${props.scenariosBucket}', + aws_user_files_s3_bucket_region: '${Aws.REGION}', + }`; + this.createCustomResource( + 'ConsoleConfig', + this.customResourceLambdaArn, + { + AwsExports: awsExports, + DestBucket: props.consoleBucketName, + Resource: 'ConfigFile' + }); + } + + public uuidGenerator() { + const uuid = this.createCustomResource( + 'CustomResourceUuid', + this.customResourceLambdaArn, + { + Resource: 'UUID' + } + ); + return { + uuid: uuid.getAttString('UUID').toString(), + suffix: uuid.getAttString('SUFFIX').toString() + }; + } + + public sendAnonymousMetricsCR(props: SendAnonymousMetricsCRProps) { + const sendAnonymousMetrics = this.createCustomResource( + 'AnonymousMetric', + this.customResourceLambdaArn, + { + existingVPC: props.existingVpc, + Region: Aws.REGION, + Resource: 'AnonymousMetric', + SolutionId: props.solutionId, + UUID: props.uuid, + VERSION: props.solutionVersion, + }); + const cfnSendAnonymousMetrics = sendAnonymousMetrics.node.defaultChild as CfnCustomResource; + cfnSendAnonymousMetrics.cfnOptions.condition = props.sendAnonymousUsageCondition; + } + + public testingResourcesConfigCR(props: TestingResourcesConfigCRProps) { + const testingResourcesConfig = { + "region": Aws.REGION, + "subnetA": props.subnetA, + "subnetB": props.subnetB, + "ecsCloudWatchLogGroup": props.ecsCloudWatchLogGroup, + "taskSecurityGroup": props.taskSecurityGroup, + "taskDefinition": props.taskDefinition, + "taskImage": `${Aws.STACK_NAME}-load-tester`, + "taskCluster": props.taskCluster + }; + this.createCustomResource( + 'TestingResourcesConfig', + this.customResourceLambdaArn, + { + TestingResourcesConfig: testingResourcesConfig, + Resource: 'TestingResourcesConfigFile', + Uuid: props.uuid + }); + } +} \ No newline at end of file diff --git a/source/infrastructure/lib/distributed-load-testing-on-aws-regional-stack.ts b/source/infrastructure/lib/distributed-load-testing-on-aws-regional-stack.ts new file mode 100644 index 0000000..57fcb98 --- /dev/null +++ b/source/infrastructure/lib/distributed-load-testing-on-aws-regional-stack.ts @@ -0,0 +1,335 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + Aspects, + Aws, + CfnCondition, + CfnMapping, + CfnOutput, + CfnParameter, + CfnResource, + CfnRule, + Fn, + IAspect, + Stack, + StackProps, + Tags +} from 'aws-cdk-lib'; +import { Construct, IConstruct } from 'constructs'; +import { CommonResourcesConstruct } from './common-resources/common-resources'; +import { ECSResourcesConstruct } from "./testing-resources/ecs"; +import { CustomResourceInfraConstruct } from './custom-resources/custom-resources-infra'; +import { CustomResourcesConstruct } from './custom-resources/custom-resources'; +import { RegionalPermissionsConstruct } from './testing-resources/regional-permissions'; +import { FargateVpcConstruct } from './testing-resources/vpc'; +import { RealTimeDataConstruct } from './testing-resources/real-time-data'; + +/** + * CDK Aspect implementation to set up conditions to the entire Construct resources + */ +class ConditionAspect implements IAspect { + private readonly condition: CfnCondition; + + constructor(condition: CfnCondition) { + this.condition = condition; + } + + /** + * Implement IAspect.visit to set the condition to whole resources in Construct. + * @param {IConstruct} node Construct node to visit + */ + visit(node: IConstruct): void { + const resource = node as CfnResource; + if (resource.cfnOptions) { + resource.cfnOptions.condition = this.condition; + } + } +} + +/** + * RegionalInfrastructureDLTStack props + * @interface RegionalInfrastructureDLTStackProps + */ +export interface RegionalInfrastructureDLTStackProps extends StackProps { + readonly codeBucket: string; + readonly codeVersion: string; + readonly description: string; + readonly publicECRRegistry: string; + readonly publicECRTag: string; + readonly stackType: string; + readonly solutionId: string; + readonly solutionName: string; + readonly url: string; +} + +/** + * Distributed Load Testing on AWS regional infrastructure deployment + */ +export class RegionalInfrastructureDLTStack extends Stack { + // VPC ID + private fargateVpcId: string; + // Subnets for Fargate tasks + private fargateSubnetA: string; + private fargateSubnetB: string; + + constructor(scope: Construct, id: string, props: RegionalInfrastructureDLTStackProps) { + super(scope, id, props); + + // Existing VPC ID + const existingVpcId = new CfnParameter(this, 'ExistingVPCId', { + type: 'String', + description: 'Existing VPC ID', + allowedPattern: '(?:^$|^vpc-[a-zA-Z0-9-]+)', + }); + + const existingSubnetA = new CfnParameter(this, 'ExistingSubnetA', { + type: 'String', + description: 'First existing subnet', + allowedPattern: '(?:^$|^subnet-[a-zA-Z0-9-]+)' + }); + + const existingSubnetB = new CfnParameter(this, 'ExistingSubnetB', { + type: 'String', + description: 'Second existing subnet', + allowedPattern: '(?:^$|^subnet-[a-zA-Z0-9-]+)' + }); + + // VPC CIDR Block + const vpcCidrBlock = new CfnParameter(this, 'VpcCidrBlock', { + type: 'String', + default: '192.168.0.0/16', + description: 'CIDR block of the new VPC where AWS Fargate will be placed', + allowedPattern: '(?:^$|(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})/(\\d{1,2}))', + constraintDescription: 'The VPC CIDR block must be a valid IP CIDR range of the form x.x.x.x/x.', + minLength: 9, + maxLength: 18 + }); + + // Subnet A CIDR Block + const subnetACidrBlock = new CfnParameter(this, 'SubnetACidrBlock', { + type: 'String', + default: '192.168.0.0/20', + description: 'CIDR block for subnet A of the AWS Fargate VPC', + allowedPattern: '(?:^$|(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})/(\\d{1,2}))', + constraintDescription: 'The subnet CIDR block must be a valid IP CIDR range of the form x.x.x.x/x.', + minLength: 9, + maxLength: 18 + }); + + // Subnet B CIDR Block + const subnetBCidrBlock = new CfnParameter(this, 'SubnetBCidrBlock', { + type: 'String', + default: '192.168.16.0/20', + description: 'CIDR block for subnet B of the AWS Fargate VPC', + allowedPattern: '(?:^$|(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})/(\\d{1,2}))', + constraintDescription: 'The subnet CIDR block must be a valid IP CIDR range of the form x.x.x.x/x.' + }); + + // Egress CIDR Block + const egressCidrBlock = new CfnParameter(this, 'EgressCidr', { + type: 'String', + default: '0.0.0.0/0', + description: 'CIDR Block to restrict the Fargate container outbound access', + minLength: 9, + maxLength: 18, + allowedPattern: '(?:^$|(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})/(\\d{1,2}))', + constraintDescription: 'The Egress CIDR block must be a valid IP CIDR range of the form x.x.x.x/x.' + }); + + new CfnRule(this, 'ExistingVPCRule', { + ruleCondition: Fn.conditionNot(Fn.conditionEquals(existingVpcId.value, '')), + assertions: [ + { + assert: Fn.conditionNot(Fn.conditionEquals(existingSubnetA.value, '')), + assertDescription: 'If an existing VPC Id is provided, 2 subnet ids need to be provided as well. You neglected to enter the first subnet id' + }, + { + assert: Fn.conditionNot(Fn.conditionEquals(existingSubnetB.value, '')), + assertDescription: 'If an existing VPC Id is provided, 2 subnet ids need to be provided as well. You neglected to enter the second subnet id' + } + ] + }); + + // CFN Mappings + const solutionMapping = new CfnMapping(this, 'Solution', { + mapping: { + Config: { + APIServicesLambdaRoleName: 'API_SERVICES_ROLE', + CodeVersion: props.codeVersion, + ContainerImage: `${props.publicECRRegistry}/distributed-load-testing-on-aws-load-tester:${props.publicECRTag}`, + KeyPrefix: `${props.solutionName}/${props.codeVersion}`, + MainStackRegion: 'MAIN_STACK_REGION', + ResultsParserRoleName: 'RESULTS_PARSER_ROLE', + S3Bucket: props.codeBucket, + ScenariosS3Bucket: 'SCENARIOS_BUCKET', + ScenariosTable: 'SCENARIOS_DDB_TABLE', + SendAnonymousUsage: 'Yes', + SolutionId: props.solutionId, + stackType: props.stackType, + TaskRunnerRoleName: 'TASK_RUNNER_ROLE', + TaskCancelerRoleName: 'TASK_CANCELER_ROLE', + TaskStatusCheckerRoleName: 'TASK_STATUS_ROLE', + URL: props.url, + Uuid: 'STACK_UUID' + } + } + }); + const apiServicesLambdaRoleName = solutionMapping.findInMap('Config', 'APIServicesLambdaRoleName'); + const containerImage = solutionMapping.findInMap('Config', 'ContainerImage'); + const mainStackRegion = solutionMapping.findInMap('Config', 'MainStackRegion'); + const metricsUrl = solutionMapping.findInMap('Config', 'URL'); + const resultsParserRoleName = solutionMapping.findInMap('Config', 'ResultsParserRoleName'); + const scenariosS3Bucket = solutionMapping.findInMap('Config', 'ScenariosS3Bucket'); + const scenariosTable = solutionMapping.findInMap('Config', 'ScenariosTable'); + const sendAnonymousUsage = solutionMapping.findInMap('Config', 'SendAnonymousUsage'); + const solutionId = solutionMapping.findInMap('Config', 'SolutionId'); + const solutionVersion = solutionMapping.findInMap('Config', 'CodeVersion'); + const sourceCodeBucket = Fn.join('-', [solutionMapping.findInMap('Config', 'S3Bucket'), Aws.REGION]); + const sourceCodePrefix = solutionMapping.findInMap('Config', 'KeyPrefix'); + const taskRunnerRoleName = solutionMapping.findInMap('Config', 'TaskRunnerRoleName'); + const taskCancelerRoleName = solutionMapping.findInMap('Config', 'TaskCancelerRoleName'); + const taskStatusCheckerRoleName = solutionMapping.findInMap('Config', 'TaskStatusCheckerRoleName'); + const uuid = solutionMapping.findInMap('Config', 'Uuid'); + + // Stack level tags + Tags.of(this).add('SolutionId', solutionId); + + // CFN Conditions + const sendAnonymousUsageCondition = new CfnCondition(this, 'SendAnonymousUsage', { + expression: Fn.conditionEquals(sendAnonymousUsage, 'Yes') + }); + + const createFargateVpcResourcesCondition = new CfnCondition(this, 'CreateFargateVPCResources', { + expression: Fn.conditionEquals(existingVpcId.valueAsString, '') + }); + + const usingExistingVpc = new CfnCondition(this, 'BoolExistingVPC', { + expression: Fn.conditionNot(Fn.conditionEquals(existingVpcId.valueAsString, '')) + }); + + const commonResources = new CommonResourcesConstruct(this, 'CommonResources', { + sourceCodeBucket + }); + + // Fargate VPC resources + const fargateVpc = new FargateVpcConstruct(this, 'DLTRegionalVpc', { + solutionId, + subnetACidrBlock: subnetACidrBlock.valueAsString, + subnetBCidrBlock: subnetBCidrBlock.valueAsString, + vpcCidrBlock: vpcCidrBlock.valueAsString, + }); + Aspects.of(fargateVpc).add(new ConditionAspect(createFargateVpcResourcesCondition)); + this.fargateVpcId = Fn.conditionIf(createFargateVpcResourcesCondition.logicalId, + fargateVpc.vpcId, + existingVpcId.valueAsString + ).toString(); + + this.fargateSubnetA = Fn.conditionIf(createFargateVpcResourcesCondition.logicalId, + fargateVpc.subnetA, + existingSubnetA.valueAsString + ).toString(); + + this.fargateSubnetB = Fn.conditionIf(createFargateVpcResourcesCondition.logicalId, + fargateVpc.subnetB, + existingSubnetB.valueAsString + ).toString(); + + const existingVpc = Fn.conditionIf(usingExistingVpc.logicalId, true, false).toString(); + + // ECS Fargate resources + const fargateResources = new ECSResourcesConstruct(this, 'DLTRegionalFargate', { + cloudWatchLogsPolicy: commonResources.cloudWatchLogsPolicy, + containerImage, + fargateVpcId: this.fargateVpcId, + scenariosS3Bucket, + securityGroupEgress: egressCidrBlock.valueAsString, + solutionId + }); + + const customResourceInfra = new CustomResourceInfraConstruct(this, 'RegionalCustomResourceInfra', { + cloudWatchPolicy: commonResources.cloudWatchLogsPolicy, + mainStackRegion, + metricsUrl, + scenariosS3Bucket, + scenariosTable, + solutionId, + solutionVersion, + sourceCodeBucket: commonResources.sourceBucket, + sourceCodePrefix, + stackType: props.stackType + }); + + new RegionalPermissionsConstruct(this, 'RegionalPermissionsForTaskLambdas', { + apiServicesLambdaRoleName, + ecsCloudWatchLogGroupArn: fargateResources.ecsCloudWatchLogGroup.logGroupArn, + resultsParserRoleName, + taskExecutionRoleArn: fargateResources.taskExecutionRoleArn, + taskRunnerRoleName, + taskCancelerRoleName, + taskStatusCheckerRoleName + }); + + const customResources = new CustomResourcesConstruct(this, 'DLTCustomResources', { + customResourceLambdaArn: customResourceInfra.customResourceArn + }); + + const iotEndpoint = customResources.getIotEndpoint(); + + new RealTimeDataConstruct(this, 'RealTimeData', { + cloudWatchLogsPolicy: commonResources.cloudWatchLogsPolicy, + ecsCloudWatchLogGroup: fargateResources.ecsCloudWatchLogGroup, + iotEndpoint: iotEndpoint, + mainRegion: mainStackRegion, + solutionId, + solutionVersion, + sourceCodeBucket: commonResources.sourceBucket, + sourceCodePrefix + }); + + customResources.testingResourcesConfigCR({ + taskCluster: fargateResources.taskClusterName, + ecsCloudWatchLogGroup: fargateResources.ecsCloudWatchLogGroup.logGroupName, + taskSecurityGroup: fargateResources.ecsSecurityGroupId, + taskDefinition: fargateResources.taskDefinitionArn, + subnetA: this.fargateSubnetA, + subnetB: this.fargateSubnetB, + uuid + }); + + customResources.sendAnonymousMetricsCR({ + existingVpc, + solutionId, + uuid, + solutionVersion, + sendAnonymousUsage, + sendAnonymousUsageCondition + }); + + // Outputs + new CfnOutput(this, 'ECSCloudWatchLogGroup', { + description: 'The CloudWatch log group for ECS', + value: fargateResources.ecsCloudWatchLogGroup.logGroupName + }); + new CfnOutput(this, 'SubnetA', { + description: 'Subnet A used by the Fargate tasks', + value: this.fargateSubnetA + }); + new CfnOutput(this, 'SubnetB', { + description: 'Subnet B used by the Fargate tasks', + value: this.fargateSubnetB + }); + new CfnOutput(this, 'TaskCluster', { + description: 'Fargate task cluster', + value: fargateResources.taskClusterName + }); + new CfnOutput(this, 'TaskDefinition', { + description: 'The Fargate task definition', + value: fargateResources.taskDefinitionArn + }); + new CfnOutput(this, 'TaskSecurityGroup', { + description: 'Security Group used by the Fargate taks', + value: fargateResources.ecsSecurityGroupId + }); + } +} \ No newline at end of file diff --git a/source/infrastructure/lib/distributed-load-testing-on-aws-stack.ts b/source/infrastructure/lib/distributed-load-testing-on-aws-stack.ts index c925e9b..34937e2 100644 --- a/source/infrastructure/lib/distributed-load-testing-on-aws-stack.ts +++ b/source/infrastructure/lib/distributed-load-testing-on-aws-stack.ts @@ -6,26 +6,29 @@ import { Aws, CfnCondition, CfnMapping, + CfnOutput, CfnParameter, CfnResource, - Construct, + CfnRule, Fn, IAspect, - IConstruct, - StackProps, Stack, - CfnOutput -} from '@aws-cdk/core'; -import { CognitoAuthConstruct } from './auth'; -import { CommonResourcesContruct } from './common-resources'; -import { FargateECSTestRunnerContruct } from './ecs'; -import { FargateVpcContruct } from './vpc'; -import { ScenarioTestRunnerStorageContruct } from './scenarios-storage'; -import { DLTConsoleContruct } from './console'; -import { CustomResourcesConstruct } from './custom-resources'; -import { DLTAPI } from './api'; -import { TestRunnerLambdasConstruct } from './test-task-lambdas'; -import { TaskRunnerStepFunctionConstruct } from './step-functions'; + StackProps, + Tags +} from 'aws-cdk-lib'; +import { Construct, IConstruct } from 'constructs'; +import { DLTAPI } from './front-end/api'; +import { CognitoAuthConstruct } from './front-end/auth'; +import { CommonResourcesConstruct } from './common-resources/common-resources'; +import { DLTConsoleConstruct } from './front-end/console'; +import { CustomResourcesConstruct } from './custom-resources/custom-resources'; +import { CustomResourceInfraConstruct } from './custom-resources/custom-resources-infra'; +import { ECSResourcesConstruct } from './testing-resources/ecs'; +import { ScenarioTestRunnerStorageConstruct } from './back-end/scenarios-storage'; +import { TaskRunnerStepFunctionConstruct } from './back-end/step-functions'; +import { TestRunnerLambdasConstruct } from './back-end/test-task-lambdas'; +import { FargateVpcConstruct } from './testing-resources/vpc'; +import { RealTimeDataConstruct } from './testing-resources/real-time-data'; /** * CDK Aspect implementation to set up conditions to the entire Construct resources @@ -49,9 +52,24 @@ class ConditionAspect implements IAspect { } } +/** + * DLTStack props + * @interface DLTStackProps + */ +export interface DLTStackProps extends StackProps { + readonly codeBucket: string; + readonly codeVersion: string; + readonly description: string; + readonly publicECRRegistry: string; + readonly publicECRTag: string; + readonly stackType: string; + readonly solutionId: string; + readonly solutionName: string; + readonly url: string; +} + /** * Distributed Load Testing on AWS main CDK Stack - * @class */ export class DLTStack extends Stack { // VPC ID @@ -60,7 +78,7 @@ export class DLTStack extends Stack { private fargateSubnetA: string; private fargateSubnetB: string; - constructor(scope: Construct, id: string, props?: StackProps) { + constructor(scope: Construct, id: string, props: DLTStackProps) { super(scope, id, props); // CFN template format version @@ -178,16 +196,34 @@ export class DLTStack extends Stack { } }; + // CFN Rules + // If the user enters a value for an existing VPC, + // require the customers to fill out values for subnets A and B + new CfnRule(this, 'ExistingVPCRule', { + ruleCondition: Fn.conditionNot(Fn.conditionEquals(existingVpcId.value, '')), + assertions: [ + { + assert: Fn.conditionNot(Fn.conditionEquals(existingSubnetA.value, '')), + assertDescription: 'If an existing VPC Id is provided, 2 subnet ids need to be provided as well. You neglected to enter the first subnet id' + }, + { + assert: Fn.conditionNot(Fn.conditionEquals(existingSubnetB.value, '')), + assertDescription: 'If an existing VPC Id is provided, 2 subnet ids need to be provided as well. You neglected to enter the second subnet id' + } + ] + }); + // CFN Mappings const solutionMapping = new CfnMapping(this, 'Solution', { mapping: { Config: { - CodeVersion: 'CODE_VERSION', - KeyPrefix: 'SOLUTION_NAME/CODE_VERSION', - S3Bucket: 'CODE_BUCKET', + CodeVersion: props.codeVersion, + ContainerImage: `${props.publicECRRegistry}/distributed-load-testing-on-aws-load-tester:${props.publicECRTag}`, + KeyPrefix: `${props.solutionName}/${props.codeVersion}`, + S3Bucket: props.codeBucket, SendAnonymousUsage: 'Yes', - SolutionId: 'SO0062', - URL: 'https://metrics.awssolutionsbuilder.com/generic' + SolutionId: props.solutionId, + URL: props.url } } }); @@ -197,6 +233,14 @@ export class DLTStack extends Stack { const sourceCodeBucket = Fn.join('-', [solutionMapping.findInMap('Config', 'S3Bucket'), Aws.REGION]); const sourceCodePrefix = solutionMapping.findInMap('Config', 'KeyPrefix'); const metricsUrl = solutionMapping.findInMap('Config', 'URL'); + const containerImage = solutionMapping.findInMap('Config', 'ContainerImage'); + const mainStackRegion = Aws.REGION; + + // Stack level tags + Tags.of(this).add('SolutionId', solutionId); + + // Stack level tags + Tags.of(this).add('SolutionId', solutionId); // CFN Conditions const sendAnonymousUsageCondition = new CfnCondition(this, 'SendAnonymousUsage', { @@ -212,67 +256,99 @@ export class DLTStack extends Stack { }); // Fargate VPC resources - const fargate = new FargateVpcContruct(this, 'DLTVpc', { + const fargateVpc = new FargateVpcConstruct(this, 'DLTVpc', { solutionId: solutionId, subnetACidrBlock: subnetACidrBlock.valueAsString, subnetBCidrBlock: subnetBCidrBlock.valueAsString, vpcCidrBlock: vpcCidrBlock.valueAsString, }); - Aspects.of(fargate).add(new ConditionAspect(createFargateVpcResourcesCondition)); + Aspects.of(fargateVpc).add(new ConditionAspect(createFargateVpcResourcesCondition)); this.fargateVpcId = Fn.conditionIf(createFargateVpcResourcesCondition.logicalId, - fargate.DLTfargateVpcId, + fargateVpc.vpcId, existingVpcId.valueAsString ).toString(); this.fargateSubnetA = Fn.conditionIf(createFargateVpcResourcesCondition.logicalId, - fargate.subnetA, + fargateVpc.subnetA, existingSubnetA.valueAsString ).toString(); this.fargateSubnetB = Fn.conditionIf(createFargateVpcResourcesCondition.logicalId, - fargate.subnetB, + fargateVpc.subnetB, existingSubnetB.valueAsString ).toString(); - // Fargate ECS resources - const fargateECSResources = new FargateECSTestRunnerContruct(this, 'DLTEcs', { - DLTfargateVpcId: this.fargateVpcId, - securityGroupEgress: egressCidrBlock.valueAsString, - solutionId: solutionId, - }); const existingVpc = Fn.conditionIf(usingExistingVpc.logicalId, true, false).toString(); - const commonResources = new CommonResourcesContruct(this, 'DLTCommonResources', { - dltEcsTaskExecutionRole: fargateECSResources.dltTaskExecutionRole, - solutionId: solutionId, - sourceCodeBucket, - sourceCodePrefix, - solutionVersion, - sendAnonymousUsageCondition, - existingVpc, - metricsUrl + + const commonResources = new CommonResourcesConstruct(this, 'DLTCommonResources', { + sourceCodeBucket }); - const dltConsole = new DLTConsoleContruct(this, 'DLTConsoleResources', { - customResource: commonResources.customResourceLambda, - s3LogsBucket: commonResources.s3LogsBucket, + const s3LogsBucket = commonResources.s3LogsBucket(); + + const dltConsole = new DLTConsoleConstruct(this, 'DLTConsoleResources', { + s3LogsBucket: s3LogsBucket, solutionId: solutionId }); - const dltStorage = new ScenarioTestRunnerStorageContruct(this, 'DLTTestRunnerStorage', { - ecsTaskExecutionRole: fargateECSResources.dltTaskExecutionRole, - s3LogsBucket: commonResources.s3LogsBucket, + const dltStorage = new ScenarioTestRunnerStorageConstruct(this, 'DLTTestRunnerStorage', { + s3LogsBucket: s3LogsBucket, cloudFrontDomainName: dltConsole.cloudFrontDomainName, solutionId, }); + const customResourceInfra = new CustomResourceInfraConstruct(this, 'DLTCustomResourceInfra', { + cloudWatchPolicy: commonResources.cloudWatchLogsPolicy, + consoleBucketArn: dltConsole.consoleBucketArn, + mainStackRegion: mainStackRegion, + metricsUrl, + scenariosS3Bucket: dltStorage.scenariosBucket.bucketName, + scenariosTable: dltStorage.scenariosTable.tableName, + solutionId, + solutionVersion, + sourceCodeBucket: commonResources.sourceBucket, + sourceCodePrefix, + stackType: props.stackType + }); + + const customResources = new CustomResourcesConstruct(this, 'DLTCustomResources', { + customResourceLambdaArn: customResourceInfra.customResourceArn + }); + + const iotEndpoint = customResources.getIotEndpoint(); + + const { uuid, suffix } = customResources.uuidGenerator(); + + const fargateResources = new ECSResourcesConstruct(this, 'DLTEcs', { + cloudWatchLogsPolicy: commonResources.cloudWatchLogsPolicy, + containerImage, + fargateVpcId: this.fargateVpcId, + scenariosS3Bucket: dltStorage.scenariosBucket.bucketName, + securityGroupEgress: egressCidrBlock.valueAsString, + solutionId: solutionId + }); + + new RealTimeDataConstruct(this, 'RealTimeData', { + cloudWatchLogsPolicy: commonResources.cloudWatchLogsPolicy, + ecsCloudWatchLogGroup: fargateResources.ecsCloudWatchLogGroup, + iotEndpoint: iotEndpoint, + mainRegion: Aws.REGION, + solutionId, + solutionVersion, + sourceCodeBucket: commonResources.sourceBucket, + sourceCodePrefix + }); + const stepLambdaFunctions = new TestRunnerLambdasConstruct(this, 'DLTLambdaFunction', { cloudWatchLogsPolicy: commonResources.cloudWatchLogsPolicy, - dynamoDbPolicy: dltStorage.dynamoDbPolicy, - ecsTaskExecutionRoleArn: fargateECSResources.dltTaskExecutionRole.roleArn, - ecsCloudWatchLogGroup: fargateECSResources.dltCloudWatchLogGroup, - ecsCluster: fargateECSResources.dltEcsClusterName, - ecsTaskDefinition: fargateECSResources.dltTaskDefinitionArn, - ecsTaskSecurityGroup: fargateECSResources.dltSecurityGroupId, + scenariosDynamoDbPolicy: dltStorage.scenarioDynamoDbPolicy, + ecsTaskExecutionRoleArn: fargateResources.taskExecutionRoleArn, + ecsCloudWatchLogGroup: fargateResources.ecsCloudWatchLogGroup, + ecsCluster: fargateResources.taskClusterName, + ecsTaskDefinition: fargateResources.taskDefinitionArn, + ecsTaskSecurityGroup: fargateResources.ecsSecurityGroupId, + historyTable: dltStorage.historyTable, + historyDynamoDbPolicy: dltStorage.historyDynamoDbPolicy, scenariosS3Policy: dltStorage.scenariosS3Policy, subnetA: this.fargateSubnetA, subnetB: this.fargateSubnetB, @@ -282,38 +358,40 @@ export class DLTStack extends Stack { solutionVersion, sourceCodeBucket: commonResources.sourceBucket, sourceCodePrefix, - testScenariosBucket: dltStorage.scenariosBucket.bucketName, - testScenariosTable: dltStorage.scenariosTable, - uuid: commonResources.uuid, - }) + scenariosBucket: dltStorage.scenariosBucket.bucketName, + scenariosTable: dltStorage.scenariosTable, + uuid + }); const taskRunnerStepFunctions = new TaskRunnerStepFunctionConstruct(this, 'DLTStepFunction', { taskStatusChecker: stepLambdaFunctions.taskStatusChecker, taskRunner: stepLambdaFunctions.taskRunner, resultsParser: stepLambdaFunctions.resultsParser, taskCanceler: stepLambdaFunctions.taskCanceler, - solutionId + solutionId, + suffix }); const dltApi = new DLTAPI(this, 'DLTApi', { - ecsCloudWatchLogGroup: fargateECSResources.dltCloudWatchLogGroup, cloudWatchLogsPolicy: commonResources.cloudWatchLogsPolicy, - dynamoDbPolicy: dltStorage.dynamoDbPolicy, - taskCancelerInvokePolicy: stepLambdaFunctions.taskCancelerInvokePolicy, + ecsCloudWatchLogGroup: fargateResources.ecsCloudWatchLogGroup, + ecsTaskExecutionRoleArn: fargateResources.taskExecutionRoleArn, + historyDynamoDbPolicy: dltStorage.historyDynamoDbPolicy, + historyTable: dltStorage.historyTable.tableName, scenariosBucketName: dltStorage.scenariosBucket.bucketName, + scenariosDynamoDbPolicy: dltStorage.scenarioDynamoDbPolicy, scenariosS3Policy: dltStorage.scenariosS3Policy, scenariosTableName: dltStorage.scenariosTable.tableName, - ecsCuster: fargateECSResources.dltEcsClusterName, - ecsTaskExecutionRoleArn: fargateECSResources.dltTaskExecutionRole.roleArn, + taskCancelerArn: stepLambdaFunctions.taskCanceler.functionArn, + taskCancelerInvokePolicy: stepLambdaFunctions.taskCancelerInvokePolicy, taskRunnerStepFunctionsArn: taskRunnerStepFunctions.taskRunnerStepFunctions.stateMachineArn, - tastCancelerArn: stepLambdaFunctions.taskCanceler.functionArn, metricsUrl, sendAnonymousUsage, solutionId, solutionVersion, sourceCodeBucket: commonResources.sourceBucket, sourceCodePrefix, - uuid: commonResources.uuid + uuid }); const cognitoResources = new CognitoAuthConstruct(this, 'DLTCognitoAuth', { @@ -324,16 +402,61 @@ export class DLTStack extends Stack { scenariosBucketArn: dltStorage.scenariosBucket.bucketArn, }); - new CustomResourcesConstruct(this, 'DLTCustomResources', { + customResources.copyConsoleFiles({ + consoleBucketName: dltConsole.consoleBucket.bucketName, + scenariosBucket: dltStorage.scenariosBucket.bucketName, + sourceCodeBucketName: sourceCodeBucket, + sourceCodePrefix + }); + + customResources.putRegionalTemplate({ + sourceCodeBucketName: sourceCodeBucket, + regionalTemplatePrefix: sourceCodePrefix, + scenariosBucket: dltStorage.scenariosBucket.bucketName, + mainStackRegion: mainStackRegion, + apiServicesLambdaRoleName: dltApi.apiServicesLambdaRoleName, + resultsParserRoleName: stepLambdaFunctions.resultsParser.role!.roleName, + scenariosTable: dltStorage.scenariosTable.tableName, + taskRunnerRoleName: stepLambdaFunctions.taskRunner.role!.roleName, + taskCancelerRoleName: stepLambdaFunctions.taskCanceler.role!.roleName, + taskStatusCheckerRoleName: stepLambdaFunctions.taskStatusChecker.role!.roleName, + uuid + }); + + customResources.detachIotPrincipalPolicy({ + iotPolicyName: cognitoResources.iotPolicy.ref + }); + + customResources.consoleConfig({ apiEndpoint: dltApi.apiEndpointPath, - customResourceLambda: commonResources.customResourceLambda.functionArn, cognitoIdentityPool: cognitoResources.cognitoIdentityPoolId, cognitoUserPool: cognitoResources.cognitoUserPoolId, cognitoUserPoolClient: cognitoResources.cognitoUserPoolClientId, consoleBucketName: dltConsole.consoleBucket.bucketName, scenariosBucket: dltStorage.scenariosBucket.bucketName, - sourceCodeBucketName: commonResources.sourceBucket.bucketName, - sourceCodePrefix + sourceCodeBucketName: sourceCodeBucket, + sourceCodePrefix, + iotEndpoint: iotEndpoint, + iotPolicy: cognitoResources.iotPolicy.ref + }); + + customResources.testingResourcesConfigCR({ + taskCluster: fargateResources.taskClusterName, + ecsCloudWatchLogGroup: fargateResources.ecsCloudWatchLogGroup.logGroupName, + taskSecurityGroup: fargateResources.ecsSecurityGroupId, + taskDefinition: fargateResources.taskDefinitionArn, + subnetA: this.fargateSubnetA, + subnetB: this.fargateSubnetB, + uuid + }); + + customResources.sendAnonymousMetricsCR({ + existingVpc, + solutionId, + uuid, + solutionVersion, + sendAnonymousUsage, + sendAnonymousUsageCondition }); //Outputs @@ -343,7 +466,12 @@ export class DLTStack extends Stack { }); new CfnOutput(this, 'SolutionUUID', { description: 'Solution UUID', - value: commonResources.uuid - }) + value: uuid + }); + new CfnOutput(this, 'RegionalCFTemplate', { + description: 'S3 URL for regional CloudFormation template', + value: dltStorage.scenariosBucket.urlForObject('regional-template/distributed-load-testing-on-aws-regional.template'), + exportName: 'RegionalCFTemplate' + }); } } diff --git a/source/infrastructure/lib/ecs.ts b/source/infrastructure/lib/ecs.ts deleted file mode 100644 index 710081b..0000000 --- a/source/infrastructure/lib/ecs.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { Aws, CfnResource, Construct, RemovalPolicy } from '@aws-cdk/core'; -import { Repository } from '@aws-cdk/aws-ecr'; -import { CfnCluster, CfnTaskDefinition } from '@aws-cdk/aws-ecs'; -import { ManagedPolicy, Role, ServicePrincipal } from '@aws-cdk/aws-iam'; -import { LogGroup, RetentionDays } from '@aws-cdk/aws-logs'; -import { CfnSecurityGroup, CfnSecurityGroupEgress, CfnSecurityGroupIngress } from '@aws-cdk/aws-ec2'; - -/** - * FargateECSTestRunnerContruct props - * @interface FargateECSTestRunnerContructProps - */ -export interface FargateECSTestRunnerContructProps { - // Fargate VPC ID - readonly DLTfargateVpcId: string; - // IP CIDR for Fargate egress - readonly securityGroupEgress: string; - // Solution ID - readonly solutionId: string; -} - -/** - * @class - * Distributed Load Testing on AWS Fargate and ECS test runner construct. - * This creates the ECS cluster, Fargate task definition, and Security Group - */ -export class FargateECSTestRunnerContruct extends Construct { - public dltEcsClusterName: string; - public dltCloudWatchLogGroup: LogGroup; - public dltTaskDefinitionArn: string; - public dltTaskExecutionRole: Role; - public dltSecurityGroupId: string; - - constructor(scope: Construct, id: string, props: FargateECSTestRunnerContructProps) { - super(scope, id); - - const dltEcr = new Repository(this, 'DLTECR', { - imageScanOnPush: true - }); - dltEcr.applyRemovalPolicy(RemovalPolicy.RETAIN); - - const dltEcsCluster = new CfnCluster(this, 'DLTEcsCluster', { - clusterName: Aws.STACK_NAME, - clusterSettings: [{ 'name': 'containerInsights', 'value': 'enabled' }], - tags: [ - { - 'key': 'SolutionId', - 'value': props.solutionId - }, - { - 'key': 'CloudFormation Stack', - 'value': Aws.STACK_NAME - } - ] - - }); - - this.dltEcsClusterName = dltEcsCluster.ref; - - this.dltTaskExecutionRole = new Role(this, 'DLTTaskExecutionRole', { - assumedBy: new ServicePrincipal('ecs-tasks.amazonaws.com'), - managedPolicies: [ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonECSTaskExecutionRolePolicy')] - }); - - this.dltCloudWatchLogGroup = new LogGroup(this, 'DLTCloudWatchLogsGroup', { - retention: RetentionDays.ONE_YEAR - }); - const dltLogsGroupResource = this.dltCloudWatchLogGroup.node.defaultChild as CfnResource; - dltLogsGroupResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W84', - reason: 'KMS encryption unnecessary for log group' - }] - }); - - const dltTaskDefinition = new CfnTaskDefinition(this, 'DLTTaskDefinition', { - cpu: '2048', - memory: '4096', - networkMode: 'awsvpc', - executionRoleArn: this.dltTaskExecutionRole.roleArn, - requiresCompatibilities: ['FARGATE'], - taskRoleArn: this.dltTaskExecutionRole.roleArn, - containerDefinitions: [ - { - essential: true, - name: `${Aws.STACK_NAME}-load-tester`, - image: 'PUBLIC_ECR_REGISTRY/distributed-load-testing-on-aws-load-tester:PUBLIC_ECR_TAG', - memory: 4096, - logConfiguration: { - logDriver: 'awslogs', - options: { - 'awslogs-group': this.dltCloudWatchLogGroup.logGroupName, - 'awslogs-stream-prefix': 'load-testing', - 'awslogs-region': `${Aws.REGION}` - } - } - } - ], - }); - - this.dltTaskDefinitionArn = dltTaskDefinition.ref; - - const dltEcsSecurityGroup = new CfnSecurityGroup(this, 'DLTEcsSecurityGroup', { - vpcId: props.DLTfargateVpcId, - groupDescription: 'DLTS Tasks Security Group' - }); - dltEcsSecurityGroup.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W40', - reason: 'IpProtocol set to -1 (any) as ports are not known prior to running tests' - }] - }); - - this.dltSecurityGroupId = dltEcsSecurityGroup.ref; - - new CfnSecurityGroupEgress(this, 'DLTSecGroupEgress', { - cidrIp: props.securityGroupEgress, - description: 'Allow tasks to call out to external resources', - groupId: dltEcsSecurityGroup.ref, - ipProtocol: '-1' - }); - - new CfnSecurityGroupIngress(this, 'DLTSecGroupIngress', { - description: 'Allow tasks to communicate', - fromPort: 50000, - groupId: dltEcsSecurityGroup.ref, - ipProtocol: 'tcp', - sourceSecurityGroupId: dltEcsSecurityGroup.ref, - toPort: 50000 - }); - } -} diff --git a/source/infrastructure/lib/front-end/api.ts b/source/infrastructure/lib/front-end/api.ts new file mode 100644 index 0000000..5c2f274 --- /dev/null +++ b/source/infrastructure/lib/front-end/api.ts @@ -0,0 +1,382 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Code, Function as LambdaFunction, Runtime } from 'aws-cdk-lib/aws-lambda'; +import { Aws, ArnFormat, CfnResource, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { IBucket } from 'aws-cdk-lib/aws-s3'; +import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs'; +import { Effect, Policy, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; +import { + AccessLogFormat, + AuthorizationType, + CfnAccount, + ContentHandling, + Deployment, + EndpointType, + Integration, + IntegrationType, + LogGroupLogDestination, + MethodLoggingLevel, + MethodOptions, + PassthroughBehavior, + RequestValidator, + RestApi, + Stage +} from 'aws-cdk-lib/aws-apigateway'; +import { Construct } from 'constructs'; + +/** + * @interface DLTAPIProps + * DLTAPI props +*/ +export interface DLTAPIProps { + readonly cloudWatchLogsPolicy: Policy; + // ECS CloudWatch Log Group + readonly ecsCloudWatchLogGroup: LogGroup; + // ECS Task Execution Role ARN + readonly ecsTaskExecutionRoleArn: string; + // History DynamoDB table policy + readonly historyDynamoDbPolicy: Policy; + // History DynamoDB table + readonly historyTable: string; + // Test scenarios S3 bucket + readonly scenariosBucketName: string; + // Scenarios DynamoDB table policy + readonly scenariosDynamoDbPolicy: Policy; + // Test scenarios S3 bucket policy + readonly scenariosS3Policy: Policy; + // Test scenarios DynamoDB table + readonly scenariosTableName: string; + // Task canceler ARN + readonly taskCancelerArn: string; + //Task Canceler Invoke Policy + readonly taskCancelerInvokePolicy: Policy; + // Task Runner state function + readonly taskRunnerStepFunctionsArn: string; + + /** + * Solution config properties. + * the metric URL endpoint, send anonymous usage, solution ID, version, source code bucket, and source code prefix + */ + readonly metricsUrl: string; + readonly sendAnonymousUsage: string; + readonly solutionId: string; + readonly solutionVersion: string; + readonly sourceCodeBucket: IBucket; + readonly sourceCodePrefix: string; + readonly uuid: string; +} + +/** + * Distributed Load Testing on AWS API construct + */ +export class DLTAPI extends Construct { + apiId: string; + apiEndpointPath: string; + apiServicesLambdaRoleName: string; + + constructor(scope: Construct, id: string, props: DLTAPIProps) { + super(scope, id); + + const taskArn = Stack.of(this).formatArn({ service: 'ecs', resource: 'task', resourceName: '*', arnFormat: ArnFormat.SLASH_RESOURCE_NAME }); + const taskDefArn = Stack.of(this).formatArn({ service: 'ecs', resource: 'task-definition/' }); + + const dltApiServicesLambdaRole = new Role(this, 'DLTAPIServicesLambdaRole', { + assumedBy: new ServicePrincipal('lambda.amazonaws.com'), + inlinePolicies: { + 'DLTAPIServicesLambdaPolicy': new PolicyDocument({ + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['ecs:ListTasks'], + resources: ['*'] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'ecs:RunTask', + 'ecs:DescribeTasks' + ], + resources: [ + taskArn, + taskDefArn + ] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['iam:PassRole'], + resources: [props.ecsTaskExecutionRoleArn] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['states:StartExecution'], + resources: [props.taskRunnerStepFunctionsArn] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['logs:DeleteMetricFilter'], + resources: [props.ecsCloudWatchLogGroup.logGroupArn] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['cloudwatch:DeleteDashboards'], + resources: [Stack.of(this).formatArn({ service: 'cloudwatch', region: '', resource: 'dashboard', resourceName: 'EcsLoadTesting*' })] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['cloudformation:ListExports'], + resources: ['*'] + }) + ] + }) + } + }); + this.apiServicesLambdaRoleName = dltApiServicesLambdaRole.roleName; + dltApiServicesLambdaRole.attachInlinePolicy(props.cloudWatchLogsPolicy); + dltApiServicesLambdaRole.attachInlinePolicy(props.historyDynamoDbPolicy); + dltApiServicesLambdaRole.attachInlinePolicy(props.scenariosDynamoDbPolicy); + dltApiServicesLambdaRole.attachInlinePolicy(props.scenariosS3Policy); + dltApiServicesLambdaRole.attachInlinePolicy(props.taskCancelerInvokePolicy); + + const ruleSchedArn = Stack.of(this).formatArn({ service: 'events', resource: 'rule', resourceName: '*Scheduled' }); + const ruleCreateArn = Stack.of(this).formatArn({ service: 'events', resource: 'rule', resourceName: '*Create' }); + const ruleListArn = Stack.of(this).formatArn({ service: 'events', resource: 'rule', resourceName: '*' }); + + const lambdaApiEventsPolicy = new Policy(this, 'LambdaApiEventsPolicy', { + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'events:PutTargets', + 'events:PutRule', + 'events:DeleteRule', + 'events:RemoveTargets' + ], + resources: [ + ruleSchedArn, + ruleCreateArn + ] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'events:ListRules' + ], + resources: [ + ruleListArn + ] + }) + ] + }); + dltApiServicesLambdaRole.attachInlinePolicy(lambdaApiEventsPolicy); + + const apiLambdaRoleResource = dltApiServicesLambdaRole.node.defaultChild as CfnResource; + apiLambdaRoleResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W11', + reason: 'ecs:ListTasks and cloudformation:ListExports do not support resource level permissions' + }] + }); + + const dltApiServicesLambda = new LambdaFunction(this, 'DLTAPIServicesLambda', { + description: 'API microservices for creating, updating, listing and deleting test scenarios', + code: Code.fromBucket(props.sourceCodeBucket, `${props.sourceCodePrefix}/api-services.zip`), + runtime: Runtime.NODEJS_14_X, + handler: 'index.handler', + timeout: Duration.seconds(120), + environment: { + HISTORY_TABLE: props.historyTable, + METRIC_URL: props.metricsUrl, + SCENARIOS_BUCKET: props.scenariosBucketName, + SCENARIOS_TABLE: props.scenariosTableName, + SEND_METRIC: props.sendAnonymousUsage, + SOLUTION_ID: props.solutionId, + STACK_ID: Aws.STACK_ID, + STATE_MACHINE_ARN: props.taskRunnerStepFunctionsArn, + TASK_CANCELER_ARN: props.taskCancelerArn, + UUID: props.uuid, + VERSION: props.solutionVersion, + }, + role: dltApiServicesLambdaRole + }); + const apiLambdaResource = dltApiServicesLambda.node.defaultChild as CfnResource; + apiLambdaResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W58', + reason: 'CloudWatchLogsPolicy covers a permission to write CloudWatch logs.' + }, { + id: 'W89', + reason: 'VPC not needed for lambda' + }, { + id: 'W92', + reason: 'Does not run concurrent executions' + }] + }); + + const lambdaApiPermissionPolicy = new Policy(this, 'LambdaApiPermissionPolicy', { + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'lambda:AddPermission', + 'lambda:RemovePermission' + ], + resources: [dltApiServicesLambda.functionArn] + }) + ] + }); + dltApiServicesLambdaRole.attachInlinePolicy(lambdaApiPermissionPolicy); + + const apiLogs = new LogGroup(this, 'APILogs', { + retention: RetentionDays.ONE_YEAR, + removalPolicy: RemovalPolicy.RETAIN + }); + const apiLogsResource = apiLogs.node.defaultChild as CfnResource; + apiLogsResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W84', + reason: 'KMS encryption unnecessary for log group' + }] + }); + + const logsArn = Stack.of(this).formatArn({ service: 'logs', resource: '*' }); + const apiLoggingRole = new Role(this, 'APILoggingRole', { + assumedBy: new ServicePrincipal('apigateway.amazonaws.com'), + inlinePolicies: { + 'apiLoggingPolicy': new PolicyDocument({ + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'logs:CreateLogGroup', + 'logs:CreateLogStream', + 'logs:DescribeLogGroups', + 'logs:DescribeLogStreams', + 'logs:PutLogEvents', + 'logs:GetLogEvents', + 'logs:FilterLogEvent', + ], + resources: [ + logsArn + ] + }) + ] + }) + } + }); + + const api = new RestApi(this, 'DLTApi', { + defaultCorsPreflightOptions: { + allowOrigins: ['*'], + allowHeaders: [ + 'Authorization', + 'Content-Type', + 'X-Amz-Date', + 'X-Amz-Security-Token', + 'X-Api-Key' + ], + allowMethods: [ + 'DELETE', + 'GET', + 'HEAD', + 'OPTIONS', + 'PATCH', + 'POST', + 'PUT' + ], + statusCode: 200 + }, + deploy: true, + deployOptions: { + accessLogDestination: new LogGroupLogDestination(apiLogs), + accessLogFormat: AccessLogFormat.jsonWithStandardFields(), + loggingLevel: MethodLoggingLevel.INFO, + stageName: 'prod', + tracingEnabled: true + }, + description: `Distributed Load Testing API - version ${props.solutionVersion}`, + endpointTypes: [EndpointType.EDGE] + }); + + this.apiId = api.restApiId; + this.apiEndpointPath = api.url.slice(0, -1); + + const apiAccountConfig = new CfnAccount(this, 'ApiAccountConfig', { + cloudWatchRoleArn: apiLoggingRole.roleArn + }); + apiAccountConfig.addDependsOn(api.node.defaultChild as CfnResource); + + const apiAllRequestValidator = new RequestValidator(this, 'APIAllRequestValidator', { + restApi: api, + validateRequestBody: true, + validateRequestParameters: true + }); + + const apiDeployment = api.node.findChild('Deployment') as Deployment; + const apiDeploymentResource = apiDeployment.node.defaultChild as CfnResource; + apiDeploymentResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W68', + reason: 'The solution does not require the usage plan.' + }] + }); + + const apiFindProdResource = api.node.findChild('DeploymentStage.prod') as Stage; + const apiProdResource = apiFindProdResource.node.defaultChild as CfnResource; + apiProdResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W64', + reason: 'The solution does not require the usage plan.' + }] + }); + + const allIntegration = new Integration({ + type: IntegrationType.AWS_PROXY, + integrationHttpMethod: 'POST', + options: { + contentHandling: ContentHandling.CONVERT_TO_TEXT, + integrationResponses: [{ statusCode: '200' }], + passthroughBehavior: PassthroughBehavior.WHEN_NO_MATCH, + }, + uri: `arn:${Aws.PARTITION}:apigateway:${Aws.REGION}:lambda:path/2015-03-31/functions/${dltApiServicesLambda.functionArn}/invocations` + }); + const allMethodOptions: MethodOptions = { + authorizationType: AuthorizationType.IAM, + methodResponses: [{ + statusCode: '200', + responseModels: { + 'application/json': { modelId: 'Empty' } + } + }], + requestValidator: apiAllRequestValidator + }; + + /** Test scenario API + * /regions + * /scenarios + * /scenarios/{testId} + * /tasks + */ + + const regionsResource = api.root.addResource('regions'); + regionsResource.addMethod('ANY', allIntegration, allMethodOptions); + + const scenariosResource = api.root.addResource('scenarios'); + scenariosResource.addMethod('ANY', allIntegration, allMethodOptions); + + const testIds = scenariosResource.addResource('{testId}'); + testIds.addMethod('ANY', allIntegration, allMethodOptions); + + const tasksResource = api.root.addResource('tasks'); + tasksResource.addMethod('ANY', allIntegration, allMethodOptions); + + + const invokeSourceArn = Stack.of(this).formatArn({ service: 'execute-api', resource: api.restApiId, resourceName: '*' }); + dltApiServicesLambda.addPermission('DLTApiInvokePermission', { + action: 'lambda:InvokeFunction', + principal: new ServicePrincipal('apigateway.amazonaws.com'), + sourceArn: invokeSourceArn + }); + + } +} \ No newline at end of file diff --git a/source/infrastructure/lib/front-end/auth.ts b/source/infrastructure/lib/front-end/auth.ts new file mode 100644 index 0000000..344e41b --- /dev/null +++ b/source/infrastructure/lib/front-end/auth.ts @@ -0,0 +1,234 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { UserPool, CfnUserPool, UserPoolClient, ClientAttributes, CfnIdentityPool, CfnIdentityPoolRoleAttachment, CfnUserPoolUser } from 'aws-cdk-lib/aws-cognito'; +import { Effect, FederatedPrincipal, PolicyDocument, PolicyStatement, Role } from 'aws-cdk-lib/aws-iam'; +import { Aws, ArnFormat, CfnResource, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { CfnPolicy } from 'aws-cdk-lib/aws-iot'; +import { Construct } from 'constructs'; + +/** + * CognitoAuthConstruct props +* @interface CognitoAuthConstructProps +*/ +export interface CognitoAuthConstructProps { + adminEmail: string; + adminName: string; + apiId: string; + cloudFrontDomainName: string; + scenariosBucketArn: string; +} + +export class CognitoAuthConstruct extends Construct { + cognitoIdentityPoolId: string; + cognitoUserPoolClientId: string; + cognitoUserPoolId: string; + public iotPolicy: CfnPolicy; + + constructor (scope: Construct, id: string, props: CognitoAuthConstructProps) { + super(scope, id); + + + const dltIotPolicy = new CfnPolicy(this, 'IoT-Policy', { + policyDocument: new PolicyDocument({ + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["iot:Connect"], + resources: [Stack.of(this).formatArn({ service: 'iot', resource: 'client', resourceName: '*', arnFormat: ArnFormat.SLASH_RESOURCE_NAME })] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["iot:Subscribe"], + resources: [Stack.of(this).formatArn({ service: 'iot', resource: 'topicfilter', resourceName: '*', arnFormat: ArnFormat.SLASH_RESOURCE_NAME })] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["iot:Receive"], + resources: [Stack.of(this).formatArn({ service: 'iot', resource: 'topic', resourceName: '*', arnFormat: ArnFormat.SLASH_RESOURCE_NAME })] + }) + ] + }) + }); + this.iotPolicy = dltIotPolicy; + dltIotPolicy.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W11', + reason: 'Cannot specify the resource to attach policy to identity' + }] + }); + + const cognitoUserPool = new UserPool(this, 'DLTUserPool', { + autoVerify: { + email: true + }, + passwordPolicy: { + minLength: 12, + requireLowercase: true, + requireDigits: true, + requireSymbols: true, + requireUppercase: true + }, + removalPolicy: RemovalPolicy.DESTROY, + selfSignUpEnabled: false, + signInAliases: { + email: true, + username: true + }, + standardAttributes: { + email: { + required: true + } + }, + userInvitation: { + emailSubject: 'Welcome to Distributed Load Testing', + emailBody: ` +

    + Please use the credentials below to login to the Distributed Load Testing console. +

    +

    + Username: {username} +

    +

    + Password: {####} +

    +

    + Console: https://${props.cloudFrontDomainName}/ +

    + `, + smsMessage: 'Your username is {username} and temporary password is {####}.' + }, + userPoolName: `${Aws.STACK_NAME}-user-pool` + }); + (cognitoUserPool.node.defaultChild as CfnUserPool).userPoolAddOns = { advancedSecurityMode: 'ENFORCED' }; + this.cognitoUserPoolId = cognitoUserPool.userPoolId; + + const clientWriteAttributes = new ClientAttributes().withStandardAttributes({ + address: true, + email: true, + phoneNumber: true + }); + + const cognitoUserPoolClient = new UserPoolClient(this, 'DLTUserPoolClient', { + userPoolClientName: `${Aws.STACK_NAME}-userpool-client`, + userPool: cognitoUserPool, + generateSecret: false, + writeAttributes: clientWriteAttributes, + refreshTokenValidity: Duration.days(1), + }); + + this.cognitoUserPoolClientId = cognitoUserPoolClient.userPoolClientId; + + const cognitoIdentityPool = new CfnIdentityPool(this, 'DLTIdentityPool', { + allowUnauthenticatedIdentities: false, + cognitoIdentityProviders: [ + { + clientId: this.cognitoUserPoolClientId, + providerName: cognitoUserPool.userPoolProviderName + } + ] + }); + + this.cognitoIdentityPoolId = cognitoIdentityPool.ref; + + const apiProdExecuteArn = Stack.of(this).formatArn({ service: 'execute-api', resource: props.apiId, resourceName: 'prod/*' }); + const cognitoAuthorizedRole = new Role(this, 'DLTCognitoAuthorizedRole', { + assumedBy: new FederatedPrincipal( + 'cognito-identity.amazonaws.com', + { + StringEquals: { 'cognito-identity.amazonaws.com:aud': this.cognitoIdentityPoolId }, + 'ForAnyValue:StringLike': { 'cognito-identity.amazonaws.com:amr': 'authenticated' } + }, + 'sts:AssumeRoleWithWebIdentity' + ), + description: `${Aws.STACK_NAME} Identity Pool authenticated role`, + inlinePolicies: { + 'InvokeApiPolicy': new PolicyDocument({ + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['execute-api:Invoke'], + resources: [ + apiProdExecuteArn + ] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 's3:PutObject', + 's3:GetObject' + ], + resources: [ + `${props.scenariosBucketArn}/public/*`, + `${props.scenariosBucketArn}/cloudWatchImages/*` + ] + }) + ] + }), + 'IoTPolicy': new PolicyDocument({ + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + "iot:AttachPrincipalPolicy" + ], + resources: ['*'] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["iot:Connect"], + resources: [Stack.of(this).formatArn({ service: 'iot', resource: 'client', resourceName: '*', arnFormat: ArnFormat.SLASH_RESOURCE_NAME })] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["iot:Subscribe"], + resources: [Stack.of(this).formatArn({ service: 'iot', resource: 'topicfilter', resourceName: '*', arnFormat: ArnFormat.SLASH_RESOURCE_NAME })] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["iot:Receive"], + resources: [Stack.of(this).formatArn({ service: 'iot', resource: 'topic', resourceName: '*', arnFormat: ArnFormat.SLASH_RESOURCE_NAME })] + }) + ] + }) + } + }); + (cognitoAuthorizedRole.node.defaultChild as CfnResource).addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W11', + reason: 'iot:AttachPrincipalPolicy does not allow for resource specification' + }] + }); + + const cognitoUnauthorizedRole = new Role(this, 'DLTCognitoUnauthorizedRole', { + assumedBy: new FederatedPrincipal( + 'cognito-identity.amazonaws.com', + { + StringEquals: { 'cognito-identity.amazonaws.com:aud': this.cognitoIdentityPoolId }, + 'ForAnyValue:StringLike': { 'cognito-identity.amazonaws.com:amr': 'unauthenticated' } + }, + 'sts:AssumeRoleWithWebIdentity' + ) + }); + + new CfnIdentityPoolRoleAttachment(this, 'CognitoAttachRole', { + identityPoolId: this.cognitoIdentityPoolId, + roles: { + unauthenticated: cognitoUnauthorizedRole.roleArn, + authenticated: cognitoAuthorizedRole.roleArn + } + }); + + new CfnUserPoolUser(this, 'CognitoUser', { + desiredDeliveryMediums: ['EMAIL'], + forceAliasCreation: true, + userAttributes: [ + { name: 'email', value: props.adminEmail }, + { name: 'nickname', value: props.adminName }, + { name: 'email_verified', value: 'true' } + ], + username: props.adminName, + userPoolId: this.cognitoUserPoolId + }); + } +} \ No newline at end of file diff --git a/source/infrastructure/lib/front-end/console.ts b/source/infrastructure/lib/front-end/console.ts new file mode 100644 index 0000000..af22936 --- /dev/null +++ b/source/infrastructure/lib/front-end/console.ts @@ -0,0 +1,56 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CloudFrontToS3 } from '@aws-solutions-constructs/aws-cloudfront-s3'; +import { Bucket, IBucket } from 'aws-cdk-lib/aws-s3'; +import { Construct } from 'constructs'; + +/** + * @interface DLTConsoleConstructProps + * DLTConsoleConstruct props + */ +export interface DLTConsoleConstructProps { + // S3 Logs Bucket + readonly s3LogsBucket: Bucket; + // Solution ID + readonly solutionId: string; +} + +/** + * Distributed Load Testing on AWS console construct + * This creates the S3 bucket and CloudFront distribution + * and Cognito resources for the web front end. + */ +export class DLTConsoleConstruct extends Construct { + public cloudFrontDomainName: string; + public consoleBucketArn: string; + public consoleBucket: IBucket; + + constructor(scope: Construct, id: string, props: DLTConsoleConstructProps) { + super(scope, id); + + const dltS3CloudFrontDist = new CloudFrontToS3(this, 'DLTCloudFrontToS3', { + bucketProps: { + serverAccessLogsBucket: props.s3LogsBucket, + serverAccessLogsPrefix: 'console-bucket-access/', + }, + cloudFrontDistributionProps: { + comment: 'Website distribution for the Distributed Load Testing solution', + enableLogging: true, + errorResponses: [ + { httpStatus: 403, responseHttpStatus: 200, responsePagePath: '/index.html' }, + { httpStatus: 404, responseHttpStatus: 200, responsePagePath: '/index.html' } + ], + httpVersion: 'http2', + logBucket: props.s3LogsBucket, + logFilePrefix: 'cloudfront-logs/' + }, + insertHttpSecurityHeaders: false + }); + + this.cloudFrontDomainName = dltS3CloudFrontDist.cloudFrontWebDistribution.domainName; + this.consoleBucket = dltS3CloudFrontDist.s3BucketInterface; + + this.consoleBucketArn = dltS3CloudFrontDist.s3BucketInterface.bucketArn; + } +} \ No newline at end of file diff --git a/source/infrastructure/lib/scenarios-storage.ts b/source/infrastructure/lib/scenarios-storage.ts deleted file mode 100644 index 7c29586..0000000 --- a/source/infrastructure/lib/scenarios-storage.ts +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { Construct, RemovalPolicy, Tags } from '@aws-cdk/core'; -import { AttributeType, BillingMode, Table, TableEncryption } from '@aws-cdk/aws-dynamodb'; -import { AnyPrincipal, Effect, Policy, PolicyStatement, Role } from '@aws-cdk/aws-iam'; -import { BlockPublicAccess, Bucket, BucketEncryption, HttpMethods } from '@aws-cdk/aws-s3'; - -/** - * @interface ScenarioTestRunnerContructProps - * ScenarioTestRunnerStorageContruct props - */ -export interface ScenarioTestRunnerStorageContructProps { - // ECS Task Execution Role - readonly ecsTaskExecutionRole: Role; - // S3 Logs Bucket - readonly s3LogsBucket: Bucket; - // CloudFront domain name - readonly cloudFrontDomainName: string; - // Solution Id - readonly solutionId: string; -} - -/** - * Distributed Load Testing storage construct - * Creates an S3 bucket to store test scenarios and - * a Dynamodb table to store tests and test configuration - */ -export class ScenarioTestRunnerStorageContruct extends Construct { - public scenariosBucket: Bucket; - public scenariosS3Policy: Policy; - public scenariosTable: Table; - public dynamoDbPolicy: Policy; - - - constructor(scope: Construct, id: string, props: ScenarioTestRunnerStorageContructProps) { - super(scope, id); - - this.scenariosBucket = new Bucket(this, 'DLTScenariosBucket', { - removalPolicy: RemovalPolicy.RETAIN, - serverAccessLogsBucket: props.s3LogsBucket, - serverAccessLogsPrefix: 'scenarios-bucket-access/', - encryption: BucketEncryption.KMS_MANAGED, - blockPublicAccess: BlockPublicAccess.BLOCK_ALL, - cors: [ - { - allowedMethods: [HttpMethods.GET, HttpMethods.POST, HttpMethods.PUT], - allowedOrigins: [`https://${props.cloudFrontDomainName}`], - allowedHeaders: ['*'], - exposedHeaders: ['ETag'] - } - ] - }); - Tags.of(this.scenariosBucket).add('SolutionId', props.solutionId); - - this.scenariosBucket.addToResourcePolicy(new PolicyStatement({ - actions: ['s3:*'], - resources: [this.scenariosBucket.bucketArn, `${this.scenariosBucket.bucketArn}/*`], - effect: Effect.DENY, - principals: [new AnyPrincipal], - conditions: { - 'Bool': { - 'aws:SecureTransport': false - } - } - })); - - this.scenariosS3Policy = new Policy(this, 'ScenariosS3Policy', { - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 's3:HeadObject', - 's3:PutObject', - 's3:GetObject', - 's3:ListBucket' - ], - resources: [ - this.scenariosBucket.bucketArn, - `${this.scenariosBucket.bucketArn}/*` - ] - }) - ] - }); - props.ecsTaskExecutionRole.attachInlinePolicy(this.scenariosS3Policy); - - this.scenariosTable = new Table(this, 'DLTScenariosTable', { - billingMode: BillingMode.PAY_PER_REQUEST, - encryption: TableEncryption.AWS_MANAGED, - partitionKey: { name: 'testId', type: AttributeType.STRING }, - pointInTimeRecovery: true - }); - Tags.of(this.scenariosTable).add('SolutionId', props.solutionId); - - this.dynamoDbPolicy = new Policy(this, 'DynamoDbPolicy', { - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'dynamodb:DeleteItem', - 'dynamodb:GetItem', - 'dynamodb:PutItem', - 'dynamodb:Scan', - 'dynamodb:UpdateItem' - ], - resources: [this.scenariosTable.tableArn] - }) - ] - }) - } -} \ No newline at end of file diff --git a/source/infrastructure/lib/step-functions.ts b/source/infrastructure/lib/step-functions.ts deleted file mode 100644 index a0049fb..0000000 --- a/source/infrastructure/lib/step-functions.ts +++ /dev/null @@ -1,165 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { Chain, Choice, Condition, DISCARD, Fail, LogLevel, StateMachine, Succeed, Wait, WaitTime } from "@aws-cdk/aws-stepfunctions"; -import { LambdaInvoke } from "@aws-cdk/aws-stepfunctions-tasks"; -import { CfnResource, Construct, Duration, Tags } from "@aws-cdk/core"; -import { Policy } from "@aws-cdk/aws-iam"; -import { LogGroup, RetentionDays } from "@aws-cdk/aws-logs"; -import { IFunction } from "@aws-cdk/aws-lambda"; - - -/** - * CustomResourcesConstruct props - * @interface TaskRunnerStepFunctionConstructProps - */ -export interface TaskRunnerStepFunctionConstructProps { - // State machine Lambda functions - readonly taskStatusChecker: IFunction; - readonly taskRunner: IFunction; - readonly resultsParser: IFunction; - readonly taskCanceler: IFunction; - // Solution ID - readonly solutionId: string; -} - -/** - * @class - */ -export class TaskRunnerStepFunctionConstruct extends Construct { - public taskRunnerStepFunctions: StateMachine; - - constructor(scope: Construct, id: string, props: TaskRunnerStepFunctionConstructProps) { - super(scope, id); - - const stepFunctionsLogGroup = new LogGroup(this, 'StepFunctionsLogGroup', { - retention: RetentionDays.ONE_YEAR - }); - const stepFunctionsLogGroupResource = stepFunctionsLogGroup.node.defaultChild as CfnResource; - stepFunctionsLogGroupResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W84', - reason: 'KMS encryption unnecessary for log group' - }] - }); - - const done = new Succeed(this, 'Done'); - - const parseResult = new LambdaInvoke(this, 'Parse result', { - lambdaFunction: props.resultsParser - }); - parseResult.next(done); - - const checkWorkerStatus = new LambdaInvoke(this, 'Check worker status', { - lambdaFunction: props.taskStatusChecker, - inputPath: '$', - outputPath: '$.Payload' - }); - - const checkTaskStatus = new LambdaInvoke(this, 'Check task status', { - lambdaFunction: props.taskStatusChecker, - inputPath: '$', - outputPath: '$.Payload' - }); - - const waitTask = new Wait(this, 'Wait 1 minute - task status', { - comment: 'Wait 1 minute to check task status again', - time: WaitTime.duration(Duration.seconds(60)) - }); - waitTask.next(checkTaskStatus); - - const allTasksDone = new Choice(this, 'Are all tasks done?'); - allTasksDone.when(Condition.booleanEquals('$.isRunning', false), parseResult); - allTasksDone.otherwise(waitTask); - - checkTaskStatus.next(allTasksDone); - - const cancelTest = new LambdaInvoke(this, 'Cancel Test', { - lambdaFunction: props.taskCanceler, - inputPath: '$', - outputPath: '$.Payload', - resultPath: DISCARD - }); - cancelTest.next(parseResult); - - const waitWorker = new Wait(this, 'Wait 1 minute - worker status', { - comment: 'Wait 1 minute to check task status again', - time: WaitTime.duration(Duration.seconds(60)) - }); - waitWorker.next(checkWorkerStatus); - - const runWorkers = new LambdaInvoke(this, 'Run workers', { - lambdaFunction: props.taskRunner, - inputPath: '$', - outputPath: '$.Payload' - }); - - const allWorkersLaunched = new Choice(this, 'Are all workers launched?'); - allWorkersLaunched.when(Condition.booleanEquals('$.isRunning', false), cancelTest); - allWorkersLaunched.when(Condition.numberEquals('$.taskRunner.runTaskCount', 1), waitWorker); - allWorkersLaunched.when(Condition.numberEquals('$.taskRunner.runTaskCount', 0), waitTask); - allWorkersLaunched.otherwise(runWorkers); - - runWorkers.next(allWorkersLaunched); - - const runLeaderTask = new LambdaInvoke(this, 'Run leader task', { - lambdaFunction: props.taskRunner, - inputPath: '$', - outputPath: '$.Payload' - }); - runLeaderTask.next(waitTask); - - const allWorkersRunning = new Choice(this, 'Are all workers running?'); - allWorkersRunning.when(Condition.numberEqualsJsonPath('$.numTasksRunning', '$.scenario.taskCount'), runLeaderTask); - allWorkersRunning.when(Condition.booleanEquals('$.isRunning', false), parseResult); - allWorkersRunning.otherwise(waitWorker); - - checkWorkerStatus.next(allWorkersRunning); - - const testIsStillRunning = new Fail(this, 'Test is still running', { - cause: 'The same test is already running.', - error: 'TestAlreadyRunning' - }); - - const noRunningTests = new Choice(this, 'No running tests'); - noRunningTests.when(Condition.booleanEquals('$.isRunning', false), runWorkers); - noRunningTests.otherwise(testIsStillRunning); - - const checkRunningTests = new LambdaInvoke(this, 'Check running tests', { - lambdaFunction: props.taskStatusChecker, - inputPath: '$', - outputPath: '$.Payload' - }); - checkRunningTests.next(noRunningTests); - - const definition = Chain - .start(checkRunningTests) - - this.taskRunnerStepFunctions = new StateMachine(this, 'TaskRunnerStepFunctions', { - definition, - logs: { - destination: stepFunctionsLogGroup, - level: LogLevel.ALL, - includeExecutionData: false - } - }); - const stepFunctionsRoleResource = this.taskRunnerStepFunctions.role.node.defaultChild as CfnResource; - stepFunctionsRoleResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W11', - reason: 'CloudWatch logs actions do not support resource level permissions' - }, { - id: 'W12', - reason: 'CloudWatch logs actions do not support resource level permissions' - }] - }); - const stepFunctionPolicy = this.taskRunnerStepFunctions.role.node.findChild('DefaultPolicy') as Policy; - const policyResource = stepFunctionPolicy.node.defaultChild as CfnResource; - policyResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W12', - reason: 'CloudWatch logs actions do not support resource level permissions' - }] - }); - Tags.of(this.taskRunnerStepFunctions).add('SolutionId', props.solutionId); - } -} \ No newline at end of file diff --git a/source/infrastructure/lib/test-task-lambdas.ts b/source/infrastructure/lib/test-task-lambdas.ts deleted file mode 100644 index fb45df0..0000000 --- a/source/infrastructure/lib/test-task-lambdas.ts +++ /dev/null @@ -1,367 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { Aws, CfnResource, Construct, Duration, Stack, Tags } from '@aws-cdk/core'; -import { Code, Function as LambdaFunction, Runtime } from '@aws-cdk/aws-lambda'; -import { Effect, PolicyStatement, PolicyDocument, Role, ServicePrincipal, Policy } from '@aws-cdk/aws-iam'; -import { IBucket } from '@aws-cdk/aws-s3'; -import { Table } from '@aws-cdk/aws-dynamodb'; -import { LogGroup } from '@aws-cdk/aws-logs'; - -/** - * TestRunnerLambdasConstruct props - * @interface TestRunnerLambdaConstructProps - */ -export interface TestRunnerLambdasContructProps { - readonly cloudWatchLogsPolicy: Policy; - // DynamoDB policy - readonly dynamoDbPolicy: Policy; - //ECS Task Execution Role ARN - readonly ecsTaskExecutionRoleArn: string; - // ECS CloudWatch LogGroup ; - readonly ecsCloudWatchLogGroup: LogGroup; - // ECS Cluster - readonly ecsCluster: string; - // ECS Task definition - readonly ecsTaskDefinition: string; - // ECS Security Group - readonly ecsTaskSecurityGroup: string; - // Scenarios S3 Bucket policy - readonly scenariosS3Policy: Policy; - // Subnet A Id - readonly subnetA: string; - // Subnet B Id - readonly subnetB: string - /** - * Solution config properties. - * the metric URL endpoint, send anonymous usage, solution ID, version, source code bucket, and source code prefix - */ - readonly metricsUrl: string; - readonly sendAnonymousUsage: string; - readonly solutionId: string; - readonly solutionVersion: string; - readonly sourceCodeBucket: IBucket; - readonly sourceCodePrefix: string; - // Test scenarios bucket - readonly testScenariosBucket: string; - // Test scenarios table - readonly testScenariosTable: Table; - // Stack UUID - readonly uuid: string; -} - -/** -* @class -* Distributed Load Testing on AWS Test Runner Lambdas construct. -* This creates the Results parser, Task Runner, Task Canceler, -* and Task Status Checker -*/ -export class TestRunnerLambdasConstruct extends Construct { - public resultsParser: LambdaFunction; - public taskRunner: LambdaFunction; - public taskCanceler: LambdaFunction; - public taskCancelerInvokePolicy: Policy; - public taskStatusChecker: LambdaFunction; - - constructor(scope: Construct, id: string, props: TestRunnerLambdasContructProps) { - super(scope, id); - - const lambdaResultsRole = new Role(this, 'LambdaResultsRole', { - assumedBy: new ServicePrincipal('lambda.amazonaws.com') - }); - const cfnPolicy = new Policy(this, 'LambdaResultsPolicy', { - statements: [ - new PolicyStatement({ - resources: ['*'], - actions: ['cloudwatch:GetMetricWidgetImage'] - }) - ] - }); - lambdaResultsRole.attachInlinePolicy(cfnPolicy); - lambdaResultsRole.attachInlinePolicy(props.cloudWatchLogsPolicy); - lambdaResultsRole.attachInlinePolicy(props.dynamoDbPolicy); - lambdaResultsRole.attachInlinePolicy(props.scenariosS3Policy); - - const resultsRoleResource = lambdaResultsRole.node.defaultChild as CfnResource; - resultsRoleResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W12', - reason: 'The action does not support resource level permissions.' - }] - }); - const resultsPolicyResource = cfnPolicy.node.defaultChild as CfnResource; - resultsPolicyResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W12', - reason: 'The action does not support resource level permissions.' - }] - }); - - this.resultsParser = new LambdaFunction(this, 'ResultsParser', { - description: 'Result parser for indexing xml test results to DynamoDB', - handler: 'index.handler', - role: lambdaResultsRole, - code: Code.fromBucket(props.sourceCodeBucket, `${props.sourceCodePrefix}/results-parser.zip`), - runtime: Runtime.NODEJS_14_X, - timeout: Duration.seconds(120), - environment: { - SCENARIOS_BUCKET: props.testScenariosBucket, - SCENARIOS_TABLE: props.testScenariosTable.tableName, - SOLUTION_ID: props.solutionId, - UUID: props.uuid, - VERSION: props.solutionVersion, - SEND_METRIC: props.sendAnonymousUsage, - METRIC_URL: props.metricsUrl - }, - }); - Tags.of(this.resultsParser).add('SolutionId', props.solutionId); - const resultsParserResource = this.resultsParser.node.defaultChild as CfnResource; - resultsParserResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W58', - reason: 'CloudWatchLogsPolicy covers a permission to write CloudWatch logs.' - }, { - id: 'W89', - reason: 'This Lambda function does not require a VPC' - }, { - id: 'W92', - reason: 'Does not run concurrent executions' - },] - }); - - const taskArn = Stack.of(this).formatArn({ service: 'ecs', resource: 'task', sep: '/', resourceName: '*' }); - const taskDefArn = Stack.of(this).formatArn({ service: 'ecs', resource: 'task-definition', resourceName: '*:*' }); - - const lambdaTaskRole = new Role(this, 'DLTTestLambdaTaskRole', { - assumedBy: new ServicePrincipal('lambda.amazonaws.com'), - inlinePolicies: { - 'TaskLambdaPolicy': new PolicyDocument({ - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['ecs:ListTasks'], - resources: ['*'] - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - 'ecs:RunTask', - 'ecs:DescribeTasks' - ], - resources: [ - taskArn, - taskDefArn - ] - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['iam:PassRole'], - resources: [props.ecsTaskExecutionRoleArn] - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['logs:PutMetricFilter'], - resources: [props.ecsCloudWatchLogGroup.logGroupArn] - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['cloudwatch:PutDashboard'], - resources: [ - `arn:${Aws.PARTITION}:cloudwatch::${Aws.ACCOUNT_ID}:dashboard/EcsLoadTesting*` - ] - }) - ] - }) - } - }); - lambdaTaskRole.attachInlinePolicy(props.cloudWatchLogsPolicy); - lambdaTaskRole.attachInlinePolicy(props.dynamoDbPolicy); - - const lambdaTaskRoleResource = lambdaTaskRole.node.defaultChild as CfnResource; - lambdaTaskRoleResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W11', - reason: 'ecs:ListTasks does not support resource level permissions' - }] - }); - - this.taskRunner = new LambdaFunction(this, 'TaskRunner', { - description: 'Task runner for ECS task definitions', - handler: 'index.handler', - role: lambdaTaskRole, - code: Code.fromBucket(props.sourceCodeBucket, `${props.sourceCodePrefix}/task-runner.zip`), - environment: { - SCENARIOS_BUCKET: props.testScenariosBucket, - SCENARIOS_TABLE: props.testScenariosTable.tableName, - TASK_CLUSTER: props.ecsCluster, - TASK_DEFINITION: props.ecsTaskDefinition, - TASK_SECURITY_GROUP: props.ecsTaskSecurityGroup, - TASK_IMAGE: `${Aws.STACK_NAME}-load-tester`, - SUBNET_A: props.subnetA, - SUBNET_B: props.subnetB, - API_INTERVAL: '10', - ECS_LOG_GROUP: props.ecsCloudWatchLogGroup.logGroupName, - SOLUTION_ID: props.solutionId, - VERSION: props.solutionVersion - }, - runtime: Runtime.NODEJS_14_X, - timeout: Duration.seconds(900) - }); - Tags.of(this.taskRunner).add('SolutionId', props.solutionId); - const taskRunnerResource = this.taskRunner.node.defaultChild as CfnResource; - taskRunnerResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W58', - reason: 'CloudWatchLogsPolicy covers a permission to write CloudWatch logs.' - }, { - id: 'W89', - reason: 'This Lambda function does not require a VPC' - }, { - id: 'W92', - reason: 'Does not run concurrent executions' - }] - }); - - const taskCancelerRole = new Role(this, 'LambdaTaskCancelerRole', { - assumedBy: new ServicePrincipal('lambda.amazonaws.com'), - inlinePolicies: { - 'TaskCancelerPolicy': new PolicyDocument({ - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['ecs:ListTasks'], - resources: ['*'] - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['ecs:StopTask'], - resources: [ - taskArn, - taskDefArn - ] - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['dynamodb:UpdateItem'], - resources: [props.testScenariosTable.tableArn] - }) - ] - }) - } - }); - taskCancelerRole.attachInlinePolicy(props.cloudWatchLogsPolicy); - - const taskCancelerRoleResource = taskCancelerRole.node.defaultChild as CfnResource; - taskCancelerRoleResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W11', - reason: 'ecs:ListTasks does not support resource level permissions' - }] - }); - - this.taskCanceler = new LambdaFunction(this, 'TaskCanceler', { - description: 'Stops ECS task', - handler: 'index.handler', - role: taskCancelerRole, - code: Code.fromBucket(props.sourceCodeBucket, `${props.sourceCodePrefix}/task-canceler.zip`), - runtime: Runtime.NODEJS_14_X, - timeout: Duration.seconds(300), - environment: { - METRIC_URL: props.metricsUrl, - SOLUTION_ID: props.solutionId, - VERSION: props.solutionVersion, - SCENARIOS_TABLE: props.testScenariosTable.tableName, - TASK_CLUSTER: props.ecsCluster - } - }); - Tags.of(this.taskCanceler).add('SolutionId', props.solutionId); - const taskCancelerResource = this.taskCanceler.node.defaultChild as CfnResource; - taskCancelerResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W58', - reason: 'CloudWatchLogsPolicy covers a permission to write CloudWatch logs.' - }, { - id: 'W89', - reason: 'This Lambda function does not require a VPC' - }, { - id: 'W92', - reason: 'Does not run concurrent executions' - }] - }); - - this.taskCancelerInvokePolicy = new Policy(this, 'TaskCancelerInvokePolicy', { - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['lambda:InvokeFunction'], - resources: [this.taskCanceler.functionArn] - }) - ] - }) - - const taskStatusCheckerRole = new Role(this, 'TaskStatusRole', { - assumedBy: new ServicePrincipal('lambda.amazonaws.com'), - inlinePolicies: { - 'TaskStatusPolicy': new PolicyDocument({ - statements: [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['ecs:ListTasks'], - resources: ['*'] - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ['ecs:DescribeTasks'], - resources: [ - taskArn - ] - }) - ] - }) - } - }); - taskStatusCheckerRole.attachInlinePolicy(props.cloudWatchLogsPolicy); - taskStatusCheckerRole.attachInlinePolicy(this.taskCancelerInvokePolicy); - taskStatusCheckerRole.attachInlinePolicy(props.dynamoDbPolicy); - - const taskStatusCheckerRoleResource = taskStatusCheckerRole.node.defaultChild as CfnResource; - taskStatusCheckerRoleResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W11', - reason: 'ecs:ListTasks does not support resource level permissions' - }, { - id: 'W58', - reason: 'CloudWatchLogsPolicy covers a permission to write CloudWatch logs.' - }] - }); - - this.taskStatusChecker = new LambdaFunction(this, 'TaskStatusChecker', { - description: 'Task status checker', - handler: 'index.handler', - role: taskStatusCheckerRole, - code: Code.fromBucket(props.sourceCodeBucket, `${props.sourceCodePrefix}/task-status-checker.zip`), - runtime: Runtime.NODEJS_14_X, - timeout: Duration.seconds(180), - environment: { - TASK_CLUSTER: props.ecsCluster, - SCENARIOS_TABLE: props.testScenariosTable.tableName, - TASK_CANCELER_ARN: this.taskCanceler.functionArn, - SOLUTION_ID: props.solutionId, - VERSION: props.solutionVersion - } - }); - Tags.of(this.taskStatusChecker).add('SolutionId', props.solutionId); - const taskStatusCheckerResource = this.taskStatusChecker.node.defaultChild as CfnResource; - taskStatusCheckerResource.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W58', - reason: 'CloudWatchLogsPolicy covers a permission to write CloudWatch logs.' - }, { - id: 'W89', - reason: 'This Lambda function does not require a VPC' - }, { - id: 'W92', - reason: 'Does not run concurrent executions' - },] - }); - } -} \ No newline at end of file diff --git a/source/infrastructure/lib/testing-resources/ecs.ts b/source/infrastructure/lib/testing-resources/ecs.ts new file mode 100644 index 0000000..96cd19b --- /dev/null +++ b/source/infrastructure/lib/testing-resources/ecs.ts @@ -0,0 +1,152 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Aws, CfnResource } from 'aws-cdk-lib'; +import { CfnCluster, CfnTaskDefinition } from 'aws-cdk-lib/aws-ecs'; +import { Effect, ManagedPolicy, ServicePrincipal, Role, PolicyDocument, PolicyStatement, Policy } from 'aws-cdk-lib/aws-iam'; +import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs'; +import { CfnSecurityGroup, CfnSecurityGroupEgress, CfnSecurityGroupIngress } from 'aws-cdk-lib/aws-ec2'; +import { Bucket } from 'aws-cdk-lib/aws-s3'; +import { Construct } from 'constructs'; + +export interface ECSResourcesConstructProps { + readonly cloudWatchLogsPolicy: Policy; + // Container image + readonly containerImage: string; + // Fargate VPC ID + readonly fargateVpcId: string; + // Scenarios S3 bucket + readonly scenariosS3Bucket: string; + // IP CIDR for Fargate egress + readonly securityGroupEgress: string; + // Solution ID + readonly solutionId: string; +} + +/** + * Distributed Load Testing on AWS Fargate and ECS test runner construct. + * This creates the ECS cluster, Fargate task definition, and Security Group + */ +export class ECSResourcesConstruct extends Construct { + public taskClusterName: string; + public ecsCloudWatchLogGroup: LogGroup; + public taskDefinitionArn: string; + public taskExecutionRoleArn: string; + public ecsSecurityGroupId: string; + + constructor(scope: Construct, id: string, props: ECSResourcesConstructProps) { + super(scope, id); + + const dltTaskCluster = new CfnCluster(this, 'DLTEcsCluster', { + clusterName: Aws.STACK_NAME, + clusterSettings: [{ 'name': 'containerInsights', 'value': 'enabled' }], + tags: [ + { + 'key': 'SolutionId', + 'value': props.solutionId + }, + { + 'key': 'CloudFormation Stack', + 'value': Aws.STACK_NAME + } + ] + }); + + this.taskClusterName = dltTaskCluster.ref; + + const scenariosBucketArn = Bucket.fromBucketName(this, 'ScenariosBucket', props.scenariosS3Bucket).bucketArn; + + const dltTaskExecutionRole = new Role(this, 'DLTTaskExecutionRole', { + assumedBy: new ServicePrincipal('ecs-tasks.amazonaws.com'), + managedPolicies: [ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonECSTaskExecutionRolePolicy')], + inlinePolicies: { + 'ScenariosS3Policy': new PolicyDocument({ + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 's3:HeadObject', + 's3:PutObject', + 's3:GetObject', + 's3:ListBucket' + ], + resources: [ + scenariosBucketArn, + `${scenariosBucketArn}/*` + ] + }) + ] + }) + } + }); + dltTaskExecutionRole.attachInlinePolicy(props.cloudWatchLogsPolicy); + this.taskExecutionRoleArn = dltTaskExecutionRole.roleArn; + + this.ecsCloudWatchLogGroup = new LogGroup(this, 'DLTCloudWatchLogsGroup', { + retention: RetentionDays.ONE_YEAR + }); + const dltLogsGroupResource = this.ecsCloudWatchLogGroup.node.defaultChild as CfnResource; + dltLogsGroupResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W84', + reason: 'KMS encryption unnecessary for log group' + }] + }); + + const dltTaskDefinition = new CfnTaskDefinition(this, 'DLTTaskDefinition', { + cpu: '2048', + memory: '4096', + networkMode: 'awsvpc', + executionRoleArn: this.taskExecutionRoleArn, + requiresCompatibilities: ['FARGATE'], + taskRoleArn: this.taskExecutionRoleArn, + containerDefinitions: [ + { + essential: true, + name: `${Aws.STACK_NAME}-load-tester`, + image: props.containerImage, + memory: 4096, + logConfiguration: { + logDriver: 'awslogs', + options: { + 'awslogs-group': this.ecsCloudWatchLogGroup.logGroupName, + 'awslogs-stream-prefix': 'load-testing', + 'awslogs-region': `${Aws.REGION}` + } + } + } + ], + }); + + this.taskDefinitionArn = dltTaskDefinition.ref; + + const ecsSecurityGroup = new CfnSecurityGroup(this, 'DLTEcsSecurityGroup', { + vpcId: props.fargateVpcId, + groupDescription: 'DLTS Tasks Security Group' + }); + ecsSecurityGroup.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W40', + reason: 'IpProtocol set to -1 (any) as ports are not known prior to running tests' + }] + }); + + this.ecsSecurityGroupId = ecsSecurityGroup.ref; + + new CfnSecurityGroupEgress(this, 'DLTSecGroupEgress', { + cidrIp: props.securityGroupEgress, + description: 'Allow tasks to call out to external resources', + groupId: ecsSecurityGroup.ref, + ipProtocol: '-1' + }); + + new CfnSecurityGroupIngress(this, 'DLTSecGroupIngress', { + description: 'Allow tasks to communicate', + fromPort: 50000, + groupId: ecsSecurityGroup.ref, + ipProtocol: 'tcp', + sourceSecurityGroupId: ecsSecurityGroup.ref, + toPort: 50000 + }); + } +} \ No newline at end of file diff --git a/source/infrastructure/lib/testing-resources/real-time-data.ts b/source/infrastructure/lib/testing-resources/real-time-data.ts new file mode 100644 index 0000000..63de369 --- /dev/null +++ b/source/infrastructure/lib/testing-resources/real-time-data.ts @@ -0,0 +1,81 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ArnFormat, CfnResource, Duration, Stack } from 'aws-cdk-lib'; +import { Code, Function as LambdaFunction, Runtime } from 'aws-cdk-lib/aws-lambda'; +import { Effect, PolicyStatement, PolicyDocument, Role, ServicePrincipal, Policy } from 'aws-cdk-lib/aws-iam'; +import { IBucket } from 'aws-cdk-lib/aws-s3'; +import { LogGroup, FilterPattern } from 'aws-cdk-lib/aws-logs'; +import { LambdaDestination } from 'aws-cdk-lib/aws-logs-destinations'; +import { Construct } from 'constructs'; + +export interface RealTimeDataConstructProps { + readonly cloudWatchLogsPolicy: Policy; + readonly ecsCloudWatchLogGroup: LogGroup; + readonly iotEndpoint: string; + readonly mainRegion: string; + /** + * Solution config properties. + * solution ID, version, source code bucket, and source code prefix + */ + readonly solutionId: string; + readonly solutionVersion: string; + readonly sourceCodeBucket: IBucket; + readonly sourceCodePrefix: string; +} + +export class RealTimeDataConstruct extends Construct { + constructor(scope: Construct, id: string, props: RealTimeDataConstructProps) { + super(scope, id); + + const realTimeDataPublisherRole = new Role(this, 'realTimeDataPublisherRole', { + assumedBy: new ServicePrincipal('lambda.amazonaws.com'), + inlinePolicies: { + 'IoTPolicy': new PolicyDocument({ + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['iot:Publish'], + resources: [Stack.of(this).formatArn({ region: props.mainRegion, service: 'iot', resource: 'topic', resourceName: '*', arnFormat: ArnFormat.SLASH_RESOURCE_NAME })] + }), + ] + }) + } + }); + realTimeDataPublisherRole.attachInlinePolicy(props.cloudWatchLogsPolicy); + + const realTimeDataPublisher = new LambdaFunction(this, 'RealTimeDataPublisher', { + description: 'Real time data publisher', + handler: 'index.handler', + role: realTimeDataPublisherRole, + code: Code.fromBucket(props.sourceCodeBucket, `${props.sourceCodePrefix}/real-time-data-publisher.zip`), + runtime: Runtime.NODEJS_14_X, + timeout: Duration.seconds(180), + environment: { + MAIN_REGION: props.mainRegion, + IOT_ENDPOINT: props.iotEndpoint, + SOLUTION_ID: props.solutionId, + VERSION: props.solutionVersion + } + }); + + const realTimeDataPublisherResource = realTimeDataPublisher.node.defaultChild as CfnResource; + realTimeDataPublisherResource.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W58', + reason: 'CloudWatchLogsPolicy covers a permission to write CloudWatch logs.' + }, { + id: 'W89', + reason: 'This Lambda function does not require a VPC' + }, { + id: 'W92', + reason: 'Does not run concurrent executions' + }] + }); + + props.ecsCloudWatchLogGroup.addSubscriptionFilter('ECSLogSubscriptionFilter', { + destination: new LambdaDestination(realTimeDataPublisher), + filterPattern: FilterPattern.allTerms("INFO: Current:", "live=true") + }); + } +} \ No newline at end of file diff --git a/source/infrastructure/lib/testing-resources/regional-permissions.ts b/source/infrastructure/lib/testing-resources/regional-permissions.ts new file mode 100644 index 0000000..429767f --- /dev/null +++ b/source/infrastructure/lib/testing-resources/regional-permissions.ts @@ -0,0 +1,133 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ArnFormat, Aws, Stack } from 'aws-cdk-lib'; +import { Effect, Policy, PolicyStatement, Role } from 'aws-cdk-lib/aws-iam'; +import { Construct } from 'constructs'; + +/** + * RegionalPermissionsConstruct props + * @interface RegionalPermissionsConstructProps + */ +export interface RegionalPermissionsConstructProps { + apiServicesLambdaRoleName: string; + readonly ecsCloudWatchLogGroupArn: string; + resultsParserRoleName: string; + readonly taskExecutionRoleArn: string; + taskRunnerRoleName: string; + taskCancelerRoleName: string; + taskStatusCheckerRoleName: string; +} + +/** +* Distributed Load Testing on AWS regional permissions construct. +* Adds the necessary permissions for the regional tests running in Fargate +* to already existing roles attached to Lambda functions in the main stack. +*/ +export class RegionalPermissionsConstruct extends Construct { + + constructor(scope: Construct, id: string, props: RegionalPermissionsConstructProps) { + super(scope, id); + + const taskArn = Stack.of(this).formatArn({ service: 'ecs', resource: 'task', resourceName: '*', arnFormat: ArnFormat.SLASH_RESOURCE_NAME }); + const taskDefArn = Stack.of(this).formatArn({ service: 'ecs', resource: 'task-definition', resourceName: '*:*' }); + const apiServicesLambdaRoleArn = Stack.of(this).formatArn({ service: 'iam', resource: 'role', resourceName: props.apiServicesLambdaRoleName }); + const lambdaTaskRoleArn = Stack.of(this).formatArn({ service: 'iam', resource: 'role', resourceName: props.taskRunnerRoleName }); + const taskCancelerRoleArn = Stack.of(this).formatArn({ service: 'iam', resource: 'role', resourceName: props.taskCancelerRoleName }); + const taskStatusCheckerRoleArn = Stack.of(this).formatArn({ service: 'iam', resource: 'role', resourceName: props.taskStatusCheckerRoleName }); + const resultsParserRoleNameArn = Stack.of(this).formatArn({ service: 'iam', resource: 'role', resourceName: props.resultsParserRoleName }); + + const ecsPolicyName = `RegionalECRPerms-${Aws.STACK_NAME}-${Aws.REGION}`; + const ecsRegionPolicy = new Policy(this, 'RegionalECRPerms', { + policyName: ecsPolicyName, + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'ecs:RunTask', + 'ecs:DescribeTasks' + ], + resources: [ + taskArn, + taskDefArn + ] + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['iam:PassRole'], + resources: [props.taskExecutionRoleArn] + }) + ] + }); + + const cloudwatchPolicyName = `ECSCloudWatchPutMetrics-${Aws.STACK_NAME}-${Aws.REGION}`; + const ecsCloudWatchPutMetricsPolicy = new Policy(this, 'ECSCloudWatchPutMetricsd', { + policyName: cloudwatchPolicyName, + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['logs:PutMetricFilter'], + resources: [props.ecsCloudWatchLogGroupArn] + }) + ] + }); + + const lambdaTaskRole = Role.fromRoleArn(this, 'RegionalPermissionsForTaskRole', lambdaTaskRoleArn); + lambdaTaskRole.attachInlinePolicy(ecsRegionPolicy); + lambdaTaskRole.attachInlinePolicy(ecsCloudWatchPutMetricsPolicy); + + const cloudwatchDelName = `ECSCloudWatchDelMetrics-${Aws.STACK_NAME}-${Aws.REGION}`; + const ecsCloudWatchDelMetricsPolicy = new Policy(this, 'ECSCloudWatchDelMetrics', { + policyName: cloudwatchDelName, + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['logs:DeleteMetricFilter'], + resources: [props.ecsCloudWatchLogGroupArn] + }) + ] + }); + + const apiServicesLambdaRole = Role.fromRoleArn(this, 'APIServicesLambdaRole', apiServicesLambdaRoleArn); + apiServicesLambdaRole.attachInlinePolicy(ecsRegionPolicy); + apiServicesLambdaRole.attachInlinePolicy(ecsCloudWatchDelMetricsPolicy); + + const resultsParserLambdaRole = Role.fromRoleArn(this, 'ResultsParserRole', resultsParserRoleNameArn); + resultsParserLambdaRole.attachInlinePolicy(ecsCloudWatchDelMetricsPolicy); + + const ecsStopName = `ECSStopPolicy-${Aws.STACK_NAME}-${Aws.REGION}`; + const ecsStopPolicy = new Policy(this, 'ECSStopPolicy', { + policyName: ecsStopName, + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['ecs:StopTask'], + resources: [ + taskArn, + taskDefArn + ] + }) + ] + }); + + const taskCancelerRole = Role.fromRoleArn(this, 'TaskCancelerRole', taskCancelerRoleArn); + taskCancelerRole.attachInlinePolicy(ecsStopPolicy); + + const ecsDescribeName = `ECSDescribePolicy${Aws.REGION}`; + const ecsDescribePolicy = new Policy(this, 'ECSDescribePolicy', { + policyName: ecsDescribeName, + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['ecs:DescribeTasks'], + resources: [ + taskArn + ] + }) + ] + }); + + const taskStatusCheckerRole = Role.fromRoleArn(this, 'TaskStatusCheckerRole', taskStatusCheckerRoleArn); + taskStatusCheckerRole.attachInlinePolicy(ecsDescribePolicy); + } +} \ No newline at end of file diff --git a/source/infrastructure/lib/testing-resources/vpc.ts b/source/infrastructure/lib/testing-resources/vpc.ts new file mode 100644 index 0000000..d6a51fc --- /dev/null +++ b/source/infrastructure/lib/testing-resources/vpc.ts @@ -0,0 +1,104 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Aws, Fn, Tags } from 'aws-cdk-lib'; +import { + CfnSubnet, + CfnVPC, + CfnInternetGateway, + CfnRouteTable, + CfnVPCGatewayAttachment, + CfnRoute, + CfnSubnetRouteTableAssociation +} from 'aws-cdk-lib/aws-ec2'; +import { Construct } from 'constructs'; + +/** + * FargageVPCConstruct props + * @interface FargateVpcConstructProps + */ +export interface FargateVpcConstructProps { + // IP CIDR block for Subnet A + readonly subnetACidrBlock: string; + // IP CIDR block for Subnet B + readonly subnetBCidrBlock: string; + // Solution ID + readonly solutionId: string; + // IP CIDR block for VPC + readonly vpcCidrBlock: string; +} + +/** + * Distributed Load Testing on AWS VPC construct. + * Creates a VPC for the Fagate test tasks. + * Includes 2 subnets across 2 availability zones and supporting resources. + */ +export class FargateVpcConstruct extends Construct { + // VPC + public vpcId: string; + public subnetA: string; + public subnetB: string; + + constructor(scope: Construct, id: string, props: FargateVpcConstructProps) { + super(scope, id); + + const fargateVpc = new CfnVPC(this, 'DLTFargateVpc', { + cidrBlock: props.vpcCidrBlock, + enableDnsHostnames: true, + enableDnsSupport: true + }); + + fargateVpc.addMetadata('cfn_nag', { + rules_to_suppress: [{ + id: 'W60', + reason: 'This VPC is used for the test runner Fargate tasks only, it does not require VPC flow logs.' + }] + }); + + Tags.of(fargateVpc).add('Name', Aws.STACK_NAME); + this.vpcId = fargateVpc.ref; + + const subnetAResource = new CfnSubnet(this, 'DLTSubnetA', { + cidrBlock: props.subnetACidrBlock, + vpcId: this.vpcId, + availabilityZone: Fn.select(0, Fn.getAzs()) + }); + this.subnetA = subnetAResource.ref; + + const subnetBResource = new CfnSubnet(this, 'DLTSubnetB', { + cidrBlock: props.subnetBCidrBlock, + vpcId: this.vpcId, + availabilityZone: Fn.select(1, Fn.getAzs()) + }); + this.subnetB = subnetBResource.ref; + + const ig = new CfnInternetGateway(this, 'DLTFargateIG', { + }); + + const mainRouteTable = new CfnRouteTable(this, 'DLTFargateRT', { + vpcId: this.vpcId, + }); + + const gwa = new CfnVPCGatewayAttachment(this, 'DLTGatewayattachment', { + vpcId: this.vpcId, + internetGatewayId: ig.ref + }); + + const routeToInternet = new CfnRoute(this, 'DLTRoute', { + destinationCidrBlock: '0.0.0.0/0', + routeTableId: mainRouteTable.ref, + gatewayId: ig.ref + }); + routeToInternet.addDependsOn(gwa); + + new CfnSubnetRouteTableAssociation(this, 'DLTRouteTableAssociationA', { + routeTableId: mainRouteTable.ref, + subnetId: subnetAResource.ref + }); + + new CfnSubnetRouteTableAssociation(this, 'DLTRouteTableAssociationB', { + routeTableId: mainRouteTable.ref, + subnetId: subnetBResource.ref + }); + } +} \ No newline at end of file diff --git a/source/infrastructure/lib/vpc.ts b/source/infrastructure/lib/vpc.ts deleted file mode 100644 index b3f36fc..0000000 --- a/source/infrastructure/lib/vpc.ts +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { - CfnSubnet, - CfnVPC, - CfnInternetGateway, - CfnRouteTable, - CfnVPCGatewayAttachment, - CfnRoute, - CfnSubnetRouteTableAssociation -} from '@aws-cdk/aws-ec2'; -import { Aws, Construct, Fn, Tags } from '@aws-cdk/core'; - - -/** - * FargageVPCConstruct props - * @interface FargateVpcContructProps - */ -export interface FargateVpcContructProps { - // IP CIDR block for Subnet A - readonly subnetACidrBlock: string; - // IP CIDR block for Subnet B - readonly subnetBCidrBlock: string; - // Solution ID - readonly solutionId: string; - // IP CIDR block for VPC - readonly vpcCidrBlock: string; -} - -/** - * @class - * Distributed Load Testing on AWS VPC construct. - * Creates a VPC for the Fagate test tasks. - * Includes 2 subnets across 2 availability zones and supporting resources. - */ -export class FargateVpcContruct extends Construct { - // VPC - public DLTfargateVpcId: string; - public subnetA: string; - public subnetB: string; - - constructor(scope: Construct, id: string, props: FargateVpcContructProps) { - super(scope, id); - - const fargateVpc = new CfnVPC(this, 'DLTFargateVpc', { - cidrBlock: props.vpcCidrBlock, - enableDnsHostnames: true, - enableDnsSupport: true - }); - - fargateVpc.addMetadata('cfn_nag', { - rules_to_suppress: [{ - id: 'W60', - reason: 'This VPC is used for the test runner Fargate tasks only, it does not require VPC flow logs.' - }] - }); - - Tags.of(fargateVpc).add('SolutionId', props.solutionId); - Tags.of(fargateVpc).add('Name', Aws.STACK_NAME) - this.DLTfargateVpcId = fargateVpc.ref - - const subnetAResource = new CfnSubnet(this, 'DLTSubnetA', { - cidrBlock: props.subnetACidrBlock, - vpcId: this.DLTfargateVpcId, - availabilityZone: Fn.select(0, Fn.getAzs()) - }); - Tags.of(subnetAResource).add('SolutionId', props.solutionId); - this.subnetA = subnetAResource.ref - - const subnetBResource = new CfnSubnet(this, 'DLTSubnetB', { - cidrBlock: props.subnetBCidrBlock, - vpcId: this.DLTfargateVpcId, - availabilityZone: Fn.select(1, Fn.getAzs()) - }); - Tags.of(subnetBResource).add('SolutionId', props.solutionId); - this.subnetB = subnetBResource.ref - - const ig = new CfnInternetGateway(this, 'DLTFargateIG', { - }); - Tags.of(ig).add('SolutionId', props.solutionId); - - const mainRouteTable = new CfnRouteTable(this, 'DLTFargateRT', { - vpcId: this.DLTfargateVpcId, - }); - Tags.of(mainRouteTable).add('SolutionId', props.solutionId); - - const gwa = new CfnVPCGatewayAttachment(this, 'DLTGatewayattachment', { - vpcId: this.DLTfargateVpcId, - internetGatewayId: ig.ref - }); - - const routeToInternet = new CfnRoute(this, 'DLTRoute', { - destinationCidrBlock: '0.0.0.0/0', - routeTableId: mainRouteTable.ref, - gatewayId: ig.ref - }); - routeToInternet.addDependsOn(gwa); - - new CfnSubnetRouteTableAssociation(this, 'DLTRouteTableAssociationA', { - routeTableId: mainRouteTable.ref, - subnetId: subnetAResource.ref - }); - - new CfnSubnetRouteTableAssociation(this, 'DLTRouteTableAssociationB', { - routeTableId: mainRouteTable.ref, - subnetId: subnetBResource.ref - }); - - } -} \ No newline at end of file diff --git a/source/infrastructure/package.json b/source/infrastructure/package.json index 57a0a8e..c0da2ba 100644 --- a/source/infrastructure/package.json +++ b/source/infrastructure/package.json @@ -1,6 +1,6 @@ { "name": "distributed-load-testing-on-aws-infrastructure", - "version": "2.0.1", + "version": "3.0.0", "bin": { "distributed-load-testing-on-aws": "bin/distributed-load-testing-on-aws.ts" }, @@ -12,31 +12,19 @@ "cdk": "cdk" }, "devDependencies": { - "@aws-cdk/assert": "1.124.0", + "@aws-cdk/assert": "2.23.0", + "@aws-solutions-constructs/aws-cloudfront-s3": "2.8.0", + "aws-cdk": "2.23.0", + "aws-cdk-lib": "2.23.0", + "constructs": "10.1.23", "@types/jest": "^26.0.10", "@types/node": "10.17.27", - "aws-cdk": "1.124.0", "jest": "^26.4.2", "ts-jest": "^26.2.0", "ts-node": "^9.0.0", - "typescript": "~3.9.7" + "typescript": "~4.5.5" }, "dependencies": { - "@aws-cdk/aws-apigateway": "1.124.0", - "@aws-cdk/aws-cloudfront": "1.124.0", - "@aws-cdk/aws-cognito": "1.124.0", - "@aws-cdk/aws-dynamodb": "1.124.0", - "@aws-cdk/aws-ec2": "1.124.0", - "@aws-cdk/aws-ecr": "1.124.0", - "@aws-cdk/aws-ecs": "1.124.0", - "@aws-cdk/aws-iam": "1.124.0", - "@aws-cdk/aws-lambda": "1.124.0", - "@aws-cdk/aws-logs": "1.124.0", - "@aws-cdk/aws-s3": "1.124.0", - "@aws-cdk/aws-stepfunctions": "1.124.0", - "@aws-cdk/aws-stepfunctions-tasks": "1.124.0", - "@aws-cdk/core": "1.124.0", - "@aws-solutions-constructs/aws-cloudfront-s3": "1.124.0", "source-map-support": "^0.5.16" } } diff --git a/source/infrastructure/test/__snapshots__/api.test.ts.snap b/source/infrastructure/test/__snapshots__/api.test.ts.snap index 1ce3635..bc33d9d 100644 --- a/source/infrastructure/test/__snapshots__/api.test.ts.snap +++ b/source/infrastructure/test/__snapshots__/api.test.ts.snap @@ -162,17 +162,17 @@ Object { "Description": "API microservices for creating, updating, listing and deleting test scenarios", "Environment": Object { "Variables": Object { - "ECS_LOG_GROUP": Object { - "Ref": "TestLogGroup4EEF7AD4", - }, + "HISTORY_TABLE": "testHistoryDDBTable", "METRIC_URL": "http://testurl.com", "SCENARIOS_BUCKET": "testScenarioBucketName", "SCENARIOS_TABLE": "testDDBTable", "SEND_METRIC": "Yes", "SOLUTION_ID": "testId", + "STACK_ID": Object { + "Ref": "AWS::StackId", + }, "STATE_MACHINE_ARN": "arn:aws:states:us-east-1:111122223333:stateMachine:HelloWorld-StateMachine", "TASK_CANCELER_ARN": "arn:aws:lambda:us-east-1:111122223333:function:HelloFunction", - "TASK_CLUSTER": "testECSCluster", "UUID": "abc123", "VERSION": "testVersion", }, @@ -185,12 +185,6 @@ Object { ], }, "Runtime": "nodejs14.x", - "Tags": Array [ - Object { - "Key": "SolutionId", - "Value": "testId", - }, - ], "Timeout": 120, }, "Type": "AWS::Lambda::Function", @@ -238,7 +232,7 @@ Object { "rules_to_suppress": Array [ Object { "id": "W11", - "reason": "ecs:ListTasks does not support resource level permissions", + "reason": "ecs:ListTasks and cloudformation:ListExports do not support resource level permissions", }, ], }, @@ -354,6 +348,11 @@ Object { ], }, }, + Object { + "Action": "cloudformation:ListExports", + "Effect": "Allow", + "Resource": "*", + }, ], "Version": "2012-10-17", }, @@ -420,10 +419,13 @@ Object { }, "Type": "AWS::IAM::Role", }, - "TestAPIDLTApiDeployment7935ADA287019c10d28ca4c24b1843e7ae095419": Object { + "TestAPIDLTApiDeployment7935ADA24573564c36777eba9168f50b28fad0fb": Object { "DependsOn": Array [ "TestAPIAPIAllRequestValidator9A179146", "TestAPIDLTApiOPTIONS380FFB97", + "TestAPIDLTApiregionsANYC5A4C4FB", + "TestAPIDLTApiregionsOPTIONSB6A98797", + "TestAPIDLTApiregionsFB81DCC6", "TestAPIDLTApiscenariostestIdANY5DE40CF0", "TestAPIDLTApiscenariostestIdOPTIONSF10FECFD", "TestAPIDLTApiscenariostestId0D5FBA86", @@ -453,6 +455,9 @@ Object { "Type": "AWS::ApiGateway::Deployment", }, "TestAPIDLTApiDeploymentStageprodA10AE629": Object { + "DependsOn": Array [ + "TestAPIDLTApiAccount780DB186", + ], "Metadata": Object { "cfn_nag": Object { "rules_to_suppress": Array [ @@ -474,10 +479,11 @@ Object { "Format": "{\\"requestId\\":\\"$context.requestId\\",\\"ip\\":\\"$context.identity.sourceIp\\",\\"user\\":\\"$context.identity.user\\",\\"caller\\":\\"$context.identity.caller\\",\\"requestTime\\":\\"$context.requestTime\\",\\"httpMethod\\":\\"$context.httpMethod\\",\\"resourcePath\\":\\"$context.resourcePath\\",\\"status\\":\\"$context.status\\",\\"protocol\\":\\"$context.protocol\\",\\"responseLength\\":\\"$context.responseLength\\"}", }, "DeploymentId": Object { - "Ref": "TestAPIDLTApiDeployment7935ADA287019c10d28ca4c24b1843e7ae095419", + "Ref": "TestAPIDLTApiDeployment7935ADA24573564c36777eba9168f50b28fad0fb", }, "MethodSettings": Array [ Object { + "DataTraceEnabled": false, "HttpMethod": "*", "LoggingLevel": "INFO", "ResourcePath": "/*", @@ -533,6 +539,118 @@ Object { }, "Type": "AWS::ApiGateway::Method", }, + "TestAPIDLTApiregionsANYC5A4C4FB": Object { + "Properties": Object { + "AuthorizationType": "AWS_IAM", + "HttpMethod": "ANY", + "Integration": Object { + "ContentHandling": "CONVERT_TO_TEXT", + "IntegrationHttpMethod": "POST", + "IntegrationResponses": Array [ + Object { + "StatusCode": "200", + }, + ], + "PassthroughBehavior": "WHEN_NO_MATCH", + "Type": "AWS_PROXY", + "Uri": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":apigateway:", + Object { + "Ref": "AWS::Region", + }, + ":lambda:path/2015-03-31/functions/", + Object { + "Fn::GetAtt": Array [ + "TestAPIDLTAPIServicesLambda1C09DECC", + "Arn", + ], + }, + "/invocations", + ], + ], + }, + }, + "MethodResponses": Array [ + Object { + "ResponseModels": Object { + "application/json": "Empty", + }, + "StatusCode": "200", + }, + ], + "RequestValidatorId": Object { + "Ref": "TestAPIAPIAllRequestValidator9A179146", + }, + "ResourceId": Object { + "Ref": "TestAPIDLTApiregionsFB81DCC6", + }, + "RestApiId": Object { + "Ref": "TestAPIDLTApi434DC190", + }, + }, + "Type": "AWS::ApiGateway::Method", + }, + "TestAPIDLTApiregionsFB81DCC6": Object { + "Properties": Object { + "ParentId": Object { + "Fn::GetAtt": Array [ + "TestAPIDLTApi434DC190", + "RootResourceId", + ], + }, + "PathPart": "regions", + "RestApiId": Object { + "Ref": "TestAPIDLTApi434DC190", + }, + }, + "Type": "AWS::ApiGateway::Resource", + }, + "TestAPIDLTApiregionsOPTIONSB6A98797": Object { + "Properties": Object { + "AuthorizationType": "NONE", + "HttpMethod": "OPTIONS", + "Integration": Object { + "IntegrationResponses": Array [ + Object { + "ResponseParameters": Object { + "method.response.header.Access-Control-Allow-Headers": "'Authorization,Content-Type,X-Amz-Date,X-Amz-Security-Token,X-Api-Key'", + "method.response.header.Access-Control-Allow-Methods": "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'", + "method.response.header.Access-Control-Allow-Origin": "'*'", + }, + "StatusCode": "200", + }, + ], + "RequestTemplates": Object { + "application/json": "{ statusCode: 200 }", + }, + "Type": "MOCK", + }, + "MethodResponses": Array [ + Object { + "ResponseParameters": Object { + "method.response.header.Access-Control-Allow-Headers": true, + "method.response.header.Access-Control-Allow-Methods": true, + "method.response.header.Access-Control-Allow-Origin": true, + }, + "StatusCode": "200", + }, + ], + "ResourceId": Object { + "Ref": "TestAPIDLTApiregionsFB81DCC6", + }, + "RestApiId": Object { + "Ref": "TestAPIDLTApi434DC190", + }, + }, + "Type": "AWS::ApiGateway::Method", + }, "TestAPIDLTApiscenariosANYEC9F0ED6": Object { "Properties": Object { "AuthorizationType": "AWS_IAM", diff --git a/source/infrastructure/test/__snapshots__/auth.test.ts.snap b/source/infrastructure/test/__snapshots__/auth.test.ts.snap index f47f2a3..1658eed 100644 --- a/source/infrastructure/test/__snapshots__/auth.test.ts.snap +++ b/source/infrastructure/test/__snapshots__/auth.test.ts.snap @@ -53,6 +53,16 @@ Object { "Type": "AWS::Cognito::UserPoolUser", }, "TestAuthDLTCognitoAuthorizedRole00B36330": Object { + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W11", + "reason": "iot:AttachPrincipalPolicy does not allow for resource specification", + }, + ], + }, + }, "Properties": Object { "AssumeRolePolicyDocument": Object { "Statement": Array [ @@ -131,6 +141,91 @@ Object { }, "PolicyName": "InvokeApiPolicy", }, + Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "iot:AttachPrincipalPolicy", + "Effect": "Allow", + "Resource": "*", + }, + Object { + "Action": "iot:Connect", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":client/*", + ], + ], + }, + }, + Object { + "Action": "iot:Subscribe", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":topicfilter/*", + ], + ], + }, + }, + Object { + "Action": "iot:Receive", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":topic/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "IoTPolicy", + }, ], }, "Type": "AWS::IAM::Role", @@ -313,6 +408,98 @@ Object { }, "Type": "AWS::Cognito::UserPoolClient", }, + "TestAuthIoTPolicyF897DD43": Object { + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W11", + "reason": "Cannot specify the resource to attach policy to identity", + }, + ], + }, + }, + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "iot:Connect", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":client/*", + ], + ], + }, + }, + Object { + "Action": "iot:Subscribe", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":topicfilter/*", + ], + ], + }, + }, + Object { + "Action": "iot:Receive", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":topic/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + }, + "Type": "AWS::IoT::Policy", + }, }, } `; diff --git a/source/infrastructure/test/__snapshots__/common-resources.test.ts.snap b/source/infrastructure/test/__snapshots__/common-resources.test.ts.snap index 120aa7e..ca50937 100644 --- a/source/infrastructure/test/__snapshots__/common-resources.test.ts.snap +++ b/source/infrastructure/test/__snapshots__/common-resources.test.ts.snap @@ -1,304 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`DLT API Test 1`] = ` -Object { - "Conditions": Object { - "condition": Object { - "Fn::If": Array [ - "testCondition", - true, - false, - ], - }, - }, - "Resources": Object { - "TestCommonResourcesAnonymousMetric29B2B95B": Object { - "Condition": "condition", - "DeletionPolicy": "Delete", - "Properties": Object { - "Region": Object { - "Ref": "AWS::Region", - }, - "Resource": "AnonymousMetric", - "ServiceToken": Object { - "Fn::GetAtt": Array [ - "TestCommonResourcesCustomResourceLambda707B5671", - "Arn", - ], - }, - "SolutionId": "testId", - "UUID": Object { - "Fn::GetAtt": Array [ - "TestCommonResourcesUUIDFDB821D1", - "UUID", - ], - }, - "VERSION": "testVersion", - "existingVPC": "false", - }, - "Type": "Custom::AnonymousMetric", - "UpdateReplacePolicy": "Delete", - }, - "TestCommonResourcesCloudWatchLogsPolicy1662253E": Object { - "Properties": Object { - "PolicyDocument": Object { - "Statement": Array [ - Object { - "Action": Array [ - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents", - ], - "Effect": "Allow", - "Resource": Object { - "Fn::Join": Array [ - "", - Array [ - "arn:", - Object { - "Ref": "AWS::Partition", - }, - ":logs:", - Object { - "Ref": "AWS::Region", - }, - ":", - Object { - "Ref": "AWS::AccountId", - }, - ":log-group:/aws/lambda/*", - ], - ], - }, - }, - ], - "Version": "2012-10-17", - }, - "PolicyName": "TestCommonResourcesCloudWatchLogsPolicy1662253E", - "Roles": Array [ - Object { - "Ref": "TestRole6C9272DF", - }, - Object { - "Ref": "TestCommonResourcesCustomResourceLambdaRole15E04CF9", - }, - ], - }, - "Type": "AWS::IAM::Policy", - }, - "TestCommonResourcesCustomResourceLambda707B5671": Object { - "DependsOn": Array [ - "TestCommonResourcesCustomResourceLambdaRole15E04CF9", - ], - "Metadata": Object { - "cfn_nag": Object { - "rules_to_suppress": Array [ - Object { - "id": "W58", - "reason": "CloudWatchLogsPolicy covers a permission to write CloudWatch logs.", - }, - Object { - "id": "W89", - "reason": "VPC not needed for lambda", - }, - Object { - "id": "W92", - "reason": "Does not run concurrent executions", - }, - ], - }, - }, - "Properties": Object { - "Code": Object { - "S3Bucket": "testbucketname", - "S3Key": "/testPrefix/custom-resource.zip", - }, - "Description": "CFN Lambda backed custom resource to deploy assets to s3", - "Environment": Object { - "Variables": Object { - "METRIC_URL": "http://testMetricsUrl.com", - "SOLUTION_ID": "testId", - "VERSION": "testVersion", - }, - }, - "Handler": "index.handler", - "Role": Object { - "Fn::GetAtt": Array [ - "TestCommonResourcesCustomResourceLambdaRole15E04CF9", - "Arn", - ], - }, - "Runtime": "nodejs14.x", - "Tags": Array [ - Object { - "Key": "SolutionId", - "Value": "testId", - }, - ], - "Timeout": 120, - }, - "Type": "AWS::Lambda::Function", - }, - "TestCommonResourcesCustomResourceLambdaRole15E04CF9": Object { - "Properties": Object { - "AssumeRolePolicyDocument": Object { - "Statement": Array [ - Object { - "Action": "sts:AssumeRole", - "Effect": "Allow", - "Principal": Object { - "Service": "lambda.amazonaws.com", - }, - }, - ], - "Version": "2012-10-17", - }, - "Policies": Array [ - Object { - "PolicyDocument": Object { - "Statement": Array [ - Object { - "Action": "s3:GetObject", - "Effect": "Allow", - "Resource": Object { - "Fn::Join": Array [ - "", - Array [ - "arn:", - Object { - "Ref": "AWS::Partition", - }, - ":s3:::testbucketname/*", - ], - ], - }, - }, - ], - "Version": "2012-10-17", - }, - "PolicyName": "CustomResourcePolicy", - }, - ], - }, - "Type": "AWS::IAM::Role", - }, - "TestCommonResourcesLogsBucket5B4DBD4F": Object { - "DeletionPolicy": "Retain", - "Metadata": Object { - "cfn_nag": Object { - "rules_to_suppress": Array [ - Object { - "id": "W35", - "reason": "This is the logging bucket, it does not require logging.", - }, - ], - }, - }, - "Properties": Object { - "AccessControl": "LogDeliveryWrite", - "BucketEncryption": Object { - "ServerSideEncryptionConfiguration": Array [ - Object { - "ServerSideEncryptionByDefault": Object { - "SSEAlgorithm": "AES256", - }, - }, - ], - }, - "PublicAccessBlockConfiguration": Object { - "BlockPublicAcls": true, - "BlockPublicPolicy": true, - "IgnorePublicAcls": true, - "RestrictPublicBuckets": true, - }, - "Tags": Array [ - Object { - "Key": "SolutionId", - "Value": "testId", - }, - ], - }, - "Type": "AWS::S3::Bucket", - "UpdateReplacePolicy": "Retain", - }, - "TestCommonResourcesLogsBucketPolicyAB18A08E": Object { - "Properties": Object { - "Bucket": Object { - "Ref": "TestCommonResourcesLogsBucket5B4DBD4F", - }, - "PolicyDocument": Object { - "Statement": Array [ - Object { - "Action": "s3:*", - "Condition": Object { - "Bool": Object { - "aws:SecureTransport": "false", - }, - }, - "Effect": "Deny", - "Principal": Object { - "AWS": "*", - }, - "Resource": Array [ - Object { - "Fn::GetAtt": Array [ - "TestCommonResourcesLogsBucket5B4DBD4F", - "Arn", - ], - }, - Object { - "Fn::Join": Array [ - "", - Array [ - Object { - "Fn::GetAtt": Array [ - "TestCommonResourcesLogsBucket5B4DBD4F", - "Arn", - ], - }, - "/*", - ], - ], - }, - ], - }, - ], - "Version": "2012-10-17", - }, - }, - "Type": "AWS::S3::BucketPolicy", - }, - "TestCommonResourcesUUIDFDB821D1": Object { - "DeletionPolicy": "Delete", - "Properties": Object { - "Resource": "UUID", - "ServiceToken": Object { - "Fn::GetAtt": Array [ - "TestCommonResourcesCustomResourceLambda707B5671", - "Arn", - ], - }, - }, - "Type": "Custom::UUID", - "UpdateReplacePolicy": "Delete", - }, - "TestRole6C9272DF": Object { - "Properties": Object { - "AssumeRolePolicyDocument": Object { - "Statement": Array [ - Object { - "Action": "sts:AssumeRole", - "Effect": "Allow", - "Principal": Object { - "Service": "apigateway.amazonaws.com", - }, - }, - ], - "Version": "2012-10-17", - }, - }, - "Type": "AWS::IAM::Role", - }, - }, -} -`; +exports[`DLT API Test 1`] = `Object {}`; diff --git a/source/infrastructure/test/__snapshots__/console.test.ts.snap b/source/infrastructure/test/__snapshots__/console.test.ts.snap index 09d9a83..4003df7 100644 --- a/source/infrastructure/test/__snapshots__/console.test.ts.snap +++ b/source/infrastructure/test/__snapshots__/console.test.ts.snap @@ -73,12 +73,6 @@ Object { }, ], }, - "Tags": Array [ - Object { - "Key": "SolutionId", - "Value": "testId", - }, - ], }, "Type": "AWS::CloudFront::Distribution", }, @@ -127,12 +121,6 @@ Object { "IgnorePublicAcls": true, "RestrictPublicBuckets": true, }, - "Tags": Array [ - Object { - "Key": "SolutionId", - "Value": "testId", - }, - ], "VersioningConfiguration": Object { "Status": "Enabled", }, @@ -158,7 +146,7 @@ Object { "PolicyDocument": Object { "Statement": Array [ Object { - "Action": "*", + "Action": "s3:*", "Condition": Object { "Bool": Object { "aws:SecureTransport": "false", @@ -169,6 +157,12 @@ Object { "AWS": "*", }, "Resource": Array [ + Object { + "Fn::GetAtt": Array [ + "TestConsoleResourcesDLTCloudFrontToS3S3BucketAC4D9E17", + "Arn", + ], + }, Object { "Fn::Join": Array [ "", @@ -183,14 +177,7 @@ Object { ], ], }, - Object { - "Fn::GetAtt": Array [ - "TestConsoleResourcesDLTCloudFrontToS3S3BucketAC4D9E17", - "Arn", - ], - }, ], - "Sid": "HttpsOnly", }, Object { "Action": "s3:GetObject", @@ -224,101 +211,6 @@ Object { }, "Type": "AWS::S3::BucketPolicy", }, - "TestCustomResourceRole4295FDB5": Object { - "Properties": Object { - "AssumeRolePolicyDocument": Object { - "Statement": Array [ - Object { - "Action": "sts:AssumeRole", - "Effect": "Allow", - "Principal": Object { - "Service": "lambda.amazonaws.com", - }, - }, - ], - "Version": "2012-10-17", - }, - "Policies": Array [ - Object { - "PolicyDocument": Object { - "Statement": Array [ - Object { - "Action": "*", - "Effect": "Deny", - "Resource": "*", - }, - ], - "Version": "2012-10-17", - }, - "PolicyName": "DenyPolicy", - }, - ], - }, - "Type": "AWS::IAM::Role", - }, - "TestCustomResourceRoleDefaultPolicy81383378": Object { - "Properties": Object { - "PolicyDocument": Object { - "Statement": Array [ - Object { - "Action": "s3:PutObject", - "Effect": "Allow", - "Resource": Array [ - Object { - "Fn::GetAtt": Array [ - "TestConsoleResourcesDLTCloudFrontToS3S3BucketAC4D9E17", - "Arn", - ], - }, - Object { - "Fn::Join": Array [ - "", - Array [ - Object { - "Fn::GetAtt": Array [ - "TestConsoleResourcesDLTCloudFrontToS3S3BucketAC4D9E17", - "Arn", - ], - }, - "/*", - ], - ], - }, - ], - }, - ], - "Version": "2012-10-17", - }, - "PolicyName": "TestCustomResourceRoleDefaultPolicy81383378", - "Roles": Array [ - Object { - "Ref": "TestCustomResourceRole4295FDB5", - }, - ], - }, - "Type": "AWS::IAM::Policy", - }, - "TestFunction22AD90FC": Object { - "DependsOn": Array [ - "TestCustomResourceRoleDefaultPolicy81383378", - "TestCustomResourceRole4295FDB5", - ], - "Properties": Object { - "Code": Object { - "S3Bucket": "TestBucket", - "S3Key": "custom-resource.zip", - }, - "Handler": "index.handler", - "Role": Object { - "Fn::GetAtt": Array [ - "TestCustomResourceRole4295FDB5", - "Arn", - ], - }, - "Runtime": "nodejs14.x", - }, - "Type": "AWS::Lambda::Function", - }, "testSourceCodeBucketC577B176": Object { "DeletionPolicy": "Retain", "Properties": Object { diff --git a/source/infrastructure/test/__snapshots__/custom-resources-infra.test.ts.snap b/source/infrastructure/test/__snapshots__/custom-resources-infra.test.ts.snap new file mode 100644 index 0000000..e4b47e0 --- /dev/null +++ b/source/infrastructure/test/__snapshots__/custom-resources-infra.test.ts.snap @@ -0,0 +1,240 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DLT API Test 1`] = ` +Object { + "Resources": Object { + "TestCustomResourceInfraCustomResourceLambda92E822A7": Object { + "DependsOn": Array [ + "TestCustomResourceInfraCustomResourceLambdaRoleDefaultPolicy46B2072F", + "TestCustomResourceInfraCustomResourceLambdaRole03671AE8", + ], + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W58", + "reason": "CloudWatchLogsPolicy covers a permission to write CloudWatch logs.", + }, + Object { + "id": "W89", + "reason": "VPC not needed for lambda", + }, + Object { + "id": "W92", + "reason": "Does not run concurrent executions", + }, + ], + }, + }, + "Properties": Object { + "Code": Object { + "S3Bucket": Object { + "Ref": "testSourceCodeBucketC577B176", + }, + "S3Key": "test/source/prefix/main-custom-resource.zip", + }, + "Description": "CFN Lambda backed custom resource to deploy assets to s3", + "Environment": Object { + "Variables": Object { + "DDB_TABLE": "scenarioTestTable", + "MAIN_REGION": "test-region-1", + "METRIC_URL": "http://testurl.com", + "S3_BUCKET": "scenariotestbucket", + "SOLUTION_ID": "S0XXX", + "VERSION": "testVersion", + }, + }, + "Handler": "index.handler", + "Role": Object { + "Fn::GetAtt": Array [ + "TestCustomResourceInfraCustomResourceLambdaRole03671AE8", + "Arn", + ], + }, + "Runtime": "nodejs14.x", + "Timeout": 120, + }, + "Type": "AWS::Lambda::Function", + }, + "TestCustomResourceInfraCustomResourceLambdaRole03671AE8": Object { + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W11", + "reason": "iot:DescribeEndpoint and iot:DetachPrincipalPolicy cannot specify the resource.", + }, + ], + }, + }, + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": Array [ + Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "s3:GetObject", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Fn::GetAtt": Array [ + "testSourceCodeBucketC577B176", + "Arn", + ], + }, + "/*", + ], + ], + }, + }, + Object { + "Action": Array [ + "s3:PutObject", + "s3:DeleteObject", + ], + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":s3:::scenariotestbucket/*", + ], + ], + }, + }, + Object { + "Action": Array [ + "dynamodb:PutItem", + "dynamodb:DeleteItem", + ], + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":dynamodb:test-region-1:", + Object { + "Ref": "AWS::AccountId", + }, + ":table/scenarioTestTable", + ], + ], + }, + }, + Object { + "Action": Array [ + "iot:DescribeEndpoint", + "iot:DetachPrincipalPolicy", + ], + "Effect": "Allow", + "Resource": "*", + }, + Object { + "Action": "iot:ListTargetsForPolicy", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":policy/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "CustomResourcePolicy", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "TestCustomResourceInfraCustomResourceLambdaRoleDefaultPolicy46B2072F": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "s3:PutObject", + "Effect": "Allow", + "Resource": Array [ + "test:console:bucket:arn", + "test:console:bucket:arn/*", + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "TestCustomResourceInfraCustomResourceLambdaRoleDefaultPolicy46B2072F", + "Roles": Array [ + Object { + "Ref": "TestCustomResourceInfraCustomResourceLambdaRole03671AE8", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "TestPolicyCC05E598": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "cloudwatch:Get*", + "Effect": "Allow", + "Resource": "*", + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "TestPolicyCC05E598", + "Roles": Array [ + Object { + "Ref": "TestCustomResourceInfraCustomResourceLambdaRole03671AE8", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "testSourceCodeBucketC577B176": Object { + "DeletionPolicy": "Retain", + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + }, + }, +} +`; diff --git a/source/infrastructure/test/__snapshots__/custom-resources.test.ts.snap b/source/infrastructure/test/__snapshots__/custom-resources.test.ts.snap index 38f48aa..1e11db7 100644 --- a/source/infrastructure/test/__snapshots__/custom-resources.test.ts.snap +++ b/source/infrastructure/test/__snapshots__/custom-resources.test.ts.snap @@ -2,8 +2,39 @@ exports[`DLT API Test 1`] = ` Object { + "Conditions": Object { + "condition": Object { + "Fn::If": Array [ + "testCondition", + true, + false, + ], + }, + }, "Resources": Object { - "TestCustomResourcesConsoleConfigB02EE6D7": Object { + "DLTCustomResourcesAnonymousMetricE30E46B4": Object { + "Condition": "condition", + "DeletionPolicy": "Delete", + "Properties": Object { + "Region": Object { + "Ref": "AWS::Region", + }, + "Resource": "AnonymousMetric", + "ServiceToken": Object { + "Fn::GetAtt": Array [ + "TestCustomResourceInfraCustomResourceLambda92E822A7", + "Arn", + ], + }, + "SolutionId": "testId", + "UUID": "abc-123-def-456", + "VERSION": "testVersion", + "existingVPC": "false", + }, + "Type": "AWS::CloudFormation::CustomResource", + "UpdateReplacePolicy": "Delete", + }, + "DLTCustomResourcesConsoleConfig9F494EAB": Object { "DeletionPolicy": "Delete", "Properties": Object { "AwsExports": Object { @@ -11,12 +42,14 @@ Object { "", Array [ "const awsConfig = { - cw_dashboard: 'https://console.aws.amazon.com/cloudwatch/home?region=", + aws_iot_endpoint: 'testIoTEndpoint', + aws_iot_policy_name: 'testIoTPolicy', + cw_dashboard: 'https://console.aws.amazon.com/cloudwatch/home?region=", Object { "Ref": "AWS::Region", }, "#dashboards:name=', - ecs_dashboard: 'https://", + ecs_dashboard: 'https://", Object { "Ref": "AWS::Region", }, @@ -29,61 +62,340 @@ Object { "Ref": "AWS::StackName", }, "/tasks', - aws_project_region: '", + aws_project_region: '", Object { "Ref": "AWS::Region", }, "', - aws_cognito_region: '", + aws_cognito_region: '", Object { "Ref": "AWS::Region", }, "', - aws_cognito_identity_pool_id: 'testIdentityPool', - aws_user_pools_id: 'testUserPool', - aws_user_pools_web_client_id: 'testUserPoolClient', - oauth: {}, - aws_cloud_logic_custom: [ - { - name: 'dlts', - endpoint: 'http://testEndpointUrl.com', - region: '", + aws_cognito_identity_pool_id: 'testIdentityPool', + aws_user_pools_id: 'testUserPool', + aws_user_pools_web_client_id: 'testUserPoolClient', + oauth: {}, + aws_cloud_logic_custom: [ + { + name: 'dlts', + endpoint: 'http://testEndpointUrl.com', + region: '", Object { "Ref": "AWS::Region", }, "' - } - ], - aws_user_files_s3_bucket: 'testScenariosBucket', - aws_user_files_s3_bucket_region: '", + } + ], + aws_user_files_s3_bucket: 'testscenariobucket', + aws_user_files_s3_bucket_region: '", Object { "Ref": "AWS::Region", }, - "' - }", + "', + }", ], ], }, - "DestBucket": "testConsoleBucket", + "DestBucket": "testconsolebucket", "Resource": "ConfigFile", - "ServiceToken": "testcustomlambda", + "ServiceToken": Object { + "Fn::GetAtt": Array [ + "TestCustomResourceInfraCustomResourceLambda92E822A7", + "Arn", + ], + }, }, - "Type": "Custom::CopyConfigFiles", + "Type": "AWS::CloudFormation::CustomResource", "UpdateReplacePolicy": "Delete", }, - "TestCustomResourcesCopyConsoleFilesA46BE1B8": Object { + "DLTCustomResourcesCopyConsoleFiles2EBD447E": Object { "DeletionPolicy": "Delete", "Properties": Object { - "DestBucket": "testConsoleBucket", + "DestBucket": "testconsolebucket", "ManifestFile": "console-manifest.json", "Resource": "CopyAssets", - "ServiceToken": "testcustomlambda", - "SrcBucket": "testCodeBucket", + "ServiceToken": Object { + "Fn::GetAtt": Array [ + "TestCustomResourceInfraCustomResourceLambda92E822A7", + "Arn", + ], + }, + "SrcBucket": "testcodebucket", "SrcPath": "testCodePrefix//console", }, - "Type": "Custom::CopyConsoleFiles", + "Type": "AWS::CloudFormation::CustomResource", + "UpdateReplacePolicy": "Delete", + }, + "DLTCustomResourcesTestingResourcesConfig0BCA657F": Object { + "DeletionPolicy": "Delete", + "Properties": Object { + "Resource": "TestingResourcesConfigFile", + "ServiceToken": Object { + "Fn::GetAtt": Array [ + "TestCustomResourceInfraCustomResourceLambda92E822A7", + "Arn", + ], + }, + "TestingResourcesConfig": Object { + "ecsCloudWatchLogGroup": "testCloudWatchLogGroup", + "region": Object { + "Ref": "AWS::Region", + }, + "subnetA": "subnet-123", + "subnetB": "subnet-abc", + "taskCluster": "testTaskCluster", + "taskDefinition": "task:def:arn:123", + "taskImage": Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Ref": "AWS::StackName", + }, + "-load-tester", + ], + ], + }, + "taskSecurityGroup": "sg-test123", + }, + "Uuid": "abc-123-def-456", + }, + "Type": "AWS::CloudFormation::CustomResource", "UpdateReplacePolicy": "Delete", }, + "TestCustomResourceInfraCustomResourceLambda92E822A7": Object { + "DependsOn": Array [ + "TestCustomResourceInfraCustomResourceLambdaRoleDefaultPolicy46B2072F", + "TestCustomResourceInfraCustomResourceLambdaRole03671AE8", + ], + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W58", + "reason": "CloudWatchLogsPolicy covers a permission to write CloudWatch logs.", + }, + Object { + "id": "W89", + "reason": "VPC not needed for lambda", + }, + Object { + "id": "W92", + "reason": "Does not run concurrent executions", + }, + ], + }, + }, + "Properties": Object { + "Code": Object { + "S3Bucket": Object { + "Ref": "testSourceCodeBucketC577B176", + }, + "S3Key": "test/source/prefix/main-custom-resource.zip", + }, + "Description": "CFN Lambda backed custom resource to deploy assets to s3", + "Environment": Object { + "Variables": Object { + "DDB_TABLE": "scenarioTestTable", + "MAIN_REGION": "test-region-1", + "METRIC_URL": "http://testurl.com", + "S3_BUCKET": "scenariotestbucket", + "SOLUTION_ID": "S0XXX", + "VERSION": "testVersion", + }, + }, + "Handler": "index.handler", + "Role": Object { + "Fn::GetAtt": Array [ + "TestCustomResourceInfraCustomResourceLambdaRole03671AE8", + "Arn", + ], + }, + "Runtime": "nodejs14.x", + "Timeout": 120, + }, + "Type": "AWS::Lambda::Function", + }, + "TestCustomResourceInfraCustomResourceLambdaRole03671AE8": Object { + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W11", + "reason": "iot:DescribeEndpoint and iot:DetachPrincipalPolicy cannot specify the resource.", + }, + ], + }, + }, + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": Array [ + Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "s3:GetObject", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Fn::GetAtt": Array [ + "testSourceCodeBucketC577B176", + "Arn", + ], + }, + "/*", + ], + ], + }, + }, + Object { + "Action": Array [ + "s3:PutObject", + "s3:DeleteObject", + ], + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":s3:::scenariotestbucket/*", + ], + ], + }, + }, + Object { + "Action": Array [ + "dynamodb:PutItem", + "dynamodb:DeleteItem", + ], + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":dynamodb:test-region-1:", + Object { + "Ref": "AWS::AccountId", + }, + ":table/scenarioTestTable", + ], + ], + }, + }, + Object { + "Action": Array [ + "iot:DescribeEndpoint", + "iot:DetachPrincipalPolicy", + ], + "Effect": "Allow", + "Resource": "*", + }, + Object { + "Action": "iot:ListTargetsForPolicy", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":policy/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "CustomResourcePolicy", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "TestCustomResourceInfraCustomResourceLambdaRoleDefaultPolicy46B2072F": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "s3:PutObject", + "Effect": "Allow", + "Resource": Array [ + "test:console:bucket:arn", + "test:console:bucket:arn/*", + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "TestCustomResourceInfraCustomResourceLambdaRoleDefaultPolicy46B2072F", + "Roles": Array [ + Object { + "Ref": "TestCustomResourceInfraCustomResourceLambdaRole03671AE8", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "TestPolicyCC05E598": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "cloudwatch:Get*", + "Effect": "Allow", + "Resource": "*", + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "TestPolicyCC05E598", + "Roles": Array [ + Object { + "Ref": "TestCustomResourceInfraCustomResourceLambdaRole03671AE8", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "testSourceCodeBucketC577B176": Object { + "DeletionPolicy": "Retain", + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + }, }, } `; diff --git a/source/infrastructure/test/__snapshots__/distributed-load-testing-on-aws-regional.test.ts.snap b/source/infrastructure/test/__snapshots__/distributed-load-testing-on-aws-regional.test.ts.snap new file mode 100644 index 0000000..e56c55e --- /dev/null +++ b/source/infrastructure/test/__snapshots__/distributed-load-testing-on-aws-regional.test.ts.snap @@ -0,0 +1,1748 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Distributed Load Testing Regional stack test 1`] = ` +Object { + "Conditions": Object { + "BoolExistingVPC": Object { + "Fn::Not": Array [ + Object { + "Fn::Equals": Array [ + Object { + "Ref": "ExistingVPCId", + }, + "", + ], + }, + ], + }, + "CreateFargateVPCResources": Object { + "Fn::Equals": Array [ + Object { + "Ref": "ExistingVPCId", + }, + "", + ], + }, + "SendAnonymousUsage": Object { + "Fn::Equals": Array [ + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SendAnonymousUsage", + ], + }, + "Yes", + ], + }, + }, + "Description": "Distributed Load Testing on AWS regional deployment.", + "Mappings": Object { + "Solution": Object { + "Config": Object { + "APIServicesLambdaRoleName": "API_SERVICES_ROLE", + "CodeVersion": "testversion", + "ContainerImage": "testRegistry/distributed-load-testing-on-aws-load-tester:testTag", + "KeyPrefix": "distributed-load-testing-on-aws/testversion", + "MainStackRegion": "MAIN_STACK_REGION", + "ResultsParserRoleName": "RESULTS_PARSER_ROLE", + "S3Bucket": "testbucket", + "ScenariosS3Bucket": "SCENARIOS_BUCKET", + "ScenariosTable": "SCENARIOS_DDB_TABLE", + "SendAnonymousUsage": "Yes", + "SolutionId": "testId", + "TaskCancelerRoleName": "TASK_CANCELER_ROLE", + "TaskRunnerRoleName": "TASK_RUNNER_ROLE", + "TaskStatusCheckerRoleName": "TASK_STATUS_ROLE", + "URL": "http://testurl.com", + "Uuid": "STACK_UUID", + "stackType": "regional", + }, + }, + }, + "Outputs": Object { + "ECSCloudWatchLogGroup": Object { + "Description": "The CloudWatch log group for ECS", + "Value": Object { + "Ref": "DLTRegionalFargateDLTCloudWatchLogsGroupAE1278C6", + }, + }, + "SubnetA": Object { + "Description": "Subnet A used by the Fargate tasks", + "Value": Object { + "Fn::If": Array [ + "CreateFargateVPCResources", + Object { + "Ref": "DLTRegionalVpcDLTSubnetACEFDB0F7", + }, + Object { + "Ref": "ExistingSubnetA", + }, + ], + }, + }, + "SubnetB": Object { + "Description": "Subnet B used by the Fargate tasks", + "Value": Object { + "Fn::If": Array [ + "CreateFargateVPCResources", + Object { + "Ref": "DLTRegionalVpcDLTSubnetB02222C79", + }, + Object { + "Ref": "ExistingSubnetB", + }, + ], + }, + }, + "TaskCluster": Object { + "Description": "Fargate task cluster", + "Value": Object { + "Ref": "DLTRegionalFargateDLTEcsCluster1D89D366", + }, + }, + "TaskDefinition": Object { + "Description": "The Fargate task definition", + "Value": Object { + "Ref": "DLTRegionalFargateDLTTaskDefinitionB27F0AFD", + }, + }, + "TaskSecurityGroup": Object { + "Description": "Security Group used by the Fargate taks", + "Value": Object { + "Ref": "DLTRegionalFargateDLTEcsSecurityGroup76A38D53", + }, + }, + }, + "Parameters": Object { + "EgressCidr": Object { + "AllowedPattern": "(?:^$|(\\\\d{1,3})\\\\.(\\\\d{1,3})\\\\.(\\\\d{1,3})\\\\.(\\\\d{1,3})/(\\\\d{1,2}))", + "ConstraintDescription": "The Egress CIDR block must be a valid IP CIDR range of the form x.x.x.x/x.", + "Default": "0.0.0.0/0", + "Description": "CIDR Block to restrict the Fargate container outbound access", + "MaxLength": 18, + "MinLength": 9, + "Type": "String", + }, + "ExistingSubnetA": Object { + "AllowedPattern": "(?:^$|^subnet-[a-zA-Z0-9-]+)", + "Description": "First existing subnet", + "Type": "String", + }, + "ExistingSubnetB": Object { + "AllowedPattern": "(?:^$|^subnet-[a-zA-Z0-9-]+)", + "Description": "Second existing subnet", + "Type": "String", + }, + "ExistingVPCId": Object { + "AllowedPattern": "(?:^$|^vpc-[a-zA-Z0-9-]+)", + "Description": "Existing VPC ID", + "Type": "String", + }, + "SubnetACidrBlock": Object { + "AllowedPattern": "(?:^$|(\\\\d{1,3})\\\\.(\\\\d{1,3})\\\\.(\\\\d{1,3})\\\\.(\\\\d{1,3})/(\\\\d{1,2}))", + "ConstraintDescription": "The subnet CIDR block must be a valid IP CIDR range of the form x.x.x.x/x.", + "Default": "192.168.0.0/20", + "Description": "CIDR block for subnet A of the AWS Fargate VPC", + "MaxLength": 18, + "MinLength": 9, + "Type": "String", + }, + "SubnetBCidrBlock": Object { + "AllowedPattern": "(?:^$|(\\\\d{1,3})\\\\.(\\\\d{1,3})\\\\.(\\\\d{1,3})\\\\.(\\\\d{1,3})/(\\\\d{1,2}))", + "ConstraintDescription": "The subnet CIDR block must be a valid IP CIDR range of the form x.x.x.x/x.", + "Default": "192.168.16.0/20", + "Description": "CIDR block for subnet B of the AWS Fargate VPC", + "Type": "String", + }, + "VpcCidrBlock": Object { + "AllowedPattern": "(?:^$|(\\\\d{1,3})\\\\.(\\\\d{1,3})\\\\.(\\\\d{1,3})\\\\.(\\\\d{1,3})/(\\\\d{1,2}))", + "ConstraintDescription": "The VPC CIDR block must be a valid IP CIDR range of the form x.x.x.x/x.", + "Default": "192.168.0.0/16", + "Description": "CIDR block of the new VPC where AWS Fargate will be placed", + "MaxLength": 18, + "MinLength": 9, + "Type": "String", + }, + }, + "Resources": Object { + "CommonResourcesCloudWatchLogsPolicyB8257A4C": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": Array [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + ], + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":logs:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":log-group:/aws/lambda/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "CommonResourcesCloudWatchLogsPolicyB8257A4C", + "Roles": Array [ + Object { + "Ref": "DLTRegionalFargateDLTTaskExecutionRole22C06EF4", + }, + Object { + "Ref": "RegionalCustomResourceInfraCustomResourceLambdaRoleEBA8B208", + }, + Object { + "Ref": "RealTimeDatarealTimeDataPublisherRoleA8976D01", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "DLTCustomResourcesAnonymousMetricE30E46B4": Object { + "Condition": "SendAnonymousUsage", + "DeletionPolicy": "Delete", + "Properties": Object { + "Region": Object { + "Ref": "AWS::Region", + }, + "Resource": "AnonymousMetric", + "ServiceToken": Object { + "Fn::GetAtt": Array [ + "RegionalCustomResourceInfraCustomResourceLambda86A7E873", + "Arn", + ], + }, + "SolutionId": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + "UUID": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "Uuid", + ], + }, + "VERSION": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "CodeVersion", + ], + }, + "existingVPC": Object { + "Fn::If": Array [ + "BoolExistingVPC", + true, + false, + ], + }, + }, + "Type": "AWS::CloudFormation::CustomResource", + "UpdateReplacePolicy": "Delete", + }, + "DLTCustomResourcesGetIotEndpoint700ABCC8": Object { + "DeletionPolicy": "Delete", + "Properties": Object { + "Resource": "GetIotEndpoint", + "ServiceToken": Object { + "Fn::GetAtt": Array [ + "RegionalCustomResourceInfraCustomResourceLambda86A7E873", + "Arn", + ], + }, + }, + "Type": "AWS::CloudFormation::CustomResource", + "UpdateReplacePolicy": "Delete", + }, + "DLTCustomResourcesTestingResourcesConfig0BCA657F": Object { + "DeletionPolicy": "Delete", + "Properties": Object { + "Resource": "TestingResourcesConfigFile", + "ServiceToken": Object { + "Fn::GetAtt": Array [ + "RegionalCustomResourceInfraCustomResourceLambda86A7E873", + "Arn", + ], + }, + "TestingResourcesConfig": Object { + "ecsCloudWatchLogGroup": Object { + "Ref": "DLTRegionalFargateDLTCloudWatchLogsGroupAE1278C6", + }, + "region": Object { + "Ref": "AWS::Region", + }, + "subnetA": Object { + "Fn::If": Array [ + "CreateFargateVPCResources", + Object { + "Ref": "DLTRegionalVpcDLTSubnetACEFDB0F7", + }, + Object { + "Ref": "ExistingSubnetA", + }, + ], + }, + "subnetB": Object { + "Fn::If": Array [ + "CreateFargateVPCResources", + Object { + "Ref": "DLTRegionalVpcDLTSubnetB02222C79", + }, + Object { + "Ref": "ExistingSubnetB", + }, + ], + }, + "taskCluster": Object { + "Ref": "DLTRegionalFargateDLTEcsCluster1D89D366", + }, + "taskDefinition": Object { + "Ref": "DLTRegionalFargateDLTTaskDefinitionB27F0AFD", + }, + "taskImage": Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Ref": "AWS::StackName", + }, + "-load-tester", + ], + ], + }, + "taskSecurityGroup": Object { + "Ref": "DLTRegionalFargateDLTEcsSecurityGroup76A38D53", + }, + }, + "Uuid": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "Uuid", + ], + }, + }, + "Type": "AWS::CloudFormation::CustomResource", + "UpdateReplacePolicy": "Delete", + }, + "DLTRegionalFargateDLTCloudWatchLogsGroupAE1278C6": Object { + "DeletionPolicy": "Retain", + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W84", + "reason": "KMS encryption unnecessary for log group", + }, + ], + }, + }, + "Properties": Object { + "RetentionInDays": 365, + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + }, + "Type": "AWS::Logs::LogGroup", + "UpdateReplacePolicy": "Retain", + }, + "DLTRegionalFargateDLTCloudWatchLogsGroupECSLogSubscriptionFilter018E070A": Object { + "Properties": Object { + "DestinationArn": Object { + "Fn::GetAtt": Array [ + "RealTimeDataRealTimeDataPublisher7E8F8F6C", + "Arn", + ], + }, + "FilterPattern": "\\"INFO: Current:\\" \\"live=true\\"", + "LogGroupName": Object { + "Ref": "DLTRegionalFargateDLTCloudWatchLogsGroupAE1278C6", + }, + }, + "Type": "AWS::Logs::SubscriptionFilter", + }, + "DLTRegionalFargateDLTCloudWatchLogsGroupECSLogSubscriptionFilterCanInvokeLambda20B8ED69": Object { + "Properties": Object { + "Action": "lambda:InvokeFunction", + "FunctionName": Object { + "Fn::GetAtt": Array [ + "RealTimeDataRealTimeDataPublisher7E8F8F6C", + "Arn", + ], + }, + "Principal": "logs.amazonaws.com", + "SourceArn": Object { + "Fn::GetAtt": Array [ + "DLTRegionalFargateDLTCloudWatchLogsGroupAE1278C6", + "Arn", + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "DLTRegionalFargateDLTEcsCluster1D89D366": Object { + "Properties": Object { + "ClusterName": Object { + "Ref": "AWS::StackName", + }, + "ClusterSettings": Array [ + Object { + "Name": "containerInsights", + "Value": "enabled", + }, + ], + "Tags": Array [ + Object { + "Key": "CloudFormation Stack", + "Value": Object { + "Ref": "AWS::StackName", + }, + }, + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + }, + "Type": "AWS::ECS::Cluster", + }, + "DLTRegionalFargateDLTEcsSecurityGroup76A38D53": Object { + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W40", + "reason": "IpProtocol set to -1 (any) as ports are not known prior to running tests", + }, + ], + }, + }, + "Properties": Object { + "GroupDescription": "DLTS Tasks Security Group", + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + "VpcId": Object { + "Fn::If": Array [ + "CreateFargateVPCResources", + Object { + "Ref": "DLTRegionalVpcDLTFargateVpc27038C26", + }, + Object { + "Ref": "ExistingVPCId", + }, + ], + }, + }, + "Type": "AWS::EC2::SecurityGroup", + }, + "DLTRegionalFargateDLTSecGroupEgress64416366": Object { + "Properties": Object { + "CidrIp": Object { + "Ref": "EgressCidr", + }, + "Description": "Allow tasks to call out to external resources", + "GroupId": Object { + "Ref": "DLTRegionalFargateDLTEcsSecurityGroup76A38D53", + }, + "IpProtocol": "-1", + }, + "Type": "AWS::EC2::SecurityGroupEgress", + }, + "DLTRegionalFargateDLTSecGroupIngress5C8E0E6E": Object { + "Properties": Object { + "Description": "Allow tasks to communicate", + "FromPort": 50000, + "GroupId": Object { + "Ref": "DLTRegionalFargateDLTEcsSecurityGroup76A38D53", + }, + "IpProtocol": "tcp", + "SourceSecurityGroupId": Object { + "Ref": "DLTRegionalFargateDLTEcsSecurityGroup76A38D53", + }, + "ToPort": 50000, + }, + "Type": "AWS::EC2::SecurityGroupIngress", + }, + "DLTRegionalFargateDLTTaskDefinitionB27F0AFD": Object { + "Properties": Object { + "ContainerDefinitions": Array [ + Object { + "Essential": true, + "Image": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "ContainerImage", + ], + }, + "LogConfiguration": Object { + "LogDriver": "awslogs", + "Options": Object { + "awslogs-group": Object { + "Ref": "DLTRegionalFargateDLTCloudWatchLogsGroupAE1278C6", + }, + "awslogs-region": Object { + "Ref": "AWS::Region", + }, + "awslogs-stream-prefix": "load-testing", + }, + }, + "Memory": 4096, + "Name": Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Ref": "AWS::StackName", + }, + "-load-tester", + ], + ], + }, + }, + ], + "Cpu": "2048", + "ExecutionRoleArn": Object { + "Fn::GetAtt": Array [ + "DLTRegionalFargateDLTTaskExecutionRole22C06EF4", + "Arn", + ], + }, + "Memory": "4096", + "NetworkMode": "awsvpc", + "RequiresCompatibilities": Array [ + "FARGATE", + ], + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + "TaskRoleArn": Object { + "Fn::GetAtt": Array [ + "DLTRegionalFargateDLTTaskExecutionRole22C06EF4", + "Arn", + ], + }, + }, + "Type": "AWS::ECS::TaskDefinition", + }, + "DLTRegionalFargateDLTTaskExecutionRole22C06EF4": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "ecs-tasks.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", + ], + ], + }, + ], + "Policies": Array [ + Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": Array [ + "s3:HeadObject", + "s3:PutObject", + "s3:GetObject", + "s3:ListBucket", + ], + "Effect": "Allow", + "Resource": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":s3:::", + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "ScenariosS3Bucket", + ], + }, + ], + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":s3:::", + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "ScenariosS3Bucket", + ], + }, + "/*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "ScenariosS3Policy", + }, + ], + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "DLTRegionalVpcDLTFargateIGE3847EB3": Object { + "Condition": "CreateFargateVPCResources", + "Properties": Object { + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + }, + "Type": "AWS::EC2::InternetGateway", + }, + "DLTRegionalVpcDLTFargateRT243A2CBC": Object { + "Condition": "CreateFargateVPCResources", + "Properties": Object { + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + "VpcId": Object { + "Ref": "DLTRegionalVpcDLTFargateVpc27038C26", + }, + }, + "Type": "AWS::EC2::RouteTable", + }, + "DLTRegionalVpcDLTFargateVpc27038C26": Object { + "Condition": "CreateFargateVPCResources", + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W60", + "reason": "This VPC is used for the test runner Fargate tasks only, it does not require VPC flow logs.", + }, + ], + }, + }, + "Properties": Object { + "CidrBlock": Object { + "Ref": "VpcCidrBlock", + }, + "EnableDnsHostnames": true, + "EnableDnsSupport": true, + "Tags": Array [ + Object { + "Key": "Name", + "Value": Object { + "Ref": "AWS::StackName", + }, + }, + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + }, + "Type": "AWS::EC2::VPC", + }, + "DLTRegionalVpcDLTGatewayattachmentA7E0AEA0": Object { + "Condition": "CreateFargateVPCResources", + "Properties": Object { + "InternetGatewayId": Object { + "Ref": "DLTRegionalVpcDLTFargateIGE3847EB3", + }, + "VpcId": Object { + "Ref": "DLTRegionalVpcDLTFargateVpc27038C26", + }, + }, + "Type": "AWS::EC2::VPCGatewayAttachment", + }, + "DLTRegionalVpcDLTRoute2F53B3BB": Object { + "Condition": "CreateFargateVPCResources", + "DependsOn": Array [ + "DLTRegionalVpcDLTGatewayattachmentA7E0AEA0", + ], + "Properties": Object { + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": Object { + "Ref": "DLTRegionalVpcDLTFargateIGE3847EB3", + }, + "RouteTableId": Object { + "Ref": "DLTRegionalVpcDLTFargateRT243A2CBC", + }, + }, + "Type": "AWS::EC2::Route", + }, + "DLTRegionalVpcDLTRouteTableAssociationAF1EA6E2D": Object { + "Condition": "CreateFargateVPCResources", + "Properties": Object { + "RouteTableId": Object { + "Ref": "DLTRegionalVpcDLTFargateRT243A2CBC", + }, + "SubnetId": Object { + "Ref": "DLTRegionalVpcDLTSubnetACEFDB0F7", + }, + }, + "Type": "AWS::EC2::SubnetRouteTableAssociation", + }, + "DLTRegionalVpcDLTRouteTableAssociationB661C90B5": Object { + "Condition": "CreateFargateVPCResources", + "Properties": Object { + "RouteTableId": Object { + "Ref": "DLTRegionalVpcDLTFargateRT243A2CBC", + }, + "SubnetId": Object { + "Ref": "DLTRegionalVpcDLTSubnetB02222C79", + }, + }, + "Type": "AWS::EC2::SubnetRouteTableAssociation", + }, + "DLTRegionalVpcDLTSubnetACEFDB0F7": Object { + "Condition": "CreateFargateVPCResources", + "Properties": Object { + "AvailabilityZone": Object { + "Fn::Select": Array [ + 0, + Object { + "Fn::GetAZs": "", + }, + ], + }, + "CidrBlock": Object { + "Ref": "SubnetACidrBlock", + }, + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + "VpcId": Object { + "Ref": "DLTRegionalVpcDLTFargateVpc27038C26", + }, + }, + "Type": "AWS::EC2::Subnet", + }, + "DLTRegionalVpcDLTSubnetB02222C79": Object { + "Condition": "CreateFargateVPCResources", + "Properties": Object { + "AvailabilityZone": Object { + "Fn::Select": Array [ + 1, + Object { + "Fn::GetAZs": "", + }, + ], + }, + "CidrBlock": Object { + "Ref": "SubnetBCidrBlock", + }, + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + "VpcId": Object { + "Ref": "DLTRegionalVpcDLTFargateVpc27038C26", + }, + }, + "Type": "AWS::EC2::Subnet", + }, + "RealTimeDataRealTimeDataPublisher7E8F8F6C": Object { + "DependsOn": Array [ + "RealTimeDatarealTimeDataPublisherRoleA8976D01", + ], + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W58", + "reason": "CloudWatchLogsPolicy covers a permission to write CloudWatch logs.", + }, + Object { + "id": "W89", + "reason": "This Lambda function does not require a VPC", + }, + Object { + "id": "W92", + "reason": "Does not run concurrent executions", + }, + ], + }, + }, + "Properties": Object { + "Code": Object { + "S3Bucket": Object { + "Fn::Join": Array [ + "-", + Array [ + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "S3Bucket", + ], + }, + Object { + "Ref": "AWS::Region", + }, + ], + ], + }, + "S3Key": Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "KeyPrefix", + ], + }, + "/real-time-data-publisher.zip", + ], + ], + }, + }, + "Description": "Real time data publisher", + "Environment": Object { + "Variables": Object { + "IOT_ENDPOINT": Object { + "Fn::GetAtt": Array [ + "DLTCustomResourcesGetIotEndpoint700ABCC8", + "IOT_ENDPOINT", + ], + }, + "MAIN_REGION": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "MainStackRegion", + ], + }, + "SOLUTION_ID": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + "VERSION": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "CodeVersion", + ], + }, + }, + }, + "Handler": "index.handler", + "Role": Object { + "Fn::GetAtt": Array [ + "RealTimeDatarealTimeDataPublisherRoleA8976D01", + "Arn", + ], + }, + "Runtime": "nodejs14.x", + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + "Timeout": 180, + }, + "Type": "AWS::Lambda::Function", + }, + "RealTimeDatarealTimeDataPublisherRoleA8976D01": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": Array [ + Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "iot:Publish", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:", + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "MainStackRegion", + ], + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":topic/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "IoTPolicy", + }, + ], + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "RegionalCustomResourceInfraCustomResourceLambda86A7E873": Object { + "DependsOn": Array [ + "RegionalCustomResourceInfraCustomResourceLambdaRoleEBA8B208", + ], + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W58", + "reason": "CloudWatchLogsPolicy covers a permission to write CloudWatch logs.", + }, + Object { + "id": "W89", + "reason": "VPC not needed for lambda", + }, + Object { + "id": "W92", + "reason": "Does not run concurrent executions", + }, + ], + }, + }, + "Properties": Object { + "Code": Object { + "S3Bucket": Object { + "Fn::Join": Array [ + "-", + Array [ + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "S3Bucket", + ], + }, + Object { + "Ref": "AWS::Region", + }, + ], + ], + }, + "S3Key": Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "KeyPrefix", + ], + }, + "/regional-custom-resource.zip", + ], + ], + }, + }, + "Description": "CFN Lambda backed custom resource to deploy assets to s3", + "Environment": Object { + "Variables": Object { + "DDB_TABLE": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "ScenariosTable", + ], + }, + "MAIN_REGION": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "MainStackRegion", + ], + }, + "METRIC_URL": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "URL", + ], + }, + "S3_BUCKET": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "ScenariosS3Bucket", + ], + }, + "SOLUTION_ID": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + "VERSION": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "CodeVersion", + ], + }, + }, + }, + "Handler": "index.handler", + "Role": Object { + "Fn::GetAtt": Array [ + "RegionalCustomResourceInfraCustomResourceLambdaRoleEBA8B208", + "Arn", + ], + }, + "Runtime": "nodejs14.x", + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + "Timeout": 120, + }, + "Type": "AWS::Lambda::Function", + }, + "RegionalCustomResourceInfraCustomResourceLambdaRoleEBA8B208": Object { + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W11", + "reason": "iot:DescribeEndpoint and iot:DetachPrincipalPolicy cannot specify the resource.", + }, + ], + }, + }, + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": Array [ + Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "s3:GetObject", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":s3:::", + Object { + "Fn::Join": Array [ + "-", + Array [ + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "S3Bucket", + ], + }, + Object { + "Ref": "AWS::Region", + }, + ], + ], + }, + "/*", + ], + ], + }, + }, + Object { + "Action": Array [ + "s3:PutObject", + "s3:DeleteObject", + ], + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":s3:::", + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "ScenariosS3Bucket", + ], + }, + "/*", + ], + ], + }, + }, + Object { + "Action": Array [ + "dynamodb:PutItem", + "dynamodb:DeleteItem", + ], + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":dynamodb:", + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "MainStackRegion", + ], + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":table/", + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "ScenariosTable", + ], + }, + ], + ], + }, + }, + Object { + "Action": Array [ + "iot:DescribeEndpoint", + "iot:DetachPrincipalPolicy", + ], + "Effect": "Allow", + "Resource": "*", + }, + Object { + "Action": "iot:ListTargetsForPolicy", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":policy/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "CustomResourcePolicy", + }, + ], + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "RegionalPermissionsForTaskLambdasECSCloudWatchDelMetrics67273BC1": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "logs:DeleteMetricFilter", + "Effect": "Allow", + "Resource": Object { + "Fn::GetAtt": Array [ + "DLTRegionalFargateDLTCloudWatchLogsGroupAE1278C6", + "Arn", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": Object { + "Fn::Join": Array [ + "", + Array [ + "ECSCloudWatchDelMetrics-", + Object { + "Ref": "AWS::StackName", + }, + "-", + Object { + "Ref": "AWS::Region", + }, + ], + ], + }, + "Roles": Array [ + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "APIServicesLambdaRoleName", + ], + }, + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "ResultsParserRoleName", + ], + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "RegionalPermissionsForTaskLambdasECSCloudWatchPutMetricsd8D7608DF": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "logs:PutMetricFilter", + "Effect": "Allow", + "Resource": Object { + "Fn::GetAtt": Array [ + "DLTRegionalFargateDLTCloudWatchLogsGroupAE1278C6", + "Arn", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": Object { + "Fn::Join": Array [ + "", + Array [ + "ECSCloudWatchPutMetrics-", + Object { + "Ref": "AWS::StackName", + }, + "-", + Object { + "Ref": "AWS::Region", + }, + ], + ], + }, + "Roles": Array [ + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "TaskRunnerRoleName", + ], + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "RegionalPermissionsForTaskLambdasECSDescribePolicyAE42B7AD": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "ecs:DescribeTasks", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":ecs:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":task/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": Object { + "Fn::Join": Array [ + "", + Array [ + "ECSDescribePolicy", + Object { + "Ref": "AWS::Region", + }, + ], + ], + }, + "Roles": Array [ + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "TaskStatusCheckerRoleName", + ], + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "RegionalPermissionsForTaskLambdasECSStopPolicy32D8C7AF": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "ecs:StopTask", + "Effect": "Allow", + "Resource": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":ecs:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":task/*", + ], + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":ecs:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":task-definition/*:*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": Object { + "Fn::Join": Array [ + "", + Array [ + "ECSStopPolicy-", + Object { + "Ref": "AWS::StackName", + }, + "-", + Object { + "Ref": "AWS::Region", + }, + ], + ], + }, + "Roles": Array [ + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "TaskCancelerRoleName", + ], + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "RegionalPermissionsForTaskLambdasRegionalECRPerms80299EED": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": Array [ + "ecs:RunTask", + "ecs:DescribeTasks", + ], + "Effect": "Allow", + "Resource": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":ecs:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":task/*", + ], + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":ecs:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":task-definition/*:*", + ], + ], + }, + ], + }, + Object { + "Action": "iam:PassRole", + "Effect": "Allow", + "Resource": Object { + "Fn::GetAtt": Array [ + "DLTRegionalFargateDLTTaskExecutionRole22C06EF4", + "Arn", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": Object { + "Fn::Join": Array [ + "", + Array [ + "RegionalECRPerms-", + Object { + "Ref": "AWS::StackName", + }, + "-", + Object { + "Ref": "AWS::Region", + }, + ], + ], + }, + "Roles": Array [ + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "TaskRunnerRoleName", + ], + }, + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "APIServicesLambdaRoleName", + ], + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + }, + "Rules": Object { + "ExistingVPCRule": Object { + "Assertions": Array [ + Object { + "Assert": Object { + "Fn::Not": Array [ + Object { + "Fn::Equals": Array [ + Object { + "Ref": "ExistingSubnetA", + }, + "", + ], + }, + ], + }, + "AssertDescription": "If an existing VPC Id is provided, 2 subnet ids need to be provided as well. You neglected to enter the first subnet id", + }, + Object { + "Assert": Object { + "Fn::Not": Array [ + Object { + "Fn::Equals": Array [ + Object { + "Ref": "ExistingSubnetB", + }, + "", + ], + }, + ], + }, + "AssertDescription": "If an existing VPC Id is provided, 2 subnet ids need to be provided as well. You neglected to enter the second subnet id", + }, + ], + "RuleCondition": Object { + "Fn::Not": Array [ + Object { + "Fn::Equals": Array [ + Object { + "Ref": "ExistingVPCId", + }, + "", + ], + }, + ], + }, + }, + }, +} +`; diff --git a/source/infrastructure/test/__snapshots__/distributed-load-testing-on-aws-stack.test.ts.snap b/source/infrastructure/test/__snapshots__/distributed-load-testing-on-aws-stack.test.ts.snap index 086b5fe..6286df5 100644 --- a/source/infrastructure/test/__snapshots__/distributed-load-testing-on-aws-stack.test.ts.snap +++ b/source/infrastructure/test/__snapshots__/distributed-load-testing-on-aws-stack.test.ts.snap @@ -37,15 +37,109 @@ Object { ], }, }, + "Description": "Distributed Load Testing on AWS is a reference architecture to perform application load testing at scale.", "Mappings": Object { + "ServiceprincipalMap": Object { + "af-south-1": Object { + "states": "states.af-south-1.amazonaws.com", + }, + "ap-east-1": Object { + "states": "states.ap-east-1.amazonaws.com", + }, + "ap-northeast-1": Object { + "states": "states.ap-northeast-1.amazonaws.com", + }, + "ap-northeast-2": Object { + "states": "states.ap-northeast-2.amazonaws.com", + }, + "ap-northeast-3": Object { + "states": "states.ap-northeast-3.amazonaws.com", + }, + "ap-south-1": Object { + "states": "states.ap-south-1.amazonaws.com", + }, + "ap-southeast-1": Object { + "states": "states.ap-southeast-1.amazonaws.com", + }, + "ap-southeast-2": Object { + "states": "states.ap-southeast-2.amazonaws.com", + }, + "ap-southeast-3": Object { + "states": "states.ap-southeast-3.amazonaws.com", + }, + "ca-central-1": Object { + "states": "states.ca-central-1.amazonaws.com", + }, + "cn-north-1": Object { + "states": "states.cn-north-1.amazonaws.com", + }, + "cn-northwest-1": Object { + "states": "states.cn-northwest-1.amazonaws.com", + }, + "eu-central-1": Object { + "states": "states.eu-central-1.amazonaws.com", + }, + "eu-north-1": Object { + "states": "states.eu-north-1.amazonaws.com", + }, + "eu-south-1": Object { + "states": "states.eu-south-1.amazonaws.com", + }, + "eu-south-2": Object { + "states": "states.eu-south-2.amazonaws.com", + }, + "eu-west-1": Object { + "states": "states.eu-west-1.amazonaws.com", + }, + "eu-west-2": Object { + "states": "states.eu-west-2.amazonaws.com", + }, + "eu-west-3": Object { + "states": "states.eu-west-3.amazonaws.com", + }, + "me-south-1": Object { + "states": "states.me-south-1.amazonaws.com", + }, + "sa-east-1": Object { + "states": "states.sa-east-1.amazonaws.com", + }, + "us-east-1": Object { + "states": "states.us-east-1.amazonaws.com", + }, + "us-east-2": Object { + "states": "states.us-east-2.amazonaws.com", + }, + "us-gov-east-1": Object { + "states": "states.us-gov-east-1.amazonaws.com", + }, + "us-gov-west-1": Object { + "states": "states.us-gov-west-1.amazonaws.com", + }, + "us-iso-east-1": Object { + "states": "states.amazonaws.com", + }, + "us-iso-west-1": Object { + "states": "states.amazonaws.com", + }, + "us-isob-east-1": Object { + "states": "states.amazonaws.com", + }, + "us-west-1": Object { + "states": "states.us-west-1.amazonaws.com", + }, + "us-west-2": Object { + "states": "states.us-west-2.amazonaws.com", + }, + }, "Solution": Object { "Config": Object { - "CodeVersion": "CODE_VERSION", - "KeyPrefix": "SOLUTION_NAME/CODE_VERSION", - "S3Bucket": "CODE_BUCKET", + "CodeVersion": "testversion", + "ContainerImage": "testRegistry/distributed-load-testing-on-aws-load-tester:testTag", + "KeyPrefix": "distributed-load-testing-on-aws/testversion", + "S3Bucket": "testbucket", "SendAnonymousUsage": "Yes", - "SolutionId": "SO0062", - "URL": "https://metrics.awssolutionsbuilder.com/generic", + "SolutionId": "testId", + "URL": "http://testurl.com", }, }, }, @@ -150,11 +244,37 @@ Object { ], }, }, + "RegionalCFTemplate": Object { + "Description": "S3 URL for regional CloudFormation template", + "Export": Object { + "Name": "RegionalCFTemplate", + }, + "Value": Object { + "Fn::Join": Array [ + "", + Array [ + "https://s3.", + Object { + "Ref": "AWS::Region", + }, + ".", + Object { + "Ref": "AWS::URLSuffix", + }, + "/", + Object { + "Ref": "DLTTestRunnerStorageDLTScenariosBucketA9290D21", + }, + "/regional-template/distributed-load-testing-on-aws-regional.template", + ], + ], + }, + }, "SolutionUUID": Object { "Description": "Solution UUID", "Value": Object { "Fn::GetAtt": Array [ - "DLTCommonResourcesUUID2FD025A2", + "DLTCustomResourcesCustomResourceUuidD1C03F15", "UUID", ], }, @@ -250,6 +370,18 @@ Object { ], }, "Name": "DLTApi", + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], }, "Type": "AWS::ApiGateway::RestApi", }, @@ -319,6 +451,18 @@ Object { "PolicyName": "apiLoggingPolicy", }, ], + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], }, "Type": "AWS::IAM::Role", }, @@ -336,6 +480,18 @@ Object { }, "Properties": Object { "RetentionInDays": 365, + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], }, "Type": "AWS::Logs::LogGroup", "UpdateReplacePolicy": "Retain", @@ -396,6 +552,18 @@ Object { ], }, ], + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], }, "Type": "AWS::IAM::Role", }, @@ -459,8 +627,8 @@ Object { "Description": "API microservices for creating, updating, listing and deleting test scenarios", "Environment": Object { "Variables": Object { - "ECS_LOG_GROUP": Object { - "Ref": "DLTEcsDLTCloudWatchLogsGroupFE9EC144", + "HISTORY_TABLE": Object { + "Ref": "DLTTestRunnerStorageDLTHistoryTable46D850CC", }, "METRIC_URL": Object { "Fn::FindInMap": Array [ @@ -489,6 +657,9 @@ Object { "SolutionId", ], }, + "STACK_ID": Object { + "Ref": "AWS::StackId", + }, "STATE_MACHINE_ARN": Object { "Ref": "DLTStepFunctionTaskRunnerStepFunctionsC295A535", }, @@ -498,12 +669,9 @@ Object { "Arn", ], }, - "TASK_CLUSTER": Object { - "Ref": "DLTEcsDLTEcsClusterBC5CE23B", - }, "UUID": Object { "Fn::GetAtt": Array [ - "DLTCommonResourcesUUID2FD025A2", + "DLTCustomResourcesCustomResourceUuidD1C03F15", "UUID", ], }, @@ -583,7 +751,7 @@ Object { "rules_to_suppress": Array [ Object { "id": "W11", - "reason": "ecs:ListTasks does not support resource level permissions", + "reason": "ecs:ListTasks and cloudformation:ListExports do not support resource level permissions", }, ], }, @@ -706,19 +874,39 @@ Object { ], }, }, + Object { + "Action": "cloudformation:ListExports", + "Effect": "Allow", + "Resource": "*", + }, ], "Version": "2012-10-17", }, "PolicyName": "DLTAPIServicesLambdaPolicy", }, ], + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], }, "Type": "AWS::IAM::Role", }, - "DLTApiDeployment098FF8886f7513fc05d0ec41147a211bea1fef76": Object { + "DLTApiDeployment098FF888fcb0a704d2b4426ea5c0c0559611635b": Object { "DependsOn": Array [ "DLTApiAPIAllRequestValidator02C9D47F", "DLTApiOPTIONS823B5F09", + "DLTApiregionsANY2B8B3A61", + "DLTApiregionsOPTIONSCB04B2B1", + "DLTApiregionsC4EF9783", "DLTApiscenariostestIdANY993028D3", "DLTApiscenariostestIdOPTIONS0B339CE6", "DLTApiscenariostestId4C170989", @@ -748,6 +936,9 @@ Object { "Type": "AWS::ApiGateway::Deployment", }, "DLTApiDeploymentStageprodC81F8DCB": Object { + "DependsOn": Array [ + "DLTApiAccount80CB63FF", + ], "Metadata": Object { "cfn_nag": Object { "rules_to_suppress": Array [ @@ -769,10 +960,11 @@ Object { "Format": "{\\"requestId\\":\\"$context.requestId\\",\\"ip\\":\\"$context.identity.sourceIp\\",\\"user\\":\\"$context.identity.user\\",\\"caller\\":\\"$context.identity.caller\\",\\"requestTime\\":\\"$context.requestTime\\",\\"httpMethod\\":\\"$context.httpMethod\\",\\"resourcePath\\":\\"$context.resourcePath\\",\\"status\\":\\"$context.status\\",\\"protocol\\":\\"$context.protocol\\",\\"responseLength\\":\\"$context.responseLength\\"}", }, "DeploymentId": Object { - "Ref": "DLTApiDeployment098FF8886f7513fc05d0ec41147a211bea1fef76", + "Ref": "DLTApiDeployment098FF888fcb0a704d2b4426ea5c0c0559611635b", }, "MethodSettings": Array [ Object { + "DataTraceEnabled": false, "HttpMethod": "*", "LoggingLevel": "INFO", "ResourcePath": "/*", @@ -782,6 +974,18 @@ Object { "Ref": "DLTApi0C903EB5", }, "StageName": "prod", + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], "TracingEnabled": true, }, "Type": "AWS::ApiGateway::Stage", @@ -948,6 +1152,118 @@ Object { }, "Type": "AWS::ApiGateway::Method", }, + "DLTApiregionsANY2B8B3A61": Object { + "Properties": Object { + "AuthorizationType": "AWS_IAM", + "HttpMethod": "ANY", + "Integration": Object { + "ContentHandling": "CONVERT_TO_TEXT", + "IntegrationHttpMethod": "POST", + "IntegrationResponses": Array [ + Object { + "StatusCode": "200", + }, + ], + "PassthroughBehavior": "WHEN_NO_MATCH", + "Type": "AWS_PROXY", + "Uri": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":apigateway:", + Object { + "Ref": "AWS::Region", + }, + ":lambda:path/2015-03-31/functions/", + Object { + "Fn::GetAtt": Array [ + "DLTApiDLTAPIServicesLambda9D76BA5C", + "Arn", + ], + }, + "/invocations", + ], + ], + }, + }, + "MethodResponses": Array [ + Object { + "ResponseModels": Object { + "application/json": "Empty", + }, + "StatusCode": "200", + }, + ], + "RequestValidatorId": Object { + "Ref": "DLTApiAPIAllRequestValidator02C9D47F", + }, + "ResourceId": Object { + "Ref": "DLTApiregionsC4EF9783", + }, + "RestApiId": Object { + "Ref": "DLTApi0C903EB5", + }, + }, + "Type": "AWS::ApiGateway::Method", + }, + "DLTApiregionsC4EF9783": Object { + "Properties": Object { + "ParentId": Object { + "Fn::GetAtt": Array [ + "DLTApi0C903EB5", + "RootResourceId", + ], + }, + "PathPart": "regions", + "RestApiId": Object { + "Ref": "DLTApi0C903EB5", + }, + }, + "Type": "AWS::ApiGateway::Resource", + }, + "DLTApiregionsOPTIONSCB04B2B1": Object { + "Properties": Object { + "AuthorizationType": "NONE", + "HttpMethod": "OPTIONS", + "Integration": Object { + "IntegrationResponses": Array [ + Object { + "ResponseParameters": Object { + "method.response.header.Access-Control-Allow-Headers": "'Authorization,Content-Type,X-Amz-Date,X-Amz-Security-Token,X-Api-Key'", + "method.response.header.Access-Control-Allow-Methods": "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'", + "method.response.header.Access-Control-Allow-Origin": "'*'", + }, + "StatusCode": "200", + }, + ], + "RequestTemplates": Object { + "application/json": "{ statusCode: 200 }", + }, + "Type": "MOCK", + }, + "MethodResponses": Array [ + Object { + "ResponseParameters": Object { + "method.response.header.Access-Control-Allow-Headers": true, + "method.response.header.Access-Control-Allow-Methods": true, + "method.response.header.Access-Control-Allow-Origin": true, + }, + "StatusCode": "200", + }, + ], + "ResourceId": Object { + "Ref": "DLTApiregionsC4EF9783", + }, + "RestApiId": Object { + "Ref": "DLTApi0C903EB5", + }, + }, + "Type": "AWS::ApiGateway::Method", + }, "DLTApiscenariosANYDEF83622": Object { "Properties": Object { "AuthorizationType": "AWS_IAM", @@ -1337,6 +1653,16 @@ Object { "Type": "AWS::Cognito::UserPoolUser", }, "DLTCognitoAuthDLTCognitoAuthorizedRole9977D4DC": Object { + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W11", + "reason": "iot:AttachPrincipalPolicy does not allow for resource specification", + }, + ], + }, + }, "Properties": Object { "AssumeRolePolicyDocument": Object { "Statement": Array [ @@ -1445,15 +1771,112 @@ Object { }, "PolicyName": "InvokeApiPolicy", }, - ], - }, - "Type": "AWS::IAM::Role", - }, - "DLTCognitoAuthDLTCognitoUnauthorizedRole6FC43D42": Object { - "Properties": Object { - "AssumeRolePolicyDocument": Object { - "Statement": Array [ - Object { + Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "iot:AttachPrincipalPolicy", + "Effect": "Allow", + "Resource": "*", + }, + Object { + "Action": "iot:Connect", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":client/*", + ], + ], + }, + }, + Object { + "Action": "iot:Subscribe", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":topicfilter/*", + ], + ], + }, + }, + Object { + "Action": "iot:Receive", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":topic/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "IoTPolicy", + }, + ], + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "DLTCognitoAuthDLTCognitoUnauthorizedRole6FC43D42": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { "Action": "sts:AssumeRoleWithWebIdentity", "Condition": Object { "ForAnyValue:StringLike": Object { @@ -1473,6 +1896,18 @@ Object { ], "Version": "2012-10-17", }, + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], }, "Type": "AWS::IAM::Role", }, @@ -1631,6 +2066,15 @@ Object { ], ], }, + "UserPoolTags": Object { + "SolutionId": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, "VerificationMessageTemplate": Object { "DefaultEmailOption": "CONFIRM_WITH_CODE", "EmailMessage": "The verification code to your new account is {####}", @@ -1641,50 +2085,97 @@ Object { "Type": "AWS::Cognito::UserPool", "UpdateReplacePolicy": "Delete", }, - "DLTCommonResourcesAnonymousMetric33685222": Object { - "Condition": "SendAnonymousUsage", - "DeletionPolicy": "Delete", - "Properties": Object { - "Region": Object { - "Ref": "AWS::Region", - }, - "Resource": "AnonymousMetric", - "ServiceToken": Object { - "Fn::GetAtt": Array [ - "DLTCommonResourcesCustomResourceLambda0D529C66", - "Arn", - ], - }, - "SolutionId": Object { - "Fn::FindInMap": Array [ - "Solution", - "Config", - "SolutionId", - ], - }, - "UUID": Object { - "Fn::GetAtt": Array [ - "DLTCommonResourcesUUID2FD025A2", - "UUID", - ], - }, - "VERSION": Object { - "Fn::FindInMap": Array [ - "Solution", - "Config", - "CodeVersion", + "DLTCognitoAuthIoTPolicyB8FDFE53": Object { + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W11", + "reason": "Cannot specify the resource to attach policy to identity", + }, ], }, - "existingVPC": Object { - "Fn::If": Array [ - "BoolExistingVPC", - true, - false, + }, + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "iot:Connect", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":client/*", + ], + ], + }, + }, + Object { + "Action": "iot:Subscribe", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":topicfilter/*", + ], + ], + }, + }, + Object { + "Action": "iot:Receive", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":topic/*", + ], + ], + }, + }, ], + "Version": "2012-10-17", }, }, - "Type": "Custom::AnonymousMetric", - "UpdateReplacePolicy": "Delete", + "Type": "AWS::IoT::Policy", }, "DLTCommonResourcesCloudWatchLogsPolicyB29337B0": Object { "Properties": Object { @@ -1723,11 +2214,14 @@ Object { }, "PolicyName": "DLTCommonResourcesCloudWatchLogsPolicyB29337B0", "Roles": Array [ + Object { + "Ref": "DLTCustomResourceInfraCustomResourceLambdaRoleCC09066C", + }, Object { "Ref": "DLTEcsDLTTaskExecutionRoleDE668717", }, Object { - "Ref": "DLTCommonResourcesCustomResourceLambdaRole0608CAD2", + "Ref": "RealTimeDatarealTimeDataPublisherRoleA8976D01", }, Object { "Ref": "DLTLambdaFunctionLambdaResultsRole2CF2D707", @@ -1748,225 +2242,14 @@ Object { }, "Type": "AWS::IAM::Policy", }, - "DLTCommonResourcesCustomResourceLambda0D529C66": Object { - "DependsOn": Array [ - "DLTCommonResourcesCustomResourceLambdaRoleDefaultPolicy7828F0D2", - "DLTCommonResourcesCustomResourceLambdaRole0608CAD2", - ], + "DLTCommonResourcesLogsBucket48A2774D": Object { + "DeletionPolicy": "Retain", "Metadata": Object { "cfn_nag": Object { "rules_to_suppress": Array [ Object { - "id": "W58", - "reason": "CloudWatchLogsPolicy covers a permission to write CloudWatch logs.", - }, - Object { - "id": "W89", - "reason": "VPC not needed for lambda", - }, - Object { - "id": "W92", - "reason": "Does not run concurrent executions", - }, - ], - }, - }, - "Properties": Object { - "Code": Object { - "S3Bucket": Object { - "Fn::Join": Array [ - "-", - Array [ - Object { - "Fn::FindInMap": Array [ - "Solution", - "Config", - "S3Bucket", - ], - }, - Object { - "Ref": "AWS::Region", - }, - ], - ], - }, - "S3Key": Object { - "Fn::Join": Array [ - "", - Array [ - Object { - "Fn::FindInMap": Array [ - "Solution", - "Config", - "KeyPrefix", - ], - }, - "/custom-resource.zip", - ], - ], - }, - }, - "Description": "CFN Lambda backed custom resource to deploy assets to s3", - "Environment": Object { - "Variables": Object { - "METRIC_URL": Object { - "Fn::FindInMap": Array [ - "Solution", - "Config", - "URL", - ], - }, - "SOLUTION_ID": Object { - "Fn::FindInMap": Array [ - "Solution", - "Config", - "SolutionId", - ], - }, - "VERSION": Object { - "Fn::FindInMap": Array [ - "Solution", - "Config", - "CodeVersion", - ], - }, - }, - }, - "Handler": "index.handler", - "Role": Object { - "Fn::GetAtt": Array [ - "DLTCommonResourcesCustomResourceLambdaRole0608CAD2", - "Arn", - ], - }, - "Runtime": "nodejs14.x", - "Tags": Array [ - Object { - "Key": "SolutionId", - "Value": Object { - "Fn::FindInMap": Array [ - "Solution", - "Config", - "SolutionId", - ], - }, - }, - ], - "Timeout": 120, - }, - "Type": "AWS::Lambda::Function", - }, - "DLTCommonResourcesCustomResourceLambdaRole0608CAD2": Object { - "Properties": Object { - "AssumeRolePolicyDocument": Object { - "Statement": Array [ - Object { - "Action": "sts:AssumeRole", - "Effect": "Allow", - "Principal": Object { - "Service": "lambda.amazonaws.com", - }, - }, - ], - "Version": "2012-10-17", - }, - "Policies": Array [ - Object { - "PolicyDocument": Object { - "Statement": Array [ - Object { - "Action": "s3:GetObject", - "Effect": "Allow", - "Resource": Object { - "Fn::Join": Array [ - "", - Array [ - "arn:", - Object { - "Ref": "AWS::Partition", - }, - ":s3:::", - Object { - "Fn::Join": Array [ - "-", - Array [ - Object { - "Fn::FindInMap": Array [ - "Solution", - "Config", - "S3Bucket", - ], - }, - Object { - "Ref": "AWS::Region", - }, - ], - ], - }, - "/*", - ], - ], - }, - }, - ], - "Version": "2012-10-17", - }, - "PolicyName": "CustomResourcePolicy", - }, - ], - }, - "Type": "AWS::IAM::Role", - }, - "DLTCommonResourcesCustomResourceLambdaRoleDefaultPolicy7828F0D2": Object { - "Properties": Object { - "PolicyDocument": Object { - "Statement": Array [ - Object { - "Action": "s3:PutObject", - "Effect": "Allow", - "Resource": Array [ - Object { - "Fn::GetAtt": Array [ - "DLTConsoleResourcesDLTCloudFrontToS3S3Bucket4FED8B63", - "Arn", - ], - }, - Object { - "Fn::Join": Array [ - "", - Array [ - Object { - "Fn::GetAtt": Array [ - "DLTConsoleResourcesDLTCloudFrontToS3S3Bucket4FED8B63", - "Arn", - ], - }, - "/*", - ], - ], - }, - ], - }, - ], - "Version": "2012-10-17", - }, - "PolicyName": "DLTCommonResourcesCustomResourceLambdaRoleDefaultPolicy7828F0D2", - "Roles": Array [ - Object { - "Ref": "DLTCommonResourcesCustomResourceLambdaRole0608CAD2", - }, - ], - }, - "Type": "AWS::IAM::Policy", - }, - "DLTCommonResourcesLogsBucket48A2774D": Object { - "DeletionPolicy": "Retain", - "Metadata": Object { - "cfn_nag": Object { - "rules_to_suppress": Array [ - Object { - "id": "W35", - "reason": "This is the logging bucket, it does not require logging.", + "id": "W35", + "reason": "This is the logging bucket, it does not require logging.", }, ], }, @@ -2051,20 +2334,6 @@ Object { }, "Type": "AWS::S3::BucketPolicy", }, - "DLTCommonResourcesUUID2FD025A2": Object { - "DeletionPolicy": "Delete", - "Properties": Object { - "Resource": "UUID", - "ServiceToken": Object { - "Fn::GetAtt": Array [ - "DLTCommonResourcesCustomResourceLambda0D529C66", - "Arn", - ], - }, - }, - "Type": "Custom::UUID", - "UpdateReplacePolicy": "Delete", - }, "DLTConsoleResourcesDLTCloudFrontToS3CloudFrontDistribution3EF384B4": Object { "Metadata": Object { "cfn_nag": Object { @@ -2232,7 +2501,7 @@ Object { "PolicyDocument": Object { "Statement": Array [ Object { - "Action": "*", + "Action": "s3:*", "Condition": Object { "Bool": Object { "aws:SecureTransport": "false", @@ -2243,6 +2512,12 @@ Object { "AWS": "*", }, "Resource": Array [ + Object { + "Fn::GetAtt": Array [ + "DLTConsoleResourcesDLTCloudFrontToS3S3Bucket4FED8B63", + "Arn", + ], + }, Object { "Fn::Join": Array [ "", @@ -2257,14 +2532,7 @@ Object { ], ], }, - Object { - "Fn::GetAtt": Array [ - "DLTConsoleResourcesDLTCloudFrontToS3S3Bucket4FED8B63", - "Arn", - ], - }, ], - "Sid": "HttpsOnly", }, Object { "Action": "s3:GetObject", @@ -2298,6 +2566,378 @@ Object { }, "Type": "AWS::S3::BucketPolicy", }, + "DLTCustomResourceInfraCustomResourceLambdaA4053269": Object { + "DependsOn": Array [ + "DLTCustomResourceInfraCustomResourceLambdaRoleDefaultPolicyE011C696", + "DLTCustomResourceInfraCustomResourceLambdaRoleCC09066C", + ], + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W58", + "reason": "CloudWatchLogsPolicy covers a permission to write CloudWatch logs.", + }, + Object { + "id": "W89", + "reason": "VPC not needed for lambda", + }, + Object { + "id": "W92", + "reason": "Does not run concurrent executions", + }, + ], + }, + }, + "Properties": Object { + "Code": Object { + "S3Bucket": Object { + "Fn::Join": Array [ + "-", + Array [ + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "S3Bucket", + ], + }, + Object { + "Ref": "AWS::Region", + }, + ], + ], + }, + "S3Key": Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "KeyPrefix", + ], + }, + "/main-custom-resource.zip", + ], + ], + }, + }, + "Description": "CFN Lambda backed custom resource to deploy assets to s3", + "Environment": Object { + "Variables": Object { + "DDB_TABLE": Object { + "Ref": "DLTTestRunnerStorageDLTScenariosTableAB6F5C2A", + }, + "MAIN_REGION": Object { + "Ref": "AWS::Region", + }, + "METRIC_URL": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "URL", + ], + }, + "S3_BUCKET": Object { + "Ref": "DLTTestRunnerStorageDLTScenariosBucketA9290D21", + }, + "SOLUTION_ID": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + "VERSION": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "CodeVersion", + ], + }, + }, + }, + "Handler": "index.handler", + "Role": Object { + "Fn::GetAtt": Array [ + "DLTCustomResourceInfraCustomResourceLambdaRoleCC09066C", + "Arn", + ], + }, + "Runtime": "nodejs14.x", + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + "Timeout": 120, + }, + "Type": "AWS::Lambda::Function", + }, + "DLTCustomResourceInfraCustomResourceLambdaRoleCC09066C": Object { + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W11", + "reason": "iot:DescribeEndpoint and iot:DetachPrincipalPolicy cannot specify the resource.", + }, + ], + }, + }, + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": Array [ + Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "s3:GetObject", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":s3:::", + Object { + "Fn::Join": Array [ + "-", + Array [ + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "S3Bucket", + ], + }, + Object { + "Ref": "AWS::Region", + }, + ], + ], + }, + "/*", + ], + ], + }, + }, + Object { + "Action": Array [ + "s3:PutObject", + "s3:DeleteObject", + ], + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":s3:::", + Object { + "Ref": "DLTTestRunnerStorageDLTScenariosBucketA9290D21", + }, + "/*", + ], + ], + }, + }, + Object { + "Action": Array [ + "dynamodb:PutItem", + "dynamodb:DeleteItem", + ], + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":dynamodb:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":table/", + Object { + "Ref": "DLTTestRunnerStorageDLTScenariosTableAB6F5C2A", + }, + ], + ], + }, + }, + Object { + "Action": Array [ + "iot:DescribeEndpoint", + "iot:DetachPrincipalPolicy", + ], + "Effect": "Allow", + "Resource": "*", + }, + Object { + "Action": "iot:ListTargetsForPolicy", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":policy/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "CustomResourcePolicy", + }, + ], + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "DLTCustomResourceInfraCustomResourceLambdaRoleDefaultPolicyE011C696": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "s3:PutObject", + "Effect": "Allow", + "Resource": Array [ + Object { + "Fn::GetAtt": Array [ + "DLTConsoleResourcesDLTCloudFrontToS3S3Bucket4FED8B63", + "Arn", + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Fn::GetAtt": Array [ + "DLTConsoleResourcesDLTCloudFrontToS3S3Bucket4FED8B63", + "Arn", + ], + }, + "/*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "DLTCustomResourceInfraCustomResourceLambdaRoleDefaultPolicyE011C696", + "Roles": Array [ + Object { + "Ref": "DLTCustomResourceInfraCustomResourceLambdaRoleCC09066C", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "DLTCustomResourcesAnonymousMetricE30E46B4": Object { + "Condition": "SendAnonymousUsage", + "DeletionPolicy": "Delete", + "Properties": Object { + "Region": Object { + "Ref": "AWS::Region", + }, + "Resource": "AnonymousMetric", + "ServiceToken": Object { + "Fn::GetAtt": Array [ + "DLTCustomResourceInfraCustomResourceLambdaA4053269", + "Arn", + ], + }, + "SolutionId": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + "UUID": Object { + "Fn::GetAtt": Array [ + "DLTCustomResourcesCustomResourceUuidD1C03F15", + "UUID", + ], + }, + "VERSION": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "CodeVersion", + ], + }, + "existingVPC": Object { + "Fn::If": Array [ + "BoolExistingVPC", + true, + false, + ], + }, + }, + "Type": "AWS::CloudFormation::CustomResource", + "UpdateReplacePolicy": "Delete", + }, "DLTCustomResourcesConsoleConfig9F494EAB": Object { "DeletionPolicy": "Delete", "Properties": Object { @@ -2306,12 +2946,25 @@ Object { "", Array [ "const awsConfig = { - cw_dashboard: 'https://console.aws.amazon.com/cloudwatch/home?region=", + aws_iot_endpoint: '", + Object { + "Fn::GetAtt": Array [ + "DLTCustomResourcesGetIotEndpoint700ABCC8", + "IOT_ENDPOINT", + ], + }, + "', + aws_iot_policy_name: '", + Object { + "Ref": "DLTCognitoAuthIoTPolicyB8FDFE53", + }, + "', + cw_dashboard: 'https://console.aws.amazon.com/cloudwatch/home?region=", Object { "Ref": "AWS::Region", }, "#dashboards:name=', - ecs_dashboard: 'https://", + ecs_dashboard: 'https://", Object { "Ref": "AWS::Region", }, @@ -2324,36 +2977,36 @@ Object { "Ref": "AWS::StackName", }, "/tasks', - aws_project_region: '", + aws_project_region: '", Object { "Ref": "AWS::Region", }, "', - aws_cognito_region: '", + aws_cognito_region: '", Object { "Ref": "AWS::Region", }, "', - aws_cognito_identity_pool_id: '", + aws_cognito_identity_pool_id: '", Object { "Ref": "DLTCognitoAuthDLTIdentityPoolE110578F", }, "', - aws_user_pools_id: '", + aws_user_pools_id: '", Object { "Ref": "DLTCognitoAuthDLTUserPoolFA41A712", }, "', - aws_user_pools_web_client_id: '", + aws_user_pools_web_client_id: '", Object { "Ref": "DLTCognitoAuthDLTUserPoolClientA2F8B2DB", }, "', - oauth: {}, - aws_cloud_logic_custom: [ - { - name: 'dlts', - endpoint: 'https://", + oauth: {}, + aws_cloud_logic_custom: [ + { + name: 'dlts', + endpoint: 'https://", Object { "Ref": "DLTApi0C903EB5", }, @@ -2370,24 +3023,24 @@ Object { "Ref": "DLTApiDeploymentStageprodC81F8DCB", }, "', - region: '", + region: '", Object { "Ref": "AWS::Region", }, "' - } - ], - aws_user_files_s3_bucket: '", + } + ], + aws_user_files_s3_bucket: '", Object { "Ref": "DLTTestRunnerStorageDLTScenariosBucketA9290D21", }, "', - aws_user_files_s3_bucket_region: '", + aws_user_files_s3_bucket_region: '", Object { "Ref": "AWS::Region", }, - "' - }", + "', + }", ], ], }, @@ -2397,12 +3050,12 @@ Object { "Resource": "ConfigFile", "ServiceToken": Object { "Fn::GetAtt": Array [ - "DLTCommonResourcesCustomResourceLambda0D529C66", + "DLTCustomResourceInfraCustomResourceLambdaA4053269", "Arn", ], }, }, - "Type": "Custom::CopyConfigFiles", + "Type": "AWS::CloudFormation::CustomResource", "UpdateReplacePolicy": "Delete", }, "DLTCustomResourcesCopyConsoleFiles2EBD447E": Object { @@ -2415,7 +3068,7 @@ Object { "Resource": "CopyAssets", "ServiceToken": Object { "Fn::GetAtt": Array [ - "DLTCommonResourcesCustomResourceLambda0D529C66", + "DLTCustomResourceInfraCustomResourceLambdaA4053269", "Arn", ], }, @@ -2452,9 +3105,226 @@ Object { ], }, }, - "Type": "Custom::CopyConsoleFiles", + "Type": "AWS::CloudFormation::CustomResource", + "UpdateReplacePolicy": "Delete", + }, + "DLTCustomResourcesCustomResourceUuidD1C03F15": Object { + "DeletionPolicy": "Delete", + "Properties": Object { + "Resource": "UUID", + "ServiceToken": Object { + "Fn::GetAtt": Array [ + "DLTCustomResourceInfraCustomResourceLambdaA4053269", + "Arn", + ], + }, + }, + "Type": "AWS::CloudFormation::CustomResource", + "UpdateReplacePolicy": "Delete", + }, + "DLTCustomResourcesDetachIotPrincipalPolicyE4A7C1B8": Object { + "DeletionPolicy": "Delete", + "Properties": Object { + "IotPolicyName": Object { + "Ref": "DLTCognitoAuthIoTPolicyB8FDFE53", + }, + "Resource": "DetachIotPolicy", + "ServiceToken": Object { + "Fn::GetAtt": Array [ + "DLTCustomResourceInfraCustomResourceLambdaA4053269", + "Arn", + ], + }, + }, + "Type": "AWS::CloudFormation::CustomResource", + "UpdateReplacePolicy": "Delete", + }, + "DLTCustomResourcesGetIotEndpoint700ABCC8": Object { + "DeletionPolicy": "Delete", + "Properties": Object { + "Resource": "GetIotEndpoint", + "ServiceToken": Object { + "Fn::GetAtt": Array [ + "DLTCustomResourceInfraCustomResourceLambdaA4053269", + "Arn", + ], + }, + }, + "Type": "AWS::CloudFormation::CustomResource", + "UpdateReplacePolicy": "Delete", + }, + "DLTCustomResourcesPutRegionalTemplate5479575B": Object { + "DeletionPolicy": "Delete", + "Properties": Object { + "APIServicesLambdaRoleName": Object { + "Ref": "DLTApiDLTAPIServicesLambdaRole4465EAA4", + }, + "DestBucket": Object { + "Ref": "DLTTestRunnerStorageDLTScenariosBucketA9290D21", + }, + "MainStackRegion": Object { + "Ref": "AWS::Region", + }, + "Resource": "PutRegionalTemplate", + "ResultsParserRoleName": Object { + "Ref": "DLTLambdaFunctionLambdaResultsRole2CF2D707", + }, + "ScenariosTable": Object { + "Ref": "DLTTestRunnerStorageDLTScenariosTableAB6F5C2A", + }, + "ServiceToken": Object { + "Fn::GetAtt": Array [ + "DLTCustomResourceInfraCustomResourceLambdaA4053269", + "Arn", + ], + }, + "SrcBucket": Object { + "Fn::Join": Array [ + "-", + Array [ + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "S3Bucket", + ], + }, + Object { + "Ref": "AWS::Region", + }, + ], + ], + }, + "SrcPath": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "KeyPrefix", + ], + }, + "TaskCancelerRoleName": Object { + "Ref": "DLTLambdaFunctionLambdaTaskCancelerRoleAE2C84CF", + }, + "TaskRunnerRoleName": Object { + "Ref": "DLTLambdaFunctionDLTTestLambdaTaskRole1FDBCEDD", + }, + "TaskStatusCheckerRoleName": Object { + "Ref": "DLTLambdaFunctionTaskStatusRole9288E645", + }, + "Uuid": Object { + "Fn::GetAtt": Array [ + "DLTCustomResourcesCustomResourceUuidD1C03F15", + "UUID", + ], + }, + }, + "Type": "AWS::CloudFormation::CustomResource", + "UpdateReplacePolicy": "Delete", + }, + "DLTCustomResourcesTestingResourcesConfig0BCA657F": Object { + "DeletionPolicy": "Delete", + "Properties": Object { + "Resource": "TestingResourcesConfigFile", + "ServiceToken": Object { + "Fn::GetAtt": Array [ + "DLTCustomResourceInfraCustomResourceLambdaA4053269", + "Arn", + ], + }, + "TestingResourcesConfig": Object { + "ecsCloudWatchLogGroup": Object { + "Ref": "DLTEcsDLTCloudWatchLogsGroupFE9EC144", + }, + "region": Object { + "Ref": "AWS::Region", + }, + "subnetA": Object { + "Fn::If": Array [ + "CreateFargateVPCResources", + Object { + "Ref": "DLTVpcDLTSubnetAAE7DDEE8", + }, + Object { + "Ref": "ExistingSubnetA", + }, + ], + }, + "subnetB": Object { + "Fn::If": Array [ + "CreateFargateVPCResources", + Object { + "Ref": "DLTVpcDLTSubnetB294F4ED2", + }, + Object { + "Ref": "ExistingSubnetB", + }, + ], + }, + "taskCluster": Object { + "Ref": "DLTEcsDLTEcsClusterBC5CE23B", + }, + "taskDefinition": Object { + "Ref": "DLTEcsDLTTaskDefinition6BFC2400", + }, + "taskImage": Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Ref": "AWS::StackName", + }, + "-load-tester", + ], + ], + }, + "taskSecurityGroup": Object { + "Ref": "DLTEcsDLTEcsSecurityGroup69E6743C", + }, + }, + "Uuid": Object { + "Fn::GetAtt": Array [ + "DLTCustomResourcesCustomResourceUuidD1C03F15", + "UUID", + ], + }, + }, + "Type": "AWS::CloudFormation::CustomResource", "UpdateReplacePolicy": "Delete", }, + "DLTEcsDLTCloudWatchLogsGroupECSLogSubscriptionFilterC5BB4DB5": Object { + "Properties": Object { + "DestinationArn": Object { + "Fn::GetAtt": Array [ + "RealTimeDataRealTimeDataPublisher7E8F8F6C", + "Arn", + ], + }, + "FilterPattern": "\\"INFO: Current:\\" \\"live=true\\"", + "LogGroupName": Object { + "Ref": "DLTEcsDLTCloudWatchLogsGroupFE9EC144", + }, + }, + "Type": "AWS::Logs::SubscriptionFilter", + }, + "DLTEcsDLTCloudWatchLogsGroupECSLogSubscriptionFilterCanInvokeLambdaF6EFF73B": Object { + "Properties": Object { + "Action": "lambda:InvokeFunction", + "FunctionName": Object { + "Fn::GetAtt": Array [ + "RealTimeDataRealTimeDataPublisher7E8F8F6C", + "Arn", + ], + }, + "Principal": "logs.amazonaws.com", + "SourceArn": Object { + "Fn::GetAtt": Array [ + "DLTEcsDLTCloudWatchLogsGroupFE9EC144", + "Arn", + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, "DLTEcsDLTCloudWatchLogsGroupFE9EC144": Object { "DeletionPolicy": "Retain", "Metadata": Object { @@ -2469,20 +3339,22 @@ Object { }, "Properties": Object { "RetentionInDays": 365, + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], }, "Type": "AWS::Logs::LogGroup", "UpdateReplacePolicy": "Retain", }, - "DLTEcsDLTECR2419F66F": Object { - "DeletionPolicy": "Retain", - "Properties": Object { - "ImageScanningConfiguration": Object { - "ScanOnPush": true, - }, - }, - "Type": "AWS::ECR::Repository", - "UpdateReplacePolicy": "Retain", - }, "DLTEcsDLTEcsClusterBC5CE23B": Object { "Properties": Object { "ClusterName": Object { @@ -2528,6 +3400,18 @@ Object { }, "Properties": Object { "GroupDescription": "DLTS Tasks Security Group", + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], "VpcId": Object { "Fn::If": Array [ "CreateFargateVPCResources", @@ -2575,7 +3459,13 @@ Object { "ContainerDefinitions": Array [ Object { "Essential": true, - "Image": "PUBLIC_ECR_REGISTRY/distributed-load-testing-on-aws-load-tester:PUBLIC_ECR_TAG", + "Image": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "ContainerImage", + ], + }, "LogConfiguration": Object { "LogDriver": "awslogs", "Options": Object { @@ -2614,6 +3504,18 @@ Object { "RequiresCompatibilities": Array [ "FARGATE", ], + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], "TaskRoleArn": Object { "Fn::GetAtt": Array [ "DLTEcsDLTTaskExecutionRoleDE668717", @@ -2648,7 +3550,71 @@ Object { }, ":iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", ], - ], + ], + }, + ], + "Policies": Array [ + Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": Array [ + "s3:HeadObject", + "s3:PutObject", + "s3:GetObject", + "s3:ListBucket", + ], + "Effect": "Allow", + "Resource": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":s3:::", + Object { + "Ref": "DLTTestRunnerStorageDLTScenariosBucketA9290D21", + }, + ], + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":s3:::", + Object { + "Ref": "DLTTestRunnerStorageDLTScenariosBucketA9290D21", + }, + "/*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "ScenariosS3Policy", + }, + ], + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, }, ], }, @@ -2782,6 +3748,18 @@ Object { "PolicyName": "TaskLambdaPolicy", }, ], + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], }, "Type": "AWS::IAM::Role", }, @@ -2804,6 +3782,16 @@ Object { "Effect": "Allow", "Resource": "*", }, + Object { + "Action": "logs:DeleteMetricFilter", + "Effect": "Allow", + "Resource": Object { + "Fn::GetAtt": Array [ + "DLTEcsDLTCloudWatchLogsGroupFE9EC144", + "Arn", + ], + }, + }, ], "Version": "2012-10-17", }, @@ -2840,6 +3828,18 @@ Object { ], "Version": "2012-10-17", }, + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], }, "Type": "AWS::IAM::Role", }, @@ -2938,6 +3938,18 @@ Object { "PolicyName": "TaskCancelerPolicy", }, ], + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], }, "Type": "AWS::IAM::Role", }, @@ -3001,6 +4013,9 @@ Object { "Description": "Result parser for indexing xml test results to DynamoDB", "Environment": Object { "Variables": Object { + "HISTORY_TABLE": Object { + "Ref": "DLTTestRunnerStorageDLTHistoryTable46D850CC", + }, "METRIC_URL": Object { "Fn::FindInMap": Array [ "Solution", @@ -3030,7 +4045,7 @@ Object { }, "UUID": Object { "Fn::GetAtt": Array [ - "DLTCommonResourcesUUID2FD025A2", + "DLTCustomResourcesCustomResourceUuidD1C03F15", "UUID", ], }, @@ -3144,9 +4159,6 @@ Object { "SolutionId", ], }, - "TASK_CLUSTER": Object { - "Ref": "DLTEcsDLTEcsClusterBC5CE23B", - }, "VERSION": Object { "Fn::FindInMap": Array [ "Solution", @@ -3269,10 +4281,6 @@ Object { "Description": "Task runner for ECS task definitions", "Environment": Object { "Variables": Object { - "API_INTERVAL": "10", - "ECS_LOG_GROUP": Object { - "Ref": "DLTEcsDLTCloudWatchLogsGroupFE9EC144", - }, "SCENARIOS_BUCKET": Object { "Ref": "DLTTestRunnerStorageDLTScenariosBucketA9290D21", }, @@ -3286,48 +4294,6 @@ Object { "SolutionId", ], }, - "SUBNET_A": Object { - "Fn::If": Array [ - "CreateFargateVPCResources", - Object { - "Ref": "DLTVpcDLTSubnetAAE7DDEE8", - }, - Object { - "Ref": "ExistingSubnetA", - }, - ], - }, - "SUBNET_B": Object { - "Fn::If": Array [ - "CreateFargateVPCResources", - Object { - "Ref": "DLTVpcDLTSubnetB294F4ED2", - }, - Object { - "Ref": "ExistingSubnetB", - }, - ], - }, - "TASK_CLUSTER": Object { - "Ref": "DLTEcsDLTEcsClusterBC5CE23B", - }, - "TASK_DEFINITION": Object { - "Ref": "DLTEcsDLTTaskDefinition6BFC2400", - }, - "TASK_IMAGE": Object { - "Fn::Join": Array [ - "", - Array [ - Object { - "Ref": "AWS::StackName", - }, - "-load-tester", - ], - ], - }, - "TASK_SECURITY_GROUP": Object { - "Ref": "DLTEcsDLTEcsSecurityGroup69E6743C", - }, "VERSION": Object { "Fn::FindInMap": Array [ "Solution", @@ -3437,9 +4403,6 @@ Object { "Arn", ], }, - "TASK_CLUSTER": Object { - "Ref": "DLTEcsDLTEcsClusterBC5CE23B", - }, "VERSION": Object { "Fn::FindInMap": Array [ "Solution", @@ -3481,10 +4444,6 @@ Object { "id": "W11", "reason": "ecs:ListTasks does not support resource level permissions", }, - Object { - "id": "W58", - "reason": "CloudWatchLogsPolicy covers a permission to write CloudWatch logs.", - }, ], }, }, @@ -3540,6 +4499,18 @@ Object { "PolicyName": "TaskStatusPolicy", }, ], + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], }, "Type": "AWS::IAM::Role", }, @@ -3556,7 +4527,36 @@ Object { }, }, "Properties": Object { + "LogGroupName": Object { + "Fn::Join": Array [ + "", + Array [ + "/aws/vendedlogs/states/StepFunctionsLogGroup", + Object { + "Ref": "AWS::StackName", + }, + Object { + "Fn::GetAtt": Array [ + "DLTCustomResourcesCustomResourceUuidD1C03F15", + "SUFFIX", + ], + }, + ], + ], + }, "RetentionInDays": 365, + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], }, "Type": "AWS::Logs::LogGroup", "UpdateReplacePolicy": "Retain", @@ -3571,7 +4571,7 @@ Object { "Fn::Join": Array [ "", Array [ - "{\\"StartAt\\":\\"Check running tests\\",\\"States\\":{\\"Check running tests\\":{\\"Next\\":\\"No running tests\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"InputPath\\":\\"$\\",\\"OutputPath\\":\\"$.Payload\\",\\"Resource\\":\\"arn:", + "{\\"StartAt\\":\\"Regions for testing\\",\\"States\\":{\\"Regions for testing\\":{\\"Type\\":\\"Map\\",\\"ResultPath\\":null,\\"Next\\":\\"Parse result\\",\\"InputPath\\":\\"$\\",\\"Parameters\\":{\\"testTaskConfig.$\\":\\"$$.Map.Item.Value\\",\\"testId.$\\":\\"$.testId\\",\\"testType.$\\":\\"$.testType\\",\\"fileType.$\\":\\"$.fileType\\",\\"showLive.$\\":\\"$.showLive\\",\\"prefix.$\\":\\"$.prefix\\"},\\"Iterator\\":{\\"StartAt\\":\\"Check running tests\\",\\"States\\":{\\"Check running tests\\":{\\"Next\\":\\"No running tests\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"InputPath\\":\\"$\\",\\"OutputPath\\":\\"$.Payload\\",\\"Resource\\":\\"arn:", Object { "Ref": "AWS::Partition", }, @@ -3582,7 +4582,7 @@ Object { "Arn", ], }, - "\\",\\"Payload.$\\":\\"$\\"}},\\"No running tests\\":{\\"Type\\":\\"Choice\\",\\"Choices\\":[{\\"Variable\\":\\"$.isRunning\\",\\"BooleanEquals\\":false,\\"Next\\":\\"Run workers\\"}],\\"Default\\":\\"Test is still running\\"},\\"Test is still running\\":{\\"Type\\":\\"Fail\\",\\"Error\\":\\"TestAlreadyRunning\\",\\"Cause\\":\\"The same test is already running.\\"},\\"Run workers\\":{\\"Next\\":\\"Are all workers launched?\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"InputPath\\":\\"$\\",\\"OutputPath\\":\\"$.Payload\\",\\"Resource\\":\\"arn:", + "\\",\\"Payload.$\\":\\"$\\"}},\\"No running tests\\":{\\"Type\\":\\"Choice\\",\\"Choices\\":[{\\"Variable\\":\\"$.isRunning\\",\\"BooleanEquals\\":false,\\"Next\\":\\"Run workers\\"}],\\"Default\\":\\"Test is still running\\"},\\"Test is still running\\":{\\"Type\\":\\"Fail\\",\\"Error\\":\\"TestAlreadyRunning\\",\\"Cause\\":\\"The same test is already running.\\"},\\"Run workers\\":{\\"Next\\":\\"Requires leader?\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"InputPath\\":\\"$\\",\\"OutputPath\\":\\"$.Payload\\",\\"Resource\\":\\"arn:", Object { "Ref": "AWS::Partition", }, @@ -3593,62 +4593,62 @@ Object { "Arn", ], }, - "\\",\\"Payload.$\\":\\"$\\"}},\\"Are all workers launched?\\":{\\"Type\\":\\"Choice\\",\\"Choices\\":[{\\"Variable\\":\\"$.isRunning\\",\\"BooleanEquals\\":false,\\"Next\\":\\"Cancel Test\\"},{\\"Variable\\":\\"$.taskRunner.runTaskCount\\",\\"NumericEquals\\":1,\\"Next\\":\\"Wait 1 minute - worker status\\"},{\\"Variable\\":\\"$.taskRunner.runTaskCount\\",\\"NumericEquals\\":0,\\"Next\\":\\"Wait 1 minute - task status\\"}],\\"Default\\":\\"Run workers\\"},\\"Cancel Test\\":{\\"Next\\":\\"Parse result\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"InputPath\\":\\"$\\",\\"OutputPath\\":\\"$.Payload\\",\\"ResultPath\\":null,\\"Resource\\":\\"arn:", + "\\",\\"Payload.$\\":\\"$\\"}},\\"Requires leader?\\":{\\"Type\\":\\"Choice\\",\\"Choices\\":[{\\"Variable\\":\\"$.isRunning\\",\\"BooleanEquals\\":false,\\"Next\\":\\"Cancel Test\\"},{\\"Variable\\":\\"$.taskIds\\",\\"IsPresent\\":false,\\"Next\\":\\"Wait 1 minute - task status\\"}],\\"Default\\":\\"Wait 1 minute - worker status\\"},\\"Wait 1 minute - worker status\\":{\\"Type\\":\\"Wait\\",\\"Comment\\":\\"Wait 1 minute to check task status again\\",\\"Seconds\\":60,\\"Next\\":\\"Check worker status\\"},\\"Check worker status\\":{\\"Next\\":\\"Are all workers running?\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"InputPath\\":\\"$\\",\\"OutputPath\\":\\"$.Payload\\",\\"Resource\\":\\"arn:", Object { "Ref": "AWS::Partition", }, ":states:::lambda:invoke\\",\\"Parameters\\":{\\"FunctionName\\":\\"", Object { "Fn::GetAtt": Array [ - "DLTLambdaFunctionTaskCanceler4E12BDA6", + "DLTLambdaFunctionTaskStatusChecker1AA63EC9", "Arn", ], }, - "\\",\\"Payload.$\\":\\"$\\"}},\\"Parse result\\":{\\"Next\\":\\"Done\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"Resource\\":\\"arn:", + "\\",\\"Payload.$\\":\\"$\\"}},\\"Are all workers running?\\":{\\"Type\\":\\"Choice\\",\\"Choices\\":[{\\"Variable\\":\\"$.isRunning\\",\\"BooleanEquals\\":false,\\"Next\\":\\"Cancel Test\\"},{\\"Variable\\":\\"$.numTasksRunning\\",\\"NumericEqualsPath\\":\\"$.numTasksTotal\\",\\"Next\\":\\"Run leader task\\"}],\\"Default\\":\\"Wait 1 minute - worker status\\"},\\"Cancel Test\\":{\\"Next\\":\\"Map End\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"InputPath\\":\\"$\\",\\"ResultPath\\":null,\\"Resource\\":\\"arn:", Object { "Ref": "AWS::Partition", }, ":states:::lambda:invoke\\",\\"Parameters\\":{\\"FunctionName\\":\\"", Object { "Fn::GetAtt": Array [ - "DLTLambdaFunctionResultsParserFF5CC920", + "DLTLambdaFunctionTaskCanceler4E12BDA6", "Arn", ], }, - "\\",\\"Payload.$\\":\\"$\\"}},\\"Are all tasks done?\\":{\\"Type\\":\\"Choice\\",\\"Choices\\":[{\\"Variable\\":\\"$.isRunning\\",\\"BooleanEquals\\":false,\\"Next\\":\\"Parse result\\"}],\\"Default\\":\\"Wait 1 minute - task status\\"},\\"Check task status\\":{\\"Next\\":\\"Are all tasks done?\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"InputPath\\":\\"$\\",\\"OutputPath\\":\\"$.Payload\\",\\"Resource\\":\\"arn:", + "\\",\\"Payload.$\\":\\"$\\"}},\\"Run leader task\\":{\\"Next\\":\\"Wait 1 minute - task status\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Catch\\":[{\\"ErrorEquals\\":[\\"States.ALL\\"],\\"ResultPath\\":\\"$.error\\",\\"Next\\":\\"Cancel Test\\"}],\\"Type\\":\\"Task\\",\\"InputPath\\":\\"$\\",\\"OutputPath\\":\\"$.Payload\\",\\"Resource\\":\\"arn:", Object { "Ref": "AWS::Partition", }, ":states:::lambda:invoke\\",\\"Parameters\\":{\\"FunctionName\\":\\"", Object { "Fn::GetAtt": Array [ - "DLTLambdaFunctionTaskStatusChecker1AA63EC9", + "DLTLambdaFunctionTaskRunnerAAAD9171", "Arn", ], }, - "\\",\\"Payload.$\\":\\"$\\"}},\\"Wait 1 minute - task status\\":{\\"Type\\":\\"Wait\\",\\"Comment\\":\\"Wait 1 minute to check task status again\\",\\"Seconds\\":60,\\"Next\\":\\"Check task status\\"},\\"Run leader task\\":{\\"Next\\":\\"Wait 1 minute - task status\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"InputPath\\":\\"$\\",\\"OutputPath\\":\\"$.Payload\\",\\"Resource\\":\\"arn:", + "\\",\\"Payload.$\\":\\"$\\"}},\\"Wait 1 minute - task status\\":{\\"Type\\":\\"Wait\\",\\"Comment\\":\\"Wait 1 minute to check task status again\\",\\"Seconds\\":60,\\"Next\\":\\"Check task status\\"},\\"Check task status\\":{\\"Next\\":\\"Are all tasks done?\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"InputPath\\":\\"$\\",\\"OutputPath\\":\\"$.Payload\\",\\"Resource\\":\\"arn:", Object { "Ref": "AWS::Partition", }, ":states:::lambda:invoke\\",\\"Parameters\\":{\\"FunctionName\\":\\"", Object { "Fn::GetAtt": Array [ - "DLTLambdaFunctionTaskRunnerAAAD9171", + "DLTLambdaFunctionTaskStatusChecker1AA63EC9", "Arn", ], }, - "\\",\\"Payload.$\\":\\"$\\"}},\\"Are all workers running?\\":{\\"Type\\":\\"Choice\\",\\"Choices\\":[{\\"Variable\\":\\"$.numTasksRunning\\",\\"NumericEqualsPath\\":\\"$.scenario.taskCount\\",\\"Next\\":\\"Run leader task\\"},{\\"Variable\\":\\"$.isRunning\\",\\"BooleanEquals\\":false,\\"Next\\":\\"Parse result\\"}],\\"Default\\":\\"Wait 1 minute - worker status\\"},\\"Check worker status\\":{\\"Next\\":\\"Are all workers running?\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"InputPath\\":\\"$\\",\\"OutputPath\\":\\"$.Payload\\",\\"Resource\\":\\"arn:", + "\\",\\"Payload.$\\":\\"$\\"}},\\"Are all tasks done?\\":{\\"Type\\":\\"Choice\\",\\"Choices\\":[{\\"Variable\\":\\"$.isRunning\\",\\"BooleanEquals\\":false,\\"Next\\":\\"Map End\\"}],\\"Default\\":\\"Wait 1 minute - task status\\"},\\"Map End\\":{\\"Type\\":\\"Pass\\",\\"End\\":true}}},\\"ItemsPath\\":\\"$.testTaskConfig\\"},\\"Parse result\\":{\\"Next\\":\\"Done\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"Resource\\":\\"arn:", Object { "Ref": "AWS::Partition", }, ":states:::lambda:invoke\\",\\"Parameters\\":{\\"FunctionName\\":\\"", Object { "Fn::GetAtt": Array [ - "DLTLambdaFunctionTaskStatusChecker1AA63EC9", + "DLTLambdaFunctionResultsParserFF5CC920", "Arn", ], }, - "\\",\\"Payload.$\\":\\"$\\"}},\\"Wait 1 minute - worker status\\":{\\"Type\\":\\"Wait\\",\\"Comment\\":\\"Wait 1 minute to check task status again\\",\\"Seconds\\":60,\\"Next\\":\\"Check worker status\\"},\\"Done\\":{\\"Type\\":\\"Succeed\\"}}}", + "\\",\\"Payload.$\\":\\"$\\"}},\\"Done\\":{\\"Type\\":\\"Succeed\\"}}}", ], ], }, @@ -3712,15 +4712,12 @@ Object { "Effect": "Allow", "Principal": Object { "Service": Object { - "Fn::Join": Array [ - "", - Array [ - "states.", - Object { - "Ref": "AWS::Region", - }, - ".amazonaws.com", - ], + "Fn::FindInMap": Array [ + "ServiceprincipalMap", + Object { + "Ref": "AWS::Region", + }, + "states", ], }, }, @@ -3751,6 +4748,10 @@ Object { "id": "W12", "reason": "CloudWatch logs actions do not support resource level permissions", }, + Object { + "id": "W76", + "reason": "The IAM policy is written for least-privilege access.", + }, ], }, }, @@ -3774,54 +4775,164 @@ Object { Object { "Action": "lambda:InvokeFunction", "Effect": "Allow", - "Resource": Object { - "Fn::GetAtt": Array [ - "DLTLambdaFunctionTaskStatusChecker1AA63EC9", - "Arn", - ], - }, + "Resource": Array [ + Object { + "Fn::GetAtt": Array [ + "DLTLambdaFunctionResultsParserFF5CC920", + "Arn", + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Fn::GetAtt": Array [ + "DLTLambdaFunctionResultsParserFF5CC920", + "Arn", + ], + }, + ":*", + ], + ], + }, + ], }, Object { "Action": "lambda:InvokeFunction", "Effect": "Allow", - "Resource": Object { - "Fn::GetAtt": Array [ - "DLTLambdaFunctionTaskRunnerAAAD9171", - "Arn", - ], - }, + "Resource": Array [ + Object { + "Fn::GetAtt": Array [ + "DLTLambdaFunctionTaskStatusChecker1AA63EC9", + "Arn", + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Fn::GetAtt": Array [ + "DLTLambdaFunctionTaskStatusChecker1AA63EC9", + "Arn", + ], + }, + ":*", + ], + ], + }, + ], }, Object { "Action": "lambda:InvokeFunction", "Effect": "Allow", - "Resource": Object { - "Fn::GetAtt": Array [ - "DLTLambdaFunctionTaskCanceler4E12BDA6", - "Arn", - ], - }, + "Resource": Array [ + Object { + "Fn::GetAtt": Array [ + "DLTLambdaFunctionTaskRunnerAAAD9171", + "Arn", + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Fn::GetAtt": Array [ + "DLTLambdaFunctionTaskRunnerAAAD9171", + "Arn", + ], + }, + ":*", + ], + ], + }, + ], }, Object { "Action": "lambda:InvokeFunction", "Effect": "Allow", - "Resource": Object { - "Fn::GetAtt": Array [ - "DLTLambdaFunctionResultsParserFF5CC920", - "Arn", - ], - }, + "Resource": Array [ + Object { + "Fn::GetAtt": Array [ + "DLTLambdaFunctionTaskCanceler4E12BDA6", + "Arn", + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Fn::GetAtt": Array [ + "DLTLambdaFunctionTaskCanceler4E12BDA6", + "Arn", + ], + }, + ":*", + ], + ], + }, + ], }, ], "Version": "2012-10-17", }, - "PolicyName": "DLTStepFunctionTaskRunnerStepFunctionsRoleDefaultPolicy8F17B49F", - "Roles": Array [ + "PolicyName": "DLTStepFunctionTaskRunnerStepFunctionsRoleDefaultPolicy8F17B49F", + "Roles": Array [ + Object { + "Ref": "DLTStepFunctionTaskRunnerStepFunctionsRoleC2237F06", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "DLTTestRunnerStorageDLTHistoryTable46D850CC": Object { + "DeletionPolicy": "Retain", + "Properties": Object { + "AttributeDefinitions": Array [ + Object { + "AttributeName": "testId", + "AttributeType": "S", + }, + Object { + "AttributeName": "testRunId", + "AttributeType": "S", + }, + ], + "BillingMode": "PAY_PER_REQUEST", + "KeySchema": Array [ + Object { + "AttributeName": "testId", + "KeyType": "HASH", + }, + Object { + "AttributeName": "testRunId", + "KeyType": "RANGE", + }, + ], + "PointInTimeRecoverySpecification": Object { + "PointInTimeRecoveryEnabled": true, + }, + "SSESpecification": Object { + "SSEEnabled": true, + }, + "Tags": Array [ Object { - "Ref": "DLTStepFunctionTaskRunnerStepFunctionsRoleC2237F06", + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, }, ], }, - "Type": "AWS::IAM::Policy", + "Type": "AWS::DynamoDB::Table", + "UpdateReplacePolicy": "Retain", }, "DLTTestRunnerStorageDLTScenariosBucketA9290D21": Object { "DeletionPolicy": "Retain", @@ -3981,7 +5092,40 @@ Object { "Type": "AWS::DynamoDB::Table", "UpdateReplacePolicy": "Retain", }, - "DLTTestRunnerStorageDynamoDbPolicyC83287AF": Object { + "DLTTestRunnerStorageHistoryDynamoDbPolicyA439CB46": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": Array [ + "dynamodb:BatchWriteItem", + "dynamodb:PutItem", + "dynamodb:Query", + ], + "Effect": "Allow", + "Resource": Object { + "Fn::GetAtt": Array [ + "DLTTestRunnerStorageDLTHistoryTable46D850CC", + "Arn", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "DLTTestRunnerStorageHistoryDynamoDbPolicyA439CB46", + "Roles": Array [ + Object { + "Ref": "DLTLambdaFunctionLambdaResultsRole2CF2D707", + }, + Object { + "Ref": "DLTApiDLTAPIServicesLambdaRole4465EAA4", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "DLTTestRunnerStorageScenarioDynamoDbPolicy8B391249": Object { "Properties": Object { "PolicyDocument": Object { "Statement": Array [ @@ -4004,7 +5148,7 @@ Object { ], "Version": "2012-10-17", }, - "PolicyName": "DLTTestRunnerStorageDynamoDbPolicyC83287AF", + "PolicyName": "DLTTestRunnerStorageScenarioDynamoDbPolicy8B391249", "Roles": Array [ Object { "Ref": "DLTLambdaFunctionLambdaResultsRole2CF2D707", @@ -4062,9 +5206,6 @@ Object { }, "PolicyName": "DLTTestRunnerStorageScenariosS3PolicyD20D3673", "Roles": Array [ - Object { - "Ref": "DLTEcsDLTTaskExecutionRoleDE668717", - }, Object { "Ref": "DLTLambdaFunctionLambdaResultsRole2CF2D707", }, @@ -4269,6 +5410,226 @@ Object { }, "Type": "AWS::EC2::Subnet", }, + "RealTimeDataRealTimeDataPublisher7E8F8F6C": Object { + "DependsOn": Array [ + "RealTimeDatarealTimeDataPublisherRoleA8976D01", + ], + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W58", + "reason": "CloudWatchLogsPolicy covers a permission to write CloudWatch logs.", + }, + Object { + "id": "W89", + "reason": "This Lambda function does not require a VPC", + }, + Object { + "id": "W92", + "reason": "Does not run concurrent executions", + }, + ], + }, + }, + "Properties": Object { + "Code": Object { + "S3Bucket": Object { + "Fn::Join": Array [ + "-", + Array [ + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "S3Bucket", + ], + }, + Object { + "Ref": "AWS::Region", + }, + ], + ], + }, + "S3Key": Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "KeyPrefix", + ], + }, + "/real-time-data-publisher.zip", + ], + ], + }, + }, + "Description": "Real time data publisher", + "Environment": Object { + "Variables": Object { + "IOT_ENDPOINT": Object { + "Fn::GetAtt": Array [ + "DLTCustomResourcesGetIotEndpoint700ABCC8", + "IOT_ENDPOINT", + ], + }, + "MAIN_REGION": Object { + "Ref": "AWS::Region", + }, + "SOLUTION_ID": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + "VERSION": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "CodeVersion", + ], + }, + }, + }, + "Handler": "index.handler", + "Role": Object { + "Fn::GetAtt": Array [ + "RealTimeDatarealTimeDataPublisherRoleA8976D01", + "Arn", + ], + }, + "Runtime": "nodejs14.x", + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + "Timeout": 180, + }, + "Type": "AWS::Lambda::Function", + }, + "RealTimeDatarealTimeDataPublisherRoleA8976D01": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": Array [ + Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "iot:Publish", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":topic/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "IoTPolicy", + }, + ], + "Tags": Array [ + Object { + "Key": "SolutionId", + "Value": Object { + "Fn::FindInMap": Array [ + "Solution", + "Config", + "SolutionId", + ], + }, + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + }, + "Rules": Object { + "ExistingVPCRule": Object { + "Assertions": Array [ + Object { + "Assert": Object { + "Fn::Not": Array [ + Object { + "Fn::Equals": Array [ + Object { + "Ref": "ExistingSubnetA", + }, + "", + ], + }, + ], + }, + "AssertDescription": "If an existing VPC Id is provided, 2 subnet ids need to be provided as well. You neglected to enter the first subnet id", + }, + Object { + "Assert": Object { + "Fn::Not": Array [ + Object { + "Fn::Equals": Array [ + Object { + "Ref": "ExistingSubnetB", + }, + "", + ], + }, + ], + }, + "AssertDescription": "If an existing VPC Id is provided, 2 subnet ids need to be provided as well. You neglected to enter the second subnet id", + }, + ], + "RuleCondition": Object { + "Fn::Not": Array [ + Object { + "Fn::Equals": Array [ + Object { + "Ref": "ExistingVPCId", + }, + "", + ], + }, + ], + }, + }, }, } `; diff --git a/source/infrastructure/test/__snapshots__/ecs.test.ts.snap b/source/infrastructure/test/__snapshots__/ecs.test.ts.snap index 75cc759..5320267 100644 --- a/source/infrastructure/test/__snapshots__/ecs.test.ts.snap +++ b/source/infrastructure/test/__snapshots__/ecs.test.ts.snap @@ -21,16 +21,6 @@ Object { "Type": "AWS::Logs::LogGroup", "UpdateReplacePolicy": "Retain", }, - "TestECSDLTECR0AA15B8E": Object { - "DeletionPolicy": "Retain", - "Properties": Object { - "ImageScanningConfiguration": Object { - "ScanOnPush": true, - }, - }, - "Type": "AWS::ECR::Repository", - "UpdateReplacePolicy": "Retain", - }, "TestECSDLTEcsCluster7C6F0F5D": Object { "Properties": Object { "ClusterName": Object { @@ -105,7 +95,7 @@ Object { "ContainerDefinitions": Array [ Object { "Essential": true, - "Image": "PUBLIC_ECR_REGISTRY/distributed-load-testing-on-aws-load-tester:PUBLIC_ECR_TAG", + "Image": "testRepository/testImage:testTag", "LogConfiguration": Object { "LogDriver": "awslogs", "Options": Object { @@ -181,9 +171,75 @@ Object { ], }, ], + "Policies": Array [ + Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": Array [ + "s3:HeadObject", + "s3:PutObject", + "s3:GetObject", + "s3:ListBucket", + ], + "Effect": "Allow", + "Resource": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":s3:::testscenariobucket", + ], + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":s3:::testscenariobucket/*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "ScenariosS3Policy", + }, + ], }, "Type": "AWS::IAM::Role", }, + "TestPolicyCC05E598": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "cloudwatch:Get*", + "Effect": "Allow", + "Resource": "*", + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "TestPolicyCC05E598", + "Roles": Array [ + Object { + "Ref": "TestECSDLTTaskExecutionRole104D057B", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, }, } `; diff --git a/source/infrastructure/test/__snapshots__/real-time-data.test.ts.snap b/source/infrastructure/test/__snapshots__/real-time-data.test.ts.snap new file mode 100644 index 0000000..4c45def --- /dev/null +++ b/source/infrastructure/test/__snapshots__/real-time-data.test.ts.snap @@ -0,0 +1,166 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DLT real time data resources Test 1`] = ` +Object { + "Resources": Object { + "TestECSRealTimeDataPublisherF2114D5B": Object { + "DependsOn": Array [ + "TestECSrealTimeDataPublisherRoleEFE9F1CD", + ], + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W58", + "reason": "CloudWatchLogsPolicy covers a permission to write CloudWatch logs.", + }, + Object { + "id": "W89", + "reason": "This Lambda function does not require a VPC", + }, + Object { + "id": "W92", + "reason": "Does not run concurrent executions", + }, + ], + }, + }, + "Properties": Object { + "Code": Object { + "S3Bucket": "testbucket", + "S3Key": "testPrefix/real-time-data-publisher.zip", + }, + "Description": "Real time data publisher", + "Environment": Object { + "Variables": Object { + "IOT_ENDPOINT": "iotEndpoint", + "MAIN_REGION": "test-region-1", + "SOLUTION_ID": "testID", + "VERSION": "testVersion", + }, + }, + "Handler": "index.handler", + "Role": Object { + "Fn::GetAtt": Array [ + "TestECSrealTimeDataPublisherRoleEFE9F1CD", + "Arn", + ], + }, + "Runtime": "nodejs14.x", + "Timeout": 180, + }, + "Type": "AWS::Lambda::Function", + }, + "TestECSrealTimeDataPublisherRoleEFE9F1CD": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": Array [ + Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "iot:Publish", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iot:test-region-1:", + Object { + "Ref": "AWS::AccountId", + }, + ":topic/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "IoTPolicy", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "TestLogsGroup54B681C7": Object { + "DeletionPolicy": "Retain", + "Properties": Object { + "RetentionInDays": 731, + }, + "Type": "AWS::Logs::LogGroup", + "UpdateReplacePolicy": "Retain", + }, + "TestLogsGroupECSLogSubscriptionFilterBFBFAB24": Object { + "Properties": Object { + "DestinationArn": Object { + "Fn::GetAtt": Array [ + "TestECSRealTimeDataPublisherF2114D5B", + "Arn", + ], + }, + "FilterPattern": "\\"INFO: Current:\\" \\"live=true\\"", + "LogGroupName": Object { + "Ref": "TestLogsGroup54B681C7", + }, + }, + "Type": "AWS::Logs::SubscriptionFilter", + }, + "TestLogsGroupECSLogSubscriptionFilterCanInvokeLambdaD7381D91": Object { + "Properties": Object { + "Action": "lambda:InvokeFunction", + "FunctionName": Object { + "Fn::GetAtt": Array [ + "TestECSRealTimeDataPublisherF2114D5B", + "Arn", + ], + }, + "Principal": "logs.amazonaws.com", + "SourceArn": Object { + "Fn::GetAtt": Array [ + "TestLogsGroup54B681C7", + "Arn", + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "TestPolicyCC05E598": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "cloudwatch:Get*", + "Effect": "Allow", + "Resource": "*", + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "TestPolicyCC05E598", + "Roles": Array [ + Object { + "Ref": "TestECSrealTimeDataPublisherRoleEFE9F1CD", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + }, +} +`; diff --git a/source/infrastructure/test/__snapshots__/regional-permissions.test.ts.snap b/source/infrastructure/test/__snapshots__/regional-permissions.test.ts.snap new file mode 100644 index 0000000..799c45b --- /dev/null +++ b/source/infrastructure/test/__snapshots__/regional-permissions.test.ts.snap @@ -0,0 +1,280 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DLT Regional Permission Test 1`] = ` +Object { + "Resources": Object { + "TestRegionalPermissionsECSCloudWatchDelMetrics281D272A": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "logs:DeleteMetricFilter", + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-2:123456789012:log-group:test_log_group_name", + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": Object { + "Fn::Join": Array [ + "", + Array [ + "ECSCloudWatchDelMetrics-", + Object { + "Ref": "AWS::StackName", + }, + "-", + Object { + "Ref": "AWS::Region", + }, + ], + ], + }, + "Roles": Array [ + "testApiServicesLambdaRoleName", + "testResultsParserRoleName", + ], + }, + "Type": "AWS::IAM::Policy", + }, + "TestRegionalPermissionsECSCloudWatchPutMetricsdD0A5AD2E": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "logs:PutMetricFilter", + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-2:123456789012:log-group:test_log_group_name", + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": Object { + "Fn::Join": Array [ + "", + Array [ + "ECSCloudWatchPutMetrics-", + Object { + "Ref": "AWS::StackName", + }, + "-", + Object { + "Ref": "AWS::Region", + }, + ], + ], + }, + "Roles": Array [ + "testTaskRunnerRoleName", + ], + }, + "Type": "AWS::IAM::Policy", + }, + "TestRegionalPermissionsECSDescribePolicyAA323A68": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "ecs:DescribeTasks", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":ecs:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":task/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": Object { + "Fn::Join": Array [ + "", + Array [ + "ECSDescribePolicy", + Object { + "Ref": "AWS::Region", + }, + ], + ], + }, + "Roles": Array [ + "testTaskStatusCheckerRoleName", + ], + }, + "Type": "AWS::IAM::Policy", + }, + "TestRegionalPermissionsECSStopPolicy4B5507E2": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "ecs:StopTask", + "Effect": "Allow", + "Resource": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":ecs:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":task/*", + ], + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":ecs:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":task-definition/*:*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": Object { + "Fn::Join": Array [ + "", + Array [ + "ECSStopPolicy-", + Object { + "Ref": "AWS::StackName", + }, + "-", + Object { + "Ref": "AWS::Region", + }, + ], + ], + }, + "Roles": Array [ + "testTaskCancelerRoleName", + ], + }, + "Type": "AWS::IAM::Policy", + }, + "TestRegionalPermissionsRegionalECRPermsDD807682": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": Array [ + "ecs:RunTask", + "ecs:DescribeTasks", + ], + "Effect": "Allow", + "Resource": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":ecs:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":task/*", + ], + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":ecs:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":task-definition/*:*", + ], + ], + }, + ], + }, + Object { + "Action": "iam:PassRole", + "Effect": "Allow", + "Resource": "arn:aws:iam::123456789012:role/testRole", + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": Object { + "Fn::Join": Array [ + "", + Array [ + "RegionalECRPerms-", + Object { + "Ref": "AWS::StackName", + }, + "-", + Object { + "Ref": "AWS::Region", + }, + ], + ], + }, + "Roles": Array [ + "testTaskRunnerRoleName", + "testApiServicesLambdaRoleName", + ], + }, + "Type": "AWS::IAM::Policy", + }, + }, +} +`; diff --git a/source/infrastructure/test/__snapshots__/scenarios-storage.test.ts.snap b/source/infrastructure/test/__snapshots__/scenarios-storage.test.ts.snap new file mode 100644 index 0000000..37181f6 --- /dev/null +++ b/source/infrastructure/test/__snapshots__/scenarios-storage.test.ts.snap @@ -0,0 +1,171 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DLT API Test 1`] = ` +Object { + "Resources": Object { + "TestScenarioStorageDLTHistoryTable3639137E": Object { + "DeletionPolicy": "Retain", + "Properties": Object { + "AttributeDefinitions": Array [ + Object { + "AttributeName": "testId", + "AttributeType": "S", + }, + Object { + "AttributeName": "testRunId", + "AttributeType": "S", + }, + ], + "BillingMode": "PAY_PER_REQUEST", + "KeySchema": Array [ + Object { + "AttributeName": "testId", + "KeyType": "HASH", + }, + Object { + "AttributeName": "testRunId", + "KeyType": "RANGE", + }, + ], + "PointInTimeRecoverySpecification": Object { + "PointInTimeRecoveryEnabled": true, + }, + "SSESpecification": Object { + "SSEEnabled": true, + }, + }, + "Type": "AWS::DynamoDB::Table", + "UpdateReplacePolicy": "Retain", + }, + "TestScenarioStorageDLTScenariosBucket9A78F6FF": Object { + "DeletionPolicy": "Retain", + "Properties": Object { + "BucketEncryption": Object { + "ServerSideEncryptionConfiguration": Array [ + Object { + "ServerSideEncryptionByDefault": Object { + "SSEAlgorithm": "aws:kms", + }, + }, + ], + }, + "CorsConfiguration": Object { + "CorsRules": Array [ + Object { + "AllowedHeaders": Array [ + "*", + ], + "AllowedMethods": Array [ + "GET", + "POST", + "PUT", + ], + "AllowedOrigins": Array [ + "https://test.exampledomain.com", + ], + "ExposedHeaders": Array [ + "ETag", + ], + }, + ], + }, + "LoggingConfiguration": Object { + "DestinationBucketName": Object { + "Ref": "testLogsBucket85E419AD", + }, + "LogFilePrefix": "scenarios-bucket-access/", + }, + "PublicAccessBlockConfiguration": Object { + "BlockPublicAcls": true, + "BlockPublicPolicy": true, + "IgnorePublicAcls": true, + "RestrictPublicBuckets": true, + }, + }, + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + }, + "TestScenarioStorageDLTScenariosBucketPolicyDD7EC971": Object { + "Properties": Object { + "Bucket": Object { + "Ref": "TestScenarioStorageDLTScenariosBucket9A78F6FF", + }, + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "s3:*", + "Condition": Object { + "Bool": Object { + "aws:SecureTransport": false, + }, + }, + "Effect": "Deny", + "Principal": Object { + "AWS": "*", + }, + "Resource": Array [ + Object { + "Fn::GetAtt": Array [ + "TestScenarioStorageDLTScenariosBucket9A78F6FF", + "Arn", + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Fn::GetAtt": Array [ + "TestScenarioStorageDLTScenariosBucket9A78F6FF", + "Arn", + ], + }, + "/*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + }, + "Type": "AWS::S3::BucketPolicy", + }, + "TestScenarioStorageDLTScenariosTable136C8D56": Object { + "DeletionPolicy": "Retain", + "Properties": Object { + "AttributeDefinitions": Array [ + Object { + "AttributeName": "testId", + "AttributeType": "S", + }, + ], + "BillingMode": "PAY_PER_REQUEST", + "KeySchema": Array [ + Object { + "AttributeName": "testId", + "KeyType": "HASH", + }, + ], + "PointInTimeRecoverySpecification": Object { + "PointInTimeRecoveryEnabled": true, + }, + "SSESpecification": Object { + "SSEEnabled": true, + }, + }, + "Type": "AWS::DynamoDB::Table", + "UpdateReplacePolicy": "Retain", + }, + "testLogsBucket85E419AD": Object { + "DeletionPolicy": "Retain", + "Properties": Object { + "AccessControl": "LogDeliveryWrite", + }, + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + }, + }, +} +`; diff --git a/source/infrastructure/test/__snapshots__/step-functions.test.ts.snap b/source/infrastructure/test/__snapshots__/step-functions.test.ts.snap new file mode 100644 index 0000000..bab4286 --- /dev/null +++ b/source/infrastructure/test/__snapshots__/step-functions.test.ts.snap @@ -0,0 +1,409 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DLT API Test 1`] = ` +Object { + "Mappings": Object { + "ServiceprincipalMap": Object { + "af-south-1": Object { + "states": "states.af-south-1.amazonaws.com", + }, + "ap-east-1": Object { + "states": "states.ap-east-1.amazonaws.com", + }, + "ap-northeast-1": Object { + "states": "states.ap-northeast-1.amazonaws.com", + }, + "ap-northeast-2": Object { + "states": "states.ap-northeast-2.amazonaws.com", + }, + "ap-northeast-3": Object { + "states": "states.ap-northeast-3.amazonaws.com", + }, + "ap-south-1": Object { + "states": "states.ap-south-1.amazonaws.com", + }, + "ap-southeast-1": Object { + "states": "states.ap-southeast-1.amazonaws.com", + }, + "ap-southeast-2": Object { + "states": "states.ap-southeast-2.amazonaws.com", + }, + "ap-southeast-3": Object { + "states": "states.ap-southeast-3.amazonaws.com", + }, + "ca-central-1": Object { + "states": "states.ca-central-1.amazonaws.com", + }, + "cn-north-1": Object { + "states": "states.cn-north-1.amazonaws.com", + }, + "cn-northwest-1": Object { + "states": "states.cn-northwest-1.amazonaws.com", + }, + "eu-central-1": Object { + "states": "states.eu-central-1.amazonaws.com", + }, + "eu-north-1": Object { + "states": "states.eu-north-1.amazonaws.com", + }, + "eu-south-1": Object { + "states": "states.eu-south-1.amazonaws.com", + }, + "eu-south-2": Object { + "states": "states.eu-south-2.amazonaws.com", + }, + "eu-west-1": Object { + "states": "states.eu-west-1.amazonaws.com", + }, + "eu-west-2": Object { + "states": "states.eu-west-2.amazonaws.com", + }, + "eu-west-3": Object { + "states": "states.eu-west-3.amazonaws.com", + }, + "me-south-1": Object { + "states": "states.me-south-1.amazonaws.com", + }, + "sa-east-1": Object { + "states": "states.sa-east-1.amazonaws.com", + }, + "us-east-1": Object { + "states": "states.us-east-1.amazonaws.com", + }, + "us-east-2": Object { + "states": "states.us-east-2.amazonaws.com", + }, + "us-gov-east-1": Object { + "states": "states.us-gov-east-1.amazonaws.com", + }, + "us-gov-west-1": Object { + "states": "states.us-gov-west-1.amazonaws.com", + }, + "us-iso-east-1": Object { + "states": "states.amazonaws.com", + }, + "us-iso-west-1": Object { + "states": "states.amazonaws.com", + }, + "us-isob-east-1": Object { + "states": "states.amazonaws.com", + }, + "us-west-1": Object { + "states": "states.us-west-1.amazonaws.com", + }, + "us-west-2": Object { + "states": "states.us-west-2.amazonaws.com", + }, + }, + }, + "Resources": Object { + "TaskRunnerStepFunctionStepFunctionsLogGroup39F53B04": Object { + "DeletionPolicy": "Retain", + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W84", + "reason": "KMS encryption unnecessary for log group", + }, + ], + }, + }, + "Properties": Object { + "LogGroupName": Object { + "Fn::Join": Array [ + "", + Array [ + "/aws/vendedlogs/states/StepFunctionsLogGroup", + Object { + "Ref": "AWS::StackName", + }, + "abc-def-xyz", + ], + ], + }, + "RetentionInDays": 365, + }, + "Type": "AWS::Logs::LogGroup", + "UpdateReplacePolicy": "Retain", + }, + "TaskRunnerStepFunctionTaskRunnerStepFunctionsC50015AC": Object { + "DependsOn": Array [ + "TaskRunnerStepFunctionTaskRunnerStepFunctionsRoleDefaultPolicyACCEC854", + "TaskRunnerStepFunctionTaskRunnerStepFunctionsRole706897AC", + ], + "Properties": Object { + "DefinitionString": Object { + "Fn::Join": Array [ + "", + Array [ + "{\\"StartAt\\":\\"Regions for testing\\",\\"States\\":{\\"Regions for testing\\":{\\"Type\\":\\"Map\\",\\"ResultPath\\":null,\\"Next\\":\\"Parse result\\",\\"InputPath\\":\\"$\\",\\"Parameters\\":{\\"testTaskConfig.$\\":\\"$$.Map.Item.Value\\",\\"testId.$\\":\\"$.testId\\",\\"testType.$\\":\\"$.testType\\",\\"fileType.$\\":\\"$.fileType\\",\\"showLive.$\\":\\"$.showLive\\",\\"prefix.$\\":\\"$.prefix\\"},\\"Iterator\\":{\\"StartAt\\":\\"Check running tests\\",\\"States\\":{\\"Check running tests\\":{\\"Next\\":\\"No running tests\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"InputPath\\":\\"$\\",\\"OutputPath\\":\\"$.Payload\\",\\"Resource\\":\\"arn:", + Object { + "Ref": "AWS::Partition", + }, + ":states:::lambda:invoke\\",\\"Parameters\\":{\\"FunctionName\\":\\"", + Object { + "Fn::GetAtt": Array [ + "TestFunction22AD90FC", + "Arn", + ], + }, + "\\",\\"Payload.$\\":\\"$\\"}},\\"No running tests\\":{\\"Type\\":\\"Choice\\",\\"Choices\\":[{\\"Variable\\":\\"$.isRunning\\",\\"BooleanEquals\\":false,\\"Next\\":\\"Run workers\\"}],\\"Default\\":\\"Test is still running\\"},\\"Test is still running\\":{\\"Type\\":\\"Fail\\",\\"Error\\":\\"TestAlreadyRunning\\",\\"Cause\\":\\"The same test is already running.\\"},\\"Run workers\\":{\\"Next\\":\\"Requires leader?\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"InputPath\\":\\"$\\",\\"OutputPath\\":\\"$.Payload\\",\\"Resource\\":\\"arn:", + Object { + "Ref": "AWS::Partition", + }, + ":states:::lambda:invoke\\",\\"Parameters\\":{\\"FunctionName\\":\\"", + Object { + "Fn::GetAtt": Array [ + "TestFunction22AD90FC", + "Arn", + ], + }, + "\\",\\"Payload.$\\":\\"$\\"}},\\"Requires leader?\\":{\\"Type\\":\\"Choice\\",\\"Choices\\":[{\\"Variable\\":\\"$.isRunning\\",\\"BooleanEquals\\":false,\\"Next\\":\\"Cancel Test\\"},{\\"Variable\\":\\"$.taskIds\\",\\"IsPresent\\":false,\\"Next\\":\\"Wait 1 minute - task status\\"}],\\"Default\\":\\"Wait 1 minute - worker status\\"},\\"Wait 1 minute - worker status\\":{\\"Type\\":\\"Wait\\",\\"Comment\\":\\"Wait 1 minute to check task status again\\",\\"Seconds\\":60,\\"Next\\":\\"Check worker status\\"},\\"Check worker status\\":{\\"Next\\":\\"Are all workers running?\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"InputPath\\":\\"$\\",\\"OutputPath\\":\\"$.Payload\\",\\"Resource\\":\\"arn:", + Object { + "Ref": "AWS::Partition", + }, + ":states:::lambda:invoke\\",\\"Parameters\\":{\\"FunctionName\\":\\"", + Object { + "Fn::GetAtt": Array [ + "TestFunction22AD90FC", + "Arn", + ], + }, + "\\",\\"Payload.$\\":\\"$\\"}},\\"Are all workers running?\\":{\\"Type\\":\\"Choice\\",\\"Choices\\":[{\\"Variable\\":\\"$.isRunning\\",\\"BooleanEquals\\":false,\\"Next\\":\\"Cancel Test\\"},{\\"Variable\\":\\"$.numTasksRunning\\",\\"NumericEqualsPath\\":\\"$.numTasksTotal\\",\\"Next\\":\\"Run leader task\\"}],\\"Default\\":\\"Wait 1 minute - worker status\\"},\\"Cancel Test\\":{\\"Next\\":\\"Map End\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"InputPath\\":\\"$\\",\\"ResultPath\\":null,\\"Resource\\":\\"arn:", + Object { + "Ref": "AWS::Partition", + }, + ":states:::lambda:invoke\\",\\"Parameters\\":{\\"FunctionName\\":\\"", + Object { + "Fn::GetAtt": Array [ + "TestFunction22AD90FC", + "Arn", + ], + }, + "\\",\\"Payload.$\\":\\"$\\"}},\\"Run leader task\\":{\\"Next\\":\\"Wait 1 minute - task status\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Catch\\":[{\\"ErrorEquals\\":[\\"States.ALL\\"],\\"ResultPath\\":\\"$.error\\",\\"Next\\":\\"Cancel Test\\"}],\\"Type\\":\\"Task\\",\\"InputPath\\":\\"$\\",\\"OutputPath\\":\\"$.Payload\\",\\"Resource\\":\\"arn:", + Object { + "Ref": "AWS::Partition", + }, + ":states:::lambda:invoke\\",\\"Parameters\\":{\\"FunctionName\\":\\"", + Object { + "Fn::GetAtt": Array [ + "TestFunction22AD90FC", + "Arn", + ], + }, + "\\",\\"Payload.$\\":\\"$\\"}},\\"Wait 1 minute - task status\\":{\\"Type\\":\\"Wait\\",\\"Comment\\":\\"Wait 1 minute to check task status again\\",\\"Seconds\\":60,\\"Next\\":\\"Check task status\\"},\\"Check task status\\":{\\"Next\\":\\"Are all tasks done?\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"InputPath\\":\\"$\\",\\"OutputPath\\":\\"$.Payload\\",\\"Resource\\":\\"arn:", + Object { + "Ref": "AWS::Partition", + }, + ":states:::lambda:invoke\\",\\"Parameters\\":{\\"FunctionName\\":\\"", + Object { + "Fn::GetAtt": Array [ + "TestFunction22AD90FC", + "Arn", + ], + }, + "\\",\\"Payload.$\\":\\"$\\"}},\\"Are all tasks done?\\":{\\"Type\\":\\"Choice\\",\\"Choices\\":[{\\"Variable\\":\\"$.isRunning\\",\\"BooleanEquals\\":false,\\"Next\\":\\"Map End\\"}],\\"Default\\":\\"Wait 1 minute - task status\\"},\\"Map End\\":{\\"Type\\":\\"Pass\\",\\"End\\":true}}},\\"ItemsPath\\":\\"$.testTaskConfig\\"},\\"Parse result\\":{\\"Next\\":\\"Done\\",\\"Retry\\":[{\\"ErrorEquals\\":[\\"Lambda.ServiceException\\",\\"Lambda.AWSLambdaException\\",\\"Lambda.SdkClientException\\"],\\"IntervalSeconds\\":2,\\"MaxAttempts\\":6,\\"BackoffRate\\":2}],\\"Type\\":\\"Task\\",\\"Resource\\":\\"arn:", + Object { + "Ref": "AWS::Partition", + }, + ":states:::lambda:invoke\\",\\"Parameters\\":{\\"FunctionName\\":\\"", + Object { + "Fn::GetAtt": Array [ + "TestFunction22AD90FC", + "Arn", + ], + }, + "\\",\\"Payload.$\\":\\"$\\"}},\\"Done\\":{\\"Type\\":\\"Succeed\\"}}}", + ], + ], + }, + "LoggingConfiguration": Object { + "Destinations": Array [ + Object { + "CloudWatchLogsLogGroup": Object { + "LogGroupArn": Object { + "Fn::GetAtt": Array [ + "TaskRunnerStepFunctionStepFunctionsLogGroup39F53B04", + "Arn", + ], + }, + }, + }, + ], + "IncludeExecutionData": false, + "Level": "ALL", + }, + "RoleArn": Object { + "Fn::GetAtt": Array [ + "TaskRunnerStepFunctionTaskRunnerStepFunctionsRole706897AC", + "Arn", + ], + }, + }, + "Type": "AWS::StepFunctions::StateMachine", + }, + "TaskRunnerStepFunctionTaskRunnerStepFunctionsRole706897AC": Object { + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W11", + "reason": "CloudWatch logs actions do not support resource level permissions", + }, + Object { + "id": "W12", + "reason": "CloudWatch logs actions do not support resource level permissions", + }, + ], + }, + }, + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": Object { + "Fn::FindInMap": Array [ + "ServiceprincipalMap", + Object { + "Ref": "AWS::Region", + }, + "states", + ], + }, + }, + }, + ], + "Version": "2012-10-17", + }, + }, + "Type": "AWS::IAM::Role", + }, + "TaskRunnerStepFunctionTaskRunnerStepFunctionsRoleDefaultPolicyACCEC854": Object { + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W12", + "reason": "CloudWatch logs actions do not support resource level permissions", + }, + Object { + "id": "W76", + "reason": "The IAM policy is written for least-privilege access.", + }, + ], + }, + }, + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": Array [ + "logs:CreateLogDelivery", + "logs:GetLogDelivery", + "logs:UpdateLogDelivery", + "logs:DeleteLogDelivery", + "logs:ListLogDeliveries", + "logs:PutResourcePolicy", + "logs:DescribeResourcePolicies", + "logs:DescribeLogGroups", + ], + "Effect": "Allow", + "Resource": "*", + }, + Object { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": Array [ + Object { + "Fn::GetAtt": Array [ + "TestFunction22AD90FC", + "Arn", + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Fn::GetAtt": Array [ + "TestFunction22AD90FC", + "Arn", + ], + }, + ":*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "TaskRunnerStepFunctionTaskRunnerStepFunctionsRoleDefaultPolicyACCEC854", + "Roles": Array [ + Object { + "Ref": "TaskRunnerStepFunctionTaskRunnerStepFunctionsRole706897AC", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "TestFunction22AD90FC": Object { + "DependsOn": Array [ + "TestRole6C9272DF", + ], + "Properties": Object { + "Code": Object { + "S3Bucket": "testbucket", + "S3Key": "custom-resource.zip", + }, + "Handler": "index.handler", + "Role": Object { + "Fn::GetAtt": Array [ + "TestRole6C9272DF", + "Arn", + ], + }, + "Runtime": "nodejs14.x", + }, + "Type": "AWS::Lambda::Function", + }, + "TestRole6C9272DF": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": Array [ + Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "*", + "Effect": "Deny", + "Resource": "*", + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "DenyPolicy", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + }, +} +`; diff --git a/source/infrastructure/test/__snapshots__/test-task-lambdas.test.ts.snap b/source/infrastructure/test/__snapshots__/test-task-lambdas.test.ts.snap new file mode 100644 index 0000000..38dd831 --- /dev/null +++ b/source/infrastructure/test/__snapshots__/test-task-lambdas.test.ts.snap @@ -0,0 +1,712 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DLT Task Lambda Test 1`] = ` +Object { + "Resources": Object { + "TaskRunnerLambdaFunctionsDLTTestLambdaTaskRoleCB13DE78": Object { + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W11", + "reason": "ecs:ListTasks does not support resource level permissions", + }, + ], + }, + }, + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": Array [ + Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "ecs:ListTasks", + "Effect": "Allow", + "Resource": "*", + }, + Object { + "Action": Array [ + "ecs:RunTask", + "ecs:DescribeTasks", + ], + "Effect": "Allow", + "Resource": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":ecs:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":task/*", + ], + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":ecs:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":task-definition/*:*", + ], + ], + }, + ], + }, + Object { + "Action": "iam:PassRole", + "Effect": "Allow", + "Resource": "arn:aws:iam:us-east-1:111122223333:roleArn", + }, + Object { + "Action": "logs:PutMetricFilter", + "Effect": "Allow", + "Resource": Object { + "Fn::GetAtt": Array [ + "TestLogsGroup54B681C7", + "Arn", + ], + }, + }, + Object { + "Action": "cloudwatch:PutDashboard", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":cloudwatch::", + Object { + "Ref": "AWS::AccountId", + }, + ":dashboard/EcsLoadTesting*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "TaskLambdaPolicy", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "TaskRunnerLambdaFunctionsLambdaResultsPolicy6D0AF2FB": Object { + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W12", + "reason": "The action does not support resource level permissions.", + }, + ], + }, + }, + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "cloudwatch:GetMetricWidgetImage", + "Effect": "Allow", + "Resource": "*", + }, + Object { + "Action": "logs:DeleteMetricFilter", + "Effect": "Allow", + "Resource": Object { + "Fn::GetAtt": Array [ + "TestLogsGroup54B681C7", + "Arn", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "TaskRunnerLambdaFunctionsLambdaResultsPolicy6D0AF2FB", + "Roles": Array [ + Object { + "Ref": "TaskRunnerLambdaFunctionsLambdaResultsRole1AF5AB18", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "TaskRunnerLambdaFunctionsLambdaResultsRole1AF5AB18": Object { + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W12", + "reason": "The action does not support resource level permissions.", + }, + ], + }, + }, + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + }, + "Type": "AWS::IAM::Role", + }, + "TaskRunnerLambdaFunctionsLambdaTaskCancelerRoleEEC6795B": Object { + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W11", + "reason": "ecs:ListTasks does not support resource level permissions", + }, + ], + }, + }, + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": Array [ + Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "ecs:ListTasks", + "Effect": "Allow", + "Resource": "*", + }, + Object { + "Action": "ecs:StopTask", + "Effect": "Allow", + "Resource": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":ecs:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":task/*", + ], + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":ecs:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":task-definition/*:*", + ], + ], + }, + ], + }, + Object { + "Action": "dynamodb:UpdateItem", + "Effect": "Allow", + "Resource": Object { + "Fn::GetAtt": Array [ + "TestTable5769773A", + "Arn", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "TaskCancelerPolicy", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "TaskRunnerLambdaFunctionsResultsParserD7D42F22": Object { + "DependsOn": Array [ + "TaskRunnerLambdaFunctionsLambdaResultsRole1AF5AB18", + ], + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W58", + "reason": "CloudWatchLogsPolicy covers a permission to write CloudWatch logs.", + }, + Object { + "id": "W89", + "reason": "This Lambda function does not require a VPC", + }, + Object { + "id": "W92", + "reason": "Does not run concurrent executions", + }, + ], + }, + }, + "Properties": Object { + "Code": Object { + "S3Bucket": "testbucket", + "S3Key": "testPrefix/results-parser.zip", + }, + "Description": "Result parser for indexing xml test results to DynamoDB", + "Environment": Object { + "Variables": Object { + "HISTORY_TABLE": Object { + "Ref": "TestTable5769773A", + }, + "METRIC_URL": "test.example.net", + "SCENARIOS_BUCKET": "testBucket", + "SCENARIOS_TABLE": Object { + "Ref": "TestTable5769773A", + }, + "SEND_METRIC": "No", + "SOLUTION_ID": "testId", + "UUID": "testId", + "VERSION": "testVersion", + }, + }, + "Handler": "index.handler", + "Role": Object { + "Fn::GetAtt": Array [ + "TaskRunnerLambdaFunctionsLambdaResultsRole1AF5AB18", + "Arn", + ], + }, + "Runtime": "nodejs14.x", + "Timeout": 120, + }, + "Type": "AWS::Lambda::Function", + }, + "TaskRunnerLambdaFunctionsTaskCancelerDAA76ED9": Object { + "DependsOn": Array [ + "TaskRunnerLambdaFunctionsLambdaTaskCancelerRoleEEC6795B", + ], + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W58", + "reason": "CloudWatchLogsPolicy covers a permission to write CloudWatch logs.", + }, + Object { + "id": "W89", + "reason": "This Lambda function does not require a VPC", + }, + Object { + "id": "W92", + "reason": "Does not run concurrent executions", + }, + ], + }, + }, + "Properties": Object { + "Code": Object { + "S3Bucket": "testbucket", + "S3Key": "testPrefix/task-canceler.zip", + }, + "Description": "Stops ECS task", + "Environment": Object { + "Variables": Object { + "METRIC_URL": "test.example.net", + "SCENARIOS_TABLE": Object { + "Ref": "TestTable5769773A", + }, + "SOLUTION_ID": "testId", + "VERSION": "testVersion", + }, + }, + "Handler": "index.handler", + "Role": Object { + "Fn::GetAtt": Array [ + "TaskRunnerLambdaFunctionsLambdaTaskCancelerRoleEEC6795B", + "Arn", + ], + }, + "Runtime": "nodejs14.x", + "Timeout": 300, + }, + "Type": "AWS::Lambda::Function", + }, + "TaskRunnerLambdaFunctionsTaskCancelerInvokePolicyD51068E2": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": Object { + "Fn::GetAtt": Array [ + "TaskRunnerLambdaFunctionsTaskCancelerDAA76ED9", + "Arn", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "TaskRunnerLambdaFunctionsTaskCancelerInvokePolicyD51068E2", + "Roles": Array [ + Object { + "Ref": "TaskRunnerLambdaFunctionsTaskStatusRole4B498DE5", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "TaskRunnerLambdaFunctionsTaskRunner626792CE": Object { + "DependsOn": Array [ + "TaskRunnerLambdaFunctionsDLTTestLambdaTaskRoleCB13DE78", + ], + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W58", + "reason": "CloudWatchLogsPolicy covers a permission to write CloudWatch logs.", + }, + Object { + "id": "W89", + "reason": "This Lambda function does not require a VPC", + }, + Object { + "id": "W92", + "reason": "Does not run concurrent executions", + }, + ], + }, + }, + "Properties": Object { + "Code": Object { + "S3Bucket": "testbucket", + "S3Key": "testPrefix/task-runner.zip", + }, + "Description": "Task runner for ECS task definitions", + "Environment": Object { + "Variables": Object { + "SCENARIOS_BUCKET": "testBucket", + "SCENARIOS_TABLE": Object { + "Ref": "TestTable5769773A", + }, + "SOLUTION_ID": "testId", + "VERSION": "testVersion", + }, + }, + "Handler": "index.handler", + "Role": Object { + "Fn::GetAtt": Array [ + "TaskRunnerLambdaFunctionsDLTTestLambdaTaskRoleCB13DE78", + "Arn", + ], + }, + "Runtime": "nodejs14.x", + "Timeout": 900, + }, + "Type": "AWS::Lambda::Function", + }, + "TaskRunnerLambdaFunctionsTaskStatusCheckerBA69E13B": Object { + "DependsOn": Array [ + "TaskRunnerLambdaFunctionsTaskStatusRole4B498DE5", + ], + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W58", + "reason": "CloudWatchLogsPolicy covers a permission to write CloudWatch logs.", + }, + Object { + "id": "W89", + "reason": "This Lambda function does not require a VPC", + }, + Object { + "id": "W92", + "reason": "Does not run concurrent executions", + }, + ], + }, + }, + "Properties": Object { + "Code": Object { + "S3Bucket": "testbucket", + "S3Key": "testPrefix/task-status-checker.zip", + }, + "Description": "Task status checker", + "Environment": Object { + "Variables": Object { + "SCENARIOS_TABLE": Object { + "Ref": "TestTable5769773A", + }, + "SOLUTION_ID": "testId", + "TASK_CANCELER_ARN": Object { + "Fn::GetAtt": Array [ + "TaskRunnerLambdaFunctionsTaskCancelerDAA76ED9", + "Arn", + ], + }, + "VERSION": "testVersion", + }, + }, + "Handler": "index.handler", + "Role": Object { + "Fn::GetAtt": Array [ + "TaskRunnerLambdaFunctionsTaskStatusRole4B498DE5", + "Arn", + ], + }, + "Runtime": "nodejs14.x", + "Timeout": 180, + }, + "Type": "AWS::Lambda::Function", + }, + "TaskRunnerLambdaFunctionsTaskStatusRole4B498DE5": Object { + "Metadata": Object { + "cfn_nag": Object { + "rules_to_suppress": Array [ + Object { + "id": "W11", + "reason": "ecs:ListTasks does not support resource level permissions", + }, + ], + }, + }, + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": Array [ + Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "ecs:ListTasks", + "Effect": "Allow", + "Resource": "*", + }, + Object { + "Action": "ecs:DescribeTasks", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":ecs:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":task/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "TaskStatusPolicy", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "TestDynamoDBPolicy7AA7B6CD": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "dynamodb:*", + "Effect": "Deny", + "Resource": "*", + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "TestDynamoDBPolicy7AA7B6CD", + "Roles": Array [ + Object { + "Ref": "TaskRunnerLambdaFunctionsLambdaResultsRole1AF5AB18", + }, + Object { + "Ref": "TaskRunnerLambdaFunctionsDLTTestLambdaTaskRoleCB13DE78", + }, + Object { + "Ref": "TaskRunnerLambdaFunctionsTaskStatusRole4B498DE5", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "TestLogsGroup54B681C7": Object { + "DeletionPolicy": "Retain", + "Properties": Object { + "RetentionInDays": 731, + }, + "Type": "AWS::Logs::LogGroup", + "UpdateReplacePolicy": "Retain", + }, + "TestPolicyCC05E598": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "cloudwatch:Get*", + "Effect": "Allow", + "Resource": "*", + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "TestPolicyCC05E598", + "Roles": Array [ + Object { + "Ref": "TaskRunnerLambdaFunctionsLambdaResultsRole1AF5AB18", + }, + Object { + "Ref": "TaskRunnerLambdaFunctionsDLTTestLambdaTaskRoleCB13DE78", + }, + Object { + "Ref": "TaskRunnerLambdaFunctionsLambdaTaskCancelerRoleEEC6795B", + }, + Object { + "Ref": "TaskRunnerLambdaFunctionsTaskStatusRole4B498DE5", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "TestS3Policy438528DE": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "s3:*", + "Effect": "Deny", + "Resource": "*", + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "TestS3Policy438528DE", + "Roles": Array [ + Object { + "Ref": "TaskRunnerLambdaFunctionsLambdaResultsRole1AF5AB18", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "TestTable5769773A": Object { + "DeletionPolicy": "Retain", + "Properties": Object { + "AttributeDefinitions": Array [ + Object { + "AttributeName": "id", + "AttributeType": "S", + }, + ], + "KeySchema": Array [ + Object { + "AttributeName": "id", + "KeyType": "HASH", + }, + ], + "ProvisionedThroughput": Object { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5, + }, + }, + "Type": "AWS::DynamoDB::Table", + "UpdateReplacePolicy": "Retain", + }, + }, +} +`; diff --git a/source/infrastructure/test/__snapshots__/vpc.test.ts.snap b/source/infrastructure/test/__snapshots__/vpc.test.ts.snap index e363c1d..1f636f8 100644 --- a/source/infrastructure/test/__snapshots__/vpc.test.ts.snap +++ b/source/infrastructure/test/__snapshots__/vpc.test.ts.snap @@ -4,24 +4,10 @@ exports[`DLT VPC Test 1`] = ` Object { "Resources": Object { "TestVPCDLTFargateIG4FFBAA11": Object { - "Properties": Object { - "Tags": Array [ - Object { - "Key": "SolutionId", - "Value": "SO0062", - }, - ], - }, "Type": "AWS::EC2::InternetGateway", }, "TestVPCDLTFargateRT6952750D": Object { "Properties": Object { - "Tags": Array [ - Object { - "Key": "SolutionId", - "Value": "SO0062", - }, - ], "VpcId": Object { "Ref": "TestVPCDLTFargateVpc0EC32C36", }, @@ -50,10 +36,6 @@ Object { "Ref": "AWS::StackName", }, }, - Object { - "Key": "SolutionId", - "Value": "SO0062", - }, ], }, "Type": "AWS::EC2::VPC", @@ -117,12 +99,6 @@ Object { ], }, "CidrBlock": "10.0.0.0/24", - "Tags": Array [ - Object { - "Key": "SolutionId", - "Value": "SO0062", - }, - ], "VpcId": Object { "Ref": "TestVPCDLTFargateVpc0EC32C36", }, @@ -140,12 +116,6 @@ Object { ], }, "CidrBlock": "10.0.1.0/24", - "Tags": Array [ - Object { - "Key": "SolutionId", - "Value": "SO0062", - }, - ], "VpcId": Object { "Ref": "TestVPCDLTFargateVpc0EC32C36", }, diff --git a/source/infrastructure/test/api.test.ts b/source/infrastructure/test/api.test.ts index bf745e0..2b72063 100644 --- a/source/infrastructure/test/api.test.ts +++ b/source/infrastructure/test/api.test.ts @@ -3,11 +3,11 @@ import '@aws-cdk/assert/jest'; import { SynthUtils } from '@aws-cdk/assert'; -import { Stack } from '@aws-cdk/core'; -import { LogGroup } from '@aws-cdk/aws-logs'; -import { DLTAPI } from '../lib/api'; -import { Bucket } from '@aws-cdk/aws-s3'; -import { Policy, PolicyStatement } from '@aws-cdk/aws-iam'; +import { Stack } from 'aws-cdk-lib'; +import { LogGroup } from 'aws-cdk-lib/aws-logs'; +import { DLTAPI } from '../lib/front-end/api'; +import { Bucket } from 'aws-cdk-lib/aws-s3'; +import { Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam'; test('DLT API Test', () => { const stack = new Stack(); @@ -22,17 +22,18 @@ test('DLT API Test', () => { }); const testSourceBucket = Bucket.fromBucketName(stack, 'SourceCodeBucket', 'test-bucket-region'); const api = new DLTAPI(stack, 'TestAPI', { - ecsCloudWatchLogGroup: testLog, cloudWatchLogsPolicy: testPolicy, - dynamoDbPolicy: testPolicy, + ecsCloudWatchLogGroup: testLog, + historyDynamoDbPolicy: testPolicy, + historyTable: 'testHistoryDDBTable', + scenariosDynamoDbPolicy: testPolicy, taskCancelerInvokePolicy: testPolicy, scenariosS3Policy: testPolicy, scenariosBucketName: 'testScenarioBucketName', scenariosTableName: 'testDDBTable', - ecsCuster: 'testECSCluster', ecsTaskExecutionRoleArn: 'arn:aws:iam::1234567890:role/MyRole-AJJHDSKSDF', taskRunnerStepFunctionsArn: 'arn:aws:states:us-east-1:111122223333:stateMachine:HelloWorld-StateMachine', - tastCancelerArn: 'arn:aws:lambda:us-east-1:111122223333:function:HelloFunction', + taskCancelerArn: 'arn:aws:lambda:us-east-1:111122223333:function:HelloFunction', metricsUrl: 'http://testurl.com', sendAnonymousUsage: 'Yes', solutionId: 'testId', @@ -41,6 +42,33 @@ test('DLT API Test', () => { sourceCodePrefix: 'testPrefix/', uuid: 'abc123' }); - expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); + expect(api.apiId).toBeDefined(); + expect(api.apiEndpointPath).toBeDefined(); + expect(stack).toHaveResource('AWS::Lambda::Function', { + Description: 'API microservices for creating, updating, listing and deleting test scenarios', + Handler: 'index.handler', + Runtime: 'nodejs14.x', + Environment: { + Variables: { + HISTORY_TABLE: "testHistoryDDBTable", + SCENARIOS_BUCKET: 'testScenarioBucketName', + SCENARIOS_TABLE: 'testDDBTable', + STATE_MACHINE_ARN: 'arn:aws:states:us-east-1:111122223333:stateMachine:HelloWorld-StateMachine', + SOLUTION_ID: 'testId', + UUID: 'abc123', + VERSION: 'testVersion', + SEND_METRIC: 'Yes', + METRIC_URL: 'http://testurl.com', + TASK_CANCELER_ARN: 'arn:aws:lambda:us-east-1:111122223333:function:HelloFunction', + STACK_ID: { + "Ref": "AWS::StackId" + } + } + } + }); + expect(stack).toHaveResourceLike('AWS::ApiGateway::RequestValidator', { + ValidateRequestBody: true, + ValidateRequestParameters: true + }); }); diff --git a/source/infrastructure/test/auth.test.ts b/source/infrastructure/test/auth.test.ts index 7ef6f0f..04942d2 100644 --- a/source/infrastructure/test/auth.test.ts +++ b/source/infrastructure/test/auth.test.ts @@ -3,9 +3,9 @@ import '@aws-cdk/assert/jest'; import { SynthUtils } from '@aws-cdk/assert'; -import { Stack } from '@aws-cdk/core'; +import { Stack } from 'aws-cdk-lib'; -import { CognitoAuthConstruct } from '../lib/auth'; +import { CognitoAuthConstruct } from '../lib/front-end/auth'; test('DLT API Test', () => { @@ -20,4 +20,7 @@ test('DLT API Test', () => { }); expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); + expect(auth.cognitoIdentityPoolId).toBeDefined(); + expect(auth.cognitoUserPoolClientId).toBeDefined(); + expect(auth.cognitoUserPoolId).toBeDefined(); }); diff --git a/source/infrastructure/test/common-resources.test.ts b/source/infrastructure/test/common-resources.test.ts index d2eb557..d369e94 100644 --- a/source/infrastructure/test/common-resources.test.ts +++ b/source/infrastructure/test/common-resources.test.ts @@ -3,30 +3,17 @@ import '@aws-cdk/assert/jest'; import { SynthUtils } from '@aws-cdk/assert'; -import { CfnCondition, Fn, Stack } from '@aws-cdk/core'; -import { Role, ServicePrincipal } from '@aws-cdk/aws-iam'; -import { CommonResourcesContruct } from '../lib/common-resources'; +import { Stack } from 'aws-cdk-lib'; +import { CommonResourcesConstruct } from '../lib/common-resources/common-resources'; test('DLT API Test', () => { const stack = new Stack(); - const testRole = new Role(stack, 'TestRole', { - assumedBy: new ServicePrincipal('apigateway.amazonaws.com') - }); - const sendAnonymousUsageCondition = new CfnCondition(stack, 'condition', { - expression: Fn.conditionIf('testCondition', true, false) - }) - const boolExistingVpc = 'false'; - const common = new CommonResourcesContruct(stack, 'TestCommonResources', { - dltEcsTaskExecutionRole: testRole, - solutionId: 'testId', - solutionVersion: 'testVersion', - sourceCodeBucket: 'testbucketname', - sourceCodePrefix: '/testPrefix', - sendAnonymousUsageCondition, - existingVpc: boolExistingVpc, - metricsUrl: 'http://testMetricsUrl.com' + const common = new CommonResourcesConstruct(stack, 'TestCommonResources', { + sourceCodeBucket: 'testbucketname' }); expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); + expect(common.s3LogsBucket).toBeDefined(); + expect(common.sourceBucket).toBeDefined(); }); diff --git a/source/infrastructure/test/console.test.ts b/source/infrastructure/test/console.test.ts index 4cfa7e8..591fae9 100644 --- a/source/infrastructure/test/console.test.ts +++ b/source/infrastructure/test/console.test.ts @@ -3,44 +3,22 @@ import '@aws-cdk/assert/jest'; import { SynthUtils } from '@aws-cdk/assert'; -import { Stack } from '@aws-cdk/core'; -import { DLTConsoleContruct } from '../lib/console'; -import { Code, Function as LambdaFunction, Runtime } from '@aws-cdk/aws-lambda'; -import { Bucket } from '@aws-cdk/aws-s3'; -import { Effect, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from '@aws-cdk/aws-iam'; +import { Stack } from 'aws-cdk-lib'; +import { DLTConsoleConstruct } from '../lib/front-end/console'; +import { Bucket } from 'aws-cdk-lib/aws-s3'; test('DLT API Test', () => { const stack = new Stack(); const testSourceBucket = new Bucket(stack, 'testSourceCodeBucket'); - const testRole = new Role(stack, 'TestCustomResourceRole', { - assumedBy: new ServicePrincipal('lambda.amazonaws.com'), - inlinePolicies: { - 'DenyPolicy': new PolicyDocument({ - statements: [ - new PolicyStatement({ - effect: Effect.DENY, - actions: ['*'], - resources: ['*'] - }) - ] - }) - } - }); - const testLambda = new LambdaFunction(stack, 'TestFunction', { - code: Code.fromBucket(Bucket.fromBucketName(stack, 'SourceCodeBucket', 'TestBucket'), 'custom-resource.zip'), - handler: 'index.handler', - runtime: Runtime.NODEJS_14_X, - role: testRole - }); - - - const console = new DLTConsoleContruct(stack, 'TestConsoleResources', { - customResource: testLambda, + const console = new DLTConsoleConstruct(stack, 'TestConsoleResources', { s3LogsBucket: testSourceBucket, solutionId: 'testId', }); expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); + expect(console.cloudFrontDomainName).toBeDefined(); + expect(console.consoleBucket).toBeDefined(); + expect(console.consoleBucketArn).toBeDefined(); }); diff --git a/source/infrastructure/test/custom-resources-infra.test.ts b/source/infrastructure/test/custom-resources-infra.test.ts new file mode 100644 index 0000000..da88a78 --- /dev/null +++ b/source/infrastructure/test/custom-resources-infra.test.ts @@ -0,0 +1,68 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import '@aws-cdk/assert/jest'; +import { SynthUtils } from '@aws-cdk/assert'; +import { Stack } from 'aws-cdk-lib'; +import { Bucket } from 'aws-cdk-lib/aws-s3'; +import { Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { CustomResourceInfraConstruct } from '../lib/custom-resources/custom-resources-infra'; + + +test('DLT API Test', () => { + + const stack = new Stack(); + const testSourceBucket = new Bucket(stack, 'testSourceCodeBucket'); + const testPolicy = new Policy(stack, 'TestPolicy', { + statements: [ + new PolicyStatement({ + resources: ['*'], + actions: ['cloudwatch:Get*'] + }) + ] + }); + + new CustomResourceInfraConstruct(stack, 'TestCustomResourceInfra', { + cloudWatchPolicy: testPolicy, + consoleBucketArn: 'test:console:bucket:arn', + mainStackRegion: 'test-region-1', + metricsUrl: 'http://testurl.com', + scenariosS3Bucket: 'scenariotestbucket', + scenariosTable: 'scenarioTestTable', + solutionId: 'S0XXX', + solutionVersion: 'testVersion', + sourceCodeBucket: testSourceBucket, + sourceCodePrefix: 'test/source/prefix', + stackType: 'main' + }); + + expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); + expect(stack).toHaveResourceLike('AWS::Lambda::Function', { + Code: { + S3Bucket: { + "Ref": "testSourceCodeBucketC577B176", + }, + S3Key: "test/source/prefix/main-custom-resource.zip", + }, + Description: "CFN Lambda backed custom resource to deploy assets to s3", + Environment: { + Variables: { + DDB_TABLE: "scenarioTestTable", + MAIN_REGION: "test-region-1", + METRIC_URL: "http://testurl.com", + S3_BUCKET: "scenariotestbucket", + SOLUTION_ID: "S0XXX", + VERSION: "testVersion", + } + }, + Handler: "index.handler", + Role: { + "Fn::GetAtt": [ + "TestCustomResourceInfraCustomResourceLambdaRole03671AE8", + "Arn", + ], + }, + Runtime: "nodejs14.x", + Timeout: 120 + }); +}); diff --git a/source/infrastructure/test/custom-resources.test.ts b/source/infrastructure/test/custom-resources.test.ts index 80faadf..7c18d30 100644 --- a/source/infrastructure/test/custom-resources.test.ts +++ b/source/infrastructure/test/custom-resources.test.ts @@ -3,25 +3,98 @@ import '@aws-cdk/assert/jest'; import { SynthUtils } from '@aws-cdk/assert'; -import { Stack } from '@aws-cdk/core'; -import { CustomResourcesConstruct } from '../lib/custom-resources'; +import { CfnCondition, Fn, Stack } from 'aws-cdk-lib'; +import { Bucket } from 'aws-cdk-lib/aws-s3'; +import { Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { CustomResourcesConstruct } from '../lib/custom-resources/custom-resources'; +import { CustomResourceInfraConstruct } from '../lib/custom-resources/custom-resources-infra'; test('DLT API Test', () => { + const stack = new Stack(); + const testSourceBucket = new Bucket(stack, 'testSourceCodeBucket'); + const testPolicy = new Policy(stack, 'TestPolicy', { + statements: [ + new PolicyStatement({ + resources: ['*'], + actions: ['cloudwatch:Get*'] + }) + ] + }); + + const testCustomResourceInfra = new CustomResourceInfraConstruct(stack, 'TestCustomResourceInfra', { + cloudWatchPolicy: testPolicy, + consoleBucketArn: 'test:console:bucket:arn', + mainStackRegion: 'test-region-1', + metricsUrl: 'http://testurl.com', + scenariosS3Bucket: 'scenariotestbucket', + scenariosTable: 'scenarioTestTable', + solutionId: 'S0XXX', + solutionVersion: 'testVersion', + sourceCodeBucket: testSourceBucket, + sourceCodePrefix: 'test/source/prefix', + stackType: 'main' + }); + + const sendAnonymousUsageCondition = new CfnCondition(stack, 'condition', { + expression: Fn.conditionIf('testCondition', true, false) + }); + const boolExistingVpc = 'false'; + + const customResources = new CustomResourcesConstruct(stack, 'DLTCustomResources', { + customResourceLambdaArn: testCustomResourceInfra.customResourceArn + }); + + customResources.copyConsoleFiles({ + consoleBucketName: 'testconsolebucket', + scenariosBucket: 'testscenariosbucket', + sourceCodeBucketName: 'testcodebucket', + sourceCodePrefix: 'testCodePrefix/', + }); - const custResources = new CustomResourcesConstruct(stack, 'TestCustomResources', { + customResources.consoleConfig({ apiEndpoint: 'http://testEndpointUrl.com', - customResourceLambda: 'testcustomlambda', cognitoIdentityPool: 'testIdentityPool', cognitoUserPool: 'testUserPool', cognitoUserPoolClient: 'testUserPoolClient', - consoleBucketName: 'testConsoleBucket', - scenariosBucket: 'testScenariosBucket', - sourceCodeBucketName: 'testCodeBucket', - sourceCodePrefix: 'testCodePrefix/', + consoleBucketName: 'testconsolebucket', + scenariosBucket: 'testscenariobucket', + sourceCodeBucketName: 'sourcebucket', + sourceCodePrefix: 'sourcecode/prefix', + iotEndpoint: 'testIoTEndpoint', + iotPolicy: 'testIoTPolicy' + }); + + customResources.testingResourcesConfigCR({ + taskCluster: 'testTaskCluster', + ecsCloudWatchLogGroup: 'testCloudWatchLogGroup', + taskSecurityGroup: 'sg-test123', + taskDefinition: 'task:def:arn:123', + subnetA: 'subnet-123', + subnetB: 'subnet-abc', + uuid: 'abc-123-def-456' + }); + + customResources.sendAnonymousMetricsCR({ + existingVpc: boolExistingVpc, + solutionId: 'testId', + uuid: 'abc-123-def-456', + solutionVersion: 'testVersion', + sendAnonymousUsage: 'Yes', + sendAnonymousUsageCondition, }); expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); - expect(stack).toHaveResource('Custom::CopyConsoleFiles'); - expect(stack).toHaveResource('Custom::CopyConfigFiles'); + expect(stack).toHaveResourceLike('AWS::CloudFormation::CustomResource', { + Resource: "CopyAssets" + }); + expect(stack).toHaveResourceLike('AWS::CloudFormation::CustomResource', { + Resource: "ConfigFile" + }); + expect(stack).toHaveResourceLike('AWS::CloudFormation::CustomResource', { + Resource: "TestingResourcesConfigFile" + }); + expect(stack).toHaveResourceLike('AWS::CloudFormation::CustomResource', { + Resource: "AnonymousMetric" + }); }); diff --git a/source/infrastructure/test/distributed-load-testing-on-aws-regional.test.ts b/source/infrastructure/test/distributed-load-testing-on-aws-regional.test.ts new file mode 100644 index 0000000..006c186 --- /dev/null +++ b/source/infrastructure/test/distributed-load-testing-on-aws-regional.test.ts @@ -0,0 +1,26 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import '@aws-cdk/assert/jest'; +import { SynthUtils } from '@aws-cdk/assert'; +import { App } from 'aws-cdk-lib'; +import { RegionalInfrastructureDLTStack } from '../lib/distributed-load-testing-on-aws-regional-stack'; + +const props = { + codeBucket: 'testbucket', + codeVersion: 'testversion', + description: 'Distributed Load Testing on AWS regional deployment.', + publicECRRegistry: 'testRegistry', + publicECRTag: 'testTag', + solutionId: 'testId', + solutionName: 'distributed-load-testing-on-aws', + stackType: 'regional', + url: 'http://testurl.com' +}; + +test('Distributed Load Testing Regional stack test', () => { + const app = new App(); + const stack = new RegionalInfrastructureDLTStack(app, 'TestDLTRegionalStack', props); + + expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); +}); \ No newline at end of file diff --git a/source/infrastructure/test/distributed-load-testing-on-aws-stack.test.ts b/source/infrastructure/test/distributed-load-testing-on-aws-stack.test.ts index 2dd1661..7c09e5b 100644 --- a/source/infrastructure/test/distributed-load-testing-on-aws-stack.test.ts +++ b/source/infrastructure/test/distributed-load-testing-on-aws-stack.test.ts @@ -3,12 +3,24 @@ import '@aws-cdk/assert/jest'; import { SynthUtils } from '@aws-cdk/assert'; -import { App } from '@aws-cdk/core'; +import { App } from 'aws-cdk-lib'; import { DLTStack } from '../lib/distributed-load-testing-on-aws-stack'; +const props = { + codeBucket: 'testbucket', + codeVersion: 'testversion', + description: 'Distributed Load Testing on AWS is a reference architecture to perform application load testing at scale.', + publicECRRegistry: 'testRegistry', + publicECRTag: 'testTag', + solutionId: 'testId', + solutionName: 'distributed-load-testing-on-aws', + stackType: 'main', + url: 'http://testurl.com' +}; + test('Distributed Load Testing stack test', () => { - const app = new App(); - const stack = new DLTStack(app, 'TestDLTStack'); + const app = new App(); + const stack = new DLTStack(app, 'TestDLTStack', props); - expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); -}); \ No newline at end of file + expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); +}); diff --git a/source/infrastructure/test/ecs.test.ts b/source/infrastructure/test/ecs.test.ts index c32542e..1c46b07 100644 --- a/source/infrastructure/test/ecs.test.ts +++ b/source/infrastructure/test/ecs.test.ts @@ -1,59 +1,72 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 + import '@aws-cdk/assert/jest'; import { SynthUtils } from '@aws-cdk/assert'; -import { Stack } from '@aws-cdk/core'; -import { FargateECSTestRunnerContruct } from '../lib/ecs'; +import { Stack } from 'aws-cdk-lib'; +import { Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { ECSResourcesConstruct } from '../lib/testing-resources/ecs'; test('DLT ECS Test', () => { - const stack = new Stack(); - const ecs = new FargateECSTestRunnerContruct(stack, 'TestECS', { - DLTfargateVpcId: 'vpc-1a2b3c4d5e', - securityGroupEgress: '0.0.0.0/0', - solutionId: 'SO0062' - }); - expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); - expect(stack).toHaveResource('AWS::ECR::Repository', { - ImageScanningConfiguration: { - ScanOnPush: true - } - }); - expect(stack).toHaveResource('AWS::ECS::Cluster', { - ClusterSettings: [ - { - 'Name': 'containerInsights', - 'Value': 'enabled' - } - ] - }); - expect(stack).toHaveResource('AWS::IAM::Role', { - "AssumeRolePolicyDocument": { - "Statement": [ - { - "Action": "sts:AssumeRole", - "Effect": "Allow", - "Principal": { - "Service": "ecs-tasks.amazonaws.com" - } - } - ], - "Version": "2012-10-17" - } - }); - expect(stack).toHaveResource('AWS::Logs::LogGroup', { - "RetentionInDays": 365 - }); - expect(stack).toHaveResource('AWS::ECS::TaskDefinition'); - expect(stack).toHaveResource('AWS::EC2::SecurityGroup'); - expect(stack).toHaveResource('AWS::EC2::SecurityGroupIngress', { - GroupId: { - Ref: "TestECSDLTEcsSecurityGroupFE5016DC", - }, - SourceSecurityGroupId: { - Ref: "TestECSDLTEcsSecurityGroupFE5016DC", + const stack = new Stack(); + const testPolicy = new Policy(stack, 'TestPolicy', { + statements: [ + new PolicyStatement({ + resources: ['*'], + actions: ['cloudwatch:Get*'] + }) + ] + }); + const ecs = new ECSResourcesConstruct(stack, 'TestECS', { + cloudWatchLogsPolicy: testPolicy, + containerImage: 'testRepository/testImage:testTag', + fargateVpcId: 'vpc-1a2b3c4d5e', + scenariosS3Bucket: 'testscenariobucket', + securityGroupEgress: '0.0.0.0/0', + solutionId: 'SO0062' + }); + expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); + expect(stack).toHaveResource('AWS::ECS::Cluster', { + ClusterSettings: [ + { + 'Name': 'containerInsights', + 'Value': 'enabled' + } + ] + }); + expect(stack).toHaveResource('AWS::IAM::Role', { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ecs-tasks.amazonaws.com" + } } - }); - expect(stack).toHaveResource('AWS::EC2::SecurityGroupEgress', { - Description: 'Allow tasks to call out to external resources' - }) + ], + "Version": "2012-10-17" + } + }); + expect(stack).toHaveResource('AWS::Logs::LogGroup', { + "RetentionInDays": 365 + }); + expect(stack).toHaveResource('AWS::ECS::TaskDefinition'); + expect(stack).toHaveResource('AWS::EC2::SecurityGroup'); + expect(stack).toHaveResource('AWS::EC2::SecurityGroupIngress', { + GroupId: { + Ref: "TestECSDLTEcsSecurityGroupFE5016DC", + }, + SourceSecurityGroupId: { + Ref: "TestECSDLTEcsSecurityGroupFE5016DC", + } + }); + expect(stack).toHaveResource('AWS::EC2::SecurityGroupEgress', { + Description: 'Allow tasks to call out to external resources' + }); + expect(ecs.taskClusterName).toBeDefined(); + expect(ecs.ecsCloudWatchLogGroup).toBeDefined(); + expect(ecs.taskDefinitionArn).toBeDefined(); + expect(ecs.taskExecutionRoleArn).toBeDefined(); + expect(ecs.ecsSecurityGroupId).toBeDefined(); }); \ No newline at end of file diff --git a/source/infrastructure/test/real-time-data.test.ts b/source/infrastructure/test/real-time-data.test.ts new file mode 100644 index 0000000..4b2937e --- /dev/null +++ b/source/infrastructure/test/real-time-data.test.ts @@ -0,0 +1,87 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import '@aws-cdk/assert/jest'; +import { SynthUtils } from '@aws-cdk/assert'; +import { Stack } from 'aws-cdk-lib'; +import { Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { LogGroup } from 'aws-cdk-lib/aws-logs'; +import { Bucket } from 'aws-cdk-lib/aws-s3'; +import { RealTimeDataConstruct } from '../lib/testing-resources/real-time-data'; + +test('DLT real time data resources Test', () => { + const stack = new Stack(); + const testPolicy = new Policy(stack, 'TestPolicy', { + statements: [ + new PolicyStatement({ + resources: ['*'], + actions: ['cloudwatch:Get*'] + }) + ] + }); + const testLogGroup = new LogGroup(stack, 'TestLogsGroup'); + const testBucket = Bucket.fromBucketName(stack, 'SourceCodeBucket', 'testbucket'); + + const realTimeData = new RealTimeDataConstruct(stack, 'TestECS', { + cloudWatchLogsPolicy: testPolicy, + ecsCloudWatchLogGroup: testLogGroup, + iotEndpoint: 'iotEndpoint', + mainRegion: 'test-region-1', + solutionId: 'testID', + solutionVersion: 'testVersion', + sourceCodeBucket: testBucket, + sourceCodePrefix: 'testPrefix' + }); + + expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); + expect(stack).toHaveResourceLike("AWS::Lambda::Function", { + Code: { + S3Bucket: "testbucket", + S3Key: "testPrefix/real-time-data-publisher.zip", + }, + Environment: { + Variables: { + MAIN_REGION: 'test-region-1', + IOT_ENDPOINT: 'iotEndpoint', + SOLUTION_ID: "testID", + VERSION: "testVersion", + }, + }, + Handler: "index.handler", + Runtime: "nodejs14.x", + Timeout: 180, + }); + expect(stack).toHaveResourceLike("AWS::Logs::SubscriptionFilter", { + "FilterPattern": '"INFO: Current:" "live=true"', + }); + expect(stack).toHaveResourceLike("AWS::IAM::Role", { + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": "iot:Publish", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iot:test-region-1:", + { + "Ref": "AWS::AccountId", + }, + ":topic/*", + ], + ], + }, + }, + ] + } + } + ] + }); +}); \ No newline at end of file diff --git a/source/infrastructure/test/regional-permissions.test.ts b/source/infrastructure/test/regional-permissions.test.ts new file mode 100644 index 0000000..770200e --- /dev/null +++ b/source/infrastructure/test/regional-permissions.test.ts @@ -0,0 +1,206 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import '@aws-cdk/assert/jest'; +import { SynthUtils } from '@aws-cdk/assert'; +import { Stack } from 'aws-cdk-lib'; +import { RegionalPermissionsConstruct } from '../lib/testing-resources/regional-permissions'; + +test('DLT Regional Permission Test', () => { + const stack = new Stack(); + + new RegionalPermissionsConstruct(stack, 'TestRegionalPermissions', { + apiServicesLambdaRoleName: 'testApiServicesLambdaRoleName', + resultsParserRoleName: 'testResultsParserRoleName', + taskExecutionRoleArn: 'arn:aws:iam::123456789012:role/testRole', + ecsCloudWatchLogGroupArn: 'arn:aws:logs:us-east-2:123456789012:log-group:test_log_group_name', + taskRunnerRoleName: 'testTaskRunnerRoleName', + taskCancelerRoleName: 'testTaskCancelerRoleName', + taskStatusCheckerRoleName: 'testTaskStatusCheckerRoleName' + }); + expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); + expect(stack).toHaveResourceLike("AWS::IAM::Policy", { + PolicyDocument: { + Statement: [ + { + Action: [ + "ecs:RunTask", + "ecs:DescribeTasks", + ], + Effect: "Allow", + Resource: [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + Ref: "AWS::Partition", + }, + ":ecs:", + { + Ref: "AWS::Region", + }, + ":", + { + Ref: "AWS::AccountId", + }, + ":task/*", + ], + ], + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + Ref: "AWS::Partition", + }, + ":ecs:", + { + Ref: "AWS::Region", + }, + ":", + { + Ref: "AWS::AccountId", + }, + ":task-definition/*:*", + ], + ], + }, + ], + }, + { + Action: "iam:PassRole", + Effect: "Allow", + Resource: "arn:aws:iam::123456789012:role/testRole", + }, + ], + Version: "2012-10-17", + }, + PolicyName: { + "Fn::Join": [ + "", + [ + "RegionalECRPerms-", + { + Ref: "AWS::StackName", + }, + "-", + { + Ref: "AWS::Region", + } + ] + ] + } + }); + expect(stack).toHaveResourceLike("AWS::IAM::Policy", { + PolicyDocument: { + Statement: [ + { + Action: "logs:PutMetricFilter", + Effect: "Allow", + Resource: "arn:aws:logs:us-east-2:123456789012:log-group:test_log_group_name", + }, + ], + Version: "2012-10-17", + } + }); + expect(stack).toHaveResourceLike("AWS::IAM::Policy", { + PolicyDocument: { + Statement: [ + { + Action: "logs:DeleteMetricFilter", + Effect: "Allow", + Resource: "arn:aws:logs:us-east-2:123456789012:log-group:test_log_group_name", + }, + ], + Version: "2012-10-17", + } + }); + expect(stack).toHaveResourceLike("AWS::IAM::Policy", { + PolicyDocument: { + Statement: [ + { + Action: "ecs:StopTask", + Effect: "Allow", + Resource: [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + Ref: "AWS::Partition", + }, + ":ecs:", + { + Ref: "AWS::Region", + }, + ":", + { + Ref: "AWS::AccountId", + }, + ":task/*", + ], + ], + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + Ref: "AWS::Partition", + }, + ":ecs:", + { + Ref: "AWS::Region", + }, + ":", + { + Ref: "AWS::AccountId", + }, + ":task-definition/*:*", + ], + ], + }, + ], + }, + ], + Version: "2012-10-17", + } + }); + expect(stack).toHaveResourceLike("AWS::IAM::Policy", { + PolicyDocument: { + Statement: [ + { + Action: "ecs:DescribeTasks", + Effect: "Allow", + Resource: { + "Fn::Join": [ + "", + [ + "arn:", + { + Ref: "AWS::Partition", + }, + ":ecs:", + { + Ref: "AWS::Region", + }, + ":", + { + Ref: "AWS::AccountId", + }, + ":task/*", + ], + ], + }, + }, + ], + Version: "2012-10-17", + } + }); +}); \ No newline at end of file diff --git a/source/infrastructure/test/scenarios-storage.test.ts b/source/infrastructure/test/scenarios-storage.test.ts new file mode 100644 index 0000000..338389f --- /dev/null +++ b/source/infrastructure/test/scenarios-storage.test.ts @@ -0,0 +1,28 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import '@aws-cdk/assert/jest'; +import { SynthUtils } from '@aws-cdk/assert'; +import { Stack } from 'aws-cdk-lib'; +import { ScenarioTestRunnerStorageConstruct } from '../lib/back-end/scenarios-storage'; +import { Bucket } from 'aws-cdk-lib/aws-s3'; + + +test('DLT API Test', () => { + const stack = new Stack(); + const testLogsBucket = new Bucket(stack, 'testLogsBucket'); + + const storage = new ScenarioTestRunnerStorageConstruct(stack, 'TestScenarioStorage', { + s3LogsBucket: testLogsBucket, + cloudFrontDomainName: 'test.exampledomain.com', + solutionId: 'testId' + }); + + expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); + expect(storage.scenariosBucket).toBeDefined(); + expect(storage.scenariosS3Policy).toBeDefined(); + expect(storage.scenariosTable).toBeDefined(); + expect(storage.historyTable).toBeDefined(); + expect(storage.scenarioDynamoDbPolicy).toBeDefined(); + expect(storage.historyDynamoDbPolicy).toBeDefined(); +}); diff --git a/source/infrastructure/test/step-functions.test.ts b/source/infrastructure/test/step-functions.test.ts new file mode 100644 index 0000000..7e660d0 --- /dev/null +++ b/source/infrastructure/test/step-functions.test.ts @@ -0,0 +1,50 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import '@aws-cdk/assert/jest'; +import { SynthUtils } from '@aws-cdk/assert'; +import { Stack } from 'aws-cdk-lib'; +import { TaskRunnerStepFunctionConstruct } from '../lib/back-end/step-functions'; +import { Code, Function as LambdaFunction, Runtime } from 'aws-cdk-lib/aws-lambda'; +import { Bucket } from 'aws-cdk-lib/aws-s3'; +import { Effect, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; + + +test('DLT API Test', () => { + const stack = new Stack(); + + const testRole = new Role(stack, 'TestRole', { + assumedBy: new ServicePrincipal('lambda.amazonaws.com'), + inlinePolicies: { + 'DenyPolicy': new PolicyDocument({ + statements: [ + new PolicyStatement({ + effect: Effect.DENY, + actions: ['*'], + resources: ['*'] + }) + ] + }) + } + }); + + const testLambda = new LambdaFunction(stack, 'TestFunction', { + code: Code.fromBucket(Bucket.fromBucketName(stack, 'SourceCodeBucket', 'testbucket'), 'custom-resource.zip'), + handler: 'index.handler', + runtime: Runtime.NODEJS_14_X, + role: testRole + }); + + + const testStateMachine = new TaskRunnerStepFunctionConstruct(stack, 'TaskRunnerStepFunction', { + taskStatusChecker: testLambda, + taskRunner: testLambda, + resultsParser: testLambda, + taskCanceler: testLambda, + solutionId: 'testId', + suffix: 'abc-def-xyz' + }); + + expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); + expect(testStateMachine.taskRunnerStepFunctions).toBeDefined(); +}); diff --git a/source/infrastructure/test/test-task-lambdas.test.ts b/source/infrastructure/test/test-task-lambdas.test.ts new file mode 100644 index 0000000..d972b7f --- /dev/null +++ b/source/infrastructure/test/test-task-lambdas.test.ts @@ -0,0 +1,287 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import '@aws-cdk/assert/jest'; +import { SynthUtils } from '@aws-cdk/assert'; +import { Stack } from 'aws-cdk-lib'; +import { TestRunnerLambdasConstruct } from '../lib/back-end/test-task-lambdas'; +import { Bucket } from 'aws-cdk-lib/aws-s3'; +import { Effect, Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { LogGroup } from 'aws-cdk-lib/aws-logs'; +import { AttributeType, Table } from 'aws-cdk-lib/aws-dynamodb'; + +test('DLT Task Lambda Test', () => { + const stack = new Stack(); + + const testDBPolicy = new Policy(stack, 'TestDynamoDBPolicy', { + statements: [new PolicyStatement({ + effect: Effect.DENY, + actions: ['dynamodb:*'], + resources: ['*'] + })] + }); + + const testS3Policy = new Policy(stack, 'TestS3Policy', { + statements: [new PolicyStatement({ + effect: Effect.DENY, + actions: ['s3:*'], + resources: ['*'] + })] + }); + + const testPolicy = new Policy(stack, 'TestPolicy', { + statements: [ + new PolicyStatement({ + resources: ['*'], + actions: ['cloudwatch:Get*'] + }) + ] + }); + + const testLogGroup = new LogGroup(stack, 'TestLogsGroup'); + + const testBucket = Bucket.fromBucketName(stack, 'SourceCodeBucket', 'testbucket'); + const testTable = new Table(stack, 'TestTable', { + partitionKey: { + name: 'id', type: AttributeType.STRING + } + }); + + const testFunctions = new TestRunnerLambdasConstruct(stack, 'TaskRunnerLambdaFunctions', { + cloudWatchLogsPolicy: testPolicy, + scenariosDynamoDbPolicy: testDBPolicy, + ecsTaskExecutionRoleArn: 'arn:aws:iam:us-east-1:111122223333:roleArn', + ecsCloudWatchLogGroup: testLogGroup, + ecsCluster: 'testCluster', + ecsTaskDefinition: 'testTaskDefinition', + ecsTaskSecurityGroup: 'testSecurityGroup', + historyTable: testTable, + historyDynamoDbPolicy: testDBPolicy, + scenariosS3Policy: testS3Policy, + subnetA: 'testSubnetA', + subnetB: 'testSubnetB', + metricsUrl: 'test.example.net', + sendAnonymousUsage: 'No', + solutionId: 'testId', + solutionVersion: 'testVersion', + sourceCodeBucket: testBucket, + sourceCodePrefix: 'testPrefix', + scenariosBucket: 'testBucket', + scenariosTable: testTable, + uuid: 'testId' + }); + + expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); + + expect(stack).toHaveResourceLike("AWS::IAM::Policy", { + PolicyDocument: { + Statement: [ + { + Action: "cloudwatch:GetMetricWidgetImage", + Effect: "Allow", + Resource: "*", + }, + ], + "Version": "2012-10-17", + }, + Roles: [ + { + Ref: "TaskRunnerLambdaFunctionsLambdaResultsRole1AF5AB18", + }, + ] + }); + + expect(stack).toHaveResourceLike("AWS::Lambda::Function", { + Code: { + S3Bucket: "testbucket", + S3Key: "testPrefix/results-parser.zip", + }, + Environment: { + Variables: { + METRIC_URL: "test.example.net", + SCENARIOS_BUCKET: "testBucket", + SCENARIOS_TABLE: { + Ref: "TestTable5769773A", + }, + SEND_METRIC: "No", + SOLUTION_ID: "testId", + UUID: "testId", + VERSION: "testVersion", + }, + }, + Handler: "index.handler", + Role: { + "Fn::GetAtt": [ + "TaskRunnerLambdaFunctionsLambdaResultsRole1AF5AB18", + "Arn", + ], + }, + Runtime: "nodejs14.x", + Timeout: 120, + }); + + expect(stack).toHaveResourceLike("AWS::IAM::Policy", { + PolicyDocument: { + Statement: [ + { + Action: "dynamodb:*", + Effect: "Deny", + Resource: "*", + }, + ], + Version: "2012-10-17", + }, + PolicyName: "TestDynamoDBPolicy7AA7B6CD", + Roles: [ + { + Ref: "TaskRunnerLambdaFunctionsLambdaResultsRole1AF5AB18", + }, + { + Ref: "TaskRunnerLambdaFunctionsDLTTestLambdaTaskRoleCB13DE78", + }, + { + Ref: "TaskRunnerLambdaFunctionsTaskStatusRole4B498DE5", + }, + ] + }); + + expect(stack).toHaveResourceLike("AWS::IAM::Policy", { + PolicyDocument: { + Statement: [ + { + Action: "cloudwatch:GetMetricWidgetImage", + Effect: "Allow", + Resource: "*", + }, + ], + Version: "2012-10-17", + }, + Roles: [ + { + "Ref": "TaskRunnerLambdaFunctionsLambdaResultsRole1AF5AB18", + }, + ] + }); + + expect(stack).toHaveResourceLike("AWS::IAM::Role", { + Policies: [ + { + PolicyDocument: { + Statement: [ + { + Action: "ecs:ListTasks", + Effect: "Allow", + Resource: "*", + }, + { + Action: "ecs:StopTask", + Effect: "Allow", + Resource: [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + Ref: "AWS::Partition", + }, + ":ecs:", + { + Ref: "AWS::Region", + }, + ":", + { + Ref: "AWS::AccountId", + }, + ":task/*", + ], + ], + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + Ref: "AWS::Partition", + }, + ":ecs:", + { + Ref: "AWS::Region", + }, + ":", + { + Ref: "AWS::AccountId", + }, + ":task-definition/*:*", + ], + ], + }, + ], + }, + { + Action: "dynamodb:UpdateItem", + Effect: "Allow", + Resource: { + "Fn::GetAtt": [ + "TestTable5769773A", + "Arn", + ], + }, + }, + ], + Version: "2012-10-17", + }, + PolicyName: "TaskCancelerPolicy", + }, + ] + }); + + expect(stack).toHaveResourceLike("AWS::IAM::Role", { + Policies: [ + { + PolicyDocument: { + Statement: [ + { + Action: "ecs:ListTasks", + Effect: "Allow", + Resource: "*", + }, + { + Action: "ecs:DescribeTasks", + Effect: "Allow", + Resource: { + "Fn::Join": [ + "", + [ + "arn:", + { + Ref: "AWS::Partition", + }, + ":ecs:", + { + Ref: "AWS::Region", + }, + ":", + { + Ref: "AWS::AccountId", + }, + ":task/*", + ], + ], + }, + }, + ], + Version: "2012-10-17", + }, + PolicyName: "TaskStatusPolicy", + }, + ] + }); + + expect(testFunctions.resultsParser).toBeDefined(); + expect(testFunctions.taskRunner).toBeDefined(); + expect(testFunctions.taskCanceler).toBeDefined(); + expect(testFunctions.taskCancelerInvokePolicy).toBeDefined(); + expect(testFunctions.taskStatusChecker).toBeDefined(); +}); diff --git a/source/infrastructure/test/vpc.test.ts b/source/infrastructure/test/vpc.test.ts index 9b3946c..22411bb 100644 --- a/source/infrastructure/test/vpc.test.ts +++ b/source/infrastructure/test/vpc.test.ts @@ -3,63 +3,63 @@ import '@aws-cdk/assert/jest'; import { SynthUtils } from '@aws-cdk/assert'; -import { Stack } from '@aws-cdk/core'; -import { FargateVpcContruct } from '../lib/vpc'; +import { Stack } from 'aws-cdk-lib'; +import { FargateVpcConstruct } from '../lib/testing-resources/vpc'; test('DLT VPC Test', () => { - const stack = new Stack(); - const vpc = new FargateVpcContruct(stack, 'TestVPC', { - subnetACidrBlock: '10.0.0.0/24', - subnetBCidrBlock: '10.0.1.0/24', - solutionId: 'SO0062', - vpcCidrBlock: '10.0.0.0/16', - }); + const stack = new Stack(); + const vpc = new FargateVpcConstruct(stack, 'TestVPC', { + subnetACidrBlock: '10.0.0.0/24', + subnetBCidrBlock: '10.0.1.0/24', + solutionId: 'SO0062', + vpcCidrBlock: '10.0.0.0/16', + }); - expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); - expect(vpc.DLTfargateVpcId).toBeDefined(); - expect(stack).toHaveResource('AWS::EC2::VPC', { - CidrBlock: '10.0.0.0/16', - EnableDnsHostnames: true, - EnableDnsSupport: true - }) - expect(vpc.subnetA).toBeDefined(); - expect(stack).toHaveResource('AWS::EC2::Subnet', { - CidrBlock: '10.0.0.0/24' - }); - expect(vpc.subnetB).toBeDefined(); - expect(stack).toHaveResource('AWS::EC2::Subnet', { - CidrBlock: '10.0.1.0/24' - }); - expect(stack).toHaveResource('AWS::EC2::InternetGateway'); - expect(stack).toHaveResource('AWS::EC2::Route', { - DestinationCidrBlock: '0.0.0.0/0', - GatewayId: { - Ref: "TestVPCDLTFargateIG4FFBAA11", - }, - RouteTableId: { - Ref: "TestVPCDLTFargateRT6952750D", - } - }); - expect(stack).toHaveResource('AWS::EC2::RouteTable'); - expect(stack).toHaveResource('AWS::EC2::VPCGatewayAttachment', { - InternetGatewayId: { - Ref: "TestVPCDLTFargateIG4FFBAA11", - } - }); - expect(stack).toHaveResource('AWS::EC2::SubnetRouteTableAssociation', { - RouteTableId: { - Ref: "TestVPCDLTFargateRT6952750D", - }, - SubnetId: { - Ref: "TestVPCDLTSubnetA8E320A43", - } - }); - expect(stack).toHaveResource('AWS::EC2::SubnetRouteTableAssociation', { - RouteTableId: { - Ref: "TestVPCDLTFargateRT6952750D", - }, - SubnetId: { - Ref: "TestVPCDLTSubnetB7A2BD254", - } - }) + expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); + expect(vpc.vpcId).toBeDefined(); + expect(stack).toHaveResource('AWS::EC2::VPC', { + CidrBlock: '10.0.0.0/16', + EnableDnsHostnames: true, + EnableDnsSupport: true + }); + expect(vpc.subnetA).toBeDefined(); + expect(stack).toHaveResource('AWS::EC2::Subnet', { + CidrBlock: '10.0.0.0/24' + }); + expect(vpc.subnetB).toBeDefined(); + expect(stack).toHaveResource('AWS::EC2::Subnet', { + CidrBlock: '10.0.1.0/24' + }); + expect(stack).toHaveResource('AWS::EC2::InternetGateway'); + expect(stack).toHaveResource('AWS::EC2::Route', { + DestinationCidrBlock: '0.0.0.0/0', + GatewayId: { + Ref: "TestVPCDLTFargateIG4FFBAA11", + }, + RouteTableId: { + Ref: "TestVPCDLTFargateRT6952750D", + } + }); + expect(stack).toHaveResource('AWS::EC2::RouteTable'); + expect(stack).toHaveResource('AWS::EC2::VPCGatewayAttachment', { + InternetGatewayId: { + Ref: "TestVPCDLTFargateIG4FFBAA11", + } + }); + expect(stack).toHaveResource('AWS::EC2::SubnetRouteTableAssociation', { + RouteTableId: { + Ref: "TestVPCDLTFargateRT6952750D", + }, + SubnetId: { + Ref: "TestVPCDLTSubnetA8E320A43", + } + }); + expect(stack).toHaveResource('AWS::EC2::SubnetRouteTableAssociation', { + RouteTableId: { + Ref: "TestVPCDLTFargateRT6952750D", + }, + SubnetId: { + Ref: "TestVPCDLTSubnetB7A2BD254", + } + }); }); \ No newline at end of file diff --git a/source/real-time-data-publisher/index.js b/source/real-time-data-publisher/index.js new file mode 100644 index 0000000..bdb71ff --- /dev/null +++ b/source/real-time-data-publisher/index.js @@ -0,0 +1,69 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const zlib = require('zlib'); +const util = require('util'); +const unzip = util.promisify(zlib.gunzip); +const AWS = require("aws-sdk"); +const solutionUtils = require('solution-utils'); +const { MAIN_REGION, IOT_ENDPOINT } = process.env; +let options = { + region: MAIN_REGION, + endpoint: IOT_ENDPOINT +}; +options = solutionUtils.getOptions(options); +const iot = new AWS.IotData(options); + +exports.handler = async function (event) { + const payload = Buffer.from(event.awslogs.data, 'base64'); + const aggregatedTestResultData = { [process.env.AWS_REGION]: [] }; + let testId = ""; + + try { + //decompress gzip data, convert to ascii string, and parse JSON + const decompressedPayload = await unzip(payload); + const jsonPayload = JSON.parse(decompressedPayload.toString('ascii')); + console.log("Event Data:", JSON.stringify(jsonPayload, null, 2)); + + //for each logItem, extract necessary information + //i.e. testId, virtual users, avgRt, succ count, fail count, and timestamp + for (const logItem of jsonPayload.logEvents) { + const logString = logItem.message; + const regex = /^\w+|\d+(\.\d+)?(?=\svu)|\d+(\.\d+)?(?=\ssucc)|\d+(\.\d+)?(?=\sfail)|\d+(\.\d+)?(?=\savg rt\s)/g; + const keys = ["testId", "vu", "succ", "fail", "avgRt"]; + const extractedData = {}; + + //Extract data and parse into JSON object using keys + for (const [index, value] of (logString.match(regex)).entries()) { + if (index > 0) { + extractedData[keys[index]] = parseFloat(value, 10); + } else { + extractedData[keys[index]] = value; + } + } + + //get testId if not already received + testId = testId || extractedData.testId; + + //add timestamp and push individual line data to aggregated data array + extractedData.timestamp = Math.round(logItem.timestamp / 1000) * 1000; + aggregatedTestResultData[process.env.AWS_REGION].push(extractedData); + } + } catch (error) { + console.error("Error decompressing payload: ", error); + throw error; + } + + //publish to testId topic using endpoint in main region + const params = { + topic: `dlt/${testId}`, + payload: JSON.stringify(aggregatedTestResultData), + }; + try { + await iot.publish(params).promise(); + console.log(`Successfully sent data to topic dlt/${testId}`); + } catch (error) { + console.error("Error publishing to IoT Topic: ", error); + throw error; + } +}; \ No newline at end of file diff --git a/source/real-time-data-publisher/jest.config.js b/source/real-time-data-publisher/jest.config.js new file mode 100644 index 0000000..7d3eaec --- /dev/null +++ b/source/real-time-data-publisher/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + roots: ['/lib'], + testMatch: ['**/*.spec.js'], + collectCoverageFrom: [ + '**/*.js' + ], + coverageReporters: [ + "text", + "clover", + "json", + ["lcov", { "projectRoot": "../../" }] + ] +}; \ No newline at end of file diff --git a/source/real-time-data-publisher/lib/index.spec.js b/source/real-time-data-publisher/lib/index.spec.js new file mode 100644 index 0000000..b3e0433 --- /dev/null +++ b/source/real-time-data-publisher/lib/index.spec.js @@ -0,0 +1,140 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Mock AWS SDK +const mockAWS = require('aws-sdk'); +const mockIotData = { + publish: jest.fn() +}; +mockAWS.IotData = jest.fn(() => ({ + publish: mockIotData.publish +})); + +//required modules +const util = require('util'); +const zlib = require('zlib'); + +//spy zlib.gunzip +jest.spyOn(zlib, 'gunzip'); + +//env variables and lambda package +process.env = { + IOT_ENDPOINT: "test.endpoint", + MAIN_REGION: "test-region-1", + VERSION: "3.x.x", + SOLUTION_ID: "sO0062", + AWS_REGION: 'test-region-2' +}; +const lambda = require('../index.js'); + +//turn zlib.gzip into a promise +const zip = util.promisify(zlib.gzip); + +//event parameters +const eventData = JSON.stringify({ + "messageType": "DATA_MESSAGE", + "owner": "1234", + "logGroup": "fake-log-group", + "logStream": "load-testing/fake-dlt-load-tester/1234", + "subscriptionFilters": [ + "dlt-filter" + ], + "logEvents": [ + { + "id": "36658745263028322096775493507662137474727159327305236480", + "timestamp": 1643834990117, + "message": "zlppmfYHww 20:49:50 INFO: Current: 100 vu\t58 succ\t0 fail\t3.631 avg rt\t/\tCumulative: 5.374 avg rt, 0% failures" + }, + { + "id": "36658745284905353136534034809508677100195201964672024577", + "timestamp": 1643834991098, + "message": "zlppmfYHww 20:49:51 INFO: Current: 100 vu\t27 succ\t0 fail\t3.916 avg rt\t/\tCumulative: 5.353 avg rt, 0% failures" + } + ] +}); +const event = { + awslogs: { + data: "placeholder" + } +}; + +//expected data +const resultData = [ + { + testId: 'zlppmfYHww', + vu: 100, + succ: 58, + fail: 0, + avgRt: 3.631, + timestamp: 1643834990000 + }, + { + testId: 'zlppmfYHww', + vu: 100, + succ: 27, + fail: 0, + avgRt: 3.916, + timestamp: 1643834991000 + } +] +const topic = 'dlt/zlppmfYHww' + +describe('#REAL TIME DATA PUBLISHER:: ', () => { + beforeEach(() => { + //reset iot mock before each test + mockIotData.publish.mockReset(); + }); + beforeAll(async () => { + //zip and encode event data once before all tests + const zippedEventData = await zip(eventData); + event.awslogs.data = Buffer.from(zippedEventData, 'binary').toString('base64'); + }); + it('Should call publish with correct data', async () => { + //mock IoT publish call + mockIotData.publish.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({}); + } + } + }); + + //test lambda + await lambda.handler(event); + expectedResult = { + payload: JSON.stringify({ [process.env.AWS_REGION]: resultData }), + topic: topic + } + expect(mockIotData.publish).toHaveBeenCalledWith(expectedResult); + }); + it('Should fail if zlib.gunzip fails', async () => { + //mock zlib.gunzip + zlib.gunzip.mockImplementationOnce((buffer, callback) => { + callback('Error', null); + }) + + //test lambda + try { + await lambda.handler(event); + } catch (error) { + expect(error).toBe("Error"); + } + }); + it('Should fail if publishing to IoT endpoint fails', async () => { + //mock IoT publish failure + mockIotData.publish.mockImplementationOnce(() => { + return { + promise() { + return Promise.reject("Error"); + } + } + }); + + //test lambda + try { + await lambda.handler(event); + } catch (error) { + expect(error).toBe("Error"); + } + }); +}); \ No newline at end of file diff --git a/source/real-time-data-publisher/package.json b/source/real-time-data-publisher/package.json new file mode 100644 index 0000000..02cc596 --- /dev/null +++ b/source/real-time-data-publisher/package.json @@ -0,0 +1,27 @@ +{ + "name": "real-time-data-publisher", + "version": "3.0.0", + "description": "Publishes real time test data to an IoT endpoint", + "repository": { + "type": "git", + "url": "https://github.com/aws-solutions/distributed-load-testing-on-aws" + }, + "license": "Apache-2.0", + "author": "aws-solution-builders", + "main": "index.js", + "scripts": { + "clean": "rm -rf node_modules package-lock.json", + "test": "jest lib/*.spec.js --coverage --silent" + }, + "dependencies": { + "solution-utils": "file:../solution-utils" + }, + "devDependencies": { + "aws-sdk": "2.1001.0", + "jest": "26.6.3" + }, + "engines": { + "node": "^14.x" + }, + "readme": "./README.md" +} diff --git a/source/results-parser/index.js b/source/results-parser/index.js index 64db131..7e0f55d 100644 --- a/source/results-parser/index.js +++ b/source/results-parser/index.js @@ -1,130 +1,170 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +const moment = require('moment'); const parser = require('./lib/parser/'); const metrics = require('./lib/metrics/'); const AWS = require('aws-sdk'); -const { SOLUTION_ID, VERSION } = process.env; +const utils = require('solution-utils'); let options = {}; -if (SOLUTION_ID && VERSION && SOLUTION_ID.trim() && VERSION.trim()) { - options.customUserAgent = `AwsSolution/${SOLUTION_ID}/${VERSION}`; -} +options = utils.getOptions(options); const s3 = new AWS.S3(options); const dynamoDb = new AWS.DynamoDB.DocumentClient(options); exports.handler = async (event) => { - console.log(JSON.stringify(event, null, 2)); - const { scenario, prefix } = event; - const { testId, fileType } = scenario; - - try { - const ddbParams = { - TableName: process.env.SCENARIOS_TABLE, - Key: { - testId: testId - }, - AttributesToGet: [ - 'startTime', - 'status', - 'taskCount', - 'testType', - 'testScenario', - 'testDescription' - ] + console.log(JSON.stringify(event, null, 2)); + const { testId, fileType, prefix, testTaskConfig: eventConfigs } = event; + + try { + const ddbParams = { + TableName: process.env.SCENARIOS_TABLE, + Key: { + testId: testId + }, + AttributesToGet: [ + 'startTime', + 'status', + 'testTaskConfigs', + 'testType', + 'testScenario', + 'testDescription' + ] + }; + const ddbGetResponse = await dynamoDb.get(ddbParams).promise(); + const { startTime, testTaskConfigs, testType, testScenario, testDescription } = ddbGetResponse.Item; + let { status } = ddbGetResponse.Item; + const endTime = moment().utc().format('YYYY-MM-DD HH:mm:ss'); + let totalDuration = 0; + let testResult = status; + + if (!['cancelling', 'cancelled', 'failed'].includes(status)) { + const bucket = process.env.SCENARIOS_BUCKET; + let resultList = []; + let nextContinuationToken = undefined; + + // Get the latest test result from S3 + do { + const params = { + Bucket: bucket, + Prefix: `results/${testId}/${prefix}` }; - const ddbGetResponse = await dynamoDb.get(ddbParams).promise(); - const { startTime, status, taskCount, testType, testScenario, testDescription } = ddbGetResponse.Item; - let totalDuration = 0; - let testResult = status; - - if (!['cancelling', 'cancelled', 'failed'].includes(status)) { - const bucket = process.env.SCENARIOS_BUCKET; - let resultList = []; - let nextContinuationToken = undefined; - - // Get the latest test result from S3 - do { - const params = { - Bucket: bucket, - Prefix: `results/${testId}/${prefix}` - }; - - if (nextContinuationToken) { - params.ContinuationToken = nextContinuationToken - } - const result = await s3.listObjectsV2(params).promise(); - resultList = resultList.concat(result.Contents); - nextContinuationToken = result.IsTruncated ? result.NextContinuationToken : null; - } while (nextContinuationToken); - - if (resultList.length > 0) { - const data = []; - const promises = []; - - for (const content of resultList) { - promises.push( - s3.getObject({ - Bucket: process.env.SCENARIOS_BUCKET, - Key: content.Key - }).promise() - ); - } - - const result = await Promise.all(promises); - for (const content of result) { - const parsedResult = parser.results(content.Body, testId); - let duration = parseInt(parsedResult.duration); - totalDuration += isNaN(duration) ? 0 : duration; - - data.push(parsedResult); - } - console.log('All Tasks Complete'); - - // Parser final results and update dynamodb - await parser.finalResults(testId, data, startTime, taskCount, testScenario, testDescription, testType); - testResult = 'completed'; - } else { - // If there's no result files in S3 bucket, there's a possibility that the test failed in the Fargate tasks. - await dynamoDb.update({ - TableName: process.env.SCENARIOS_TABLE, - Key: { testId }, - UpdateExpression: 'set #s = :s, #e = :e', - ExpressionAttributeNames: { - '#s': 'status', - '#e': 'errorReason' - }, - ExpressionAttributeValues: { - ':s': 'failed', - ':e': 'Test might be failed to run.' - } - }).promise(); - testResult = 'failed'; - } + + if (nextContinuationToken) { + params.ContinuationToken = nextContinuationToken; } + const result = await s3.listObjectsV2(params).promise(); + resultList = resultList.concat(result.Contents); + nextContinuationToken = result.IsTruncated ? result.NextContinuationToken : null; + } while (nextContinuationToken); + if (resultList.length > 0) { + let aggregateData = []; + const promises = {}; + + //get all results files from test sorted by region + for (const content of resultList) { + //extract region from file name + const regex = /\w+-\w+-\w+(?=.xml)/g; + const fileRegion = content.Key.match(regex).pop(); + !(fileRegion in promises) && (promises[fileRegion] = []); + promises[fileRegion].push( + s3.getObject({ + Bucket: process.env.SCENARIOS_BUCKET, + Key: content.Key + }).promise() + ); + } + + const finalResults = {}; + const completeTasks = {}; + let allMetrics = []; + + //Get results per region + for (const eventConfig of eventConfigs) { + const data = []; + const result = await Promise.all(promises[eventConfig.region]); + + //parse each results file + for (const content of result) { + const parsedResult = parser.results(content.Body, testId); + let duration = parseInt(parsedResult.duration); + totalDuration += isNaN(duration) ? 0 : duration; + data.push(parsedResult); + } - // Send anonymous metrics - if (process.env.SEND_METRIC === 'Yes') { - await metrics.send({ testType, totalDuration, fileType, testResult }); + //record regional data + completeTasks[eventConfig.region] = data.length; + aggregateData = aggregateData.concat(data); + + // Parser final results for region + finalResults[eventConfig.region] = await parser.finalResults(testId, data); + + //create widget image for region + const { metricS3Location, metrics: taskMetrics } = await parser.createWidget(startTime, endTime, eventConfig.region, testId, []); + finalResults[eventConfig.region].metricS3Location = metricS3Location; + allMetrics = allMetrics.concat(taskMetrics); + + //delete regional metric filter + await parser.deleteRegionalMetricFilter(testId, eventConfig.region, eventConfig.taskCluster, eventConfig.ecsCloudWatchLogGroup); } - return 'success'; - } catch (error) { - console.error(error); + //parse aggregate final results + finalResults['total'] = await parser.finalResults(testId, aggregateData); + + //create aggregate widget image + const { metricS3Location: aggMetricS3Loc } = await parser.createWidget(startTime, endTime, 'total', testId, allMetrics); + finalResults['total'].metricS3Location = aggMetricS3Loc; + + // Write test run data to history table + status = 'complete'; + const historyParams = { status, testId, finalResults, startTime, endTime, testTaskConfigs, testScenario, testDescription, testType, completeTasks }; + await parser.putTestHistory(historyParams); + //update dynamoDB table + const updateTableParams = { status, testId, finalResults, endTime, completeTasks }; + await parser.updateTable(updateTableParams); + testResult = 'completed'; + } else { + // If there's no result files in S3 bucket, there's a possibility that the test failed in the Fargate tasks. await dynamoDb.update({ - TableName: process.env.SCENARIOS_TABLE, - Key: { testId }, - UpdateExpression: 'set #s = :s, #e = :e', - ExpressionAttributeNames: { - '#s': 'status', - '#e': 'errorReason' - }, - ExpressionAttributeValues: { - ':s': 'failed', - ':e': 'Failed to parse the results.' - } + TableName: process.env.SCENARIOS_TABLE, + Key: { testId }, + UpdateExpression: 'set #s = :s, #e = :e', + ExpressionAttributeNames: { + '#s': 'status', + '#e': 'errorReason' + }, + ExpressionAttributeValues: { + ':s': 'failed', + ':e': 'Test might be failed to run.' + } }).promise(); + testResult = 'failed'; + } + } - throw error; + // Send anonymous metrics + if (process.env.SEND_METRIC === 'Yes') { + await metrics.send({ testType, totalDuration, fileType, testResult }); } + + return 'success'; + } catch (error) { + console.error(error); + + await dynamoDb.update({ + TableName: process.env.SCENARIOS_TABLE, + Key: { testId }, + UpdateExpression: 'set #s = :s, #e = :e', + ExpressionAttributeNames: { + '#s': 'status', + '#e': 'errorReason' + }, + ExpressionAttributeValues: { + ':s': 'failed', + ':e': 'Failed to parse the results.' + } + }).promise(); + + throw error; + } }; diff --git a/source/results-parser/jest.config.js b/source/results-parser/jest.config.js new file mode 100644 index 0000000..7d3eaec --- /dev/null +++ b/source/results-parser/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + roots: ['/lib'], + testMatch: ['**/*.spec.js'], + collectCoverageFrom: [ + '**/*.js' + ], + coverageReporters: [ + "text", + "clover", + "json", + ["lcov", { "projectRoot": "../../" }] + ] +}; \ No newline at end of file diff --git a/source/results-parser/lib/metrics/index.js b/source/results-parser/lib/metrics/index.js index 3e4596f..4ceaa27 100644 --- a/source/results-parser/lib/metrics/index.js +++ b/source/results-parser/lib/metrics/index.js @@ -5,46 +5,46 @@ const axios = require('axios'); const moment = require('moment'); /** - * Sends anonymouse metrics. + * Sends anonymous metrics. * @param {{ totalDuration: number, testType: string, fileType: string, testResult: string }} - the total time the test ran for in seconds, the test type, the file type, and the test result */ const send = async (obj) => { - let data; + let data; - try { - const metrics = { - Solution: process.env.SOLUTION_ID, - UUID: process.env.UUID, - TimeStamp: moment().utc().format('YYYY-MM-DD HH:mm:ss.S'), - Version: process.env.VERSION, - Data: { - Type: 'TaskCompletion', - TestType: obj.testType, - FileType: obj.fileType || (obj.testType === 'simple' ? 'none' : 'script'), - TestResult: obj.testResult, - Duration: obj.totalDuration - } - }; - const params = { - method: 'post', - port: 443, - url: process.env.METRIC_URL, - headers: { - 'Content-Type': 'application/json' - }, - data: metrics - }; - //Send Metrics & retun status code. - data = await axios(params); - } catch (err) { - //Not returning an error to avoid Metrics affecting the Application - console.log(err); - } - return data.status; + try { + const metrics = { + Solution: process.env.SOLUTION_ID, + UUID: process.env.UUID, + TimeStamp: moment().utc().format('YYYY-MM-DD HH:mm:ss.S'), + Version: process.env.VERSION, + Data: { + Type: 'TaskCompletion', + TestType: obj.testType, + FileType: obj.fileType || (obj.testType === 'simple' ? 'none' : 'script'), + TestResult: obj.testResult, + Duration: obj.totalDuration + } + }; + const params = { + method: 'post', + port: 443, + url: process.env.METRIC_URL, + headers: { + 'Content-Type': 'application/json' + }, + data: metrics + }; + //Send Metrics & return status code. + data = await axios(params); + } catch (err) { + //Not returning an error to avoid Metrics affecting the Application + console.log(err); + } + return data.status; }; module.exports = { - send: send + send: send }; diff --git a/source/results-parser/lib/metrics/index.spec.js b/source/results-parser/lib/metrics/index.spec.js index 4dcee4b..92b8483 100644 --- a/source/results-parser/lib/metrics/index.spec.js +++ b/source/results-parser/lib/metrics/index.spec.js @@ -10,41 +10,41 @@ const _duration = 300.0; describe('#SEND METRICS', () => { - it('should return "200" on a send metrics sucess for simple test', async () => { + it('should return "200" on a send metrics success for simple test', async () => { - let mock = new MockAdapter(axios); - mock.onPost().reply(200, {}); + let mock = new MockAdapter(axios); + mock.onPost().reply(200, {}); - let response = await lambda.send({ totalDuration: _duration, testType: 'simple', testResult: 'completed' }); - expect(response).toEqual(200); - }); + let response = await lambda.send({ totalDuration: _duration, testType: 'simple', testResult: 'completed' }); + expect(response).toEqual(200); + }); - it('should return "200" on a send metrics sucess for zip JMeter test', async () => { + it('should return "200" on a send metrics success for zip JMeter test', async () => { - let mock = new MockAdapter(axios); - mock.onPost().reply(200, {}); + let mock = new MockAdapter(axios); + mock.onPost().reply(200, {}); - let response = await lambda.send({ totalDuration: _duration, testType: 'jmeter', fileType: 'zip', testResult: 'failed' }); - expect(response).toEqual(200); - }); + let response = await lambda.send({ totalDuration: _duration, testType: 'jmeter', fileType: 'zip', testResult: 'failed' }); + expect(response).toEqual(200); + }); - it('should return "200" on a send metrics sucess for script JMeter test', async () => { + it('should return "200" on a send metrics success for script JMeter test', async () => { - let mock = new MockAdapter(axios); - mock.onPost().reply(200, {}); + let mock = new MockAdapter(axios); + mock.onPost().reply(200, {}); - let response = await lambda.send({ totalDuration: _duration, testType: 'jmeter', testResult: 'cancelled' }); - expect(response).toEqual(200); - }); + let response = await lambda.send({ totalDuration: _duration, testType: 'jmeter', testResult: 'cancelled' }); + expect(response).toEqual(200); + }); - it('should return "Network Error" on connection timedout', async () => { + it('should return "Network Error" on connection timeout', async () => { - let mock = new MockAdapter(axios); - mock.onPost().networkError(); + let mock = new MockAdapter(axios); + mock.onPost().networkError(); - await lambda.send({ totalDuration: _duration, testType: 'simple', testResult: 'completed' }).catch(err => { - expect(err.toString()).toEqual("TypeError: Cannot read property 'status' of undefined"); - }); - }); + await lambda.send({ totalDuration: _duration, testType: 'simple', testResult: 'completed' }).catch(err => { + expect(err.toString()).toEqual("TypeError: Cannot read property 'status' of undefined"); + }); + }); }); diff --git a/source/results-parser/lib/parser/index.js b/source/results-parser/lib/parser/index.js index 025d847..3ef02a3 100644 --- a/source/results-parser/lib/parser/index.js +++ b/source/results-parser/lib/parser/index.js @@ -3,28 +3,12 @@ const AWS = require('aws-sdk'); const parser = require('xml-js'); -const moment = require('moment'); -const { customAlphabet } = require('nanoid'); -const { SOLUTION_ID, VERSION } = process.env; -let options = {}; -if (SOLUTION_ID && VERSION && SOLUTION_ID.trim() && VERSION.trim()) { - options.customUserAgent = `AwsSolution/${SOLUTION_ID}/${VERSION}`; -} -options.region = process.env.AWS_REGION; -const dynamoDb = new AWS.DynamoDB.DocumentClient(options); -const cloudwatch = new AWS.CloudWatch(options); -const s3 = new AWS.S3(options); +const utils = require('solution-utils'); +const { AWS_REGION, HISTORY_TABLE, SCENARIOS_TABLE } = process.env; +const awsOptions = utils.getOptions({ region: AWS_REGION }); +const dynamoDb = new AWS.DynamoDB.DocumentClient(awsOptions); +const s3 = new AWS.S3(awsOptions); -const ALPHA_NUMERIC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; -/** - * Generates an unique ID based on the parameter length. - * @param length The length of the unique ID - * @returns The unique ID - */ -function generateUniqueId(length) { - const nanoid = customAlphabet(ALPHA_NUMERIC, length); - return nanoid(); -} /** * Parses test result XML from S3 to JSON, and return the result summary. * @param {object} content S3 object body - XML @@ -32,180 +16,220 @@ function generateUniqueId(length) { * @return {Promise<{ stats: object, labels: object[], duration: string }>} Test result from one task */ function results(content, testId) { - console.log(`Processing results, testId: ${testId}`); - - try { - const options = { - nativeType: true, - compact: true, - ignoreAttributes: false - }; - const json = parser.xml2js(content, options); - const jsonData = json.FinalStatus; - let labels = []; - let result = {}; - - console.log(`xml to json: ${JSON.stringify(jsonData, null, 2)}`); - - // loop through results - for (let i = 0; i < jsonData.Group.length; i++) { - const group = jsonData.Group[i]; - const label = group._attributes.label; - let stats = { - rc: [] - }; - - // loop through group results - for (let r in group) { - if (r !== '_attributes' && r !== 'perc' && r !== 'rc') { - stats[r] = group[r].value._text; - } - } - - // loop through response codes, rc is a object for single responses array for multiple - if (Array.isArray(group.rc)) { - for (let j = 0; j < group.rc.length; j++) { - if (group.rc[j]._attributes.param !== '200') { - stats.rc.push({ code: group.rc[j]._attributes.param, count: parseInt(group.rc[j]._attributes.value) }); - } - } - } else { - if (group.rc._attributes.param !== '200') { - stats.rc.push({ code: group.rc._attributes.param, count: parseInt(group.rc._attributes.value) }); - } - } - - // loop through percentiles and rename/convert keys to strings - for (let j = 0; j < group.perc.length; j++) { - const perc = 'p' + group.perc[j]._attributes.param.replace('.', '_'); - stats[perc] = group.perc[j].value._text; - } - // check if the resuts are for the group (label '') or for a specific label - // label '' is the average results for all the labels. - if (label) { - stats.label = label; - labels.push(stats); - } else { - result = stats; - } + console.log(`Processing results, testId: ${testId}`); + + try { + const options = { + nativeType: true, + compact: true, + ignoreAttributes: false + }; + const json = parser.xml2js(content, options); + const jsonData = json.FinalStatus; + let labels = []; + let result = {}; + + console.log(`xml to json: ${JSON.stringify(jsonData, null, 2)}`); + + // loop through results + for (let resultsItem of jsonData.Group) { + const group = resultsItem; + const label = group._attributes.label; + let stats = { + rc: [] + }; + + // loop through group results + for (let r in group) { + if (r !== '_attributes' && r !== 'perc' && r !== 'rc') { + stats[r] = group[r].value._text; + } + } + + // loop through response codes, rc is a object for single responses array for multiple + if (Array.isArray(group.rc)) { + for (let responseCode of group.rc) { + if (responseCode._attributes.param !== '200') { + stats.rc.push({ code: responseCode._attributes.param, count: parseInt(responseCode._attributes.value) }); + } } - result.testDuration = jsonData.TestDuration._text; - - return { - stats: result, - labels, - duration: jsonData.TestDuration._text - }; - } catch (error) { - console.error('results function error', error); - throw error; + } else { + if (group.rc._attributes.param !== '200') { + stats.rc.push({ code: group.rc._attributes.param, count: parseInt(group.rc._attributes.value) }); + } + } + + // loop through percentiles and rename/convert keys to strings + for (let percentiles of group.perc) { + const perc = 'p' + percentiles._attributes.param.replace('.', '_'); + stats[perc] = percentiles.value._text; + } + // check if the results are for the group (label '') or for a specific label + // label '' is the average results for all the labels. + if (label) { + stats.label = label; + labels.push(stats); + } else { + result = stats; + } } + result.testDuration = jsonData.TestDuration._text; + return { + stats: result, + labels, + duration: jsonData.TestDuration._text + }; + } catch (error) { + console.error('results function error', error); + throw error; + } } /** - * Integrates the all test results and updates DynamoDB record - * @param {string} testId Test ID - * @param {object} data Test result data - * @param {string} startTime Test start time - * @param {string} taskCount number of tasks run for the test - * @param {object} testScenario Test run details - * @param {string} testDescription Test description - * @param {string} testType Test type + * Returns the average value of an array. + * @param {number[]} array Number array to get the average value + * @return {number} Average number of the numbers in the array */ -async function finalResults(testId, data, startTime, taskCount, testScenario, testDescription, testType) { - console.log(`Parsing Final Results for ${testId}`); +const getAvg = (array) => { + if (array.length === 0) return 0; + return array.reduce((a, b) => a + b, 0) / array.length; +}; - const thisTestScenario = JSON.parse(testScenario); - /** - * Retruns the average value of an array. - * @param {number[]} array Number array to get the average value - * @return {number} Average number of the numbers in the array - */ - const getAvg = (array) => { - if (array.length === 0) return 0; - return array.reduce((a, b) => a + b, 0) / array.length; - }; +/** + * Returns the summarized response codes and sum count. + * @param {object[]} array Response code object array which includes { code: string, count: number|string } objects + * @return {object[]} Summarized response codes and sum count + */ +const getReducedResponseCodes = (array) => { + return array.reduce((accumulator, currentValue) => { + const count = parseInt(currentValue.count); + currentValue.count = isNaN(count) ? 0 : count; + + const existing = accumulator.find(acc => acc.code === currentValue.code); + if (existing) { + existing.count += currentValue.count; + } else { + accumulator.push(currentValue); + } + return accumulator; + }, []); +}; - /** - * Returns the summarized response codes and sum count. - * @param {object[]} array Response code object array which includes { code: string, count: number|string } objects - * @return {object[]} Summarized response codes and sum count - */ - const getReducedResponceCodes = (array) => { - return array.reduce((accumulator, currentValue) => { - const { count } = currentValue; - currentValue.count = isNaN(parseInt(count)) ? 0 : parseInt(count); - - let existing = accumulator.find(acc => acc.code === currentValue.code); - if (existing) { - existing.count += currentValue.count; - } else { - accumulator.push(currentValue); - } - return accumulator; - }, []); - }; +/** + * Integrates the all test results and updates DynamoDB record + * @param {object} finalResultParams object containing test configuration and test result details + */ +async function finalResults(testId, data) { + console.log(`Parsing Final Results for ${testId}`); + + /** + * Aggregates the all results from Taurus to one result object. + * @param {object} stats Stats object which includes all the results from Taurus + * @param {object} result Result object which aggregates the same key values into + */ + const createAggregatedData = (stats, result) => { + for (let key in stats) { + if (key === 'label') { + result.label = stats[key]; + } else if (key === 'rc') { + result.rc = result.rc.concat(stats.rc); + } else { + result[key].push(stats[key]); + } + } + }; + + /** + * Created the final results + * @param {object} source Aggregated Taurus results + * @param {object} result Summarized final results + */ + const createFinalResults = (source, result) => { + for (let key in source) { + switch (key) { + case 'label': + case 'labels': + case 'rc': + result[key] = source[key]; + break; + case 'fail': + case 'succ': + case 'throughput': + result[key] = source[key].reduce((a, b) => a + b); + break; + case 'bytes': + case 'concurrency': + case 'testDuration': + result[key] = getAvg(source[key]).toFixed(0); + break; + case 'avg_ct': + case 'avg_lt': + case 'avg_rt': + result[key] = getAvg(source[key]).toFixed(5); + break; + default: + result[key] = getAvg(source[key]).toFixed(3); + } + } + }; + + let testFinalResults = {}; + let all = { + avg_ct: [], + avg_lt: [], + avg_rt: [], + bytes: [], + concurrency: [], + fail: [], + p0_0: [], + p100_0: [], + p50_0: [], + p90_0: [], + p95_0: [], + p99_0: [], + p99_9: [], + stdev_rt: [], + succ: [], + testDuration: [], + throughput: [], + rc: [], + labels: [] + }; + + for (let result of data) { + const { labels, stats } = result; + createAggregatedData(stats, all); + + // Sub results if any + if (labels.length > 0) { + all.labels = all.labels.concat(labels); + } + } - /** - * Aggregates the all results from Taurus to one result object. - * @param {object} stats Stats object which includes all the results from Taurus - * @param {object} result Result object which aggregates the same key values into - */ - const createAggregatedData = (stats, result) => { - for (let key in stats) { - if (key === 'label') { - result.label = stats[key]; - } else if (key === 'rc') { - result.rc = result.rc.concat(stats.rc); - } else { - result[key].push(stats[key]); - } - } - }; + // find duplicates in response codes and sum count + if (all.rc.length > 0) { + all.rc = getReducedResponseCodes(all.rc); + } - /** - * Created the final results - * @param {object} source Aggregated Taurus results - * @param {object} result Summarized final results - */ - const createFinalResults = (source, result) => { - for (let key in source) { - switch (key) { - case 'label': - case 'labels': - case 'rc': - result[key] = source[key]; - break; - case 'fail': - case 'succ': - case 'throughput': - result[key] = source[key].reduce((a, b) => a + b); - break; - case 'bytes': - case 'concurrency': - case 'testDuration': - result[key] = getAvg(source[key]).toFixed(0); - break; - case 'avg_ct': - case 'avg_lt': - case 'avg_rt': - result[key] = getAvg(source[key]).toFixed(5); - break; - default: - result[key] = getAvg(source[key]).toFixed(3); - } - } - }; + // summarize the test result per label + if (all.labels.length > 0) { + let set = new Set(); + let labels = []; + + for (let label of all.labels) { + set.add(label.label); + } - const endTime = moment().utc().format('YYYY-MM-DD HH:mm:ss'); - let testFinalResults = {}; - let all = { + for (let label of set.keys()) { + let labelTestFinalResults = {}; + let labelAll = { avg_ct: [], avg_lt: [], avg_rt: [], bytes: [], concurrency: [], fail: [], + label: '', p0_0: [], p100_0: [], p50_0: [], @@ -217,208 +241,214 @@ async function finalResults(testId, data, startTime, taskCount, testScenario, te succ: [], testDuration: [], throughput: [], - rc: [], - labels: [] - }; + rc: [] + }; + + const labelStats = all.labels.filter((stats) => stats.label === label); + for (let stat of labelStats) { + createAggregatedData(stat, labelAll); + } + + // find duplicates in response codes and sum count + if (labelAll.rc.length > 0) { + labelAll.rc = getReducedResponseCodes(labelAll.rc); + } + + // parse all of the results to generate the final results. + createFinalResults(labelAll, labelTestFinalResults); + labels.push(labelTestFinalResults); + } - try { - for (let result of data) { - const { labels, stats } = result; - createAggregatedData(stats, all); + all.labels = labels; + } - // Sub results if any - if (labels.length > 0) { - all.labels = all.labels.concat(labels); - } - } + // parse all of the results to generate the final results. + createFinalResults(all, testFinalResults); + console.log('Final Results: ', JSON.stringify(testFinalResults, null, 2)); + return testFinalResults; +} - // find duplicates in response codes and sum count - if (all.rc.length > 0) { - all.rc = getReducedResponceCodes(all.rc); - } +async function putTestHistory(historyParams) { + try { + const { status, testId, finalResults: finalTestResults, startTime, endTime, testTaskConfigs, testScenario, testDescription, testType, completeTasks } = historyParams; + const thisTestScenario = JSON.parse(testScenario); + const succPercent = ((finalTestResults['total'].succ / finalTestResults['total'].throughput) * 100).toFixed(2); + const history = { + testRunId: utils.generateUniqueId(10), + startTime, + endTime, + results: finalTestResults, + status, + succPercent, + testId, + testTaskConfigs, + testScenario: thisTestScenario, + testDescription, + testType, + completeTasks + }; + const ddbParams = { + TableName: HISTORY_TABLE, + Item: history + }; + await dynamoDb.put(ddbParams).promise(); + } catch (err) { + console.error(err); + throw err; + } +} - // summarize the test result per label - if (all.labels.length > 0) { - let set = new Set(); - let labels = []; - - for (let label of all.labels) { - set.add(label.label); - } - - for (let label of set.keys()) { - let labelTestFinalResults = {}; - let labelAll = { - avg_ct: [], - avg_lt: [], - avg_rt: [], - bytes: [], - concurrency: [], - fail: [], - label: '', - p0_0: [], - p100_0: [], - p50_0: [], - p90_0: [], - p95_0: [], - p99_0: [], - p99_9: [], - stdev_rt: [], - succ: [], - testDuration: [], - throughput: [], - rc: [] - }; - - const labelStats = all.labels.filter((stats) => stats.label === label); - for (let stat of labelStats) { - createAggregatedData(stat, labelAll); - } - - // find duplicates in response codes and sum count - if (labelAll.rc.length > 0) { - labelAll.rc = getReducedResponceCodes(labelAll.rc); - } - - // parse all of the results to generate the final results. - createFinalResults(labelAll, labelTestFinalResults); - labels.push(labelTestFinalResults); - } - - all.labels = labels; - } +async function updateTable(params) { + const { status, testId, finalResults: finalTestResults, endTime, completeTasks } = params; + + const ddbUpdateParams = { + TableName: SCENARIOS_TABLE, + Key: { + testId: testId + }, + UpdateExpression: 'set #r = :r, #t = :t, #s = :s, #ct = :ct', + ExpressionAttributeNames: { + '#r': 'results', + '#t': 'endTime', + '#s': 'status', + '#ct': 'completeTasks' + }, + ExpressionAttributeValues: { + ':r': finalTestResults, + ':t': endTime, + ':s': status, + ':ct': completeTasks + }, + ReturnValues: 'ALL_NEW' + }; + await dynamoDb.update(ddbUpdateParams).promise(); + return 'Success'; +} - // parse all of the results to generate the final results. - createFinalResults(all, testFinalResults); - console.log('Final Results: ', JSON.stringify(testFinalResults, null, 2)); - - const widget = { - title: 'CloudWatch Metrics', - width: 600, - height: 395, - metrics: [ - [ - "distributed-load-testing", `${testId}-avgRt`, - { - color: '#FF9900', - label: 'Avg Response Time' - } - ], - [ - ".", `${testId}-numVu`, - { - "color": "#1f77b4", - "yAxis": "right", - "stat": "Sum", - "label": "Virtual Users" - } - ], - [ - ".", `${testId}-numSucc`, - { - "color": "#2CA02C", - "yAxis": "right", - "stat": "Sum", - "label": "Succcess" - } - ], - [ - ".", `${testId}-numFail`, - { - "color": "#D62728", - "yAxis": "right", - "stat": "Sum", - "label": "Failures" - } - ] - ], - period: 10, - yAxis: { - "left": { - "showUnits": false, - "label": "Seconds" - }, - "right": { - "showUnits": false, - "label": "Total" - } - }, - stat: 'Average', - view: 'timeSeries', - start: new Date(startTime).toISOString(), - end: new Date(endTime).toISOString() - }; - const cwParams = { - MetricWidget: JSON.stringify(widget) - }; - console.log(widget); - - // Write the image to S3, store the object key in DDB - const image = await cloudwatch.getMetricWidgetImage(cwParams).promise(); - const metricWidgetImage = Buffer.from(image.MetricWidgetImage).toString('base64'); - const metricImageTitle = `${widget.title}-${widget.start}`; - const metricS3ObjectKey = `cloudwatch-images/${testId}/${metricImageTitle}`; - const s3PutObjectParams = { - Body: metricWidgetImage, - Bucket: process.env.SCENARIOS_BUCKET, - Key: `public/${metricS3ObjectKey}`, - ContentEncoding: 'base64', - ContentType: 'image/jpeg' - }; - await s3.putObject(s3PutObjectParams).promise(); - - const succPercent = ((testFinalResults.succ / testFinalResults.throughput) * 100).toFixed(2); - const status = 'complete'; - - // Update Scenarios Table with final results and history. - const history = { - id: generateUniqueId(10), - startTime, - endTime, - results: testFinalResults, - status, - succPercent, - taskCount, - metricS3Location: metricS3ObjectKey, - testScenario: thisTestScenario, - testDescription, - testType - }; - const ddbUpdateParams = { - TableName: process.env.SCENARIOS_TABLE, - Key: { - testId: testId - }, - UpdateExpression: 'set #r = :r, #t = :t, #msl = :msl, #s = :s, #h = list_append(:h, if_not_exists(#h, :l)), #ct = :ct', - ExpressionAttributeNames: { - '#r': 'results', - '#t': 'endTime', - '#msl': 'metricS3Location', - '#s': 'status', - '#h': 'history', - '#ct': 'completeTasks' - }, - ExpressionAttributeValues: { - ':r': testFinalResults, - ':t': endTime, - ':msl': metricS3ObjectKey, - ':s': status, - ':h': [history], - ':l': [], - ':ct': data.length - }, - ReturnValues: 'ALL_NEW' - }; - await dynamoDb.update(ddbUpdateParams).promise(); - - return testFinalResults; - } catch (error) { - console.error('finalResults function error', error); - throw error; +function getWidgetMetrics(testId, options) { + const metrics = []; + const metricOptions = { + 'avgRt': { + label: 'Avg Response Time', + color: '#FF9900' + }, + 'numVu': { + label: 'Virtual Users', + color: '#1f77b4', + }, + 'numSucc': { + label: 'Successes', + color: '#2CA02C' + }, + 'numFail': { + label: 'Failures', + color: '#D62728' } + }; + + for (const key in metricOptions) { + let metric = []; + let addedOptions = {}; + //add either stat or expression + if (options.expression) { + addedOptions.expression = key === 'avgRt' ? `AVG([${options.expression[key]}])` : `SUM([${options.expression[key]}])`; + } else { + addedOptions = options; + metric = ['distributed-load-testing', `${testId}-${key}`]; + (key !== 'avgRt') && (metricOptions[key].stat = 'Sum'); + } + //if key is not avgRt add sum and yAxis options + (key !== 'avgRt') && (metricOptions[key].yAxis = 'right'); + + //add in provided options + metricOptions[key] = { ...metricOptions[key], ...addedOptions }; + + metric.push(metricOptions[key]); + metrics.push(metric); + } + + return metrics; +} + +async function createWidget(startTime, endTime, region, testId, metrics) { + if (region !== 'total') { + metrics = getWidgetMetrics(testId, { region: region }); + } else { + const metricIds = { avgRt: [], numVu: [], numSucc: [], numFail: [] }; + metrics = metrics.map((metric) => { + const metricName = metric[1].split('-').pop(); + const metricId = `${metricName}${metricIds[metricName].length}`; + metricIds[metricName].push(metricId); + metric[2] = { ...metric[2], visible: false, id: metricId }; + return metric; + }); + metrics = metrics.concat(getWidgetMetrics(testId, { expression: metricIds })); + } + + const widget = { + title: `CloudWatchMetrics-${region}`, + width: 600, + height: 395, + metrics: metrics, + period: 10, + yAxis: { + "left": { + "showUnits": false, + "label": "Seconds" + }, + "right": { + "showUnits": false, + "label": "Total" + } + }, + stat: 'Average', + view: 'timeSeries', + start: new Date(startTime).toISOString(), + end: new Date(endTime).toISOString() + }; + const cwParams = { + MetricWidget: JSON.stringify(widget) + }; + console.log(JSON.stringify(widget)); + // Write the image to S3, store the object key in DDB + awsOptions.region = region === 'total' ? AWS_REGION : region; + const cloudwatch = new AWS.CloudWatch(awsOptions); + const image = await cloudwatch.getMetricWidgetImage(cwParams).promise(); + const metricWidgetImage = Buffer.from(image.MetricWidgetImage).toString('base64'); + const metricImageTitle = `${widget.title}-${widget.start}`; + const metricS3ObjectKey = `cloudwatch-images/${testId}/${metricImageTitle}`; + const s3PutObjectParams = { + Body: metricWidgetImage, + Bucket: process.env.SCENARIOS_BUCKET, + Key: `public/${metricS3ObjectKey}`, + ContentEncoding: 'base64', + ContentType: 'image/jpeg' + }; + await s3.putObject(s3PutObjectParams).promise(); + console.log(`Wrote metric widget public/${metricS3ObjectKey} to S3 bucket`); + + return { metricS3Location: metricS3ObjectKey, metrics: widget.metrics }; +} + +async function deleteRegionalMetricFilter(testId, region, taskCluster, ecsCloudWatchLogGroup) { + awsOptions.region = region; + const cloudwatchLogs = new AWS.CloudWatchLogs(awsOptions); + const metrics = ["numVu", "numSucc", "numFail", "avgRt"]; + for (const metric of metrics) { + const deleteMetricFilterParams = { + filterName: `${taskCluster}-Ecs${metric}-${testId}`, + logGroupName: ecsCloudWatchLogGroup + }; + await cloudwatchLogs.deleteMetricFilter(deleteMetricFilterParams).promise(); + } + return 'Success'; } module.exports = { - results, - finalResults -} \ No newline at end of file + results, + finalResults, + createWidget, + deleteRegionalMetricFilter, + putTestHistory, + updateTable +}; diff --git a/source/results-parser/lib/parser/index.spec.js b/source/results-parser/lib/parser/index.spec.js index 49352a0..748b3cd 100644 --- a/source/results-parser/lib/parser/index.spec.js +++ b/source/results-parser/lib/parser/index.spec.js @@ -4,407 +4,604 @@ // Mock AWS SDK const mockDynamoDB = jest.fn(); const mockCloudWatch = jest.fn(); +const mockCloudWatchLogs = jest.fn(); const mockS3 = jest.fn(); const mockAWS = require('aws-sdk'); +mockAWS.CloudWatchLogs = jest.fn(() => ({ + deleteMetricFilter: mockCloudWatchLogs +})); mockAWS.CloudWatch = jest.fn(() => ({ - getMetricWidgetImage: mockCloudWatch + getMetricWidgetImage: mockCloudWatch })); mockAWS.DynamoDB.DocumentClient = jest.fn(() => ({ - update: mockDynamoDB, + update: mockDynamoDB, + put: mockDynamoDB })); mockAWS.S3 = jest.fn(() => ({ - putObject: mockS3 + putObject: mockS3 })); // Mock xml-js const mockParse = jest.fn(); jest.mock('xml-js', () => { - return { - xml2js: mockParse - }; + return { + xml2js: mockParse + }; }); process.env.SOLUTION_ID = 'SO0062'; -process.env.VERSION = '2.0.1'; +process.env.VERSION = '3.0.0'; const lambda = require('./index.js'); describe('#RESULTS PARSER::', () => { - process.env.SCENARIOS_BUCKET = 'scenario_bucket'; - const content = 'XML_FILE_CONTENT'; - const testId = 'abcd'; - const json = { - "FinalStatus": { - "TestDuration": { - "_text": 123 - }, - "Group": [ - { - "_attributes": { - "label": "" - }, - "avg_ct": { - "_attributes": { - "value": "0.23043" - }, - "name": { - "_text": "avg_ct" - }, - "value": { - "_text": 0.23043 - } - }, - "rc": { - "_attributes": { - "value": "20753", - "param": "UnknownHostException" - }, - "name": { - "_text": "rc/UnknownHostException" - }, - "value": { - "_text": 20753 - } - }, - "perc": [ - { - "_attributes": { - "value": "4.89600", - "param": "95.0" - }, - "name": { - "_text": "perc/95.0" - }, - "value": { - "_text": 4.896 - } - } - ] - }, - { - "_attributes": { - "label": "HTTP GET Request" - }, - "avg_ct": { - "_attributes": { - "value": "0.23043" - }, - "name": { - "_text": "avg_ct" - }, - "value": { - "_text": 0.23043 - } - }, - "rc": { - "_attributes": { - "value": "20753", - "param": "UnknownHostException" - }, - "name": { - "_text": "rc/UnknownHostException" - }, - "value": { - "_text": 20753 - } - }, - "perc": [ - { - "_attributes": { - "value": "4.89600", - "param": "95.0" - }, - "name": { - "_text": "perc/95.0" - }, - "value": { - "_text": 4.896 - } - } - ] - } - ] - } - }; - const resultJson = { - avg_ct: 0.23043, - p95_0: 4.896, - rc: [ - { - code: 'UnknownHostException', - count: 20753 - } - ], - testDuration: 123 - }; - const finalData = [ - { - duration: '39', - labels: [ - { - avg_ct: 0.00096, - avg_lt: 0, - avg_rt: 0.00103, - bytes: 48258556, - concurrency: 4, - fail: 21064, - label: 'HTTP GET Request', - p0_0: 0, - p50_0: 0, - p90_0: 0, - p95_0: 0.001, - p99_0: 0.013, - p99_9: 0.105, - p100_0: 0.396, - stdev_rt: 0.01049, - succ: 0, - testDuration: 39, - throughput: 21064, - rc: [ - { code: 'UnknownHostException', count: 20753 }, - { code: 'UnknownHostException', count: 20753 } - ] - } - ], - stats: { - avg_ct: 0.00096, - avg_lt: 0, - avg_rt: 0.00103, - bytes: 48258556, - concurrency: 4, - fail: 21064, - p0_0: 0, - p50_0: 0, - p90_0: 0, - p95_0: 0.001, - p99_0: 0.013, - p99_9: 0.105, - p100_0: 0.396, - stdev_rt: 0.01049, - succ: 0, - testDuration: 39, - throughput: 21064, - rc: [ - { code: 'UnknownHostException', count: 20753 }, - { code: 'UnknownHostException', count: 20753 } - ] - } - } - ]; - const finalAggregatedResult = { - avg_ct: '0.00096', - avg_lt: '0.00000', - avg_rt: '0.00103', - bytes: '48258556', - concurrency: '4', - fail: 21064, - p0_0: '0.000', - p50_0: '0.000', - p95_0: '0.001', - p90_0: '0.000', - p99_0: '0.013', - p99_9: '0.105', - p100_0: '0.396', - rc: [{ code: 'UnknownHostException', count: 41506 }], - stdev_rt: '0.010', - succ: 0, - testDuration: '39', - throughput: 21064, - labels: [ - { - avg_ct: '0.00096', - avg_lt: '0.00000', - avg_rt: '0.00103', - bytes: '48258556', - concurrency: '4', - fail: 21064, - label: 'HTTP GET Request', - p0_0: '0.000', - p50_0: '0.000', - p95_0: '0.001', - p90_0: '0.000', - p99_0: '0.013', - p99_9: '0.105', - p100_0: '0.396', - rc: [{ code: 'UnknownHostException', count: 41506 }], - stdev_rt: '0.010', - succ: 0, - testDuration: '39', - throughput: 21064 - } - ] - }; - const startTime = '2020-09-01 00:00:00' - const taskCount = 2 - const testScenario = "{\"execution\":[{\"concurrency\":20,\"ramp-up\":\"10s\",\"hold-for\":\"2m\",\"scenario\":\"test1-5-err\"}],\"scenarios\":{\"test1-5-err\":{\"script\":\"ryWOD3EDT.jmx\"}},\"reporting\":[{\"module\":\"final-stats\",\"summary\":true,\"percentiles\":true,\"summary-labels\":true,\"test-duration\":true,\"dump-xml\":\"/tmp/artifacts/results.xml\"}]}" - const testDescription = 'This test description' - - beforeEach(() => { - mockDynamoDB.mockReset(); - mockCloudWatch.mockReset(); - mockParse.mockReset(); - mockS3.mockReset(); - }); - - //Positive tests - it('should return the result object when parse results are processed successfully for the single RC', async () => { - mockParse.mockImplementation(() => { - return json; - }); - - const response = await lambda.results(content, testId); - expect(response).toEqual({ - stats: resultJson, - labels: [{ - avg_ct: 0.23043, - p95_0: 4.896, - rc: [{ - code: 'UnknownHostException', - count: 20753, - }], - label: 'HTTP GET Request' - }], - duration: json.FinalStatus.TestDuration._text - }); - }); - it('should return the result object when parse results are processed successfully for the multiple RCs', async () => { - json.FinalStatus.Group[0].rc = [ - { - _attributes: { - value: '20753', - param: 'UnknownHostException' - }, - name: { - _text: 'rc/UnknownHostException' - }, - value: { - _text: 20753 - } - }, - { - _attributes: { - value: '1', - param: '200' - }, - name: { - _text: 'rc/200' - }, - value: { - _text: 1 - } - } - ]; - json.FinalStatus.Group[1].rc = json.FinalStatus.Group[0].rc; - - mockParse.mockImplementation(() => { - return json; - }); - - const response = await lambda.results(content, testId); - expect(response).toEqual({ - stats: resultJson, - labels: [{ - avg_ct: 0.23043, - p95_0: 4.896, - rc: [{ - code: 'UnknownHostException', - count: 20753, - }], - label: 'HTTP GET Request' - }], - duration: json.FinalStatus.TestDuration._text - }); - }); - it('should return the final result when final results are processed successfully', async () => { - mockDynamoDB.mockImplementationOnce(() => { - return { - promise() { - // update - return Promise.resolve(); - } - }; - }); - mockCloudWatch.mockImplementation(() => { - return { - promise() { - // getMetricWidgetImage - return Promise.resolve({ MetricWidgetImage: 'CloudWatchImage' }); - } - }; - }); - - mockS3.mockImplementation(() => { - return { - promise() { - // putObject - return Promise.resolve(); - } - }; - }); - - const response = await lambda.finalResults(testId, finalData, startTime, taskCount, testScenario, testDescription); - expect(response).toEqual(finalAggregatedResult); - }); - - //Negative Tests - it('should return "XML ERROR" when parse results fails', async () => { - mockParse.mockImplementation(() => { - throw 'XML ERROR'; - }); - - try { - await lambda.results(content, testId, startTime); - } catch (error) { - expect(error).toEqual('XML ERROR'); - } - }); - it('should return "CLOUDWATCH ERROR" when final results fails', async () => { - mockCloudWatch.mockImplementation(() => { - return { - promise() { - // getMetricWidgetImage - return Promise.reject('CLOUDWATCH ERROR'); - } - }; - }); - - try { - await lambda.finalResults(testId, finalData, startTime, taskCount, testScenario, testDescription); - } catch (error) { - expect(error).toEqual('CLOUDWATCH ERROR'); - } - }); - it('should return "DB UPDATE ERROR" when final results fails', async () => { - mockDynamoDB.mockImplementationOnce(() => { - return { - promise() { - // update - return Promise.reject('DB UPDATE ERROR'); - } - }; - }); - mockCloudWatch.mockImplementation(() => { - return { - promise() { - // getMetricWidgetImage - return Promise.resolve({ MetricWidgetImage: 'CloudWatchImage' }); - } - }; - }); - - mockS3.mockImplementation(() => { - return { - promise() { - // putObject - return Promise.resolve(); - } - }; - }); - - try { - await lambda.finalResults(testId, finalData, startTime, taskCount, testScenario, testDescription); - } catch (error) { - expect(error).toEqual('DB UPDATE ERROR'); - } - }); + process.env.SCENARIOS_BUCKET = 'scenario_bucket'; + const content = 'XML_FILE_CONTENT'; + const testId = 'abcd'; + const json = { + "FinalStatus": { + "TestDuration": { + "_text": 123 + }, + "Group": [ + { + "_attributes": { + "label": "" + }, + "avg_ct": { + "_attributes": { + "value": "0.23043" + }, + "name": { + "_text": "avg_ct" + }, + "value": { + "_text": 0.23043 + } + }, + "rc": { + "_attributes": { + "value": "20753", + "param": "UnknownHostException" + }, + "name": { + "_text": "rc/UnknownHostException" + }, + "value": { + "_text": 20753 + } + }, + "perc": [ + { + "_attributes": { + "value": "4.89600", + "param": "95.0" + }, + "name": { + "_text": "perc/95.0" + }, + "value": { + "_text": 4.896 + } + } + ] + }, + { + "_attributes": { + "label": "HTTP GET Request" + }, + "avg_ct": { + "_attributes": { + "value": "0.23043" + }, + "name": { + "_text": "avg_ct" + }, + "value": { + "_text": 0.23043 + } + }, + "rc": { + "_attributes": { + "value": "20753", + "param": "UnknownHostException" + }, + "name": { + "_text": "rc/UnknownHostException" + }, + "value": { + "_text": 20753 + } + }, + "perc": [ + { + "_attributes": { + "value": "4.89600", + "param": "95.0" + }, + "name": { + "_text": "perc/95.0" + }, + "value": { + "_text": 4.896 + } + } + ] + } + ] + } + }; + const resultJson = { + avg_ct: 0.23043, + p95_0: 4.896, + rc: [ + { + code: 'UnknownHostException', + count: 20753 + } + ], + testDuration: 123 + }; + const finalData = [ + { + duration: '39', + labels: [ + { + avg_ct: 0.00096, + avg_lt: 0, + avg_rt: 0.00103, + bytes: 48258556, + concurrency: 4, + fail: 21064, + label: 'HTTP GET Request', + p0_0: 0, + p50_0: 0, + p90_0: 0, + p95_0: 0.001, + p99_0: 0.013, + p99_9: 0.105, + p100_0: 0.396, + stdev_rt: 0.01049, + succ: 0, + testDuration: 39, + throughput: 21064, + rc: [ + { code: 'UnknownHostException', count: 20753 }, + { code: 'UnknownHostException', count: 20753 } + ] + } + ], + stats: { + avg_ct: 0.00096, + avg_lt: 0, + avg_rt: 0.00103, + bytes: 48258556, + concurrency: 4, + fail: 21064, + p0_0: 0, + p50_0: 0, + p90_0: 0, + p95_0: 0.001, + p99_0: 0.013, + p99_9: 0.105, + p100_0: 0.396, + stdev_rt: 0.01049, + succ: 0, + testDuration: 39, + throughput: 21064, + rc: [ + { code: 'UnknownHostException', count: 20753 }, + { code: 'UnknownHostException', count: 20753 } + ] + } + } + ]; + const singleAggregatedResult = { + avg_ct: '0.00096', + avg_lt: '0.00000', + avg_rt: '0.00103', + bytes: '48258556', + concurrency: '4', + fail: 21064, + p0_0: '0.000', + p50_0: '0.000', + p95_0: '0.001', + p90_0: '0.000', + p99_0: '0.013', + p99_9: '0.105', + p100_0: '0.396', + rc: [{ code: 'UnknownHostException', count: 41506 }], + stdev_rt: '0.010', + succ: 0, + testDuration: '39', + throughput: 21064, + labels: [ + { + avg_ct: '0.00096', + avg_lt: '0.00000', + avg_rt: '0.00103', + bytes: '48258556', + concurrency: '4', + fail: 21064, + label: 'HTTP GET Request', + p0_0: '0.000', + p50_0: '0.000', + p95_0: '0.001', + p90_0: '0.000', + p99_0: '0.013', + p99_9: '0.105', + p100_0: '0.396', + rc: [{ code: 'UnknownHostException', count: 41506 }], + stdev_rt: '0.010', + succ: 0, + testDuration: '39', + throughput: 21064 + } + ] + }; + const finalAggregatedResults = { + "us-east-1": singleAggregatedResult, + "total": singleAggregatedResult + }; + + const startTime = '2020-09-01 00:00:00'; + const endTime = '2020-09-01 00:02:00'; + const testScenario = "{\"execution\":[{\"ramp-up\":\"10s\",\"hold-for\":\"2m\",\"scenario\":\"test1-5-err\"}],\"scenarios\":{\"test1-5-err\":{\"script\":\"ryWOD3EDT.jmx\"}},\"reporting\":[{\"module\":\"final-stats\",\"summary\":true,\"percentiles\":true,\"summary-labels\":true,\"test-duration\":true,\"dump-xml\":\"/tmp/artifacts/results.xml\"}]}"; + const testDescription = 'This test description'; + const testTaskConfigs = [ + { + region: 'us-east-1', + concurrency: 1, + taskCount: 2 + } + ]; + const testType = 'simple'; + const region = 'us-east-1'; + const ecsCloudWatchLogGroup = 'testEcsLogGroup'; + const taskCluster = 'testCluster'; + + const updateTableParams = { testId, finalResults: finalAggregatedResults, startTime, endTime, testTaskConfigs, testScenario, testDescription, testType, region, ecsCloudWatchLogGroup, taskCluster }; + const widgetMetricsRegion1 = [ + ["distributed-load-testing", `${testId}-avgRt`, { "region": "us-east-1", "color": "#FF9900", "label": "Avg Response Time" }], + ["distributed-load-testing", `${testId}-numVu`, { "region": "us-east-1", "color": "#1f77b4", "label": "Virtual Users", "stat": "Sum", "yAxis": "right" }], + ["distributed-load-testing", `${testId}-numSucc`, { "region": "us-east-1", "color": "#2CA02C", "label": "Successes", "stat": "Sum", "yAxis": "right" }], + ["distributed-load-testing", `${testId}-numFail`, { "region": "us-east-1", "color": "#D62728", "label": "Failures", "stat": "Sum", "yAxis": "right" }] + ]; + const widgetMetricsRegion2 = [ + ["distributed-load-testing", `${testId}-avgRt`, { "region": "us-east-2", "color": "#FF9900", "label": "Avg Response Time" }], + ["distributed-load-testing", `${testId}-numVu`, { "region": "us-east-2", "color": "#1f77b4", "label": "Virtual Users", "stat": "Sum", "yAxis": "right" }], + ["distributed-load-testing", `${testId}-numSucc`, { "region": "us-east-2", "color": "#2CA02C", "label": "Successes", "stat": "Sum", "yAxis": "right" }], + ["distributed-load-testing", `${testId}-numFail`, { "region": "us-east-2", "color": "#D62728", "label": "Failures", "stat": "Sum", "yAxis": "right" }] + ]; + const aggregateWidgetMetrics = [ + ["distributed-load-testing", `${testId}-avgRt`, { "region": "us-east-1", "color": "#FF9900", "label": "Avg Response Time", id: 'avgRt0', visible: false }], + ["distributed-load-testing", `${testId}-numVu`, { "region": "us-east-1", "color": "#1f77b4", "label": "Virtual Users", "stat": "Sum", "yAxis": "right", id: 'numVu0', visible: false }], + ["distributed-load-testing", `${testId}-numSucc`, { "region": "us-east-1", "color": "#2CA02C", "label": "Successes", "stat": "Sum", "yAxis": "right", id: 'numSucc0', visible: false }], + ["distributed-load-testing", `${testId}-numFail`, { "region": "us-east-1", "color": "#D62728", "label": "Failures", "stat": "Sum", "yAxis": "right", id: 'numFail0', visible: false }], + ["distributed-load-testing", `${testId}-avgRt`, { "region": "us-east-2", "color": "#FF9900", "label": "Avg Response Time", id: 'avgRt1', visible: false }], + ["distributed-load-testing", `${testId}-numVu`, { "region": "us-east-2", "color": "#1f77b4", "label": "Virtual Users", "stat": "Sum", "yAxis": "right", id: 'numVu1', visible: false }], + ["distributed-load-testing", `${testId}-numSucc`, { "region": "us-east-2", "color": "#2CA02C", "label": "Successes", "stat": "Sum", "yAxis": "right", id: 'numSucc1', visible: false }], + ["distributed-load-testing", `${testId}-numFail`, { "region": "us-east-2", "color": "#D62728", "label": "Failures", "stat": "Sum", "yAxis": "right", id: 'numFail1', visible: false }], + [{ expression: 'AVG([avgRt0,avgRt1])', "color": "#FF9900", "label": "Avg Response Time" }], + [{ expression: 'SUM([numVu0,numVu1])', "color": "#1f77b4", "label": "Virtual Users", "yAxis": "right" }], + [{ expression: 'SUM([numSucc0,numSucc1])', "color": "#2CA02C", "label": "Successes", "yAxis": "right" }], + [{ expression: 'SUM([numFail0,numFail1])', "color": "#D62728", "label": "Failures", "yAxis": "right" }] + ]; + beforeEach(() => { + mockDynamoDB.mockReset(); + mockCloudWatch.mockReset(); + mockParse.mockReset(); + mockS3.mockReset(); + }); + + it('should return the result object when parse results are processed successfully for the single RC', async () => { + mockParse.mockImplementation(() => { + return json; + }); + + const response = await lambda.results(content, testId); + expect(response).toEqual({ + stats: resultJson, + labels: [{ + avg_ct: 0.23043, + p95_0: 4.896, + rc: [{ + code: 'UnknownHostException', + count: 20753, + }], + label: 'HTTP GET Request' + }], + duration: json.FinalStatus.TestDuration._text + }); + }); + + it('should return the result object when parse results are processed successfully for the multiple RCs', async () => { + json.FinalStatus.Group[0].rc = [ + { + _attributes: { + value: '20753', + param: 'UnknownHostException' + }, + name: { + _text: 'rc/UnknownHostException' + }, + value: { + _text: 20753 + } + }, + { + _attributes: { + value: '1', + param: '200' + }, + name: { + _text: 'rc/200' + }, + value: { + _text: 1 + } + } + ]; + json.FinalStatus.Group[1].rc = json.FinalStatus.Group[0].rc; + + mockParse.mockImplementation(() => { + return json; + }); + + const response = await lambda.results(content, testId); + expect(response).toEqual({ + stats: resultJson, + labels: [{ + avg_ct: 0.23043, + p95_0: 4.896, + rc: [{ + code: 'UnknownHostException', + count: 20753, + }], + label: 'HTTP GET Request' + }], + duration: json.FinalStatus.TestDuration._text + }); + }); + + it('should return the final result when final results are processed successfully', async () => { + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve(); + } + }; + }); + mockCloudWatch.mockImplementation(() => { + return { + promise() { + // getMetricWidgetImage + return Promise.resolve({ MetricWidgetImage: 'CloudWatchImage' }); + } + }; + }); + + mockS3.mockImplementation(() => { + return { + promise() { + // putObject + return Promise.resolve(); + } + }; + }); + mockCloudWatchLogs.mockImplementation(() => { + return { + promise() { + // deleteMetricFilter + return Promise.resolve(); + } + }; + }); + const response = await lambda.finalResults(testId, finalData); + expect(response).toEqual(singleAggregatedResult); + }); + + it('should return "XML ERROR" when parse results fails', async () => { + mockParse.mockImplementation(() => { + throw 'XML ERROR'; + }); + + try { + await lambda.results(content, testId, startTime); + } catch (error) { + expect(error).toEqual('XML ERROR'); + } + }); + + it('should return "DB UPDATE ERROR" when final results fails', async () => { + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.reject('DB UPDATE ERROR'); + } + }; + }); + mockCloudWatch.mockImplementation(() => { + return { + promise() { + // getMetricWidgetImage + return Promise.resolve({ MetricWidgetImage: 'CloudWatchImage' }); + } + }; + }); + + mockS3.mockImplementation(() => { + return { + promise() { + // putObject + return Promise.resolve(); + } + }; + }); + + try { + await lambda.finalResults(testId, finalData); + } catch (error) { + expect(error).toEqual('DB UPDATE ERROR'); + } + }); + + it('should succeed on updateTable call', async () => { + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // put history + return Promise.resolve('success'); + } + }; + }); + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve('success'); + } + }; + }); + const result = await lambda.updateTable(updateTableParams); + expect(result).toEqual('Success'); + + }); + + it('should fail on DynamoDB failure in updateTable', async () => { + mockDynamoDB.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.reject('Failure'); + } + }; + }); + + try { + await lambda.updateTable(updateTableParams); + } catch (err) { + expect(err).toEqual('Failure'); + } + }); + + it('should save image and return key and metric on createWidget success', async () => { + mockCloudWatch.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve({ MetricWidgetImage: 'Image' }); + } + }; + }); + mockS3.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve('Success'); + } + }; + }); + + const result = await lambda.createWidget(startTime, endTime, region, testId, []); + const expectedImageLocation = `cloudwatch-images/${testId}/CloudWatchMetrics-${region}-${new Date(startTime).toISOString()}`; + expect(mockS3).toHaveBeenCalledWith({ + Body: Buffer.from('Image').toString('base64'), + Bucket: 'scenario_bucket', + Key: `public/cloudwatch-images/${testId}/CloudWatchMetrics-${region}-${new Date(startTime).toISOString()}`, + ContentEncoding: 'base64', + ContentType: 'image/jpeg' + }); + expect(result).toEqual({ metricS3Location: expectedImageLocation, metrics: widgetMetricsRegion1 }); + }); + + it('should save image and return key and metrics for totals on createWidget success', async () => { + mockCloudWatch.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve({ MetricWidgetImage: 'Image' }); + } + }; + }); + mockS3.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve('Success'); + } + }; + }); + + const result = await lambda.createWidget(startTime, endTime, 'total', testId, widgetMetricsRegion1.concat(widgetMetricsRegion2)); + const expectedImageLocation = `cloudwatch-images/${testId}/CloudWatchMetrics-total-${new Date(startTime).toISOString()}`; + expect(mockS3).toHaveBeenCalledWith({ + Body: Buffer.from('Image').toString('base64'), + Bucket: 'scenario_bucket', + Key: `public/cloudwatch-images/${testId}/CloudWatchMetrics-total-${new Date(startTime).toISOString()}`, + ContentEncoding: 'base64', + ContentType: 'image/jpeg' + }); + + expect(result).toEqual({ metricS3Location: expectedImageLocation, metrics: aggregateWidgetMetrics }); + }); + + it('should fail on getMetricWidgetImage failure in createWidget', async () => { + mockCloudWatch.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.reject('Failure'); + } + }; + }); + + try { + await lambda.createWidget(startTime, endTime, region, testId, []); + } catch (err) { + expect(err).toEqual('Failure'); + } + }); + + it('should fail on S3 failure in createWidget', async () => { + mockCloudWatch.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve({ MetricWidgetImage: 'Image' }); + } + }; + }); + mockS3.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.reject('Failure'); + } + }; + }); + + try { + await lambda.createWidget(startTime, endTime, region, testId, []); + } catch (err) { + expect(err).toEqual('Failure'); + } + }); + + it('should delete metric filter on deleteRegionalMetricFilter success', async () => { + mockCloudWatchLogs.mockImplementationOnce(() => { + return { + promise() { + // update + return Promise.resolve('Success'); + } + }; + }); + + await lambda.deleteRegionalMetricFilter(testId, region, taskCluster, ecsCloudWatchLogGroup); + expect(mockCloudWatchLogs).toHaveBeenCalledTimes(4); + expect(mockCloudWatchLogs).toHaveBeenCalledWith(expect.objectContaining({ logGroupName: ecsCloudWatchLogGroup })); + }); }); diff --git a/source/results-parser/package.json b/source/results-parser/package.json index 5de12df..65b5a21 100644 --- a/source/results-parser/package.json +++ b/source/results-parser/package.json @@ -1,10 +1,13 @@ { "name": "results-parser", - "version": "2.0.1", - "engines": { - "node": "^12.x" - }, + "version": "3.0.0", "description": "result parser for indexing xml test results to DynamoDB", + "repository": { + "type": "git", + "url": "https://github.com/aws-solutions/distributed-load-testing-on-aws" + }, + "license": "Apache-2.0", + "author": "aws-solution-builders", "main": "index.js", "scripts": { "clean": "rm -rf node_modules package-lock.json", @@ -13,7 +16,7 @@ "dependencies": { "axios": "^0.21.0", "moment": "^2.29.1", - "nanoid": "^3.1.25", + "solution-utils": "file:../solution-utils", "xml-js": "^1.6.11" }, "devDependencies": { @@ -21,11 +24,8 @@ "axios-mock-adapter": "1.19.0", "jest": "26.6.3" }, - "repository": { - "type": "git", - "url": "https://github.com/aws-solutions/distributed-load-testing-on-aws" + "engines": { + "node": "^14.x" }, - "author": "aws-solution-builders", - "license": "Apache-2.0", "readme": "./README.md" } diff --git a/source/solution-utils/package.json b/source/solution-utils/package.json new file mode 100644 index 0000000..16cc6c8 --- /dev/null +++ b/source/solution-utils/package.json @@ -0,0 +1,21 @@ +{ + "name": "solution-utils", + "version": "3.0.0", + "description": "Utilities package for Distributed Load Testing on AWS", + "license": "Apache-2.0", + "author": "aws-solution-builders", + "main": "utils", + "scripts": { + "clean": "rm -rf node_modules package-lock.json", + "test": "jest --coverage --silent" + }, + "dependencies": { + "nanoid": "^3.1.25" + }, + "devDependencies": { + "jest": "26.6.3" + }, + "engines": { + "node": "^14.x" + } +} diff --git a/source/solution-utils/test/index.spec.js b/source/solution-utils/test/index.spec.js new file mode 100644 index 0000000..f9f17ae --- /dev/null +++ b/source/solution-utils/test/index.spec.js @@ -0,0 +1,78 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const utils = require('../utils'); + +describe('#GET OPTIONS:: ', () => { + const OLD_ENV = process.env; + + beforeEach(() => { + process.env = { ...OLD_ENV }; + }); + + it('should return an empty object if no solution ID or versions are not provided', () => { + let options = {}; + options = utils.getOptions(options); + expect(options).toEqual({}); + }); + + it('should return an empty object if no solution ID or versions are provided as empty strings', () => { + process.env.SOLUTION_ID = ' '; + process.env.SOLUTION_VERSION = ' '; + let options = {}; + options = utils.getOptions(options); + expect(options).toEqual({}); + }); + + it('should return an unchanged object', () => { + let options = { + region: 'us-west-2' + }; + options = utils.getOptions(options); + expect(options).toEqual({ region: 'us-west-2' }); + }); + + it('should return an empty object if no solution ID is an empty string', () => { + process.env.SOLUTION_ID = ' '; + process.env.SOLUTION_VERSION = 'testVersion'; + let options = {}; + options = utils.getOptions(options); + expect(options).toEqual({}); + }); + + it('should return an empty object if no version is provided', () => { + process.env.SOLUTION_ID = 'SOxxx'; + process.env.SOLUTION_VERSION = ' '; + let options = {}; + options = utils.getOptions(options); + expect(options).toEqual({}); + }); + + it('should return an object with the custom agent user string set', () => { + process.env.SOLUTION_ID = 'SOxxx'; + process.env.SOLUTION_VERSION = 'testVersion'; + let options = {}; + options = utils.getOptions(options); + expect(options).toEqual({ customUserAgent: 'AwsSolution/SOxxx/testVersion' }); + }); + +}); + +describe('#GENERATE UNIQUE ID:: ', () => { + + it('should return a unique id of length 1', () => { + const uniqueId = utils.generateUniqueId(1); + expect(uniqueId).toHaveLength(1); + }); + + it('should return a unique id of length 10', () => { + const uniqueId = utils.generateUniqueId(); + expect(uniqueId).toHaveLength(10); + }); + + it('should return a unique id of length 20', () => { + const uniqueId = utils.generateUniqueId(20); + expect(uniqueId).toHaveLength(20); + }); + +}); \ No newline at end of file diff --git a/source/solution-utils/utils.js b/source/solution-utils/utils.js new file mode 100644 index 0000000..6ff7fa0 --- /dev/null +++ b/source/solution-utils/utils.js @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const { customAlphabet } = require('nanoid'); + +/** + * Generates an unique ID based on the parameter length. + * @param length The length of the unique ID + * @returns The unique ID + */ +const generateUniqueId = (length = 10) => { + const ALPHA_NUMERIC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const nanoid = customAlphabet(ALPHA_NUMERIC, length); + return nanoid(); +}; + +/** + * Sets the customUserAgent if SOLUTION_ID and SOLUTION_VERSION are provided as environment variables. + * @param options An object, can be empty {} + * @returns The options object with customUserAgent set if environment variables exist + */ + +const getOptions = (options) => { + const { SOLUTION_ID, SOLUTION_VERSION } = process.env; + if (SOLUTION_ID && SOLUTION_VERSION) { + if (SOLUTION_ID.trim() !== '' && SOLUTION_VERSION.trim() !== '') { + options.customUserAgent = `AwsSolution/${SOLUTION_ID}/${SOLUTION_VERSION}`; + } + } + + return options; +}; + +module.exports = { + generateUniqueId: generateUniqueId, + getOptions: getOptions +}; \ No newline at end of file diff --git a/source/task-canceler/index.js b/source/task-canceler/index.js index 0a9cfa6..e231a77 100644 --- a/source/task-canceler/index.js +++ b/source/task-canceler/index.js @@ -2,85 +2,89 @@ // SPDX-License-Identifier: Apache-2.0 const AWS = require('aws-sdk'); +const utils = require('solution-utils'); AWS.config.logger = console; -const { SOLUTION_ID, VERSION } = process.env; let options = {}; -if (SOLUTION_ID && VERSION && SOLUTION_ID.trim() && VERSION.trim()) { - options.customUserAgent = `AwsSolution/${SOLUTION_ID}/${VERSION}`; -} -options.region = process.env.AWS_REGION +options = utils.getOptions(options); +options.region = process.env.AWS_REGION; const dynamoDB = new AWS.DynamoDB.DocumentClient(options); -const ecs = new AWS.ECS(options); exports.handler = async (event) => { - const testId = event.scenario ? event.scenario.testId : event.testId; - console.log(`Cancelling test: ${testId}`); - const sleep = s => new Promise(resolve => setTimeout(resolve, s * 1000)); + const testId = event.testId; + const region = event.testTaskConfig.region; + const taskCluster = event.testTaskConfig.taskCluster; + console.log(`Cancelling test: ${testId}`); + const sleep = s => new Promise(resolve => setTimeout(resolve, s * 1000)); + options.region = region; + const ecs = new AWS.ECS(options); + try { - try { - let params; + //Function to list tasks belonging to test + async function listTasks(params) { + let currentRunningTasks = []; + let data; + do { + data = await ecs.listTasks(params).promise(); + currentRunningTasks = currentRunningTasks.concat(data.taskArns); + params.nextToken = data.nextToken; + } while (data.nextToken); - //Function to list tasks belonging to test - let listTasks = async (params) => { - params = { - cluster: process.env.TASK_CLUSTER, - desiredStatus: 'RUNNING', - startedBy: testId - }; + return currentRunningTasks; + } + + const listTaskParams = { + cluster: taskCluster, + desiredStatus: 'RUNNING', + startedBy: testId + }; - let runningTasks = []; - let data; - do { - data = await ecs.listTasks(params).promise(); - runningTasks = runningTasks.concat(data.taskArns); - params.nextToken = data.nextToken; - } while(data.nextToken); + //1. get a list of all running tasks + let runningTasks = await listTasks(listTaskParams); - return runningTasks; + //2. Stop tasks in batches of 100 + while (runningTasks.length > 0) { + let promises = []; + const runningTasksSubset = runningTasks.splice(0, 100); + for (const task of runningTasksSubset) { + const stopTaskParams = { + cluster: taskCluster, + task: task }; + //create stopTask promise and add to array + promises.push(ecs.stopTask(stopTaskParams).promise().catch((err) => console.error(err))); + } + //await subset of 100 or less stopTask promises + await Promise.all(promises); + sleep(5); - //1. get a list of all running tasks - let runningTasks = await listTasks(params); - - //2. Stop tasks in batches of 100 - while (runningTasks.length > 0) { - let promises = []; - const runningTasksSubset = runningTasks.splice(0,100); - for (const task of runningTasksSubset) { - params = { - cluster: process.env.TASK_CLUSTER, - task: task - }; - //create stopTask promise and add to array - promises.push(ecs.stopTask(params).promise().catch((err) => console.error(err))); - } - //await subset of 100 or less stopTask promises - await Promise.all(promises); - sleep(5); - - - //Double check if any tasks remain, add back if necessary - if (runningTasks.length === 0) runningTasks = await listTasks(params); + + //Double check if any tasks remain, add back if necessary + if (runningTasks.length === 0) runningTasks = await listTasks(listTaskParams); + } + + //3. Update table + if (!event.error) { + const ddbParams = { + TableName: process.env.SCENARIOS_TABLE, + Key: { + testId: testId + }, + UpdateExpression: 'set #s = :s', + ExpressionAttributeNames: { + '#s': 'status' + }, + ExpressionAttributeValues: { + ':s': 'cancelled' } - - //3. Update table - params = { - TableName: process.env.SCENARIOS_TABLE, - Key: { - testId: testId - }, - UpdateExpression: 'set #s = :s', - ExpressionAttributeNames: { - '#s':'status' - }, - ExpressionAttributeValues: { - ':s': 'cancelled' - } - }; - await dynamoDB.update(params).promise(); - - return 'test cancelled'; - } catch (err) { - throw err; + }; + await dynamoDB.update(ddbParams).promise(); + + return 'test cancelled'; + } else { + return 'test stopped due to error' } + } catch (err) { + console.log(err); + throw err; + } }; diff --git a/source/task-canceler/jest.config.js b/source/task-canceler/jest.config.js new file mode 100644 index 0000000..7d3eaec --- /dev/null +++ b/source/task-canceler/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + roots: ['/lib'], + testMatch: ['**/*.spec.js'], + collectCoverageFrom: [ + '**/*.js' + ], + coverageReporters: [ + "text", + "clover", + "json", + ["lcov", { "projectRoot": "../../" }] + ] +}; \ No newline at end of file diff --git a/source/task-canceler/lib/index.spec.js b/source/task-canceler/lib/index.spec.js index 5cf5872..9a6163d 100644 --- a/source/task-canceler/lib/index.spec.js +++ b/source/task-canceler/lib/index.spec.js @@ -4,216 +4,278 @@ // Mock AWS SDK const mockAWS = require('aws-sdk'); const mockDynamoDb = { - update: jest.fn() + update: jest.fn() }; const mockEcs = { - listTasks: jest.fn(), - stopTask: jest.fn() + listTasks: jest.fn(), + stopTask: jest.fn() }; mockAWS.ECS = jest.fn(() => ({ - listTasks: mockEcs.listTasks, - stopTask: mockEcs.stopTask + listTasks: mockEcs.listTasks, + stopTask: mockEcs.stopTask })); mockAWS.DynamoDB.DocumentClient = jest.fn(() => ({ - update: mockDynamoDb.update + update: mockDynamoDb.update })); process.env = { - TASK_CLUSTER: "arn:of:taskCluster", - SCENARIOS_TABLE: "arn:of:scenariosTable", - VERSION: "2.0.1", - SOLUTION_ID: "sO0062" + SCENARIOS_TABLE: "arn:of:scenariosTable", + VERSION: "2.0.1", + SOLUTION_ID: "sO0062" }; const lambda = require('../index.js'); let event = { - testId: "mockTestId" -} - - + testId: "mockTestId", + testTaskConfig: { + region: "testRegion", + taskCluster: "testTaskCluster" + } +}; let listTasksResponse = (numTasks) => { - let taskList = []; - for (i = 0; i < numTasks; i++) { - taskList.push(`arn:of:task${i}`); - } - return { taskArns: taskList }; -} + let taskList = []; + for (i = 0; i < numTasks; i++) { + taskList.push(`arn:of:task${i}`); + } + return { taskArns: taskList }; +}; describe('#TASK RUNNER:: ', () => { - beforeEach(() => { - mockEcs.stopTask.mockReset(); - mockEcs.listTasks.mockReset(); - mockDynamoDb.update.mockReset(); - }); - - it('Should return test canceled when there is less than 100 tasks to delete', async () => { - let listResponse = listTasksResponse(1); - mockEcs.listTasks.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(listResponse); - } - } - }); - - mockEcs.stopTask.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - mockEcs.listTasks.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve({ taskArns: [] }); - } - } - }); - - mockDynamoDb.update.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - - const response = await lambda.handler(event); - expect(response).toEqual('test cancelled'); - }); - it('Should return test canceled when and call listTasks multiple times when nextToken', async () => { - let listResponse = listTasksResponse(1); - mockEcs.listTasks.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve({ taskArns: listResponse.taskArns, nextToken: "a1" }); - } - } - }); - delete listResponse.nextToken; - mockEcs.listTasks.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(listResponse); - } - } - }); - - mockEcs.stopTask.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - mockEcs.listTasks.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve({ taskArns: [] }); - } - } - }); - - mockDynamoDb.update.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - - const response = await lambda.handler(event); - - expect(mockEcs.listTasks).toHaveBeenCalledTimes(3); - expect(response).toEqual('test cancelled'); - }); - it('Should return "test canceled" and wait between stopTask when more than 100 tasks', async () => { - let listResponse = listTasksResponse(50); - mockEcs.listTasks.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve({ taskArns: listResponse.taskArns, nextToken: "a1" }); - } - } - }); - listResponseSecondCall = listTasksResponse(51); - mockEcs.listTasks.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(listResponseSecondCall); - } - } - }); - - mockEcs.stopTask.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - mockEcs.listTasks.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve({ taskArns: [] }); - } - } - }); - - mockDynamoDb.update.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - - const response = await lambda.handler(event); - - expect(mockEcs.stopTask).toHaveBeenCalledTimes(101); - expect(response).toEqual('test cancelled'); - }); - //negative tests - it('Should return "test canceled" and wait between stopTask when more than 100 tasks', async () => { - let listResponse = listTasksResponse(50); - mockEcs.listTasks.mockImplementationOnce(() => { - return { - promise() { - return Promise.reject("List Tasks Error"); - } - } - }); - - mockEcs.stopTask.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - mockEcs.listTasks.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve({ taskArns: [] }); - } - } - }); - - mockDynamoDb.update.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - try { - await lambda.handler(event); - } catch (err) { - expect(err).toEqual("List Tasks Error"); + beforeEach(() => { + mockEcs.stopTask.mockReset(); + mockEcs.listTasks.mockReset(); + mockDynamoDb.update.mockReset(); + }); + + it('Should return test canceled when there is less than 100 tasks to delete', async () => { + let listResponse = listTasksResponse(1); + mockEcs.listTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(listResponse); + } + }; + }); + + mockEcs.stopTask.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockEcs.listTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ taskArns: [] }); + } + }; + }); + + mockDynamoDb.update.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + + const response = await lambda.handler(event); + expect(response).toEqual('test cancelled'); + }); + it('Should return "test stopped due to error" when error is included in event', async () => { + let listResponse = listTasksResponse(1); + event.error = "error" + mockEcs.listTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(listResponse); + } + }; + }); + + mockEcs.stopTask.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockEcs.listTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ taskArns: [] }); + } + }; + }); + + mockDynamoDb.update.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + + const response = await lambda.handler(event); + expect(response).toEqual('test stopped due to error'); + delete event.error; + }); + it('Should return test canceled when and call listTasks multiple times when nextToken', async () => { + let listResponse = listTasksResponse(1); + const mockEcs = { + listTasks: jest.fn(), + stopTask: jest.fn() + }; + mockAWS.ECS = jest.fn(() => ({ + listTasks: mockEcs.listTasks, + stopTask: mockEcs.stopTask + })); + mockEcs.listTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ taskArns: listResponse.taskArns, nextToken: "a1" }); + } + }; + }); + delete listResponse.nextToken; + mockEcs.listTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(listResponse); + } + }; + }); + + mockEcs.stopTask.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); } + }; }); + mockEcs.listTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ taskArns: [] }); + } + }; + }); + + mockDynamoDb.update.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + + const response = await lambda.handler(event); + + expect(mockEcs.listTasks).toHaveBeenCalledTimes(3); + expect(response).toEqual('test cancelled'); + }); + it('Should return "test canceled" and wait between stopTask when more than 100 tasks', async () => { + let listResponse = listTasksResponse(50); + const mockEcs = { + listTasks: jest.fn(), + stopTask: jest.fn() + }; + mockAWS.ECS = jest.fn(() => ({ + listTasks: mockEcs.listTasks, + stopTask: mockEcs.stopTask + })); + mockEcs.listTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ taskArns: listResponse.taskArns, nextToken: "a1" }); + } + }; + }); + const listResponseSecondCall = listTasksResponse(51); + mockEcs.listTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(listResponseSecondCall); + } + }; + }); + + mockEcs.stopTask.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockEcs.listTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ taskArns: [] }); + } + }; + }); + + mockDynamoDb.update.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + + const response = await lambda.handler(event); + + expect(mockEcs.stopTask).toHaveBeenCalledTimes(101); + expect(response).toEqual('test cancelled'); + }); + //negative tests + it('Should throw error when listTasks fails', async () => { + const mockEcs = { + listTasks: jest.fn(), + stopTask: jest.fn() + }; + mockAWS.ECS = jest.fn(() => ({ + listTasks: mockEcs.listTasks, + stopTask: mockEcs.stopTask + })); + mockEcs.listTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.reject("List Tasks Error"); + } + }; + }); + + mockEcs.stopTask.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockEcs.listTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ taskArns: [] }); + } + }; + }); + + mockDynamoDb.update.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + try { + await lambda.handler(event); + } catch (err) { + expect(err).toEqual("List Tasks Error"); + } + }); }); \ No newline at end of file diff --git a/source/task-canceler/package.json b/source/task-canceler/package.json index 6dc2dd2..8f21365 100644 --- a/source/task-canceler/package.json +++ b/source/task-canceler/package.json @@ -1,25 +1,27 @@ { - "name": "task-canceler", - "version": "2.0.1", - "engines": { - "node": "^12.x" - }, - "description": "Triggered by api-services lambda function, cancels ecs tasks", - "main": "index.js", - "scripts": { - "clean": "rm -rf node_modules package-lock.json", - "test": "jest lib/*.spec.js --coverage --silent" - }, - "dependencies": {}, - "devDependencies": { - "aws-sdk": "2.1001.0", - "jest": "26.6.3" - }, - "repository": { - "type": "git", - "url": "https://github.com/aws-solutions/distributed-load-testing-on-aws" - }, - "author": "aws-solution-builders", - "license": "Apache-2.0", - "readme": "./README.md" + "name": "task-canceler", + "version": "3.0.0", + "description": "Triggered by api-services lambda function, cancels ecs tasks", + "repository": { + "type": "git", + "url": "https://github.com/aws-solutions/distributed-load-testing-on-aws" + }, + "license": "Apache-2.0", + "author": "aws-solution-builders", + "main": "index.js", + "scripts": { + "clean": "rm -rf node_modules package-lock.json", + "test": "jest lib/*.spec.js --coverage --silent" + }, + "dependencies": { + "solution-utils": "file:../solution-utils" + }, + "devDependencies": { + "aws-sdk": "2.1001.0", + "jest": "26.6.3" + }, + "engines": { + "node": "^14.x" + }, + "readme": "./README.md" } diff --git a/source/task-runner/index.js b/source/task-runner/index.js index 6ded86c..02ebdc5 100644 --- a/source/task-runner/index.js +++ b/source/task-runner/index.js @@ -2,278 +2,290 @@ // SPDX-License-Identifier: Apache-2.0 const AWS = require('aws-sdk'); -const { SOLUTION_ID, VERSION } = process.env; +const utils = require('solution-utils'); let options = {}; -if (SOLUTION_ID && VERSION && SOLUTION_ID.trim() && VERSION.trim()) { - options.customUserAgent = `AwsSolution/${SOLUTION_ID}/${VERSION}`; -} -options.region = process.env.AWS_REGION -const ecs = new AWS.ECS(options); +options = utils.getOptions(options); +options.region = process.env.AWS_REGION; const dynamo = new AWS.DynamoDB.DocumentClient(options); -const cloudwatch = new AWS.CloudWatch(options); -const cloudwatchLogs = new AWS.CloudWatchLogs(options); + +const checkRunningTasks = async (ecs, runTaskCount, taskCount, taskCluster, testId) => { + let desiredWorkers = taskCount - 1; + let listTaskParams = { + cluster: taskCluster, + desiredStatus: 'RUNNING', + startedBy: testId + }; + let runningTasks = []; + let data; + //get running tasks belonging to test + do { + data = await ecs.listTasks(listTaskParams).promise(); + runningTasks = runningTasks.concat(data.taskArns); + listTaskParams.nextToken = data.nextToken; + } while (data.nextToken); + let actualWorkers = runningTasks.length; + let neededWorkers = desiredWorkers - actualWorkers; + //add back workers if necessary + runTaskCount = runTaskCount + neededWorkers; + return runTaskCount; +}; + +const checkTestStatus = async (testId, isRunning) => { + let data; + const ddbParams = { + TableName: process.env.SCENARIOS_TABLE, + Key: { + testId: testId + }, + AttributesToGet: [ + 'status' + ] + }; + data = await dynamo.get(ddbParams).promise(); + const { status } = data.Item; + if (status !== 'running') isRunning = false; + return isRunning; +}; exports.handler = async (event, context) => { - console.log(JSON.stringify(event, null, 2)); + console.log(JSON.stringify(event, null, 2)); - const { scenario } = event; - const { testId, taskCount, testType, fileType } = scenario; - const API_INTERVAL = parseFloat(process.env.API_INTERVAL) || 10; - let runTaskCount = event.taskRunner ? event.taskRunner.runTaskCount : taskCount; - let timeRemaining; - let isRunning = true; + const { testId, testType, fileType, showLive, prefix } = event; + const { taskDefinition, taskCluster, region, taskCount, ecsCloudWatchLogGroup, subnetA, subnetB, taskImage, taskSecurityGroup } = event.testTaskConfig; + //1 call every 1.5 sec , 2 min to enter running, 1 min launch for leader, 2 min for leader to enter running + 5 min buffer + const timeout = Math.floor(Math.ceil(taskCount / 10) * 1.5 + 600); + //if there is a list of taskIds, workers have been launched and only the leader is left. + let runTaskCount = event.taskIds ? 1 : taskCount; + let timeRemaining; + let isRunning = true; - /** - * Prefix is reversed date. e.g. 878.14:32:40T30-90-0202 - * Each tasks are going to create new result object in S3. - * Prefix is going to be used to distinguish the current result S3 objects. - */ - const prefix = event.prefix || new Date().toISOString().replace('Z', '').split('').reverse().join(''); - // Run tasks in batches of 10 - const params = { - taskDefinition: process.env.TASK_DEFINITION, - cluster: process.env.TASK_CLUSTER, - count: 0, - group: testId, - launchType: 'FARGATE', - networkConfiguration: { - awsvpcConfiguration: { - assignPublicIp: 'ENABLED', - securityGroups: [process.env.TASK_SECURITY_GROUP], - subnets: [ - process.env.SUBNET_A, - process.env.SUBNET_B - ] - } - }, - overrides: { - containerOverrides: [{ - name: process.env.TASK_IMAGE, - environment: [ - { name: 'S3_BUCKET', value: process.env.SCENARIOS_BUCKET }, - { name: 'TEST_ID', value: testId }, - { name: 'TEST_TYPE', value: testType }, - { name: 'FILE_TYPE', value: fileType }, - { name: 'PREFIX', value: prefix }, - { name: 'SCRIPT', value: 'ecslistener.py' } - ] - }] - }, - propagateTags: 'TASK_DEFINITION', - startedBy: testId, - tags: [ - { key: "TestId", value: testId }, - { key: "SolutionId", value: process.env.SOLUTION_ID } - ] - }; + options = utils.getOptions(options); + options.region = region; + const ecs = new AWS.ECS(options); + const cloudwatch = new AWS.CloudWatch(options); + const cloudwatchLogs = new AWS.CloudWatchLogs(options); - try { + // Run tasks in batches of 10 + const params = { + taskDefinition: taskDefinition, + cluster: taskCluster, + count: 0, + group: testId, + launchType: 'FARGATE', + networkConfiguration: { + awsvpcConfiguration: { + assignPublicIp: 'ENABLED', + securityGroups: [taskSecurityGroup], + subnets: [ + subnetA, + subnetB + ] + } + }, + overrides: { + containerOverrides: [{ + name: taskImage, + environment: [ + { name: 'S3_BUCKET', value: process.env.SCENARIOS_BUCKET }, + { name: 'TEST_ID', value: testId }, + { name: 'TEST_TYPE', value: testType }, + { name: 'FILE_TYPE', value: fileType }, + { name: 'LIVE_DATA_ENABLED', value: `live=${showLive}` }, + { name: 'TIMEOUT', value: timeout.toString() }, + { name: 'PREFIX', value: prefix }, + { name: 'SCRIPT', value: 'ecslistener.py' } + ] + }] + }, + propagateTags: 'TASK_DEFINITION', + startedBy: testId, + tags: [ + { key: "TestId", value: testId }, + { key: "SolutionId", value: process.env.SOLUTION_ID } + ] + }; - //if not yet created by previous call, create widgets and dashboard - if (!event.taskRunner) { - //Create metric filters and dashboard - const metrics = ["numVu", "numSucc", "numFail", "avgRt"]; - const metricNames = ["Virtual Users", "Success", "Failures", "Average Response Time"]; - let widgets = []; - const widgetPlacement = [[8, 0], [0, 8], [8, 8], [0, 0]]; - //Create metric filter and widget for each metric - for (const [index, metric] of metrics.entries()) { - let metricNameParam = `${testId}-${metric}`; - let stat = metric === 'avgRt' ? 'avg' : 'sum'; - let [x, y] = widgetPlacement[index]; - //Create metric filter - let metricFilterParams = { - filterName: `${process.env.TASK_CLUSTER}-Ecs${metric}-${testId}`, - filterPattern: `[testId="${testId}", time, logType=INFO*, logTitle=Current*, numVu, vu, numSucc, succ, numFail, fail, avgRt, x]`, - logGroupName: `${process.env.ECS_LOG_GROUP}`, - metricTransformations: [ - { - metricName: metricNameParam, - metricNamespace: "distributed-load-testing", - metricValue: `$${metric}` - } - ] - }; - await cloudwatchLogs.putMetricFilter(metricFilterParams).promise(); + try { - //create widget - let query = `SOURCE '${process.env.ECS_LOG_GROUP}'| limit 10000 | \ + //if not yet created by previous call, create widgets and dashboard + if (!event.taskIds) { + //Create metric filters and dashboard + const metrics = ["numVu", "numSucc", "numFail", "avgRt"]; + const metricNames = ["Virtual Users", "Success", "Failures", "Average Response Time"]; + let widgets = []; + const widgetPlacement = [[8, 0], [0, 8], [8, 8], [0, 0]]; + //Create metric filter and widget for each metric + for (const [index, metric] of metrics.entries()) { + let metricNameParam = `${testId}-${metric}`; + let stat = metric === 'avgRt' ? 'avg' : 'sum'; + let [x, y] = widgetPlacement[index]; + //Create metric filter + let metricFilterParams = { + filterName: `${taskCluster}-Ecs${metric}-${testId}`, + filterPattern: `[testId="${testId}", live, time, logType=INFO*, logTitle=Current*, numVu, vu, numSucc, succ, numFail, fail, avgRt, x]`, + logGroupName: `${ecsCloudWatchLogGroup}`, + metricTransformations: [ + { + metricName: metricNameParam, + metricNamespace: "distributed-load-testing", + metricValue: `$${metric}` + } + ] + }; + await cloudwatchLogs.putMetricFilter(metricFilterParams).promise(); + //create widget + let query = `SOURCE '${ecsCloudWatchLogGroup}'| limit 10000 | \ fields @logStream | \ filter @message like /${testId}.*INFO: Current:/ | \ parse @message /^.*\\s(?<@numVu>\\d+)\\svu\\s(?<@numSucc>\\d+)\\ssucc\\s(?<@numFail>\\d+)\\sfail\\s(?<@avgRt>\\d*.\\d*).*$/| \ stat ${stat}(@${metric}) by bin(1s)`; - let title = `${metricNames[index]}`; - let widget = { - "type": "log", - "x": x, - "y": y, - "width": 8, - "height": 8, - "properties": { - "query": query, - "region": process.env.AWS_REGION, - "stacked": 'false', - "title": title, - "view": "timeSeries", - } - }; - widgets.push(widget); - } - //create dashboard - const dashboardBody = { "widgets": widgets }; - await cloudwatch.putDashboard({ DashboardName: `EcsLoadTesting-${testId}`, DashboardBody: JSON.stringify(dashboardBody) }).promise(); - } - /** - * The max number of containers (taskCount) per task execution is 10 so if the taskCount is - * more than 10 the task definition will need to be run multiple times. - * @runTaskCount is the number of sets of 10 in the taskCount - */ - const sleep = s => new Promise(resolve => setTimeout(resolve, s * 1000)); - params.count = 10; - - //declare variables runTaskResponse for runTask response and taskIds to save task Ids - let runTaskResponse; - let taskIds = event.taskRunner ? event.taskRunner.taskIds : []; - - //loop through list of tasks and push to taskIds array - let collectTaskIds = (tasks) => { - tasks.forEach(task => { - taskIds.push(task.taskArn.split(process.env.TASK_CLUSTER + "/").pop()); - }); + let title = `${metricNames[index]}`; + let widget = { + "type": "log", + "x": x, + "y": y, + "width": 8, + "height": 8, + "properties": { + "query": query, + "region": region, + "stacked": 'false', + "title": title, + "view": "timeSeries", + } }; - //if only running a single task - if (runTaskCount === 1) { - //if leader task - if (event.taskRunner) { - //Get IP Addresses of worker nodes - let ipAddresses = []; - let ipNetworkPortion; - while (taskIds.length > 0) { - //get task info in chunks of 100 or less - let taskIdSubset = taskIds.splice(0, 100); - let describeTasksParams = { "cluster": process.env.TASK_CLUSTER, tasks: taskIdSubset }; - let runningNodeInfo = await ecs.describeTasks(describeTasksParams).promise(); + widgets.push(widget); + } + //create dashboard + const dashboardBody = { "widgets": widgets }; + await cloudwatch.putDashboard({ DashboardName: `EcsLoadTesting-${testId}-${region}`, DashboardBody: JSON.stringify(dashboardBody) }).promise(); + } + /** + * The max number of containers (taskCount) per task execution is 10 so if the taskCount is + * more than 10 the task definition will need to be run multiple times. + * @runTaskCount is the number of sets of 10 in the taskCount + */ + params.count = 10; - //get IPV4 Address info - let ipAddress; - runningNodeInfo.tasks.forEach(task => { - //get second half of ip address - ipAddress = task.containers[0].networkInterfaces[0].privateIpv4Address; - ipAddresses.push(ipAddress.split(".").slice(2).join(".")); - }); - //save first half of ip address if not already saved (same for all ipv4 addressess) - ipNetworkPortion = ipNetworkPortion || ipAddress.split(".", 2).join("."); - } + //declare variables runTaskResponse for runTask response and taskIds to save task Ids + let runTaskResponse; + let taskIds = event.taskIds || []; - //copy needed for testing in jest, use shallow copy for less resource utilization - let leaderParams = Object.assign({}, params); - leaderParams.count = runTaskCount; - //override environment variables for leader node - leaderParams.overrides.containerOverrides[0].environment.push({ name: "IPNETWORK", value: ipNetworkPortion.toString() }); - leaderParams.overrides.containerOverrides[0].environment.push({ name: "IPHOSTS", value: ipAddresses.toString() }); - leaderParams.overrides.containerOverrides[0].environment.forEach(item => { - if (item.name === 'SCRIPT') item.value = "ecscontroller.py"; - }); + //loop through list of tasks and push to taskIds array + let collectTaskIds = (tasks) => { + tasks.forEach(task => { + taskIds.push(task.taskArn.split(taskCluster + "/").pop()); + }); + }; + //if only running a single task + if (runTaskCount === 1) { + //if leader task + if (event.taskIds) { + //Get IP Addresses of worker nodes + let ipAddresses = []; + let ipNetworkPortion; + while (taskIds.length > 0) { + //get task info in chunks of 100 or less + let taskIdSubset = taskIds.splice(0, 100); + let describeTasksParams = { "cluster": taskCluster, tasks: taskIdSubset }; + let runningNodeInfo = await ecs.describeTasks(describeTasksParams).promise(); - //run leader node task - console.log('STARTING LEADER NODE AND RUNNING TESTS'); - await ecs.runTask(leaderParams).promise(); - runTaskCount -= 1; + //get IPV4 Address info + let ipAddress; + runningNodeInfo.tasks.forEach(task => { + //get second half of ip address + ipAddress = task.containers[0].networkInterfaces[0].privateIpv4Address; + ipAddresses.push(ipAddress.split(".").slice(2).join(".")); + }); + //save first half of ip address if not already saved (same for all ipv4 addressess) + ipNetworkPortion = ipNetworkPortion || ipAddress.split(".", 2).join("."); + } - } else { //if single task test - params.count = runTaskCount; - console.log('Starting Task'); - params.overrides.containerOverrides[0].environment.pop(); - await ecs.runTask(params).promise(); - runTaskCount -= 1; - } - } else { - //function to run workers, and keep track of amount run - let launchWorkers = async (runTaskCount, params) => { - //adjust parameters if less than 10 - const count = runTaskCount > 10 ? 10 : runTaskCount - 1; - let taskParams = count >= 10 ? params : Object.assign({}, params); - taskParams.count = count; - //run tasks - console.log(`STARTING ${count} WORKER TASKS`); - runTaskResponse = await ecs.runTask(taskParams).promise(); - //get amount succesfully launched - let actualLaunched = runTaskResponse.tasks.length; - runTaskCount = runTaskCount - actualLaunched; - //record task Ids - collectTaskIds(runTaskResponse.tasks); - //sleep - console.log(`sleep ${API_INTERVAL} seconds to avoid ThrottlingException`); - await sleep(API_INTERVAL); - return runTaskCount; - }; - do { - //run tasks - runTaskCount = await launchWorkers(runTaskCount, params); + //copy needed for testing in jest, use shallow copy for less resource utilization + let leaderParams = Object.assign({}, params); + leaderParams.count = runTaskCount; + //override environment variables for leader node + leaderParams.overrides.containerOverrides[0].environment.push({ name: "IPNETWORK", value: ipNetworkPortion.toString() }); + leaderParams.overrides.containerOverrides[0].environment.push({ name: "IPHOSTS", value: ipAddresses.toString() }); + leaderParams.overrides.containerOverrides[0].environment.forEach(item => { + if (item.name === 'SCRIPT') item.value = "ecscontroller.py"; + }); - //get time left - timeRemaining = context.getRemainingTimeInMillis(); + //run leader node task + console.log('STARTING LEADER NODE AND RUNNING TESTS'); + const leadTaskResponse = await ecs.runTask(leaderParams).promise(); + //if leader node fails to launch, log error and end test + if (leadTaskResponse.failures.length > 0) { + throw ("The lead task failed to launch:\n", leadTaskResponse.failures); + } + } else { //if single task test + params.count = runTaskCount; + console.log('Starting Task'); + params.overrides.containerOverrides[0].environment.pop(); + const singleTaskRunResponse = await ecs.runTask(params).promise(); + if (singleTaskRunResponse.failures.length > 0) { + throw ("The task failed to launch:\n", singleTaskRunResponse.failures); + } + } + } else { + //function to run workers, and keep track of amount run + let launchWorkers = async (runTaskWorkersCount, launchParams) => { + //adjust parameters if less than 10 + const count = runTaskWorkersCount > 10 ? 10 : runTaskWorkersCount - 1; + let taskParams = count >= 10 ? launchParams : Object.assign({}, launchParams); + taskParams.count = count; + //run tasks + console.log(`STARTING ${count} WORKER TASKS`); + runTaskResponse = await ecs.runTask(taskParams).promise(); + //get amount successfully launched + let actualLaunched = runTaskResponse.tasks.length; + runTaskWorkersCount = runTaskWorkersCount - actualLaunched; + runTaskResponse.failures.length > 0 && console.log("Failed tasks:\n", runTaskResponse.failures); + //record task Ids + collectTaskIds(runTaskResponse.tasks); + return runTaskWorkersCount; + }; + do { + //run tasks + runTaskCount = await launchWorkers(runTaskCount, params); - //check if test has been cancelled - if (runTaskCount <= 1 || timeRemaining <= 60000) { - let data; - const ddbParams = { - TableName: process.env.SCENARIOS_TABLE, - Key: { - testId: testId - }, - AttributesToGet: [ - 'status' - ] - }; - data = await dynamo.get(ddbParams).promise(); - const { status } = data.Item; - if (status !== 'running') isRunning = false; - } + //get time left + timeRemaining = context.getRemainingTimeInMillis(); - //if still running, double check if all tasks running, if not, add what is needed - if (isRunning && runTaskCount <= 1) { - let desiredWorkers = taskCount - 1; - let params = { - cluster: process.env.TASK_CLUSTER, - desiredStatus: 'RUNNING', - startedBy: testId - }; - let runningTasks = []; - let data; - //get running tasks belonging to test - do { - data = await ecs.listTasks(params).promise(); - runningTasks = runningTasks.concat(data.taskArns); - params.nextToken = data.nextToken; - } while (data.nextToken); - let actualWorkers = runningTasks.length; - let neededWorkers = desiredWorkers - actualWorkers; - //add back workers if necessary - runTaskCount = runTaskCount + neededWorkers; - } - } while (runTaskCount > 1 && parseInt(timeRemaining, 10) > 60000); //end if out of time or no tasks left + //check if test has been cancelled + if (runTaskCount <= 1 || timeRemaining <= 60000) { + isRunning = await checkTestStatus(testId, isRunning); } - console.log('success'); - return { scenario, prefix, isRunning, taskRunner: { runTaskCount, taskIds } }; - } catch (err) { - console.error(err); - - // Update DynamoDB with Status FAILED and Error Message - await dynamo.update({ - TableName: process.env.SCENARIOS_TABLE, - Key: { testId }, - UpdateExpression: 'set #s = :s, #e = :e', - ExpressionAttributeNames: { - '#s': 'status', - '#e': 'errorReason' - }, - ExpressionAttributeValues: { - ':s': 'failed', - ':e': 'Failed to run Fargate tasks.' - } - }).promise(); - throw err; + //if still running, double check if all tasks running, if not, add what is needed + if (isRunning && runTaskCount <= 1) { + runTaskCount = await checkRunningTasks(ecs, runTaskCount, taskCount, taskCluster, testId); + } + } while (runTaskCount > 1 && parseInt(timeRemaining, 10) > 60000); //end if out of time or no tasks left } + console.log('success'); + event.prefix = prefix; + event.isRunning = isRunning; + (taskIds.length > 0) && (event.taskIds = taskIds); + return event; + } catch (err) { + console.error(err); + + // Update DynamoDB with Status FAILED and Error Message + await dynamo.update({ + TableName: process.env.SCENARIOS_TABLE, + Key: { testId }, + UpdateExpression: 'set #s = :s, #e = :e', + ExpressionAttributeNames: { + '#s': 'status', + '#e': 'errorReason' + }, + ExpressionAttributeValues: { + ':s': 'failed', + ':e': 'Failed to run Fargate tasks.' + } + }).promise(); + + throw err; + } }; diff --git a/source/task-runner/jest.config.js b/source/task-runner/jest.config.js new file mode 100644 index 0000000..7d3eaec --- /dev/null +++ b/source/task-runner/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + roots: ['/lib'], + testMatch: ['**/*.spec.js'], + collectCoverageFrom: [ + '**/*.js' + ], + coverageReporters: [ + "text", + "clover", + "json", + ["lcov", { "projectRoot": "../../" }] + ] +}; \ No newline at end of file diff --git a/source/task-runner/lib/index.spec.js b/source/task-runner/lib/index.spec.js index 299c2e2..4e26616 100644 --- a/source/task-runner/lib/index.spec.js +++ b/source/task-runner/lib/index.spec.js @@ -3,39 +3,38 @@ // Mock AWS SDK const mockAWS = require('aws-sdk'); -const { run } = require('jest'); const mockDynamoDb = { - update: jest.fn(), - get: jest.fn() + update: jest.fn(), + get: jest.fn() }; const mockEcs = { - runTask: jest.fn(), - listTasks: jest.fn(), - describeTasks: jest.fn() + runTask: jest.fn(), + listTasks: jest.fn(), + describeTasks: jest.fn() }; const mockCloudWatch = { - putDashboard: jest.fn(), - getDashboard: jest.fn() + putDashboard: jest.fn(), + getDashboard: jest.fn() }; const mockCloudWatchLogs = { - putMetricFilter: jest.fn() -} + putMetricFilter: jest.fn() +}; mockAWS.ECS = jest.fn(() => ({ - runTask: mockEcs.runTask, - listTasks: mockEcs.listTasks, - describeTasks: mockEcs.describeTasks + runTask: mockEcs.runTask, + listTasks: mockEcs.listTasks, + describeTasks: mockEcs.describeTasks })); mockAWS.DynamoDB.DocumentClient = jest.fn(() => ({ - update: mockDynamoDb.update, - get: mockDynamoDb.get + update: mockDynamoDb.update, + get: mockDynamoDb.get })); mockAWS.CloudWatch = jest.fn(() => ({ - putDashboard: mockCloudWatch.putDashboard, - getDashboard: mockCloudWatch.getDashboard + putDashboard: mockCloudWatch.putDashboard, + getDashboard: mockCloudWatch.getDashboard })); mockAWS.CloudWatchLogs = jest.fn(() => ({ - putMetricFilter: mockCloudWatchLogs.putMetricFilter, + putMetricFilter: mockCloudWatchLogs.putMetricFilter, })); // Mock Date @@ -44,455 +43,548 @@ global.Date = jest.fn(() => now); global.Date.getTime = now.getTime(); process.env = { - API_INTERVAL: '0.01', - SCENARIOS_BUCKET: 'mock-bucket', - SUBNET_A: 'mock-subnet-a', - SUBNET_B: 'mock-subnet-b', - TASK_DEFINITION: 'mock-task-definition', - TASK_CLUSTER: 'mock-cluster', - TASK_SECURITY_GROUP: 'mock-security-group', - TASK_IMAGE: 'mock-task-image', - ECS_LOG_GROUP: 'mock-ecs-log-group', - SOLUTION_ID: 'SO0062', - VERSION: '2.0.1' + SCENARIOS_BUCKET: 'mock-bucket', + SOLUTION_ID: 'SO0062', + VERSION: '2.0.1' }; const lambda = require('../index.js'); -const event = { - scenario: { - testId: 'testId', - taskCount: 5, - testType: 'simple', - fileType: 'none' - }, - isRunning: false +let event = { + testTaskConfig: { + region: "us-west-2", + concurrency: 3, + taskCount: 5, + ecsCloudWatchLogGroup: "testEcsCloudWatchLogGroup", + taskCluster: "testTaskCluster", + taskDefinition: "arn:aws:ecs:us-west-2:123456789012:task-definition/testTaskDefinition:1", + subnetB: "subnet-1111aaaa", + taskImage: "test-load-tester", + subnetA: "subnet-2222bbbb", + taskSecurityGroup: "sg-abcd1234", + }, + testId: "testId", + testType: "simple", + fileType: "none", + showLive: true, + isRunning: false, + prefix: now.toISOString().replace('Z', '').split('').reverse().join('') }; -const prefix = now.toISOString().replace('Z', '').split('').reverse().join(''); -const mockParam = { - taskDefinition: process.env.TASK_DEFINITION, - cluster: process.env.TASK_CLUSTER, - count: 0, - group: event.scenario.testId, - launchType: 'FARGATE', - networkConfiguration: { - awsvpcConfiguration: { - assignPublicIp: 'ENABLED', - securityGroups: [process.env.TASK_SECURITY_GROUP], - subnets: [ - process.env.SUBNET_A, - process.env.SUBNET_B - ] - } - }, - overrides: { - containerOverrides: [{ - name: process.env.TASK_IMAGE, - environment: [ - { name: 'S3_BUCKET', value: process.env.SCENARIOS_BUCKET }, - { name: 'TEST_ID', value: event.scenario.testId }, - { name: 'TEST_TYPE', value: 'simple' }, - { name: 'FILE_TYPE', value: 'none' }, - { name: 'PREFIX', value: prefix }, - { name: 'SCRIPT', value: 'ecslistener.py' } - ] - }] - }, - propagateTags: "TASK_DEFINITION", - startedBy: "testId", - tags: [ - { key: "TestId", value: 'testId' }, - { key: "SolutionId", value: process.env.SOLUTION_ID } - ] +const origEvent = event; + +const calcTimeout = (taskCount) => (Math.floor(Math.ceil(taskCount / 10) * 1.5 + 600)).toString(); + +let mockParam = { + taskDefinition: event.testTaskConfig.taskDefinition, + cluster: event.testTaskConfig.taskCluster, + count: 0, + group: event.testId, + launchType: 'FARGATE', + networkConfiguration: { + awsvpcConfiguration: { + assignPublicIp: 'ENABLED', + securityGroups: [event.testTaskConfig.taskSecurityGroup], + subnets: [ + event.testTaskConfig.subnetA, + event.testTaskConfig.subnetB + ] + } + }, + overrides: { + containerOverrides: [{ + name: event.testTaskConfig.taskImage, + environment: [ + { name: 'S3_BUCKET', value: process.env.SCENARIOS_BUCKET }, + { name: 'TEST_ID', value: event.testId }, + { name: 'TEST_TYPE', value: 'simple' }, + { name: 'FILE_TYPE', value: 'none' }, + { name: 'LIVE_DATA_ENABLED', value: 'live=true' }, + { name: 'TIMEOUT', value: calcTimeout(event.testTaskConfig.taskCount) }, + { name: 'PREFIX', value: event.prefix }, + { name: 'SCRIPT', value: 'ecslistener.py' } + ] + }] + }, + propagateTags: "TASK_DEFINITION", + startedBy: "testId", + tags: [ + { key: "TestId", value: 'testId' }, + { key: "SolutionId", value: process.env.SOLUTION_ID } + ] }; - -let describeTasksReturn = (numTasks, param) => { - taskList = []; - ipNetwork = "1.1"; - ipHosts = []; - for (let i = 0; i < numTasks; i++) { - taskList.push({ containers: [{ networkInterfaces: [{ privateIpv4Address: `1.1.1.${i}` }] }] }); - ipHosts.push(`1.${i}`); - } - - param.overrides.containerOverrides[0].environment[6].value = ipNetwork.toString(); - param.overrides.containerOverrides[0].environment[7].value = ipHosts.toString(); - return taskList; +const origMockParam = mockParam; +const modifyContainerOverrides = (key, value) => { + mockParam.overrides.containerOverrides[0].environment.forEach((envVar, index) => { + key === envVar.name && (mockParam.overrides.containerOverrides[0].environment[index].value = value); + }) +} +let describeTasksReturn = (numTasks) => { + taskList = []; + ipNetwork = "1.1"; + ipHosts = []; + for (let i = 0; i < numTasks; i++) { + taskList.push({ containers: [{ networkInterfaces: [{ privateIpv4Address: `1.1.1.${i}` }] }] }); + ipHosts.push(`1.${i}`); + } + + modifyContainerOverrides('IPNETWORK', ipNetwork.toString()); + modifyContainerOverrides('IPHOSTS', ipHosts.toString()); + return taskList; }; let getTaskIds = (numTasks) => { - tasks = [] - for (i = 0; i < numTasks; i++) { - let num = i > 9 ? i - 10 : i; - tasks.push(`a/${num}`); - } - return tasks + tasks = []; + for (i = 0; i < numTasks; i++) { + let num = i > 9 ? i - 10 : i; + tasks.push(`a/${num}`); + } + return tasks; }; let runTaskReturn = (numTasks) => { - let tasks = []; - for (let i = 0; i < numTasks; i++) { - tasks.push({ taskArn: `a/${i}` }) - } - return tasks; -} + let tasks = []; + for (let i = 0; i < numTasks; i++) { + tasks.push({ taskArn: `a/${i}` }); + } + return tasks; +}; let mockGetRemainingTimeInMillis = () => { - return 120000 -} + return 120000; +}; let mockContext = { - getRemainingTimeInMillis: mockGetRemainingTimeInMillis + getRemainingTimeInMillis: mockGetRemainingTimeInMillis }; - describe('#TASK RUNNER:: ', () => { - beforeEach(() => { - mockEcs.runTask.mockReset(); - mockEcs.listTasks.mockReset(); - mockEcs.describeTasks.mockReset(); - mockDynamoDb.update.mockReset(); - mockCloudWatch.putDashboard.mockReset(); - mockCloudWatchLogs.putMetricFilter.mockReset(); - }); - - it('should return scenario and prefix when running ECS worker tasks succeeds', async () => { - mockCloudWatchLogs.putMetricFilter.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - mockCloudWatch.putDashboard.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - - mockEcs.runTask.mockImplementationOnce(() => { - let taskList = runTaskReturn(4); - return { - promise() { - return Promise.resolve({ - tasks: taskList - }); - } - }; - }); - - mockDynamoDb.get.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve({ - Item: { status: 'running' } - }); - } - }; - }); - - mockEcs.listTasks.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve({ taskArns: [1, 2, 3, 4] }); - } - } - }); - - const response = await lambda.handler(event, mockContext); - let expectedResponse = { - isRunning: true, - scenario: event.scenario, - prefix, - taskRunner: { - runTaskCount: 1, - taskIds: ['a/0', 'a/1', 'a/2', 'a/3'] - } - } - expect(mockEcs.runTask).toHaveBeenCalledTimes(1); - console.log(mockParam.overrides.containerOverrides[0].environment); - expect(mockEcs.runTask).toHaveBeenCalledWith({ ...mockParam, count: 4 }); - expect(response).toEqual(expectedResponse); - }); - it('should return scenario and prefix when running more than 10 ECS workers succeeds', async () => { - mockCloudWatchLogs.putMetricFilter.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - mockCloudWatch.putDashboard.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - mockEcs.runTask - .mockImplementationOnce(() => { - let taskList = runTaskReturn(10); - return { - promise() { - return Promise.resolve({ - tasks: taskList - }); - } - }; - }) - .mockImplementationOnce(() => { - let taskList = runTaskReturn(9); - return { - promise() { - return Promise.resolve({ - tasks: taskList - }); - } - }; - }); - - mockDynamoDb.get.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve({ - Item: { status: 'running' } - }); - } - }; - }); - - mockEcs.listTasks.mockImplementationOnce(() => { - let taskList = runTaskReturn(10); - taskList = taskList.concat(runTaskReturn(9)); - return { - promise() { - return Promise.resolve({ taskArns: taskList }); - } - }; - }); - - event.scenario.taskCount = 20; - - let tasks = getTaskIds(19); - let expectedResponse = { - isRunning: true, - scenario: event.scenario, - prefix, - taskRunner: { - runTaskCount: 1, - taskIds: tasks - } - } - const response = await lambda.handler(event, mockContext); - expect(mockEcs.runTask).toHaveBeenCalledTimes(2); - expect(mockEcs.runTask).toHaveBeenNthCalledWith(1, { ...mockParam, count: 10 }); - expect(mockEcs.runTask).toHaveBeenNthCalledWith(2, { ...mockParam, count: 9 }); - expect(response).toEqual(expectedResponse); - }); - it('should return when launching leader task is successful', async () => { - mockParam.overrides.containerOverrides[0].environment[5].value = 'ecscontroller.py'; - mockParam.overrides.containerOverrides[0].environment.push({ "name": "IPNETWORK", "value": "" }); - mockParam.overrides.containerOverrides[0].environment.push({ "name": "IPHOSTS", "value": "" }); - event.taskRunner = {} - let taskIds = getTaskIds(5); - event.taskRunner.taskIds = taskIds; - event.taskRunner.runTaskCount = 1; - let taskList = describeTasksReturn(4, mockParam); - - mockEcs.describeTasks.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve({ tasks: taskList }); - } - }; - }); - - mockEcs.runTask.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - }; - }); - - mockEcs.listTasks.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(); - } - }; - }) - - let expectedResponse = { - isRunning: true, - scenario: event.scenario, - prefix, - taskRunner: { - runTaskCount: 0, - taskIds: taskIds - } - }; - - const response = await lambda.handler(event); - expect(mockEcs.runTask).toHaveBeenCalledWith({ ...mockParam, count: 1 }); - expect(response).toEqual(expectedResponse); - - delete event.taskRunner; - console.log(event); - - }); - it('should return scenario and prefix when API_INTERVAL is not provided but running ECS tasks succeeds', async () => { - mockCloudWatchLogs.putMetricFilter.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - mockCloudWatch.putDashboard.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - mockEcs.runTask.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - }; - }); - - mockEcs.listTasks.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(); - } - }; - }) - - process.env.API_INTERVAL = undefined; - event.scenario.taskCount = 1; - let expectedResponse = { - isRunning: true, - scenario: event.scenario, - prefix, - taskRunner: { - runTaskCount: 0, - taskIds: [] - } - } - const response = await lambda.handler(event); - mockParam.overrides.containerOverrides[0].environment.pop(); - mockParam.overrides.containerOverrides[0].environment.pop(); - mockParam.overrides.containerOverrides[0].environment.pop(); - expect(mockEcs.runTask).toHaveBeenCalledWith({ ...mockParam, count: 1 }); - expect(response).toEqual(expectedResponse); - }); - - it('should throw "ECS ERROR" when ECS.runTask fails', async () => { - mockCloudWatchLogs.putMetricFilter.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - mockCloudWatch.putDashboard.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - mockEcs.runTask.mockImplementationOnce(() => { - return { - promise() { - return Promise.reject('ECS ERROR'); - } - }; - }); - mockDynamoDb.update.mockImplementationOnce(() => { - return { - promise() { - return Promise.resolve(); - } - }; - }); - - try { - event.scenario.taskCount = 1; - await lambda.handler(event); - } catch (error) { - expect(mockEcs.runTask).toHaveBeenCalledWith({ ...mockParam, count: 1 }); - expect(mockDynamoDb.update).toHaveBeenCalledWith({ - TableName: process.env.SCENARIOS_TABLE, - Key: { - testId: event.scenario.testId - }, - UpdateExpression: 'set #s = :s, #e = :e', - ExpressionAttributeNames: { - '#s': 'status', - '#e': 'errorReason' - }, - ExpressionAttributeValues: { - ':s': 'failed', - ':e': 'Failed to run Fargate tasks.' - } - }); - expect(error).toEqual('ECS ERROR'); - } - }); - it('should throw an error when DynamoDB.DocumentClient.update fails and not update the DynamoDB', async () => { - mockCloudWatchLogs.putMetricFilter.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - mockCloudWatch.putDashboard.mockImplementation(() => { - return { - promise() { - return Promise.resolve(); - } - } - }); - mockEcs.runTask.mockImplementationOnce(() => { - return { - promise() { - return Promise.reject('ECS ERROR'); - } - }; - }); - mockDynamoDb.update.mockImplementationOnce(() => { - return { - promise() { - return Promise.reject('DynamoDB.DocumentClient.update failed'); - } - }; - }); - - try { - await lambda.handler(event); - } catch (error) { - expect(mockEcs.runTask).toHaveBeenCalledWith({ ...mockParam, count: 1 }); - expect(mockDynamoDb.update).toHaveBeenCalledWith({ - TableName: process.env.SCENARIOS_TABLE, - Key: { - testId: event.scenario.testId - }, - UpdateExpression: 'set #s = :s, #e = :e', - ExpressionAttributeNames: { - '#s': 'status', - '#e': 'errorReason' - }, - ExpressionAttributeValues: { - ':s': 'failed', - ':e': 'Failed to run Fargate tasks.' - } - }); - expect(error).toEqual('DynamoDB.DocumentClient.update failed'); - } - }); + beforeEach(() => { + mockEcs.runTask.mockReset(); + mockEcs.listTasks.mockReset(); + mockEcs.describeTasks.mockReset(); + mockDynamoDb.update.mockReset(); + mockCloudWatch.putDashboard.mockReset(); + mockCloudWatchLogs.putMetricFilter.mockReset(); + event = { ...origEvent }; + mockParam = { ...origMockParam }; + }); + + it('should return scenario and prefix when running ECS worker tasks succeeds', async () => { + mockCloudWatchLogs.putMetricFilter.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockCloudWatch.putDashboard.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + + // Checking for worker tasks - so it's total tasks - 1 + mockEcs.runTask.mockImplementationOnce(() => { + let taskList = runTaskReturn(4); + return { + promise() { + return Promise.resolve({ + tasks: taskList, + failures: [] + }); + } + }; + }); + + mockDynamoDb.get.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ + Item: { status: 'running' } + }); + } + }; + }); + + mockEcs.listTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ taskArns: [1, 2, 3, 4] }); + } + }; + }); + + const response = await lambda.handler(event, mockContext); + let expectedResponse = { + fileType: "none", + isRunning: true, + prefix: event.prefix, + showLive: true, + testId: "testId", + taskIds: ['a/0', 'a/1', 'a/2', 'a/3'], + }; + const test = { + concurrency: 3, + ecsCloudWatchLogGroup: "testEcsCloudWatchLogGroup", + taskDefinition: "arn:aws:ecs:us-west-2:123456789012:task-definition/testTaskDefinition:1", + subnetB: "subnet-1111aaaa", + taskImage: "test-load-tester", + subnetA: "subnet-2222bbbb", + taskSecurityGroup: "sg-abcd1234", + testType: "simple", + taskCluster: "testTaskCluster", + region: "us-west-2", + taskCount: 5 + } + expect(mockEcs.runTask).toHaveBeenCalledTimes(1); + expect(mockEcs.runTask).toHaveBeenCalledWith({ ...mockParam, count: 4 }); + expect(response).toEqual(expect.objectContaining(expectedResponse)); + }); + it('isRunning should be false if DDB returns "status !== running', async () => { + mockCloudWatchLogs.putMetricFilter.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockCloudWatch.putDashboard.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockEcs.runTask.mockImplementationOnce(() => { + let taskList = runTaskReturn(4); + return { + promise() { + return Promise.resolve({ + tasks: taskList, + failures: [] + }); + } + }; + }); + + mockDynamoDb.get.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ + Item: { status: 'stopped' } + }); + } + }; + }); + const response = await lambda.handler(event, mockContext); + expect(response.isRunning).toEqual(false); + }); + it('should return scenario and prefix when running more than 10 ECS workers succeeds', async () => { + mockCloudWatchLogs.putMetricFilter.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockCloudWatch.putDashboard.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockEcs.runTask + .mockImplementationOnce(() => { + let taskList = runTaskReturn(10); + return { + promise() { + return Promise.resolve({ + tasks: taskList, + failures: [] + }); + } + }; + }) + .mockImplementationOnce(() => { + let taskList = runTaskReturn(9); + return { + promise() { + return Promise.resolve({ + tasks: taskList, + failures: [] + }); + } + }; + }); + + mockDynamoDb.get.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ + Item: { status: 'running' } + }); + } + }; + }); + + mockEcs.listTasks.mockImplementationOnce(() => { + let taskList = runTaskReturn(10); + taskList = taskList.concat(runTaskReturn(9)); + return { + promise() { + return Promise.resolve({ taskArns: taskList }); + } + }; + }); + + event.testTaskConfig.taskCount = 20; + modifyContainerOverrides('TIMEOUT', calcTimeout(event.testTaskConfig.taskCount)); + let tasks = getTaskIds(19); + let expectedResponse = { + isRunning: true, + prefix: event.prefix, + taskIds: tasks, + testTaskConfig: { + concurrency: 3, + ecsCloudWatchLogGroup: "testEcsCloudWatchLogGroup", + region: "us-west-2", + subnetA: "subnet-2222bbbb", + subnetB: "subnet-1111aaaa", + taskCluster: "testTaskCluster", + taskCount: 20, + taskDefinition: "arn:aws:ecs:us-west-2:123456789012:task-definition/testTaskDefinition:1", + taskImage: "test-load-tester", + taskSecurityGroup: "sg-abcd1234", + }, + fileType: "none", + showLive: true, + testId: "testId", + testType: "simple" + }; + + const response = await lambda.handler(event, mockContext); + expect(mockEcs.runTask).toHaveBeenCalledTimes(2); + expect(mockEcs.runTask).toHaveBeenNthCalledWith(1, { ...mockParam, count: 10 }); + expect(mockEcs.runTask).toHaveBeenNthCalledWith(2, { ...mockParam, count: 9 }); + expect(response).toEqual(expect.objectContaining(expectedResponse)); + }); + it('should return when launching leader task is successful', async () => { + mockParam.overrides.containerOverrides[0].environment[7].value = 'ecscontroller.py'; + mockParam.overrides.containerOverrides[0].environment.push({ "name": "IPNETWORK", "value": "" }); + mockParam.overrides.containerOverrides[0].environment.push({ "name": "IPHOSTS", "value": "" }); + let taskIds = getTaskIds(5); + event.taskIds = taskIds; + let taskList = describeTasksReturn(4); + + mockEcs.describeTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ tasks: taskList }); + } + }; + }); + + mockEcs.runTask.mockImplementation(() => { + let leadTask = runTaskReturn(1); + return { + promise() { + return Promise.resolve({ + tasks: leadTask, + failures: [] + + }); + } + }; + }); + + mockEcs.listTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + + let expectedResponse = { + fileType: "none", + isRunning: true, + prefix: event.prefix, + testTaskConfig: { + concurrency: event.testTaskConfig.concurrency, + ecsCloudWatchLogGroup: "testEcsCloudWatchLogGroup", + subnetA: "subnet-2222bbbb", + subnetB: "subnet-1111aaaa", + taskCluster: "testTaskCluster", + taskCount: event.testTaskConfig.taskCount, + taskDefinition: "arn:aws:ecs:us-west-2:123456789012:task-definition/testTaskDefinition:1", + taskImage: "test-load-tester", + taskSecurityGroup: "sg-abcd1234", + region: "us-west-2", + }, + showLive: true, + testId: "testId", + testType: "simple", + }; + + const response = await lambda.handler(event); + expect(mockEcs.runTask).toHaveBeenCalledWith({ ...mockParam, count: 1 }); + expect(response).toEqual(expect.objectContaining(expectedResponse)); + }); + it('an error should be thrown when lead task fails to run', async () => { + let taskIds = getTaskIds(5); + event.taskIds = taskIds; + let taskList = describeTasksReturn(4); + + mockEcs.describeTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ tasks: taskList }); + } + }; + }); + + mockEcs.runTask.mockImplementation(() => { + return { + promise() { + return Promise.resolve({ + tasks: [], + failures: ['Task failure'] + + }); + } + }; + }); + + mockEcs.listTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockDynamoDb.update.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + + try { + await lambda.handler(event); + } catch (error) { + expect(mockEcs.runTask).toHaveBeenCalledWith({ ...mockParam, count: 1 }); + expect(error).toEqual(['Task failure']); + } + mockParam.overrides.containerOverrides[0].environment.pop(); + mockParam.overrides.containerOverrides[0].environment.pop(); + mockParam.overrides.containerOverrides[0].environment.pop(); + }); + it('should throw "ECS ERROR" when ECS.runTask fails', async () => { + mockCloudWatchLogs.putMetricFilter.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockCloudWatch.putDashboard.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockEcs.runTask.mockImplementationOnce(() => { + return { + promise() { + return Promise.reject('ECS ERROR'); + } + }; + }); + mockDynamoDb.update.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + + try { + event.testTaskConfig.taskCount = 1; + modifyContainerOverrides('TIMEOUT', calcTimeout(event.testTaskConfig.taskCount)); + await lambda.handler(event); + } catch (error) { + expect(mockEcs.runTask).toHaveBeenCalledWith({ ...mockParam, count: 1 }); + expect(mockDynamoDb.update).toHaveBeenCalledWith({ + TableName: process.env.SCENARIOS_TABLE, + Key: { + testId: event.testId + }, + UpdateExpression: 'set #s = :s, #e = :e', + ExpressionAttributeNames: { + '#s': 'status', + '#e': 'errorReason' + }, + ExpressionAttributeValues: { + ':s': 'failed', + ':e': 'Failed to run Fargate tasks.' + } + }); + expect(error).toEqual('ECS ERROR'); + } + }); + it('should throw an error when DynamoDB.DocumentClient.update fails and not update the DynamoDB', async () => { + mockCloudWatchLogs.putMetricFilter.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockCloudWatch.putDashboard.mockImplementation(() => { + return { + promise() { + return Promise.resolve(); + } + }; + }); + mockEcs.runTask.mockImplementationOnce(() => { + return { + promise() { + return Promise.reject('ECS ERROR'); + } + }; + }); + mockDynamoDb.update.mockImplementationOnce(() => { + return { + promise() { + return Promise.reject('DynamoDB.DocumentClient.update failed'); + } + }; + }); + + try { + event.taskCount = 1; + await lambda.handler(event); + } catch (error) { + expect(mockEcs.runTask).toHaveBeenCalledWith({ ...mockParam, count: 1 }); + expect(mockDynamoDb.update).toHaveBeenCalledWith({ + TableName: process.env.SCENARIOS_TABLE, + Key: { + testId: event.testId + }, + UpdateExpression: 'set #s = :s, #e = :e', + ExpressionAttributeNames: { + '#s': 'status', + '#e': 'errorReason' + }, + ExpressionAttributeValues: { + ':s': 'failed', + ':e': 'Failed to run Fargate tasks.' + } + }); + expect(error).toEqual('DynamoDB.DocumentClient.update failed'); + } + }); }); \ No newline at end of file diff --git a/source/task-runner/package.json b/source/task-runner/package.json index 3d1d489..254fad2 100644 --- a/source/task-runner/package.json +++ b/source/task-runner/package.json @@ -1,27 +1,28 @@ { "name": "task-runner", - "version": "2.0.1", - "engines": { - "node": "^12.x" - }, + "version": "3.0.0", "description": "Triggered by Step Functions, runs ecs task Definitions", + "repository": { + "type": "git", + "url": "https://github.com/aws-solutions/distributed-load-testing-on-aws" + }, + "license": "Apache-2.0", + "author": "aws-solution-builders", "main": "index.js", "scripts": { "clean": "rm -rf node_modules package-lock.json", "test": "jest lib/*.spec.js --coverage --silent" }, "dependencies": { - "false": "^0.0.4" + "false": "^0.0.4", + "solution-utils": "file:../solution-utils" }, "devDependencies": { "aws-sdk": "2.1001.0", "jest": "26.6.3" }, - "repository": { - "type": "git", - "url": "https://github.com/aws-solutions/distributed-load-testing-on-aws" + "engines": { + "node": "^14.x" }, - "author": "aws-solution-builders", - "license": "Apache-2.0", "readme": "./README.md" } diff --git a/source/task-status-checker/index.js b/source/task-status-checker/index.js index ec9089f..e416031 100644 --- a/source/task-status-checker/index.js +++ b/source/task-status-checker/index.js @@ -2,19 +2,37 @@ // SPDX-License-Identifier: Apache-2.0 const AWS = require('aws-sdk'); -const { SOLUTION_ID, VERSION } = process.env; +const utils = require('solution-utils'); let options = {}; -if (SOLUTION_ID && VERSION && SOLUTION_ID.trim() && VERSION.trim()) { - options.customUserAgent = `AwsSolution/${SOLUTION_ID}/${VERSION}`; -} -const ecs = new AWS.ECS(options); + +options = utils.getOptions(options); const dynamoDb = new AWS.DynamoDB.DocumentClient(options); const lambda = new AWS.Lambda(options); +const checkTestStatus = async (testId, isRunning) => { + let data; + const ddbParams = { + TableName: process.env.SCENARIOS_TABLE, + Key: { + testId: testId + }, + AttributesToGet: [ + 'status' + ] + }; + data = await dynamoDb.get(ddbParams).promise(); + const { status } = data.Item; + return status === 'running' && isRunning; +}; + exports.handler = async (event) => { console.log(JSON.stringify(event, null, 2)); - const { scenario, taskRunner } = event; - const { testId } = scenario; + + const { testId, taskRunner } = event; + const { region, taskCluster } = event.testTaskConfig; + options = utils.getOptions(options); + options.region = region; + const ecs = new AWS.ECS(options); try { let nextToken = null; @@ -23,12 +41,12 @@ exports.handler = async (event) => { // Runs while loop while there are tasks in the ECS cluster. Then, call describeTasks to get task's group, which is test ID. do { - const response = await listTasks(nextToken); + const response = await listTasks(nextToken, region, taskCluster); nextToken = response.NextToken; if (response.Tasks.length > 0) { const describedTasks = await ecs.describeTasks({ - cluster: process.env.TASK_CLUSTER, + cluster: taskCluster, tasks: response.Tasks }).promise(); @@ -43,11 +61,16 @@ exports.handler = async (event) => { } } while (nextToken); //get number of tasks in running state - let numTasksRunning = runningTasks.reduce(((accumulator, task) => task.lastStatus === "RUNNING" ? ++accumulator : accumulator), 0); + let numTasksRunning = 0; + runningTasks.forEach((task) => task.lastStatus === "RUNNING" && ++numTasksRunning); //add 1 to match scenario total for step functions numTasksRunning++; - const result = { scenario, isRunning, numTasksRunning, taskRunner }; + const result = event; + result.isRunning = isRunning; + result.numTasksRunning = numTasksRunning; + result.taskRunner = taskRunner; + result.numTasksTotal = runningTasks.length + 1; /** * When prefix is provided, it means tests are running. * To prevent infinitely running tests, after 10 times (10 minutes) retries, stop the ECS cluster tasks after any tasks complete. @@ -56,7 +79,7 @@ exports.handler = async (event) => { if (event.prefix) { result.prefix = event.prefix; const runningTaskCount = runningTasks.length; - const { taskCount } = scenario; + const { taskCount } = event.testTaskConfig; if (runningTaskCount > 0) { result.isRunning = true; @@ -67,9 +90,12 @@ exports.handler = async (event) => { if (result.timeoutCount === 0) { // Stop the ECS tasks const params = { - FunctionName: process.env.TASK_CANCELER_ARN, - InvocationType: "Event", - Payload: JSON.stringify({testId: testId}) + FunctionName: process.env.TASK_CANCELER_ARN, + InvocationType: "Event", + Payload: JSON.stringify({ + testId: testId, + testTaskConfig: event.testTaskConfig + }) }; await lambda.invoke(params).promise(); result.isRunning = false; @@ -77,7 +103,7 @@ exports.handler = async (event) => { } } } - + result.isRunning = await checkTestStatus(testId, result.isRunning); return result; } catch (error) { console.error(error); @@ -99,7 +125,7 @@ exports.handler = async (event) => { throw error; } -} +}; /** * Returns the list of ECS cluster's task ARNs. @@ -107,12 +133,13 @@ exports.handler = async (event) => { * @param {string|undefined} nextToken The next token to get list tasks * @return {Promise<{ Tasks: Array|undefined, NextToken: String|undefined }>} The list of ECS cluster's task ARNs */ -async function listTasks(nextToken) { - let param = { cluster: process.env.TASK_CLUSTER }; +async function listTasks(nextToken, region, taskCluster) { + options.region = region; + const ecs = new AWS.ECS(options); + let param = { cluster: taskCluster }; if (nextToken) { param.nextToken = nextToken; } - const response = await ecs.listTasks(param).promise(); return { Tasks: response.taskArns, diff --git a/source/task-status-checker/jest.config.js b/source/task-status-checker/jest.config.js new file mode 100644 index 0000000..72a3b38 --- /dev/null +++ b/source/task-status-checker/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + roots: ['/lib'], + testMatch: ['**/*.spec.js'], + collectCoverageFrom: [ + '**/*.js' + ], + coverageReporters: [ + "text", + "clover", + "json", + ["lcov", { "projectRoot": "../.." }] + ] +} \ No newline at end of file diff --git a/source/task-status-checker/lib/index.spec.js b/source/task-status-checker/lib/index.spec.js index f404a43..3d4747b 100644 --- a/source/task-status-checker/lib/index.spec.js +++ b/source/task-status-checker/lib/index.spec.js @@ -9,7 +9,8 @@ const mockEcs = { stopTask: jest.fn() }; const mockDynamoDb = { - update: jest.fn() + update: jest.fn(), + get: jest.fn() }; const mockLambda = { invoke: jest.fn() @@ -20,14 +21,14 @@ mockAWS.ECS = jest.fn(() => ({ stopTask: mockEcs.stopTask })); mockAWS.DynamoDB.DocumentClient = jest.fn(() => ({ - update: mockDynamoDb.update + update: mockDynamoDb.update, + get: mockDynamoDb.get })); mockAWS.Lambda = jest.fn(() => ({ invoke: mockLambda.invoke })); process.env = { - TASK_CLUSTER: 'mock-task-cluster', SCENARIOS_TABLE: 'mock-scenario-table', TASK_CANCELER_ARN: 'mock-task-canceler-arn', VERSION: '2.0.1', @@ -35,15 +36,33 @@ process.env = { }; const lambda = require('../index.js'); -const event = { - scenario: { testId: 'xyz' } +let event = { + testTaskConfig: { + region: "us-west-2", + concurrency: 3, + taskCount: 5, + ecsCloudWatchLogGroup: "testEcsCloudWatchLogGroup", + taskCluster: "testTaskCluster", + taskDefinition: "arn:aws:ecs:us-west-2:123456789012:task-definition/testTaskDefinition:1", + subnetB: "subnet-1111aaaa", + taskImage: "test-load-tester", + subnetA: "subnet-2222bbbb", + taskSecurityGroup: "sg-abcd1234", + }, + testId: "testId", + testType: "simple", + fileType: "none", + isRunning: false }; +const origEvent = event; -describe('task-status-cheker', () => { +describe('task-status-checker', () => { beforeEach(() => { mockEcs.listTasks.mockReset(); mockEcs.describeTasks.mockReset(); mockDynamoDb.update.mockReset(); + mockDynamoDb.get.mockReset(); + event = { ...origEvent }; }); it('should return false for isRunning when there is no running task', async () => { @@ -54,15 +73,36 @@ describe('task-status-cheker', () => { } }; }); + mockDynamoDb.get.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ Item: { status: 'running' } }); + } + }; + }); const response = await lambda.handler(event); - expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: process.env.TASK_CLUSTER }); - expect(response).toEqual({ - scenario: event.scenario, + expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster }); + expect(response).toEqual(expect.objectContaining({ + testTaskConfig: { + concurrency: 3, + ecsCloudWatchLogGroup: "testEcsCloudWatchLogGroup", + region: "us-west-2", + subnetA: "subnet-2222bbbb", + subnetB: "subnet-1111aaaa", + taskCluster: "testTaskCluster", + taskCount: 5, + taskDefinition: "arn:aws:ecs:us-west-2:123456789012:task-definition/testTaskDefinition:1", + taskImage: "test-load-tester", + taskSecurityGroup: "sg-abcd1234" + }, + fileType: "none", numTasksRunning: 1, isRunning: false, - taskRunner: undefined - }); + taskRunner: undefined, + testId: "testId", + testType: "simple", + })); }); it('should return false for isRunning when there is a running task but not a test task', async () => { @@ -80,16 +120,24 @@ describe('task-status-cheker', () => { } }; }); + mockDynamoDb.get.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ Item: { status: 'running' } }); + } + }; + }); + + const response = await lambda.handler(event); - expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: process.env.TASK_CLUSTER }); - expect(mockEcs.describeTasks).toHaveBeenCalledWith({ cluster: process.env.TASK_CLUSTER, tasks: ['arn:of:ecs:task'] }); - expect(response).toEqual({ - scenario: event.scenario, + expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster }); + expect(mockEcs.describeTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster, tasks: ['arn:of:ecs:task'] }); + expect(response).toEqual(expect.objectContaining({ isRunning: false, numTasksRunning: 1, taskRunner: undefined - }); + })); }); it('should return false for isRunning when there are running tasks but not test tasks', async () => { @@ -119,18 +167,24 @@ describe('task-status-cheker', () => { } }; }); + mockDynamoDb.get.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ Item: { status: 'running' } }); + } + }; + }); const response = await lambda.handler(event); - expect(mockEcs.listTasks).toHaveBeenNthCalledWith(1, { cluster: process.env.TASK_CLUSTER }); - expect(mockEcs.listTasks).toHaveBeenNthCalledWith(2, { cluster: process.env.TASK_CLUSTER, nextToken: 'next' }); - expect(mockEcs.describeTasks).toHaveBeenNthCalledWith(1, { cluster: process.env.TASK_CLUSTER, tasks: ['arn:of:ecs:task1'], }); - expect(mockEcs.describeTasks).toHaveBeenNthCalledWith(2, { cluster: process.env.TASK_CLUSTER, tasks: ['arn:of:ecs:task2'], }); - expect(response).toEqual({ - scenario: event.scenario, + expect(mockEcs.listTasks).toHaveBeenNthCalledWith(1, { cluster: event.testTaskConfig.taskCluster }); + expect(mockEcs.listTasks).toHaveBeenNthCalledWith(2, { cluster: event.testTaskConfig.taskCluster, nextToken: 'next' }); + expect(mockEcs.describeTasks).toHaveBeenNthCalledWith(1, { cluster: event.testTaskConfig.taskCluster, tasks: ['arn:of:ecs:task1'], }); + expect(mockEcs.describeTasks).toHaveBeenNthCalledWith(2, { cluster: event.testTaskConfig.taskCluster, tasks: ['arn:of:ecs:task2'], }); + expect(response).toEqual(expect.objectContaining({ isRunning: false, numTasksRunning: 1, taskRunner: undefined - }); + })); }); it('should return true for isRunning when there is a running test task', async () => { @@ -144,20 +198,89 @@ describe('task-status-cheker', () => { mockEcs.describeTasks.mockImplementationOnce(() => { return { promise() { - return Promise.resolve({ tasks: [{ group: 'xyz' }] }); + return Promise.resolve({ tasks: [{ group: event.testId }] }); + } + }; + }); + mockDynamoDb.get.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ Item: { status: 'running' } }); } }; }); const response = await lambda.handler(event); - expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: process.env.TASK_CLUSTER }); - expect(mockEcs.describeTasks).toHaveBeenCalledWith({ cluster: process.env.TASK_CLUSTER, tasks: ['arn:of:ecs:task'] }); - expect(response).toEqual({ - scenario: event.scenario, + expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster }); + expect(mockEcs.describeTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster, tasks: ['arn:of:ecs:task'] }); + expect(response).toEqual(expect.objectContaining({ + testTaskConfig: { + concurrency: 3, + ecsCloudWatchLogGroup: "testEcsCloudWatchLogGroup", + region: "us-west-2", + subnetA: "subnet-2222bbbb", + subnetB: "subnet-1111aaaa", + taskCluster: "testTaskCluster", + taskCount: 5, + taskDefinition: "arn:aws:ecs:us-west-2:123456789012:task-definition/testTaskDefinition:1", + taskImage: "test-load-tester", + taskSecurityGroup: "sg-abcd1234" + }, isRunning: true, numTasksRunning: 1, - taskRunner: undefined + taskRunner: undefined, + fileType: "none", + testId: "testId", + testType: "simple", + })); + }); + + it('should return false for isRunning when there is a running task but test status is not "running"', async () => { + mockEcs.listTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ taskArns: ['arn:of:ecs:task'] }); + } + }; + }); + mockEcs.describeTasks.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ tasks: [{ group: event.testId }] }); + } + }; + }); + mockDynamoDb.get.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ Item: { status: 'cancelled' } }); + } + }; }); + + const response = await lambda.handler(event); + expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster }); + expect(mockEcs.describeTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster, tasks: ['arn:of:ecs:task'] }); + expect(response).toEqual(expect.objectContaining({ + testTaskConfig: { + concurrency: 3, + ecsCloudWatchLogGroup: "testEcsCloudWatchLogGroup", + region: "us-west-2", + subnetA: "subnet-2222bbbb", + subnetB: "subnet-1111aaaa", + taskCluster: "testTaskCluster", + taskCount: 5, + taskDefinition: "arn:aws:ecs:us-west-2:123456789012:task-definition/testTaskDefinition:1", + taskImage: "test-load-tester", + taskSecurityGroup: "sg-abcd1234" + }, + isRunning: false, + numTasksRunning: 1, + taskRunner: undefined, + fileType: "none", + testId: "testId", + testType: "simple", + })); }); it('should return false for isRunning and prefix when there is no test running and prefix is provided', async () => { @@ -168,18 +291,24 @@ describe('task-status-cheker', () => { } }; }); + mockDynamoDb.get.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ Item: { status: 'running' } }); + } + }; + }); event.prefix = 'prefix'; - event.scenario.taskCount = 2; + event.taskCount = 2; const response = await lambda.handler(event); - expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: process.env.TASK_CLUSTER }); - expect(response).toEqual({ - scenario: event.scenario, + expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster }); + expect(response).toEqual(expect.objectContaining({ isRunning: false, prefix: 'prefix', numTasksRunning: 1, taskRunner: undefined - }); + })); }); it('should return true for isRunning and prefix when a test is still running and prefix is provided', async () => { @@ -193,25 +322,31 @@ describe('task-status-cheker', () => { mockEcs.describeTasks.mockImplementationOnce(() => { return { promise() { - return Promise.resolve({ tasks: [{ group: 'xyz' }, { group: 'xyz' }] }); + return Promise.resolve({ tasks: [{ group: event.testId }, { group: event.testId }] }); } }; }); - + mockDynamoDb.get.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ Item: { status: 'running' } }); + } + }; + }); + event.prefix = 'prefix'; const response = await lambda.handler(event); - expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: process.env.TASK_CLUSTER }); - expect(mockEcs.describeTasks).toHaveBeenCalledWith({ cluster: process.env.TASK_CLUSTER, tasks: ['arn:of:ecs:task1', 'arn:of:ecs:task2'] }); - expect(response).toEqual({ - scenario: event.scenario, + expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster }); + expect(mockEcs.describeTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster, tasks: ['arn:of:ecs:task1', 'arn:of:ecs:task2'] }); + expect(response).toEqual(expect.objectContaining({ isRunning: true, prefix: 'prefix', numTasksRunning: 1, taskRunner: undefined - }); + })); }); it('should return true for isRunning, timeoutCount and prefix when a test is still running, any tasks completed, and prefix is provided', async () => { - event.scenario.taskCount = 3; + event.taskCount = 3; mockEcs.listTasks.mockImplementation(() => { return { promise() { @@ -222,22 +357,28 @@ describe('task-status-cheker', () => { mockEcs.describeTasks.mockImplementationOnce(() => { return { promise() { - return Promise.resolve({ tasks: [{ group: 'xyz', taskArn: 'arn:of:ecs:task1' }] }); + return Promise.resolve({ tasks: [{ group: event.testId, taskArn: 'arn:of:ecs:task1' }] }); } }; }); - + mockDynamoDb.get.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ Item: { status: 'running' } }); + } + }; + }); + event.prefix = 'prefix'; const response = await lambda.handler(event); - expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: process.env.TASK_CLUSTER }); - expect(mockEcs.describeTasks).toHaveBeenCalledWith({ cluster: process.env.TASK_CLUSTER, tasks: ['arn:of:ecs:task1'] }); - expect(response).toEqual({ - scenario: event.scenario, + expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster }); + expect(mockEcs.describeTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster, tasks: ['arn:of:ecs:task1'] }); + expect(response).toEqual(expect.objectContaining({ isRunning: true, prefix: 'prefix', timeoutCount: 10, numTasksRunning: 1, taskRunner: undefined - }); + })); }); it('should return false for isRunning and prefix when timeout happens', async () => { @@ -251,7 +392,7 @@ describe('task-status-cheker', () => { mockEcs.describeTasks.mockImplementationOnce(() => { return { promise() { - return Promise.resolve({ tasks: [{ group: 'xyz', taskArn: 'arn:of:ecs:task1' }] }); + return Promise.resolve({ tasks: [{ group: event.testId, taskArn: 'arn:of:ecs:task1' }] }); } }; }); @@ -262,24 +403,31 @@ describe('task-status-cheker', () => { } }; }); + mockDynamoDb.get.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ Item: { status: 'running' } }); + } + }; + }); + event.prefix = 'prefix'; event.timeoutCount = 1; const response = await lambda.handler(event); - expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: process.env.TASK_CLUSTER }); - expect(mockEcs.describeTasks).toHaveBeenCalledWith({ cluster: process.env.TASK_CLUSTER, tasks: ['arn:of:ecs:task1'] }); + expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster }); + expect(mockEcs.describeTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster, tasks: ['arn:of:ecs:task1'] }); expect(mockLambda.invoke).toHaveBeenCalledWith({ FunctionName: process.env.TASK_CANCELER_ARN, InvocationType: 'Event', - Payload: JSON.stringify({ testId: 'xyz' }) + Payload: JSON.stringify({ testId: event.testId, testTaskConfig: event.testTaskConfig }) }); - expect(response).toEqual({ - scenario: event.scenario, + expect(response).toEqual(expect.objectContaining({ isRunning: false, prefix: 'prefix', timeoutCount: 0, numTasksRunning: 1, taskRunner: undefined - }); + })); }); it('should throw an error when listTasks fails', async () => { @@ -301,11 +449,11 @@ describe('task-status-cheker', () => { try { await lambda.handler(event); } catch (error) { - expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: process.env.TASK_CLUSTER }); + expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster }); expect(mockDynamoDb.update).toHaveBeenCalledWith({ TableName: process.env.SCENARIOS_TABLE, Key: { - testId: event.scenario.testId + testId: event.testId }, UpdateExpression: 'set #s = :s, #e = :e', ExpressionAttributeNames: { @@ -347,12 +495,12 @@ describe('task-status-cheker', () => { try { await lambda.handler(event); } catch (error) { - expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: process.env.TASK_CLUSTER }); - expect(mockEcs.describeTasks).toHaveBeenCalledWith({ cluster: process.env.TASK_CLUSTER, tasks: ['arn:of:ecs:task'] }); + expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster }); + expect(mockEcs.describeTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster, tasks: ['arn:of:ecs:task'] }); expect(mockDynamoDb.update).toHaveBeenCalledWith({ TableName: process.env.SCENARIOS_TABLE, Key: { - testId: event.scenario.testId + testId: event.testId }, UpdateExpression: 'set #s = :s, #e = :e', ExpressionAttributeNames: { @@ -397,21 +545,28 @@ describe('task-status-cheker', () => { } }; }); + mockDynamoDb.get.mockImplementationOnce(() => { + return { + promise() { + return Promise.resolve({ Item: { status: 'running' } }); + } + }; + }); try { await lambda.handler(event); } catch (error) { - expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: process.env.TASK_CLUSTER }); - expect(mockEcs.describeTasks).toHaveBeenCalledWith({ cluster: process.env.TASK_CLUSTER, tasks: ['arn:of:ecs:task'] }); + expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster }); + expect(mockEcs.describeTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster, tasks: ['arn:of:ecs:task'] }); expect(mockLambda.invoke).toHaveBeenCalledWith({ FunctionName: process.env.TASK_CANCELER_ARN, - InvocationType: "Event", - Payload: JSON.stringify({ testId: 'xyz' }) + InvocationType: 'Event', + Payload: JSON.stringify({ testId: event.testId, testTaskConfig: event.testTaskConfig }) }); expect(mockDynamoDb.update).toHaveBeenCalledWith({ TableName: process.env.SCENARIOS_TABLE, Key: { - testId: event.scenario.testId + testId: event.testId }, UpdateExpression: 'set #s = :s, #e = :e', ExpressionAttributeNames: { @@ -446,11 +601,11 @@ describe('task-status-cheker', () => { try { await lambda.handler(event); } catch (error) { - expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: process.env.TASK_CLUSTER }); + expect(mockEcs.listTasks).toHaveBeenCalledWith({ cluster: event.testTaskConfig.taskCluster }); expect(mockDynamoDb.update).toHaveBeenCalledWith({ TableName: process.env.SCENARIOS_TABLE, Key: { - testId: event.scenario.testId + testId: event.testId }, UpdateExpression: 'set #s = :s, #e = :e', ExpressionAttributeNames: { @@ -465,4 +620,4 @@ describe('task-status-cheker', () => { expect(error).toEqual('DynamoDB.DocumentClient.update failed'); } }); -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/source/task-status-checker/package.json b/source/task-status-checker/package.json index a5811b3..e3dff58 100644 --- a/source/task-status-checker/package.json +++ b/source/task-status-checker/package.json @@ -1,25 +1,27 @@ { "name": "task-status-checker", - "version": "2.0.1", - "engines": { - "node": "^12.x" - }, + "version": "3.0.0", "description": "checks if tasks are running or not", + "repository": { + "type": "git", + "url": "https://github.com/aws-solutions/distributed-load-testing-on-aws" + }, + "license": "Apache-2.0", + "author": "aws-solution-builders", "main": "index.js", "scripts": { "clean": "rm -rf node_modules package-lock.json", "test": "jest lib/*.spec.js --coverage --silent" }, - "dependencies": {}, + "dependencies": { + "solution-utils": "file:../solution-utils" + }, "devDependencies": { "aws-sdk": "2.1001.0", "jest": "26.6.3" }, - "repository": { - "type": "git", - "url": "https://github.com/aws-solutions/distributed-load-testing-on-aws" + "engines": { + "node": "^14.x" }, - "author": "aws-solution-builders", - "license": "Apache-2.0", "readme": "./README.md" }