diff --git a/cmd/create.go b/cmd/create.go index 55a8e27..3103a8e 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -15,8 +15,12 @@ package cmd import ( + "fmt" "os" + "slices" + "time" + "github.com/fatih/color" "github.com/ksctl/ksctl/v2/pkg/consts" "github.com/ksctl/ksctl/v2/pkg/provider" @@ -73,7 +77,7 @@ func (k *KsctlCommand) metadataForSelfManagedCluster( os.Exit(1) } - k.handleRegionSelection(metaClient, meta) + allAvailRegions := k.handleRegionSelection(metaClient, meta) cp := k.handleInstanceTypeSelection(metaClient, meta, provider.ComputeIntensive, "Select instance_type for Control Plane") etcd := k.handleInstanceTypeSelection(metaClient, meta, provider.MemoryIntensive, "Select instance_type for Etcd Nodes") @@ -118,11 +122,18 @@ func (k *KsctlCommand) metadataForSelfManagedCluster( meta.NoDS = v } + isOptimizeInstanceRegionReady := make(chan []RecommendationSelfManagedCost) + + go func() { + isOptimizeInstanceRegionReady <- k.OptimizeSelfManagedInstanceTypesAcrossRegions(meta, allAvailRegions, cp, wp, etcd, lb) + }() + bootstrapVers, err := metaClient.ListAllBootstrapVersions() if err != nil { k.l.Error("Failed to get the list of bootstrap versions", "Reason", err) os.Exit(1) } + if v, err := k.menuDriven.DropDownList("Select the bootstrap version", bootstrapVers, cli.WithDefaultValue(bootstrapVers[0])); err != nil { k.l.Error("Failed to get the bootstrap version", "Reason", err) os.Exit(1) @@ -144,6 +155,7 @@ func (k *KsctlCommand) metadataForSelfManagedCluster( meta.EtcdVersion = v } + k.l.Print(k.Ctx, "Current Selection will cost you") _, err = metaClient.PriceCalculator( controllerMeta.PriceCalculatorInput{ Currency: cp.Price.Currency, @@ -160,6 +172,53 @@ func (k *KsctlCommand) metadataForSelfManagedCluster( os.Exit(1) } + func() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + select { + case o := <-isOptimizeInstanceRegionReady: + k.PrintRecommendationSelfManagedCost( + o, + meta.NoCP, + meta.NoWP, + meta.NoDS, + cp.Sku, + wp.Sku, + etcd.Sku, + lb.Sku, + ) + pos := slices.IndexFunc(o, func(i RecommendationSelfManagedCost) bool { + return i.region == meta.Region + }) + o = append(o[:pos], o[pos+1:]...) + + availRegions := []string{"No don't change"} + for _, _o := range o[:5] { + availRegions = append(availRegions, _o.region) + } + + v, err := k.menuDriven.DropDownList(fmt.Sprintf("Region Switch. Currently set (%s)", meta.Region), availRegions, + cli.WithDefaultValue("Don't change"), + ) + if err != nil { + k.l.Error("Skipping it becuase failed to get the region switch", "Reason", err) + return + } + if v == "Don't change" { + return + } + + k.l.Print(k.Ctx, "changed the region", "from", color.HiRedString(meta.Region), "to", color.HiGreenString(v)) + meta.Region = v + + return + case <-ticker.C: + k.l.Print(k.Ctx, "Still optimizing instance types...") + } + } + }() + managedCNI, defaultCNI, ksctlCNI, defaultKsctl, err := metaClient.ListBootstrapCNIs() if err != nil { k.l.Error("Failed to get the list of self managed CNIs", "Reason", err) @@ -236,8 +295,10 @@ func (k *KsctlCommand) metadataForManagedCluster( meta.NoMP = v } + isOptimizeInstanceRegionReady := make(chan []RecommendationManagedCost) + if meta.Provider != consts.CloudLocal { - k.handleRegionSelection(metaClient, meta) + allAvailRegions := k.handleRegionSelection(metaClient, meta) category := provider.Unknown if meta.Provider != consts.CloudLocal { @@ -266,6 +327,12 @@ func (k *KsctlCommand) metadataForManagedCluster( offeringSelected = v } + go func() { + isOptimizeInstanceRegionReady <- k.OptimizeManagedOfferingsAcrossRegions(meta, allAvailRegions, listOfOfferings[offeringSelected], vm) + }() + + k.l.Print(k.Ctx, "Current Selection will cost you") + _, err = metaClient.PriceCalculator( controllerMeta.PriceCalculatorInput{ ManagedControlPlaneMachine: listOfOfferings[offeringSelected], @@ -276,6 +343,50 @@ func (k *KsctlCommand) metadataForManagedCluster( k.l.Error("Failed to calculate the price", "Reason", err) os.Exit(1) } + + func() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + select { + case o := <-isOptimizeInstanceRegionReady: + k.PrintRecommendationManagedCost( + o, + meta.NoMP, + listOfOfferings[offeringSelected].Sku, + vm.Sku, + ) + + pos := slices.IndexFunc(o, func(i RecommendationManagedCost) bool { + return i.region == meta.Region + }) + o = append(o[:pos], o[pos+1:]...) + + availRegions := []string{"No don't change"} + for _, _o := range o[:5] { + availRegions = append(availRegions, _o.region) + } + + v, err := k.menuDriven.DropDownList(fmt.Sprintf("Region Switch. Currently set (%s)", meta.Region), availRegions, + cli.WithDefaultValue("Don't change"), + ) + if err != nil { + k.l.Error("Skipping it becuase failed to get the region switch", "Reason", err) + return + } + if v == "Don't change" { + return + } + + k.l.Print(k.Ctx, "changed the region", "from", color.HiRedString(meta.Region), "to", color.HiGreenString(v)) + meta.Region = v + + return + case <-ticker.C: + k.l.Print(k.Ctx, "Still optimizing instance types...") + } + } + }() } managedCNI, defaultCNI, ksctlCNI, defaultKsctl, err := metaClient.ListManagedCNIs() diff --git a/cmd/handle_meta.go b/cmd/handle_meta.go index e35bd9e..65c52f1 100644 --- a/cmd/handle_meta.go +++ b/cmd/handle_meta.go @@ -63,7 +63,7 @@ func (k *KsctlCommand) baseMetadataFields(m *controller.Metadata) { } } -func (k *KsctlCommand) handleRegionSelection(meta *controllerMeta.Controller, m *controller.Metadata) { +func (k *KsctlCommand) handleRegionSelection(meta *controllerMeta.Controller, m *controller.Metadata) []provider.RegionOutput { ss := k.menuDriven.GetProgressAnimation() ss.Start("Fetching the region list") @@ -80,6 +80,8 @@ func (k *KsctlCommand) handleRegionSelection(meta *controllerMeta.Controller, m } else { m.Region = v } + + return listOfRegions } func (k *KsctlCommand) handleInstanceCategorySelection() provider.MachineCategory { diff --git a/cmd/optimize.go b/cmd/optimize.go new file mode 100644 index 0000000..57be3fc --- /dev/null +++ b/cmd/optimize.go @@ -0,0 +1,367 @@ +// Copyright 2025 Ksctl Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "cmp" + "fmt" + "slices" + + "github.com/fatih/color" + "github.com/ksctl/ksctl/v2/pkg/handler/cluster/controller" + controllerMeta "github.com/ksctl/ksctl/v2/pkg/handler/cluster/metadata" + "github.com/ksctl/ksctl/v2/pkg/provider" +) + +func (k *KsctlCommand) findManagedOfferingCostAcrossRegions( + meta controller.Metadata, + availRegions []provider.RegionOutput, + managedOfferingSku string, +) (map[string]float64, error) { + metaClient, err := controllerMeta.NewController( + k.Ctx, + k.l, + &controller.Client{ + Metadata: meta, + }, + ) + if err != nil { + return nil, err + } + resultChan := make(chan struct { + region string + price float64 + err error + }, len(availRegions)) + + for _, region := range availRegions { + regSku := region.Sku + go func(sku string) { + _price, err := metaClient.ListAllManagedClusterManagementOfferings(regSku, nil) + if err == nil { + v, ok := _price[managedOfferingSku] + if ok { + resultChan <- struct { + region string + price float64 + err error + }{sku, v.GetCost(), nil} + } else { + resultChan <- struct { + region string + price float64 + err error + }{sku, 0.0, fmt.Errorf("managed offering not found")} + } + + } else { + resultChan <- struct { + region string + price float64 + err error + }{sku, 0.0, err} + } + }(regSku) + } + + cost := make(map[string]float64, len(availRegions)) + for i := 0; i < len(availRegions); i++ { + result := <-resultChan + if result.err == nil { + cost[result.region] = result.price + } + } + + return cost, nil +} + +// findInstanceCostAcrossRegions it returns a map of K[V] where K is the region and V is the cost of the instance +func (k *KsctlCommand) findInstanceCostAcrossRegions( + meta controller.Metadata, + availRegions []provider.RegionOutput, + instanceSku string, +) (map[string]float64, error) { + metaClient, err := controllerMeta.NewController( + k.Ctx, + k.l, + &controller.Client{ + Metadata: meta, + }, + ) + if err != nil { + return nil, err + } + + resultChan := make(chan struct { + region string + price float64 + err error + }, len(availRegions)) + + for _, region := range availRegions { + regSku := region.Sku + go func(sku string) { + price, err := metaClient.GetPriceForInstance(sku, instanceSku) + resultChan <- struct { + region string + price float64 + err error + }{sku, price, err} + }(regSku) + } + + cost := make(map[string]float64, len(availRegions)) + for i := 0; i < len(availRegions); i++ { + result := <-resultChan + if result.err == nil { + cost[result.region] = result.price + } + } + + return cost, nil +} + +type RecommendationManagedCost struct { + region string + totalCost float64 + + cpCost float64 + wpCost float64 +} + +func (k *KsctlCommand) getBestRegionsWithTotalCostManaged( + allAvailRegions []provider.RegionOutput, + costForCP map[string]float64, + costForWP map[string]float64, +) []RecommendationManagedCost { + + checkRegion := func(region string, m map[string]float64) bool { + _, ok := m[region] + return ok + } + + var costForCluster []RecommendationManagedCost + + for _, region := range allAvailRegions { + if !checkRegion(region.Sku, costForCP) || + !checkRegion(region.Sku, costForWP) { + continue + } + + totalCost := costForCP[region.Sku] + costForWP[region.Sku] + + costForCluster = append(costForCluster, RecommendationManagedCost{ + region: region.Sku, + cpCost: costForCP[region.Sku], + wpCost: costForWP[region.Sku], + totalCost: totalCost, + }) + } + + slices.SortFunc(costForCluster, func(a, b RecommendationManagedCost) int { + return cmp.Compare(a.totalCost, b.totalCost) + }) + + return costForCluster +} + +type RecommendationSelfManagedCost struct { + region string + totalCost float64 + + cpCost float64 + wpCost float64 + etcdCost float64 + lbCost float64 +} + +func (k *KsctlCommand) getBestRegionsWithTotalCostSelfManaged( + allAvailRegions []provider.RegionOutput, + costForCP map[string]float64, + costForWP map[string]float64, + costForDS map[string]float64, + costForLB map[string]float64, +) []RecommendationSelfManagedCost { + + checkRegion := func(region string, m map[string]float64) bool { + _, ok := m[region] + return ok + } + + var costForCluster []RecommendationSelfManagedCost + + for _, region := range allAvailRegions { + if !checkRegion(region.Sku, costForCP) || + !checkRegion(region.Sku, costForWP) || + !checkRegion(region.Sku, costForDS) || + !checkRegion(region.Sku, costForLB) { + continue + } + + totalCost := costForCP[region.Sku] + costForWP[region.Sku] + costForDS[region.Sku] + costForLB[region.Sku] + + costForCluster = append(costForCluster, RecommendationSelfManagedCost{ + region: region.Sku, + cpCost: costForCP[region.Sku], + wpCost: costForWP[region.Sku], + etcdCost: costForDS[region.Sku], + lbCost: costForLB[region.Sku], + totalCost: totalCost, + }) + } + + slices.SortFunc(costForCluster, func(a, b RecommendationSelfManagedCost) int { + return cmp.Compare(a.totalCost, b.totalCost) + }) + + return costForCluster +} + +// OptimizeSelfManagedInstanceTypesAcrossRegions it returns a sorted list of regions based on the total cost in ascending order across all the regions +// +// It is a core function that is used to optimize the cost of the self-managed cluster instanceType across all the regions (Cost Optimization) +func (k *KsctlCommand) OptimizeSelfManagedInstanceTypesAcrossRegions( + meta *controller.Metadata, + allAvailRegions []provider.RegionOutput, + cp provider.InstanceRegionOutput, + wp provider.InstanceRegionOutput, + etcd provider.InstanceRegionOutput, + lb provider.InstanceRegionOutput, +) []RecommendationSelfManagedCost { + cpInstanceCosts, err := k.findInstanceCostAcrossRegions(*meta, allAvailRegions, cp.Sku) + if err != nil { + k.l.Error("Failed to get the cost of control plane instances", "Reason", err) + } + + wpInstanceCosts, err := k.findInstanceCostAcrossRegions(*meta, allAvailRegions, wp.Sku) + if err != nil { + k.l.Error("Failed to get the cost of worker plane instances", "Reason", err) + } + + etcdInstanceCosts, err := k.findInstanceCostAcrossRegions(*meta, allAvailRegions, etcd.Sku) + if err != nil { + k.l.Error("Failed to get the cost of etcd instances", "Reason", err) + } + + lbInstanceCosts, err := k.findInstanceCostAcrossRegions(*meta, allAvailRegions, lb.Sku) + if err != nil { + k.l.Error("Failed to get the cost of load balancer instances", "Reason", err) + } + + return k.getBestRegionsWithTotalCostSelfManaged( + allAvailRegions, + cpInstanceCosts, + wpInstanceCosts, + etcdInstanceCosts, + lbInstanceCosts, + ) +} + +func (k *KsctlCommand) OptimizeManagedOfferingsAcrossRegions( + meta *controller.Metadata, + allAvailRegions []provider.RegionOutput, + cp provider.ManagedClusterOutput, + wp provider.InstanceRegionOutput, +) []RecommendationManagedCost { + wpInstanceCosts, err := k.findInstanceCostAcrossRegions(*meta, allAvailRegions, wp.Sku) + if err != nil { + k.l.Error("Failed to get the cost of worker plane instances", "Reason", err) + } + + cpInstanceCosts, err := k.findManagedOfferingCostAcrossRegions(*meta, allAvailRegions, cp.Sku) + if err != nil { + k.l.Error("Failed to get the cost of control plane managed offerings", "Reason", err) + } + + return k.getBestRegionsWithTotalCostManaged( + allAvailRegions, + cpInstanceCosts, + wpInstanceCosts, + ) +} + +func (k *KsctlCommand) PrintRecommendationSelfManagedCost( + costs []RecommendationSelfManagedCost, + noOfCP int, + noOfWP int, + noOfDS int, + instanceTypeCP string, + instanceTypeWP string, + instanceTypeDS string, + instanceTypeLB string, +) { + k.l.Print(k.Ctx, + "Here is your recommendation", + "Parameter", "Region wise cost", + "OptimizedRegion", color.HiCyanString(costs[0].region), + ) + + headers := []string{ + "Region", + fmt.Sprintf("Control Plane (%s)", instanceTypeCP), + fmt.Sprintf("Worker Plane (%s)", instanceTypeWP), + fmt.Sprintf("Etcd Nodes (%s)", instanceTypeDS), + fmt.Sprintf("Load Balancer (%s)", instanceTypeLB), + "Total Monthly Cost", + } + + var data [][]string + for _, cost := range costs { + total := cost.cpCost*float64(noOfCP) + cost.wpCost*float64(noOfWP) + cost.etcdCost*float64(noOfDS) + cost.lbCost + data = append(data, []string{ + cost.region, + fmt.Sprintf("$%.2f X %d", cost.cpCost, noOfCP), + fmt.Sprintf("$%.2f X %d", cost.wpCost, noOfWP), + fmt.Sprintf("$%.2f X %d", cost.etcdCost, noOfDS), + fmt.Sprintf("$%.2f X 1", cost.lbCost), + fmt.Sprintf("$%.2f", total), + }) + } + + k.l.Table(k.Ctx, headers, data) +} + +func (k *KsctlCommand) PrintRecommendationManagedCost( + costs []RecommendationManagedCost, + noOfWP int, + managedOfferingCP string, + instanceTypeWP string, +) { + k.l.Print(k.Ctx, + "Here is your recommendation", + "Parameter", "Region wise cost", + "OptimizedRegion", color.HiCyanString(costs[0].region), + ) + + headers := []string{ + "Region", + fmt.Sprintf("Control Plane (%s)", managedOfferingCP), + fmt.Sprintf("Worker Plane (%s)", instanceTypeWP), + "Total Monthly Cost", + } + + var data [][]string + for _, cost := range costs { + total := cost.cpCost + cost.wpCost*float64(noOfWP) + data = append(data, []string{ + cost.region, + fmt.Sprintf("$%.2f X 1", cost.cpCost), + fmt.Sprintf("$%.2f X %d", cost.wpCost, noOfWP), + fmt.Sprintf("$%.2f", total), + }) + } + + k.l.Table(k.Ctx, headers, data) +} diff --git a/cmd/userinput.go b/cmd/userinput.go index 3fc1013..b6a88ef 100644 --- a/cmd/userinput.go +++ b/cmd/userinput.go @@ -105,7 +105,7 @@ func (k *KsctlCommand) getSelectedInstanceCategory(categories map[string]provide for k, _v := range categories { useCases := strings.Join(_v.UseCases(), ", ") - key := fmt.Sprintf(">>> %s <<<\n Used for: %s\n", k, useCases) + key := fmt.Sprintf("%s\n Used for: %s\n", k, useCases) vr[key] = string(_v) } diff --git a/go.mod b/go.mod index 4239bce..dec43c1 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/Delta456/box-cli-maker/v2 v2.3.0 github.com/creack/pty v1.1.24 github.com/fatih/color v1.18.0 - github.com/ksctl/ksctl/v2 v2.1.0 + github.com/ksctl/ksctl/v2 v2.2.0 github.com/pterm/pterm v0.12.80 github.com/rodaine/table v1.3.0 github.com/spf13/cobra v1.9.1 diff --git a/go.sum b/go.sum index 7e30648..2b8bbbe 100644 --- a/go.sum +++ b/go.sum @@ -351,8 +351,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/ksctl/ksctl/v2 v2.1.0 h1:RuS+KA5Ab0/WdKIj2d+l5GnafGoIuCPZnVd/Nz3LrNY= -github.com/ksctl/ksctl/v2 v2.1.0/go.mod h1:w9I+7Jm8umrF6JbeiShOYnj4PNRE+534Ji6QAifAr4s= +github.com/ksctl/ksctl/v2 v2.2.0 h1:yi732F7viIGxJimtq8C58NYxVW3/1dXMe5N6XYt7nY4= +github.com/ksctl/ksctl/v2 v2.2.0/go.mod h1:w9I+7Jm8umrF6JbeiShOYnj4PNRE+534Ji6QAifAr4s= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=