├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── NOTICE.txt ├── README.md ├── deployment ├── build-s3-dist.sh ├── operations-conductor.template └── run-unit-tests.sh └── source ├── console ├── package.json ├── public │ ├── apple-icon.png │ ├── favicon.ico │ ├── images │ │ └── logo.png │ ├── index.html │ └── manifest.json ├── src │ ├── .eslintrc.js │ ├── App.tsx │ ├── AppWithAuth.tsx │ ├── Authenticator │ │ └── Customizations.tsx │ ├── __tests__ │ │ ├── Actions.test.tsx │ │ ├── App.test.tsx │ │ ├── TaskDetail.test.tsx │ │ ├── Tasks.test.tsx │ │ ├── Users.test.tsx │ │ └── mocks │ │ │ └── server.ts │ ├── assets │ │ └── css │ │ │ └── style.css │ ├── components │ │ ├── CustomUtil.tsx │ │ └── Footer.tsx │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── setupTests.ts │ └── views │ │ ├── Actions.tsx │ │ ├── AutomationExecutionDetail.tsx │ │ ├── AutomationExecutions.tsx │ │ ├── TaskCreate.tsx │ │ ├── TaskDetail.tsx │ │ ├── Tasks.tsx │ │ └── Users.tsx └── tsconfig.json └── services ├── actions ├── actions.spec.ts ├── actions.ts ├── app.ts └── index.ts ├── common ├── interfaces.ts ├── util.spec.ts └── util.ts ├── custom-resource ├── CustomResourceRequests.ts ├── index.ts ├── lambda-edge │ └── index.js ├── resource │ ├── CopyWebsite.ts │ ├── CreateDocuments.ts │ ├── CreateLambdaEdge.ts │ ├── CreateUserPoolClient.ts │ ├── CreateUuid.ts │ ├── DeletePoolClient.ts │ ├── PutWebsiteConfig.ts │ ├── SendAnonymousMetric.ts │ └── UploadCloudFormationTemplates.ts ├── ssm │ ├── OperationsConductor-CopySnapshot │ │ ├── automation_document.yaml │ │ └── cloudformation.template │ ├── OperationsConductor-CreateSnapshot │ │ ├── automation_document.yaml │ │ └── cloudformation.template │ ├── OperationsConductor-DeleteSnapshot │ │ ├── automation_document.yaml │ │ └── cloudformation.template │ ├── OperationsConductor-ResizeInstance │ │ ├── automation_document.yaml │ │ └── cloudformation.template │ ├── OperationsConductor-SetDynamoDBCapacity │ │ ├── automation_document.yaml │ │ └── cloudformation.template │ ├── index.spec.ts │ └── index.ts └── utils.ts ├── jestconfig.json ├── logger └── index.ts ├── metrics └── index.ts ├── package.json ├── queue-consumer └── index.ts ├── resource-selector └── index.ts ├── tasks ├── app.ts ├── event-handler.template ├── index.ts ├── tasks.spec.ts └── tasks.ts ├── tsconfig.json └── users ├── app.ts ├── index.ts ├── users.spec.ts └── users.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | **/dist 3 | **/open-source 4 | **/.zip 5 | **/tmp 6 | **/out-tsc 7 | **/build 8 | **/global-s3-assets 9 | **/regional-s3-assets 10 | deployment/package-lock.json 11 | 12 | # dependencies 13 | **/node_modules 14 | 15 | # e2e 16 | **/e2e/*.js 17 | **/e2e/*.map 18 | 19 | # misc 20 | **/npm-debug.log 21 | **/testem.log 22 | **/.vscode 23 | **/.idea 24 | **.nyc_output 25 | **.pem 26 | **/aws_exports.js 27 | 28 | # System Files 29 | **/.DS_Store 30 | 31 | # Coverage files 32 | **/coverage 33 | 34 | # Local 35 | **/local.ts -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.0.4] - 2022-12-22 8 | ### Changed 9 | - Update login flow. 10 | - Update Lambda versions to Node.js 16. 11 | - Update to node dependencies. 12 | 13 | ## [1.0.3] - 2022-11-10 14 | ### Fixed 15 | - List-Documents API call from actions.ts is failing in us-east-1. issue is resolved by adding additional filter key Owner. 16 | 17 | ## [1.0.2] - 2021-12-31 18 | ### Fixed 19 | - Custom Resource Lambda function failing to publish a new version of the Lambda edge function. 20 | 21 | ## [1.0.1] - 2021-09-01 22 | ### Changed 23 | - Update Lambda versions to Node.js 14. 24 | - Update to the node depdendencies. 25 | 26 | ## [1.0.0] - 2019-11-13 27 | ### Added 28 | - Operations Conductor release 29 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check [existing open](https://github.com/aws-solutions/operations-conductor/issues), or [recently closed](https://github.com/aws-solutions/operations-conductor/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels ((enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/aws-solutions/operations-conductor/labels/help%20wanted) issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](https://github.com/aws-solutions/operations-conductor/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | 61 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 62 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /NOTICE.txt: -------------------------------------------------------------------------------- 1 | Operations Conductor Reference Architecture 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"). 6 | You may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | ********************** 18 | THIRD PARTY COMPONENTS 19 | ********************** 20 | This software includes third party software subject to the following copyrights: 21 | 22 | aws-amplify under the Apache License Version 2.0 23 | aws-amplify-react under the Apache License Version 2.0 24 | aws-lambda under no license 25 | aws-serverless-express under the Apache License Version 2.0 26 | body-parser under the Massachusetts Institute of Technology (MIT) license 27 | bootstrap under the Massachusetts Institute of Technology (MIT) license 28 | cors under the Massachusetts Institute of Technology (MIT) license 29 | express under the Massachusetts Institute of Technology (MIT) license 30 | jwt-decode under the Massachusetts Institute of Technology (MIT) license 31 | moment under the Massachusetts Institute of Technology (MIT) license 32 | node-sass under the Massachusetts Institute of Technology (MIT) license 33 | react under the Massachusetts Institute of Technology (MIT) license 34 | react-bootstrap under the Massachusetts Institute of Technology (MIT) license 35 | react-dom under the Massachusetts Institute of Technology (MIT) license 36 | react-router-bootstrap under the Apache License Version 2.0 37 | react-router-dom under the Massachusetts Institute of Technology (MIT) license 38 | react-scripts under the Massachusetts Institute of Technology (MIT) license 39 | request under the Apache License Version 2.0 40 | request-promise under the ISC License 41 | typescript-logging under the Apache License Version 2.0 42 | uuid under the Massachusetts Institute of Technology (MIT) license -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deprecation Notice 2 | As of 03/03/2023, Operations Conductor on AWS has been deprecated and will not be receiving any additional 3 | features or updates. We encourage customers to explore using 4 | [Amazon EventBridge Rules](https://docs.aws.amazon.com/scheduler/latest/UserGuide/what-is-scheduler.html) 5 | to create, run, and manage tasks from one central, managed service. 6 | 7 | 8 | # Operations Conductor on AWS 9 | 10 | ## Description 11 | Operations Conductor on AWS is a framework that provides an easy-to-use interface for automating and orchestrating operational tasks to manage AWS resources at scale. 12 | 13 | ## Running unit tests for customization 14 | * Clone the repository, then make the desired code changes. 15 | ```bash 16 | git clone https://github.com/aws-solutions/operations-conductor.git 17 | cd operations-conductor 18 | export OPS_CO_PATH=`pwd` 19 | ``` 20 | 21 | * Next, run unit tests to make sure added customization passees the tests. 22 | ```bash 23 | cd $OPS_CO_PATH/deployment 24 | chmod +x ./run-unit-tests.sh 25 | ./run-unit-tests.sh 26 | ``` 27 | 28 | ## Building the customized solution 29 | * Configure the build paraemters. 30 | ```bash 31 | export DIST_OUTPUT_BUCKET=my-bucket-name # bucket where customized code will reside 32 | export VERSION=my-version # version number for the customized code 33 | export SOLUTION_NAME=operations-conductor # solution name for the customized code 34 | ``` 35 | _Note:_ You would have to create an S3 bucket with the prefix 'my-bucket-name-' as whole Lambda functions are going to get the source codes from the 'my-bucket-name-' bucket; aws_region is where you are deployting the customized solution (e.g. us-east-1, us-east-2, etc.). 36 | 37 | * Build the customized solution 38 | ```bash 39 | cd $OPS_CO_PATH/deployment 40 | chmod +x ./build-s3-dist.sh 41 | ./build-s3-dist.sh $DIST_OUTPUT_BUCKET $SOLUTION_NAME $VERSION 42 | ``` 43 | 44 | * Deploy the source codes to an Amazon S3 bucket in your account. _Note:_ You must have the AWS Command Line Interface installed and create the Amazon S3 bucket in your account prior to copy source codes. 45 | ```bash 46 | export AWS_REGION=us-east-1 # the AWS region you are going to deploy the solution in your account. 47 | export AWS_PROFILE=default # the AWS Command Line Interface profile 48 | 49 | aws s3 cp $OPS_CO_PATH/deployment/global-s3-assets/ s3://$DIST_OUTPUT_BUCKET-$AWS_REGION/operations-conductor/$VERSION/ --recursive --acl bucket-owner-full-control --profile $AWS_PROFILE 50 | aws s3 cp $OPS_CO_PATH/deployment/regional-s3-assets/ s3://$DIST_OUTPUT_BUCKET-$AWS_REGION/operations-conductor/$VERSION/ --recursive --acl bucket-owner-full-control --profile $AWS_PROFILE 51 | ``` 52 | 53 | ## Deploying the customized solution 54 | * Get the link of the operations-conductor.template uploaded to your Amazon S3 bucket. 55 | * Deploy the Operations Conductor solution to your account by launching a new AWS CloudFormation stack using the link of the operations-conductor.template. 56 | 57 | ## Collection of operational metrics 58 | This solution collects anonymous operational metrics to help AWS improve the quality and features of the solution. For more information, including how to disable this capability, please see the [implementation guide](https://docs.aws.amazon.com/solutions/latest/operations-conductor/welcome.html). 59 | 60 | *** 61 | 62 | Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. 63 | 64 | Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with the License. A copy of the License is located at 65 | 66 | http://www.apache.org/licenses/LICENSE-2.0 67 | 68 | or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 69 | -------------------------------------------------------------------------------- /deployment/build-s3-dist.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | 7 | # 8 | # This assumes all of the OS-level configuration has been completed and git repo has already been cloned 9 | # 10 | # This script should be run from the repo's deployment directory 11 | # cd deployment 12 | # ./build-s3-dist.sh source-bucket-base-name trademarked-solution-name version-code 13 | # 14 | # Parameters: 15 | # - source-bucket-base-name: Name for the S3 bucket location where the template will source the Lambda 16 | # code from. The template will append '-[region_name]' to this bucket name. 17 | # For example: ./build-s3-dist.sh solutions my-solution v1.0.2 18 | # The template will then expect the source code to be located in the solutions-[region_name] bucket 19 | # 20 | # - trademarked-solution-name: name of the solution for consistency 21 | # 22 | # - version-code: version of the package 23 | 24 | # Check to see if input has been provided: 25 | if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]; then 26 | echo "Please provide the base source bucket name, trademark approved solution name and version where the lambda code will eventually reside." 27 | echo "For example: ./build-s3-dist.sh solutions trademarked-solution-name v1.0.4" 28 | exit 1 29 | fi 30 | 31 | # Get reference for all important folders 32 | template_dir="$PWD" 33 | template_dist_dir="$template_dir/global-s3-assets" 34 | build_dist_dir="$template_dir/regional-s3-assets" 35 | source_dir="$template_dir/../source" 36 | 37 | echo "------------------------------------------------------------------------------" 38 | echo "[Init] Clean old dist folders" 39 | echo "------------------------------------------------------------------------------" 40 | echo "rm -rf $template_dist_dir" 41 | rm -rf $template_dist_dir 42 | echo "mkdir -p $template_dist_dir" 43 | mkdir -p $template_dist_dir 44 | echo "rm -rf $build_dist_dir" 45 | rm -rf $build_dist_dir 46 | echo "mkdir -p $build_dist_dir" 47 | mkdir -p $build_dist_dir 48 | 49 | echo "------------------------------------------------------------------------------" 50 | echo "[Packing] Templates" 51 | echo "------------------------------------------------------------------------------" 52 | echo "cp $template_dir/operations-conductor.template $template_dist_dir/operations-conductor.template" 53 | cp $template_dir/operations-conductor.template $template_dist_dir/operations-conductor.template 54 | 55 | if [[ "$OSTYPE" == "darwin"* ]]; then 56 | # Mac OS 57 | echo "Updating code source bucket in template with $1" 58 | replace="s/%%BUCKET_NAME%%/$1/g" 59 | echo "sed -i '' -e $replace $template_dist_dir/operations-conductor.template" 60 | sed -i '' -e $replace $template_dist_dir/operations-conductor.template 61 | replace="s/%%SOLUTION_NAME%%/$2/g" 62 | echo "sed -i '' -e $replace $template_dist_dir/operations-conductor.template" 63 | sed -i '' -e $replace $template_dist_dir/operations-conductor.template 64 | replace="s/%%VERSION%%/$3/g" 65 | echo "sed -i '' -e $replace $template_dist_dir/operations-conductor.template" 66 | sed -i '' -e $replace $template_dist_dir/operations-conductor.template 67 | else 68 | # Other linux 69 | echo "Updating code source bucket in template with $1" 70 | replace="s/%%BUCKET_NAME%%/$1/g" 71 | echo "sed -i -e $replace $template_dist_dir/operations-conductor.template" 72 | sed -i -e $replace $template_dist_dir/operations-conductor.template 73 | replace="s/%%SOLUTION_NAME%%/$2/g" 74 | echo "sed -i -e $replace $template_dist_dir/operations-conductor.template" 75 | sed -i -e $replace $template_dist_dir/operations-conductor.template 76 | replace="s/%%VERSION%%/$3/g" 77 | echo "sed -i -e $replace $template_dist_dir/operations-conductor.template" 78 | sed -i -e $replace $template_dist_dir/operations-conductor.template 79 | fi 80 | 81 | echo "------------------------------------------------------------------------------" 82 | echo "[Rebuild] Console" 83 | echo "------------------------------------------------------------------------------" 84 | cd $source_dir/console 85 | INLINE_RUNTIME_CHUNK=false npm run build 86 | mkdir $build_dist_dir/console 87 | cd $source_dir/console/build 88 | cp -r ./ $build_dist_dir/console/ 89 | 90 | echo "------------------------------------------------------------------------------" 91 | echo "[Create] Console manifest" 92 | echo "------------------------------------------------------------------------------" 93 | echo "Creating console manifest file" 94 | cd $source_dir/console/build 95 | manifest=(`find * -type f ! -iname "aws_exports.js" ! -iname ".DS_Store"`) 96 | manifest_json=$(IFS=,;printf "%s" "${manifest[*]}") 97 | echo "{\"files\":[\"$manifest_json\"]}" | sed 's/,/","/g' >> $build_dist_dir/console/site-manifest.json 98 | 99 | echo "------------------------------------------------------------------------------" 100 | echo "[Rebuild] Services" 101 | echo "------------------------------------------------------------------------------" 102 | cd $source_dir/services/ 103 | npm run package 104 | cp ./build/operations-conductor-services.zip $build_dist_dir/operations-conductor-services.zip 105 | 106 | echo "------------------------------------------------------------------------------" 107 | echo "[Copy] Lambda Edge" 108 | echo "------------------------------------------------------------------------------" 109 | cd $source_dir/services/ 110 | mkdir -p $source_dir/services/build/lambda-edge 111 | npm run build:lambda-edge 112 | cp ./build/lambda-edge/operations-conductor-lambda-edge.zip $build_dist_dir/operations-conductor-lambda-edge.zip -------------------------------------------------------------------------------- /deployment/run-unit-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | 7 | # 8 | # This assumes all of the OS-level configuration has been completed and git repo has already been cloned 9 | # 10 | # This script should be run from the repo's deployment directory 11 | # cd deployment 12 | # ./run-unit-tests.sh 13 | # 14 | 15 | # Get reference for all important folders 16 | template_dir="$PWD" 17 | source_dir="$template_dir/../source" 18 | 19 | echo "------------------------------------------------------------------------------" 20 | echo "[Test] Services" 21 | echo "------------------------------------------------------------------------------" 22 | cd $source_dir/services/ 23 | npm run build 24 | npm test 25 | 26 | echo "------------------------------------------------------------------------------" 27 | echo "[Test] console" 28 | echo "------------------------------------------------------------------------------" 29 | cd $source_dir/console/ 30 | npm run build 31 | npm run test 32 | -------------------------------------------------------------------------------- /source/console/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "operations-conductor-console", 3 | "description": "The Operations Conductor Web console", 4 | "version": "1.0.4", 5 | "private": true, 6 | "dependencies": { 7 | "@aws-amplify/api": "^4.0.0", 8 | "@aws-amplify/auth": "^4.0.0", 9 | "@aws-amplify/core": "^4.0.0", 10 | "@aws-amplify/ui-react": "^3.0.0", 11 | "bootstrap": "^3.3.7", 12 | "react": "^16.11.0", 13 | "react-bootstrap": "^0.32.4", 14 | "react-dom": "^16.11.0", 15 | "react-router-bootstrap": "^0.25.0", 16 | "react-router-dom": "^5.1.2", 17 | "react-scripts": "^5.0.1", 18 | "uuid": "8.3.2" 19 | }, 20 | "devDependencies": { 21 | "@testing-library/jest-dom": "^5.16.5", 22 | "@testing-library/react": "^12.1.5", 23 | "@testing-library/user-event": "^14.4.3", 24 | "@types/react": "^16.9.9", 25 | "@types/react-bootstrap": "^0.32.20", 26 | "@types/react-dom": "^16.9.2", 27 | "@types/react-router-bootstrap": "^0.24.5", 28 | "@types/react-router-dom": "^5.1.0", 29 | "eslint-plugin-jest-dom": "^4.0.3", 30 | "msw": "^0.49.1", 31 | "typescript": "~4.4.0" 32 | }, 33 | "overrides": { 34 | "nth-check": "^2.0.0" 35 | }, 36 | "scripts": { 37 | "start": "react-scripts start", 38 | "build": "npm ci && react-scripts build", 39 | "test": "CI=true react-scripts test --coverage", 40 | "eject": "react-scripts eject" 41 | }, 42 | "eslintConfig": { 43 | "extends": "react-app" 44 | }, 45 | "browserslist": { 46 | "production": [ 47 | ">0.2%", 48 | "not dead", 49 | "not op_mini all" 50 | ], 51 | "development": [ 52 | "last 1 chrome version", 53 | "last 1 firefox version", 54 | "last 1 safari version" 55 | ] 56 | }, 57 | "jest": { 58 | "testMatch": [ 59 | "**/*.test.ts(x)?" 60 | ], 61 | "collectCoverageFrom": [ 62 | "src/**/*.{ts,tsx}", 63 | "!src/react-app-env.d.ts", 64 | "!src/types/aws-amplify-react.d.ts", 65 | "!src/index.tsx" 66 | ] 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /source/console/public/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/operations-conductor/b3ecd01fe19ac6a40bed734da2ea03723d4d91db/source/console/public/apple-icon.png -------------------------------------------------------------------------------- /source/console/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/operations-conductor/b3ecd01fe19ac6a40bed734da2ea03723d4d91db/source/console/public/favicon.ico -------------------------------------------------------------------------------- /source/console/public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-solutions/operations-conductor/b3ecd01fe19ac6a40bed734da2ea03723d4d91db/source/console/public/images/logo.png -------------------------------------------------------------------------------- /source/console/public/index.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Operations Conductor on AWS 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /source/console/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "LBDR", 3 | "name": "AWS Operations Conductor", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /source/console/src/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | module.exports = { 7 | extends: "plugin:jest-dom/recommended" 8 | } -------------------------------------------------------------------------------- /source/console/src/App.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as React from 'react'; 7 | import { Logger } from '@aws-amplify/core'; 8 | import Auth from '@aws-amplify/auth'; 9 | 10 | import { Navbar, Nav, NavItem, Glyphicon } from 'react-bootstrap'; 11 | import { BrowserRouter as Router, Route, Switch, Redirect } from 'react-router-dom'; 12 | import { LinkContainer } from 'react-router-bootstrap'; 13 | import { LOGGING_LEVEL} from './components/CustomUtil'; 14 | 15 | import Users from './views/Users'; 16 | import Actions from './views/Actions'; 17 | import Tasks from './views/Tasks'; 18 | import TaskCreate from './views/TaskCreate'; 19 | import TaskDetail from './views/TaskDetail'; 20 | import AutomationExecutions from './views/AutomationExecutions'; 21 | import AutomationExecutionDetail from './views/AutomationExecutionDetail'; 22 | import Footer from './components/Footer'; 23 | 24 | interface IProps { 25 | userGroups: string[]; 26 | } 27 | 28 | interface IState { 29 | token: string; 30 | } 31 | 32 | const LOGGER = new Logger('App', LOGGING_LEVEL); 33 | 34 | class App extends React.Component { 35 | constructor(props: Readonly) { 36 | super(props); 37 | 38 | this.state = { 39 | token: '' 40 | }; 41 | } 42 | 43 | // Gets API token 44 | getApiToken = async () => { 45 | let user = await Auth.currentAuthenticatedUser(); 46 | return user.signInUserSession.idToken.jwtToken; 47 | }; 48 | 49 | // Signs out 50 | signout = () => { 51 | Auth.signOut().catch((error) => { 52 | LOGGER.error('Error happened while signing out', error); 53 | }); 54 | }; 55 | 56 | userIsInAdminGroup = () => { 57 | return this.props.userGroups.indexOf('Admin') > -1; 58 | }; 59 | 60 | render() { 61 | return ( 62 |
63 | 64 | 65 | 66 | Operations Conductor on AWS 67 | 68 | 83 | 86 | 87 | 88 | ()} /> 90 | ()} /> 92 | ()} /> 94 | ()} /> 96 | ()} /> 98 | ()} /> 100 | ()} /> 102 | ()} /> 104 | 105 | 106 | 107 |
108 |
109 | ); 110 | 111 | } 112 | } 113 | 114 | export default App; -------------------------------------------------------------------------------- /source/console/src/AppWithAuth.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as React from 'react'; 7 | import { Authenticator } from '@aws-amplify/ui-react' 8 | import '@aws-amplify/ui-react/styles.css' 9 | import {CognitoUser} from '@aws-amplify/auth'; 10 | import {Amplify, I18n, Logger} from '@aws-amplify/core'; 11 | 12 | import App from './App'; 13 | import { LOGGING_LEVEL} from './components/CustomUtil'; 14 | import authComponents from "./Authenticator/Customizations"; 15 | 16 | declare var aws_exports: any; 17 | 18 | interface IProps {} 19 | 20 | interface IState { 21 | userState: string; 22 | } 23 | 24 | const LOGGER = new Logger('AppWithAuth', LOGGING_LEVEL); 25 | 26 | class AppWithAuth extends React.Component { 27 | constructor(props: Readonly) { 28 | super(props); 29 | 30 | this.state = { 31 | userState: '' 32 | } 33 | 34 | Amplify.configure(aws_exports); 35 | 36 | //overrides error message in email verification flow 37 | I18n.putVocabulariesForLanguage('en', { 38 | "1 validation error detected: Value null at 'attributeName' failed to satisfy constraint: Member must not be null": 'You must select an authentication method to verify' 39 | }) 40 | } 41 | 42 | 43 | userGroupsFromUser(user?: CognitoUser): string[] { 44 | let groups = user?.getSignInUserSession()?.getIdToken().payload['cognito:groups'] 45 | return groups ? groups : []; 46 | } 47 | 48 | render() { 49 | return ( 50 | 52 | {({user}) => ( 53 | 54 | )} 55 | 56 | ) 57 | } 58 | } 59 | 60 | 61 | 62 | export default AppWithAuth; -------------------------------------------------------------------------------- /source/console/src/Authenticator/Customizations.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import {Image, Nav, Navbar, NavItem} from "react-bootstrap"; 7 | import * as React from "react"; 8 | 9 | const authComponents = { 10 | Header: () => ( 11 | 12 | 13 | Operations Conductor on AWS 14 | 15 | 16 | ), 17 | Footer: () => ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 29 | 30 | ) 31 | 32 | 33 | } 34 | 35 | export default authComponents 36 | -------------------------------------------------------------------------------- /source/console/src/__tests__/Actions.test.tsx: -------------------------------------------------------------------------------- 1 | import {server} from "./mocks/server" 2 | import {PathParams, ResponseResolver, rest, RestContext, RestRequest} from "msw"; 3 | import {render, screen, within} from "@testing-library/react"; 4 | import userEvent from "@testing-library/user-event" 5 | import Actions, {Action} from "../views/Actions"; 6 | import {MemoryRouter, Route, Switch} from "react-router-dom"; 7 | import TaskCreate from "../views/TaskCreate"; 8 | import * as React from "react"; 9 | 10 | const mockActionsList: Action[] = [ 11 | { 12 | name: "action1", 13 | owner: "self", 14 | description: "action 1 description", 15 | }, 16 | { 17 | name: "action2", 18 | owner: "self", 19 | description: "action 2 description", 20 | }, 21 | { 22 | name: "action3", 23 | owner: "self", 24 | description: "action 3 description", 25 | }, 26 | ] 27 | 28 | function actionsApi() { 29 | return { 30 | returnsActions: (actions: Action[]) => { 31 | server.use( 32 | rest.get("/actions", (request, response, context) => { 33 | return response( 34 | context.status(200), 35 | context.json(actions) 36 | ) 37 | }) 38 | ) 39 | }, 40 | returnsActionsAfterDelay: (actions: Action[], msDelay: number) => { 41 | server.use( 42 | rest.get("/actions", (request, response, context) => { 43 | return response( 44 | context.delay(msDelay), 45 | context.status(200), 46 | context.json(actions) 47 | ) 48 | }) 49 | ) 50 | }, 51 | returnsResponse: (resolver: ResponseResolver>, RestContext>) => { 52 | server.use( 53 | rest.get("/actions", (request, response, context) => { 54 | return resolver(request, response, context); 55 | }) 56 | ) 57 | } 58 | } 59 | } 60 | function renderActionsPage() { 61 | return render( 62 | 63 | 64 | ( ""} />)} /> 66 | ( ""} />)} /> 68 | 69 | 70 | ) 71 | } 72 | 73 | async function actionsHaveLoaded() { 74 | await screen.findByRole('table'); 75 | } 76 | describe("ListActions page", ()=> { 77 | 78 | 79 | test("shows loading bar while actions are loading", async ()=> { 80 | actionsApi().returnsActionsAfterDelay(mockActionsList, 500) 81 | renderActionsPage(); 82 | 83 | expect(await screen.findByRole('progressbar')).toBeInTheDocument() 84 | }) 85 | 86 | test("does not show loading bar when actions have loaded", async ()=> { 87 | actionsApi().returnsActions(mockActionsList) 88 | renderActionsPage(); 89 | await actionsHaveLoaded(); 90 | 91 | expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() 92 | }) 93 | 94 | test("lists all actions", async ()=> { 95 | actionsApi().returnsActions(mockActionsList) 96 | renderActionsPage() 97 | await actionsHaveLoaded() 98 | 99 | const actionsTable = await screen.getByRole('table') 100 | 101 | expect(within(actionsTable).queryByRole('row', {name: /action1/})).toBeInTheDocument() 102 | expect(within(actionsTable).queryByRole('row', {name: /action2/})).toBeInTheDocument() 103 | expect(within(actionsTable).queryByRole('row', {name: /action3/})).toBeInTheDocument() 104 | }) 105 | 106 | test("search filters actions correctly", async () => { 107 | //arrange 108 | actionsApi().returnsActions(mockActionsList); 109 | renderActionsPage() 110 | await actionsHaveLoaded() 111 | 112 | //elements of interest 113 | const searchBar = screen.getByRole('textbox') 114 | const actionsTable = await screen.findByRole('table') 115 | 116 | //act 117 | await userEvent.type(searchBar, "action1") 118 | 119 | //assert 120 | expect(within(actionsTable).queryByRole('row', {name: /action1/})).toBeInTheDocument() 121 | expect(within(actionsTable).queryByRole('row', {name: /action2/})).not.toBeInTheDocument() 122 | expect(within(actionsTable).queryByRole('row', {name: /action3/})).not.toBeInTheDocument() 123 | }) 124 | 125 | test("search filter is case insensitive", async ()=> { 126 | actionsApi().returnsActions(mockActionsList); 127 | renderActionsPage() 128 | await actionsHaveLoaded() 129 | 130 | const searchBar = screen.getByRole('textbox') 131 | const actionsTable = screen.getByRole('table') 132 | 133 | await userEvent.type(searchBar, "AcTIon1") 134 | 135 | expect(within(actionsTable).queryByRole('row', {name: /action1/})).toBeInTheDocument() 136 | }) 137 | 138 | test("search filter performs partial matching", async ()=> { 139 | actionsApi().returnsActions([{name:"ActionWithVeryVeryLongName", owner: "owner", description: "description"}]); 140 | renderActionsPage() 141 | await actionsHaveLoaded() 142 | 143 | const searchBar = screen.getByRole('textbox') 144 | const actionsTable = screen.getByRole('table') 145 | 146 | await userEvent.type(searchBar, "longname") 147 | 148 | expect(within(actionsTable).queryByRole('row', {name: /ActionWithVeryVeryLongName/})).toBeInTheDocument() 149 | }) 150 | 151 | test("action initial sort order is ascending", async ()=> { 152 | actionsApi().returnsActions(mockActionsList) 153 | renderActionsPage() 154 | await actionsHaveLoaded() 155 | 156 | const actionsTable = screen.getByRole('table') 157 | const actionRows = within(actionsTable).getAllByRole('row'); 158 | const action1Row = within(actionsTable).getByRole('row', {name: /action1/}) 159 | const action2Row = within(actionsTable).getByRole('row', {name: /action2/}) 160 | 161 | expect(actionRows.indexOf(action1Row)).toBeLessThan(actionRows.indexOf(action2Row)); 162 | }) 163 | 164 | test("action sort order can be toggled", async ()=> { 165 | //setup 166 | actionsApi().returnsActions(mockActionsList) 167 | renderActionsPage() 168 | await actionsHaveLoaded() 169 | 170 | const actionsTable = screen.getByRole('table'); 171 | const sortButton = screen.getByTestId('sort-btn') 172 | const action1Row = within(actionsTable).getByRole('row', {name: /action1/}) 173 | const action2Row = within(actionsTable).getByRole('row', {name: /action2/}) 174 | 175 | //act 176 | await userEvent.click(sortButton) 177 | 178 | //first toggle toggles to descending order 179 | let actionRows = within(actionsTable).getAllByRole('row'); 180 | expect(actionRows.indexOf(action1Row)).toBeGreaterThan(actionRows.indexOf(action2Row)); 181 | 182 | //act 183 | await(userEvent.click(sortButton)); 184 | 185 | //toggling again toggles back to ascending order 186 | actionRows = within(actionsTable).getAllByRole('row'); 187 | expect(actionRows.indexOf(action1Row)).toBeLessThan(actionRows.indexOf(action2Row)); 188 | }) 189 | 190 | test("displays error when actions fail to fetch", async ()=> { 191 | actionsApi().returnsResponse((request, response, context) => { 192 | return response( 193 | context.status(500), 194 | context.json({ 195 | message: 'Error loading Actions' 196 | }) 197 | ) 198 | }) 199 | 200 | renderActionsPage() 201 | expect(await screen.findByRole('alert')).toBeInTheDocument() 202 | }) 203 | 204 | test("each action has create action button", async ()=> { 205 | actionsApi().returnsActions(mockActionsList) 206 | renderActionsPage() 207 | await actionsHaveLoaded(); 208 | 209 | const action1Row = screen.getByRole('row', {name:/action1/}) 210 | const action2Row = screen.getByRole('row', {name:/action2/}) 211 | const action3Row = screen.getByRole('row', {name:/action3/}) 212 | 213 | expect(within(action1Row).getByRole('button', {name:/create/i})).toBeInTheDocument() 214 | expect(within(action2Row).getByRole('button', {name:/create/i})).toBeInTheDocument() 215 | expect(within(action3Row).getByRole('button', {name:/create/i})).toBeInTheDocument() 216 | }) 217 | 218 | test("clicking action create button opens correct create page", async ()=> { 219 | actionsApi().returnsActions(mockActionsList) 220 | renderActionsPage() 221 | await actionsHaveLoaded(); 222 | 223 | const action1Row = screen.getByRole('row', {name:/action1/}) 224 | const createAction1TaskBtn = within(action1Row).getByRole('button', {name:/create/i}) 225 | 226 | await userEvent.click(createAction1TaskBtn); 227 | 228 | expect(await screen.findByText(/create a task/i)).toBeInTheDocument() 229 | expect(screen.getByText(/action1/)).toBeInTheDocument() 230 | }) 231 | 232 | }) -------------------------------------------------------------------------------- /source/console/src/__tests__/App.test.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | 7 | import {render, screen, within} from "@testing-library/react"; 8 | import App from "../App"; 9 | import userEvent from "@testing-library/user-event"; 10 | 11 | const USER_GROUPS = { 12 | ADMIN: "Admin" 13 | } 14 | 15 | function renderApp(params: { 16 | userGroups?: string[] 17 | }) { 18 | return render( 19 | 20 | ) 21 | } 22 | 23 | describe('Main App', ()=> { 24 | test("loads", ()=> { 25 | renderApp({userGroups: [USER_GROUPS.ADMIN]}) 26 | 27 | expect(screen.getByText(/Operations Conductor on AWS/i)).toBeInTheDocument() 28 | }) 29 | 30 | test("users nav link is shown to admins", async ()=> { 31 | renderApp({userGroups: [USER_GROUPS.ADMIN]}) 32 | 33 | expect(screen.queryByRole('link', {name:/users/i})).toBeInTheDocument() 34 | }) 35 | 36 | test("users nav link is not shown to non-admins", async ()=> { 37 | renderApp({userGroups: []}) 38 | 39 | expect(screen.queryByRole('link', {name:/users/i})).not.toBeInTheDocument() 40 | }) 41 | 42 | test("tasks button goes to My Tasks page", async ()=> { 43 | renderApp({}) 44 | 45 | const tasksNavButton = screen.getByRole('link', {name: /tasks/i}) 46 | 47 | await userEvent.click(tasksNavButton) 48 | 49 | expect(screen.queryByRole('heading', {name: /my tasks/i})).toBeInTheDocument() 50 | }) 51 | 52 | test("users button goes to Users page", async ()=> { 53 | renderApp({userGroups: [USER_GROUPS.ADMIN]}) 54 | 55 | const usersNavButton = screen.getByRole('link', {name: /users/i}) 56 | 57 | await userEvent.click(usersNavButton) 58 | 59 | expect(screen.queryByRole('heading', {name: /users/i})).toBeInTheDocument() 60 | }) 61 | 62 | test("AWS Solutions Button in Footer redirects to AWS Solutions Library", async ()=>{ 63 | renderApp({}) 64 | 65 | const awsNavButton = screen.getByRole('link', {name: /aws solutions/i}) 66 | 67 | expect(awsNavButton).toHaveAttribute('href', 'https://aws.amazon.com/solutions/') 68 | }) 69 | }) -------------------------------------------------------------------------------- /source/console/src/__tests__/TaskDetail.test.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import {render, screen, within} from "@testing-library/react"; 7 | import {MemoryRouter, Route, Switch} from "react-router-dom"; 8 | import * as React from "react"; 9 | import TaskDetail, {ScheduledType, Task} from "../views/TaskDetail"; 10 | import {server} from "./mocks/server"; 11 | import {PathParams, ResponseResolver, rest, RestContext, RestRequest} from "msw"; 12 | import TaskCreate from "../views/TaskCreate"; 13 | import userEvent from "@testing-library/user-event"; 14 | 15 | function getApiToken() { 16 | return "" //no need for api token in these tests 17 | } 18 | 19 | const mockCronTask: Task = { 20 | taskId: "cronTask", 21 | actionName: "actionName", 22 | name: "name", //taskName 23 | description: "description", 24 | triggerType: "Schedule", 25 | targetTag: "targetTag", 26 | accounts: ["account1", "account2"], 27 | regions: ["us-east-1", "us-east-2"], 28 | templateUrl: "templateUrl", 29 | 30 | scheduledType: ScheduledType.CronExpression, 31 | scheduledCronExpression: "0/5 * ? * * *", 32 | enabled: true, 33 | 34 | 35 | taskParameters: [ 36 | {Name: "param1Name", Value: "param1Value", Type: "String", Description: "param1Description" }, 37 | {Name: "param2Name", Value: "param2Value", Type: "String", Description: "param2Description" } 38 | ], 39 | } 40 | function taskApi() { 41 | return { 42 | onGet: (taskId: string) => { 43 | return { 44 | returnsTask: (task: Task) => { 45 | server.use( 46 | rest.get(`/task/${taskId}`, (request, response, context) => { 47 | return response( 48 | context.status(200), 49 | context.json(task) 50 | ) 51 | }) 52 | ) 53 | }, 54 | returnsTaskAfterDelay: (task: Task, msDelay: number) => { 55 | server.use( 56 | rest.get(`/task/${taskId}`, (request, response, context) => { 57 | return response( 58 | context.delay(msDelay), 59 | context.status(200), 60 | context.json(task) 61 | ) 62 | }) 63 | ) 64 | }, 65 | returnsResponse: (resolver: ResponseResolver>, RestContext>) => { 66 | server.use( 67 | rest.get(`/task/${taskId}`, (request, response, context) => { 68 | return resolver(request, response, context); 69 | }) 70 | ) 71 | } 72 | } 73 | } 74 | } 75 | } 76 | function renderTaskDetailPage(taskId: string) { 77 | return render( 78 | 79 | 80 | ()} /> 82 | ()} /> 84 | 85 | 86 | ) 87 | } 88 | 89 | describe('TaskDetail page', ()=> { 90 | test("loads", async ()=> { 91 | taskApi().onGet(mockCronTask.taskId!).returnsTask(mockCronTask) 92 | renderTaskDetailPage(mockCronTask.taskId!) 93 | 94 | expect(screen.getByRole('heading', {name:/Task Detail/i})).toBeInTheDocument() 95 | }) 96 | }) -------------------------------------------------------------------------------- /source/console/src/__tests__/Tasks.test.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import {MemoryRouter, Route, Switch} from "react-router-dom"; 7 | import {render, screen, within} from "@testing-library/react"; 8 | import * as React from "react"; 9 | import Tasks, {Task} from "../views/Tasks"; 10 | import Actions, {Action} from "../views/Actions"; 11 | import {server} from "./mocks/server"; 12 | import {PathParams, ResponseResolver, rest, RestContext, RestRequest} from "msw"; 13 | import userEvent from "@testing-library/user-event"; 14 | import TaskDetail from "../views/TaskDetail"; 15 | 16 | 17 | const mockTasksList: Task[] = [ 18 | { 19 | name: "task1", 20 | taskId: "id1", 21 | description: "task 1 description", 22 | }, 23 | { 24 | name: "task2", 25 | taskId: "id2", 26 | description: "task 2 description", 27 | }, 28 | { 29 | name: "task3", 30 | taskId: "id3", 31 | description: "task 3 description", 32 | }, 33 | ] 34 | 35 | function getApiToken() { 36 | return "" //no need for api token in these tests 37 | } 38 | function renderTasksPage() { 39 | return render( 40 | 41 | 42 | ()} /> 44 | ()} /> 46 | ()} /> 48 | 49 | 50 | ) 51 | } 52 | 53 | function tasksApi() { 54 | return { 55 | returnsTasks: (tasks: Task[]) => { 56 | server.use( 57 | rest.get("/tasks", (request, response, context) => { 58 | return response( 59 | context.status(200), 60 | context.json(tasks) 61 | ) 62 | }) 63 | ) 64 | }, 65 | returnsTasksAfterDelay: (tasks: Task[], msDelay: number) => { 66 | server.use( 67 | rest.get("/tasks", (request, response, context) => { 68 | return response( 69 | context.delay(msDelay), 70 | context.status(200), 71 | context.json(tasks) 72 | ) 73 | }) 74 | ) 75 | }, 76 | returnsResponse: (resolver: ResponseResolver>, RestContext>) => { 77 | server.use( 78 | rest.get("/tasks", (request, response, context) => { 79 | return resolver(request, response, context); 80 | }) 81 | ) 82 | } 83 | } 84 | } 85 | 86 | async function tasksHaveLoaded() { 87 | //'taskname-panel' is included on all task panels as a testId 88 | await screen.findAllByTestId(/.*-panel/); 89 | } 90 | describe('ListTasks Page', ()=> { 91 | test('loads', async ()=> { 92 | renderTasksPage() 93 | expect(screen.getByRole('heading', {name:/Tasks/i})).toBeInTheDocument() 94 | }) 95 | 96 | test('displays all tasks', async ()=> { 97 | tasksApi().returnsTasks(mockTasksList); 98 | renderTasksPage() 99 | await tasksHaveLoaded() 100 | 101 | expect(screen.getByText(/task1/)).toBeInTheDocument() 102 | expect(screen.getByText(/task2/)).toBeInTheDocument() 103 | expect(screen.getByText(/task3/)).toBeInTheDocument() 104 | }) 105 | 106 | test('search bar filters tasks correctly', async ()=> { 107 | tasksApi().returnsTasks(mockTasksList); 108 | renderTasksPage() 109 | await tasksHaveLoaded() 110 | 111 | const searchBar = screen.getByRole('textbox') 112 | 113 | await userEvent.type(searchBar, "task1"); 114 | 115 | expect(screen.queryByText(/task1/)).toBeInTheDocument() 116 | expect(screen.queryByText(/task2/)).not.toBeInTheDocument() 117 | expect(screen.queryByText(/task3/)).not.toBeInTheDocument() 118 | }) 119 | 120 | test('search filter is case insensitive', async ()=> { 121 | tasksApi().returnsTasks(mockTasksList) 122 | renderTasksPage() 123 | await tasksHaveLoaded() 124 | 125 | const searchBar = screen.getByRole('textbox') 126 | 127 | await userEvent.type(searchBar, "tASk1"); 128 | 129 | expect(screen.queryByText(/task1/)).toBeInTheDocument() 130 | }) 131 | 132 | test('search filter performs partial matching', async ()=> { 133 | tasksApi().returnsTasks([{name: "ATaskWithAVeryVeryVeryLongName", taskId: "id", description: "description"}]) 134 | renderTasksPage() 135 | await tasksHaveLoaded() 136 | 137 | const searchBar = screen.getByRole('textbox') 138 | 139 | await userEvent.type(searchBar, "longname"); 140 | 141 | expect(screen.queryByText(/ATaskWithAVeryVeryVeryLongName/)).toBeInTheDocument() 142 | }) 143 | 144 | test('initial task sort order is ascending by name', async()=> { 145 | tasksApi().returnsTasks(mockTasksList) 146 | renderTasksPage() 147 | await tasksHaveLoaded() 148 | 149 | const tasks = screen.getAllByTestId(/.*-panel/); 150 | const task1Panel = screen.getByTestId(/task1-panel/); 151 | const task2Panel = screen.getByTestId(/task2-panel/); 152 | 153 | expect(tasks.indexOf(task1Panel)).toBeLessThan(tasks.indexOf(task2Panel)) 154 | screen.debug() 155 | }) 156 | 157 | test('user can select task sort order', async()=> { 158 | tasksApi().returnsTasks(mockTasksList) 159 | renderTasksPage() 160 | await tasksHaveLoaded() 161 | 162 | 163 | const task1Panel = screen.getByTestId(/task1-panel/); 164 | const task2Panel = screen.getByTestId(/task2-panel/); 165 | const sortOrderSelector = screen.getByRole('combobox') 166 | const ascOrderSortOption = within(sortOrderSelector).getByRole('option', {name: /a-z/i}); 167 | const descOrderSortOption = within(sortOrderSelector).getByRole('option', {name: /z-a/i}); 168 | 169 | await userEvent.selectOptions(sortOrderSelector, descOrderSortOption) 170 | 171 | //confirm descending sort order 172 | let tasks = screen.getAllByTestId(/.*-panel/); 173 | expect(tasks.indexOf(task1Panel)).toBeGreaterThan(tasks.indexOf(task2Panel)) 174 | 175 | await userEvent.selectOptions(sortOrderSelector, ascOrderSortOption) 176 | 177 | //confirm ascending sort order (user can switch back and forth 178 | tasks = screen.getAllByTestId(/.*-panel/); 179 | expect(tasks.indexOf(task1Panel)).toBeLessThan(tasks.indexOf(task2Panel)) 180 | }) 181 | 182 | test('each task has own details button', async ()=> { 183 | tasksApi().returnsTasks(mockTasksList) 184 | renderTasksPage() 185 | await tasksHaveLoaded() 186 | 187 | screen.getAllByTestId(/.*-panel/).forEach(task => { 188 | expect(within(task).queryByRole('button', {name:/detail/i})).toBeInTheDocument() 189 | }); 190 | }) 191 | 192 | test('get start button opens action select page', async ()=> { 193 | renderTasksPage() 194 | 195 | const getStartedButton = screen.getByRole('button', {name: /get started/i}) 196 | 197 | await userEvent.click(getStartedButton) 198 | 199 | expect(screen.queryByRole('heading', {name: /action catalog/i})).toBeInTheDocument() 200 | }) 201 | 202 | test('click task detail button opens correct details page', async ()=> { 203 | tasksApi().returnsTasks([{ 204 | name: "task1", 205 | taskId: "task1id", 206 | description: "task 1 description", 207 | },]) 208 | 209 | //necessary for taskdetails page 210 | server.use( 211 | rest.get("/tasks/task1id", (request, response, context) => { 212 | return response( 213 | context.status(200), 214 | context.json({ 215 | taskId: "task1id", 216 | name: "task1", 217 | }) 218 | ) 219 | }) 220 | ) 221 | renderTasksPage() 222 | await tasksHaveLoaded() 223 | 224 | const action1Panel = screen.getByTestId(/task1-panel/) 225 | const action1DetailsBtn = within(action1Panel).getByRole('button', {name:/detail/i}) 226 | await userEvent.click(action1DetailsBtn); 227 | 228 | expect(await screen.findByRole('heading', {name: /task detail/i})).toBeInTheDocument() 229 | expect(await screen.findByRole('heading', {name: /task1/i})).toBeInTheDocument() //requires refactoring of taskDetails to be testable 230 | 231 | }) 232 | 233 | 234 | 235 | test('shows error when tasks fail to load', async ()=> { 236 | tasksApi().returnsResponse((request, response, context) => { 237 | return response( 238 | context.status(500), 239 | context.json({ 240 | message: 'Error loading Actions' 241 | }) 242 | ) 243 | }) 244 | renderTasksPage(); 245 | 246 | expect(await screen.findByRole('alert')).toBeInTheDocument() 247 | }) 248 | 249 | test('shows loading bar while tasks are loading', async ()=> { 250 | tasksApi().returnsTasksAfterDelay(mockTasksList, 500) 251 | renderTasksPage() 252 | 253 | expect(await screen.findByRole('progressbar')).toBeInTheDocument() 254 | }) 255 | 256 | test('does not show loading bar when tasks have finished loading', async ()=> { 257 | tasksApi().returnsTasks(mockTasksList) 258 | renderTasksPage() 259 | await tasksHaveLoaded() 260 | 261 | expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() 262 | }) 263 | 264 | 265 | }) -------------------------------------------------------------------------------- /source/console/src/__tests__/Users.test.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import {server} from "./mocks/server" 7 | import {PathParams, ResponseResolver, rest, RestContext, RestRequest} from "msw"; 8 | import {render, screen, within} from "@testing-library/react"; 9 | import Users, {User} from "../views/Users"; 10 | import {MemoryRouter, Route, Switch} from "react-router-dom"; 11 | 12 | const mockUsers : User[] = [ 13 | { 14 | name: 'user1', 15 | username: 'username1', 16 | email: 'user1@example.com', 17 | group: 'Member', 18 | status: 'CONFIRMED' 19 | }, 20 | { 21 | name: 'user2', 22 | username: 'username2', 23 | email: 'user2@example.com', 24 | group: 'Member', 25 | status: 'CONFIRMED' 26 | }, 27 | { 28 | name: 'user3', 29 | username: 'username3', 30 | email: 'user3@example.com', 31 | group: 'Member', 32 | status: 'FORCE_CHANGE_PASSWORD' 33 | }, 34 | ] 35 | 36 | function usersApi() { 37 | return { 38 | returnsUsers: (users: User[]) => { 39 | server.use( 40 | rest.get("/users", (request, response, context) => { 41 | return response( 42 | context.status(200), 43 | context.json(users) 44 | ) 45 | }) 46 | ) 47 | }, 48 | returnsUsersAfterDelay: (users: User[], msDelay: number) => { 49 | server.use( 50 | rest.get("/users", (request, response, context) => { 51 | return response( 52 | context.delay(msDelay), 53 | context.status(200), 54 | context.json(users) 55 | ) 56 | }) 57 | ) 58 | }, 59 | returnsResponse: (resolver: ResponseResolver>, RestContext>) => { 60 | server.use( 61 | rest.get("/users", (request, response, context) => { 62 | return resolver(request, response, context); 63 | }) 64 | ) 65 | } 66 | } 67 | } 68 | 69 | function getApiToken() { 70 | return "" //no need for api token in these tests 71 | } 72 | function renderUsersPage() { 73 | return render( 74 | 75 | 76 | ()} /> 78 | 79 | 80 | ) 81 | } 82 | 83 | async function usersHaveLoaded() { 84 | await screen.findByRole('table'); 85 | } 86 | 87 | describe('Users page', ()=> { 88 | test('page loads', ()=> { 89 | renderUsersPage() 90 | expect(screen.getByRole('heading', {name:/Users/i})).toBeInTheDocument() 91 | }) 92 | }) 93 | 94 | -------------------------------------------------------------------------------- /source/console/src/__tests__/mocks/server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import {setupServer} from "msw/node"; 7 | 8 | export const server = setupServer() -------------------------------------------------------------------------------- /source/console/src/assets/css/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | @media (min-width: 1300px) { 7 | .container { 8 | width: 1270px; 9 | } 10 | } 11 | 12 | footer { 13 | position: absolute; 14 | left: 0; 15 | bottom: 0; 16 | width: 100%; 17 | } 18 | 19 | .main-wrapper { 20 | padding: 0 0 100px; 21 | position: relative; 22 | } 23 | 24 | .custom-navbar { 25 | margin: 0 0 0 0; 26 | background-color: white !important; 27 | border-color: white !important; 28 | } 29 | 30 | .execution-blue { 31 | color: blue; 32 | } 33 | 34 | .execution-green { 35 | color: green; 36 | } 37 | 38 | .execution-red { 39 | color: red; 40 | } 41 | 42 | .execution-link { 43 | cursor: pointer; 44 | color: #337ab7; 45 | } 46 | 47 | .execution-link:hover { 48 | color: #315e85; 49 | text-decoration: underline; 50 | } 51 | 52 | .step-failure-glyphicon { 53 | color: red; 54 | } 55 | 56 | .step-failure-p { 57 | color: #333; 58 | } 59 | 60 | textarea.form-control { 61 | height: 300px !important; 62 | resize: none !important; 63 | } 64 | 65 | code { 66 | color: #333; 67 | background-color: white; 68 | } -------------------------------------------------------------------------------- /source/console/src/components/CustomUtil.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | export const LOGGING_LEVEL = 'INFO'; 7 | -------------------------------------------------------------------------------- /source/console/src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as React from 'react'; 7 | import { Navbar, Nav, NavItem, Image} from 'react-bootstrap'; 8 | 9 | class Footer extends React.Component { 10 | render() { 11 | return ( 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 24 | 25 |
26 | ); 27 | } 28 | } 29 | 30 | export default Footer; -------------------------------------------------------------------------------- /source/console/src/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import React from 'react'; 7 | import ReactDOM from 'react-dom'; 8 | import AppWithAuth from './AppWithAuth'; 9 | 10 | import './assets/css/style.css'; 11 | import 'bootstrap/dist/css/bootstrap.min.css'; 12 | 13 | ReactDOM.render(, document.getElementById('root')); 14 | -------------------------------------------------------------------------------- /source/console/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | /// 7 | 8 | // See https://github.com/facebook/create-react-app/issues/6560 for why this file exists. -------------------------------------------------------------------------------- /source/console/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom' 6 | 7 | 8 | import {Amplify} from "@aws-amplify/core"; 9 | import {server} from "./__tests__/mocks/server"; 10 | 11 | 12 | 13 | beforeAll( async () => { 14 | Amplify.configure({ 15 | "API": { 16 | "endpoints": [ 17 | { 18 | "endpoint": "", // empty endpoint URL means the mock server is called 19 | "name": "operations-conductor-api" 20 | } 21 | ] 22 | } 23 | }) 24 | 25 | //returning server.listen's promise ensures setup waits for the server to be finished setting up 26 | return server.listen() 27 | }) -------------------------------------------------------------------------------- /source/console/src/views/Actions.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as React from 'react'; 7 | 8 | import API from '@aws-amplify/api'; 9 | import { Logger } from '@aws-amplify/core'; 10 | 11 | import { Grid, Row, Col, Button, ProgressBar, Table, PageHeader, Breadcrumb, BreadcrumbItem, Alert, 12 | Glyphicon, FormGroup, InputGroup, FormControl } from 'react-bootstrap'; 13 | import { LinkContainer } from 'react-router-bootstrap'; 14 | import { LOGGING_LEVEL} from '../components/CustomUtil'; 15 | 16 | // Properties 17 | interface IProps { 18 | history?: any; 19 | getApiToken: Function; 20 | } 21 | 22 | // States 23 | interface IState { 24 | token: string; 25 | actions: DisplayAction[]; 26 | sortDirection: SortDirection 27 | isLoading: boolean; 28 | error: string; 29 | } 30 | 31 | // Action interface 32 | export interface Action { 33 | name: string; 34 | owner: string; 35 | description: string; 36 | } 37 | interface DisplayAction extends Action{ 38 | visible?: boolean; 39 | } 40 | 41 | enum SortDirection { 42 | ASC, DESC 43 | } 44 | 45 | // External variables 46 | const LOGGER = new Logger('Actions', LOGGING_LEVEL); 47 | const API_NAME = 'operations-conductor-api'; 48 | 49 | class Actions extends React.Component { 50 | constructor(props: Readonly) { 51 | super(props); 52 | 53 | this.state = { 54 | token: '', 55 | actions: [], 56 | sortDirection: SortDirection.ASC, 57 | isLoading: false, 58 | error: '' 59 | }; 60 | } 61 | 62 | componentDidMount() { 63 | this.setApiToken().then(() => { 64 | this.getActions(); 65 | }).catch((error) => { 66 | this.handleError('Error occurred while setting API token', error); 67 | }); 68 | } 69 | 70 | // Sets API token 71 | setApiToken = async () => { 72 | let token = await this.props.getApiToken(); 73 | this.setState({ token }); 74 | }; 75 | 76 | // Gets actions 77 | getActions = async () => { 78 | this.setState({ 79 | isLoading: true, 80 | error: '', 81 | actions: [] 82 | }); 83 | 84 | let path = '/actions'; 85 | let params = { 86 | headers: { 87 | 'Authorization': this.state.token 88 | } 89 | }; 90 | 91 | try { 92 | let actions: DisplayAction[] = await API.get(API_NAME, path, params); 93 | for (let action of actions) { 94 | action.visible = true; 95 | } 96 | this.sortActions(actions, this.state.sortDirection) 97 | this.setState({ actions }); 98 | } catch (error) { 99 | this.handleError('Error occurred while getting list of actions.', error); 100 | } finally { 101 | this.setState({ isLoading: false }); 102 | } 103 | }; 104 | 105 | // Handles value changes 106 | handleSearch = (event: any) => { 107 | let keyword = event.target.value; 108 | let actions = this.state.actions; 109 | for (let action of actions) { 110 | if (keyword === '' || action.name.toLowerCase().indexOf(keyword.toLowerCase()) > -1) { 111 | action.visible = true; 112 | } else { 113 | action.visible = false; 114 | } 115 | } 116 | 117 | this.setState({ actions }); 118 | }; 119 | 120 | toggleSortDirection = () => { 121 | this.setState((prevState, props) => { 122 | const newSortDirection = prevState.sortDirection === SortDirection.ASC ? SortDirection.DESC : SortDirection.ASC 123 | const newlySortedActions = this.sortActions(prevState.actions, newSortDirection) 124 | 125 | return { 126 | sortDirection: newSortDirection, 127 | actions: newlySortedActions 128 | } 129 | }) 130 | }; 131 | 132 | sortActions = (actions: DisplayAction[], sortDirection: SortDirection) => { 133 | switch (sortDirection) { 134 | case SortDirection.ASC: return actions.sort((a: DisplayAction, b: DisplayAction) => a.name.localeCompare(b.name)); 135 | case SortDirection.DESC: return actions.sort((a: DisplayAction, b: DisplayAction) => b.name.localeCompare(a.name)); 136 | default: throw new Error("Invalid SortDirection") 137 | } 138 | 139 | } 140 | 141 | 142 | getSortIconName = () => { 143 | switch (this.state.sortDirection) { 144 | case SortDirection.ASC: return 'sort-by-attributes'; 145 | case SortDirection.DESC: return 'sort-by-attributes-alt'; 146 | } 147 | } 148 | 149 | 150 | // Handles error 151 | handleError = (message: string, error: any) => { 152 | if (error.response !== undefined) { 153 | LOGGER.error(message, error.response.data.message); 154 | this.setState({ error: error.response.data.message }); 155 | } else { 156 | LOGGER.error(message, error.message); 157 | this.setState({ error: error.message }); 158 | } 159 | }; 160 | 161 | render() { 162 | return ( 163 |
164 | 165 | 166 | 167 | 168 | 169 | Tasks 170 | 171 | Actions 172 | 173 | 174 | 175 | 176 | 177 | Action Catalog 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | { 211 | this.state.isLoading && 212 | 213 | 214 | 215 | } 216 | { 217 | this.state.actions.length === 0 && !this.state.isLoading && 218 | 219 | 220 | 221 | } 222 | { 223 | this.state.actions 224 | .filter((action: DisplayAction) => action.visible) 225 | .map((action: DisplayAction) => { 226 | return ( 227 | 228 | 229 | 230 | 231 | 235 | 236 | ); 237 | }) 238 | } 239 | 240 |
198 | Action Name 199 |   200 | 203 | OwnerDescriptionAction
Loading...
No action found.
{action.name}{action.owner}{action.description} 232 | 234 |
241 | 242 |
243 | { 244 | this.state.error && 245 | 246 | 247 | 248 | Error:
249 | {this.state.error} 250 |
251 | 252 |
253 | } 254 | { 255 | this.state.isLoading && 256 | 257 | 258 | 259 | 260 | 261 | } 262 |
263 |
264 | ); 265 | } 266 | } 267 | 268 | export default Actions; -------------------------------------------------------------------------------- /source/console/src/views/Tasks.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as React from 'react'; 7 | 8 | import API from '@aws-amplify/api'; 9 | import { Logger } from '@aws-amplify/core'; 10 | 11 | import { Grid, Row, Col, Button, PageHeader, Panel, Breadcrumb, BreadcrumbItem, Jumbotron, Alert, 12 | ProgressBar, FormGroup, FormControl, InputGroup, Glyphicon } from 'react-bootstrap'; 13 | import { LOGGING_LEVEL} from '../components/CustomUtil'; 14 | 15 | // Properties 16 | interface IProps { 17 | history?: any; 18 | getApiToken: Function; 19 | } 20 | 21 | // States 22 | interface IState { 23 | token: string; 24 | tasks: TaskDisplay[]; 25 | isLoading: boolean; 26 | error: string; 27 | } 28 | 29 | // Task 30 | export interface Task { 31 | taskId: string; 32 | name: string; 33 | description: string; 34 | } 35 | interface TaskDisplay extends Task { 36 | visible?: boolean; 37 | } 38 | 39 | // External variables 40 | const LOGGER = new Logger('Tasks', LOGGING_LEVEL); 41 | const API_NAME = 'operations-conductor-api'; 42 | 43 | class Tasks extends React.Component { 44 | constructor(props: Readonly) { 45 | super(props); 46 | 47 | this.state = { 48 | token: '', 49 | tasks: [], 50 | isLoading: true, 51 | error: '' 52 | }; 53 | } 54 | 55 | componentDidMount() { 56 | this.setApiToken().then(() => { 57 | this.getTasks(); 58 | }).catch((error) => { 59 | this.handleError('Error occurred while setting API token', error); 60 | }); 61 | } 62 | 63 | // Sets API token 64 | setApiToken = async () => { 65 | let token = await this.props.getApiToken(); 66 | this.setState({ token }); 67 | }; 68 | 69 | // Gets tasks 70 | getTasks = async () => { 71 | this.setState({ 72 | isLoading: true, 73 | error: '', 74 | tasks: [] 75 | }); 76 | 77 | let path = '/tasks'; 78 | let params = { 79 | headers: { 80 | 'Authorization': this.state.token 81 | } 82 | }; 83 | 84 | try { 85 | let tasks: TaskDisplay[] = await API.get(API_NAME, path, params); 86 | for (let task of tasks) { 87 | task.visible = true; 88 | } 89 | tasks.sort((a: TaskDisplay, b: TaskDisplay) => a.name.localeCompare(b.name)); 90 | this.setState({ tasks }); 91 | } catch (error) { 92 | this.handleError('Error occurred while getting list of tasks.', error); 93 | } finally { 94 | this.setState({ isLoading: false }); 95 | } 96 | }; 97 | 98 | // Handles value changes 99 | handleSearch = (event: any) => { 100 | let keyword = event.target.value; 101 | let tasks = this.state.tasks; 102 | for (let task of tasks) { 103 | if (keyword === '' || task.name.toLowerCase().indexOf(keyword.toLowerCase()) > -1) { 104 | task.visible = true; 105 | } else { 106 | task.visible = false; 107 | } 108 | } 109 | 110 | this.setState({ tasks }); 111 | }; 112 | handleSort = (event: any) => { 113 | let order = event.target.value; 114 | let tasks = this.state.tasks; 115 | if (order === 'asc') { 116 | tasks.sort((a: TaskDisplay, b: TaskDisplay) => a.name.localeCompare(b.name)); 117 | } else if (order === 'desc') { 118 | tasks.sort((a: TaskDisplay, b: TaskDisplay) => b.name.localeCompare(a.name)); 119 | } 120 | 121 | this.setState({ tasks }); 122 | }; 123 | 124 | // Handles error 125 | handleError = (message: string, error: any) => { 126 | if (error.response !== undefined) { 127 | LOGGER.error(message, error.response.data.message); 128 | this.setState({ error: error.response.data.message }); 129 | } else { 130 | LOGGER.error(message, error.message); 131 | this.setState({ error: error.message }); 132 | } 133 | }; 134 | 135 | render() { 136 | return ( 137 |
138 | 139 | 140 | 141 | 142 | Tasks 143 | 144 | 145 | 146 | 147 | 148 | My Tasks 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 |   159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | { 188 | this.state.tasks.length === 0 && !this.state.isLoading && 189 | 190 | 191 |

192 | No task found. 193 |

194 |
195 | 196 | } 197 | { 198 | this.state.tasks 199 | .filter((task: TaskDisplay) => task.visible) 200 | .map((task: TaskDisplay) => { 201 | return ( 202 | 203 | 204 | 205 | {task.name} 206 | 207 | 208 |
209 | {task.description} 210 |
211 | 212 |
213 |
214 | 215 | ); 216 | }) 217 | } 218 |
219 | { 220 | this.state.error && 221 | 222 | 223 | 224 | Error:
225 | {this.state.error} 226 |
227 | 228 |
229 | } 230 | { 231 | this.state.isLoading && 232 | 233 | 234 | 235 | 236 | 237 | } 238 |
239 |
240 | ); 241 | } 242 | } 243 | 244 | export default Tasks; -------------------------------------------------------------------------------- /source/console/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "preserve" 21 | }, 22 | "include": [ 23 | "src", 24 | "__tests__" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /source/services/actions/actions.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { Action } from './actions'; 7 | 8 | const mockAction = { 9 | name: 'OperationsConductor-MockAction', 10 | owner: 'owner-account-id', 11 | description: 'Mock document', 12 | parameters: [ 13 | { 14 | Name: 'ActionId', 15 | Type: 'String', 16 | Description: 'Action ID', 17 | DefaultValue: '' 18 | } 19 | ] 20 | }; 21 | const mockActions = [ 22 | { 23 | name: mockAction.name, 24 | owner: mockAction.owner, 25 | description: mockAction.description 26 | } 27 | ]; 28 | const mockDocument = { 29 | Document: { 30 | Hash: 'hash-value', 31 | HashType: 'Sha256', 32 | Name: mockAction.name, 33 | Owner: mockAction.owner, 34 | CreatedDate: '2019-08-06T04:38:05.499Z', 35 | Status: 'Active', 36 | DocumentVersion: '1', 37 | Description: mockAction.description, 38 | Parameters: mockAction.parameters, 39 | PlatformTypes: [ 'Windows', 'Linux' ], 40 | DocumentType: 'Automation', 41 | SchemaVersion: '0.3', 42 | LatestVersion: '1', 43 | DefaultVersion: '1', 44 | DocumentFormat: 'YAML', 45 | Tags: [] 46 | } 47 | }; 48 | const mockDocuments = { 49 | DocumentIdentifiers: [ 50 | { 51 | Name: mockDocument.Document.Name, 52 | Owner: mockDocument.Document.Owner, 53 | PlatformTypes: mockDocument.Document.PlatformTypes, 54 | DocumentVersion: mockDocument.Document.DocumentVersion, 55 | DocumentType: mockDocument.Document.DocumentType, 56 | SchemaVersion: mockDocument.Document.SchemaVersion, 57 | DocumentFormat: mockDocument.Document.DocumentFormat, 58 | Tags: [ 59 | { 60 | Key: 'SomeKey', 61 | Value: 'SomeValue' 62 | } 63 | ] 64 | } 65 | ] 66 | }; 67 | 68 | const mockSsm = jest.fn(); 69 | jest.mock('aws-sdk', () => { 70 | return { 71 | SSM: jest.fn(() => ({ 72 | listDocuments: mockSsm, 73 | describeDocument: mockSsm 74 | })) 75 | }; 76 | }); 77 | 78 | process.env.FilterTagKey = 'SomeKey'; 79 | process.env.FilterTagValue = 'SomeValue'; 80 | 81 | const action = new Action(); 82 | const InvalidDocument = { 83 | code: 'InvalidDocument', 84 | statusCode: 400, 85 | message: 'Document with name Invalid does not exist.' 86 | }; 87 | 88 | describe('Actions', () => { 89 | describe('getActions', () => { 90 | beforeEach(() => { 91 | mockSsm.mockReset(); 92 | }); 93 | 94 | test('returns a success response', (done) => { 95 | mockSsm.mockImplementationOnce((data) => { 96 | expect(data).toStrictEqual({"Filters":[{"Key":"tag:SomeKey","Values":["SomeValue"]},{"Key":"Owner","Values":["Self"]}]}) 97 | return { 98 | promise() { 99 | return Promise.resolve(mockDocuments); 100 | } 101 | }; 102 | }).mockImplementationOnce((data) => { 103 | return { 104 | promise() { 105 | return Promise.resolve(mockDocument); 106 | } 107 | }; 108 | }); 109 | 110 | action.getActions().then((data) => { 111 | expect(data).toEqual(mockActions); 112 | done(); 113 | }).catch((error) => { 114 | done(error); 115 | }); 116 | }); 117 | 118 | test('returns a success response with nextToken', (done) => { 119 | let documentsWithNextToken = mockDocuments; 120 | documentsWithNextToken['NextToken'] = 'token'; 121 | 122 | mockSsm.mockImplementationOnce((data) => { 123 | expect(data).toStrictEqual({"Filters":[{"Key":"tag:SomeKey","Values":["SomeValue"]},{"Key":"Owner","Values":["Self"]}]}) 124 | return { 125 | promise() { 126 | return Promise.resolve(documentsWithNextToken); 127 | } 128 | }; 129 | }).mockImplementationOnce(() => { 130 | return { 131 | promise() { 132 | return Promise.resolve(mockDocument); 133 | } 134 | }; 135 | }).mockImplementationOnce(() => { 136 | return { 137 | promise() { 138 | delete documentsWithNextToken['NextToken'] 139 | return Promise.resolve(documentsWithNextToken); 140 | } 141 | }; 142 | }).mockImplementationOnce(() => { 143 | return { 144 | promise() { 145 | return Promise.resolve(mockDocument); 146 | } 147 | }; 148 | });; 149 | 150 | action.getActions().then((data) => { 151 | expect(data).toEqual([...mockActions, ...mockActions]); 152 | done(); 153 | }).catch((error) => { 154 | done(error); 155 | }); 156 | }); 157 | 158 | test('returns an error when getting actions fails', (done) => { 159 | mockSsm.mockImplementation((data) => { 160 | expect(data).toStrictEqual({"Filters":[{"Key":"tag:SomeKey","Values":["SomeValue"]},{"Key":"Owner","Values":["Self"]}]}) 161 | return { 162 | promise() { 163 | return Promise.reject('error'); 164 | } 165 | }; 166 | }); 167 | 168 | action.getActions().then(() => { 169 | done('invalid failure for negative test'); 170 | }).catch((error) => { 171 | expect(error).toEqual({ 172 | code: 'GetActionsFailure', 173 | statusCode: 500, 174 | message: 'Error occurred while getting actions.' 175 | }); 176 | done(); 177 | }); 178 | }); 179 | }); 180 | 181 | describe('getAction', () => { 182 | beforeEach(() => { 183 | mockSsm.mockReset(); 184 | }); 185 | 186 | test('returns a success response', (done) => { 187 | mockSsm.mockImplementation(() => { 188 | return { 189 | promise() { 190 | return Promise.resolve(mockDocument); 191 | } 192 | }; 193 | }); 194 | 195 | action.getAction(mockAction.name).then((data) => { 196 | expect(data).toEqual(mockAction); 197 | done(); 198 | }).catch((error) => { 199 | done(error); 200 | }); 201 | }); 202 | 203 | test('returns an error when document does not exists', (done) => { 204 | mockSsm.mockImplementation(() => { 205 | return { 206 | promise() { 207 | return Promise.reject(InvalidDocument); 208 | } 209 | }; 210 | }); 211 | 212 | action.getAction(mockAction.name).then(() => { 213 | done('invalid failure for negative test'); 214 | }).catch((error) => { 215 | expect(error).toEqual({ 216 | code: 'GetActionFailure', 217 | statusCode: InvalidDocument.statusCode, 218 | message: InvalidDocument.message 219 | }); 220 | done(); 221 | }); 222 | }); 223 | 224 | test('returns an error when getting an action fails', (done) => { 225 | mockSsm.mockImplementation(() => { 226 | return { 227 | promise() { 228 | return Promise.reject('error'); 229 | } 230 | }; 231 | }); 232 | 233 | action.getAction(mockAction.name).then(() => { 234 | done('invalid failure for negative test'); 235 | }).catch((error) => { 236 | expect(error).toEqual({ 237 | code: 'GetActionFailure', 238 | statusCode: 500, 239 | message: 'Error occurred while getting an action.' 240 | }); 241 | done(); 242 | }); 243 | }); 244 | }); 245 | }); -------------------------------------------------------------------------------- /source/services/actions/actions.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as AWS from 'aws-sdk'; 7 | import { factory } from '../logger'; 8 | import { CommonUtil } from '../common/util'; 9 | import { ErrorReturn } from '../common/interfaces'; 10 | 11 | // Logger 12 | const LOGGER = factory.getLogger('actions.Action'); 13 | 14 | // Common util 15 | const COMMON_UTIL = new CommonUtil(); 16 | 17 | /** 18 | * @interface ActionInfo 19 | */ 20 | interface ActionInfo { 21 | name: string; 22 | owner: string; 23 | description: string; 24 | parameters?: object[]; 25 | } 26 | 27 | /** 28 | * Performs actions for any users 29 | * @class Action 30 | */ 31 | export class Action { 32 | // System Manager 33 | ssm: AWS.SSM; 34 | 35 | // Action document filter tag 36 | filterTagKey: string; 37 | filterTagValue: string; 38 | 39 | /** 40 | * @constructor 41 | */ 42 | constructor() { 43 | this.ssm = new AWS.SSM(); 44 | this.filterTagKey = process.env.FilterTagKey; 45 | this.filterTagValue = process.env.FilterTagValue; 46 | } 47 | 48 | /** 49 | * Gets actions - this filters AWS Systems Manager documents based on a tag provided during the launch 50 | */ 51 | async getActions(): Promise { 52 | let nextToken = 'nextToken'; 53 | 54 | try { 55 | let result = []; 56 | let params = { 57 | Filters: [ 58 | { 59 | Key: `tag:${this.filterTagKey}`, 60 | Values: [ 61 | this.filterTagValue 62 | ] 63 | }, 64 | { 65 | Key: `Owner`, 66 | Values: [ 67 | `Self` 68 | ] 69 | } 70 | ] 71 | }; 72 | while (nextToken) { 73 | let actions = await this.ssm.listDocuments(params).promise(); 74 | let documentIdentifiers = actions.DocumentIdentifiers; 75 | 76 | for (let documentIdentifier of documentIdentifiers) { 77 | let actionId = documentIdentifier.Name; 78 | let action: ActionInfo | ErrorReturn = await this.getAction(actionId); 79 | result.push({ 80 | name: actionId, 81 | owner: (action as ActionInfo).owner, 82 | description: (action as ActionInfo).description 83 | }); 84 | } 85 | nextToken = actions.NextToken; 86 | params['NextToken'] = nextToken; 87 | } 88 | 89 | return Promise.resolve(result); 90 | } catch (error) { 91 | LOGGER.error(`getActions Error: ${JSON.stringify(error)}`); 92 | return Promise.reject( 93 | COMMON_UTIL.getErrorObject('GetActionsFailure', 500, 'Error occurred while getting actions.', error) 94 | ); 95 | } 96 | } 97 | 98 | /** 99 | * Gets an action 100 | * @param {string} actionId - an action ID 101 | */ 102 | async getAction(actionId: string): Promise { 103 | const params = { 104 | Name: actionId 105 | }; 106 | 107 | try { 108 | let action = await this.ssm.describeDocument(params).promise(); 109 | let document = action.Document; 110 | 111 | return Promise.resolve({ 112 | name: document.Name, 113 | owner: document.Owner, 114 | description: document.Description, 115 | parameters: document.Parameters 116 | }); 117 | } catch (error) { 118 | LOGGER.error(`getAction Error: ${JSON.stringify(error)}`); 119 | return Promise.reject( 120 | COMMON_UTIL.getErrorObject('GetActionFailure', 500, 'Error occurred while getting an action.', error) 121 | ); 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /source/services/actions/app.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as express from 'express'; 7 | import * as bodyParser from 'body-parser'; 8 | import * as cors from 'cors'; 9 | import * as awsServerlessExpressMiddleware from 'aws-serverless-express/middleware'; 10 | import { factory } from '../logger'; 11 | import { Action } from './actions'; 12 | 13 | export const configureApp = () => { 14 | // Logger 15 | const logger = factory.getLogger('actions.App'); 16 | 17 | // Declares a new express app 18 | const app = express(); 19 | const router = express.Router(); 20 | 21 | router.use(cors({ 22 | origin: process.env.CorsAllowedOrigins 23 | })); 24 | router.use((req: any, res: any, next: any) => { 25 | bodyParser.json()(req, res, (err: any) => { 26 | if (err) { 27 | return res.status(400).json({ 28 | code: 400, 29 | error: 'BadRequest', 30 | message: err.message 31 | }); 32 | } 33 | next(); 34 | }); 35 | }); 36 | router.use(bodyParser.urlencoded({ extended: true })); 37 | router.use(awsServerlessExpressMiddleware.eventContext()); 38 | 39 | // Declares Action class 40 | const action = new Action(); 41 | 42 | // GET /actions 43 | router.get('/actions', async (req: any, res: any) => { 44 | logger.info('GET /actions'); 45 | try { 46 | const result = await action.getActions(); 47 | res.status(200).json(result); 48 | } catch (error) { 49 | logger.error(JSON.stringify(error)); 50 | res.status(error.statusCode).json(error); 51 | } 52 | }); 53 | 54 | // GET /actions/{actionId} 55 | router.get('/actions/:actionId', async (req: any, res: any) => { 56 | logger.info('GET /actions/:actionId'); 57 | const { actionId } = req.params; 58 | try { 59 | const result = await action.getAction(actionId); 60 | res.status(200).json(result); 61 | } catch (error) { 62 | logger.error(JSON.stringify(error)); 63 | res.status(error.statusCode).json(error); 64 | } 65 | }); 66 | 67 | app.use('/', router); 68 | 69 | return app; 70 | }; 71 | -------------------------------------------------------------------------------- /source/services/actions/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { Context } from 'aws-lambda'; 7 | import { createServer, proxy } from 'aws-serverless-express'; 8 | import { configureApp } from './app'; 9 | 10 | const app = configureApp(); 11 | const server = createServer(app); 12 | 13 | export const handler = (event: any, context: Context) => { 14 | proxy(server, event, context); 15 | }; 16 | -------------------------------------------------------------------------------- /source/services/common/interfaces.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | /** 7 | * The interface of error return 8 | * @interface ErrorReturn 9 | */ 10 | export interface ErrorReturn { 11 | code: string; 12 | statusCode: number; 13 | message: string; 14 | } 15 | 16 | /** 17 | * @interface MetricInfo 18 | */ 19 | export interface MetricInfo { 20 | Solution: string; 21 | Version: string; 22 | UUID: string; 23 | TimeStamp: string; 24 | Data: { 25 | EventType: string; 26 | EventData?: object; 27 | } 28 | } -------------------------------------------------------------------------------- /source/services/common/util.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { CommonUtil } from './util'; 7 | 8 | const COMMON_UTIL = new CommonUtil(); 9 | 10 | // TODO: make sure to test every edge case 11 | describe('CommonUtil', () => { 12 | describe('areNonEmptyStrings', () => { 13 | test('is true when passed non-empty strings', () => { 14 | expect(COMMON_UTIL.areNonEmptyStrings(["non", "empty"])).toBeTruthy() 15 | }); 16 | 17 | test('is false when an element is undefined', () => { 18 | expect(COMMON_UTIL.areNonEmptyStrings(["string", undefined])).toBeFalsy() 19 | }) 20 | 21 | test('is false when an element is empty', () => { 22 | expect(COMMON_UTIL.areNonEmptyStrings(["", "string"])).toBeFalsy() 23 | }) 24 | }); 25 | 26 | describe('isObjectEmpty', () => { 27 | test('success', (done) => { 28 | done(); 29 | }); 30 | }); 31 | 32 | describe('getErrorObject', () => { 33 | test('success', (done) => { 34 | done(); 35 | }); 36 | }); 37 | 38 | describe('sendAnonymousMetric', () => { 39 | test('success', (done) => { 40 | done(); 41 | }); 42 | }); 43 | }); -------------------------------------------------------------------------------- /source/services/common/util.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as moment from 'moment'; 7 | import { ErrorReturn, MetricInfo } from './interfaces'; 8 | import { factory } from '../logger'; 9 | import { Metrics } from '../metrics'; 10 | 11 | // Logger 12 | const LOGGER = factory.getLogger('common.CommonUtil'); 13 | 14 | /** 15 | * Util class for common usage 16 | * @class CommonUtil 17 | */ 18 | export class CommonUtil { 19 | /** 20 | * Checks if string values are not empty nor undefined 21 | * @param {string[]} values - string array 22 | */ 23 | areNonEmptyStrings(values: string[]): boolean { 24 | for (let value of values) { 25 | if (value === undefined) { 26 | return false; 27 | } 28 | 29 | if (typeof(value) !== 'string') { 30 | return false; 31 | } 32 | 33 | value = value.trim(); 34 | if (value === null || value === '') { 35 | return false; 36 | } 37 | } 38 | 39 | return true; 40 | } 41 | 42 | /** 43 | * Checks if object is empty 44 | * @param {object} object - object to check emptiness 45 | */ 46 | isObjectEmpty(object: object): boolean { 47 | for (let _key in object) { 48 | return false; 49 | } 50 | return true; 51 | } 52 | 53 | /** 54 | * Gets an error object 55 | * @param {string} code - error code 56 | * @param {number} statusCode - error status code 57 | * @param {string} message - error message 58 | * @param {any} error - (Optional) if error is provided, error would be returned 59 | */ 60 | getErrorObject(code: string, statusCode: number, message: string, error?: any): ErrorReturn { 61 | if (error !== undefined) { 62 | return { 63 | code: code, 64 | statusCode: error.statusCode !== undefined ? error.statusCode : statusCode, 65 | message: error.message !== undefined ? error.message : message 66 | }; 67 | } else { 68 | return { 69 | code: code, 70 | statusCode: statusCode, 71 | message: message 72 | }; 73 | } 74 | } 75 | 76 | /** 77 | * Sends anonymous metric 78 | * @param {string} solutionId - unique solution ID 79 | * @param {string} solutionVersion - solution version 80 | * @param {string} solutionUuid - uniquely launched solution UUID 81 | * @param {string} eventType - event type 82 | * @param {object} eventData - event data 83 | */ 84 | async sendAnonymousMetric(solutionId: string, solutionVersion: string, solutionUuid: string, eventType: string, eventData?: object) { 85 | const metrics = new Metrics(); 86 | let metric: MetricInfo = { 87 | Solution: solutionId, 88 | Version: solutionVersion, 89 | UUID: solutionUuid, 90 | TimeStamp: moment.utc().format('YYYY-MM-DD HH:mm:ss.S'), 91 | Data: { 92 | EventType: eventType 93 | } 94 | }; 95 | 96 | if (eventData) { 97 | metric.Data.EventData = eventData; 98 | } 99 | 100 | try { 101 | LOGGER.info(`Sending anonymous metric: ${JSON.stringify(metric)}`); 102 | let data = await metrics.sendAnonymousMetric(metric); 103 | LOGGER.info(`Metric send: ${data}`); 104 | } catch (error) { 105 | LOGGER.error(`Sending anonymous metric failed: ${JSON.stringify(metric)}`, error); 106 | } 107 | } 108 | 109 | /** 110 | * Trims strings in array 111 | * @param {string[]} strings 112 | */ 113 | trimStringInArray(strs: string[]): string[] { 114 | for (let i = 0, length = strs.length; i < length; i++) { 115 | strs[i] = strs[i].trimLeft().trimRight(); 116 | } 117 | return strs; 118 | } 119 | } -------------------------------------------------------------------------------- /source/services/custom-resource/CustomResourceRequests.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | export enum CustomResourceRequestType { 7 | CREATE = "Create", 8 | UPDATE = "Update", 9 | DELETE = "Delete" 10 | } 11 | 12 | export const noActionRequiredResponse = { 13 | Data: 'No action is needed.' 14 | } -------------------------------------------------------------------------------- /source/services/custom-resource/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { Context, Callback } from 'aws-lambda'; 7 | import * as url from 'url'; 8 | import * as requestPromise from 'request-promise'; 9 | import { factory } from '../logger'; 10 | import CopyWebsite from "./resource/CopyWebsite"; 11 | import PutWebsiteConfig from "./resource/PutWebsiteConfig"; 12 | import CreateDocuments from "./resource/CreateDocuments"; 13 | import UploadCloudFormationTemplates from "./resource/UploadCloudFormationTemplates"; 14 | import CreateLambdaEdge from "./resource/CreateLambdaEdge"; 15 | import CreateUserPoolClient from "./resource/CreateUserPoolClient"; 16 | import DeletePoolClient from "./resource/DeletePoolClient"; 17 | import CreateUuid from "./resource/CreateUuid"; 18 | import SendAnonymousMetric from "./resource/SendAnonymousMetric"; 19 | 20 | 21 | // Logger 22 | const LOGGER = factory.getLogger('custom-resource'); 23 | 24 | export const handler = async (event: any, context: Context, callback: Callback) => { 25 | LOGGER.info(`Received event: ${JSON.stringify(event, null, 2)}`); 26 | 27 | let responseData: any = { 28 | Data: 'No action is needed.' 29 | }; 30 | const requestType = event.RequestType; 31 | const resourceProps = event.ResourceProperties; 32 | 33 | try { 34 | switch (event.ResourceType) { 35 | case 'Custom::CopyWebsite': { 36 | responseData = await CopyWebsite(requestType, resourceProps); 37 | break; 38 | } 39 | case 'Custom::PutWebsiteConfig': { 40 | responseData = await PutWebsiteConfig(requestType, resourceProps); 41 | break; 42 | } 43 | case 'Custom::CreateDocuments': { 44 | responseData = await CreateDocuments(requestType, resourceProps); 45 | break; 46 | } 47 | case 'Custom::UploadCloudFormationTemplates': { 48 | responseData = await UploadCloudFormationTemplates(requestType, resourceProps); 49 | break; 50 | } 51 | case 'Custom::CreateLambdaEdge': { 52 | responseData = await CreateLambdaEdge(requestType, resourceProps); 53 | break; 54 | } 55 | case 'Custom::CreateUserPoolClient': { 56 | responseData = await CreateUserPoolClient(requestType, resourceProps); 57 | break; 58 | } 59 | case 'Custom::DeletePoolClient': { 60 | responseData = await DeletePoolClient(requestType, resourceProps); 61 | break; 62 | } 63 | case 'Custom::CreateUuid': { 64 | responseData = await CreateUuid(requestType, resourceProps); 65 | break; 66 | } 67 | case 'Custom::SendAnonymousMetric': { 68 | responseData = await SendAnonymousMetric(requestType, resourceProps); 69 | break; 70 | } 71 | } 72 | 73 | await sendResponse(event, callback, context.logStreamName, 'SUCCESS', responseData); 74 | 75 | } catch (error) { 76 | LOGGER.error(`Error occurred while ${event.RequestType}::${event.ResourceType}`, error); 77 | responseData = { 78 | Error: error.message 79 | }; 80 | await sendResponse(event, callback, context.logStreamName, 'FAILED', responseData); 81 | } 82 | }; 83 | 84 | /** 85 | * Sends a response to the pre-signed S3 URL 86 | * @param {any} event - Custom Resource event 87 | * @param {Function} callback - callback function 88 | * @param {string} logStreamName - CloudWatch logs stream 89 | * @param {string} responseStatus - response status 90 | * @param {any} responseData - response data 91 | */ 92 | const sendResponse = async (event: any, callback: Function, logStreamName: string, responseStatus: string, responseData: any) => { 93 | const responseBody = JSON.stringify({ 94 | Status: responseStatus, 95 | Reason: `${JSON.stringify(responseData)}`, 96 | PhysicalResourceId: logStreamName, 97 | StackId: event.StackId, 98 | RequestId: event.RequestId, 99 | LogicalResourceId: event.LogicalResourceId, 100 | Data: responseData 101 | }); 102 | 103 | LOGGER.info(`RESPONSE BODY:\n ${responseBody}`); 104 | let responseUrl = ''; 105 | if (event.ResponseURL.indexOf('https') > -1) { 106 | responseUrl = event.ResponseURL; 107 | } else { 108 | const parsedUrl = url.parse(event.ResponseURL); 109 | responseUrl = `https://${parsedUrl.hostname}${parsedUrl.path}`; 110 | } 111 | const options = { 112 | uri: responseUrl, 113 | port: 443, 114 | method: 'PUT', 115 | headers: { 116 | 'Contet-Type' :'', 117 | 'Content-Length': responseBody.length 118 | }, 119 | body: responseBody 120 | }; 121 | 122 | try { 123 | await requestPromise(options); 124 | LOGGER.info('Successfully sent stack response!'); 125 | callback(null, 'Successfully sent stack response!'); 126 | } catch (error) { 127 | LOGGER.error('Custom resource sendResponse error', error); 128 | callback(error); 129 | } 130 | }; -------------------------------------------------------------------------------- /source/services/custom-resource/lambda-edge/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | exports.handler = async (event, context, callback) => { 7 | const response = event.Records[0].cf.response; 8 | const headers = response.headers; 9 | 10 | headers['x-xss-protection'] = [ 11 | { 12 | key: 'X-XSS-Protection', 13 | value: '1; mode=block' 14 | } 15 | ]; 16 | headers['x-frame-options'] = [ 17 | { 18 | key: 'X-Frame-Options', 19 | value: 'DENY' 20 | } 21 | ]; 22 | headers['x-content-type-options'] = [ 23 | { 24 | key: 'X-Content-Type-Options', 25 | value: 'nosniff' 26 | } 27 | ]; 28 | headers['strict-transport-security'] = [ 29 | { 30 | key: 'Strict-Transport-Security', 31 | value: 'max-age=63072000; includeSubdomains; preload' 32 | } 33 | ]; 34 | headers['referrer-policy'] = [ 35 | { 36 | key: 'Referrer-Policy', 37 | value: 'same-origin' 38 | } 39 | ]; 40 | headers['content-security-policy'] = [ 41 | { 42 | key: 'Content-Security-Policy', 43 | value: "default-src 'none'; base-uri 'self'; img-src 'self'; script-src 'self'; style-src 'self' https:; object-src 'none'; frame-ancestors 'none'; font-src 'self' https:; form-action 'self'; manifest-src 'self'; connect-src 'self' *.amazonaws.com" 44 | 45 | }]; 46 | 47 | callback(null, response); 48 | }; -------------------------------------------------------------------------------- /source/services/custom-resource/resource/CopyWebsite.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as AWS from "aws-sdk"; 7 | import {factory} from "../../logger"; 8 | import {getContentType, sleep} from "../utils"; 9 | import {CustomResourceRequestType, noActionRequiredResponse} from "../CustomResourceRequests"; 10 | 11 | const LOGGER = factory.getLogger('custom-resource::copy-website'); 12 | 13 | export default async function (requestType: CustomResourceRequestType, resourceProps: any) { 14 | const { SourceS3Bucket, SourceS3Key, SourceManifest, DestinationS3Bucket } = resourceProps; 15 | 16 | try { 17 | switch (requestType) { 18 | case CustomResourceRequestType.CREATE: return await copyWebsite(SourceS3Bucket, SourceS3Key, SourceManifest, DestinationS3Bucket); 19 | case CustomResourceRequestType.UPDATE: return await copyWebsite(SourceS3Bucket, SourceS3Key, SourceManifest, DestinationS3Bucket); 20 | case CustomResourceRequestType.DELETE: return noActionRequiredResponse; 21 | } 22 | } catch (error) { 23 | LOGGER.error(`Copying website assets from ${SourceS3Bucket} to ${DestinationS3Bucket} failed.`, error); 24 | throw Error(`Copying website assets from ${SourceS3Bucket} to ${DestinationS3Bucket} failed.`); 25 | } 26 | 27 | 28 | } 29 | 30 | /** 31 | * Copies website assets to the destination bucket 32 | * @param {string} sourceBucket - source bucket name 33 | * @param {string} sourceKey - source object directory 34 | * @param {string} sourceManifest - website manifest 35 | * @param {string} destinationBucket - destination bucket name 36 | */ 37 | const copyWebsite = async (sourceBucket: string, sourceKey: string, sourceManifest: string, destinationBucket: string): Promise => { 38 | const s3 = new AWS.S3(); 39 | let manifest: { files: string[] } = { files: [] }; 40 | let retryCount = 3; 41 | 42 | // Gets manifest file 43 | for (let i = 1; i <= retryCount; i++) { 44 | try { 45 | LOGGER.info(`Getting manifest file... Try count: ${i}`); 46 | 47 | const params = { 48 | Bucket: sourceBucket, 49 | Key: `${sourceKey}/${sourceManifest}` 50 | }; 51 | let manifestData = await s3.getObject(params).promise(); 52 | manifest = JSON.parse(manifestData.Body.toString()); 53 | 54 | LOGGER.info('Getting manifest file completed.'); 55 | break; 56 | } catch (error) { 57 | // Retries 5 * i seconds later 58 | if (i === retryCount) { 59 | LOGGER.error('Error occurred while getting manifest file.', error); 60 | return Promise.reject(error); 61 | } else { 62 | LOGGER.info('Waiting for retry...'); 63 | await sleep(i); 64 | } 65 | } 66 | } 67 | 68 | // Gets web console assets 69 | LOGGER.info(`Copying ${manifest.files.length} asset(s) from ${sourceBucket}/${sourceKey}/console to ${destinationBucket}...`); 70 | for (let filename of manifest.files) { 71 | for (let i = 1; i <= retryCount; i++) { 72 | try { 73 | LOGGER.info(`Copying ${filename}...`); 74 | let copyParams: AWS.S3.CopyObjectRequest = { 75 | Bucket: destinationBucket, 76 | CopySource: `${sourceBucket}/${sourceKey}/${filename}`, 77 | Key: `${filename}`, 78 | ContentType: getContentType(filename) 79 | }; 80 | let result = await s3.copyObject(copyParams).promise(); 81 | LOGGER.info(JSON.stringify(result.CopyObjectResult)); 82 | 83 | break; 84 | } catch (error) { 85 | // Retries 5 * i seconds later 86 | if (i === retryCount) { 87 | LOGGER.error('Error occurred while copying website assets.', error); 88 | return Promise.reject(error); 89 | } else { 90 | LOGGER.info('Waiting for retry...'); 91 | await sleep(i); 92 | } 93 | } 94 | } 95 | } 96 | LOGGER.info('Copying asset(s) completed.'); 97 | 98 | return Promise.resolve({ 99 | Message: 'Copying website assets completed.' 100 | }); 101 | }; 102 | 103 | -------------------------------------------------------------------------------- /source/services/custom-resource/resource/CreateDocuments.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import {SSM} from "../ssm"; 7 | import {CustomResourceRequestType} from "../CustomResourceRequests"; 8 | 9 | const SSM_SLEEP_SECOND = 3; 10 | const SSM_DOCUMENTS_DIR = 'custom-resource/ssm/' 11 | 12 | export default async function handleRequest(requestType: CustomResourceRequestType, resourceProps: any) { 13 | switch (requestType) { 14 | case CustomResourceRequestType.CREATE: return await createSsmDocuments(resourceProps); 15 | case CustomResourceRequestType.UPDATE: return await updateSsmDocuments(resourceProps); 16 | case CustomResourceRequestType.DELETE: return await deleteSsmDocuments(resourceProps); 17 | 18 | } 19 | } 20 | 21 | async function createSsmDocuments(resourceProps: any) { 22 | const { StackName, FilterTagKey, FilterTagValue } = resourceProps; 23 | const ssm = new SSM(StackName, SSM_SLEEP_SECOND, FilterTagKey, FilterTagValue); 24 | let result = await ssm.createDocuments(SSM_DOCUMENTS_DIR, resourceProps); 25 | 26 | return { 27 | Message: result 28 | }; 29 | } 30 | 31 | async function deleteSsmDocuments(resourceProps: any) { 32 | const { StackName } = resourceProps; 33 | const ssm = new SSM(StackName, SSM_SLEEP_SECOND); 34 | let result = await ssm.deleteDocuments(SSM_DOCUMENTS_DIR); 35 | 36 | return { 37 | Message: result 38 | }; 39 | } 40 | 41 | async function updateSsmDocuments(resourceProps:any) { 42 | const { StackName } = resourceProps; 43 | const ssm = new SSM(StackName, SSM_SLEEP_SECOND); 44 | await ssm.deleteDocuments(SSM_DOCUMENTS_DIR); 45 | const { StackNameNew, FilterTagKey, FilterTagValue } = resourceProps; 46 | const ssmToCreate = new SSM(StackNameNew, SSM_SLEEP_SECOND, FilterTagKey, FilterTagValue); 47 | let createAgainResponse = await ssmToCreate.createDocuments(SSM_DOCUMENTS_DIR, resourceProps); 48 | 49 | return { 50 | Message: createAgainResponse 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /source/services/custom-resource/resource/CreateLambdaEdge.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as AWS from "aws-sdk"; 7 | import {factory} from "../../logger"; 8 | import {CustomResourceRequestType, noActionRequiredResponse} from "../CustomResourceRequests"; 9 | 10 | const LOGGER = factory.getLogger('custom-resource::lambda-edge'); 11 | 12 | export default async function handleRequest(requestType: CustomResourceRequestType, resourceProps: any) { 13 | switch (requestType) { 14 | case CustomResourceRequestType.CREATE: return await createLambdaEdge(resourceProps); 15 | case CustomResourceRequestType.UPDATE: return await updateLambdaEdge(resourceProps); 16 | case CustomResourceRequestType.DELETE: return noActionRequiredResponse 17 | } 18 | } 19 | 20 | async function createLambdaEdge(resourceProps: any) { 21 | LOGGER.info('Creating new Lambda Edge function in us-east-1') 22 | // Edge Lambda needs to be created in us-east-1. 23 | const lambda = new AWS.Lambda({ region: 'us-east-1' }); 24 | const { FunctionName, Role, Code } = resourceProps; 25 | let functionArn = ''; 26 | 27 | // Creates Edge Lambda 28 | try { 29 | let params: AWS.Lambda.CreateFunctionRequest = { 30 | Code, 31 | FunctionName, 32 | Role, 33 | Handler: 'index.handler', 34 | Runtime: 'nodejs16.x', 35 | Description: 'Operations Conductor Lambda Edge function', 36 | MemorySize: 128, 37 | Timeout: 15 38 | }; 39 | let result = await lambda.createFunction(params).promise(); 40 | functionArn = result.FunctionArn; 41 | } catch (error) { 42 | LOGGER.error('Creating Edge Lambda failed.', error); 43 | throw Error('Creating Edge Lambda failed.'); 44 | } 45 | 46 | return await publishLambdaEdge(lambda, functionArn) 47 | } 48 | 49 | async function updateLambdaEdge(resourceProps: any) { 50 | LOGGER.info('Updating Lambda Edge function in us-east-1') 51 | 52 | const lambda = new AWS.Lambda({ region: 'us-east-1' }) 53 | const { FunctionName, Role, Code } = resourceProps; 54 | let functionArn = ''; 55 | 56 | try { 57 | //update lambda function runtime from nodejs14 to nodejs16. 58 | let updateResult = await lambda.updateFunctionConfiguration({ 59 | FunctionName: FunctionName, 60 | Runtime: 'nodejs16.x' 61 | }).promise() 62 | functionArn = updateResult.FunctionArn; 63 | 64 | LOGGER.info("Lambda function " + FunctionName + ", updated runtime successfully, response: "+ JSON.stringify(updateResult)) 65 | } catch (error) { 66 | LOGGER.error('Lambda Edge update failed.', error); 67 | throw Error('Lambda Edge update failed.'); 68 | } 69 | 70 | return await publishLambdaEdge(lambda, functionArn) 71 | } 72 | 73 | async function publishLambdaEdge(lambda: AWS.Lambda, functionArn: string) { 74 | try { 75 | let isFunctionStateActive = false 76 | let retry = 0 77 | let delayinMilliseconds = 5000 78 | while (!isFunctionStateActive) { 79 | let response = await lambda.getFunctionConfiguration({ 80 | FunctionName: functionArn 81 | }).promise(); 82 | LOGGER.debug(`Response from get function configuration ${JSON.stringify(response)}`) 83 | if((response.State === 'Active' && response.LastUpdateStatus === 'Successful') || retry > 10) { 84 | isFunctionStateActive = true 85 | } else { 86 | await waitForTime(delayinMilliseconds) 87 | retry++ 88 | delayinMilliseconds += 5000; 89 | } 90 | } 91 | 92 | let params: AWS.Lambda.PublishVersionRequest = { 93 | FunctionName: functionArn 94 | }; 95 | 96 | let result = await lambda.publishVersion(params).promise(); 97 | 98 | return { 99 | FunctionArn: `${functionArn}:${result.Version}` 100 | }; 101 | } catch (error) { 102 | LOGGER.error('Publishing Edge Lambda version failed.', error); 103 | throw Error('Publishing Edge Lambda version failed.'); 104 | } 105 | } 106 | 107 | /** Function to add delay for waiting on process. 108 | * @param ms time in milliseconds 109 | */ 110 | const waitForTime = async (ms: number) => { 111 | return new Promise(resolve => setTimeout(resolve, ms)); 112 | }; 113 | 114 | -------------------------------------------------------------------------------- /source/services/custom-resource/resource/CreateUserPoolClient.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as AWS from "aws-sdk"; 7 | import {CustomResourceRequestType, noActionRequiredResponse} from "../CustomResourceRequests"; 8 | 9 | export default async function handleRequest(requestType: CustomResourceRequestType, resourceProps: any) { 10 | switch (requestType) { 11 | case CustomResourceRequestType.CREATE: return await createClient(resourceProps) 12 | case CustomResourceRequestType.UPDATE: return noActionRequiredResponse 13 | case CustomResourceRequestType.DELETE: return noActionRequiredResponse 14 | } 15 | } 16 | 17 | async function createClient(resourceProps: any) { 18 | let { ClientName, UserPoolId, RefreshTokenValidity, GenerateSecret, PreventUserExistenceErrors } = resourceProps; 19 | RefreshTokenValidity = parseInt(RefreshTokenValidity); 20 | GenerateSecret = GenerateSecret === 'true'; 21 | const cognitoIdentityServiceProvider = new AWS.CognitoIdentityServiceProvider(); 22 | const params: AWS.CognitoIdentityServiceProvider.CreateUserPoolClientRequest = { 23 | ClientName, 24 | UserPoolId, 25 | RefreshTokenValidity, 26 | GenerateSecret, 27 | PreventUserExistenceErrors 28 | }; 29 | 30 | let result = await cognitoIdentityServiceProvider.createUserPoolClient(params).promise(); 31 | 32 | return { 33 | ClientId: result.UserPoolClient.ClientId 34 | } 35 | } -------------------------------------------------------------------------------- /source/services/custom-resource/resource/CreateUuid.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as uuid from "uuid"; 7 | import {CustomResourceRequestType, noActionRequiredResponse} from "../CustomResourceRequests"; 8 | 9 | export default function handleRequest(requestType: CustomResourceRequestType, resourceProps: any) { 10 | switch (requestType) { 11 | case CustomResourceRequestType.CREATE: return createUUID() 12 | case CustomResourceRequestType.UPDATE: return noActionRequiredResponse 13 | case CustomResourceRequestType.DELETE: return noActionRequiredResponse 14 | } 15 | } 16 | 17 | function createUUID() { 18 | return { 19 | UUID: uuid.v4() 20 | }; 21 | } -------------------------------------------------------------------------------- /source/services/custom-resource/resource/DeletePoolClient.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as AWS from "aws-sdk"; 7 | import {CustomResourceRequestType, noActionRequiredResponse} from "../CustomResourceRequests"; 8 | 9 | export default async function handleRequest(requestType: CustomResourceRequestType, resourceProps: any) { 10 | switch (requestType) { 11 | case CustomResourceRequestType.CREATE: return noActionRequiredResponse 12 | case CustomResourceRequestType.UPDATE: return noActionRequiredResponse 13 | case CustomResourceRequestType.DELETE: return await deleteClient(resourceProps) 14 | } 15 | } 16 | 17 | async function deleteClient(resourceProps: any) { 18 | const { ClientId, UserPoolId } = resourceProps; 19 | const cognitoIdentityServiceProvider = new AWS.CognitoIdentityServiceProvider(); 20 | const params: AWS.CognitoIdentityServiceProvider.DeleteUserPoolClientRequest = { 21 | ClientId, 22 | UserPoolId 23 | }; 24 | 25 | await cognitoIdentityServiceProvider.deleteUserPoolClient(params).promise(); 26 | 27 | return { 28 | Message: 'UserPool client deleted' 29 | } 30 | } -------------------------------------------------------------------------------- /source/services/custom-resource/resource/PutWebsiteConfig.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import {factory} from "../../logger"; 7 | import {putObject} from "../utils"; 8 | import {CustomResourceRequestType, noActionRequiredResponse} from "../CustomResourceRequests"; 9 | 10 | const LOGGER = factory.getLogger('custom-resource:put-website-config'); 11 | 12 | export default async function handleRequest(requestType: CustomResourceRequestType, resourceProps: any) { 13 | switch (requestType) { 14 | case CustomResourceRequestType.CREATE: return await putWebsiteConfig(resourceProps) 15 | case CustomResourceRequestType.UPDATE: return await putWebsiteConfig(resourceProps) 16 | case CustomResourceRequestType.DELETE: return noActionRequiredResponse 17 | } 18 | } 19 | 20 | const webConfig = `'use strict'; 21 | const aws_exports = { 22 | "aws_project_region": "REGION", 23 | "aws_user_pools_id": "USER_POOLS_ID", 24 | "aws_user_pools_web_client_id": "USER_POOLS_WEB_CLIENT_ID", 25 | "oauth": {}, 26 | "aws_cloud_logic_custom": [ 27 | { 28 | "name": "operations-conductor-api", 29 | "endpoint": "API_ENDPOINT", 30 | "region": "REGION" 31 | } 32 | ] 33 | };`; 34 | 35 | async function putWebsiteConfig(resourceProps: any) { 36 | const { Region, S3Bucket, S3Key, ConfigItem } = resourceProps; 37 | try { 38 | let configFile = webConfig.replace(/REGION/g, Region) 39 | .replace('USER_POOLS_ID', ConfigItem.UserPoolsId) 40 | .replace('USER_POOLS_WEB_CLIENT_ID', ConfigItem.UserPoolsWebClientId) 41 | .replace('API_ENDPOINT', ConfigItem.Endpoint); 42 | 43 | return await putObject(S3Bucket, configFile, S3Key); 44 | } catch (error) { 45 | LOGGER.error(`Putting website config to ${S3Bucket} failed.`, error); 46 | throw Error(`Putting website config to ${S3Bucket} failed.`); 47 | } 48 | } -------------------------------------------------------------------------------- /source/services/custom-resource/resource/SendAnonymousMetric.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as moment from "moment/moment"; 7 | import {Metrics} from "../../metrics"; 8 | import {factory} from "../../logger"; 9 | import {CustomResourceRequestType, noActionRequiredResponse} from "../CustomResourceRequests"; 10 | 11 | const LOGGER = factory.getLogger('custom-resource:anonymous-metric'); 12 | 13 | export default async function handleRequest(requestType: CustomResourceRequestType, resourceProps: any) { 14 | if(resourceProps.SendAnonymousUsageData === 'Yes') { 15 | return await sendAnonymousMetric(requestType, resourceProps) 16 | } else { 17 | return noActionRequiredResponse; 18 | } 19 | } 20 | 21 | async function sendAnonymousMetric(requestType: CustomResourceRequestType, resourceProps: any) { 22 | let metric = { 23 | Solution: resourceProps.SolutionId, 24 | Version: resourceProps.SolutionVersion, 25 | UUID: resourceProps.SolutionUuid, 26 | Timestamp: moment.utc().format('YYYY-MM-DD HH:mm:ss.S'), 27 | Data: { 28 | EventType: metricEventTypeFromRequestType(requestType) 29 | } 30 | }; 31 | 32 | const metrics = new Metrics(); 33 | try { 34 | let data = await metrics.sendAnonymousMetric(metric); 35 | return { 36 | Message: data 37 | }; 38 | } catch (error) { 39 | LOGGER.error('Sending anonymous metric failed.', error); 40 | throw Error('Sending anonymous launch metric failed.'); 41 | } 42 | } 43 | 44 | function metricEventTypeFromRequestType(requestType: CustomResourceRequestType) { 45 | switch (requestType) { 46 | case CustomResourceRequestType.CREATE: return 'SolutionLaunched' 47 | case CustomResourceRequestType.UPDATE: return 'SolutionUpdated' 48 | case CustomResourceRequestType.DELETE: return 'SolutionDeleted' 49 | } 50 | } -------------------------------------------------------------------------------- /source/services/custom-resource/resource/UploadCloudFormationTemplates.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import {putObject, sleep} from "../utils"; 7 | import {factory} from "../../logger"; 8 | import * as fs from "fs"; 9 | import * as path from "path"; 10 | import {CustomResourceRequestType, noActionRequiredResponse} from "../CustomResourceRequests"; 11 | 12 | const LOGGER = factory.getLogger('custom-resource:cloud-formation-templates'); 13 | 14 | export default async function handleRequest(requestType: CustomResourceRequestType, resourceProps: any) { 15 | switch (requestType) { 16 | case CustomResourceRequestType.CREATE: return await uploadCloudFormationTemplates(resourceProps) 17 | case CustomResourceRequestType.UPDATE: return await uploadCloudFormationTemplates(resourceProps) 18 | case CustomResourceRequestType.DELETE: return noActionRequiredResponse 19 | } 20 | } 21 | 22 | async function uploadCloudFormationTemplates(resourceProps: any) { 23 | const { StackName, MasterAccount, CloudFormationBucket, 24 | ResourceSelectorExecutionRoleArn, DocumentRoleArns, SolutionVersion } = resourceProps; 25 | 26 | 27 | try { 28 | // Gets document directories except Automation-Shared 29 | let mainDirectory = 'custom-resource/ssm/'; 30 | let directories: string[] = fs.readdirSync(mainDirectory) 31 | .map((file: string) => path.join(mainDirectory, file)) 32 | .filter((file: string) => fs.lstatSync(file).isDirectory()); 33 | 34 | LOGGER.info(`Uploading ${directories.length} CloudFormation template(s)...`); 35 | for (let directory of directories) { 36 | LOGGER.info(`Processing directory '${directory}'...`); 37 | 38 | let template = fs.readFileSync(`${directory}/cloudformation.template`, 'utf8'); 39 | let actionName = directory.replace(mainDirectory, ''); 40 | template = template.replace('%%RESOURCE_SELECTOR_EXECUTION_ROLE_ARN%%', ResourceSelectorExecutionRoleArn) 41 | .replace('%%MASTER_ACCOUNT%%', MasterAccount) 42 | .replace('%%VERSION%%', SolutionVersion); 43 | 44 | for (let key in DocumentRoleArns) { 45 | template = template.replace(`%%${key}%%`, DocumentRoleArns[key]); 46 | } 47 | 48 | // Upload to S3 49 | let retryCount = 3; 50 | for (let i = 1; i < retryCount; i++) { 51 | try { 52 | let result = await putObject(CloudFormationBucket, template, `${StackName}-${actionName}/cloudformation.template`, true); 53 | LOGGER.info(JSON.stringify(result)); 54 | break; 55 | } catch (error) { 56 | // Retries 5 * i seconds later 57 | if (i === retryCount) { 58 | LOGGER.error('Error occurred while uploading CloudFormation template.', error); 59 | return Promise.reject(error); 60 | } else { 61 | LOGGER.info('Waiting for retry...'); 62 | await sleep(i); 63 | } 64 | } 65 | } 66 | } 67 | } catch (error) { 68 | LOGGER.error(`Uploading CloudFormation templates to ${CloudFormationBucket} failed.`, error); 69 | throw Error(`Uploading CloudFormation templates to ${CloudFormationBucket} failed.`); 70 | } 71 | } 72 | 73 | -------------------------------------------------------------------------------- /source/services/custom-resource/ssm/OperationsConductor-CopySnapshot/cloudformation.template: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). 4 | # You may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License is located at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | AWSTemplateFormatVersion: "2010-09-09" 16 | Description: "(SO0065) - Operations Conductor CopySnapshot for cross accounts/regions. Version %%VERSION%%" 17 | 18 | Mappings: 19 | MasterAccount: 20 | ResourceSelectorExecutionRole: 21 | Name: "%%RESOURCE_SELECTOR_EXECUTION_ROLE_ARN%%" 22 | DocumentAssumeRole: 23 | Name: "%%OperationsConductorSharedRoleArn%%" 24 | Account: 25 | Id: "%%MASTER_ACCOUNT%%" 26 | 27 | Resources: 28 | IAMRole: 29 | Type: AWS::IAM::Role 30 | Description: "Role to allow master account to perform actions" 31 | Properties: 32 | RoleName: !Sub "${AWS::AccountId}-${AWS::Region}-%%TASK_ID%%" 33 | AssumeRolePolicyDocument: 34 | Version: "2012-10-17" 35 | Statement: 36 | - 37 | Effect: "Allow" 38 | Principal: 39 | AWS: 40 | - !FindInMap ["MasterAccount", "Account", "Id"] 41 | Action: 42 | - "sts:AssumeRole" 43 | Path: "/" 44 | Policies: 45 | - 46 | PolicyName: "OperationsConductor-CopySnapshot" 47 | PolicyDocument: 48 | Version: "2012-10-17" 49 | Statement: 50 | - 51 | Effect: "Allow" 52 | Action: 53 | - "ec2:CopySnapshot" 54 | - "ec2:DescribeSnapshots" 55 | - "ec2:CreateTags" 56 | - "ec2:ModifySnapshotAttribute" 57 | - "tag:GetResources" 58 | Resource: 59 | - "*" 60 | - 61 | Effect: "Allow" 62 | Action: 63 | - "iam:PassRole" 64 | Resource: 65 | - !FindInMap ["MasterAccount", "ResourceSelectorExecutionRole", "Name"] 66 | - !FindInMap ["MasterAccount", "DocumentAssumeRole", "Name"] 67 | Metadata: 68 | cfn_nag: 69 | rules_to_suppress: 70 | - 71 | id: W11 72 | reason: "The * resource allows the master account of Operations Conductor to create EC2 snapshots." 73 | - 74 | id: W28 75 | reason: "The role name is intentional to assume role on master account and region." 76 | -------------------------------------------------------------------------------- /source/services/custom-resource/ssm/OperationsConductor-CreateSnapshot/automation_document.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | description: "(Operations Conductor) Creates an EBS Volume Snapshot" 3 | schemaVersion: "0.3" 4 | 5 | assumeRole: "%%OperationsConductorSharedRoleArn%%" 6 | 7 | parameters: 8 | SQSMsgBody: 9 | type: "String" 10 | description: "JSON Stringified version of the message body that was read off the Resource Queue" 11 | SQSMsgReceiptHandle: 12 | type: "String" 13 | description: "Receipt handle of the SQS message that was read off the queue" 14 | TargetResourceType: 15 | type: "String" 16 | description: "The AWS resource type for which this automation applies. The format of this value should be: service:resourceType" 17 | default: "ec2:volume" 18 | CopyVolumeTags: 19 | type: "String" 20 | description: "(Optional) Supply 'true' to copy the tags on the Volume to the Snapshot. Any other value will be treated as false." 21 | default: "false" 22 | 23 | mainSteps: 24 | - name: "VALIDATE_MSG_CONTENTS" 25 | action: aws:executeScript 26 | timeoutSeconds: 30 27 | description: "Validates contents of the message from the Resource Queue and parses out parameters" 28 | inputs: 29 | Runtime: python3.6 30 | Handler: script_handler 31 | InputPayload: 32 | SQSMsgBody: "{{ SQSMsgBody }}" 33 | CopyVolumeTags: "{{ CopyVolumeTags }}" 34 | Script: |- 35 | import boto3 36 | import json 37 | def script_handler(events, context): 38 | output = { "statusCode": 200 } 39 | sqs_msg_body = json.loads(events["SQSMsgBody"]) 40 | 41 | if "ResourceId" not in sqs_msg_body: 42 | raise Exception("ResourceId was not found in the SQS Message Body.") 43 | 44 | output["resource_id"] = sqs_msg_body["ResourceId"] 45 | 46 | if "ResourceRegion" not in sqs_msg_body: 47 | raise Exception("ResourceRegion was not found in the SQS Message Body.") 48 | output["source_region"] = sqs_msg_body["ResourceRegion"] 49 | 50 | if "ResourceAccount" not in sqs_msg_body: 51 | raise Exception("ResourceAccount was not found in the SQS Message Body.") 52 | output["source_account_id"] = sqs_msg_body["ResourceAccount"] 53 | 54 | if "TargetTag" not in sqs_msg_body: 55 | raise Exception("TargetTag was not found in the SQS Message Body.") 56 | output["target_tag_name"] = sqs_msg_body["TargetTag"] 57 | 58 | if "TaskId" not in sqs_msg_body: 59 | raise Exception("TaskId was not found in the SQS Message Body.") 60 | output["task_id"] = sqs_msg_body["TaskId"] 61 | 62 | if "ParentExecutionId" not in sqs_msg_body: 63 | raise Exception("ParentExecutionId was not found in the SQS Message Body.") 64 | output["parent_execution_id"] = sqs_msg_body["ParentExecutionId"] 65 | 66 | output["copy_volume_tags"] = events["CopyVolumeTags"].strip().lower() 67 | 68 | return output 69 | outputs: 70 | - Name: "resource_id" 71 | Selector: "$.Payload.resource_id" 72 | Type: "String" 73 | - Name: "source_region" 74 | Selector: "$.Payload.source_region" 75 | Type: "String" 76 | - Name: "source_account_id" 77 | Selector: "$.Payload.source_account_id" 78 | Type: "String" 79 | - Name: "target_tag_name" 80 | Selector: "$.Payload.target_tag_name" 81 | Type: "String" 82 | - Name: "task_id" 83 | Selector: "$.Payload.task_id" 84 | Type: "String" 85 | - Name: "parent_execution_id" 86 | Selector: "$.Payload.parent_execution_id" 87 | Type: "String" 88 | - Name: "copy_volume_tags" 89 | Selector: "$.Payload.copy_volume_tags" 90 | Type: "String" 91 | - name: "CREATE_PERFORM_ACTION_AUTOMATION_EXECUTION_RECORD" 92 | action: aws:executeAwsApi 93 | timeoutSeconds: 30 94 | description: "Creates a record of this automation execution in the Operations Conductor Automation Executions Table" 95 | inputs: { 96 | "Service": "dynamodb", 97 | "Api": "PutItem", 98 | "TableName": "%%AutomationExecutionsTableName%%", 99 | "Item": { 100 | "parentExecutionId": { "S": "{{ VALIDATE_MSG_CONTENTS.parent_execution_id }}" }, 101 | "automationExecutionId": { "S": "{{ automation:EXECUTION_ID }}" }, 102 | "status": { "S": "InProgress" } 103 | } 104 | } 105 | - name: "PERFORM_ACTION_ON_RESOURCE" 106 | action: aws:executeScript 107 | timeoutSeconds: 30 108 | description: "Creates a snapshot of the Volume matching the ARN that was included in the SQSMsgBody" 109 | inputs: 110 | Runtime: python3.6 111 | Handler: script_handler 112 | InputPayload: 113 | source_account_id: "{{ VALIDATE_MSG_CONTENTS.source_account_id }}" 114 | source_region: "{{ VALIDATE_MSG_CONTENTS.source_region }}" 115 | resource_id: "{{ VALIDATE_MSG_CONTENTS.resource_id }}" 116 | target_tag_name: "{{ VALIDATE_MSG_CONTENTS.target_tag_name }}" 117 | copy_volume_tags: "{{ VALIDATE_MSG_CONTENTS.copy_volume_tags }}" 118 | task_id: "{{ VALIDATE_MSG_CONTENTS.task_id }}" 119 | Script: |- 120 | import boto3 121 | import json 122 | def script_handler(events, context): 123 | task_id = events["task_id"] 124 | source_account_id = events["source_account_id"] 125 | source_region = events["source_region"] 126 | 127 | # Assume role in source account 128 | sts_connection = boto3.client('sts') 129 | assumed_role = sts_connection.assume_role( 130 | RoleArn=f"arn:aws:iam::{source_account_id}:role/{source_account_id}-{source_region}-{task_id}", 131 | RoleSessionName="ops_conductor_create_snapshot" 132 | ) 133 | 134 | # Look up the Volume by ID and make sure it is still tagged correctly 135 | ec2_client = boto3.client( 136 | 'ec2', 137 | region_name=source_region, 138 | aws_access_key_id=assumed_role['Credentials']['AccessKeyId'], 139 | aws_secret_access_key=assumed_role['Credentials']['SecretAccessKey'], 140 | aws_session_token=assumed_role['Credentials']['SessionToken'] 141 | ) 142 | 143 | desc_volumes_response = ec2_client.describe_volumes(VolumeIds=[events["resource_id"]]) 144 | 145 | if len(desc_volumes_response["Volumes"]) == 0: 146 | raise Exception(f"Volume ({ events['resource_id'] }) was not found.") 147 | elif len(desc_volumes_response["Volumes"]) > 1: 148 | raise Exception(f"More than one volume was returned when looking for Volume ID ({ events['resource_id'] })") 149 | 150 | tag_found = False 151 | volume_tags = desc_volumes_response["Volumes"][0]["Tags"] 152 | for tag in volume_tags: 153 | if tag["Key"] == events["target_tag_name"]: 154 | tag_found = True 155 | break 156 | 157 | if not tag_found: 158 | raise Exception(f"Volume ({ events['resource_id'] }) was found but it was not tagged with { events['target_tag_name'] }.") 159 | 160 | create_params = { 161 | "Description": f"Snapshot of Volume ({ events['resource_id'] }). Created by Operations Conductor", 162 | "VolumeId": events["resource_id"] 163 | } 164 | 165 | print(f"Volume ({ events['resource_id'] }) is still tagged with { events['target_tag_name'] }. Creating snapshot") 166 | if events["copy_volume_tags"] == "true": 167 | print("Also copying Volume tags to Snapshot") 168 | create_params["TagSpecifications"] = [ 169 | { 170 | "ResourceType": "snapshot", 171 | "Tags": [] 172 | } 173 | ] 174 | 175 | for tag in volume_tags: 176 | if not tag["Key"].startswith("aws:"): 177 | create_params["TagSpecifications"][0]["Tags"].append( 178 | { 179 | "Key": tag["Key"], 180 | "Value": tag["Value"] 181 | } 182 | ) 183 | 184 | snapshot = ec2_client.create_snapshot(**create_params) 185 | 186 | print(f"Snapshot ({snapshot['SnapshotId']}) was created") 187 | 188 | return { 'statusCode': 200 } 189 | - name: "REMOVE_MSG_FROM_RESOURCE_QUEUE" 190 | action: "aws:executeAwsApi" 191 | inputs: { 192 | "Service": "sqs", 193 | "Api": "DeleteMessage", 194 | "QueueUrl": "%%ResourceQueueUrl%%", 195 | "ReceiptHandle": "{{ SQSMsgReceiptHandle }}" 196 | } 197 | - name: "UPDATE_AUTOMATION_EXECUTION_RECORD" 198 | action: aws:executeAwsApi 199 | timeoutSeconds: 30 200 | description: "Updates the record of this automation execution in the Operations Conductor Automation Executions Table to mark it as successfully completed" 201 | inputs: { 202 | "Service": "dynamodb", 203 | "Api": "UpdateItem", 204 | "TableName": "%%AutomationExecutionsTableName%%", 205 | "Key": { 206 | "parentExecutionId": { "S": "{{ VALIDATE_MSG_CONTENTS.parent_execution_id }}" }, 207 | "automationExecutionId": { "S": "{{ automation:EXECUTION_ID }}" } 208 | }, 209 | "UpdateExpression": "SET #stat = :val1", 210 | "ExpressionAttributeNames": { 211 | "#stat": "status" 212 | }, 213 | "ExpressionAttributeValues": { 214 | ":val1": { "S": "Success" } 215 | } 216 | } 217 | - name: "UPDATE_TASK_EXECUTIONS_RECORD" 218 | action: aws:executeAwsApi 219 | timeoutSeconds: 30 220 | description: "Updates the record for the overall execution of the Operations Conductor Task that spawned this automation" 221 | inputs: { 222 | "Service": "dynamodb", 223 | "Api": "UpdateItem", 224 | "TableName": "%%TaskExecutionsTableName%%", 225 | "Key": { 226 | "taskId": { "S": "{{ VALIDATE_MSG_CONTENTS.task_id }}" }, 227 | "parentExecutionId": { "S": "{{ VALIDATE_MSG_CONTENTS.parent_execution_id }}" } 228 | }, 229 | "UpdateExpression": "SET completedResourceCount = completedResourceCount + :incr, lastUpdateTime = :uptime", 230 | "ExpressionAttributeValues": { 231 | ":incr": { "N": "1" }, 232 | ":uptime": { "S": "{{ global:DATE_TIME }}" } 233 | } 234 | } 235 | - name: "CHECK_FOR_TASK_EXECUTION_COMPLETION" 236 | action: aws:executeAwsApi 237 | timeoutSeconds: 30 238 | description: "Marks the overall task execution as Success if all resources have been successfully acted on" 239 | inputs: { 240 | "Service": "dynamodb", 241 | "Api": "UpdateItem", 242 | "TableName": "%%TaskExecutionsTableName%%", 243 | "Key": { 244 | "taskId": { "S": "{{ VALIDATE_MSG_CONTENTS.task_id }}" }, 245 | "parentExecutionId": { "S": "{{ VALIDATE_MSG_CONTENTS.parent_execution_id }}" } 246 | }, 247 | "UpdateExpression": "SET #s = :stat", 248 | "ConditionExpression": "completedResourceCount = totalResourceCount", 249 | "ExpressionAttributeNames": { 250 | "#s": "status" 251 | }, 252 | "ExpressionAttributeValues": { 253 | ":stat": { "S": "Success" } 254 | } 255 | } 256 | onFailure: Continue 257 | isEnd: true 258 | -------------------------------------------------------------------------------- /source/services/custom-resource/ssm/OperationsConductor-CreateSnapshot/cloudformation.template: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). 4 | # You may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License is located at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | AWSTemplateFormatVersion: "2010-09-09" 16 | Description: "(SO0065) - Operations Conductor CreateSnapshot for cross accounts/regions. Version %%VERSION%%" 17 | 18 | Mappings: 19 | MasterAccount: 20 | ResourceSelectorExecutionRole: 21 | Name: "%%RESOURCE_SELECTOR_EXECUTION_ROLE_ARN%%" 22 | DocumentAssumeRole: 23 | Name: "%%OperationsConductorSharedRoleArn%%" 24 | Account: 25 | Id: "%%MASTER_ACCOUNT%%" 26 | 27 | Resources: 28 | IAMRole: 29 | Type: AWS::IAM::Role 30 | Description: "Role to allow master account to perform actions" 31 | Properties: 32 | RoleName: !Sub "${AWS::AccountId}-${AWS::Region}-%%TASK_ID%%" 33 | AssumeRolePolicyDocument: 34 | Version: "2012-10-17" 35 | Statement: 36 | - 37 | Effect: "Allow" 38 | Principal: 39 | AWS: 40 | - !FindInMap ["MasterAccount", "Account", "Id"] 41 | Action: 42 | - "sts:AssumeRole" 43 | Path: "/" 44 | Policies: 45 | - 46 | PolicyName: "OperationsConductor-CreateSnapshot" 47 | PolicyDocument: 48 | Version: "2012-10-17" 49 | Statement: 50 | - 51 | Effect: "Allow" 52 | Action: 53 | - "ec2:DescribeInstances" 54 | - "ec2:DescribeVolumes" 55 | - "ec2:CreateSnapshot" 56 | - "ec2:DescribeSnapshots" 57 | - "ec2:CreateTags" 58 | - "tag:GetResources" 59 | Resource: 60 | - "*" 61 | - 62 | Effect: "Allow" 63 | Action: 64 | - "iam:PassRole" 65 | Resource: 66 | - !FindInMap ["MasterAccount", "ResourceSelectorExecutionRole", "Name"] 67 | - !FindInMap ["MasterAccount", "DocumentAssumeRole", "Name"] 68 | Metadata: 69 | cfn_nag: 70 | rules_to_suppress: 71 | - 72 | id: W11 73 | reason: "The * resource allows the master account of Operations Conductor to create EC2 snapshots." 74 | - 75 | id: W28 76 | reason: "The role name is intentional to assume role on master account and region." 77 | -------------------------------------------------------------------------------- /source/services/custom-resource/ssm/OperationsConductor-DeleteSnapshot/cloudformation.template: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). 4 | # You may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License is located at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | AWSTemplateFormatVersion: "2010-09-09" 16 | Description: "(SO0065) - Operations Conductor DeleteSnapshot for cross accounts/regions. Version %%VERSION%%" 17 | 18 | Mappings: 19 | MasterAccount: 20 | ResourceSelectorExecutionRole: 21 | Name: "%%RESOURCE_SELECTOR_EXECUTION_ROLE_ARN%%" 22 | DocumentAssumeRole: 23 | Name: "%%OperationsConductorSharedRoleArn%%" 24 | Account: 25 | Id: "%%MASTER_ACCOUNT%%" 26 | 27 | Resources: 28 | IAMRole: 29 | Type: AWS::IAM::Role 30 | Description: "Role to allow master account to perform actions" 31 | Properties: 32 | RoleName: !Sub "${AWS::AccountId}-${AWS::Region}-%%TASK_ID%%" 33 | AssumeRolePolicyDocument: 34 | Version: "2012-10-17" 35 | Statement: 36 | - 37 | Effect: "Allow" 38 | Principal: 39 | AWS: 40 | - !FindInMap ["MasterAccount", "Account", "Id"] 41 | Action: 42 | - "sts:AssumeRole" 43 | Path: "/" 44 | Policies: 45 | - 46 | PolicyName: "OperationsConductor-DeleteSnapshot" 47 | PolicyDocument: 48 | Version: "2012-10-17" 49 | Statement: 50 | - 51 | Effect: "Allow" 52 | Action: 53 | - "ec2:DescribeVolumes" 54 | - "ec2:DescribeSnapshots" 55 | - "ec2:DeleteSnapshot" 56 | - "tag:GetResources" 57 | Resource: 58 | - "*" 59 | - 60 | Effect: "Allow" 61 | Action: 62 | - "iam:PassRole" 63 | Resource: 64 | - !FindInMap ["MasterAccount", "ResourceSelectorExecutionRole", "Name"] 65 | - !FindInMap ["MasterAccount", "DocumentAssumeRole", "Name"] 66 | Metadata: 67 | cfn_nag: 68 | rules_to_suppress: 69 | - 70 | id: W11 71 | reason: "The * resource allows the master account of Operations Conductor to delete snapshots." 72 | - 73 | id: W28 74 | reason: "The role name is intentional to assume role on master account and region." 75 | -------------------------------------------------------------------------------- /source/services/custom-resource/ssm/OperationsConductor-ResizeInstance/cloudformation.template: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). 4 | # You may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License is located at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | AWSTemplateFormatVersion: "2010-09-09" 16 | Description: "(SO0065) - Operations Conductor ResizeInstance for cross accounts/regions. Version %%VERSION%%" 17 | 18 | Mappings: 19 | MasterAccount: 20 | ResourceSelectorExecutionRole: 21 | Name: "%%RESOURCE_SELECTOR_EXECUTION_ROLE_ARN%%" 22 | DocumentAssumeRole: 23 | Name: "%%OperationsConductorSharedRoleArn%%" 24 | Account: 25 | Id: "%%MASTER_ACCOUNT%%" 26 | 27 | Resources: 28 | IAMRole: 29 | Type: AWS::IAM::Role 30 | Description: "Role to allow master account to perform actions" 31 | Properties: 32 | RoleName: !Sub "${AWS::AccountId}-${AWS::Region}-%%TASK_ID%%" 33 | AssumeRolePolicyDocument: 34 | Version: "2012-10-17" 35 | Statement: 36 | - 37 | Effect: "Allow" 38 | Principal: 39 | AWS: 40 | - !FindInMap ["MasterAccount", "Account", "Id"] 41 | Action: 42 | - "sts:AssumeRole" 43 | Path: "/" 44 | Policies: 45 | - 46 | PolicyName: "OperationsConductor-ResizeInstance" 47 | PolicyDocument: 48 | Version: "2012-10-17" 49 | Statement: 50 | - 51 | Effect: "Allow" 52 | Action: 53 | - "ec2:DescribeInstances" 54 | - "ec2:StopInstances" 55 | - "ec2:StartInstances" 56 | - "ec2:ModifyInstanceAttribute" 57 | - "ec2:CreateTags" 58 | - "cloudwatch:GetMetricData" 59 | - "tag:GetResources" 60 | Resource: 61 | - "*" 62 | - 63 | Effect: "Allow" 64 | Action: 65 | - "iam:PassRole" 66 | Resource: 67 | - !FindInMap ["MasterAccount", "ResourceSelectorExecutionRole", "Name"] 68 | - !FindInMap ["MasterAccount", "DocumentAssumeRole", "Name"] 69 | Metadata: 70 | cfn_nag: 71 | rules_to_suppress: 72 | - 73 | id: W11 74 | reason: "The * resource allows the master account of Operations Conductor to stop/start/modify instances." 75 | - 76 | id: W28 77 | reason: "The role name is intentional to assume role on master account and region." 78 | -------------------------------------------------------------------------------- /source/services/custom-resource/ssm/OperationsConductor-SetDynamoDBCapacity/cloudformation.template: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"). 4 | # You may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License is located at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | AWSTemplateFormatVersion: "2010-09-09" 16 | Description: "(SO0065) - Operations Conductor SetDynamoDBCapacity for cross accounts/regions. Version %%VERSION%%" 17 | 18 | Mappings: 19 | MasterAccount: 20 | ResourceSelectorExecutionRole: 21 | Name: "%%RESOURCE_SELECTOR_EXECUTION_ROLE_ARN%%" 22 | DocumentAssumeRole: 23 | Name: "%%OperationsConductorSharedRoleArn%%" 24 | Account: 25 | Id: "%%MASTER_ACCOUNT%%" 26 | 27 | Resources: 28 | IAMRole: 29 | Type: AWS::IAM::Role 30 | Description: "Role to allow master account to perform actions" 31 | Properties: 32 | RoleName: !Sub "${AWS::AccountId}-${AWS::Region}-%%TASK_ID%%" 33 | AssumeRolePolicyDocument: 34 | Version: "2012-10-17" 35 | Statement: 36 | - 37 | Effect: "Allow" 38 | Principal: 39 | AWS: 40 | - !FindInMap ["MasterAccount", "Account", "Id"] 41 | Action: 42 | - "sts:AssumeRole" 43 | Path: "/" 44 | Policies: 45 | - 46 | PolicyName: "OperationsConductor-SetDynamoDBCapacity" 47 | PolicyDocument: 48 | Version: "2012-10-17" 49 | Statement: 50 | - 51 | Effect: "Allow" 52 | Action: 53 | - "dynamodb:DescribeTable" 54 | - "dynamodb:UpdateTable" 55 | - "tag:GetResources" 56 | Resource: 57 | - "*" 58 | - 59 | Effect: "Allow" 60 | Action: 61 | - "iam:PassRole" 62 | Resource: 63 | - !FindInMap ["MasterAccount", "ResourceSelectorExecutionRole", "Name"] 64 | - !FindInMap ["MasterAccount", "DocumentAssumeRole", "Name"] 65 | Metadata: 66 | cfn_nag: 67 | rules_to_suppress: 68 | - 69 | id: W11 70 | reason: "The * resource allows the master account of Operations Conductor to set DynamoDB capacity." 71 | - 72 | id: W28 73 | reason: "The role name is intentional to assume role on master account and region." 74 | -------------------------------------------------------------------------------- /source/services/custom-resource/ssm/index.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { SSM } from './index'; 7 | 8 | const mockDocument = { 9 | DocumentDescription: { 10 | Hash: 'hash-value', 11 | HashType: 'Sha256', 12 | Name: 'OperationsConductor-MockDocument', 13 | Owner: 'owner-account-id', 14 | CreatedDate: '2019-08-06T04:38:05.499Z', 15 | Status: 'Active', 16 | DocumentVersion: '1', 17 | Description: 'Mock document', 18 | Parameters: [ Object, Object ], 19 | PlatformTypes: [ 'Windows', 'Linux' ], 20 | DocumentType: 'Automation', 21 | SchemaVersion: '0.3', 22 | LatestVersion: '1', 23 | DefaultVersion: '1', 24 | DocumentFormat: 'YAML', 25 | Tags: [ 26 | { 27 | Key: 'SomeKey', 28 | Value: 'SomeValue' 29 | } 30 | ] 31 | } 32 | }; 33 | 34 | const mockSsm = jest.fn(); 35 | jest.mock('aws-sdk', () => { 36 | return { 37 | SSM: jest.fn(() => ({ 38 | createDocument: mockSsm, 39 | deleteDocument: mockSsm 40 | })) 41 | }; 42 | }); 43 | 44 | const ssm = new SSM('MockStack', 0.1, 'SomeKey', 'SomeValue'); 45 | const InvalidDocumentCreation = { 46 | code: 'InvalidDocument', 47 | statusCode: 400, 48 | message: 'Document with name Invalid does not exist.' 49 | }; 50 | const InvalidDocumentDeletion = { 51 | code: 'InvalidDocument', 52 | statusCode: 400, 53 | message: 'Document document-name does not exist in your account' 54 | }; 55 | const mainDirectory = 'custom-resource/ssm/'; 56 | 57 | describe('SSM', () => { 58 | describe('createDocuments', () => { 59 | beforeEach(() => { 60 | mockSsm.mockReset(); 61 | }); 62 | 63 | test('returns a success response', (done) => { 64 | mockSsm.mockImplementation(() => { 65 | return { 66 | promise() { 67 | return Promise.resolve(mockDocument); 68 | } 69 | }; 70 | }); 71 | 72 | let directories: string[] = ssm.getDocumentDirectories(mainDirectory); 73 | ssm.createDocuments(mainDirectory, null).then((data) => { 74 | expect(data).toEqual(`Create ${directories.length} document(s) successful`); 75 | done(); 76 | }).catch((error) => { 77 | done(error); 78 | }); 79 | }); 80 | 81 | test('returns an error when document is not valid', (done) => { 82 | mockSsm.mockImplementation(() => { 83 | return { 84 | promise() { 85 | return Promise.reject(InvalidDocumentCreation); 86 | } 87 | }; 88 | }); 89 | 90 | ssm.createDocuments(mainDirectory, null).then(() => { 91 | done('invalid failure for negative test'); 92 | }).catch((error) => { 93 | expect(error).toEqual(InvalidDocumentCreation); 94 | done(); 95 | }); 96 | }); 97 | 98 | test('returns an error when creating documents fails', (done) => { 99 | mockSsm.mockImplementation(() => { 100 | return { 101 | promise() { 102 | return Promise.reject('error'); 103 | } 104 | }; 105 | }); 106 | 107 | ssm.createDocuments(mainDirectory, null).then(() => { 108 | done('invalid failure for negative test'); 109 | }).catch((error) => { 110 | expect(error).toEqual('error'); 111 | done(); 112 | }); 113 | }); 114 | }); 115 | 116 | describe('deleteDocuments', () => { 117 | beforeEach(() => { 118 | mockSsm.mockReset(); 119 | }); 120 | 121 | test('returns a success response', (done) => { 122 | mockSsm.mockImplementation(() => { 123 | return { 124 | promise() { 125 | return Promise.resolve(); 126 | } 127 | }; 128 | }); 129 | 130 | let directories: string[] = ssm.getDocumentDirectories(mainDirectory); 131 | ssm.deleteDocuments(mainDirectory).then((data) => { 132 | expect(data).toEqual(`Delete ${directories.length} document(s) successful`); 133 | done(); 134 | }).catch((error) => { 135 | done(error); 136 | }); 137 | }); 138 | 139 | test('returns an error when document does not exist', (done) => { 140 | mockSsm.mockImplementation(() => { 141 | return { 142 | promise() { 143 | return Promise.reject(InvalidDocumentDeletion); 144 | } 145 | }; 146 | }); 147 | 148 | ssm.deleteDocuments(mainDirectory).then(() => { 149 | done('invalid failure for negative test'); 150 | }).catch((error) => { 151 | expect(error).toEqual(InvalidDocumentDeletion); 152 | done(); 153 | }); 154 | }); 155 | 156 | test('returns an error when deleting document fails', (done) => { 157 | mockSsm.mockImplementation(() => { 158 | return { 159 | promise() { 160 | return Promise.reject('error'); 161 | } 162 | }; 163 | }); 164 | 165 | ssm.deleteDocuments(mainDirectory).then(() => { 166 | done('invalid failure for negative test'); 167 | }).catch((error) => { 168 | expect(error).toEqual('error'); 169 | done(); 170 | }); 171 | }); 172 | }); 173 | }); -------------------------------------------------------------------------------- /source/services/custom-resource/ssm/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as AWS from 'aws-sdk'; 7 | import * as fs from 'fs'; 8 | import * as path from 'path'; 9 | import { factory } from '../../logger'; 10 | 11 | // Logger 12 | const LOGGER = factory.getLogger('custom-resource.SSM'); 13 | 14 | export class SSM { 15 | // System Manager 16 | ssm: AWS.SSM; 17 | 18 | // Stack Name 19 | stackName: string; 20 | 21 | // Filter Tag 22 | filterTagKey: string; 23 | filterTagValue: string; 24 | 25 | // Sleep Second 26 | sleepSecond: number; 27 | 28 | /** 29 | * @constructor 30 | * @param {string} stackName - the CloudFormation stack name 31 | * @param {number} sleepSecond - sleep second between jobs 32 | * @param {string} filterTagKey - AWS Sysmtes Manager document tag key 33 | * @param {string} filterTagValue - AWS Sysmtes Manager document tag value 34 | */ 35 | constructor(stackName: string, sleepSecond: number, filterTagKey?: string, filterTagValue?: string, ) { 36 | this.ssm = new AWS.SSM(); 37 | this.stackName = stackName; 38 | this.filterTagKey = filterTagKey; 39 | this.filterTagValue = filterTagValue; 40 | this.sleepSecond = sleepSecond; 41 | } 42 | 43 | /** 44 | * Creates documents 45 | * @param {string} mainDirectory - main directory to get SSM documents 46 | */ 47 | async createDocuments(mainDirectory: string, properties: any = {}): Promise { 48 | try { 49 | // Gets document directories 50 | let directories: string[] = this.getDocumentDirectories(mainDirectory); 51 | LOGGER.info(`Creating ${directories.length} document(s)...`); 52 | 53 | // Creates documents 54 | for (let directory of directories) { 55 | // Sleeps for a while to prevent throttling 56 | await this.sleep(this.sleepSecond); 57 | 58 | LOGGER.info(`Processing directory '${directory}'...`); 59 | 60 | let document = fs.readFileSync(`${directory}/automation_document.yaml`, 'utf8'); 61 | let documentName = directory.replace(mainDirectory, ''); 62 | 63 | if (properties) { 64 | // Replace placeholder values in automation document with 65 | // values passed to this function as properties 66 | Object.keys(properties).forEach(key => { 67 | if (Object.prototype.hasOwnProperty.call(properties, key)) { 68 | document = document.replace(new RegExp(`%%${key}%%`, 'g'), properties[key]); 69 | } 70 | }); 71 | } 72 | 73 | let params = { 74 | Name: `${this.stackName}-${documentName}`, 75 | Content: document, 76 | DocumentFormat: 'YAML', 77 | DocumentType: 'Automation', 78 | Tags: [ 79 | { 80 | Key: this.filterTagKey, 81 | Value: this.filterTagValue 82 | } 83 | ] 84 | }; 85 | 86 | let result = await this.ssm.createDocument(params).promise(); 87 | LOGGER.info(`Creating document: ${result.DocumentDescription.Name} - ${result.DocumentDescription.Status}`); 88 | } 89 | 90 | return Promise.resolve(`Create ${directories.length} document(s) successful`); 91 | } catch (error) { 92 | LOGGER.error(`Error occurred while creating documents.`, error); 93 | return Promise.reject(error); 94 | } 95 | } 96 | 97 | /** 98 | * Deletes SSM documents created by Operations Conductor 99 | * @param {string} mainDirectory - main directory to get SSM documents 100 | */ 101 | async deleteDocuments(mainDirectory: string): Promise { 102 | try { 103 | // Gets document directories 104 | let directories: string[] = this.getDocumentDirectories(mainDirectory); 105 | LOGGER.info(`Deleting ${directories.length} document(s)...`); 106 | 107 | // Deletes documents 108 | for (let directory of directories) { 109 | // Sleeps for a while to prevent throttling 110 | await this.sleep(this.sleepSecond); 111 | 112 | let documentName = directory.replace(mainDirectory, ''); 113 | LOGGER.info(`Processing document '${documentName}'...`); 114 | let params = { 115 | Name: `${this.stackName}-${documentName}` 116 | }; 117 | await this.ssm.deleteDocument(params).promise(); 118 | } 119 | 120 | return Promise.resolve(`Delete ${directories.length} document(s) successful`); 121 | } catch (error) { 122 | LOGGER.error(`Error occurred while deleting documents.`, error); 123 | return Promise.reject(error); 124 | } 125 | } 126 | 127 | /** 128 | * Gets SSM documents directories 129 | * @param {string} mainDirectory - main directory to get SSM documents 130 | */ 131 | getDocumentDirectories(mainDirectory: string): string[] { 132 | try { 133 | return fs.readdirSync(mainDirectory) 134 | .map((file: string) => path.join(mainDirectory, file)) 135 | .filter((file: string) => fs.lstatSync(file).isDirectory()); 136 | } catch (error) { 137 | throw new Error(error); 138 | } 139 | } 140 | 141 | /** 142 | * Sleeps for a while 143 | * @param {number} second - the second to sleep 144 | */ 145 | sleep(second: number) { 146 | return new Promise(resolve => setTimeout(resolve, 1000 * second)); 147 | } 148 | } -------------------------------------------------------------------------------- /source/services/custom-resource/utils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as AWS from "aws-sdk"; 7 | import {factory} from "../logger"; 8 | 9 | const LOGGER = factory.getLogger('custom-resource'); 10 | 11 | /** 12 | * Puts an object into S3 bucket 13 | * @param {string} bucket - bucket name to put an object 14 | * @param {Buffer|string} fileData - object body 15 | * @param {string} filename - object name 16 | * @param {boolean} isEncrypted - choose to encrypt the object 17 | */ 18 | export async function putObject(bucket: string, fileData: Buffer|string, filename: string, isEncrypted?: boolean): Promise { 19 | const s3 = new AWS.S3(); 20 | let params: AWS.S3.PutObjectRequest = { 21 | Bucket: bucket, 22 | Body: fileData, 23 | Key: filename, 24 | ContentType: getContentType(filename) 25 | }; 26 | 27 | if(isEncrypted) { 28 | params['ServerSideEncryption'] = 'AES256' 29 | } 30 | 31 | try { 32 | await s3.putObject(params).promise(); 33 | return Promise.resolve({ 34 | Message: `File uploaded: ${filename}` 35 | }); 36 | } catch (error) { 37 | LOGGER.error(`Error occurred while uploading ${filename}.`, error); 38 | return Promise.reject({ 39 | Error: `Error occurred while uploading ${filename}.` 40 | }); 41 | } 42 | } 43 | 44 | /** 45 | * Gets content type based on file extension 46 | * @param {string} filename - filename 47 | */ 48 | export function getContentType(filename: string): string { 49 | if (filename.endsWith('.html')) return 'text/html'; 50 | if (filename.endsWith('.css')) return 'text/css'; 51 | if (filename.endsWith('.png')) return 'image/png'; 52 | if (filename.endsWith('.svg')) return 'image/svg+xml'; 53 | if (filename.endsWith('.jpg')) return 'image/jpeg'; 54 | if (filename.endsWith('.js')) return 'application/javascript'; 55 | else return 'binary/octet-stream'; 56 | } 57 | 58 | /** 59 | * Sleeps for 5 * retry seconds 60 | * @param {number} retry - the number of retry 61 | */ 62 | export function sleep(retry: number) { 63 | return new Promise(resolve => setTimeout(resolve, 5000 * retry)); 64 | } -------------------------------------------------------------------------------- /source/services/jestconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "transform": { 3 | "^.+\\.(t|j)sx?$": "ts-jest" 4 | }, 5 | "moduleFileExtensions": [ "ts", "tsx", "js", "jsx", "json", "node" ], 6 | "coveragePathIgnorePatterns": [ "/node_modules/", "/build" ], 7 | "modulePathIgnorePatterns": [ "/node_modules/", "/build" ] 8 | } -------------------------------------------------------------------------------- /source/services/logger/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { LoggerFactoryOptions, LFService, LogGroupRule, LogLevel, LogFormat, DateFormat } from 'typescript-logging'; 7 | 8 | let logLevel = LogLevel.Info; 9 | if (parseInt(process.env.LogLevel) in LogLevel) { 10 | logLevel = parseInt(process.env.LogLevel); 11 | } 12 | 13 | /** 14 | * Logger factory 15 | */ 16 | export const factory = LFService.createLoggerFactory( 17 | new LoggerFactoryOptions().addLogGroupRule( 18 | new LogGroupRule( 19 | new RegExp(".+"), // regular expression, what mataches for logger names for this group 20 | logLevel, 21 | new LogFormat( 22 | new DateFormat(), // date format for the log 23 | false, // show timestamp - false as Lambda function logs timestamp by default 24 | true // show the logger name 25 | ) 26 | ) 27 | ) 28 | ); 29 | -------------------------------------------------------------------------------- /source/services/metrics/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as requestPromise from 'request-promise'; 7 | import { factory } from '../logger'; 8 | 9 | /** 10 | * Performs to send metrics 11 | * @class Metrics 12 | */ 13 | export class Metrics { 14 | // Logger 15 | logger: any; 16 | 17 | // Metric endpoint 18 | endpoint: string; 19 | 20 | /** 21 | * @constructor 22 | */ 23 | constructor() { 24 | this.logger = factory.getLogger('resources.Metrics'); 25 | this.endpoint = 'https://metrics.awssolutionsbuilder.com'; 26 | } 27 | 28 | /** 29 | * Sends anonymous metric 30 | * @param {object} metric - metric JSON data 31 | */ 32 | async sendAnonymousMetric(metric: object): Promise { 33 | const options = { 34 | uri: `${this.endpoint}/generic`, 35 | port: 443, 36 | method: 'POST', 37 | headers: { 38 | 'Content-Type': 'application/json', 39 | 'Content-Length': JSON.stringify(metric).length 40 | }, 41 | body: JSON.stringify(metric) 42 | }; 43 | 44 | try { 45 | await requestPromise(options); 46 | return Promise.resolve(`Metric sent: ${JSON.stringify(metric)}`); 47 | } catch (error) { 48 | this.logger.error(`Error occurred while sending metric: ${JSON.stringify(metric)}`, error); 49 | return Promise.reject(`Error occurred while sending metric: ${JSON.stringify(metric)}`); 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /source/services/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "operations-conductor-services", 3 | "version": "1.0.4", 4 | "description": "Operations Conductor solution services Lambda function package", 5 | "main": "index.js", 6 | "scripts": { 7 | "build:ts": "tsc --version && tsc --project tsconfig.json", 8 | "build:clean": "rm -rf build", 9 | "build:copy": "rsync -r --exclude=*.ts custom-resource/ssm/ build/custom-resource/ssm/ && rsync -r --exclude=*.ts tasks/ build/tasks/", 10 | "build:zip": "cp -r ./node_modules/ ./build/node_modules/ && cd build && zip -rq operations-conductor-services.zip .", 11 | "build:lambda-edge": "cp custom-resource/lambda-edge/index.js build/lambda-edge && cd build/lambda-edge && zip operations-conductor-lambda-edge.zip index.js", 12 | "build": "npm run build:clean && npm ci && npm run build:ts && npm run build:copy && npm run build:zip", 13 | "package": "npm run build:clean && npm ci && npm run build:ts && npm run build:copy && npm prune --production && npm run build:zip", 14 | "test": "npm install && CI=true jest --config jestconfig.json --coverage" 15 | }, 16 | "dependencies": { 17 | "aws-lambda": "^1.0.6", 18 | "aws-serverless-express": "^3.0.1", 19 | "body-parser": "^1.19.0", 20 | "cors": "^2.8.5", 21 | "express": "^4.17.1", 22 | "jwt-decode": "^2.2.0", 23 | "moment": "^2.24.0", 24 | "request": "^2.88.0", 25 | "request-promise": "^4.2.4", 26 | "typescript-logging": "^0.6.3", 27 | "uuid": "^8.3.2" 28 | }, 29 | "devDependencies": { 30 | "@types/aws-lambda": "^8.10.30", 31 | "@types/aws-serverless-express": "^3.0.1", 32 | "@types/cors": "^2.8.5", 33 | "@types/jest": "27.0.0", 34 | "@types/jwt-decode": "^2.2.1", 35 | "@types/node": "^12.6.8", 36 | "@types/request-promise": "^4.1.44", 37 | "@types/uuid": "^8.3.1", 38 | "@types/express": "4.17.6", 39 | "aws-sdk": "^2.496.0", 40 | "jest": "27.0.6", 41 | "ts-jest": "^27.0.5", 42 | "ts-node": "^8.3.0", 43 | "typescript": "^4.0.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /source/services/tasks/app.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as express from 'express'; 7 | import * as bodyParser from 'body-parser'; 8 | import * as cors from 'cors'; 9 | import * as awsServerlessExpressMiddleware from 'aws-serverless-express/middleware'; 10 | import { factory } from '../logger'; 11 | import { Task } from './tasks'; 12 | 13 | export const configureApp = () => { 14 | // Logger 15 | const logger = factory.getLogger('tasks.App'); 16 | 17 | // Declares a new express app 18 | const app = express(); 19 | const router = express.Router(); 20 | 21 | router.use(cors({ 22 | origin: process.env.CorsAllowedOrigins 23 | })); 24 | router.use((req: any, res: any, next: any) => { 25 | bodyParser.json()(req, res, (err: any) => { 26 | if (err) { 27 | return res.status(400).json({ 28 | code: 400, 29 | error: 'BadRequest', 30 | message: err.message 31 | }); 32 | } 33 | next(); 34 | }); 35 | }); 36 | router.use(bodyParser.urlencoded({ extended: true })); 37 | router.use(awsServerlessExpressMiddleware.eventContext()); 38 | 39 | // Declares Task class 40 | const task = new Task(); 41 | 42 | // GET /tasks 43 | router.get('/tasks', async (req: any, res: any) => { 44 | logger.info('GET /tasks'); 45 | try { 46 | const result = await task.getTasks(); 47 | res.status(200).json(result); 48 | } catch (error) { 49 | logger.error(JSON.stringify(error)); 50 | res.status(error.statusCode).json(error); 51 | } 52 | }); 53 | 54 | // POST /tasks 55 | router.post('/tasks', async (req: any, res: any) => { 56 | logger.info('POST /tasks'); 57 | const newTask = req.body; 58 | try { 59 | const result = await task.createTask(newTask); 60 | res.status(201).json(result); 61 | } catch (error) { 62 | logger.error(JSON.stringify(error)); 63 | res.status(error.statusCode).json(error); 64 | } 65 | }); 66 | 67 | // GET /tasks/{taskId} 68 | router.get('/tasks/:taskId', async (req: any, res: any) => { 69 | logger.info('GET /tasks/:taskId'); 70 | const { taskId } = req.params; 71 | try { 72 | const result = await task.getTask(taskId); 73 | res.status(200).json(result); 74 | } catch (error) { 75 | logger.error(JSON.stringify(error)); 76 | res.status(error.statusCode).json(error); 77 | } 78 | }); 79 | 80 | // PUT /tasks/{taskId} 81 | router.put('/tasks/:taskId', async (req: any, res: any) => { 82 | logger.info('PUT /tasks/:taskId'); 83 | const { taskId } = req.params; 84 | const updatedTask = req.body; 85 | try { 86 | const result = await task.editTask(taskId, updatedTask); 87 | res.status(200).json(result); 88 | } catch (error) { 89 | logger.error(JSON.stringify(error)); 90 | res.status(error.statusCode).json(error); 91 | } 92 | }); 93 | 94 | // DELETE /tasks/{taskId} 95 | router.delete('/tasks/:taskId', async (req: any, res: any) => { 96 | logger.info('DELETE /tasks/:taskId'); 97 | const { taskId } = req.params; 98 | try { 99 | await task.deleteTask(taskId); 100 | res.sendStatus(204); 101 | } catch (error) { 102 | logger.error(JSON.stringify(error)); 103 | res.status(error.statusCode).json(error); 104 | } 105 | }); 106 | 107 | // PUT /tasks/{taskId}/execute 108 | router.put('/tasks/:taskId/execute', async (req: any, res: any) => { 109 | logger.info('PUT /tasks/:taskId/execute'); 110 | const { taskId } = req.params; 111 | try { 112 | const result = await task.executeTask(taskId); 113 | res.status(200).json(result); 114 | } catch (error) { 115 | logger.error(JSON.stringify(error)); 116 | res.status(error.statusCode).json(error); 117 | } 118 | }); 119 | 120 | // POST /tasks/{taskId}/executions 121 | router.post('/tasks/:taskId/executions', async (req: any, res: any) => { 122 | logger.info('POST /tasks/:taskId/executions'); 123 | const { taskId } = req.params; 124 | const { sortType, itemsPerPage, lastKey } = req.body; 125 | try { 126 | const result = await task.getTaskExecutions(taskId, sortType, itemsPerPage, lastKey); 127 | res.status(200).json(result); 128 | } catch (error) { 129 | logger.error(JSON.stringify(error)); 130 | res.status(error.statusCode).json(error); 131 | } 132 | }); 133 | 134 | // POST /tasks/{taskId}/executions/{parentExecutionId} 135 | router.post('/tasks/:taskId/executions/:parentExecutionId', async (req: any, res: any) => { 136 | logger.info('POST /tasks/:taskId/executions/:parentExecutionId'); 137 | const { taskId, parentExecutionId } = req.params; 138 | const { itemsPerPage, lastKey } = req.body; 139 | try { 140 | const result = await task.getAutomationExecutions(taskId, parentExecutionId, itemsPerPage, lastKey); 141 | res.status(200).json(result); 142 | } catch (error) { 143 | logger.error(JSON.stringify(error)); 144 | res.status(error.statusCode).json(error); 145 | } 146 | }); 147 | 148 | // GET /tasks/{taskId}/executions/{parentExecutionId}/{automationExecutionId} 149 | router.get('/tasks/:taskId/executions/:parentExecutionId/:automationExecutionId', async (req: any, res: any) => { 150 | logger.info('GET /tasks/:taskId/executions/:parentExecutionId/:automationExecutionId'); 151 | const { taskId, parentExecutionId, automationExecutionId } = req.params; 152 | try { 153 | const result = await task.getAutomationExecution(taskId, parentExecutionId, automationExecutionId); 154 | res.status(200).json(result); 155 | } catch (error) { 156 | logger.error(JSON.stringify(error)); 157 | res.status(error.statusCode).json(error); 158 | } 159 | }); 160 | 161 | app.use('/', router); 162 | 163 | return app; 164 | }; 165 | -------------------------------------------------------------------------------- /source/services/tasks/event-handler.template: -------------------------------------------------------------------------------- 1 | CloudWatchEvent: 2 | Type: AWS::Events::Rule 3 | Properties: 4 | Name: "event-%%TASK_ID%%" 5 | Description: "%%TASK_DESCRIPTION%%" 6 | EventPattern: %%EVENT_PATTERN%% 7 | State: "ENABLED" 8 | Targets: 9 | - 10 | Arn: !GetAtt EventForwarderLambda.Arn 11 | Id: "event-%%TASK_ID%%" 12 | InputTransformer: 13 | InputPathsMap: 14 | resources: $.resources 15 | InputTemplate: | 16 | "%%TASK%%" 17 | CloudWatchEventLambdaPermission: 18 | Type: AWS::Lambda::Permission 19 | Properties: 20 | FunctionName: !GetAtt EventForwarderLambda.Arn 21 | Action: "lambda:InvokeFunction" 22 | Principal: "events.amazonaws.com" 23 | SourceArn: !GetAtt CloudWatchEvent.Arn 24 | EventForwarderRole: 25 | Type: AWS::IAM::Role 26 | Description: "Role for the Operations Conductor event forwarder Lambda function" 27 | Properties: 28 | AssumeRolePolicyDocument: 29 | Version: "2012-10-17" 30 | Statement: 31 | - 32 | Effect: "Allow" 33 | Principal: 34 | Service: 35 | - "lambda.amazonaws.com" 36 | Action: 37 | - "sts:AssumeRole" 38 | Path: "/" 39 | EventForwarderPolicy: 40 | Type: AWS::IAM::ManagedPolicy 41 | Properties: 42 | Description: "Policy for the Operations Conductor event forwarder Lambda function" 43 | PolicyDocument: 44 | Version: "2012-10-17" 45 | Statement: 46 | - 47 | Effect: "Allow" 48 | Action: 49 | - "logs:CreateLogGroup" 50 | - "logs:CreateLogStream" 51 | - "logs:PutLogEvents" 52 | Resource: 53 | - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${EventForwarderLambda}:*" 54 | - 55 | Effect: "Allow" 56 | Action: 57 | - "sns:Publish" 58 | Resource: 59 | - "%%MASTER_SNS_ARN%%" 60 | - 61 | Effect: "Allow" 62 | Action: 63 | - kms:GenerateDataKey 64 | - kms:Decrypt 65 | Resource: 66 | - "%%MASTER_KMS_ARN%%" 67 | 68 | Roles: 69 | - !Ref EventForwarderRole 70 | Metadata: 71 | cfn_nag: 72 | rules_to_suppress: 73 | - 74 | id: W13 75 | reason: "The * resource allows to create CloudWatch logs." 76 | EventForwarderLambda: 77 | Type: AWS::Lambda::Function 78 | Properties: 79 | Description: "The Operations Conductor event forwarder" 80 | Handler: index.handler 81 | Role: !GetAtt EventForwarderRole.Arn 82 | Runtime: nodejs16.x 83 | MemorySize: 128 84 | Timeout: 15 85 | Environment: 86 | Variables: 87 | MasterSnsArn: "%%MASTER_SNS_ARN%%" 88 | MasterRegion: "%%MASTER_REGION%%" 89 | Region: !Ref "AWS::Region" 90 | Account: !Ref "AWS::AccountId" 91 | Code: 92 | ZipFile: !Sub | 93 | /***************************************************************************** 94 | * Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. * 95 | * * 96 | * Licensed under the Apache License, Version 2.0 (the "License"). * 97 | * You may not use this file except in compliance with the License. * 98 | * A copy of the License is located at * 99 | * * 100 | * http://www.apache.org/licenses/LICENSE-2.0 * 101 | * * 102 | * or in the "license" file accompanying this file. This file is distributed * 103 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either * 104 | * express or implied. See the License for the specific language governing * 105 | * permissions and limitations under the License. * 106 | ****************************************************************************/ 107 | 108 | const AWS = require('aws-sdk'); 109 | const SNS = new AWS.SNS({ region: process.env.MasterRegion }); 110 | 111 | exports.handler = async (event) => { 112 | console.log('Event received:', event); 113 | let eventJson = JSON.parse(event); 114 | let resources = eventJson['resources']; 115 | eventJson['resources'] = JSON.parse(resources.replace('[', '[\"').replace(']','\"]').replace(',', '\",\"')); 116 | eventJson['resourceAccount'] = process.env.Account; 117 | eventJson['resourceRegion'] = process.env.Region; 118 | let params = { 119 | Message: JSON.stringify(eventJson), 120 | TargetArn: process.env.MasterSnsArn 121 | }; 122 | try { 123 | await SNS.publish(params).promise(); 124 | return 'Success'; 125 | } catch (error) { 126 | console.error('Error occurred while publishing SNS.', error); 127 | return 'Error'; 128 | } 129 | }; 130 | -------------------------------------------------------------------------------- /source/services/tasks/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { Context } from 'aws-lambda'; 7 | import { createServer, proxy } from 'aws-serverless-express'; 8 | import { configureApp } from './app'; 9 | 10 | const app = configureApp(); 11 | const server = createServer(app); 12 | 13 | export const handler = (event: any, context: Context) => { 14 | proxy(server, event, context); 15 | }; 16 | -------------------------------------------------------------------------------- /source/services/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "outDir": "build", 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "rootDir": ".", 9 | "sourceMap": true 10 | }, 11 | "include": [ 12 | "*.ts", 13 | "actions/*.ts", 14 | "common/*.ts", 15 | "custom-resource/*.ts", 16 | "custom-resource/ssm/*.ts", 17 | "logger/*.ts", 18 | "metrics/*.ts", 19 | "queue-consumer/*.ts", 20 | "resource-selector/*.ts", 21 | "tasks/*.ts", 22 | "users/*.ts" 23 | ], 24 | "exclude": [ 25 | "node_modules", 26 | "*.spec.ts" 27 | ] 28 | } -------------------------------------------------------------------------------- /source/services/users/app.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as express from 'express'; 7 | import * as bodyParser from 'body-parser'; 8 | import * as cors from 'cors'; 9 | import * as awsServerlessExpressMiddleware from 'aws-serverless-express/middleware'; 10 | import * as jwtDecode from 'jwt-decode'; 11 | import { factory } from '../logger'; 12 | import { User } from './users'; 13 | 14 | export const configureApp = () => { 15 | // Logger 16 | const logger = factory.getLogger('users.App'); 17 | 18 | // Declares a new express app 19 | const app = express(); 20 | const router = express.Router(); 21 | 22 | router.use(cors({ 23 | origin: process.env.CorsAllowedOrigins 24 | })); 25 | router.use((req: any, res: any, next: any) => { 26 | bodyParser.json()(req, res, (err: any) => { 27 | if (err) { 28 | return res.status(400).json({ 29 | code: 400, 30 | error: 'BadRequest', 31 | message: err.message 32 | }); 33 | } 34 | next(); 35 | }); 36 | }); 37 | router.use(bodyParser.urlencoded({ extended: true })); 38 | router.use(awsServerlessExpressMiddleware.eventContext()); 39 | 40 | // Declares User class 41 | const user = new User(); 42 | 43 | // Checks the permission 44 | const checkAuthorization = async (req: any, res: any, next: any) => { 45 | const jwt = getJwtDecode(req); 46 | let group = await user.getUserGroup(jwt['cognito:username']); 47 | 48 | if (group === 'Admin') { 49 | next(); 50 | } else { 51 | res.status(401).json({ 52 | code: 'Unauthorized', 53 | statusCode: 401, 54 | message: 'Unauthorized to access the API.' 55 | }); 56 | } 57 | }; 58 | 59 | // GET /users 60 | router.get('/users', checkAuthorization, async (req: any, res: any) => { 61 | logger.info('GET /users'); 62 | try { 63 | const result = await user.getUsers(); 64 | res.status(200).json(result); 65 | } catch (error) { 66 | logger.error(JSON.stringify(error)); 67 | res.status(error.statusCode).json(error); 68 | } 69 | }); 70 | 71 | // POST /users 72 | router.post('/users', checkAuthorization, async (req: any, res: any) => { 73 | logger.info('POST /users'); 74 | const newUser = req.body; 75 | try { 76 | const result = await user.createUser(newUser); 77 | res.status(201).json(result); 78 | } catch (error) { 79 | logger.error(JSON.stringify(error)); 80 | res.status(error.statusCode).json(error); 81 | } 82 | }); 83 | 84 | // PUT /users/{userId} 85 | router.put('/users/:userId', checkAuthorization, async (req: any, res: any) => { 86 | logger.info('PUT /users/:userId'); 87 | const jwt = getJwtDecode(req); 88 | const { userId } = req.params; 89 | if (jwt['cognito:username'] === userId) { 90 | res.status(405).json({ 91 | code: 'MethodNotAllowed', 92 | statusCode: 405, 93 | message: 'Users cannot edit themselves.' 94 | }); 95 | } 96 | 97 | const body = req.body; 98 | try { 99 | const result = await user.editUser(userId, body.group); 100 | res.status(200).json(result); 101 | } catch (error) { 102 | logger.error(JSON.stringify(error)); 103 | res.status(error.statusCode).json(error); 104 | } 105 | }); 106 | 107 | // DELETE /users/{userId} 108 | router.delete('/users/:userId', checkAuthorization, async (req: any, res: any) => { 109 | logger.info('DELETE /users/:userId'); 110 | const jwt = getJwtDecode(req); 111 | const { userId } = req.params; 112 | if (jwt['cognito:username'] === userId) { 113 | res.status(405).json({ 114 | code: 'MethodNotAllowed', 115 | statusCode: 405, 116 | message: 'Users cannot delete themselves.' 117 | }); 118 | } 119 | 120 | try { 121 | await user.deleteUser(userId); 122 | res.sendStatus(204); 123 | } catch (error) { 124 | logger.error(JSON.stringify(error)); 125 | res.status(error.statusCode).json(error); 126 | } 127 | }); 128 | 129 | app.use('/', router); 130 | 131 | return app; 132 | }; 133 | 134 | /** 135 | * Gets JWT decoded authorization 136 | * @param {any} req - request 137 | */ 138 | const getJwtDecode = (req: any) => { 139 | return jwtDecode(req.header('Authorization')); 140 | } -------------------------------------------------------------------------------- /source/services/users/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { Context } from 'aws-lambda'; 7 | import { createServer, proxy } from 'aws-serverless-express'; 8 | import { configureApp } from './app'; 9 | 10 | const app = configureApp(); 11 | const server = createServer(app); 12 | 13 | export const handler = (event: any, context: Context) => { 14 | proxy(server, event, context); 15 | }; 16 | --------------------------------------------------------------------------------