├── LICENSE
├── Makefile
├── README.md
└── functions
└── slack
└── main.go
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Kai Hendry
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 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | deploy:
2 | apex deploy
3 |
4 | test:
5 | apex invoke slack
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | Requires billing alerts to be enabled in your billing preferences & CloudWatchReadOnlyAccess in the role
4 | https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/monitor-charges.html
5 |
6 | Reference case #5314191201
7 |
8 | # Updates twice a day
9 |
10 | Triggered at the start & end of the working day in Singapore
11 |
12 |
13 |
14 | # Deployment notes
15 |
16 | Currently the code is defined for my use case and accounts. Notice two accounts
17 | are in an organisation and another uses a cross account role to get the
18 | metrics.
19 |
20 | There are many ways to deploy a serverless function, however I'm using
21 | http://apex.run/ in this instance. The `project.json` looks like:
22 |
23 | {
24 | "name": "estimatedcharges",
25 | "description": "Post to slack a summary of the estimated charges of the AWS account",
26 | "profile": "my-profile",
27 | "memory": 128,
28 | "timeout": 5,
29 | "role": "arn:aws:iam::812644853088:role/estimatedcharges_lambda_function",
30 | "environment": {
31 | "WEBHOOK": "https://hooks.slack.com/services/XXXXX/YYYYYY/etcetc"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/functions/slack/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 | "net/http"
9 | "os"
10 | "sort"
11 | "time"
12 |
13 | "github.com/apex/log"
14 | "github.com/aws/aws-lambda-go/events"
15 | "github.com/aws/aws-lambda-go/lambda"
16 | "github.com/aws/aws-sdk-go-v2/aws"
17 | "github.com/aws/aws-sdk-go-v2/aws/endpoints"
18 | "github.com/aws/aws-sdk-go-v2/aws/external"
19 | "github.com/aws/aws-sdk-go-v2/aws/stscreds"
20 | "github.com/aws/aws-sdk-go-v2/service/cloudwatch"
21 | "github.com/aws/aws-sdk-go-v2/service/sts"
22 | )
23 |
24 | type attachment struct {
25 | Fallback string `json:"fallback"`
26 | Color string `json:"color"`
27 | Title string `json:"title"`
28 | TitleLink string `json:"title_link,omitempty"`
29 | Text string `json:"text,omitempty"`
30 | Ts int64 `json:"ts"`
31 | }
32 |
33 | func main() {
34 | lambda.Start(handler)
35 |
36 | }
37 |
38 | func handler(ctx context.Context, snsEvent events.SNSEvent) {
39 |
40 | cfg, err := external.LoadDefaultAWSConfig(external.WithSharedConfigProfile("mine"))
41 | if err != nil {
42 | log.WithError(err).Fatal("setting up credentials")
43 | return
44 | }
45 | // Can only get billing info from us-east-1
46 | cfg.Region = endpoints.UsEast1RegionID
47 | demoresp, err := estimatedCharges(cfg, "915001051872")
48 | if err != nil {
49 | log.WithError(err).Fatal("unable to retrieve bill estimate for demo account")
50 | }
51 |
52 | devresp, err := estimatedCharges(cfg, "812644853088")
53 | if err != nil {
54 | log.WithError(err).Fatal("unable to retrieve bill estimate for dev account")
55 | }
56 |
57 | cfg.Credentials = stscreds.NewAssumeRoleProvider(sts.New(cfg), "arn:aws:iam::192458993663:role/estimatedcharges")
58 | prodresp, err := estimatedCharges(cfg, "")
59 | if err != nil {
60 | log.WithError(err).Fatal("unable to retrieve bill estimate for dev account")
61 | }
62 |
63 | var attachments []attachment
64 | attachments = append(attachments, slackTemplate(demoresp, "Demo 915001051872")...)
65 | attachments = append(attachments, slackTemplate(devresp, "Dev 812644853088")...)
66 | attachments = append(attachments, slackTemplate(prodresp, "Prod 192458993663")...)
67 |
68 | type slackPayload struct {
69 | Attachments []attachment `json:"attachments"`
70 | }
71 |
72 | jsonValue, _ := json.Marshal(slackPayload{Attachments: attachments})
73 |
74 | presp, err := http.Post(os.Getenv("WEBHOOK"), "application/json", bytes.NewBuffer(jsonValue))
75 | if err != nil {
76 | panic(err)
77 | }
78 | if presp.StatusCode != http.StatusOK {
79 | log.Fatalf("Post failed: %+v", presp)
80 | }
81 | }
82 |
83 | func estimatedCharges(cfg aws.Config, linkedAccount string) (resp *cloudwatch.GetMetricStatisticsOutput, err error) {
84 |
85 | now := time.Now()
86 | before := now.Add(-time.Hour * 24 * 2)
87 |
88 | svc := cloudwatch.New(cfg)
89 | // https://godoc.org/github.com/aws/aws-sdk-go-v2/service/cloudwatch#GetMetricStatisticsRequest
90 | // https://godoc.org/github.com/aws/aws-sdk-go-v2/service/cloudwatch#GetMetricStatisticsInput
91 | req := svc.GetMetricStatisticsRequest(&cloudwatch.GetMetricStatisticsInput{
92 | Dimensions: []cloudwatch.Dimension{
93 | {
94 | Name: aws.String("Currency"),
95 | Value: aws.String("USD"),
96 | },
97 | },
98 | EndTime: &now,
99 | MetricName: aws.String("EstimatedCharges"),
100 | Namespace: aws.String("AWS/Billing"),
101 | Period: aws.Int64(int64(28800)), // 8hrs periods
102 | StartTime: &before,
103 | Statistics: []cloudwatch.Statistic{cloudwatch.StatisticMaximum},
104 | })
105 |
106 | if linkedAccount != "" {
107 | req.Input.Dimensions = append(req.Input.Dimensions, cloudwatch.Dimension{
108 | Name: aws.String("LinkedAccount"),
109 | Value: aws.String(linkedAccount),
110 | })
111 | }
112 |
113 | resp, err = req.Send()
114 | return
115 | }
116 |
117 | func slackTemplate(resp *cloudwatch.GetMetricStatisticsOutput, profile string) (attachments []attachment) {
118 |
119 | // fmt.Printf("%+v\n", resp.Datapoints)
120 | sort.Slice(resp.Datapoints, func(i, j int) bool {
121 | time1 := *(resp.Datapoints[i].Timestamp)
122 | time2 := *(resp.Datapoints[j].Timestamp)
123 | return time1.Before(time2)
124 | })
125 |
126 | var preincrease float64
127 | var highestDerivative float64
128 | var lastTime int64
129 | var text string
130 | for i := 0; i < len(resp.Datapoints); i++ {
131 | if i < len(resp.Datapoints)-1 {
132 | now := *(resp.Datapoints[i].Maximum)
133 | next := *(resp.Datapoints[i+1].Maximum)
134 | timestamp := *(resp.Datapoints[i+1].Timestamp)
135 | increase := next - now
136 |
137 | // Duplicate value is just noise, so skip
138 | if increase == 0 {
139 | continue
140 | }
141 |
142 | derivative := increase - preincrease
143 | log.Infof("%s, Before: %.2f, Now: %.2f, Increase: %.2f, Derivative: %.2f", time.Since(timestamp), now, next, increase, derivative)
144 |
145 | if preincrease != 0 {
146 | text += fmt.Sprintf("• %dh ago was: %.1f, 8hrs later: %.1f, Increase: *%.2f*, Derivative: %.2f\n",
147 | int(time.Since(timestamp).Hours()), now, next, increase, derivative)
148 | if highestDerivative < derivative {
149 | highestDerivative = derivative
150 | }
151 | }
152 |
153 | // For next loop
154 | preincrease = increase
155 | lastTime = timestamp.Unix()
156 | }
157 | }
158 |
159 | // https://api.slack.com/docs/message-attachments
160 | // good, warning, danger
161 | color := "good"
162 | if highestDerivative > 1 {
163 | color = "danger"
164 | } else if highestDerivative > 0.5 {
165 | color = "warning"
166 | }
167 |
168 | return append(attachments, attachment{
169 | Fallback: text,
170 | Color: color,
171 | Title: fmt.Sprintf("%.2f on %s", highestDerivative, profile),
172 | Text: text,
173 | Ts: lastTime,
174 | })
175 | }
176 |
--------------------------------------------------------------------------------