diff --git a/cmd/experimental/kjobctl/pkg/cmd/cmd.go b/cmd/experimental/kjobctl/pkg/cmd/cmd.go index 3b2752cbce..0239c02a66 100644 --- a/cmd/experimental/kjobctl/pkg/cmd/cmd.go +++ b/cmd/experimental/kjobctl/pkg/cmd/cmd.go @@ -22,13 +22,16 @@ import ( "github.com/spf13/cobra" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/cli-runtime/pkg/genericiooptions" + "k8s.io/utils/clock" "sigs.k8s.io/kueue/cmd/experimental/kjobctl/pkg/cmd/completion" "sigs.k8s.io/kueue/cmd/experimental/kjobctl/pkg/cmd/create" + "sigs.k8s.io/kueue/cmd/experimental/kjobctl/pkg/cmd/list" "sigs.k8s.io/kueue/cmd/experimental/kjobctl/pkg/cmd/util" ) type KjobctlOptions struct { + Clock clock.Clock ConfigFlags *genericclioptions.ConfigFlags genericiooptions.IOStreams @@ -52,6 +55,10 @@ func NewKjobctlCmd(o KjobctlOptions) *cobra.Command { Short: "ML/AI/Batch Jobs Made Easy", } + if o.Clock == nil { + o.Clock = clock.RealClock{} + } + flags := cmd.PersistentFlags() configFlags := o.ConfigFlags @@ -68,6 +75,7 @@ func NewKjobctlCmd(o KjobctlOptions) *cobra.Command { cobra.CheckErr(cmd.RegisterFlagCompletionFunc("user", completion.UsersFunc(clientGetter))) cmd.AddCommand(create.NewCreateCmd(clientGetter, o.IOStreams)) + cmd.AddCommand(list.NewListCmd(clientGetter, o.IOStreams, o.Clock)) return cmd } diff --git a/cmd/experimental/kjobctl/pkg/cmd/list/helpers.go b/cmd/experimental/kjobctl/pkg/cmd/list/helpers.go new file mode 100644 index 0000000000..d27f72fc4f --- /dev/null +++ b/cmd/experimental/kjobctl/pkg/cmd/list/helpers.go @@ -0,0 +1,47 @@ +/* +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" + "os" + "strconv" +) + +const ( + defaultListRequestLimit = 100 + KjobctlListRequestLimitEnvName = "KJOBCTL_LIST_REQUEST_LIMIT" +) + +var ( + invalidListRequestLimitError = errors.New("invalid list request limit") +) + +func listRequestLimit() (int64, error) { + listRequestLimitEnv := os.Getenv(KjobctlListRequestLimitEnvName) + + if len(listRequestLimitEnv) == 0 { + return defaultListRequestLimit, nil + } + + limit, err := strconv.ParseInt(listRequestLimitEnv, 10, 64) + if err != nil { + return 0, invalidListRequestLimitError + } + + return limit, nil +} diff --git a/cmd/experimental/kjobctl/pkg/cmd/list/list.go b/cmd/experimental/kjobctl/pkg/cmd/list/list.go new file mode 100644 index 0000000000..8d7d10cdac --- /dev/null +++ b/cmd/experimental/kjobctl/pkg/cmd/list/list.go @@ -0,0 +1,43 @@ +/* +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 ( + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericiooptions" + "k8s.io/utils/clock" + + "sigs.k8s.io/kueue/cmd/experimental/kjobctl/pkg/cmd/util" +) + +const ( + listExample = ` # List Job + kjobctl list job` +) + +func NewListCmd(clientGetter util.ClientGetter, streams genericiooptions.IOStreams, clock clock.Clock) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Display resources", + Example: listExample, + SuggestFor: []string{"ps"}, + } + + cmd.AddCommand(NewJobCmd(clientGetter, streams, clock)) + + return cmd +} diff --git a/cmd/experimental/kjobctl/pkg/cmd/list/list_job.go b/cmd/experimental/kjobctl/pkg/cmd/list/list_job.go new file mode 100644 index 0000000000..1e4ce1a268 --- /dev/null +++ b/cmd/experimental/kjobctl/pkg/cmd/list/list_job.go @@ -0,0 +1,194 @@ +/* +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/client-go/kubernetes/scheme" + batchv1 "k8s.io/client-go/kubernetes/typed/batch/v1" + "k8s.io/utils/clock" + + "sigs.k8s.io/kueue/cmd/experimental/kjobctl/pkg/cmd/util" + "sigs.k8s.io/kueue/cmd/experimental/kjobctl/pkg/constants" +) + +const ( + jobExample = ` # List Job + kjobctl list job` +) + +type JobOptions struct { + Clock clock.Clock + PrintFlags *genericclioptions.PrintFlags + + Limit int64 + AllNamespaces bool + Namespace string + ProfileFilter string + FieldSelector string + LabelSelector string + ClusterQueueFilter string + + Client batchv1.BatchV1Interface + + genericiooptions.IOStreams +} + +func NewJobOptions(streams genericiooptions.IOStreams, clock clock.Clock) *JobOptions { + return &JobOptions{ + PrintFlags: genericclioptions.NewPrintFlags("").WithTypeSetter(scheme.Scheme), + IOStreams: streams, + Clock: clock, + } +} + +func NewJobCmd(clientGetter util.ClientGetter, streams genericiooptions.IOStreams, clock clock.Clock) *cobra.Command { + o := NewJobOptions(streams, clock) + + cmd := &cobra.Command{ + Use: "job [--selector key1=value1] [--field-selector key1=value1] [--all-namespaces]", + DisableFlagsInUseLine: true, + Short: "List Job", + Example: jobExample, + Run: func(cmd *cobra.Command, args []string) { + cobra.CheckErr(o.Complete(clientGetter)) + cobra.CheckErr(o.Run(cmd.Context())) + }, + } + + o.PrintFlags.AddFlags(cmd) + + util.AddAllNamespacesFlagVar(cmd, &o.AllNamespaces) + util.AddFieldSelectorFlagVar(cmd, &o.FieldSelector) + util.AddLabelSelectorFlagVar(cmd, &o.LabelSelector) + util.AddProfileFlagVar(cmd, &o.ProfileFilter) + + return cmd +} + +// Complete completes all the required options +func (o *JobOptions) Complete(clientGetter util.ClientGetter) error { + var err error + + o.Limit, err = listRequestLimit() + if err != nil { + return err + } + + o.Namespace, _, err = clientGetter.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + clientset, err := clientGetter.K8sClientset() + if err != nil { + return err + } + + o.Client = clientset.BatchV1() + + return nil +} + +func (o *JobOptions) ToPrinter(headers bool) (printers.ResourcePrinterFunc, error) { + if !o.PrintFlags.OutputFlagSpecified() { + printer := newJobTablePrinter(). + WithNamespace(o.AllNamespaces). + 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 *JobOptions) Run(ctx context.Context) error { + var totalCount int + + namespace := o.Namespace + if o.AllNamespaces { + namespace = "" + } + + opts := metav1.ListOptions{ + LabelSelector: constants.ProfileLabel, + FieldSelector: o.FieldSelector, + Limit: o.Limit, + } + + if len(o.ProfileFilter) > 0 { + opts.LabelSelector = fmt.Sprintf("%s,%s=%s", opts.LabelSelector, constants.ProfileLabel, o.ProfileFilter) + } + if len(o.LabelSelector) > 0 { + opts.LabelSelector = fmt.Sprintf("%s,%s", opts.LabelSelector, o.LabelSelector) + } + + tabWriter := printers.GetNewTabWriter(o.Out) + + for { + headers := totalCount == 0 + + list, err := o.Client.Jobs(namespace).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 { + if !o.AllNamespaces { + fmt.Fprintf(o.ErrOut, "No resources found in %s namespace.\n", o.Namespace) + } else { + fmt.Fprintln(o.ErrOut, "No resources found") + } + return nil + } + + if err := tabWriter.Flush(); err != nil { + return err + } + + return nil + } +} diff --git a/cmd/experimental/kjobctl/pkg/cmd/list/list_job_printer.go b/cmd/experimental/kjobctl/pkg/cmd/list/list_job_printer.go new file mode 100644 index 0000000000..67309068c0 --- /dev/null +++ b/cmd/experimental/kjobctl/pkg/cmd/list/list_job_printer.go @@ -0,0 +1,114 @@ +/* +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" + "fmt" + "io" + "time" + + batchv1 "k8s.io/api/batch/v1" + 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" + "k8s.io/utils/ptr" + + "sigs.k8s.io/kueue/cmd/experimental/kjobctl/pkg/constants" +) + +type listJobPrinter struct { + clock clock.Clock + printOptions printers.PrintOptions +} + +var _ printers.ResourcePrinter = (*listJobPrinter)(nil) + +func (p *listJobPrinter) PrintObj(obj runtime.Object, out io.Writer) error { + printer := printers.NewTablePrinter(p.printOptions) + + list, ok := obj.(*batchv1.JobList) + if !ok { + return errors.New("invalid object type") + } + + table := &metav1.Table{ + ColumnDefinitions: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Profile", Type: "string"}, + {Name: "Completions", Type: "string"}, + {Name: "Duration", Type: "string"}, + {Name: "Age", Type: "string"}, + }, + Rows: p.printJobList(list), + } + + return printer.PrintObj(table, out) +} + +func (p *listJobPrinter) WithNamespace(f bool) *listJobPrinter { + p.printOptions.WithNamespace = f + return p +} + +func (p *listJobPrinter) WithHeaders(f bool) *listJobPrinter { + p.printOptions.NoHeaders = !f + return p +} + +func (p *listJobPrinter) WithClock(c clock.Clock) *listJobPrinter { + p.clock = c + return p +} + +func newJobTablePrinter() *listJobPrinter { + return &listJobPrinter{ + clock: clock.RealClock{}, + } +} + +func (p *listJobPrinter) printJobList(list *batchv1.JobList) []metav1.TableRow { + rows := make([]metav1.TableRow, len(list.Items)) + for index := range list.Items { + rows[index] = p.printJob(&list.Items[index]) + } + return rows +} + +func (p *listJobPrinter) printJob(job *batchv1.Job) metav1.TableRow { + row := metav1.TableRow{ + Object: runtime.RawExtension{Object: job}, + } + var durationStr string + if job.Status.StartTime != nil { + completionTime := time.Now() + if job.Status.CompletionTime != nil { + completionTime = job.Status.CompletionTime.Time + } + durationStr = duration.HumanDuration(completionTime.Sub(job.Status.StartTime.Time)) + } + row.Cells = []any{ + job.Name, + job.ObjectMeta.Labels[constants.ProfileLabel], + fmt.Sprintf("%d/%d", job.Status.Succeeded, ptr.Deref(job.Spec.Completions, 1)), + durationStr, + duration.HumanDuration(p.clock.Since(job.CreationTimestamp.Time)), + } + return row +} diff --git a/cmd/experimental/kjobctl/pkg/cmd/list/list_job_test.go b/cmd/experimental/kjobctl/pkg/cmd/list/list_job_test.go new file mode 100644 index 0000000000..1d69f847f2 --- /dev/null +++ b/cmd/experimental/kjobctl/pkg/cmd/list/list_job_test.go @@ -0,0 +1,276 @@ +/* +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 ( + "fmt" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericiooptions" + "k8s.io/client-go/kubernetes/fake" + kubetesting "k8s.io/client-go/testing" + testingclock "k8s.io/utils/clock/testing" + + cmdtesting "sigs.k8s.io/kueue/cmd/experimental/kjobctl/pkg/cmd/testing" + "sigs.k8s.io/kueue/cmd/experimental/kjobctl/pkg/testing/wrappers" +) + +func TestJobCmd(t *testing.T) { + testStartTime := time.Now() + + testCases := map[string]struct { + ns string + objs []runtime.Object + args []string + wantOut string + wantOutErr string + wantErr error + }{ + "should print only kjobctl jobs": { + ns: "ns1", + objs: []runtime.Object{ + wrappers.MakeJob("j1", "ns1"). + Profile("profile1"). + Completions(3). + CreationTimestamp(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + StartTime(testStartTime.Add(-2 * time.Hour).Truncate(time.Second)). + CompletionTime(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Succeeded(3). + Obj(), + wrappers.MakeJob("j2", "ns2"). + Completions(3). + CreationTimestamp(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + StartTime(testStartTime.Add(-2 * time.Hour).Truncate(time.Second)). + CompletionTime(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Succeeded(3). + Obj(), + }, + wantOut: `NAME PROFILE COMPLETIONS DURATION AGE +j1 profile1 3/3 60m 60m +`, + }, + "should print job list with namespace filter": { + ns: "ns1", + objs: []runtime.Object{ + wrappers.MakeJob("j1", "ns1"). + Profile("profile1"). + Completions(3). + CreationTimestamp(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + StartTime(testStartTime.Add(-2 * time.Hour).Truncate(time.Second)). + CompletionTime(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Succeeded(3). + Obj(), + wrappers.MakeJob("j2", "ns2"). + Profile("profile2"). + Completions(3). + CreationTimestamp(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + StartTime(testStartTime.Add(-2 * time.Hour).Truncate(time.Second)). + CompletionTime(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Succeeded(3). + Obj(), + }, + wantOut: `NAME PROFILE COMPLETIONS DURATION AGE +j1 profile1 3/3 60m 60m +`, + }, + "should print job list with profile filter": { + args: []string{"--profile", "profile1"}, + objs: []runtime.Object{ + wrappers.MakeJob("j1", metav1.NamespaceDefault). + Profile("profile1"). + Completions(3). + CreationTimestamp(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + StartTime(testStartTime.Add(-2 * time.Hour).Truncate(time.Second)). + CompletionTime(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Succeeded(3). + Obj(), + wrappers.MakeJob("j2", metav1.NamespaceDefault). + Profile("profile2"). + Completions(3). + CreationTimestamp(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + StartTime(testStartTime.Add(-2 * time.Hour).Truncate(time.Second)). + CompletionTime(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Succeeded(3). + Obj(), + }, + wantOut: `NAME PROFILE COMPLETIONS DURATION AGE +j1 profile1 3/3 60m 60m +`, + }, + "should print job list with profile filter (short flag)": { + args: []string{"-p", "profile1"}, + objs: []runtime.Object{ + wrappers.MakeJob("j1", metav1.NamespaceDefault). + Profile("profile1"). + Completions(3). + CreationTimestamp(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + StartTime(testStartTime.Add(-2 * time.Hour).Truncate(time.Second)). + CompletionTime(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Succeeded(3). + Obj(), + wrappers.MakeJob("j2", metav1.NamespaceDefault). + Profile("profile2"). + Completions(3). + CreationTimestamp(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + StartTime(testStartTime.Add(-2 * time.Hour).Truncate(time.Second)). + CompletionTime(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Succeeded(3). + Obj(), + }, + wantOut: `NAME PROFILE COMPLETIONS DURATION AGE +j1 profile1 3/3 60m 60m +`, + }, + "should print job list with label selector filter": { + args: []string{"--selector", "foo=bar"}, + objs: []runtime.Object{ + wrappers.MakeJob("j1", metav1.NamespaceDefault). + Profile("profile1"). + Label("foo", "bar"). + Completions(3). + CreationTimestamp(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + StartTime(testStartTime.Add(-2 * time.Hour).Truncate(time.Second)). + CompletionTime(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Succeeded(3). + Obj(), + wrappers.MakeJob("j2", metav1.NamespaceDefault). + Profile("profile2"). + Completions(3). + CreationTimestamp(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + StartTime(testStartTime.Add(-2 * time.Hour).Truncate(time.Second)). + CompletionTime(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Succeeded(3). + Obj(), + }, + wantOut: `NAME PROFILE COMPLETIONS DURATION AGE +j1 profile1 3/3 60m 60m +`, + }, + "should print job list with label selector filter (short flag)": { + args: []string{"-l", "foo=bar"}, + objs: []runtime.Object{ + wrappers.MakeJob("j1", metav1.NamespaceDefault). + Profile("profile1"). + Label("foo", "bar"). + Completions(3). + CreationTimestamp(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + StartTime(testStartTime.Add(-2 * time.Hour).Truncate(time.Second)). + CompletionTime(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Succeeded(3). + Obj(), + wrappers.MakeJob("j2", metav1.NamespaceDefault). + Profile("profile2"). + Completions(3). + CreationTimestamp(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + StartTime(testStartTime.Add(-2 * time.Hour).Truncate(time.Second)). + CompletionTime(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Succeeded(3). + Obj(), + }, + wantOut: `NAME PROFILE COMPLETIONS DURATION AGE +j1 profile1 3/3 60m 60m +`, + }, + "should print job list with field selector filter": { + args: []string{"--field-selector", "metadata.name=j1"}, + objs: []runtime.Object{ + wrappers.MakeJob("j1", metav1.NamespaceDefault). + Profile("profile1"). + Completions(3). + CreationTimestamp(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + StartTime(testStartTime.Add(-2 * time.Hour).Truncate(time.Second)). + CompletionTime(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Succeeded(3). + Obj(), + wrappers.MakeJob("j2", metav1.NamespaceDefault). + Profile("profile2"). + Completions(3). + CreationTimestamp(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + StartTime(testStartTime.Add(-2 * time.Hour).Truncate(time.Second)). + CompletionTime(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Succeeded(3). + Obj(), + }, + wantOut: `NAME PROFILE COMPLETIONS DURATION AGE +j1 profile1 3/3 60m 60m +`, + }, + "should print not found error": { + wantOutErr: fmt.Sprintf("No resources found in %s namespace.\n", metav1.NamespaceDefault), + }, + "should print not found error with all-namespaces filter": { + args: []string{"-A"}, + wantOutErr: "No resources found\n", + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + streams, _, out, outErr := genericiooptions.NewTestIOStreams() + + clientset := fake.NewSimpleClientset(tc.objs...) + clientset.PrependReactor("list", "jobs", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) { + listAction := action.(kubetesting.ListActionImpl) + fieldsSelector := listAction.GetListRestrictions().Fields + + obj, err := clientset.Tracker().List(listAction.GetResource(), listAction.GetKind(), listAction.Namespace) + jobList := obj.(*batchv1.JobList) + + filtered := make([]batchv1.Job, 0, len(jobList.Items)) + for _, item := range jobList.Items { + fieldsSet := fields.Set{ + "metadata.name": item.Name, + } + if fieldsSelector.Matches(fieldsSet) { + filtered = append(filtered, item) + } + } + jobList.Items = filtered + return true, jobList, err + }) + + tcg := cmdtesting.NewTestClientGetter().WithK8sClientset(clientset) + if len(tc.ns) > 0 { + tcg.WithNamespace(tc.ns) + } + + cmd := NewJobCmd(tcg, streams, testingclock.NewFakeClock(testStartTime)) + 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/cmd/experimental/kjobctl/pkg/cmd/list/list_test.go b/cmd/experimental/kjobctl/pkg/cmd/list/list_test.go new file mode 100644 index 0000000000..2e6238c541 --- /dev/null +++ b/cmd/experimental/kjobctl/pkg/cmd/list/list_test.go @@ -0,0 +1,101 @@ +/* +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" + "k8s.io/client-go/kubernetes/fake" + testingclock "k8s.io/utils/clock/testing" + + cmdtesting "sigs.k8s.io/kueue/cmd/experimental/kjobctl/pkg/cmd/testing" + "sigs.k8s.io/kueue/cmd/experimental/kjobctl/pkg/testing/wrappers" +) + +func TestListCmd(t *testing.T) { + testStartTime := time.Now() + + testCases := map[string]struct { + ns string + objs []runtime.Object + args []string + wantOut string + wantOutErr string + wantErr error + }{ + "should print job list with all namespaces": { + args: []string{"job", "--all-namespaces"}, + objs: []runtime.Object{ + wrappers.MakeJob("j1", "ns1"). + Profile("profile1"). + Completions(3). + CreationTimestamp(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + StartTime(testStartTime.Add(-2 * time.Hour).Truncate(time.Second)). + CompletionTime(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Succeeded(3). + Obj(), + wrappers.MakeJob("j2", "ns2"). + Profile("profile2"). + Completions(3). + CreationTimestamp(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + StartTime(testStartTime.Add(-2 * time.Hour).Truncate(time.Second)). + CompletionTime(testStartTime.Add(-1 * time.Hour).Truncate(time.Second)). + Succeeded(3). + Obj(), + }, + wantOut: `NAMESPACE NAME PROFILE COMPLETIONS DURATION AGE +ns1 j1 profile1 3/3 60m 60m +ns2 j2 profile2 3/3 60m 60m +`, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + streams, _, out, outErr := genericiooptions.NewTestIOStreams() + + tcg := cmdtesting.NewTestClientGetter() + if len(tc.ns) > 0 { + tcg.WithNamespace(tc.ns) + } + + tcg.WithK8sClientset(fake.NewSimpleClientset(tc.objs...)) + + cmd := NewListCmd(tcg, streams, testingclock.NewFakeClock(testStartTime)) + 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/cmd/experimental/kjobctl/pkg/cmd/util/helpers.go b/cmd/experimental/kjobctl/pkg/cmd/util/helpers.go index cdb5941565..8f928a2b50 100644 --- a/cmd/experimental/kjobctl/pkg/cmd/util/helpers.go +++ b/cmd/experimental/kjobctl/pkg/cmd/util/helpers.go @@ -17,10 +17,31 @@ limitations under the License. package util import ( - "github.com/spf13/cobra" "k8s.io/klog/v2" + + "github.com/spf13/cobra" ) +func AddFieldSelectorFlagVar(cmd *cobra.Command, p *string) { + cmd.Flags().StringVar(p, "field-selector", "", + "Selector (field query) to filter on, supports '=', '==', and '!='.(e.g. --field-selector key1=value1,key2=value2). The server only supports a limited number of field queries per type.") +} + +func AddLabelSelectorFlagVar(cmd *cobra.Command, p *string) { + cmd.Flags().StringVarP(p, "selector", "l", "", + "Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2). Matching objects must satisfy all of the specified label constraints.") +} + +func AddAllNamespacesFlagVar(cmd *cobra.Command, p *bool) { + cmd.Flags().BoolVarP(p, "all-namespaces", "A", false, + "If present, list the requested object(s) across all namespaces. Namespace in current context is ignored even if specified with --namespace.") +} + +func AddProfileFlagVar(cmd *cobra.Command, p *string) { + cmd.Flags().StringVarP(p, "profile", "p", "", + "Filter by profile name which is associated with the resource.") +} + func FlagString(cmd *cobra.Command, flag string) string { s, err := cmd.Flags().GetString(flag) if err != nil { diff --git a/cmd/experimental/kjobctl/pkg/testing/wrappers/job_wrappers.go b/cmd/experimental/kjobctl/pkg/testing/wrappers/job_wrappers.go index 440ceee9dc..b27ef0a774 100644 --- a/cmd/experimental/kjobctl/pkg/testing/wrappers/job_wrappers.go +++ b/cmd/experimental/kjobctl/pkg/testing/wrappers/job_wrappers.go @@ -17,6 +17,8 @@ limitations under the License. package wrappers import ( + "time" + batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -89,3 +91,33 @@ func (j *JobWrapper) WithContainer(container corev1.Container) *JobWrapper { j.Spec.Template.Spec.Containers = append(j.Spec.Template.Spec.Containers, container) return j } + +// RestartPolicy updates the restartPolicy on the pod template. +func (j *JobWrapper) RestartPolicy(restartPolicy corev1.RestartPolicy) *JobWrapper { + j.Spec.Template.Spec.RestartPolicy = restartPolicy + return j +} + +// CreationTimestamp sets the .metadata.creationTimestamp +func (j *JobWrapper) CreationTimestamp(t time.Time) *JobWrapper { + j.ObjectMeta.CreationTimestamp = metav1.NewTime(t) + return j +} + +// StartTime sets the .status.startTime +func (j *JobWrapper) StartTime(t time.Time) *JobWrapper { + j.Status.StartTime = &metav1.Time{Time: t} + return j +} + +// CompletionTime sets the .status.completionTime +func (j *JobWrapper) CompletionTime(t time.Time) *JobWrapper { + j.Status.CompletionTime = &metav1.Time{Time: t} + return j +} + +// Succeeded sets the .status.succeeded +func (j *JobWrapper) Succeeded(value int32) *JobWrapper { + j.Status.Succeeded = value + return j +} diff --git a/cmd/experimental/kjobctl/test/integration/kjobctl/list_test.go b/cmd/experimental/kjobctl/test/integration/kjobctl/list_test.go new file mode 100644 index 0000000000..245e83d134 --- /dev/null +++ b/cmd/experimental/kjobctl/test/integration/kjobctl/list_test.go @@ -0,0 +1,110 @@ +/* +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 kjobctl + +import ( + "fmt" + "os" + "time" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/duration" + "k8s.io/cli-runtime/pkg/genericiooptions" + testingclock "k8s.io/utils/clock/testing" + + "sigs.k8s.io/kueue/cmd/experimental/kjobctl/pkg/cmd" + "sigs.k8s.io/kueue/cmd/experimental/kjobctl/pkg/cmd/list" + "sigs.k8s.io/kueue/cmd/experimental/kjobctl/pkg/testing/wrappers" + "sigs.k8s.io/kueue/cmd/experimental/kjobctl/test/util" +) + +var _ = ginkgo.Describe("Kjobctl List", ginkgo.Ordered, ginkgo.ContinueOnFailure, func() { + var ns *corev1.Namespace + + ginkgo.BeforeEach(func() { + ns = &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: "ns-"}} + gomega.Expect(k8sClient.Create(ctx, ns)).To(gomega.Succeed()) + }) + + ginkgo.AfterEach(func() { + gomega.Expect(util.DeleteNamespace(ctx, k8sClient, ns)).To(gomega.Succeed()) + os.Unsetenv(list.KjobctlListRequestLimitEnvName) + }) + + ginkgo.When("List Jobs", func() { + var ( + j1 *batchv1.Job + j2 *batchv1.Job + j3 *batchv1.Job + ) + + ginkgo.JustBeforeEach(func() { + j1 = wrappers.MakeJob("j1", ns.Name). + Profile("profile1"). + Completions(3). + WithContainer(*wrappers.MakeContainer("c1", "sleep").Obj()). + RestartPolicy(corev1.RestartPolicyOnFailure). + Obj() + gomega.Expect(k8sClient.Create(ctx, j1)).To(gomega.Succeed()) + + j2 = wrappers.MakeJob("j2", ns.Name). + Profile("profile1"). + Completions(3). + WithContainer(*wrappers.MakeContainer("c1", "sleep").Obj()). + RestartPolicy(corev1.RestartPolicyOnFailure). + Obj() + gomega.Expect(k8sClient.Create(ctx, j2)).To(gomega.Succeed()) + + j3 = wrappers.MakeJob("very-long-job-name", ns.Name). + Profile("profile1"). + Completions(3). + WithContainer(*wrappers.MakeContainer("c1", "sleep").Obj()). + RestartPolicy(corev1.RestartPolicyOnFailure). + Obj() + gomega.Expect(k8sClient.Create(ctx, j3)).To(gomega.Succeed()) + }) + + // Simple client set that are using on unit tests not allow paging. + ginkgo.It("Should print jobs list with paging", func() { + streams, _, output, errOutput := genericiooptions.NewTestIOStreams() + configFlags := CreateConfigFlagsWithRestConfig(cfg, streams) + executeTime := time.Now() + kjobctl := cmd.NewKjobctlCmd(cmd.KjobctlOptions{ConfigFlags: configFlags, IOStreams: streams, + Clock: testingclock.NewFakeClock(executeTime)}) + + os.Setenv(list.KjobctlListRequestLimitEnvName, "1") + kjobctl.SetArgs([]string{"list", "job", "--namespace", ns.Name}) + err := kjobctl.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 PROFILE COMPLETIONS DURATION AGE +j1 profile1 0/3 %s +j2 profile1 0/3 %s +very-long-job-name profile1 0/3 %s +`, + duration.HumanDuration(executeTime.Sub(j1.CreationTimestamp.Time)), + duration.HumanDuration(executeTime.Sub(j2.CreationTimestamp.Time)), + duration.HumanDuration(executeTime.Sub(j3.CreationTimestamp.Time)), + ))) + }) + }) +})