Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/cmd/cost.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ helm install \
cmd.AddCommand(newCmdTUI(streams))
cmd.AddCommand(newCmdVersion(streams, GitCommit, GitBranch, GitState, GitSummary, BuildDate))
cmd.AddCommand(NewCmdPredict(streams))
cmd.AddCommand(newCmdCostSavings(streams))

return cmd
}
103 changes: 103 additions & 0 deletions pkg/cmd/display/savings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package display

import (
"fmt"
"io"
"sort"

"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"

"github.com/kubecost/kubectl-cost/pkg/query"
)

func WriteSavingsTable(out io.Writer, recs []query.RequestSizingRecommendation, currencyCode string) {
t := MakeSavingsTable(recs, currencyCode)
t.SetOutputMirror(out)
t.Render()
}

func MakeSavingsTable(recs []query.RequestSizingRecommendation, currencyCode string) table.Writer {
t := table.NewWriter()

style := table.StyleLight
style.Options.SeparateColumns = false
style.Options.DrawBorder = false
style.Options.SeparateHeader = true
style.Title.Colors = append(style.Title.Colors, text.Bold)
t.SetStyle(style)

t.SetColumnConfigs([]table.ColumnConfig{
{Name: "Namespace", Align: text.AlignLeft},
{Name: "Controller", Align: text.AlignLeft, WidthMax: 40, WidthMaxEnforcer: text.WrapSoft},
{Name: "Container", Align: text.AlignLeft},
{Name: "Current CPU", Align: text.AlignRight},
{Name: "Rec. CPU", Align: text.AlignRight},
{Name: "Current RAM", Align: text.AlignRight},
{Name: "Rec. RAM", Align: text.AlignRight},
{Name: "CPU Eff.", Align: text.AlignRight},
{Name: "RAM Eff.", Align: text.AlignRight},
{
Name: "Savings/mo",
Align: text.AlignRight,
TransformerFooter: func(val interface{}) string {
if f, ok := val.(float64); ok {
return fmt.Sprintf("%.2f %s", f, currencyCode)
}
if s, ok := val.(string); ok {
return s
}
return ""
},
},
})

t.AppendHeader(table.Row{
"Namespace",
"Controller",
"Container",
"Current CPU",
"Rec. CPU",
"Current RAM",
"Rec. RAM",
"CPU Eff.",
"RAM Eff.",
"Savings/mo",
})

// Pre-sort by total monthly savings descending
sorted := make([]query.RequestSizingRecommendation, len(recs))
copy(sorted, recs)
sort.Slice(sorted, func(i, j int) bool {
si := sorted[i].MonthlySavings.CPU + sorted[i].MonthlySavings.Memory
sj := sorted[j].MonthlySavings.CPU + sorted[j].MonthlySavings.Memory
return si > sj
})

totalSavings := 0.0
for _, rec := range sorted {
controller := fmt.Sprintf("%s/%s", rec.ControllerKind, rec.ControllerName)
monthlySavings := rec.MonthlySavings.CPU + rec.MonthlySavings.Memory
totalSavings += monthlySavings

t.AppendRow(table.Row{
rec.Namespace,
controller,
rec.ContainerName,
rec.LatestKnownRequest.CPU,
rec.RecommendedRequest.CPU,
rec.LatestKnownRequest.Memory,
rec.RecommendedRequest.Memory,
fmt.Sprintf("%.0f%%", rec.CurrentEfficiency.CPU*100),
fmt.Sprintf("%.0f%%", rec.CurrentEfficiency.Memory*100),
fmt.Sprintf("%.2f %s", monthlySavings, currencyCode),
})
}

t.AppendFooter(table.Row{
"TOTAL", "", "", "", "", "", "", "", "",
totalSavings,
})

return t
}
162 changes: 162 additions & 0 deletions pkg/cmd/display/savings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package display

import (
"bytes"
"strings"
"testing"

"github.com/kubecost/kubectl-cost/pkg/query"
)

func TestMakeSavingsTable_Empty(t *testing.T) {
tw := MakeSavingsTable(nil, "USD")
out := tw.Render()

if !strings.Contains(strings.ToUpper(out), "NAMESPACE") {
t.Error("expected header to contain Namespace")
}
if !strings.Contains(out, "TOTAL") {
t.Error("expected footer to contain TOTAL")
}
if !strings.Contains(out, "0.00 USD") {
t.Errorf("expected total of 0.00 USD, got:\n%s", out)
}
}

func TestMakeSavingsTable_SingleRec(t *testing.T) {
recs := []query.RequestSizingRecommendation{
{
ClusterID: "cluster-one",
Namespace: "default",
ControllerKind: "Deployment",
ControllerName: "nginx",
ContainerName: "nginx",
RecommendedRequest: struct {
CPU string `json:"cpu"`
Memory string `json:"memory"`
}{CPU: "100m", Memory: "128Mi"},
MonthlySavings: struct {
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
}{CPU: 5.50, Memory: 2.30},
LatestKnownRequest: struct {
CPU string `json:"cpu"`
Memory string `json:"memory"`
}{CPU: "500m", Memory: "512Mi"},
CurrentEfficiency: struct {
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
Total float64 `json:"total"`
}{CPU: 0.20, Memory: 0.25, Total: 0.225},
},
}

tw := MakeSavingsTable(recs, "EUR")
out := tw.Render()

checks := []string{
"default",
"Deployment/nginx",
"nginx",
"500m",
"100m",
"512Mi",
"128Mi",
"20%",
"25%",
"7.80 EUR",
}
for _, want := range checks {
if !strings.Contains(out, want) {
t.Errorf("expected table to contain %q, got:\n%s", want, out)
}
}
}

func TestMakeSavingsTable_MultipleRecs_SortedBySavings(t *testing.T) {
recs := []query.RequestSizingRecommendation{
{
Namespace: "ns-a",
ControllerKind: "Deployment",
ControllerName: "small-saver",
ContainerName: "app",
RecommendedRequest: struct {
CPU string `json:"cpu"`
Memory string `json:"memory"`
}{CPU: "50m", Memory: "64Mi"},
MonthlySavings: struct {
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
}{CPU: 1.00, Memory: 0.50},
LatestKnownRequest: struct {
CPU string `json:"cpu"`
Memory string `json:"memory"`
}{CPU: "100m", Memory: "128Mi"},
CurrentEfficiency: struct {
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
Total float64 `json:"total"`
}{CPU: 0.50, Memory: 0.50, Total: 0.50},
},
{
Namespace: "ns-b",
ControllerKind: "StatefulSet",
ControllerName: "big-saver",
ContainerName: "db",
RecommendedRequest: struct {
CPU string `json:"cpu"`
Memory string `json:"memory"`
}{CPU: "1", Memory: "1Gi"},
MonthlySavings: struct {
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
}{CPU: 20.00, Memory: 10.00},
LatestKnownRequest: struct {
CPU string `json:"cpu"`
Memory string `json:"memory"`
}{CPU: "4", Memory: "8Gi"},
CurrentEfficiency: struct {
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
Total float64 `json:"total"`
}{CPU: 0.25, Memory: 0.125, Total: 0.1875},
},
}

tw := MakeSavingsTable(recs, "USD")
out := tw.Render()

// big-saver (30.00) should appear before small-saver (1.50) due to DscNumeric sort
bigIdx := strings.Index(out, "big-saver")
smallIdx := strings.Index(out, "small-saver")
if bigIdx == -1 || smallIdx == -1 {
t.Fatalf("expected both rows in output, got:\n%s", out)
}
if bigIdx > smallIdx {
t.Errorf("expected big-saver before small-saver (descending savings sort), got:\n%s", out)
}
if !strings.Contains(out, "30.00 USD") {
t.Errorf("expected 30.00 USD in output, got:\n%s", out)
}
if !strings.Contains(out, "1.50 USD") {
t.Errorf("expected 1.50 USD in output, got:\n%s", out)
}

// Total should be 31.50
if !strings.Contains(out, "31.50 USD") {
t.Errorf("expected total of 31.50 USD, got:\n%s", out)
}
}

func TestWriteSavingsTable_WritesToOutput(t *testing.T) {
var buf bytes.Buffer
WriteSavingsTable(&buf, nil, "USD")

out := buf.String()
if len(out) == 0 {
t.Error("expected non-empty output")
}
if !strings.Contains(strings.ToUpper(out), "SAVINGS/MO") {
t.Errorf("expected header in output, got:\n%s", out)
}
}
101 changes: 101 additions & 0 deletions pkg/cmd/savings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package cmd

import (
"context"
"fmt"

"github.com/kubecost/kubectl-cost/pkg/cmd/display"
"github.com/kubecost/kubectl-cost/pkg/cmd/utilities"
"github.com/kubecost/kubectl-cost/pkg/query"

"github.com/opencost/opencost/core/pkg/log"

"github.com/spf13/cobra"
"k8s.io/client-go/rest"

"k8s.io/cli-runtime/pkg/genericclioptions"
)

// SavingsOptions contains options specific to savings queries.
type SavingsOptions struct {
window string

query.QueryBackendOptions
}

func newCmdCostSavings(
streams genericclioptions.IOStreams,
) *cobra.Command {
kubeO := utilities.NewKubeOptions(streams)
savingsO := &SavingsOptions{}

cmd := &cobra.Command{
Use: "savings",
Short: "Show container request sizing recommendations and estimated monthly savings from right-sizing.",
RunE: func(c *cobra.Command, args []string) error {
if err := kubeO.Complete(c, args); err != nil {
return fmt.Errorf("complete k8s options: %s", err)
}
if err := kubeO.Validate(); err != nil {
return fmt.Errorf("validate k8s options: %s", err)
}

if err := savingsO.Complete(kubeO.RestConfig); err != nil {
return fmt.Errorf("complete: %s", err)
}
if err := savingsO.Validate(); err != nil {
return fmt.Errorf("validate: %s", err)
}

return runCostSavings(kubeO, savingsO)
},
}
cmd.Flags().StringVarP(&savingsO.window, "window", "w", "2d", "The window of data to use for the savings recommendation. See https://github.com/kubecost/docs/blob/master/allocation.md#querying for a detailed explanation of what can be passed here.")

query.AddQueryBackendOptionsFlags(cmd, &savingsO.QueryBackendOptions)
utilities.AddKubeOptionsFlags(cmd, kubeO)

cmd.SilenceUsage = true

return cmd
}

func (savingsO *SavingsOptions) Validate() error {
if err := savingsO.QueryBackendOptions.Validate(); err != nil {
return fmt.Errorf("validating query options: %s", err)
}

return nil
}

func (savingsO *SavingsOptions) Complete(restConfig *rest.Config) error {
if err := savingsO.QueryBackendOptions.Complete(restConfig); err != nil {
return fmt.Errorf("complete backend opts: %s", err)
}
return nil
}

func runCostSavings(ko *utilities.KubeOptions, so *SavingsOptions) error {
currencyCode, err := query.QueryCurrencyCode(query.CurrencyCodeParameters{
Ctx: context.Background(),
QueryBackendOptions: so.QueryBackendOptions,
})
if err != nil {
log.Debugf("failed to get currency code, displaying as empty string: %s", err)
currencyCode = ""
}

recs, err := query.QuerySavings(query.SavingsParameters{
Ctx: context.Background(),
QueryBackendOptions: so.QueryBackendOptions,
QueryParams: map[string]string{
"window": so.window,
},
})
if err != nil {
return fmt.Errorf("querying savings API: %s", err)
}

display.WriteSavingsTable(ko.Out, recs, currencyCode)
return nil
}
Loading