diff --git a/cmd/cli/job.go b/cmd/cli/job.go index d9a9d4fe1b..f81406bfd6 100644 --- a/cmd/cli/job.go +++ b/cmd/cli/job.go @@ -1,3 +1,19 @@ +/* +Copyright 2024 The Volcano 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 main import ( diff --git a/cmd/cli/jobflow.go b/cmd/cli/jobflow.go index 0763a65a4f..92bbe11f9c 100644 --- a/cmd/cli/jobflow.go +++ b/cmd/cli/jobflow.go @@ -1,3 +1,19 @@ +/* +Copyright 2024 The Volcano 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 main import ( diff --git a/cmd/cli/jobtemplate.go b/cmd/cli/jobtemplate.go index 0ddd20acaf..d32d2b5fe3 100644 --- a/cmd/cli/jobtemplate.go +++ b/cmd/cli/jobtemplate.go @@ -1,3 +1,19 @@ +/* +Copyright 2024 The Volcano 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 main import ( diff --git a/cmd/cli/pod.go b/cmd/cli/pod.go new file mode 100644 index 0000000000..25de0f67b0 --- /dev/null +++ b/cmd/cli/pod.go @@ -0,0 +1,55 @@ +/* +Copyright 2024 The Volcano 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 main + +import ( + "github.com/spf13/cobra" + + "volcano.sh/volcano/cmd/cli/util" + "volcano.sh/volcano/pkg/cli/pod" +) + +func buildPodCmd() *cobra.Command { + podCmd := &cobra.Command{ + Use: "pod", + Short: "vcctl command line operation pod", + } + + podCommandMap := map[string]struct { + Short string + RunFunction func(cmd *cobra.Command, args []string) + InitFlags func(cmd *cobra.Command) + }{ + "list": { + Short: "list pod information created by vcjob", + RunFunction: func(cmd *cobra.Command, args []string) { + util.CheckError(cmd, pod.ListPods(cmd.Context())) + }, + InitFlags: pod.InitListFlags, + }, + } + for command, config := range podCommandMap { + cmd := &cobra.Command{ + Use: command, + Short: config.Short, + Run: config.RunFunction, + } + config.InitFlags(cmd) + podCmd.AddCommand(cmd) + } + return podCmd +} diff --git a/cmd/cli/vcctl.go b/cmd/cli/vcctl.go index 734825143a..7202b681d9 100644 --- a/cmd/cli/vcctl.go +++ b/cmd/cli/vcctl.go @@ -37,6 +37,7 @@ func main() { rootCmd.AddCommand(buildQueueCmd()) rootCmd.AddCommand(buildJobTemplateCmd()) rootCmd.AddCommand(buildJobFlowCmd()) + rootCmd.AddCommand(buildPodCmd()) rootCmd.AddCommand(versionCommand()) code := cli.Run(&rootCmd) diff --git a/pkg/cli/jobflow/create.go b/pkg/cli/jobflow/create.go index 2757698610..6d79f6cba7 100644 --- a/pkg/cli/jobflow/create.go +++ b/pkg/cli/jobflow/create.go @@ -1,3 +1,19 @@ +/* +Copyright 2024 The Volcano 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 jobflow import ( diff --git a/pkg/cli/jobflow/delete.go b/pkg/cli/jobflow/delete.go index cd009d99ae..9636131e55 100644 --- a/pkg/cli/jobflow/delete.go +++ b/pkg/cli/jobflow/delete.go @@ -1,3 +1,19 @@ +/* +Copyright 2024 The Volcano 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 jobflow import ( diff --git a/pkg/cli/jobflow/describe.go b/pkg/cli/jobflow/describe.go index b9636e80c4..09c73900d5 100644 --- a/pkg/cli/jobflow/describe.go +++ b/pkg/cli/jobflow/describe.go @@ -1,3 +1,19 @@ +/* +Copyright 2024 The Volcano 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 jobflow import ( diff --git a/pkg/cli/jobflow/get.go b/pkg/cli/jobflow/get.go index 7a9e8bbf94..abf248f058 100644 --- a/pkg/cli/jobflow/get.go +++ b/pkg/cli/jobflow/get.go @@ -1,3 +1,19 @@ +/* +Copyright 2024 The Volcano 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 jobflow import ( diff --git a/pkg/cli/jobflow/jobflow_test.go b/pkg/cli/jobflow/jobflow_test.go index 889ea194a0..ea0026730b 100644 --- a/pkg/cli/jobflow/jobflow_test.go +++ b/pkg/cli/jobflow/jobflow_test.go @@ -1,3 +1,19 @@ +/* +Copyright 2024 The Volcano 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 jobflow import ( diff --git a/pkg/cli/jobflow/list.go b/pkg/cli/jobflow/list.go index 0297e92843..f52c585904 100644 --- a/pkg/cli/jobflow/list.go +++ b/pkg/cli/jobflow/list.go @@ -1,3 +1,19 @@ +/* +Copyright 2024 The Volcano 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 jobflow import ( diff --git a/pkg/cli/jobtemplate/create.go b/pkg/cli/jobtemplate/create.go index d3c7308de1..a02aae91b1 100644 --- a/pkg/cli/jobtemplate/create.go +++ b/pkg/cli/jobtemplate/create.go @@ -1,3 +1,19 @@ +/* +Copyright 2024 The Volcano 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 jobtemplate import ( diff --git a/pkg/cli/jobtemplate/delete.go b/pkg/cli/jobtemplate/delete.go index 0ce2047b32..0d4ab3fe2a 100644 --- a/pkg/cli/jobtemplate/delete.go +++ b/pkg/cli/jobtemplate/delete.go @@ -1,3 +1,19 @@ +/* +Copyright 2024 The Volcano 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 jobtemplate import ( diff --git a/pkg/cli/jobtemplate/describe.go b/pkg/cli/jobtemplate/describe.go index 70520b41b1..dc2101c727 100644 --- a/pkg/cli/jobtemplate/describe.go +++ b/pkg/cli/jobtemplate/describe.go @@ -1,3 +1,19 @@ +/* +Copyright 2024 The Volcano 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 jobtemplate import ( diff --git a/pkg/cli/jobtemplate/get.go b/pkg/cli/jobtemplate/get.go index 1c913dc93f..7fa4275f76 100644 --- a/pkg/cli/jobtemplate/get.go +++ b/pkg/cli/jobtemplate/get.go @@ -1,3 +1,19 @@ +/* +Copyright 2024 The Volcano 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 jobtemplate import ( diff --git a/pkg/cli/jobtemplate/jobtemplate_test.go b/pkg/cli/jobtemplate/jobtemplate_test.go index 3758c34ec0..7dc3203d37 100644 --- a/pkg/cli/jobtemplate/jobtemplate_test.go +++ b/pkg/cli/jobtemplate/jobtemplate_test.go @@ -1,3 +1,19 @@ +/* +Copyright 2024 The Volcano 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 jobtemplate import ( diff --git a/pkg/cli/jobtemplate/list.go b/pkg/cli/jobtemplate/list.go index bb2cdec867..94825ee671 100644 --- a/pkg/cli/jobtemplate/list.go +++ b/pkg/cli/jobtemplate/list.go @@ -1,3 +1,19 @@ +/* +Copyright 2024 The Volcano 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 jobtemplate import ( diff --git a/pkg/cli/pod/pod.go b/pkg/cli/pod/pod.go new file mode 100644 index 0000000000..d1362166e5 --- /dev/null +++ b/pkg/cli/pod/pod.go @@ -0,0 +1,503 @@ +/* +Copyright 2024 The Volcano 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 pod + +import ( + "context" + "fmt" + "io" + "os" + "strconv" + "time" + + "github.com/spf13/cobra" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/selection" + "k8s.io/apimachinery/pkg/util/duration" + kubeclientset "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + podutil "k8s.io/kubernetes/pkg/api/v1/pod" + "k8s.io/kubernetes/pkg/util/node" + + "volcano.sh/apis/pkg/apis/batch/v1alpha1" + schedulingv1beta1 "volcano.sh/apis/pkg/apis/scheduling/v1beta1" + "volcano.sh/volcano/pkg/cli/util" +) + +const ( + // Name pod name + Name string = "Name" + // Ready pod ready + Ready string = "Ready" + // Status pod status + Status string = "Status" + // Restart pod restart + Restart string = "Restart" + // Age pod age + Age string = "Age" +) + +type listFlags struct { + util.CommonFlags + // Namespace pod namespace + Namespace string + // JobName represents the pod created under this vcjob, + // filtered by volcano.sh/job-name label + // the default value is empty, which means + // that all pods under vcjob will be obtained. + JobName string + // allNamespace represents getting all namespaces + allNamespace bool + // QueueName represents queue name + QueueName string +} + +var listPodFlags = &listFlags{} + +// InitListFlags init list command flags. +func InitListFlags(cmd *cobra.Command) { + util.InitFlags(cmd, &listPodFlags.CommonFlags) + + cmd.Flags().StringVarP(&listPodFlags.QueueName, "queue", "q", "", "list pod with specified queue name") + cmd.Flags().StringVarP(&listPodFlags.JobName, "job", "j", "", "list pod with specified job name") + cmd.Flags().StringVarP(&listPodFlags.Namespace, "namespace", "n", "default", "the namespace of job") + cmd.Flags().BoolVarP(&listPodFlags.allNamespace, "all-namespaces", "", false, "list jobs in all namespaces") +} + +// ListPods lists all pods details created by vcjob +func ListPods(ctx context.Context) error { + config, err := util.BuildConfig(listPodFlags.Master, listPodFlags.Kubeconfig) + if err != nil { + return err + } + if listPodFlags.allNamespace { + listPodFlags.Namespace = "" + } + + var pods corev1.PodList + + // if job name are specified, use job name to filter pods + // if the job is specified, it means that no matter whether the queue name is specified or not, + // we can just use the job name query. Because vcjob itself will specify a specific queue + if listPodFlags.JobName != "" { + labelSelector, err := createPodLabelSelectorByJobName(listPodFlags.JobName) + if err != nil { + return err + } + listVcjobPodsRes, err := listPodByLabel(ctx, config, labelSelector) + if err != nil { + return err + } + pods.Items = append(pods.Items, listVcjobPodsRes.Items...) + if listPodFlags.QueueName != "" { + // if queue is specified, check if the queue name used by the pod matches with the one passed in + if !matchPodsLabel(listVcjobPodsRes, v1alpha1.QueueNameKey, listPodFlags.QueueName) { + return fmt.Errorf("the input vcjob %s does not match the queue %s", + listPodFlags.JobName, listPodFlags.QueueName) + } + } + } else if listPodFlags.QueueName != "" { + // if queue is specified, use queue name to filter pods + // we need to consider both the vcjob pod and other workload pods + + // first, list all pods + listAllPodsRes, err := listPodByLabel(ctx, config, labels.Everything()) + if err != nil { + return err + } + // then, filter all vcjobs's pods belong to the queue + listVcJobPodsRes := filterPodsByLabel(listAllPodsRes, v1alpha1.QueueNameKey, listPodFlags.QueueName) + + // then, filter all other workload pods belong to the queue + listNormalPodsRes := filterPodsByAnnotation(listAllPodsRes, schedulingv1beta1.QueueNameAnnotationKey, listPodFlags.QueueName) + + pods.Items = append(pods.Items, listVcJobPodsRes.Items...) + // append only not exist + pods.Items = appendIfNotExists(pods.Items, listNormalPodsRes.Items) + } else { + // if neither job name nor queue name are specified, + // use default label selector, for all vcjobs's pods + labelSelector, err := createDefaultLabelSelector() + if err != nil { + return err + } + listPodsRes, err := listPodByLabel(ctx, config, labelSelector) + if err != nil { + return err + } + pods.Items = append(pods.Items, listPodsRes.Items...) + } + + if len(pods.Items) == 0 { + fmt.Printf("No resources found\n") + return nil + } + PrintPods(&pods, os.Stdout) + + return nil +} + +func PrintPods(pods *corev1.PodList, writer io.Writer) { + maxNameLen := 0 + maxReadyLen := 0 + maxStatusLen := 0 + maxRestartLen := 0 + maxAgeLen := 0 + + var infoList []PodInfo + for _, pod := range pods.Items { + info := printPod(&pod) + infoList = append(infoList, info) + // update max length for each column + if len(info.Name) > maxNameLen { + maxNameLen = len(info.Name) + } + if len(info.ReadyContainers) > maxReadyLen { + maxReadyLen = len(info.ReadyContainers) + } + if len(info.Status) > maxStatusLen { + maxStatusLen = len(info.Status) + } + if len(info.Restarts) > maxRestartLen { + maxRestartLen = len(info.Restarts) + } + if len(info.CreationTimestamp) > maxAgeLen { + maxAgeLen = len(info.CreationTimestamp) + } + } + columnSpacing := 8 + maxNameLen += columnSpacing + maxReadyLen += columnSpacing + maxStatusLen += columnSpacing + maxRestartLen += columnSpacing + maxAgeLen += columnSpacing + formatStr := fmt.Sprintf("%%-%ds%%-%ds%%-%ds%%-%ds%%-%ds\n", maxNameLen, maxReadyLen, maxStatusLen, maxRestartLen, maxAgeLen) + _, err := fmt.Fprintf(writer, formatStr, Name, Ready, Status, Restart, Age) + if err != nil { + fmt.Printf("Failed to print Pod information: %s.\n", err) + return + } + for _, info := range infoList { + _, err := fmt.Fprintf(writer, formatStr, info.Name, info.ReadyContainers, info.Status, info.Restarts, info.CreationTimestamp) + if err != nil { + fmt.Printf("Failed to print Pod information: %s.\n", err) + return + } + } +} + +// matchPodsLabel check if the pods match the labelKey and labelValue +func matchPodsLabel(pods *corev1.PodList, labelKey, labelValue string) bool { + for _, pod := range pods.Items { + if value, exist := pod.Labels[labelKey]; exist { + if value != labelValue { + return false + } + } + } + return true +} + +// filterPodsByAnnotation filter pods based on annotationKey and annotationValue +func filterPodsByAnnotation(pods *corev1.PodList, annotationKey, annotationValue string) *corev1.PodList { + filteredPods := &corev1.PodList{} + for _, pod := range pods.Items { + if value, exist := pod.Annotations[annotationKey]; exist { + if value == annotationValue { + filteredPods.Items = append(filteredPods.Items, pod) + } + } + } + return filteredPods +} + +// filterPodsByLabel filter pods based on labelKey and labelValue +func filterPodsByLabel(pods *corev1.PodList, labelKey, labelValue string) *corev1.PodList { + filteredPods := &corev1.PodList{} + for _, pod := range pods.Items { + if value, exist := pod.Labels[labelKey]; exist { + if value == labelValue { + filteredPods.Items = append(filteredPods.Items, pod) + } + } + } + return filteredPods +} + +// listPodByLabel lists pods based on label selector +func listPodByLabel(ctx context.Context, config *rest.Config, labelSelector labels.Selector) (*corev1.PodList, error) { + client := kubeclientset.NewForConfigOrDie(config) + // Construct the list options based on label and annotation selectors + opts := metav1.ListOptions{ + LabelSelector: labelSelector.String(), + } + return client.CoreV1().Pods(listPodFlags.Namespace).List(ctx, opts) +} + +// createPodLabelSelectorByJobName creates a label selector for selecting pods belong to a vcjob. +func createPodLabelSelectorByJobName(jobName string) (labels.Selector, error) { + var labelSelector labels.Selector + inRequirement, err := labels.NewRequirement(v1alpha1.JobNameKey, selection.In, []string{jobName}) + if err != nil { + return nil, err + } + labelSelector = labels.NewSelector().Add(*inRequirement) + return labelSelector, nil +} + +// createDefaultLabelSelector creates a label selector for all vcjobs's pods. +func createDefaultLabelSelector() (labels.Selector, error) { + var labelSelector labels.Selector + // If job name is not provided, select all pods created by vcjobs. + inRequirement, err := labels.NewRequirement(v1alpha1.JobNameKey, selection.Exists, []string{}) + if err != nil { + return nil, err + } + labelSelector = labels.NewSelector().Add(*inRequirement) + return labelSelector, nil +} + +// Helper function to append items to a slice if they do not already exist +func appendIfNotExists(existing, toAppend []corev1.Pod) []corev1.Pod { + for _, pod := range toAppend { + exists := false + for _, existingPod := range existing { + if existingPod.Name == pod.Name { + exists = true + break + } + } + if !exists { + existing = append(existing, pod) + } + } + return existing +} + +// translateTimestampSince translates a timestamp into a human-readable string using time.Since. +func translateTimestampSince(timestamp metav1.Time) string { + if timestamp.IsZero() { + return "" + } + return duration.HumanDuration(time.Since(timestamp.Time)) +} + +// PodInfo holds information about a pod. +type PodInfo struct { + Name string + ReadyContainers string + Status string + Restarts string + CreationTimestamp string +} + +// printPod information in a tabular format. +// The reference implementation comes from: +// https://github.com/kubernetes/kubernetes/blob/master/pkg/printers/internalversion/printers.go +func printPod(pod *corev1.Pod) PodInfo { + restarts := 0 + restartableInitContainerRestarts := 0 + totalContainers := len(pod.Spec.Containers) + readyContainers := 0 + lastRestartDate := metav1.NewTime(time.Time{}) + lastRestartableInitContainerRestartDate := metav1.NewTime(time.Time{}) + + podPhase := pod.Status.Phase + reason := string(podPhase) + if pod.Status.Reason != "" { + reason = pod.Status.Reason + } + + // If the Pod carries {type:PodScheduled, reason:SchedulingGated}, set reason to 'SchedulingGated'. + for _, condition := range pod.Status.Conditions { + if condition.Type == corev1.PodScheduled && condition.Reason == corev1.PodReasonSchedulingGated { + reason = corev1.PodReasonSchedulingGated + } + } + + row := metav1.TableRow{ + Object: runtime.RawExtension{Object: pod}, + } + + switch pod.Status.Phase { + case corev1.PodSucceeded: + row.Conditions = podSuccessConditions + case corev1.PodFailed: + row.Conditions = podFailedConditions + } + + initContainers := make(map[string]*corev1.Container) + for i := range pod.Spec.InitContainers { + initContainers[pod.Spec.InitContainers[i].Name] = &pod.Spec.InitContainers[i] + if isRestartableInitContainer(&pod.Spec.InitContainers[i]) { + totalContainers++ + } + } + + initializing := false + for i := range pod.Status.InitContainerStatuses { + container := pod.Status.InitContainerStatuses[i] + restarts += int(container.RestartCount) + if container.LastTerminationState.Terminated != nil { + terminatedDate := container.LastTerminationState.Terminated.FinishedAt + if lastRestartDate.Before(&terminatedDate) { + lastRestartDate = terminatedDate + } + } + if isRestartableInitContainer(initContainers[container.Name]) { + restartableInitContainerRestarts += int(container.RestartCount) + if container.LastTerminationState.Terminated != nil { + terminatedDate := container.LastTerminationState.Terminated.FinishedAt + if lastRestartableInitContainerRestartDate.Before(&terminatedDate) { + lastRestartableInitContainerRestartDate = terminatedDate + } + } + } + switch { + case container.State.Terminated != nil && container.State.Terminated.ExitCode == 0: + continue + case isRestartableInitContainer(initContainers[container.Name]) && + container.Started != nil && *container.Started: + if container.Ready { + readyContainers++ + } + continue + case container.State.Terminated != nil: + // initialization is failed + if len(container.State.Terminated.Reason) == 0 { + if container.State.Terminated.Signal != 0 { + reason = fmt.Sprintf("Init:Signal:%d", container.State.Terminated.Signal) + } else { + reason = fmt.Sprintf("Init:ExitCode:%d", container.State.Terminated.ExitCode) + } + } else { + reason = "Init:" + container.State.Terminated.Reason + } + initializing = true + case container.State.Waiting != nil && len(container.State.Waiting.Reason) > 0 && container.State.Waiting.Reason != "PodInitializing": + reason = "Init:" + container.State.Waiting.Reason + initializing = true + default: + reason = fmt.Sprintf("Init:%d/%d", i, len(pod.Spec.InitContainers)) + initializing = true + } + break + } + + if !initializing || isPodInitializedConditionTrue(&pod.Status) { + restarts = restartableInitContainerRestarts + lastRestartDate = lastRestartableInitContainerRestartDate + hasRunning := false + for i := len(pod.Status.ContainerStatuses) - 1; i >= 0; i-- { + container := pod.Status.ContainerStatuses[i] + + restarts += int(container.RestartCount) + if container.LastTerminationState.Terminated != nil { + terminatedDate := container.LastTerminationState.Terminated.FinishedAt + if lastRestartDate.Before(&terminatedDate) { + lastRestartDate = terminatedDate + } + } + if container.State.Waiting != nil && container.State.Waiting.Reason != "" { + reason = container.State.Waiting.Reason + } else if container.State.Terminated != nil && container.State.Terminated.Reason != "" { + reason = container.State.Terminated.Reason + } else if container.State.Terminated != nil && container.State.Terminated.Reason == "" { + if container.State.Terminated.Signal != 0 { + reason = fmt.Sprintf("Signal:%d", container.State.Terminated.Signal) + } else { + reason = fmt.Sprintf("ExitCode:%d", container.State.Terminated.ExitCode) + } + } else if container.Ready && container.State.Running != nil { + hasRunning = true + readyContainers++ + } + } + + // change pod status back to "Running" if there is at least one container still reporting as "Running" status + if reason == "Completed" && hasRunning { + if hasPodReadyCondition(pod.Status.Conditions) { + reason = "Running" + } else { + reason = "NotReady" + } + } + } + + if pod.DeletionTimestamp != nil && pod.Status.Reason == node.NodeUnreachablePodReason { + reason = "Unknown" + } else if pod.DeletionTimestamp != nil && !podutil.IsPodPhaseTerminal(corev1.PodPhase(podPhase)) { + reason = "Terminating" + } + + restartsStr := strconv.Itoa(restarts) + if restarts != 0 && !lastRestartDate.IsZero() { + restartsStr = fmt.Sprintf("%d (%s ago)", restarts, translateTimestampSince(lastRestartDate)) + } + + podInfo := PodInfo{ + Name: pod.Name, + ReadyContainers: fmt.Sprintf("%d/%d", readyContainers, totalContainers), + Status: reason, + Restarts: restartsStr, + CreationTimestamp: translateTimestampSince(pod.CreationTimestamp), + } + return podInfo +} + +var ( + podSuccessConditions = []metav1.TableRowCondition{{Type: metav1.RowCompleted, Status: metav1.ConditionTrue, Reason: string(corev1.PodSucceeded), Message: "The pod has completed successfully."}} + podFailedConditions = []metav1.TableRowCondition{{Type: metav1.RowCompleted, Status: metav1.ConditionTrue, Reason: string(corev1.PodFailed), Message: "The pod failed."}} +) + +// hasPodReadyCondition returns true if the pod has a ready condition +func hasPodReadyCondition(conditions []corev1.PodCondition) bool { + for _, condition := range conditions { + if condition.Type == corev1.PodReady && condition.Status == corev1.ConditionTrue { + return true + } + } + return false +} + +// isRestartableInitContainer returns true if the given init container is restartable +func isRestartableInitContainer(initContainer *corev1.Container) bool { + if initContainer == nil { + return false + } + if initContainer.RestartPolicy == nil { + return false + } + + return *initContainer.RestartPolicy == corev1.ContainerRestartPolicyAlways +} + +// isPodInitializedConditionTrue returns true if the PodInitialized condition is true +func isPodInitializedConditionTrue(status *corev1.PodStatus) bool { + for _, condition := range status.Conditions { + if condition.Type != corev1.PodInitialized { + continue + } + + return condition.Status == corev1.ConditionTrue + } + return false +} diff --git a/pkg/cli/pod/pod_test.go b/pkg/cli/pod/pod_test.go new file mode 100644 index 0000000000..8f9f86fcbd --- /dev/null +++ b/pkg/cli/pod/pod_test.go @@ -0,0 +1,225 @@ +/* +Copyright 2024 The Volcano 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 pod + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "reflect" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + "volcano.sh/apis/pkg/apis/batch/v1alpha1" + schedulingv1beta1 "volcano.sh/apis/pkg/apis/scheduling/v1beta1" +) + +func TestListPod(t *testing.T) { + testCases := []struct { + name string + Response interface{} + Namespace string + JobName string + QueueName string + ExpectedErr error + ExpectedOutput string + }{ + { + name: "Normal Case", + Response: &corev1.PodList{ + Items: []corev1.Pod{ + buildPod("default", "my-pod", + map[string]string{v1alpha1.JobNameKey: "my-job1"}, map[string]string{}), + }, + }, + Namespace: "default", + JobName: "", + ExpectedErr: nil, + ExpectedOutput: `Name Ready Status Restart Age +my-pod 0/1 Running 0 0s`, + }, + { + name: "Normal Case with namespace filter", + Response: &corev1.PodList{ + Items: []corev1.Pod{ + buildPod("default", "my-pod", + map[string]string{v1alpha1.JobNameKey: "my-job1"}, map[string]string{}), + }, + }, + Namespace: "default", + JobName: "", + ExpectedErr: nil, + ExpectedOutput: `Name Ready Status Restart Age +my-pod 0/1 Running 0 0s`, + }, + { + name: "Normal Case with jobName filter", + Response: &corev1.PodList{ + Items: []corev1.Pod{ + buildPod("default", "my-pod", + map[string]string{v1alpha1.JobNameKey: "my-job1"}, map[string]string{}), + }, + }, + Namespace: "default", + JobName: "my-job1", + ExpectedErr: nil, + ExpectedOutput: `Name Ready Status Restart Age +my-pod 0/1 Running 0 0s`, + }, + { + name: "Normal Case with queueName filter", + Response: &corev1.PodList{ + Items: []corev1.Pod{ + buildPod("default", "my-pod1", + map[string]string{v1alpha1.QueueNameKey: "my-queue1"}, map[string]string{}), + buildPod("default", "my-pod2", + map[string]string{v1alpha1.JobNameKey: "my-job2", v1alpha1.QueueNameKey: "my-queue1"}, map[string]string{}), + buildPod("default", "my-pod3", + map[string]string{}, map[string]string{schedulingv1beta1.QueueNameAnnotationKey: "my-queue1"}), + }, + }, + Namespace: "default", + QueueName: "my-queue1", + ExpectedErr: nil, + ExpectedOutput: `Name Ready Status Restart Age +my-pod1 0/1 Running 0 0s +my-pod2 0/1 Running 0 0s +my-pod3 0/1 Running 0 0s`, + }, + { + name: "Normal Case with queueName filter and jobName filter", + Response: &corev1.PodList{ + Items: []corev1.Pod{ + buildPod("default", "my-pod1", + map[string]string{v1alpha1.JobNameKey: "my-job1", v1alpha1.QueueNameKey: "my-queue1"}, map[string]string{}), + }, + }, + Namespace: "default", + QueueName: "my-queue1", + JobName: "my-job1", + ExpectedErr: nil, + ExpectedOutput: `Name Ready Status Restart Age +my-pod1 0/1 Running 0 0s`, + }, + { + name: "Normal Case with queueName filter and jobName filter, and does not match", + Response: &corev1.PodList{ + Items: []corev1.Pod{ + buildPod("default", "my-pod1", + map[string]string{v1alpha1.JobNameKey: "my-job1", v1alpha1.QueueNameKey: "my-queue1"}, map[string]string{}), + }, + }, + Namespace: "default", + QueueName: "my-queue2", + JobName: "my-job1", + ExpectedErr: fmt.Errorf("the input vcjob %s does not match the queue %s", "my-job1", "my-queue2"), + ExpectedOutput: "", + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + server := createTestServer(testCase.Response) + defer server.Close() + // Set the server URL as the master flag + listPodFlags.Master = server.URL + listPodFlags.Namespace = testCase.Namespace + listPodFlags.JobName = testCase.JobName + listPodFlags.QueueName = testCase.QueueName + listPodFlags.Namespace = testCase.Namespace + r, oldStdout := redirectStdout() + defer r.Close() + + err := ListPods(context.TODO()) + gotOutput := captureOutput(r, oldStdout) + + if !reflect.DeepEqual(err, testCase.ExpectedErr) { + t.Fatalf("test case: %s failed: got: %v, want: %v", testCase.name, err, testCase.ExpectedErr) + } + if gotOutput != testCase.ExpectedOutput { + t.Errorf("test case: %s failed: got: %s, want: %s", testCase.name, gotOutput, testCase.ExpectedOutput) + } + }) + } +} + +func createTestServer(response interface{}) *httptest.Server { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + val, err := json.Marshal(response) + if err == nil { + w.Write(val) + } + }) + + server := httptest.NewServer(handler) + return server +} + +// redirectStdout redirects os.Stdout to a pipe and returns the read and write ends of the pipe. +func redirectStdout() (*os.File, *os.File) { + r, w, _ := os.Pipe() + oldStdout := os.Stdout + os.Stdout = w + return r, oldStdout +} + +// captureOutput reads from r until EOF and returns the result as a string. +func captureOutput(r *os.File, oldStdout *os.File) string { + w := os.Stdout + os.Stdout = oldStdout + w.Close() + gotOutput, _ := io.ReadAll(r) + return strings.TrimSpace(string(gotOutput)) +} + +func buildPod(namespace, name string, labels map[string]string, annotations map[string]string) corev1.Pod { + return corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + UID: types.UID(fmt.Sprintf("%v-%v", namespace, name)), + Name: name, + Namespace: namespace, + Labels: labels, + Annotations: annotations, + CreationTimestamp: metav1.Now(), + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "my-container", + Image: "nginx", + }, + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + Conditions: []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: corev1.ConditionTrue, + }, + }, + }, + } +} diff --git a/test/e2e/vcctl/vcctl.go b/test/e2e/vcctl/vcctl.go index 91799aabf8..31f048aa51 100644 --- a/test/e2e/vcctl/vcctl.go +++ b/test/e2e/vcctl/vcctl.go @@ -36,6 +36,7 @@ Available Commands: job vcctl command line operation job jobflow vcctl command line operation jobflow jobtemplate vcctl command line operation jobtemplate + pod vcctl command line operation pod queue Queue Operations version Print the version information