From cb33accc8fc4d44e902da4926eee7b828c5e51ec Mon Sep 17 00:00:00 2001 From: Dharmit Shah Date: Thu, 24 Jul 2025 19:16:28 +0530 Subject: [PATCH] JSON & YAML output for kubectl api-resources (#132604) * Add JSON & YAML output support for kubectl api-resources Create a separate `PrintFlags` struct within the apiresources.go file that handles printing only for `kubetl api-resources` because existing output formats, i.e., wide and name, are already implemented independently from HumanReadableFlags and NamePrintFlags. Signed-off-by: Dharmit Shah * Use separate printer type for all options Signed-off-by: Dharmit Shah * Unit tests for JSON & YAML outputs Signed-off-by: Dharmit Shah * Separate file for print types Signed-off-by: Dharmit Shah * Move JSON-YAML tests to separate function Signed-off-by: Dharmit Shah * Fix broken unit test Signed-off-by: Dharmit Shah * Unifying JSON & YAML unit test functions Signed-off-by: Dharmit Shah * Fix linter errors Signed-off-by: Dharmit Shah * PR feedback and linter again Signed-off-by: Dharmit Shah --------- Signed-off-by: Dharmit Shah --- .../pkg/cmd/apiresources/apiresources.go | 128 +++++----- .../pkg/cmd/apiresources/apiresources_test.go | 111 ++++++++- .../pkg/cmd/apiresources/print_flags.go | 233 ++++++++++++++++++ .../kubectl/pkg/util/completion/completion.go | 3 +- 4 files changed, 399 insertions(+), 76 deletions(-) create mode 100644 staging/src/k8s.io/kubectl/pkg/cmd/apiresources/print_flags.go diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/apiresources/apiresources.go b/staging/src/k8s.io/kubectl/pkg/cmd/apiresources/apiresources.go index 6c2dadc9e73..1959cc1109d 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/apiresources/apiresources.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/apiresources/apiresources.go @@ -23,10 +23,11 @@ import ( "strings" "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/runtime" + utilerrors "k8s.io/apimachinery/pkg/util/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/util/errors" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/cli-runtime/pkg/genericiooptions" @@ -61,12 +62,10 @@ var ( // APIResourceOptions is the start of the data required to perform the operation. // As new fields are added, add them here instead of referencing the cmd.Flags() type APIResourceOptions struct { - Output string SortBy string APIGroup string Namespaced bool Verbs []string - NoHeaders bool Cached bool Categories []string @@ -76,13 +75,8 @@ type APIResourceOptions struct { discoveryClient discovery.CachedDiscoveryInterface genericiooptions.IOStreams -} - -// groupResource contains the APIGroup and APIResource -type groupResource struct { - APIGroup string - APIGroupVersion string - APIResource metav1.APIResource + PrintFlags *PrintFlags + PrintObj printers.ResourcePrinterFunc } // NewAPIResourceOptions creates the options for APIResource @@ -90,6 +84,7 @@ func NewAPIResourceOptions(ioStreams genericiooptions.IOStreams) *APIResourceOpt return &APIResourceOptions{ IOStreams: ioStreams, Namespaced: true, + PrintFlags: NewPrintFlags(), } } @@ -109,8 +104,7 @@ func NewCmdAPIResources(restClientGetter genericclioptions.RESTClientGetter, ioS }, } - cmd.Flags().BoolVar(&o.NoHeaders, "no-headers", o.NoHeaders, "When using the default or custom-column output format, don't print headers (default print headers).") - cmd.Flags().StringVarP(&o.Output, "output", "o", o.Output, `Output format. One of: (wide, name).`) + o.PrintFlags.AddFlags(cmd) cmd.Flags().StringVar(&o.APIGroup, "api-group", o.APIGroup, "Limit to resources in the specified API group.") cmd.Flags().BoolVar(&o.Namespaced, "namespaced", o.Namespaced, "If false, non-namespaced resources will be returned, otherwise returning namespaced resources by default.") @@ -123,10 +117,6 @@ func NewCmdAPIResources(restClientGetter genericclioptions.RESTClientGetter, ioS // Validate checks to the APIResourceOptions to see if there is sufficient information run the command func (o *APIResourceOptions) Validate() error { - supportedOutputTypes := sets.New[string]("", "wide", "name") - if !supportedOutputTypes.Has(o.Output) { - return fmt.Errorf("--output %v is not available", o.Output) - } supportedSortTypes := sets.New[string]("", "name", "kind") if len(o.SortBy) > 0 { if !supportedSortTypes.Has(o.SortBy) { @@ -151,6 +141,28 @@ func (o *APIResourceOptions) Complete(restClientGetter genericclioptions.RESTCli o.groupChanged = cmd.Flags().Changed("api-group") o.nsChanged = cmd.Flags().Changed("namespaced") + var printer printers.ResourcePrinter + if o.PrintFlags.OutputFormat != nil { + printer, err = o.PrintFlags.ToPrinter() + if err != nil { + return err + } + + o.PrintObj = func(object runtime.Object, out io.Writer) error { + errs := []error{} + if !*o.PrintFlags.NoHeaders && + (o.PrintFlags.OutputFormat == nil || *o.PrintFlags.OutputFormat == "" || *o.PrintFlags.OutputFormat == "wide") { + if err = printContextHeaders(out, *o.PrintFlags.OutputFormat); err != nil { + errs = append(errs, err) + } + } + if err := printer.PrintObj(object, out); err != nil { + errs = append(errs, err) + } + return utilerrors.NewAggregate(errs) + } + } + return nil } @@ -170,7 +182,7 @@ func (o *APIResourceOptions) RunAPIResources() error { errs = append(errs, err) } - resources := []groupResource{} + var allResources []*metav1.APIResourceList for _, list := range lists { if len(list.APIResources) == 0 { @@ -180,6 +192,14 @@ func (o *APIResourceOptions) RunAPIResources() error { if err != nil { continue } + apiList := &metav1.APIResourceList{ + TypeMeta: metav1.TypeMeta{ + Kind: "APIResourceList", + APIVersion: "v1", + }, + GroupVersion: gv.String(), + } + var apiResources []metav1.APIResource for _, resource := range list.APIResources { if len(resource.Verbs) == 0 { continue @@ -200,58 +220,32 @@ func (o *APIResourceOptions) RunAPIResources() error { if len(o.Categories) > 0 && !sets.New[string](resource.Categories...).HasAll(o.Categories...) { continue } - resources = append(resources, groupResource{ - APIGroup: gv.Group, - APIGroupVersion: gv.String(), - APIResource: resource, - }) + // set these because we display a concatenation of these two values under APIVERSION column of human-readable output + resource.Group = gv.Group + resource.Version = gv.Version + apiResources = append(apiResources, resource) } + apiList.APIResources = apiResources + allResources = append(allResources, apiList) } - if o.NoHeaders == false && o.Output != "name" { - if err = printContextHeaders(w, o.Output); err != nil { - return err - } + flatList := &metav1.APIResourceList{ + TypeMeta: metav1.TypeMeta{ + APIVersion: allResources[0].APIVersion, + Kind: allResources[0].Kind, + }, + } + for _, resource := range allResources { + flatList.APIResources = append(flatList.APIResources, resource.APIResources...) } - sort.Stable(sortableResource{resources, o.SortBy}) - for _, r := range resources { - switch o.Output { - case "name": - name := r.APIResource.Name - if len(r.APIGroup) > 0 { - name += "." + r.APIGroup - } - if _, err := fmt.Fprintf(w, "%s\n", name); err != nil { - errs = append(errs, err) - } - case "wide": - if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%v\t%s\t%v\t%v\n", - r.APIResource.Name, - strings.Join(r.APIResource.ShortNames, ","), - r.APIGroupVersion, - r.APIResource.Namespaced, - r.APIResource.Kind, - strings.Join(r.APIResource.Verbs, ","), - strings.Join(r.APIResource.Categories, ",")); err != nil { - errs = append(errs, err) - } - case "": - if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%v\t%s\n", - r.APIResource.Name, - strings.Join(r.APIResource.ShortNames, ","), - r.APIGroupVersion, - r.APIResource.Namespaced, - r.APIResource.Kind); err != nil { - errs = append(errs, err) - } - } - } + sort.Stable(sortableResource{flatList.APIResources, o.SortBy}) - if len(errs) > 0 { - return errors.NewAggregate(errs) + err = o.PrintObj(flatList, w) + if err != nil { + errs = append(errs, err) } - return nil + return utilerrors.NewAggregate(errs) } func printContextHeaders(out io.Writer, output string) error { @@ -264,7 +258,7 @@ func printContextHeaders(out io.Writer, output string) error { } type sortableResource struct { - resources []groupResource + resources []metav1.APIResource sortBy string } @@ -277,7 +271,7 @@ func (s sortableResource) Less(i, j int) bool { if ret > 0 { return false } else if ret == 0 { - return strings.Compare(s.resources[i].APIResource.Name, s.resources[j].APIResource.Name) < 0 + return strings.Compare(s.resources[i].Name, s.resources[j].Name) < 0 } return true } @@ -285,9 +279,9 @@ func (s sortableResource) Less(i, j int) bool { func (s sortableResource) compareValues(i, j int) (string, string) { switch s.sortBy { case "name": - return s.resources[i].APIResource.Name, s.resources[j].APIResource.Name + return s.resources[i].Name, s.resources[j].Name case "kind": - return s.resources[i].APIResource.Kind, s.resources[j].APIResource.Kind + return s.resources[i].Kind, s.resources[j].Kind } - return s.resources[i].APIGroup, s.resources[j].APIGroup + return s.resources[i].Group, s.resources[j].Group } diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/apiresources/apiresources_test.go b/staging/src/k8s.io/kubectl/pkg/cmd/apiresources/apiresources_test.go index 43f22152880..f5081aa5dab 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/apiresources/apiresources_test.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/apiresources/apiresources_test.go @@ -17,13 +17,16 @@ limitations under the License. package apiresources import ( + "encoding/json" "testing" "github.com/spf13/cobra" - + "github.com/stretchr/testify/require" + apiequality "k8s.io/apimachinery/pkg/api/equality" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/cli-runtime/pkg/genericiooptions" cmdtesting "k8s.io/kubectl/pkg/cmd/testing" + "sigs.k8s.io/yaml" ) func TestAPIResourcesComplete(t *testing.T) { @@ -48,6 +51,16 @@ See 'kubectl api-resources -h' for help and examples` if err.Error() != expectedError { t.Fatalf("Unexpected error: %v\n expected: %v", err, expectedError) } + + *o.PrintFlags.OutputFormat = "foo" + err = o.Complete(tf, cmd, []string{}) + if err == nil { + t.Fatalf("An error was expected but not returned") + } + expectedError = `unable to match a printer suitable for the output format "foo", allowed formats are: json,name,wide,yaml` + if err.Error() != expectedError { + t.Fatalf("Unexpected error: %v\n expected: %v", err, expectedError) + } } func TestAPIResourcesValidate(t *testing.T) { @@ -61,13 +74,6 @@ func TestAPIResourcesValidate(t *testing.T) { optionSetupFn: func(o *APIResourceOptions) {}, expectedError: "", }, - { - name: "invalid output", - optionSetupFn: func(o *APIResourceOptions) { - o.Output = "foo" - }, - expectedError: "--output foo is not available", - }, { name: "invalid sort by", optionSetupFn: func(o *APIResourceOptions) { @@ -322,3 +328,92 @@ bazzes b somegroup/v1 true Baz }) } } + +// TestAPIResourcesRunJsonYaml is doing same thing as TestAPIResourcesRun but for JSON and YAML outputs +// A separate test function is created because we are using apieqaulity.Semantic.DeepEqual +// to check equality between input and output +func TestAPIResourcesRunJsonYaml(t *testing.T) { + dc := cmdtesting.NewFakeCachedDiscoveryClient() + tf := cmdtesting.NewTestFactory().WithDiscoveryClient(dc) + defer tf.Cleanup() + + testCases := []struct { + name string + expectedInvalidations int + preferredResources []*v1.APIResourceList + }{ + { + name: "one", + preferredResources: []*v1.APIResourceList{ + { + GroupVersion: "v1", + APIResources: []v1.APIResource{ + { + Name: "foos", + Namespaced: false, + Kind: "Foo", + Verbs: []string{"get", "list"}, + ShortNames: []string{"f", "fo"}, + Categories: []string{"some-category"}, + }, + }, + }, + }, + }, + { + name: "two", + preferredResources: []*v1.APIResourceList{ + { + GroupVersion: "somegroup/v1", + APIResources: []v1.APIResource{ + { + Name: "bazzes", + Namespaced: true, + Kind: "Baz", + Verbs: []string{"get", "list", "create", "delete"}, + ShortNames: []string{"b"}, + Categories: []string{"some-category", "another-category"}, + }, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(tt *testing.T) { + dc.PreferredResources = tc.preferredResources + ioStreams, _, out, errOut := genericiooptions.NewTestIOStreams() + + for _, v := range []string{"json", "yaml"} { + cmd := NewCmdAPIResources(tf, ioStreams) + err := cmd.Flags().Set("output", v) + require.NoError(tt, err) + cmd.Run(cmd, []string{}) + + if errOut.Len() > 0 { + t.Fatalf("unexpected error output: %s", errOut.String()) + } + apiResourceList := v1.APIResourceList{} + switch v { + case "json": + err = json.Unmarshal(out.Bytes(), &apiResourceList) + case "yaml": + err = yaml.Unmarshal(out.Bytes(), &apiResourceList) + } + require.NoError(tt, err) + + // this will undo custom value we add in RunAPIResources in the lines: + // resource.Group = gv.Group + // resource.Version = gv.Version + apiResourceList.GroupVersion = apiResourceList.APIResources[0].Group + "/" + apiResourceList.APIResources[0].Version + apiResourceList.APIResources[0].Version = "" + apiResourceList.APIResources[0].Group = "" + + if !apiequality.Semantic.DeepEqual(tc.preferredResources[0].APIResources[0], apiResourceList.APIResources[0]) { + tt.Fatalf("expected output: [%v]\n, but got [%v]", tc.preferredResources[0].APIResources[0], apiResourceList.APIResources[0]) + } + } + }) + } +} diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/apiresources/print_flags.go b/staging/src/k8s.io/kubectl/pkg/cmd/apiresources/print_flags.go new file mode 100644 index 00000000000..99d9671c24f --- /dev/null +++ b/staging/src/k8s.io/kubectl/pkg/cmd/apiresources/print_flags.go @@ -0,0 +1,233 @@ +/* +Copyright 2025 The Kubernetes 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 apiresources + +import ( + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" +) + +type PrintFlags struct { + JSONYamlPrintFlags *genericclioptions.JSONYamlPrintFlags + NamePrintFlags NamePrintFlags + HumanReadableFlags HumanPrintFlags + + NoHeaders *bool + OutputFormat *string +} + +func NewPrintFlags() *PrintFlags { + outputFormat := "" + noHeaders := false + + return &PrintFlags{ + OutputFormat: &outputFormat, + NoHeaders: &noHeaders, + JSONYamlPrintFlags: genericclioptions.NewJSONYamlPrintFlags(), + NamePrintFlags: APIResourcesNewNamePrintFlags(), + HumanReadableFlags: APIResourcesHumanReadableFlags(), + } +} + +func (f *PrintFlags) AddFlags(cmd *cobra.Command) { + f.JSONYamlPrintFlags.AddFlags(cmd) + f.HumanReadableFlags.AddFlags(cmd) + f.NamePrintFlags.AddFlags(cmd) + + if f.OutputFormat != nil { + cmd.Flags().StringVarP(f.OutputFormat, "output", "o", *f.OutputFormat, fmt.Sprintf("Output format. One of: (%s).", strings.Join(f.AllowedFormats(), ", "))) + } + if f.NoHeaders != nil { + cmd.Flags().BoolVar(f.NoHeaders, "no-headers", *f.NoHeaders, "When using the default or custom-column output format, don't print headers (default print headers).") + } +} + +// PrintOptions struct defines a struct for various print options +type PrintOptions struct { + SortBy *string + NoHeaders bool + Wide bool +} + +type HumanPrintFlags struct { + SortBy *string + NoHeaders bool +} + +func (f *HumanPrintFlags) AllowedFormats() []string { + return []string{"wide"} +} + +// AddFlags receives a *cobra.Command reference and binds +// flags related to human-readable printing to it +func (f *HumanPrintFlags) AddFlags(c *cobra.Command) { + if f.SortBy != nil { + c.Flags().StringVar(f.SortBy, "sort-by", *f.SortBy, "If non-empty, sort list types using this field specification. The field specification is expressed as a JSONPath expression (e.g. '{.metadata.name}'). The field in the API resource specified by this JSONPath expression must be an integer or a string.") + } +} + +// ToPrinter receives an outputFormat and returns a printer capable of +// handling human-readable output. +func (f *HumanPrintFlags) ToPrinter(outputFormat string) (printers.ResourcePrinter, error) { + if len(outputFormat) > 0 && outputFormat != "wide" { + return nil, genericclioptions.NoCompatiblePrinterError{Options: f, AllowedFormats: f.AllowedFormats()} + } + + p := HumanReadablePrinter{ + options: PrintOptions{ + NoHeaders: f.NoHeaders, + Wide: outputFormat == "wide", + }, + } + + return p, nil +} + +type HumanReadablePrinter struct { + options PrintOptions +} + +func (f HumanReadablePrinter) PrintObj(obj runtime.Object, w io.Writer) error { + flatList, ok := obj.(*metav1.APIResourceList) + if !ok { + return fmt.Errorf("object is not a APIResourceList") + } + var errs []error + for _, r := range flatList.APIResources { + gv, err := schema.ParseGroupVersion(strings.Join([]string{r.Group, r.Version}, "/")) + if err != nil { + errs = append(errs, err) + continue + } + if f.options.Wide { + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%v\t%s\t%v\t%v\n", + r.Name, + strings.Join(r.ShortNames, ","), + gv.String(), + r.Namespaced, + r.Kind, + strings.Join(r.Verbs, ","), + strings.Join(r.Categories, ",")); err != nil { + errs = append(errs, err) + } + continue + } + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%v\t%s\n", + r.Name, + strings.Join(r.ShortNames, ","), + gv.String(), + r.Namespaced, + r.Kind); err != nil { + errs = append(errs, err) + } + } + return utilerrors.NewAggregate(errs) +} + +type NamePrintFlags struct{} + +func APIResourcesNewNamePrintFlags() NamePrintFlags { + return NamePrintFlags{} +} + +func (f *NamePrintFlags) AllowedFormats() []string { + return []string{"name"} +} + +// AddFlags receives a *cobra.Command reference and binds +// flags related to name printing to it +func (f *NamePrintFlags) AddFlags(_ *cobra.Command) {} + +// ToPrinter receives an outputFormat and returns a printer capable of +// handling human-readable output. +func (f *NamePrintFlags) ToPrinter(outputFormat string) (printers.ResourcePrinter, error) { + if outputFormat == "name" { + return NamePrinter{}, nil + } + return nil, genericclioptions.NoCompatiblePrinterError{Options: f, AllowedFormats: f.AllowedFormats()} +} + +type NamePrinter struct{} + +func (f NamePrinter) PrintObj(obj runtime.Object, w io.Writer) error { + flatList, ok := obj.(*metav1.APIResourceList) + if !ok { + return fmt.Errorf("object is not a APIResourceList") + } + var errs []error + for _, r := range flatList.APIResources { + name := r.Name + if len(r.Group) > 0 { + name += "." + r.Group + } + if _, err := fmt.Fprintf(w, "%s\n", name); err != nil { + errs = append(errs, err) + } + } + return utilerrors.NewAggregate(errs) +} + +func APIResourcesHumanReadableFlags() HumanPrintFlags { + return HumanPrintFlags{ + SortBy: nil, + NoHeaders: false, + } +} + +func (f *PrintFlags) AllowedFormats() []string { + ret := []string{} + ret = append(ret, f.JSONYamlPrintFlags.AllowedFormats()...) + ret = append(ret, f.NamePrintFlags.AllowedFormats()...) + ret = append(ret, f.HumanReadableFlags.AllowedFormats()...) + return ret +} + +func (f *PrintFlags) ToPrinter() (printers.ResourcePrinter, error) { + outputFormat := "" + if f.OutputFormat != nil { + outputFormat = *f.OutputFormat + } + + noHeaders := false + if f.NoHeaders != nil { + noHeaders = *f.NoHeaders + } + f.HumanReadableFlags.NoHeaders = noHeaders + + if p, err := f.JSONYamlPrintFlags.ToPrinter(outputFormat); !genericclioptions.IsNoCompatiblePrinterError(err) { + return p, err + } + + if p, err := f.HumanReadableFlags.ToPrinter(outputFormat); !genericclioptions.IsNoCompatiblePrinterError(err) { + return p, err + } + + if p, err := f.NamePrintFlags.ToPrinter(outputFormat); !genericclioptions.IsNoCompatiblePrinterError(err) { + return p, err + } + + return nil, genericclioptions.NoCompatiblePrinterError{OutputFormat: &outputFormat, AllowedFormats: f.AllowedFormats()} +} diff --git a/staging/src/k8s.io/kubectl/pkg/util/completion/completion.go b/staging/src/k8s.io/kubectl/pkg/util/completion/completion.go index f88156484fc..3c4d5a24c11 100644 --- a/staging/src/k8s.io/kubectl/pkg/util/completion/completion.go +++ b/staging/src/k8s.io/kubectl/pkg/util/completion/completion.go @@ -25,6 +25,7 @@ import ( "time" "github.com/spf13/cobra" + "k8s.io/utils/ptr" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/cli-runtime/pkg/genericclioptions" @@ -321,7 +322,7 @@ func compGetResourceList(restClientGetter genericclioptions.RESTClientGetter, cm o.Complete(restClientGetter, cmd, nil) // Get the list of resources - o.Output = "name" + o.PrintFlags.OutputFormat = ptr.To("name") o.Cached = true o.Verbs = []string{"get"} // TODO:Should set --request-timeout=5s