Repository: okteto/okteto (go)

Issue:
Okteto namespace list outputting options
**Is your feature request related to a problem? Please describe.**
When running automation using the Okteto CLI it can be flaky and difficult to capture output from `okteto namespace list` as it only supports plain text output with a `*` to denote active namespaces, compared to `okteto previews list` which has a JSON or YAML format output that can be parsed.

**Describe the solution you'd like**
The same output format options for `okteto namespace list`

**Describe alternatives you've considered**
Currently using some grep commands as an alternative.

**Additional context**
Looking at the code for namespaces and previews there seems to be two ways of constructing cobra commands. Is `cmd/preview/list.go` the preferred pattern if I was to contribute a similar function for `cmd/namespace/list.go`?

Pull request description:
Fixes #4714. Added output option to namespace list command to specify json or yaml
# Proposed changes

Fixes #4714 

Added an `output` option in the namespace list command analogous to the one we have in `preview list` command. Possible values are `json` or `yaml`, and it changes the output accordingly

## How to validate

Connect with an Okteto instance and run `okteto namespace list` with the CLI built from this branch to verify that the expected output is returned. If no output is specified, we return the plain output as we were doing until now

Samples of outputs:

```bash
> ok namespace list 
Namespace   Status
ifbyol *    Sleeping
jlopezbarb  Sleeping
```

```bash
> ok namespace list --output json
[
 {
  "namespace": "ifbyol",
  "status": "Sleeping",
  "current": true
 },
 {
  "namespace": "jlopezbarb",
  "status": "Sleeping",
  "current": false
 }
]
```

```bash
ok namespace list --output yaml
- namespace: ifbyol
  status: Sleeping
  current: true
- namespace: jlopezbarb
  status: Sleeping
  current: false
```

```bash
ok namespace list --output yam
 x  Output format is not accepted. Value must be one of: ['json', 'yaml']
```


Patch:
diff --git a/cmd/build/command.go b/cmd/build/command.go
index 6ea18845..d1c9ec96 100644
--- a/cmd/build/command.go
+++ b/cmd/build/command.go
@@ -106,7 +106,7 @@ func Build(ctx context.Context, ioCtrl *io.Controller, at, insights buildTracker
 			// The context must be loaded before reading manifest. Otherwise,
 			// secrets will not be resolved when GetManifest is called and
 			// the manifest will load empty values.
-			oktetoContext, err := getOktetoContext(ctx, options)
+			oktetoContext, err := getOktetoContext(ctx, options, ioCtrl)
 			if err != nil {
 				return err
 			}
@@ -224,7 +224,7 @@ func validateDockerfile(file string) error {
 	return err
 }
 
-func getOktetoContext(ctx context.Context, options *types.BuildOptions) (*okteto.ContextStateless, error) {
+func getOktetoContext(ctx context.Context, options *types.BuildOptions, ioCtrl *io.Controller) (*okteto.ContextStateless, error) {
 	ctxOpts := &contextCMD.Options{
 		Context:   options.K8sContext,
 		Namespace: options.Namespace,
@@ -248,7 +248,7 @@ func getOktetoContext(ctx context.Context, options *types.BuildOptions) (*okteto
 			return nil, err
 		}
 		if create {
-			if err := namespace.NewCommandStateless(c).Create(ctx, &namespace.CreateOptions{Namespace: ctxOpts.Namespace}); err != nil {
+			if err := namespace.NewCommandStateless(c, ioCtrl).Create(ctx, &namespace.CreateOptions{Namespace: ctxOpts.Namespace}); err != nil {
 				return nil, err
 			}
 		}
diff --git a/cmd/deploy/deploy.go b/cmd/deploy/deploy.go
index e9fa449a..2c17fd26 100644
--- a/cmd/deploy/deploy.go
+++ b/cmd/deploy/deploy.go
@@ -219,7 +219,7 @@ $ okteto deploy --no-build=true`,
 				return err
 			}
 			if create {
-				nsCmd, err := namespace.NewCommand()
+				nsCmd, err := namespace.NewCommand(ioCtrl)
 				if err != nil {
 					return err
 				}
diff --git a/cmd/namespace/create.go b/cmd/namespace/create.go
index 08cb6b52..7b02d3c3 100644
--- a/cmd/namespace/create.go
+++ b/cmd/namespace/create.go
@@ -23,6 +23,7 @@ import (
 	"github.com/okteto/okteto/pkg/analytics"
 	oktetoErrors "github.com/okteto/okteto/pkg/errors"
 	oktetoLog "github.com/okteto/okteto/pkg/log"
+	"github.com/okteto/okteto/pkg/log/io"
 	"github.com/okteto/okteto/pkg/okteto"
 	"github.com/spf13/cobra"
 )
@@ -36,7 +37,7 @@ type CreateOptions struct {
 }
 
 // Create creates a namespace
-func Create(ctx context.Context) *cobra.Command {
+func Create(ctx context.Context, ioCtrl *io.Controller) *cobra.Command {
 	options := &CreateOptions{
 		Show: false,
 	}
@@ -53,7 +54,7 @@ func Create(ctx context.Context) *cobra.Command {
 				return oktetoErrors.ErrContextIsNotOktetoCluster
 			}
 
-			nsCmd, err := NewCommand()
+			nsCmd, err := NewCommand(ioCtrl)
 			if err != nil {
 				return err
 			}
diff --git a/cmd/namespace/delete.go b/cmd/namespace/delete.go
index d5fc7771..6d1f2620 100644
--- a/cmd/namespace/delete.go
+++ b/cmd/namespace/delete.go
@@ -35,7 +35,7 @@ import (
 )
 
 // Delete deletes a namespace
-func Delete(ctx context.Context, k8sLogger *io.K8sLogger) *cobra.Command {
+func Delete(ctx context.Context, k8sLogger *io.K8sLogger, ioCtrl *io.Controller) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "delete <name>",
 		Short: "Delete an Okteto Namespace",
@@ -54,7 +54,7 @@ func Delete(ctx context.Context, k8sLogger *io.K8sLogger) *cobra.Command {
 				return oktetoErrors.ErrContextIsNotOktetoCluster
 			}
 
-			nsCmd, err := NewCommand()
+			nsCmd, err := NewCommand(ioCtrl)
 			if err != nil {
 				return err
 			}
diff --git a/cmd/namespace/list.go b/cmd/namespace/list.go
index 5735d697..3001b5a5 100644
--- a/cmd/namespace/list.go
+++ b/cmd/namespace/list.go
@@ -15,6 +15,8 @@ package namespace
 
 import (
 	"context"
+	"encoding/json"
+	"errors"
 	"fmt"
 	"os"
 	"text/tabwriter"
@@ -22,13 +24,31 @@ import (
 	contextCMD "github.com/okteto/okteto/cmd/context"
 	"github.com/okteto/okteto/cmd/utils"
 	oktetoErrors "github.com/okteto/okteto/pkg/errors"
+	"github.com/okteto/okteto/pkg/log/io"
 	"github.com/okteto/okteto/pkg/okteto"
+	"github.com/okteto/okteto/pkg/types"
 	"github.com/spf13/cobra"
+	"gopkg.in/yaml.v2"
 )
 
+var (
+	errInvalidOutput = errors.New("output format is not accepted. Value must be one of: ['json', 'yaml']")
+)
+
+type listFlags struct {
+	output string
+}
+
+type namespaceOutput struct {
+	Namespace string `json:"namespace" yaml:"namespace"`
+	Status    string `json:"status" yaml:"status"`
+	Current   bool   `json:"current" yaml:"current"`
+}
+
 // List all namespace in current context
-func List(ctx context.Context) *cobra.Command {
-	return &cobra.Command{
+func List(ctx context.Context, ioCtrl *io.Controller) *cobra.Command {
+	flags := &listFlags{}
+	cmd := &cobra.Command{
 		Use:     "list",
 		Short:   "List your Okteto Namespaces",
 		Aliases: []string{"ls"},
@@ -42,31 +62,91 @@ func List(ctx context.Context) *cobra.Command {
 				return oktetoErrors.ErrContextIsNotOktetoCluster
 			}
 
-			nsCmd, err := NewCommand()
+			nsCmd, err := NewCommand(ioCtrl)
 			if err != nil {
 				return err
 			}
-			err = nsCmd.executeListNamespaces(ctx)
+			err = nsCmd.executeListNamespaces(ctx, flags.output)
 			return err
 		},
 		Args: utils.NoArgsAccepted(""),
 	}
+
+	cmd.Flags().StringVarP(&flags.output, "output", "o", "", "output format. One of: ['json', 'yaml']")
+	return cmd
 }
 
-func (nc *Command) executeListNamespaces(ctx context.Context) error {
+func (nc *Command) executeListNamespaces(ctx context.Context, output string) error {
+	if err := validateNamespaceListOutput(output); err != nil {
+		return err
+	}
+
 	spaces, err := nc.okClient.Namespaces().List(ctx)
 	if err != nil {
 		return fmt.Errorf("failed to get namespaces: %w", err)
 	}
-	w := tabwriter.NewWriter(os.Stdout, 1, 1, 2, ' ', 0)
-	fmt.Fprintf(w, "Namespace\tStatus\n")
-	for _, space := range spaces {
-		if space.ID == okteto.GetContext().Namespace {
-			space.ID += " *"
+
+	namespaces := getNamespaceOutput(spaces)
+	return nc.displayListNamespaces(namespaces, output)
+}
+
+func (nc *Command) displayListNamespaces(namespaces []namespaceOutput, output string) error {
+	switch output {
+	case "json":
+		if len(namespaces) == 0 {
+			nc.ioCtrl.Out().Println("[]")
+			return nil
+		}
+		b, err := json.MarshalIndent(namespaces, "", " ")
+		if err != nil {
+			return err
 		}
-		fmt.Fprintf(w, "%s\t%v\n", space.ID, space.Status)
+		nc.ioCtrl.Out().Println(string(b))
+	case "yaml":
+		b, err := yaml.Marshal(namespaces)
+		if err != nil {
+			return err
+		}
+		nc.ioCtrl.Out().Print(string(b))
+	default:
+		if len(namespaces) == 0 {
+			nc.ioCtrl.Out().Println("There are no namespaces")
+			return nil
+		}
+		w := tabwriter.NewWriter(os.Stdout, 1, 1, 2, ' ', 0)
+		fmt.Fprintf(w, "Namespace\tStatus\n")
+		for _, space := range namespaces {
+			id := space.Namespace
+			if id == okteto.GetContext().Namespace {
+				id += " *"
+			}
+			fmt.Fprintf(w, "%s\t%v\n", id, space.Status)
+		}
+		w.Flush()
 	}
-
-	w.Flush()
 	return nil
 }
+
+func validateNamespaceListOutput(output string) error {
+	switch output {
+	case "", "json", "yaml":
+		return nil
+	default:
+		return errInvalidOutput
+	}
+}
+
+// getNamespaceOutput transforms type.Namespace into namespaceOutput type
+func getNamespaceOutput(namespaces []types.Namespace) []namespaceOutput {
+	var namespaceSlice []namespaceOutput
+	currentNamespace := okteto.GetContext().Namespace
+	for _, ns := range namespaces {
+		previewOutput := namespaceOutput{
+			Namespace: ns.ID,
+			Status:    ns.Status,
+			Current:   ns.ID == currentNamespace,
+		}
+		namespaceSlice = append(namespaceSlice, previewOutput)
+	}
+	return namespaceSlice
+}
diff --git a/cmd/namespace/namespace.go b/cmd/namespace/namespace.go
index 9cf508b9..5e18eeec 100644
--- a/cmd/namespace/namespace.go
+++ b/cmd/namespace/namespace.go
@@ -29,10 +29,11 @@ type Command struct {
 	ctxCmd            *contextCMD.Command
 	okClient          types.OktetoInterface
 	k8sClientProvider okteto.K8sClientProviderWithLogger
+	ioCtrl            *io.Controller
 }
 
 // NewCommand creates a namespace command for use in further operations
-func NewCommand() (*Command, error) {
+func NewCommand(ioCtrl *io.Controller) (*Command, error) {
 	c, err := okteto.NewOktetoClient()
 	if err != nil {
 		return nil, err
@@ -42,35 +43,37 @@ func NewCommand() (*Command, error) {
 		ctxCmd:            contextCMD.NewContextCommand(),
 		okClient:          c,
 		k8sClientProvider: okteto.NewK8sClientProviderWithLogger(nil),
+		ioCtrl:            ioCtrl,
 	}, nil
 }
 
 // NewCommandStateless creates a namespace command for use in further operations
-func NewCommandStateless(c *okteto.Client) *Command {
+func NewCommandStateless(c *okteto.Client, ioCtrl *io.Controller) *Command {
 	return &Command{
 		ctxCmd:            contextCMD.NewContextCommand(),
 		okClient:          c,
 		k8sClientProvider: okteto.NewK8sClientProviderWithLogger(nil),
+		ioCtrl:            ioCtrl,
 	}
 }
 
 // Namespace fetch credentials for a cluster namespace
-func Namespace(ctx context.Context, k8sLogger *io.K8sLogger) *cobra.Command {
+func Namespace(ctx context.Context, k8sLogger *io.K8sLogger, ioCtrl *io.Controller) *cobra.Command {
 	options := &UseOptions{}
 	cmd := &cobra.Command{
 		Use:     "namespace",
 		Short:   "Configure the default namespace of the Okteto Context",
 		Aliases: []string{"ns"},
 		Args:    utils.MaximumNArgsAccepted(1, "https://okteto.com/docs/reference/okteto-cli/#namespace"),
-		RunE:    Use(ctx).RunE,
+		RunE:    Use(ctx, ioCtrl).RunE,
 	}
 	cmd.Flags().BoolVarP(&options.personal, "personal", "", false, "Load personal namespace")
 
-	cmd.AddCommand(Use(ctx))
-	cmd.AddCommand(List(ctx))
-	cmd.AddCommand(Create(ctx))
-	cmd.AddCommand(Delete(ctx, k8sLogger))
-	cmd.AddCommand(Sleep(ctx))
-	cmd.AddCommand(Wake(ctx))
+	cmd.AddCommand(Use(ctx, ioCtrl))
+	cmd.AddCommand(List(ctx, ioCtrl))
+	cmd.AddCommand(Create(ctx, ioCtrl))
+	cmd.AddCommand(Delete(ctx, k8sLogger, ioCtrl))
+	cmd.AddCommand(Sleep(ctx, ioCtrl))
+	cmd.AddCommand(Wake(ctx, ioCtrl))
 	return cmd
 }
diff --git a/cmd/namespace/sleep.go b/cmd/namespace/sleep.go
index 91369a27..783fb927 100644
--- a/cmd/namespace/sleep.go
+++ b/cmd/namespace/sleep.go
@@ -21,12 +21,13 @@ import (
 	"github.com/okteto/okteto/cmd/utils"
 	oktetoErrors "github.com/okteto/okteto/pkg/errors"
 	oktetoLog "github.com/okteto/okteto/pkg/log"
+	"github.com/okteto/okteto/pkg/log/io"
 	"github.com/okteto/okteto/pkg/okteto"
 	"github.com/spf13/cobra"
 )
 
 // Sleep sleeps a namespace
-func Sleep(ctx context.Context) *cobra.Command {
+func Sleep(ctx context.Context, ioCtrl *io.Controller) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "sleep <name>",
 		Short: "Sleeps a namespace",
@@ -45,7 +46,7 @@ func Sleep(ctx context.Context) *cobra.Command {
 				return oktetoErrors.ErrContextIsNotOktetoCluster
 			}
 
-			nsCmd, err := NewCommand()
+			nsCmd, err := NewCommand(ioCtrl)
 			if err != nil {
 				return err
 			}
diff --git a/cmd/namespace/use.go b/cmd/namespace/use.go
index ff91db1e..5f48b4d9 100644
--- a/cmd/namespace/use.go
+++ b/cmd/namespace/use.go
@@ -22,6 +22,7 @@ import (
 	"github.com/okteto/okteto/pkg/analytics"
 	oktetoErrors "github.com/okteto/okteto/pkg/errors"
 	oktetoLog "github.com/okteto/okteto/pkg/log"
+	"github.com/okteto/okteto/pkg/log/io"
 	"github.com/okteto/okteto/pkg/okteto"
 	"github.com/spf13/cobra"
 )
@@ -36,7 +37,7 @@ type UseOptions struct {
 }
 
 // Use sets the namespace of current context
-func Use(ctx context.Context) *cobra.Command {
+func Use(ctx context.Context, ioCtrl *io.Controller) *cobra.Command {
 	options := &UseOptions{}
 	cmd := &cobra.Command{
 		Use:     "use [namespace]",
@@ -57,7 +58,7 @@ func Use(ctx context.Context) *cobra.Command {
 				namespace = okteto.GetContext().PersonalNamespace
 			}
 
-			nsCmd, err := NewCommand()
+			nsCmd, err := NewCommand(ioCtrl)
 			if err != nil {
 				return err
 			}
diff --git a/cmd/namespace/wake.go b/cmd/namespace/wake.go
index 63d3e1d1..5f0f715d 100644
--- a/cmd/namespace/wake.go
+++ b/cmd/namespace/wake.go
@@ -21,11 +21,12 @@ import (
 	"github.com/okteto/okteto/cmd/utils"
 	oktetoErrors "github.com/okteto/okteto/pkg/errors"
 	oktetoLog "github.com/okteto/okteto/pkg/log"
+	"github.com/okteto/okteto/pkg/log/io"
 	"github.com/okteto/okteto/pkg/okteto"
 	"github.com/spf13/cobra"
 )
 
-func Wake(ctx context.Context) *cobra.Command {
+func Wake(ctx context.Context, ioCtrl *io.Controller) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "wake <name>",
 		Short: "Wakes an Okteto Namespace. By default, it wakes the default namespace in the Okteto Context",
@@ -43,7 +44,7 @@ func Wake(ctx context.Context) *cobra.Command {
 				return oktetoErrors.ErrContextIsNotOktetoCluster
 			}
 
-			nsCmd, err := NewCommand()
+			nsCmd, err := NewCommand(ioCtrl)
 			if err != nil {
 				return err
 			}
diff --git a/cmd/up/up.go b/cmd/up/up.go
index 6c823fc0..72919d41 100644
--- a/cmd/up/up.go
+++ b/cmd/up/up.go
@@ -184,7 +184,7 @@ okteto up api -- echo this is a test
 					return err
 				}
 				if create {
-					nsCmd, err := namespace.NewCommand()
+					nsCmd, err := namespace.NewCommand(ioCtrl)
 					if err != nil {
 						return err
 					}
diff --git a/main.go b/main.go
index dfeaccfb..d4408269 100644
--- a/main.go
+++ b/main.go
@@ -158,7 +158,7 @@ func main() {
 
 	root.AddCommand(build.Build(ctx, ioController, at, insights, k8sLogger))
 
-	root.AddCommand(namespace.Namespace(ctx, k8sLogger))
+	root.AddCommand(namespace.Namespace(ctx, k8sLogger, ioController))
 	root.AddCommand(up.Up(at, insights, ioController, k8sLogger, fs))
 	root.AddCommand(cmd.Down(at, k8sLogger, fs))
 	root.AddCommand(cmd.Status(fs))


Test patch:
diff --git a/cmd/namespace/list_test.go b/cmd/namespace/list_test.go
index d6de6472..3665db5b 100644
--- a/cmd/namespace/list_test.go
+++ b/cmd/namespace/list_test.go
@@ -16,9 +16,12 @@ package namespace
 import (
 	"context"
 	"fmt"
+	"io"
+	"os"
 	"testing"
 
 	"github.com/okteto/okteto/internal/test/client"
+	oktetoio "github.com/okteto/okteto/pkg/log/io"
 	"github.com/okteto/okteto/pkg/okteto"
 	"github.com/okteto/okteto/pkg/types"
 	"github.com/stretchr/testify/assert"
@@ -73,7 +76,7 @@ func Test_listNamespace(t *testing.T) {
 				okClient: fakeOktetoClient,
 				ctxCmd:   newFakeContextCommand(fakeOktetoClient, usr),
 			}
-			err := nsCmd.executeListNamespaces(ctx)
+			err := nsCmd.executeListNamespaces(ctx, "")
 			if tt.err != nil {
 				assert.Error(t, err)
 			} else {
@@ -83,3 +86,108 @@ func Test_listNamespace(t *testing.T) {
 		})
 	}
 }
+
+func Test_validateNamespaceListOutput(t *testing.T) {
+	tests := []struct {
+		name        string
+		output      string
+		expectedErr error
+	}{
+		{
+			name:   "yaml output",
+			output: "yaml",
+		},
+		{
+			name:   "json output",
+			output: "json",
+		},
+		{
+			name:        "invalid output",
+			output:      "xml",
+			expectedErr: errInvalidOutput,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := validateNamespaceListOutput(tt.output)
+			assert.Equal(t, tt.expectedErr, err)
+		})
+	}
+}
+
+func Test_displayListNamespaces(t *testing.T) {
+	tests := []struct {
+		name           string
+		format         string
+		input          []namespaceOutput
+		expectedOutput string
+	}{
+		{
+			name:           "empty default",
+			format:         "",
+			expectedOutput: "There are no namespaces\n",
+		},
+		{
+			name:           "empty json",
+			format:         "json",
+			expectedOutput: "[]\n",
+		},
+		{
+			name:   "default format",
+			format: "",
+			input: []namespaceOutput{
+				{Namespace: "test", Status: "Active", Current: true},
+				{Namespace: "test2", Status: "Sleeping", Current: false},
+			},
+			expectedOutput: "Namespace  Status\ntest *     Active\ntest2      Sleeping\n",
+		},
+		{
+			name:   "json format",
+			format: "json",
+			input: []namespaceOutput{
+				{Namespace: "test", Status: "Active", Current: true},
+				{Namespace: "test2", Status: "Sleeping", Current: false},
+			},
+			expectedOutput: "[\n {\n  \"namespace\": \"test\",\n  \"status\": \"Active\",\n  \"current\": true\n },\n {\n  \"namespace\": \"test2\",\n  \"status\": \"Sleeping\",\n  \"current\": false\n }\n]\n",
+		},
+		{
+			name:   "yaml format",
+			format: "yaml",
+			input: []namespaceOutput{
+				{Namespace: "test", Status: "Active", Current: true},
+				{Namespace: "test2", Status: "Sleeping", Current: false},
+			},
+			expectedOutput: "- namespace: test\n  status: Active\n  current: true\n- namespace: test2\n  status: Sleeping\n  current: false\n",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			okteto.CurrentStore = &okteto.ContextStore{
+				Contexts: map[string]*okteto.Context{
+					"test": {
+						Namespace: "test",
+						IsOkteto:  true,
+					},
+				},
+				CurrentContext: "test",
+			}
+			r, w, _ := os.Pipe()
+			initialStdout := os.Stdout
+			os.Stdout = w
+			nsCmd := &Command{
+				ioCtrl: oktetoio.NewIOController(),
+			}
+
+			err := nsCmd.displayListNamespaces(tt.input, tt.format)
+			assert.NoError(t, err)
+
+			w.Close()
+			out, _ := io.ReadAll(r)
+			os.Stdout = initialStdout
+
+			assert.Equal(t, tt.expectedOutput, string(out))
+		})
+	}
+}
diff --git a/cmd/test/cmd.go b/cmd/test/cmd.go
index 74359dd0..545135af 100644
--- a/cmd/test/cmd.go
+++ b/cmd/test/cmd.go
@@ -154,7 +154,7 @@ func doRun(ctx context.Context, servicesToTest []string, options *Options, ioCtr
 		return analytics.TestMetadata{}, err
 	}
 	if create {
-		nsCmd, err := namespace.NewCommand()
+		nsCmd, err := namespace.NewCommand(ioCtrl)
 		if err != nil {
 			return analytics.TestMetadata{}, err
 		}