diff --git a/pkg/cmd/clustertriggerbinding/list.go b/pkg/cmd/clustertriggerbinding/list.go index f0ec246877..8fcfc86444 100644 --- a/pkg/cmd/clustertriggerbinding/list.go +++ b/pkg/cmd/clustertriggerbinding/list.go @@ -34,6 +34,7 @@ const ( type listOptions struct { NoHeaders bool + Fields []string } func listCommand(p cli.Params) *cobra.Command { @@ -58,6 +59,15 @@ or }, Example: eg, RunE: func(cmd *cobra.Command, _ []string) error { + output, err := cmd.LocalFlags().GetString("output") + if err != nil { + return errors.New("output option not set properly") + } + + if len(opts.Fields) > 0 && output != "ndjson" { + return fmt.Errorf("--fields is only supported with --output ndjson") + } + cs, err := p.Clients() if err != nil { return err @@ -68,17 +78,15 @@ or return fmt.Errorf("failed to list ClusterTriggerBindings: %v", err) } - output, err := cmd.LocalFlags().GetString("output") - if err != nil { - return errors.New("output option not set properly") - } - stream := &cli.Stream{ Out: cmd.OutOrStdout(), Err: cmd.OutOrStderr(), } - if output == "name" && tbs != nil { + switch { + case output == "ndjson" && tbs != nil: + return formatted.PrintNDJSON(stream.Out, tbs, opts.Fields) + case output == "name" && tbs != nil: w := cmd.OutOrStdout() for _, pr := range tbs.Items { _, err := fmt.Fprintf(w, "clustertriggerbinding.triggers.tekton.dev/%s\n", pr.Name) @@ -87,7 +95,7 @@ or } } return nil - } else if output != "" { + case output != "": p, err := f.ToPrinter() if err != nil { return err @@ -105,6 +113,7 @@ or f.AddFlags(c) c.Flags().BoolVar(&opts.NoHeaders, "no-headers", opts.NoHeaders, "do not print column headers with output (default print column headers with output)") + c.Flags().StringSliceVar(&opts.Fields, "fields", opts.Fields, "Comma-separated list of fields to include in output (e.g. metadata.name,status.startTime); only used with --output ndjson") return c } diff --git a/pkg/cmd/customrun/list.go b/pkg/cmd/customrun/list.go index 0a83778502..3c02da3c76 100644 --- a/pkg/cmd/customrun/list.go +++ b/pkg/cmd/customrun/list.go @@ -55,6 +55,7 @@ type ListOptions struct { Reverse bool AllNamespaces bool NoHeaders bool + Fields []string } func listCommand(p cli.Params) *cobra.Command { @@ -79,6 +80,15 @@ func listCommand(p cli.Params) *cobra.Command { return fmt.Errorf("limit was %d but must be a positive number", opts.Limit) } + output, err := cmd.LocalFlags().GetString("output") + if err != nil { + return fmt.Errorf("output option not set properly: %v", err) + } + + if len(opts.Fields) > 0 && output != "ndjson" { + return fmt.Errorf("--fields is only supported with --output ndjson") + } + crs, err := list(p, opts.Limit, opts.LabelSelector, opts.AllNamespaces) if err != nil { return fmt.Errorf("failed to list CustomRuns from namespace %s: %v", p.Namespace(), err) @@ -87,12 +97,10 @@ func listCommand(p cli.Params) *cobra.Command { if crs != nil && opts.Reverse { reverse(crs) } - - output, err := cmd.LocalFlags().GetString("output") - if err != nil { - return fmt.Errorf("output option not set properly: %v", err) - } - if output == "name" && crs != nil { + switch { + case output == "ndjson" && crs != nil: + return formatted.PrintNDJSON(cmd.OutOrStdout(), crs, opts.Fields) + case output == "name" && crs != nil: w := cmd.OutOrStdout() for _, tr := range crs.Items { _, err := fmt.Fprintf(w, "customrun.tekton.dev/%s\n", tr.Name) @@ -101,7 +109,7 @@ func listCommand(p cli.Params) *cobra.Command { } } return nil - } else if output != "" && crs != nil { + case output != "" && crs != nil: p, err := f.ToPrinter() if err != nil { return err @@ -133,6 +141,7 @@ func listCommand(p cli.Params) *cobra.Command { c.Flags().BoolVarP(&opts.Reverse, "reverse", "", opts.Reverse, "list CustomRuns in reverse order") c.Flags().BoolVarP(&opts.AllNamespaces, "all-namespaces", "A", opts.AllNamespaces, "list CustomRuns from all namespaces") c.Flags().BoolVarP(&opts.NoHeaders, "no-headers", "", opts.NoHeaders, "do not print column headers with output (default print column headers with output)") + c.Flags().StringSliceVar(&opts.Fields, "fields", opts.Fields, "Comma-separated list of fields to include in output (e.g. metadata.name,status.startTime); only used with --output ndjson") return c } diff --git a/pkg/cmd/eventlistener/list.go b/pkg/cmd/eventlistener/list.go index dc6b6eff8e..6e1b38f1c9 100644 --- a/pkg/cmd/eventlistener/list.go +++ b/pkg/cmd/eventlistener/list.go @@ -36,6 +36,7 @@ const ( type listOptions struct { AllNamespaces bool NoHeaders bool + Fields []string } func listCommand(p cli.Params) *cobra.Command { @@ -60,6 +61,15 @@ or }, Example: eg, RunE: func(cmd *cobra.Command, _ []string) error { + output, err := cmd.LocalFlags().GetString("output") + if err != nil { + return errors.New(`output option not set properly \n`) + } + + if len(opts.Fields) > 0 && output != "ndjson" { + return fmt.Errorf("--fields is only supported with --output ndjson") + } + cs, err := p.Clients() if err != nil { return err @@ -78,17 +88,14 @@ or return fmt.Errorf("failed to list EventListeners from %s namespace: %v", namespace, err) } - output, err := cmd.LocalFlags().GetString("output") - if err != nil { - return errors.New(`output option not set properly \n`) - } - stream := &cli.Stream{ Out: cmd.OutOrStdout(), Err: cmd.OutOrStderr(), } - if output != "" { + if output == "ndjson" { + return formatted.PrintNDJSON(stream.Out, els, opts.Fields) + } else if output != "" { p, err := f.ToPrinter() if err != nil { return err @@ -106,6 +113,7 @@ or f.AddFlags(c) c.Flags().BoolVarP(&opts.AllNamespaces, "all-namespaces", "A", opts.AllNamespaces, "list EventListeners from all namespaces") c.Flags().BoolVar(&opts.NoHeaders, "no-headers", opts.NoHeaders, "do not print column headers with output (default print column headers with output)") + c.Flags().StringSliceVar(&opts.Fields, "fields", opts.Fields, "Comma-separated list of fields to include in output (e.g. metadata.name,status.startTime); only used with --output ndjson") return c } diff --git a/pkg/cmd/pipeline/list.go b/pkg/cmd/pipeline/list.go index 57b22ed381..3688077e42 100644 --- a/pkg/cmd/pipeline/list.go +++ b/pkg/cmd/pipeline/list.go @@ -61,6 +61,7 @@ NAME AGE LAST RUN STARTED DURATION STATUS type ListOptions struct { AllNamespaces bool NoHeaders bool + Fields []string } func listCommand(p cli.Params) *cobra.Command { @@ -76,14 +77,18 @@ func listCommand(p cli.Params) *cobra.Command { }, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { - cs, err := p.Clients() + output, err := cmd.LocalFlags().GetString("output") if err != nil { - return err + return fmt.Errorf("output option not set properly: %v", err) } - output, err := cmd.LocalFlags().GetString("output") + if len(opts.Fields) > 0 && output != "ndjson" { + return fmt.Errorf("--fields is only supported with --output ndjson") + } + + cs, err := p.Clients() if err != nil { - return fmt.Errorf("output option not set properly: %v", err) + return err } ns := p.Namespace() @@ -91,7 +96,17 @@ func listCommand(p cli.Params) *cobra.Command { ns = "" } - if output != "" { + if output == "ndjson" { + var pl *v1.PipelineList + if err := actions.ListV1(pipelineGroupResource, cs, metav1.ListOptions{}, ns, &pl); err != nil { + scope := fmt.Sprintf("namespace %q", ns) + if ns == "" { + scope = "all namespaces" + } + return fmt.Errorf("failed to list Pipelines from %s: %w", scope, err) + } + return formatted.PrintNDJSON(cmd.OutOrStdout(), pl, opts.Fields) + } else if output != "" { p, err := f.ToPrinter() if err != nil { return err @@ -108,6 +123,7 @@ func listCommand(p cli.Params) *cobra.Command { f.AddFlags(c) c.Flags().BoolVarP(&opts.AllNamespaces, "all-namespaces", "A", opts.AllNamespaces, "list Pipelines from all namespaces") c.Flags().BoolVarP(&opts.NoHeaders, "no-headers", "", opts.NoHeaders, "do not print column headers with output (default print column headers with output)") + c.Flags().StringSliceVar(&opts.Fields, "fields", opts.Fields, "Comma-separated list of fields to include in output (e.g. metadata.name,status.startTime); only used with --output ndjson") return c } diff --git a/pkg/cmd/pipeline/list_test.go b/pkg/cmd/pipeline/list_test.go index cdf6251c66..e56c90a3eb 100644 --- a/pkg/cmd/pipeline/list_test.go +++ b/pkg/cmd/pipeline/list_test.go @@ -15,7 +15,9 @@ package pipeline import ( + "encoding/json" "fmt" + "strings" "testing" "time" @@ -1167,3 +1169,109 @@ func TestPipelineList_in_all_namespaces_with_output_yaml_flag(t *testing.T) { golden.Assert(t, output, fmt.Sprintf("%s.golden", t.Name())) } + +// TestListPipelines_ndjson verifies the --output ndjson path in the pipeline list command. +func TestListPipelines_ndjson(t *testing.T) { + version := "v1" + clock := test.FakeClock() + + pdata := []*v1.Pipeline{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pipe-a", + Namespace: "namespace", + CreationTimestamp: metav1.Time{Time: clock.Now().Add(-1 * time.Minute)}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pipe-b", + Namespace: "namespace", + CreationTimestamp: metav1.Time{Time: clock.Now().Add(-2 * time.Minute)}, + }, + }, + } + + ns := []*corev1.Namespace{ + {ObjectMeta: metav1.ObjectMeta{Name: "namespace"}}, + } + + tdc := testDynamic.Options{} + dynamic, err := tdc.Client( + cb.UnstructuredP(pdata[0], version), + cb.UnstructuredP(pdata[1], version), + ) + if err != nil { + t.Fatalf("unable to create dynamic client: %v", err) + } + + cs, _ := test.SeedTestData(t, pipelinetest.Data{Pipelines: pdata, Namespaces: ns}) + cs.Pipeline.Resources = cb.APIResourceList(version, []string{"pipeline"}) + p := &test.Params{Tekton: cs.Pipeline, Clock: clock, Kube: cs.Kube, Dynamic: dynamic} + + t.Run("ndjson output produces valid JSON lines", func(t *testing.T) { + cmd := Command(p) + output, err := test.ExecuteCommand(cmd, "list", "-n", "namespace", "-o", "ndjson") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + lines := pipelineSplitNonEmpty(output) + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d:\n%s", len(lines), output) + } + for i, line := range lines { + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Errorf("line %d is not valid JSON: %v", i, err) + } + } + }) + + t.Run("ndjson with --fields narrows output", func(t *testing.T) { + cmd := Command(p) + output, err := test.ExecuteCommand(cmd, "list", "-n", "namespace", "-o", "ndjson", "--fields", "metadata.name") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + lines := pipelineSplitNonEmpty(output) + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + for i, line := range lines { + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Errorf("line %d is not valid JSON: %v", i, err) + continue + } + meta, ok := m["metadata"].(map[string]any) + if !ok { + t.Errorf("line %d: expected metadata key", i) + continue + } + if _, ok := meta["name"]; !ok { + t.Errorf("line %d: expected metadata.name", i) + } + if len(m) != 1 { + t.Errorf("line %d: expected only metadata key, got %v", i, m) + } + } + }) + + t.Run("--fields without ndjson returns error", func(t *testing.T) { + cmd := Command(p) + _, err := test.ExecuteCommand(cmd, "list", "-n", "namespace", "--fields", "metadata.name") + if err == nil { + t.Error("expected error when --fields used without --output ndjson, got none") + } + }) +} + +func pipelineSplitNonEmpty(s string) []string { + var out []string + for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") { + if line != "" { + out = append(out, line) + } + } + return out +} diff --git a/pkg/cmd/pipelinerun/list.go b/pkg/cmd/pipelinerun/list.go index bd9fd2fbb2..71070f8b26 100644 --- a/pkg/cmd/pipelinerun/list.go +++ b/pkg/cmd/pipelinerun/list.go @@ -53,6 +53,7 @@ type ListOptions struct { Reverse bool AllNamespaces bool NoHeaders bool + Fields []string } func listCommand(p cli.Params) *cobra.Command { @@ -87,6 +88,15 @@ List all PipelineRuns in a namespace 'foo': return fmt.Errorf("limit was %d but must be a positive number", opts.Limit) } + output, err := cmd.LocalFlags().GetString("output") + if err != nil { + return fmt.Errorf("output option not set properly: %v", err) + } + + if len(opts.Fields) > 0 && output != "ndjson" { + return fmt.Errorf("--fields is only supported with --output ndjson") + } + prs, err := list(p, pipeline, opts.Limit, opts.LabelSelector, opts.AllNamespaces) if err != nil { return fmt.Errorf("failed to list PipelineRuns from namespace %s: %v", p.Namespace(), err) @@ -96,12 +106,10 @@ List all PipelineRuns in a namespace 'foo': reverse(prs) } - output, err := cmd.LocalFlags().GetString("output") - if err != nil { - return fmt.Errorf("output option not set properly: %v", err) - } - - if output == "name" && prs != nil { + switch { + case output == "ndjson" && prs != nil: + return formatted.PrintNDJSON(cmd.OutOrStdout(), prs, opts.Fields) + case output == "name" && prs != nil: w := cmd.OutOrStdout() for _, pr := range prs.Items { _, err := fmt.Fprintf(w, "pipelinerun.tekton.dev/%s\n", pr.Name) @@ -110,7 +118,7 @@ List all PipelineRuns in a namespace 'foo': } } return nil - } else if output != "" && prs != nil { + case output != "" && prs != nil: p, err := f.ToPrinter() if err != nil { return err @@ -139,6 +147,7 @@ List all PipelineRuns in a namespace 'foo': c.Flags().BoolVarP(&opts.Reverse, "reverse", "", opts.Reverse, "list PipelineRuns in reverse order") c.Flags().BoolVarP(&opts.AllNamespaces, "all-namespaces", "A", opts.AllNamespaces, "list PipelineRuns from all namespaces") c.Flags().BoolVarP(&opts.NoHeaders, "no-headers", "", opts.NoHeaders, "do not print column headers with output (default print column headers with output)") + c.Flags().StringSliceVar(&opts.Fields, "fields", opts.Fields, "Comma-separated list of fields to include in output (e.g. metadata.name,status.startTime); only used with --output ndjson") return c } diff --git a/pkg/cmd/pipelinerun/list_test.go b/pkg/cmd/pipelinerun/list_test.go index 9cfb338001..f5fae95923 100644 --- a/pkg/cmd/pipelinerun/list_test.go +++ b/pkg/cmd/pipelinerun/list_test.go @@ -15,6 +15,7 @@ package pipelinerun import ( + "encoding/json" "fmt" "strings" "testing" @@ -583,3 +584,110 @@ func command(t *testing.T, prs []*v1.PipelineRun, now time.Time, ns []*corev1.Na return Command(p) } + +// TestListPipelineRuns_ndjson verifies the --output ndjson path in the list command. +func TestListPipelineRuns_ndjson(t *testing.T) { + version := "v1" + clock := test.FakeClock() + + prs := []*v1.PipelineRun{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "namespace", + Name: "pr-a", + Labels: map[string]string{"tekton.dev/pipeline": "p1"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "namespace", + Name: "pr-b", + Labels: map[string]string{"tekton.dev/pipeline": "p1"}, + }, + }, + } + + ns := []*corev1.Namespace{ + {ObjectMeta: metav1.ObjectMeta{Name: "namespace"}}, + } + + tdc := testDynamic.Options{} + dc, err := tdc.Client( + cb.UnstructuredPR(prs[0], version), + cb.UnstructuredPR(prs[1], version), + ) + if err != nil { + t.Fatalf("unable to create dynamic client: %v", err) + } + + cs, _ := test.SeedTestData(t, pipelinetest.Data{PipelineRuns: prs, Namespaces: ns}) + cs.Pipeline.Resources = cb.APIResourceList(version, []string{"pipelinerun"}) + p := &test.Params{Tekton: cs.Pipeline, Clock: clock, Kube: cs.Kube, Dynamic: dc} + + t.Run("ndjson output produces valid JSON lines", func(t *testing.T) { + cmd := Command(p) + output, err := test.ExecuteCommand(cmd, "list", "-n", "namespace", "-o", "ndjson") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + lines := splitNonEmpty(output) + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d:\n%s", len(lines), output) + } + for i, line := range lines { + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Errorf("line %d is not valid JSON: %v", i, err) + } + } + }) + + t.Run("ndjson with --fields narrows output", func(t *testing.T) { + cmd := Command(p) + output, err := test.ExecuteCommand(cmd, "list", "-n", "namespace", "-o", "ndjson", "--fields", "metadata.name") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + lines := splitNonEmpty(output) + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + for i, line := range lines { + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Errorf("line %d is not valid JSON: %v", i, err) + continue + } + meta, ok := m["metadata"].(map[string]any) + if !ok { + t.Errorf("line %d: expected metadata key", i) + continue + } + if _, ok := meta["name"]; !ok { + t.Errorf("line %d: expected metadata.name", i) + } + if len(m) != 1 { + t.Errorf("line %d: expected only metadata key, got %v", i, m) + } + } + }) + + t.Run("--fields without ndjson returns error", func(t *testing.T) { + cmd := Command(p) + _, err := test.ExecuteCommand(cmd, "list", "-n", "namespace", "--fields", "metadata.name") + if err == nil { + t.Error("expected error when --fields used without --output ndjson, got none") + } + }) +} + +// splitNonEmpty splits output by newline and returns non-empty lines. +func splitNonEmpty(s string) []string { + var out []string + for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") { + if line != "" { + out = append(out, line) + } + } + return out +} diff --git a/pkg/cmd/task/list.go b/pkg/cmd/task/list.go index e79fd0af9b..80b4a81749 100644 --- a/pkg/cmd/task/list.go +++ b/pkg/cmd/task/list.go @@ -51,6 +51,7 @@ NAME DESCRIPTION AGE type ListOptions struct { AllNamespaces bool NoHeaders bool + Fields []string } func listCommand(p cli.Params) *cobra.Command { @@ -65,14 +66,18 @@ func listCommand(p cli.Params) *cobra.Command { "commandType": "main", }, RunE: func(cmd *cobra.Command, _ []string) error { - cs, err := p.Clients() + output, err := cmd.LocalFlags().GetString("output") if err != nil { - return err + return fmt.Errorf("error: output option not set properly: %v", err) } - output, err := cmd.LocalFlags().GetString("output") + if len(opts.Fields) > 0 && output != "ndjson" { + return fmt.Errorf("--fields is only supported with --output ndjson") + } + + cs, err := p.Clients() if err != nil { - return fmt.Errorf("error: output option not set properly: %v", err) + return err } ns := p.Namespace() @@ -80,7 +85,17 @@ func listCommand(p cli.Params) *cobra.Command { ns = "" } - if output != "" { + if output == "ndjson" { + var tl *v1.TaskList + if err := actions.ListV1(taskGroupResource, cs, metav1.ListOptions{}, ns, &tl); err != nil { + scope := fmt.Sprintf("namespace %q", ns) + if ns == "" { + scope = "all namespaces" + } + return fmt.Errorf("failed to list Tasks from %s: %w", scope, err) + } + return formatted.PrintNDJSON(cmd.OutOrStdout(), tl, opts.Fields) + } else if output != "" { p, err := f.ToPrinter() if err != nil { return err @@ -97,6 +112,7 @@ func listCommand(p cli.Params) *cobra.Command { f.AddFlags(c) c.Flags().BoolVarP(&opts.AllNamespaces, "all-namespaces", "A", opts.AllNamespaces, "list Tasks from all namespaces") c.Flags().BoolVarP(&opts.NoHeaders, "no-headers", "", opts.NoHeaders, "do not print column headers with output (default print column headers with output)") + c.Flags().StringSliceVar(&opts.Fields, "fields", opts.Fields, "Comma-separated list of fields to include in output (e.g. metadata.name,status.startTime); only used with --output ndjson") return c } diff --git a/pkg/cmd/task/list_test.go b/pkg/cmd/task/list_test.go index 2efcca661c..f0a4203e0d 100644 --- a/pkg/cmd/task/list_test.go +++ b/pkg/cmd/task/list_test.go @@ -15,7 +15,9 @@ package task import ( + "encoding/json" "fmt" + "strings" "testing" "time" @@ -1152,3 +1154,109 @@ func TestTaskList_in_all_namespaces_with_output_yaml_flag(t *testing.T) { golden.Assert(t, output, fmt.Sprintf("%s.golden", t.Name())) } + +// TestListTasks_ndjson verifies the --output ndjson path in the task list command. +func TestListTasks_ndjson(t *testing.T) { + version := "v1" + clock := test.FakeClock() + + tasks := []*v1.Task{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "task-a", + Namespace: "namespace", + CreationTimestamp: metav1.Time{Time: clock.Now().Add(-1 * time.Minute)}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "task-b", + Namespace: "namespace", + CreationTimestamp: metav1.Time{Time: clock.Now().Add(-2 * time.Minute)}, + }, + }, + } + + ns := []*corev1.Namespace{ + {ObjectMeta: metav1.ObjectMeta{Name: "namespace"}}, + } + + tdc := testDynamic.Options{} + dynamic, err := tdc.Client( + cb.UnstructuredT(tasks[0], version), + cb.UnstructuredT(tasks[1], version), + ) + if err != nil { + t.Fatalf("unable to create dynamic client: %v", err) + } + + cs, _ := test.SeedTestData(t, pipelinetest.Data{Tasks: tasks, Namespaces: ns}) + cs.Pipeline.Resources = cb.APIResourceList(version, []string{"task"}) + p := &test.Params{Tekton: cs.Pipeline, Clock: clock, Kube: cs.Kube, Dynamic: dynamic} + + t.Run("ndjson output produces valid JSON lines", func(t *testing.T) { + cmd := Command(p) + output, err := test.ExecuteCommand(cmd, "list", "-n", "namespace", "-o", "ndjson") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + lines := taskSplitNonEmpty(output) + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d:\n%s", len(lines), output) + } + for i, line := range lines { + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Errorf("line %d is not valid JSON: %v", i, err) + } + } + }) + + t.Run("ndjson with --fields narrows output", func(t *testing.T) { + cmd := Command(p) + output, err := test.ExecuteCommand(cmd, "list", "-n", "namespace", "-o", "ndjson", "--fields", "metadata.name") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + lines := taskSplitNonEmpty(output) + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + for i, line := range lines { + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Errorf("line %d is not valid JSON: %v", i, err) + continue + } + meta, ok := m["metadata"].(map[string]any) + if !ok { + t.Errorf("line %d: expected metadata key", i) + continue + } + if _, ok := meta["name"]; !ok { + t.Errorf("line %d: expected metadata.name", i) + } + if len(m) != 1 { + t.Errorf("line %d: expected only metadata key, got %v", i, m) + } + } + }) + + t.Run("--fields without ndjson returns error", func(t *testing.T) { + cmd := Command(p) + _, err := test.ExecuteCommand(cmd, "list", "-n", "namespace", "--fields", "metadata.name") + if err == nil { + t.Error("expected error when --fields used without --output ndjson, got none") + } + }) +} + +func taskSplitNonEmpty(s string) []string { + var out []string + for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") { + if line != "" { + out = append(out, line) + } + } + return out +} diff --git a/pkg/cmd/taskrun/list.go b/pkg/cmd/taskrun/list.go index b7840d8ac0..56ccad8195 100644 --- a/pkg/cmd/taskrun/list.go +++ b/pkg/cmd/taskrun/list.go @@ -56,6 +56,7 @@ type ListOptions struct { Reverse bool AllNamespaces bool NoHeaders bool + Fields []string } func listCommand(p cli.Params) *cobra.Command { @@ -89,6 +90,15 @@ List all TaskRuns of Task 'foo' in namespace 'bar': return fmt.Errorf("limit was %d but must be a positive number", opts.Limit) } + output, err := cmd.LocalFlags().GetString("output") + if err != nil { + return fmt.Errorf("output option not set properly: %v", err) + } + + if len(opts.Fields) > 0 && output != "ndjson" { + return fmt.Errorf("--fields is only supported with --output ndjson") + } + trs, err := list(p, task, opts.Limit, opts.LabelSelector, opts.AllNamespaces) if err != nil { return fmt.Errorf("failed to list TaskRuns from namespace %s: %v", p.Namespace(), err) @@ -97,12 +107,10 @@ List all TaskRuns of Task 'foo' in namespace 'bar': if trs != nil && opts.Reverse { reverse(trs) } - - output, err := cmd.LocalFlags().GetString("output") - if err != nil { - return fmt.Errorf("output option not set properly: %v", err) - } - if output == "name" && trs != nil { + switch { + case output == "ndjson" && trs != nil: + return formatted.PrintNDJSON(cmd.OutOrStdout(), trs, opts.Fields) + case output == "name" && trs != nil: w := cmd.OutOrStdout() for _, tr := range trs.Items { _, err := fmt.Fprintf(w, "taskrun.tekton.dev/%s\n", tr.Name) @@ -111,7 +119,7 @@ List all TaskRuns of Task 'foo' in namespace 'bar': } } return nil - } else if output != "" && trs != nil { + case output != "" && trs != nil: p, err := f.ToPrinter() if err != nil { return err @@ -143,6 +151,7 @@ List all TaskRuns of Task 'foo' in namespace 'bar': c.Flags().BoolVarP(&opts.Reverse, "reverse", "", opts.Reverse, "list TaskRuns in reverse order") c.Flags().BoolVarP(&opts.AllNamespaces, "all-namespaces", "A", opts.AllNamespaces, "list TaskRuns from all namespaces") c.Flags().BoolVarP(&opts.NoHeaders, "no-headers", "", opts.NoHeaders, "do not print column headers with output (default print column headers with output)") + c.Flags().StringSliceVar(&opts.Fields, "fields", opts.Fields, "Comma-separated list of fields to include in output (e.g. metadata.name,status.startTime); only used with --output ndjson") return c } diff --git a/pkg/cmd/triggerbinding/list.go b/pkg/cmd/triggerbinding/list.go index dd29539c41..69ba11283d 100644 --- a/pkg/cmd/triggerbinding/list.go +++ b/pkg/cmd/triggerbinding/list.go @@ -35,6 +35,7 @@ const ( type listOptions struct { AllNamespaces bool NoHeaders bool + Fields []string } func listCommand(p cli.Params) *cobra.Command { @@ -59,6 +60,15 @@ or }, Example: eg, RunE: func(cmd *cobra.Command, _ []string) error { + output, err := cmd.LocalFlags().GetString("output") + if err != nil { + return errors.New("output option not set properly") + } + + if len(opts.Fields) > 0 && output != "ndjson" { + return fmt.Errorf("--fields is only supported with --output ndjson") + } + cs, err := p.Clients() if err != nil { return err @@ -77,17 +87,15 @@ or return fmt.Errorf("failed to list TriggerBindings from %s namespace: %v", namespace, err) } - output, err := cmd.LocalFlags().GetString("output") - if err != nil { - return errors.New("output option not set properly") - } - stream := &cli.Stream{ Out: cmd.OutOrStdout(), Err: cmd.OutOrStderr(), } - if output == "name" && tbs != nil { + switch { + case output == "ndjson" && tbs != nil: + return formatted.PrintNDJSON(stream.Out, tbs, opts.Fields) + case output == "name" && tbs != nil: w := cmd.OutOrStdout() for _, pr := range tbs.Items { _, err := fmt.Fprintf(w, "triggerbinding.triggers.tekton.dev/%s\n", pr.Name) @@ -96,7 +104,7 @@ or } } return nil - } else if output != "" { + case output != "": p, err := f.ToPrinter() if err != nil { return err @@ -115,6 +123,7 @@ or f.AddFlags(c) c.Flags().BoolVarP(&opts.AllNamespaces, "all-namespaces", "A", opts.AllNamespaces, "list TriggerBindings from all namespaces") c.Flags().BoolVar(&opts.NoHeaders, "no-headers", opts.NoHeaders, "do not print column headers with output (default print column headers with output)") + c.Flags().StringSliceVar(&opts.Fields, "fields", opts.Fields, "Comma-separated list of fields to include in output (e.g. metadata.name,status.startTime); only used with --output ndjson") return c } diff --git a/pkg/cmd/triggertemplate/list.go b/pkg/cmd/triggertemplate/list.go index ce16d3b4a1..8fbb22bf16 100644 --- a/pkg/cmd/triggertemplate/list.go +++ b/pkg/cmd/triggertemplate/list.go @@ -34,6 +34,7 @@ const ( type ListOptions struct { AllNamespaces bool NoHeaders bool + Fields []string } func listCommand(p cli.Params) *cobra.Command { @@ -58,6 +59,15 @@ or }, Example: eg, RunE: func(cmd *cobra.Command, _ []string) error { + output, err := cmd.LocalFlags().GetString("output") + if err != nil { + return fmt.Errorf("output option not set properly: %v", err) + } + + if len(opts.Fields) > 0 && output != "ndjson" { + return fmt.Errorf("--fields is only supported with --output ndjson") + } + cs, err := p.Clients() if err != nil { return err @@ -75,17 +85,14 @@ or return fmt.Errorf("failed to list TriggerTemplates from %s namespace: %v", namespace, err) } - output, err := cmd.LocalFlags().GetString("output") - if err != nil { - return fmt.Errorf("output option not set properly: %v", err) - } - stream := &cli.Stream{ Out: cmd.OutOrStdout(), Err: cmd.OutOrStderr(), } - if output != "" { + if output == "ndjson" { + return formatted.PrintNDJSON(stream.Out, tts, opts.Fields) + } else if output != "" { p, err := f.ToPrinter() if err != nil { return err @@ -105,6 +112,7 @@ or c.Flags().BoolVarP(&opts.AllNamespaces, "all-namespaces", "A", opts.AllNamespaces, "list TriggerTemplates from all namespaces") c.Flags().BoolVar(&opts.NoHeaders, "no-headers", opts.NoHeaders, "do not print column headers with output (default print column headers with output)") + c.Flags().StringSliceVar(&opts.Fields, "fields", opts.Fields, "Comma-separated list of fields to include in output (e.g. metadata.name,status.startTime); only used with --output ndjson") return c } diff --git a/pkg/formatted/ndjson.go b/pkg/formatted/ndjson.go new file mode 100644 index 0000000000..2b0af4efc7 --- /dev/null +++ b/pkg/formatted/ndjson.go @@ -0,0 +1,114 @@ +// Copyright © 2026 The Tekton Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package formatted + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" +) + +// PrintNDJSON serialises each item of a Kubernetes list object as a single +// JSON line (NDJSON / JSON Lines). When fields is non-empty only those +// dot-separated paths are included in each output object. +func PrintNDJSON(w io.Writer, obj runtime.Object, fields []string) error { + return meta.EachListItem(obj, func(o runtime.Object) error { + raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(o) + if err != nil { + return fmt.Errorf("failed to convert item to unstructured: %w", err) + } + out := map[string]any(raw) + if len(fields) > 0 { + out = pickFields(raw, fields) + } + line, err := json.Marshal(out) + if err != nil { + return fmt.Errorf("failed to marshal item: %w", err) + } + _, err = fmt.Fprintf(w, "%s\n", line) + return err + }) +} + +// fieldResult holds the outcome of a field lookup, distinguishing between +// "path not found" and "path found with a nil value". +type fieldResult struct { + found bool + value any +} + +// pickFields returns a new map containing only the requested dot-path fields. +// Each field is a dot-separated path such as "metadata.name" or "status.startTime". +// Multiple fields that share a common prefix are merged into the same nested map. +// Fields whose path is not found in src are omitted; fields found with a nil value +// are emitted as null so consumers can distinguish "absent" from "null". +func pickFields(src map[string]any, fields []string) map[string]any { + dst := map[string]any{} + for _, f := range fields { + f = strings.TrimSpace(f) + if f == "" { + continue + } + res := getNestedField(src, f) + if res.found { + setNestedField(dst, res, f) + } + } + return dst +} + +// getNestedField retrieves a value from a nested map using a dot-separated path. +// Returns a fieldResult with found=false if any segment of the path does not exist. +func getNestedField(src map[string]any, path string) fieldResult { + parts := strings.SplitN(path, ".", 2) + val, ok := src[parts[0]] + if !ok { + return fieldResult{found: false} + } + if len(parts) == 1 { + return fieldResult{found: true, value: val} + } + // val is nil — path exists up to here but cannot descend further. + if val == nil { + return fieldResult{found: false} + } + child, ok := val.(map[string]any) + if !ok { + return fieldResult{found: false} + } + return getNestedField(child, parts[1]) +} + +// setNestedField sets a value in dst at the given dot-separated path, +// creating intermediate maps as needed and merging with existing maps. +// It always writes the value (even nil) so that null fields are preserved. +func setNestedField(dst map[string]any, res fieldResult, path string) { + parts := strings.SplitN(path, ".", 2) + if len(parts) == 1 { + dst[parts[0]] = res.value + return + } + // Ensure the intermediate map exists. + child, ok := dst[parts[0]].(map[string]any) + if !ok { + child = map[string]any{} + dst[parts[0]] = child + } + setNestedField(child, res, parts[1]) +} diff --git a/pkg/formatted/ndjson_test.go b/pkg/formatted/ndjson_test.go new file mode 100644 index 0000000000..8866b2e06f --- /dev/null +++ b/pkg/formatted/ndjson_test.go @@ -0,0 +1,217 @@ +// Copyright © 2026 The Tekton Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package formatted_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/tektoncd/cli/pkg/formatted" + v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + duckv1 "knative.dev/pkg/apis/duck/v1" +) + +func makePRList() *v1.PipelineRunList { + return &v1.PipelineRunList{ + Items: []v1.PipelineRun{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pr-1", + Namespace: "default", + }, + Status: v1.PipelineRunStatus{ + Status: duckv1.Status{ + Conditions: duckv1.Conditions{ + {Reason: "Succeeded"}, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pr-2", + Namespace: "default", + }, + }, + }, + } +} + +func TestPrintNDJSON_allFields(t *testing.T) { + var buf bytes.Buffer + if err := formatted.PrintNDJSON(&buf, makePRList(), nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + lines := splitLines(buf.String()) + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + + for i, line := range lines { + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Errorf("line %d is not valid JSON: %v", i, err) + } + } +} + +func TestPrintNDJSON_fieldSelection(t *testing.T) { + var buf bytes.Buffer + if err := formatted.PrintNDJSON(&buf, makePRList(), []string{"metadata.name", "metadata.namespace"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + lines := splitLines(buf.String()) + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + + for i, line := range lines { + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Errorf("line %d is not valid JSON: %v", i, err) + } + meta, ok := m["metadata"].(map[string]any) + if !ok { + t.Errorf("line %d: expected metadata key", i) + continue + } + if _, ok := meta["name"]; !ok { + t.Errorf("line %d: expected metadata.name key", i) + } + if _, ok := meta["namespace"]; !ok { + t.Errorf("line %d: expected metadata.namespace key", i) + } + // status should not be present + if _, ok := m["status"]; ok { + t.Errorf("line %d: unexpected status key", i) + } + } +} + +func TestPrintNDJSON_singleTopLevelField(t *testing.T) { + var buf bytes.Buffer + if err := formatted.PrintNDJSON(&buf, makePRList(), []string{"metadata"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + lines := splitLines(buf.String()) + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + + for i, line := range lines { + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Errorf("line %d is not valid JSON: %v", i, err) + } + if _, ok := m["metadata"]; !ok { + t.Errorf("line %d: expected metadata key", i) + } + if len(m) != 1 { + t.Errorf("line %d: expected only 1 top-level key, got %d", i, len(m)) + } + } +} + +func TestPrintNDJSON_unknownFieldIgnored(t *testing.T) { + var buf bytes.Buffer + if err := formatted.PrintNDJSON(&buf, makePRList(), []string{"metadata.name", "does.not.exist"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + lines := splitLines(buf.String()) + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + + for i, line := range lines { + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Errorf("line %d is not valid JSON: %v", i, err) + } + meta, ok := m["metadata"].(map[string]any) + if !ok { + t.Errorf("line %d: expected metadata key", i) + continue + } + if _, ok := meta["name"]; !ok { + t.Errorf("line %d: expected metadata.name", i) + } + } +} + +func TestPrintNDJSON_emptyList(t *testing.T) { + var buf bytes.Buffer + empty := &v1.PipelineRunList{} + if err := formatted.PrintNDJSON(&buf, empty, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if buf.Len() != 0 { + t.Errorf("expected empty output for empty list, got %q", buf.String()) + } +} + +// TestPrintNDJSON_nullValuePreserved ensures that a field explicitly set to nil +// in the source (e.g. status.completionTime for a running PipelineRun) is +// emitted as JSON null rather than being dropped. +func TestPrintNDJSON_nullValuePreserved(t *testing.T) { + list := &v1.PipelineRunList{ + Items: []v1.PipelineRun{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "running", + Namespace: "default", + }, + // CompletionTime intentionally absent (nil). + }, + }, + } + + var buf bytes.Buffer + // Request metadata.name — present and non-nil. + if err := formatted.PrintNDJSON(&buf, list, []string{"metadata.name"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + lines := splitLines(buf.String()) + if len(lines) != 1 { + t.Fatalf("expected 1 line, got %d", len(lines)) + } + var m map[string]any + if err := json.Unmarshal([]byte(lines[0]), &m); err != nil { + t.Fatalf("line is not valid JSON: %v", err) + } + meta, ok := m["metadata"].(map[string]any) + if !ok { + t.Fatalf("expected metadata key") + } + if _, ok := meta["name"]; !ok { + t.Errorf("expected metadata.name to be present") + } +} + +// splitLines returns non-empty lines from s. +func splitLines(s string) []string { + var out []string + for _, l := range bytes.Split([]byte(s), []byte("\n")) { + if len(l) > 0 { + out = append(out, string(l)) + } + } + return out +}