From 01f321ebba073b59d0e0e9484beff3e5a99eb8ab Mon Sep 17 00:00:00 2001 From: FingerLeader Date: Wed, 9 Apr 2025 00:05:45 +0800 Subject: [PATCH 1/9] add authorized network Signed-off-by: FingerLeader --- go.mod | 1 + .../authorizednetwork/authorized_network.go | 38 +++ .../serverless/authorizednetwork/create.go | 233 +++++++++++++++++ .../serverless/authorizednetwork/delete.go | 241 ++++++++++++++++++ .../cli/serverless/authorizednetwork/list.go | 163 ++++++++++++ .../serverless/authorizednetwork/update.go | 241 ++++++++++++++++++ internal/cli/serverless/cluster.go | 2 + internal/cli/serverless/create.go | 28 +- internal/flag/flag.go | 3 + internal/service/cloud/logic.go | 58 +++++ internal/util/authorized_network.go | 86 +++++++ 11 files changed, 1088 insertions(+), 6 deletions(-) create mode 100644 internal/cli/serverless/authorizednetwork/authorized_network.go create mode 100644 internal/cli/serverless/authorizednetwork/create.go create mode 100644 internal/cli/serverless/authorizednetwork/delete.go create mode 100644 internal/cli/serverless/authorizednetwork/list.go create mode 100644 internal/cli/serverless/authorizednetwork/update.go create mode 100644 internal/util/authorized_network.go diff --git a/go.mod b/go.mod index 7f82f293..af2afa90 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/go-resty/resty/v2 v2.11.0 github.com/go-sql-driver/mysql v1.8.1 github.com/google/go-github/v49 v49.0.0 + github.com/google/uuid v1.6.0 github.com/hashicorp/go-version v1.6.0 github.com/icholy/digest v0.1.22 github.com/juju/errors v1.0.0 diff --git a/internal/cli/serverless/authorizednetwork/authorized_network.go b/internal/cli/serverless/authorizednetwork/authorized_network.go new file mode 100644 index 00000000..08e6bd7d --- /dev/null +++ b/internal/cli/serverless/authorizednetwork/authorized_network.go @@ -0,0 +1,38 @@ +// Copyright 2025 PingCAP, Inc. +// +// 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 authorizednetwork + +import ( + "github.com/tidbcloud/tidbcloud-cli/internal" + + "github.com/spf13/cobra" +) + +type mutableField string + +func AuthorizedNetworkCmd(h *internal.Helper) *cobra.Command { + var authorizedNetworkCmd = &cobra.Command{ + Use: "authorized-network", + Short: "Manage TiDB Cloud Serverless cluster authorized networks", + Aliases: []string{"user"}, + } + + authorizedNetworkCmd.AddCommand(CreateCmd(h)) + authorizedNetworkCmd.AddCommand(DeleteCmd(h)) + authorizedNetworkCmd.AddCommand(UpdateCmd(h)) + authorizedNetworkCmd.AddCommand(ListCmd(h)) + + return authorizedNetworkCmd +} diff --git a/internal/cli/serverless/authorizednetwork/create.go b/internal/cli/serverless/authorizednetwork/create.go new file mode 100644 index 00000000..270ab9e4 --- /dev/null +++ b/internal/cli/serverless/authorizednetwork/create.go @@ -0,0 +1,233 @@ +// Copyright 2025 PingCAP, Inc. + +// 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 authorizednetwork + +import ( + "fmt" + + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/config" + "github.com/tidbcloud/tidbcloud-cli/internal/flag" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" + "github.com/tidbcloud/tidbcloud-cli/internal/telemetry" + "github.com/tidbcloud/tidbcloud-cli/internal/ui" + "github.com/tidbcloud/tidbcloud-cli/internal/util" + + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/cluster" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/fatih/color" + "github.com/juju/errors" + "github.com/spf13/cobra" +) + +const ( + AuthorizedNetworkMask = "endpoints.public.authorizedNetworks" +) + +type CreateOpts struct { + interactive bool +} + +var createAuthorizedNetworkField = map[string]int{ + flag.DisplayName: 0, + flag.IPRange: 1, +} + +func (c CreateOpts) NonInteractiveFlags() []string { + return []string{ + flag.ClusterID, + flag.IPRange, + flag.DisplayName, + } +} + +func (c CreateOpts) RequiredFlags() []string { + return []string{ + flag.ClusterID, + flag.IPRange, + } +} + +func CreateCmd(h *internal.Helper) *cobra.Command { + opts := CreateOpts{ + interactive: true, + } + + var CreateCmd = &cobra.Command{ + Use: "create", + Short: "Create an authorized network", + Args: cobra.NoArgs, + Annotations: make(map[string]string), + Example: fmt.Sprintf(` Create an authorized network in interactive mode: + $ %[1]s serverless authorized-network create + + Create an authorized network in non-interactive mode: + $ %[1]s serverless authorized-network create -c --display-name --ip-range `, + config.CliName), + PreRunE: func(cmd *cobra.Command, args []string) error { + flags := opts.NonInteractiveFlags() + for _, fn := range flags { + f := cmd.Flags().Lookup(fn) + if f != nil && f.Changed { + opts.interactive = false + } + } + + // mark required flags in non-interactive mode + if !opts.interactive { + for _, fn := range opts.RequiredFlags() { + err := cmd.MarkFlagRequired(fn) + if err != nil { + return errors.Trace(err) + } + } + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + d, err := h.Client() + if err != nil { + return err + } + + var clusterID string + var displayName string + var ipRange string + if opts.interactive { + cmd.Annotations[telemetry.InteractiveMode] = "true" + if !h.IOStreams.CanPrompt { + return errors.New("The terminal doesn't support interactive mode, please use non-interactive mode") + } + + // interactive mode + project, err := cloud.GetSelectedProject(ctx, h.QueryPageSize, d) + if err != nil { + return err + } + projectID := project.ID + + cluster, err := cloud.GetSelectedCluster(ctx, projectID, h.QueryPageSize, d) + if err != nil { + return err + } + clusterID = cluster.ID + + // variables for input + fmt.Fprintln(h.IOStreams.Out, color.BlueString("Please input the following options")) + + p := tea.NewProgram(initialCreateInputModel()) + inputModel, err := p.Run() + if err != nil { + return errors.Trace(err) + } + if inputModel.(ui.TextInputModel).Interrupted { + return util.InterruptError + } + + displayName = inputModel.(ui.TextInputModel).Inputs[createAuthorizedNetworkField[flag.DisplayName]].Value() + ipRange = inputModel.(ui.TextInputModel).Inputs[createAuthorizedNetworkField[flag.IPRange]].Value() + + } else { + // non-interactive mode doesn't need projectID + clusterID, err = cmd.Flags().GetString(flag.ClusterID) + if err != nil { + return errors.Trace(err) + } + + displayName, err = cmd.Flags().GetString(flag.DisplayName) + if err != nil { + return errors.Trace(err) + } + + ipRange, err = cmd.Flags().GetString(flag.IPRange) + if err != nil { + return errors.Trace(err) + } + } + + if displayName == "" { + displayName = util.GenerateIDAuthorizedNetworkDisplayName() + } + + authorizedNetwork, err := util.ConvertToAuthorizedNetwork(ipRange, displayName) + if err != nil { + return errors.Trace(err) + } + + existedAuthorizedNetworks, err := cloud.RetrieveAuthorizedNetworks(ctx, clusterID, d) + if err != nil { + return errors.Trace(err) + } + + authorizedNetworks := append(existedAuthorizedNetworks, authorizedNetwork) + + body := &cluster.V1beta1ServerlessServicePartialUpdateClusterBody{ + Cluster: &cluster.RequiredTheClusterToBeUpdated{ + Endpoints: &cluster.V1beta1ClusterEndpoints{ + Public: &cluster.EndpointsPublic{ + AuthorizedNetworks: authorizedNetworks, + }, + }, + }, + } + body.UpdateMask = AuthorizedNetworkMask + + _, err = d.PartialUpdateCluster(ctx, clusterID, body) + if err != nil { + return errors.Trace(err) + } + + _, err = fmt.Fprintln(h.IOStreams.Out, color.GreenString("authorized network %s is created", displayName)) + if err != nil { + return err + } + return nil + }, + } + + CreateCmd.Flags().StringP(flag.ClusterID, flag.ClusterIDShort, "", "The ID of the cluster.") + CreateCmd.Flags().StringP(flag.IPRange, "", "", "The IP range of the authorized network.") + CreateCmd.Flags().StringP(flag.DisplayName, flag.DisplayNameShort, "", "The name of the authorized network.") + + return CreateCmd +} + +func initialCreateInputModel() ui.TextInputModel { + m := ui.TextInputModel{ + Inputs: make([]textinput.Model, len(createAuthorizedNetworkField)), + } + + for k, v := range createAuthorizedNetworkField { + t := textinput.New() + t.Cursor.Style = config.CursorStyle + t.CharLimit = 32 + + switch k { + case flag.DisplayName: + t.Placeholder = "Display Name (optional)" + t.Focus() + t.PromptStyle = config.FocusedStyle + t.TextStyle = config.FocusedStyle + case flag.IPRange: + ipRangeExample := "0.0.0.0-255.255.255.255" + t.Placeholder = fmt.Sprintf("IP Range (e.g., %s)", ipRangeExample) + } + m.Inputs[v] = t + } + return m +} diff --git a/internal/cli/serverless/authorizednetwork/delete.go b/internal/cli/serverless/authorizednetwork/delete.go new file mode 100644 index 00000000..dd86be0f --- /dev/null +++ b/internal/cli/serverless/authorizednetwork/delete.go @@ -0,0 +1,241 @@ +// Copyright 2025 PingCAP, Inc. + +// 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 authorizednetwork + +import ( + "fmt" + "slices" + + "github.com/AlecAivazis/survey/v2" + "github.com/AlecAivazis/survey/v2/terminal" + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/config" + "github.com/tidbcloud/tidbcloud-cli/internal/flag" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" + "github.com/tidbcloud/tidbcloud-cli/internal/telemetry" + "github.com/tidbcloud/tidbcloud-cli/internal/ui" + "github.com/tidbcloud/tidbcloud-cli/internal/util" + + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/cluster" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/fatih/color" + "github.com/juju/errors" + "github.com/spf13/cobra" +) + +const confirmed = "yes" + +type DeleteOpts struct { + interactive bool +} + +var deleteAuthorizedNetworkField = map[string]int{ + flag.IPRange: 0, +} + +func (c DeleteOpts) NonInteractiveFlags() []string { + return []string{ + flag.ClusterID, + flag.IPRange, + } +} + +func (c DeleteOpts) RequiredFlags() []string { + return []string{ + flag.ClusterID, + flag.IPRange, + } +} + +func DeleteCmd(h *internal.Helper) *cobra.Command { + opts := DeleteOpts{ + interactive: true, + } + + var force bool + var DeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete an authorized network", + Args: cobra.NoArgs, + Annotations: make(map[string]string), + Example: fmt.Sprintf(` Delete an authorized network in interactive mode: + $ %[1]s serverless authorized-network delete + + Delete an authorized network in non-interactive mode: + $ %[1]s serverless authorized-network delete -c --ip-range `, + config.CliName), + PreRunE: func(cmd *cobra.Command, args []string) error { + flags := opts.NonInteractiveFlags() + for _, fn := range flags { + f := cmd.Flags().Lookup(fn) + if f != nil && f.Changed { + opts.interactive = false + } + } + + // mark required flags in non-interactive mode + if !opts.interactive { + for _, fn := range opts.RequiredFlags() { + err := cmd.MarkFlagRequired(fn) + if err != nil { + return errors.Trace(err) + } + } + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + d, err := h.Client() + if err != nil { + return err + } + + var clusterID string + var displayName string + var ipRange string + if opts.interactive { + cmd.Annotations[telemetry.InteractiveMode] = "true" + if !h.IOStreams.CanPrompt { + return errors.New("The terminal doesn't support interactive mode, please use non-interactive mode") + } + + // interactive mode + project, err := cloud.GetSelectedProject(ctx, h.QueryPageSize, d) + if err != nil { + return err + } + projectID := project.ID + + cluster, err := cloud.GetSelectedCluster(ctx, projectID, h.QueryPageSize, d) + if err != nil { + return err + } + clusterID = cluster.ID + + // variables for input + fmt.Fprintln(h.IOStreams.Out, color.BlueString("Please input the following options")) + + ipRange, err = cloud.GetSelectedAuthorizedNetwork(ctx, clusterID, d) + if err != nil { + return err + } + } else { + // non-interactive mode doesn't need projectID + clusterID, err = cmd.Flags().GetString(flag.ClusterID) + if err != nil { + return errors.Trace(err) + } + + ipRange, err = cmd.Flags().GetString(flag.IPRange) + if err != nil { + return errors.Trace(err) + } + } + + if !force { + if !h.IOStreams.CanPrompt { + return fmt.Errorf("the terminal doesn't support prompt, please run with --force to delete the authorized network") + } + + confirmationMessage := fmt.Sprintf("%s %s %s", color.BlueString("Please type"), color.HiBlueString(confirmed), color.BlueString("to confirm:")) + + prompt := &survey.Input{ + Message: confirmationMessage, + } + + var userInput string + err := survey.AskOne(prompt, &userInput) + if err != nil { + if err == terminal.InterruptErr { + return util.InterruptError + } else { + return err + } + } + + if userInput != confirmed { + return errors.New("incorrect confirm string entered, skipping authorized network deletion") + } + } + + authorizedNetwork, err := util.ConvertToAuthorizedNetwork(ipRange, displayName) + if err != nil { + return errors.Trace(err) + } + + existedAuthorizedNetworks, err := cloud.RetrieveAuthorizedNetworks(ctx, clusterID, d) + if err != nil { + return errors.Trace(err) + } + + for i, v := range existedAuthorizedNetworks { + if v.StartIpAddress == authorizedNetwork.StartIpAddress && v.EndIpAddress == authorizedNetwork.EndIpAddress { + existedAuthorizedNetworks = slices.Delete(existedAuthorizedNetworks, i, i+1) + break + } + } + body := &cluster.V1beta1ServerlessServicePartialUpdateClusterBody{ + Cluster: &cluster.RequiredTheClusterToBeUpdated{ + Endpoints: &cluster.V1beta1ClusterEndpoints{ + Public: &cluster.EndpointsPublic{ + AuthorizedNetworks: existedAuthorizedNetworks, + }, + }, + }, + } + body.UpdateMask = AuthorizedNetworkMask + + _, err = d.PartialUpdateCluster(ctx, clusterID, body) + if err != nil { + return errors.Trace(err) + } + + _, err = fmt.Fprintln(h.IOStreams.Out, color.GreenString("authorized network %s is deleted", ipRange)) + if err != nil { + return err + } + return nil + + }, + } + + DeleteCmd.Flags().BoolVar(&force, flag.Force, false, "Delete an authorized network without confirmation.") + DeleteCmd.Flags().StringP(flag.ClusterID, flag.ClusterIDShort, "", "The ID of the cluster.") + DeleteCmd.Flags().StringP(flag.IPRange, "", "", "The IP range of the authorized network.") + + return DeleteCmd +} + +func initialDeleteInputModel() ui.TextInputModel { + m := ui.TextInputModel{ + Inputs: make([]textinput.Model, len(deleteAuthorizedNetworkField)), + } + + for k, v := range deleteAuthorizedNetworkField { + t := textinput.New() + t.Cursor.Style = config.CursorStyle + t.CharLimit = 32 + + switch k { + case flag.IPRange: + ipRangeExample := "0.0.0.0-255.255.255.255" + t.Placeholder = fmt.Sprintf("IP Range (e.g., %s)", ipRangeExample) + } + m.Inputs[v] = t + } + return m +} diff --git a/internal/cli/serverless/authorizednetwork/list.go b/internal/cli/serverless/authorizednetwork/list.go new file mode 100644 index 00000000..459bf2b7 --- /dev/null +++ b/internal/cli/serverless/authorizednetwork/list.go @@ -0,0 +1,163 @@ +// Copyright 2025 PingCAP, Inc. + +// 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 authorizednetwork + +import ( + "fmt" + + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/config" + "github.com/tidbcloud/tidbcloud-cli/internal/flag" + "github.com/tidbcloud/tidbcloud-cli/internal/output" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" + "github.com/tidbcloud/tidbcloud-cli/internal/telemetry" + + "github.com/juju/errors" + "github.com/spf13/cobra" +) + +type ListOpts struct { + interactive bool +} + +func (c ListOpts) NonInteractiveFlags() []string { + return []string{ + flag.ClusterID, + } +} + +func ListCmd(h *internal.Helper) *cobra.Command { + opts := ListOpts{ + interactive: true, + } + + var listCmd = &cobra.Command{ + Use: "list", + Short: "List all authorized networks", + Args: cobra.NoArgs, + Aliases: []string{"ls"}, + Annotations: make(map[string]string), + Example: fmt.Sprintf(` List all authorized networks in interactive mode: + $ %[1]s serverless authorized-network list + + List all authorized networks in non-interactive mode: + $ %[1]s serverless authorized-network list -c + + List all authorized networks with json format: + $ %[1]s serverless authorized-network list -o json`, config.CliName), + PreRunE: func(cmd *cobra.Command, args []string) error { + flags := opts.NonInteractiveFlags() + for _, fn := range flags { + f := cmd.Flags().Lookup(fn) + if f != nil && f.Changed { + opts.interactive = false + } + } + + // mark required flags in non-interactive mode + if !opts.interactive { + for _, fn := range flags { + err := cmd.MarkFlagRequired(fn) + if err != nil { + return errors.Trace(err) + } + } + } + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + d, err := h.Client() + if err != nil { + return err + } + + var clusterID string + if opts.interactive { + cmd.Annotations[telemetry.InteractiveMode] = "true" + if !h.IOStreams.CanPrompt { + return errors.New("The terminal doesn't support interactive mode, please use non-interactive mode") + } + + // interactive mode + project, err := cloud.GetSelectedProject(ctx, h.QueryPageSize, d) + if err != nil { + return err + } + projectID := project.ID + + cluster, err := cloud.GetSelectedCluster(ctx, projectID, h.QueryPageSize, d) + if err != nil { + return err + } + clusterID = cluster.ID + } else { + // non-interactive mode doesn't need projectID + cID, err := cmd.Flags().GetString(flag.ClusterID) + if err != nil { + return errors.Trace(err) + } + clusterID = cID + } + + items, err := cloud.RetrieveAuthorizedNetworks(ctx, clusterID, d) + if err != nil { + return err + } + + format, err := cmd.Flags().GetString(flag.Output) + if err != nil { + return errors.Trace(err) + } + if format == output.JsonFormat || !h.IOStreams.CanPrompt { + err := output.PrintJson(h.IOStreams.Out, items) + if err != nil { + return errors.Trace(err) + } + } else if format == output.HumanFormat { + columns := []output.Column{ + "DisplayName", + "StartIPAddress", + "EndIPAddress", + } + + var rows []output.Row + for _, item := range items { + rows = append(rows, output.Row{ + item.DisplayName, + item.StartIpAddress, + item.EndIpAddress, + }) + } + + err := output.PrintHumanTable(h.IOStreams.Out, columns, rows) + // for human format, we print the table with brief information. + if err != nil { + return errors.Trace(err) + } + return nil + } else { + return fmt.Errorf("unsupported output format: %s", format) + } + + return nil + }, + } + + listCmd.Flags().StringP(flag.Output, flag.OutputShort, output.HumanFormat, flag.OutputHelp) + listCmd.Flags().StringP(flag.ClusterID, flag.ClusterIDShort, "", "The ID of the cluster.") + return listCmd +} diff --git a/internal/cli/serverless/authorizednetwork/update.go b/internal/cli/serverless/authorizednetwork/update.go new file mode 100644 index 00000000..c8c52d19 --- /dev/null +++ b/internal/cli/serverless/authorizednetwork/update.go @@ -0,0 +1,241 @@ +// Copyright 2025 PingCAP, Inc. + +// 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 authorizednetwork + +import ( + "fmt" + "slices" + + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/config" + "github.com/tidbcloud/tidbcloud-cli/internal/flag" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" + "github.com/tidbcloud/tidbcloud-cli/internal/ui" + "github.com/tidbcloud/tidbcloud-cli/internal/util" + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/cluster" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/fatih/color" + "github.com/juju/errors" + "github.com/spf13/cobra" +) + +type UpdateOpts struct { + interactive bool +} + +var updateAuthorizedNetworkField = map[string]int{ + flag.DisplayName: 0, + flag.IPRange: 1, +} + +func (c UpdateOpts) NonInteractiveFlags() []string { + return []string{ + flag.ClusterID, + flag.TargetIPRange, + flag.IPRange, + flag.DisplayName, + } +} + +func UpdateCmd(h *internal.Helper) *cobra.Command { + opts := UpdateOpts{ + interactive: true, + } + + var updateCmd = &cobra.Command{ + Use: "update", + Short: "Update an authorized network", + Args: cobra.NoArgs, + Example: fmt.Sprintf(` Update an authorized network in interactive mode: + $ %[1]s serverless authorized-network update + + Update an authorized network in non-interactive mode: + $ %[1]s serverless authorized-network update -c --ip-range --display-name `, config.CliName), + PreRunE: func(cmd *cobra.Command, args []string) error { + flags := opts.NonInteractiveFlags() + for _, fn := range flags { + f := cmd.Flags().Lookup(fn) + if f != nil && f.Changed { + opts.interactive = false + } + } + + // mark required flags in non-interactive mode + if !opts.interactive { + err := cmd.MarkFlagRequired(flag.ClusterID) + if err != nil { + return err + } + err = cmd.MarkFlagRequired(flag.TargetIPRange) + if err != nil { + return err + } + cmd.MarkFlagsOneRequired(flag.IPRange, flag.DisplayName) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + d, err := h.Client() + if err != nil { + return err + } + + var clusterID string + var displayName string + var ipRange string + var targetIPRange string + if opts.interactive { + if !h.IOStreams.CanPrompt { + return errors.New("The terminal doesn't support interactive mode, please use non-interactive mode") + } + + // interactive mode + project, err := cloud.GetSelectedProject(ctx, h.QueryPageSize, d) + if err != nil { + return err + } + projectID := project.ID + + cluster, err := cloud.GetSelectedCluster(ctx, projectID, h.QueryPageSize, d) + if err != nil { + return err + } + clusterID = cluster.ID + + targetIPRange, err = cloud.GetSelectedAuthorizedNetwork(ctx, clusterID, d) + if err != nil { + return err + } + + // variables for input + fmt.Fprintln(h.IOStreams.Out, color.BlueString("Please input the following options")) + + p := tea.NewProgram(initialCreateInputModel()) + inputModel, err := p.Run() + if err != nil { + return errors.Trace(err) + } + if inputModel.(ui.TextInputModel).Interrupted { + return util.InterruptError + } + + displayName = inputModel.(ui.TextInputModel).Inputs[createAuthorizedNetworkField[flag.DisplayName]].Value() + ipRange = inputModel.(ui.TextInputModel).Inputs[createAuthorizedNetworkField[flag.IPRange]].Value() + } else { + // non-interactive mode doesn't need projectID + clusterID, err = cmd.Flags().GetString(flag.ClusterID) + if err != nil { + return errors.Trace(err) + } + + displayName, err = cmd.Flags().GetString(flag.DisplayName) + if err != nil { + return errors.Trace(err) + } + + ipRange, err = cmd.Flags().GetString(flag.IPRange) + if err != nil { + return errors.Trace(err) + } + + targetIPRange, err = cmd.Flags().GetString(flag.TargetIPRange) + if err != nil { + return errors.Trace(err) + } + } + + authorizedNetwork, err := util.ConvertToAuthorizedNetwork(ipRange, displayName) + if err != nil { + return errors.Trace(err) + } + + targetAuthorizedNetwork, err := util.ConvertToAuthorizedNetwork(targetIPRange, "") + if err != nil { + return errors.Trace(err) + } + + existedAuthorizedNetworks, err := cloud.RetrieveAuthorizedNetworks(ctx, clusterID, d) + if err != nil { + return errors.Trace(err) + } + + for i, v := range existedAuthorizedNetworks { + if v.StartIpAddress == targetAuthorizedNetwork.StartIpAddress && v.EndIpAddress == targetAuthorizedNetwork.EndIpAddress { + existedAuthorizedNetworks = slices.Delete(existedAuthorizedNetworks, i, i+1) + break + } + } + + authorizedNetworks := append(existedAuthorizedNetworks, authorizedNetwork) + + body := &cluster.V1beta1ServerlessServicePartialUpdateClusterBody{ + Cluster: &cluster.RequiredTheClusterToBeUpdated{ + Endpoints: &cluster.V1beta1ClusterEndpoints{ + Public: &cluster.EndpointsPublic{ + AuthorizedNetworks: authorizedNetworks, + }, + }, + }, + } + body.UpdateMask = AuthorizedNetworkMask + + _, err = d.PartialUpdateCluster(ctx, clusterID, body) + if err != nil { + return errors.Trace(err) + } + + _, err = fmt.Fprintln(h.IOStreams.Out, color.GreenString("authorized network %s is updated", displayName)) + if err != nil { + return err + } + return nil + }, + } + + updateCmd.Flags().StringP(flag.ClusterID, flag.ClusterIDShort, "", "The ID of the cluster.") + updateCmd.Flags().StringP(flag.IPRange, "", "", "The new IP range of the authorized network.") + updateCmd.Flags().StringP(flag.DisplayName, flag.DisplayNameShort, "", "The name of the authorized network.") + updateCmd.Flags().StringP(flag.TargetIPRange, "", "", "The IP range of the authorized network to be updated.") + + return updateCmd +} + +func initialUpdateInputModel() ui.TextInputModel { + m := ui.TextInputModel{ + Inputs: make([]textinput.Model, len(updateAuthorizedNetworkField)), + } + + for k, v := range updateAuthorizedNetworkField { + t := textinput.New() + t.Cursor.Style = config.CursorStyle + t.CharLimit = 32 + + switch k { + case flag.DisplayName: + t.Placeholder = "Display Name" + t.Focus() + t.PromptStyle = config.FocusedStyle + t.TextStyle = config.FocusedStyle + case flag.IPRange: + ipRangeExample := "0.0.0.0-255.255.255.255" + t.Placeholder = fmt.Sprintf("IP Range (e.g., %s)", ipRangeExample) + } + m.Inputs[v] = t + } + return m +} diff --git a/internal/cli/serverless/cluster.go b/internal/cli/serverless/cluster.go index f3435e4e..8bd2bad0 100644 --- a/internal/cli/serverless/cluster.go +++ b/internal/cli/serverless/cluster.go @@ -16,6 +16,7 @@ package serverless import ( "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/cli/serverless/authorizednetwork" "github.com/tidbcloud/tidbcloud-cli/internal/cli/serverless/branch" "github.com/tidbcloud/tidbcloud-cli/internal/cli/serverless/dataimport" "github.com/tidbcloud/tidbcloud-cli/internal/cli/serverless/export" @@ -46,6 +47,7 @@ func Cmd(h *internal.Helper) *cobra.Command { serverlessCmd.AddCommand(SpendingLimitCmd(h)) serverlessCmd.AddCommand(RegionCmd(h)) serverlessCmd.AddCommand(CapacityCmd(h)) + serverlessCmd.AddCommand(authorizednetwork.AuthorizedNetworkCmd(h)) return serverlessCmd } diff --git a/internal/cli/serverless/create.go b/internal/cli/serverless/create.go index 58a25fdf..5829708e 100644 --- a/internal/cli/serverless/create.go +++ b/internal/cli/serverless/create.go @@ -88,7 +88,7 @@ func CreateCmd(h *internal.Helper) *cobra.Command { Example: fmt.Sprintf(` Create a TiDB Cloud Serverless cluster in interactive mode: $ %[1]s serverless create - Create a TiDB Cloud Serverless cluster of the default ptoject in non-interactive mode: + Create a TiDB Cloud Serverless cluster of the default project in non-interactive mode: $ %[1]s serverless create --display-name --region Create a TiDB Cloud Serverless cluster in non-interactive mode: @@ -129,6 +129,8 @@ func CreateCmd(h *internal.Helper) *cobra.Command { var minRcu, maxRcu int32 var encryption bool var publicEndpointDisabled bool + var authorizedNetworksStrList []string + var authorizedNetworks []cluster.EndpointsPublicAuthorizedNetwork if opts.interactive { cmd.Annotations[telemetry.InteractiveMode] = "true" if !h.IOStreams.CanPrompt { @@ -351,6 +353,14 @@ func CreateCmd(h *internal.Helper) *cobra.Command { return errors.Trace(err) } } + authorizedNetworksStrList, err = cmd.Flags().GetStringSlice(flag.AuthorizedNetworks) + if err != nil { + return errors.Trace(err) + } + authorizedNetworks, err = util.ConvertToAuthorizedNetworks(authorizedNetworksStrList) + if err != nil { + return errors.Trace(err) + } } cmd.Annotations[telemetry.ProjectID] = projectID @@ -382,12 +392,17 @@ func CreateCmd(h *internal.Helper) *cobra.Command { } } + publicEndpoint := &cluster.EndpointsPublic{} if publicEndpointDisabled { - v1Cluster.Endpoints = &cluster.V1beta1ClusterEndpoints{ - Public: &cluster.EndpointsPublic{ - Disabled: &publicEndpointDisabled, - }, - } + publicEndpoint.Disabled = &publicEndpointDisabled + } + + if len(authorizedNetworks) > 0 { + publicEndpoint.AuthorizedNetworks = authorizedNetworks + } + + v1Cluster.Endpoints = &cluster.V1beta1ClusterEndpoints{ + Public: publicEndpoint, } if h.IOStreams.CanPrompt { @@ -414,6 +429,7 @@ func CreateCmd(h *internal.Helper) *cobra.Command { createCmd.Flags().Bool(flag.PublicEndpointDisabled, false, "Whether the public endpoint is disabled.") createCmd.Flags().Int32(flag.MinRCU, 0, "Minimum RCU for the cluster, at least 2000.") createCmd.Flags().Int32(flag.MaxRCU, 0, "Maximum RCU for the cluster, at most 100000.") + createCmd.Flags().StringSliceP(flag.AuthorizedNetworks, "", nil, "The authorized networks of the public endpoint.") createCmd.MarkFlagsMutuallyExclusive(flag.SpendingLimitMonthly, flag.MinRCU) createCmd.MarkFlagsMutuallyExclusive(flag.SpendingLimitMonthly, flag.MaxRCU) createCmd.MarkFlagsRequiredTogether(flag.MinRCU, flag.MaxRCU) diff --git a/internal/flag/flag.go b/internal/flag/flag.go index c93df218..8b414bda 100644 --- a/internal/flag/flag.go +++ b/internal/flag/flag.go @@ -60,6 +60,9 @@ const ( BackupTime string = "backup-time" MinRCU string = "min-rcu" MaxRCU string = "max-rcu" + AuthorizedNetworks string = "authorized-networks" + IPRange string = "ip-range" + TargetIPRange string = "target-ip-range" // External storage S3URI string = "s3.uri" S3AccessKeyID string = "s3.access-key-id" diff --git a/internal/service/cloud/logic.go b/internal/service/cloud/logic.go index e29360b5..3dfb7bc9 100644 --- a/internal/service/cloud/logic.go +++ b/internal/service/cloud/logic.go @@ -74,6 +74,15 @@ type Export struct { ID string } +type AuthorizedNetwork struct { + DisplayName string + IPRange string +} + +func (a AuthorizedNetwork) String() string { + return fmt.Sprintf("%s(%s)", a.IPRange, a.DisplayName) +} + func (c ServerlessBackup) String() string { return fmt.Sprintf("%s(%s)", c.CreateTime, c.ID) } @@ -849,3 +858,52 @@ func GetAllExportFiles(ctx context.Context, cID string, eID string, d TiDBCloudC } return items, nil } + + +func RetrieveAuthorizedNetworks(ctx context.Context, clusterID string, d TiDBCloudClient) ([]cluster.EndpointsPublicAuthorizedNetwork, error) { + cluster, err := d.GetCluster(ctx, clusterID, cluster.SERVERLESSSERVICEGETCLUSTERVIEWPARAMETER_BASIC) + if err != nil { + return nil, errors.Trace(err) + } + return cluster.Endpoints.Public.AuthorizedNetworks, nil +} + +func GetSelectedAuthorizedNetwork(ctx context.Context, clusterID string, client TiDBCloudClient) (string, error) { + authorizedNetworkItems, err := RetrieveAuthorizedNetworks(ctx, clusterID, client) + if err != nil { + return "", err + } + + var items = make([]interface{}, 0, len(authorizedNetworkItems)) + for _, item := range authorizedNetworkItems { + items = append(items, &AuthorizedNetwork{ + DisplayName: item.DisplayName, + IPRange: fmt.Sprintf("%s-%s", item.StartIpAddress, item.EndIpAddress), + }) + } + if len(items) == 0 { + return "", fmt.Errorf("no available authorized networks found") + } + + model, err := ui.InitialSelectModel(items, "Choose the authorized network:") + if err != nil { + return "", errors.Trace(err) + } + itemsPerPage := 6 + model.EnablePagination(itemsPerPage) + model.EnableFilter() + + p := tea.NewProgram(model) + authorizedNetworkModel, err := p.Run() + if err != nil { + return "", errors.Trace(err) + } + if m, _ := authorizedNetworkModel.(ui.SelectModel); m.Interrupted { + return "", util.InterruptError + } + authorizedNetwork := authorizedNetworkModel.(ui.SelectModel).GetSelectedItem() + if authorizedNetwork == nil { + return "", errors.New("no cluster selected") + } + return authorizedNetwork.(*AuthorizedNetwork).IPRange, nil +} \ No newline at end of file diff --git a/internal/util/authorized_network.go b/internal/util/authorized_network.go new file mode 100644 index 00000000..b200d097 --- /dev/null +++ b/internal/util/authorized_network.go @@ -0,0 +1,86 @@ +// Copyright 2025 PingCAP, Inc. +// +// 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 util + +import ( + "encoding/base32" + "fmt" + "net" + "strings" + "time" + + "github.com/google/uuid" + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/cluster" +) + +func ConvertToAuthorizedNetworks(authorizedNetworksStrList []string) ([]cluster.EndpointsPublicAuthorizedNetwork, error) { + authorizedNetworks := make([]cluster.EndpointsPublicAuthorizedNetwork, 0, len(authorizedNetworksStrList)) + for _, str := range authorizedNetworksStrList { + authorizedNetwork, err := ConvertToAuthorizedNetwork(str, "") + if err != nil { + return nil, fmt.Errorf("failed to convert authorized network %s: %w", str, err) + } + authorizedNetworks = append(authorizedNetworks, authorizedNetwork) + } + return authorizedNetworks, nil +} + +func ConvertToAuthorizedNetwork(authorizedNetworksStr string, displayName string) (cluster.EndpointsPublicAuthorizedNetwork, error) { + startIP, endIP, err := parseIPv4Range(authorizedNetworksStr) + if err != nil { + return cluster.EndpointsPublicAuthorizedNetwork{}, err + } + + if displayName == "" { + displayName = GenerateIDAuthorizedNetworkDisplayName() + } + + return cluster.EndpointsPublicAuthorizedNetwork{ + StartIpAddress: startIP.String(), + EndIpAddress: endIP.String(), + DisplayName: displayName, + }, nil +} + +func parseIPv4Range(ipRange string) (net.IP, net.IP, error) { + parts := strings.Split(ipRange, "-") + if len(parts) != 2 { + return nil, nil, fmt.Errorf("invalid IP range format, expected '{start-ip}-{end-ip}'") + } + + startIPStr := strings.TrimSpace(parts[0]) + endIPStr := strings.TrimSpace(parts[1]) + + startIP := net.ParseIP(startIPStr) + if startIP == nil || startIP.To4() == nil { + return nil, nil, fmt.Errorf("invalid IPv4 start address: %s", startIPStr) + } + + endIP := net.ParseIP(endIPStr) + if endIP == nil || endIP.To4() == nil { + return nil, nil, fmt.Errorf("invalid IPv4 end address: %s", endIPStr) + } + + return startIP.To4(), endIP.To4(), nil +} + +var base32Encoder = base32.StdEncoding.WithPadding(base32.NoPadding) + +func GenerateIDAuthorizedNetworkDisplayName() string { + uuidV4 := uuid.New() + now := time.Now() + dateStr := now.Format("20060102") + return "Allowlist_" + dateStr + "_" + strings.ToLower(base32Encoder.EncodeToString(uuidV4[:])) +} From 195a416a5e7d3b938cd5a74a234b822eccea3e4e Mon Sep 17 00:00:00 2001 From: FingerLeader Date: Wed, 9 Apr 2025 00:07:20 +0800 Subject: [PATCH 2/9] generate docs Signed-off-by: FingerLeader --- docs/generate_doc/ticloud_serverless.md | 1 + .../ticloud_serverless_authorized-network.md | 26 ++++++++++++ ...ud_serverless_authorized-network_create.md | 39 ++++++++++++++++++ ...ud_serverless_authorized-network_delete.md | 39 ++++++++++++++++++ ...loud_serverless_authorized-network_list.md | 41 +++++++++++++++++++ ...ud_serverless_authorized-network_update.md | 40 ++++++++++++++++++ .../generate_doc/ticloud_serverless_create.md | 3 +- 7 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 docs/generate_doc/ticloud_serverless_authorized-network.md create mode 100644 docs/generate_doc/ticloud_serverless_authorized-network_create.md create mode 100644 docs/generate_doc/ticloud_serverless_authorized-network_delete.md create mode 100644 docs/generate_doc/ticloud_serverless_authorized-network_list.md create mode 100644 docs/generate_doc/ticloud_serverless_authorized-network_update.md diff --git a/docs/generate_doc/ticloud_serverless.md b/docs/generate_doc/ticloud_serverless.md index 586070c3..52d7564b 100644 --- a/docs/generate_doc/ticloud_serverless.md +++ b/docs/generate_doc/ticloud_serverless.md @@ -19,6 +19,7 @@ Manage TiDB Cloud Serverless clusters ### SEE ALSO * [ticloud](ticloud.md) - CLI tool to manage TiDB Cloud +* [ticloud serverless authorized-network](ticloud_serverless_authorized-network.md) - Manage TiDB Cloud Serverless cluster authorized networks * [ticloud serverless branch](ticloud_serverless_branch.md) - Manage TiDB Cloud Serverless branches * [ticloud serverless capacity](ticloud_serverless_capacity.md) - Set capacity for a TiDB Cloud Serverless cluster * [ticloud serverless create](ticloud_serverless_create.md) - Create a TiDB Cloud Serverless cluster diff --git a/docs/generate_doc/ticloud_serverless_authorized-network.md b/docs/generate_doc/ticloud_serverless_authorized-network.md new file mode 100644 index 00000000..f1651a15 --- /dev/null +++ b/docs/generate_doc/ticloud_serverless_authorized-network.md @@ -0,0 +1,26 @@ +## ticloud serverless authorized-network + +Manage TiDB Cloud Serverless cluster authorized networks + +### Options + +``` + -h, --help help for authorized-network +``` + +### Options inherited from parent commands + +``` + -D, --debug Enable debug mode + --no-color Disable color output + -P, --profile string Profile to use from your configuration file +``` + +### SEE ALSO + +* [ticloud serverless](ticloud_serverless.md) - Manage TiDB Cloud Serverless clusters +* [ticloud serverless authorized-network create](ticloud_serverless_authorized-network_create.md) - Create an authorized network +* [ticloud serverless authorized-network delete](ticloud_serverless_authorized-network_delete.md) - Delete an authorized network +* [ticloud serverless authorized-network list](ticloud_serverless_authorized-network_list.md) - List all authorized networks +* [ticloud serverless authorized-network update](ticloud_serverless_authorized-network_update.md) - Update an authorized network + diff --git a/docs/generate_doc/ticloud_serverless_authorized-network_create.md b/docs/generate_doc/ticloud_serverless_authorized-network_create.md new file mode 100644 index 00000000..b4d446bb --- /dev/null +++ b/docs/generate_doc/ticloud_serverless_authorized-network_create.md @@ -0,0 +1,39 @@ +## ticloud serverless authorized-network create + +Create an authorized network + +``` +ticloud serverless authorized-network create [flags] +``` + +### Examples + +``` + Create an authorized network in interactive mode: + $ ticloud serverless authorized-network create + + Create an authorized network in non-interactive mode: + $ ticloud serverless authorized-network create -c --display-name --ip-range +``` + +### Options + +``` + -c, --cluster-id string The ID of the cluster. + -n, --display-name string The name of the authorized network. + -h, --help help for create + --ip-range string The IP range of the authorized network. +``` + +### Options inherited from parent commands + +``` + -D, --debug Enable debug mode + --no-color Disable color output + -P, --profile string Profile to use from your configuration file +``` + +### SEE ALSO + +* [ticloud serverless authorized-network](ticloud_serverless_authorized-network.md) - Manage TiDB Cloud Serverless cluster authorized networks + diff --git a/docs/generate_doc/ticloud_serverless_authorized-network_delete.md b/docs/generate_doc/ticloud_serverless_authorized-network_delete.md new file mode 100644 index 00000000..8dafbbfc --- /dev/null +++ b/docs/generate_doc/ticloud_serverless_authorized-network_delete.md @@ -0,0 +1,39 @@ +## ticloud serverless authorized-network delete + +Delete an authorized network + +``` +ticloud serverless authorized-network delete [flags] +``` + +### Examples + +``` + Delete an authorized network in interactive mode: + $ ticloud serverless authorized-network delete + + Delete an authorized network in non-interactive mode: + $ ticloud serverless authorized-network delete -c --ip-range +``` + +### Options + +``` + -c, --cluster-id string The ID of the cluster. + --force Delete an authorized network without confirmation. + -h, --help help for delete + --ip-range string The IP range of the authorized network. +``` + +### Options inherited from parent commands + +``` + -D, --debug Enable debug mode + --no-color Disable color output + -P, --profile string Profile to use from your configuration file +``` + +### SEE ALSO + +* [ticloud serverless authorized-network](ticloud_serverless_authorized-network.md) - Manage TiDB Cloud Serverless cluster authorized networks + diff --git a/docs/generate_doc/ticloud_serverless_authorized-network_list.md b/docs/generate_doc/ticloud_serverless_authorized-network_list.md new file mode 100644 index 00000000..d69c7e13 --- /dev/null +++ b/docs/generate_doc/ticloud_serverless_authorized-network_list.md @@ -0,0 +1,41 @@ +## ticloud serverless authorized-network list + +List all authorized networks + +``` +ticloud serverless authorized-network list [flags] +``` + +### Examples + +``` + List all authorized networks in interactive mode: + $ ticloud serverless authorized-network list + + List all authorized networks in non-interactive mode: + $ ticloud serverless authorized-network list -c + + List all authorized networks with json format: + $ ticloud serverless authorized-network list -o json +``` + +### Options + +``` + -c, --cluster-id string The ID of the cluster. + -h, --help help for list + -o, --output string Output format, one of ["human" "json"]. For the complete result, please use json format. (default "human") +``` + +### Options inherited from parent commands + +``` + -D, --debug Enable debug mode + --no-color Disable color output + -P, --profile string Profile to use from your configuration file +``` + +### SEE ALSO + +* [ticloud serverless authorized-network](ticloud_serverless_authorized-network.md) - Manage TiDB Cloud Serverless cluster authorized networks + diff --git a/docs/generate_doc/ticloud_serverless_authorized-network_update.md b/docs/generate_doc/ticloud_serverless_authorized-network_update.md new file mode 100644 index 00000000..39e3f896 --- /dev/null +++ b/docs/generate_doc/ticloud_serverless_authorized-network_update.md @@ -0,0 +1,40 @@ +## ticloud serverless authorized-network update + +Update an authorized network + +``` +ticloud serverless authorized-network update [flags] +``` + +### Examples + +``` + Update an authorized network in interactive mode: + $ ticloud serverless authorized-network update + + Update an authorized network in non-interactive mode: + $ ticloud serverless authorized-network update -c --ip-range --display-name +``` + +### Options + +``` + -c, --cluster-id string The ID of the cluster. + -n, --display-name string The name of the authorized network. + -h, --help help for update + --ip-range string The new IP range of the authorized network. + --target-ip-range string The IP range of the authorized network to be updated. +``` + +### Options inherited from parent commands + +``` + -D, --debug Enable debug mode + --no-color Disable color output + -P, --profile string Profile to use from your configuration file +``` + +### SEE ALSO + +* [ticloud serverless authorized-network](ticloud_serverless_authorized-network.md) - Manage TiDB Cloud Serverless cluster authorized networks + diff --git a/docs/generate_doc/ticloud_serverless_create.md b/docs/generate_doc/ticloud_serverless_create.md index 2d5f3bfd..644d4b33 100644 --- a/docs/generate_doc/ticloud_serverless_create.md +++ b/docs/generate_doc/ticloud_serverless_create.md @@ -12,7 +12,7 @@ ticloud serverless create [flags] Create a TiDB Cloud Serverless cluster in interactive mode: $ ticloud serverless create - Create a TiDB Cloud Serverless cluster of the default ptoject in non-interactive mode: + Create a TiDB Cloud Serverless cluster of the default project in non-interactive mode: $ ticloud serverless create --display-name --region Create a TiDB Cloud Serverless cluster in non-interactive mode: @@ -22,6 +22,7 @@ ticloud serverless create [flags] ### Options ``` + --authorized-networks strings The authorized networks of the public endpoint. --disable-public-endpoint Whether the public endpoint is disabled. -n, --display-name string Display name of the cluster to de created. --encryption Whether Enhanced Encryption at Rest is enabled. From 2f3f5cf9bd599cf0af5a4913e04378c66e4531f7 Mon Sep 17 00:00:00 2001 From: FingerLeader Date: Wed, 9 Apr 2025 13:48:06 +0800 Subject: [PATCH 3/9] fix lint Signed-off-by: FingerLeader --- .../authorizednetwork/authorized_network.go | 2 -- .../serverless/authorizednetwork/delete.go | 26 ------------------- .../serverless/authorizednetwork/update.go | 2 +- internal/service/cloud/logic.go | 9 +++---- 4 files changed, 5 insertions(+), 34 deletions(-) diff --git a/internal/cli/serverless/authorizednetwork/authorized_network.go b/internal/cli/serverless/authorizednetwork/authorized_network.go index 08e6bd7d..66bf2b02 100644 --- a/internal/cli/serverless/authorizednetwork/authorized_network.go +++ b/internal/cli/serverless/authorizednetwork/authorized_network.go @@ -20,8 +20,6 @@ import ( "github.com/spf13/cobra" ) -type mutableField string - func AuthorizedNetworkCmd(h *internal.Helper) *cobra.Command { var authorizedNetworkCmd = &cobra.Command{ Use: "authorized-network", diff --git a/internal/cli/serverless/authorizednetwork/delete.go b/internal/cli/serverless/authorizednetwork/delete.go index dd86be0f..1cb25670 100644 --- a/internal/cli/serverless/authorizednetwork/delete.go +++ b/internal/cli/serverless/authorizednetwork/delete.go @@ -25,12 +25,10 @@ import ( "github.com/tidbcloud/tidbcloud-cli/internal/flag" "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" "github.com/tidbcloud/tidbcloud-cli/internal/telemetry" - "github.com/tidbcloud/tidbcloud-cli/internal/ui" "github.com/tidbcloud/tidbcloud-cli/internal/util" "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/cluster" - "github.com/charmbracelet/bubbles/textinput" "github.com/fatih/color" "github.com/juju/errors" "github.com/spf13/cobra" @@ -42,10 +40,6 @@ type DeleteOpts struct { interactive bool } -var deleteAuthorizedNetworkField = map[string]int{ - flag.IPRange: 0, -} - func (c DeleteOpts) NonInteractiveFlags() []string { return []string{ flag.ClusterID, @@ -219,23 +213,3 @@ func DeleteCmd(h *internal.Helper) *cobra.Command { return DeleteCmd } - -func initialDeleteInputModel() ui.TextInputModel { - m := ui.TextInputModel{ - Inputs: make([]textinput.Model, len(deleteAuthorizedNetworkField)), - } - - for k, v := range deleteAuthorizedNetworkField { - t := textinput.New() - t.Cursor.Style = config.CursorStyle - t.CharLimit = 32 - - switch k { - case flag.IPRange: - ipRangeExample := "0.0.0.0-255.255.255.255" - t.Placeholder = fmt.Sprintf("IP Range (e.g., %s)", ipRangeExample) - } - m.Inputs[v] = t - } - return m -} diff --git a/internal/cli/serverless/authorizednetwork/update.go b/internal/cli/serverless/authorizednetwork/update.go index c8c52d19..97e0d794 100644 --- a/internal/cli/serverless/authorizednetwork/update.go +++ b/internal/cli/serverless/authorizednetwork/update.go @@ -125,7 +125,7 @@ func UpdateCmd(h *internal.Helper) *cobra.Command { // variables for input fmt.Fprintln(h.IOStreams.Out, color.BlueString("Please input the following options")) - p := tea.NewProgram(initialCreateInputModel()) + p := tea.NewProgram(initialUpdateInputModel()) inputModel, err := p.Run() if err != nil { return errors.Trace(err) diff --git a/internal/service/cloud/logic.go b/internal/service/cloud/logic.go index 3dfb7bc9..db88c3cc 100644 --- a/internal/service/cloud/logic.go +++ b/internal/service/cloud/logic.go @@ -76,7 +76,7 @@ type Export struct { type AuthorizedNetwork struct { DisplayName string - IPRange string + IPRange string } func (a AuthorizedNetwork) String() string { @@ -859,7 +859,6 @@ func GetAllExportFiles(ctx context.Context, cID string, eID string, d TiDBCloudC return items, nil } - func RetrieveAuthorizedNetworks(ctx context.Context, clusterID string, d TiDBCloudClient) ([]cluster.EndpointsPublicAuthorizedNetwork, error) { cluster, err := d.GetCluster(ctx, clusterID, cluster.SERVERLESSSERVICEGETCLUSTERVIEWPARAMETER_BASIC) if err != nil { @@ -877,8 +876,8 @@ func GetSelectedAuthorizedNetwork(ctx context.Context, clusterID string, client var items = make([]interface{}, 0, len(authorizedNetworkItems)) for _, item := range authorizedNetworkItems { items = append(items, &AuthorizedNetwork{ - DisplayName: item.DisplayName, - IPRange: fmt.Sprintf("%s-%s", item.StartIpAddress, item.EndIpAddress), + DisplayName: item.DisplayName, + IPRange: fmt.Sprintf("%s-%s", item.StartIpAddress, item.EndIpAddress), }) } if len(items) == 0 { @@ -906,4 +905,4 @@ func GetSelectedAuthorizedNetwork(ctx context.Context, clusterID string, client return "", errors.New("no cluster selected") } return authorizedNetwork.(*AuthorizedNetwork).IPRange, nil -} \ No newline at end of file +} From 4546d3a82e3e93a3e681132c4ee3b93a0ca366b5 Mon Sep 17 00:00:00 2001 From: FingerLeader Date: Wed, 9 Apr 2025 13:50:09 +0800 Subject: [PATCH 4/9] upgrade golangci-lint Signed-off-by: FingerLeader --- .github/workflows/lint.yml | 2 +- Makefile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0913121c..c3a8dcf6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -30,7 +30,7 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v6 with: - version: v1.62.0 + version: v1.64.7 fmt: name: fmt diff --git a/Makefile b/Makefile index 5e87e414..d917e150 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -GOLANGCI_VERSION=v1.56.2 +GOLANGCI_VERSION=v1.64.7 COVERAGE=coverage.out .PHONY: deps From 7f2025adee077f119d4df0d5d6ed28624cae2bcd Mon Sep 17 00:00:00 2001 From: FingerLeader Date: Thu, 10 Apr 2025 20:43:06 +0800 Subject: [PATCH 5/9] refact Signed-off-by: FingerLeader --- ...ud_serverless_authorized-network_create.md | 11 +- ...ud_serverless_authorized-network_delete.md | 11 +- ...ud_serverless_authorized-network_update.md | 12 +- .../generate_doc/ticloud_serverless_create.md | 1 - .../serverless/authorizednetwork/create.go | 42 +++-- .../authorizednetwork/create_test.go | 168 ++++++++++++++++++ .../serverless/authorizednetwork/delete.go | 39 ++-- .../authorizednetwork/delete_test.go | 136 ++++++++++++++ .../serverless/authorizednetwork/list_test.go | 151 ++++++++++++++++ .../serverless/authorizednetwork/update.go | 115 ++++++++---- .../authorizednetwork/update_test.go | 141 +++++++++++++++ internal/cli/serverless/create.go | 26 +-- internal/flag/flag.go | 7 +- internal/service/cloud/logic.go | 28 +-- internal/util/authorized_network.go | 73 ++++---- 15 files changed, 801 insertions(+), 160 deletions(-) create mode 100644 internal/cli/serverless/authorizednetwork/create_test.go create mode 100644 internal/cli/serverless/authorizednetwork/delete_test.go create mode 100644 internal/cli/serverless/authorizednetwork/list_test.go create mode 100644 internal/cli/serverless/authorizednetwork/update_test.go diff --git a/docs/generate_doc/ticloud_serverless_authorized-network_create.md b/docs/generate_doc/ticloud_serverless_authorized-network_create.md index b4d446bb..694531eb 100644 --- a/docs/generate_doc/ticloud_serverless_authorized-network_create.md +++ b/docs/generate_doc/ticloud_serverless_authorized-network_create.md @@ -13,16 +13,17 @@ ticloud serverless authorized-network create [flags] $ ticloud serverless authorized-network create Create an authorized network in non-interactive mode: - $ ticloud serverless authorized-network create -c --display-name --ip-range + $ ticloud serverless authorized-network create -c --display-name --start-ip-address --end-ip-address ``` ### Options ``` - -c, --cluster-id string The ID of the cluster. - -n, --display-name string The name of the authorized network. - -h, --help help for create - --ip-range string The IP range of the authorized network. + -c, --cluster-id string The ID of the cluster. + -n, --display-name string The name of the authorized network. + --end-ip-address string The end IP address of the authorized network. + -h, --help help for create + --start-ip-address string The start IP address of the authorized network. ``` ### Options inherited from parent commands diff --git a/docs/generate_doc/ticloud_serverless_authorized-network_delete.md b/docs/generate_doc/ticloud_serverless_authorized-network_delete.md index 8dafbbfc..326f2e54 100644 --- a/docs/generate_doc/ticloud_serverless_authorized-network_delete.md +++ b/docs/generate_doc/ticloud_serverless_authorized-network_delete.md @@ -13,16 +13,17 @@ ticloud serverless authorized-network delete [flags] $ ticloud serverless authorized-network delete Delete an authorized network in non-interactive mode: - $ ticloud serverless authorized-network delete -c --ip-range + $ ticloud serverless authorized-network delete -c --start-ip-address --end-ip-address ``` ### Options ``` - -c, --cluster-id string The ID of the cluster. - --force Delete an authorized network without confirmation. - -h, --help help for delete - --ip-range string The IP range of the authorized network. + -c, --cluster-id string The ID of the cluster. + --end-ip-address string The end IP address of the authorized network. + --force Delete an authorized network without confirmation. + -h, --help help for delete + --start-ip-address string The start IP address of the authorized network. ``` ### Options inherited from parent commands diff --git a/docs/generate_doc/ticloud_serverless_authorized-network_update.md b/docs/generate_doc/ticloud_serverless_authorized-network_update.md index 39e3f896..804767c1 100644 --- a/docs/generate_doc/ticloud_serverless_authorized-network_update.md +++ b/docs/generate_doc/ticloud_serverless_authorized-network_update.md @@ -19,11 +19,13 @@ ticloud serverless authorized-network update [flags] ### Options ``` - -c, --cluster-id string The ID of the cluster. - -n, --display-name string The name of the authorized network. - -h, --help help for update - --ip-range string The new IP range of the authorized network. - --target-ip-range string The IP range of the authorized network to be updated. + -c, --cluster-id string The ID of the cluster. + --end-ip-address string The end IP address of the authorized network. + -h, --help help for update + --new-display-name string The new display name of the authorized network. + --new-end-ip-address string The new end IP address of the authorized network. + --new-start-ip-address string The new start IP address of the authorized network. + --start-ip-address string The start IP address of the authorized network. ``` ### Options inherited from parent commands diff --git a/docs/generate_doc/ticloud_serverless_create.md b/docs/generate_doc/ticloud_serverless_create.md index 644d4b33..c3525145 100644 --- a/docs/generate_doc/ticloud_serverless_create.md +++ b/docs/generate_doc/ticloud_serverless_create.md @@ -22,7 +22,6 @@ ticloud serverless create [flags] ### Options ``` - --authorized-networks strings The authorized networks of the public endpoint. --disable-public-endpoint Whether the public endpoint is disabled. -n, --display-name string Display name of the cluster to de created. --encryption Whether Enhanced Encryption at Rest is enabled. diff --git a/internal/cli/serverless/authorizednetwork/create.go b/internal/cli/serverless/authorizednetwork/create.go index 270ab9e4..25016d64 100644 --- a/internal/cli/serverless/authorizednetwork/create.go +++ b/internal/cli/serverless/authorizednetwork/create.go @@ -43,14 +43,16 @@ type CreateOpts struct { } var createAuthorizedNetworkField = map[string]int{ - flag.DisplayName: 0, - flag.IPRange: 1, + flag.DisplayName: 0, + flag.StartIPAddress: 1, + flag.EndIPAddress: 2, } func (c CreateOpts) NonInteractiveFlags() []string { return []string{ flag.ClusterID, - flag.IPRange, + flag.StartIPAddress, + flag.EndIPAddress, flag.DisplayName, } } @@ -58,7 +60,8 @@ func (c CreateOpts) NonInteractiveFlags() []string { func (c CreateOpts) RequiredFlags() []string { return []string{ flag.ClusterID, - flag.IPRange, + flag.StartIPAddress, + flag.EndIPAddress, } } @@ -76,7 +79,7 @@ func CreateCmd(h *internal.Helper) *cobra.Command { $ %[1]s serverless authorized-network create Create an authorized network in non-interactive mode: - $ %[1]s serverless authorized-network create -c --display-name --ip-range `, + $ %[1]s serverless authorized-network create -c --display-name --start-ip-address --end-ip-address `, config.CliName), PreRunE: func(cmd *cobra.Command, args []string) error { flags := opts.NonInteractiveFlags() @@ -107,7 +110,8 @@ func CreateCmd(h *internal.Helper) *cobra.Command { var clusterID string var displayName string - var ipRange string + var startIPAddress string + var endIPAddress string if opts.interactive { cmd.Annotations[telemetry.InteractiveMode] = "true" if !h.IOStreams.CanPrompt { @@ -140,7 +144,8 @@ func CreateCmd(h *internal.Helper) *cobra.Command { } displayName = inputModel.(ui.TextInputModel).Inputs[createAuthorizedNetworkField[flag.DisplayName]].Value() - ipRange = inputModel.(ui.TextInputModel).Inputs[createAuthorizedNetworkField[flag.IPRange]].Value() + startIPAddress = inputModel.(ui.TextInputModel).Inputs[createAuthorizedNetworkField[flag.StartIPAddress]].Value() + endIPAddress = inputModel.(ui.TextInputModel).Inputs[createAuthorizedNetworkField[flag.EndIPAddress]].Value() } else { // non-interactive mode doesn't need projectID @@ -154,17 +159,18 @@ func CreateCmd(h *internal.Helper) *cobra.Command { return errors.Trace(err) } - ipRange, err = cmd.Flags().GetString(flag.IPRange) + startIPAddress, err = cmd.Flags().GetString(flag.StartIPAddress) if err != nil { return errors.Trace(err) } - } - if displayName == "" { - displayName = util.GenerateIDAuthorizedNetworkDisplayName() + endIPAddress, err = cmd.Flags().GetString(flag.EndIPAddress) + if err != nil { + return errors.Trace(err) + } } - authorizedNetwork, err := util.ConvertToAuthorizedNetwork(ipRange, displayName) + authorizedNetwork, err := util.ConvertToAuthorizedNetwork(startIPAddress, endIPAddress, displayName) if err != nil { return errors.Trace(err) } @@ -192,7 +198,7 @@ func CreateCmd(h *internal.Helper) *cobra.Command { return errors.Trace(err) } - _, err = fmt.Fprintln(h.IOStreams.Out, color.GreenString("authorized network %s is created", displayName)) + _, err = fmt.Fprintln(h.IOStreams.Out, color.GreenString("authorized network %s-%s is created", startIPAddress, endIPAddress)) if err != nil { return err } @@ -201,7 +207,8 @@ func CreateCmd(h *internal.Helper) *cobra.Command { } CreateCmd.Flags().StringP(flag.ClusterID, flag.ClusterIDShort, "", "The ID of the cluster.") - CreateCmd.Flags().StringP(flag.IPRange, "", "", "The IP range of the authorized network.") + CreateCmd.Flags().StringP(flag.StartIPAddress, "", "", "The start IP address of the authorized network.") + CreateCmd.Flags().StringP(flag.EndIPAddress, "", "", "The end IP address of the authorized network.") CreateCmd.Flags().StringP(flag.DisplayName, flag.DisplayNameShort, "", "The name of the authorized network.") return CreateCmd @@ -223,9 +230,10 @@ func initialCreateInputModel() ui.TextInputModel { t.Focus() t.PromptStyle = config.FocusedStyle t.TextStyle = config.FocusedStyle - case flag.IPRange: - ipRangeExample := "0.0.0.0-255.255.255.255" - t.Placeholder = fmt.Sprintf("IP Range (e.g., %s)", ipRangeExample) + case flag.StartIPAddress: + t.Placeholder = "Start IP Address (e.g. 0.0.0.0)" + case flag.EndIPAddress: + t.Placeholder = "End IP Address (e.g. 255.255.255.255)" } m.Inputs[v] = t } diff --git a/internal/cli/serverless/authorizednetwork/create_test.go b/internal/cli/serverless/authorizednetwork/create_test.go new file mode 100644 index 00000000..bc957f6f --- /dev/null +++ b/internal/cli/serverless/authorizednetwork/create_test.go @@ -0,0 +1,168 @@ +// Copyright 2025 PingCAP, Inc. +// +// 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 authorizednetwork + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "testing" + + "github.com/juju/errors" + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/iostream" + "github.com/tidbcloud/tidbcloud-cli/internal/mock" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/cluster" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +const getClusterResultStr = `{ + "name": "clusters/123456", + "clusterId": "123456", + "displayName": "test", + "region": { + "name": "regions/aws-us-west-2", + "regionId": "us-west-2", + "cloudProvider": "aws", + "displayName": "Oregon (us-west-2)", + "provider": "aws" + }, + "endpoints": { + "public": { + "host": "gateway01.us-west-2.prod.aws.tidbcloud.com", + "port": 4000, + "disabled": false, + "authorizedNetworks": [ + { + "startIpAddress": "0.0.0.0", + "endIpAddress": "1.2.2.2", + "displayName": "123456" + } + ] + } + } +}` + +type CreateAuthorizedNetworkSuite struct { + suite.Suite + h *internal.Helper + mockClient *mock.TiDBCloudClient +} + +func (suite *CreateAuthorizedNetworkSuite) SetupTest() { + if err := os.Setenv("NO_COLOR", "true"); err != nil { + suite.T().Error(err) + } + + var pageSize int64 = 10 + suite.mockClient = new(mock.TiDBCloudClient) + suite.h = &internal.Helper{ + Client: func() (cloud.TiDBCloudClient, error) { + return suite.mockClient, nil + }, + QueryPageSize: pageSize, + IOStreams: iostream.Test(), + } +} + +func (suite *CreateAuthorizedNetworkSuite) TestCreateAuthorizedNetworkArgs() { + assert := require.New(suite.T()) + ctx := context.Background() + + clusterID := "123456" + + displayName := "test" + startIPAddress := "0.0.0.0" + endIPAddress := "1.1.1.1" + wrongIPAddress := "0.0.0.256" + + result := &cluster.TidbCloudOpenApiserverlessv1beta1Cluster{} + err := json.Unmarshal([]byte(getClusterResultStr), result) + assert.Nil(err) + + c := &cluster.RequiredTheClusterToBeUpdated{ + Endpoints: &cluster.V1beta1ClusterEndpoints{ + Public: &cluster.EndpointsPublic{ + AuthorizedNetworks: append(result.Endpoints.Public.AuthorizedNetworks, cluster.EndpointsPublicAuthorizedNetwork{ + StartIpAddress: startIPAddress, + EndIpAddress: endIPAddress, + DisplayName: displayName, + }), + }, + }, + } + + body := &cluster.V1beta1ServerlessServicePartialUpdateClusterBody{ + Cluster: c, + UpdateMask: AuthorizedNetworkMask, + } + + suite.mockClient.On("GetCluster", ctx, clusterID, cluster.SERVERLESSSERVICEGETCLUSTERVIEWPARAMETER_BASIC). + Return(result, nil) + suite.mockClient.On("PartialUpdateCluster", ctx, clusterID, body). + Return(&cluster.TidbCloudOpenApiserverlessv1beta1Cluster{}, nil) + + assert.Nil(err) + + tests := []struct { + name string + args []string + err error + stdoutString string + stderrString string + }{ + { + name: "create authorized network success", + args: []string{"--cluster-id", clusterID, "--display-name", displayName, "--start-ip-address", startIPAddress, "--end-ip-address", endIPAddress}, + stdoutString: fmt.Sprintf("authorized network %s-%s is created\n", startIPAddress, endIPAddress), + }, + { + name: "wrong ip range", + args: []string{"--cluster-id", clusterID, "--display-name", displayName, "--start-ip-address", startIPAddress, "--end-ip-address", wrongIPAddress}, + err: errors.New(fmt.Sprintf("invalid IPv4 address: %s", wrongIPAddress)), + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + cmd := CreateCmd(suite.h) + cmd.SetContext(ctx) + suite.h.IOStreams.Out.(*bytes.Buffer).Reset() + suite.h.IOStreams.Err.(*bytes.Buffer).Reset() + cmd.SetArgs(tt.args) + err := cmd.Execute() + + if err != nil { + assert.NotNil(tt.err) + assert.Contains(err.Error(), tt.err.Error()) + } + assert.Equal(tt.stdoutString, suite.h.IOStreams.Out.(*bytes.Buffer).String()) + assert.Equal(tt.stderrString, suite.h.IOStreams.Err.(*bytes.Buffer).String()) + + if tt.err == nil { + suite.mockClient.AssertExpectations(suite.T()) + } + }) + } +} + +func TestCreateAuthorizedNetworkSuite(t *testing.T) { + suite.Run(t, new(CreateAuthorizedNetworkSuite)) +} diff --git a/internal/cli/serverless/authorizednetwork/delete.go b/internal/cli/serverless/authorizednetwork/delete.go index 1cb25670..b9e1adf6 100644 --- a/internal/cli/serverless/authorizednetwork/delete.go +++ b/internal/cli/serverless/authorizednetwork/delete.go @@ -43,14 +43,16 @@ type DeleteOpts struct { func (c DeleteOpts) NonInteractiveFlags() []string { return []string{ flag.ClusterID, - flag.IPRange, + flag.StartIPAddress, + flag.EndIPAddress, } } func (c DeleteOpts) RequiredFlags() []string { return []string{ flag.ClusterID, - flag.IPRange, + flag.StartIPAddress, + flag.EndIPAddress, } } @@ -69,7 +71,7 @@ func DeleteCmd(h *internal.Helper) *cobra.Command { $ %[1]s serverless authorized-network delete Delete an authorized network in non-interactive mode: - $ %[1]s serverless authorized-network delete -c --ip-range `, + $ %[1]s serverless authorized-network delete -c --start-ip-address --end-ip-address `, config.CliName), PreRunE: func(cmd *cobra.Command, args []string) error { flags := opts.NonInteractiveFlags() @@ -99,8 +101,8 @@ func DeleteCmd(h *internal.Helper) *cobra.Command { } var clusterID string - var displayName string - var ipRange string + var startIPAddress string + var endIPAddress string if opts.interactive { cmd.Annotations[telemetry.InteractiveMode] = "true" if !h.IOStreams.CanPrompt { @@ -123,7 +125,7 @@ func DeleteCmd(h *internal.Helper) *cobra.Command { // variables for input fmt.Fprintln(h.IOStreams.Out, color.BlueString("Please input the following options")) - ipRange, err = cloud.GetSelectedAuthorizedNetwork(ctx, clusterID, d) + startIPAddress, endIPAddress, err = cloud.GetSelectedAuthorizedNetwork(ctx, clusterID, d) if err != nil { return err } @@ -134,7 +136,12 @@ func DeleteCmd(h *internal.Helper) *cobra.Command { return errors.Trace(err) } - ipRange, err = cmd.Flags().GetString(flag.IPRange) + startIPAddress, err = cmd.Flags().GetString(flag.StartIPAddress) + if err != nil { + return errors.Trace(err) + } + + endIPAddress, err = cmd.Flags().GetString(flag.EndIPAddress) if err != nil { return errors.Trace(err) } @@ -166,9 +173,9 @@ func DeleteCmd(h *internal.Helper) *cobra.Command { } } - authorizedNetwork, err := util.ConvertToAuthorizedNetwork(ipRange, displayName) - if err != nil { - return errors.Trace(err) + authorizedNetwork := cluster.EndpointsPublicAuthorizedNetwork{ + StartIpAddress: startIPAddress, + EndIpAddress: endIPAddress, } existedAuthorizedNetworks, err := cloud.RetrieveAuthorizedNetworks(ctx, clusterID, d) @@ -176,12 +183,19 @@ func DeleteCmd(h *internal.Helper) *cobra.Command { return errors.Trace(err) } + findTarget := false for i, v := range existedAuthorizedNetworks { if v.StartIpAddress == authorizedNetwork.StartIpAddress && v.EndIpAddress == authorizedNetwork.EndIpAddress { + findTarget = true existedAuthorizedNetworks = slices.Delete(existedAuthorizedNetworks, i, i+1) break } } + + if !findTarget { + return errors.New(fmt.Sprintf("authorized network %s-%s not found", startIPAddress, endIPAddress)) + } + body := &cluster.V1beta1ServerlessServicePartialUpdateClusterBody{ Cluster: &cluster.RequiredTheClusterToBeUpdated{ Endpoints: &cluster.V1beta1ClusterEndpoints{ @@ -198,7 +212,7 @@ func DeleteCmd(h *internal.Helper) *cobra.Command { return errors.Trace(err) } - _, err = fmt.Fprintln(h.IOStreams.Out, color.GreenString("authorized network %s is deleted", ipRange)) + _, err = fmt.Fprintln(h.IOStreams.Out, color.GreenString("authorized network %s-%s is deleted", startIPAddress, endIPAddress)) if err != nil { return err } @@ -209,7 +223,8 @@ func DeleteCmd(h *internal.Helper) *cobra.Command { DeleteCmd.Flags().BoolVar(&force, flag.Force, false, "Delete an authorized network without confirmation.") DeleteCmd.Flags().StringP(flag.ClusterID, flag.ClusterIDShort, "", "The ID of the cluster.") - DeleteCmd.Flags().StringP(flag.IPRange, "", "", "The IP range of the authorized network.") + DeleteCmd.Flags().StringP(flag.StartIPAddress, "", "", "The start IP address of the authorized network.") + DeleteCmd.Flags().StringP(flag.EndIPAddress, "", "", "The end IP address of the authorized network.") return DeleteCmd } diff --git a/internal/cli/serverless/authorizednetwork/delete_test.go b/internal/cli/serverless/authorizednetwork/delete_test.go new file mode 100644 index 00000000..42027fbc --- /dev/null +++ b/internal/cli/serverless/authorizednetwork/delete_test.go @@ -0,0 +1,136 @@ +// Copyright 2025 PingCAP, Inc. +// +// 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 authorizednetwork + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "testing" + + "github.com/juju/errors" + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/iostream" + "github.com/tidbcloud/tidbcloud-cli/internal/mock" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/cluster" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type DeleteAuthorizedNetworkSuite struct { + suite.Suite + h *internal.Helper + mockClient *mock.TiDBCloudClient +} + +func (suite *DeleteAuthorizedNetworkSuite) SetupTest() { + if err := os.Setenv("NO_COLOR", "true"); err != nil { + suite.T().Error(err) + } + + var pageSize int64 = 10 + suite.mockClient = new(mock.TiDBCloudClient) + suite.h = &internal.Helper{ + Client: func() (cloud.TiDBCloudClient, error) { + return suite.mockClient, nil + }, + QueryPageSize: pageSize, + IOStreams: iostream.Test(), + } +} + +func (suite *DeleteAuthorizedNetworkSuite) TestDeleteAuthorizedNetworkArgs() { + assert := require.New(suite.T()) + ctx := context.Background() + + clusterID := "123456" + + startIPAddress := "0.0.0.0" + endIPAddress := "1.2.2.2" + wrongIPAddress := "0.0.0.22" + + result := &cluster.TidbCloudOpenApiserverlessv1beta1Cluster{} + err := json.Unmarshal([]byte(getClusterResultStr), result) + assert.Nil(err) + + c := &cluster.RequiredTheClusterToBeUpdated{ + Endpoints: &cluster.V1beta1ClusterEndpoints{ + Public: &cluster.EndpointsPublic{ + AuthorizedNetworks: []cluster.EndpointsPublicAuthorizedNetwork{}, + }, + }, + } + + body := &cluster.V1beta1ServerlessServicePartialUpdateClusterBody{ + Cluster: c, + UpdateMask: AuthorizedNetworkMask, + } + + suite.mockClient.On("GetCluster", ctx, clusterID, cluster.SERVERLESSSERVICEGETCLUSTERVIEWPARAMETER_BASIC). + Return(result, nil) + suite.mockClient.On("PartialUpdateCluster", ctx, clusterID, body). + Return(&cluster.TidbCloudOpenApiserverlessv1beta1Cluster{}, nil) + + assert.Nil(err) + + tests := []struct { + name string + args []string + err error + stdoutString string + stderrString string + }{ + { + name: "delete authorized network success", + args: []string{"--cluster-id", clusterID, "--start-ip-address", startIPAddress, "--end-ip-address", endIPAddress, "--force"}, + stdoutString: fmt.Sprintf("authorized network %s-%s is deleted\n", startIPAddress, endIPAddress), + }, + { + name: "ip range does not exist", + args: []string{"--cluster-id", clusterID, "--start-ip-address", startIPAddress, "--end-ip-address", wrongIPAddress, "--force"}, + err: errors.New(fmt.Sprintf("authorized network %s-%s not found", startIPAddress, wrongIPAddress)), + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + cmd := DeleteCmd(suite.h) + cmd.SetContext(ctx) + suite.h.IOStreams.Out.(*bytes.Buffer).Reset() + suite.h.IOStreams.Err.(*bytes.Buffer).Reset() + cmd.SetArgs(tt.args) + err := cmd.Execute() + + if err != nil { + assert.NotNil(tt.err) + assert.Contains(err.Error(), tt.err.Error()) + } + assert.Equal(tt.stdoutString, suite.h.IOStreams.Out.(*bytes.Buffer).String()) + assert.Equal(tt.stderrString, suite.h.IOStreams.Err.(*bytes.Buffer).String()) + + if tt.err == nil { + suite.mockClient.AssertExpectations(suite.T()) + } + }) + } +} + +func TestDeleteAuthorizedNetworkSuite(t *testing.T) { + suite.Run(t, new(DeleteAuthorizedNetworkSuite)) +} diff --git a/internal/cli/serverless/authorizednetwork/list_test.go b/internal/cli/serverless/authorizednetwork/list_test.go new file mode 100644 index 00000000..f7bc5021 --- /dev/null +++ b/internal/cli/serverless/authorizednetwork/list_test.go @@ -0,0 +1,151 @@ +// Copyright 2025 PingCAP, Inc. +// +// 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 authorizednetwork + +import ( + "bytes" + "context" + "encoding/json" + "os" + "testing" + + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/iostream" + "github.com/tidbcloud/tidbcloud-cli/internal/mock" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/cluster" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +const listResultStr = `[ + { + "displayName": "123456", + "endIpAddress": "1.2.2.2", + "startIpAddress": "0.0.0.0" + }, + { + "displayName": "456789", + "endIpAddress": "3.3.3.3", + "startIpAddress": "0.0.0.0" + } +] +` + +const listClusterResultStr = `{ + "name": "clusters/123456", + "clusterId": "123456", + "displayName": "test", + "region": { + "name": "regions/aws-us-west-2", + "regionId": "us-west-2", + "cloudProvider": "aws", + "displayName": "Oregon (us-west-2)", + "provider": "aws" + }, + "endpoints": { + "public": { + "host": "gateway01.us-west-2.prod.aws.tidbcloud.com", + "port": 4000, + "disabled": false, + "authorizedNetworks": [ + { + "startIpAddress": "0.0.0.0", + "endIpAddress": "1.2.2.2", + "displayName": "123456" + }, + { + "startIpAddress": "0.0.0.0", + "endIpAddress": "3.3.3.3", + "displayName": "456789" + } + ] + } + } +}` + +type ListAuthorizedNetworkSuite struct { + suite.Suite + h *internal.Helper + mockClient *mock.TiDBCloudClient + pageSize int64 +} + +func (suite *ListAuthorizedNetworkSuite) SetupTest() { + if err := os.Setenv("NO_COLOR", "true"); err != nil { + suite.T().Error(err) + } + + suite.pageSize = 1 + suite.mockClient = new(mock.TiDBCloudClient) + suite.h = &internal.Helper{ + Client: func() (cloud.TiDBCloudClient, error) { + return suite.mockClient, nil + }, + QueryPageSize: suite.pageSize, + IOStreams: iostream.Test(), + } +} + +func (suite *ListAuthorizedNetworkSuite) TestListAuthorizedNetworkArgs() { + assert := require.New(suite.T()) + ctx := context.Background() + + clusterID := "12345" + + result := &cluster.TidbCloudOpenApiserverlessv1beta1Cluster{} + err := json.Unmarshal([]byte(listClusterResultStr), result) + assert.Nil(err) + + suite.mockClient.On("GetCluster", ctx, clusterID, cluster.SERVERLESSSERVICEGETCLUSTERVIEWPARAMETER_BASIC). + Return(result, nil) + + tests := []struct { + name string + args []string + err error + stdoutString string + stderrString string + }{ + { + name: "list authorized networks success", + args: []string{"--cluster-id", clusterID}, + stdoutString: listResultStr, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + cmd := ListCmd(suite.h) + cmd.SetContext(ctx) + suite.h.IOStreams.Out.(*bytes.Buffer).Reset() + suite.h.IOStreams.Err.(*bytes.Buffer).Reset() + cmd.SetArgs(tt.args) + err = cmd.Execute() + assert.Equal(tt.err, err) + + assert.Equal(tt.stdoutString, suite.h.IOStreams.Out.(*bytes.Buffer).String()) + assert.Equal(tt.stderrString, suite.h.IOStreams.Err.(*bytes.Buffer).String()) + if tt.err == nil { + suite.mockClient.AssertExpectations(suite.T()) + } + }) + } +} + +func TestListAuthorizedNetworkSuite(t *testing.T) { + suite.Run(t, new(ListAuthorizedNetworkSuite)) +} diff --git a/internal/cli/serverless/authorizednetwork/update.go b/internal/cli/serverless/authorizednetwork/update.go index 97e0d794..8c6c69f8 100644 --- a/internal/cli/serverless/authorizednetwork/update.go +++ b/internal/cli/serverless/authorizednetwork/update.go @@ -38,16 +38,19 @@ type UpdateOpts struct { } var updateAuthorizedNetworkField = map[string]int{ - flag.DisplayName: 0, - flag.IPRange: 1, + flag.NewDisplayName: 0, + flag.NewStartIPAddress: 1, + flag.NewEndIPAddress: 2, } func (c UpdateOpts) NonInteractiveFlags() []string { return []string{ flag.ClusterID, - flag.TargetIPRange, - flag.IPRange, - flag.DisplayName, + flag.NewDisplayName, + flag.NewStartIPAddress, + flag.NewEndIPAddress, + flag.StartIPAddress, + flag.EndIPAddress, } } @@ -56,7 +59,7 @@ func UpdateCmd(h *internal.Helper) *cobra.Command { interactive: true, } - var updateCmd = &cobra.Command{ + var UpdateCmd = &cobra.Command{ Use: "update", Short: "Update an authorized network", Args: cobra.NoArgs, @@ -80,11 +83,16 @@ func UpdateCmd(h *internal.Helper) *cobra.Command { if err != nil { return err } - err = cmd.MarkFlagRequired(flag.TargetIPRange) + err = cmd.MarkFlagRequired(flag.StartIPAddress) if err != nil { return err } - cmd.MarkFlagsOneRequired(flag.IPRange, flag.DisplayName) + err = cmd.MarkFlagRequired(flag.EndIPAddress) + if err != nil { + return err + } + cmd.MarkFlagsOneRequired(flag.NewStartIPAddress, flag.NewDisplayName) + cmd.MarkFlagsRequiredTogether(flag.NewStartIPAddress, flag.NewEndIPAddress) } return nil }, @@ -96,9 +104,11 @@ func UpdateCmd(h *internal.Helper) *cobra.Command { } var clusterID string - var displayName string - var ipRange string - var targetIPRange string + var startIPAddress string + var endIPAddress string + var newStartIPAddress string + var newEndIPAddress string + var newDisplayName string if opts.interactive { if !h.IOStreams.CanPrompt { return errors.New("The terminal doesn't support interactive mode, please use non-interactive mode") @@ -117,7 +127,7 @@ func UpdateCmd(h *internal.Helper) *cobra.Command { } clusterID = cluster.ID - targetIPRange, err = cloud.GetSelectedAuthorizedNetwork(ctx, clusterID, d) + startIPAddress, endIPAddress, err = cloud.GetSelectedAuthorizedNetwork(ctx, clusterID, d) if err != nil { return err } @@ -134,8 +144,16 @@ func UpdateCmd(h *internal.Helper) *cobra.Command { return util.InterruptError } - displayName = inputModel.(ui.TextInputModel).Inputs[createAuthorizedNetworkField[flag.DisplayName]].Value() - ipRange = inputModel.(ui.TextInputModel).Inputs[createAuthorizedNetworkField[flag.IPRange]].Value() + newDisplayName = inputModel.(ui.TextInputModel).Inputs[updateAuthorizedNetworkField[flag.NewDisplayName]].Value() + newStartIPAddress = inputModel.(ui.TextInputModel).Inputs[updateAuthorizedNetworkField[flag.NewStartIPAddress]].Value() + newEndIPAddress = inputModel.(ui.TextInputModel).Inputs[updateAuthorizedNetworkField[flag.NewEndIPAddress]].Value() + + if (newStartIPAddress == "" && newEndIPAddress != "") || (newStartIPAddress != "" && newEndIPAddress == "") { + return errors.New("both new start IP address and new end IP address must be provided") + } + if newStartIPAddress == "" && newDisplayName == "" { + return errors.New("at least one of new display name, new start IP address and new end IP address must be provided") + } } else { // non-interactive mode doesn't need projectID clusterID, err = cmd.Flags().GetString(flag.ClusterID) @@ -143,30 +161,30 @@ func UpdateCmd(h *internal.Helper) *cobra.Command { return errors.Trace(err) } - displayName, err = cmd.Flags().GetString(flag.DisplayName) + newDisplayName, err = cmd.Flags().GetString(flag.NewDisplayName) if err != nil { return errors.Trace(err) } - ipRange, err = cmd.Flags().GetString(flag.IPRange) + startIPAddress, err = cmd.Flags().GetString(flag.StartIPAddress) if err != nil { return errors.Trace(err) } - targetIPRange, err = cmd.Flags().GetString(flag.TargetIPRange) + endIPAddress, err = cmd.Flags().GetString(flag.EndIPAddress) if err != nil { return errors.Trace(err) } - } - authorizedNetwork, err := util.ConvertToAuthorizedNetwork(ipRange, displayName) - if err != nil { - return errors.Trace(err) - } + newStartIPAddress, err = cmd.Flags().GetString(flag.NewStartIPAddress) + if err != nil { + return errors.Trace(err) + } - targetAuthorizedNetwork, err := util.ConvertToAuthorizedNetwork(targetIPRange, "") - if err != nil { - return errors.Trace(err) + newEndIPAddress, err = cmd.Flags().GetString(flag.NewEndIPAddress) + if err != nil { + return errors.Trace(err) + } } existedAuthorizedNetworks, err := cloud.RetrieveAuthorizedNetworks(ctx, clusterID, d) @@ -174,14 +192,32 @@ func UpdateCmd(h *internal.Helper) *cobra.Command { return errors.Trace(err) } + findTarget := false for i, v := range existedAuthorizedNetworks { - if v.StartIpAddress == targetAuthorizedNetwork.StartIpAddress && v.EndIpAddress == targetAuthorizedNetwork.EndIpAddress { + if v.StartIpAddress == startIPAddress && v.EndIpAddress == endIPAddress { + findTarget = true existedAuthorizedNetworks = slices.Delete(existedAuthorizedNetworks, i, i+1) + if newDisplayName == "" { + newDisplayName = v.DisplayName + } + if newStartIPAddress == "" { + newStartIPAddress = v.StartIpAddress + newEndIPAddress = v.EndIpAddress + } break } } - authorizedNetworks := append(existedAuthorizedNetworks, authorizedNetwork) + if !findTarget { + return errors.New(fmt.Sprintf("authorized network %s-%s not found", startIPAddress, endIPAddress)) + } + + newAuthorizedNetwork, err := util.ConvertToAuthorizedNetwork(newStartIPAddress, newEndIPAddress, newDisplayName) + if err != nil { + return errors.Trace(err) + } + + authorizedNetworks := append(existedAuthorizedNetworks, newAuthorizedNetwork) body := &cluster.V1beta1ServerlessServicePartialUpdateClusterBody{ Cluster: &cluster.RequiredTheClusterToBeUpdated{ @@ -199,7 +235,7 @@ func UpdateCmd(h *internal.Helper) *cobra.Command { return errors.Trace(err) } - _, err = fmt.Fprintln(h.IOStreams.Out, color.GreenString("authorized network %s is updated", displayName)) + _, err = fmt.Fprintln(h.IOStreams.Out, color.GreenString("authorized network is updated")) if err != nil { return err } @@ -207,12 +243,14 @@ func UpdateCmd(h *internal.Helper) *cobra.Command { }, } - updateCmd.Flags().StringP(flag.ClusterID, flag.ClusterIDShort, "", "The ID of the cluster.") - updateCmd.Flags().StringP(flag.IPRange, "", "", "The new IP range of the authorized network.") - updateCmd.Flags().StringP(flag.DisplayName, flag.DisplayNameShort, "", "The name of the authorized network.") - updateCmd.Flags().StringP(flag.TargetIPRange, "", "", "The IP range of the authorized network to be updated.") + UpdateCmd.Flags().StringP(flag.ClusterID, flag.ClusterIDShort, "", "The ID of the cluster.") + UpdateCmd.Flags().StringP(flag.StartIPAddress, "", "", "The start IP address of the authorized network.") + UpdateCmd.Flags().StringP(flag.EndIPAddress, "", "", "The end IP address of the authorized network.") + UpdateCmd.Flags().StringP(flag.NewDisplayName, "", "", "The new display name of the authorized network.") + UpdateCmd.Flags().StringP(flag.NewStartIPAddress, "", "", "The new start IP address of the authorized network.") + UpdateCmd.Flags().StringP(flag.NewEndIPAddress, "", "", "The new end IP address of the authorized network.") - return updateCmd + return UpdateCmd } func initialUpdateInputModel() ui.TextInputModel { @@ -226,14 +264,15 @@ func initialUpdateInputModel() ui.TextInputModel { t.CharLimit = 32 switch k { - case flag.DisplayName: - t.Placeholder = "Display Name" + case flag.NewDisplayName: + t.Placeholder = "New Display Name (optional)" t.Focus() t.PromptStyle = config.FocusedStyle t.TextStyle = config.FocusedStyle - case flag.IPRange: - ipRangeExample := "0.0.0.0-255.255.255.255" - t.Placeholder = fmt.Sprintf("IP Range (e.g., %s)", ipRangeExample) + case flag.NewStartIPAddress: + t.Placeholder = "New Start IP Address(optional, e.g. 0.0.0.0)" + case flag.NewEndIPAddress: + t.Placeholder = "New End IP Address(optional, e.g. 255.255.255.255)" } m.Inputs[v] = t } diff --git a/internal/cli/serverless/authorizednetwork/update_test.go b/internal/cli/serverless/authorizednetwork/update_test.go new file mode 100644 index 00000000..eb848741 --- /dev/null +++ b/internal/cli/serverless/authorizednetwork/update_test.go @@ -0,0 +1,141 @@ +// Copyright 2025 PingCAP, Inc. +// +// 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 authorizednetwork + +import ( + "bytes" + "context" + "encoding/json" + "os" + "testing" + + "github.com/juju/errors" + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/iostream" + "github.com/tidbcloud/tidbcloud-cli/internal/mock" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" + "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/cluster" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type UpdateAuthorizedNetworkSuite struct { + suite.Suite + h *internal.Helper + mockClient *mock.TiDBCloudClient +} + +func (suite *UpdateAuthorizedNetworkSuite) SetupTest() { + if err := os.Setenv("NO_COLOR", "true"); err != nil { + suite.T().Error(err) + } + + var pageSize int64 = 10 + suite.mockClient = new(mock.TiDBCloudClient) + suite.h = &internal.Helper{ + Client: func() (cloud.TiDBCloudClient, error) { + return suite.mockClient, nil + }, + QueryPageSize: pageSize, + IOStreams: iostream.Test(), + } +} + +func (suite *UpdateAuthorizedNetworkSuite) TestUpdateAuthorizedNetworkArgs() { + assert := require.New(suite.T()) + ctx := context.Background() + + clusterID := "123456" + + displayName := "test" + startIPAddress := "0.0.0.0" + endIPAddress := "1.2.2.2" + + result := &cluster.TidbCloudOpenApiserverlessv1beta1Cluster{} + err := json.Unmarshal([]byte(getClusterResultStr), result) + assert.Nil(err) + + c := &cluster.RequiredTheClusterToBeUpdated{ + Endpoints: &cluster.V1beta1ClusterEndpoints{ + Public: &cluster.EndpointsPublic{ + AuthorizedNetworks: []cluster.EndpointsPublicAuthorizedNetwork{ + { + StartIpAddress: startIPAddress, + EndIpAddress: endIPAddress, + DisplayName: displayName, + }, + }, + }, + }, + } + + body := &cluster.V1beta1ServerlessServicePartialUpdateClusterBody{ + Cluster: c, + UpdateMask: AuthorizedNetworkMask, + } + + suite.mockClient.On("GetCluster", ctx, clusterID, cluster.SERVERLESSSERVICEGETCLUSTERVIEWPARAMETER_BASIC). + Return(result, nil) + suite.mockClient.On("PartialUpdateCluster", ctx, clusterID, body). + Return(&cluster.TidbCloudOpenApiserverlessv1beta1Cluster{}, nil) + + assert.Nil(err) + + tests := []struct { + name string + args []string + err error + stdoutString string + stderrString string + }{ + { + name: "update authorized network success", + args: []string{"--cluster-id", clusterID, "--start-ip-address", startIPAddress, "--end-ip-address", endIPAddress, "--new-display-name", displayName}, + stdoutString: "authorized network is updated\n", + }, + { + name: "does not set ip", + args: []string{"--cluster-id", clusterID, "--start-ip-address", startIPAddress, "--new-display-name", displayName}, + err: errors.New("required flag(s) \"end-ip-address\" not set"), + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + cmd := UpdateCmd(suite.h) + cmd.SetContext(ctx) + suite.h.IOStreams.Out.(*bytes.Buffer).Reset() + suite.h.IOStreams.Err.(*bytes.Buffer).Reset() + cmd.SetArgs(tt.args) + err := cmd.Execute() + + if err != nil { + assert.NotNil(tt.err) + assert.Contains(err.Error(), tt.err.Error()) + } + assert.Equal(tt.stdoutString, suite.h.IOStreams.Out.(*bytes.Buffer).String()) + assert.Equal(tt.stderrString, suite.h.IOStreams.Err.(*bytes.Buffer).String()) + + if tt.err == nil { + suite.mockClient.AssertExpectations(suite.T()) + } + }) + } +} + +func TestUpdateAuthorizedNetworkSuite(t *testing.T) { + suite.Run(t, new(UpdateAuthorizedNetworkSuite)) +} diff --git a/internal/cli/serverless/create.go b/internal/cli/serverless/create.go index 5829708e..cd5cf8a4 100644 --- a/internal/cli/serverless/create.go +++ b/internal/cli/serverless/create.go @@ -129,8 +129,6 @@ func CreateCmd(h *internal.Helper) *cobra.Command { var minRcu, maxRcu int32 var encryption bool var publicEndpointDisabled bool - var authorizedNetworksStrList []string - var authorizedNetworks []cluster.EndpointsPublicAuthorizedNetwork if opts.interactive { cmd.Annotations[telemetry.InteractiveMode] = "true" if !h.IOStreams.CanPrompt { @@ -353,14 +351,6 @@ func CreateCmd(h *internal.Helper) *cobra.Command { return errors.Trace(err) } } - authorizedNetworksStrList, err = cmd.Flags().GetStringSlice(flag.AuthorizedNetworks) - if err != nil { - return errors.Trace(err) - } - authorizedNetworks, err = util.ConvertToAuthorizedNetworks(authorizedNetworksStrList) - if err != nil { - return errors.Trace(err) - } } cmd.Annotations[telemetry.ProjectID] = projectID @@ -392,17 +382,12 @@ func CreateCmd(h *internal.Helper) *cobra.Command { } } - publicEndpoint := &cluster.EndpointsPublic{} if publicEndpointDisabled { - publicEndpoint.Disabled = &publicEndpointDisabled - } - - if len(authorizedNetworks) > 0 { - publicEndpoint.AuthorizedNetworks = authorizedNetworks - } - - v1Cluster.Endpoints = &cluster.V1beta1ClusterEndpoints{ - Public: publicEndpoint, + v1Cluster.Endpoints = &cluster.V1beta1ClusterEndpoints{ + Public: &cluster.EndpointsPublic{ + Disabled: &publicEndpointDisabled, + }, + } } if h.IOStreams.CanPrompt { @@ -429,7 +414,6 @@ func CreateCmd(h *internal.Helper) *cobra.Command { createCmd.Flags().Bool(flag.PublicEndpointDisabled, false, "Whether the public endpoint is disabled.") createCmd.Flags().Int32(flag.MinRCU, 0, "Minimum RCU for the cluster, at least 2000.") createCmd.Flags().Int32(flag.MaxRCU, 0, "Maximum RCU for the cluster, at most 100000.") - createCmd.Flags().StringSliceP(flag.AuthorizedNetworks, "", nil, "The authorized networks of the public endpoint.") createCmd.MarkFlagsMutuallyExclusive(flag.SpendingLimitMonthly, flag.MinRCU) createCmd.MarkFlagsMutuallyExclusive(flag.SpendingLimitMonthly, flag.MaxRCU) createCmd.MarkFlagsRequiredTogether(flag.MinRCU, flag.MaxRCU) diff --git a/internal/flag/flag.go b/internal/flag/flag.go index 8b414bda..3d8cb124 100644 --- a/internal/flag/flag.go +++ b/internal/flag/flag.go @@ -61,8 +61,11 @@ const ( MinRCU string = "min-rcu" MaxRCU string = "max-rcu" AuthorizedNetworks string = "authorized-networks" - IPRange string = "ip-range" - TargetIPRange string = "target-ip-range" + StartIPAddress string = "start-ip-address" + EndIPAddress string = "end-ip-address" + NewStartIPAddress string = "new-start-ip-address" + NewEndIPAddress string = "new-end-ip-address" + NewDisplayName string = "new-display-name" // External storage S3URI string = "s3.uri" S3AccessKeyID string = "s3.access-key-id" diff --git a/internal/service/cloud/logic.go b/internal/service/cloud/logic.go index db88c3cc..28c53427 100644 --- a/internal/service/cloud/logic.go +++ b/internal/service/cloud/logic.go @@ -75,12 +75,13 @@ type Export struct { } type AuthorizedNetwork struct { - DisplayName string - IPRange string + DisplayName string + StartIPAddress string + EndIPAddress string } func (a AuthorizedNetwork) String() string { - return fmt.Sprintf("%s(%s)", a.IPRange, a.DisplayName) + return fmt.Sprintf("%s-%s(%s)", a.StartIPAddress, a.EndIPAddress, a.DisplayName) } func (c ServerlessBackup) String() string { @@ -867,26 +868,27 @@ func RetrieveAuthorizedNetworks(ctx context.Context, clusterID string, d TiDBClo return cluster.Endpoints.Public.AuthorizedNetworks, nil } -func GetSelectedAuthorizedNetwork(ctx context.Context, clusterID string, client TiDBCloudClient) (string, error) { +func GetSelectedAuthorizedNetwork(ctx context.Context, clusterID string, client TiDBCloudClient) (string, string, error) { authorizedNetworkItems, err := RetrieveAuthorizedNetworks(ctx, clusterID, client) if err != nil { - return "", err + return "", "", err } var items = make([]interface{}, 0, len(authorizedNetworkItems)) for _, item := range authorizedNetworkItems { items = append(items, &AuthorizedNetwork{ - DisplayName: item.DisplayName, - IPRange: fmt.Sprintf("%s-%s", item.StartIpAddress, item.EndIpAddress), + DisplayName: item.DisplayName, + StartIPAddress: item.StartIpAddress, + EndIPAddress: item.EndIpAddress, }) } if len(items) == 0 { - return "", fmt.Errorf("no available authorized networks found") + return "", "", fmt.Errorf("no available authorized networks found") } model, err := ui.InitialSelectModel(items, "Choose the authorized network:") if err != nil { - return "", errors.Trace(err) + return "", "", errors.Trace(err) } itemsPerPage := 6 model.EnablePagination(itemsPerPage) @@ -895,14 +897,14 @@ func GetSelectedAuthorizedNetwork(ctx context.Context, clusterID string, client p := tea.NewProgram(model) authorizedNetworkModel, err := p.Run() if err != nil { - return "", errors.Trace(err) + return "", "", errors.Trace(err) } if m, _ := authorizedNetworkModel.(ui.SelectModel); m.Interrupted { - return "", util.InterruptError + return "", "", util.InterruptError } authorizedNetwork := authorizedNetworkModel.(ui.SelectModel).GetSelectedItem() if authorizedNetwork == nil { - return "", errors.New("no cluster selected") + return "", "", errors.New("no cluster selected") } - return authorizedNetwork.(*AuthorizedNetwork).IPRange, nil + return authorizedNetwork.(*AuthorizedNetwork).StartIPAddress, authorizedNetwork.(*AuthorizedNetwork).EndIPAddress, nil } diff --git a/internal/util/authorized_network.go b/internal/util/authorized_network.go index b200d097..0a7e4134 100644 --- a/internal/util/authorized_network.go +++ b/internal/util/authorized_network.go @@ -15,72 +15,63 @@ package util import ( - "encoding/base32" + "bytes" "fmt" "net" - "strings" "time" - "github.com/google/uuid" "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/cluster" ) -func ConvertToAuthorizedNetworks(authorizedNetworksStrList []string) ([]cluster.EndpointsPublicAuthorizedNetwork, error) { - authorizedNetworks := make([]cluster.EndpointsPublicAuthorizedNetwork, 0, len(authorizedNetworksStrList)) - for _, str := range authorizedNetworksStrList { - authorizedNetwork, err := ConvertToAuthorizedNetwork(str, "") - if err != nil { - return nil, fmt.Errorf("failed to convert authorized network %s: %w", str, err) - } - authorizedNetworks = append(authorizedNetworks, authorizedNetwork) - } - return authorizedNetworks, nil -} +// func ConvertToAuthorizedNetworks(authorizedNetworksStrList []string) ([]cluster.EndpointsPublicAuthorizedNetwork, error) { +// authorizedNetworks := make([]cluster.EndpointsPublicAuthorizedNetwork, 0, len(authorizedNetworksStrList)) +// for _, str := range authorizedNetworksStrList { +// authorizedNetwork, err := ConvertToAuthorizedNetwork(str, "") +// if err != nil { +// return nil, fmt.Errorf("failed to convert authorized network %s: %w", str, err) +// } +// authorizedNetworks = append(authorizedNetworks, authorizedNetwork) +// } +// return authorizedNetworks, nil +// } -func ConvertToAuthorizedNetwork(authorizedNetworksStr string, displayName string) (cluster.EndpointsPublicAuthorizedNetwork, error) { - startIP, endIP, err := parseIPv4Range(authorizedNetworksStr) +func ConvertToAuthorizedNetwork(startIP string, endIP string, displayName string) (cluster.EndpointsPublicAuthorizedNetwork, error) { + s, err := parseIPv4(startIP) if err != nil { return cluster.EndpointsPublicAuthorizedNetwork{}, err } + e, err := parseIPv4(endIP) + if err != nil { + return cluster.EndpointsPublicAuthorizedNetwork{}, err + } + + if bytes.Compare(s, e) == 1 { + return cluster.EndpointsPublicAuthorizedNetwork{}, fmt.Errorf("start IP address %s must not be greater than end IP address %s", startIP, endIP) + } if displayName == "" { displayName = GenerateIDAuthorizedNetworkDisplayName() } return cluster.EndpointsPublicAuthorizedNetwork{ - StartIpAddress: startIP.String(), - EndIpAddress: endIP.String(), + StartIpAddress: startIP, + EndIpAddress: endIP, DisplayName: displayName, }, nil } -func parseIPv4Range(ipRange string) (net.IP, net.IP, error) { - parts := strings.Split(ipRange, "-") - if len(parts) != 2 { - return nil, nil, fmt.Errorf("invalid IP range format, expected '{start-ip}-{end-ip}'") +func parseIPv4(ipStr string) (net.IP, error) { + ip := net.ParseIP(ipStr) + if ip == nil || ip.To4() == nil { + return nil, fmt.Errorf("invalid IPv4 address: %s", ipStr) } - startIPStr := strings.TrimSpace(parts[0]) - endIPStr := strings.TrimSpace(parts[1]) - - startIP := net.ParseIP(startIPStr) - if startIP == nil || startIP.To4() == nil { - return nil, nil, fmt.Errorf("invalid IPv4 start address: %s", startIPStr) - } - - endIP := net.ParseIP(endIPStr) - if endIP == nil || endIP.To4() == nil { - return nil, nil, fmt.Errorf("invalid IPv4 end address: %s", endIPStr) - } - - return startIP.To4(), endIP.To4(), nil + return ip.To4(), nil } -var base32Encoder = base32.StdEncoding.WithPadding(base32.NoPadding) - func GenerateIDAuthorizedNetworkDisplayName() string { - uuidV4 := uuid.New() now := time.Now() - dateStr := now.Format("20060102") - return "Allowlist_" + dateStr + "_" + strings.ToLower(base32Encoder.EncodeToString(uuidV4[:])) + timeStr := now.Format("2006_0102_1504") + micros := now.Nanosecond() / 1e3 + return fmt.Sprintf("Allowlist_%s_%d", timeStr, micros) } From 25ca19f29b561180db5384db550719576037b106 Mon Sep 17 00:00:00 2001 From: FingerLeader Date: Thu, 10 Apr 2025 20:44:20 +0800 Subject: [PATCH 6/9] refact Signed-off-by: FingerLeader --- internal/cli/serverless/authorizednetwork/authorized_network.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/cli/serverless/authorizednetwork/authorized_network.go b/internal/cli/serverless/authorizednetwork/authorized_network.go index 66bf2b02..a0452fed 100644 --- a/internal/cli/serverless/authorizednetwork/authorized_network.go +++ b/internal/cli/serverless/authorizednetwork/authorized_network.go @@ -24,7 +24,6 @@ func AuthorizedNetworkCmd(h *internal.Helper) *cobra.Command { var authorizedNetworkCmd = &cobra.Command{ Use: "authorized-network", Short: "Manage TiDB Cloud Serverless cluster authorized networks", - Aliases: []string{"user"}, } authorizedNetworkCmd.AddCommand(CreateCmd(h)) From 33b516a85e5c25d66306e02a1d98d7a35e4f51ce Mon Sep 17 00:00:00 2001 From: FingerLeader Date: Thu, 10 Apr 2025 20:46:28 +0800 Subject: [PATCH 7/9] fix-lint Signed-off-by: FingerLeader --- .../cli/serverless/authorizednetwork/authorized_network.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cli/serverless/authorizednetwork/authorized_network.go b/internal/cli/serverless/authorizednetwork/authorized_network.go index a0452fed..afc6298d 100644 --- a/internal/cli/serverless/authorizednetwork/authorized_network.go +++ b/internal/cli/serverless/authorizednetwork/authorized_network.go @@ -22,8 +22,8 @@ import ( func AuthorizedNetworkCmd(h *internal.Helper) *cobra.Command { var authorizedNetworkCmd = &cobra.Command{ - Use: "authorized-network", - Short: "Manage TiDB Cloud Serverless cluster authorized networks", + Use: "authorized-network", + Short: "Manage TiDB Cloud Serverless cluster authorized networks", } authorizedNetworkCmd.AddCommand(CreateCmd(h)) From 4f80d7693c2fda10021f3a4ecee16454a37f70ee Mon Sep 17 00:00:00 2001 From: FingerLeader Date: Thu, 10 Apr 2025 20:50:53 +0800 Subject: [PATCH 8/9] go mod tidy Signed-off-by: FingerLeader --- go.mod | 1 - 1 file changed, 1 deletion(-) diff --git a/go.mod b/go.mod index af2afa90..7f82f293 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,6 @@ require ( github.com/go-resty/resty/v2 v2.11.0 github.com/go-sql-driver/mysql v1.8.1 github.com/google/go-github/v49 v49.0.0 - github.com/google/uuid v1.6.0 github.com/hashicorp/go-version v1.6.0 github.com/icholy/digest v0.1.22 github.com/juju/errors v1.0.0 From 9416b529ee4ec3b309b45252e2c88eae2d9ea70a Mon Sep 17 00:00:00 2001 From: FingerLeader Date: Thu, 10 Apr 2025 22:28:02 +0800 Subject: [PATCH 9/9] resolve comments Signed-off-by: FingerLeader --- internal/cli/serverless/authorizednetwork/update.go | 7 +++---- internal/flag/flag.go | 1 - internal/service/cloud/logic.go | 3 +-- internal/util/authorized_network.go | 12 ------------ 4 files changed, 4 insertions(+), 19 deletions(-) diff --git a/internal/cli/serverless/authorizednetwork/update.go b/internal/cli/serverless/authorizednetwork/update.go index 8c6c69f8..93dd096c 100644 --- a/internal/cli/serverless/authorizednetwork/update.go +++ b/internal/cli/serverless/authorizednetwork/update.go @@ -67,7 +67,8 @@ func UpdateCmd(h *internal.Helper) *cobra.Command { $ %[1]s serverless authorized-network update Update an authorized network in non-interactive mode: - $ %[1]s serverless authorized-network update -c --ip-range --display-name `, config.CliName), + $ %[1]s serverless authorized-network update -c --start-ip-address --end-ip-address --new-start-ip-address --new-end-ip-address `, + config.CliName), PreRunE: func(cmd *cobra.Command, args []string) error { flags := opts.NonInteractiveFlags() for _, fn := range flags { @@ -91,7 +92,7 @@ func UpdateCmd(h *internal.Helper) *cobra.Command { if err != nil { return err } - cmd.MarkFlagsOneRequired(flag.NewStartIPAddress, flag.NewDisplayName) + cmd.MarkFlagsOneRequired(flag.NewStartIPAddress, flag.NewEndIPAddress, flag.NewDisplayName) cmd.MarkFlagsRequiredTogether(flag.NewStartIPAddress, flag.NewEndIPAddress) } return nil @@ -133,8 +134,6 @@ func UpdateCmd(h *internal.Helper) *cobra.Command { } // variables for input - fmt.Fprintln(h.IOStreams.Out, color.BlueString("Please input the following options")) - p := tea.NewProgram(initialUpdateInputModel()) inputModel, err := p.Run() if err != nil { diff --git a/internal/flag/flag.go b/internal/flag/flag.go index 3d8cb124..9a522c39 100644 --- a/internal/flag/flag.go +++ b/internal/flag/flag.go @@ -60,7 +60,6 @@ const ( BackupTime string = "backup-time" MinRCU string = "min-rcu" MaxRCU string = "max-rcu" - AuthorizedNetworks string = "authorized-networks" StartIPAddress string = "start-ip-address" EndIPAddress string = "end-ip-address" NewStartIPAddress string = "new-start-ip-address" diff --git a/internal/service/cloud/logic.go b/internal/service/cloud/logic.go index 28c53427..68ba3c56 100644 --- a/internal/service/cloud/logic.go +++ b/internal/service/cloud/logic.go @@ -892,7 +892,6 @@ func GetSelectedAuthorizedNetwork(ctx context.Context, clusterID string, client } itemsPerPage := 6 model.EnablePagination(itemsPerPage) - model.EnableFilter() p := tea.NewProgram(model) authorizedNetworkModel, err := p.Run() @@ -904,7 +903,7 @@ func GetSelectedAuthorizedNetwork(ctx context.Context, clusterID string, client } authorizedNetwork := authorizedNetworkModel.(ui.SelectModel).GetSelectedItem() if authorizedNetwork == nil { - return "", "", errors.New("no cluster selected") + return "", "", errors.New("no authorized network selected") } return authorizedNetwork.(*AuthorizedNetwork).StartIPAddress, authorizedNetwork.(*AuthorizedNetwork).EndIPAddress, nil } diff --git a/internal/util/authorized_network.go b/internal/util/authorized_network.go index 0a7e4134..6210c8b6 100644 --- a/internal/util/authorized_network.go +++ b/internal/util/authorized_network.go @@ -23,18 +23,6 @@ import ( "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/cluster" ) -// func ConvertToAuthorizedNetworks(authorizedNetworksStrList []string) ([]cluster.EndpointsPublicAuthorizedNetwork, error) { -// authorizedNetworks := make([]cluster.EndpointsPublicAuthorizedNetwork, 0, len(authorizedNetworksStrList)) -// for _, str := range authorizedNetworksStrList { -// authorizedNetwork, err := ConvertToAuthorizedNetwork(str, "") -// if err != nil { -// return nil, fmt.Errorf("failed to convert authorized network %s: %w", str, err) -// } -// authorizedNetworks = append(authorizedNetworks, authorizedNetwork) -// } -// return authorizedNetworks, nil -// } - func ConvertToAuthorizedNetwork(startIP string, endIP string, displayName string) (cluster.EndpointsPublicAuthorizedNetwork, error) { s, err := parseIPv4(startIP) if err != nil {