diff --git a/.changelog/4785.txt b/.changelog/4785.txt new file mode 100644 index 0000000000..e3599f31d1 --- /dev/null +++ b/.changelog/4785.txt @@ -0,0 +1,3 @@ +```release-note:feature +cli: add new output format "archive" to proxy list command. It will write command output to json file. +``` diff --git a/cli/cmd/proxy/list/command.go b/cli/cmd/proxy/list/command.go index 7d7fe6e4a7..47ddbc6419 100644 --- a/cli/cmd/proxy/list/command.go +++ b/cli/cmd/proxy/list/command.go @@ -7,6 +7,9 @@ import ( "encoding/json" "errors" "fmt" + "os" + "path/filepath" + "slices" "sort" "strings" "sync" @@ -30,7 +33,16 @@ const ( flagNameAllNamespaces = "all-namespaces" flagNameKubeConfig = "kubeconfig" flagNameKubeContext = "context" - flagOutputFormat = "output-format" + flagNameOutputFormat = "output" +) +const ( + opFormatTable = "table" + opFormatJSON = "json" + opFormatArchive = "archive" +) +const ( + filePerm = 0644 + dirPerm = 0755 ) // ListCommand is the command struct for the proxy list command. @@ -71,8 +83,8 @@ func (c *ListCommand) init() { Aliases: []string{"A"}, }) f.StringVar(&flag.StringVar{ - Name: flagOutputFormat, - Default: "table", + Name: flagNameOutputFormat, + Default: opFormatTable, Target: &c.flagOutputFormat, Usage: "Output format", Aliases: []string{"o"}, @@ -151,7 +163,7 @@ func (c *ListCommand) AutocompleteFlags() complete.Flags { fmt.Sprintf("-%s", flagNameAllNamespaces): complete.PredictNothing, fmt.Sprintf("-%s", flagNameKubeConfig): complete.PredictFiles("*"), fmt.Sprintf("-%s", flagNameKubeContext): complete.PredictNothing, - fmt.Sprintf("-%s", flagOutputFormat): complete.PredictNothing, + fmt.Sprintf("-%s", flagNameOutputFormat): complete.PredictNothing, } } @@ -170,6 +182,9 @@ func (c *ListCommand) validateFlags() error { if errs := validation.ValidateNamespaceName(c.flagNamespace, false); c.flagNamespace != "" && len(errs) > 0 { return fmt.Errorf("invalid namespace name passed for -namespace/-n: %v", strings.Join(errs, "; ")) } + if outputs := []string{opFormatArchive, opFormatJSON, opFormatTable}; !slices.Contains(outputs, c.flagOutputFormat) { + return fmt.Errorf("-output must be one of %s.", strings.Join(outputs, ", ")) + } return nil } @@ -321,15 +336,38 @@ func (c *ListCommand) output(pods []v1.Pod) { } } - if c.flagOutputFormat == "json" { + switch c.flagOutputFormat { + case opFormatJSON: tableJson := tbl.ToJson() jsonSt, err := json.MarshalIndent(tableJson, "", " ") if err != nil { - c.UI.Output("Error converting table to json: %v", err.Error(), terminal.WithErrorStyle()) + c.UI.Output("error converting table to json: %v", err.Error(), terminal.WithErrorStyle()) } else { c.UI.Output(string(jsonSt)) } - } else { + case opFormatArchive: + tableJson := tbl.ToJson() + jsonSt, err := json.MarshalIndent(tableJson, "", " ") + if err != nil { + c.UI.Output("error converting proxy list output to json: %v", err.Error(), terminal.WithErrorStyle()) + } + + // Create file path and directory for storing proxy list + // NOTE: currently it is writing stats file in cwd '/proxy' only. Also, file contents will be overwritten + // if the command is run multiple times or if file already exists. + proxyListFilePath := filepath.Join("proxy", "proxy-list.json") + err = os.MkdirAll(filepath.Dir(proxyListFilePath), dirPerm) + if err != nil { + fmt.Printf("error creating proxy list output directory: %v", err) + } + err = os.WriteFile(proxyListFilePath, jsonSt, filePerm) + if err != nil { + // Note: Please do not delete the directory created above even if writing file fails. + // This (/proxy) directory is used by all proxy read, log, list, stats command, for storing their outputs as archive. + fmt.Printf("error writing proxy list output to json file: %v", err) + } + c.UI.Output("proxy list output saved to '%s'", proxyListFilePath, terminal.WithSuccessStyle()) + default: // opFormatTable if !c.flagAllNamespaces { c.UI.Output("Namespace: %s\n", c.namespace()) } diff --git a/cli/cmd/proxy/list/command_test.go b/cli/cmd/proxy/list/command_test.go index d0ad84fa0b..3d534f7212 100644 --- a/cli/cmd/proxy/list/command_test.go +++ b/cli/cmd/proxy/list/command_test.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "os" + "path/filepath" "testing" "github.com/hashicorp/go-hclog" @@ -43,6 +44,10 @@ func TestFlagParsing(t *testing.T) { args: []string{"-namespace", "YOLO"}, out: 1, }, + "Invalid argument passed, -output-format pdf ": { + args: []string{"-namespace", "YOLO"}, + out: 1, + }, } for name, tc := range cases { @@ -452,6 +457,127 @@ func TestListCommandOutputInJsonFormat(t *testing.T) { assert.Equal(t, "terminating-gateway", actual[5].Name) assert.Equal(t, "pod1", actual[6].Name) } +func TestListCommandOutputInArchiveFormat(t *testing.T) { + pods := []v1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "ingress-gateway", + Namespace: "default", + Labels: map[string]string{ + "component": "ingress-gateway", + "chart": "consul-helm", + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "mesh-gateway", + Namespace: "consul", + Labels: map[string]string{ + "component": "mesh-gateway", + "chart": "consul-helm", + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "terminating-gateway", + Namespace: "consul", + Labels: map[string]string{ + "component": "terminating-gateway", + "chart": "consul-helm", + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "api-gateway", + Namespace: "consul", + Labels: map[string]string{ + "component": "api-gateway", + "gateway.consul.hashicorp.com/managed": "true", + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "both-labels-api-gateway", + Namespace: "consul", + Labels: map[string]string{ + "api-gateway.consul.hashicorp.com/managed": "true", + "component": "api-gateway", + "chart": "consul-helm", + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "deprecated-api-gateway", + Namespace: "consul", + Labels: map[string]string{ + "api-gateway.consul.hashicorp.com/managed": "true", + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "dont-fetch", + Namespace: "default", + Labels: map[string]string{}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Labels: map[string]string{ + "consul.hashicorp.com/connect-inject-status": "injected", + }, + }, + }, + } + client := fake.NewSimpleClientset(&v1.PodList{Items: pods}) + + // Create a temporary directory for this test run. + tempDir := t.TempDir() + + // Change the current working directory to our temporary directory. + originalWD, err := os.Getwd() + require.NoError(t, err) + err = os.Chdir(tempDir) + require.NoError(t, err) + defer os.Chdir(originalWD) // Ensure we change back. + + buf := new(bytes.Buffer) + c := setupCommand(buf) + c.kubernetes = client + + out := c.Run([]string{"-A", "-o", "archive"}) + require.Equal(t, 0, out) + + expectedFilePath := filepath.Join(tempDir, "proxy", "proxy-list.json") + _, err = os.Stat(expectedFilePath) + require.NoError(t, err, "expected output file to be created, but it was not") + + actualJSON, err := os.ReadFile(expectedFilePath) + require.NoError(t, err) + + var actual []struct { + Name string `json:"Name"` + Namespace string `json:"Namespace"` + Type string `json:"Type"` + } + require.NoErrorf(t, json.Unmarshal(actualJSON, &actual), "failed to parse json output: %s", actualJSON) + + require.Len(t, actual, 7) + assert.Equal(t, "api-gateway", actual[0].Name) + assert.Equal(t, "both-labels-api-gateway", actual[1].Name) + assert.Equal(t, "deprecated-api-gateway", actual[2].Name) + assert.Equal(t, "ingress-gateway", actual[3].Name) + assert.Equal(t, "mesh-gateway", actual[4].Name) + assert.Equal(t, "terminating-gateway", actual[5].Name) + assert.Equal(t, "pod1", actual[6].Name) +} func TestNoPodsFound(t *testing.T) { cases := map[string]struct {