/* Copyright 2016 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 validation import ( _ "time/tzdata" "fmt" "strings" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/kubernetes/pkg/apis/batch" api "k8s.io/kubernetes/pkg/apis/core" corevalidation "k8s.io/kubernetes/pkg/apis/core/validation" "k8s.io/utils/pointer" ) var ( timeZoneEmpty = "" timeZoneLocal = "LOCAL" timeZoneUTC = "UTC" timeZoneCorrect = "Europe/Rome" timeZoneBadPrefix = " Europe/Rome" timeZoneBadSuffix = "Europe/Rome " timeZoneBadName = "Europe/InvalidRome" timeZoneEmptySpace = " " ) var ignoreErrValueDetail = cmpopts.IgnoreFields(field.Error{}, "BadValue", "Detail") func getValidManualSelector() *metav1.LabelSelector { return &metav1.LabelSelector{ MatchLabels: map[string]string{"a": "b"}, } } func getValidPodTemplateSpecForManual(selector *metav1.LabelSelector) api.PodTemplateSpec { return api.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: selector.MatchLabels, }, Spec: api.PodSpec{ RestartPolicy: api.RestartPolicyOnFailure, DNSPolicy: api.DNSClusterFirst, Containers: []api.Container{{Name: "abc", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: api.TerminationMessageReadFile}}, }, } } func getValidGeneratedSelector() *metav1.LabelSelector { return &metav1.LabelSelector{ MatchLabels: map[string]string{"controller-uid": "1a2b3c", "job-name": "myjob"}, } } func getValidPodTemplateSpecForGenerated(selector *metav1.LabelSelector) api.PodTemplateSpec { return api.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: selector.MatchLabels, }, Spec: api.PodSpec{ RestartPolicy: api.RestartPolicyOnFailure, DNSPolicy: api.DNSClusterFirst, Containers: []api.Container{{Name: "abc", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: api.TerminationMessageReadFile}}, InitContainers: []api.Container{{Name: "def", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: api.TerminationMessageReadFile}}, }, } } func TestValidateJob(t *testing.T) { validJobObjectMeta := metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), } validManualSelector := getValidManualSelector() validPodTemplateSpecForManual := getValidPodTemplateSpecForManual(validManualSelector) validGeneratedSelector := getValidGeneratedSelector() validPodTemplateSpecForGenerated := getValidPodTemplateSpecForGenerated(validGeneratedSelector) validPodTemplateSpecForGeneratedRestartPolicyNever := getValidPodTemplateSpecForGenerated(validGeneratedSelector) validPodTemplateSpecForGeneratedRestartPolicyNever.Spec.RestartPolicy = api.RestartPolicyNever successCases := map[string]struct { opts JobValidationOptions job batch.Job }{ "valid pod failure policy": { job: batch.Job{ ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionIgnore, OnPodConditions: []batch.PodFailurePolicyOnPodConditionsPattern{ { Type: api.DisruptionTarget, Status: api.ConditionTrue, }, }, }, { Action: batch.PodFailurePolicyActionFailJob, OnPodConditions: []batch.PodFailurePolicyOnPodConditionsPattern{ { Type: api.PodConditionType("CustomConditionType"), Status: api.ConditionFalse, }, }, }, { Action: batch.PodFailurePolicyActionCount, OnExitCodes: &batch.PodFailurePolicyOnExitCodesRequirement{ ContainerName: pointer.String("abc"), Operator: batch.PodFailurePolicyOnExitCodesOpIn, Values: []int32{1, 2, 3}, }, }, { Action: batch.PodFailurePolicyActionIgnore, OnExitCodes: &batch.PodFailurePolicyOnExitCodesRequirement{ ContainerName: pointer.String("def"), Operator: batch.PodFailurePolicyOnExitCodesOpIn, Values: []int32{4}, }, }, { Action: batch.PodFailurePolicyActionFailJob, OnExitCodes: &batch.PodFailurePolicyOnExitCodesRequirement{ Operator: batch.PodFailurePolicyOnExitCodesOpNotIn, Values: []int32{5, 6, 7}, }, }, }, }, }, }, }, "valid manual selector": { job: batch.Job{ ObjectMeta: metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), Annotations: map[string]string{"foo": "bar"}, }, Spec: batch.JobSpec{ Selector: validManualSelector, ManualSelector: pointer.BoolPtr(true), Template: validPodTemplateSpecForManual, }, }, }, "valid generated selector": { job: batch.Job{ ObjectMeta: metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, }, "valid NonIndexed completion mode": { job: batch.Job{ ObjectMeta: metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, CompletionMode: completionModePtr(batch.NonIndexedCompletion), }, }, }, "valid Indexed completion mode": { job: batch.Job{ ObjectMeta: metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, CompletionMode: completionModePtr(batch.IndexedCompletion), Completions: pointer.Int32Ptr(2), Parallelism: pointer.Int32Ptr(100000), }, }, }, "valid job tracking annotation": { opts: JobValidationOptions{ AllowTrackingAnnotation: true, }, job: batch.Job{ ObjectMeta: metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), Annotations: map[string]string{ batch.JobTrackingFinalizer: "", }, }, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, }, } for k, v := range successCases { t.Run(k, func(t *testing.T) { if errs := ValidateJob(&v.job, v.opts); len(errs) != 0 { t.Errorf("Got unexpected validation errors: %v", errs) } }) } negative := int32(-1) negative64 := int64(-1) errorCases := map[string]batch.Job{ `spec.podFailurePolicy.rules[0]: Invalid value: specifying one of OnExitCodes and OnPodConditions is required`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionFailJob, }, }, }, }, }, `spec.podFailurePolicy.rules[0].onExitCodes.values[1]: Duplicate value: 11`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionFailJob, OnExitCodes: &batch.PodFailurePolicyOnExitCodesRequirement{ Operator: batch.PodFailurePolicyOnExitCodesOpIn, Values: []int32{11, 11}, }, }, }, }, }, }, `spec.podFailurePolicy.rules[0].onExitCodes.values: Too many: 256: must have at most 255 items`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionFailJob, OnExitCodes: &batch.PodFailurePolicyOnExitCodesRequirement{ Operator: batch.PodFailurePolicyOnExitCodesOpIn, Values: func() (values []int32) { tooManyValues := make([]int32, maxPodFailurePolicyOnExitCodesValues+1) for i := range tooManyValues { tooManyValues[i] = int32(i) } return tooManyValues }(), }, }, }, }, }, }, `spec.podFailurePolicy.rules: Too many: 21: must have at most 20 items`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: func() []batch.PodFailurePolicyRule { tooManyRules := make([]batch.PodFailurePolicyRule, maxPodFailurePolicyRules+1) for i := range tooManyRules { tooManyRules[i] = batch.PodFailurePolicyRule{ Action: batch.PodFailurePolicyActionFailJob, OnExitCodes: &batch.PodFailurePolicyOnExitCodesRequirement{ Operator: batch.PodFailurePolicyOnExitCodesOpIn, Values: []int32{int32(i + 1)}, }, } } return tooManyRules }(), }, }, }, `spec.podFailurePolicy.rules[0].onPodConditions: Too many: 21: must have at most 20 items`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionFailJob, OnPodConditions: func() []batch.PodFailurePolicyOnPodConditionsPattern { tooManyPatterns := make([]batch.PodFailurePolicyOnPodConditionsPattern, maxPodFailurePolicyOnPodConditionsPatterns+1) for i := range tooManyPatterns { tooManyPatterns[i] = batch.PodFailurePolicyOnPodConditionsPattern{ Type: api.PodConditionType(fmt.Sprintf("CustomType_%d", i)), Status: api.ConditionTrue, } } return tooManyPatterns }(), }, }, }, }, }, `spec.podFailurePolicy.rules[0].onExitCodes.values[2]: Duplicate value: 13`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionFailJob, OnExitCodes: &batch.PodFailurePolicyOnExitCodesRequirement{ Operator: batch.PodFailurePolicyOnExitCodesOpIn, Values: []int32{12, 13, 13, 13}, }, }, }, }, }, }, `spec.podFailurePolicy.rules[0].onExitCodes.values: Invalid value: []int32{19, 11}: must be ordered`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionFailJob, OnExitCodes: &batch.PodFailurePolicyOnExitCodesRequirement{ Operator: batch.PodFailurePolicyOnExitCodesOpIn, Values: []int32{19, 11}, }, }, }, }, }, }, `spec.podFailurePolicy.rules[0].onExitCodes.values: Invalid value: []int32{}: at least one value is required`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionFailJob, OnExitCodes: &batch.PodFailurePolicyOnExitCodesRequirement{ Operator: batch.PodFailurePolicyOnExitCodesOpIn, Values: []int32{}, }, }, }, }, }, }, `spec.podFailurePolicy.rules[0].action: Required value: valid values: ["Count" "FailJob" "Ignore"]`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: "", OnExitCodes: &batch.PodFailurePolicyOnExitCodesRequirement{ Operator: batch.PodFailurePolicyOnExitCodesOpIn, Values: []int32{1, 2, 3}, }, }, }, }, }, }, `spec.podFailurePolicy.rules[0].onExitCodes.operator: Required value: valid values: ["In" "NotIn"]`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionFailJob, OnExitCodes: &batch.PodFailurePolicyOnExitCodesRequirement{ Operator: "", Values: []int32{1, 2, 3}, }, }, }, }, }, }, `spec.podFailurePolicy.rules[0]: Invalid value: specifying both OnExitCodes and OnPodConditions is not supported`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionFailJob, OnExitCodes: &batch.PodFailurePolicyOnExitCodesRequirement{ ContainerName: pointer.String("abc"), Operator: batch.PodFailurePolicyOnExitCodesOpIn, Values: []int32{1, 2, 3}, }, OnPodConditions: []batch.PodFailurePolicyOnPodConditionsPattern{ { Type: api.DisruptionTarget, Status: api.ConditionTrue, }, }, }, }, }, }, }, `spec.podFailurePolicy.rules[0].onExitCodes.values[1]: Invalid value: 0: must not be 0 for the In operator`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionIgnore, OnExitCodes: &batch.PodFailurePolicyOnExitCodesRequirement{ Operator: batch.PodFailurePolicyOnExitCodesOpIn, Values: []int32{1, 0, 2}, }, }, }, }, }, }, `spec.podFailurePolicy.rules[1].onExitCodes.containerName: Invalid value: "xyz": must be one of the container or initContainer names in the pod template`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionIgnore, OnExitCodes: &batch.PodFailurePolicyOnExitCodesRequirement{ ContainerName: pointer.String("abc"), Operator: batch.PodFailurePolicyOnExitCodesOpIn, Values: []int32{1, 2, 3}, }, }, { Action: batch.PodFailurePolicyActionFailJob, OnExitCodes: &batch.PodFailurePolicyOnExitCodesRequirement{ ContainerName: pointer.String("xyz"), Operator: batch.PodFailurePolicyOnExitCodesOpIn, Values: []int32{5, 6, 7}, }, }, }, }, }, }, `spec.podFailurePolicy.rules[0].action: Unsupported value: "UnknownAction": supported values: "Count", "FailJob", "Ignore"`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: "UnknownAction", OnExitCodes: &batch.PodFailurePolicyOnExitCodesRequirement{ ContainerName: pointer.String("abc"), Operator: batch.PodFailurePolicyOnExitCodesOpIn, Values: []int32{1, 2, 3}, }, }, }, }, }, }, `spec.podFailurePolicy.rules[0].onExitCodes.operator: Unsupported value: "UnknownOperator": supported values: "In", "NotIn"`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionIgnore, OnExitCodes: &batch.PodFailurePolicyOnExitCodesRequirement{ Operator: "UnknownOperator", Values: []int32{1, 2, 3}, }, }, }, }, }, }, `spec.podFailurePolicy.rules[0].onPodConditions[0].status: Required value: valid values: ["False" "True" "Unknown"]`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionIgnore, OnPodConditions: []batch.PodFailurePolicyOnPodConditionsPattern{ { Type: api.DisruptionTarget, }, }, }, }, }, }, }, `spec.podFailurePolicy.rules[0].onPodConditions[0].status: Unsupported value: "UnknownStatus": supported values: "False", "True", "Unknown"`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionIgnore, OnPodConditions: []batch.PodFailurePolicyOnPodConditionsPattern{ { Type: api.DisruptionTarget, Status: "UnknownStatus", }, }, }, }, }, }, }, `spec.podFailurePolicy.rules[0].onPodConditions[0].type: Invalid value: "": name part must be non-empty`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionIgnore, OnPodConditions: []batch.PodFailurePolicyOnPodConditionsPattern{ { Status: api.ConditionTrue, }, }, }, }, }, }, }, `spec.podFailurePolicy.rules[0].onPodConditions[0].type: Invalid value: "Invalid Condition Type": name part must consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyName', or 'my.name', or '123-abc', regex used for validation is '([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]')`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionIgnore, OnPodConditions: []batch.PodFailurePolicyOnPodConditionsPattern{ { Type: api.PodConditionType("Invalid Condition Type"), Status: api.ConditionTrue, }, }, }, }, }, }, }, `spec.template.spec.restartPolicy: Invalid value: "OnFailure": only "Never" is supported when podFailurePolicy is specified`: { ObjectMeta: validJobObjectMeta, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: api.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: validGeneratedSelector.MatchLabels, }, Spec: api.PodSpec{ RestartPolicy: api.RestartPolicyOnFailure, DNSPolicy: api.DNSClusterFirst, Containers: []api.Container{{Name: "abc", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: api.TerminationMessageReadFile}}, }, }, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{}, }, }, }, "spec.parallelism:must be greater than or equal to 0": { ObjectMeta: metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.JobSpec{ Parallelism: &negative, Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, "spec.backoffLimit:must be greater than or equal to 0": { ObjectMeta: metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.JobSpec{ BackoffLimit: &negative, Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, "spec.completions:must be greater than or equal to 0": { ObjectMeta: metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.JobSpec{ Completions: &negative, Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, "spec.activeDeadlineSeconds:must be greater than or equal to 0": { ObjectMeta: metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.JobSpec{ ActiveDeadlineSeconds: &negative64, Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, "spec.selector:Required value": { ObjectMeta: metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.JobSpec{ Template: validPodTemplateSpecForGenerated, }, }, "spec.template.metadata.labels: Invalid value: map[string]string{\"y\":\"z\"}: `selector` does not match template `labels`": { ObjectMeta: metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.JobSpec{ Selector: validManualSelector, ManualSelector: pointer.BoolPtr(true), Template: api.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: map[string]string{"y": "z"}, }, Spec: api.PodSpec{ RestartPolicy: api.RestartPolicyOnFailure, DNSPolicy: api.DNSClusterFirst, Containers: []api.Container{{Name: "abc", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: api.TerminationMessageReadFile}}, }, }, }, }, "spec.template.metadata.labels: Invalid value: map[string]string{\"controller-uid\":\"4d5e6f\"}: `selector` does not match template `labels`": { ObjectMeta: metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.JobSpec{ Selector: validManualSelector, ManualSelector: pointer.BoolPtr(true), Template: api.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: map[string]string{"controller-uid": "4d5e6f"}, }, Spec: api.PodSpec{ RestartPolicy: api.RestartPolicyOnFailure, DNSPolicy: api.DNSClusterFirst, Containers: []api.Container{{Name: "abc", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: api.TerminationMessageReadFile}}, }, }, }, }, "spec.template.spec.restartPolicy: Required value": { ObjectMeta: metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.JobSpec{ Selector: validManualSelector, ManualSelector: pointer.BoolPtr(true), Template: api.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: validManualSelector.MatchLabels, }, Spec: api.PodSpec{ RestartPolicy: api.RestartPolicyAlways, DNSPolicy: api.DNSClusterFirst, Containers: []api.Container{{Name: "abc", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: api.TerminationMessageReadFile}}, }, }, }, }, "spec.template.spec.restartPolicy: Unsupported value": { ObjectMeta: metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.JobSpec{ Selector: validManualSelector, ManualSelector: pointer.BoolPtr(true), Template: api.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: validManualSelector.MatchLabels, }, Spec: api.PodSpec{ RestartPolicy: "Invalid", DNSPolicy: api.DNSClusterFirst, Containers: []api.Container{{Name: "abc", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: api.TerminationMessageReadFile}}, }, }, }, }, "spec.ttlSecondsAfterFinished: must be greater than or equal to 0": { ObjectMeta: metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.JobSpec{ TTLSecondsAfterFinished: &negative, Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, "spec.completions: Required value: when completion mode is Indexed": { ObjectMeta: metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, CompletionMode: completionModePtr(batch.IndexedCompletion), }, }, "spec.parallelism: must be less than or equal to 100000 when completion mode is Indexed": { ObjectMeta: metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, CompletionMode: completionModePtr(batch.IndexedCompletion), Completions: pointer.Int32Ptr(2), Parallelism: pointer.Int32Ptr(100001), }, }, "metadata.annotations[batch.kubernetes.io/job-tracking]: cannot add this annotation": { ObjectMeta: metav1.ObjectMeta{ Name: "myjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), Annotations: map[string]string{ batch.JobTrackingFinalizer: "", }, }, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, } for k, v := range errorCases { t.Run(k, func(t *testing.T) { errs := ValidateJob(&v, JobValidationOptions{}) if len(errs) == 0 { t.Errorf("expected failure for %s", k) } else { s := strings.SplitN(k, ":", 2) err := errs[0] if err.Field != s[0] || !strings.Contains(err.Error(), s[1]) { t.Errorf("unexpected error: %v, expected: %s", err, k) } } }) } } func TestValidateJobUpdate(t *testing.T) { validGeneratedSelector := getValidGeneratedSelector() validPodTemplateSpecForGenerated := getValidPodTemplateSpecForGenerated(validGeneratedSelector) validPodTemplateSpecForGeneratedRestartPolicyNever := getValidPodTemplateSpecForGenerated(validGeneratedSelector) validPodTemplateSpecForGeneratedRestartPolicyNever.Spec.RestartPolicy = api.RestartPolicyNever validNodeAffinity := &api.Affinity{ NodeAffinity: &api.NodeAffinity{ RequiredDuringSchedulingIgnoredDuringExecution: &api.NodeSelector{ NodeSelectorTerms: []api.NodeSelectorTerm{ { MatchExpressions: []api.NodeSelectorRequirement{ { Key: "foo", Operator: api.NodeSelectorOpIn, Values: []string{"bar", "value2"}, }, }, }, }, }, }, } validPodTemplateWithAffinity := getValidPodTemplateSpecForGenerated(validGeneratedSelector) validPodTemplateWithAffinity.Spec.Affinity = &api.Affinity{ NodeAffinity: &api.NodeAffinity{ RequiredDuringSchedulingIgnoredDuringExecution: &api.NodeSelector{ NodeSelectorTerms: []api.NodeSelectorTerm{ { MatchExpressions: []api.NodeSelectorRequirement{ { Key: "foo", Operator: api.NodeSelectorOpIn, Values: []string{"bar", "value"}, }, }, }, }, }, }, } // This is to test immutability of the selector, both the new and old // selector should match the labels in the template, which is immutable // on its own; therfore, the only way to test selector immutability is // when the new selector is changed but still matches the existing labels. newSelector := getValidGeneratedSelector() newSelector.MatchLabels["foo"] = "bar" validTolerations := []api.Toleration{{ Key: "foo", Operator: api.TolerationOpEqual, Value: "bar", Effect: api.TaintEffectPreferNoSchedule, }} cases := map[string]struct { old batch.Job update func(*batch.Job) opts JobValidationOptions err *field.Error }{ "mutable fields": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, Parallelism: pointer.Int32Ptr(5), ActiveDeadlineSeconds: pointer.Int64Ptr(2), TTLSecondsAfterFinished: pointer.Int32Ptr(1), }, }, update: func(job *batch.Job) { job.Spec.Parallelism = pointer.Int32Ptr(2) job.Spec.ActiveDeadlineSeconds = pointer.Int64Ptr(3) job.Spec.TTLSecondsAfterFinished = pointer.Int32Ptr(2) job.Spec.ManualSelector = pointer.BoolPtr(true) }, }, "immutable completions for non-indexed jobs": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, update: func(job *batch.Job) { job.Spec.Completions = pointer.Int32Ptr(1) }, err: &field.Error{ Type: field.ErrorTypeInvalid, Field: "spec.completions", }, }, "immutable completions for indexed job when AllowElasticIndexedJobs is false": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, update: func(job *batch.Job) { job.Spec.Completions = pointer.Int32Ptr(1) }, err: &field.Error{ Type: field.ErrorTypeInvalid, Field: "spec.completions", }, }, "immutable selector": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: getValidPodTemplateSpecForGenerated(newSelector), }, }, update: func(job *batch.Job) { job.Spec.Selector = newSelector }, err: &field.Error{ Type: field.ErrorTypeInvalid, Field: "spec.selector", }, }, "add pod failure policy": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, }, }, update: func(job *batch.Job) { job.Spec.PodFailurePolicy = &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionIgnore, OnPodConditions: []batch.PodFailurePolicyOnPodConditionsPattern{ { Type: api.DisruptionTarget, Status: api.ConditionTrue, }, }, }, }, } }, err: &field.Error{ Type: field.ErrorTypeInvalid, Field: "spec.podFailurePolicy", }, }, "remove pod failure policy": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionIgnore, OnPodConditions: []batch.PodFailurePolicyOnPodConditionsPattern{ { Type: api.DisruptionTarget, Status: api.ConditionTrue, }, }, }, }, }, }, }, update: func(job *batch.Job) { job.Spec.PodFailurePolicy.Rules = append(job.Spec.PodFailurePolicy.Rules, batch.PodFailurePolicyRule{ Action: batch.PodFailurePolicyActionCount, OnPodConditions: []batch.PodFailurePolicyOnPodConditionsPattern{ { Type: api.DisruptionTarget, Status: api.ConditionTrue, }, }, }) }, err: &field.Error{ Type: field.ErrorTypeInvalid, Field: "spec.podFailurePolicy", }, }, "update pod failure policy": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGeneratedRestartPolicyNever, PodFailurePolicy: &batch.PodFailurePolicy{ Rules: []batch.PodFailurePolicyRule{ { Action: batch.PodFailurePolicyActionIgnore, OnPodConditions: []batch.PodFailurePolicyOnPodConditionsPattern{ { Type: api.DisruptionTarget, Status: api.ConditionTrue, }, }, }, }, }, }, }, update: func(job *batch.Job) { job.Spec.PodFailurePolicy = nil }, err: &field.Error{ Type: field.ErrorTypeInvalid, Field: "spec.podFailurePolicy", }, }, "immutable pod template": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, update: func(job *batch.Job) { job.Spec.Template.Spec.DNSPolicy = api.DNSClusterFirstWithHostNet }, err: &field.Error{ Type: field.ErrorTypeInvalid, Field: "spec.template", }, }, "immutable completion mode": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, CompletionMode: completionModePtr(batch.IndexedCompletion), Completions: pointer.Int32Ptr(2), }, }, update: func(job *batch.Job) { job.Spec.CompletionMode = completionModePtr(batch.NonIndexedCompletion) }, err: &field.Error{ Type: field.ErrorTypeInvalid, Field: "spec.completionMode", }, }, "Completions but not indexed jobs": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, CompletionMode: completionModePtr(batch.NonIndexedCompletion), Completions: pointer.Int32Ptr(2), }, }, update: func(job *batch.Job) { job.Spec.Completions = pointer.Int32Ptr(4) }, err: &field.Error{ Type: field.ErrorTypeInvalid, Field: "spec.completions", }, opts: JobValidationOptions{AllowElasticIndexedJobs: true}, }, "immutable node affinity": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, update: func(job *batch.Job) { job.Spec.Template.Spec.Affinity = validNodeAffinity }, err: &field.Error{ Type: field.ErrorTypeInvalid, Field: "spec.template", }, }, "add node affinity": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, update: func(job *batch.Job) { job.Spec.Template.Spec.Affinity = validNodeAffinity }, opts: JobValidationOptions{ AllowMutableSchedulingDirectives: true, }, }, "update node affinity": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateWithAffinity, }, }, update: func(job *batch.Job) { job.Spec.Template.Spec.Affinity = validNodeAffinity }, opts: JobValidationOptions{ AllowMutableSchedulingDirectives: true, }, }, "remove node affinity": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateWithAffinity, }, }, update: func(job *batch.Job) { job.Spec.Template.Spec.Affinity.NodeAffinity = nil }, opts: JobValidationOptions{ AllowMutableSchedulingDirectives: true, }, }, "remove affinity": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateWithAffinity, }, }, update: func(job *batch.Job) { job.Spec.Template.Spec.Affinity = nil }, opts: JobValidationOptions{ AllowMutableSchedulingDirectives: true, }, }, "immutable tolerations": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, update: func(job *batch.Job) { job.Spec.Template.Spec.Tolerations = validTolerations }, err: &field.Error{ Type: field.ErrorTypeInvalid, Field: "spec.template", }, }, "mutable tolerations": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, update: func(job *batch.Job) { job.Spec.Template.Spec.Tolerations = validTolerations }, opts: JobValidationOptions{ AllowMutableSchedulingDirectives: true, }, }, "immutable node selector": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, update: func(job *batch.Job) { job.Spec.Template.Spec.NodeSelector = map[string]string{"foo": "bar"} }, err: &field.Error{ Type: field.ErrorTypeInvalid, Field: "spec.template", }, }, "mutable node selector": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, update: func(job *batch.Job) { job.Spec.Template.Spec.NodeSelector = map[string]string{"foo": "bar"} }, opts: JobValidationOptions{ AllowMutableSchedulingDirectives: true, }, }, "immutable annotations": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, update: func(job *batch.Job) { job.Spec.Template.Annotations = map[string]string{"foo": "baz"} }, err: &field.Error{ Type: field.ErrorTypeInvalid, Field: "spec.template", }, }, "mutable annotations": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, update: func(job *batch.Job) { job.Spec.Template.Annotations = map[string]string{"foo": "baz"} }, opts: JobValidationOptions{ AllowMutableSchedulingDirectives: true, }, }, "immutable labels": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, update: func(job *batch.Job) { newLabels := getValidGeneratedSelector().MatchLabels newLabels["bar"] = "baz" job.Spec.Template.Labels = newLabels }, err: &field.Error{ Type: field.ErrorTypeInvalid, Field: "spec.template", }, }, "mutable labels": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, update: func(job *batch.Job) { newLabels := getValidGeneratedSelector().MatchLabels newLabels["bar"] = "baz" job.Spec.Template.Labels = newLabels }, opts: JobValidationOptions{ AllowMutableSchedulingDirectives: true, }, }, "immutable schedulingGates": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, update: func(job *batch.Job) { job.Spec.Template.Spec.SchedulingGates = append(job.Spec.Template.Spec.SchedulingGates, api.PodSchedulingGate{Name: "gate"}) }, err: &field.Error{ Type: field.ErrorTypeInvalid, Field: "spec.template", }, }, "mutable schedulingGates": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, }, }, update: func(job *batch.Job) { job.Spec.Template.Spec.SchedulingGates = append(job.Spec.Template.Spec.SchedulingGates, api.PodSchedulingGate{Name: "gate"}) }, opts: JobValidationOptions{ AllowMutableSchedulingDirectives: true, }, }, "update completions and parallelism to same value is valid": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, Completions: pointer.Int32Ptr(1), Parallelism: pointer.Int32Ptr(1), CompletionMode: completionModePtr(batch.IndexedCompletion), }, }, update: func(job *batch.Job) { job.Spec.Completions = pointer.Int32Ptr(2) job.Spec.Parallelism = pointer.Int32Ptr(2) }, opts: JobValidationOptions{ AllowElasticIndexedJobs: true, }, }, "previous parallelism != previous completions, new parallelism == new completions": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, Completions: pointer.Int32Ptr(1), Parallelism: pointer.Int32Ptr(2), CompletionMode: completionModePtr(batch.IndexedCompletion), }, }, update: func(job *batch.Job) { job.Spec.Completions = pointer.Int32Ptr(3) job.Spec.Parallelism = pointer.Int32Ptr(3) }, opts: JobValidationOptions{ AllowElasticIndexedJobs: true, }, }, "indexed job updating completions and parallelism to different values is invalid": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, Completions: pointer.Int32Ptr(1), Parallelism: pointer.Int32Ptr(1), CompletionMode: completionModePtr(batch.IndexedCompletion), }, }, update: func(job *batch.Job) { job.Spec.Completions = pointer.Int32Ptr(2) job.Spec.Parallelism = pointer.Int32Ptr(3) }, opts: JobValidationOptions{ AllowElasticIndexedJobs: true, }, err: &field.Error{ Type: field.ErrorTypeInvalid, Field: "spec.completions", }, }, "indexed job with completions set updated to nil does not panic": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, Completions: pointer.Int32Ptr(1), Parallelism: pointer.Int32Ptr(1), CompletionMode: completionModePtr(batch.IndexedCompletion), }, }, update: func(job *batch.Job) { job.Spec.Completions = nil job.Spec.Parallelism = pointer.Int32Ptr(3) }, opts: JobValidationOptions{ AllowElasticIndexedJobs: true, }, err: &field.Error{ Type: field.ErrorTypeRequired, Field: "spec.completions", }, }, "indexed job with completions unchanged, parallelism reduced to less than completions": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, Completions: pointer.Int32Ptr(2), Parallelism: pointer.Int32Ptr(2), CompletionMode: completionModePtr(batch.IndexedCompletion), }, }, update: func(job *batch.Job) { job.Spec.Completions = pointer.Int32Ptr(2) job.Spec.Parallelism = pointer.Int32Ptr(1) }, opts: JobValidationOptions{ AllowElasticIndexedJobs: true, }, }, "indexed job with completions unchanged, parallelism increased higher than completions": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "abc", Namespace: metav1.NamespaceDefault}, Spec: batch.JobSpec{ Selector: validGeneratedSelector, Template: validPodTemplateSpecForGenerated, Completions: pointer.Int32Ptr(2), Parallelism: pointer.Int32Ptr(2), CompletionMode: completionModePtr(batch.IndexedCompletion), }, }, update: func(job *batch.Job) { job.Spec.Completions = pointer.Int32Ptr(2) job.Spec.Parallelism = pointer.Int32Ptr(3) }, opts: JobValidationOptions{ AllowElasticIndexedJobs: true, }, }, } ignoreValueAndDetail := cmpopts.IgnoreFields(field.Error{}, "BadValue", "Detail") for k, tc := range cases { t.Run(k, func(t *testing.T) { tc.old.ResourceVersion = "1" update := tc.old.DeepCopy() tc.update(update) errs := ValidateJobUpdate(update, &tc.old, tc.opts) var wantErrs field.ErrorList if tc.err != nil { wantErrs = append(wantErrs, tc.err) } if diff := cmp.Diff(wantErrs, errs, ignoreValueAndDetail); diff != "" { t.Errorf("Unexpected validation errors (-want,+got):\n%s", diff) } }) } } func TestValidateJobUpdateStatus(t *testing.T) { cases := map[string]struct { old batch.Job update batch.Job wantErrs field.ErrorList }{ "valid": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{ Name: "abc", Namespace: metav1.NamespaceDefault, ResourceVersion: "1", }, Status: batch.JobStatus{ Active: 1, Succeeded: 2, Failed: 3, }, }, update: batch.Job{ ObjectMeta: metav1.ObjectMeta{ Name: "abc", Namespace: metav1.NamespaceDefault, ResourceVersion: "1", }, Status: batch.JobStatus{ Active: 2, Succeeded: 3, Failed: 4, Ready: pointer.Int32(1), }, }, }, "nil ready": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{ Name: "abc", Namespace: metav1.NamespaceDefault, ResourceVersion: "1", }, Status: batch.JobStatus{ Active: 1, Succeeded: 2, Failed: 3, }, }, update: batch.Job{ ObjectMeta: metav1.ObjectMeta{ Name: "abc", Namespace: metav1.NamespaceDefault, ResourceVersion: "1", }, Status: batch.JobStatus{ Active: 2, Succeeded: 3, Failed: 4, }, }, }, "negative counts": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{ Name: "abc", Namespace: metav1.NamespaceDefault, ResourceVersion: "10", }, Status: batch.JobStatus{ Active: 1, Succeeded: 2, Failed: 3, }, }, update: batch.Job{ ObjectMeta: metav1.ObjectMeta{ Name: "abc", Namespace: metav1.NamespaceDefault, ResourceVersion: "10", }, Status: batch.JobStatus{ Active: -1, Succeeded: -2, Failed: -3, Ready: pointer.Int32(-1), }, }, wantErrs: field.ErrorList{ {Type: field.ErrorTypeInvalid, Field: "status.active"}, {Type: field.ErrorTypeInvalid, Field: "status.succeeded"}, {Type: field.ErrorTypeInvalid, Field: "status.failed"}, {Type: field.ErrorTypeInvalid, Field: "status.ready"}, }, }, "empty and duplicated uncounted pods": { old: batch.Job{ ObjectMeta: metav1.ObjectMeta{ Name: "abc", Namespace: metav1.NamespaceDefault, ResourceVersion: "5", }, }, update: batch.Job{ ObjectMeta: metav1.ObjectMeta{ Name: "abc", Namespace: metav1.NamespaceDefault, ResourceVersion: "5", }, Status: batch.JobStatus{ UncountedTerminatedPods: &batch.UncountedTerminatedPods{ Succeeded: []types.UID{"a", "b", "c", "a", ""}, Failed: []types.UID{"c", "d", "e", "d", ""}, }, }, }, wantErrs: field.ErrorList{ {Type: field.ErrorTypeDuplicate, Field: "status.uncountedTerminatedPods.succeeded[3]"}, {Type: field.ErrorTypeInvalid, Field: "status.uncountedTerminatedPods.succeeded[4]"}, {Type: field.ErrorTypeDuplicate, Field: "status.uncountedTerminatedPods.failed[0]"}, {Type: field.ErrorTypeDuplicate, Field: "status.uncountedTerminatedPods.failed[3]"}, {Type: field.ErrorTypeInvalid, Field: "status.uncountedTerminatedPods.failed[4]"}, }, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { errs := ValidateJobUpdateStatus(&tc.update, &tc.old) if diff := cmp.Diff(tc.wantErrs, errs, ignoreErrValueDetail); diff != "" { t.Errorf("Unexpected errors (-want,+got):\n%s", diff) } }) } } func TestValidateCronJob(t *testing.T) { validManualSelector := getValidManualSelector() validPodTemplateSpec := getValidPodTemplateSpecForGenerated(getValidGeneratedSelector()) validPodTemplateSpec.Labels = map[string]string{} successCases := map[string]batch.CronJob{ "basic scheduled job": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "* * * * ?", ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "non-standard scheduled": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "@hourly", ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "correct timeZone value": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: &timeZoneCorrect, ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, } for k, v := range successCases { t.Run(k, func(t *testing.T) { if errs := ValidateCronJobCreate(&v, corevalidation.PodValidationOptions{}); len(errs) != 0 { t.Errorf("expected success for %s: %v", k, errs) } // Update validation should pass same success cases // copy to avoid polluting the testcase object, set a resourceVersion to allow validating update, and test a no-op update v = *v.DeepCopy() v.ResourceVersion = "1" if errs := ValidateCronJobUpdate(&v, &v, corevalidation.PodValidationOptions{}); len(errs) != 0 { t.Errorf("expected success for %s: %v", k, errs) } }) } negative := int32(-1) negative64 := int64(-1) errorCases := map[string]batch.CronJob{ "spec.schedule: Invalid value": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "error", ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "spec.schedule: Required value": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "", ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "spec.schedule: cannot use both timeZone field and TZ or CRON_TZ in schedule": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "TZ=UTC 0 * * * *", TimeZone: &timeZoneUTC, ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "spec.timeZone: timeZone must be nil or non-empty string": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: &timeZoneEmpty, ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "spec.timeZone: timeZone must be an explicit time zone as defined in https://www.iana.org/time-zones": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: &timeZoneLocal, ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "spec.timeZone: Invalid value: \" Continent/Zone\": unknown time zone Continent/Zone": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: &timeZoneBadPrefix, ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "spec.timeZone: Invalid value: \"Continent/InvalidZone\": unknown time zone Continent/InvalidZone": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: &timeZoneBadName, ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "spec.timeZone: Invalid value: \" \": unknown time zone ": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: &timeZoneEmptySpace, ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "spec.timeZone: Invalid value: \"Continent/Zone \": unknown time zone Continent/Zone ": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: &timeZoneBadSuffix, ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "spec.startingDeadlineSeconds:must be greater than or equal to 0": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "* * * * ?", ConcurrencyPolicy: batch.AllowConcurrent, StartingDeadlineSeconds: &negative64, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "spec.successfulJobsHistoryLimit: must be greater than or equal to 0": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "* * * * ?", ConcurrencyPolicy: batch.AllowConcurrent, SuccessfulJobsHistoryLimit: &negative, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "spec.failedJobsHistoryLimit: must be greater than or equal to 0": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "* * * * ?", ConcurrencyPolicy: batch.AllowConcurrent, FailedJobsHistoryLimit: &negative, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "spec.concurrencyPolicy: Required value": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "* * * * ?", JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "spec.jobTemplate.spec.parallelism:must be greater than or equal to 0": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "* * * * ?", ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Parallelism: &negative, Template: validPodTemplateSpec, }, }, }, }, "spec.jobTemplate.spec.completions:must be greater than or equal to 0": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "* * * * ?", ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Completions: &negative, Template: validPodTemplateSpec, }, }, }, }, "spec.jobTemplate.spec.activeDeadlineSeconds:must be greater than or equal to 0": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "* * * * ?", ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ ActiveDeadlineSeconds: &negative64, Template: validPodTemplateSpec, }, }, }, }, "spec.jobTemplate.spec.selector: Invalid value: {\"matchLabels\":{\"a\":\"b\"}}: `selector` will be auto-generated": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "* * * * ?", ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Selector: validManualSelector, Template: validPodTemplateSpec, }, }, }, }, "metadata.name: must be no more than 52 characters": { ObjectMeta: metav1.ObjectMeta{ Name: "10000000002000000000300000000040000000005000000000123", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "* * * * ?", ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "spec.jobTemplate.spec.manualSelector: Unsupported value": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "* * * * ?", ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ ManualSelector: pointer.BoolPtr(true), Template: validPodTemplateSpec, }, }, }, }, "spec.jobTemplate.spec.template.spec.restartPolicy: Required value": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "* * * * ?", ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: api.PodTemplateSpec{ Spec: api.PodSpec{ RestartPolicy: api.RestartPolicyAlways, DNSPolicy: api.DNSClusterFirst, Containers: []api.Container{{Name: "abc", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: api.TerminationMessageReadFile}}, }, }, }, }, }, }, "spec.jobTemplate.spec.template.spec.restartPolicy: Unsupported value": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "* * * * ?", ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: api.PodTemplateSpec{ Spec: api.PodSpec{ RestartPolicy: "Invalid", DNSPolicy: api.DNSClusterFirst, Containers: []api.Container{{Name: "abc", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: api.TerminationMessageReadFile}}, }, }, }, }, }, }, "spec.jobTemplate.spec.ttlSecondsAfterFinished:must be greater than or equal to 0": { ObjectMeta: metav1.ObjectMeta{ Name: "mycronjob", Namespace: metav1.NamespaceDefault, UID: types.UID("1a2b3c"), }, Spec: batch.CronJobSpec{ Schedule: "* * * * ?", ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ TTLSecondsAfterFinished: &negative, Template: validPodTemplateSpec, }, }, }, }, } for k, v := range errorCases { t.Run(k, func(t *testing.T) { errs := ValidateCronJobCreate(&v, corevalidation.PodValidationOptions{}) if len(errs) == 0 { t.Errorf("expected failure for %s", k) } else { s := strings.Split(k, ":") err := errs[0] if err.Field != s[0] || !strings.Contains(err.Error(), s[1]) { t.Errorf("unexpected error: %v, expected: %s", err, k) } } // Update validation should fail all failure cases other than the 52 character name limit // copy to avoid polluting the testcase object, set a resourceVersion to allow validating update, and test a no-op update oldSpec := *v.DeepCopy() oldSpec.ResourceVersion = "1" oldSpec.Spec.TimeZone = nil newSpec := *v.DeepCopy() newSpec.ResourceVersion = "2" errs = ValidateCronJobUpdate(&newSpec, &oldSpec, corevalidation.PodValidationOptions{}) if len(errs) == 0 { if k == "metadata.name: must be no more than 52 characters" { return } t.Errorf("expected failure for %s", k) } else { s := strings.Split(k, ":") err := errs[0] if err.Field != s[0] || !strings.Contains(err.Error(), s[1]) { t.Errorf("unexpected error: %v, expected: %s", err, k) } } }) } } func TestValidateCronJobSpec(t *testing.T) { validPodTemplateSpec := getValidPodTemplateSpecForGenerated(getValidGeneratedSelector()) validPodTemplateSpec.Labels = map[string]string{} type testCase struct { old *batch.CronJobSpec new *batch.CronJobSpec expectErr bool } cases := map[string]testCase{ "no validation because timeZone is nil for old and new": { old: &batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: nil, ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, new: &batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: nil, ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "check validation because timeZone is different for new": { old: &batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: nil, ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, new: &batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: pointer.String("America/New_York"), ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "check validation because timeZone is different for new and invalid": { old: &batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: nil, ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, new: &batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: pointer.String("broken"), ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, expectErr: true, }, "old timeZone and new timeZone are valid": { old: &batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: pointer.String("America/New_York"), ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, new: &batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: pointer.String("America/Chicago"), ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "old timeZone is valid, but new timeZone is invalid": { old: &batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: pointer.String("America/New_York"), ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, new: &batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: pointer.String("broken"), ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, expectErr: true, }, "old timeZone and new timeZone are invalid, but unchanged": { old: &batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: pointer.String("broken"), ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, new: &batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: pointer.String("broken"), ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, "old timeZone and new timeZone are invalid, but different": { old: &batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: pointer.String("broken"), ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, new: &batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: pointer.String("still broken"), ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, expectErr: true, }, "old timeZone is invalid, but new timeZone is valid": { old: &batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: pointer.String("broken"), ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, new: &batch.CronJobSpec{ Schedule: "0 * * * *", TimeZone: pointer.String("America/New_York"), ConcurrencyPolicy: batch.AllowConcurrent, JobTemplate: batch.JobTemplateSpec{ Spec: batch.JobSpec{ Template: validPodTemplateSpec, }, }, }, }, } for k, v := range cases { errs := validateCronJobSpec(v.new, v.old, field.NewPath("spec"), corevalidation.PodValidationOptions{}) if len(errs) > 0 && !v.expectErr { t.Errorf("unexpected error for %s: %v", k, errs) } else if len(errs) == 0 && v.expectErr { t.Errorf("expected error for %s but got nil", k) } } } func completionModePtr(m batch.CompletionMode) *batch.CompletionMode { return &m } func TestTimeZones(t *testing.T) { // all valid time zones as of go1.19 release on 2022-08-02 data := []string{ `Africa/Abidjan`, `Africa/Accra`, `Africa/Addis_Ababa`, `Africa/Algiers`, `Africa/Asmara`, `Africa/Asmera`, `Africa/Bamako`, `Africa/Bangui`, `Africa/Banjul`, `Africa/Bissau`, `Africa/Blantyre`, `Africa/Brazzaville`, `Africa/Bujumbura`, `Africa/Cairo`, `Africa/Casablanca`, `Africa/Ceuta`, `Africa/Conakry`, `Africa/Dakar`, `Africa/Dar_es_Salaam`, `Africa/Djibouti`, `Africa/Douala`, `Africa/El_Aaiun`, `Africa/Freetown`, `Africa/Gaborone`, `Africa/Harare`, `Africa/Johannesburg`, `Africa/Juba`, `Africa/Kampala`, `Africa/Khartoum`, `Africa/Kigali`, `Africa/Kinshasa`, `Africa/Lagos`, `Africa/Libreville`, `Africa/Lome`, `Africa/Luanda`, `Africa/Lubumbashi`, `Africa/Lusaka`, `Africa/Malabo`, `Africa/Maputo`, `Africa/Maseru`, `Africa/Mbabane`, `Africa/Mogadishu`, `Africa/Monrovia`, `Africa/Nairobi`, `Africa/Ndjamena`, `Africa/Niamey`, `Africa/Nouakchott`, `Africa/Ouagadougou`, `Africa/Porto-Novo`, `Africa/Sao_Tome`, `Africa/Timbuktu`, `Africa/Tripoli`, `Africa/Tunis`, `Africa/Windhoek`, `America/Adak`, `America/Anchorage`, `America/Anguilla`, `America/Antigua`, `America/Araguaina`, `America/Argentina/Buenos_Aires`, `America/Argentina/Catamarca`, `America/Argentina/ComodRivadavia`, `America/Argentina/Cordoba`, `America/Argentina/Jujuy`, `America/Argentina/La_Rioja`, `America/Argentina/Mendoza`, `America/Argentina/Rio_Gallegos`, `America/Argentina/Salta`, `America/Argentina/San_Juan`, `America/Argentina/San_Luis`, `America/Argentina/Tucuman`, `America/Argentina/Ushuaia`, `America/Aruba`, `America/Asuncion`, `America/Atikokan`, `America/Atka`, `America/Bahia`, `America/Bahia_Banderas`, `America/Barbados`, `America/Belem`, `America/Belize`, `America/Blanc-Sablon`, `America/Boa_Vista`, `America/Bogota`, `America/Boise`, `America/Buenos_Aires`, `America/Cambridge_Bay`, `America/Campo_Grande`, `America/Cancun`, `America/Caracas`, `America/Catamarca`, `America/Cayenne`, `America/Cayman`, `America/Chicago`, `America/Chihuahua`, `America/Coral_Harbour`, `America/Cordoba`, `America/Costa_Rica`, `America/Creston`, `America/Cuiaba`, `America/Curacao`, `America/Danmarkshavn`, `America/Dawson`, `America/Dawson_Creek`, `America/Denver`, `America/Detroit`, `America/Dominica`, `America/Edmonton`, `America/Eirunepe`, `America/El_Salvador`, `America/Ensenada`, `America/Fort_Nelson`, `America/Fort_Wayne`, `America/Fortaleza`, `America/Glace_Bay`, `America/Godthab`, `America/Goose_Bay`, `America/Grand_Turk`, `America/Grenada`, `America/Guadeloupe`, `America/Guatemala`, `America/Guayaquil`, `America/Guyana`, `America/Halifax`, `America/Havana`, `America/Hermosillo`, `America/Indiana/Indianapolis`, `America/Indiana/Knox`, `America/Indiana/Marengo`, `America/Indiana/Petersburg`, `America/Indiana/Tell_City`, `America/Indiana/Vevay`, `America/Indiana/Vincennes`, `America/Indiana/Winamac`, `America/Indianapolis`, `America/Inuvik`, `America/Iqaluit`, `America/Jamaica`, `America/Jujuy`, `America/Juneau`, `America/Kentucky/Louisville`, `America/Kentucky/Monticello`, `America/Knox_IN`, `America/Kralendijk`, `America/La_Paz`, `America/Lima`, `America/Los_Angeles`, `America/Louisville`, `America/Lower_Princes`, `America/Maceio`, `America/Managua`, `America/Manaus`, `America/Marigot`, `America/Martinique`, `America/Matamoros`, `America/Mazatlan`, `America/Mendoza`, `America/Menominee`, `America/Merida`, `America/Metlakatla`, `America/Mexico_City`, `America/Miquelon`, `America/Moncton`, `America/Monterrey`, `America/Montevideo`, `America/Montreal`, `America/Montserrat`, `America/Nassau`, `America/New_York`, `America/Nipigon`, `America/Nome`, `America/Noronha`, `America/North_Dakota/Beulah`, `America/North_Dakota/Center`, `America/North_Dakota/New_Salem`, `America/Nuuk`, `America/Ojinaga`, `America/Panama`, `America/Pangnirtung`, `America/Paramaribo`, `America/Phoenix`, `America/Port-au-Prince`, `America/Port_of_Spain`, `America/Porto_Acre`, `America/Porto_Velho`, `America/Puerto_Rico`, `America/Punta_Arenas`, `America/Rainy_River`, `America/Rankin_Inlet`, `America/Recife`, `America/Regina`, `America/Resolute`, `America/Rio_Branco`, `America/Rosario`, `America/Santa_Isabel`, `America/Santarem`, `America/Santiago`, `America/Santo_Domingo`, `America/Sao_Paulo`, `America/Scoresbysund`, `America/Shiprock`, `America/Sitka`, `America/St_Barthelemy`, `America/St_Johns`, `America/St_Kitts`, `America/St_Lucia`, `America/St_Thomas`, `America/St_Vincent`, `America/Swift_Current`, `America/Tegucigalpa`, `America/Thule`, `America/Thunder_Bay`, `America/Tijuana`, `America/Toronto`, `America/Tortola`, `America/Vancouver`, `America/Virgin`, `America/Whitehorse`, `America/Winnipeg`, `America/Yakutat`, `America/Yellowknife`, `Antarctica/Casey`, `Antarctica/Davis`, `Antarctica/DumontDUrville`, `Antarctica/Macquarie`, `Antarctica/Mawson`, `Antarctica/McMurdo`, `Antarctica/Palmer`, `Antarctica/Rothera`, `Antarctica/South_Pole`, `Antarctica/Syowa`, `Antarctica/Troll`, `Antarctica/Vostok`, `Arctic/Longyearbyen`, `Asia/Aden`, `Asia/Almaty`, `Asia/Amman`, `Asia/Anadyr`, `Asia/Aqtau`, `Asia/Aqtobe`, `Asia/Ashgabat`, `Asia/Ashkhabad`, `Asia/Atyrau`, `Asia/Baghdad`, `Asia/Bahrain`, `Asia/Baku`, `Asia/Bangkok`, `Asia/Barnaul`, `Asia/Beirut`, `Asia/Bishkek`, `Asia/Brunei`, `Asia/Calcutta`, `Asia/Chita`, `Asia/Choibalsan`, `Asia/Chongqing`, `Asia/Chungking`, `Asia/Colombo`, `Asia/Dacca`, `Asia/Damascus`, `Asia/Dhaka`, `Asia/Dili`, `Asia/Dubai`, `Asia/Dushanbe`, `Asia/Famagusta`, `Asia/Gaza`, `Asia/Harbin`, `Asia/Hebron`, `Asia/Ho_Chi_Minh`, `Asia/Hong_Kong`, `Asia/Hovd`, `Asia/Irkutsk`, `Asia/Istanbul`, `Asia/Jakarta`, `Asia/Jayapura`, `Asia/Jerusalem`, `Asia/Kabul`, `Asia/Kamchatka`, `Asia/Karachi`, `Asia/Kashgar`, `Asia/Kathmandu`, `Asia/Katmandu`, `Asia/Khandyga`, `Asia/Kolkata`, `Asia/Krasnoyarsk`, `Asia/Kuala_Lumpur`, `Asia/Kuching`, `Asia/Kuwait`, `Asia/Macao`, `Asia/Macau`, `Asia/Magadan`, `Asia/Makassar`, `Asia/Manila`, `Asia/Muscat`, `Asia/Nicosia`, `Asia/Novokuznetsk`, `Asia/Novosibirsk`, `Asia/Omsk`, `Asia/Oral`, `Asia/Phnom_Penh`, `Asia/Pontianak`, `Asia/Pyongyang`, `Asia/Qatar`, `Asia/Qostanay`, `Asia/Qyzylorda`, `Asia/Rangoon`, `Asia/Riyadh`, `Asia/Saigon`, `Asia/Sakhalin`, `Asia/Samarkand`, `Asia/Seoul`, `Asia/Shanghai`, `Asia/Singapore`, `Asia/Srednekolymsk`, `Asia/Taipei`, `Asia/Tashkent`, `Asia/Tbilisi`, `Asia/Tehran`, `Asia/Tel_Aviv`, `Asia/Thimbu`, `Asia/Thimphu`, `Asia/Tokyo`, `Asia/Tomsk`, `Asia/Ujung_Pandang`, `Asia/Ulaanbaatar`, `Asia/Ulan_Bator`, `Asia/Urumqi`, `Asia/Ust-Nera`, `Asia/Vientiane`, `Asia/Vladivostok`, `Asia/Yakutsk`, `Asia/Yangon`, `Asia/Yekaterinburg`, `Asia/Yerevan`, `Atlantic/Azores`, `Atlantic/Bermuda`, `Atlantic/Canary`, `Atlantic/Cape_Verde`, `Atlantic/Faeroe`, `Atlantic/Faroe`, `Atlantic/Jan_Mayen`, `Atlantic/Madeira`, `Atlantic/Reykjavik`, `Atlantic/South_Georgia`, `Atlantic/St_Helena`, `Atlantic/Stanley`, `Australia/ACT`, `Australia/Adelaide`, `Australia/Brisbane`, `Australia/Broken_Hill`, `Australia/Canberra`, `Australia/Currie`, `Australia/Darwin`, `Australia/Eucla`, `Australia/Hobart`, `Australia/LHI`, `Australia/Lindeman`, `Australia/Lord_Howe`, `Australia/Melbourne`, `Australia/North`, `Australia/NSW`, `Australia/Perth`, `Australia/Queensland`, `Australia/South`, `Australia/Sydney`, `Australia/Tasmania`, `Australia/Victoria`, `Australia/West`, `Australia/Yancowinna`, `Brazil/Acre`, `Brazil/DeNoronha`, `Brazil/East`, `Brazil/West`, `Canada/Atlantic`, `Canada/Central`, `Canada/Eastern`, `Canada/Mountain`, `Canada/Newfoundland`, `Canada/Pacific`, `Canada/Saskatchewan`, `Canada/Yukon`, `CET`, `Chile/Continental`, `Chile/EasterIsland`, `CST6CDT`, `Cuba`, `EET`, `Egypt`, `Eire`, `EST`, `EST5EDT`, `Etc/GMT`, `Etc/GMT+0`, `Etc/GMT+1`, `Etc/GMT+10`, `Etc/GMT+11`, `Etc/GMT+12`, `Etc/GMT+2`, `Etc/GMT+3`, `Etc/GMT+4`, `Etc/GMT+5`, `Etc/GMT+6`, `Etc/GMT+7`, `Etc/GMT+8`, `Etc/GMT+9`, `Etc/GMT-0`, `Etc/GMT-1`, `Etc/GMT-10`, `Etc/GMT-11`, `Etc/GMT-12`, `Etc/GMT-13`, `Etc/GMT-14`, `Etc/GMT-2`, `Etc/GMT-3`, `Etc/GMT-4`, `Etc/GMT-5`, `Etc/GMT-6`, `Etc/GMT-7`, `Etc/GMT-8`, `Etc/GMT-9`, `Etc/GMT0`, `Etc/Greenwich`, `Etc/UCT`, `Etc/Universal`, `Etc/UTC`, `Etc/Zulu`, `Europe/Amsterdam`, `Europe/Andorra`, `Europe/Astrakhan`, `Europe/Athens`, `Europe/Belfast`, `Europe/Belgrade`, `Europe/Berlin`, `Europe/Bratislava`, `Europe/Brussels`, `Europe/Bucharest`, `Europe/Budapest`, `Europe/Busingen`, `Europe/Chisinau`, `Europe/Copenhagen`, `Europe/Dublin`, `Europe/Gibraltar`, `Europe/Guernsey`, `Europe/Helsinki`, `Europe/Isle_of_Man`, `Europe/Istanbul`, `Europe/Jersey`, `Europe/Kaliningrad`, `Europe/Kiev`, `Europe/Kirov`, `Europe/Lisbon`, `Europe/Ljubljana`, `Europe/London`, `Europe/Luxembourg`, `Europe/Madrid`, `Europe/Malta`, `Europe/Mariehamn`, `Europe/Minsk`, `Europe/Monaco`, `Europe/Moscow`, `Europe/Nicosia`, `Europe/Oslo`, `Europe/Paris`, `Europe/Podgorica`, `Europe/Prague`, `Europe/Riga`, `Europe/Rome`, `Europe/Samara`, `Europe/San_Marino`, `Europe/Sarajevo`, `Europe/Saratov`, `Europe/Simferopol`, `Europe/Skopje`, `Europe/Sofia`, `Europe/Stockholm`, `Europe/Tallinn`, `Europe/Tirane`, `Europe/Tiraspol`, `Europe/Ulyanovsk`, `Europe/Uzhgorod`, `Europe/Vaduz`, `Europe/Vatican`, `Europe/Vienna`, `Europe/Vilnius`, `Europe/Volgograd`, `Europe/Warsaw`, `Europe/Zagreb`, `Europe/Zaporozhye`, `Europe/Zurich`, `Factory`, `GB`, `GB-Eire`, `GMT`, `GMT+0`, `GMT-0`, `GMT0`, `Greenwich`, `Hongkong`, `HST`, `Iceland`, `Indian/Antananarivo`, `Indian/Chagos`, `Indian/Christmas`, `Indian/Cocos`, `Indian/Comoro`, `Indian/Kerguelen`, `Indian/Mahe`, `Indian/Maldives`, `Indian/Mauritius`, `Indian/Mayotte`, `Indian/Reunion`, `Iran`, `Israel`, `Jamaica`, `Japan`, `Kwajalein`, `Libya`, `MET`, `Mexico/BajaNorte`, `Mexico/BajaSur`, `Mexico/General`, `MST`, `MST7MDT`, `Navajo`, `NZ`, `NZ-CHAT`, `Pacific/Apia`, `Pacific/Auckland`, `Pacific/Bougainville`, `Pacific/Chatham`, `Pacific/Chuuk`, `Pacific/Easter`, `Pacific/Efate`, `Pacific/Enderbury`, `Pacific/Fakaofo`, `Pacific/Fiji`, `Pacific/Funafuti`, `Pacific/Galapagos`, `Pacific/Gambier`, `Pacific/Guadalcanal`, `Pacific/Guam`, `Pacific/Honolulu`, `Pacific/Johnston`, `Pacific/Kanton`, `Pacific/Kiritimati`, `Pacific/Kosrae`, `Pacific/Kwajalein`, `Pacific/Majuro`, `Pacific/Marquesas`, `Pacific/Midway`, `Pacific/Nauru`, `Pacific/Niue`, `Pacific/Norfolk`, `Pacific/Noumea`, `Pacific/Pago_Pago`, `Pacific/Palau`, `Pacific/Pitcairn`, `Pacific/Pohnpei`, `Pacific/Ponape`, `Pacific/Port_Moresby`, `Pacific/Rarotonga`, `Pacific/Saipan`, `Pacific/Samoa`, `Pacific/Tahiti`, `Pacific/Tarawa`, `Pacific/Tongatapu`, `Pacific/Truk`, `Pacific/Wake`, `Pacific/Wallis`, `Pacific/Yap`, `Poland`, `Portugal`, `PRC`, `PST8PDT`, `ROC`, `ROK`, `Singapore`, `Turkey`, `UCT`, `Universal`, `US/Alaska`, `US/Aleutian`, `US/Arizona`, `US/Central`, `US/East-Indiana`, `US/Eastern`, `US/Hawaii`, `US/Indiana-Starke`, `US/Michigan`, `US/Mountain`, `US/Pacific`, `US/Samoa`, `UTC`, `W-SU`, `WET`, `Zulu`, } for _, tz := range data { errs := validateTimeZone(&tz, nil) if len(errs) > 0 { t.Errorf("%s failed: %v", tz, errs) } } }