From d536549a53011add1bd075a4f32bd7677b23f123 Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Mon, 27 Jul 2026 16:56:06 +0530 Subject: [PATCH] feat: add ndjson output format with field selection to list commands --- pkg/cmd/clustertriggerbinding/list.go | 9 +- pkg/cmd/customrun/list.go | 9 +- pkg/cmd/eventlistener/list.go | 6 +- pkg/cmd/pipeline/list.go | 10 +- pkg/cmd/pipelinerun/list.go | 9 +- pkg/cmd/task/list.go | 10 +- pkg/cmd/taskrun/list.go | 9 +- pkg/cmd/triggerbinding/list.go | 9 +- pkg/cmd/triggertemplate/list.go | 6 +- pkg/formatted/ndjson.go | 116 +++++++++++++++++ pkg/formatted/ndjson_test.go | 179 ++++++++++++++++++++++++++ 11 files changed, 358 insertions(+), 14 deletions(-) create mode 100644 pkg/formatted/ndjson.go create mode 100644 pkg/formatted/ndjson_test.go diff --git a/pkg/cmd/clustertriggerbinding/list.go b/pkg/cmd/clustertriggerbinding/list.go index f0ec246877..da0af6f7a6 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 { @@ -78,7 +79,10 @@ or Err: cmd.OutOrStderr(), } - if output == "name" && tbs != nil { + switch { + case output == "ndjson": + 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 +91,7 @@ or } } return nil - } else if output != "" { + case output != "": p, err := f.ToPrinter() if err != nil { return err @@ -105,6 +109,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..de87e9ced2 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 { @@ -92,7 +93,10 @@ func listCommand(p cli.Params) *cobra.Command { 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 +105,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 +137,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..bfca064c20 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 { @@ -88,7 +89,9 @@ or 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 +109,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..5c560d8fb5 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 { @@ -91,7 +92,13 @@ 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 { + return fmt.Errorf("failed to list Pipelines from namespace %s: %v", ns, err) + } + return formatted.PrintNDJSON(cmd.OutOrStdout(), pl, opts.Fields) + } else if output != "" { p, err := f.ToPrinter() if err != nil { return err @@ -108,6 +115,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/pipelinerun/list.go b/pkg/cmd/pipelinerun/list.go index bd9fd2fbb2..3ca04d23ea 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 { @@ -101,7 +102,10 @@ List all PipelineRuns in a namespace 'foo': 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 +114,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 +143,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/task/list.go b/pkg/cmd/task/list.go index e79fd0af9b..15b502acb0 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 { @@ -80,7 +81,13 @@ 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 { + return fmt.Errorf("failed to list Tasks from namespace %s: %v", ns, err) + } + return formatted.PrintNDJSON(cmd.OutOrStdout(), tl, opts.Fields) + } else if output != "" { p, err := f.ToPrinter() if err != nil { return err @@ -97,6 +104,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/taskrun/list.go b/pkg/cmd/taskrun/list.go index b7840d8ac0..8ba9566401 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 { @@ -102,7 +103,10 @@ List all TaskRuns of Task 'foo' in namespace 'bar': 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 +115,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 +147,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..470224013b 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 { @@ -87,7 +88,10 @@ or Err: cmd.OutOrStderr(), } - if output == "name" && tbs != nil { + switch { + case output == "ndjson": + 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 +100,7 @@ or } } return nil - } else if output != "" { + case output != "": p, err := f.ToPrinter() if err != nil { return err @@ -115,6 +119,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..7f301d0af1 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 { @@ -85,7 +86,9 @@ or 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 +108,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..84c55ab30e --- /dev/null +++ b/pkg/formatted/ndjson.go @@ -0,0 +1,116 @@ +// Copyright © 2024 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/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 { + // Convert the list to unstructured so we can work with raw map[string]any. + raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return fmt.Errorf("failed to convert object to unstructured: %w", err) + } + + itemsVal, ok := raw["items"] + if !ok { + return nil + } + items, ok := itemsVal.([]any) + if !ok { + return nil + } + + for _, item := range items { + m, ok := item.(map[string]any) + if !ok { + continue + } + out := m + if len(fields) > 0 { + out = pickFields(m, fields) + } + line, err := json.Marshal(out) + if err != nil { + return fmt.Errorf("failed to marshal item: %w", err) + } + if _, err := fmt.Fprintf(w, "%s\n", line); err != nil { + return err + } + } + return nil +} + +// 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. +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 + } + setNestedField(dst, getNestedField(src, f), f) + } + return dst +} + +// getNestedField retrieves a value from a nested map using a dot-separated path. +// Returns nil if the path does not exist. +func getNestedField(src map[string]any, path string) any { + parts := strings.SplitN(path, ".", 2) + val, ok := src[parts[0]] + if !ok { + return nil + } + if len(parts) == 1 { + return val + } + child, ok := val.(map[string]any) + if !ok { + return nil + } + 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. +func setNestedField(dst map[string]any, val any, path string) { + if val == nil { + return + } + parts := strings.SplitN(path, ".", 2) + if len(parts) == 1 { + dst[parts[0]] = val + 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, val, parts[1]) +} diff --git a/pkg/formatted/ndjson_test.go b/pkg/formatted/ndjson_test.go new file mode 100644 index 0000000000..6ba1bd30d9 --- /dev/null +++ b/pkg/formatted/ndjson_test.go @@ -0,0 +1,179 @@ +// Copyright © 2024 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()) + } +} + +// 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 +}