mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-07-27 13:37:30 +00:00
TimeZone support for CronJobs
This commit is contained in:
parent
f25c0e5f09
commit
98837de446
@ -22,6 +22,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
_ "time/tzdata" // for CronJob Time Zone support
|
||||||
|
|
||||||
"k8s.io/component-base/cli"
|
"k8s.io/component-base/cli"
|
||||||
_ "k8s.io/component-base/logs/json/register" // for JSON log format registration
|
_ "k8s.io/component-base/logs/json/register" // for JSON log format registration
|
||||||
|
@ -376,6 +376,12 @@ type CronJobSpec struct {
|
|||||||
// The schedule in Cron format, see https://en.wikipedia.org/wiki/Cron.
|
// The schedule in Cron format, see https://en.wikipedia.org/wiki/Cron.
|
||||||
Schedule string
|
Schedule string
|
||||||
|
|
||||||
|
// The time zone for the given schedule, see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones.
|
||||||
|
// If not specified, this will rely on the time zone of the kube-controller-manager process.
|
||||||
|
// ALPHA: This field is in alpha and must be enabled via the `CronJobTimeZone` feature gate.
|
||||||
|
// +optional
|
||||||
|
TimeZone *string
|
||||||
|
|
||||||
// Optional deadline in seconds for starting the job if it misses scheduled
|
// Optional deadline in seconds for starting the job if it misses scheduled
|
||||||
// time for any reason. Missed jobs executions will be counted as failed ones.
|
// time for any reason. Missed jobs executions will be counted as failed ones.
|
||||||
// +optional
|
// +optional
|
||||||
|
@ -18,6 +18,8 @@ package validation
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
@ -308,11 +310,14 @@ func ValidateCronJobSpec(spec *batch.CronJobSpec, fldPath *field.Path, opts apiv
|
|||||||
if len(spec.Schedule) == 0 {
|
if len(spec.Schedule) == 0 {
|
||||||
allErrs = append(allErrs, field.Required(fldPath.Child("schedule"), ""))
|
allErrs = append(allErrs, field.Required(fldPath.Child("schedule"), ""))
|
||||||
} else {
|
} else {
|
||||||
allErrs = append(allErrs, validateScheduleFormat(spec.Schedule, fldPath.Child("schedule"))...)
|
allErrs = append(allErrs, validateScheduleFormat(spec.Schedule, spec.TimeZone, fldPath.Child("schedule"))...)
|
||||||
}
|
}
|
||||||
|
|
||||||
if spec.StartingDeadlineSeconds != nil {
|
if spec.StartingDeadlineSeconds != nil {
|
||||||
allErrs = append(allErrs, apivalidation.ValidateNonnegativeField(int64(*spec.StartingDeadlineSeconds), fldPath.Child("startingDeadlineSeconds"))...)
|
allErrs = append(allErrs, apivalidation.ValidateNonnegativeField(int64(*spec.StartingDeadlineSeconds), fldPath.Child("startingDeadlineSeconds"))...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allErrs = append(allErrs, validateTimeZone(spec.TimeZone, fldPath.Child("timeZone"))...)
|
||||||
allErrs = append(allErrs, validateConcurrencyPolicy(&spec.ConcurrencyPolicy, fldPath.Child("concurrencyPolicy"))...)
|
allErrs = append(allErrs, validateConcurrencyPolicy(&spec.ConcurrencyPolicy, fldPath.Child("concurrencyPolicy"))...)
|
||||||
allErrs = append(allErrs, ValidateJobTemplateSpec(&spec.JobTemplate, fldPath.Child("jobTemplate"), opts)...)
|
allErrs = append(allErrs, ValidateJobTemplateSpec(&spec.JobTemplate, fldPath.Child("jobTemplate"), opts)...)
|
||||||
|
|
||||||
@ -343,11 +348,36 @@ func validateConcurrencyPolicy(concurrencyPolicy *batch.ConcurrencyPolicy, fldPa
|
|||||||
return allErrs
|
return allErrs
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateScheduleFormat(schedule string, fldPath *field.Path) field.ErrorList {
|
func validateScheduleFormat(schedule string, timeZone *string, fldPath *field.Path) field.ErrorList {
|
||||||
allErrs := field.ErrorList{}
|
allErrs := field.ErrorList{}
|
||||||
if _, err := cron.ParseStandard(schedule); err != nil {
|
if _, err := cron.ParseStandard(schedule); err != nil {
|
||||||
allErrs = append(allErrs, field.Invalid(fldPath, schedule, err.Error()))
|
allErrs = append(allErrs, field.Invalid(fldPath, schedule, err.Error()))
|
||||||
}
|
}
|
||||||
|
if strings.Contains(schedule, "TZ") && timeZone != nil {
|
||||||
|
allErrs = append(allErrs, field.Invalid(fldPath, schedule, "cannot use both timeZone field and TZ or CRON_TZ in schedule"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return allErrs
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTimeZone(timeZone *string, fldPath *field.Path) field.ErrorList {
|
||||||
|
allErrs := field.ErrorList{}
|
||||||
|
if timeZone == nil {
|
||||||
|
return allErrs
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(*timeZone) == 0 {
|
||||||
|
allErrs = append(allErrs, field.Invalid(fldPath, timeZone, "timeZone must be nil or non-empty string"))
|
||||||
|
return allErrs
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.EqualFold(*timeZone, "Local") {
|
||||||
|
allErrs = append(allErrs, field.Invalid(fldPath, timeZone, "timeZone must be an explicit time zone as defined in https://www.iana.org/time-zones"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := time.LoadLocation(*timeZone); err != nil {
|
||||||
|
allErrs = append(allErrs, field.Invalid(fldPath, timeZone, err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
return allErrs
|
return allErrs
|
||||||
}
|
}
|
||||||
|
@ -31,6 +31,18 @@ import (
|
|||||||
"k8s.io/utils/pointer"
|
"k8s.io/utils/pointer"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
timeZoneEmpty = ""
|
||||||
|
timeZoneLocal = "LOCAL"
|
||||||
|
timeZoneUTC = "UTC"
|
||||||
|
timeZoneCorrectCasing = "America/New_York"
|
||||||
|
timeZoneBadCasing = "AMERICA/new_york"
|
||||||
|
timeZoneBadPrefix = " America/New_York"
|
||||||
|
timeZoneBadSuffix = "America/New_York "
|
||||||
|
timeZoneBadName = "America/New York"
|
||||||
|
timeZoneEmptySpace = " "
|
||||||
|
)
|
||||||
|
|
||||||
var ignoreErrValueDetail = cmpopts.IgnoreFields(field.Error{}, "BadValue", "Detail")
|
var ignoreErrValueDetail = cmpopts.IgnoreFields(field.Error{}, "BadValue", "Detail")
|
||||||
|
|
||||||
func getValidManualSelector() *metav1.LabelSelector {
|
func getValidManualSelector() *metav1.LabelSelector {
|
||||||
@ -902,6 +914,23 @@ func TestValidateCronJob(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"correct timeZone value casing": {
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "mycronjob",
|
||||||
|
Namespace: metav1.NamespaceDefault,
|
||||||
|
UID: types.UID("1a2b3c"),
|
||||||
|
},
|
||||||
|
Spec: batch.CronJobSpec{
|
||||||
|
Schedule: "0 * * * *",
|
||||||
|
TimeZone: &timeZoneCorrectCasing,
|
||||||
|
ConcurrencyPolicy: batch.AllowConcurrent,
|
||||||
|
JobTemplate: batch.JobTemplateSpec{
|
||||||
|
Spec: batch.JobSpec{
|
||||||
|
Template: validPodTemplateSpec,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for k, v := range successCases {
|
for k, v := range successCases {
|
||||||
if errs := ValidateCronJob(&v, corevalidation.PodValidationOptions{}); len(errs) != 0 {
|
if errs := ValidateCronJob(&v, corevalidation.PodValidationOptions{}); len(errs) != 0 {
|
||||||
@ -953,6 +982,142 @@ func TestValidateCronJob(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"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: \"AMERICA/new_york\": unknown time zone AMERICA/new_york": {
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "mycronjob",
|
||||||
|
Namespace: metav1.NamespaceDefault,
|
||||||
|
UID: types.UID("1a2b3c"),
|
||||||
|
},
|
||||||
|
Spec: batch.CronJobSpec{
|
||||||
|
Schedule: "0 * * * *",
|
||||||
|
TimeZone: &timeZoneBadCasing,
|
||||||
|
ConcurrencyPolicy: batch.AllowConcurrent,
|
||||||
|
JobTemplate: batch.JobTemplateSpec{
|
||||||
|
Spec: batch.JobSpec{
|
||||||
|
Template: validPodTemplateSpec,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"spec.timeZone: Invalid value: \" America/New_York\": unknown time zone America/New_York": {
|
||||||
|
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: \"America/New_York \": unknown time zone America/New_York ": {
|
||||||
|
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.timeZone: Invalid value: \"America/New York\": unknown time zone America/New York": {
|
||||||
|
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.startingDeadlineSeconds:must be greater than or equal to 0": {
|
"spec.startingDeadlineSeconds:must be greater than or equal to 0": {
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: "mycronjob",
|
Name: "mycronjob",
|
||||||
|
@ -35,6 +35,8 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/types"
|
"k8s.io/apimachinery/pkg/types"
|
||||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||||
"k8s.io/apimachinery/pkg/util/wait"
|
"k8s.io/apimachinery/pkg/util/wait"
|
||||||
|
"k8s.io/apiserver/pkg/features"
|
||||||
|
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||||
batchv1informers "k8s.io/client-go/informers/batch/v1"
|
batchv1informers "k8s.io/client-go/informers/batch/v1"
|
||||||
clientset "k8s.io/client-go/kubernetes"
|
clientset "k8s.io/client-go/kubernetes"
|
||||||
"k8s.io/client-go/kubernetes/scheme"
|
"k8s.io/client-go/kubernetes/scheme"
|
||||||
@ -48,6 +50,7 @@ import (
|
|||||||
"k8s.io/klog/v2"
|
"k8s.io/klog/v2"
|
||||||
"k8s.io/kubernetes/pkg/controller"
|
"k8s.io/kubernetes/pkg/controller"
|
||||||
"k8s.io/kubernetes/pkg/controller/cronjob/metrics"
|
"k8s.io/kubernetes/pkg/controller/cronjob/metrics"
|
||||||
|
"k8s.io/utils/pointer"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -371,6 +374,7 @@ func (jm *ControllerV2) enqueueControllerAfter(obj interface{}, t time.Duration)
|
|||||||
// updateCronJob re-queues the CronJob for next scheduled time if there is a
|
// updateCronJob re-queues the CronJob for next scheduled time if there is a
|
||||||
// change in spec.schedule otherwise it re-queues it now
|
// change in spec.schedule otherwise it re-queues it now
|
||||||
func (jm *ControllerV2) updateCronJob(old interface{}, curr interface{}) {
|
func (jm *ControllerV2) updateCronJob(old interface{}, curr interface{}) {
|
||||||
|
timeZoneEnabled := utilfeature.DefaultFeatureGate.Enabled(features.CronJobTimeZone)
|
||||||
oldCJ, okOld := old.(*batchv1.CronJob)
|
oldCJ, okOld := old.(*batchv1.CronJob)
|
||||||
newCJ, okNew := curr.(*batchv1.CronJob)
|
newCJ, okNew := curr.(*batchv1.CronJob)
|
||||||
|
|
||||||
@ -381,9 +385,9 @@ func (jm *ControllerV2) updateCronJob(old interface{}, curr interface{}) {
|
|||||||
// if the change in schedule results in next requeue having to be sooner than it already was,
|
// if the change in schedule results in next requeue having to be sooner than it already was,
|
||||||
// it will be handled here by the queue. If the next requeue is further than previous schedule,
|
// it will be handled here by the queue. If the next requeue is further than previous schedule,
|
||||||
// the sync loop will essentially be a no-op for the already queued key with old schedule.
|
// the sync loop will essentially be a no-op for the already queued key with old schedule.
|
||||||
if oldCJ.Spec.Schedule != newCJ.Spec.Schedule {
|
if oldCJ.Spec.Schedule != newCJ.Spec.Schedule || (timeZoneEnabled && !pointer.StringEqual(oldCJ.Spec.TimeZone, newCJ.Spec.TimeZone)) {
|
||||||
// schedule changed, change the requeue time
|
// schedule changed, change the requeue time
|
||||||
sched, err := cron.ParseStandard(newCJ.Spec.Schedule)
|
sched, err := cron.ParseStandard(formatSchedule(timeZoneEnabled, newCJ, jm.recorder))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// this is likely a user error in defining the spec value
|
// this is likely a user error in defining the spec value
|
||||||
// we should log the error and not reconcile this cronjob until an update to spec
|
// we should log the error and not reconcile this cronjob until an update to spec
|
||||||
@ -420,6 +424,7 @@ func (jm *ControllerV2) syncCronJob(
|
|||||||
cronJob = cronJob.DeepCopy()
|
cronJob = cronJob.DeepCopy()
|
||||||
now := jm.now()
|
now := jm.now()
|
||||||
updateStatus := false
|
updateStatus := false
|
||||||
|
timeZoneEnabled := utilfeature.DefaultFeatureGate.Enabled(features.CronJobTimeZone)
|
||||||
|
|
||||||
childrenJobs := make(map[types.UID]bool)
|
childrenJobs := make(map[types.UID]bool)
|
||||||
for _, j := range jobs {
|
for _, j := range jobs {
|
||||||
@ -487,12 +492,21 @@ func (jm *ControllerV2) syncCronJob(
|
|||||||
return cronJob, nil, updateStatus, nil
|
return cronJob, nil, updateStatus, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if timeZoneEnabled && cronJob.Spec.TimeZone != nil {
|
||||||
|
if _, err := time.LoadLocation(*cronJob.Spec.TimeZone); err != nil {
|
||||||
|
timeZone := pointer.StringDeref(cronJob.Spec.TimeZone, "")
|
||||||
|
klog.V(4).InfoS("Not starting job because timeZone is invalid", "cronjob", klog.KRef(cronJob.GetNamespace(), cronJob.GetName()), "timeZone", timeZone, "err", err)
|
||||||
|
jm.recorder.Eventf(cronJob, corev1.EventTypeWarning, "UnknownTimeZone", "invalid timeZone: %q: %s", timeZone, err)
|
||||||
|
return cronJob, nil, updateStatus, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if cronJob.Spec.Suspend != nil && *cronJob.Spec.Suspend {
|
if cronJob.Spec.Suspend != nil && *cronJob.Spec.Suspend {
|
||||||
klog.V(4).InfoS("Not starting job because the cron is suspended", "cronjob", klog.KRef(cronJob.GetNamespace(), cronJob.GetName()))
|
klog.V(4).InfoS("Not starting job because the cron is suspended", "cronjob", klog.KRef(cronJob.GetNamespace(), cronJob.GetName()))
|
||||||
return cronJob, nil, updateStatus, nil
|
return cronJob, nil, updateStatus, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
sched, err := cron.ParseStandard(cronJob.Spec.Schedule)
|
sched, err := cron.ParseStandard(formatSchedule(timeZoneEnabled, cronJob, jm.recorder))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// this is likely a user error in defining the spec value
|
// this is likely a user error in defining the spec value
|
||||||
// we should log the error and not reconcile this cronjob until an update to spec
|
// we should log the error and not reconcile this cronjob until an update to spec
|
||||||
@ -501,10 +515,6 @@ func (jm *ControllerV2) syncCronJob(
|
|||||||
return cronJob, nil, updateStatus, nil
|
return cronJob, nil, updateStatus, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(cronJob.Spec.Schedule, "TZ") {
|
|
||||||
jm.recorder.Eventf(cronJob, corev1.EventTypeWarning, "UnsupportedSchedule", "CRON_TZ or TZ used in schedule %q is not officially supported, see https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/ for more details", cronJob.Spec.Schedule)
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduledTime, err := getNextScheduleTime(*cronJob, now, sched, jm.recorder)
|
scheduledTime, err := getNextScheduleTime(*cronJob, now, sched, jm.recorder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// this is likely a user error in defining the spec value
|
// this is likely a user error in defining the spec value
|
||||||
@ -739,3 +749,20 @@ func deleteJob(cj *batchv1.CronJob, job *batchv1.Job, jc jobControlInterface, re
|
|||||||
func getRef(object runtime.Object) (*corev1.ObjectReference, error) {
|
func getRef(object runtime.Object) (*corev1.ObjectReference, error) {
|
||||||
return ref.GetReference(scheme.Scheme, object)
|
return ref.GetReference(scheme.Scheme, object)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatSchedule(timeZoneEnabled bool, cj *batchv1.CronJob, recorder record.EventRecorder) string {
|
||||||
|
if strings.Contains(cj.Spec.Schedule, "TZ") {
|
||||||
|
recorder.Eventf(cj, corev1.EventTypeWarning, "UnsupportedSchedule", "CRON_TZ or TZ used in schedule %q is not officially supported, see https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/ for more details", cj.Spec.Schedule)
|
||||||
|
return cj.Spec.Schedule
|
||||||
|
}
|
||||||
|
|
||||||
|
if timeZoneEnabled && cj.Spec.TimeZone != nil {
|
||||||
|
if _, err := time.LoadLocation(*cj.Spec.TimeZone); err != nil {
|
||||||
|
return cj.Spec.Schedule
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("TZ=%s %s", *cj.Spec.TimeZone, cj.Spec.Schedule)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cj.Spec.Schedule
|
||||||
|
}
|
||||||
|
@ -32,10 +32,13 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
"k8s.io/apimachinery/pkg/types"
|
"k8s.io/apimachinery/pkg/types"
|
||||||
|
"k8s.io/apiserver/pkg/features"
|
||||||
|
"k8s.io/apiserver/pkg/util/feature"
|
||||||
"k8s.io/client-go/informers"
|
"k8s.io/client-go/informers"
|
||||||
"k8s.io/client-go/kubernetes/fake"
|
"k8s.io/client-go/kubernetes/fake"
|
||||||
"k8s.io/client-go/tools/record"
|
"k8s.io/client-go/tools/record"
|
||||||
"k8s.io/client-go/util/workqueue"
|
"k8s.io/client-go/util/workqueue"
|
||||||
|
featuregatetesting "k8s.io/component-base/featuregate/testing"
|
||||||
_ "k8s.io/kubernetes/pkg/apis/batch/install"
|
_ "k8s.io/kubernetes/pkg/apis/batch/install"
|
||||||
_ "k8s.io/kubernetes/pkg/apis/core/install"
|
_ "k8s.io/kubernetes/pkg/apis/core/install"
|
||||||
"k8s.io/kubernetes/pkg/controller"
|
"k8s.io/kubernetes/pkg/controller"
|
||||||
@ -50,6 +53,9 @@ var (
|
|||||||
errorSchedule = "obvious error schedule"
|
errorSchedule = "obvious error schedule"
|
||||||
// schedule is hourly on the hour
|
// schedule is hourly on the hour
|
||||||
onTheHour = "0 * * * ?"
|
onTheHour = "0 * * * ?"
|
||||||
|
|
||||||
|
errorTimeZone = "bad timezone"
|
||||||
|
newYork = "America/New_York"
|
||||||
)
|
)
|
||||||
|
|
||||||
// returns a cronJob with some fields filled in.
|
// returns a cronJob with some fields filled in.
|
||||||
@ -127,6 +133,19 @@ func justAfterTheHour() *time.Time {
|
|||||||
return &T1
|
return &T1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func justAfterTheHourInZone(tz string) time.Time {
|
||||||
|
location, err := time.LoadLocation(tz)
|
||||||
|
if err != nil {
|
||||||
|
panic("tz error: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
T1, err := time.ParseInLocation(time.RFC3339, "2016-05-19T10:01:00Z", location)
|
||||||
|
if err != nil {
|
||||||
|
panic("test setup error: " + err.Error())
|
||||||
|
}
|
||||||
|
return T1
|
||||||
|
}
|
||||||
|
|
||||||
func justBeforeTheHour() time.Time {
|
func justBeforeTheHour() time.Time {
|
||||||
T1, err := time.Parse(time.RFC3339, "2016-05-19T09:59:00Z")
|
T1, err := time.Parse(time.RFC3339, "2016-05-19T09:59:00Z")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -162,6 +181,7 @@ func TestControllerV2SyncCronJob(t *testing.T) {
|
|||||||
concurrencyPolicy batchv1.ConcurrencyPolicy
|
concurrencyPolicy batchv1.ConcurrencyPolicy
|
||||||
suspend bool
|
suspend bool
|
||||||
schedule string
|
schedule string
|
||||||
|
timeZone *string
|
||||||
deadline int64
|
deadline int64
|
||||||
|
|
||||||
// cj status
|
// cj status
|
||||||
@ -173,6 +193,7 @@ func TestControllerV2SyncCronJob(t *testing.T) {
|
|||||||
now time.Time
|
now time.Time
|
||||||
jobCreateError error
|
jobCreateError error
|
||||||
jobGetErr error
|
jobGetErr error
|
||||||
|
enableTimeZone bool
|
||||||
|
|
||||||
// expectations
|
// expectations
|
||||||
expectCreate bool
|
expectCreate bool
|
||||||
@ -212,6 +233,17 @@ func TestControllerV2SyncCronJob(t *testing.T) {
|
|||||||
expectedWarnings: 1,
|
expectedWarnings: 1,
|
||||||
jobPresentInCJActiveStatus: true,
|
jobPresentInCJActiveStatus: true,
|
||||||
},
|
},
|
||||||
|
"never ran, not valid time zone": {
|
||||||
|
concurrencyPolicy: "Allow",
|
||||||
|
schedule: onTheHour,
|
||||||
|
timeZone: &errorTimeZone,
|
||||||
|
deadline: noDead,
|
||||||
|
jobCreationTime: justAfterThePriorHour(),
|
||||||
|
now: justBeforeTheHour(),
|
||||||
|
enableTimeZone: true,
|
||||||
|
expectedWarnings: 1,
|
||||||
|
jobPresentInCJActiveStatus: true,
|
||||||
|
},
|
||||||
"never ran, not time, A": {
|
"never ran, not time, A": {
|
||||||
concurrencyPolicy: "Allow",
|
concurrencyPolicy: "Allow",
|
||||||
schedule: onTheHour,
|
schedule: onTheHour,
|
||||||
@ -238,6 +270,17 @@ func TestControllerV2SyncCronJob(t *testing.T) {
|
|||||||
expectRequeueAfter: true,
|
expectRequeueAfter: true,
|
||||||
jobPresentInCJActiveStatus: true,
|
jobPresentInCJActiveStatus: true,
|
||||||
},
|
},
|
||||||
|
"never ran, not time in zone": {
|
||||||
|
concurrencyPolicy: "Allow",
|
||||||
|
schedule: onTheHour,
|
||||||
|
timeZone: &newYork,
|
||||||
|
deadline: noDead,
|
||||||
|
jobCreationTime: justAfterThePriorHour(),
|
||||||
|
now: justBeforeTheHour(),
|
||||||
|
enableTimeZone: true,
|
||||||
|
expectRequeueAfter: true,
|
||||||
|
jobPresentInCJActiveStatus: true,
|
||||||
|
},
|
||||||
"never ran, is time, A": {
|
"never ran, is time, A": {
|
||||||
concurrencyPolicy: "Allow",
|
concurrencyPolicy: "Allow",
|
||||||
schedule: onTheHour,
|
schedule: onTheHour,
|
||||||
@ -274,6 +317,48 @@ func TestControllerV2SyncCronJob(t *testing.T) {
|
|||||||
expectUpdateStatus: true,
|
expectUpdateStatus: true,
|
||||||
jobPresentInCJActiveStatus: true,
|
jobPresentInCJActiveStatus: true,
|
||||||
},
|
},
|
||||||
|
"never ran, is time in zone, but time zone disabled": {
|
||||||
|
concurrencyPolicy: "Allow",
|
||||||
|
schedule: onTheHour,
|
||||||
|
timeZone: &newYork,
|
||||||
|
deadline: noDead,
|
||||||
|
jobCreationTime: justAfterThePriorHour(),
|
||||||
|
now: justAfterTheHourInZone(newYork),
|
||||||
|
enableTimeZone: false,
|
||||||
|
expectCreate: true,
|
||||||
|
expectActive: 1,
|
||||||
|
expectRequeueAfter: true,
|
||||||
|
expectUpdateStatus: true,
|
||||||
|
jobPresentInCJActiveStatus: true,
|
||||||
|
},
|
||||||
|
"never ran, is time in zone": {
|
||||||
|
concurrencyPolicy: "Allow",
|
||||||
|
schedule: onTheHour,
|
||||||
|
timeZone: &newYork,
|
||||||
|
deadline: noDead,
|
||||||
|
jobCreationTime: justAfterThePriorHour(),
|
||||||
|
now: justAfterTheHourInZone(newYork),
|
||||||
|
enableTimeZone: true,
|
||||||
|
expectCreate: true,
|
||||||
|
expectActive: 1,
|
||||||
|
expectRequeueAfter: true,
|
||||||
|
expectUpdateStatus: true,
|
||||||
|
jobPresentInCJActiveStatus: true,
|
||||||
|
},
|
||||||
|
"never ran, is time in zone, but TZ is also set in schedule": {
|
||||||
|
concurrencyPolicy: "Allow",
|
||||||
|
schedule: "TZ=UTC " + onTheHour,
|
||||||
|
timeZone: &newYork,
|
||||||
|
deadline: noDead,
|
||||||
|
jobCreationTime: justAfterThePriorHour(),
|
||||||
|
now: justAfterTheHourInZone(newYork),
|
||||||
|
enableTimeZone: true,
|
||||||
|
expectCreate: true,
|
||||||
|
expectedWarnings: 1,
|
||||||
|
expectRequeueAfter: true,
|
||||||
|
expectUpdateStatus: true,
|
||||||
|
jobPresentInCJActiveStatus: true,
|
||||||
|
},
|
||||||
"never ran, is time, suspended": {
|
"never ran, is time, suspended": {
|
||||||
concurrencyPolicy: "Allow",
|
concurrencyPolicy: "Allow",
|
||||||
suspend: true,
|
suspend: true,
|
||||||
@ -820,10 +905,15 @@ func TestControllerV2SyncCronJob(t *testing.T) {
|
|||||||
cj.Spec.ConcurrencyPolicy = tc.concurrencyPolicy
|
cj.Spec.ConcurrencyPolicy = tc.concurrencyPolicy
|
||||||
cj.Spec.Suspend = &tc.suspend
|
cj.Spec.Suspend = &tc.suspend
|
||||||
cj.Spec.Schedule = tc.schedule
|
cj.Spec.Schedule = tc.schedule
|
||||||
|
cj.Spec.TimeZone = tc.timeZone
|
||||||
if tc.deadline != noDead {
|
if tc.deadline != noDead {
|
||||||
cj.Spec.StartingDeadlineSeconds = &tc.deadline
|
cj.Spec.StartingDeadlineSeconds = &tc.deadline
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tc.enableTimeZone {
|
||||||
|
defer featuregatetesting.SetFeatureGateDuringTest(t, feature.DefaultFeatureGate, features.CronJobTimeZone, true)
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
job *batchv1.Job
|
job *batchv1.Job
|
||||||
err error
|
err error
|
||||||
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/types"
|
"k8s.io/apimachinery/pkg/types"
|
||||||
)
|
)
|
||||||
@ -146,7 +146,7 @@ type JobSpec struct {
|
|||||||
|
|
||||||
// Describes the pod that will be created when executing a job.
|
// Describes the pod that will be created when executing a job.
|
||||||
// More info: https://kubernetes.io/docs/concepts/workloads/controllers/jobs-run-to-completion/
|
// More info: https://kubernetes.io/docs/concepts/workloads/controllers/jobs-run-to-completion/
|
||||||
Template v1.PodTemplateSpec `json:"template" protobuf:"bytes,6,opt,name=template"`
|
Template corev1.PodTemplateSpec `json:"template" protobuf:"bytes,6,opt,name=template"`
|
||||||
|
|
||||||
// ttlSecondsAfterFinished limits the lifetime of a Job that has finished
|
// ttlSecondsAfterFinished limits the lifetime of a Job that has finished
|
||||||
// execution (either Complete or Failed). If this field is set,
|
// execution (either Complete or Failed). If this field is set,
|
||||||
@ -304,7 +304,7 @@ type JobCondition struct {
|
|||||||
// Type of job condition, Complete or Failed.
|
// Type of job condition, Complete or Failed.
|
||||||
Type JobConditionType `json:"type" protobuf:"bytes,1,opt,name=type,casttype=JobConditionType"`
|
Type JobConditionType `json:"type" protobuf:"bytes,1,opt,name=type,casttype=JobConditionType"`
|
||||||
// Status of the condition, one of True, False, Unknown.
|
// Status of the condition, one of True, False, Unknown.
|
||||||
Status v1.ConditionStatus `json:"status" protobuf:"bytes,2,opt,name=status,casttype=k8s.io/api/core/v1.ConditionStatus"`
|
Status corev1.ConditionStatus `json:"status" protobuf:"bytes,2,opt,name=status,casttype=k8s.io/api/core/v1.ConditionStatus"`
|
||||||
// Last time the condition was checked.
|
// Last time the condition was checked.
|
||||||
// +optional
|
// +optional
|
||||||
LastProbeTime metav1.Time `json:"lastProbeTime,omitempty" protobuf:"bytes,3,opt,name=lastProbeTime"`
|
LastProbeTime metav1.Time `json:"lastProbeTime,omitempty" protobuf:"bytes,3,opt,name=lastProbeTime"`
|
||||||
@ -375,6 +375,12 @@ type CronJobSpec struct {
|
|||||||
// The schedule in Cron format, see https://en.wikipedia.org/wiki/Cron.
|
// The schedule in Cron format, see https://en.wikipedia.org/wiki/Cron.
|
||||||
Schedule string `json:"schedule" protobuf:"bytes,1,opt,name=schedule"`
|
Schedule string `json:"schedule" protobuf:"bytes,1,opt,name=schedule"`
|
||||||
|
|
||||||
|
// The time zone for the given schedule, see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones.
|
||||||
|
// If not specified, this will rely on the time zone of the kube-controller-manager process.
|
||||||
|
// ALPHA: This field is in alpha and must be enabled via the `CronJobTimeZone` feature gate.
|
||||||
|
// +optional
|
||||||
|
TimeZone *string `json:"timeZone,omitempty" protobuf:"bytes,8,opt,name=timeZone"`
|
||||||
|
|
||||||
// Optional deadline in seconds for starting the job if it misses scheduled
|
// Optional deadline in seconds for starting the job if it misses scheduled
|
||||||
// time for any reason. Missed jobs executions will be counted as failed ones.
|
// time for any reason. Missed jobs executions will be counted as failed ones.
|
||||||
// +optional
|
// +optional
|
||||||
@ -431,7 +437,7 @@ type CronJobStatus struct {
|
|||||||
// A list of pointers to currently running jobs.
|
// A list of pointers to currently running jobs.
|
||||||
// +optional
|
// +optional
|
||||||
// +listType=atomic
|
// +listType=atomic
|
||||||
Active []v1.ObjectReference `json:"active,omitempty" protobuf:"bytes,1,rep,name=active"`
|
Active []corev1.ObjectReference `json:"active,omitempty" protobuf:"bytes,1,rep,name=active"`
|
||||||
|
|
||||||
// Information when was the last time the job was successfully scheduled.
|
// Information when was the last time the job was successfully scheduled.
|
||||||
// +optional
|
// +optional
|
||||||
|
@ -104,6 +104,12 @@ type CronJobSpec struct {
|
|||||||
// The schedule in Cron format, see https://en.wikipedia.org/wiki/Cron.
|
// The schedule in Cron format, see https://en.wikipedia.org/wiki/Cron.
|
||||||
Schedule string `json:"schedule" protobuf:"bytes,1,opt,name=schedule"`
|
Schedule string `json:"schedule" protobuf:"bytes,1,opt,name=schedule"`
|
||||||
|
|
||||||
|
// The time zone for the given schedule, see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones.
|
||||||
|
// If not specified, this will rely on the time zone of the kube-controller-manager process.
|
||||||
|
// ALPHA: This field is in alpha and must be enabled via the `CronJobTimeZone` feature gate.
|
||||||
|
// +optional
|
||||||
|
TimeZone *string `json:"timeZone,omitempty" protobuf:"bytes,8,opt,name=timeZone"`
|
||||||
|
|
||||||
// Optional deadline in seconds for starting the job if it misses scheduled
|
// Optional deadline in seconds for starting the job if it misses scheduled
|
||||||
// time for any reason. Missed jobs executions will be counted as failed ones.
|
// time for any reason. Missed jobs executions will be counted as failed ones.
|
||||||
// +optional
|
// +optional
|
||||||
|
@ -178,6 +178,13 @@ const (
|
|||||||
//
|
//
|
||||||
// Enables server-side field validation.
|
// Enables server-side field validation.
|
||||||
ServerSideFieldValidation featuregate.Feature = "ServerSideFieldValidation"
|
ServerSideFieldValidation featuregate.Feature = "ServerSideFieldValidation"
|
||||||
|
|
||||||
|
// owner: @deejross
|
||||||
|
// kep: http://kep.k8s.io/3140
|
||||||
|
// alpha: v1.24
|
||||||
|
//
|
||||||
|
// Enables support for time zones in CronJobs.
|
||||||
|
CronJobTimeZone featuregate.Feature = "CronJobTimeZone"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@ -207,4 +214,5 @@ var defaultKubernetesFeatureGates = map[featuregate.Feature]featuregate.FeatureS
|
|||||||
CustomResourceValidationExpressions: {Default: false, PreRelease: featuregate.Alpha},
|
CustomResourceValidationExpressions: {Default: false, PreRelease: featuregate.Alpha},
|
||||||
OpenAPIV3: {Default: false, PreRelease: featuregate.Alpha},
|
OpenAPIV3: {Default: false, PreRelease: featuregate.Alpha},
|
||||||
ServerSideFieldValidation: {Default: true, PreRelease: featuregate.Beta},
|
ServerSideFieldValidation: {Default: true, PreRelease: featuregate.Beta},
|
||||||
|
CronJobTimeZone: {Default: false, PreRelease: featuregate.Alpha},
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user