├── __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 |
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 |
--------------------------------------------------------------------------------