diff --git a/cli/cmd/cluster_prepare.go b/cli/cmd/cluster_prepare.go index f6990789..b7c89a94 100644 --- a/cli/cmd/cluster_prepare.go +++ b/cli/cmd/cluster_prepare.go @@ -45,13 +45,13 @@ func (r *runners) InitClusterPrepare(parent *cobra.Command) *cobra.Command { cmd := &cobra.Command{ Use: "prepare", Short: "prepare cluster for testing", - Long: `The cluster prepare command will provision a cluster and install + Long: `The cluster prepare command will provision a cluster and install a local helm chart with a custom values.yaml and custom replicated sdk entitlements. This is a higher level CLI command that is useful in CI when you have a Helm chart and want it running in a variety of clusters. -For more control over the workflow, consider using the cluster create command and +For more control over the workflow, consider using the cluster create command and using kubectl and helm CLI tools to install your application. Example: @@ -235,7 +235,7 @@ func (r *runners) prepareCluster(_ *cobra.Command, args []string) error { email := fmt.Sprintf("%s@replicated.com", clusterName) customerOpts := kotsclient.CreateCustomerOpts{ Name: clusterName, - ChannelID: "", + Channels: []kotsclient.CustomerChannel{}, AppID: r.appID, LicenseType: "test", Email: email, diff --git a/cli/cmd/customer_create.go b/cli/cmd/customer_create.go index e7f89fb1..4fb1742b 100644 --- a/cli/cmd/customer_create.go +++ b/cli/cmd/customer_create.go @@ -1,12 +1,14 @@ package cmd import ( + "os" "time" "github.com/pkg/errors" "github.com/replicatedhq/replicated/cli/print" "github.com/replicatedhq/replicated/client" "github.com/replicatedhq/replicated/pkg/kotsclient" + "github.com/replicatedhq/replicated/pkg/logger" "github.com/spf13/cobra" ) @@ -22,7 +24,8 @@ func (r *runners) InitCustomersCreateCommand(parent *cobra.Command) *cobra.Comma parent.AddCommand(cmd) cmd.Flags().StringVar(&r.args.customerCreateName, "name", "", "Name of the customer") cmd.Flags().StringVar(&r.args.customerCreateCustomID, "custom-id", "", "Set a custom customer ID to more easily tie this customer record to your external data systems") - cmd.Flags().StringVar(&r.args.customerCreateChannel, "channel", "", "Release channel to which the customer should be assigned") + cmd.Flags().StringArrayVar(&r.args.customerCreateChannel, "channel", []string{}, "Release channel to which the customer should be assigned (can be specified multiple times)") + cmd.Flags().StringVar(&r.args.customerCreateDefaultChannel, "default-channel", "", "Which of the specified channels should be the default channel. if not set, the first channel specified will be the default channel.") cmd.Flags().DurationVar(&r.args.customerCreateExpiryDuration, "expires-in", 0, "If set, an expiration date will be set on the license. Supports Go durations like '72h' or '3600m'") cmd.Flags().BoolVar(&r.args.customerCreateEnsureChannel, "ensure-channel", false, "If set, channel will be created if it does not exist.") cmd.Flags().BoolVar(&r.args.customerCreateIsAirgapEnabled, "airgap", false, "If set, the license will allow airgap installs.") @@ -38,6 +41,9 @@ func (r *runners) InitCustomersCreateCommand(parent *cobra.Command) *cobra.Comma cmd.Flags().StringVar(&r.args.customerCreateEmail, "email", "", "Email address of the customer that is to be created.") cmd.Flags().StringVar(&r.args.customerCreateType, "type", "dev", "The license type to create. One of: dev|trial|paid|community|test (default: dev)") cmd.Flags().StringVar(&r.outputFormat, "output", "table", "The output format to use. One of: json|table (default: table)") + + cmd.MarkFlagRequired("channel") + return cmd } @@ -58,12 +64,14 @@ func (r *runners) createCustomer(cmd *cobra.Command, _ []string) (err error) { r.args.customerCreateType = "prod" } - channelID := "" - if r.args.customerCreateChannel != "" { + channels := []kotsclient.CustomerChannel{} + + foundDefaultChannel := false + for _, requestedChannel := range r.args.customerCreateChannel { getOrCreateChannelOptions := client.GetOrCreateChannelOptions{ AppID: r.appID, AppType: r.appType, - NameOrID: r.args.customerCreateChannel, + NameOrID: requestedChannel, Description: "", CreateIfAbsent: r.args.customerCreateEnsureChannel, } @@ -73,13 +81,38 @@ func (r *runners) createCustomer(cmd *cobra.Command, _ []string) (err error) { return errors.Wrap(err, "get channel") } - channelID = channel.ID + customerChannel := kotsclient.CustomerChannel{ + ID: channel.ID, + } + + if r.args.customerCreateDefaultChannel == requestedChannel { + customerChannel.IsDefault = true + foundDefaultChannel = true + } + + channels = append(channels, customerChannel) + } + + if len(channels) == 0 { + return errors.New("no channels found") + } + + if r.args.customerUpdateDefaultChannel != "" && !foundDefaultChannel { + return errors.New("default channel not found in specified channels") + } + + if !foundDefaultChannel { + log := logger.NewLogger(os.Stdout) + log.Info("No default channel specified, defaulting to the first channel specified.") + firstChannel := channels[0] + firstChannel.IsDefault = true + channels[0] = firstChannel } opts := kotsclient.CreateCustomerOpts{ Name: r.args.customerCreateName, CustomID: r.args.customerCreateCustomID, - ChannelID: channelID, + Channels: channels, AppID: r.appID, ExpiresAtDuration: r.args.customerCreateExpiryDuration, IsAirgapEnabled: r.args.customerCreateIsAirgapEnabled, diff --git a/cli/cmd/customer_update.go b/cli/cmd/customer_update.go index 6393e011..c1a225b8 100644 --- a/cli/cmd/customer_update.go +++ b/cli/cmd/customer_update.go @@ -1,18 +1,20 @@ package cmd import ( + "os" "time" "github.com/pkg/errors" "github.com/replicatedhq/replicated/cli/print" "github.com/replicatedhq/replicated/client" "github.com/replicatedhq/replicated/pkg/kotsclient" + "github.com/replicatedhq/replicated/pkg/logger" "github.com/spf13/cobra" ) func (r *runners) InitCustomerUpdateCommand(parent *cobra.Command) *cobra.Command { cmd := &cobra.Command{ - Use: "update", + Use: "update --customer --name [options]", Short: "update a customer", Long: `update a customer`, RunE: r.updateCustomer, @@ -21,10 +23,10 @@ func (r *runners) InitCustomerUpdateCommand(parent *cobra.Command) *cobra.Comman } parent.AddCommand(cmd) cmd.Flags().StringVar(&r.args.customerUpdateID, "customer", "", "The ID of the customer to update") - _ = cmd.MarkFlagRequired("customer") cmd.Flags().StringVar(&r.args.customerUpdateName, "name", "", "Name of the customer") cmd.Flags().StringVar(&r.args.customerUpdateCustomID, "custom-id", "", "Set a custom customer ID to more easily tie this customer record to your external data systems") - cmd.Flags().StringVar(&r.args.customerUpdateChannel, "channel", "", "Release channel to which the customer should be assigned") + cmd.Flags().StringArrayVar(&r.args.customerUpdateChannel, "channel", []string{}, "Release channel to which the customer should be assigned (can be specified multiple times)") + cmd.Flags().StringVar(&r.args.customerUpdateDefaultChannel, "default-channel", "", "Which of the specified channels should be the default channel. if not set, the first channel specified will be the default channel.") cmd.Flags().DurationVar(&r.args.customerUpdateExpiryDuration, "expires-in", 0, "If set, an expiration date will be set on the license. Supports Go durations like '72h' or '3600m'") cmd.Flags().BoolVar(&r.args.customerUpdateEnsureChannel, "ensure-channel", false, "If set, channel will be created if it does not exist.") cmd.Flags().BoolVar(&r.args.customerUpdateIsAirgapEnabled, "airgap", false, "If set, the license will allow airgap installs.") @@ -39,6 +41,11 @@ func (r *runners) InitCustomerUpdateCommand(parent *cobra.Command) *cobra.Comman cmd.Flags().StringVar(&r.args.customerUpdateEmail, "email", "", "Email address of the customer that is to be updated.") cmd.Flags().StringVar(&r.args.customerUpdateType, "type", "dev", "The license type to update. One of: dev|trial|paid|community|test (default: dev)") cmd.Flags().StringVar(&r.outputFormat, "output", "table", "The output format to use. One of: json|table (default: table)") + + cmd.MarkFlagRequired("customer") + cmd.MarkFlagRequired("channel") + cmd.MarkFlagRequired("name") // until the API supports better patching, this is actually a required field + return cmd } @@ -62,14 +69,16 @@ func (r *runners) updateCustomer(cmd *cobra.Command, _ []string) (err error) { r.args.customerUpdateType = "prod" } - channelID := "" - if r.args.customerUpdateChannel != "" { + channels := []kotsclient.CustomerChannel{} + + foundDefaultChannel := false + for _, requestedChannel := range r.args.customerUpdateChannel { getOrCreateChannelOptions := client.GetOrCreateChannelOptions{ AppID: r.appID, AppType: r.appType, - NameOrID: r.args.customerUpdateChannel, + NameOrID: requestedChannel, Description: "", - CreateIfAbsent: r.args.customerUpdateEnsureChannel, + CreateIfAbsent: r.args.customerCreateEnsureChannel, } channel, err := r.api.GetOrCreateChannelByName(getOrCreateChannelOptions) @@ -77,13 +86,38 @@ func (r *runners) updateCustomer(cmd *cobra.Command, _ []string) (err error) { return errors.Wrap(err, "get channel") } - channelID = channel.ID + customerChannel := kotsclient.CustomerChannel{ + ID: channel.ID, + } + + if r.args.customerUpdateDefaultChannel == requestedChannel { + customerChannel.IsDefault = true + foundDefaultChannel = true + } + + channels = append(channels, customerChannel) + } + + if len(channels) == 0 { + return errors.New("no channels found") + } + + if r.args.customerUpdateDefaultChannel != "" && !foundDefaultChannel { + return errors.New("default channel not found in specified channels") + } + + if !foundDefaultChannel { + log := logger.NewLogger(os.Stdout) + log.Info("No default channel specified, defaulting to the first channel specified.") + firstChannel := channels[0] + firstChannel.IsDefault = true + channels[0] = firstChannel } opts := kotsclient.UpdateCustomerOpts{ Name: r.args.customerUpdateName, CustomID: r.args.customerUpdateCustomID, - ChannelID: channelID, + Channels: channels, AppID: r.appID, ExpiresAtDuration: r.args.customerUpdateExpiryDuration, IsAirgapEnabled: r.args.customerUpdateIsAirgapEnabled, diff --git a/cli/cmd/runner.go b/cli/cmd/runner.go index f4d1ff82..1170a69c 100644 --- a/cli/cmd/runner.go +++ b/cli/cmd/runner.go @@ -78,7 +78,8 @@ type runnerArgs struct { customerArchiveNameOrId string customerCreateName string customerCreateCustomID string - customerCreateChannel string + customerCreateChannel []string + customerCreateDefaultChannel string customerCreateEnsureChannel bool customerCreateExpiryDuration time.Duration customerCreateIsAirgapEnabled bool @@ -97,7 +98,8 @@ type runnerArgs struct { customerUpdateID string customerUpdateName string customerUpdateCustomID string - customerUpdateChannel string + customerUpdateChannel []string + customerUpdateDefaultChannel string customerUpdateEnsureChannel bool customerUpdateExpiryDuration time.Duration customerUpdateIsAirgapEnabled bool diff --git a/pkg/kotsclient/customer_create.go b/pkg/kotsclient/customer_create.go index f32f355e..83d4d60d 100644 --- a/pkg/kotsclient/customer_create.go +++ b/pkg/kotsclient/customer_create.go @@ -13,9 +13,15 @@ type EntitlementValue struct { Value string `json:"value"` } +type CustomerChannel struct { + ID string `json:"channel_id"` + PinnedChannelSequence *int64 `json:"pinned_channel_sequence"` + IsDefault bool `json:"is_default_for_customer"` +} + type CreateCustomerRequest struct { Name string `json:"name"` - ChannelID string `json:"channel_id"` + Channels []CustomerChannel `json:"channels"` CustomID string `json:"custom_id"` AppID string `json:"app_id"` Type string `json:"type"` @@ -41,7 +47,7 @@ type CreateCustomerResponse struct { type CreateCustomerOpts struct { Name string CustomID string - ChannelID string + Channels []CustomerChannel AppID string ExpiresAt string ExpiresAtDuration time.Duration @@ -64,7 +70,7 @@ func (c *VendorV3Client) CreateCustomer(opts CreateCustomerOpts) (*types.Custome request := &CreateCustomerRequest{ Name: opts.Name, CustomID: opts.CustomID, - ChannelID: opts.ChannelID, + Channels: opts.Channels, AppID: opts.AppID, Type: opts.LicenseType, IsAirgapEnabled: opts.IsAirgapEnabled, diff --git a/pkg/kotsclient/customer_update.go b/pkg/kotsclient/customer_update.go index 52404ed1..04f45a1e 100644 --- a/pkg/kotsclient/customer_update.go +++ b/pkg/kotsclient/customer_update.go @@ -11,7 +11,7 @@ import ( type UpdateCustomerRequest struct { Name string `json:"name"` - ChannelID string `json:"channel_id"` + Channels []CustomerChannel `json:"channels"` CustomID string `json:"custom_id"` AppID string `json:"app_id"` Type string `json:"type"` @@ -36,7 +36,7 @@ type UpdateCustomerResponse struct { type UpdateCustomerOpts struct { Name string CustomID string - ChannelID string + Channels []CustomerChannel AppID string ExpiresAt string ExpiresAtDuration time.Duration @@ -58,7 +58,7 @@ func (c *VendorV3Client) UpdateCustomer(customerID string, opts UpdateCustomerOp request := &UpdateCustomerRequest{ Name: opts.Name, CustomID: opts.CustomID, - ChannelID: opts.ChannelID, + Channels: opts.Channels, AppID: opts.AppID, Type: opts.LicenseType, IsAirgapEnabled: opts.IsAirgapEnabled, diff --git a/pkg/platformclient/client.go b/pkg/platformclient/client.go index 76902cc6..6542c7e0 100644 --- a/pkg/platformclient/client.go +++ b/pkg/platformclient/client.go @@ -137,6 +137,24 @@ func (c *HTTPClient) DoJSON(method string, path string, successStatus int, reqBo } if resp.StatusCode != successStatus { if resp.StatusCode == http.StatusForbidden { + // look for a response message in the body + body, err := io.ReadAll(resp.Body) + if err != nil { + return ErrForbidden + } + + // some of the methods in the api have a standardized response for 403 + type forbiddenResponse struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + var fr forbiddenResponse + if err := json.Unmarshal(body, &fr); err == nil { + return errors.New(fr.Error.Message) + } + return ErrForbidden } body, _ := io.ReadAll(resp.Body)