diff --git a/cmd/kueuectl/app/list/list.go b/cmd/kueuectl/app/list/list.go index 51e832a42f..85902849b0 100644 --- a/cmd/kueuectl/app/list/list.go +++ b/cmd/kueuectl/app/list/list.go @@ -40,6 +40,7 @@ func NewListCmd(clientGetter util.ClientGetter, streams genericiooptions.IOStrea cmd.AddCommand(NewLocalQueueCmd(clientGetter, streams, clock)) cmd.AddCommand(NewClusterQueueCmd(clientGetter, streams, clock)) cmd.AddCommand(NewWorkloadCmd(clientGetter, streams, clock)) + cmd.AddCommand(NewResourceFlavorCmd(clientGetter, streams, clock)) return cmd } diff --git a/cmd/kueuectl/app/list/list_resourceflavor.go b/cmd/kueuectl/app/list/list_resourceflavor.go new file mode 100644 index 0000000000..adfda5c055 --- /dev/null +++ b/cmd/kueuectl/app/list/list_resourceflavor.go @@ -0,0 +1,164 @@ +/* +Copyright 2024 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 list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/genericiooptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/utils/clock" + + "sigs.k8s.io/kueue/client-go/clientset/versioned/scheme" + kueuev1beta1 "sigs.k8s.io/kueue/client-go/clientset/versioned/typed/kueue/v1beta1" + "sigs.k8s.io/kueue/cmd/kueuectl/app/util" +) + +const ( + rfExample = ` # List ResourceFlavor + kueuectl list resourceflavor` +) + +type ResourceFlavorOptions struct { + Clock clock.Clock + PrintFlags *genericclioptions.PrintFlags + + Limit int64 + FieldSelector string + LabelSelector string + + Client kueuev1beta1.KueueV1beta1Interface + + genericiooptions.IOStreams +} + +func NewResourceFlavorOptions(streams genericiooptions.IOStreams, clock clock.Clock) *ResourceFlavorOptions { + return &ResourceFlavorOptions{ + PrintFlags: genericclioptions.NewPrintFlags("").WithTypeSetter(scheme.Scheme), + IOStreams: streams, + Clock: clock, + } +} + +func NewResourceFlavorCmd(clientGetter util.ClientGetter, streams genericiooptions.IOStreams, clock clock.Clock) *cobra.Command { + o := NewResourceFlavorOptions(streams, clock) + + cmd := &cobra.Command{ + Use: "resourceflavor [--selector KEY=VALUE] [--field-selector FIELD_NAME=VALUE]", + DisableFlagsInUseLine: true, + Aliases: []string{"rf"}, + Short: "List ResourceFlavor", + Example: rfExample, + Run: func(cmd *cobra.Command, args []string) { + cobra.CheckErr(o.Complete(clientGetter)) + cobra.CheckErr(o.Run(cmd.Context())) + }, + } + + o.PrintFlags.AddFlags(cmd) + + addFieldSelectorFlagVar(cmd, &o.FieldSelector) + addLabelSelectorFlagVar(cmd, &o.LabelSelector) + + return cmd +} + +// Complete completes all the required options +func (o *ResourceFlavorOptions) Complete(clientGetter util.ClientGetter) error { + var err error + + o.Limit, err = listRequestLimit() + if err != nil { + return err + } + + clientset, err := clientGetter.KueueClientSet() + if err != nil { + return err + } + + o.Client = clientset.KueueV1beta1() + + return nil +} + +func (o *ResourceFlavorOptions) ToPrinter(headers bool) (printers.ResourcePrinterFunc, error) { + if !o.PrintFlags.OutputFlagSpecified() { + printer := newResourceFlavorTablePrinter().WithHeaders(headers).WithClock(o.Clock) + return printer.PrintObj, nil + } + + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return nil, err + } + + return printer.PrintObj, nil +} + +// Run performs the list operation. +func (o *ResourceFlavorOptions) Run(ctx context.Context) error { + var totalCount int + + opts := metav1.ListOptions{ + LabelSelector: o.LabelSelector, + FieldSelector: o.FieldSelector, + Limit: o.Limit, + } + + tabWriter := printers.GetNewTabWriter(o.Out) + + for { + headers := totalCount == 0 + + list, err := o.Client.ResourceFlavors().List(ctx, opts) + if err != nil { + return err + } + + totalCount += len(list.Items) + + printer, err := o.ToPrinter(headers) + if err != nil { + return err + } + + if err := printer.PrintObj(list, tabWriter); err != nil { + return err + } + + if list.Continue != "" { + opts.Continue = list.Continue + continue + } + + if totalCount == 0 { + fmt.Fprintln(o.ErrOut, "No resources found") + return nil + } + + if err := tabWriter.Flush(); err != nil { + return err + } + + return nil + } +} diff --git a/cmd/kueuectl/app/list/list_resourceflavor_printer.go b/cmd/kueuectl/app/list/list_resourceflavor_printer.go new file mode 100644 index 0000000000..28ab42f60c --- /dev/null +++ b/cmd/kueuectl/app/list/list_resourceflavor_printer.go @@ -0,0 +1,89 @@ +/* +Copyright 2024 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 list + +import ( + "errors" + "io" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/duration" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/utils/clock" + + "sigs.k8s.io/kueue/apis/kueue/v1beta1" +) + +type listResourceFlavorPrinter struct { + clock clock.Clock + printOptions printers.PrintOptions +} + +var _ printers.ResourcePrinter = (*listResourceFlavorPrinter)(nil) + +func (p *listResourceFlavorPrinter) PrintObj(obj runtime.Object, out io.Writer) error { + printer := printers.NewTablePrinter(p.printOptions) + + list, ok := obj.(*v1beta1.ResourceFlavorList) + if !ok { + return errors.New("invalid object type") + } + + table := &metav1.Table{ + ColumnDefinitions: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Age", Type: "string"}, + }, + Rows: p.printResourceFlavorList(list), + } + + return printer.PrintObj(table, out) +} + +func (p *listResourceFlavorPrinter) WithHeaders(f bool) *listResourceFlavorPrinter { + p.printOptions.NoHeaders = !f + return p +} + +func (p *listResourceFlavorPrinter) WithClock(c clock.Clock) *listResourceFlavorPrinter { + p.clock = c + return p +} + +func newResourceFlavorTablePrinter() *listResourceFlavorPrinter { + return &listResourceFlavorPrinter{ + clock: clock.RealClock{}, + } +} + +func (p *listResourceFlavorPrinter) printResourceFlavorList(list *v1beta1.ResourceFlavorList) []metav1.TableRow { + rows := make([]metav1.TableRow, len(list.Items)) + for index := range list.Items { + rows[index] = p.printResourceFlavor(&list.Items[index]) + } + return rows +} + +func (p *listResourceFlavorPrinter) printResourceFlavor(resourceFlavor *v1beta1.ResourceFlavor) metav1.TableRow { + row := metav1.TableRow{Object: runtime.RawExtension{Object: resourceFlavor}} + row.Cells = []any{ + resourceFlavor.Name, + duration.HumanDuration(p.clock.Since(resourceFlavor.CreationTimestamp.Time)), + } + return row +} diff --git a/cmd/kueuectl/app/list/list_resourceflavor_printer_test.go b/cmd/kueuectl/app/list/list_resourceflavor_printer_test.go new file mode 100644 index 0000000000..76db812be6 --- /dev/null +++ b/cmd/kueuectl/app/list/list_resourceflavor_printer_test.go @@ -0,0 +1,70 @@ +/* +Copyright 2024 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 list + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + testingclock "k8s.io/utils/clock/testing" + utiltesting "sigs.k8s.io/kueue/pkg/util/testing" + + "sigs.k8s.io/kueue/apis/kueue/v1beta1" +) + +func TestResourceFlavorPrint(t *testing.T) { + testStartTime := time.Now() + + testCases := map[string]struct { + options *ResourceFlavorOptions + in *v1beta1.ResourceFlavorList + out []metav1.TableRow + }{ + "should print resource flavor list": { + options: &ResourceFlavorOptions{}, + in: &v1beta1.ResourceFlavorList{ + Items: []v1beta1.ResourceFlavor{ + *utiltesting.MakeResourceFlavor("rf"). + Creation(testStartTime.Add(-time.Hour).Truncate(time.Second)). + Obj(), + }, + }, + out: []metav1.TableRow{ + { + Cells: []any{"rf", "60m"}, + Object: runtime.RawExtension{ + Object: utiltesting.MakeResourceFlavor("rf"). + Creation(testStartTime.Add(-time.Hour).Truncate(time.Second)). + Obj(), + }, + }, + }, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + p := newResourceFlavorTablePrinter().WithClock(testingclock.NewFakeClock(testStartTime)) + out := p.printResourceFlavorList(tc.in) + if diff := cmp.Diff(tc.out, out); diff != "" { + t.Errorf("Unexpected result (-want,+got):\n%s", diff) + } + }) + } +} diff --git a/cmd/kueuectl/app/list/list_resourceflavor_test.go b/cmd/kueuectl/app/list/list_resourceflavor_test.go new file mode 100644 index 0000000000..5275b24354 --- /dev/null +++ b/cmd/kueuectl/app/list/list_resourceflavor_test.go @@ -0,0 +1,121 @@ +/* +Copyright 2024 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 list + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericiooptions" + testingclock "k8s.io/utils/clock/testing" + + "sigs.k8s.io/kueue/client-go/clientset/versioned/fake" + cmdtesting "sigs.k8s.io/kueue/cmd/kueuectl/app/testing" + utiltesting "sigs.k8s.io/kueue/pkg/util/testing" +) + +func TestResourceFlavorCmd(t *testing.T) { + testStartTime := time.Now() + + testCases := map[string]struct { + objs []runtime.Object + args []string + wantOut string + wantOutErr string + wantErr error + }{ + "should print resource flavor list with namespace filter": { + objs: []runtime.Object{ + utiltesting.MakeResourceFlavor("rf1"). + Creation(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Obj(), + utiltesting.MakeResourceFlavor("rf2"). + Creation(testStartTime.Add(-2 * time.Hour).Truncate(time.Second)). + Obj(), + }, + wantOut: `NAME AGE +rf1 60m +rf2 120m +`, + }, + "should print resource flavor list with label selector filter": { + args: []string{"--selector", "key=value1"}, + objs: []runtime.Object{ + utiltesting.MakeResourceFlavor("rf1"). + ObjectMetaLabel("key", "value1"). + Creation(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Obj(), + utiltesting.MakeResourceFlavor("rf2"). + Creation(testStartTime.Add(-2*time.Hour).Truncate(time.Second)). + ObjectMetaLabel("key", "value2"). + Obj(), + }, + wantOut: `NAME AGE +rf1 60m +`, + }, + "should print resource flavor list with label selector filter (short flag)": { + args: []string{"-l", "foo=bar"}, + objs: []runtime.Object{ + utiltesting.MakeResourceFlavor("rf1"). + ObjectMetaLabel("foo", "bar"). + Creation(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Obj(), + utiltesting.MakeResourceFlavor("rf2"). + Creation(testStartTime.Add(-2 * time.Hour).Truncate(time.Second)). + Obj(), + }, + wantOut: `NAME AGE +rf1 60m +`, + }, + "should print not found error": { + wantOutErr: "No resources found\n", + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + streams, _, out, outErr := genericiooptions.NewTestIOStreams() + + tf := cmdtesting.NewTestClientGetter() + tf.KueueClientset = fake.NewSimpleClientset(tc.objs...) + + cmd := NewResourceFlavorCmd(tf, streams, testingclock.NewFakeClock(testStartTime)) + cmd.SetOut(out) + cmd.SetErr(outErr) + cmd.SetArgs(tc.args) + + gotErr := cmd.Execute() + if diff := cmp.Diff(tc.wantErr, gotErr, cmpopts.EquateErrors()); diff != "" { + t.Errorf("Unexpected error (-want/+got)\n%s", diff) + } + + gotOut := out.String() + if diff := cmp.Diff(tc.wantOut, gotOut); diff != "" { + t.Errorf("Unexpected output (-want/+got)\n%s", diff) + } + + gotOutErr := outErr.String() + if diff := cmp.Diff(tc.wantOutErr, gotOutErr); diff != "" { + t.Errorf("Unexpected output (-want/+got)\n%s", diff) + } + }) + } +} diff --git a/keps/2076-kueuectl/README.md b/keps/2076-kueuectl/README.md index 168aec405d..67f23ad939 100644 --- a/keps/2076-kueuectl/README.md +++ b/keps/2076-kueuectl/README.md @@ -16,6 +16,7 @@ - [List ClusterQueue](#list-clusterqueue) - [List LocalQueue](#list-localqueue) - [List Workloads](#list-workloads) + - [List ResourceFlavors](#list-resourceflavors) - [Stop ClusterQueue](#stop-clusterqueue) - [Resume ClusterQueue](#resume-clusterqueue) - [Stop LocalQueue](#stop-localqueue) @@ -225,6 +226,18 @@ Output: * Position in Queue (if Pending) * Age +### List ResourceFlavors + +Lists ResourceFlavors. Format: + +``` +kueuectl list resourceflavors +``` + +Output: + +* Name +* Age ### Stop ClusterQueue diff --git a/pkg/util/testing/wrappers.go b/pkg/util/testing/wrappers.go index 43ebc4c631..be90b01991 100644 --- a/pkg/util/testing/wrappers.go +++ b/pkg/util/testing/wrappers.go @@ -831,6 +831,15 @@ func (rf *ResourceFlavorWrapper) Obj() *kueue.ResourceFlavor { return &rf.ResourceFlavor } +// ObjectMetaLabel sets the label on the ResourceFlavor. +func (rf *ResourceFlavorWrapper) ObjectMetaLabel(k, v string) *ResourceFlavorWrapper { + if rf.ObjectMeta.Labels == nil { + rf.ObjectMeta.Labels = map[string]string{} + } + rf.ObjectMeta.Labels[k] = v + return rf +} + // Label add a label kueue and value pair to the ResourceFlavor. func (rf *ResourceFlavorWrapper) Label(k, v string) *ResourceFlavorWrapper { rf.Spec.NodeLabels[k] = v @@ -849,6 +858,12 @@ func (rf *ResourceFlavorWrapper) Toleration(t corev1.Toleration) *ResourceFlavor return rf } +// Creation sets the creation timestamp of the LocalQueue. +func (rf *ResourceFlavorWrapper) Creation(t time.Time) *ResourceFlavorWrapper { + rf.CreationTimestamp = metav1.NewTime(t) + return rf +} + // RuntimeClassWrapper wraps a RuntimeClass. type RuntimeClassWrapper struct{ nodev1.RuntimeClass } diff --git a/site/content/en/docs/reference/kubectl-kueue/commands/list.md b/site/content/en/docs/reference/kubectl-kueue/commands/list.md index 212e296108..8bfb800826 100644 --- a/site/content/en/docs/reference/kubectl-kueue/commands/list.md +++ b/site/content/en/docs/reference/kubectl-kueue/commands/list.md @@ -1,7 +1,7 @@ --- title: "kubectl kueue list" linkTitle: "List" -date: 2024-05-13 +date: 2024-07-03 weight: 10 description: > List resource @@ -24,14 +24,18 @@ kubectl kueue list localqueue # List workloads kubectl kueue list workload + +# List resource flavors +kubectl kueue list resourceflavor ``` ## Resource types The following table includes a list of all the supported resource types and their abbreviated aliases: -| Name | Short | API version | Namespaced | Kind | -|--------------|-------|------------------------|------------|--------------| -| localqueue | lq | kueue.x-k8s.io/v1beta1 | true | LocalQueue | -| clusterqueue | cq | kueue.x-k8s.io/v1beta1 | false | ClusterQueue | -| workload | wl | kueue.x-k8s.io/v1beta1 | true | WorkLoad | +| Name | Short | API version | Namespaced | Kind | +|----------------|-------|------------------------|------------|----------------| +| localqueue | lq | kueue.x-k8s.io/v1beta1 | true | LocalQueue | +| clusterqueue | cq | kueue.x-k8s.io/v1beta1 | false | ClusterQueue | +| workload | wl | kueue.x-k8s.io/v1beta1 | true | WorkLoad | +| resourceflavor | wl | kueue.x-k8s.io/v1beta1 | false | ResourceFlavor | diff --git a/test/integration/kueuectl/list_test.go b/test/integration/kueuectl/list_test.go index ee092d8c23..1ede31f934 100644 --- a/test/integration/kueuectl/list_test.go +++ b/test/integration/kueuectl/list_test.go @@ -246,4 +246,73 @@ wl2 very-long-local-queue-name ))) }) }) + + ginkgo.When("List ResourceFlavors", func() { + var ( + rf1 *v1beta1.ResourceFlavor + rf2 *v1beta1.ResourceFlavor + ) + + ginkgo.JustBeforeEach(func() { + rf1 = testing.MakeResourceFlavor("rf1").Obj() + gomega.Expect(k8sClient.Create(ctx, rf1)).To(gomega.Succeed()) + + rf2 = testing.MakeResourceFlavor("very-long-resource-flavor-name").Obj() + gomega.Expect(k8sClient.Create(ctx, rf2)).To(gomega.Succeed()) + }) + + ginkgo.JustAfterEach(func() { + util.ExpectResourceFlavorToBeDeleted(ctx, k8sClient, rf1, true) + util.ExpectResourceFlavorToBeDeleted(ctx, k8sClient, rf2, true) + }) + + // Simple client set that are using on unit tests not allow to filter by field selector. + ginkgo.It("Should print resource flavor list filtered by field selector", func() { + streams, _, output, errOutput := genericiooptions.NewTestIOStreams() + configFlags := CreateConfigFlagsWithRestConfig(cfg, streams) + executeTime := time.Now() + kueuectl := app.NewKueuectlCmd(app.KueuectlOptions{ + ConfigFlags: configFlags, + IOStreams: streams, + Clock: testingclock.NewFakeClock(executeTime), + }) + + kueuectl.SetArgs([]string{"list", "resourceflavor", "--field-selector", + fmt.Sprintf("metadata.name=%s", rf1.Name)}) + err := kueuectl.Execute() + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "%s: %s", err, output) + gomega.Expect(errOutput.String()).Should(gomega.BeEmpty()) + gomega.Expect(output.String()).Should(gomega.Equal(fmt.Sprintf(`NAME AGE +rf1 %s +`, + duration.HumanDuration(executeTime.Sub(rf1.CreationTimestamp.Time)), + ))) + }) + + // Simple client set that are using on unit tests not allow paging. + ginkgo.It("Should print resource flavor list with paging", func() { + streams, _, output, errOutput := genericiooptions.NewTestIOStreams() + configFlags := CreateConfigFlagsWithRestConfig(cfg, streams) + executeTime := time.Now() + kueuectl := app.NewKueuectlCmd(app.KueuectlOptions{ + ConfigFlags: configFlags, + IOStreams: streams, + Clock: testingclock.NewFakeClock(executeTime), + }) + + os.Setenv(list.KueuectlListRequestLimitEnvName, "1") + kueuectl.SetArgs([]string{"list", "resourceflavor"}) + err := kueuectl.Execute() + + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "%s: %s", err, output) + gomega.Expect(errOutput.String()).Should(gomega.BeEmpty()) + gomega.Expect(output.String()).Should(gomega.Equal(fmt.Sprintf(`NAME AGE +rf1 %s +very-long-resource-flavor-name %s +`, + duration.HumanDuration(executeTime.Sub(rf1.CreationTimestamp.Time)), + duration.HumanDuration(executeTime.Sub(rf2.CreationTimestamp.Time)), + ))) + }) + }) })