Repository: digitalocean/doctl
Pull request #1252 (2022-09-26 10:14:12)

Finish eliminating plugin usage in 'doctl sls functions' + testing improvements
This change covers internal improvements not intended to be visible to typical `doctl` users.

1. Toward the goal of reducing the separate plugin for serverless:
     1.  Remove dependence on the plugin for `doctl sls fn list`
     2. Remove dependence on the plugin for `doctl sls status`
2.  Adds a (permanently) hidden pair of flags to `doctl sls connect` permitting connection to serverless clusters that are not in production (and hence unknown to the serverless portal).   This is needed to support internal testing and debugging use cases for which we currently use the `nim` CLI (which should be phased out).

joshuaauerbachwatson: This will stay as a draft until PR #1232 is merged since it has higher priority.  Then it will be rebased and marked for review.

Patch:
diff --git a/commands/functions.go b/commands/functions.go
index fc53f772..8800d33c 100644
--- a/commands/functions.go
+++ b/commands/functions.go
@@ -19,6 +19,7 @@ import (
 	"errors"
 	"fmt"
 	"os"
+	"sort"
 	"strings"
 
 	"github.com/apache/openwhisk-client-go/whisk"
@@ -250,34 +251,48 @@ func RunFunctionsList(c *CmdConfig) error {
 	if argCount > 1 {
 		return doctl.NewTooManyArgsErr(c.NS)
 	}
+	var pkg string
+	if argCount == 1 {
+		pkg = c.Args[0]
+	}
 	// Determine if '--count' is requested since we will use simple text output in that case.
 	// Count is mutually exclusive with the global format flag.
 	count, _ := c.Doit.GetBool(c.NS, flagCount)
 	if count && c.Doit.IsSet("format") {
 		return errors.New("the --count and --format flags are mutually exclusive")
 	}
-	// Add JSON flag so we can control output format
-	if !count {
-		c.Doit.Set(c.NS, flagJSON, true)
-	}
-	output, err := RunServerlessExec(actionList, c, []string{flagCount, flagNameSort, flagNameName, flagJSON}, []string{flagLimit, flagSkip})
+	// Retrieve other flags
+	skip, _ := c.Doit.GetInt(c.NS, flagSkip)
+	limit, _ := c.Doit.GetInt(c.NS, flagLimit)
+	nameSort, _ := c.Doit.GetBool(c.NS, flagNameSort)
+	nameName, _ := c.Doit.GetBool(c.NS, flagNameName)
+	// Get information from backend
+	list, err := c.Serverless().ListFunctions(pkg, skip, limit)
 	if err != nil {
 		return err
 	}
 	if count {
-		return c.PrintServerlessTextOutput(output)
+		plural := "s"
+		are := "are"
+		if len(list) == 1 {
+			plural = ""
+			are = "is"
+		}
+		fmt.Fprintf(c.Out, "There %s %d function%s in this namespace.\n", are, len(list), plural)
+		return nil
 	}
-	// Reparse the output to use a more specific type, which can then be passed to the displayer
-	rawOutput, err := json.Marshal(output.Entity)
-	if err != nil {
-		return err
+	if nameSort || nameName {
+		sortFunctionList(list)
 	}
-	var formatted []whisk.Action
-	err = json.Unmarshal(rawOutput, &formatted)
-	if err != nil {
-		return err
+	return c.Display(&displayers.Functions{Info: list})
+}
+
+// sortFunctionList performs a sort of a function list (by name)
+func sortFunctionList(list []whisk.Action) {
+	isLess := func(i, j int) bool {
+		return list[i].Name < list[j].Name
 	}
-	return c.Display(&displayers.Functions{Info: formatted})
+	sort.Slice(list, isLess)
 }
 
 // consolidateParams accepts parameters from a file, the command line, or both, and consolidates all
diff --git a/commands/serverless.go b/commands/serverless.go
index 134e5acc..44fb43a7 100644
--- a/commands/serverless.go
+++ b/commands/serverless.go
@@ -83,7 +83,7 @@ The install operation is long-running, and a network connection is required.`,
 	CmdBuilder(cmd, RunServerlessUninstall, "uninstall", "Removes the serverless support", `Removes serverless support from `+"`"+`doctl`+"`",
 		Writer)
 
-	CmdBuilder(cmd, RunServerlessConnect, "connect [<hint>]", "Connects local serverless support to a functions namespace",
+	connect := CmdBuilder(cmd, RunServerlessConnect, "connect [<hint>]", "Connects local serverless support to a functions namespace",
 		`This command connects `+"`"+`doctl serverless`+"`"+` support to a functions namespace of your choice.
 The optional argument should be a (complete or partial) match to a namespace label or id.
 If there is no argument, all namespaces are matched.  If the result is exactly one namespace,
@@ -91,6 +91,12 @@ you are connected to it.  If there are multiple namespaces, you have an opportun
 the one you want from a dialog.  Use `+"`"+`doctl serverless namespaces`+"`"+` to create, delete, and
 list your namespaces.`,
 		Writer)
+	// The apihost and auth flags will always be hidden.  They support testing using doctl on clusters that are not in production
+	// and hence are unknown to the portal.
+	AddStringFlag(connect, "apihost", "", "", "")
+	AddStringFlag(connect, "auth", "", "", "")
+	connect.Flags().MarkHidden("apihost")
+	connect.Flags().MarkHidden("auth")
 
 	status := CmdBuilder(cmd, RunServerlessStatus, "status", "Provide information about serverless support",
 		`This command reports the status of serverless support and some details concerning its connected functions namespace.
@@ -194,13 +200,34 @@ func RunServerlessConnect(c *CmdConfig) error {
 	var (
 		err error
 	)
+	sls := c.Serverless()
+
+	// Support the hidden capability to connect to non-production clusters to support various kinds of testing.
+	// The presence of 'auth' and 'apihost' flags trumps other parts of the syntax, but both must be present.
+	apihost, _ := c.Doit.GetString(c.NS, "apihost")
+	auth, _ := c.Doit.GetString(c.NS, "auth")
+	if len(apihost) > 0 && len(auth) > 0 {
+		namespace, err := sls.GetNamespaceFromCluster(apihost, auth)
+		if err != nil {
+			return err
+		}
+		credential := do.ServerlessCredential{Auth: auth}
+		creds := do.ServerlessCredentials{
+			APIHost:     apihost,
+			Namespace:   namespace,
+			Credentials: map[string]map[string]do.ServerlessCredential{apihost: {namespace: credential}},
+		}
+		return finishConnecting(sls, creds, "", c.Out)
+	}
+	if len(apihost) > 0 || len(auth) > 0 {
+		return fmt.Errorf("If either of 'apihost' or 'auth' is specified then both must be specified")
+	}
+	// Neither 'auth' nor 'apihost' was specified, so continue with other options.
 
 	if len(c.Args) > 1 {
 		return doctl.NewTooManyArgsErr(c.NS)
 	}
 
-	sls := c.Serverless()
-
 	// Non-standard check for the connect command (only): it's ok to not be connected.
 	err = sls.CheckServerlessStatus(hashAccessToken(c))
 	if err != nil && err != do.ErrServerlessNotConnected {
@@ -298,7 +325,8 @@ func finishConnecting(sls do.ServerlessService, creds do.ServerlessCredentials,
 
 // RunServerlessStatus gives a report on the status of the serverless (installed, up to date, connected)
 func RunServerlessStatus(c *CmdConfig) error {
-	status := c.Serverless().CheckServerlessStatus(hashAccessToken(c))
+	sls := c.Serverless()
+	status := sls.CheckServerlessStatus(hashAccessToken(c))
 	if status == do.ErrServerlessNotInstalled {
 		return status
 	}
@@ -321,20 +349,20 @@ func RunServerlessStatus(c *CmdConfig) error {
 	}
 	// Check the connected state more deeply (since this is a status command we want to
 	// be more accurate; the connected check in checkServerlessStatus is lightweight and heuristic).
-	result, err := ServerlessExec(c, "auth/current", "--apihost", "--name")
-	if err != nil || len(result.Error) > 0 {
-		return do.ErrServerlessNotConnected
+	creds, err := sls.ReadCredentials()
+	if err != nil {
+		return nil
 	}
-	if result.Entity == nil {
-		return errors.New("Could not retrieve information about the connected namespace")
+	auth := creds.Credentials[creds.APIHost][creds.Namespace].Auth
+	checkNS, err := sls.GetNamespaceFromCluster(creds.APIHost, auth)
+	if err != nil || checkNS != creds.Namespace {
+		return do.ErrServerlessNotConnected
 	}
-	mapResult := result.Entity.(map[string]interface{})
-	apiHost := mapResult["apihost"].(string)
-	fmt.Fprintf(c.Out, "Connected to functions namespace '%s' on API host '%s'\n", mapResult["name"], apiHost)
+	fmt.Fprintf(c.Out, "Connected to functions namespace '%s' on API host '%s'\n", creds.Namespace, creds.APIHost)
 	fmt.Fprintf(c.Out, "Serverless software version is %s\n\n", do.GetMinServerlessVersion())
 	languages, _ := c.Doit.GetBool(c.NS, "languages")
 	if languages {
-		return showLanguageInfo(c, apiHost)
+		return showLanguageInfo(c, creds.APIHost)
 	}
 	return nil
 }
diff --git a/do/mocks/ServerlessService.go b/do/mocks/ServerlessService.go
index 8fa4ccf3..c6a498cc 100644
--- a/do/mocks/ServerlessService.go
+++ b/do/mocks/ServerlessService.go
@@ -199,6 +199,21 @@ func (mr *MockServerlessServiceMockRecorder) GetNamespace(arg0, arg1 interface{}
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNamespace", reflect.TypeOf((*MockServerlessService)(nil).GetNamespace), arg0, arg1)
 }
 
+// GetNamespaceFromCluster mocks base method.
+func (m *MockServerlessService) GetNamespaceFromCluster(arg0, arg1 string) (string, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetNamespaceFromCluster", arg0, arg1)
+	ret0, _ := ret[0].(string)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetNamespaceFromCluster indicates an expected call of GetNamespaceFromCluster.
+func (mr *MockServerlessServiceMockRecorder) GetNamespaceFromCluster(arg0, arg1 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNamespaceFromCluster", reflect.TypeOf((*MockServerlessService)(nil).GetNamespaceFromCluster), arg0, arg1)
+}
+
 // GetServerlessNamespace mocks base method.
 func (m *MockServerlessService) GetServerlessNamespace(arg0 context.Context) (do.ServerlessCredentials, error) {
 	m.ctrl.T.Helper()
@@ -272,6 +287,21 @@ func (mr *MockServerlessServiceMockRecorder) InvokeFunctionViaWeb(arg0, arg1 int
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InvokeFunctionViaWeb", reflect.TypeOf((*MockServerlessService)(nil).InvokeFunctionViaWeb), arg0, arg1)
 }
 
+// ListFunctions mocks base method.
+func (m *MockServerlessService) ListFunctions(arg0 string, arg1, arg2 int) ([]whisk.Action, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ListFunctions", arg0, arg1, arg2)
+	ret0, _ := ret[0].([]whisk.Action)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// ListFunctions indicates an expected call of ListFunctions.
+func (mr *MockServerlessServiceMockRecorder) ListFunctions(arg0, arg1, arg2 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListFunctions", reflect.TypeOf((*MockServerlessService)(nil).ListFunctions), arg0, arg1, arg2)
+}
+
 // ListNamespaces mocks base method.
 func (m *MockServerlessService) ListNamespaces(arg0 context.Context) (do.NamespaceListResponse, error) {
 	m.ctrl.T.Helper()
diff --git a/do/serverless.go b/do/serverless.go
index 3d45b289..9601b227 100644
--- a/do/serverless.go
+++ b/do/serverless.go
@@ -206,6 +206,7 @@ type ServerlessService interface {
 	GetServerlessNamespace(context.Context) (ServerlessCredentials, error)
 	ListNamespaces(context.Context) (NamespaceListResponse, error)
 	GetNamespace(context.Context, string) (ServerlessCredentials, error)
+	GetNamespaceFromCluster(string, string) (string, error)
 	CreateNamespace(context.Context, string, string) (ServerlessCredentials, error)
 	DeleteNamespace(context.Context, string) error
 	ListTriggers(context.Context, string) ([]ServerlessTrigger, error)
@@ -217,6 +218,7 @@ type ServerlessService interface {
 	CheckServerlessStatus(string) error
 	InstallServerless(string, bool) error
 	GetFunction(string, bool) (whisk.Action, []FunctionParameter, error)
+	ListFunctions(string, int, int) ([]whisk.Action, error)
 	InvokeFunction(string, interface{}, bool, bool) (map[string]interface{}, error)
 	InvokeFunctionViaWeb(string, map[string]interface{}) error
 	GetConnectedAPIHost() (string, error)
@@ -239,7 +241,7 @@ const (
 	// Minimum required version of the sandbox plugin code.  The first part is
 	// the version of the incorporated Nimbella CLI and the second part is the
 	// version of the bridge code in the sandbox plugin repository.
-	minServerlessVersion = "4.2.6-1.3.1"
+	minServerlessVersion = "4.2.7-1.3.1"
 
 	// The version of nodejs to download alongsize the plugin download.
 	nodeVersion = "v16.13.0"
@@ -635,6 +637,27 @@ func (s *serverlessService) GetNamespace(ctx context.Context, name string) (Serv
 	return executeNamespaceRequest(ctx, s, req)
 }
 
+// GetNamespaceFromCluster obtains the namespace that uniquely owns a valid combination of API host and "auth"
+// (uuid:key).  This can be used to connect to clusters not known to the portal (e.g. dev clusters) or simply
+// to check that credentials are valid.
+func (s *serverlessService) GetNamespaceFromCluster(APIhost string, auth string) (string, error) {
+	// We do not use the shared client in serverlessService for this because it uses the stored
+	// credentials, not the passed ones.
+	config := whisk.Config{Host: APIhost, AuthToken: auth}
+	client, err := whisk.NewClient(http.DefaultClient, &config)
+	if err != nil {
+		return "", err
+	}
+	ns, _, err := client.Namespaces.List()
+	if err != nil {
+		return "", err
+	}
+	if len(ns) != 1 {
+		return "", fmt.Errorf("unexpected response when validating apihost and auth")
+	}
+	return ns[0].Name, nil
+}
+
 // CreateNamespace creates a new namespace and returns its credentials, given a label and region
 func (s *serverlessService) CreateNamespace(ctx context.Context, label string, region string) (ServerlessCredentials, error) {
 	reqBody := newNamespaceRequest{Namespace: inputNamespace{Label: label, Region: region}}
@@ -697,6 +720,23 @@ func (s *serverlessService) GetFunction(name string, fetchCode bool) (whisk.Acti
 	return *action, parameters, nil
 }
 
+// ListFunctions lists the functions of the connected namespace
+func (s *serverlessService) ListFunctions(pkg string, skip int, limit int) ([]whisk.Action, error) {
+	err := initWhisk(s)
+	if err != nil {
+		return []whisk.Action{}, err
+	}
+	if limit == 0 {
+		limit = 30
+	}
+	options := &whisk.ActionListOptions{
+		Skip:  skip,
+		Limit: limit,
+	}
+	list, _, err := s.owClient.Actions.List(pkg, options)
+	return list, err
+}
+
 // InvokeFunction invokes a function via POST with authentication
 func (s *serverlessService) InvokeFunction(name string, params interface{}, blocking bool, result bool) (map[string]interface{}, error) {
 	var empty map[string]interface{}


Test patch:
diff --git a/commands/functions_test.go b/commands/functions_test.go
index 1b05a025..f356eb54 100644
--- a/commands/functions_test.go
+++ b/commands/functions_test.go
@@ -16,9 +16,10 @@ package commands
 import (
 	"bytes"
 	"os"
-	"os/exec"
 	"sort"
+	"strings"
 	"testing"
+	"time"
 
 	"github.com/apache/openwhisk-client-go/whisk"
 	"github.com/digitalocean/doctl/do"
@@ -250,54 +251,135 @@ func TestFunctionsInvoke(t *testing.T) {
 }
 
 func TestFunctionsList(t *testing.T) {
+	// The displayer for function list is time-zone sensitive so we need to pre-convert the timestamps using the local
+	// time-zone to get exact matches.
+	timestamps := []int64{1662610000, 1662620000, 1662630000}
+	symbols := []string{"%DATE1%", "%DATE2%", "%DATE3%"}
+	dates := []string{
+		time.UnixMilli(timestamps[0]).Format("01/02 03:04:05"),
+		time.UnixMilli(timestamps[1]).Format("01/02 03:04:05"),
+		time.UnixMilli(timestamps[2]).Format("01/02 03:04:05"),
+	}
+
 	tests := []struct {
-		name            string
-		doctlArgs       string
-		doctlFlags      map[string]string
-		expectedNimArgs []string
+		name           string
+		doctlFlags     map[string]string
+		doctlArg       string
+		skip           int
+		limit          int
+		expectedOutput string
 	}{
 		{
-			name:            "no flags or args",
-			expectedNimArgs: []string{"--json"},
+			name:  "no flags or args",
+			skip:  0,
+			limit: 0,
+			expectedOutput: `%DATE1%    0.0.1    nodejs:14    daily/hello
+%DATE2%    0.0.2    nodejs:14    daily/goodbye
+%DATE3%    0.0.3    nodejs:14    sometimes/meAgain
+`,
+		},
+		{
+			name:     "with package arg",
+			doctlArg: "daily",
+			skip:     0,
+			limit:    0,
+			expectedOutput: `%DATE1%    0.0.1    nodejs:14    daily/hello
+%DATE2%    0.0.2    nodejs:14    daily/goodbye
+`,
+		},
+		{
+			name:           "count flag",
+			doctlFlags:     map[string]string{"count": ""},
+			skip:           0,
+			limit:          0,
+			expectedOutput: "There are 3 functions in this namespace.\n",
+		},
+		{
+			name:           "limit flag",
+			doctlFlags:     map[string]string{"limit": "1"},
+			skip:           0,
+			limit:          1,
+			expectedOutput: "%DATE1%    0.0.1    nodejs:14    daily/hello\n",
+		},
+		{
+			name:       "name flag",
+			doctlFlags: map[string]string{"name": ""},
+			skip:       0,
+			limit:      0,
+			expectedOutput: `%DATE2%    0.0.2    nodejs:14    daily/goodbye
+%DATE1%    0.0.1    nodejs:14    daily/hello
+%DATE3%    0.0.3    nodejs:14    sometimes/meAgain
+`,
 		},
 		{
-			name:            "count flag",
-			doctlFlags:      map[string]string{"count": ""},
-			expectedNimArgs: []string{"--count"},
+			name:       "name-sort flag",
+			doctlFlags: map[string]string{"name-sort": ""},
+			skip:       0,
+			limit:      0,
+			expectedOutput: `%DATE2%    0.0.2    nodejs:14    daily/goodbye
+%DATE1%    0.0.1    nodejs:14    daily/hello
+%DATE3%    0.0.3    nodejs:14    sometimes/meAgain
+`,
 		},
 		{
-			name:            "limit flag",
-			doctlFlags:      map[string]string{"limit": "1"},
-			expectedNimArgs: []string{"--json", "--limit", "1"},
+			name:       "skip flag",
+			doctlFlags: map[string]string{"skip": "1"},
+			skip:       1,
+			limit:      0,
+			expectedOutput: `%DATE2%    0.0.2    nodejs:14    daily/goodbye
+%DATE3%    0.0.3    nodejs:14    sometimes/meAgain
+`,
 		},
+	}
+
+	theList := []whisk.Action{
 		{
-			name:            "name flag",
-			doctlFlags:      map[string]string{"name": ""},
-			expectedNimArgs: []string{"--name", "--json"},
+			Name:      "hello",
+			Namespace: "theNamespace/daily",
+			Updated:   timestamps[0],
+			Version:   "0.0.1",
+			Annotations: whisk.KeyValueArr{
+				whisk.KeyValue{
+					Key:   "exec",
+					Value: "nodejs:14",
+				},
+			},
 		},
 		{
-			name:            "name-sort flag",
-			doctlFlags:      map[string]string{"name-sort": ""},
-			expectedNimArgs: []string{"--name-sort", "--json"},
+			Name:      "goodbye",
+			Namespace: "theNamespace/daily",
+			Updated:   timestamps[1],
+			Version:   "0.0.2",
+			Annotations: whisk.KeyValueArr{
+				whisk.KeyValue{
+					Key:   "exec",
+					Value: "nodejs:14",
+				},
+			},
 		},
 		{
-			name:            "skip flag",
-			doctlFlags:      map[string]string{"skip": "1"},
-			expectedNimArgs: []string{"--json", "--skip", "1"},
+			Name:      "meAgain",
+			Namespace: "theNamespace/sometimes",
+			Version:   "0.0.3",
+			Updated:   timestamps[2],
+			Annotations: whisk.KeyValueArr{
+				whisk.KeyValue{
+					Key:   "exec",
+					Value: "nodejs:14",
+				},
+			},
 		},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
-				fakeCmd := &exec.Cmd{
-					Stdout: config.Out,
-				}
+				buf := &bytes.Buffer{}
+				config.Out = buf
 
-				if tt.doctlArgs != "" {
-					config.Args = append(config.Args, tt.doctlArgs)
+				if tt.doctlArg != "" {
+					config.Args = append(config.Args, tt.doctlArg)
 				}
-
 				if tt.doctlFlags != nil {
 					for k, v := range tt.doctlFlags {
 						if v == "" {
@@ -307,14 +389,40 @@ func TestFunctionsList(t *testing.T) {
 						}
 					}
 				}
+				config.Doit.Set(config.NS, "no-header", true)
 
-				tm.serverless.EXPECT().CheckServerlessStatus(hashAccessToken(config)).MinTimes(1).Return(nil)
-				tm.serverless.EXPECT().Cmd("action/list", tt.expectedNimArgs).Return(fakeCmd, nil)
-				tm.serverless.EXPECT().Exec(fakeCmd).Return(do.ServerlessOutput{}, nil)
+				answer := selectPackage(theList, tt.doctlArg)[tt.skip:]
+				if tt.limit != 0 {
+					answer = answer[0:tt.limit]
+				}
+				tm.serverless.EXPECT().ListFunctions(tt.doctlArg, tt.skip, tt.limit).Return(answer, nil)
 
 				err := RunFunctionsList(config)
 				require.NoError(t, err)
+				expected := tt.expectedOutput
+				for i := range symbols {
+					expected = strings.Replace(expected, symbols[i], dates[i], 1)
+				}
+				assert.Equal(t, expected, buf.String())
 			})
 		})
 	}
 }
+
+// selectPackage is a testing support utility to trim a master list of functions by package membership
+// Also ensures the array is copied, because the logic being tested may sort it in place.
+func selectPackage(masterList []whisk.Action, pkg string) []whisk.Action {
+	if pkg == "" {
+		copiedList := make([]whisk.Action, len(masterList))
+		copy(copiedList, masterList)
+		return copiedList
+	}
+	namespace := "theNamespace/" + pkg
+	answer := []whisk.Action{}
+	for _, action := range masterList {
+		if action.Namespace == namespace {
+			answer = append(answer, action)
+		}
+	}
+	return answer
+}
diff --git a/commands/serverless_test.go b/commands/serverless_test.go
index d1f9fe65..bcdc39e7 100644
--- a/commands/serverless_test.go
+++ b/commands/serverless_test.go
@@ -127,18 +127,20 @@ func TestServerlessStatusWhenConnected(t *testing.T) {
 	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
 		buf := &bytes.Buffer{}
 		config.Out = buf
-		fakeCmd := &exec.Cmd{
-			Stdout: config.Out,
-		}
 
 		tm.serverless.EXPECT().CheckServerlessStatus(hashAccessToken(config)).MinTimes(1).Return(nil)
-		tm.serverless.EXPECT().Cmd("auth/current", []string{"--apihost", "--name"}).Return(fakeCmd, nil)
-		tm.serverless.EXPECT().Exec(fakeCmd).Return(do.ServerlessOutput{
-			Entity: map[string]interface{}{
-				"name":    "hello",
-				"apihost": "https://api.example.com",
+		tm.serverless.EXPECT().ReadCredentials().Return(do.ServerlessCredentials{
+			APIHost:   "https://api.example.com",
+			Namespace: "hello",
+			Credentials: map[string]map[string]do.ServerlessCredential{
+				"https://api.example.com": {
+					"hello": do.ServerlessCredential{
+						Auth: "here-are-some-credentials",
+					},
+				},
 			},
 		}, nil)
+		tm.serverless.EXPECT().GetNamespaceFromCluster("https://api.example.com", "here-are-some-credentials").Return("hello", nil)
 
 		err := RunServerlessStatus(config)
 		require.NoError(t, err)
@@ -151,9 +153,6 @@ func TestServerlessStatusWithLanguages(t *testing.T) {
 		buf := &bytes.Buffer{}
 		config.Out = buf
 		config.Doit.Set(config.NS, "languages", true)
-		fakeCmd := &exec.Cmd{
-			Stdout: config.Out,
-		}
 		fakeHostInfo := do.ServerlessHostInfo{
 			Runtimes: map[string][]do.ServerlessRuntime{
 				"go": {
@@ -184,14 +183,20 @@ func TestServerlessStatusWithLanguages(t *testing.T) {
 `
 
 		tm.serverless.EXPECT().CheckServerlessStatus(hashAccessToken(config)).MinTimes(1).Return(nil)
-		tm.serverless.EXPECT().Cmd("auth/current", []string{"--apihost", "--name"}).Return(fakeCmd, nil)
-		tm.serverless.EXPECT().Exec(fakeCmd).Return(do.ServerlessOutput{
-			Entity: map[string]interface{}{
-				"name":    "hello",
-				"apihost": "https://api.example.com",
+		tm.serverless.EXPECT().ReadCredentials().Return(do.ServerlessCredentials{
+			APIHost:   "https://api.example.com",
+			Namespace: "hello",
+			Credentials: map[string]map[string]do.ServerlessCredential{
+				"https://api.example.com": {
+					"hello": do.ServerlessCredential{
+						Auth: "here-are-some-credentials",
+					},
+				},
 			},
 		}, nil)
+		tm.serverless.EXPECT().GetNamespaceFromCluster("https://api.example.com", "here-are-some-credentials").Return("hello", nil)
 		tm.serverless.EXPECT().GetHostInfo("https://api.example.com").Return(fakeHostInfo, nil)
+
 		err := RunServerlessStatus(config)
 		require.NoError(t, err)
 		assert.Contains(t, buf.String(), expectedDisplay)
@@ -200,15 +205,20 @@ func TestServerlessStatusWithLanguages(t *testing.T) {
 
 func TestServerlessStatusWhenNotConnected(t *testing.T) {
 	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
-		fakeCmd := &exec.Cmd{
-			Stdout: config.Out,
-		}
 
 		tm.serverless.EXPECT().CheckServerlessStatus(hashAccessToken(config)).MinTimes(1).Return(nil)
-		tm.serverless.EXPECT().Cmd("auth/current", []string{"--apihost", "--name"}).Return(fakeCmd, nil)
-		tm.serverless.EXPECT().Exec(fakeCmd).Return(do.ServerlessOutput{
-			Error: "403",
+		tm.serverless.EXPECT().ReadCredentials().Return(do.ServerlessCredentials{
+			APIHost:   "https://api.example.com",
+			Namespace: "hello",
+			Credentials: map[string]map[string]do.ServerlessCredential{
+				"https://api.example.com": {
+					"hello": do.ServerlessCredential{
+						Auth: "here-are-some-credentials",
+					},
+				},
+			},
 		}, nil)
+		tm.serverless.EXPECT().GetNamespaceFromCluster("https://api.example.com", "here-are-some-credentials").Return("not-hello", errors.New("an error"))
 
 		err := RunServerlessStatus(config)
 		require.Error(t, err)