diff --git a/README.md b/README.md index a96e32a..96410ae 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ ## What is aws_cost_usage Notify daily cost usage of an AWS account to slack channel. +![](./docs/notification.jpg) + ## How to use this -You can deploy with Terraform resources on your AWS account. +### Execution +#### Lambda +You can deploy with Terraform resources on your AWS account with AWS Lambda. ```hcl terraform { @@ -28,6 +32,15 @@ module "cost" { } ``` +#### Others +You can download binary from GitHub Release. + +### Support multi languages +- English + - Set `LANGUAGE=en` +- Japanese + - Set `LANGUAGE=ja` + ## Development - Init ``` @@ -38,3 +51,8 @@ cp .env{.sample,} ``` make run ``` + +## Known issues +### Got daily cost as $0.000 +If you set CloudWatch Events Schedule near AM 0:00 in UTC, AWS has not reflect daily cost yet. +So, you need to set the schedule for more later. diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..e7d1ecc --- /dev/null +++ b/config/config.go @@ -0,0 +1,17 @@ +package config + +import "github.com/kelseyhightower/envconfig" + +type Config struct { + SlackToken string `required:"true" envconfig:"SLACK_TOKEN"` + SlackChannel string `required:"true" envconfig:"SLACK_CHANNEL"` + Language string `required:"true" envconfig:"LANGUAGE" default:"ja"` +} + +func New() (Config, error) { + config := Config{} + if err := envconfig.Process("", &config); err != nil { + return Config{}, err + } + return config, nil +} diff --git a/docs/notification.jpg b/docs/notification.jpg new file mode 100644 index 0000000..9058d22 Binary files /dev/null and b/docs/notification.jpg differ diff --git a/go.mod b/go.mod index fda7cf6..76b6742 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,8 @@ require ( github.com/slack-go/slack v0.9.3 github.com/stretchr/testify v1.8.4 github.com/ucpr/mongo-streamer v0.0.4 + golang.org/x/text v0.14.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -48,7 +50,6 @@ require ( golang.org/x/oauth2 v0.8.0 // indirect golang.org/x/sync v0.5.0 // indirect golang.org/x/sys v0.15.0 // indirect - golang.org/x/text v0.14.0 // indirect google.golang.org/api v0.128.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc // indirect @@ -56,5 +57,4 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc // indirect google.golang.org/grpc v1.56.3 // indirect google.golang.org/protobuf v1.31.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/i18y/i18y.go b/i18y/i18y.go new file mode 100644 index 0000000..fc5d0e5 --- /dev/null +++ b/i18y/i18y.go @@ -0,0 +1,72 @@ +package i18y + +import ( + "embed" + _ "embed" + "fmt" + "io/fs" + "path/filepath" + "strings" + + "golang.org/x/text/language" + "golang.org/x/text/message" + "gopkg.in/yaml.v3" +) + +const ( + LanguagesDir = "languages" +) + +var ( + languages = []language.Tag{ + language.Japanese, + language.English, + } + //go:embed languages/*.yaml + configByte embed.FS +) + +type Messages map[string]map[string]string + +func Init() error { + files, err := configByte.ReadDir(LanguagesDir) + if err != nil { + return err + } + + for _, file := range files { + data, err := fs.ReadFile(configByte, fmt.Sprintf("%s/%s", LanguagesDir, file.Name())) + if err != nil { + return err + } + + var m map[string]string + err = yaml.Unmarshal(data, &m) + if err != nil { + return err + } + + // Make language tag from file name + l := language.Make(strings.TrimSuffix(file.Name(), filepath.Ext(file.Name()))) + for k, v := range m { + err = message.SetString(l, k, v) + if err != nil { + return err + } + } + } + + return nil +} + +func Translate(acceptLanguage string, msg string, args ...interface{}) string { + t, _, err := language.ParseAcceptLanguage(acceptLanguage) + if err != nil { + return msg + } + + matcher := language.NewMatcher(languages) + tag, _, _ := matcher.Match(t...) + p := message.NewPrinter(tag) + return p.Sprintf(msg, args...) +} diff --git a/i18y/i18y_test.go b/i18y/i18y_test.go new file mode 100644 index 0000000..1f69768 --- /dev/null +++ b/i18y/i18y_test.go @@ -0,0 +1,38 @@ +package i18y + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/text/language" +) + +func TestTranslate(t *testing.T) { + err := Init() + assert.NoError(t, err) + + tests := []struct { + name string + acceptLanguage string + key string + out string + }{ + { + name: "japanese", + acceptLanguage: language.Japanese.String(), + key: "cost", + out: "料金", + }, + { + name: "english", + acceptLanguage: language.English.String(), + key: "cost", + out: "Cost", + }, + } + + for _, tt := range tests { + out := Translate(tt.acceptLanguage, tt.key) + assert.Equal(t, tt.out, out) + } +} diff --git a/i18y/languages/en.yaml b/i18y/languages/en.yaml new file mode 100644 index 0000000..f15cd28 --- /dev/null +++ b/i18y/languages/en.yaml @@ -0,0 +1,3 @@ +title: "%s cost of %s is `$%.3f`" +cost: Cost +usage: Usage diff --git a/i18y/languages/ja.yaml b/i18y/languages/ja.yaml new file mode 100644 index 0000000..75dc47e --- /dev/null +++ b/i18y/languages/ja.yaml @@ -0,0 +1,3 @@ +title: "%s : %s のコスト : `$%.3f`" +cost: 料金 +usage: 利用量 diff --git a/main.go b/main.go index bfa6d49..888c9de 100644 --- a/main.go +++ b/main.go @@ -9,16 +9,16 @@ import ( "github.com/aws/aws-lambda-go/lambda" "github.com/aws/aws-sdk-go/service/costexplorer" - "github.com/kelseyhightower/envconfig" "github.com/slack-go/slack" + "github.com/tetsuya28/aws-cost-report/config" "github.com/tetsuya28/aws-cost-report/external" + "github.com/tetsuya28/aws-cost-report/i18y" "github.com/ucpr/mongo-streamer/pkg/log" ) -type Config struct { - SlackToken string `required:"true" envconfig:"SLACK_TOKEN"` - SlackChannel string `required:"true" envconfig:"SLACK_CHANNEL"` -} +var ( + Language string +) type DailyCost struct { Total float64 @@ -37,14 +37,25 @@ func main() { } func handler() error { - config := Config{} - if err := envconfig.Process("", &config); err != nil { - panic(err) + cfg, err := config.New() + if err != nil { + log.Warn("failed to new config, err=%w", err) + return err } - slk := external.NewSlack(config.SlackToken) + + Language = cfg.Language + + err = i18y.Init() + if err != nil { + log.Warn("failed to init i18y, err=%w", err) + return err + } + + slk := external.NewSlack(cfg.SlackToken) result, err := external.GetCost() if err != nil { + log.Warn("failed to get cost, err=%w", err) return err } @@ -74,7 +85,7 @@ func handler() error { dailyCost.Services[serviceName] = c - // 日次合計を計算する + // Sum total daily cost dailyCost.Total += c.CostAmount } @@ -83,17 +94,19 @@ func handler() error { fullName, err := external.GetAccountFullName(context.Background()) if err != nil { + log.Warn("failed to get account info, err=%w", err) return err } now := time.Now() yesterday := now.AddDate(0, 0, -1) - text := fmt.Sprintf("%s の %s コスト\n合計金額: $%.3f", fullName, yesterday.Format("2006-01-02"), cost[0].Total) + text := i18y.Translate(Language, "title", fullName, yesterday.Format("2006-01-02"), cost[1].Total) option := slack.MsgOptionText(text, false) attachments := toAttachment(cost) - err = slk.PostMessage(config.SlackChannel, option, slack.MsgOptionAttachments(attachments...)) + err = slk.PostMessage(cfg.SlackChannel, option, slack.MsgOptionAttachments(attachments...)) if err != nil { + log.Warn("failed to post message to Slack, err=%w", err) return err } @@ -146,9 +159,9 @@ func toCost(result *costexplorer.Group) (ServiceDetail, error) { } func toAttachment(cost []DailyCost) []slack.Attachment { - // 一昨日、昨日のコスト比較なので 2 つのみ - // [0] : 一昨日、 [1] : 昨日 + // Just day before yesterday and yesterday if len(cost) != 2 { + log.Warn("cost length is not 2") return nil } @@ -162,22 +175,21 @@ func toAttachment(cost []DailyCost) []slack.Attachment { diff := (detail.CostAmount / before.CostAmount) * 100 if !math.IsNaN(diff) { - priceDiffStatement += " ( 前日比 : " - - // 前日よりも高くなってたら赤色にする + diffMark := "" + // Set red color if diff is over 100% if diff == 100 { color = "#ffffff" - priceDiffStatement += "" } else if diff > 100 { color = "#ff0000" - priceDiffStatement += "📈 " + diffMark = "📈" } else { color = "#0000ff" - priceDiffStatement += "📉 " + diffMark = "📉" } - priceDiffStatement += fmt.Sprintf( - "%.1f%% )", + priceDiffStatement = fmt.Sprintf( + " ( %s %.1f%% )", + diffMark, diff, ) } @@ -185,7 +197,7 @@ func toAttachment(cost []DailyCost) []slack.Attachment { fields := []slack.AttachmentField{ { - Title: "料金", + Title: i18y.Translate(Language, "cost"), Value: fmt.Sprintf( "%.3f%s%s", detail.CostAmount, @@ -195,7 +207,7 @@ func toAttachment(cost []DailyCost) []slack.Attachment { Short: true, }, { - Title: "使用量", + Title: i18y.Translate(Language, "usage"), Value: fmt.Sprintf("%.3f%s", detail.UsageAmount, detail.UsageUnit), Short: true, },