├── __init__.py ├── aws_billing_messaging ├── __init__.py ├── requirements.txt └── app.py ├── assets └── example.png ├── LICENSE ├── template.yaml ├── README.md └── .gitignore /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aws_billing_messaging/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aws_billing_messaging/requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | boto3 -------------------------------------------------------------------------------- /assets/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brunoxd13/aws-monthly-billing-slack-bot/HEAD/assets/example.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Bruno Russi Lautenschlager 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: > 4 | aws-billing-messaging 5 | 6 | Send AWS monthy summary billing every day in a slack channel or telegram. 7 | 8 | Globals: 9 | Function: 10 | Timeout: 3 11 | MemorySize: 128 12 | 13 | Parameters: 14 | SlackWebhookToken: 15 | Type: String 16 | Default: "" 17 | 18 | TelegramChatId: 19 | Type: String 20 | Default: "" 21 | 22 | TelegramToken: 23 | Type: String 24 | Default: "" 25 | 26 | ServiceQuantity: 27 | Type: Number 28 | Default: 5 29 | 30 | LowCost: 31 | Type: Number 32 | Default: 10 33 | 34 | HighCost: 35 | Type: Number 36 | Default: 50 37 | 38 | Resources: 39 | LambdaFunction: 40 | Type: AWS::Serverless::Function 41 | Properties: 42 | CodeUri: aws_billing_messaging/ 43 | Handler: app.lambda_handler 44 | Runtime: python3.9 45 | Architectures: 46 | - x86_64 47 | Environment: 48 | Variables: 49 | SLACK_WEBHOOK_TOKEN: !Ref SlackWebhookToken 50 | TELEGRAM_CHAT_ID: !Ref TelegramChatId 51 | TELEGRAM_TOKEN: !Ref TelegramToken 52 | SERVICE_QUANTITY: !Ref ServiceQuantity 53 | LOW_COST: !Ref LowCost 54 | HIGH_COST: !Ref HighCost 55 | Events: 56 | MyFunctionSchedule: 57 | Type: Schedule 58 | Properties: 59 | # Every day at 11:00 UTC / 08am BRT 60 | Schedule: cron(0 11 * * ? *) 61 | Policies: 62 | - Statement: 63 | - Sid: CostExplorerLambdaPermission 64 | Effect: Allow 65 | Action: 66 | - ce:GetCostAndUsage 67 | Resource: "*" 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS monthly billing slack bot 2 | 3 | Send AWS monthy summary billing in a slack channel via webhook. 4 | 5 | ## Example of message 6 | 7 | This is an example of message send in Slack: 8 | 9 |

10 | logo 11 |

12 | 13 |
14 | 15 | ## Getting Started 16 | 17 | ### Prerequisites 18 | 19 | To perform the project installation you need to have Serverless Application Model Command Line Interface (SAM CLI) installed in your environment, and to use the SAM CLI, you need the following tools: 20 | 21 | * SAM CLI - [Install the SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) 22 | * [Python 3 installed](https://www.python.org/downloads/) 23 | * Docker - [Install Docker community edition](https://hub.docker.com/search/?type=edition&offering=community) 24 | 25 | ## Installing process 26 | 27 | ### Cloning the project 28 | 29 | ```bash 30 | git clone https://github.com/brunoxd13/aws-monthly-billing-slack-bot.git 31 | 32 | cd aws-monthly-billing-slack-bot 33 | ``` 34 | 35 | ### Instaling the project 36 | 37 | Install project depencencies: 38 | 39 | To build and deploy your application for the first time, run the following in your shell: 40 | 41 | ```bash 42 | sam build --use-container 43 | ``` 44 | 45 | or 46 | 47 | ```bash 48 | sam build 49 | ``` 50 | 51 | ### Configure Slack 52 | 53 | Create an [incoming webhook](https://www.slack.com/apps/new/A0F7XDUAZ) on slack. 54 | 55 | ### Deploy 56 | 57 | **IMPORTANT:** Remember to be autentichated in your AWS Account using the AWS CLI. 58 | 59 | Deploy the project to your AWS account with the following command: 60 | 61 | ```bash 62 | sam deploy --guided 63 | ``` 64 | 65 | ## Author 66 | 67 | [Bruno Russi Lautenschlager](https://github.com/brunoxd13) 68 | 69 | ## License 70 | 71 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### OSX ### 20 | *.DS_Store 21 | .AppleDouble 22 | .LSOverride 23 | 24 | # Icon must end with two \r 25 | Icon 26 | 27 | # Thumbnails 28 | ._* 29 | 30 | # Files that might appear in the root of a volume 31 | .DocumentRevisions-V100 32 | .fseventsd 33 | .Spotlight-V100 34 | .TemporaryItems 35 | .Trashes 36 | .VolumeIcon.icns 37 | .com.apple.timemachine.donotpresent 38 | 39 | # Directories potentially created on remote AFP share 40 | .AppleDB 41 | .AppleDesktop 42 | Network Trash Folder 43 | Temporary Items 44 | .apdisk 45 | 46 | ### PyCharm ### 47 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 48 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 49 | 50 | # User-specific stuff: 51 | .idea/**/workspace.xml 52 | .idea/**/tasks.xml 53 | .idea/dictionaries 54 | 55 | # Sensitive or high-churn files: 56 | .idea/**/dataSources/ 57 | .idea/**/dataSources.ids 58 | .idea/**/dataSources.xml 59 | .idea/**/dataSources.local.xml 60 | .idea/**/sqlDataSources.xml 61 | .idea/**/dynamic.xml 62 | .idea/**/uiDesigner.xml 63 | 64 | # Gradle: 65 | .idea/**/gradle.xml 66 | .idea/**/libraries 67 | 68 | # CMake 69 | cmake-build-debug/ 70 | 71 | # Mongo Explorer plugin: 72 | .idea/**/mongoSettings.xml 73 | 74 | ## File-based project format: 75 | *.iws 76 | 77 | ## Plugin-specific files: 78 | 79 | # IntelliJ 80 | /out/ 81 | 82 | # mpeltonen/sbt-idea plugin 83 | .idea_modules/ 84 | 85 | # JIRA plugin 86 | atlassian-ide-plugin.xml 87 | 88 | # Cursive Clojure plugin 89 | .idea/replstate.xml 90 | 91 | # Ruby plugin and RubyMine 92 | /.rakeTasks 93 | 94 | # Crashlytics plugin (for Android Studio and IntelliJ) 95 | com_crashlytics_export_strings.xml 96 | crashlytics.properties 97 | crashlytics-build.properties 98 | fabric.properties 99 | 100 | ### PyCharm Patch ### 101 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 102 | 103 | # *.iml 104 | # modules.xml 105 | # .idea/misc.xml 106 | # *.ipr 107 | 108 | # Sonarlint plugin 109 | .idea/sonarlint 110 | 111 | ### Python ### 112 | # Byte-compiled / optimized / DLL files 113 | __pycache__/ 114 | *.py[cod] 115 | *$py.class 116 | 117 | # C extensions 118 | *.so 119 | 120 | # Distribution / packaging 121 | .Python 122 | build/ 123 | develop-eggs/ 124 | dist/ 125 | downloads/ 126 | eggs/ 127 | .eggs/ 128 | lib/ 129 | lib64/ 130 | parts/ 131 | sdist/ 132 | var/ 133 | wheels/ 134 | *.egg-info/ 135 | .installed.cfg 136 | *.egg 137 | 138 | # PyInstaller 139 | # Usually these files are written by a python script from a template 140 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 141 | *.manifest 142 | *.spec 143 | 144 | # Installer logs 145 | pip-log.txt 146 | pip-delete-this-directory.txt 147 | 148 | # Unit test / coverage reports 149 | htmlcov/ 150 | .tox/ 151 | .coverage 152 | .coverage.* 153 | .cache 154 | .pytest_cache/ 155 | nosetests.xml 156 | coverage.xml 157 | *.cover 158 | .hypothesis/ 159 | 160 | # Translations 161 | *.mo 162 | *.pot 163 | 164 | # Flask stuff: 165 | instance/ 166 | .webassets-cache 167 | 168 | # Scrapy stuff: 169 | .scrapy 170 | 171 | # Sphinx documentation 172 | docs/_build/ 173 | 174 | # PyBuilder 175 | target/ 176 | 177 | # Jupyter Notebook 178 | .ipynb_checkpoints 179 | 180 | # pyenv 181 | .python-version 182 | 183 | # celery beat schedule file 184 | celerybeat-schedule.* 185 | 186 | # SageMath parsed files 187 | *.sage.py 188 | 189 | # Environments 190 | .env 191 | .venv 192 | env/ 193 | venv/ 194 | ENV/ 195 | env.bak/ 196 | venv.bak/ 197 | 198 | # Spyder project settings 199 | .spyderproject 200 | .spyproject 201 | 202 | # Rope project settings 203 | .ropeproject 204 | 205 | # mkdocs documentation 206 | /site 207 | 208 | # mypy 209 | .mypy_cache/ 210 | 211 | ### VisualStudioCode ### 212 | .vscode/* 213 | !.vscode/settings.json 214 | !.vscode/tasks.json 215 | !.vscode/launch.json 216 | !.vscode/extensions.json 217 | .history 218 | 219 | ### Windows ### 220 | # Windows thumbnail cache files 221 | Thumbs.db 222 | ehthumbs.db 223 | ehthumbs_vista.db 224 | 225 | # Folder config file 226 | Desktop.ini 227 | 228 | # Recycle Bin used on file shares 229 | $RECYCLE.BIN/ 230 | 231 | # Windows Installer files 232 | *.cab 233 | *.msi 234 | *.msm 235 | *.msp 236 | 237 | # Windows shortcuts 238 | *.lnk 239 | 240 | # Build folder 241 | 242 | */build/* 243 | 244 | # End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode -------------------------------------------------------------------------------- /aws_billing_messaging/app.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | import http.client 4 | import calendar 5 | import datetime 6 | import boto3 7 | import json 8 | import os 9 | 10 | 11 | low_cost = int(os.getenv("LOW_COST")) 12 | high_cost = int(os.getenv("HIGH_COST")) 13 | 14 | 15 | TELEGRAM_API_HOST = "api.telegram.org" 16 | SLACK_API_URL = "hooks.slack.com" 17 | 18 | def lambda_handler(_event, _context): 19 | report_cost = get_report_cost() 20 | 21 | send_slack_message(report_cost) 22 | send_telegram_message(report_cost) 23 | 24 | 25 | def get_report_cost(): 26 | client = boto3.client('ce') 27 | 28 | last_month_day = calendar.monthrange( 29 | datetime.date.today().year, datetime.date.today().month)[1] 30 | 31 | start_date = datetime.date.today().replace(day=1) 32 | end_date = datetime.date.today().replace(day=last_month_day) 33 | 34 | query = { 35 | "TimePeriod": { 36 | "Start": start_date.strftime('%Y-%m-%d'), 37 | "End": end_date.strftime('%Y-%m-%d'), 38 | }, 39 | "Granularity": "MONTHLY", 40 | "Filter": { 41 | "Not": { 42 | "Dimensions": { 43 | "Key": "RECORD_TYPE", 44 | "Values": [ 45 | "Credit", 46 | "Refund", 47 | "Upfront", 48 | "Support", 49 | ] 50 | } 51 | } 52 | }, 53 | "Metrics": ["UnblendedCost"], 54 | "GroupBy": [ 55 | { 56 | "Type": "DIMENSION", 57 | "Key": "SERVICE", 58 | }, 59 | ], 60 | } 61 | 62 | result = client.get_cost_and_usage(**query) 63 | 64 | buffer = "%-40s %5s\n" % ("Services", "Budget") 65 | 66 | cost_by_service = defaultdict(list) 67 | 68 | # Build a map of service -> array of daily costs for the time frame 69 | for day in result['ResultsByTime']: 70 | for group in day['Groups']: 71 | key = group['Keys'][0] 72 | cost = float(group['Metrics']['UnblendedCost']['Amount']) 73 | 74 | cost_by_service[key].append(cost) 75 | 76 | most_expensive_services = sorted( 77 | cost_by_service.items(), key=lambda i: i[1][-1], reverse=True) 78 | 79 | service_quantity = int(os.getenv("SERVICE_QUANTITY")) 80 | 81 | if len(most_expensive_services) < service_quantity: 82 | service_quantity = len(most_expensive_services) 83 | 84 | for service_name, costs in most_expensive_services[:service_quantity]: 85 | buffer += "%-40s US$ %5.2f\n" % (service_name, costs[-1]) 86 | 87 | other_costs = 0.0 88 | for service_name, costs in most_expensive_services[service_quantity:]: 89 | for i, cost in enumerate(costs): 90 | other_costs += cost 91 | 92 | buffer += "%-40s US$ %5.2f\n" % ("Other", other_costs) 93 | 94 | total_costs = 0.0 95 | for service_name, costs in most_expensive_services: 96 | for i, cost in enumerate(costs): 97 | total_costs += cost 98 | 99 | buffer += "%-40s US$ %5.2f\n" % ("Total", total_costs) 100 | 101 | if total_costs < low_cost: 102 | emoji = "💰" 103 | elif total_costs > high_cost: 104 | emoji = "😱 ATTENTION the billing is very high ❗❗❗\n" 105 | else: 106 | emoji = "🙉 ATTENTION billing is at a worrying level ⚠\n" 107 | 108 | summary = "%s Current billing is at: US$ *%5.2f*" % (emoji, total_costs) 109 | 110 | 111 | text = summary + "\n\n```\n" + buffer + "```" 112 | 113 | return text 114 | 115 | 116 | def send_telegram_message(message): 117 | telegram_token = os.getenv('TELEGRAM_TOKEN') 118 | telegram_chat_id = os.getenv('TELEGRAM_CHAT_ID') 119 | 120 | conn = http.client.HTTPSConnection(TELEGRAM_API_HOST) 121 | 122 | if telegram_token is not None or telegram_chat_id is not None: 123 | 124 | endpoint = f"/bot{telegram_token}/sendMessage" 125 | 126 | payload = { 127 | "chat_id": telegram_chat_id, 128 | "text": message, 129 | "parse_mode": "Markdown", 130 | } 131 | 132 | headers = {"content-type": "application/json"} 133 | 134 | conn.request("POST", endpoint, json.dumps(payload), headers) 135 | 136 | res = conn.getresponse() 137 | 138 | return { 139 | "statusCode": res.status, 140 | "body": json.dumps("Lambda executed.") 141 | } 142 | else: 143 | raise EnvironmentError("Missing TELEGRAM_TOKEN, TELEGRAM_CHAT_ID env variable!") 144 | 145 | 146 | def send_slack_message(message): 147 | slack_token = os.environ.get('SLACK_WEBHOOK_TOKEN') 148 | 149 | if slack_token is not None: 150 | conn = http.client.HTTPSConnection(SLACK_API_URL) 151 | 152 | endpoint = f"/services/{slack_token}" 153 | 154 | payload = { "text": message } 155 | 156 | headers = { "content-type": "application/json" } 157 | 158 | conn.request("POST", endpoint, json.dumps(payload), headers) 159 | 160 | res = conn.getresponse() 161 | 162 | return { 163 | "statusCode": res.status, 164 | "body": json.dumps("Lambda executed.") 165 | } 166 | else: 167 | raise EnvironmentError("Missing SLACK_WEBHOOK_TOKEN env variable!") 168 | 169 | --------------------------------------------------------------------------------