From 21c20a7189c78020e5039450358be46d716dae69 Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Mon, 27 Jul 2026 16:56:06 +0530 Subject: [PATCH 1/3] 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 +} From f2cfdd266b57def666b7e2aee317de7ab14490c3 Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Mon, 10 Aug 2026 22:59:04 +0530 Subject: [PATCH 2/3] fix: improve error messages and ndjson items validation --- pkg/cmd/pipeline/list.go | 6 +++++- pkg/cmd/task/list.go | 6 +++++- pkg/formatted/ndjson.go | 5 +++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/pipeline/list.go b/pkg/cmd/pipeline/list.go index 5c560d8fb5..0e77438abc 100644 --- a/pkg/cmd/pipeline/list.go +++ b/pkg/cmd/pipeline/list.go @@ -95,7 +95,11 @@ func listCommand(p cli.Params) *cobra.Command { 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) + 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 != "" { diff --git a/pkg/cmd/task/list.go b/pkg/cmd/task/list.go index 15b502acb0..dc065ae418 100644 --- a/pkg/cmd/task/list.go +++ b/pkg/cmd/task/list.go @@ -84,7 +84,11 @@ func listCommand(p cli.Params) *cobra.Command { 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) + 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 != "" { diff --git a/pkg/formatted/ndjson.go b/pkg/formatted/ndjson.go index 84c55ab30e..39d2670d58 100644 --- a/pkg/formatted/ndjson.go +++ b/pkg/formatted/ndjson.go @@ -34,12 +34,13 @@ func PrintNDJSON(w io.Writer, obj runtime.Object, fields []string) error { } itemsVal, ok := raw["items"] - if !ok { + if !ok || itemsVal == nil { + // A missing or nil "items" field means an empty list — nothing to emit. return nil } items, ok := itemsVal.([]any) if !ok { - return nil + return fmt.Errorf("\"items\" field is not a slice (got %T): not a list type", itemsVal) } for _, item := range items { From 731148e9bf629b1ce1418cbfb94a406a686f3c2a Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Fri, 4 Sep 2026 21:05:35 +0530 Subject: [PATCH 3/3] feat: validate --fields requires --output ndjson across list commands --- pkg/cmd/clustertriggerbinding/list.go | 16 ++-- pkg/cmd/customrun/list.go | 14 ++-- pkg/cmd/eventlistener/list.go | 14 ++-- pkg/cmd/pipeline/list.go | 12 ++- pkg/cmd/pipeline/list_test.go | 108 ++++++++++++++++++++++++++ pkg/cmd/pipelinerun/list.go | 14 ++-- pkg/cmd/pipelinerun/list_test.go | 108 ++++++++++++++++++++++++++ pkg/cmd/task/list.go | 12 ++- pkg/cmd/task/list_test.go | 108 ++++++++++++++++++++++++++ pkg/cmd/taskrun/list.go | 14 ++-- pkg/cmd/triggerbinding/list.go | 16 ++-- pkg/cmd/triggertemplate/list.go | 14 ++-- pkg/formatted/ndjson.go | 77 +++++++++--------- pkg/formatted/ndjson_test.go | 40 +++++++++- 14 files changed, 481 insertions(+), 86 deletions(-) diff --git a/pkg/cmd/clustertriggerbinding/list.go b/pkg/cmd/clustertriggerbinding/list.go index da0af6f7a6..8fcfc86444 100644 --- a/pkg/cmd/clustertriggerbinding/list.go +++ b/pkg/cmd/clustertriggerbinding/list.go @@ -59,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 @@ -69,18 +78,13 @@ 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(), } switch { - case output == "ndjson": + case output == "ndjson" && tbs != nil: return formatted.PrintNDJSON(stream.Out, tbs, opts.Fields) case output == "name" && tbs != nil: w := cmd.OutOrStdout() diff --git a/pkg/cmd/customrun/list.go b/pkg/cmd/customrun/list.go index de87e9ced2..3c02da3c76 100644 --- a/pkg/cmd/customrun/list.go +++ b/pkg/cmd/customrun/list.go @@ -80,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) @@ -88,11 +97,6 @@ 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) - } switch { case output == "ndjson" && crs != nil: return formatted.PrintNDJSON(cmd.OutOrStdout(), crs, opts.Fields) diff --git a/pkg/cmd/eventlistener/list.go b/pkg/cmd/eventlistener/list.go index bfca064c20..6e1b38f1c9 100644 --- a/pkg/cmd/eventlistener/list.go +++ b/pkg/cmd/eventlistener/list.go @@ -61,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 @@ -79,11 +88,6 @@ 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(), diff --git a/pkg/cmd/pipeline/list.go b/pkg/cmd/pipeline/list.go index 0e77438abc..3688077e42 100644 --- a/pkg/cmd/pipeline/list.go +++ b/pkg/cmd/pipeline/list.go @@ -77,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() 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 3ca04d23ea..71070f8b26 100644 --- a/pkg/cmd/pipelinerun/list.go +++ b/pkg/cmd/pipelinerun/list.go @@ -88,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) @@ -97,11 +106,6 @@ 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) - } - switch { case output == "ndjson" && prs != nil: return formatted.PrintNDJSON(cmd.OutOrStdout(), prs, opts.Fields) 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 dc065ae418..80b4a81749 100644 --- a/pkg/cmd/task/list.go +++ b/pkg/cmd/task/list.go @@ -66,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() 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 8ba9566401..56ccad8195 100644 --- a/pkg/cmd/taskrun/list.go +++ b/pkg/cmd/taskrun/list.go @@ -90,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) @@ -98,11 +107,6 @@ 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) - } switch { case output == "ndjson" && trs != nil: return formatted.PrintNDJSON(cmd.OutOrStdout(), trs, opts.Fields) diff --git a/pkg/cmd/triggerbinding/list.go b/pkg/cmd/triggerbinding/list.go index 470224013b..69ba11283d 100644 --- a/pkg/cmd/triggerbinding/list.go +++ b/pkg/cmd/triggerbinding/list.go @@ -60,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 @@ -78,18 +87,13 @@ 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(), } switch { - case output == "ndjson": + case output == "ndjson" && tbs != nil: return formatted.PrintNDJSON(stream.Out, tbs, opts.Fields) case output == "name" && tbs != nil: w := cmd.OutOrStdout() diff --git a/pkg/cmd/triggertemplate/list.go b/pkg/cmd/triggertemplate/list.go index 7f301d0af1..8fbb22bf16 100644 --- a/pkg/cmd/triggertemplate/list.go +++ b/pkg/cmd/triggertemplate/list.go @@ -59,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 @@ -76,11 +85,6 @@ 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(), diff --git a/pkg/formatted/ndjson.go b/pkg/formatted/ndjson.go index 39d2670d58..2b0af4efc7 100644 --- a/pkg/formatted/ndjson.go +++ b/pkg/formatted/ndjson.go @@ -1,4 +1,4 @@ -// Copyright © 2024 The Tekton Authors. +// 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. @@ -20,6 +20,7 @@ import ( "io" "strings" + "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime" ) @@ -27,45 +28,36 @@ import ( // 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 || itemsVal == nil { - // A missing or nil "items" field means an empty list — nothing to emit. - return nil - } - items, ok := itemsVal.([]any) - if !ok { - return fmt.Errorf("\"items\" field is not a slice (got %T): not a list type", itemsVal) - } - - for _, item := range items { - m, ok := item.(map[string]any) - if !ok { - continue + 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 := m + out := map[string]any(raw) if len(fields) > 0 { - out = pickFields(m, fields) + out = pickFields(raw, 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 + _, 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 { @@ -73,38 +65,43 @@ func pickFields(src map[string]any, fields []string) map[string]any { if f == "" { continue } - setNestedField(dst, getNestedField(src, f), f) + 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 nil if the path does not exist. -func getNestedField(src map[string]any, path string) any { +// 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 nil + return fieldResult{found: false} } if len(parts) == 1 { - return val + 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 nil + 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. -func setNestedField(dst map[string]any, val any, path string) { - if val == nil { - return - } +// 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]] = val + dst[parts[0]] = res.value return } // Ensure the intermediate map exists. @@ -113,5 +110,5 @@ func setNestedField(dst map[string]any, val any, path string) { child = map[string]any{} dst[parts[0]] = child } - setNestedField(child, val, parts[1]) + setNestedField(child, res, parts[1]) } diff --git a/pkg/formatted/ndjson_test.go b/pkg/formatted/ndjson_test.go index 6ba1bd30d9..8866b2e06f 100644 --- a/pkg/formatted/ndjson_test.go +++ b/pkg/formatted/ndjson_test.go @@ -1,4 +1,4 @@ -// Copyright © 2024 The Tekton Authors. +// 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. @@ -167,6 +167,44 @@ func TestPrintNDJSON_emptyList(t *testing.T) { } } +// 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