├── 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 | Summarise AWS expenditure 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 | --------------------------------------------------------------------------------