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..9e21fe1213 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,15 @@ 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")) + items = formatted.StripManagedFieldsList(items, f.JSONYamlPrintFlags.ShowManagedFields) + 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..2469e553bd 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,15 @@ 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")) + items = formatted.StripManagedFieldsList(items, f.JSONYamlPrintFlags.ShowManagedFields) + 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..3895ff4431 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,15 @@ or } if output != "" { + if formatted.IsStructured(output) { + items := els.Items + if items == nil { + items = []v1beta1.EventListener{} + } + formatted.SetTypeMeta(items, v1beta1.SchemeGroupVersion.WithKind("EventListener")) + items = formatted.StripManagedFieldsList(items, f.JSONYamlPrintFlags.ShowManagedFields) + 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..8419c2417b 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,28 @@ 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")) + items = formatted.StripManagedFieldsList(items, f.JSONYamlPrintFlags.ShowManagedFields) + 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..d177146085 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,311 @@ 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_output_json_managed_fields(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)}, + ManagedFields: []metav1.ManagedFieldsEntry{{Manager: "kubectl"}}, + }, + }, + } + + nsList := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "namespace", + }, + }, + } + + tdc := testDynamic.Options{} + dynamic, err := tdc.Client( + cb.UnstructuredP(pdata[0], 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) + } + if strings.Contains(output, "managedFields") { + t.Errorf("expected managedFields to be omitted by default, got:\n%s", output) + } + + shown, err := test.ExecuteCommand(pipeline, "list", "-n", "namespace", "-o", "json", "--show-managed-fields") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !strings.Contains(shown, `"manager": "kubectl"`) { + t.Errorf("expected managedFields to be kept with --show-managed-fields, got:\n%s", shown) + } +} + +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..c6de4b95d4 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,15 @@ 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")) + items = formatted.StripManagedFieldsList(items, f.JSONYamlPrintFlags.ShowManagedFields) + 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..0fc7f04df1 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,19 @@ 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")) + items = formatted.StripManagedFieldsList(items, f.JSONYamlPrintFlags.ShowManagedFields) + 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..2852f8dd40 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,15 @@ 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")) + items = formatted.StripManagedFieldsList(items, f.JSONYamlPrintFlags.ShowManagedFields) + 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..e84890d0af 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,15 @@ 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")) + items = formatted.StripManagedFieldsList(items, f.JSONYamlPrintFlags.ShowManagedFields) + 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..415db374c2 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,15 @@ or } if output != "" { + if formatted.IsStructured(output) { + items := tts.Items + if items == nil { + items = []v1beta1.TriggerTemplate{} + } + formatted.SetTypeMeta(items, v1beta1.SchemeGroupVersion.WithKind("TriggerTemplate")) + items = formatted.StripManagedFieldsList(items, f.JSONYamlPrintFlags.ShowManagedFields) + 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..170cf1ff95 --- /dev/null +++ b/pkg/formatted/output.go @@ -0,0 +1,118 @@ +// 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" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "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) + } +} + +// PrintStructuredOutput writes obj to w as pretty-printed JSON or YAML. +// Callers that print Kubernetes objects and support --show-managed-fields +// should call StripManagedFields or StripManagedFieldsList first; +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) + } +} + +// StripManagedFields returns a copy of obj with metadata.managedFields +// cleared. The original obj is not mutated. Values that do not implement +// SetManagedFields are returned unchanged. +// +// T should be a struct value (for example v1.Pipeline) +func StripManagedFields[T any](obj T) T { + setter, ok := any(&obj).(interface { + SetManagedFields([]metav1.ManagedFieldsEntry) + }) + if ok { + setter.SetManagedFields(nil) + } + return obj +} + +// StripManagedFieldsList returns a copy of items with managedFields cleared +// on each element. If show is true (kubectl --show-managed-fields), items +// is returned unchanged. +func StripManagedFieldsList[T any](items []T, showManagedFields bool) []T { + if showManagedFields { + return items + } + out := make([]T, len(items)) + for i := range items { + out[i] = StripManagedFields(items[i]) + } + return out +} diff --git a/pkg/formatted/output_test.go b/pkg/formatted/output_test.go new file mode 100644 index 0000000000..540ef6ec1c --- /dev/null +++ b/pkg/formatted/output_test.go @@ -0,0 +1,214 @@ +// 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") +} + +type managedObj struct { + metav1.ObjectMeta +} + +func TestStripManagedFields(t *testing.T) { + original := managedObj{ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + ManagedFields: []metav1.ManagedFieldsEntry{{Manager: "kubectl"}}, + }} + + got := StripManagedFields(original) + + assert.Equal(t, 0, len(got.ManagedFields)) + assert.Equal(t, 1, len(original.ManagedFields)) + assert.Equal(t, "kubectl", original.ManagedFields[0].Manager) +} + +func TestStripManagedFields_nonObject(t *testing.T) { + assert.Equal(t, "foo", StripManagedFields("foo")) +} + +func TestStripManagedFieldsList(t *testing.T) { + items := []managedObj{ + {ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + ManagedFields: []metav1.ManagedFieldsEntry{{Manager: "kubectl"}}, + }}, + {ObjectMeta: metav1.ObjectMeta{ + Name: "bar", + ManagedFields: []metav1.ManagedFieldsEntry{{Manager: "tkn"}}, + }}, + } + + stripped := StripManagedFieldsList(items, false) + assert.Equal(t, 2, len(stripped)) + assert.Equal(t, 0, len(stripped[0].ManagedFields)) + assert.Equal(t, 0, len(stripped[1].ManagedFields)) + assert.Equal(t, 1, len(items[0].ManagedFields)) + assert.Equal(t, 1, len(items[1].ManagedFields)) + + kept := StripManagedFieldsList(items, true) + assert.Equal(t, 1, len(kept[0].ManagedFields)) + assert.Equal(t, "kubectl", kept[0].ManagedFields[0].Manager) +} + +func TestStripManagedFieldsList_empty(t *testing.T) { + got := StripManagedFieldsList([]managedObj{}, false) + assert.Equal(t, 0, len(got)) +} 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{