From b83303fcc12a5578f72b86d0ca75619e4e866757 Mon Sep 17 00:00:00 2001 From: LX Date: Wed, 7 Aug 2024 08:41:09 +0000 Subject: [PATCH 1/2] add webhook for jobflow and jobtemplate Signed-off-by: LX --- cmd/webhook-manager/main.go | 2 + .../jobflows/validate/admit_jobflow.go | 118 ++++++++ .../admission/jobflows/validate/util.go | 52 ++++ .../validate/admit_jobtemplate.go | 262 ++++++++++++++++++ .../admission/jobtemplates/validate/util.go | 173 ++++++++++++ pkg/webhooks/schema/schema.go | 42 +++ 6 files changed, 649 insertions(+) create mode 100644 pkg/webhooks/admission/jobflows/validate/admit_jobflow.go create mode 100644 pkg/webhooks/admission/jobflows/validate/util.go create mode 100644 pkg/webhooks/admission/jobtemplates/validate/admit_jobtemplate.go create mode 100644 pkg/webhooks/admission/jobtemplates/validate/util.go diff --git a/cmd/webhook-manager/main.go b/cmd/webhook-manager/main.go index 45bdfc4b98..5c8d45a047 100644 --- a/cmd/webhook-manager/main.go +++ b/cmd/webhook-manager/main.go @@ -37,6 +37,8 @@ import ( _ "volcano.sh/volcano/pkg/webhooks/admission/pods/validate" _ "volcano.sh/volcano/pkg/webhooks/admission/queues/mutate" _ "volcano.sh/volcano/pkg/webhooks/admission/queues/validate" + _ "volcano.sh/volcano/pkg/webhooks/admission/jobflows/validate" + _ "volcano.sh/volcano/pkg/webhooks/admission/jobtemplates/validate" ) var logFlushFreq = pflag.Duration("log-flush-frequency", 5*time.Second, "Maximum number of seconds between log flushes") diff --git a/pkg/webhooks/admission/jobflows/validate/admit_jobflow.go b/pkg/webhooks/admission/jobflows/validate/admit_jobflow.go new file mode 100644 index 0000000000..390db51d6a --- /dev/null +++ b/pkg/webhooks/admission/jobflows/validate/admit_jobflow.go @@ -0,0 +1,118 @@ +package validate + +import ( + "fmt" + + admissionv1 "k8s.io/api/admission/v1" + whv1 "k8s.io/api/admissionregistration/v1" + "k8s.io/klog" + jobflowv1alpha1 "volcano.sh/apis/pkg/apis/flow/v1alpha1" + "volcano.sh/apis/pkg/apis/helpers" + "volcano.sh/volcano/pkg/webhooks/router" + "volcano.sh/volcano/pkg/webhooks/schema" + "volcano.sh/volcano/pkg/webhooks/util" +) + +func init() { + router.RegisterAdmission(service) +} + +var service = &router.AdmissionService{ + Path: "/jobflows/validate", + Func: AdmitJobFlows, + + Config: config, + + ValidatingConfig: &whv1.ValidatingWebhookConfiguration{ + Webhooks: []whv1.ValidatingWebhook{{ + Name: "validatejobflow.volcano.sh", + Rules: []whv1.RuleWithOperations{ + { + Operations: []whv1.OperationType{whv1.Create}, + Rule: whv1.Rule{ + APIGroups: []string{helpers.JobFlowKind.Group}, + APIVersions: []string{helpers.JobFlowKind.Version}, + Resources: []string{"jobflows"}, + }, + }, + }, + }}, + }, +} + +var config = &router.AdmissionServiceConfig{} + +// AdmitJobFlows is to admit jobFlows and return response. +func AdmitJobFlows(ar admissionv1.AdmissionReview) *admissionv1.AdmissionResponse { + klog.V(3).Infof("admitting jobflows -- %s", ar.Request.Operation) + + jobFlow, err := schema.DecodeJobFlow(ar.Request.Object, ar.Request.Resource) + if err != nil { + return util.ToAdmissionResponse(err) + } + + switch ar.Request.Operation { + case admissionv1.Create: + err = validateJobFlowCreate(jobFlow) + if err != nil { + return util.ToAdmissionResponse(err) + } + reviewResponse := admissionv1.AdmissionResponse{} + reviewResponse.Allowed = true + return &reviewResponse + default: + return util.ToAdmissionResponse(fmt.Errorf("only support 'CREATE' operation")) + } +} + +func validateJobFlowCreate(jobFlow *jobflowv1alpha1.JobFlow) error { + flows := jobFlow.Spec.Flows + var msg string + templateNames := map[string][]string{} + vertexMap := make(map[string]*Vertex) + dag := &DAG{} + var duplicatedTemplate = false + for _, template := range flows { + if _, found := templateNames[template.Name]; found { + // duplicate task name + msg += fmt.Sprintf(" duplicated template name %s;", template.Name) + duplicatedTemplate = true + break + } else { + if template.DependsOn == nil || template.DependsOn.Targets == nil { + template.DependsOn = new(jobflowv1alpha1.DependsOn) + } + templateNames[template.Name] = template.DependsOn.Targets + vertexMap[template.Name] = &Vertex{Key: template.Name} + } + } + // Skip closed-loop detection if there are duplicate templates + if !duplicatedTemplate { + // Build dag through dependencies + for current, parents := range templateNames { + if len(parents) > 0 { + for _, parent := range parents { + if _, found := vertexMap[parent]; !found { + msg += fmt.Sprintf("cannot find the template: %s ", parent) + vertexMap = nil + break + } + dag.AddEdge(vertexMap[parent], vertexMap[current]) + } + } + } + // Check if there is a closed loop + for k := range vertexMap { + if err := dag.BFS(vertexMap[k]); err != nil { + msg += fmt.Sprintf("%v;", err) + break + } + } + } + + if msg != "" { + return fmt.Errorf("failed to create jobFlow for: %s", msg) + } + + return nil +} \ No newline at end of file diff --git a/pkg/webhooks/admission/jobflows/validate/util.go b/pkg/webhooks/admission/jobflows/validate/util.go new file mode 100644 index 0000000000..fc0a27c232 --- /dev/null +++ b/pkg/webhooks/admission/jobflows/validate/util.go @@ -0,0 +1,52 @@ +package validate + +import ( + "fmt" +) + +type Vertex struct { + Key string + Parents []*Vertex + Children []*Vertex + Value interface{} +} + +type DAG struct { + Vertexes []*Vertex +} + +func (dag *DAG) AddVertex(v *Vertex) { + dag.Vertexes = append(dag.Vertexes, v) +} + +func (dag *DAG) AddEdge(from, to *Vertex) { + from.Children = append(from.Children, to) + + to.Parents = append(from.Parents, from) +} + +func (dag *DAG) BFS(root *Vertex) error { + var q []*Vertex + + visitMap := make(map[string]bool) + visitMap[root.Key] = true + + q = append(q, root) + + for len(q) > 0 { + current := q[0] + q = q[1:] + + for _, v := range current.Children { + if v.Key == root.Key { + return fmt.Errorf("find bad dependency, please check the dependencies of your templates") + } + if !visitMap[v.Key] { + visitMap[v.Key] = true + q = append(q, v) + } + } + } + + return nil +} diff --git a/pkg/webhooks/admission/jobtemplates/validate/admit_jobtemplate.go b/pkg/webhooks/admission/jobtemplates/validate/admit_jobtemplate.go new file mode 100644 index 0000000000..323db89eb3 --- /dev/null +++ b/pkg/webhooks/admission/jobtemplates/validate/admit_jobtemplate.go @@ -0,0 +1,262 @@ +package validate + +import ( + "fmt" + + admissionv1 "k8s.io/api/admission/v1" + whv1 "k8s.io/api/admissionregistration/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/klog" + k8score "k8s.io/kubernetes/pkg/apis/core" + k8scorev1 "k8s.io/kubernetes/pkg/apis/core/v1" + v1qos "k8s.io/kubernetes/pkg/apis/core/v1/helper/qos" + k8scorevalid "k8s.io/kubernetes/pkg/apis/core/validation" + "volcano.sh/apis/pkg/apis/helpers" + + "volcano.sh/apis/pkg/apis/batch/v1alpha1" + jobflowv1alpha1 "volcano.sh/apis/pkg/apis/flow/v1alpha1" + "volcano.sh/volcano/pkg/webhooks/router" + "volcano.sh/volcano/pkg/webhooks/schema" + "volcano.sh/volcano/pkg/webhooks/util" +) + +func init() { + router.RegisterAdmission(service) +} + +var service = &router.AdmissionService{ + Path: "/jobtemplates/validate", + Func: AdmitJobTemplates, + + Config: config, + + ValidatingConfig: &whv1.ValidatingWebhookConfiguration{ + Webhooks: []whv1.ValidatingWebhook{{ + Name: "validatetemplate.volcano.sh", + Rules: []whv1.RuleWithOperations{ + { + Operations: []whv1.OperationType{whv1.Create}, + Rule: whv1.Rule{ + APIGroups: []string{helpers.JobTemplateKind.Group}, + APIVersions: []string{helpers.JobTemplateKind.Version}, + Resources: []string{"jobtemplates"}, + }, + }, + }, + }}, + }, +} + +var config = &router.AdmissionServiceConfig{} + +// AdmitJobTemplates is to admit jobTemplates and return response. +func AdmitJobTemplates(ar admissionv1.AdmissionReview) *admissionv1.AdmissionResponse { + klog.V(3).Infof("admitting jobtemplates -- %s", ar.Request.Operation) + + jobTemplate, err := schema.DecodeJobTemplate(ar.Request.Object, ar.Request.Resource) + if err != nil { + return util.ToAdmissionResponse(err) + } + + switch ar.Request.Operation { + case admissionv1.Create: + err = validateJobTemplateCreate(jobTemplate) + if err != nil { + return util.ToAdmissionResponse(err) + } + reviewResponse := admissionv1.AdmissionResponse{} + reviewResponse.Allowed = true + return &reviewResponse + default: + return util.ToAdmissionResponse(fmt.Errorf("only support 'CREATE' operation")) + } +} + +func validateJobTemplateCreate(job *jobflowv1alpha1.JobTemplate) error { + klog.V(3).Infof("validate create %s", job.Name) + var msg string + taskNames := map[string]string{} + var totalReplicas int32 + + if job.Spec.MinAvailable < 0 { + return fmt.Errorf("job 'minAvailable' must be >= 0") + } + + if job.Spec.MaxRetry < 0 { + return fmt.Errorf("'maxRetry' cannot be less than zero") + } + + if job.Spec.TTLSecondsAfterFinished != nil && *job.Spec.TTLSecondsAfterFinished < 0 { + return fmt.Errorf("'ttlSecondsAfterFinished' cannot be less than zero") + } + + if len(job.Spec.Tasks) == 0 { + return fmt.Errorf("no task specified in job spec") + } + + for index, task := range job.Spec.Tasks { + if task.Replicas < 0 { + msg += fmt.Sprintf(" 'replicas' < 0 in task: %s;", task.Name) + } + + if task.MinAvailable != nil && *task.MinAvailable > task.Replicas { + msg += fmt.Sprintf(" 'minAvailable' is greater than 'replicas' in task: %s, job: %s", task.Name, job.Name) + } + + // count replicas + totalReplicas += task.Replicas + + // validate task name + if errMsgs := validation.IsDNS1123Label(task.Name); len(errMsgs) > 0 { + msg += fmt.Sprintf(" %v;", errMsgs) + } + + // duplicate task name + if _, found := taskNames[task.Name]; found { + msg += fmt.Sprintf(" duplicated task name %s;", task.Name) + break + } else { + taskNames[task.Name] = task.Name + } + + podName := makePodName(job.Name, task.Name, index) + if err := validateK8sPodNameLength(podName); err != nil { + msg += err.Error() + } + if err := validateTaskTemplate(task, job, index); err != nil { + msg += err.Error() + } + } + + if err := validateJobName(job); err != nil { + msg += err.Error() + } + + if totalReplicas < job.Spec.MinAvailable { + msg += "job 'minAvailable' should not be greater than total replicas in tasks;" + } + + if err := validatePolicies(job.Spec.Policies, field.NewPath("spec.policies")); err != nil { + msg = msg + err.Error() + fmt.Sprintf(" valid events are %v, valid actions are %v;", + GetValidEvents(), GetValidActions()) + } + + if err := validateIO(job.Spec.Volumes); err != nil { + msg += err.Error() + } + + if msg != "" { + return fmt.Errorf(msg) + } + + return nil +} + +func validateTaskTemplate(task v1alpha1.TaskSpec, job *jobflowv1alpha1.JobTemplate, index int) error { + var v1PodTemplate v1.PodTemplate + v1PodTemplate.Template = *task.Template.DeepCopy() + k8scorev1.SetObjectDefaults_PodTemplate(&v1PodTemplate) + + var coreTemplateSpec k8score.PodTemplateSpec + if err := k8scorev1.Convert_v1_PodTemplateSpec_To_core_PodTemplateSpec(&v1PodTemplate.Template, &coreTemplateSpec, nil); err != nil { + return fmt.Errorf("failed to convert v1_PodTemplateSpec to core_PodTemplateSpec") + } + + // Skip verify container SecurityContex.Privileged as it depends on + // the kube-apiserver `allow-privileged` flag. + for i, container := range coreTemplateSpec.Spec.Containers { + if container.SecurityContext != nil && container.SecurityContext.Privileged != nil { + coreTemplateSpec.Spec.Containers[i].SecurityContext.Privileged = nil + } + } + + corePodTemplate := k8score.PodTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: task.Name, + Namespace: job.Namespace, + }, + Template: coreTemplateSpec, + } + + opts := k8scorevalid.PodValidationOptions{} + if allErrs := k8scorevalid.ValidatePodTemplate(&corePodTemplate, opts); len(allErrs) > 0 { + msg := fmt.Sprintf("spec.task[%d].", index) + for index := range allErrs { + msg += allErrs[index].Error() + ". " + } + return fmt.Errorf(msg) + } + + err := validateTaskTopoPolicy(task, index) + if err != nil { + return err + } + + return nil +} + +// MakePodName creates pod name. +func makePodName(jobName string, taskName string, index int) string { + return fmt.Sprintf("%s-%s-%d", jobName, taskName, index) +} +func validateK8sPodNameLength(podName string) error { + if errMsgs := validation.IsQualifiedName(podName); len(errMsgs) > 0 { + return fmt.Errorf(" create pod with name %s validate failed %v", podName, errMsgs) + } + return nil +} +func validateJobName(job *jobflowv1alpha1.JobTemplate) error { + if errMsgs := validation.IsQualifiedName(job.Name); len(errMsgs) > 0 { + return fmt.Errorf(" create job with name %s validate failed %v", job.Name, errMsgs) + } + return nil +} + +func validateTaskTopoPolicy(task v1alpha1.TaskSpec, index int) error { + if task.TopologyPolicy == "" || task.TopologyPolicy == v1alpha1.None { + return nil + } + + template := task.Template.DeepCopy() + + for id, container := range template.Spec.Containers { + if len(container.Resources.Requests) == 0 { + template.Spec.Containers[id].Resources.Requests = container.Resources.Limits.DeepCopy() + } + } + + for id, container := range template.Spec.InitContainers { + if len(container.Resources.Requests) == 0 { + template.Spec.InitContainers[id].Resources.Requests = container.Resources.Limits.DeepCopy() + } + } + + pod := &v1.Pod{ + Spec: template.Spec, + } + + if v1qos.GetPodQOS(pod) != v1.PodQOSGuaranteed { + return fmt.Errorf("spec.task[%d] isn't Guaranteed pod, kind=%v", index, v1qos.GetPodQOS(pod)) + } + + for id, container := range append(template.Spec.Containers, template.Spec.InitContainers...) { + requestNum := guaranteedCPUs(container) + if requestNum == 0 { + return fmt.Errorf("the cpu request isn't an integer in spec.task[%d] container[%d]", index, id) + } + } + + return nil +} + +func guaranteedCPUs(container v1.Container) int { + cpuQuantity := container.Resources.Requests[v1.ResourceCPU] + if cpuQuantity.Value()*1000 != cpuQuantity.MilliValue() { + return 0 + } + + return int(cpuQuantity.Value()) +} \ No newline at end of file diff --git a/pkg/webhooks/admission/jobtemplates/validate/util.go b/pkg/webhooks/admission/jobtemplates/validate/util.go new file mode 100644 index 0000000000..8a7d633357 --- /dev/null +++ b/pkg/webhooks/admission/jobtemplates/validate/util.go @@ -0,0 +1,173 @@ +package validate + +import ( + "fmt" + + "github.com/hashicorp/go-multierror" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/kubernetes/pkg/apis/core/validation" + batchv1alpha1 "volcano.sh/apis/pkg/apis/batch/v1alpha1" + busv1alpha1 "volcano.sh/apis/pkg/apis/bus/v1alpha1" +) + +// policyEventMap defines all policy events and whether to allow external use. +var policyEventMap = map[busv1alpha1.Event]bool{ + busv1alpha1.AnyEvent: true, + busv1alpha1.PodFailedEvent: true, + busv1alpha1.PodEvictedEvent: true, + busv1alpha1.JobUnknownEvent: true, + busv1alpha1.TaskCompletedEvent: true, + busv1alpha1.TaskFailedEvent: true, + busv1alpha1.OutOfSyncEvent: false, + busv1alpha1.CommandIssuedEvent: false, + busv1alpha1.JobUpdatedEvent: true, +} + +// policyActionMap defines all policy actions and whether to allow external use. +var policyActionMap = map[busv1alpha1.Action]bool{ + busv1alpha1.AbortJobAction: true, + busv1alpha1.RestartJobAction: true, + busv1alpha1.RestartTaskAction: true, + busv1alpha1.TerminateJobAction: true, + busv1alpha1.CompleteJobAction: true, + busv1alpha1.ResumeJobAction: true, + busv1alpha1.SyncJobAction: false, + busv1alpha1.EnqueueAction: false, + busv1alpha1.SyncQueueAction: false, + busv1alpha1.OpenQueueAction: false, + busv1alpha1.CloseQueueAction: false, +} + +func validatePolicies(policies []batchv1alpha1.LifecyclePolicy, fldPath *field.Path) error { + var err error + policyEvents := map[busv1alpha1.Event]struct{}{} + exitCodes := map[int32]struct{}{} + + for _, policy := range policies { + if (policy.Event != "" || len(policy.Events) != 0) && policy.ExitCode != nil { + err = multierror.Append(err, fmt.Errorf("must not specify event and exitCode simultaneously")) + break + } + + if policy.Event == "" && len(policy.Events) == 0 && policy.ExitCode == nil { + err = multierror.Append(err, fmt.Errorf("either event and exitCode should be specified")) + break + } + + if len(policy.Event) != 0 || len(policy.Events) != 0 { + bFlag := false + policyEventsList := getEventList(policy) + for _, event := range policyEventsList { + if allow, ok := policyEventMap[event]; !ok || !allow { + err = multierror.Append(err, field.Invalid(fldPath, event, "invalid policy event")) + bFlag = true + break + } + + if allow, ok := policyActionMap[policy.Action]; !ok || !allow { + err = multierror.Append(err, field.Invalid(fldPath, policy.Action, "invalid policy action")) + bFlag = true + break + } + if _, found := policyEvents[event]; found { + err = multierror.Append(err, fmt.Errorf("duplicate event %v across different policy", event)) + bFlag = true + break + } else { + policyEvents[event] = struct{}{} + } + } + if bFlag { + break + } + } else { + if *policy.ExitCode == 0 { + err = multierror.Append(err, fmt.Errorf("0 is not a valid error code")) + break + } + if _, found := exitCodes[*policy.ExitCode]; found { + err = multierror.Append(err, fmt.Errorf("duplicate exitCode %v", *policy.ExitCode)) + break + } else { + exitCodes[*policy.ExitCode] = struct{}{} + } + } + } + + if _, found := policyEvents[busv1alpha1.AnyEvent]; found && len(policyEvents) > 1 { + err = multierror.Append(err, fmt.Errorf("if there's * here, no other policy should be here")) + } + + return err +} + +func getEventList(policy batchv1alpha1.LifecyclePolicy) []busv1alpha1.Event { + policyEventsList := policy.Events + if len(policy.Event) > 0 { + policyEventsList = append(policyEventsList, policy.Event) + } + uniquePolicyEventlist := removeDuplicates(policyEventsList) + return uniquePolicyEventlist +} + +func removeDuplicates(eventList []busv1alpha1.Event) []busv1alpha1.Event { + keys := make(map[busv1alpha1.Event]bool) + list := []busv1alpha1.Event{} + for _, val := range eventList { + if _, value := keys[val]; !value { + keys[val] = true + list = append(list, val) + } + } + return list +} + +func GetValidEvents() []busv1alpha1.Event { + var events []busv1alpha1.Event + for e, allow := range policyEventMap { + if allow { + events = append(events, e) + } + } + + return events +} + +func GetValidActions() []busv1alpha1.Action { + var actions []busv1alpha1.Action + for a, allow := range policyActionMap { + if allow { + actions = append(actions, a) + } + } + + return actions +} + +// validateIO validates IO configuration. +func validateIO(volumes []batchv1alpha1.VolumeSpec) error { + volumeMap := map[string]bool{} + for _, volume := range volumes { + if len(volume.MountPath) == 0 { + return fmt.Errorf(" mountPath is required;") + } + if _, found := volumeMap[volume.MountPath]; found { + return fmt.Errorf(" duplicated mountPath: %s;", volume.MountPath) + } + if volume.VolumeClaim == nil && volume.VolumeClaimName == "" { + return fmt.Errorf(" either VolumeClaim or VolumeClaimName must be specified;") + } + if len(volume.VolumeClaimName) != 0 { + if volume.VolumeClaim != nil { + return fmt.Errorf("conflict: If you want to use an existing PVC, just specify VolumeClaimName." + + "If you want to create a new PVC, you do not need to specify VolumeClaimName") + } + if errMsgs := validation.ValidatePersistentVolumeName(volume.VolumeClaimName, false); len(errMsgs) > 0 { + return fmt.Errorf("invalid VolumeClaimName %s : %v", volume.VolumeClaimName, errMsgs) + } + } + + volumeMap[volume.MountPath] = true + } + return nil +} \ No newline at end of file diff --git a/pkg/webhooks/schema/schema.go b/pkg/webhooks/schema/schema.go index 8c1427d74c..6f6463a1df 100644 --- a/pkg/webhooks/schema/schema.go +++ b/pkg/webhooks/schema/schema.go @@ -29,6 +29,8 @@ import ( corev1 "k8s.io/kubernetes/pkg/apis/core/v1" batchv1alpha1 "volcano.sh/apis/pkg/apis/batch/v1alpha1" + jobflowv1alpha1 "volcano.sh/apis/pkg/apis/flow/v1alpha1" + "volcano.sh/apis/pkg/apis/helpers" schedulingv1beta1 "volcano.sh/apis/pkg/apis/scheduling/v1beta1" ) @@ -127,3 +129,43 @@ func DecodePodGroup(object runtime.RawExtension, resource metav1.GroupVersionRes return &podgroup, nil } + +// DecodeJobFlow decodes the jobFlow using deserializer from the raw object. +func DecodeJobFlow(object runtime.RawExtension, resource metav1.GroupVersionResource) (*jobflowv1alpha1.JobFlow, error) { + jobFlowResource := metav1.GroupVersionResource{Group: helpers.JobFlowKind.Group, Version: helpers.JobFlowKind.Version, Resource: "jobflows"} + raw := object.Raw + jobFlow := jobflowv1alpha1.JobFlow{} + + if resource != jobFlowResource { + err := fmt.Errorf("expect resource to be %s", jobFlowResource) + return &jobFlow, err + } + + deserializer := Codecs.UniversalDeserializer() + if _, _, err := deserializer.Decode(raw, nil, &jobFlow); err != nil { + return &jobFlow, err + } + klog.V(3).Infof("the jobFlow struct is %+v", jobFlow) + + return &jobFlow, nil +} + +// DecodeJobTemplate decodes the jobTemplate using deserializer from the raw object. +func DecodeJobTemplate(object runtime.RawExtension, resource metav1.GroupVersionResource) (*jobflowv1alpha1.JobTemplate, error) { + jobTemplateResource := metav1.GroupVersionResource{Group: helpers.JobTemplateKind.Group, Version: helpers.JobTemplateKind.Version, Resource: "jobtemplates"} + raw := object.Raw + jobTemplate := jobflowv1alpha1.JobTemplate{} + + if resource != jobTemplateResource { + err := fmt.Errorf("expect resource to be %s", jobTemplateResource) + return &jobTemplate, err + } + + deserializer := Codecs.UniversalDeserializer() + if _, _, err := deserializer.Decode(raw, nil, &jobTemplate); err != nil { + return &jobTemplate, err + } + klog.V(3).Infof("the jobTemplate struct is %+v", jobTemplate) + + return &jobTemplate, nil +} From f251581405740b418028c0f12a15a1eea17af22d Mon Sep 17 00:00:00 2001 From: LX Date: Fri, 16 Aug 2024 03:28:15 +0000 Subject: [PATCH 2/2] add kind/question label for question issue Signed-off-by: LX --- .github/ISSUE_TEMPLATE/question.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/question.yaml b/.github/ISSUE_TEMPLATE/question.yaml index 37a84fc91f..443d039100 100644 --- a/.github/ISSUE_TEMPLATE/question.yaml +++ b/.github/ISSUE_TEMPLATE/question.yaml @@ -1,5 +1,6 @@ name: Question description: Have a question about volcano +labels: kind/question body: - type: textarea attributes: