diff --git a/src/go/rpk/pkg/cli/cmd/cloud.go b/src/go/rpk/pkg/cli/cmd/cloud.go index 662c2c2e56bf..8700a19fe882 100644 --- a/src/go/rpk/pkg/cli/cmd/cloud.go +++ b/src/go/rpk/pkg/cli/cmd/cloud.go @@ -24,6 +24,7 @@ func NewCloudCommand(fs afero.Fs) *cobra.Command { command.AddCommand(cloud.NewLoginCommand(fs)) command.AddCommand(cloud.NewLogoutCommand(fs)) + command.AddCommand(cloud.NewGetCommand(fs)) return command } diff --git a/src/go/rpk/pkg/cli/cmd/cloud/clusters.go b/src/go/rpk/pkg/cli/cmd/cloud/clusters.go new file mode 100644 index 000000000000..2d2a50d6a4f5 --- /dev/null +++ b/src/go/rpk/pkg/cli/cmd/cloud/clusters.go @@ -0,0 +1,81 @@ +// Copyright 2021 Vectorized, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package cloud + +import ( + "errors" + "io" + "strconv" + + log "github.com/sirupsen/logrus" + "github.com/spf13/afero" + "github.com/spf13/cobra" + "github.com/vectorizedio/redpanda/src/go/rpk/pkg/vcloud/config" + "github.com/vectorizedio/redpanda/src/go/rpk/pkg/vcloud/ui" + "github.com/vectorizedio/redpanda/src/go/rpk/pkg/vcloud/yak" +) + +func NewClustersCommand(fs afero.Fs) *cobra.Command { + var ( + namespaceName string + ) + command := &cobra.Command{ + Use: "clusters", + Short: "Get clusters in given namespace", + Long: `List clusters that you have created in your namespace in your vectorized cloud organization.`, + RunE: func(cmd *cobra.Command, args []string) error { + if namespaceName == "" { + return errors.New("please provide --namespace flag") + } + yakClient := yak.NewYakClient(config.NewVCloudConfigReaderWriter(fs)) + return GetClusters(yakClient, log.StandardLogger().Out, namespaceName) + }, + } + + command.Flags().StringVarP( + &namespaceName, + "namespace", + "n", + "", + "Namespace name from your vectorized cloud organization", + ) + + return command +} + +func GetClusters( + c yak.CloudApiClient, out io.Writer, namespaceName string, +) error { + clusters, err := c.GetClusters(namespaceName) + if _, ok := err.(yak.ErrLoginTokenMissing); ok { + log.Info("Please run `rpk cloud login` first. ") + return err + } + + if err != nil { + return err + } + + printFormattedClusters(clusters, out) + return nil +} + +func printFormattedClusters(clusters []*yak.Cluster, out io.Writer) { + t := ui.NewVcloudTable(out) + t.SetHeader([]string{"id", "name", "ready"}) + for _, c := range clusters { + t.Append([]string{ + c.Id, + c.Name, + strconv.FormatBool(c.Ready), + }) + } + t.Render() +} diff --git a/src/go/rpk/pkg/cli/cmd/cloud/clusters_test.go b/src/go/rpk/pkg/cli/cmd/cloud/clusters_test.go new file mode 100644 index 000000000000..80a0b9cf0a17 --- /dev/null +++ b/src/go/rpk/pkg/cli/cmd/cloud/clusters_test.go @@ -0,0 +1,67 @@ +// Copyright 2021 Vectorized, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package cloud_test + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/sirupsen/logrus" + "github.com/vectorizedio/redpanda/src/go/rpk/pkg/cli/cmd/cloud" + "github.com/vectorizedio/redpanda/src/go/rpk/pkg/vcloud/yak" +) + +func TestClusters(t *testing.T) { + tests := []struct { + name string + client yak.CloudApiClient + expectedOutput []string + expectedError string + }{ + { + "success", + &mockYakClient{}, + []string{"notready", "false"}, + "", + }, + { + "not logged in", + &erroredYakClient{yak.ErrLoginTokenMissing{errors.New("inner")}}, + []string{"rpk cloud login"}, + "retrieving login token", + }, + { + "generic client error", + &erroredYakClient{errors.New("other error")}, + []string{}, + "other error", + }, + } + + for _, tt := range tests { + var buf bytes.Buffer + logrus.SetOutput(&buf) + err := cloud.GetClusters(tt.client, &buf, "ns") + if len(tt.expectedOutput) > 0 { + for _, s := range tt.expectedOutput { + if !strings.Contains(buf.String(), s) { + t.Errorf("%s: expecting string %s in output %s", tt.name, s, buf.String()) + } + } + } + if tt.expectedError != "" { + if err == nil || !strings.Contains(err.Error(), tt.expectedError) { + t.Errorf("%s: expecting error %s got %v", tt.name, tt.expectedError, err) + } + } + } +} diff --git a/src/go/rpk/pkg/cli/cmd/cloud/get.go b/src/go/rpk/pkg/cli/cmd/cloud/get.go new file mode 100644 index 000000000000..4409f7e15bdf --- /dev/null +++ b/src/go/rpk/pkg/cli/cmd/cloud/get.go @@ -0,0 +1,28 @@ +// Copyright 2021 Vectorized, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package cloud + +import ( + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +func NewGetCommand(fs afero.Fs) *cobra.Command { + command := &cobra.Command{ + Use: "get", + Short: "Get resource from Vectorized cloud", + Hidden: true, + } + + command.AddCommand(NewNamespacesCommand(fs)) + command.AddCommand(NewClustersCommand(fs)) + + return command +} diff --git a/src/go/rpk/pkg/cli/cmd/cloud/namespaces.go b/src/go/rpk/pkg/cli/cmd/cloud/namespaces.go new file mode 100644 index 000000000000..c2e57222f4db --- /dev/null +++ b/src/go/rpk/pkg/cli/cmd/cloud/namespaces.go @@ -0,0 +1,63 @@ +// Copyright 2021 Vectorized, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package cloud + +import ( + "fmt" + "io" + + "github.com/sirupsen/logrus" + log "github.com/sirupsen/logrus" + "github.com/spf13/afero" + "github.com/spf13/cobra" + "github.com/vectorizedio/redpanda/src/go/rpk/pkg/vcloud/config" + "github.com/vectorizedio/redpanda/src/go/rpk/pkg/vcloud/ui" + "github.com/vectorizedio/redpanda/src/go/rpk/pkg/vcloud/yak" +) + +func NewNamespacesCommand(fs afero.Fs) *cobra.Command { + return &cobra.Command{ + Use: "namespaces", + Aliases: []string{"ns"}, + Short: "Get namespaces in your vectorized cloud", + Long: `List namespaces that you have created in your vectorized cloud organization.`, + RunE: func(cmd *cobra.Command, args []string) error { + yakClient := yak.NewYakClient(config.NewVCloudConfigReaderWriter(fs)) + return GetNamespaces(yakClient, logrus.StandardLogger().Out) + }, + } +} + +func GetNamespaces(c yak.CloudApiClient, out io.Writer) error { + ns, err := c.GetNamespaces() + if _, ok := err.(yak.ErrLoginTokenMissing); ok { + log.Info("Please run `rpk cloud login` first. ") + return err + } + if err != nil { + return err + } + + printFormatted(ns, out) + return nil +} + +func printFormatted(ns []*yak.Namespace, out io.Writer) { + t := ui.NewVcloudTable(out) + t.SetHeader([]string{"id", "name", "clusters"}) + for _, n := range ns { + t.Append([]string{ + n.Id, + n.Name, + fmt.Sprint(len(n.ClusterIds)), + }) + } + t.Render() +} diff --git a/src/go/rpk/pkg/cli/cmd/cloud/namespaces_test.go b/src/go/rpk/pkg/cli/cmd/cloud/namespaces_test.go new file mode 100644 index 000000000000..25612f987512 --- /dev/null +++ b/src/go/rpk/pkg/cli/cmd/cloud/namespaces_test.go @@ -0,0 +1,108 @@ +// Copyright 2021 Vectorized, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package cloud_test + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/sirupsen/logrus" + "github.com/vectorizedio/redpanda/src/go/rpk/pkg/cli/cmd/cloud" + "github.com/vectorizedio/redpanda/src/go/rpk/pkg/vcloud/yak" +) + +func TestNamespaces(t *testing.T) { + tests := []struct { + name string + client yak.CloudApiClient + expectedOutput []string + expectedError string + }{ + { + name: "success", + client: &mockYakClient{}, + expectedOutput: []string{"test", "2"}, + expectedError: "", + }, + { + name: "not logged in", + client: &erroredYakClient{yak.ErrLoginTokenMissing{errors.New("inner")}}, + expectedOutput: []string{"rpk cloud login"}, + expectedError: "retrieving login token", + }, + { + name: "generic client error", + client: &erroredYakClient{errors.New("other error")}, + expectedOutput: []string{}, + expectedError: "other error", + }, + } + + for _, tt := range tests { + var buf bytes.Buffer + logrus.SetOutput(&buf) + err := cloud.GetNamespaces(tt.client, &buf) + if len(tt.expectedOutput) > 0 { + for _, s := range tt.expectedOutput { + if !strings.Contains(buf.String(), s) { + t.Errorf("%s: expecting string %s in output %s", tt.name, s, buf.String()) + } + } + } + if tt.expectedError != "" { + if err == nil || !strings.Contains(err.Error(), tt.expectedError) { + t.Errorf("%s: expecting error %s got %v", tt.name, tt.expectedError, err) + } + } + } +} + +// Yak client returning fixed mocked responses +type mockYakClient struct { +} + +func (yc *mockYakClient) GetNamespaces() ([]*yak.Namespace, error) { + return []*yak.Namespace{ + { + Id: "test", + Name: "test", + ClusterIds: []string{"1", "2"}, + }, + }, nil +} + +func (yc *mockYakClient) GetClusters( + namespaceName string, +) ([]*yak.Cluster, error) { + return []*yak.Cluster{ + { + Id: "notready", + Name: "notready", + Ready: false, + }, + }, nil +} + +// Yak client returning error provided on creation +type erroredYakClient struct { + err error +} + +func (yc *erroredYakClient) GetNamespaces() ([]*yak.Namespace, error) { + return nil, yc.err +} + +func (yc *erroredYakClient) GetClusters( + namespaceName string, +) ([]*yak.Cluster, error) { + return nil, yc.err +} diff --git a/src/go/rpk/pkg/vcloud/ui/tables.go b/src/go/rpk/pkg/vcloud/ui/tables.go new file mode 100644 index 000000000000..3b8c963eddbd --- /dev/null +++ b/src/go/rpk/pkg/vcloud/ui/tables.go @@ -0,0 +1,28 @@ +// Copyright 2020 Vectorized, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package ui + +import ( + "io" + + "github.com/olekukonko/tablewriter" +) + +func NewVcloudTable(writer io.Writer) *tablewriter.Table { + table := tablewriter.NewWriter(writer) + table.SetBorder(false) + table.SetColumnSeparator("") + table.SetHeaderLine(false) + table.SetColWidth(80) + table.SetAutoWrapText(true) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + return table +} diff --git a/src/go/rpk/pkg/vcloud/yak/api.go b/src/go/rpk/pkg/vcloud/yak/api.go new file mode 100644 index 000000000000..1f28a3e8e958 --- /dev/null +++ b/src/go/rpk/pkg/vcloud/yak/api.go @@ -0,0 +1,65 @@ +// Copyright 2021 Vectorized, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package yak + +import ( + "errors" + "fmt" +) + +type CloudApiClient interface { + // GetNamespaces returns list of all namespaces available in the user's + // organization. Returns `ErrLoginTokenMissing` if token cannot be + // retrieved. Returns `ErrNotAuthorized` if user is not authorized to list + // namespaces. + GetNamespaces() ([]*Namespace, error) + // GetClusters lists all redpanda clusters available in given namespace. + // Returns `ErrLoginTokenMissing` if token cannot be retrieved. Returns + // `ErrNotAuthorized` if user is not authorized to list clusters. Returns + // `ErrNamespaceDoesNotExists` if namespace of given name was not + // found. + GetClusters(namespaceName string) ([]*Cluster, error) +} + +type Namespace struct { + Id string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + ClusterIds []string `json:"clusterIds,omitempty"` +} + +// Cluster is definition of redpanda cluster +type Cluster struct { + Id string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Hosts []string `json:"hosts,omitempty"` + Ready bool `json:"ready,omitempty"` + Provider string `json:"provider,omitempty"` + Region string `json:"region,omitempty"` +} + +type ErrLoginTokenMissing struct { + InnerError error +} + +func (e ErrLoginTokenMissing) Error() string { + return fmt.Sprintf("Error retrieving login token: %v", e.InnerError) +} + +type ErrNamespaceDoesNotExist struct { + Name string +} + +func (e ErrNamespaceDoesNotExist) Error() string { + return fmt.Sprintf("Namespace %s does not exist", e.Name) +} + +var ( + ErrNotAuthorized = errors.New("User is not authorized to view this resource") +) diff --git a/src/go/rpk/pkg/vcloud/yak/client.go b/src/go/rpk/pkg/vcloud/yak/client.go new file mode 100644 index 000000000000..986720e170b8 --- /dev/null +++ b/src/go/rpk/pkg/vcloud/yak/client.go @@ -0,0 +1,135 @@ +// Copyright 2021 Vectorized, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package yak + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + log "github.com/sirupsen/logrus" + "github.com/vectorizedio/redpanda/src/go/rpk/pkg/vcloud/config" +) + +type YakClient struct { + conf config.ConfigReaderWriter +} + +const ( + namespaceRoute = "namespace" + // requires namespaceId + getClustersRouteTemplate = namespaceRoute + "/%s/cluster" + + // TODO(av) make this configurable and point it to production + DefaultYakUrl = "https://backend.dev.vectorized.cloud" +) + +func NewYakClient(conf config.ConfigReaderWriter) CloudApiClient { + return &YakClient{ + conf: conf, + } +} + +func (yc *YakClient) GetNamespaces() ([]*Namespace, error) { + token, err := yc.conf.ReadToken() + if err != nil { + return nil, ErrLoginTokenMissing{err} + } + url := fmt.Sprintf("%s/%s", DefaultYakUrl, namespaceRoute) + _, body, err := yc.get(token, url) + if err != nil { + return nil, fmt.Errorf("error calling api: %w", err) + } + + var namespaces []*Namespace + err = json.Unmarshal(body, &namespaces) + if err != nil { + return nil, fmt.Errorf("error unmarshaling response. %w", err) + } + return namespaces, nil +} + +func (yc *YakClient) GetClusters(namespaceName string) ([]*Cluster, error) { + token, err := yc.conf.ReadToken() + if err != nil { + return nil, ErrLoginTokenMissing{err} + } + // map namespace name to namespace id + namespaceId, err := yc.getNamespaceId(namespaceName) + if err != nil { + return nil, err + } + // retrieve clusters + url := fmt.Sprintf("%s/%s", DefaultYakUrl, fmt.Sprintf(getClustersRouteTemplate, namespaceId)) + _, body, err := yc.get(token, url) + if err != nil { + return nil, fmt.Errorf("error calling api: %w", err) + } + + var clusters []*Cluster + err = json.Unmarshal(body, &clusters) + if err != nil { + return nil, fmt.Errorf("error unmarshaling response. %w", err) + } + return clusters, nil +} + +func (yc *YakClient) get(token, url string) (*http.Response, []byte, error) { + log.Debugf("Calling yak api on url %s", url) + req, err := http.NewRequest(http.MethodGet, url, bytes.NewBuffer(nil)) + if err != nil { + return nil, nil, fmt.Errorf("error creating new request. %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, nil, err + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("error reading response body: %w", err) + } + err = yc.handleErrResponseCodes(resp, body) + if err != nil { + return resp, body, err + } + return resp, body, nil +} + +func (yc *YakClient) handleErrResponseCodes( + resp *http.Response, body []byte, +) error { + // targetting HTTP codes 401, 403 which are used by yak + if resp.StatusCode > 400 && resp.StatusCode < 404 { + log.Debug(string(body)) + return ErrNotAuthorized + } + if resp.StatusCode != 200 { + return fmt.Errorf("error retrieving resource, http code %d. %s", resp.StatusCode, body) + } + return nil +} + +func (yc *YakClient) getNamespaceId(namespaceName string) (string, error) { + ns, err := yc.GetNamespaces() + if err != nil { + return "", err + } + for _, n := range ns { + if n.Name == namespaceName { + return n.Id, nil + } + } + return "", ErrNamespaceDoesNotExist{namespaceName} +}