Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Make several CLI commands testable #86

Merged
merged 7 commits into from
Dec 27, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 50 additions & 24 deletions cli/cmd/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import (
"errors"
"fmt"

"github.com/runconduit/conduit/cli/k8s"
"github.com/runconduit/conduit/cli/shell"

pb "github.com/runconduit/conduit/controller/gen/public"
"github.com/spf13/cobra"
)
Expand All @@ -17,36 +20,59 @@ var getCmd = &cobra.Command{
Valid resource types include:
* pods (aka pod, po)`,
RunE: func(cmd *cobra.Command, args []string) error {
switch len(args) {
case 1:
resourceType := args[0]
switch resourceType {
case "pod", "pods", "po":
client, err := newApiClient()
if err != nil {
return err
}
resp, err := client.ListPods(context.Background(), &pb.Empty{})
if err != nil {
return err
}

for _, pod := range resp.GetPods() {
fmt.Println(pod.Name)
}

default:
return errors.New("invalid resource type")
}

return nil
default:
if len(args) < 1 {
return errors.New("please specify a resource type")
}

if len(args) > 1 {
return errors.New("please specify only one resource type")
}

friendlyName := args[0]
resourceType, err := k8s.CanonicalKubernetesNameFromFriendlyName(friendlyName)

if err != nil || resourceType != k8s.KubernetesPods {
return fmt.Errorf("invalid resource type %s, only %s are allowed as resource types", friendlyName, k8s.KubernetesPods)
}

kubeApi, err := k8s.MakeK8sAPi(shell.MakeUnixShell(), kubeconfigPath, apiAddr)
if err != nil {
return err
}

client, err := newApiClient(kubeApi)
if err != nil {
return err
}

podNames, err := getPods(client)
if err != nil {
return err
}

for _, podName := range podNames {
fmt.Println(podName)
}

return nil
},
}

func init() {
RootCmd.AddCommand(getCmd)
addControlPlaneNetworkingArgs(getCmd)
}

func getPods(apiClient pb.ApiClient) ([]string, error) {
resp, err := apiClient.ListPods(context.Background(), &pb.Empty{})
if err != nil {
return nil, err
}

names := make([]string, 0)
for _, pod := range resp.GetPods() {
names = append(names, pod.Name)
}

return names, nil
}
69 changes: 69 additions & 0 deletions cli/cmd/get_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package cmd

import (
"errors"
"testing"

pb "github.com/runconduit/conduit/controller/gen/public"
)

func TestGetPods(t *testing.T) {
t.Run("Returns names of existing pods if everything went ok", func(t *testing.T) {
mockClient := &mockApiClient{}

pods := []*pb.Pod{
{Name: "pod-a"},
{Name: "pod-b"},
{Name: "pod-c"},
}

expectedPodNames := []string{
"pod-a",
"pod-b",
"pod-c",
}
response := &pb.ListPodsResponse{
Pods: pods,
}

mockClient.listPodsResponseToReturn = response
actualPodNames, err := getPods(mockClient)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}

for i, actualName := range actualPodNames {
expectedName := expectedPodNames[i]
if expectedName != actualName {
t.Fatalf("Expected %dth element on %v to be [%s], but was [%s]", i, actualPodNames, expectedName, actualName)
}
}
})

t.Run("Returns empty list if no [ods found", func(t *testing.T) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[ods

mockClient := &mockApiClient{}

mockClient.listPodsResponseToReturn = &pb.ListPodsResponse{
Pods: []*pb.Pod{},
}

actualPodNames, err := getPods(mockClient)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}

if len(actualPodNames) != 0 {
t.Fatalf("Expecting no pod names, got %v", actualPodNames)
}
})

t.Run("Returns error if cant find pods in API", func(t *testing.T) {
mockClient := &mockApiClient{}
mockClient.errorToReturn = errors.New("expected")

_, err := getPods(mockClient)
if err == nil {
t.Fatalf("Expecting error, got noting")
}
})
}
8 changes: 1 addition & 7 deletions cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"os"

"github.com/runconduit/conduit/cli/k8s"
"github.com/runconduit/conduit/cli/shell"
"github.com/runconduit/conduit/controller/api/public"
pb "github.com/runconduit/conduit/controller/gen/public"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -34,12 +33,7 @@ func addControlPlaneNetworkingArgs(cmd *cobra.Command) {
cmd.PersistentFlags().StringVar(&apiAddr, "api-addr", "", "Override kubeconfig and communicate directly with the control plane at host:port (mostly for testing)")
}

func newApiClient() (pb.ApiClient, error) {
kubeApi, err := k8s.MakeK8sAPi(shell.MakeUnixShell(), kubeconfigPath, apiAddr)
if err != nil {
return nil, err
}

func newApiClient(kubeApi k8s.KubernetesApi) (pb.ApiClient, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious: was this change to newApiClient needed for this round of test changes, or is it in anticipation of future testing? how does this change relate to mockApiClient?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to be consistent with the way we create other objects. With this PR we have every command instantiating the objects it needs and wiring them together. Arguably this function should not be in the root command but rather follow the pattern established elsewhere and be something like conduitclient.MakeApiClient(kubeApi k8s.KubernetesApi) (pb.ApiClient, error), but I didn't want to propose something like this before there was an actual need for it.

url, err := kubeApi.UrlFor(controlPlaneNamespace, "/services/http:api:http/proxy/")
if err != nil {
return nil, err
Expand Down
117 changes: 72 additions & 45 deletions cli/cmd/stat.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,15 @@ import (
"strings"
"text/tabwriter"

"github.com/runconduit/conduit/cli/k8s"
"github.com/runconduit/conduit/cli/shell"

"github.com/runconduit/conduit/controller/api/util"
pb "github.com/runconduit/conduit/controller/gen/public"
"github.com/spf13/cobra"
)

const padding = 3

type row struct {
requestRate float64
successRate float64
latencyP50 int64
latencyP99 int64
}
const ConduitPaths = "paths"

var target string
var timeWindow string
Expand All @@ -40,70 +36,100 @@ Valid resource types include:

The optional [TARGET] option can be either a name for a deployment or pod resource`,
RunE: func(cmd *cobra.Command, args []string) error {
var resourceType string
var friendlyNameForResourceType string

switch len(args) {
case 1:
resourceType = args[0]
friendlyNameForResourceType = args[0]
case 2:
resourceType = args[0]
friendlyNameForResourceType = args[0]
target = args[1]
default:
return errors.New("please specify a resource type: pods, deployments or paths")
}

switch resourceType {
case "pods", "pod", "po":
return makeStatsRequest(pb.AggregationType_TARGET_POD)
case "deployments", "deployment", "deploy":
return makeStatsRequest(pb.AggregationType_TARGET_DEPLOY)
case "paths", "path", "pa":
return makeStatsRequest(pb.AggregationType_PATH)
default:
return errors.New("invalid resource type")
validatedResourceType, err := k8s.CanonicalKubernetesNameFromFriendlyName(friendlyNameForResourceType)
if err != nil {
switch friendlyNameForResourceType {
case "paths", "path", "pa":
validatedResourceType = ConduitPaths
default:
return fmt.Errorf("invalid resource type %s, only %v are allowed as resource types", friendlyNameForResourceType, []string{k8s.KubernetesPods, k8s.KubernetesDeployments, ConduitPaths})
}
}
kubeApi, err := k8s.MakeK8sAPi(shell.MakeUnixShell(), kubeconfigPath, apiAddr)
if err != nil {
return err
}

client, err := newApiClient(kubeApi)
if err != nil {
return fmt.Errorf("error creating api client while making stats request: %v", err)
}

return nil
output, err := requestStatsFromApi(client, validatedResourceType)
if err != nil {
return err
}

_, err = fmt.Print(output)

return err
},
}

func makeStatsRequest(aggType pb.AggregationType) error {
client, err := newApiClient()
if err != nil {
return fmt.Errorf("error creating api client while making stats request: %v", err)
}
func init() {
RootCmd.AddCommand(statCmd)
addControlPlaneNetworkingArgs(statCmd)
statCmd.PersistentFlags().StringVarP(&timeWindow, "time-window", "t", "1m", "Stat window. One of: '10s', '1m', '10m', '1h', '6h', '24h'.")
statCmd.PersistentFlags().BoolVarP(&watch, "watch", "w", false, "After listing/getting the requested object, watch for changes.")
statCmd.PersistentFlags().BoolVar(&watchOnly, "watch-only", false, "Watch for changes to the requested object(s), without listing/getting first.")
}

var resourceTypeToAggregationType = map[string]pb.AggregationType{
k8s.KubernetesPods: pb.AggregationType_TARGET_POD,
k8s.KubernetesDeployments: pb.AggregationType_TARGET_DEPLOY,
ConduitPaths: pb.AggregationType_PATH,
}

func requestStatsFromApi(client pb.ApiClient, resourceType string) (string, error) {
aggType := resourceTypeToAggregationType[resourceType]
req, err := buildMetricRequest(aggType)
if err != nil {
return fmt.Errorf("error creating metrics request while making stats request: %v", err)
return "", fmt.Errorf("error creating metrics request while making stats request: %v", err)
}

resp, err := client.Stat(context.Background(), req)
if err != nil {
return fmt.Errorf("error calling stat with request: %v", err)
return "", fmt.Errorf("error calling stat with request: %v", err)
}

return renderStats(resp)
}

func renderStats(resp *pb.MetricResponse) (string, error) {
var buffer bytes.Buffer
w := tabwriter.NewWriter(&buffer, 0, 0, padding, ' ', tabwriter.AlignRight)
displayStats(resp, w)
writeStatsToBuffer(resp, w)
w.Flush()

// strip left padding on the first column
out := string(buffer.Bytes()[padding:])
out = strings.Replace(out, "\n"+strings.Repeat(" ", padding), "\n", -1)

_, err = fmt.Print(out)
return err
return out, nil
}

func sortStatsKeys(stats map[string]*row) []string {
var sortedKeys []string
for key, _ := range stats {
sortedKeys = append(sortedKeys, key)
}
sort.Strings(sortedKeys)
return sortedKeys
const padding = 3

type row struct {
requestRate float64
successRate float64
latencyP50 int64
latencyP99 int64
}

func displayStats(resp *pb.MetricResponse, w *tabwriter.Writer) {
func writeStatsToBuffer(resp *pb.MetricResponse, w *tabwriter.Writer) {
nameHeader := "NAME"
maxNameLength := len(nameHeader)

Expand Down Expand Up @@ -200,10 +226,11 @@ func buildMetricRequest(aggregationType pb.AggregationType) (*pb.MetricRequest,
}, nil
}

func init() {
RootCmd.AddCommand(statCmd)
addControlPlaneNetworkingArgs(statCmd)
statCmd.PersistentFlags().StringVarP(&timeWindow, "time-window", "t", "1m", "Stat window. One of: '10s', '1m', '10m', '1h', '6h', '24h'.")
statCmd.PersistentFlags().BoolVarP(&watch, "watch", "w", false, "After listing/getting the requested object, watch for changes.")
statCmd.PersistentFlags().BoolVar(&watchOnly, "watch-only", false, "Watch for changes to the requested object(s), without listing/getting first.")
func sortStatsKeys(stats map[string]*row) []string {
var sortedKeys []string
for key, _ := range stats {
sortedKeys = append(sortedKeys, key)
}
sort.Strings(sortedKeys)
return sortedKeys
}
Loading