├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── approve-request ├── __init__.py ├── app.py └── requirements.txt ├── architecture.png ├── ec2_approval_template.yaml ├── get-ec2-pricing ├── __init__.py ├── app.py └── requirements.txt ├── linux-ami-lookup ├── index.js └── package.json ├── master_data.py ├── process-requests ├── __init__.py ├── app.py └── requirements.txt ├── rebase-budgets ├── __init__.py ├── app.py └── requirements.txt ├── save-request ├── __init__.py ├── app.py └── requirements.txt └── template.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 3 | # SAM CLI 4 | *.toml 5 | ### Linux ### 6 | *~ 7 | 8 | # SAM Lambda Events 9 | events 10 | 11 | # temporary files which can be created if a process still has a handle open of a deleted file 12 | .fuse_hidden* 13 | .vscode 14 | 15 | # KDE directory preferences 16 | .directory 17 | 18 | # Linux trash folder which might appear on any partition or disk 19 | .Trash-* 20 | 21 | # .nfs files are created when an open file is removed but is still being accessed 22 | .nfs* 23 | 24 | ### OSX ### 25 | *.DS_Store 26 | .AppleDouble 27 | .LSOverride 28 | 29 | # Icon must end with two \r 30 | Icon 31 | 32 | # Thumbnails 33 | ._* 34 | 35 | # Files that might appear in the root of a volume 36 | .DocumentRevisions-V100 37 | .fseventsd 38 | .Spotlight-V100 39 | .TemporaryItems 40 | .Trashes 41 | .VolumeIcon.icns 42 | .com.apple.timemachine.donotpresent 43 | 44 | # Directories potentially created on remote AFP share 45 | .AppleDB 46 | .AppleDesktop 47 | Network Trash Folder 48 | Temporary Items 49 | .apdisk 50 | 51 | ### PyCharm ### 52 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 53 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 54 | 55 | # User-specific stuff: 56 | .idea/**/workspace.xml 57 | .idea/**/tasks.xml 58 | .idea/dictionaries 59 | 60 | # Sensitive or high-churn files: 61 | .idea/**/dataSources/ 62 | .idea/**/dataSources.ids 63 | .idea/**/dataSources.xml 64 | .idea/**/dataSources.local.xml 65 | .idea/**/sqlDataSources.xml 66 | .idea/**/dynamic.xml 67 | .idea/**/uiDesigner.xml 68 | 69 | # Gradle: 70 | .idea/**/gradle.xml 71 | .idea/**/libraries 72 | 73 | # CMake 74 | cmake-build-debug/ 75 | 76 | # Mongo Explorer plugin: 77 | .idea/**/mongoSettings.xml 78 | 79 | ## File-based project format: 80 | *.iws 81 | 82 | ## Plugin-specific files: 83 | 84 | # IntelliJ 85 | /out/ 86 | 87 | # mpeltonen/sbt-idea plugin 88 | .idea_modules/ 89 | 90 | # JIRA plugin 91 | atlassian-ide-plugin.xml 92 | 93 | # Cursive Clojure plugin 94 | .idea/replstate.xml 95 | 96 | # Ruby plugin and RubyMine 97 | /.rakeTasks 98 | 99 | # Crashlytics plugin (for Android Studio and IntelliJ) 100 | com_crashlytics_export_strings.xml 101 | crashlytics.properties 102 | crashlytics-build.properties 103 | fabric.properties 104 | 105 | ### PyCharm Patch ### 106 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 107 | 108 | # *.iml 109 | # modules.xml 110 | # .idea/misc.xml 111 | # *.ipr 112 | 113 | # Sonarlint plugin 114 | .idea/sonarlint 115 | 116 | ### Python ### 117 | # Byte-compiled / optimized / DLL files 118 | __pycache__/ 119 | *.py[cod] 120 | *$py.class 121 | 122 | # C extensions 123 | *.so 124 | 125 | # Distribution / packaging 126 | .Python 127 | build/ 128 | develop-eggs/ 129 | dist/ 130 | downloads/ 131 | eggs/ 132 | .eggs/ 133 | lib/ 134 | lib64/ 135 | parts/ 136 | sdist/ 137 | var/ 138 | wheels/ 139 | *.egg-info/ 140 | .installed.cfg 141 | *.egg 142 | 143 | # PyInstaller 144 | # Usually these files are written by a python script from a template 145 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 146 | *.manifest 147 | *.spec 148 | 149 | # Installer logs 150 | pip-log.txt 151 | pip-delete-this-directory.txt 152 | 153 | # Unit test / coverage reports 154 | htmlcov/ 155 | .tox/ 156 | .coverage 157 | .coverage.* 158 | .cache 159 | .pytest_cache/ 160 | nosetests.xml 161 | coverage.xml 162 | *.cover 163 | .hypothesis/ 164 | 165 | # Translations 166 | *.mo 167 | *.pot 168 | 169 | # Flask stuff: 170 | instance/ 171 | .webassets-cache 172 | 173 | # Scrapy stuff: 174 | .scrapy 175 | 176 | # Sphinx documentation 177 | docs/_build/ 178 | 179 | # PyBuilder 180 | target/ 181 | 182 | # Jupyter Notebook 183 | .ipynb_checkpoints 184 | 185 | # pyenv 186 | .python-version 187 | 188 | # celery beat schedule file 189 | celerybeat-schedule.* 190 | 191 | # SageMath parsed files 192 | *.sage.py 193 | 194 | # Environments 195 | .env 196 | .venv 197 | env/ 198 | venv/ 199 | ENV/ 200 | env.bak/ 201 | venv.bak/ 202 | 203 | # Spyder project settings 204 | .spyderproject 205 | .spyproject 206 | 207 | # Rope project settings 208 | .ropeproject 209 | 210 | # mkdocs documentation 211 | /site 212 | 213 | # mypy 214 | .mypy_cache/ 215 | 216 | ### VisualStudioCode ### 217 | .vscode/* 218 | !.vscode/settings.json 219 | !.vscode/tasks.json 220 | !.vscode/launch.json 221 | !.vscode/extensions.json 222 | .history 223 | 224 | ### PyCharm ### 225 | .idea/ 226 | 227 | ### Windows ### 228 | # Windows thumbnail cache files 229 | Thumbs.db 230 | ehthumbs.db 231 | ehthumbs_vista.db 232 | 233 | # Folder config file 234 | Desktop.ini 235 | 236 | # Recycle Bin used on file shares 237 | $RECYCLE.BIN/ 238 | 239 | # Windows Installer files 240 | *.cab 241 | *.msi 242 | *.msm 243 | *.msp 244 | 245 | # Windows shortcuts 246 | *.lnk 247 | 248 | # Build folder 249 | 250 | */build/* 251 | 252 | # End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode -------------------------------------------------------------------------------- /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, or recently closed, 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' 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](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: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to AWS Cost Control Approval Workflow Project 2 | 3 | This is a sample project to demonstrate the solution that allows organizations to proactively save cost by keeping the monthly expense with a certain threshold. Users requests AWS resources via AWS Service Catalog, the requested AWS resources are deployed only if their monthly cost is within the pre-approved budget, if monthly cost exceeds the pre-approved budget, an approval workflow is triggered to a pre-configured adminstrator. 4 | 5 | This project uses combination of AWS Service Catalog along with AWS CloudFormation resources like `WaitCondition`, `WaitHandle` and Custom Resources to trigger an approval workflow. Based on the workflow outcome, either the CloudFormation stack is deployed or rolledback. This project uses its own internal ledger to keep track of approved, rejected and forecasted spend of AWS resources relying on AWS Budgets & AWS Pricing APIs. These internal ledgers are updated at periodic intervals to align with AWS Budgets dashboard and are reset at beginning of every calendar month. 6 | 7 | ## Examples 8 | 9 | | # | Description | Requested Cost | Monthly Budget | Available Budget | Request Status | 10 | |---|---------------------------------------------------|:--------------:|:--------------:|:----------------:|-------------------------------------| 11 | | 1 | Request # 1 - AWS resources costing $10 per month | 10 | 100 | 20 | Auto Approved by the system | 12 | | 2 | Request # 2 - AWS resources costing $30 per month | 30 | 100 | 20 | Pending for administrator approval | 13 | | 3 | Request # 3 - AWS resources costing $15 per month | 15 | 100 | 20 | Blocked for decision on Request # 2 | 14 | 15 | ## High Level Architecture 16 | 17 | ![Cost Control Approval Workflow Architecture](./architecture.png) 18 | 19 | 1. User launches a product (ex. Amazon Linux EC2) from Service Catalog. 20 | 2. Associated CloudFormation template has a `WaitCondition`, `WaitHandle` and custom resources (`linux-ami-lookup`, `get-ec2-pricing` & `save-request`) which determines the AMI ID (based on the user inputs caputured in Service Catalog Launch Product form), estimated price of the requested InstanceType. 21 | 3. A CloudFormation custom resource (`save-request`) saves the metadata of product request, AMI information and pricing information to a DynamoDB table. 22 | 4. Cloudwatch Rule invokes `process-requests` Lambda every 5 mins (configurable in `template.yaml`). `process-requests` Lambda looks for saved/pending/blocked requests and routes the request (if requested cost is greater than available budget) to approver(s) based on configuration stored in the DynamoDB table. if requested cost is within the available budget, the request is auto approved and the CloudFormation template is deployed. 23 | 5. Amazon Simple Notification Service configured to sends email notifications with links to approve/reject a request to all subscribers (administrators) of the SNS topic. (i.e., If cost is going to exceed the pre-approved budget then email is triggered) 24 | 6. Administrator reviews the email and acts on the request by clicking Approve/Reject url links received in the email. Note: Ignoring the request for 12 hrs will automatically revoke the CloudFormation template. 25 | 7. Approving/Rejecting a request invokes a REST API backed by Lambda `approve-request`. 26 | 8. `approve-request` Lambda submits a POST request to respective CloudFormation `WaitHandle` url to resume the deployment of stack or rollback the stack. Lambda also updates the status in DynamoDB accordingly. 27 | 9. Once CloudFormation template is deployed/rollback, product launch request status is updated accordingly in Service Catalog. 28 | 10. Whenever Cost & Usage Report update is available, the report is stored in configured S3 Bucket. This Bucket is configured to trigger `rebase-budgets` Lambda, which in turn resets `budgetLimit`, `forecastedSpend` & `actualSpend` for every Business Entity in DynamoDB database 29 | 11. At the begining of every month, a CloudWatch Rule triggers `rebase-budgets` Lambda, which in turn resets `accruedApprovedSpend` for every Business Entity in DynamoDB database 30 | 31 | ## Project Structure 32 | 33 | This project contains source code and supporting files for a serverless application that you can deploy with the SAM CLI. It includes the following files and folders. 34 | 35 | - `save-request` - A Lambda functions which records the user's launch request in DynamoDB table. 36 | - `process-requests` - A Lambda function triggered by CloudWatch Rule at a pre-configured interval (default 5 mins). This Lambda is responsible for processing the requests that are in SAVED, PENDING & BLOCKED states. This Lambda also keeps track of internal ledgers and constantly re-evaluates the requests. 37 | - `approve-request` - A Lambda function used by the API Gateway to handle the requests when an Administrator approves/rejects the request using the links available in email notification. 38 | - `rebase-budgets` - A Lambda function that gets triggered in 2 different scenarios, whenever AWS CUR (Cost & Usage Reports) update is available or at the beginning of every calendar month. This Lambda is responsible to update the Master data with latest Budget Limits, Actual Spends and Forecasted Spend for a particular month. This Lambda is also responsible to reset the internal ledgers at beginning of each month. 39 | - `linux-ami-lookup` - A Generic Lambda function used to get the ami-id of Linux EC2 instance based on the inputs selected by the user. 40 | - `get-ec2-pricing` - A Generic Lambda function used to calculate the price of an EC2 instance based on the inputs selected by the user. 41 | - `ec2_approval_template.yaml` - A sample CloudFormation template that can be used to configure a sample Service Catalog Product. 42 | - `template.yaml` - A template that defines the application's AWS resources. 43 | - `master_data.py` - Sample master data that needs to be loaded to DynamoDB table. 44 | 45 | ## Database 46 | 47 | - DynamoDB table uses 2 partitions 48 | - BUDGET - used to represent metadata of a Business Entity 49 | - REQUEST - used to represent a Service Catalog Product Launch request 50 | - `budgetLimit` - Budget Limit for specific Business Entity maintained by the AWS Budgets Dashboard. Updated by `rebase-budgets` whenever there is a CUR data refersh. 51 | - `actualSpend` - Acutal Spend for specific Business Entity maintained by the AWS Budgets Dashboard. Updated by `rebase-budgets` whenever there is a CUR data refersh. 52 | - `forecastedSpend` - Forecasted Spend for specific Business Entity maintained by the AWS Budgets Dashboard. Updated by `rebase-budgets` whenever there is a CUR data refersh. 53 | - `accruedForecastedSpend` - Internally maintained ledger spend that stores the accruals of forecasted spend before Cost & Usage data udpate is available. This is managed by `process-requests` Lambda. 54 | - `accruedBlockedSpend` - Internally maintained ledger spend that stores the accruals of each requested product per Business Entity. Reset whenever a request is rejected. 55 | - `accruedApprovedSpend` - Internally maintained ledger spend that stores the accruals of each approved request per Business Entity. This is reset at begining of every calendar month by `rebase-budgets` Lambda. 56 | 57 | ## Prerequisites 58 | 59 | - Create a Fixed Monthly Budget in AWS Budgets 60 | - Cost & Usage Reports Enabled in AWS Budgets 61 | 62 | ## Deploying the Project 63 | 64 | ### 1. Deploy SAM application 65 | 66 | The Serverless Application Model Command Line Interface (SAM CLI) is an extension of the AWS CLI that adds functionality for building and testing Lambda applications. It uses Docker to run your functions in an Amazon Linux environment that matches Lambda. It can also emulate your application's build environment and API. 67 | 68 | To use the SAM CLI, you need the following tools. 69 | 70 | - SAM CLI - [Install the SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) 71 | - [Python 3 installed](https://www.python.org/downloads/) 72 | - Docker - [Install Docker community edition](https://hub.docker.com/search/?type=edition&offering=community) 73 | 74 | To build and deploy your application for the first time, run the following in your shell: 75 | 76 | ```bash 77 | sam build --use-container 78 | sam deploy --guided 79 | ``` 80 | 81 | The first command will build the source of your application. The second command will package and deploy your application to AWS, with a series of prompts: 82 | 83 | - **Stack Name**: The name of the stack to deploy to CloudFormation. This should be unique to your account and region, and a good starting point would be something matching your project name. 84 | - **AWS Region**: The AWS region you want to deploy your app to. 85 | - **ResourcePrefix**: A string that is used to prefix all AWS resources provisioned by this application. 86 | - **Confirm changes before deploy**: If set to yes, any change sets will be shown to you before execution for manual review. If set to no, the AWS SAM CLI will automatically deploy application changes. 87 | - **Allow SAM CLI IAM role creation**: Many AWS SAM templates, including this example, create AWS IAM roles required for the AWS Lambda function(s) included to access AWS services. By default, these are scoped down to minimum required permissions. To deploy an AWS CloudFormation stack which creates or modified IAM roles, the `CAPABILITY_NAMED_IAM` value for `capabilities` must be provided. If permission isn't provided through this prompt, to deploy this example you must explicitly pass `--capabilities CAPABILITY_NAMED_IAM` to the `sam deploy` command. 88 | - **Save arguments to samconfig.toml**: If set to yes, your choices will be saved to a configuration file inside the project, so that in the future you can just re-run `sam deploy` without parameters to deploy changes to your application. 89 | 90 | **Note:** Make sure to save the CloudFormation Outputs, you will need these in next steps. 91 | 92 | ### 2. Setup Amazon Simple Notification Service Topic 93 | 94 | For each business entity you would like to setup in the system, create a SNS topic and a email subscription to the topic with adminstrator's email address. 95 | 96 | [SNS documentation to create a SNS topic](https://docs.aws.amazon.com/sns/latest/dg/sns-tutorial-create-topic.html) 97 | 98 | ### 3. Load Master Data 99 | 100 | Update the name of the DynamoDB table in `master_data.py` file created in [Step 1.](#1-deploy-sam-application) You will find the name of the table as Cloudformatin outputs from deployed stack. 101 | 102 | For each business entity you would like to setup in the system, make an entry in `budgets` array in `master_data.py` file. 103 | 104 | JSON Object structure - 105 | 106 | ```python 107 | { 108 | "partitionKey": "BUDGET", 109 | "rangeKey": str(uuid.uuid4()), 110 | "budgetName": "", 111 | "budgetLimit": 123 # Update it with Budget Limit shown in the AWS Budgets Dashboard 112 | "actualSpend": 123 # Update it with Budget Actual Spend shown in the AWS Budget Dashboard 113 | "forecastedSpend": 123 # Update it with Forecasted Spend shown in the AWS Budget Dashboard 114 | "approverEmail": "", 115 | "notifySNSTopic": "", 116 | "accruedForecastedSpend": 0, 117 | "accruedBlockedSpend": 0, 118 | "accruedApprovedSpend": 0, 119 | "businessEntity": "", 120 | "budgetForecastProcessed": False, 121 | "budgetUpdatedAt": str(datetime.datetime.utcnow()) 122 | } 123 | ``` 124 | 125 | Once all the above placeholders is updated in `master_data.py` file, run the file from a terminal window. 126 | 127 | ### 4. Setup Portfolio & Product in Service Catalog 128 | 129 | #### 4.1. Refer following link to create a sample Service Catalog Portfolio & Product 130 | 131 | [Service Catalog Create Portfolio & Product (Refer Step 3 & Step 4)](https://docs.aws.amazon.com/servicecatalog/latest/adminguide/getstarted.html) 132 | 133 | While setting up the sample product, use the `ec2_approval_template.yaml` to setup the product. 134 | 135 | **Note:** Make sure to update the CloudFormation custom resources (`SaveRequestFunction`, `GetAMIInfo` & `GetEC2PricingInfo`) `ServiceToken` attribute. `ServiceToken` in the `ec2_approvaal_template.yaml` is configured to import outputs from the stack deployed in [Step 1](#1-deploy-sam-application). 136 | 137 | Following Import keys needs to be udpated in `ec2_approval_template.yaml` file - 138 | 139 | - `-SaveRequestLambda` 140 | - `-LinuxAMILookupLambda` 141 | - `-EC2PricingLambda` 142 | 143 | #### 4.2. Refer following link to create a Launch Constraint 144 | 145 | [Service Catalog Add a Launch Constraint (Refer Step 6)](https://docs.aws.amazon.com/servicecatalog/latest/adminguide/getstarted-launchconstraint.html) 146 | 147 | Navigate to Portfolio created in [Step 4.1](#41-refer-following-link-to-create-a-sample-service-catalog-portfolio--product) and create a Launch Constraint for Product created in [Step 4.1](#41-refer-following-link-to-create-a-sample-service-catalog-portfolio--product). Select IAM role created in [Step 1](#1-deploy-sam-application). Use CloudFormation Outputs `LaunchConstraintIAMRoleARN` 148 | 149 | ### 5. Create Cost & Usage Report in AWS Budgets 150 | 151 | **Note** Use the name of the S3 Bucket (Refer CloudFormation Outputs `CURBucketName`) created in [Step 1](#1-deploy-sam-application) to configure Cost & Usage Report. This report uploads to S3 Bucket and acts as a trigger to update the internal ledger maintained by the system. 152 | 153 | [Cost & Usage Report Creation Documentation](https://docs.aws.amazon.com/cur/latest/userguide/cur-create.html) 154 | 155 | ## Limitations 156 | 157 | - Internally maintained ledger for each Business Entity is not updated when a product is terminated in Service Catalog. 158 | - EBS volume pricing is not considered in the workflow. 159 | - User is not notified about the status of the launched Product. 160 | - Supports only Fixed Monthly Budget. 161 | 162 | ## Cleanup 163 | 164 | To delete the sample project that you created, use the AWS CLI. Assuming you used your project name for the stack name, you can run the following: 165 | 166 | ```bash 167 | aws CloudFormation delete-stack --stack-name "" 168 | ``` 169 | 170 | ## Security 171 | 172 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 173 | 174 | ## License 175 | 176 | This library is licensed under the MIT-0 License. See the LICENSE file. 177 | 178 | ## Resources 179 | 180 | See the [AWS SAM developer guide](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html) for an introduction to SAM specification, the SAM CLI, and serverless application concepts. 181 | 182 | See the [AWS Service Catalog](https://docs.aws.amazon.com/servicecatalog/latest/adminguide/introduction.html) for introduction to Service Catalog. 183 | 184 | See the [AWS Cloudformation](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/Welcome.html) for introduction to CloudFormation. -------------------------------------------------------------------------------- /approve-request/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cost-control-approval-workflow/b67b1648690cefca843835be617959484317563c/approve-request/__init__.py -------------------------------------------------------------------------------- /approve-request/app.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # 3 | # MIT No Attribution 4 | # 5 | # Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 8 | # software and associated documentation files (the "Software"), to deal in the Software 9 | # without restriction, including without limitation the rights to use, copy, modify, 10 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 11 | # permit persons to whom the Software is furnished to do so. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 14 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 15 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 16 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 17 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 18 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | # 20 | ################################################################################ 21 | import json 22 | import logging 23 | import os 24 | from datetime import datetime 25 | 26 | import boto3 27 | import requests 28 | 29 | logger = logging.getLogger() 30 | logger.setLevel(logging.INFO) 31 | region = os.environ['AWS_REGION'] 32 | budgets_table_name = os.environ['BudgetsTable'] 33 | dynamodb = boto3.resource('dynamodb', region_name=region) 34 | budgets_table = dynamodb.Table(budgets_table_name) 35 | request_partition = 'REQUEST' 36 | budget_partition = 'BUDGET' 37 | 38 | 39 | def lambda_handler(event, context): 40 | logger.info(json.dumps(event)) 41 | success_response_data = { 42 | "Status": "SUCCESS", 43 | "Reason": "Approved", 44 | "UniqueId": 'None', 45 | "Data": "Owner approved the stack creation" 46 | } 47 | if event['queryStringParameters'] and 'requestId' in event['queryStringParameters'] and 'requestStatus' in event['queryStringParameters']: 48 | request_id = event['queryStringParameters']['requestId'] 49 | request_status = event['queryStringParameters']['requestStatus'] 50 | success_response_data['UniqueId'] = request_id 51 | request = get_request_item(request_id) 52 | requested_amt = request['pricingInfoAtRequest']['EstCurrMonthPrice'] 53 | business_entity_id = request['businessEntityId'] 54 | budget = get_budgets_for_request(business_entity_id) 55 | accrued_blocked = budget['accruedBlockedSpend'] 56 | accrued_forecast = budget['accruedForecastedSpend'] 57 | accrued_approved = budget['accruedApprovedSpend'] 58 | requested_amt_monthly = request['pricingInfoAtRequest']['31DayPrice'] 59 | wait_url = request['stackWaitUrl'] 60 | try: 61 | logger.info("Accruals before processing the request Blocked: {}, Forecasted: {}, Approved: {}".format(accrued_blocked, accrued_forecast, accrued_approved)) 62 | if request['requestStatus'] in ['PENDING', 'BLOCKED']: 63 | if request_status == "Approve": 64 | success_response_data['Status'] = "SUCCESS" 65 | update_approval_request_status(request_id) 66 | # Recalculate the accruals and move the requested amt to forecasted from blocked 67 | accrued_blocked = accrued_blocked - requested_amt_monthly 68 | accrued_forecast = accrued_forecast + requested_amt 69 | accrued_approved = accrued_approved + (requested_amt_monthly - requested_amt) 70 | update_accrued_amt(business_entity_id, accrued_forecast, accrued_blocked, accrued_approved) 71 | elif request_status == "Reject": 72 | success_response_data['Status'] = "FAILURE" 73 | success_response_data['Reason'] = "Rejected" 74 | success_response_data['Data'] = "Admin rejected the stack" 75 | update_rejection_request_status(request_id) 76 | # Remove the blocked amount since request is rejected 77 | accrued_blocked = accrued_blocked - requested_amt_monthly 78 | update_accrued_amt(business_entity_id, accrued_forecast, accrued_blocked, accrued_approved) 79 | 80 | response = requests.put(wait_url, data=json.dumps(success_response_data)) 81 | logger.info("Successfully responded for wait handle with response: {}".format(response)) 82 | else: 83 | logger.info('Request can abe approved/rejected only when it is in blocked or pending state') 84 | except Exception as e: 85 | logger.error("Failed approving the request: {}".format(e)) 86 | response = {"data": 'Successfully Processed the request'} 87 | return {'statusCode': '200', 'body': json.dumps(response)} 88 | else: 89 | response = {"error": 'Mandatory request parameters not found'} 90 | return {'statusCode': '200', 'body': json.dumps(response)} 91 | 92 | 93 | # updates the rejection status in database 94 | def update_rejection_request_status(request_id): 95 | logger.info('Received request to terminate a stack with request id: {}'.format(request_id)) 96 | response = budgets_table.update_item( 97 | Key={'partitionKey': request_partition, 'rangeKey': request_id}, 98 | UpdateExpression="set requestStatus = :s, requestRejectionTime=:a, resourceStatus=:r", 99 | ExpressionAttributeValues={ 100 | ':s': 'REJECTED_ADMIN', 101 | ':a': str(datetime.utcnow()), 102 | ':r': 'REJECTED' 103 | }, 104 | ReturnValues="UPDATED_NEW" 105 | ) 106 | logger.debug("UpdateItem succeeded:") 107 | logger.debug(json.dumps(response)) 108 | 109 | 110 | # Update the status of the request in dynamo-db 111 | def update_approval_request_status(request_id): 112 | response = budgets_table.update_item( 113 | Key={'partitionKey': request_partition, 'rangeKey': request_id}, 114 | UpdateExpression="set requestStatus = :s, requestApprovalTime=:a, resourceStatus=:r", 115 | ExpressionAttributeValues={ 116 | ':s': 'APPROVED_ADMIN', 117 | ':a': str(datetime.utcnow()), 118 | ':r': 'ACTIVE' 119 | }, 120 | ReturnValues="UPDATED_NEW" 121 | ) 122 | logger.debug("UpdateItem succeeded:") 123 | logger.debug(json.dumps(response)) 124 | 125 | 126 | # Get the request item for a given request id 127 | def get_request_item(request_id): 128 | response = budgets_table.get_item( 129 | Key={'partitionKey': request_partition, 'rangeKey': request_id}, 130 | ProjectionExpression='stackWaitUrl, requestStatus, businessEntityId, pricingInfoAtRequest' 131 | ) 132 | return response['Item'] 133 | 134 | 135 | # Update the Accruals in database 136 | def update_accrued_amt(business_entity_id, accrued_forecasted_spend, accrued_blocked_spend, accrued_approved_spend): 137 | logger.info("Update the Budget with new accrued amounts Blocked: {}, Forecasted: {}, Approved: {}".format(accrued_blocked_spend, accrued_forecasted_spend, accrued_approved_spend)) 138 | update_expression = "set accruedForecastedSpend=:a, accruedBlockedSpend=:b, accruedApprovedSpend=:c" 139 | expression_attributes = { 140 | ':a': accrued_forecasted_spend, 141 | ':b': accrued_blocked_spend, 142 | ':c': accrued_approved_spend 143 | } 144 | response = budgets_table.update_item( 145 | Key={'partitionKey': budget_partition, 'rangeKey': business_entity_id}, 146 | UpdateExpression=update_expression, 147 | ExpressionAttributeValues=expression_attributes, 148 | ReturnValues="UPDATED_NEW" 149 | ) 150 | logger.info('Successfully Updated accrued Amt for Key: {} with response {}'.format(business_entity_id, response)) 151 | return True 152 | 153 | 154 | # Gets the Budget information for a given business entity Id 155 | def get_budgets_for_request(business_entity_id): 156 | response = budgets_table.get_item( 157 | Key={'partitionKey': budget_partition, 'rangeKey': business_entity_id}, 158 | ProjectionExpression='accruedForecastedSpend, accruedBlockedSpend, accruedApprovedSpend' 159 | ) 160 | return response['Item'] 161 | -------------------------------------------------------------------------------- /approve-request/requirements.txt: -------------------------------------------------------------------------------- 1 | requests -------------------------------------------------------------------------------- /architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cost-control-approval-workflow/b67b1648690cefca843835be617959484317563c/architecture.png -------------------------------------------------------------------------------- /ec2_approval_template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: 'A sample AWS Cloudformation template to demonstrate a provisioning of EC2 Linux Instance from defined list of instance types. 3 | This template has a wait condition which can be used to trigger a approval workflow. This template depends on a stack to import values 4 | that are required to lookup a linux ami id and also trigger approval workflow' 5 | Metadata: 6 | LICENSE: >- 7 | MIT No Attribution 8 | 9 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 12 | software and associated documentation files (the "Software"), to deal in the Software 13 | without restriction, including without limitation the rights to use, copy, modify, 14 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 15 | permit persons to whom the Software is furnished to do so. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 18 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 19 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 20 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | Parameters: 24 | BusinessEntity: 25 | Type: String 26 | AllowedValues: 27 | - business_entity_1 28 | - business_entity_2 29 | - business_entity_3 30 | - business_entity_4 31 | ConstraintDescription: must be the name of the business entity 32 | Description: Name of the Business Entity 33 | Default: business_entity_1 34 | InstanceType: 35 | AllowedValues: 36 | - t2.nano 37 | - t2.micro 38 | - t2.small 39 | - t2.medium 40 | - t2.large 41 | - t2.xlarge 42 | - m4.large 43 | - m4.xlarge 44 | - m4.2xlarge 45 | - m4.4xlarge 46 | - m4.10xlarge 47 | - c4.large 48 | - c4.xlarge 49 | - c4.2xlarge 50 | - c4.4xlarge 51 | - c4.8xlarge 52 | - r3.large 53 | - r3.xlarge 54 | - r3.2xlarge 55 | - r3.4xlarge 56 | - r3.8xlarge 57 | - i2.xlarge 58 | - i2.2xlarge 59 | - i2.4xlarge 60 | - i2.8xlarge 61 | - d2.xlarge 62 | - d2.2xlarge 63 | - d2.4xlarge 64 | - d2.8xlarge 65 | ConstraintDescription: must be a valid EC2 instance type. 66 | Default: t2.small 67 | Description: EC2 instance type 68 | Type: String 69 | KeyName: 70 | ConstraintDescription: must be the name of an existing EC2 KeyPair. 71 | Description: Name of an existing EC2 KeyPair to enable SSH access to the instances 72 | Type: AWS::EC2::KeyPair::KeyName 73 | SSHLocation: 74 | AllowedPattern: (\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})/(\d{1,2}) 75 | ConstraintDescription: must be a valid IP CIDR range of the form x.x.x.x/x. 76 | Default: 0.0.0.0/0 77 | Description: The IP address range that can be used to SSH to the EC2 instances 78 | MaxLength: '18' 79 | MinLength: '9' 80 | Type: String 81 | UserEmail: 82 | AllowedPattern: '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}' 83 | ConstraintDescription: This is not a valid email id. 84 | Default: "abc.xyz@email.com" 85 | Description: Enter your Email ID. You will be contacted by approver for more information. 86 | MaxLength: '64' 87 | MinLength: '1' 88 | Type: String 89 | Mappings: 90 | AWSInstanceType2Arch: 91 | t2.micro: 92 | Arch: HVM64 93 | t2.small: 94 | Arch: HVM64 95 | t2.medium: 96 | Arch: HVM64 97 | t2.nano: 98 | Arch: HVM64 99 | t2.xlarge: 100 | Arch: HVM64 101 | t2.large: 102 | Arch: HVM64 103 | t2.2xlarge: 104 | Arch: HVM64 105 | m4.large: 106 | Arch: HVM64 107 | m4.xlarge: 108 | Arch: HVM64 109 | m4.2xlarge: 110 | Arch: HVM64 111 | m4.4xlarge: 112 | Arch: HVM64 113 | m4.10xlarge: 114 | Arch: HVM64 115 | c4.large: 116 | Arch: HVM64 117 | c4.xlarge: 118 | Arch: HVM64 119 | c4.2xlarge: 120 | Arch: HVM64 121 | c4.4xlarge: 122 | Arch: HVM64 123 | c4.8xlarge: 124 | Arch: HVM64 125 | r3.large: 126 | Arch: HVM64 127 | r3.xlarge: 128 | Arch: HVM64 129 | r3.2xlarge: 130 | Arch: HVM64 131 | r3.4xlarge: 132 | Arch: HVM64 133 | r3.8xlarge: 134 | Arch: HVM64 135 | i2.xlarge: 136 | Arch: HVM64 137 | i2.2xlarge: 138 | Arch: HVM64 139 | i2.4xlarge: 140 | Arch: HVM64 141 | i2.8xlarge: 142 | Arch: HVM64 143 | d2.xlarge: 144 | Arch: HVM64 145 | d2.2xlarge: 146 | Arch: HVM64 147 | d2.4xlarge: 148 | Arch: HVM64 149 | d2.8xlarge: 150 | Arch: HVM64 151 | Resources: 152 | WaitHandle: 153 | Type: 'AWS::CloudFormation::WaitConditionHandle' 154 | WaitCondition: 155 | Type: 'AWS::CloudFormation::WaitCondition' 156 | Properties: 157 | Handle: 158 | Ref: 'WaitHandle' 159 | Timeout: '43200' 160 | SaveRequestFunction: 161 | Type: Custom::SaveRequestFunction 162 | Properties: 163 | ServiceToken: 164 | !ImportValue "-SaveRequestLambda" # Replace with output of CloudFormation template deployed in Step 1 165 | WaitUrl: !Ref WaitHandle 166 | EmailID: !Ref UserEmail 167 | ImageId: !GetAtt GetAMIInfo.Id 168 | InstanceType: !Ref InstanceType 169 | ProductName: EC2-LINUX 170 | BusinessEntity: !Ref BusinessEntity 171 | StackName: !Ref AWS::StackName 172 | EC2Pricing: !GetAtt GetEC2PricingInfo.Pricing 173 | GetAMIInfo: 174 | Type: Custom::GetAMIInfo 175 | Properties: 176 | ServiceToken: 177 | !ImportValue "-LinuxAMILookupLambda" # Replace with output of CloudFormation template deployed in Step 1 178 | Architecture: !FindInMap [AWSInstanceType2Arch, !Ref InstanceType, Arch] 179 | GetEC2PricingInfo: 180 | Type: Custom::GetEC2PricingInfo 181 | Properties: 182 | ServiceToken: 183 | !ImportValue "-EC2PricingLambda" # Replace with output of CloudFormation template deployed in Step 1 184 | InstanceType: !Ref InstanceType 185 | OperatingSystem: Linux 186 | TermType: OnDemand 187 | LinuxEC2Instance: 188 | Type: AWS::EC2::Instance 189 | DependsOn: 'WaitCondition' 190 | Properties: 191 | ImageId: !GetAtt GetAMIInfo.Id 192 | InstanceType: 193 | Ref: InstanceType 194 | KeyName: 195 | Ref: KeyName 196 | SecurityGroups: 197 | - Ref: LinuxEC2SecurityGroup 198 | Tags: 199 | - Key: business-entity 200 | Value: !Ref BusinessEntity 201 | LinuxEC2SecurityGroup: 202 | Type: AWS::EC2::SecurityGroup 203 | Metadata: 204 | cfn_nag: 205 | rules_to_suppress: 206 | - id: W40 207 | reason: 'this is a sample template demonstrating ec2 in public subnet' 208 | DependsOn: 'WaitCondition' 209 | Properties: 210 | GroupDescription: "Enable ssh access via port 22 to specified CIDR" 211 | SecurityGroupEgress: 212 | - IpProtocol: -1 213 | Description: 'allow outbound traffic' 214 | SecurityGroupIngress: 215 | - CidrIp: !Ref SSHLocation 216 | Description: 'allow ssh access' 217 | FromPort: '22' 218 | IpProtocol: tcp 219 | ToPort: '22' 220 | Outputs: 221 | EC2PublicIP: 222 | Description: Requested EC2 public IP 223 | Value: !GetAtt LinuxEC2Instance.PublicIp 224 | -------------------------------------------------------------------------------- /get-ec2-pricing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cost-control-approval-workflow/b67b1648690cefca843835be617959484317563c/get-ec2-pricing/__init__.py -------------------------------------------------------------------------------- /get-ec2-pricing/app.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # 3 | # MIT No Attribution 4 | # 5 | # Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 8 | # software and associated documentation files (the "Software"), to deal in the Software 9 | # without restriction, including without limitation the rights to use, copy, modify, 10 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 11 | # permit persons to whom the Software is furnished to do so. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 14 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 15 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 16 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 17 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 18 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | # 20 | ################################################################################ 21 | # Reference - https://s3.amazonaws.com/cloudformation-examples/lambda/amilookup.zip 22 | import calendar 23 | import datetime 24 | import logging 25 | import os 26 | from decimal import Decimal 27 | 28 | import boto3 29 | import requests 30 | import simplejson as json 31 | 32 | logger = logging.getLogger() 33 | logger.setLevel(logging.INFO) 34 | region = os.environ['AWS_REGION'] 35 | 36 | 37 | def lambda_handler(event, context): 38 | # Do not do anything for CFN Update and Delete 39 | if 'RequestType' in event and event['RequestType'] != 'Create': 40 | send_response(event, context, 'SUCCESS', {}) 41 | return 42 | 43 | logger.info(json.dumps(event)) 44 | event_payload = event["ResourceProperties"] 45 | instance_type = event_payload['InstanceType'] 46 | term_type = event_payload['TermType'] 47 | operating_system = event_payload['OperatingSystem'] 48 | hours_left = hours_left_for_current_month() 49 | next_month_hrs = hours_for_next_month() 50 | logger.info("# of Hrs left for this month {}".format(hours_left)) 51 | unit_price = get_price_from_api(operating_system, instance_type, region, term_type) 52 | logger.info("Unit Price {}".format(unit_price)) 53 | monthly_price = hours_left * unit_price 54 | monthly_avg = 31 * 24 * unit_price 55 | next_month_price = next_month_hrs * unit_price 56 | logger.info("Monthly Price: {}".format(monthly_price)) 57 | result = { 58 | 'Pricing': { 59 | 'OperatingSystem': operating_system, 60 | 'TermType': term_type, 61 | 'InstanceType': instance_type, 62 | 'UnitPrice': unit_price, 63 | 'EstCurrMonthPrice': monthly_price, 64 | '31DayPrice': monthly_avg, 65 | 'NextMonthPrice': next_month_price, 66 | 'HoursLeftInCurrMonth': hours_left, 67 | 'ResponseTime': str(datetime.datetime.utcnow()), 68 | } 69 | } 70 | send_response(event, context, 'SUCCESS', result) 71 | return result 72 | # instCost = Decimal(str(round(Decimal(getHoursLeft()*instCost),2))) 73 | 74 | 75 | # Get total # of hrs for next month 76 | def hours_for_next_month(): 77 | now = datetime.datetime.utcnow() 78 | month = now.month 79 | year = now.year 80 | next_month = month + 1 81 | if month == 12: 82 | year = year + 1 83 | if next_month > 12: 84 | next_month = next_month % 12 85 | return calendar.monthrange(year, next_month)[1] * 24 86 | 87 | 88 | # Get total # of hrs left in current month 89 | def hours_left_for_current_month(): 90 | now = datetime.datetime.utcnow() 91 | total_hours_in_cur_month = calendar.monthrange(now.year, now.month)[1] * 24 92 | hours_consumed_in_cur_month = ((now.day - 1) * 24) + now.hour 93 | hours_left = total_hours_in_cur_month - hours_consumed_in_cur_month 94 | return hours_left 95 | 96 | 97 | # Get region code 98 | def region_lookup(region_name): 99 | lookup = { 100 | 'us-west-1': "US West (N. California)", 101 | 'us-west-2': "US West (Oregon)", 102 | 'us-east-1': "US East (N. Virginia)", 103 | 'us-east-2': "US East (Ohio)", 104 | 'ca-central-1': "Canada (Central)", 105 | 'ap-south-1': "Asia Pacific (Mumbai)", 106 | 'ap-northeast-2': "Asia Pacific (Seoul)", 107 | 'ap-southeast-1': "Asia Pacific (Singapore)", 108 | 'ap-southeast-2': "Asia Pacific (Sydney)", 109 | 'ap-northeast-1': "Asia Pacific (Tokyo)", 110 | 'eu-central-1': "EU (Frankfurt)", 111 | 'eu-west-1': "EU (Ireland)", 112 | 'eu-west-2': "EU (London)", 113 | 'sa-east-1': "South America (Sao Paulo)", 114 | 'us-gov-west-1': "GovCloud (US)", 115 | } 116 | return lookup.get(region_name.lower(), "Region Not Found") 117 | 118 | 119 | # Send response back to CFN hook about the status of the function 120 | def send_response(event, context, response_status, response_data): 121 | response_body = { 122 | 'Status': response_status, 123 | 'Reason': 'See the details in CloudWatch Log Stream ' + context.log_stream_name, 124 | 'PhysicalResourceId': context.log_stream_name, 125 | 'StackId': event['StackId'], 126 | 'RequestId': event['RequestId'], 127 | 'LogicalResourceId': event['LogicalResourceId'], 128 | 'Data': response_data, 129 | } 130 | try: 131 | response = requests.put(event['ResponseURL'], data=json.dumps(response_body, use_decimal=True)) 132 | return True 133 | except Exception as e: 134 | logger.info("Failed executing HTTP request: {}".format(e)) 135 | return False 136 | 137 | 138 | # Function to get the price of the EC2 Instance 139 | def get_price_from_api(oper_sys, instance_type, region_name, term_type): 140 | try: 141 | pricing = boto3.client('pricing', region_name='us-east-1') 142 | logger.info("instance: {}".format(instance_type)) 143 | search_filters = [ 144 | { 145 | 'Type': 'TERM_MATCH', 146 | 'Field': 'tenancy', 147 | 'Value': 'Shared' 148 | }, 149 | { 150 | 'Type': 'TERM_MATCH', 151 | 'Field': 'location', 152 | 'Value': region_lookup(region_name) 153 | }, 154 | { 155 | 'Type': 'TERM_MATCH', 156 | 'Field': 'operatingSystem', 157 | 'Value': oper_sys 158 | }, 159 | { 160 | 'Type': 'TERM_MATCH', 161 | 'Field': 'preInstalledSw', 162 | 'Value': 'NA' 163 | }, 164 | { 165 | 'Type': 'TERM_MATCH', 166 | 'Field': 'termType', 167 | 'Value': term_type 168 | }, 169 | {'Type': 'TERM_MATCH', 'Field': 'capacityStatus', 'Value': 'Used'}, 170 | { 171 | 'Type': 'TERM_MATCH', 172 | 'Field': 'instanceType', 173 | 'Value': instance_type 174 | } 175 | ] 176 | # windows adds an extra license filter 177 | if 'Windows' in oper_sys: 178 | search_filters.append({"Type": "TERM_MATCH", "Field": "licenseModel", "Value": "No License required"}) 179 | response = pricing.get_products( 180 | ServiceCode='AmazonEC2', # required 181 | Filters=search_filters, 182 | FormatVersion='aws_v1', # optional 183 | NextToken='', # optional 184 | MaxResults=20 # optional 185 | ) 186 | if len(response['PriceList']) > 1: 187 | logger.info("Pricing list has more than one entry, considering first entry") 188 | elif len(response['PriceList']) == 0: 189 | logger.info("Couldn't query pricing with given filters") 190 | resp_json = json.loads(response['PriceList'][0]) 191 | price = 0 192 | for key, value in resp_json['terms'][term_type].items(): 193 | logger.info("Reading Price for termType {}, key {}".format(term_type, key)) 194 | for dim_key, dim_value in value['priceDimensions'].items(): 195 | logger.info("Reading Price for dimension key {}".format(dim_key)) 196 | price = dim_value['pricePerUnit']['USD'] 197 | return Decimal(price) 198 | except Exception as e: 199 | print(e) 200 | raise e 201 | -------------------------------------------------------------------------------- /get-ec2-pricing/requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | simplejson -------------------------------------------------------------------------------- /linux-ami-lookup/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT No Attribution 3 | * 4 | * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 7 | * software and associated documentation files (the "Software"), to deal in the Software 8 | * without restriction, including without limitation the rights to use, copy, modify, 9 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 10 | * permit persons to whom the Software is furnished to do so. 11 | * 12 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 13 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 14 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 15 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 16 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 17 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | */ 19 | // Map instance architectures to an AMI name pattern 20 | var archToAMINamePattern = { 21 | "PV64": "amzn-ami-pv*x86_64-ebs", 22 | "HVM64": "amzn-ami-hvm*x86_64-gp2", 23 | "HVMG2": "amzn-ami-graphics-hvm*x86_64-ebs*" 24 | }; 25 | var aws = require("aws-sdk"); 26 | 27 | exports.handler = function(event, context) { 28 | 29 | console.log("REQUEST RECEIVED:\n" + JSON.stringify(event)); 30 | 31 | // For Delete requests, immediately send a SUCCESS response. 32 | if (event.RequestType == "Delete") { 33 | sendResponse(event, context, "SUCCESS"); 34 | return; 35 | } 36 | 37 | var responseStatus = "FAILED"; 38 | var responseData = {}; 39 | 40 | var ec2 = new aws.EC2({region: process.env.AWS_REGION}); 41 | var describeImagesParams = { 42 | Filters: [{ Name: "name", Values: [archToAMINamePattern[event.ResourceProperties.Architecture]]}], 43 | Owners: [event.ResourceProperties.Architecture == "HVMG2" ? "679593333241" : "amazon"] 44 | }; 45 | 46 | // Get AMI IDs with the specified name pattern and owner 47 | ec2.describeImages(describeImagesParams, function(err, describeImagesResult) { 48 | if (err) { 49 | responseData = {Error: "DescribeImages call failed"}; 50 | console.log(responseData.Error + ":\n", err); 51 | } 52 | else { 53 | var images = describeImagesResult.Images; 54 | // Sort images by name in decscending order. The names contain the AMI version, formatted as YYYY.MM.Ver. 55 | images.sort(function(x, y) { return y.Name.localeCompare(x.Name); }); 56 | for (var j = 0; j < images.length; j++) { 57 | if (isBeta(images[j].Name)) continue; 58 | responseStatus = "SUCCESS"; 59 | responseData["Id"] = images[j].ImageId; 60 | responseData["Volumes"] = images[j].BlockDeviceMappings; 61 | break; 62 | } 63 | } 64 | console.log("Response Data: "+JSON.stringify(responseData)); 65 | sendResponse(event, context, responseStatus, responseData); 66 | }); 67 | }; 68 | // Check if the image is a beta or rc image. The Lambda function won't return any of those images. 69 | function isBeta(imageName) { 70 | return imageName.toLowerCase().indexOf("beta") > -1 || imageName.toLowerCase().indexOf(".rc") > -1; 71 | } 72 | // Send response to the pre-signed S3 URL 73 | function sendResponse(event, context, responseStatus, responseData) { 74 | var responseBody = JSON.stringify({ 75 | Status: responseStatus, 76 | Reason: "See the details in CloudWatch Log Stream: " + context.logStreamName, 77 | PhysicalResourceId: context.logStreamName, 78 | StackId: event.StackId, 79 | RequestId: event.RequestId, 80 | LogicalResourceId: event.LogicalResourceId, 81 | Data: responseData 82 | }); 83 | 84 | console.log("RESPONSE BODY:\n", responseBody); 85 | 86 | var https = require("https"); 87 | var url = require("url"); 88 | 89 | var parsedUrl = url.parse(event.ResponseURL); 90 | var options = { 91 | hostname: parsedUrl.hostname, 92 | port: 443, 93 | path: parsedUrl.path, 94 | method: "PUT", 95 | headers: { 96 | "content-type": "", 97 | "content-length": responseBody.length 98 | } 99 | }; 100 | 101 | console.log("SENDING RESPONSE...\n"); 102 | 103 | var request = https.request(options, function(response) { 104 | console.log("STATUS: " + response.statusCode); 105 | console.log("HEADERS: " + JSON.stringify(response.headers)); 106 | // Tell AWS Lambda that the function execution is done 107 | context.done(); 108 | }); 109 | 110 | request.on("error", function(error) { 111 | console.log("sendResponse Error:" + error); 112 | // Tell AWS Lambda that the function execution is done 113 | context.done(); 114 | }); 115 | 116 | // write data to request body 117 | request.write(responseBody); 118 | request.end(); 119 | } -------------------------------------------------------------------------------- /linux-ami-lookup/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@aws-samples/linux-ami-lookup", 3 | "version": "1.0.0", 4 | "description": "lambda function used to lookup a linux ec2 instance ami id", 5 | "main": "index.js" 6 | } 7 | -------------------------------------------------------------------------------- /master_data.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | import uuid 3 | import datetime 4 | 5 | dynamodb = boto3.resource('dynamodb', region_name='') # TODO:: Update with aws-region where thes stack is deployed 6 | table = dynamodb.Table('aws-samples-budgets') # TODO:: Once stack is deployed, update the DynamoDB Table Name 7 | 8 | def insert_data(db_item): 9 | table.put_item(Item=db_item) 10 | 11 | budgets = [ 12 | { 13 | "partitionKey": "BUDGET", 14 | "rangeKey": str(uuid.uuid4()), 15 | "budgetName": "bu1-monthly-budget", 16 | "budgetLimit": 0, 17 | "actualSpend": 0, 18 | "forecastedSpend": 0, 19 | "approverEmail": "admin1@email.com", # Email address of the admin for the business unit 20 | "notifySNSTopic": "arn:aws:sns:ap-south-1:1234567891235:approval-notification", # Update the SNS notification for the business unit 21 | "accruedForecastedSpend": 0, 22 | "accruedBlockedSpend": 0, 23 | "accruedApprovedSpend": 0, 24 | "businessEntity": "business_entity_1", 25 | "budgetForecastProcessed": False, 26 | "budgetUpdatedAt": str(datetime.datetime.utcnow()) 27 | }, 28 | { 29 | "partitionKey": "BUDGET", 30 | "rangeKey": str(uuid.uuid4()), 31 | "budgetName": "bu2-monthly-budget", 32 | "budgetLimit": 0, 33 | "actualSpend": 0, 34 | "forecastedSpend": 0, 35 | "approverEmail": "admin2@email.com", # Email address of the admin for the business unit 36 | "notifySNSTopic": "arn:aws:sns:ap-south-1:1234567891235:approval-notification", # Update the SNS notification for the business unit 37 | "accruedForecastedSpend": 0, 38 | "accruedBlockedSpend": 0, 39 | "accruedApprovedSpend": 0, 40 | "businessEntity": "business_entity_2", 41 | "budgetForecastProcessed": False, 42 | "budgetUpdatedAt": str(datetime.datetime.utcnow()) 43 | }, 44 | { 45 | "partitionKey": "BUDGET", 46 | "rangeKey": str(uuid.uuid4()), 47 | "budgetName": "bu3-monthly-budget", 48 | "budgetLimit": 0, 49 | "actualSpend": 0, 50 | "forecastedSpend": 0, 51 | "approverEmail": "admin3@email.com", # Email address of the admin for the business unit 52 | "notifySNSTopic": "arn:aws:sns:ap-south-1:1234567891235:approval-notification", # Update the SNS notification for the business unit 53 | "accruedForecastedSpend": 0, 54 | "accruedBlockedSpend": 0, 55 | "accruedApprovedSpend": 0, 56 | "businessEntity": "business_entity_3", 57 | "budgetForecastProcessed": False, 58 | "budgetUpdatedAt": str(datetime.datetime.utcnow()) 59 | }, 60 | { 61 | "partitionKey": "BUDGET", 62 | "rangeKey": str(uuid.uuid4()), 63 | "budgetName": "bu4-monthly-budget", 64 | "budgetLimit": 0, 65 | "actualSpend": 0, 66 | "forecastedSpend": 0, 67 | "approverEmail": "admin4@email.com", # Email address of the admin for the business unit 68 | "notifySNSTopic": "arn:aws:sns:ap-south-1:1234567891235:approval-notification", # Update the SNS notification for the business unit 69 | "accruedForecastedSpend": 0, 70 | "accruedBlockedSpend": 0, 71 | "accruedApprovedSpend": 0, 72 | "businessEntity": "business_entity_4", 73 | "budgetForecastProcessed": False, 74 | "budgetUpdatedAt": str(datetime.datetime.utcnow()) 75 | } 76 | ] 77 | 78 | for item in budgets: 79 | insert_data(item) 80 | -------------------------------------------------------------------------------- /process-requests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cost-control-approval-workflow/b67b1648690cefca843835be617959484317563c/process-requests/__init__.py -------------------------------------------------------------------------------- /process-requests/app.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # 3 | # MIT No Attribution 4 | # 5 | # Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 8 | # software and associated documentation files (the "Software"), to deal in the Software 9 | # without restriction, including without limitation the rights to use, copy, modify, 10 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 11 | # permit persons to whom the Software is furnished to do so. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 14 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 15 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 16 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 17 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 18 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | # 20 | ################################################################################ 21 | import calendar 22 | import json 23 | import logging 24 | import os 25 | from datetime import datetime 26 | 27 | import boto3 28 | import requests 29 | from boto3.dynamodb.conditions import Key 30 | 31 | logger = logging.getLogger() 32 | logger.setLevel(logging.INFO) 33 | region = os.environ['AWS_REGION'] 34 | budgets_table_name = os.environ['BudgetsTable'] 35 | dynamodb = boto3.resource('dynamodb', region_name=region) 36 | budgets_table = dynamodb.Table(budgets_table_name) 37 | sns = boto3.resource('sns') 38 | budgets_partition_key = 'BUDGET' 39 | requests_partition_key = 'REQUEST' 40 | saved_req_status = 'SAVED' 41 | pending_req_status = 'PENDING' 42 | blocked_req_status = 'BLOCKED' 43 | 44 | 45 | def lambda_handler(event, context): 46 | logger.info(json.dumps(event)) 47 | # Get Budget Info 48 | budget_info = get_budget_info() 49 | # convert List to Dict for easier lookup 50 | budget_dict = {} 51 | update_budget_accruals = False 52 | for budget in budget_info: 53 | business_entity = budget['businessEntity'] 54 | if not budget['budgetForecastProcessed']: 55 | logger.info("New Forecast Available for {}, replacing the accruedForecast with forecast from AWS budgets".format(business_entity)) 56 | budget['accruedForecastedSpend'] = budget['forecastedSpend'] 57 | update_budget_accruals = True 58 | else: 59 | logger.info("No Budget updated available for {} ".format(business_entity)) 60 | budget_dict[business_entity] = budget 61 | logger.info("Local Dictionary for Budgets: {}".format(budget_dict)) 62 | # Get Request that are in pending state 63 | pending_requests = get_requests(pending_req_status) 64 | 65 | for pending_request in pending_requests: 66 | business_entity = pending_request['businessEntity'] 67 | if 'pendingRequestExists' in budget_dict[business_entity]: 68 | # there could be multiple requests for same business Entity, just skips those 69 | continue 70 | else: 71 | budget_dict[business_entity]['pendingRequestExists'] = True 72 | 73 | pending_request_count = len(pending_requests) 74 | if pending_request_count > 0: 75 | # recompute blocked requests to see if there is a change in forecast 76 | process_requests(pending_requests, budget_dict) 77 | update_budget_accruals = True 78 | 79 | # Get if there are any blocked requests 80 | blocked_requests = get_requests(blocked_req_status) 81 | 82 | blocked_request_count = len(blocked_requests) 83 | if blocked_request_count > 0: 84 | # Process blocked requests for each Business Entity 85 | process_requests(blocked_requests, budget_dict) 86 | update_budget_accruals = True 87 | 88 | # Get requests in SAVED state 89 | saved_requests = get_requests(saved_req_status) 90 | 91 | saved_request_count = len(saved_requests) 92 | if saved_request_count > 0: 93 | # process requests that are in saved state 94 | process_requests(saved_requests, budget_dict) 95 | update_budget_accruals = True 96 | 97 | if update_budget_accruals: 98 | logger.info("Updating Budgets Accruals") 99 | # update the budgets with newly calculated accrued amts 100 | update_accrued_amt(budget_dict) 101 | 102 | 103 | def process_requests(requests, budget_dict): 104 | for request in requests: 105 | request_id = request['rangeKey'] 106 | budget = budget_dict[request['businessEntity']] 107 | logger.info("Available Budget while processing request {} is {}".format(request_id, budget)) 108 | budget_amt = budget['budgetLimit'] 109 | curr_req_status = request['requestStatus'] 110 | requested_amt = request['pricingInfoAtRequest']['EstCurrMonthPrice'] # EstCurrMonthPrice 111 | requested_amt_monthly = request['pricingInfoAtRequest']['31DayPrice'] # EstCurrMonthPrice 112 | logger.info("Pricing info for request {} is {}".format(request_id, request['pricingInfoAtRequest'])) 113 | blocked_amt = budget['accruedBlockedSpend'] 114 | approved_amt = budget['accruedApprovedSpend'] 115 | forecast_spend = budget['accruedForecastedSpend'] if budget['accruedForecastedSpend'] > 0 else budget['forecastedSpend'] 116 | remaining_amt = budget_amt - forecast_spend - requested_amt_monthly - blocked_amt - approved_amt 117 | logger.info("Remaining Amount for request {} after calculation is {}".format(request_id, remaining_amt)) 118 | if remaining_amt < 0: 119 | logger.info("No Enough budget left for request {}".format(request_id)) 120 | if curr_req_status == saved_req_status: 121 | logger.info("Request is in SAVED state, adjusting the local accruals before further processing... Request Id : {}".format(request_id)) 122 | budget['accruedBlockedSpend'] = blocked_amt + requested_amt_monthly 123 | if not 'pendingRequestExists' in budget or not budget['pendingRequestExists'] or ( 124 | not budget['budgetForecastProcessed'] and curr_req_status == pending_req_status): 125 | logger.info("There is no pending request exist for business entity or there is a pricing rebase.. update the status and notify admin. Request Id: {}".format(request_id)) 126 | # mark the status of the request denoting waiting for approval 127 | update_request_status(request_id, pending_req_status, budget['rangeKey']) 128 | # send approval to admin 129 | notify_admin(request, budget) 130 | budget['pendingRequestExists'] = True 131 | elif curr_req_status == saved_req_status: 132 | logger.info('Pending request exists for business entity, keeping the request in blocked state {}'.format(request_id)) 133 | # mark rest of the requests denoting blocked by a existing request 134 | update_request_status(request_id, blocked_req_status, budget['rangeKey']) 135 | else: 136 | logger.info('Request is within the budget, prepping to auto approve the request {}'.format(request_id)) 137 | budget['accruedForecastedSpend'] = forecast_spend + requested_amt 138 | budget['accruedApprovedSpend'] = approved_amt + (requested_amt_monthly - requested_amt) 139 | # if request is in blocked state, it means that a blocked request is rejected, we must 140 | # deduct the blocked amount and add it forecast amount since we would added to blocked amt 141 | # when we marked this request as blocked 142 | if curr_req_status in (pending_req_status, blocked_req_status): 143 | budget['accruedBlockedSpend'] = blocked_amt - requested_amt_monthly 144 | budget['pendingRequestExists'] = False 145 | 146 | # approve the request 147 | approve_request(request_id, request['stackWaitUrl']) 148 | # mark the request status as auto approved by the system 149 | update_request_status(request_id, 'APPROVED_SYSTEM', budget['rangeKey']) 150 | # logger.info("Auto approve requests if there is any't blocked amt") 151 | 152 | 153 | # Approve a request id since it falls within budget 154 | def approve_request(request_id, approval_url): 155 | logger.info("Request received to auto approval a product with request Id: {}".format(request_id)) 156 | success_response_data = { 157 | "Status": "SUCCESS", 158 | "Reason": "APPROVED", 159 | "UniqueId": request_id, 160 | "Data": "System approved the stack creation" 161 | } 162 | response = requests.put(approval_url, data=json.dumps(success_response_data)) 163 | logger.info("Successfully auto approved a request with request id: {} with response {}".format(request_id, response)) 164 | 165 | 166 | # Notify an admin over a SNS topic 167 | def notify_admin(request, budget): 168 | logger.info("Request received to notify admin for requestid : {}".format(request['rangeKey'])) 169 | now = datetime.now() 170 | month = now.month 171 | year = now.year 172 | curr_month_name = calendar.month_name[month] + ', ' + str(year) 173 | 174 | topic_arn = budget['notifySNSTopic'] 175 | topic = sns.Topic(topic_arn) 176 | email_id = request['requestorEmail'] 177 | instance_type = request['requestPayload']['InstanceType'] 178 | approval_url = request['requestApprovalUrl'] 179 | rejection_url = request['requestRejectionUrl'] 180 | budget_limit = budget['budgetLimit'] 181 | accrued_blocked = budget['accruedBlockedSpend'] 182 | requested_amt_31days = request['pricingInfoAtRequest']['31DayPrice'] 183 | forecasted_spend = budget['accruedForecastedSpend'] + budget['accruedApprovedSpend'] 184 | actual_spend = budget['actualSpend'] 185 | response = topic.publish( 186 | Subject='Request for approval to launch a Linux EC2 Instance', 187 | Message='\ 188 | Dear Admin,\n\ 189 | An user (' + email_id + ') has requested to launch a Linux EC2 instance (' + instance_type + ').\n\n\ 190 | Monthly Budget Limit : ' + str(budget_limit) + '\n\ 191 | Forecasted spend for month of ' + curr_month_name + ': ' + str(forecasted_spend)+'\n\ 192 | Actual spend for month of ' + curr_month_name + ' (MTD): ' + str(actual_spend)+'\n\ 193 | Total spend of pending requests in pipeline (exclusive of current request): ' + str(accrued_blocked - requested_amt_31days) + '\n\ 194 | Exception requested amount (Monthly Recurring): ' + str(requested_amt_31days) + '\n\ 195 | \n\nKindly act by clicking the below URLs.\n\n' + 196 | 'Approval Url (click to approve) ' + approval_url + 197 | '\n\nRejection Url (click to reject) ' + rejection_url + 198 | '\n\nPlease note that request will be auto rejected in 12 hrs if no action is taken\n\n\ 199 | Thanks,\n\ 200 | Product Approval Team\n') 201 | logger.info("Status of email notification: {}".format(response)) 202 | return True 203 | 204 | 205 | # update accruals in the database 206 | def update_accrued_amt(budget_dict): 207 | logger.info("Updated Dict Object before updating the accrued spends: {}".format(budget_dict)) 208 | for key, value in budget_dict.items(): 209 | logger.info("Updating accrued Amt for key {}".format(key)) 210 | update_expression = "set accruedForecastedSpend=:a, accruedBlockedSpend=:b, accruedApprovedSpend=:c" 211 | expression_attributes = { 212 | ':b': value['accruedBlockedSpend'], 213 | ':a': value['accruedForecastedSpend'], 214 | ':c': value['accruedApprovedSpend'] 215 | } 216 | if not value['budgetForecastProcessed']: 217 | logger.info("Set budgetForcast Processed to True for business entity {}".format(key)) 218 | update_expression = update_expression + ', budgetForecastProcessed=:e, budgetForecastProcessedAt=:d' 219 | expression_attributes[':e'] = True 220 | expression_attributes[':d'] = str(datetime.utcnow()) 221 | 222 | response = budgets_table.update_item( 223 | Key={'partitionKey': budgets_partition_key, 'rangeKey': value['rangeKey']}, 224 | UpdateExpression=update_expression, 225 | ExpressionAttributeValues=expression_attributes, 226 | ReturnValues="UPDATED_NEW" 227 | ) 228 | logger.info('Successfully Updated accrued Amt for Key: {} with response {}'.format(key, response)) 229 | return True 230 | 231 | 232 | # get budgets for all business entities 233 | def get_budget_info(): 234 | response = budgets_table.query( 235 | KeyConditionExpression=Key('partitionKey').eq(budgets_partition_key), 236 | ProjectionExpression='notifySNSTopic,accruedApprovedSpend,businessEntity,rangeKey,accruedBlockedSpend,actualSpend,approverEmail,budgetLimit,forecastedSpend,accruedForecastedSpend,budgetForecastProcessed' 237 | ) 238 | logger.info("Budget Info fetched from database") 239 | return response['Items'] 240 | 241 | 242 | # Get requests by state 243 | def get_requests(request_state): 244 | response = budgets_table.query( 245 | IndexName='query-by-request-status', 246 | KeyConditionExpression=Key('requestStatus').eq(request_state), 247 | ScanIndexForward=True, 248 | ProjectionExpression='stackWaitUrl,rangeKey,requestorEmail,requestApprovalUrl,pricingInfoAtRequest,requestPayload,businessEntity,requestStatus,requestRejectionUrl' 249 | ) 250 | logger.info("Requests fetched from DB for state: {}, request count {}".format(request_state, len(response['Items']))) 251 | return response['Items'] 252 | 253 | 254 | # Update the status of the request in dynamo-db 255 | def update_request_status(request_id, request_status, busines_entity_id): 256 | update_expression = "set requestStatus = :s, businessEntityId=:b" 257 | expression_attributes = { 258 | ':s': request_status, 259 | ':b': busines_entity_id, 260 | # ':r': 'Active' 261 | } 262 | if request_status == "APPROVED_SYSTEM": 263 | update_expression = update_expression + ", requestApprovalTime=:c, resourceStatus=:d" 264 | expression_attributes[':c'] = str(datetime.utcnow()) 265 | expression_attributes[':d'] = 'ACTIVE' 266 | 267 | response = budgets_table.update_item( 268 | Key={'partitionKey': requests_partition_key, 'rangeKey': request_id}, 269 | UpdateExpression=update_expression, 270 | ExpressionAttributeValues=expression_attributes, 271 | ReturnValues="UPDATED_NEW") 272 | logger.debug("UpdateItem succeeded:") 273 | logger.debug(json.dumps(response)) 274 | -------------------------------------------------------------------------------- /process-requests/requirements.txt: -------------------------------------------------------------------------------- 1 | requests -------------------------------------------------------------------------------- /rebase-budgets/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cost-control-approval-workflow/b67b1648690cefca843835be617959484317563c/rebase-budgets/__init__.py -------------------------------------------------------------------------------- /rebase-budgets/app.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # 3 | # MIT No Attribution 4 | # 5 | # Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 8 | # software and associated documentation files (the "Software"), to deal in the Software 9 | # without restriction, including without limitation the rights to use, copy, modify, 10 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 11 | # permit persons to whom the Software is furnished to do so. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 14 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 15 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 16 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 17 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 18 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | # 20 | ################################################################################ 21 | import json 22 | import logging 23 | import os 24 | from datetime import datetime 25 | from decimal import Decimal 26 | 27 | import boto3 28 | from boto3.dynamodb.conditions import Key 29 | 30 | logger = logging.getLogger() 31 | logger.setLevel(logging.INFO) 32 | region = os.environ['AWS_REGION'] 33 | budgets_table_name = os.environ['BudgetsTable'] 34 | dynamodb = boto3.resource('dynamodb', region_name=region) 35 | budgets_table = dynamodb.Table(budgets_table_name) 36 | partition_key = 'BUDGET' 37 | req_partition_key = 'REQUEST' 38 | client = boto3.client('budgets') 39 | 40 | 41 | def lambda_handler(event, context): 42 | logger.info(json.dumps(event)) 43 | account_id = os.environ['AccountId'] 44 | try: 45 | # Fetch available business units from database 46 | business_entities = get_business_entities() 47 | # Check for request from S3 48 | if 'Records' in event: 49 | for record in event['Records']: 50 | key = record['s3']['object']['key'] 51 | # Look for manifest file only, it may be the case that there are multiple files uploaded by CUR 52 | # we do not want to rebase multiple times 53 | if key.split(".")[-1] == "json": 54 | # fetch pricing and save the data to ddb 55 | logger.info("Pricing Manifest file found at {}".format(key)) 56 | for entity in business_entities: 57 | logger.info("Processing Budget for Entity {}".format(entity)) 58 | budget_name = entity['budgetName'] 59 | range_key = entity['rangeKey'] 60 | budget_info = get_budget_details(account_id, budget_name) 61 | budget_amt = Decimal(budget_info['Budget']['BudgetLimit']['Amount']) 62 | actual_spend = Decimal(budget_info['Budget']['CalculatedSpend']['ActualSpend']['Amount']) 63 | forecast_spend = Decimal(budget_info['Budget']['CalculatedSpend']['ForecastedSpend']['Amount']) 64 | # Reset accrued_forcasted_spend whenever there is a budget update from AWS 65 | update_pricing_info(range_key, budget_name, budget_amt, actual_spend, forecast_spend) 66 | return {'statusCode': '200', 'body': 'Successfully rebased accruedForecastSpend'} 67 | # Monthly rebase of accruedApprovalSpend 68 | elif 'source' in event and event['source'] == 'aws.events': 69 | logger.info("Event received from CloudWatchRule") 70 | for entity in business_entities: 71 | logger.info("Reset accruedApprovedSpend for business entity {}".format(entity)) 72 | budget_name = entity['budgetName'] 73 | range_key = entity['rangeKey'] 74 | reset_accrued_approved_amt(range_key, budget_name) 75 | return {'statusCode': '200', 'body': 'Successfully rebased AccruedApproval Amount'} 76 | except Exception as e: 77 | logger.error(e) 78 | return {'statusCode': '500', 'body': e} 79 | 80 | 81 | # Reset Accruals in database 82 | def reset_accrued_approved_amt(range_key, budget_name): 83 | logger.info("Resetting the accruedApprovedSpent at beginning of the month for business entity id {}".format(range_key)) 84 | response = budgets_table.update_item( 85 | Key={'partitionKey': partition_key, 'rangeKey': range_key}, 86 | UpdateExpression="set accruedApprovedSpend=:a", 87 | ExpressionAttributeValues={':a': Decimal(0.0)}, 88 | ReturnValues="UPDATED_NEW" 89 | ) 90 | logger.info('Updated Pricing Info for Budget: {} with response {}'.format(budget_name, response)) 91 | return True 92 | 93 | 94 | # Update pricing information for given business entity 95 | def update_pricing_info(range_key, budget_name, budget_limit, actual_spend, forcasted_spend): 96 | response = budgets_table.update_item( 97 | Key={'partitionKey': partition_key, 'rangeKey': range_key}, 98 | UpdateExpression="set budgetLimit=:a, actualSpend=:b, forecastedSpend=:c, budgetUpdatedAt=:d, budgetForecastProcessed=:e", 99 | ExpressionAttributeValues={ 100 | ':a': budget_limit, 101 | ':b': actual_spend, 102 | ':c': forcasted_spend, 103 | ':d': str(datetime.utcnow()), 104 | ':e': False, 105 | }, 106 | ReturnValues="UPDATED_NEW" 107 | ) 108 | logger.info('Updated Pricing Info for Budget: {} with response {}'.format(budget_name, response)) 109 | return True 110 | 111 | 112 | # Get all budget information for all business entities 113 | def get_business_entities(): 114 | response = budgets_table.query( 115 | KeyConditionExpression=Key('partitionKey').eq(partition_key), 116 | ProjectionExpression='rangeKey,budgetName' 117 | ) 118 | logger.info("Business Entities fetched from DB") 119 | return response['Items'] 120 | 121 | 122 | # Get budget details for a given account and budget name 123 | def get_budget_details(account_id, budget_name): 124 | response = client.describe_budget(AccountId=account_id, BudgetName=budget_name) 125 | return response 126 | 127 | 128 | # Get requests by state 129 | def get_requests(request_state): 130 | response = budgets_table.query( 131 | IndexName='query-by-request-status', 132 | KeyConditionExpression=Key('requestStatus').eq(request_state), 133 | ScanIndexForward=True, 134 | ProjectionExpression='rangeKey,requestorEmail,requestApprovalUrl,pricingInfoAtRequest,accuredForcastedSpend, businessEntity' 135 | ) 136 | logger.info("Business Entities fetched from DB") 137 | return response['Items'] 138 | -------------------------------------------------------------------------------- /rebase-budgets/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cost-control-approval-workflow/b67b1648690cefca843835be617959484317563c/rebase-budgets/requirements.txt -------------------------------------------------------------------------------- /save-request/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-cost-control-approval-workflow/b67b1648690cefca843835be617959484317563c/save-request/__init__.py -------------------------------------------------------------------------------- /save-request/app.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # 3 | # MIT No Attribution 4 | # 5 | # Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 8 | # software and associated documentation files (the "Software"), to deal in the Software 9 | # without restriction, including without limitation the rights to use, copy, modify, 10 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 11 | # permit persons to whom the Software is furnished to do so. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 14 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 15 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 16 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 17 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 18 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | # 20 | ################################################################################ 21 | import json 22 | import logging 23 | import os 24 | from datetime import datetime 25 | from decimal import Decimal 26 | 27 | import boto3 28 | import requests 29 | 30 | logger = logging.getLogger() 31 | logger.setLevel(logging.INFO) 32 | partition_key = 'REQUEST' 33 | budget_partition_key = 'BUDGET' 34 | api_gw_url = os.environ['ApprovalUrl'] 35 | region = os.environ['AWS_REGION'] 36 | budgets_table_name = os.environ['BudgetsTable'] 37 | dynamodb = boto3.resource('dynamodb', region_name=region) 38 | budgets_table = dynamodb.Table(budgets_table_name) 39 | 40 | 41 | def lambda_handler(event, context): 42 | response_data = {'Status': 'Request successfully saved to Dynamo DB'} 43 | logger.info(json.dumps(event)) 44 | try: 45 | if event['RequestType'] == 'Delete': 46 | update_termination_request_status(event['StackId'].split("/")[-1]) 47 | response_data = {'Status': 'Request successfully updated as Terminated in Dynamo DB'} 48 | send_response(event, context, 'SUCCESS', response_data) 49 | return True 50 | elif event['RequestType'] != 'Create': 51 | response_data = {'Status': 'No Special handling for Updated Stack, skip the event'} 52 | send_response(event, context, 'SUCCESS', response_data) 53 | return True 54 | wait_url = event['ResourceProperties']['WaitUrl'] 55 | email_id = event['ResourceProperties']['EmailID'] 56 | approval_url = "{}?requestStatus={}&requestId={}".format(api_gw_url, 'Approve', event['StackId'].split("/")[-1]) 57 | rejection_url = "{}?requestStatus={}&requestId={}".format(api_gw_url, 'Reject', event['StackId'].split("/")[-1]) 58 | event['ResourceProperties']['StackId'] = event['StackId'] 59 | pricing_info = json.dumps(event['ResourceProperties']['EC2Pricing']) 60 | logger.info(type(pricing_info)) 61 | logger.info("Pricing Info: {}".format(pricing_info)) 62 | business_entity = event['ResourceProperties']['BusinessEntity'] 63 | event['ResourceProperties'].pop('EC2Pricing') 64 | event['ResourceProperties'].pop('BusinessEntity') 65 | db_item = { 66 | 'partitionKey': partition_key, 67 | 'rangeKey': event['StackId'].split("/")[-1], 68 | 'requestApprovalUrl': approval_url, 69 | 'requestRejectionUrl': rejection_url, 70 | 'stackWaitUrl': wait_url, 71 | 'requestTime': str(datetime.utcnow()), 72 | 'requestorEmail': email_id, 73 | 'requestStatus': 'SAVED', 74 | 'resourceStatus': 'PENDING', 75 | 'businessEntity': business_entity, 76 | 'businessEntityId': '', 77 | 'pricingInfoAtRequest': json.loads(pricing_info, parse_float=Decimal), 78 | 'productName': event['ResourceProperties']['ProductName'], 79 | 'requestPayload': event['ResourceProperties'] 80 | } 81 | create_approval_req_item(db_item) 82 | send_response(event, context, 'SUCCESS', response_data) 83 | return True 84 | except Exception as e: 85 | logger.info("Error while saving the request in datatbase, termiante the stack: {}".format(e)) 86 | send_response(event, context, 'FAILED', {}) 87 | return False 88 | 89 | 90 | # Update the status of the request in dynamo-db 91 | def update_termination_request_status(request_id): 92 | logger.info('Received termination request for stack id: {}'.format(request_id)) 93 | existing_req = budgets_table.get_item( 94 | Key={'partitionKey': partition_key, 'rangeKey': request_id}, 95 | ProjectionExpression='requestStatus, businessEntity, businessEntityId, pricingInfoAtRequest' 96 | ) 97 | if 'Item' not in existing_req: 98 | return False 99 | logger.info('Fetched Request Item from Database: {}'.format(existing_req['Item'])) 100 | requested_amt = existing_req['Item']['pricingInfoAtRequest']['EstCurrMonthPrice'] 101 | requested_amt_monthly = existing_req['Item']['pricingInfoAtRequest']['31DayPrice'] 102 | business_entity_id = existing_req['Item']['businessEntityId'] 103 | request_status = existing_req['Item']['requestStatus'] 104 | 105 | # if status is pending/rejected/blocked, then deduct from accrued blocked amt 106 | if len(business_entity_id) > 0 and request_status in ["PENDING", "BLOCKED"]: 107 | logger.info('Adjusting Accruals since request is in {} state'.format(request_status)) 108 | budget_info = budgets_table.get_item( 109 | Key={'partitionKey': budget_partition_key, 'rangeKey': business_entity_id}, 110 | ProjectionExpression='accruedBlockedSpend' 111 | ) 112 | accrued_blocked_spend = budget_info['Item']['accruedBlockedSpend'] 113 | accrued_blocked_spend = accrued_blocked_spend - requested_amt_monthly 114 | logger.info("Clear the blocked amt if exists") 115 | response = budgets_table.update_item( 116 | Key={'partitionKey': budget_partition_key, 'rangeKey': business_entity_id}, 117 | UpdateExpression="set accruedBlockedSpend=:b", 118 | ExpressionAttributeValues={':b': accrued_blocked_spend}, 119 | ReturnValues="UPDATED_NEW" 120 | ) 121 | logger.info("Blocked amount cleared successfully: {}".format(response)) 122 | update_expression = "set resourceTerminationTime=:a, resourceStatus=:r" 123 | expression_attributes = { 124 | ':a': str(datetime.utcnow()), 125 | ':r': 'TERMINATED' 126 | } 127 | if request_status in ['PENDING', 'BLOCKED', 'SAVED']: 128 | update_expression = update_expression + ", requestStatus=:c" 129 | expression_attributes[':c'] = 'REJECTED_SYSTEM' 130 | elif request_status != 'REJECTED_ADMIN': 131 | update_expression = update_expression + ", requestStatus=:c" 132 | expression_attributes[':c'] = request_status + '_TERMINATED' 133 | response = budgets_table.update_item( 134 | Key={'partitionKey': partition_key, 'rangeKey': request_id}, 135 | UpdateExpression=update_expression, 136 | ExpressionAttributeValues=expression_attributes, 137 | ReturnValues="UPDATED_NEW" 138 | ) 139 | logger.debug("UpdateItem succeeded:") 140 | logger.debug(json.dumps(response)) 141 | return True 142 | 143 | 144 | # Create a request in database 145 | def create_approval_req_item(db_item): 146 | response = budgets_table.put_item(Item=db_item) 147 | logger.debug("CreateItem succeeded:") 148 | logger.debug(json.dumps(response)) 149 | 150 | 151 | # Send response to CFN 152 | def send_response(event, context, response_status, response_data): 153 | response_body = { 154 | 'Status': response_status, 155 | 'Reason': 'See the details in CloudWatch Log Stream ' + context.log_stream_name, 156 | 'PhysicalResourceId': context.log_stream_name, 157 | 'StackId': event['StackId'], 158 | 'RequestId': event['RequestId'], 159 | 'LogicalResourceId': event['LogicalResourceId'], 160 | 'Data': response_data, 161 | } 162 | try: 163 | response = requests.put(event['ResponseURL'], data=json.dumps(response_body)) 164 | return True 165 | except Exception as e: 166 | logger.info("Failed executing HTTP request: {}".format(e)) 167 | return False 168 | -------------------------------------------------------------------------------- /save-request/requirements.txt: -------------------------------------------------------------------------------- 1 | requests -------------------------------------------------------------------------------- /template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: > 4 | Approval workflow for service catalog product launch 5 | Metadata: 6 | LICENSE: >- 7 | MIT No Attribution 8 | 9 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 12 | software and associated documentation files (the "Software"), to deal in the Software 13 | without restriction, including without limitation the rights to use, copy, modify, 14 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 15 | permit persons to whom the Software is furnished to do so. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 18 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 19 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 20 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | Globals: 24 | Function: 25 | Timeout: 60 26 | Parameters: 27 | ResourcePrefix: 28 | ConstraintDescription: Resource prefix cannot be empty, please provide a valid resource prefix 29 | Default: "aws-sample-" 30 | Description: Prefix used to prepend the resources that this CloudFormation template provisions/creates 31 | MaxLength: '64' 32 | MinLength: '1' 33 | Type: String 34 | Resources: 35 | LinuxEC2Role: 36 | Type: AWS::IAM::Role 37 | Properties: 38 | Description: 'Assumed by the service catalog while provisioning the Linux EC2 product' 39 | AssumeRolePolicyDocument: 40 | Version: '2012-10-17' 41 | Statement: 42 | - Effect: Allow 43 | Principal: 44 | Service: 45 | - servicecatalog.amazonaws.com 46 | Action: 47 | - sts:AssumeRole 48 | Path: '/' 49 | Policies: 50 | - PolicyName: !Join ["",[!Ref ResourcePrefix, "linux-ec2-policy"]] 51 | PolicyDocument: 52 | Version: '2012-10-17' 53 | Statement: 54 | - Effect: Allow 55 | Action: 56 | - cloudformation:SetStackPolicy 57 | - cloudformation:GetTemplateSummary 58 | - cloudformation:DescribeStacks 59 | - cloudformation:DescribeStackEvents 60 | - cloudformation:CreateStack 61 | - cloudformation:DeleteStack 62 | - cloudformation:ValidateTemplate 63 | - sns:Get* 64 | - sns:List* 65 | - sns:Publish 66 | - lambda:InvokeFunction 67 | - ec2:DescribeKeyPairs 68 | - ec2:CreateTags 69 | - ec2:CreateNetworkInterface 70 | - ec2:CreateVolume 71 | - ec2:DescribeSecurityGroups 72 | - ec2:CreateSecurityGroup 73 | - ec2:DeleteSecurityGroup 74 | - ec2:AuthorizeSecurityGroupIngress 75 | - ec2:AuthorizeSecurityGroupEgress 76 | - ec2:RunInstances 77 | - ec2:StopInstances 78 | - ec2:TerminateInstances 79 | - ec2:DescribeInstances 80 | - servicecatalog:ProvisionProduct 81 | - servicecatalog:DescribeProduct 82 | - servicecatalog:DescribePortfolio 83 | Resource: '*' 84 | - Effect: Allow 85 | Action: 86 | - s3:GetObject 87 | Resource: '*' 88 | EC2PricingLambdaRole: 89 | Type: AWS::IAM::Role 90 | Properties: 91 | ManagedPolicyArns: 92 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 93 | AssumeRolePolicyDocument: 94 | Version: '2012-10-17' 95 | Statement: 96 | - Effect: Allow 97 | Principal: 98 | Service: 99 | - lambda.amazonaws.com 100 | Action: 101 | - sts:AssumeRole 102 | Path: '/' 103 | Policies: 104 | - PolicyName: !Join ["",[!Ref ResourcePrefix, "describe-ec2"]] 105 | PolicyDocument: 106 | Version: '2012-10-17' 107 | Statement: 108 | - Effect: Allow 109 | Action: pricing:GetProducts 110 | Resource: '*' 111 | AMILambdaExecRole: 112 | Type: AWS::IAM::Role 113 | Properties: 114 | ManagedPolicyArns: 115 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 116 | AssumeRolePolicyDocument: 117 | Version: '2012-10-17' 118 | Statement: 119 | - Effect: Allow 120 | Principal: 121 | Service: 122 | - lambda.amazonaws.com 123 | Action: 124 | - sts:AssumeRole 125 | Path: '/' 126 | Policies: 127 | - PolicyName: !Join ["",[!Ref ResourcePrefix, "describe-ec2"]] 128 | PolicyDocument: 129 | Version: '2012-10-17' 130 | Statement: 131 | - Effect: Allow 132 | Action: ec2:DescribeImages 133 | Resource: '*' 134 | RebaseBudgetsFunctionRole: 135 | Type: AWS::IAM::Role 136 | Properties: 137 | ManagedPolicyArns: 138 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 139 | AssumeRolePolicyDocument: 140 | Version: '2012-10-17' 141 | Statement: 142 | - Effect: Allow 143 | Principal: 144 | Service: 145 | - lambda.amazonaws.com 146 | Action: 147 | - sts:AssumeRole 148 | Path: '/' 149 | Policies: 150 | - PolicyName: !Join ["",[!Ref ResourcePrefix, "lambda-budget-dynamo-sns-policy"]] 151 | PolicyDocument: 152 | Version: '2012-10-17' 153 | Statement: 154 | - Effect: Allow 155 | Action: 156 | - budgets:ViewBudget 157 | Resource: '*' 158 | - Effect: Allow 159 | Action: 160 | - sns:Get* 161 | - sns:List* 162 | - sns:Publish 163 | Resource: '*' 164 | - Effect: Allow 165 | Action: 166 | - dynamodb:BatchGetItem 167 | - dynamodb:Query 168 | - dynamodb:Scan 169 | - dynamodb:GetItem 170 | - dynamodb:BatchWriteItem 171 | - dynamodb:UpdateItem 172 | - dynamodb:PutItem 173 | Resource: 174 | - !GetAtt DynamoBudgetsTable.Arn 175 | - !Join ["", [!GetAtt DynamoBudgetsTable.Arn, "/index/*"]] 176 | ProcessRequestsFunctionRole: 177 | Type: AWS::IAM::Role 178 | Properties: 179 | ManagedPolicyArns: 180 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 181 | AssumeRolePolicyDocument: 182 | Version: '2012-10-17' 183 | Statement: 184 | - Effect: Allow 185 | Principal: 186 | Service: 187 | - lambda.amazonaws.com 188 | Action: 189 | - sts:AssumeRole 190 | Path: '/' 191 | Policies: 192 | - PolicyName: !Join ["",[!Ref ResourcePrefix, "lambda-dynamo-sns-budget-policy"]] 193 | PolicyDocument: 194 | Version: '2012-10-17' 195 | Statement: 196 | - Effect: Allow 197 | Action: 198 | - budgets:ViewBudget 199 | Resource: '*' 200 | - Effect: Allow 201 | Action: 202 | - sns:Get* 203 | - sns:List* 204 | - sns:Publish 205 | Resource: '*' 206 | - Effect: Allow 207 | Action: 208 | - dynamodb:BatchGetItem 209 | - dynamodb:Query 210 | - dynamodb:Scan 211 | - dynamodb:GetItem 212 | - dynamodb:BatchWriteItem 213 | - dynamodb:UpdateItem 214 | - dynamodb:PutItem 215 | Resource: 216 | - !GetAtt DynamoBudgetsTable.Arn 217 | - !Join ["", [!GetAtt DynamoBudgetsTable.Arn, "/index/*"]] 218 | SaveProdRequestFunctionRole: 219 | Type: AWS::IAM::Role 220 | Properties: 221 | ManagedPolicyArns: 222 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 223 | AssumeRolePolicyDocument: 224 | Version: '2012-10-17' 225 | Statement: 226 | - Effect: Allow 227 | Principal: 228 | Service: 229 | - lambda.amazonaws.com 230 | Action: 231 | - sts:AssumeRole 232 | Path: '/' 233 | Policies: 234 | - PolicyName: lambda-dynamo-policy 235 | PolicyDocument: 236 | Version: '2012-10-17' 237 | Statement: 238 | - Effect: Allow 239 | Action: 240 | - dynamodb:BatchGetItem 241 | - dynamodb:Query 242 | - dynamodb:Scan 243 | - dynamodb:GetItem 244 | - dynamodb:BatchWriteItem 245 | - dynamodb:UpdateItem 246 | - dynamodb:PutItem 247 | Resource: 248 | - !GetAtt DynamoBudgetsTable.Arn 249 | - !Join ["", [!GetAtt DynamoBudgetsTable.Arn, "/index/*"]] 250 | ApproveLambdaExecutionRole: 251 | Type: AWS::IAM::Role 252 | Properties: 253 | ManagedPolicyArns: 254 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 255 | AssumeRolePolicyDocument: 256 | Version: '2012-10-17' 257 | Statement: 258 | - Effect: Allow 259 | Principal: 260 | Service: 261 | - lambda.amazonaws.com 262 | Action: 263 | - sts:AssumeRole 264 | Path: '/' 265 | Policies: 266 | - PolicyName: lambda-dynamo-policy 267 | PolicyDocument: 268 | Version: '2012-10-17' 269 | Statement: 270 | - Effect: Allow 271 | Action: 272 | - dynamodb:BatchGetItem 273 | - dynamodb:Query 274 | - dynamodb:Scan 275 | - dynamodb:GetItem 276 | - dynamodb:BatchWriteItem 277 | - dynamodb:UpdateItem 278 | - dynamodb:PutItem 279 | Resource: 280 | - !GetAtt DynamoBudgetsTable.Arn 281 | - !Join ["", [!GetAtt DynamoBudgetsTable.Arn, "/index/*"]] 282 | DynamoBudgetsTable: 283 | Type: AWS::DynamoDB::Table 284 | Properties: 285 | BillingMode: PROVISIONED 286 | SSESpecification: 287 | KMSMasterKeyId: 'alias/aws/dynamodb' 288 | SSEEnabled: True 289 | SSEType: KMS 290 | ProvisionedThroughput: 291 | ReadCapacityUnits: 2 292 | WriteCapacityUnits: 2 293 | AttributeDefinitions: 294 | - AttributeName: partitionKey 295 | AttributeType: S 296 | - AttributeName: rangeKey 297 | AttributeType: S 298 | - AttributeName: requestStatus 299 | AttributeType: S 300 | - AttributeName: requestTime 301 | AttributeType: S 302 | KeySchema: 303 | - AttributeName: partitionKey 304 | KeyType: HASH 305 | - AttributeName: rangeKey 306 | KeyType: RANGE 307 | GlobalSecondaryIndexes: 308 | - IndexName: query-by-request-status 309 | KeySchema: 310 | - AttributeName: requestStatus 311 | KeyType: HASH 312 | - AttributeName: requestTime 313 | KeyType: RANGE 314 | Projection: 315 | ProjectionType: ALL 316 | ProvisionedThroughput: 317 | ReadCapacityUnits: 2 318 | WriteCapacityUnits: 2 319 | AMILinuxLookupFunction: 320 | Type: AWS::Serverless::Function 321 | Properties: 322 | Description: Looks up the linux ami id 323 | FunctionName: !Join ["",[!Ref ResourcePrefix, "linux-ami-lookup"]] 324 | Handler: index.handler 325 | Role: !GetAtt AMILambdaExecRole.Arn 326 | Runtime: nodejs14.x 327 | CodeUri: linux-ami-lookup/ 328 | EC2PricingFunction: 329 | Type: AWS::Serverless::Function 330 | Properties: 331 | Description: Calculates the pricing of an ec2 machine (linux/windows) 332 | FunctionName: !Join ["",[!Ref ResourcePrefix, "calc-ec2-pricing"]] 333 | Handler: app.lambda_handler 334 | Runtime: python3.9 335 | CodeUri: get-ec2-pricing/ 336 | Role: !GetAtt EC2PricingLambdaRole.Arn 337 | ApprovalFunction: 338 | Type: AWS::Serverless::Function 339 | Properties: 340 | Description: triggered by api gateway to approve/decline the budget approval exception 341 | FunctionName: !Join ["",[!Ref ResourcePrefix, "workflow-approver"]] 342 | CodeUri: approve-request/ 343 | Handler: app.lambda_handler 344 | Runtime: python3.9 345 | Role: !GetAtt ApproveLambdaExecutionRole.Arn 346 | Environment: 347 | Variables: 348 | BudgetsTable: !Ref DynamoBudgetsTable 349 | Events: 350 | ApprovalMethod: 351 | Type: Api 352 | Properties: 353 | RestApiId: 354 | Ref: WorkflowApiGateway 355 | Path: /approveRequest 356 | Method: get 357 | SaveProdRequestFunction: 358 | Type: AWS::Serverless::Function 359 | Properties: 360 | Description: Saves the resource request to database 361 | FunctionName: !Join ["",[!Ref ResourcePrefix, "save-request"]] 362 | CodeUri: save-request/ 363 | Handler: app.lambda_handler 364 | Runtime: python3.9 365 | Role: !GetAtt SaveProdRequestFunctionRole.Arn 366 | Environment: 367 | Variables: 368 | BudgetsTable: !Ref DynamoBudgetsTable 369 | ApprovalUrl: !Sub https://${WorkflowApiGateway}.execute-api.${AWS::Region}.amazonaws.com/Prod/approveRequest 370 | WorkflowApiGateway: 371 | Type: AWS::Serverless::Api 372 | Properties: 373 | Name: !Join ["",[!Ref ResourcePrefix, "budgets-workflow-api"]] 374 | StageName: Prod 375 | Cors: 376 | AllowMethods: "'GET, OPTIONS'" 377 | AllowOrigin: "'*'" 378 | ProcessRequestsFunction: 379 | Type: AWS::Serverless::Function 380 | Properties: 381 | FunctionName: !Join ["",[!Ref ResourcePrefix, "process-requests"]] 382 | Description: Processed the requests in database, triggerred periodically by cloudwatch events 383 | Runtime: python3.9 384 | Role: !GetAtt ProcessRequestsFunctionRole.Arn 385 | Handler: app.lambda_handler 386 | CodeUri: process-requests/ 387 | Events: 388 | CWEvent: 389 | Type: Schedule 390 | Properties: 391 | Schedule: 'rate(5 minutes)' 392 | Name: !Join ["",[!Ref ResourcePrefix, "process-requests-schedule"]] 393 | Description: Keeps tracks of pending requests and routes requests to approver or auto approves based on available budget 394 | Enabled: True 395 | Environment: 396 | Variables: 397 | BudgetsTable: !Ref DynamoBudgetsTable 398 | RebaseBudgetsFunction: 399 | Type: AWS::Serverless::Function 400 | Properties: 401 | FunctionName: !Join ["",[!Ref ResourcePrefix, "rebase-budgets"]] 402 | Runtime: python3.9 403 | Role: !GetAtt RebaseBudgetsFunctionRole.Arn 404 | Handler: app.lambda_handler 405 | CodeUri: rebase-budgets/ 406 | Environment: 407 | Variables: 408 | AccountId: !Ref AWS::AccountId 409 | BudgetsTable: !Ref DynamoBudgetsTable 410 | Events: 411 | PricingRefreshEvent: 412 | Type: S3 413 | Properties: 414 | Bucket: 415 | Ref: CostUsagePricingBucket 416 | Events: s3:ObjectCreated:* 417 | CWEvent: 418 | Type: Schedule 419 | Properties: 420 | Schedule: 'cron(5 0 1 * ? *)' 421 | Name: !Join ["",[!Ref ResourcePrefix, "reset-accruedApprovalSpend-schedule"]] 422 | Description: calls the pricing rebase function to reset the accrued approval spend for each business entity 423 | Enabled: True 424 | CostUsagePricingBucket: 425 | Type: AWS::S3::Bucket 426 | Properties: 427 | VersioningConfiguration: 428 | Status: Enabled 429 | BucketEncryption: 430 | ServerSideEncryptionConfiguration: 431 | - ServerSideEncryptionByDefault: 432 | SSEAlgorithm: 'aws:kms' 433 | KMSMasterKeyID: 'alias/aws/s3' 434 | CostUsagePricingBucketPolicy: 435 | Type: AWS::S3::BucketPolicy 436 | Properties: 437 | Bucket: !Ref CostUsagePricingBucket 438 | PolicyDocument: 439 | Statement: 440 | - Effect: Allow 441 | Action: 442 | - s3:GetBucketAcl 443 | - s3:GetBucketPolicy 444 | - s3:PutObject 445 | Resource: 446 | - !Join ["", ["arn:aws:s3:::",!Ref CostUsagePricingBucket, "/*"]] 447 | - !Join ["", ["arn:aws:s3:::",!Ref CostUsagePricingBucket]] 448 | Principal: 449 | Service: 450 | - billingreports.amazonaws.com 451 | - Effect: Deny 452 | Action: 's3:*' 453 | Resource: 454 | - !Join ["", ["arn:aws:s3:::",!Ref CostUsagePricingBucket, "/*"]] 455 | - !Join ["", ["arn:aws:s3:::",!Ref CostUsagePricingBucket]] 456 | Condition: 457 | Bool: 458 | aws:SecureTransport: 'false' 459 | Principal: '*' 460 | Outputs: 461 | DynamoDBTable: 462 | Description: "DynamoDB table where master data and requests information is saved" 463 | Value: !Ref DynamoBudgetsTable 464 | ApprovalApi: 465 | Description: "API Gateway endpoint URL for Prod stage for Budget Approval" 466 | Value: !Sub "https://${WorkflowApiGateway}.execute-api.${AWS::Region}.amazonaws.com/Prod/" 467 | CURS3BucketName: 468 | Description: S3 bucket used to store Cost & Usage Report 469 | Value: !Ref CostUsagePricingBucket 470 | LaunchConstraintIAMRoleARN: 471 | Description: IAM Role used in Service Catalog Launch Constraint 472 | Value: !Ref LinuxEC2Role 473 | SaveRequestLambdaARN: 474 | Description: Lambda function ARN to notify approver. Will be used as a Cloudformation Custom Resource ServiceToken to save a Service Catalog Product Launch request into database 475 | Value: !GetAtt SaveProdRequestFunction.Arn 476 | Export: 477 | Name: 478 | !Sub "${AWS::StackName}-SaveRequestLambda" 479 | LinuxAMILookupLambdaARN: 480 | Description: Lambda function to lookup linux AMI Id. Will be used as a Cloudformation Custom Resource ServiceToken to lookup ami-id of the EC2 instance 481 | Value: !GetAtt AMILinuxLookupFunction.Arn 482 | Export: 483 | Name: 484 | !Sub "${AWS::StackName}-LinuxAMILookupLambda" 485 | EC2PricingLambdaARN: 486 | Description: Lambda function to get price of a ec2 instance. Will be used as a Cloudformation Custom Resource ServiceToken to fetch price of Amazon Linux EC2 Instance 487 | Value: !GetAtt EC2PricingFunction.Arn 488 | Export: 489 | Name: 490 | !Sub "${AWS::StackName}-EC2PricingLambda" 491 | CURS3Bucket: 492 | Description: S3 Bucket to be configured with AWS Budgets CUR to store budget info 493 | Value: !Ref CostUsagePricingBucket 494 | Export: 495 | Name: 496 | !Sub "${AWS::StackName}-CURS3Bucket" --------------------------------------------------------------------------------