Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Try many options and datacenters when placing an order #23

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
2 changes: 1 addition & 1 deletion cmd/check/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func runner(cmd *cobra.Command, args []string) error {
// Check availability
availabilities, err := k.GetAvailabilities(datacenters, planCode, options)
if err != nil {
if kimsufi.IsNotAvailableError(err) {
if kimsufi.IsAvailabilityNotFoundError(err) {
message := datacenterAvailableMessageFormatter(datacenters)
log.Printf("%s is not available in %s\n", planCode, message)
return nil
Expand Down
2 changes: 1 addition & 1 deletion cmd/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func runner(cmd *cobra.Command, args []string) error {
}

// Format availability status
datacenters := availabilities.GetPlanCodeAvailableDatacenters(plan.PlanCode)
datacenters := availabilities.GetByPlanCode(plan.PlanCode).GetAvailableDatacenters()

var datacenterNames []string
if humanLevel > 0 {
Expand Down
159 changes: 130 additions & 29 deletions cmd/order/order.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import (
"github.com/spf13/cobra"
)

const (
anyOption = "any"
)

var (
Cmd = &cobra.Command{
Use: "order",
Expand All @@ -29,13 +33,13 @@ var (
}

// Flags variables
autoPay bool
datacenter string
planCode string
quantity int
autoPay bool
datacenters []string
planCode string
quantity int

itemUserConfigurations map[string]string
itemUserOptions map[string]string
itemUserOptions []string

listConfigurations bool
listOptions bool
Expand All @@ -55,11 +59,11 @@ func init() {
flag.BindPlanCodeFlag(Cmd, &planCode)

Cmd.PersistentFlags().BoolVar(&autoPay, "auto-pay", false, "automatically pay the order")
Cmd.PersistentFlags().StringVarP(&datacenter, "datacenter", "d", "", fmt.Sprintf("datacenter (known values: %s)", strings.Join(kimsufiavailability.GetDatacentersKnownCodes(), ", ")))
Cmd.PersistentFlags().StringSliceVarP(&datacenters, "datacenters", "d", nil, fmt.Sprintf(`datacenters, comma separated list, %q to try all datacenters (known values: %s)`, anyOption, strings.Join(kimsufiavailability.GetDatacentersKnownCodes(), ", ")))
Cmd.PersistentFlags().IntVarP(&quantity, "quantity", "q", kimsufiorder.QuantityDefault, "item quantity")

Cmd.PersistentFlags().StringToStringVarP(&itemUserConfigurations, "item-configuration", "i", nil, "item configuration, see --list-configurations for available values (e.g. region=europe)")
Cmd.PersistentFlags().StringToStringVarP(&itemUserOptions, "item-option", "o", nil, "item option, see --list-options for available values (e.g. memory=ram-64g-noecc-2133-24ska01)")
Cmd.PersistentFlags().StringToStringVarP(&itemUserConfigurations, "item-configuration", "i", nil, "item configuration, comma separated list, see --list-configurations for available values (e.g. region=europe)")
Cmd.PersistentFlags().StringSliceVarP(&itemUserOptions, "item-option", "o", nil, fmt.Sprintf("item option, comma separated list, use any to include all options, see --list-options for available values (e.g. memory=ram-64g-noecc-2133-24ska01, memory=%[1]s, %[1]s)", anyOption))

Cmd.PersistentFlags().BoolVar(&listConfigurations, "list-configurations", false, "list available item configurations")
Cmd.PersistentFlags().BoolVar(&listOptions, "list-options", false, "list available item options")
Expand Down Expand Up @@ -147,13 +151,29 @@ func runner(cmd *cobra.Command, args []string) error {
return nil
}

if datacenter == "" {
if len(datacenters) == 0 {
return fmt.Errorf("--datacenter is required")
} else if slices.Contains(datacenters, anyOption) {
catalog, err := k.ListServers(cmd.Flag(flag.CountryFlagName).Value.String())
if err != nil {
return fmt.Errorf("failed to list servers: %w", err)
}

plan := catalog.GetPlan(planCode)
if plan == nil {
return fmt.Errorf("plan %s not found", planCode)
}

datacenterConfiguration := plan.GetConfiguration(kimsufiorder.ConfigurationLabelDatacenter)
if datacenterConfiguration == nil {
return fmt.Errorf("datacenter configuration not found")
}

datacenters = datacenterConfiguration.Values
}

// Prepare item configurations
itemConfigurations := kimsufiorder.NewItemConfigurationsFromMap(itemUserConfigurations)
itemConfigurations.Add(kimsufiorder.ConfigurationLabelDatacenter, datacenter)
r := kimsufiregion.GetRegionFromEndpoint(endpoint)
if r != nil {
itemConfigurations.Add(kimsufiorder.ConfigurationLabelRegion, r.Region)
Expand All @@ -168,26 +188,70 @@ func runner(cmd *cobra.Command, args []string) error {
configurations := userConfigs.Merge(manualConfigs)

// Configure item
resp, err := k.ConfigureItem(cart.CartID, item.ItemID, configurations)
if err != nil {
return fmt.Errorf("error: %w", err)
}
for _, r := range resp {
fmt.Printf("> cart item configured: %s=%s\n", r.Label, r.Value)
for _, configuration := range configurations {
resp, err := k.AddItemConfiguration(cart.CartID, item.ItemID, configuration)
if err != nil {
return fmt.Errorf("error: %w", err)
}
fmt.Printf("> cart item configured: %s=%s\n", resp.Label, resp.Value)
}

// Prepare item options
userOptions := kimsufiorder.NewOptionsFromMap(itemUserOptions)
var optionsCombinations []kimsufiorder.Options
var mergedOptions kimsufiorder.Options
allOptions := slices.Contains(itemUserOptions, anyOption)
if allOptions {
// Get all mandatory options
mergedOptions = ecoOptions.GetMandatoryOptions(nil).ToOptions()
optionsCombinations = kimsufiorder.NewOptionsCombinationsFromSlice(mergedOptions)
} else {
userOptions, err := kimsufiorder.NewOptionsFromSlice(itemUserOptions)
if err != nil {
return fmt.Errorf("error: %w", err)
}
anyOptions, userOptions := userOptions.SplitByPlanCode(anyOption)
anyFamilies := anyOptions.Families()

optionFilter := func(opts kimsufiorder.EcoItemOptions, o kimsufiorder.EcoItemOption) bool {
defautPriceConfig := kimsufiorder.EcoItemPriceConfig{
Duration: kimsufiorder.PriceDuration,
PricingMode: kimsufiorder.PricingMode,
}

// Inclue option if it is marked as any
if slices.Contains(anyFamilies, o.Family) {
return true
}

// Include option if its family is not already included
current := opts.Get(o.Family)
if current == nil {
return true
}

newPrice := o.GetPriceByConfig(defautPriceConfig)
if newPrice == nil {
return false
}

currentPrice := current.GetPriceByConfig(defautPriceConfig)
if currentPrice == nil {
return false
}

// Include option if its price is lower than the current one
return newPrice.PriceInUcents < currentPrice.PriceInUcents
}

// Configure item options
options, err := k.ConfigureEcoItemOptions(cart.CartID, item.ItemID, ecoOptions, userOptions, priceConfig)
if err != nil {
return fmt.Errorf("error: %w", err)
}
for _, o := range options {
fmt.Printf("> cart option set: %s=%s\n", o.Family, o.PlanCode)
mandatoryOptions := ecoOptions.GetMandatoryOptions(optionFilter)
mergedOptions = userOptions.Merge(mandatoryOptions.ToOptions())
optionsCombinations = kimsufiorder.NewOptionsCombinationsFromSlice(mergedOptions)
}

fmt.Printf("> item options: %d %v\n", len(mergedOptions), mergedOptions.PlanCodes())
fmt.Printf("> datacenter(s): %d\n", len(datacenters))
fmt.Printf("> combinations: %d\n", len(optionsCombinations)*len(datacenters))

// Stop on dry-run
if dryRun {
fmt.Println("> dry-run enabled, skipping order submission")
Expand Down Expand Up @@ -221,12 +285,49 @@ func runner(cmd *cobra.Command, args []string) error {
}
fmt.Println("> cart assigned")

// Checkout and complete the order
checkoutResp, err := k.CheckoutCart(cart.CartID, autoPay)
if err != nil {
return fmt.Errorf("error: %w", err)
// Try all options combinations
for _, options := range optionsCombinations {
// Configure item options
for _, option := range options {
err = k.ConfigureEcoItemOption(cart.CartID, item.ItemID, option, priceConfig)
if err != nil {
return fmt.Errorf("error: %w", err)
}
fmt.Printf("> cart option set: %s=%s\n", option.Family, option.PlanCode)
}

// Try all datacenters
for _, datacenter := range datacenters {
datacenterConfiguration := kimsufiorder.ItemConfigurationRequest{
Label: kimsufiorder.ConfigurationLabelDatacenter,
Value: datacenter,
}

resp, err := k.AddItemConfiguration(cart.CartID, item.ItemID, datacenterConfiguration)
if err != nil {
return fmt.Errorf("error: %w", err)
}
fmt.Printf("> datacenter %s configured\n", resp.Value)

// Checkout and complete the order
checkoutResp, err := k.CheckoutCart(cart.CartID, autoPay)
if err == nil {
fmt.Printf("> order completed: %s\n", checkoutResp.URL)
return nil
}

if kimsufi.IsNotAvailableError(err) {
fmt.Printf("> datacenter %s not available\n", datacenter)
} else {
fmt.Printf("> error: %v\n", err)
}

err = k.RemoveItemConfiguration(cart.CartID, item.ItemID, resp.ID)
if err != nil {
return fmt.Errorf("error: %w", err)
}
}
}
fmt.Printf("> order completed: %s\n", checkoutResp.URL)

return nil
}
Expand Down
18 changes: 14 additions & 4 deletions pkg/kimsufi/availability/availability_methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ import (
"strings"
)

func (a Availabilities) GetByPlanCode(planCode string) Availabilities {
var availabilities Availabilities

for _, availability := range a {
if availability.PlanCode == planCode {
availabilities = append(availabilities, availability)
}
}

return availabilities
}

// GetAvailableDatacenters returns the list of available datacenters.
func (a Availability) GetAvailableDatacenters() Datacenters {
var datacenters []Datacenter
Expand All @@ -19,13 +31,11 @@ func (a Availability) GetAvailableDatacenters() Datacenters {
}

// GetPlanCodeAvailableDatacenters returns the list of available datacenters for a given plan code.
func (a Availabilities) GetPlanCodeAvailableDatacenters(planCode string) Datacenters {
func (a Availabilities) GetAvailableDatacenters() Datacenters {
var datacenters []Datacenter

for _, availability := range a {
if availability.PlanCode == planCode {
datacenters = append(datacenters, availability.GetAvailableDatacenters()...)
}
datacenters = append(datacenters, availability.GetAvailableDatacenters()...)
}

slices.SortFunc(datacenters, func(i, j Datacenter) int {
Expand Down
15 changes: 13 additions & 2 deletions pkg/kimsufi/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,26 @@ import (
"github.com/ovh/go-ovh/ovh"
)

// IsAvailabilityNotFoundError checks if the error is an ovh.APIError
// which contains an availability not found error message.
func IsAvailabilityNotFoundError(err error) bool {
var ovhAPIError *ovh.APIError
if !errors.As(err, &ovhAPIError) {
return false
}

return ovhAPIError.Code == http.StatusNotFound && strings.HasPrefix(ovhAPIError.Message, "No availabilities found")
}

// IsNotAvailableError checks if the error is an ovh.APIError
// which contains an availability error message.
// which contains an not available error message.
func IsNotAvailableError(err error) bool {
var ovhAPIError *ovh.APIError
if !errors.As(err, &ovhAPIError) {
return false
}

return ovhAPIError.Code == http.StatusNotFound && strings.HasPrefix(ovhAPIError.Message, "No availabilities found")
return ovhAPIError.Code == http.StatusBadRequest && strings.Contains(ovhAPIError.Message, "is not available in")
}

// IsForbiddenError checks if the error is an ovh.APIError
Expand Down
62 changes: 27 additions & 35 deletions pkg/kimsufi/order.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func (s *Service) GetEcoInfo(cartID, planCode string) (kimsufiorder.EcoItemInfos
}

// GetEcoOptions returns the options for an eco item in the cart.
func (s *Service) GetEcoOptions(cartID string, planCode string) ([]kimsufiorder.EcoItemOption, error) {
func (s *Service) GetEcoOptions(cartID string, planCode string) (kimsufiorder.EcoItemOptions, error) {
u, err := url.Parse(fmt.Sprintf("/order/cart/%s/eco/options", cartID))
if err != nil {
return nil, err
Expand All @@ -80,7 +80,7 @@ func (s *Service) GetEcoOptions(cartID string, planCode string) ([]kimsufiorder.
q.Set("planCode", planCode)
u.RawQuery = q.Encode()

var options []kimsufiorder.EcoItemOption
var options kimsufiorder.EcoItemOptions
err = s.client.GetUnAuth(u.String(), &options)
if err != nil {
return nil, err
Expand All @@ -89,33 +89,22 @@ func (s *Service) GetEcoOptions(cartID string, planCode string) ([]kimsufiorder.
return options, nil
}

// ConfigureEcoItemOptions configures the item options in the cart.
// ConfigureEcoItemOption configures the item options in the cart.
// It finds the cheapest mandatory options and merges them into the user options.
func (s *Service) ConfigureEcoItemOptions(cartID string, itemID int, options kimsufiorder.EcoItemOptions, userOptions kimsufiorder.Options, priceConfig kimsufiorder.EcoItemPriceConfig) (kimsufiorder.Options, error) {
func (s *Service) ConfigureEcoItemOption(cartID string, itemID int, option kimsufiorder.Option, priceConfig kimsufiorder.EcoItemPriceConfig) error {
u := fmt.Sprintf("/order/cart/%s/eco/options", cartID)

mandatoryOptions := options.GetCheapestMandatoryOptions()

mergedOptions := userOptions.Merge(mandatoryOptions.ToOptions())

for _, option := range mergedOptions {
req := kimsufiorder.EcoItemOptionRequest{
EcoItemRequest: kimsufiorder.EcoItemRequest{
EcoItemPriceConfig: priceConfig,
PlanCode: option.PlanCode,
Quantity: kimsufiorder.QuantityDefault,
},
ItemID: itemID,
}

s.logger.Debugf("ConfigureItemOptions request: %+#v", req)
err := s.client.PostUnAuth(u, req, nil)
if err != nil {
return nil, err
}
req := kimsufiorder.EcoItemOptionRequest{
EcoItemRequest: kimsufiorder.EcoItemRequest{
EcoItemPriceConfig: priceConfig,
PlanCode: option.PlanCode,
Quantity: kimsufiorder.QuantityDefault,
},
ItemID: itemID,
}

return mergedOptions, nil
s.logger.Debugf("ConfigureItemOptions request: %+#v", req)
return s.client.PostUnAuth(u, req, nil)
}

// GetItemRequiredConfiguration returns the required configuration options for an item in the cart.
Expand All @@ -132,21 +121,24 @@ func (s *Service) GetItemRequiredConfiguration(cartID string, itemID int) ([]kim
}

// ConfigureItem configures an item in the cart with the given configurations.
func (s *Service) ConfigureItem(cartID string, itemID int, configurations []kimsufiorder.ItemConfigurationRequest) ([]kimsufiorder.ItemConfigurationResponse, error) {
func (s *Service) AddItemConfiguration(cartID string, itemID int, configuration kimsufiorder.ItemConfigurationRequest) (*kimsufiorder.ItemConfigurationResponse, error) {
u := fmt.Sprintf("/order/cart/%s/item/%d/configuration", cartID, itemID)

var resps []kimsufiorder.ItemConfigurationResponse
for _, c := range configurations {
var resp kimsufiorder.ItemConfigurationResponse
s.logger.Debugf("ConfigureItem request: %+#v", c)
err := s.client.PostUnAuth(u, c, &resp)
if err != nil {
return nil, err
}
resps = append(resps, resp)
var resp kimsufiorder.ItemConfigurationResponse
s.logger.Debugf("ConfigureItem request: %+#v", configuration)
err := s.client.PostUnAuth(u, configuration, &resp)
if err != nil {
return nil, err
}

return resps, nil
return &resp, nil
}

// RemoveItemConfiguration removes a configuration from an item in the cart.
func (s *Service) RemoveItemConfiguration(cartID string, itemID, configurationID int) error {
u := fmt.Sprintf("/order/cart/%s/item/%d/configuration/%d", cartID, itemID, configurationID)

return s.client.DeleteUnAuth(u, nil)
}

// AssignCart assigns the cart to the user's account.
Expand Down
Loading
Loading