From 834f683514229e6e3c0cbe3cc76e0411ed441e0c Mon Sep 17 00:00:00 2001 From: Gerrit Date: Thu, 26 Sep 2024 14:35:09 +0200 Subject: [PATCH] Billing commands. --- cmd/billing.go | 77 +++++++++++++++++++++++ cmd/project-reservations.go | 7 ++- cmd/sorters/project-reservations.go | 32 ++++++++++ cmd/tableprinters/printer.go | 2 + cmd/tableprinters/project-reservations.go | 54 ++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 7 files changed, 174 insertions(+), 4 deletions(-) diff --git a/cmd/billing.go b/cmd/billing.go index b29a30d..4f384ff 100644 --- a/cmd/billing.go +++ b/cmd/billing.go @@ -6,6 +6,7 @@ import ( "github.com/fi-ts/cloud-go/api/client/accounting" "github.com/fi-ts/cloud-go/api/models" + "github.com/fi-ts/cloudctl/cmd/sorters" "github.com/go-openapi/strfmt" "github.com/go-playground/validator/v10" "github.com/jinzhu/now" @@ -123,6 +124,24 @@ export CLOUDCTL_COSTS_HOUR=0.01 # costs per hour return c.machineUsage() }, } + machineReservationBillingCmd := &cobra.Command{ + Use: "machine-reservation", + Short: "look at machine reservation bills", + Long: ` +You may want to convert the usage to a price in Euro by using the prices from your contract. You can use the following environment variables: + +export CLOUDCTL_COSTS_HOUR=0.01 # costs per hour + +⚠ Please be aware that any costs calculated in this fashion can still be different from the final bill as it does not include contract specific details like minimum purchase, discounts, etc. +`, + RunE: func(cmd *cobra.Command, args []string) error { + err := initBillingOpts() + if err != nil { + return err + } + return c.machineReservationUsage() + }, + } productOptionBillingCmd := &cobra.Command{ Use: "product-option", Short: "look at product option bills", @@ -227,6 +246,7 @@ export CLOUDCTL_COSTS_STORAGE_GI_HOUR=0.01 # Costs per capacity hour billingCmd.AddCommand(volumeBillingCmd) billingCmd.AddCommand(postgresBillingCmd) billingCmd.AddCommand(machineBillingCmd) + billingCmd.AddCommand(machineReservationBillingCmd) billingCmd.AddCommand(productOptionBillingCmd) billingOpts = &BillingOpts{} @@ -282,6 +302,23 @@ export CLOUDCTL_COSTS_STORAGE_GI_HOUR=0.01 # Costs per capacity hour genericcli.Must(viper.BindPFlags(machineBillingCmd.Flags())) + machineReservationBillingCmd.Flags().StringVarP(&billingOpts.Tenant, "tenant", "t", "", "the tenant to account") + machineReservationBillingCmd.Flags().StringP("time-format", "", "2006-01-02", "the time format used to parse the arguments 'from' and 'to'") + machineReservationBillingCmd.Flags().StringVarP(&billingOpts.FromString, "from", "", "", "the start time in the accounting window to look at (optional, defaults to start of the month") + machineReservationBillingCmd.Flags().StringVarP(&billingOpts.ToString, "to", "", "", "the end time in the accounting window to look at (optional, defaults to current system time)") + machineReservationBillingCmd.Flags().StringVarP(&billingOpts.ProjectID, "project-id", "p", "", "the project to account") + machineReservationBillingCmd.Flags().String("id", "", "the id to account") + machineReservationBillingCmd.Flags().String("size-id", "", "the size-id to account") + machineReservationBillingCmd.Flags().String("partition-id", "", "the partition-id to account") + + genericcli.Must(machineReservationBillingCmd.RegisterFlagCompletionFunc("tenant", c.comp.TenantListCompletion)) + genericcli.Must(machineReservationBillingCmd.RegisterFlagCompletionFunc("project-id", c.comp.ProjectListCompletion)) + genericcli.Must(machineReservationBillingCmd.RegisterFlagCompletionFunc("partition-id", c.comp.PartitionListCompletion)) + genericcli.Must(machineReservationBillingCmd.RegisterFlagCompletionFunc("size-id", c.comp.SizeListCompletion)) + genericcli.AddSortFlag(machineReservationBillingCmd, sorters.MachineReservationsBillingUsageSorter()) + + genericcli.Must(viper.BindPFlags(machineReservationBillingCmd.Flags())) + productOptionBillingCmd.Flags().StringVarP(&billingOpts.Tenant, "tenant", "t", "", "the tenant to account") productOptionBillingCmd.Flags().StringP("time-format", "", "2006-01-02", "the time format used to parse the arguments 'from' and 'to'") productOptionBillingCmd.Flags().StringVarP(&billingOpts.FromString, "from", "", "", "the start time in the accounting window to look at (optional, defaults to start of the month") @@ -495,6 +532,46 @@ func (c *config) machineUsage() error { return c.listPrinter.Print(response.Payload) } +func (c *config) machineReservationUsage() error { + from := strfmt.DateTime(billingOpts.From) + cur := models.V1MachineReservationUsageRequest{ + From: &from, + To: strfmt.DateTime(billingOpts.To), + } + if billingOpts.Tenant != "" { + cur.Tenant = billingOpts.Tenant + } + if billingOpts.ProjectID != "" { + cur.Projectid = billingOpts.ProjectID + } + if viper.IsSet("id") { + cur.ID = viper.GetString("id") + } + if viper.IsSet("size-id") { + cur.Sizeid = viper.GetString("size-id") + } + if viper.IsSet("partition-id") { + cur.Partition = viper.GetString("partition-id") + } + + response, err := c.cloud.Accounting.MachineReservationUsage(accounting.NewMachineReservationUsageParams().WithBody(&cur), nil) + if err != nil { + return err + } + + keys, err := genericcli.ParseSortFlags() + if err != nil { + return err + } + + err = sorters.MachineReservationsBillingUsageSorter().SortBy(response.Payload.Usage, keys...) + if err != nil { + return err + } + + return c.listPrinter.Print(response.Payload) +} + func (c *config) productOptionUsage() error { from := strfmt.DateTime(billingOpts.From) cur := models.V1ProductOptionUsageRequest{ diff --git a/cmd/project-reservations.go b/cmd/project-reservations.go index 2356d47..5d3e12c 100644 --- a/cmd/project-reservations.go +++ b/cmd/project-reservations.go @@ -203,7 +203,12 @@ func (m machineReservationsCmd) machineReservationsUsage() error { return err } - err = sorters.MachineReservationsUsageSorter().SortBy(resp.Payload) + keys, err := genericcli.ParseSortFlags() + if err != nil { + return err + } + + err = sorters.MachineReservationsUsageSorter().SortBy(resp.Payload, keys...) if err != nil { return err } diff --git a/cmd/sorters/project-reservations.go b/cmd/sorters/project-reservations.go index db29ba8..c30c97c 100644 --- a/cmd/sorters/project-reservations.go +++ b/cmd/sorters/project-reservations.go @@ -1,6 +1,8 @@ package sorters import ( + "strconv" + "github.com/fi-ts/cloud-go/api/models" "github.com/metal-stack/metal-lib/pkg/multisort" p "github.com/metal-stack/metal-lib/pkg/pointer" @@ -51,3 +53,33 @@ func MachineReservationsUsageSorter() *multisort.Sorter[*models.V1MachineReserva }, }, multisort.Keys{{ID: "tenant"}, {ID: "project"}, {ID: "partition"}, {ID: "size"}, {ID: "id"}}) } + +func MachineReservationsBillingUsageSorter() *multisort.Sorter[*models.V1MachineReservationUsage] { + return multisort.New(multisort.FieldMap[*models.V1MachineReservationUsage]{ + "id": func(a, b *models.V1MachineReservationUsage, descending bool) multisort.CompareResult { + return multisort.Compare(p.SafeDeref(a.ID), p.SafeDeref(b.ID), descending) + }, + "tenant": func(a, b *models.V1MachineReservationUsage, descending bool) multisort.CompareResult { + return multisort.Compare(p.SafeDeref(a.Tenant), p.SafeDeref(b.Tenant), descending) + }, + "project": func(a, b *models.V1MachineReservationUsage, descending bool) multisort.CompareResult { + return multisort.Compare(p.SafeDeref(a.Projectid), p.SafeDeref(b.Projectid), descending) + }, + "size": func(a, b *models.V1MachineReservationUsage, descending bool) multisort.CompareResult { + return multisort.Compare(p.SafeDeref(a.Sizeid), p.SafeDeref(b.Sizeid), descending) + }, + "partition": func(a, b *models.V1MachineReservationUsage, descending bool) multisort.CompareResult { + return multisort.Compare(p.SafeDeref(a.Partition), p.SafeDeref(b.Partition), descending) + }, + "reservation-seconds": func(a, b *models.V1MachineReservationUsage, descending bool) multisort.CompareResult { + aSeconds, _ := strconv.ParseInt(p.SafeDeref(a.Reservationseconds), 10, 64) + bSeconds, _ := strconv.ParseInt(p.SafeDeref(b.Reservationseconds), 10, 64) + return multisort.Compare(aSeconds, bSeconds, descending) + }, + "average": func(a, b *models.V1MachineReservationUsage, descending bool) multisort.CompareResult { + aSeconds, _ := strconv.ParseFloat(p.SafeDeref(a.Average), 64) + bSeconds, _ := strconv.ParseFloat(p.SafeDeref(b.Average), 64) + return multisort.Compare(aSeconds, bSeconds, descending) + }, + }, multisort.Keys{{ID: "tenant"}, {ID: "project"}, {ID: "partition"}, {ID: "size"}, {ID: "id"}}) +} diff --git a/cmd/tableprinters/printer.go b/cmd/tableprinters/printer.go index edaa41e..179f4dc 100644 --- a/cmd/tableprinters/printer.go +++ b/cmd/tableprinters/printer.go @@ -43,6 +43,8 @@ func (t *TablePrinter) ToHeaderAndRows(data any, wide bool) ([]string, [][]strin return t.MachineReservationsUsageTable(pointer.WrapInSlice(d), wide) case []*models.V1MachineReservationUsageResponse: return t.MachineReservationsUsageTable(d, wide) + case *models.V1MachineReservationBillingUsageResponse: + return t.MachineReservationsBillingTable(d, wide) default: // fallback to old printer for as long as the migration takes: diff --git a/cmd/tableprinters/project-reservations.go b/cmd/tableprinters/project-reservations.go index 9db829d..8a61b2b 100644 --- a/cmd/tableprinters/project-reservations.go +++ b/cmd/tableprinters/project-reservations.go @@ -5,11 +5,14 @@ import ( "sort" "strconv" "strings" + "time" "github.com/fi-ts/cloud-go/api/models" + "github.com/fi-ts/cloudctl/cmd/helper" "github.com/metal-stack/metal-lib/pkg/genericcli" "github.com/metal-stack/metal-lib/pkg/pointer" "github.com/olekukonko/tablewriter" + "github.com/spf13/viper" ) func (t *TablePrinter) MachineReservationsTable(data []*models.V1MachineReservationResponse, wide bool) ([]string, [][]string, error) { @@ -101,3 +104,54 @@ func (t *TablePrinter) MachineReservationsUsageTable(data []*models.V1MachineRes return header, rows, nil } + +func (t *TablePrinter) MachineReservationsBillingTable(data *models.V1MachineReservationBillingUsageResponse, wide bool) ([]string, [][]string, error) { + var ( + header = []string{"Tenant", "From", "To", "ProjectID", "ProjectName", "Partition", "Size", "ID", "Reservations * Time", "Average"} + rows [][]string + ) + + for _, rv := range data.Usage { + row := []string{ + pointer.SafeDeref(rv.Tenant), + time.Time(pointer.SafeDeref(data.From)).String(), + time.Time(data.To).String(), + pointer.SafeDeref(rv.Projectid), + pointer.SafeDeref(rv.Projectname), + pointer.SafeDeref(rv.Partition), + pointer.SafeDeref(rv.Sizeid), + pointer.SafeDeref(rv.ID), + humanizeSeconds(pointer.SafeDeref(rv.Reservationseconds)), + pointer.SafeDeref(rv.Average), + } + + rows = append(rows, row) + } + + rows = append(rows, []string{"Total", "", "", "", "", "", "", "", + humanizeSeconds(pointer.SafeDeref(data.Accumulatedusage.Reservationseconds)) + secondsCosts(pointer.SafeDeref(data.Accumulatedusage.Reservationseconds)), + pointer.SafeDeref(data.Accumulatedusage.Average), + }) + + return header, rows, nil +} + +func humanizeSeconds(seconds string) string { + duration, err := strconv.ParseInt(seconds, 10, 64) + if err == nil { + return helper.HumanizeDuration(time.Duration(duration) * time.Second) + } + return "" +} + +func secondsCosts(seconds string) string { + costsPerHour := viper.GetFloat64("costs-hour") + if costsPerHour <= 0 { + return "" + } + duration, err := strconv.ParseInt(seconds, 10, 64) + if err == nil { + return fmt.Sprintf(" (%.2f €)", float64(duration/3600)*costsPerHour) + } + return "" +} diff --git a/go.mod b/go.mod index e9b22b0..0cefad6 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/fatih/color v1.17.0 github.com/fi-ts/accounting-go v0.10.0 - github.com/fi-ts/cloud-go v0.28.1-0.20240926110310-7a45ce24c224 + github.com/fi-ts/cloud-go v0.28.1-0.20240926120644-8d097ad6845c github.com/gardener/gardener v1.91.0 github.com/gardener/machine-controller-manager v0.53.1 github.com/go-openapi/runtime v0.28.0 diff --git a/go.sum b/go.sum index 937da5e..859ca5d 100644 --- a/go.sum +++ b/go.sum @@ -90,8 +90,8 @@ github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/fi-ts/accounting-go v0.10.0 h1:vbPgTWq1iicyBWFRajX0bawZ1ADbhKGuJyNEtXjpr08= github.com/fi-ts/accounting-go v0.10.0/go.mod h1:ARKouuFYUV44xUKytAlczpzoti/S+o+PnXCN5BQA6nQ= -github.com/fi-ts/cloud-go v0.28.1-0.20240926110310-7a45ce24c224 h1:nur9xTZKDxMsCjlQEmAYnrSJkdQVTGO25CgVXoU+O4c= -github.com/fi-ts/cloud-go v0.28.1-0.20240926110310-7a45ce24c224/go.mod h1:pcGGl+M2OmtvwyuTEOimqSHrZngDotG69lmBzEbx6cc= +github.com/fi-ts/cloud-go v0.28.1-0.20240926120644-8d097ad6845c h1:9ESEl2gizrHyypb6vkLkk2EAaCUdHrxkGn/tJMLBl0c= +github.com/fi-ts/cloud-go v0.28.1-0.20240926120644-8d097ad6845c/go.mod h1:pcGGl+M2OmtvwyuTEOimqSHrZngDotG69lmBzEbx6cc= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=