From bf00fb6876834606b434b4c2abbccb710e0f40e7 Mon Sep 17 00:00:00 2001 From: Arvind Date: Fri, 11 Sep 2026 16:34:13 +0530 Subject: [PATCH] feat: add structured JSON/YAML output for list commands Add -o json and -o yaml support to list commands for pipelines, tasks, pipeline runs, task runs, event listeners, trigger bindings, cluster trigger bindings, trigger templates, and custom runs. Structured output is rendered as arrays of resource objects rather than Kubernetes List wrappers. Update help examples, generated docs, man pages, and add tests for structured, empty, invalid, and help output. Signed-off-by: Arvind Assisted-by: Cursor Grok 4.6 --- docs/cmd/tkn_clustertriggerbinding_list.md | 8 + docs/cmd/tkn_customrun_list.md | 8 + docs/cmd/tkn_eventlistener_list.md | 8 + docs/cmd/tkn_pipeline_list.md | 15 + docs/cmd/tkn_pipelinerun_list.md | 8 + docs/cmd/tkn_task_list.md | 15 + docs/cmd/tkn_taskrun_list.md | 8 + docs/cmd/tkn_triggerbinding_list.md | 8 + docs/cmd/tkn_triggertemplate_list.md | 8 + .../man/man1/tkn-clustertriggerbinding-list.1 | 24 ++ docs/man/man1/tkn-customrun-list.1 | 24 ++ docs/man/man1/tkn-eventlistener-list.1 | 24 ++ docs/man/man1/tkn-pipeline-list.1 | 38 +++ docs/man/man1/tkn-pipelinerun-list.1 | 24 ++ docs/man/man1/tkn-task-list.1 | 38 +++ docs/man/man1/tkn-taskrun-list.1 | 24 ++ docs/man/man1/tkn-triggerbinding-list.1 | 24 ++ docs/man/man1/tkn-triggertemplate-list.1 | 24 ++ pkg/cmd/clustertriggerbinding/list.go | 16 ++ pkg/cmd/clustertriggerbinding/list_test.go | 82 ++++++ pkg/cmd/customrun/list.go | 16 ++ pkg/cmd/customrun/list_test.go | 119 ++++++++ pkg/cmd/eventlistener/list.go | 16 ++ pkg/cmd/eventlistener/list_test.go | 107 ++++++++ pkg/cmd/pipeline/list.go | 30 +- pkg/cmd/pipeline/list_test.go | 257 ++++++++++++++++++ ...ll_namespaces_with_output_yaml_flag.golden | 18 +- .../TestPipelineList_output_json.golden | 22 ++ pkg/cmd/pipelinerun/list.go | 16 ++ pkg/cmd/pipelinerun/list_test.go | 123 +++++++++ pkg/cmd/task/list.go | 24 ++ pkg/cmd/task/list_test.go | 132 +++++++++ ...ll_namespaces_with_output_yaml_flag.golden | 22 +- pkg/cmd/taskrun/list.go | 16 ++ pkg/cmd/taskrun/list_test.go | 119 ++++++++ pkg/cmd/triggerbinding/list.go | 16 ++ pkg/cmd/triggerbinding/list_test.go | 107 ++++++++ pkg/cmd/triggertemplate/list.go | 16 ++ pkg/cmd/triggertemplate/list_test.go | 107 ++++++++ pkg/formatted/output.go | 86 ++++++ pkg/formatted/output_test.go | 164 +++++++++++ test/e2e/pipeline/pipeline_test.go | 15 +- 42 files changed, 1945 insertions(+), 31 deletions(-) create mode 100644 pkg/cmd/pipeline/testdata/TestPipelineList_output_json.golden create mode 100644 pkg/formatted/output.go create mode 100644 pkg/formatted/output_test.go diff --git a/docs/cmd/tkn_clustertriggerbinding_list.md b/docs/cmd/tkn_clustertriggerbinding_list.md index fefe905aa1..42a198ce1a 100644 --- a/docs/cmd/tkn_clustertriggerbinding_list.md +++ b/docs/cmd/tkn_clustertriggerbinding_list.md @@ -24,6 +24,14 @@ or tkn ctb ls +List ClusterTriggerBindings as a JSON array: + + tkn clustertriggerbinding list -o json + +List ClusterTriggerBindings as a YAML array: + + tkn clustertriggerbinding list -o yaml + ### Options diff --git a/docs/cmd/tkn_customrun_list.md b/docs/cmd/tkn_customrun_list.md index 197d723602..6390ac297c 100644 --- a/docs/cmd/tkn_customrun_list.md +++ b/docs/cmd/tkn_customrun_list.md @@ -20,6 +20,14 @@ List all CustomRuns in namespace 'bar': tkn cr list -n bar +List CustomRuns as a JSON array: + + tkn customrun list -o json + +List CustomRuns as a YAML array: + + tkn customrun list -o yaml + ### Options diff --git a/docs/cmd/tkn_eventlistener_list.md b/docs/cmd/tkn_eventlistener_list.md index 9af89dc8a4..964f54a6be 100644 --- a/docs/cmd/tkn_eventlistener_list.md +++ b/docs/cmd/tkn_eventlistener_list.md @@ -24,6 +24,14 @@ or tkn el ls -n bar +List EventListeners as a JSON array: + + tkn eventlistener list -o json + +List EventListeners as a YAML array: + + tkn eventlistener list -o yaml + ### Options diff --git a/docs/cmd/tkn_pipeline_list.md b/docs/cmd/tkn_pipeline_list.md index 6e945fa286..dac449d1d4 100644 --- a/docs/cmd/tkn_pipeline_list.md +++ b/docs/cmd/tkn_pipeline_list.md @@ -14,6 +14,21 @@ tkn pipeline list Lists Pipelines in a namespace +### Examples + +List Pipelines in namespace 'bar': + + tkn pipeline list -n bar + +List Pipelines as a JSON array: + + tkn pipeline list -o json + +List Pipelines as a YAML array: + + tkn pipeline list -o yaml + + ### Options ``` diff --git a/docs/cmd/tkn_pipelinerun_list.md b/docs/cmd/tkn_pipelinerun_list.md index 62c5bb53e4..3bccd653e7 100644 --- a/docs/cmd/tkn_pipelinerun_list.md +++ b/docs/cmd/tkn_pipelinerun_list.md @@ -24,6 +24,14 @@ List all PipelineRuns in a namespace 'foo': tkn pr list -n foo +List PipelineRuns as a JSON array: + + tkn pipelinerun list -o json + +List PipelineRuns as a YAML array: + + tkn pipelinerun list -o yaml + ### Options diff --git a/docs/cmd/tkn_task_list.md b/docs/cmd/tkn_task_list.md index bfbec5895b..1dda2b0a73 100644 --- a/docs/cmd/tkn_task_list.md +++ b/docs/cmd/tkn_task_list.md @@ -14,6 +14,21 @@ tkn task list Lists Tasks in a namespace +### Examples + +List Tasks in namespace 'bar': + + tkn task list -n bar + +List Tasks as a JSON array: + + tkn task list -o json + +List Tasks as a YAML array: + + tkn task list -o yaml + + ### Options ``` diff --git a/docs/cmd/tkn_taskrun_list.md b/docs/cmd/tkn_taskrun_list.md index d05aae1645..b2ab3044db 100644 --- a/docs/cmd/tkn_taskrun_list.md +++ b/docs/cmd/tkn_taskrun_list.md @@ -24,6 +24,14 @@ List all TaskRuns of Task 'foo' in namespace 'bar': tkn taskrun list foo -n bar +List TaskRuns as a JSON array: + + tkn taskrun list -o json + +List TaskRuns as a YAML array: + + tkn taskrun list -o yaml + ### Options diff --git a/docs/cmd/tkn_triggerbinding_list.md b/docs/cmd/tkn_triggerbinding_list.md index 74bd021924..63340aeeed 100644 --- a/docs/cmd/tkn_triggerbinding_list.md +++ b/docs/cmd/tkn_triggerbinding_list.md @@ -24,6 +24,14 @@ or tkn tb ls -n bar +List TriggerBindings as a JSON array: + + tkn triggerbinding list -o json + +List TriggerBindings as a YAML array: + + tkn triggerbinding list -o yaml + ### Options diff --git a/docs/cmd/tkn_triggertemplate_list.md b/docs/cmd/tkn_triggertemplate_list.md index 4a3d6c0679..fad5d5719a 100644 --- a/docs/cmd/tkn_triggertemplate_list.md +++ b/docs/cmd/tkn_triggertemplate_list.md @@ -24,6 +24,14 @@ or tkn tt ls -n bar +List TriggerTemplates as a JSON array: + + tkn triggertemplate list -o json + +List TriggerTemplates as a YAML array: + + tkn triggertemplate list -o yaml + ### Options diff --git a/docs/man/man1/tkn-clustertriggerbinding-list.1 b/docs/man/man1/tkn-clustertriggerbinding-list.1 index 2062b1a34b..0a2470ef52 100644 --- a/docs/man/man1/tkn-clustertriggerbinding-list.1 +++ b/docs/man/man1/tkn-clustertriggerbinding-list.1 @@ -84,6 +84,30 @@ tkn ctb ls .fi .RE +.PP +List ClusterTriggerBindings as a JSON array: + +.PP +.RS + +.nf +tkn clustertriggerbinding list \-o json + +.fi +.RE + +.PP +List ClusterTriggerBindings as a YAML array: + +.PP +.RS + +.nf +tkn clustertriggerbinding list \-o yaml + +.fi +.RE + .SH SEE ALSO .PP diff --git a/docs/man/man1/tkn-customrun-list.1 b/docs/man/man1/tkn-customrun-list.1 index cebb3cba18..b7b211d8fb 100644 --- a/docs/man/man1/tkn-customrun-list.1 +++ b/docs/man/man1/tkn-customrun-list.1 @@ -92,6 +92,30 @@ tkn cr list \-n bar .fi .RE +.PP +List CustomRuns as a JSON array: + +.PP +.RS + +.nf +tkn customrun list \-o json + +.fi +.RE + +.PP +List CustomRuns as a YAML array: + +.PP +.RS + +.nf +tkn customrun list \-o yaml + +.fi +.RE + .SH SEE ALSO .PP diff --git a/docs/man/man1/tkn-eventlistener-list.1 b/docs/man/man1/tkn-eventlistener-list.1 index 0b0f5aee0a..09042dd1da 100644 --- a/docs/man/man1/tkn-eventlistener-list.1 +++ b/docs/man/man1/tkn-eventlistener-list.1 @@ -92,6 +92,30 @@ tkn el ls \-n bar .fi .RE +.PP +List EventListeners as a JSON array: + +.PP +.RS + +.nf +tkn eventlistener list \-o json + +.fi +.RE + +.PP +List EventListeners as a YAML array: + +.PP +.RS + +.nf +tkn eventlistener list \-o yaml + +.fi +.RE + .SH SEE ALSO .PP diff --git a/docs/man/man1/tkn-pipeline-list.1 b/docs/man/man1/tkn-pipeline-list.1 index 20eeb0d61f..08ed71112a 100644 --- a/docs/man/man1/tkn-pipeline-list.1 +++ b/docs/man/man1/tkn-pipeline-list.1 @@ -67,6 +67,44 @@ Lists Pipelines in a namespace disable coloring (default: false) +.SH EXAMPLE +.PP +List Pipelines in namespace 'bar': + +.PP +.RS + +.nf +tkn pipeline list \-n bar + +.fi +.RE + +.PP +List Pipelines as a JSON array: + +.PP +.RS + +.nf +tkn pipeline list \-o json + +.fi +.RE + +.PP +List Pipelines as a YAML array: + +.PP +.RS + +.nf +tkn pipeline list \-o yaml + +.fi +.RE + + .SH SEE ALSO .PP \fBtkn\-pipeline(1)\fP diff --git a/docs/man/man1/tkn-pipelinerun-list.1 b/docs/man/man1/tkn-pipelinerun-list.1 index 191cc6d74e..94f31c8075 100644 --- a/docs/man/man1/tkn-pipelinerun-list.1 +++ b/docs/man/man1/tkn-pipelinerun-list.1 @@ -104,6 +104,30 @@ tkn pr list \-n foo .fi .RE +.PP +List PipelineRuns as a JSON array: + +.PP +.RS + +.nf +tkn pipelinerun list \-o json + +.fi +.RE + +.PP +List PipelineRuns as a YAML array: + +.PP +.RS + +.nf +tkn pipelinerun list \-o yaml + +.fi +.RE + .SH SEE ALSO .PP diff --git a/docs/man/man1/tkn-task-list.1 b/docs/man/man1/tkn-task-list.1 index ad7f179671..2a61f33efe 100644 --- a/docs/man/man1/tkn-task-list.1 +++ b/docs/man/man1/tkn-task-list.1 @@ -67,6 +67,44 @@ Lists Tasks in a namespace disable coloring (default: false) +.SH EXAMPLE +.PP +List Tasks in namespace 'bar': + +.PP +.RS + +.nf +tkn task list \-n bar + +.fi +.RE + +.PP +List Tasks as a JSON array: + +.PP +.RS + +.nf +tkn task list \-o json + +.fi +.RE + +.PP +List Tasks as a YAML array: + +.PP +.RS + +.nf +tkn task list \-o yaml + +.fi +.RE + + .SH SEE ALSO .PP \fBtkn\-task(1)\fP diff --git a/docs/man/man1/tkn-taskrun-list.1 b/docs/man/man1/tkn-taskrun-list.1 index f3d423f303..cbe8c7b93c 100644 --- a/docs/man/man1/tkn-taskrun-list.1 +++ b/docs/man/man1/tkn-taskrun-list.1 @@ -104,6 +104,30 @@ tkn taskrun list foo \-n bar .fi .RE +.PP +List TaskRuns as a JSON array: + +.PP +.RS + +.nf +tkn taskrun list \-o json + +.fi +.RE + +.PP +List TaskRuns as a YAML array: + +.PP +.RS + +.nf +tkn taskrun list \-o yaml + +.fi +.RE + .SH SEE ALSO .PP diff --git a/docs/man/man1/tkn-triggerbinding-list.1 b/docs/man/man1/tkn-triggerbinding-list.1 index cfd7dfe5ba..d6636d7219 100644 --- a/docs/man/man1/tkn-triggerbinding-list.1 +++ b/docs/man/man1/tkn-triggerbinding-list.1 @@ -92,6 +92,30 @@ tkn tb ls \-n bar .fi .RE +.PP +List TriggerBindings as a JSON array: + +.PP +.RS + +.nf +tkn triggerbinding list \-o json + +.fi +.RE + +.PP +List TriggerBindings as a YAML array: + +.PP +.RS + +.nf +tkn triggerbinding list \-o yaml + +.fi +.RE + .SH SEE ALSO .PP diff --git a/docs/man/man1/tkn-triggertemplate-list.1 b/docs/man/man1/tkn-triggertemplate-list.1 index 4f751a29a7..260ece9302 100644 --- a/docs/man/man1/tkn-triggertemplate-list.1 +++ b/docs/man/man1/tkn-triggertemplate-list.1 @@ -92,6 +92,30 @@ tkn tt ls \-n bar .fi .RE +.PP +List TriggerTemplates as a JSON array: + +.PP +.RS + +.nf +tkn triggertemplate list \-o json + +.fi +.RE + +.PP +List TriggerTemplates as a YAML array: + +.PP +.RS + +.nf +tkn triggertemplate list \-o yaml + +.fi +.RE + .SH SEE ALSO .PP diff --git a/pkg/cmd/clustertriggerbinding/list.go b/pkg/cmd/clustertriggerbinding/list.go index f0ec246877..02cdc07a45 100644 --- a/pkg/cmd/clustertriggerbinding/list.go +++ b/pkg/cmd/clustertriggerbinding/list.go @@ -47,6 +47,14 @@ func listCommand(p cli.Params) *cobra.Command { or tkn ctb ls + +List ClusterTriggerBindings as a JSON array: + + tkn clustertriggerbinding list -o json + +List ClusterTriggerBindings as a YAML array: + + tkn clustertriggerbinding list -o yaml ` c := &cobra.Command{ @@ -88,6 +96,14 @@ or } return nil } else if output != "" { + if formatted.IsStructured(output) { + items := tbs.Items + if items == nil { + items = []v1beta1.ClusterTriggerBinding{} + } + formatted.SetTypeMeta(items, v1beta1.SchemeGroupVersion.WithKind("ClusterTriggerBinding")) + return formatted.PrintStructuredOutput(cmd.OutOrStdout(), output, items) + } p, err := f.ToPrinter() if err != nil { return err diff --git a/pkg/cmd/clustertriggerbinding/list_test.go b/pkg/cmd/clustertriggerbinding/list_test.go index d5ca21e209..e7ea702802 100644 --- a/pkg/cmd/clustertriggerbinding/list_test.go +++ b/pkg/cmd/clustertriggerbinding/list_test.go @@ -15,6 +15,7 @@ package clustertriggerbinding import ( + "encoding/json" "fmt" "strings" "testing" @@ -30,6 +31,7 @@ import ( "gotest.tools/v3/golden" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/yaml" ) func TestListClusterTriggerBinding(t *testing.T) { @@ -129,3 +131,83 @@ func command(t *testing.T, ctbs []*v1beta1.ClusterTriggerBinding, now time.Time) p := &test.Params{Triggers: cs.Triggers, Kube: cs.Kube, Dynamic: dc, Tekton: cs.Pipeline, Clock: clock} return Command(p) } + +func TestListClusterTriggerBinding_structured_output(t *testing.T) { + now := time.Now() + ctbs := []*v1beta1.ClusterTriggerBinding{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "ctb-a", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "ctb-b", + }, + }, + } + cmd := command(t, ctbs, now) + + jsonOut, err := test.ExecuteCommand(cmd, "list", "-o", "json") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + yamlOut, err := test.ExecuteCommand(command(t, ctbs, now), "list", "-o", "yaml") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var fromJSON, fromYAML []v1beta1.ClusterTriggerBinding + if err := json.Unmarshal([]byte(jsonOut), &fromJSON); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, jsonOut) + } + if err := yaml.Unmarshal([]byte(yamlOut), &fromYAML); err != nil { + t.Fatalf("output is not a valid YAML sequence: %v\n%s", err, yamlOut) + } + if len(fromJSON) != 2 || len(fromYAML) != 2 { + t.Errorf("expected 2 clustertriggerbindings, got json=%d yaml=%d", len(fromJSON), len(fromYAML)) + } + if strings.Contains(jsonOut, "ClusterTriggerBindingList") { + t.Errorf("json output should be an array of ClusterTriggerBinding objects, not a ClusterTriggerBindingList wrapper") + } +} + +func TestListClusterTriggerBinding_empty_output_json(t *testing.T) { + now := time.Now() + output, err := test.ExecuteCommand(command(t, []*v1beta1.ClusterTriggerBinding{}, now), "list", "-o", "json") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var got []v1beta1.ClusterTriggerBinding + if err := json.Unmarshal([]byte(output), &got); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, output) + } + if len(got) != 0 { + t.Errorf("expected empty JSON array, got %d items", len(got)) + } + if strings.Contains(output, emptyMsg) { + t.Errorf("structured output should not include the empty table message") + } +} + +func TestListClusterTriggerBinding_invalid_output(t *testing.T) { + now := time.Now() + _, err := test.ExecuteCommand(command(t, []*v1beta1.ClusterTriggerBinding{}, now), "list", "-o", "csv") + if err == nil { + t.Fatal("expected error for invalid output format") + } + if !strings.Contains(err.Error(), "csv") { + t.Errorf("expected error to mention csv, got %q", err.Error()) + } +} + +func TestListClusterTriggerBinding_help_shows_output_examples(t *testing.T) { + cmd := listCommand(&test.Params{}) + if cmd.Flags().Lookup("output") == nil { + t.Fatal("expected --output flag") + } + if !strings.Contains(cmd.Example, "-o json") || !strings.Contains(cmd.Example, "-o yaml") { + t.Errorf("expected help examples for json and yaml output, got %q", cmd.Example) + } +} diff --git a/pkg/cmd/customrun/list.go b/pkg/cmd/customrun/list.go index 0a83778502..cd091f965f 100644 --- a/pkg/cmd/customrun/list.go +++ b/pkg/cmd/customrun/list.go @@ -64,6 +64,14 @@ func listCommand(p cli.Params) *cobra.Command { eg := `List all CustomRuns in namespace 'bar': tkn cr list -n bar + +List CustomRuns as a JSON array: + + tkn customrun list -o json + +List CustomRuns as a YAML array: + + tkn customrun list -o yaml ` c := &cobra.Command{ @@ -102,6 +110,14 @@ func listCommand(p cli.Params) *cobra.Command { } return nil } else if output != "" && crs != nil { + if formatted.IsStructured(output) { + items := crs.Items + if items == nil { + items = []v1beta1.CustomRun{} + } + formatted.SetTypeMeta(items, v1beta1.SchemeGroupVersion.WithKind("CustomRun")) + return formatted.PrintStructuredOutput(cmd.OutOrStdout(), output, items) + } p, err := f.ToPrinter() if err != nil { return err diff --git a/pkg/cmd/customrun/list_test.go b/pkg/cmd/customrun/list_test.go index a55b270b56..f9c1d705f9 100644 --- a/pkg/cmd/customrun/list_test.go +++ b/pkg/cmd/customrun/list_test.go @@ -15,6 +15,7 @@ package customrun import ( + "encoding/json" "fmt" "strings" "testing" @@ -32,6 +33,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/dynamic" duckv1 "knative.dev/pkg/apis/duck/v1" + "sigs.k8s.io/yaml" ) const ( @@ -377,3 +379,120 @@ func commandV1beta1(t *testing.T, crs []*v1beta1.CustomRun, now time.Time, ns [] return Command(p) } + +func TestListCustomRuns_structured_output(t *testing.T) { + now := time.Now() + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + crs := []*v1beta1.CustomRun{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "foo", + Name: "cr-a", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "foo", + Name: "cr-b", + }, + }, + } + tdc := testDynamic.Options{} + dc, err := tdc.Client( + cb.UnstructuredV1beta1CustomRun(crs[0], versionv1beta1), + cb.UnstructuredV1beta1CustomRun(crs[1], versionv1beta1), + ) + if err != nil { + t.Fatalf("unable to create dynamic client: %v", err) + } + + jsonOut, err := test.ExecuteCommand(commandV1beta1(t, crs, now, ns, dc), "list", "-n", "foo", "-o", "json") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + yamlOut, err := test.ExecuteCommand(commandV1beta1(t, crs, now, ns, dc), "list", "-n", "foo", "-o", "yaml") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var fromJSON, fromYAML []v1beta1.CustomRun + if err := json.Unmarshal([]byte(jsonOut), &fromJSON); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, jsonOut) + } + if err := yaml.Unmarshal([]byte(yamlOut), &fromYAML); err != nil { + t.Fatalf("output is not a valid YAML sequence: %v\n%s", err, yamlOut) + } + if len(fromJSON) != 2 || len(fromYAML) != 2 { + t.Errorf("expected 2 customruns, got json=%d yaml=%d", len(fromJSON), len(fromYAML)) + } + if strings.Contains(jsonOut, "CustomRunList") { + t.Errorf("json output should be an array of CustomRun objects, not a CustomRunList wrapper") + } +} + +func TestListCustomRuns_empty_output_json(t *testing.T) { + now := time.Now() + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + tdc := testDynamic.Options{} + dc, err := tdc.Client() + if err != nil { + t.Fatalf("unable to create dynamic client: %v", err) + } + output, err := test.ExecuteCommand(commandV1beta1(t, []*v1beta1.CustomRun{}, now, ns, dc), "list", "-n", "foo", "-o", "json") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var got []v1beta1.CustomRun + if err := json.Unmarshal([]byte(output), &got); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, output) + } + if len(got) != 0 { + t.Errorf("expected empty JSON array, got %d items", len(got)) + } +} + +func TestListCustomRuns_invalid_output(t *testing.T) { + now := time.Now() + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + tdc := testDynamic.Options{} + dc, err := tdc.Client() + if err != nil { + t.Fatalf("unable to create dynamic client: %v", err) + } + _, err = test.ExecuteCommand(commandV1beta1(t, []*v1beta1.CustomRun{}, now, ns, dc), "list", "-n", "foo", "-o", "csv") + if err == nil { + t.Fatal("expected error for invalid output format") + } + if !strings.Contains(err.Error(), "csv") { + t.Errorf("expected error to mention csv, got %q", err.Error()) + } +} + +func TestListCustomRuns_help_shows_output_examples(t *testing.T) { + cmd := listCommand(&test.Params{}) + if cmd.Flags().Lookup("output") == nil { + t.Fatal("expected --output flag") + } + if !strings.Contains(cmd.Example, "-o json") || !strings.Contains(cmd.Example, "-o yaml") { + t.Errorf("expected help examples for json and yaml output, got %q", cmd.Example) + } +} diff --git a/pkg/cmd/eventlistener/list.go b/pkg/cmd/eventlistener/list.go index dc6b6eff8e..6397f04ed3 100644 --- a/pkg/cmd/eventlistener/list.go +++ b/pkg/cmd/eventlistener/list.go @@ -49,6 +49,14 @@ func listCommand(p cli.Params) *cobra.Command { or tkn el ls -n bar + +List EventListeners as a JSON array: + + tkn eventlistener list -o json + +List EventListeners as a YAML array: + + tkn eventlistener list -o yaml ` c := &cobra.Command{ @@ -89,6 +97,14 @@ or } if output != "" { + if formatted.IsStructured(output) { + items := els.Items + if items == nil { + items = []v1beta1.EventListener{} + } + formatted.SetTypeMeta(items, v1beta1.SchemeGroupVersion.WithKind("EventListener")) + return formatted.PrintStructuredOutput(cmd.OutOrStdout(), output, items) + } p, err := f.ToPrinter() if err != nil { return err diff --git a/pkg/cmd/eventlistener/list_test.go b/pkg/cmd/eventlistener/list_test.go index d7c99ce413..fe88d220be 100644 --- a/pkg/cmd/eventlistener/list_test.go +++ b/pkg/cmd/eventlistener/list_test.go @@ -15,6 +15,7 @@ package eventlistener import ( + "encoding/json" "fmt" "strings" "testing" @@ -33,6 +34,7 @@ import ( "knative.dev/pkg/apis" duckv1 "knative.dev/pkg/apis/duck/v1" duckv1beta1 "knative.dev/pkg/apis/duck/v1beta1" + "sigs.k8s.io/yaml" ) func TestListEventListener(t *testing.T) { @@ -286,3 +288,108 @@ func TestEventListenersList_empty(t *testing.T) { out, _ := test.ExecuteCommand(Command(listEls), "list", "--all-namespaces") test.AssertOutput(t, emptyMsg+"\n", out) } + +func TestListEventListener_structured_output(t *testing.T) { + now := time.Now() + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + els := []*v1beta1.EventListener{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "el-a", + Namespace: "foo", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "el-b", + Namespace: "foo", + }, + }, + } + p := command(t, els, now, ns) + + jsonOut, err := test.ExecuteCommand(Command(p), "list", "-n", "foo", "-o", "json") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + yamlOut, err := test.ExecuteCommand(Command(p), "list", "-n", "foo", "-o", "yaml") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var fromJSON, fromYAML []v1beta1.EventListener + if err := json.Unmarshal([]byte(jsonOut), &fromJSON); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, jsonOut) + } + if err := yaml.Unmarshal([]byte(yamlOut), &fromYAML); err != nil { + t.Fatalf("output is not a valid YAML sequence: %v\n%s", err, yamlOut) + } + if len(fromJSON) != 2 || len(fromYAML) != 2 { + t.Errorf("expected 2 eventlisteners, got json=%d yaml=%d", len(fromJSON), len(fromYAML)) + } + if strings.Contains(jsonOut, "EventListenerList") { + t.Errorf("json output should be an array of EventListener objects, not an EventListenerList wrapper") + } +} + +func TestListEventListener_empty_output_json(t *testing.T) { + now := time.Now() + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + p := command(t, []*v1beta1.EventListener{}, now, ns) + output, err := test.ExecuteCommand(Command(p), "list", "-n", "foo", "-o", "json") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var got []v1beta1.EventListener + if err := json.Unmarshal([]byte(output), &got); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, output) + } + if len(got) != 0 { + t.Errorf("expected empty JSON array, got %d items", len(got)) + } + if strings.Contains(output, emptyMsg) { + t.Errorf("structured output should not include the empty table message") + } +} + +func TestListEventListener_invalid_output(t *testing.T) { + now := time.Now() + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + p := command(t, []*v1beta1.EventListener{}, now, ns) + _, err := test.ExecuteCommand(Command(p), "list", "-n", "foo", "-o", "csv") + if err == nil { + t.Fatal("expected error for invalid output format") + } + if !strings.Contains(err.Error(), "csv") { + t.Errorf("expected error to mention csv, got %q", err.Error()) + } +} + +func TestListEventListener_help_shows_output_examples(t *testing.T) { + cmd := listCommand(&test.Params{}) + if cmd.Flags().Lookup("output") == nil { + t.Fatal("expected --output flag") + } + if !strings.Contains(cmd.Example, "-o json") || !strings.Contains(cmd.Example, "-o yaml") { + t.Errorf("expected help examples for json and yaml output, got %q", cmd.Example) + } +} diff --git a/pkg/cmd/pipeline/list.go b/pkg/cmd/pipeline/list.go index 57b22ed381..16065eb246 100644 --- a/pkg/cmd/pipeline/list.go +++ b/pkg/cmd/pipeline/list.go @@ -74,6 +74,18 @@ func listCommand(p cli.Params) *cobra.Command { Annotations: map[string]string{ "commandType": "main", }, + Example: `List Pipelines in namespace 'bar': + + tkn pipeline list -n bar + +List Pipelines as a JSON array: + + tkn pipeline list -o json + +List Pipelines as a YAML array: + + tkn pipeline list -o yaml +`, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { cs, err := p.Clients() @@ -92,15 +104,27 @@ func listCommand(p cli.Params) *cobra.Command { } if output != "" { - p, err := f.ToPrinter() + if formatted.IsStructured(output) { + var pipelines *v1.PipelineList + if err := actions.ListV1(pipelineGroupResource, cs, metav1.ListOptions{}, ns, &pipelines); err != nil { + return err + } + items := pipelines.Items + if items == nil { + items = []v1.Pipeline{} + } + formatted.SetTypeMeta(items, v1.SchemeGroupVersion.WithKind("Pipeline")) + return formatted.PrintStructuredOutput(cmd.OutOrStdout(), output, items) + } + printer, err := f.ToPrinter() if err != nil { return err } - return actions.PrintObjects(pipelineGroupResource, cmd.OutOrStdout(), cs.Dynamic, cs.Tekton.Discovery(), p, ns) + return actions.PrintObjects(pipelineGroupResource, cmd.OutOrStdout(), cs.Dynamic, cs.Tekton.Discovery(), printer, ns) } stream := &cli.Stream{ Out: cmd.OutOrStdout(), - Err: cmd.OutOrStderr(), + Err: cmd.ErrOrStderr(), } return printPipelineDetails(stream, p, opts.AllNamespaces, opts.NoHeaders) }, diff --git a/pkg/cmd/pipeline/list_test.go b/pkg/cmd/pipeline/list_test.go index cdf6251c66..ee50acf92e 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" @@ -29,6 +31,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" duckv1 "knative.dev/pkg/apis/duck/v1" + "sigs.k8s.io/yaml" ) func TestPipelinesList_invalid_namespace_v1beta1(t *testing.T) { @@ -1165,5 +1168,259 @@ func TestPipelineList_in_all_namespaces_with_output_yaml_flag(t *testing.T) { t.Errorf("Unexpected error: %v", err) } + var got []v1.Pipeline + if err := yaml.Unmarshal([]byte(output), &got); err != nil { + t.Fatalf("output is not valid YAML: %v\n%s", err, output) + } + if len(got) != 6 { + t.Errorf("expected 6 pipelines, got %d", len(got)) + } + golden.Assert(t, output, fmt.Sprintf("%s.golden", t.Name())) } + +func TestPipelineList_output_json(t *testing.T) { + clock := test.FakeClock() + version := "v1" + + pdata := []*v1.Pipeline{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "tomatoes", + Namespace: "namespace", + CreationTimestamp: metav1.Time{Time: clock.Now().Add(-1 * time.Minute)}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "mangoes", + Namespace: "namespace", + CreationTimestamp: metav1.Time{Time: clock.Now().Add(-20 * time.Second)}, + }, + }, + } + + nsList := []*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.Errorf("unable to create dynamic client: %v", err) + } + cs, _ := test.SeedTestData(t, pipelinetest.Data{Pipelines: pdata, Namespaces: nsList}) + p := &test.Params{Tekton: cs.Pipeline, Clock: clock, Kube: cs.Kube, Dynamic: dynamic} + cs.Pipeline.Resources = cb.APIResourceList(version, []string{"pipeline"}) + pipeline := Command(p) + + output, err := test.ExecuteCommand(pipeline, "list", "-n", "namespace", "-o", "json") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + var got []v1.Pipeline + if err := json.Unmarshal([]byte(output), &got); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, output) + } + if len(got) != 2 { + t.Errorf("expected 2 pipelines, got %d", len(got)) + } + + names := map[string]bool{} + for _, p := range got { + names[p.Name] = true + } + if !names["tomatoes"] || !names["mangoes"] { + t.Errorf("unexpected pipeline names in JSON output: %v", names) + } + + golden.Assert(t, output, fmt.Sprintf("%s.golden", t.Name())) +} + +func TestPipelineList_empty_output_json(t *testing.T) { + nsList := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + + cs, _ := test.SeedTestData(t, pipelinetest.Data{Namespaces: nsList}) + cs.Pipeline.Resources = cb.APIResourceList("v1", []string{"pipeline"}) + tdc := testDynamic.Options{} + dc, err := tdc.Client() + if err != nil { + t.Errorf("unable to create dynamic client: %v", err) + } + + p := &test.Params{Tekton: cs.Pipeline, Kube: cs.Kube, Dynamic: dc} + pipeline := Command(p) + output, err := test.ExecuteCommand(pipeline, "list", "-n", "foo", "-o", "json") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + var got []v1.Pipeline + if err := json.Unmarshal([]byte(output), &got); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, output) + } + if len(got) != 0 { + t.Errorf("expected empty JSON array, got %d items", len(got)) + } +} + +func TestPipelineList_invalid_output(t *testing.T) { + nsList := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + + cs, _ := test.SeedTestData(t, pipelinetest.Data{Namespaces: nsList}) + cs.Pipeline.Resources = cb.APIResourceList("v1", []string{"pipeline"}) + tdc := testDynamic.Options{} + dc, err := tdc.Client() + if err != nil { + t.Errorf("unable to create dynamic client: %v", err) + } + + p := &test.Params{Tekton: cs.Pipeline, Kube: cs.Kube, Dynamic: dc} + pipeline := Command(p) + _, err = test.ExecuteCommand(pipeline, "list", "-n", "foo", "-o", "csv") + if err == nil { + t.Fatal("expected error for invalid output format") + } + if !strings.Contains(err.Error(), "csv") { + t.Errorf("expected error to mention csv, got %q", err.Error()) + } +} + +func TestPipelineList_empty_output_yaml(t *testing.T) { + nsList := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + + cs, _ := test.SeedTestData(t, pipelinetest.Data{Namespaces: nsList}) + cs.Pipeline.Resources = cb.APIResourceList("v1", []string{"pipeline"}) + tdc := testDynamic.Options{} + dc, err := tdc.Client() + if err != nil { + t.Errorf("unable to create dynamic client: %v", err) + } + + p := &test.Params{Tekton: cs.Pipeline, Kube: cs.Kube, Dynamic: dc} + pipeline := Command(p) + output, err := test.ExecuteCommand(pipeline, "list", "-n", "foo", "-o", "yaml") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + var got []v1.Pipeline + if err := yaml.Unmarshal([]byte(output), &got); err != nil { + t.Fatalf("output is not a valid YAML sequence: %v\n%s", err, output) + } + if len(got) != 0 { + t.Errorf("expected empty YAML sequence, got %d items", len(got)) + } +} + +func TestPipelineList_json_yaml_equivalent(t *testing.T) { + clock := test.FakeClock() + version := "v1" + + pdata := []*v1.Pipeline{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "tomatoes", + Namespace: "namespace", + CreationTimestamp: metav1.Time{Time: clock.Now().Add(-1 * time.Minute)}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "mangoes", + Namespace: "namespace", + CreationTimestamp: metav1.Time{Time: clock.Now().Add(-20 * time.Second)}, + }, + }, + } + + nsList := []*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.Errorf("unable to create dynamic client: %v", err) + } + cs, _ := test.SeedTestData(t, pipelinetest.Data{Pipelines: pdata, Namespaces: nsList}) + p := &test.Params{Tekton: cs.Pipeline, Clock: clock, Kube: cs.Kube, Dynamic: dynamic} + cs.Pipeline.Resources = cb.APIResourceList(version, []string{"pipeline"}) + + jsonOut, err := test.ExecuteCommand(Command(p), "list", "-n", "namespace", "-o", "json") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + yamlOut, err := test.ExecuteCommand(Command(p), "list", "-n", "namespace", "-o", "yaml") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + var fromJSON, fromYAML []v1.Pipeline + if err := json.Unmarshal([]byte(jsonOut), &fromJSON); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, jsonOut) + } + if err := yaml.Unmarshal([]byte(yamlOut), &fromYAML); err != nil { + t.Fatalf("output is not a valid YAML sequence: %v\n%s", err, yamlOut) + } + if len(fromJSON) != len(fromYAML) { + t.Fatalf("json and yaml item counts differ: json=%d yaml=%d", len(fromJSON), len(fromYAML)) + } + for i := range fromJSON { + if fromJSON[i].Name != fromYAML[i].Name || fromJSON[i].Namespace != fromYAML[i].Namespace { + t.Errorf("item %d differs: json=%s/%s yaml=%s/%s", i, fromJSON[i].Namespace, fromJSON[i].Name, fromYAML[i].Namespace, fromYAML[i].Name) + } + if fromJSON[i].APIVersion != "tekton.dev/v1" || fromJSON[i].Kind != "Pipeline" { + t.Errorf("json item %d has unexpected type meta: apiVersion=%q kind=%q", i, fromJSON[i].APIVersion, fromJSON[i].Kind) + } + } + if strings.Contains(jsonOut, "PipelineList") { + t.Errorf("json output should be an array of Pipeline objects, not a PipelineList wrapper") + } +} + +func TestPipelineList_help_shows_output_examples(t *testing.T) { + cmd := listCommand(&test.Params{}) + if cmd.Flags().Lookup("output") == nil { + t.Fatal("expected --output flag") + } + if !strings.Contains(cmd.Example, "-o json") { + t.Errorf("expected help example for json output, got %q", cmd.Example) + } + if !strings.Contains(cmd.Example, "-o yaml") { + t.Errorf("expected help example for yaml output, got %q", cmd.Example) + } +} diff --git a/pkg/cmd/pipeline/testdata/TestPipelineList_in_all_namespaces_with_output_yaml_flag.golden b/pkg/cmd/pipeline/testdata/TestPipelineList_in_all_namespaces_with_output_yaml_flag.golden index ce392024c8..ea7fcb1e87 100644 --- a/pkg/cmd/pipeline/testdata/TestPipelineList_in_all_namespaces_with_output_yaml_flag.golden +++ b/pkg/cmd/pipeline/testdata/TestPipelineList_in_all_namespaces_with_output_yaml_flag.golden @@ -1,48 +1,42 @@ -apiVersion: tekton.dev/v1 -items: - apiVersion: tekton.dev/v1 - kind: pipeline + kind: Pipeline metadata: creationTimestamp: "1984-03-13T16:00:00Z" name: bananes namespace: espace-de-nom spec: {} - apiVersion: tekton.dev/v1 - kind: pipeline + kind: Pipeline metadata: creationTimestamp: "1984-04-03T23:59:40Z" name: mangues namespace: espace-de-nom spec: {} - apiVersion: tekton.dev/v1 - kind: pipeline + kind: Pipeline metadata: creationTimestamp: "1984-04-03T23:59:00Z" name: tomates namespace: espace-de-nom spec: {} - apiVersion: tekton.dev/v1 - kind: pipeline + kind: Pipeline metadata: creationTimestamp: "1984-03-13T16:00:00Z" name: bananas namespace: namespace spec: {} - apiVersion: tekton.dev/v1 - kind: pipeline + kind: Pipeline metadata: creationTimestamp: "1984-04-03T23:59:40Z" name: mangoes namespace: namespace spec: {} - apiVersion: tekton.dev/v1 - kind: pipeline + kind: Pipeline metadata: creationTimestamp: "1984-04-03T23:59:00Z" name: tomatoes namespace: namespace spec: {} -kind: PipelineList -metadata: - continue: "" - resourceVersion: "7" diff --git a/pkg/cmd/pipeline/testdata/TestPipelineList_output_json.golden b/pkg/cmd/pipeline/testdata/TestPipelineList_output_json.golden new file mode 100644 index 0000000000..da291941e4 --- /dev/null +++ b/pkg/cmd/pipeline/testdata/TestPipelineList_output_json.golden @@ -0,0 +1,22 @@ +[ + { + "kind": "Pipeline", + "apiVersion": "tekton.dev/v1", + "metadata": { + "name": "mangoes", + "namespace": "namespace", + "creationTimestamp": "1984-04-03T23:59:40Z" + }, + "spec": {} + }, + { + "kind": "Pipeline", + "apiVersion": "tekton.dev/v1", + "metadata": { + "name": "tomatoes", + "namespace": "namespace", + "creationTimestamp": "1984-04-03T23:59:00Z" + }, + "spec": {} + } +] diff --git a/pkg/cmd/pipelinerun/list.go b/pkg/cmd/pipelinerun/list.go index bd9fd2fbb2..55ef0421c6 100644 --- a/pkg/cmd/pipelinerun/list.go +++ b/pkg/cmd/pipelinerun/list.go @@ -66,6 +66,14 @@ func listCommand(p cli.Params) *cobra.Command { List all PipelineRuns in a namespace 'foo': tkn pr list -n foo + +List PipelineRuns as a JSON array: + + tkn pipelinerun list -o json + +List PipelineRuns as a YAML array: + + tkn pipelinerun list -o yaml ` c := &cobra.Command{ @@ -111,6 +119,14 @@ List all PipelineRuns in a namespace 'foo': } return nil } else if output != "" && prs != nil { + if formatted.IsStructured(output) { + items := prs.Items + if items == nil { + items = []v1.PipelineRun{} + } + formatted.SetTypeMeta(items, v1.SchemeGroupVersion.WithKind("PipelineRun")) + return formatted.PrintStructuredOutput(cmd.OutOrStdout(), output, items) + } p, err := f.ToPrinter() if err != nil { return err diff --git a/pkg/cmd/pipelinerun/list_test.go b/pkg/cmd/pipelinerun/list_test.go index 9cfb338001..e7c033ac1f 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" @@ -33,6 +34,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/dynamic" duckv1 "knative.dev/pkg/apis/duck/v1" + "sigs.k8s.io/yaml" ) func TestListPipelineRuns_v1beta1(t *testing.T) { @@ -583,3 +585,124 @@ func command(t *testing.T, prs []*v1.PipelineRun, now time.Time, ns []*corev1.Na return Command(p) } + +func TestListPipelineRuns_structured_output(t *testing.T) { + clock := test.FakeClock() + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "namespace", + }, + }, + } + prs := []*v1.PipelineRun{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "namespace", + Name: "pr-a", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "namespace", + Name: "pr-b", + }, + }, + } + 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) + } + + jsonOut, err := test.ExecuteCommand(command(t, prs, clock.Now(), ns, version, dc), "list", "-n", "namespace", "-o", "json") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + yamlOut, err := test.ExecuteCommand(command(t, prs, clock.Now(), ns, version, dc), "list", "-n", "namespace", "-o", "yaml") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var fromJSON, fromYAML []v1.PipelineRun + if err := json.Unmarshal([]byte(jsonOut), &fromJSON); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, jsonOut) + } + if err := yaml.Unmarshal([]byte(yamlOut), &fromYAML); err != nil { + t.Fatalf("output is not a valid YAML sequence: %v\n%s", err, yamlOut) + } + if len(fromJSON) != 2 || len(fromYAML) != 2 { + t.Errorf("expected 2 pipelineruns, got json=%d yaml=%d", len(fromJSON), len(fromYAML)) + } + if strings.Contains(jsonOut, "PipelineRunList") { + t.Errorf("json output should be an array of PipelineRun objects, not a PipelineRunList wrapper") + } +} + +func TestListPipelineRuns_empty_output_json(t *testing.T) { + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "ns", + }, + }, + } + cs, _ := test.SeedTestData(t, pipelinetest.Data{Namespaces: ns}) + cs.Pipeline.Resources = cb.APIResourceList(version, []string{"pipelinerun"}) + tdc := testDynamic.Options{} + dc, err := tdc.Client() + if err != nil { + t.Fatalf("unable to create dynamic client: %v", err) + } + p := &test.Params{Tekton: cs.Pipeline, Kube: cs.Kube, Dynamic: dc} + output, err := test.ExecuteCommand(Command(p), "list", "-n", "ns", "-o", "json") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var got []v1.PipelineRun + if err := json.Unmarshal([]byte(output), &got); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, output) + } + if len(got) != 0 { + t.Errorf("expected empty JSON array, got %d items", len(got)) + } +} + +func TestListPipelineRuns_invalid_output(t *testing.T) { + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "ns", + }, + }, + } + cs, _ := test.SeedTestData(t, pipelinetest.Data{Namespaces: ns}) + cs.Pipeline.Resources = cb.APIResourceList(version, []string{"pipelinerun"}) + tdc := testDynamic.Options{} + dc, err := tdc.Client() + if err != nil { + t.Fatalf("unable to create dynamic client: %v", err) + } + p := &test.Params{Tekton: cs.Pipeline, Kube: cs.Kube, Dynamic: dc} + _, err = test.ExecuteCommand(Command(p), "list", "-n", "ns", "-o", "csv") + if err == nil { + t.Fatal("expected error for invalid output format") + } + if !strings.Contains(err.Error(), "csv") { + t.Errorf("expected error to mention csv, got %q", err.Error()) + } +} + +func TestListPipelineRuns_help_shows_output_examples(t *testing.T) { + cmd := listCommand(&test.Params{}) + if cmd.Flags().Lookup("output") == nil { + t.Fatal("expected --output flag") + } + if !strings.Contains(cmd.Example, "-o json") || !strings.Contains(cmd.Example, "-o yaml") { + t.Errorf("expected help examples for json and yaml output, got %q", cmd.Example) + } +} diff --git a/pkg/cmd/task/list.go b/pkg/cmd/task/list.go index e79fd0af9b..c2d0444945 100644 --- a/pkg/cmd/task/list.go +++ b/pkg/cmd/task/list.go @@ -64,6 +64,18 @@ func listCommand(p cli.Params) *cobra.Command { Annotations: map[string]string{ "commandType": "main", }, + Example: `List Tasks in namespace 'bar': + + tkn task list -n bar + +List Tasks as a JSON array: + + tkn task list -o json + +List Tasks as a YAML array: + + tkn task list -o yaml +`, RunE: func(cmd *cobra.Command, _ []string) error { cs, err := p.Clients() if err != nil { @@ -81,6 +93,18 @@ func listCommand(p cli.Params) *cobra.Command { } if output != "" { + if formatted.IsStructured(output) { + var tasks *v1.TaskList + if err := actions.ListV1(taskGroupResource, cs, metav1.ListOptions{}, ns, &tasks); err != nil { + return err + } + items := tasks.Items + if items == nil { + items = []v1.Task{} + } + formatted.SetTypeMeta(items, v1.SchemeGroupVersion.WithKind("Task")) + return formatted.PrintStructuredOutput(cmd.OutOrStdout(), output, items) + } p, err := f.ToPrinter() if err != nil { return err diff --git a/pkg/cmd/task/list_test.go b/pkg/cmd/task/list_test.go index 2efcca661c..cf470d8979 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" @@ -28,6 +30,7 @@ import ( "gotest.tools/v3/golden" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" ) func TestTaskList_Invalid_Namespace_v1beta1(t *testing.T) { @@ -1150,5 +1153,134 @@ func TestTaskList_in_all_namespaces_with_output_yaml_flag(t *testing.T) { t.Errorf("Unexpected error: %v", err) } + var got []v1.Task + if err := yaml.Unmarshal([]byte(output), &got); err != nil { + t.Fatalf("output is not valid YAML: %v\n%s", err, output) + } + if len(got) != 8 { + t.Errorf("expected 8 tasks, got %d", len(got)) + } + golden.Assert(t, output, fmt.Sprintf("%s.golden", t.Name())) } + +func TestTaskList_output_json(t *testing.T) { + clock := test.FakeClock() + version := "v1" + tasks := []*v1.Task{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "tomatoes", + Namespace: "namespace", + CreationTimestamp: metav1.Time{Time: clock.Now().Add(-1 * time.Minute)}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "mangoes", + Namespace: "namespace", + CreationTimestamp: metav1.Time{Time: clock.Now().Add(-20 * time.Second)}, + }, + }, + } + nsList := []*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.Errorf("unable to create dynamic client: %v", err) + } + cs, _ := test.SeedTestData(t, pipelinetest.Data{Tasks: tasks, Namespaces: nsList}) + p := &test.Params{Tekton: cs.Pipeline, Clock: clock, Kube: cs.Kube, Dynamic: dynamic} + cs.Pipeline.Resources = cb.APIResourceList(version, []string{"task"}) + + output, err := test.ExecuteCommand(Command(p), "list", "-n", "namespace", "-o", "json") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + var got []v1.Task + if err := json.Unmarshal([]byte(output), &got); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, output) + } + if len(got) != 2 { + t.Errorf("expected 2 tasks, got %d", len(got)) + } + if strings.Contains(output, "TaskList") { + t.Errorf("json output should be an array of Task objects, not a TaskList wrapper") + } +} + +func TestTaskList_empty_output_json(t *testing.T) { + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + cs, _ := test.SeedTestData(t, pipelinetest.Data{Namespaces: ns}) + cs.Pipeline.Resources = cb.APIResourceList("v1", []string{"task"}) + tdc := testDynamic.Options{} + dc, err := tdc.Client() + if err != nil { + t.Errorf("unable to create dynamic client: %v", err) + } + p := &test.Params{Tekton: cs.Pipeline, Kube: cs.Kube, Dynamic: dc} + output, err := test.ExecuteCommand(Command(p), "list", "-n", "foo", "-o", "json") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + var got []v1.Task + if err := json.Unmarshal([]byte(output), &got); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, output) + } + if len(got) != 0 { + t.Errorf("expected empty JSON array, got %d items", len(got)) + } +} + +func TestTaskList_invalid_output(t *testing.T) { + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + cs, _ := test.SeedTestData(t, pipelinetest.Data{Namespaces: ns}) + cs.Pipeline.Resources = cb.APIResourceList("v1", []string{"task"}) + tdc := testDynamic.Options{} + dc, err := tdc.Client() + if err != nil { + t.Errorf("unable to create dynamic client: %v", err) + } + p := &test.Params{Tekton: cs.Pipeline, Kube: cs.Kube, Dynamic: dc} + _, err = test.ExecuteCommand(Command(p), "list", "-n", "foo", "-o", "csv") + if err == nil { + t.Fatal("expected error for invalid output format") + } + if !strings.Contains(err.Error(), "csv") { + t.Errorf("expected error to mention csv, got %q", err.Error()) + } +} + +func TestTaskList_help_shows_output_examples(t *testing.T) { + cmd := listCommand(&test.Params{}) + if cmd.Flags().Lookup("output") == nil { + t.Fatal("expected --output flag") + } + if !strings.Contains(cmd.Example, "-o json") || !strings.Contains(cmd.Example, "-o yaml") { + t.Errorf("expected help examples for json and yaml output, got %q", cmd.Example) + } +} diff --git a/pkg/cmd/task/testdata/TestTaskList_in_all_namespaces_with_output_yaml_flag.golden b/pkg/cmd/task/testdata/TestTaskList_in_all_namespaces_with_output_yaml_flag.golden index 555ae35e0d..018f7ed19d 100644 --- a/pkg/cmd/task/testdata/TestTaskList_in_all_namespaces_with_output_yaml_flag.golden +++ b/pkg/cmd/task/testdata/TestTaskList_in_all_namespaces_with_output_yaml_flag.golden @@ -1,21 +1,19 @@ -apiVersion: tekton.dev/v1 -items: - apiVersion: tekton.dev/v1 - kind: task + kind: Task metadata: creationTimestamp: "1984-03-13T16:00:00Z" name: bananes namespace: espace-de-nom spec: {} - apiVersion: tekton.dev/v1 - kind: task + kind: Task metadata: creationTimestamp: "1984-04-03T23:59:40Z" name: mangues namespace: espace-de-nom spec: {} - apiVersion: tekton.dev/v1 - kind: task + kind: Task metadata: creationTimestamp: "1984-03-13T13:00:00Z" name: oignons @@ -23,41 +21,37 @@ items: spec: description: a test task to test description of task - apiVersion: tekton.dev/v1 - kind: task + kind: Task metadata: creationTimestamp: "1984-03-13T15:00:00Z" name: pommes namespace: espace-de-nom spec: {} - apiVersion: tekton.dev/v1 - kind: task + kind: Task metadata: creationTimestamp: "1984-04-03T23:59:00Z" name: tomates namespace: espace-de-nom spec: {} - apiVersion: tekton.dev/v1 - kind: task + kind: Task metadata: creationTimestamp: "1984-03-13T16:00:00Z" name: bananas namespace: namespace spec: {} - apiVersion: tekton.dev/v1 - kind: task + kind: Task metadata: creationTimestamp: "1984-04-03T23:59:40Z" name: mangoes namespace: namespace spec: {} - apiVersion: tekton.dev/v1 - kind: task + kind: Task metadata: creationTimestamp: "1984-04-03T23:59:00Z" name: tomatoes namespace: namespace spec: {} -kind: TaskList -metadata: - continue: "" - resourceVersion: "9" diff --git a/pkg/cmd/taskrun/list.go b/pkg/cmd/taskrun/list.go index b7840d8ac0..b12e9d80a8 100644 --- a/pkg/cmd/taskrun/list.go +++ b/pkg/cmd/taskrun/list.go @@ -69,6 +69,14 @@ func listCommand(p cli.Params) *cobra.Command { List all TaskRuns of Task 'foo' in namespace 'bar': tkn taskrun list foo -n bar + +List TaskRuns as a JSON array: + + tkn taskrun list -o json + +List TaskRuns as a YAML array: + + tkn taskrun list -o yaml ` c := &cobra.Command{ @@ -112,6 +120,14 @@ List all TaskRuns of Task 'foo' in namespace 'bar': } return nil } else if output != "" && trs != nil { + if formatted.IsStructured(output) { + items := trs.Items + if items == nil { + items = []v1.TaskRun{} + } + formatted.SetTypeMeta(items, v1.SchemeGroupVersion.WithKind("TaskRun")) + return formatted.PrintStructuredOutput(cmd.OutOrStdout(), output, items) + } p, err := f.ToPrinter() if err != nil { return err diff --git a/pkg/cmd/taskrun/list_test.go b/pkg/cmd/taskrun/list_test.go index d8b7f587aa..91f918d334 100644 --- a/pkg/cmd/taskrun/list_test.go +++ b/pkg/cmd/taskrun/list_test.go @@ -15,6 +15,7 @@ package taskrun import ( + "encoding/json" "fmt" "strings" "testing" @@ -33,6 +34,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/dynamic" duckv1 "knative.dev/pkg/apis/duck/v1" + "sigs.k8s.io/yaml" ) func TestListTaskRuns_v1beta1(t *testing.T) { @@ -845,3 +847,120 @@ func command(t *testing.T, trs []*v1.TaskRun, now time.Time, ns []*corev1.Namesp return Command(p) } + +func TestListTaskRuns_structured_output(t *testing.T) { + now := time.Now() + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + trs := []*v1.TaskRun{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "foo", + Name: "tr-a", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "foo", + Name: "tr-b", + }, + }, + } + tdc := testDynamic.Options{} + dc, err := tdc.Client( + cb.UnstructuredTR(trs[0], version), + cb.UnstructuredTR(trs[1], version), + ) + if err != nil { + t.Fatalf("unable to create dynamic client: %v", err) + } + + jsonOut, err := test.ExecuteCommand(command(t, trs, now, ns, dc), "list", "-n", "foo", "-o", "json") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + yamlOut, err := test.ExecuteCommand(command(t, trs, now, ns, dc), "list", "-n", "foo", "-o", "yaml") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var fromJSON, fromYAML []v1.TaskRun + if err := json.Unmarshal([]byte(jsonOut), &fromJSON); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, jsonOut) + } + if err := yaml.Unmarshal([]byte(yamlOut), &fromYAML); err != nil { + t.Fatalf("output is not a valid YAML sequence: %v\n%s", err, yamlOut) + } + if len(fromJSON) != 2 || len(fromYAML) != 2 { + t.Errorf("expected 2 taskruns, got json=%d yaml=%d", len(fromJSON), len(fromYAML)) + } + if strings.Contains(jsonOut, "TaskRunList") { + t.Errorf("json output should be an array of TaskRun objects, not a TaskRunList wrapper") + } +} + +func TestListTaskRuns_empty_output_json(t *testing.T) { + now := time.Now() + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + tdc := testDynamic.Options{} + dc, err := tdc.Client() + if err != nil { + t.Fatalf("unable to create dynamic client: %v", err) + } + output, err := test.ExecuteCommand(command(t, []*v1.TaskRun{}, now, ns, dc), "list", "-n", "foo", "-o", "json") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var got []v1.TaskRun + if err := json.Unmarshal([]byte(output), &got); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, output) + } + if len(got) != 0 { + t.Errorf("expected empty JSON array, got %d items", len(got)) + } +} + +func TestListTaskRuns_invalid_output(t *testing.T) { + now := time.Now() + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + tdc := testDynamic.Options{} + dc, err := tdc.Client() + if err != nil { + t.Fatalf("unable to create dynamic client: %v", err) + } + _, err = test.ExecuteCommand(command(t, []*v1.TaskRun{}, now, ns, dc), "list", "-n", "foo", "-o", "csv") + if err == nil { + t.Fatal("expected error for invalid output format") + } + if !strings.Contains(err.Error(), "csv") { + t.Errorf("expected error to mention csv, got %q", err.Error()) + } +} + +func TestListTaskRuns_help_shows_output_examples(t *testing.T) { + cmd := listCommand(&test.Params{}) + if cmd.Flags().Lookup("output") == nil { + t.Fatal("expected --output flag") + } + if !strings.Contains(cmd.Example, "-o json") || !strings.Contains(cmd.Example, "-o yaml") { + t.Errorf("expected help examples for json and yaml output, got %q", cmd.Example) + } +} diff --git a/pkg/cmd/triggerbinding/list.go b/pkg/cmd/triggerbinding/list.go index dd29539c41..3aea097118 100644 --- a/pkg/cmd/triggerbinding/list.go +++ b/pkg/cmd/triggerbinding/list.go @@ -48,6 +48,14 @@ func listCommand(p cli.Params) *cobra.Command { or tkn tb ls -n bar + +List TriggerBindings as a JSON array: + + tkn triggerbinding list -o json + +List TriggerBindings as a YAML array: + + tkn triggerbinding list -o yaml ` c := &cobra.Command{ @@ -97,6 +105,14 @@ or } return nil } else if output != "" { + if formatted.IsStructured(output) { + items := tbs.Items + if items == nil { + items = []v1beta1.TriggerBinding{} + } + formatted.SetTypeMeta(items, v1beta1.SchemeGroupVersion.WithKind("TriggerBinding")) + return formatted.PrintStructuredOutput(cmd.OutOrStdout(), output, items) + } p, err := f.ToPrinter() if err != nil { return err diff --git a/pkg/cmd/triggerbinding/list_test.go b/pkg/cmd/triggerbinding/list_test.go index b79a61ef19..2183c322fa 100644 --- a/pkg/cmd/triggerbinding/list_test.go +++ b/pkg/cmd/triggerbinding/list_test.go @@ -15,6 +15,7 @@ package triggerbinding import ( + "encoding/json" "fmt" "strings" "testing" @@ -30,6 +31,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/yaml" ) func TestListTriggerBinding(t *testing.T) { @@ -191,3 +193,108 @@ func TestTriggerBindingList_empty(t *testing.T) { out, _ := test.ExecuteCommand(Command(listtb), "list", "--all-namespaces") test.AssertOutput(t, emptyMsg+"\n", out) } + +func TestListTriggerBinding_structured_output(t *testing.T) { + now := time.Now() + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + tbs := []*v1beta1.TriggerBinding{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "tb-a", + Namespace: "foo", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "tb-b", + Namespace: "foo", + }, + }, + } + p := command(t, tbs, now, ns) + + jsonOut, err := test.ExecuteCommand(Command(p), "list", "-n", "foo", "-o", "json") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + yamlOut, err := test.ExecuteCommand(Command(p), "list", "-n", "foo", "-o", "yaml") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var fromJSON, fromYAML []v1beta1.TriggerBinding + if err := json.Unmarshal([]byte(jsonOut), &fromJSON); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, jsonOut) + } + if err := yaml.Unmarshal([]byte(yamlOut), &fromYAML); err != nil { + t.Fatalf("output is not a valid YAML sequence: %v\n%s", err, yamlOut) + } + if len(fromJSON) != 2 || len(fromYAML) != 2 { + t.Errorf("expected 2 triggerbindings, got json=%d yaml=%d", len(fromJSON), len(fromYAML)) + } + if strings.Contains(jsonOut, "TriggerBindingList") { + t.Errorf("json output should be an array of TriggerBinding objects, not a TriggerBindingList wrapper") + } +} + +func TestListTriggerBinding_empty_output_json(t *testing.T) { + now := time.Now() + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + p := command(t, []*v1beta1.TriggerBinding{}, now, ns) + output, err := test.ExecuteCommand(Command(p), "list", "-n", "foo", "-o", "json") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var got []v1beta1.TriggerBinding + if err := json.Unmarshal([]byte(output), &got); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, output) + } + if len(got) != 0 { + t.Errorf("expected empty JSON array, got %d items", len(got)) + } + if strings.Contains(output, emptyMsg) { + t.Errorf("structured output should not include the empty table message") + } +} + +func TestListTriggerBinding_invalid_output(t *testing.T) { + now := time.Now() + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + p := command(t, []*v1beta1.TriggerBinding{}, now, ns) + _, err := test.ExecuteCommand(Command(p), "list", "-n", "foo", "-o", "csv") + if err == nil { + t.Fatal("expected error for invalid output format") + } + if !strings.Contains(err.Error(), "csv") { + t.Errorf("expected error to mention csv, got %q", err.Error()) + } +} + +func TestListTriggerBinding_help_shows_output_examples(t *testing.T) { + cmd := listCommand(&test.Params{}) + if cmd.Flags().Lookup("output") == nil { + t.Fatal("expected --output flag") + } + if !strings.Contains(cmd.Example, "-o json") || !strings.Contains(cmd.Example, "-o yaml") { + t.Errorf("expected help examples for json and yaml output, got %q", cmd.Example) + } +} diff --git a/pkg/cmd/triggertemplate/list.go b/pkg/cmd/triggertemplate/list.go index ce16d3b4a1..51c592a794 100644 --- a/pkg/cmd/triggertemplate/list.go +++ b/pkg/cmd/triggertemplate/list.go @@ -47,6 +47,14 @@ func listCommand(p cli.Params) *cobra.Command { or tkn tt ls -n bar + +List TriggerTemplates as a JSON array: + + tkn triggertemplate list -o json + +List TriggerTemplates as a YAML array: + + tkn triggertemplate list -o yaml ` c := &cobra.Command{ @@ -86,6 +94,14 @@ or } if output != "" { + if formatted.IsStructured(output) { + items := tts.Items + if items == nil { + items = []v1beta1.TriggerTemplate{} + } + formatted.SetTypeMeta(items, v1beta1.SchemeGroupVersion.WithKind("TriggerTemplate")) + return formatted.PrintStructuredOutput(cmd.OutOrStdout(), output, items) + } p, err := f.ToPrinter() if err != nil { return err diff --git a/pkg/cmd/triggertemplate/list_test.go b/pkg/cmd/triggertemplate/list_test.go index 7d5dd51fb7..c661f70eb2 100644 --- a/pkg/cmd/triggertemplate/list_test.go +++ b/pkg/cmd/triggertemplate/list_test.go @@ -15,6 +15,7 @@ package triggertemplate import ( + "encoding/json" "fmt" "strings" "testing" @@ -30,6 +31,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/yaml" ) func TestListTriggerTemplate(t *testing.T) { @@ -167,6 +169,111 @@ func TestTriggerTemplateList_empty(t *testing.T) { test.AssertOutput(t, emptyMsg+"\n", out) } +func TestListTriggerTemplate_structured_output(t *testing.T) { + now := time.Now() + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + tts := []*v1beta1.TriggerTemplate{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "tt-a", + Namespace: "foo", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "tt-b", + Namespace: "foo", + }, + }, + } + p := command(t, tts, now, ns) + + jsonOut, err := test.ExecuteCommand(Command(p), "list", "-n", "foo", "-o", "json") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + yamlOut, err := test.ExecuteCommand(Command(p), "list", "-n", "foo", "-o", "yaml") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var fromJSON, fromYAML []v1beta1.TriggerTemplate + if err := json.Unmarshal([]byte(jsonOut), &fromJSON); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, jsonOut) + } + if err := yaml.Unmarshal([]byte(yamlOut), &fromYAML); err != nil { + t.Fatalf("output is not a valid YAML sequence: %v\n%s", err, yamlOut) + } + if len(fromJSON) != 2 || len(fromYAML) != 2 { + t.Errorf("expected 2 triggertemplates, got json=%d yaml=%d", len(fromJSON), len(fromYAML)) + } + if strings.Contains(jsonOut, "TriggerTemplateList") { + t.Errorf("json output should be an array of TriggerTemplate objects, not a TriggerTemplateList wrapper") + } +} + +func TestListTriggerTemplate_empty_output_json(t *testing.T) { + now := time.Now() + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + p := command(t, []*v1beta1.TriggerTemplate{}, now, ns) + output, err := test.ExecuteCommand(Command(p), "list", "-n", "foo", "-o", "json") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var got []v1beta1.TriggerTemplate + if err := json.Unmarshal([]byte(output), &got); err != nil { + t.Fatalf("output is not a valid JSON array: %v\n%s", err, output) + } + if len(got) != 0 { + t.Errorf("expected empty JSON array, got %d items", len(got)) + } + if strings.Contains(output, emptyMsg) { + t.Errorf("structured output should not include the empty table message") + } +} + +func TestListTriggerTemplate_invalid_output(t *testing.T) { + now := time.Now() + ns := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + }, + } + p := command(t, []*v1beta1.TriggerTemplate{}, now, ns) + _, err := test.ExecuteCommand(Command(p), "list", "-n", "foo", "-o", "csv") + if err == nil { + t.Fatal("expected error for invalid output format") + } + if !strings.Contains(err.Error(), "csv") { + t.Errorf("expected error to mention csv, got %q", err.Error()) + } +} + +func TestListTriggerTemplate_help_shows_output_examples(t *testing.T) { + cmd := listCommand(&test.Params{}) + if cmd.Flags().Lookup("output") == nil { + t.Fatal("expected --output flag") + } + if !strings.Contains(cmd.Example, "-o json") || !strings.Contains(cmd.Example, "-o yaml") { + t.Errorf("expected help examples for json and yaml output, got %q", cmd.Example) + } +} + func command(t *testing.T, tts []*v1beta1.TriggerTemplate, now time.Time, ns []*corev1.Namespace) *test.Params { // fake clock advanced by 1 hour clock := clockwork.NewFakeClockAt(now) diff --git a/pkg/formatted/output.go b/pkg/formatted/output.go new file mode 100644 index 0000000000..1bcf9bf0e8 --- /dev/null +++ b/pkg/formatted/output.go @@ -0,0 +1,86 @@ +// 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/runtime/schema" + "sigs.k8s.io/yaml" +) + +const ( + // OutputJSON is the JSON output format. + OutputJSON = "json" + // OutputYAML is the YAML output format. + OutputYAML = "yaml" + // OutputFlagUsage is the shared --output flag help text for json/yaml commands. + OutputFlagUsage = "Output format. One of: json|yaml" +) + +// NormalizeOutput lowercases and trims an --output value. +func NormalizeOutput(format string) string { + return strings.ToLower(strings.TrimSpace(format)) +} + +// IsStructured reports whether format is json or yaml. +func IsStructured(format string) bool { + switch NormalizeOutput(format) { + case OutputJSON, OutputYAML: + return true + default: + return false + } +} + +// SetTypeMeta populates apiVersion and kind on each item from gvk so JSON/YAML +// include the canonical Kubernetes TypeMeta instead of the lowercase resource name. +func SetTypeMeta[T any](items []T, gvk schema.GroupVersionKind) { + for i := range items { + tm, ok := any(&items[i]).(interface { + SetGroupVersionKind(schema.GroupVersionKind) + }) + if !ok { + continue + } + tm.SetGroupVersionKind(gvk) + } +} + +// Print writes obj to w as pretty-printed JSON or YAML. +func PrintStructuredOutput(w io.Writer, format string, obj interface{}) error { + switch NormalizeOutput(format) { + case OutputJSON: + data, err := json.MarshalIndent(obj, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + _, err = w.Write(data) + return err + case OutputYAML: + data, err := yaml.Marshal(obj) + if err != nil { + return err + } + _, err = w.Write(data) + return err + default: + return fmt.Errorf("invalid structured output format %q: must be json or yaml", format) + } +} diff --git a/pkg/formatted/output_test.go b/pkg/formatted/output_test.go new file mode 100644 index 0000000000..aea8de9843 --- /dev/null +++ b/pkg/formatted/output_test.go @@ -0,0 +1,164 @@ +// 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 ( + "bytes" + "encoding/json" + "testing" + + "gotest.tools/v3/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/yaml" +) + +func TestIsStructured(t *testing.T) { + tests := []struct { + name string + format string + want bool + }{ + {name: "json", format: "json", want: true}, + {name: "uppercase yaml", format: "YAML", want: true}, + {name: "unsupported format", format: "csv", want: false}, + {name: "empty", format: "", want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, IsStructured(tc.format), tc.want) + }) + } +} + +func TestPrintStructuredOutput(t *testing.T) { + tests := []struct { + name string + format string + input interface{} + wantErr string + verify func(t *testing.T, out []byte) + }{ + { + name: "json array", + format: "json", + input: []map[string]string{ + {"name": "foo"}, + {"name": "bar"}, + }, + verify: func(t *testing.T, out []byte) { + var got []map[string]string + assert.NilError(t, json.Unmarshal(out, &got)) + assert.DeepEqual(t, []map[string]string{{"name": "foo"}, {"name": "bar"}}, got) + }, + }, + { + name: "yaml array", + format: "yaml", + input: []map[string]string{ + {"name": "foo"}, + {"name": "bar"}, + }, + verify: func(t *testing.T, out []byte) { + var got []map[string]string + assert.NilError(t, yaml.Unmarshal(out, &got)) + assert.DeepEqual(t, []map[string]string{{"name": "foo"}, {"name": "bar"}}, got) + }, + }, + { + name: "json object", + format: "json", + input: map[string]string{"name": "foo"}, + verify: func(t *testing.T, out []byte) { + var got map[string]string + assert.NilError(t, json.Unmarshal(out, &got)) + assert.DeepEqual(t, map[string]string{"name": "foo"}, got) + }, + }, + { + name: "invalid format", + format: "csv", + input: map[string]string{"name": "foo"}, + wantErr: `invalid structured output format "csv"`, + }, + { + name: "empty slice is json array", + format: "json", + input: []string{}, + verify: func(t *testing.T, out []byte) { + var got []string + assert.NilError(t, json.Unmarshal(out, &got)) + assert.Equal(t, len(got), 0) + }, + }, + { + name: "empty slice is yaml sequence", + format: "yaml", + input: []string{}, + verify: func(t *testing.T, out []byte) { + var got []string + assert.NilError(t, yaml.Unmarshal(out, &got)) + assert.Equal(t, len(got), 0) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + err := PrintStructuredOutput(&buf, tc.format, tc.input) + + if tc.wantErr != "" { + if err == nil { + t.Fatal("expected error for invalid output format") + } + assert.ErrorContains(t, err, tc.wantErr) + return + } + + assert.NilError(t, err) + tc.verify(t, buf.Bytes()) + }) + } +} + +func TestNormalizeOutput(t *testing.T) { + tests := []struct { + name string + format string + want string + }{ + {name: "trims and lowercases json", format: " JSON ", want: "json"}, + {name: "lowercases yaml", format: "YAML", want: "yaml"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, NormalizeOutput(tc.format), tc.want) + }) + } +} + +func TestSetTypeMeta(t *testing.T) { + type obj struct { + metav1.TypeMeta + Name string + } + items := []obj{{Name: "foo"}} + SetTypeMeta(items, schema.GroupVersionKind{Group: "tekton.dev", Version: "v1", Kind: "Pipeline"}) + assert.Equal(t, items[0].Kind, "Pipeline") + assert.Equal(t, items[0].APIVersion, "tekton.dev/v1") +} diff --git a/test/e2e/pipeline/pipeline_test.go b/test/e2e/pipeline/pipeline_test.go index f48b0323e8..8214e78f69 100644 --- a/test/e2e/pipeline/pipeline_test.go +++ b/test/e2e/pipeline/pipeline_test.go @@ -115,7 +115,7 @@ func TestPipelinesE2E(t *testing.T) { t.Run("Pipeline json Schema validation with -o (output) flag, as Json ", func(t *testing.T) { res := tkn.MustSucceed(t, "pipelines", "list", "-o", "json") - assert.NilError(t, json.Unmarshal([]byte(res.Stdout()), &v1.PipelineList{})) + assertPipelineListJSONArray(t, res.Stdout()) }) t.Run("Validate Pipeline describe command in namespace "+namespace, func(t *testing.T) { @@ -403,7 +403,7 @@ func TestPipelinesNegativeE2E(t *testing.T) { t.Run("Pipeline json Schema validation with -o (output) flag, as Json ", func(t *testing.T) { res := tkn.MustSucceed(t, "pipelines", "list", "-o", "json") - assert.NilError(t, json.Unmarshal([]byte(res.Stdout()), &v1.PipelineList{})) + assertPipelineListJSONArray(t, res.Stdout()) }) t.Run("Validate Pipeline describe command in namespace "+namespace, func(t *testing.T) { @@ -539,6 +539,17 @@ func TestDeletePipelinesE2E(t *testing.T) { }) } +func assertPipelineListJSONArray(t *testing.T, stdout string) { + t.Helper() + var pipelines []v1.Pipeline + assert.NilError(t, json.Unmarshal([]byte(stdout), &pipelines)) + assert.Assert(t, len(pipelines) > 0, "expected a JSON array of Pipeline objects, got %s", stdout) + for i, p := range pipelines { + assert.Equal(t, p.Kind, "Pipeline", "item %d missing canonical kind", i) + assert.Equal(t, p.APIVersion, "tekton.dev/v1", "item %d missing apiVersion", i) + } +} + func getPipeline(pipelineName string, namespace string, createFiletaskName string) *v1.Pipeline { return &v1.Pipeline{ ObjectMeta: metav1.ObjectMeta{