From 238f59b4724bcb605965b91d3a2081dca7932854 Mon Sep 17 00:00:00 2001 From: winebarrel Date: Tue, 19 Dec 2023 12:12:38 +0900 Subject: [PATCH] first implementation --- .gitignore | 1 + LICENSE | 21 ++++++ README.md | 1 + client.go | 86 +++++++++++++++++++++++ cmd/ddusage/main.go | 39 +++++++++++ go.mod | 24 +++++++ go.sum | 62 +++++++++++++++++ internal/util/map.go | 32 +++++++++ options.go | 57 +++++++++++++++ print.go | 161 +++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 484 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 client.go create mode 100644 cmd/ddusage/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/util/map.go create mode 100644 options.go create mode 100644 print.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b1c09e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/ddusage diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..58e8af0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Genki Sugawara + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4399bbf --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# ddusage diff --git a/client.go b/client.go new file mode 100644 index 0000000..6e75f6f --- /dev/null +++ b/client.go @@ -0,0 +1,86 @@ +package ddusage + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/DataDog/datadog-api-client-go/v2/api/datadog" + "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" +) + +type Client struct { + options *ClientOptions + api *datadogV1.UsageMeteringApi +} + +func NewClient(options *ClientOptions) *Client { + configuration := datadog.NewConfiguration() + apiClient := datadog.NewAPIClient(configuration) + api := datadogV1.NewUsageMeteringApi(apiClient) + + client := &Client{ + options: options, + api: api, + } + + return client +} + +func (client *Client) withAPIKey(ctx context.Context) context.Context { + ctx = context.WithValue( + ctx, + datadog.ContextAPIKeys, + map[string]datadog.APIKey{ + "apiKeyAuth": { + Key: client.options.APIKey, + }, + "appKeyAuth": { + Key: client.options.APPKey, + }, + }, + ) + + return ctx +} + +func (client *Client) PrintUsageSummary(out io.Writer, options *PrintUsageSummaryOptions) error { + timeStartMonth, timeEndMonth, err := options.calcPeriod() + + if err != nil { + return err + } + + ctx := client.withAPIKey(context.Background()) + resp, _, err := client.api.GetUsageSummary( + ctx, + timeStartMonth, + *datadogV1.NewGetUsageSummaryOptionalParameters(). + WithEndMonth(timeEndMonth). + WithIncludeOrgDetails(options.IncludeOrgDetails), + ) + + if err != nil { + var dderr datadog.GenericOpenAPIError + + if errors.As(err, &dderr) { + err = fmt.Errorf("%w: %s", err, dderr.ErrorBody) + } + + return err + } + + switch options.Output { + case "table": + printTable(&resp, out, options.Humanize) + case "tsv": + printTSV(&resp, out, "\t") + case "json": + printJSON(&resp, out) + case "csv": + printTSV(&resp, out, ",") + } + + return nil +} diff --git a/cmd/ddusage/main.go b/cmd/ddusage/main.go new file mode 100644 index 0000000..3325d30 --- /dev/null +++ b/cmd/ddusage/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "log" + "os" + + "github.com/alecthomas/kong" + "github.com/winebarrel/ddusage" +) + +var version string + +type options struct { + ddusage.ClientOptions + ddusage.PrintUsageSummaryOptions +} + +func init() { + log.SetFlags(0) +} + +func main() { + var cli struct { + options + Version kong.VersionFlag + } + + kong.Parse( + &cli, + kong.Vars{"version": version}, + ) + + client := ddusage.NewClient(&cli.ClientOptions) + err := client.PrintUsageSummary(os.Stdout, &cli.PrintUsageSummaryOptions) + + if err != nil { + log.Fatal(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b36801e --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module github.com/winebarrel/ddusage + +go 1.21.5 + +require ( + github.com/DataDog/datadog-api-client-go/v2 v2.20.0 + github.com/alecthomas/kong v0.8.1 + github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de + github.com/dustin/go-humanize v1.0.1 + github.com/olekukonko/tablewriter v0.0.5 + golang.org/x/exp v0.0.0-20231214170342-aacd6d4b4611 +) + +require ( + github.com/DataDog/zstd v1.5.2 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/mattn/go-runewidth v0.0.10 // indirect + github.com/rivo/uniseg v0.1.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/oauth2 v0.10.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bd6a033 --- /dev/null +++ b/go.sum @@ -0,0 +1,62 @@ +github.com/DataDog/datadog-api-client-go/v2 v2.20.0 h1:80T+UuTh+28qODc2vw+HxzMoIu0dYBT7/RCHXxdYpJE= +github.com/DataDog/datadog-api-client-go/v2 v2.20.0/go.mod h1:oD5Lx8Li3oPRa/BSBenkn4i48z+91gwYORF/+6ph71g= +github.com/DataDog/zstd v1.5.2 h1:vUG4lAyuPCXO0TLbXvPv7EB7cNK1QV/luu55UHLrrn8= +github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= +github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0= +github.com/alecthomas/assert/v2 v2.1.0/go.mod h1:b/+1DI2Q6NckYi+3mXyH3wFb8qG37K/DuK80n7WefXA= +github.com/alecthomas/kong v0.8.1 h1:acZdn3m4lLRobeh3Zi2S2EpnXTd1mOL6U7xVml+vfkY= +github.com/alecthomas/kong v0.8.1/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U= +github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE= +github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/exp v0.0.0-20231214170342-aacd6d4b4611 h1:qCEDpW1G+vcj3Y7Fy52pEM1AWm3abj8WimGYejI3SC4= +golang.org/x/exp v0.0.0-20231214170342-aacd6d4b4611/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8= +golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/util/map.go b/internal/util/map.go new file mode 100644 index 0000000..84844cc --- /dev/null +++ b/internal/util/map.go @@ -0,0 +1,32 @@ +package util + +import ( + "sort" + + "golang.org/x/exp/constraints" +) + +func MapValueOrDefault[K comparable, V any](m map[K]V, key K, defval V) V { + v, ok := m[key] + + if !ok { + v = defval + m[key] = v + } + + return v +} + +func MapSortKeys[K constraints.Ordered, V any](m map[K]V) []K { + keys := []K{} + + for k := range m { + keys = append(keys, k) + } + + sort.Slice(keys, func(i, j int) bool { + return keys[i] < keys[j] + }) + + return keys +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..7b07b8d --- /dev/null +++ b/options.go @@ -0,0 +1,57 @@ +package ddusage + +import ( + "time" + + "github.com/araddon/dateparse" +) + +var ( + defaultStartMonth time.Time + defaultEndMonth time.Time +) + +func init() { + defaultEndMonth = time.Now() + defaultStartMonth = defaultEndMonth.AddDate(0, -6, 0) +} + +type ClientOptions struct { + APIKey string `env:"DD_API_KEY" required:"" help:"Datadog API key."` + APPKey string `env:"DD_APP_KEY" required:"" help:"Datadog APP key."` +} + +type PrintUsageSummaryOptions struct { + IncludeOrgDetails bool `short:"x" help:"Include usage summaries for each sub-org.."` + Output string `short:"o" enum:"table,tsv,json,csv" default:"table" help:"Formatting style for output (table, tsv, json, csv)."` + StartMonth string `short:"s" help:"Usage beginning this month."` + EndMonth string `short:"e" help:"Usage ending this month."` + Humanize bool `short:"H" help:"Convert usage numbers to to human-friendly strings."` +} + +func (options *PrintUsageSummaryOptions) calcPeriod() (time.Time, time.Time, error) { + timeStartMonth := defaultStartMonth + timeEndMonth := defaultEndMonth + + if options.StartMonth != "" { + t, err := dateparse.ParseAny(options.StartMonth) + + if err != nil { + return timeStartMonth, timeEndMonth, err + } + + timeStartMonth = t + } + + if options.EndMonth != "" { + t, err := dateparse.ParseAny(options.EndMonth) + + if err != nil { + return timeStartMonth, timeEndMonth, err + } + + timeEndMonth = t + } + + return timeStartMonth, timeEndMonth, nil +} diff --git a/print.go b/print.go new file mode 100644 index 0000000..f426378 --- /dev/null +++ b/print.go @@ -0,0 +1,161 @@ +package ddusage + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" + "github.com/dustin/go-humanize" + "github.com/olekukonko/tablewriter" + "github.com/winebarrel/ddusage/internal/util" +) + +type Usage float64 + +func (c Usage) String() string { + return fmt.Sprintf("%.0f", c) +} + +func (c Usage) Float64() float64 { + return float64(c) +} + +// month/usage +type UsageByMonth map[string]Usage + +// product_name/month/usage +type UsageByProduct map[string]UsageByMonth + +// org_name/product_name/month/cost +type UsageBreakdown map[string]UsageByProduct + +func breakdownUsage(resp *datadogV1.UsageSummaryResponse) (UsageBreakdown, []string) { + ubd := UsageBreakdown{} + monthSet := map[string]struct{}{} + + for _, u := range resp.Usage { + month := u.Date.Format("2006-01") + monthSet[month] = struct{}{} + + breakdownUsageByOrg := func(org string, props map[string]any) { + byProduct := util.MapValueOrDefault(ubd, org, UsageByProduct{}) + + for product, usage := range props { + if usage == nil { + continue + } + + if v, ok := usage.(float64); ok { + byMonth := util.MapValueOrDefault(byProduct, product, UsageByMonth{}) + byMonth[month] = Usage(v) + } + } + } + + if len(u.Orgs) == 0 { + breakdownUsageByOrg("-", u.AdditionalProperties) + } else { + for _, org := range u.Orgs { + breakdownUsageByOrg(*org.Name, org.AdditionalProperties) + } + } + } + + return ubd, util.MapSortKeys(monthSet) +} + +func printTable(resp *datadogV1.UsageSummaryResponse, out io.Writer, h bool) { + ubd, months := breakdownUsage(resp) + + table := tablewriter.NewWriter(out) + table.SetBorder(false) + + if h { + table.SetAlignment(tablewriter.ALIGN_LEFT) + } + + header := []string{"org", "product"} + header = append(header, months...) + table.SetHeader(header) + + printTable0(ubd, months, out, h, func(row []string) { + table.Append(row) + }) + + table.Render() +} + +func printTSV(resp *datadogV1.UsageSummaryResponse, out io.Writer, sep string) { + ubd, months := breakdownUsage(resp) + + header := []string{"org", "product"} + header = append(header, months...) + fmt.Fprintln(out, strings.Join(header, sep)) + + printTable0(ubd, months, out, false, func(row []string) { + if strings.Join(row, "") != "" { + fmt.Fprintln(out, strings.Join(row, sep)) + } else { + fmt.Fprintln(out) + } + }) +} + +func printTable0(ubd UsageBreakdown, months []string, out io.Writer, h bool, procRow func([]string)) { + emptyLine := make([]string, len(months)+2) + idxOrg := 0 + + for _, org := range util.MapSortKeys(ubd) { + usageByProduct := ubd[org] + rows := [][]string{} + + for _, product := range util.MapSortKeys(usageByProduct) { + usageByMonth := usageByProduct[product] + row := []string{"", product} + + for _, month := range util.MapSortKeys(usageByMonth) { + usage := usageByMonth[month] + column := "" + + if h { + if usage != 0 { + v, unit := humanize.ComputeSI(usage.Float64()) + column = humanize.FtoaWithDigits(v, 2) + unit + } + } else { + column = usage.String() + } + + row = append(row, column) + } + + if h && strings.Join(row[2:], "") == "" { + continue + } + + rows = append(rows, row) + } + + if len(rows) > 0 { + if idxOrg != 0 { + procRow(emptyLine) + } + + rows[0][0] = org + + for _, row := range rows { + procRow(row) + } + + idxOrg++ + } + } +} + +func printJSON(resp *datadogV1.UsageSummaryResponse, out io.Writer) { + ubd, _ := breakdownUsage(resp) + m, _ := json.MarshalIndent(ubd, "", " ") + fmt.Fprintln(out, string(m)) +}