diff --git a/api/openapi-spec/swagger.json b/api/openapi-spec/swagger.json index 39807afb6632..ac984a8f5ad1 100644 --- a/api/openapi-spec/swagger.json +++ b/api/openapi-spec/swagger.json @@ -4369,7 +4369,7 @@ "description": "JobSpec describes how the job execution will look like.", "properties": { "activeDeadlineSeconds": { - "description": "Specifies the duration in seconds relative to the startTime that the job may be active before the system tries to terminate it; value must be positive integer", + "description": "Specifies the duration in seconds relative to the startTime that the job may be continuously active before the system tries to terminate it; value must be positive integer. If a Job is suspended (at creation or through an update), this timer will effectively be stopped and reset when the Job is resumed again.", "format": "int64", "type": "integer" }, @@ -4400,6 +4400,10 @@ "$ref": "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelector", "description": "A label query over pods that should match the pod count. Normally, the system sets this field for you. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors" }, + "suspend": { + "description": "Suspend specifies whether the Job controller should create Pods or not. If a Job is created with suspend set to true, no Pods are created by the Job controller. If a Job is suspended after creation (i.e. the flag goes from false to true), the Job controller will delete all active Pods associated with this Job. Users must design their workload to gracefully handle this. Suspending a Job will reset the StartTime field of the Job, effectively resetting the ActiveDeadlineSeconds timer too. This is an alpha field and requires the SuspendJob feature gate to be enabled; otherwise this field may not be set to true. Defaults to false.", + "type": "boolean" + }, "template": { "$ref": "#/definitions/io.k8s.api.core.v1.PodTemplateSpec", "description": "Describes the pod that will be created when executing a job. More info: https://kubernetes.io/docs/concepts/workloads/controllers/jobs-run-to-completion/" @@ -4432,7 +4436,7 @@ "description": "Represents time when the job was completed. It is not guaranteed to be set in happens-before order across separate operations. It is represented in RFC3339 form and is in UTC. The completion time is only set when the job finishes successfully." }, "conditions": { - "description": "The latest available observations of an object's current state. When a job fails, one of the conditions will have type == \"Failed\". More info: https://kubernetes.io/docs/concepts/workloads/controllers/jobs-run-to-completion/", + "description": "The latest available observations of an object's current state. When a Job fails, one of the conditions will have type \"Failed\" and status true. When a Job is suspended, one of the conditions will have type \"Suspended\" and status true; when the Job is resumed, the status of this condition will become false. When a Job is completed, one of the conditions will have type \"Complete\" and status true. More info: https://kubernetes.io/docs/concepts/workloads/controllers/jobs-run-to-completion/", "items": { "$ref": "#/definitions/io.k8s.api.batch.v1.JobCondition" }, @@ -4448,7 +4452,7 @@ }, "startTime": { "$ref": "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.Time", - "description": "Represents time when the job was acknowledged by the job controller. It is not guaranteed to be set in happens-before order across separate operations. It is represented in RFC3339 form and is in UTC." + "description": "Represents time when the job controller started processing a job. When a Job is created in the suspended state, this field is not set until the first time it is resumed. This field is reset every time a Job is resumed from suspension. It is represented in RFC3339 form and is in UTC." }, "succeeded": { "description": "The number of pods which reached phase Succeeded.", diff --git a/pkg/apis/batch/fuzzer/fuzzer.go b/pkg/apis/batch/fuzzer/fuzzer.go index a9c947d92cd4..154d9f6bfc5f 100644 --- a/pkg/apis/batch/fuzzer/fuzzer.go +++ b/pkg/apis/batch/fuzzer/fuzzer.go @@ -20,14 +20,9 @@ import ( fuzz "github.com/google/gofuzz" runtimeserializer "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/kubernetes/pkg/apis/batch" + "k8s.io/utils/pointer" ) -func newBool(val bool) *bool { - p := new(bool) - *p = val - return p -} - // Funcs returns the fuzzer functions for the batch api group. var Funcs = func(codecs runtimeserializer.CodecFactory) []interface{} { return []interface{}{ @@ -48,7 +43,7 @@ var Funcs = func(codecs runtimeserializer.CodecFactory) []interface{} { j.Parallelism = ¶llelism j.BackoffLimit = &backoffLimit if c.Rand.Int31()%2 == 0 { - j.ManualSelector = newBool(true) + j.ManualSelector = pointer.BoolPtr(true) } else { j.ManualSelector = nil } @@ -57,6 +52,9 @@ var Funcs = func(codecs runtimeserializer.CodecFactory) []interface{} { } else { j.CompletionMode = batch.IndexedCompletion } + // We're fuzzing the internal JobSpec type, not the v1 type, so we don't + // need to fuzz the nil value. + j.Suspend = pointer.BoolPtr(c.RandBool()) }, func(sj *batch.CronJobSpec, c fuzz.Continue) { c.FuzzNoCustom(sj) diff --git a/pkg/apis/batch/types.go b/pkg/apis/batch/types.go index 08c2253a88d9..19357eaec880 100644 --- a/pkg/apis/batch/types.go +++ b/pkg/apis/batch/types.go @@ -119,8 +119,11 @@ type JobSpec struct { // +optional Completions *int32 - // Optional duration in seconds relative to the startTime that the job may be active - // before the system tries to terminate it; value must be positive integer + // Specifies the duration in seconds relative to the startTime that the job + // may be continuously active before the system tries to terminate it; value + // must be positive integer. If a Job is suspended (at creation or through an + // update), this timer will effectively be stopped and reset when the Job is + // resumed again. // +optional ActiveDeadlineSeconds *int64 @@ -187,19 +190,36 @@ type JobSpec struct { // controller skips updates for the Job. // +optional CompletionMode CompletionMode + + // Suspend specifies whether the Job controller should create Pods or not. If + // a Job is created with suspend set to true, no Pods are created by the Job + // controller. If a Job is suspended after creation (i.e. the flag goes from + // false to true), the Job controller will delete all active Pods associated + // with this Job. Users must design their workload to gracefully handle this. + // Suspending a Job will reset the StartTime field of the Job, effectively + // resetting the ActiveDeadlineSeconds timer too. This is an alpha field and + // requires the SuspendJob feature gate to be enabled; otherwise this field + // may not be set to true. Defaults to false. + // +optional + Suspend *bool } // JobStatus represents the current state of a Job. type JobStatus struct { - // The latest available observations of an object's current state. - // When a job fails, one of the conditions will have type == "Failed". + // The latest available observations of an object's current state. When a Job + // fails, one of the conditions will have type "Failed" and status true. When + // a Job is suspended, one of the conditions will have type "Suspended" and + // status true; when the Job is resumed, the status of this condition will + // become false. When a Job is completed, one of the conditions will have + // type "Complete" and status true. // +optional Conditions []JobCondition - // Represents time when the job was acknowledged by the job controller. - // It is not guaranteed to be set in happens-before order across separate operations. - // It is represented in RFC3339 form and is in UTC. + // Represents time when the job controller started processing a job. When a + // Job is created in the suspended state, this field is not set until the + // first time it is resumed. This field is reset every time a Job is resumed + // from suspension. It is represented in RFC3339 form and is in UTC. // +optional StartTime *metav1.Time @@ -238,6 +258,8 @@ type JobConditionType string // These are valid conditions of a job. const ( + // JobSuspended means the job has been suspended. + JobSuspended JobConditionType = "Suspended" // JobComplete means the job has completed its execution. JobComplete JobConditionType = "Complete" // JobFailed means the job has failed its execution. @@ -246,7 +268,7 @@ const ( // JobCondition describes current state of a job. type JobCondition struct { - // Type of job condition, Complete or Failed. + // Type of job condition. Type JobConditionType // Status of the condition, one of True, False, Unknown. Status api.ConditionStatus @@ -319,7 +341,7 @@ type CronJobSpec struct { ConcurrencyPolicy ConcurrencyPolicy // This flag tells the controller to suspend subsequent executions, it does - // not apply to already started executions. Defaults to false. + // not apply to already started executions. Defaults to false. // +optional Suspend *bool diff --git a/pkg/apis/batch/v1/defaults.go b/pkg/apis/batch/v1/defaults.go index a1ac58ad1799..9638950e91b0 100644 --- a/pkg/apis/batch/v1/defaults.go +++ b/pkg/apis/batch/v1/defaults.go @@ -46,6 +46,9 @@ func SetDefaults_Job(obj *batchv1.Job) { if len(obj.Spec.CompletionMode) == 0 { obj.Spec.CompletionMode = batchv1.NonIndexedCompletion } + if obj.Spec.Suspend == nil { + obj.Spec.Suspend = utilpointer.BoolPtr(false) + } } func SetDefaults_CronJob(obj *batchv1.CronJob) { diff --git a/pkg/apis/batch/v1/defaults_test.go b/pkg/apis/batch/v1/defaults_test.go index b20548a10e39..6e1cd173341d 100644 --- a/pkg/apis/batch/v1/defaults_test.go +++ b/pkg/apis/batch/v1/defaults_test.go @@ -20,6 +20,7 @@ import ( "reflect" "testing" + "github.com/google/go-cmp/cmp" batchv1 "k8s.io/api/batch/v1" "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -27,7 +28,7 @@ import ( "k8s.io/kubernetes/pkg/api/legacyscheme" _ "k8s.io/kubernetes/pkg/apis/batch/install" _ "k8s.io/kubernetes/pkg/apis/core/install" - utilpointer "k8s.io/utils/pointer" + "k8s.io/utils/pointer" . "k8s.io/kubernetes/pkg/apis/batch/v1" ) @@ -49,15 +50,36 @@ func TestSetDefaultJob(t *testing.T) { }, expected: &batchv1.Job{ Spec: batchv1.JobSpec{ - Completions: utilpointer.Int32Ptr(1), - Parallelism: utilpointer.Int32Ptr(1), - BackoffLimit: utilpointer.Int32Ptr(6), + Completions: pointer.Int32Ptr(1), + Parallelism: pointer.Int32Ptr(1), + BackoffLimit: pointer.Int32Ptr(6), CompletionMode: batchv1.NonIndexedCompletion, + Suspend: pointer.BoolPtr(false), }, }, expectLabels: true, }, - "All unspecified -> all integers are defaulted and no default labels": { + "suspend set, everything else is defaulted": { + original: &batchv1.Job{ + Spec: batchv1.JobSpec{ + Suspend: pointer.BoolPtr(true), + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: defaultLabels}, + }, + }, + }, + expected: &batchv1.Job{ + Spec: batchv1.JobSpec{ + Completions: pointer.Int32Ptr(1), + Parallelism: pointer.Int32Ptr(1), + BackoffLimit: pointer.Int32Ptr(6), + CompletionMode: batchv1.NonIndexedCompletion, + Suspend: pointer.BoolPtr(true), + }, + }, + expectLabels: true, + }, + "All unspecified -> all pointers, CompletionMode are defaulted and no default labels": { original: &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ Labels: map[string]string{"mylabel": "myvalue"}, @@ -70,17 +92,18 @@ func TestSetDefaultJob(t *testing.T) { }, expected: &batchv1.Job{ Spec: batchv1.JobSpec{ - Completions: utilpointer.Int32Ptr(1), - Parallelism: utilpointer.Int32Ptr(1), - BackoffLimit: utilpointer.Int32Ptr(6), + Completions: pointer.Int32Ptr(1), + Parallelism: pointer.Int32Ptr(1), + BackoffLimit: pointer.Int32Ptr(6), CompletionMode: batchv1.NonIndexedCompletion, + Suspend: pointer.BoolPtr(false), }, }, }, "WQ: Parallelism explicitly 0 and completions unset -> BackoffLimit is defaulted": { original: &batchv1.Job{ Spec: batchv1.JobSpec{ - Parallelism: utilpointer.Int32Ptr(0), + Parallelism: pointer.Int32Ptr(0), Template: v1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: defaultLabels}, }, @@ -88,9 +111,10 @@ func TestSetDefaultJob(t *testing.T) { }, expected: &batchv1.Job{ Spec: batchv1.JobSpec{ - Parallelism: utilpointer.Int32Ptr(0), - BackoffLimit: utilpointer.Int32Ptr(6), + Parallelism: pointer.Int32Ptr(0), + BackoffLimit: pointer.Int32Ptr(6), CompletionMode: batchv1.NonIndexedCompletion, + Suspend: pointer.BoolPtr(false), }, }, expectLabels: true, @@ -98,7 +122,7 @@ func TestSetDefaultJob(t *testing.T) { "WQ: Parallelism explicitly 2 and completions unset -> BackoffLimit is defaulted": { original: &batchv1.Job{ Spec: batchv1.JobSpec{ - Parallelism: utilpointer.Int32Ptr(2), + Parallelism: pointer.Int32Ptr(2), Template: v1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: defaultLabels}, }, @@ -106,9 +130,10 @@ func TestSetDefaultJob(t *testing.T) { }, expected: &batchv1.Job{ Spec: batchv1.JobSpec{ - Parallelism: utilpointer.Int32Ptr(2), - BackoffLimit: utilpointer.Int32Ptr(6), + Parallelism: pointer.Int32Ptr(2), + BackoffLimit: pointer.Int32Ptr(6), CompletionMode: batchv1.NonIndexedCompletion, + Suspend: pointer.BoolPtr(false), }, }, expectLabels: true, @@ -116,7 +141,7 @@ func TestSetDefaultJob(t *testing.T) { "Completions explicitly 2 and others unset -> parallelism and BackoffLimit are defaulted": { original: &batchv1.Job{ Spec: batchv1.JobSpec{ - Completions: utilpointer.Int32Ptr(2), + Completions: pointer.Int32Ptr(2), Template: v1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: defaultLabels}, }, @@ -124,10 +149,11 @@ func TestSetDefaultJob(t *testing.T) { }, expected: &batchv1.Job{ Spec: batchv1.JobSpec{ - Completions: utilpointer.Int32Ptr(2), - Parallelism: utilpointer.Int32Ptr(1), - BackoffLimit: utilpointer.Int32Ptr(6), + Completions: pointer.Int32Ptr(2), + Parallelism: pointer.Int32Ptr(1), + BackoffLimit: pointer.Int32Ptr(6), CompletionMode: batchv1.NonIndexedCompletion, + Suspend: pointer.BoolPtr(false), }, }, expectLabels: true, @@ -135,7 +161,7 @@ func TestSetDefaultJob(t *testing.T) { "BackoffLimit explicitly 5 and others unset -> parallelism and completions are defaulted": { original: &batchv1.Job{ Spec: batchv1.JobSpec{ - BackoffLimit: utilpointer.Int32Ptr(5), + BackoffLimit: pointer.Int32Ptr(5), Template: v1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: defaultLabels}, }, @@ -143,10 +169,11 @@ func TestSetDefaultJob(t *testing.T) { }, expected: &batchv1.Job{ Spec: batchv1.JobSpec{ - Completions: utilpointer.Int32Ptr(1), - Parallelism: utilpointer.Int32Ptr(1), - BackoffLimit: utilpointer.Int32Ptr(5), + Completions: pointer.Int32Ptr(1), + Parallelism: pointer.Int32Ptr(1), + BackoffLimit: pointer.Int32Ptr(5), CompletionMode: batchv1.NonIndexedCompletion, + Suspend: pointer.BoolPtr(false), }, }, expectLabels: true, @@ -154,10 +181,11 @@ func TestSetDefaultJob(t *testing.T) { "All set -> no change": { original: &batchv1.Job{ Spec: batchv1.JobSpec{ - Completions: utilpointer.Int32Ptr(8), - Parallelism: utilpointer.Int32Ptr(9), - BackoffLimit: utilpointer.Int32Ptr(10), + Completions: pointer.Int32Ptr(8), + Parallelism: pointer.Int32Ptr(9), + BackoffLimit: pointer.Int32Ptr(10), CompletionMode: batchv1.NonIndexedCompletion, + Suspend: pointer.BoolPtr(true), Template: v1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: defaultLabels}, }, @@ -165,10 +193,11 @@ func TestSetDefaultJob(t *testing.T) { }, expected: &batchv1.Job{ Spec: batchv1.JobSpec{ - Completions: utilpointer.Int32Ptr(8), - Parallelism: utilpointer.Int32Ptr(9), - BackoffLimit: utilpointer.Int32Ptr(10), + Completions: pointer.Int32Ptr(8), + Parallelism: pointer.Int32Ptr(9), + BackoffLimit: pointer.Int32Ptr(10), CompletionMode: batchv1.NonIndexedCompletion, + Suspend: pointer.BoolPtr(true), Template: v1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: defaultLabels}, }, @@ -179,10 +208,11 @@ func TestSetDefaultJob(t *testing.T) { "All set, flipped -> no change": { original: &batchv1.Job{ Spec: batchv1.JobSpec{ - Completions: utilpointer.Int32Ptr(11), - Parallelism: utilpointer.Int32Ptr(10), - BackoffLimit: utilpointer.Int32Ptr(9), + Completions: pointer.Int32Ptr(11), + Parallelism: pointer.Int32Ptr(10), + BackoffLimit: pointer.Int32Ptr(9), CompletionMode: batchv1.IndexedCompletion, + Suspend: pointer.BoolPtr(true), Template: v1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: defaultLabels}, }, @@ -190,10 +220,11 @@ func TestSetDefaultJob(t *testing.T) { }, expected: &batchv1.Job{ Spec: batchv1.JobSpec{ - Completions: utilpointer.Int32Ptr(11), - Parallelism: utilpointer.Int32Ptr(10), - BackoffLimit: utilpointer.Int32Ptr(9), + Completions: pointer.Int32Ptr(11), + Parallelism: pointer.Int32Ptr(10), + BackoffLimit: pointer.Int32Ptr(9), CompletionMode: batchv1.IndexedCompletion, + Suspend: pointer.BoolPtr(true), }, }, expectLabels: true, @@ -211,6 +242,9 @@ func TestSetDefaultJob(t *testing.T) { t.Fatalf("Unexpected object: %v", actual) } + if diff := cmp.Diff(expected.Spec.Suspend, actual.Spec.Suspend); diff != "" { + t.Errorf(".spec.suspend does not match; -want,+got:\n%s", diff) + } validateDefaultInt32(t, "Completions", actual.Spec.Completions, expected.Spec.Completions) validateDefaultInt32(t, "Parallelism", actual.Spec.Parallelism, expected.Spec.Parallelism) validateDefaultInt32(t, "BackoffLimit", actual.Spec.BackoffLimit, expected.Spec.BackoffLimit) @@ -271,8 +305,8 @@ func TestSetDefaultCronJob(t *testing.T) { Spec: batchv1.CronJobSpec{ ConcurrencyPolicy: batchv1.AllowConcurrent, Suspend: newBool(false), - SuccessfulJobsHistoryLimit: utilpointer.Int32Ptr(3), - FailedJobsHistoryLimit: utilpointer.Int32Ptr(1), + SuccessfulJobsHistoryLimit: pointer.Int32Ptr(3), + FailedJobsHistoryLimit: pointer.Int32Ptr(1), }, }, }, @@ -281,16 +315,16 @@ func TestSetDefaultCronJob(t *testing.T) { Spec: batchv1.CronJobSpec{ ConcurrencyPolicy: batchv1.ForbidConcurrent, Suspend: newBool(true), - SuccessfulJobsHistoryLimit: utilpointer.Int32Ptr(5), - FailedJobsHistoryLimit: utilpointer.Int32Ptr(5), + SuccessfulJobsHistoryLimit: pointer.Int32Ptr(5), + FailedJobsHistoryLimit: pointer.Int32Ptr(5), }, }, expected: &batchv1.CronJob{ Spec: batchv1.CronJobSpec{ ConcurrencyPolicy: batchv1.ForbidConcurrent, Suspend: newBool(true), - SuccessfulJobsHistoryLimit: utilpointer.Int32Ptr(5), - FailedJobsHistoryLimit: utilpointer.Int32Ptr(5), + SuccessfulJobsHistoryLimit: pointer.Int32Ptr(5), + FailedJobsHistoryLimit: pointer.Int32Ptr(5), }, }, }, diff --git a/pkg/apis/batch/v1/zz_generated.conversion.go b/pkg/apis/batch/v1/zz_generated.conversion.go index 4d60279c11a6..eae9cda5ec88 100644 --- a/pkg/apis/batch/v1/zz_generated.conversion.go +++ b/pkg/apis/batch/v1/zz_generated.conversion.go @@ -393,6 +393,7 @@ func autoConvert_v1_JobSpec_To_batch_JobSpec(in *v1.JobSpec, out *batch.JobSpec, } out.TTLSecondsAfterFinished = (*int32)(unsafe.Pointer(in.TTLSecondsAfterFinished)) out.CompletionMode = batch.CompletionMode(in.CompletionMode) + out.Suspend = (*bool)(unsafe.Pointer(in.Suspend)) return nil } @@ -408,6 +409,7 @@ func autoConvert_batch_JobSpec_To_v1_JobSpec(in *batch.JobSpec, out *v1.JobSpec, } out.TTLSecondsAfterFinished = (*int32)(unsafe.Pointer(in.TTLSecondsAfterFinished)) out.CompletionMode = v1.CompletionMode(in.CompletionMode) + out.Suspend = (*bool)(unsafe.Pointer(in.Suspend)) return nil } diff --git a/pkg/apis/batch/zz_generated.deepcopy.go b/pkg/apis/batch/zz_generated.deepcopy.go index 63c935c31362..b1e1f8e814c9 100644 --- a/pkg/apis/batch/zz_generated.deepcopy.go +++ b/pkg/apis/batch/zz_generated.deepcopy.go @@ -271,6 +271,11 @@ func (in *JobSpec) DeepCopyInto(out *JobSpec) { *out = new(int32) **out = **in } + if in.Suspend != nil { + in, out := &in.Suspend, &out.Suspend + *out = new(bool) + **out = **in + } return } diff --git a/pkg/controller/job/job_controller.go b/pkg/controller/job/job_controller.go index 560fdac815e3..d23cd770ea0e 100644 --- a/pkg/controller/job/job_controller.go +++ b/pkg/controller/job/job_controller.go @@ -487,9 +487,9 @@ func (jm *Controller) syncJob(key string) (bool, error) { activePods := controller.FilterActivePods(pods) active := int32(len(activePods)) succeeded, failed := getStatus(&job, pods) - conditions := len(job.Status.Conditions) - // job first start - if job.Status.StartTime == nil { + // Job first start. Set StartTime and start the ActiveDeadlineSeconds timer + // only if the job is not in the suspended state. + if job.Status.StartTime == nil && !jobSuspended(&job) { now := metav1.Now() job.Status.StartTime = &now // enqueue a sync to check if job past ActiveDeadlineSeconds @@ -524,6 +524,8 @@ func (jm *Controller) syncJob(key string) (bool, error) { failureMessage = "Job was active longer than specified deadline" } + jobConditionsChanged := false + manageJobCalled := false if jobFailed { // TODO(#28486): Account for pod failures in status once we can track // completions without lingering pods. @@ -532,11 +534,13 @@ func (jm *Controller) syncJob(key string) (bool, error) { // update status values accordingly failed += active active = 0 - job.Status.Conditions = append(job.Status.Conditions, newCondition(batch.JobFailed, failureReason, failureMessage)) + job.Status.Conditions = append(job.Status.Conditions, newCondition(batch.JobFailed, v1.ConditionTrue, failureReason, failureMessage)) + jobConditionsChanged = true jm.recorder.Event(&job, v1.EventTypeWarning, failureReason, failureMessage) } else { if jobNeedsSync && job.DeletionTimestamp == nil { active, manageJobErr = jm.manageJob(&job, activePods, succeeded, pods) + manageJobCalled = true } completions := succeeded complete := false @@ -566,10 +570,40 @@ func (jm *Controller) syncJob(key string) (bool, error) { } } if complete { - job.Status.Conditions = append(job.Status.Conditions, newCondition(batch.JobComplete, "", "")) + job.Status.Conditions = append(job.Status.Conditions, newCondition(batch.JobComplete, v1.ConditionTrue, "", "")) + jobConditionsChanged = true now := metav1.Now() job.Status.CompletionTime = &now jm.recorder.Event(&job, v1.EventTypeNormal, "Completed", "Job completed") + } else if utilfeature.DefaultFeatureGate.Enabled(features.SuspendJob) && manageJobCalled { + // Update the conditions / emit events only if manageJob was called in + // this syncJob. Otherwise wait for the right syncJob call to make + // updates. + if job.Spec.Suspend != nil && *job.Spec.Suspend { + // Job can be in the suspended state only if it is NOT completed. + var isUpdated bool + job.Status.Conditions, isUpdated = ensureJobConditionStatus(job.Status.Conditions, batch.JobSuspended, v1.ConditionTrue, "JobSuspended", "Job suspended") + if isUpdated { + jobConditionsChanged = true + jm.recorder.Event(&job, v1.EventTypeNormal, "Suspended", "Job suspended") + } + } else { + // Job not suspended. + var isUpdated bool + job.Status.Conditions, isUpdated = ensureJobConditionStatus(job.Status.Conditions, batch.JobSuspended, v1.ConditionFalse, "JobResumed", "Job resumed") + if isUpdated { + jobConditionsChanged = true + jm.recorder.Event(&job, v1.EventTypeNormal, "Resumed", "Job resumed") + // Resumed jobs will always reset StartTime to current time. This is + // done because the ActiveDeadlineSeconds timer shouldn't go off + // whilst the Job is still suspended and resetting StartTime is + // consistent with resuming a Job created in the suspended state. + // (ActiveDeadlineSeconds is interpreted as the number of seconds a + // Job is continuously active.) + now := metav1.Now() + job.Status.StartTime = &now + } + } } } @@ -583,7 +617,7 @@ func (jm *Controller) syncJob(key string) (bool, error) { } // no need to update the job if the status hasn't changed since last time - if job.Status.Active != active || job.Status.Succeeded != succeeded || job.Status.Failed != failed || len(job.Status.Conditions) != conditions { + if job.Status.Active != active || job.Status.Succeeded != succeeded || job.Status.Failed != failed || jobConditionsChanged { job.Status.Active = active job.Status.Succeeded = succeeded job.Status.Failed = failed @@ -660,9 +694,11 @@ func pastBackoffLimitOnFailure(job *batch.Job, pods []*v1.Pod) bool { return result >= *job.Spec.BackoffLimit } -// pastActiveDeadline checks if job has ActiveDeadlineSeconds field set and if it is exceeded. +// pastActiveDeadline checks if job has ActiveDeadlineSeconds field set and if +// it is exceeded. If the job is currently suspended, the function will always +// return false. func pastActiveDeadline(job *batch.Job) bool { - if job.Spec.ActiveDeadlineSeconds == nil || job.Status.StartTime == nil { + if job.Spec.ActiveDeadlineSeconds == nil || job.Status.StartTime == nil || jobSuspended(job) { return false } now := metav1.Now() @@ -672,10 +708,10 @@ func pastActiveDeadline(job *batch.Job) bool { return duration >= allowedDuration } -func newCondition(conditionType batch.JobConditionType, reason, message string) batch.JobCondition { +func newCondition(conditionType batch.JobConditionType, status v1.ConditionStatus, reason, message string) batch.JobCondition { return batch.JobCondition{ Type: conditionType, - Status: v1.ConditionTrue, + Status: status, LastProbeTime: metav1.Now(), LastTransitionTime: metav1.Now(), Reason: reason, @@ -690,6 +726,12 @@ func getStatus(job *batch.Job, pods []*v1.Pod) (succeeded, failed int32) { return } +// jobSuspended returns whether a Job is suspended while taking the feature +// gate into account. +func jobSuspended(job *batch.Job) bool { + return utilfeature.DefaultFeatureGate.Enabled(features.SuspendJob) && job.Spec.Suspend != nil && *job.Spec.Suspend +} + // manageJob is the core method responsible for managing the number of running // pods according to what is specified in the job.Spec. // Does NOT modify . @@ -702,6 +744,15 @@ func (jm *Controller) manageJob(job *batch.Job, activePods []*v1.Pod, succeeded return 0, nil } + if jobSuspended(job) { + klog.V(4).InfoS("Deleting all active pods in suspended job", "job", klog.KObj(job), "active", active) + podsToDelete := activePodsForRemoval(job, activePods, int(active)) + jm.expectations.ExpectDeletions(jobKey, len(podsToDelete)) + removed, err := jm.deleteJobPods(job, jobKey, podsToDelete) + active -= removed + return active, err + } + rmAtLeast := active - parallelism if rmAtLeast < 0 { rmAtLeast = 0 @@ -709,7 +760,7 @@ func (jm *Controller) manageJob(job *batch.Job, activePods []*v1.Pod, succeeded podsToDelete := activePodsForRemoval(job, activePods, int(rmAtLeast)) if len(podsToDelete) > 0 { jm.expectations.ExpectDeletions(jobKey, len(podsToDelete)) - klog.V(4).InfoS("Too many pods running for job", "job", klog.KObj(job), "deleted", len(podsToDelete), "target", parallelism) + klog.V(4).InfoS("Too many pods running for job", "job", klog.KObj(job), "deleted", rmAtLeast, "target", parallelism) removed, err := jm.deleteJobPods(job, jobKey, podsToDelete) active -= removed if err != nil { @@ -910,3 +961,29 @@ func errorFromChannel(errCh <-chan error) error { } return nil } + +// ensureJobConditionStatus appends or updates an existing job condition of the +// given type with the given status value. Note that this function will not +// append to the conditions list if the new condition's status is false +// (because going from nothing to false is meaningless); it can, however, +// update the status condition to false. The function returns a bool to let the +// caller know if the list was changed (either appended or updated). +func ensureJobConditionStatus(list []batch.JobCondition, cType batch.JobConditionType, status v1.ConditionStatus, reason, message string) ([]batch.JobCondition, bool) { + for i := range list { + if list[i].Type == cType { + if list[i].Status != status || list[i].Reason != reason || list[i].Message != message { + list[i].Status = status + list[i].LastTransitionTime = metav1.Now() + list[i].Reason = reason + list[i].Message = message + return list, true + } + return list, false + } + } + // A condition with that type doesn't exist in the list. + if status != v1.ConditionFalse { + return append(list, newCondition(cType, status, reason, message)), true + } + return list, false +} diff --git a/pkg/controller/job/job_controller_test.go b/pkg/controller/job/job_controller_test.go index 708d4b572299..16ca21c54d85 100644 --- a/pkg/controller/job/job_controller_test.go +++ b/pkg/controller/job/job_controller_test.go @@ -47,6 +47,7 @@ import ( "k8s.io/kubernetes/pkg/controller" "k8s.io/kubernetes/pkg/controller/testutil" "k8s.io/kubernetes/pkg/features" + "k8s.io/utils/pointer" ) var alwaysReady = func() bool { return true } @@ -156,6 +157,7 @@ func setPodsStatusesWithIndexes(podIndexer cache.Indexer, job *batch.Job, status func TestControllerSyncJob(t *testing.T) { jobConditionComplete := batch.JobComplete jobConditionFailed := batch.JobFailed + jobConditionSuspended := batch.JobSuspended testCases := map[string]struct { // job setup @@ -165,15 +167,18 @@ func TestControllerSyncJob(t *testing.T) { deleting bool podLimit int completionMode batch.CompletionMode + wasSuspended bool + suspend bool // pod setup - podControllerError error - jobKeyForget bool - pendingPods int32 - activePods int32 - succeededPods int32 - failedPods int32 - podsWithIndexes []indexPhase + podControllerError error + jobKeyForget bool + pendingPods int32 + activePods int32 + succeededPods int32 + failedPods int32 + podsWithIndexes []indexPhase + fakeExpectationAtCreation int32 // negative: ExpectDeletions, positive: ExpectCreations // expectations expectedCreations int32 @@ -183,11 +188,13 @@ func TestControllerSyncJob(t *testing.T) { expectedCompletedIdxs string expectedFailed int32 expectedCondition *batch.JobConditionType + expectedConditionStatus v1.ConditionStatus expectedConditionReason string expectedCreatedIndexes sets.Int // features indexedJobEnabled bool + suspendJobEnabled bool }{ "job start": { parallelism: 2, @@ -334,24 +341,26 @@ func TestControllerSyncJob(t *testing.T) { expectedSucceeded: 1, }, "WQ job all finished": { - parallelism: 2, - completions: -1, - backoffLimit: 6, - jobKeyForget: true, - succeededPods: 2, - expectedSucceeded: 2, - expectedCondition: &jobConditionComplete, + parallelism: 2, + completions: -1, + backoffLimit: 6, + jobKeyForget: true, + succeededPods: 2, + expectedSucceeded: 2, + expectedCondition: &jobConditionComplete, + expectedConditionStatus: v1.ConditionTrue, }, "WQ job all finished despite one failure": { - parallelism: 2, - completions: -1, - backoffLimit: 6, - jobKeyForget: true, - succeededPods: 1, - failedPods: 1, - expectedSucceeded: 1, - expectedFailed: 1, - expectedCondition: &jobConditionComplete, + parallelism: 2, + completions: -1, + backoffLimit: 6, + jobKeyForget: true, + succeededPods: 1, + failedPods: 1, + expectedSucceeded: 1, + expectedFailed: 1, + expectedCondition: &jobConditionComplete, + expectedConditionStatus: v1.ConditionTrue, }, "more active pods than completions": { parallelism: 2, @@ -401,6 +410,7 @@ func TestControllerSyncJob(t *testing.T) { failedPods: 1, expectedFailed: 1, expectedCondition: &jobConditionFailed, + expectedConditionStatus: v1.ConditionTrue, expectedConditionReason: "BackoffLimitExceeded", }, "indexed job start": { @@ -510,11 +520,78 @@ func TestControllerSyncJob(t *testing.T) { // No status updates. indexedJobEnabled: false, }, + "suspending a job with satisfied expectations": { + // Suspended Job should delete active pods when expectations are + // satisfied. + suspendJobEnabled: true, + suspend: true, + parallelism: 2, + activePods: 2, // parallelism == active, expectations satisfied + completions: 4, + backoffLimit: 6, + jobKeyForget: true, + expectedCreations: 0, + expectedDeletions: 2, + expectedActive: 0, + expectedCondition: &jobConditionSuspended, + expectedConditionStatus: v1.ConditionTrue, + expectedConditionReason: "JobSuspended", + }, + "suspending a job with unsatisfied expectations": { + // Unlike the previous test, we expect the controller to NOT suspend the + // Job in the syncJob call because the controller will wait for + // expectations to be satisfied first. The next syncJob call (not tested + // here) will be the same as the previous test. + suspendJobEnabled: true, + suspend: true, + parallelism: 2, + activePods: 3, // active > parallelism, expectations unsatisfied + fakeExpectationAtCreation: -1, // the controller is expecting a deletion + completions: 4, + backoffLimit: 6, + jobKeyForget: true, + expectedCreations: 0, + expectedDeletions: 0, + expectedActive: 3, + }, + "resuming a suspended job": { + suspendJobEnabled: true, + wasSuspended: true, + suspend: false, + parallelism: 2, + completions: 4, + backoffLimit: 6, + jobKeyForget: true, + expectedCreations: 2, + expectedDeletions: 0, + expectedActive: 2, + expectedCondition: &jobConditionSuspended, + expectedConditionStatus: v1.ConditionFalse, + expectedConditionReason: "JobResumed", + }, + "suspending a deleted job": { + // We would normally expect the active pods to be deleted (see a few test + // cases above), but since this job is being deleted, we don't expect + // anything changed here from before the job was suspended. The + // JobSuspended condition is also missing. + suspendJobEnabled: true, + suspend: true, + deleting: true, + parallelism: 2, + activePods: 2, // parallelism == active, expectations satisfied + completions: 4, + backoffLimit: 6, + jobKeyForget: true, + expectedCreations: 0, + expectedDeletions: 0, + expectedActive: 2, + }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { defer featuregatetesting.SetFeatureGateDuringTest(t, feature.DefaultFeatureGate, features.IndexedJob, tc.indexedJobEnabled)() + defer featuregatetesting.SetFeatureGateDuringTest(t, feature.DefaultFeatureGate, features.SuspendJob, tc.suspendJobEnabled)() // job manager setup clientSet := clientset.NewForConfigOrDie(&restclient.Config{Host: "", ContentConfig: restclient.ContentConfig{GroupVersion: &schema.GroupVersion{Group: "", Version: "v1"}}}) @@ -526,6 +603,19 @@ func TestControllerSyncJob(t *testing.T) { // job & pods setup job := newJob(tc.parallelism, tc.completions, tc.backoffLimit, tc.completionMode) + job.Spec.Suspend = pointer.BoolPtr(tc.suspend) + key, err := controller.KeyFunc(job) + if err != nil { + t.Errorf("Unexpected error getting job key: %v", err) + } + if tc.fakeExpectationAtCreation < 0 { + manager.expectations.ExpectDeletions(key, int(-tc.fakeExpectationAtCreation)) + } else if tc.fakeExpectationAtCreation > 0 { + manager.expectations.ExpectCreations(key, int(tc.fakeExpectationAtCreation)) + } + if tc.wasSuspended { + job.Status.Conditions = append(job.Status.Conditions, newCondition(batch.JobSuspended, v1.ConditionTrue, "JobSuspended", "Job suspended")) + } if tc.deleting { now := metav1.Now() job.DeletionTimestamp = &now @@ -608,13 +698,19 @@ func TestControllerSyncJob(t *testing.T) { if actual.Status.Failed != tc.expectedFailed { t.Errorf("Unexpected number of failed pods. Expected %d, saw %d\n", tc.expectedFailed, actual.Status.Failed) } - if actual.Status.StartTime == nil && tc.indexedJobEnabled { + if actual.Status.StartTime != nil && tc.suspend { + t.Error("Unexpected .status.startTime not nil when suspend is true") + } + if actual.Status.StartTime == nil && tc.indexedJobEnabled && !tc.suspend { t.Error("Missing .status.startTime") } // validate conditions - if tc.expectedCondition != nil && !getCondition(actual, *tc.expectedCondition, tc.expectedConditionReason) { + if tc.expectedCondition != nil && !getCondition(actual, *tc.expectedCondition, tc.expectedConditionStatus, tc.expectedConditionReason) { t.Errorf("Expected completion condition. Got %#v", actual.Status.Conditions) } + if tc.expectedCondition == nil && tc.suspend && len(actual.Status.Conditions) != 0 { + t.Errorf("Unexpected conditions %v", actual.Status.Conditions) + } // validate slow start expectedLimit := 0 for pass := uint8(0); expectedLimit <= tc.podLimit; pass++ { @@ -652,6 +748,7 @@ func TestSyncJobPastDeadline(t *testing.T) { activeDeadlineSeconds int64 startTime int64 backoffLimit int32 + suspend bool // pod setup activePods int32 @@ -664,7 +761,11 @@ func TestSyncJobPastDeadline(t *testing.T) { expectedActive int32 expectedSucceeded int32 expectedFailed int32 + expectedCondition batch.JobConditionType expectedConditionReason string + + // features + suspendJobEnabled bool }{ "activeDeadlineSeconds less than single pod execution": { parallelism: 1, @@ -676,6 +777,7 @@ func TestSyncJobPastDeadline(t *testing.T) { expectedForGetKey: true, expectedDeletions: 1, expectedFailed: 1, + expectedCondition: batch.JobFailed, expectedConditionReason: "DeadlineExceeded", }, "activeDeadlineSeconds bigger than single pod execution": { @@ -690,6 +792,7 @@ func TestSyncJobPastDeadline(t *testing.T) { expectedDeletions: 1, expectedSucceeded: 1, expectedFailed: 1, + expectedCondition: batch.JobFailed, expectedConditionReason: "DeadlineExceeded", }, "activeDeadlineSeconds times-out before any pod starts": { @@ -699,6 +802,7 @@ func TestSyncJobPastDeadline(t *testing.T) { startTime: 10, backoffLimit: 6, expectedForGetKey: true, + expectedCondition: batch.JobFailed, expectedConditionReason: "DeadlineExceeded", }, "activeDeadlineSeconds with backofflimit reach": { @@ -709,12 +813,27 @@ func TestSyncJobPastDeadline(t *testing.T) { failedPods: 1, expectedForGetKey: true, expectedFailed: 1, + expectedCondition: batch.JobFailed, expectedConditionReason: "BackoffLimitExceeded", }, + "activeDeadlineSeconds is not triggered when Job is suspended": { + suspendJobEnabled: true, + suspend: true, + parallelism: 1, + completions: 2, + activeDeadlineSeconds: 10, + startTime: 15, + backoffLimit: 6, + expectedForGetKey: true, + expectedCondition: batch.JobSuspended, + expectedConditionReason: "JobSuspended", + }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, feature.DefaultFeatureGate, features.SuspendJob, tc.suspendJobEnabled)() + // job manager setup clientSet := clientset.NewForConfigOrDie(&restclient.Config{Host: "", ContentConfig: restclient.ContentConfig{GroupVersion: &schema.GroupVersion{Group: "", Version: "v1"}}}) manager, sharedInformerFactory := newControllerFromClient(clientSet, controller.NoResyncPeriodFunc) @@ -731,6 +850,7 @@ func TestSyncJobPastDeadline(t *testing.T) { // job & pods setup job := newJob(tc.parallelism, tc.completions, tc.backoffLimit, batch.NonIndexedCompletion) job.Spec.ActiveDeadlineSeconds = &tc.activeDeadlineSeconds + job.Spec.Suspend = pointer.BoolPtr(tc.suspend) start := metav1.Unix(metav1.Now().Time.Unix()-tc.startTime, 0) job.Status.StartTime = &start sharedInformerFactory.Batch().V1().Jobs().Informer().GetIndexer().Add(job) @@ -766,16 +886,16 @@ func TestSyncJobPastDeadline(t *testing.T) { t.Error("Missing .status.startTime") } // validate conditions - if !getCondition(actual, batch.JobFailed, tc.expectedConditionReason) { + if !getCondition(actual, tc.expectedCondition, v1.ConditionTrue, tc.expectedConditionReason) { t.Errorf("Expected fail condition. Got %#v", actual.Status.Conditions) } }) } } -func getCondition(job *batch.Job, condition batch.JobConditionType, reason string) bool { +func getCondition(job *batch.Job, condition batch.JobConditionType, status v1.ConditionStatus, reason string) bool { for _, v := range job.Status.Conditions { - if v.Type == condition && v.Status == v1.ConditionTrue && v.Reason == reason { + if v.Type == condition && v.Status == status && v.Reason == reason { return true } } @@ -800,7 +920,7 @@ func TestSyncPastDeadlineJobFinished(t *testing.T) { job.Spec.ActiveDeadlineSeconds = &activeDeadlineSeconds start := metav1.Unix(metav1.Now().Time.Unix()-15, 0) job.Status.StartTime = &start - job.Status.Conditions = append(job.Status.Conditions, newCondition(batch.JobFailed, "DeadlineExceeded", "Job was active longer than specified deadline")) + job.Status.Conditions = append(job.Status.Conditions, newCondition(batch.JobFailed, v1.ConditionTrue, "DeadlineExceeded", "Job was active longer than specified deadline")) sharedInformerFactory.Batch().V1().Jobs().Informer().GetIndexer().Add(job) forget, err := manager.syncJob(testutil.GetKey(job, t)) if err != nil { @@ -829,7 +949,7 @@ func TestSyncJobComplete(t *testing.T) { manager.jobStoreSynced = alwaysReady job := newJob(1, 1, 6, batch.NonIndexedCompletion) - job.Status.Conditions = append(job.Status.Conditions, newCondition(batch.JobComplete, "", "")) + job.Status.Conditions = append(job.Status.Conditions, newCondition(batch.JobComplete, v1.ConditionTrue, "", "")) sharedInformerFactory.Batch().V1().Jobs().Informer().GetIndexer().Add(job) forget, err := manager.syncJob(testutil.GetKey(job, t)) if err != nil { @@ -1572,7 +1692,7 @@ func TestJobBackoffReset(t *testing.T) { if retries != 0 { t.Errorf("%s: expected exactly 0 retries, got %d", name, retries) } - if getCondition(actual, batch.JobFailed, "BackoffLimitExceeded") { + if getCondition(actual, batch.JobFailed, v1.ConditionTrue, "BackoffLimitExceeded") { t.Errorf("%s: unexpected job failure", name) } } @@ -1760,7 +1880,7 @@ func TestJobBackoffForOnFailure(t *testing.T) { t.Errorf("unexpected number of failed pods. Expected %d, saw %d\n", tc.expectedFailed, actual.Status.Failed) } // validate conditions - if tc.expectedCondition != nil && !getCondition(actual, *tc.expectedCondition, tc.expectedConditionReason) { + if tc.expectedCondition != nil && !getCondition(actual, *tc.expectedCondition, v1.ConditionTrue, tc.expectedConditionReason) { t.Errorf("expected completion condition. Got %#v", actual.Status.Conditions) } }) @@ -1864,13 +1984,99 @@ func TestJobBackoffOnRestartPolicyNever(t *testing.T) { t.Errorf("unexpected number of failed pods. Expected %d, saw %d\n", tc.expectedFailed, actual.Status.Failed) } // validate conditions - if tc.expectedCondition != nil && !getCondition(actual, *tc.expectedCondition, tc.expectedConditionReason) { + if tc.expectedCondition != nil && !getCondition(actual, *tc.expectedCondition, v1.ConditionTrue, tc.expectedConditionReason) { t.Errorf("expected completion condition. Got %#v", actual.Status.Conditions) } }) } } +func TestEnsureJobConditions(t *testing.T) { + testCases := []struct { + name string + haveList []batch.JobCondition + wantType batch.JobConditionType + wantStatus v1.ConditionStatus + wantReason string + expectList []batch.JobCondition + expectUpdate bool + }{ + { + name: "append true condition", + haveList: []batch.JobCondition{}, + wantType: batch.JobSuspended, + wantStatus: v1.ConditionTrue, + wantReason: "foo", + expectList: []batch.JobCondition{newCondition(batch.JobSuspended, v1.ConditionTrue, "foo", "")}, + expectUpdate: true, + }, + { + name: "append false condition", + haveList: []batch.JobCondition{}, + wantType: batch.JobSuspended, + wantStatus: v1.ConditionFalse, + wantReason: "foo", + expectList: []batch.JobCondition{}, + expectUpdate: false, + }, + { + name: "update true condition reason", + haveList: []batch.JobCondition{newCondition(batch.JobSuspended, v1.ConditionTrue, "foo", "")}, + wantType: batch.JobSuspended, + wantStatus: v1.ConditionTrue, + wantReason: "bar", + expectList: []batch.JobCondition{newCondition(batch.JobSuspended, v1.ConditionTrue, "bar", "")}, + expectUpdate: true, + }, + { + name: "update true condition status", + haveList: []batch.JobCondition{newCondition(batch.JobSuspended, v1.ConditionTrue, "foo", "")}, + wantType: batch.JobSuspended, + wantStatus: v1.ConditionFalse, + wantReason: "foo", + expectList: []batch.JobCondition{newCondition(batch.JobSuspended, v1.ConditionFalse, "foo", "")}, + expectUpdate: true, + }, + { + name: "update false condition status", + haveList: []batch.JobCondition{newCondition(batch.JobSuspended, v1.ConditionFalse, "foo", "")}, + wantType: batch.JobSuspended, + wantStatus: v1.ConditionTrue, + wantReason: "foo", + expectList: []batch.JobCondition{newCondition(batch.JobSuspended, v1.ConditionTrue, "foo", "")}, + expectUpdate: true, + }, + { + name: "condition already exists", + haveList: []batch.JobCondition{newCondition(batch.JobSuspended, v1.ConditionTrue, "foo", "")}, + wantType: batch.JobSuspended, + wantStatus: v1.ConditionTrue, + wantReason: "foo", + expectList: []batch.JobCondition{newCondition(batch.JobSuspended, v1.ConditionTrue, "foo", "")}, + expectUpdate: false, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + gotList, isUpdated := ensureJobConditionStatus(tc.haveList, tc.wantType, tc.wantStatus, tc.wantReason, "") + if isUpdated != tc.expectUpdate { + t.Errorf("Got isUpdated=%v, want %v", isUpdated, tc.expectUpdate) + } + if len(gotList) != len(tc.expectList) { + t.Errorf("got a list of length %d, want %d", len(gotList), len(tc.expectList)) + } + for i := range gotList { + // Make timestamps the same before comparing the two lists. + gotList[i].LastProbeTime = tc.expectList[i].LastProbeTime + gotList[i].LastTransitionTime = tc.expectList[i].LastTransitionTime + } + if diff := cmp.Diff(tc.expectList, gotList); diff != "" { + t.Errorf("Unexpected JobCondition list: (-want,+got):\n%s", diff) + } + }) + } +} + func checkJobCompletionEnvVariable(t *testing.T, spec *v1.PodSpec) { t.Helper() want := []v1.EnvVar{ diff --git a/pkg/features/kube_features.go b/pkg/features/kube_features.go index 05f411590c92..36b7a4e5adb5 100644 --- a/pkg/features/kube_features.go +++ b/pkg/features/kube_features.go @@ -716,6 +716,12 @@ const ( // // Enables node-local routing for Service internal traffic ServiceInternalTrafficPolicy featuregate.Feature = "ServiceInternalTrafficPolicy" + + // owner: @adtac + // alpha: v1.21 + // + // Allows jobs to be created in the suspended state. + SuspendJob featuregate.Feature = "SuspendJob" ) func init() { @@ -824,6 +830,7 @@ var defaultKubernetesFeatureGates = map[featuregate.Feature]featuregate.FeatureS LogarithmicScaleDown: {Default: false, PreRelease: featuregate.Alpha}, IngressClassNamespacedParams: {Default: false, PreRelease: featuregate.Alpha}, ServiceInternalTrafficPolicy: {Default: false, PreRelease: featuregate.Alpha}, + SuspendJob: {Default: false, PreRelease: featuregate.Alpha}, // inherited features from generic apiserver, relisted here to get a conflict if it is changed // unintentionally on either side: diff --git a/pkg/registry/batch/job/strategy.go b/pkg/registry/batch/job/strategy.go index 8df719df45bb..7466f7bfc917 100644 --- a/pkg/registry/batch/job/strategy.go +++ b/pkg/registry/batch/job/strategy.go @@ -39,6 +39,7 @@ import ( "k8s.io/kubernetes/pkg/apis/batch" "k8s.io/kubernetes/pkg/apis/batch/validation" "k8s.io/kubernetes/pkg/features" + "k8s.io/utils/pointer" ) // jobStrategy implements verification logic for Replication Controllers. @@ -84,6 +85,10 @@ func (jobStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) { job.Spec.CompletionMode = batch.NonIndexedCompletion } + if !utilfeature.DefaultFeatureGate.Enabled(features.SuspendJob) { + job.Spec.Suspend = pointer.BoolPtr(false) + } + pod.DropDisabledTemplateFields(&job.Spec.Template, nil) } @@ -101,6 +106,16 @@ func (jobStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object newJob.Spec.CompletionMode = batch.NonIndexedCompletion } + if !utilfeature.DefaultFeatureGate.Enabled(features.SuspendJob) { + // There are 3 possible values (nil, true, false) for each flag, so 9 + // combinations. We want to disallow everything except true->false and + // true->nil when the feature gate is disabled. Or, basically allow this + // only when oldJob is true. + if oldJob.Spec.Suspend == nil || !*oldJob.Spec.Suspend { + newJob.Spec.Suspend = oldJob.Spec.Suspend + } + } + pod.DropDisabledTemplateFields(&newJob.Spec.Template, &oldJob.Spec.Template) } diff --git a/pkg/registry/batch/job/strategy_test.go b/pkg/registry/batch/job/strategy_test.go index b76f9d7ff6ac..a9da0f196b23 100644 --- a/pkg/registry/batch/job/strategy_test.go +++ b/pkg/registry/batch/job/strategy_test.go @@ -31,20 +31,14 @@ import ( _ "k8s.io/kubernetes/pkg/apis/batch/install" api "k8s.io/kubernetes/pkg/apis/core" "k8s.io/kubernetes/pkg/features" + "k8s.io/utils/pointer" ) -func newBool(a bool) *bool { - return &a -} - -func newInt32(i int32) *int32 { - return &i -} - func TestJobStrategy(t *testing.T) { cases := map[string]struct { ttlEnabled bool indexedJobEnabled bool + suspendJobEnabled bool }{ "features disabled": {}, "ttl enabled": { @@ -53,11 +47,15 @@ func TestJobStrategy(t *testing.T) { "indexed job enabled": { indexedJobEnabled: true, }, + "suspend job enabled": { + suspendJobEnabled: true, + }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.TTLAfterFinished, tc.ttlEnabled)() defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.IndexedJob, tc.indexedJobEnabled)() + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.SuspendJob, tc.suspendJobEnabled)() testJobStrategy(t) }) } @@ -66,6 +64,7 @@ func TestJobStrategy(t *testing.T) { func testJobStrategy(t *testing.T) { ttlEnabled := utilfeature.DefaultFeatureGate.Enabled(features.TTLAfterFinished) indexedJobEnabled := utilfeature.DefaultFeatureGate.Enabled(features.IndexedJob) + suspendJobEnabled := utilfeature.DefaultFeatureGate.Enabled(features.SuspendJob) ctx := genericapirequest.NewDefaultContext() if !Strategy.NamespaceScoped() { t.Errorf("Job must be namespace scoped") @@ -95,10 +94,11 @@ func testJobStrategy(t *testing.T) { Spec: batch.JobSpec{ Selector: validSelector, Template: validPodTemplateSpec, - ManualSelector: newBool(true), - Completions: newInt32(2), + ManualSelector: pointer.BoolPtr(true), + Completions: pointer.Int32Ptr(2), // Set gated values. - TTLSecondsAfterFinished: newInt32(0), + Suspend: pointer.BoolPtr(true), + TTLSecondsAfterFinished: pointer.Int32Ptr(0), CompletionMode: batch.IndexedCompletion, }, Status: batch.JobStatus{ @@ -120,15 +120,18 @@ func testJobStrategy(t *testing.T) { if indexedJobEnabled != (job.Spec.CompletionMode != batch.NonIndexedCompletion) { t.Errorf("Job should allow setting .spec.completionMode=Indexed only when %v feature is enabled", features.IndexedJob) } + if !suspendJobEnabled && *job.Spec.Suspend { + t.Errorf("[SuspendJob=%v] .spec.suspend should be set to true", suspendJobEnabled) + } parallelism := int32(10) updatedJob := &batch.Job{ ObjectMeta: metav1.ObjectMeta{Name: "bar", ResourceVersion: "4"}, Spec: batch.JobSpec{ Parallelism: ¶llelism, - Completions: newInt32(2), + Completions: pointer.Int32Ptr(2), // Update gated features. - TTLSecondsAfterFinished: newInt32(1), + TTLSecondsAfterFinished: pointer.Int32Ptr(1), CompletionMode: batch.IndexedCompletion, // No change because field is immutable. }, Status: batch.JobStatus{ @@ -151,17 +154,28 @@ func testJobStrategy(t *testing.T) { } // Existing gated fields should be preserved - job.Spec.TTLSecondsAfterFinished = newInt32(1) + job.Spec.TTLSecondsAfterFinished = pointer.Int32Ptr(1) job.Spec.CompletionMode = batch.IndexedCompletion - updatedJob.Spec.TTLSecondsAfterFinished = newInt32(2) + updatedJob.Spec.TTLSecondsAfterFinished = pointer.Int32Ptr(2) updatedJob.Spec.CompletionMode = batch.IndexedCompletion + // Test updating suspend false->true and nil-> true when the feature gate is + // disabled. We don't care about other combinations. + job.Spec.Suspend, updatedJob.Spec.Suspend = pointer.BoolPtr(false), pointer.BoolPtr(true) Strategy.PrepareForUpdate(ctx, updatedJob, job) if job.Spec.TTLSecondsAfterFinished == nil || updatedJob.Spec.TTLSecondsAfterFinished == nil { - t.Errorf("existing TTLSecondsAfterFinished should be preserved") + t.Errorf("existing .spec.ttlSecondsAfterFinished should be preserved") } if job.Spec.CompletionMode == "" || updatedJob.Spec.CompletionMode == "" { t.Errorf("existing completionMode should be preserved") } + if !suspendJobEnabled && *updatedJob.Spec.Suspend { + t.Errorf("[SuspendJob=%v] .spec.suspend should not be updated from false to true", suspendJobEnabled) + } + job.Spec.Suspend, updatedJob.Spec.Suspend = nil, pointer.BoolPtr(true) + Strategy.PrepareForUpdate(ctx, updatedJob, job) + if !suspendJobEnabled && updatedJob.Spec.Suspend != nil { + t.Errorf("[SuspendJob=%v] .spec.suspend should not be updated from nil to non-nil", suspendJobEnabled) + } // Make sure we correctly implement the interface. // Otherwise a typo could silently change the default. diff --git a/staging/src/k8s.io/api/batch/v1/generated.pb.go b/staging/src/k8s.io/api/batch/v1/generated.pb.go index 73f19cf97068..ef1c0bce6dbe 100644 --- a/staging/src/k8s.io/api/batch/v1/generated.pb.go +++ b/staging/src/k8s.io/api/batch/v1/generated.pb.go @@ -344,89 +344,89 @@ func init() { } var fileDescriptor_3b52da57c93de713 = []byte{ - // 1301 bytes of a gzipped FileDescriptorProto + // 1307 bytes of a gzipped FileDescriptorProto 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xcc, 0x56, 0xcf, 0x8f, 0xdb, 0xc4, 0x17, 0x5f, 0x6f, 0x36, 0x9b, 0x64, 0xb2, 0xbb, 0x4d, 0xa7, 0xdf, 0xb6, 0xf9, 0x86, 0x2a, 0x5e, 0xc2, 0x0f, 0x2d, 0x08, 0x1c, 0xb6, 0xac, 0x10, 0x42, 0x80, 0xb4, 0xde, 0xaa, 0xa2, 0x4b, 0x56, 0x5d, 0x26, 0x5b, 0x21, 0x41, 0x41, 0x4c, 0xec, 0x49, 0xd6, 0x5d, 0xdb, 0x63, 0x79, 0x26, 0x11, - 0xb9, 0xf1, 0x0f, 0x20, 0xf1, 0x57, 0x20, 0x4e, 0x5c, 0xb8, 0x73, 0x44, 0x3d, 0xf6, 0xd8, 0x93, - 0x45, 0xcd, 0x8d, 0x0b, 0xf7, 0xe5, 0x82, 0x3c, 0x9e, 0xd8, 0x4e, 0x62, 0x2f, 0x6d, 0x0f, 0x15, - 0xb7, 0xf8, 0xcd, 0xe7, 0xf3, 0x99, 0x97, 0xf7, 0xde, 0xbc, 0xf7, 0xc0, 0x87, 0x67, 0xef, 0x33, - 0xcd, 0xa2, 0xdd, 0xb3, 0xf1, 0x80, 0xf8, 0x2e, 0xe1, 0x84, 0x75, 0x27, 0xc4, 0x35, 0xa9, 0xdf, - 0x95, 0x07, 0xd8, 0xb3, 0xba, 0x03, 0xcc, 0x8d, 0xd3, 0xee, 0x64, 0xb7, 0x3b, 0x22, 0x2e, 0xf1, - 0x31, 0x27, 0xa6, 0xe6, 0xf9, 0x94, 0x53, 0x78, 0x25, 0x06, 0x69, 0xd8, 0xb3, 0x34, 0x01, 0xd2, - 0x26, 0xbb, 0xad, 0xb7, 0x47, 0x16, 0x3f, 0x1d, 0x0f, 0x34, 0x83, 0x3a, 0xdd, 0x11, 0x1d, 0xd1, - 0xae, 0xc0, 0x0e, 0xc6, 0x43, 0xf1, 0x25, 0x3e, 0xc4, 0xaf, 0x58, 0xa3, 0xd5, 0xc9, 0x5c, 0x64, - 0x50, 0x9f, 0xe4, 0xdc, 0xd3, 0xda, 0x4b, 0x31, 0x0e, 0x36, 0x4e, 0x2d, 0x97, 0xf8, 0xd3, 0xae, - 0x77, 0x36, 0x8a, 0x0c, 0xac, 0xeb, 0x10, 0x8e, 0xf3, 0x58, 0xdd, 0x22, 0x96, 0x3f, 0x76, 0xb9, - 0xe5, 0x90, 0x25, 0xc2, 0x7b, 0xff, 0x46, 0x60, 0xc6, 0x29, 0x71, 0xf0, 0x22, 0xaf, 0xf3, 0xb7, - 0x02, 0x2a, 0x07, 0x3e, 0x75, 0x0f, 0xe9, 0x00, 0x7e, 0x03, 0xaa, 0x91, 0x3f, 0x26, 0xe6, 0xb8, - 0xa9, 0x6c, 0x2b, 0x3b, 0xf5, 0x9b, 0xef, 0x68, 0x69, 0x94, 0x12, 0x59, 0xcd, 0x3b, 0x1b, 0x45, - 0x06, 0xa6, 0x45, 0x68, 0x6d, 0xb2, 0xab, 0xdd, 0x1d, 0x3c, 0x20, 0x06, 0x3f, 0x22, 0x1c, 0xeb, - 0xf0, 0x61, 0xa0, 0xae, 0x84, 0x81, 0x0a, 0x52, 0x1b, 0x4a, 0x54, 0xa1, 0x0e, 0xd6, 0x98, 0x47, - 0x8c, 0xe6, 0xaa, 0x50, 0xdf, 0xd6, 0x72, 0x72, 0xa0, 0x49, 0x6f, 0xfa, 0x1e, 0x31, 0xf4, 0x0d, - 0xa9, 0xb6, 0x16, 0x7d, 0x21, 0xc1, 0x85, 0x87, 0x60, 0x9d, 0x71, 0xcc, 0xc7, 0xac, 0x59, 0x12, - 0x2a, 0x9d, 0x0b, 0x55, 0x04, 0x52, 0xdf, 0x92, 0x3a, 0xeb, 0xf1, 0x37, 0x92, 0x0a, 0x9d, 0x9f, - 0x15, 0x50, 0x97, 0xc8, 0x9e, 0xc5, 0x38, 0xbc, 0xbf, 0x14, 0x01, 0xed, 0xe9, 0x22, 0x10, 0xb1, - 0xc5, 0xff, 0x6f, 0xc8, 0x9b, 0xaa, 0x33, 0x4b, 0xe6, 0xdf, 0xef, 0x83, 0xb2, 0xc5, 0x89, 0xc3, - 0x9a, 0xab, 0xdb, 0xa5, 0x9d, 0xfa, 0xcd, 0x1b, 0x17, 0x39, 0xae, 0x6f, 0x4a, 0xa1, 0xf2, 0x9d, - 0x88, 0x82, 0x62, 0x66, 0xe7, 0xa7, 0xb5, 0xc4, 0xe1, 0x28, 0x24, 0xf0, 0x2d, 0x50, 0x8d, 0x12, - 0x6b, 0x8e, 0x6d, 0x22, 0x1c, 0xae, 0xa5, 0x0e, 0xf4, 0xa5, 0x1d, 0x25, 0x08, 0x78, 0x0f, 0x5c, - 0x67, 0x1c, 0xfb, 0xdc, 0x72, 0x47, 0xb7, 0x08, 0x36, 0x6d, 0xcb, 0x25, 0x7d, 0x62, 0x50, 0xd7, - 0x64, 0x22, 0x23, 0x25, 0xfd, 0xa5, 0x30, 0x50, 0xaf, 0xf7, 0xf3, 0x21, 0xa8, 0x88, 0x0b, 0xef, - 0x83, 0xcb, 0x06, 0x75, 0x8d, 0xb1, 0xef, 0x13, 0xd7, 0x98, 0x1e, 0x53, 0xdb, 0x32, 0xa6, 0x22, - 0x39, 0x35, 0x5d, 0x93, 0xde, 0x5c, 0x3e, 0x58, 0x04, 0x9c, 0xe7, 0x19, 0xd1, 0xb2, 0x10, 0x7c, - 0x0d, 0x54, 0xd8, 0x98, 0x79, 0xc4, 0x35, 0x9b, 0x6b, 0xdb, 0xca, 0x4e, 0x55, 0xaf, 0x87, 0x81, - 0x5a, 0xe9, 0xc7, 0x26, 0x34, 0x3b, 0x83, 0x5f, 0x82, 0xfa, 0x03, 0x3a, 0x38, 0x21, 0x8e, 0x67, - 0x63, 0x4e, 0x9a, 0x65, 0x91, 0xbd, 0x57, 0x73, 0x43, 0x7c, 0x98, 0xe2, 0x44, 0x95, 0x5d, 0x91, - 0x4e, 0xd6, 0x33, 0x07, 0x28, 0xab, 0x06, 0xbf, 0x06, 0x2d, 0x36, 0x36, 0x0c, 0xc2, 0xd8, 0x70, - 0x6c, 0x1f, 0xd2, 0x01, 0xfb, 0xc4, 0x62, 0x9c, 0xfa, 0xd3, 0x9e, 0xe5, 0x58, 0xbc, 0xb9, 0xbe, - 0xad, 0xec, 0x94, 0xf5, 0x76, 0x18, 0xa8, 0xad, 0x7e, 0x21, 0x0a, 0x5d, 0xa0, 0x00, 0x11, 0xb8, - 0x36, 0xc4, 0x96, 0x4d, 0xcc, 0x25, 0xed, 0x8a, 0xd0, 0x6e, 0x85, 0x81, 0x7a, 0xed, 0x76, 0x2e, - 0x02, 0x15, 0x30, 0x3b, 0xbf, 0xae, 0x82, 0xcd, 0xb9, 0x57, 0x00, 0x3f, 0x05, 0xeb, 0xd8, 0xe0, - 0xd6, 0x24, 0x2a, 0x95, 0xa8, 0x00, 0x5f, 0xc9, 0x46, 0x27, 0xea, 0x5f, 0xe9, 0x5b, 0x46, 0x64, - 0x48, 0xa2, 0x24, 0x90, 0xf4, 0xe9, 0xec, 0x0b, 0x2a, 0x92, 0x12, 0xd0, 0x06, 0x0d, 0x1b, 0x33, - 0x3e, 0xab, 0xb2, 0x13, 0xcb, 0x21, 0x22, 0x3f, 0xf5, 0x9b, 0x6f, 0x3e, 0xdd, 0x93, 0x89, 0x18, - 0xfa, 0xff, 0xc2, 0x40, 0x6d, 0xf4, 0x16, 0x74, 0xd0, 0x92, 0x32, 0xf4, 0x01, 0x14, 0xb6, 0x24, - 0x84, 0xe2, 0xbe, 0xf2, 0x33, 0xdf, 0x77, 0x2d, 0x0c, 0x54, 0xd8, 0x5b, 0x52, 0x42, 0x39, 0xea, - 0x9d, 0xbf, 0x14, 0x50, 0x7a, 0x31, 0x6d, 0xf1, 0xe3, 0xb9, 0xb6, 0x78, 0xa3, 0xa8, 0x68, 0x0b, - 0x5b, 0xe2, 0xed, 0x85, 0x96, 0xd8, 0x2e, 0x54, 0xb8, 0xb8, 0x1d, 0xfe, 0x56, 0x02, 0x1b, 0x87, - 0x74, 0x70, 0x40, 0x5d, 0xd3, 0xe2, 0x16, 0x75, 0xe1, 0x1e, 0x58, 0xe3, 0x53, 0x6f, 0xd6, 0x5a, - 0xb6, 0x67, 0x57, 0x9f, 0x4c, 0x3d, 0x72, 0x1e, 0xa8, 0x8d, 0x2c, 0x36, 0xb2, 0x21, 0x81, 0x86, - 0xbd, 0xc4, 0x9d, 0x55, 0xc1, 0xdb, 0x9b, 0xbf, 0xee, 0x3c, 0x50, 0x73, 0x06, 0xa7, 0x96, 0x28, - 0xcd, 0x3b, 0x05, 0x47, 0x60, 0x33, 0x4a, 0xce, 0xb1, 0x4f, 0x07, 0x71, 0x95, 0x95, 0x9e, 0x39, - 0xeb, 0x57, 0xa5, 0x03, 0x9b, 0xbd, 0xac, 0x10, 0x9a, 0xd7, 0x85, 0x93, 0xb8, 0xc6, 0x4e, 0x7c, - 0xec, 0xb2, 0xf8, 0x2f, 0x3d, 0x5f, 0x4d, 0xb7, 0xe4, 0x6d, 0xa2, 0xce, 0xe6, 0xd5, 0x50, 0xce, - 0x0d, 0xf0, 0x75, 0xb0, 0xee, 0x13, 0xcc, 0xa8, 0x2b, 0xea, 0xb9, 0x96, 0x66, 0x07, 0x09, 0x2b, - 0x92, 0xa7, 0xf0, 0x0d, 0x50, 0x71, 0x08, 0x63, 0x78, 0x44, 0x44, 0xc7, 0xa9, 0xe9, 0x97, 0x24, - 0xb0, 0x72, 0x14, 0x9b, 0xd1, 0xec, 0xbc, 0xf3, 0xa3, 0x02, 0x2a, 0x2f, 0x66, 0xa6, 0x7d, 0x34, - 0x3f, 0xd3, 0x9a, 0x45, 0x95, 0x57, 0x30, 0xcf, 0xbe, 0x2f, 0x0b, 0x47, 0xc5, 0x2c, 0xdb, 0x05, - 0x75, 0x0f, 0xfb, 0xd8, 0xb6, 0x89, 0x6d, 0x31, 0x47, 0xf8, 0x5a, 0xd6, 0x2f, 0x45, 0x7d, 0xf9, - 0x38, 0x35, 0xa3, 0x2c, 0x26, 0xa2, 0x18, 0xd4, 0xf1, 0x6c, 0x12, 0x05, 0x33, 0x2e, 0x37, 0x49, - 0x39, 0x48, 0xcd, 0x28, 0x8b, 0x81, 0x77, 0xc1, 0xd5, 0xb8, 0x83, 0x2d, 0x4e, 0xc0, 0x92, 0x98, - 0x80, 0xff, 0x0f, 0x03, 0xf5, 0xea, 0x7e, 0x1e, 0x00, 0xe5, 0xf3, 0xe0, 0x1e, 0xd8, 0x18, 0x60, - 0xe3, 0x8c, 0x0e, 0x87, 0xd9, 0x8e, 0xdd, 0x08, 0x03, 0x75, 0x43, 0xcf, 0xd8, 0xd1, 0x1c, 0x0a, - 0x7e, 0x05, 0xaa, 0x8c, 0xd8, 0xc4, 0xe0, 0xd4, 0x97, 0x25, 0xf6, 0xee, 0x53, 0x66, 0x05, 0x0f, - 0x88, 0xdd, 0x97, 0x54, 0x7d, 0x43, 0x4c, 0x7a, 0xf9, 0x85, 0x12, 0x49, 0xf8, 0x01, 0xd8, 0x72, - 0xb0, 0x3b, 0xc6, 0x09, 0x52, 0xd4, 0x56, 0x55, 0x87, 0x61, 0xa0, 0x6e, 0x1d, 0xcd, 0x9d, 0xa0, - 0x05, 0x24, 0xfc, 0x0c, 0x54, 0xf9, 0x6c, 0x8c, 0xae, 0x0b, 0xd7, 0x72, 0x07, 0xc5, 0x31, 0x35, - 0xe7, 0xa6, 0x68, 0x52, 0x25, 0xc9, 0x08, 0x4d, 0x64, 0xa2, 0xc5, 0x83, 0x73, 0x5b, 0x46, 0x6c, - 0x7f, 0xc8, 0x89, 0x7f, 0xdb, 0x72, 0x2d, 0x76, 0x4a, 0xcc, 0x66, 0x55, 0x84, 0x4b, 0x2c, 0x1e, - 0x27, 0x27, 0xbd, 0x3c, 0x08, 0x2a, 0xe2, 0xc2, 0x63, 0xb0, 0x95, 0xa6, 0xf6, 0x88, 0x9a, 0xa4, - 0x59, 0x13, 0x0f, 0x63, 0x47, 0xba, 0xb2, 0x75, 0x30, 0x77, 0x7a, 0xbe, 0x64, 0x41, 0x0b, 0xfc, - 0xce, 0x9f, 0x25, 0x50, 0x4b, 0x07, 0xe6, 0x3d, 0x00, 0x8c, 0x59, 0x57, 0x62, 0x72, 0x68, 0xbe, - 0x5c, 0x54, 0xe1, 0x49, 0xff, 0x4a, 0x9b, 0x7d, 0x62, 0x62, 0x28, 0x23, 0x04, 0x3f, 0x07, 0x35, - 0xb1, 0x4a, 0x89, 0xfe, 0xb2, 0xfa, 0xcc, 0xfd, 0x65, 0x33, 0x0c, 0xd4, 0x5a, 0x7f, 0x26, 0x80, - 0x52, 0x2d, 0x38, 0xcc, 0xc6, 0xe3, 0x39, 0x7b, 0x25, 0x9c, 0x8f, 0x9b, 0xb8, 0x62, 0x41, 0x35, - 0xea, 0x58, 0x72, 0x91, 0x58, 0x13, 0xd9, 0x2b, 0xda, 0x11, 0xba, 0xa0, 0x26, 0x96, 0x1e, 0x62, - 0x12, 0x53, 0x14, 0x60, 0x59, 0xbf, 0x2c, 0xa1, 0xb5, 0xfe, 0xec, 0x00, 0xa5, 0x98, 0x48, 0x38, - 0xde, 0x66, 0xe4, 0x4e, 0x95, 0x08, 0xc7, 0xbb, 0x0f, 0x92, 0xa7, 0xf0, 0x16, 0x68, 0x48, 0x97, - 0x88, 0x79, 0xc7, 0x35, 0xc9, 0xb7, 0x84, 0x89, 0x77, 0x57, 0xd3, 0x9b, 0x92, 0xd1, 0x38, 0x58, - 0x38, 0x47, 0x4b, 0x8c, 0xce, 0x2f, 0x0a, 0xb8, 0xb4, 0xb0, 0x0b, 0xfe, 0xf7, 0x87, 0xbd, 0xbe, - 0xf3, 0xf0, 0x49, 0x7b, 0xe5, 0xd1, 0x93, 0xf6, 0xca, 0xe3, 0x27, 0xed, 0x95, 0xef, 0xc2, 0xb6, - 0xf2, 0x30, 0x6c, 0x2b, 0x8f, 0xc2, 0xb6, 0xf2, 0x38, 0x6c, 0x2b, 0xbf, 0x87, 0x6d, 0xe5, 0x87, - 0x3f, 0xda, 0x2b, 0x5f, 0xac, 0x4e, 0x76, 0xff, 0x09, 0x00, 0x00, 0xff, 0xff, 0x27, 0x57, 0xd4, - 0x63, 0x21, 0x0f, 0x00, 0x00, + 0xb9, 0xf1, 0x27, 0xf0, 0x57, 0x20, 0x4e, 0x5c, 0xe0, 0xcc, 0x11, 0xf5, 0xd8, 0x63, 0x4f, 0x16, + 0x35, 0x37, 0x2e, 0xdc, 0x97, 0x0b, 0xf2, 0x78, 0x62, 0x3b, 0x89, 0xbd, 0xb4, 0x3d, 0x54, 0xdc, + 0xe2, 0x37, 0x9f, 0xcf, 0x67, 0x5e, 0xde, 0x7b, 0xf3, 0xde, 0x03, 0x1f, 0x9e, 0xbd, 0xcf, 0x34, + 0x8b, 0x76, 0xcf, 0xc6, 0x03, 0xe2, 0xbb, 0x84, 0x13, 0xd6, 0x9d, 0x10, 0xd7, 0xa4, 0x7e, 0x57, + 0x1e, 0x60, 0xcf, 0xea, 0x0e, 0x30, 0x37, 0x4e, 0xbb, 0x93, 0xdd, 0xee, 0x88, 0xb8, 0xc4, 0xc7, + 0x9c, 0x98, 0x9a, 0xe7, 0x53, 0x4e, 0xe1, 0x95, 0x18, 0xa4, 0x61, 0xcf, 0xd2, 0x04, 0x48, 0x9b, + 0xec, 0xb6, 0xde, 0x1e, 0x59, 0xfc, 0x74, 0x3c, 0xd0, 0x0c, 0xea, 0x74, 0x47, 0x74, 0x44, 0xbb, + 0x02, 0x3b, 0x18, 0x0f, 0xc5, 0x97, 0xf8, 0x10, 0xbf, 0x62, 0x8d, 0x56, 0x27, 0x73, 0x91, 0x41, + 0x7d, 0x92, 0x73, 0x4f, 0x6b, 0x2f, 0xc5, 0x38, 0xd8, 0x38, 0xb5, 0x5c, 0xe2, 0x4f, 0xbb, 0xde, + 0xd9, 0x28, 0x32, 0xb0, 0xae, 0x43, 0x38, 0xce, 0x63, 0x75, 0x8b, 0x58, 0xfe, 0xd8, 0xe5, 0x96, + 0x43, 0x96, 0x08, 0xef, 0xfd, 0x1b, 0x81, 0x19, 0xa7, 0xc4, 0xc1, 0x8b, 0xbc, 0xce, 0xdf, 0x0a, + 0xa8, 0x1c, 0xf8, 0xd4, 0x3d, 0xa4, 0x03, 0xf8, 0x0d, 0xa8, 0x46, 0xfe, 0x98, 0x98, 0xe3, 0xa6, + 0xb2, 0xad, 0xec, 0xd4, 0x6f, 0xbe, 0xa3, 0xa5, 0x51, 0x4a, 0x64, 0x35, 0xef, 0x6c, 0x14, 0x19, + 0x98, 0x16, 0xa1, 0xb5, 0xc9, 0xae, 0x76, 0x77, 0xf0, 0x80, 0x18, 0xfc, 0x88, 0x70, 0xac, 0xc3, + 0x87, 0x81, 0xba, 0x12, 0x06, 0x2a, 0x48, 0x6d, 0x28, 0x51, 0x85, 0x3a, 0x58, 0x63, 0x1e, 0x31, + 0x9a, 0xab, 0x42, 0x7d, 0x5b, 0xcb, 0xc9, 0x81, 0x26, 0xbd, 0xe9, 0x7b, 0xc4, 0xd0, 0x37, 0xa4, + 0xda, 0x5a, 0xf4, 0x85, 0x04, 0x17, 0x1e, 0x82, 0x75, 0xc6, 0x31, 0x1f, 0xb3, 0x66, 0x49, 0xa8, + 0x74, 0x2e, 0x54, 0x11, 0x48, 0x7d, 0x4b, 0xea, 0xac, 0xc7, 0xdf, 0x48, 0x2a, 0x74, 0x7e, 0x52, + 0x40, 0x5d, 0x22, 0x7b, 0x16, 0xe3, 0xf0, 0xfe, 0x52, 0x04, 0xb4, 0xa7, 0x8b, 0x40, 0xc4, 0x16, + 0xff, 0xbf, 0x21, 0x6f, 0xaa, 0xce, 0x2c, 0x99, 0x7f, 0xbf, 0x0f, 0xca, 0x16, 0x27, 0x0e, 0x6b, + 0xae, 0x6e, 0x97, 0x76, 0xea, 0x37, 0x6f, 0x5c, 0xe4, 0xb8, 0xbe, 0x29, 0x85, 0xca, 0x77, 0x22, + 0x0a, 0x8a, 0x99, 0x9d, 0x1f, 0xd7, 0x12, 0x87, 0xa3, 0x90, 0xc0, 0xb7, 0x40, 0x35, 0x4a, 0xac, + 0x39, 0xb6, 0x89, 0x70, 0xb8, 0x96, 0x3a, 0xd0, 0x97, 0x76, 0x94, 0x20, 0xe0, 0x3d, 0x70, 0x9d, + 0x71, 0xec, 0x73, 0xcb, 0x1d, 0xdd, 0x22, 0xd8, 0xb4, 0x2d, 0x97, 0xf4, 0x89, 0x41, 0x5d, 0x93, + 0x89, 0x8c, 0x94, 0xf4, 0x97, 0xc2, 0x40, 0xbd, 0xde, 0xcf, 0x87, 0xa0, 0x22, 0x2e, 0xbc, 0x0f, + 0x2e, 0x1b, 0xd4, 0x35, 0xc6, 0xbe, 0x4f, 0x5c, 0x63, 0x7a, 0x4c, 0x6d, 0xcb, 0x98, 0x8a, 0xe4, + 0xd4, 0x74, 0x4d, 0x7a, 0x73, 0xf9, 0x60, 0x11, 0x70, 0x9e, 0x67, 0x44, 0xcb, 0x42, 0xf0, 0x35, + 0x50, 0x61, 0x63, 0xe6, 0x11, 0xd7, 0x6c, 0xae, 0x6d, 0x2b, 0x3b, 0x55, 0xbd, 0x1e, 0x06, 0x6a, + 0xa5, 0x1f, 0x9b, 0xd0, 0xec, 0x0c, 0x7e, 0x09, 0xea, 0x0f, 0xe8, 0xe0, 0x84, 0x38, 0x9e, 0x8d, + 0x39, 0x69, 0x96, 0x45, 0xf6, 0x5e, 0xcd, 0x0d, 0xf1, 0x61, 0x8a, 0x13, 0x55, 0x76, 0x45, 0x3a, + 0x59, 0xcf, 0x1c, 0xa0, 0xac, 0x1a, 0xfc, 0x1a, 0xb4, 0xd8, 0xd8, 0x30, 0x08, 0x63, 0xc3, 0xb1, + 0x7d, 0x48, 0x07, 0xec, 0x13, 0x8b, 0x71, 0xea, 0x4f, 0x7b, 0x96, 0x63, 0xf1, 0xe6, 0xfa, 0xb6, + 0xb2, 0x53, 0xd6, 0xdb, 0x61, 0xa0, 0xb6, 0xfa, 0x85, 0x28, 0x74, 0x81, 0x02, 0x44, 0xe0, 0xda, + 0x10, 0x5b, 0x36, 0x31, 0x97, 0xb4, 0x2b, 0x42, 0xbb, 0x15, 0x06, 0xea, 0xb5, 0xdb, 0xb9, 0x08, + 0x54, 0xc0, 0xec, 0xfc, 0xba, 0x0a, 0x36, 0xe7, 0x5e, 0x01, 0xfc, 0x14, 0xac, 0x63, 0x83, 0x5b, + 0x93, 0xa8, 0x54, 0xa2, 0x02, 0x7c, 0x25, 0x1b, 0x9d, 0xa8, 0x7f, 0xa5, 0x6f, 0x19, 0x91, 0x21, + 0x89, 0x92, 0x40, 0xd2, 0xa7, 0xb3, 0x2f, 0xa8, 0x48, 0x4a, 0x40, 0x1b, 0x34, 0x6c, 0xcc, 0xf8, + 0xac, 0xca, 0x4e, 0x2c, 0x87, 0x88, 0xfc, 0xd4, 0x6f, 0xbe, 0xf9, 0x74, 0x4f, 0x26, 0x62, 0xe8, + 0xff, 0x0b, 0x03, 0xb5, 0xd1, 0x5b, 0xd0, 0x41, 0x4b, 0xca, 0xd0, 0x07, 0x50, 0xd8, 0x92, 0x10, + 0x8a, 0xfb, 0xca, 0xcf, 0x7c, 0xdf, 0xb5, 0x30, 0x50, 0x61, 0x6f, 0x49, 0x09, 0xe5, 0xa8, 0x77, + 0xfe, 0x52, 0x40, 0xe9, 0xc5, 0xb4, 0xc5, 0x8f, 0xe7, 0xda, 0xe2, 0x8d, 0xa2, 0xa2, 0x2d, 0x6c, + 0x89, 0xb7, 0x17, 0x5a, 0x62, 0xbb, 0x50, 0xe1, 0xe2, 0x76, 0xf8, 0x5b, 0x09, 0x6c, 0x1c, 0xd2, + 0xc1, 0x01, 0x75, 0x4d, 0x8b, 0x5b, 0xd4, 0x85, 0x7b, 0x60, 0x8d, 0x4f, 0xbd, 0x59, 0x6b, 0xd9, + 0x9e, 0x5d, 0x7d, 0x32, 0xf5, 0xc8, 0x79, 0xa0, 0x36, 0xb2, 0xd8, 0xc8, 0x86, 0x04, 0x1a, 0xf6, + 0x12, 0x77, 0x56, 0x05, 0x6f, 0x6f, 0xfe, 0xba, 0xf3, 0x40, 0xcd, 0x19, 0x9c, 0x5a, 0xa2, 0x34, + 0xef, 0x14, 0x1c, 0x81, 0xcd, 0x28, 0x39, 0xc7, 0x3e, 0x1d, 0xc4, 0x55, 0x56, 0x7a, 0xe6, 0xac, + 0x5f, 0x95, 0x0e, 0x6c, 0xf6, 0xb2, 0x42, 0x68, 0x5e, 0x17, 0x4e, 0xe2, 0x1a, 0x3b, 0xf1, 0xb1, + 0xcb, 0xe2, 0xbf, 0xf4, 0x7c, 0x35, 0xdd, 0x92, 0xb7, 0x89, 0x3a, 0x9b, 0x57, 0x43, 0x39, 0x37, + 0xc0, 0xd7, 0xc1, 0xba, 0x4f, 0x30, 0xa3, 0xae, 0xa8, 0xe7, 0x5a, 0x9a, 0x1d, 0x24, 0xac, 0x48, + 0x9e, 0xc2, 0x37, 0x40, 0xc5, 0x21, 0x8c, 0xe1, 0x11, 0x11, 0x1d, 0xa7, 0xa6, 0x5f, 0x92, 0xc0, + 0xca, 0x51, 0x6c, 0x46, 0xb3, 0xf3, 0xce, 0x0f, 0x0a, 0xa8, 0xbc, 0x98, 0x99, 0xf6, 0xd1, 0xfc, + 0x4c, 0x6b, 0x16, 0x55, 0x5e, 0xc1, 0x3c, 0xfb, 0xa5, 0x2c, 0x1c, 0x15, 0xb3, 0x6c, 0x17, 0xd4, + 0x3d, 0xec, 0x63, 0xdb, 0x26, 0xb6, 0xc5, 0x1c, 0xe1, 0x6b, 0x59, 0xbf, 0x14, 0xf5, 0xe5, 0xe3, + 0xd4, 0x8c, 0xb2, 0x98, 0x88, 0x62, 0x50, 0xc7, 0xb3, 0x49, 0x14, 0xcc, 0xb8, 0xdc, 0x24, 0xe5, + 0x20, 0x35, 0xa3, 0x2c, 0x06, 0xde, 0x05, 0x57, 0xe3, 0x0e, 0xb6, 0x38, 0x01, 0x4b, 0x62, 0x02, + 0xfe, 0x3f, 0x0c, 0xd4, 0xab, 0xfb, 0x79, 0x00, 0x94, 0xcf, 0x83, 0x7b, 0x60, 0x63, 0x80, 0x8d, + 0x33, 0x3a, 0x1c, 0x66, 0x3b, 0x76, 0x23, 0x0c, 0xd4, 0x0d, 0x3d, 0x63, 0x47, 0x73, 0x28, 0xf8, + 0x15, 0xa8, 0x32, 0x62, 0x13, 0x83, 0x53, 0x5f, 0x96, 0xd8, 0xbb, 0x4f, 0x99, 0x15, 0x3c, 0x20, + 0x76, 0x5f, 0x52, 0xf5, 0x0d, 0x31, 0xe9, 0xe5, 0x17, 0x4a, 0x24, 0xe1, 0x07, 0x60, 0xcb, 0xc1, + 0xee, 0x18, 0x27, 0x48, 0x51, 0x5b, 0x55, 0x1d, 0x86, 0x81, 0xba, 0x75, 0x34, 0x77, 0x82, 0x16, + 0x90, 0xf0, 0x33, 0x50, 0xe5, 0xb3, 0x31, 0xba, 0x2e, 0x5c, 0xcb, 0x1d, 0x14, 0xc7, 0xd4, 0x9c, + 0x9b, 0xa2, 0x49, 0x95, 0x24, 0x23, 0x34, 0x91, 0x89, 0x16, 0x0f, 0xce, 0x6d, 0x19, 0xb1, 0xfd, + 0x21, 0x27, 0xfe, 0x6d, 0xcb, 0xb5, 0xd8, 0x29, 0x31, 0x9b, 0x55, 0x11, 0x2e, 0xb1, 0x78, 0x9c, + 0x9c, 0xf4, 0xf2, 0x20, 0xa8, 0x88, 0x0b, 0x8f, 0xc1, 0x56, 0x9a, 0xda, 0x23, 0x6a, 0x92, 0x66, + 0x4d, 0x3c, 0x8c, 0x1d, 0xe9, 0xca, 0xd6, 0xc1, 0xdc, 0xe9, 0xf9, 0x92, 0x05, 0x2d, 0xf0, 0xb3, + 0xcb, 0x06, 0x28, 0x5e, 0x36, 0x3a, 0x7f, 0x96, 0x40, 0x2d, 0x9d, 0xab, 0xf7, 0x00, 0x30, 0x66, + 0xcd, 0x8b, 0xc9, 0xd9, 0xfa, 0x72, 0xd1, 0x43, 0x48, 0xda, 0x5c, 0x3a, 0x13, 0x12, 0x13, 0x43, + 0x19, 0x21, 0xf8, 0x39, 0xa8, 0x89, 0x8d, 0x4b, 0xb4, 0xa1, 0xd5, 0x67, 0x6e, 0x43, 0x9b, 0x61, + 0xa0, 0xd6, 0xfa, 0x33, 0x01, 0x94, 0x6a, 0xc1, 0x61, 0x36, 0x6c, 0xcf, 0xd9, 0x52, 0xe1, 0x7c, + 0x78, 0xc5, 0x15, 0x0b, 0xaa, 0x51, 0x63, 0x93, 0xfb, 0xc6, 0x9a, 0x48, 0x72, 0xd1, 0x2a, 0xd1, + 0x05, 0x35, 0xb1, 0x1b, 0x11, 0x93, 0x98, 0xa2, 0x4e, 0xcb, 0xfa, 0x65, 0x09, 0xad, 0xf5, 0x67, + 0x07, 0x28, 0xc5, 0x44, 0xc2, 0xf1, 0xd2, 0x23, 0x57, 0xaf, 0x44, 0x38, 0x5e, 0x91, 0x90, 0x3c, + 0x85, 0xb7, 0x40, 0x43, 0xba, 0x44, 0xcc, 0x3b, 0xae, 0x49, 0xbe, 0x25, 0x4c, 0x3c, 0xcf, 0x9a, + 0xde, 0x94, 0x8c, 0xc6, 0xc1, 0xc2, 0x39, 0x5a, 0x62, 0x74, 0x7e, 0x56, 0xc0, 0xa5, 0x85, 0x95, + 0xf1, 0xbf, 0xbf, 0x13, 0xe8, 0x3b, 0x0f, 0x9f, 0xb4, 0x57, 0x1e, 0x3d, 0x69, 0xaf, 0x3c, 0x7e, + 0xd2, 0x5e, 0xf9, 0x2e, 0x6c, 0x2b, 0x0f, 0xc3, 0xb6, 0xf2, 0x28, 0x6c, 0x2b, 0x8f, 0xc3, 0xb6, + 0xf2, 0x7b, 0xd8, 0x56, 0xbe, 0xff, 0xa3, 0xbd, 0xf2, 0xc5, 0xea, 0x64, 0xf7, 0x9f, 0x00, 0x00, + 0x00, 0xff, 0xff, 0x3d, 0x6d, 0x62, 0x04, 0x48, 0x0f, 0x00, 0x00, } func (m *CronJob) Marshal() (dAtA []byte, err error) { @@ -841,6 +841,16 @@ func (m *JobSpec) MarshalToSizedBuffer(dAtA []byte) (int, error) { _ = i var l int _ = l + if m.Suspend != nil { + i-- + if *m.Suspend { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x50 + } i -= len(m.CompletionMode) copy(dAtA[i:], m.CompletionMode) i = encodeVarintGenerated(dAtA, i, uint64(len(m.CompletionMode))) @@ -1202,6 +1212,9 @@ func (m *JobSpec) Size() (n int) { } l = len(m.CompletionMode) n += 1 + l + sovGenerated(uint64(l)) + if m.Suspend != nil { + n += 2 + } return n } @@ -1370,6 +1383,7 @@ func (this *JobSpec) String() string { `BackoffLimit:` + valueToStringGenerated(this.BackoffLimit) + `,`, `TTLSecondsAfterFinished:` + valueToStringGenerated(this.TTLSecondsAfterFinished) + `,`, `CompletionMode:` + fmt.Sprintf("%v", this.CompletionMode) + `,`, + `Suspend:` + valueToStringGenerated(this.Suspend) + `,`, `}`, }, "") return s @@ -2825,6 +2839,27 @@ func (m *JobSpec) Unmarshal(dAtA []byte) error { } m.CompletionMode = CompletionMode(dAtA[iNdEx:postIndex]) iNdEx = postIndex + case 10: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Suspend", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenerated + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + b := bool(v != 0) + m.Suspend = &b default: iNdEx = preIndex skippy, err := skipGenerated(dAtA[iNdEx:]) diff --git a/staging/src/k8s.io/api/batch/v1/generated.proto b/staging/src/k8s.io/api/batch/v1/generated.proto index 465e5fe1e512..54cfbb1b4c67 100644 --- a/staging/src/k8s.io/api/batch/v1/generated.proto +++ b/staging/src/k8s.io/api/batch/v1/generated.proto @@ -184,8 +184,11 @@ message JobSpec { // +optional optional int32 completions = 2; - // Specifies the duration in seconds relative to the startTime that the job may be active - // before the system tries to terminate it; value must be positive integer + // Specifies the duration in seconds relative to the startTime that the job + // may be continuously active before the system tries to terminate it; value + // must be positive integer. If a Job is suspended (at creation or through an + // update), this timer will effectively be stopped and reset when the Job is + // resumed again. // +optional optional int64 activeDeadlineSeconds = 3; @@ -250,12 +253,28 @@ message JobSpec { // controller skips updates for the Job. // +optional optional string completionMode = 9; + + // Suspend specifies whether the Job controller should create Pods or not. If + // a Job is created with suspend set to true, no Pods are created by the Job + // controller. If a Job is suspended after creation (i.e. the flag goes from + // false to true), the Job controller will delete all active Pods associated + // with this Job. Users must design their workload to gracefully handle this. + // Suspending a Job will reset the StartTime field of the Job, effectively + // resetting the ActiveDeadlineSeconds timer too. This is an alpha field and + // requires the SuspendJob feature gate to be enabled; otherwise this field + // may not be set to true. Defaults to false. + // +optional + optional bool suspend = 10; } // JobStatus represents the current state of a Job. message JobStatus { - // The latest available observations of an object's current state. - // When a job fails, one of the conditions will have type == "Failed". + // The latest available observations of an object's current state. When a Job + // fails, one of the conditions will have type "Failed" and status true. When + // a Job is suspended, one of the conditions will have type "Suspended" and + // status true; when the Job is resumed, the status of this condition will + // become false. When a Job is completed, one of the conditions will have + // type "Complete" and status true. // More info: https://kubernetes.io/docs/concepts/workloads/controllers/jobs-run-to-completion/ // +optional // +patchMergeKey=type @@ -263,9 +282,10 @@ message JobStatus { // +listType=atomic repeated JobCondition conditions = 1; - // Represents time when the job was acknowledged by the job controller. - // It is not guaranteed to be set in happens-before order across separate operations. - // It is represented in RFC3339 form and is in UTC. + // Represents time when the job controller started processing a job. When a + // Job is created in the suspended state, this field is not set until the + // first time it is resumed. This field is reset every time a Job is resumed + // from suspension. It is represented in RFC3339 form and is in UTC. // +optional optional k8s.io.apimachinery.pkg.apis.meta.v1.Time startTime = 2; diff --git a/staging/src/k8s.io/api/batch/v1/types.go b/staging/src/k8s.io/api/batch/v1/types.go index 529053441a63..0140c76b72d6 100644 --- a/staging/src/k8s.io/api/batch/v1/types.go +++ b/staging/src/k8s.io/api/batch/v1/types.go @@ -95,8 +95,11 @@ type JobSpec struct { // +optional Completions *int32 `json:"completions,omitempty" protobuf:"varint,2,opt,name=completions"` - // Specifies the duration in seconds relative to the startTime that the job may be active - // before the system tries to terminate it; value must be positive integer + // Specifies the duration in seconds relative to the startTime that the job + // may be continuously active before the system tries to terminate it; value + // must be positive integer. If a Job is suspended (at creation or through an + // update), this timer will effectively be stopped and reset when the Job is + // resumed again. // +optional ActiveDeadlineSeconds *int64 `json:"activeDeadlineSeconds,omitempty" protobuf:"varint,3,opt,name=activeDeadlineSeconds"` @@ -166,12 +169,28 @@ type JobSpec struct { // controller skips updates for the Job. // +optional CompletionMode CompletionMode `json:"completionMode,omitempty" protobuf:"bytes,9,opt,name=completionMode,casttype=CompletionMode"` + + // Suspend specifies whether the Job controller should create Pods or not. If + // a Job is created with suspend set to true, no Pods are created by the Job + // controller. If a Job is suspended after creation (i.e. the flag goes from + // false to true), the Job controller will delete all active Pods associated + // with this Job. Users must design their workload to gracefully handle this. + // Suspending a Job will reset the StartTime field of the Job, effectively + // resetting the ActiveDeadlineSeconds timer too. This is an alpha field and + // requires the SuspendJob feature gate to be enabled; otherwise this field + // may not be set to true. Defaults to false. + // +optional + Suspend *bool `json:"suspend,omitempty" protobuf:"varint,10,opt,name=suspend"` } // JobStatus represents the current state of a Job. type JobStatus struct { - // The latest available observations of an object's current state. - // When a job fails, one of the conditions will have type == "Failed". + // The latest available observations of an object's current state. When a Job + // fails, one of the conditions will have type "Failed" and status true. When + // a Job is suspended, one of the conditions will have type "Suspended" and + // status true; when the Job is resumed, the status of this condition will + // become false. When a Job is completed, one of the conditions will have + // type "Complete" and status true. // More info: https://kubernetes.io/docs/concepts/workloads/controllers/jobs-run-to-completion/ // +optional // +patchMergeKey=type @@ -179,9 +198,10 @@ type JobStatus struct { // +listType=atomic Conditions []JobCondition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` - // Represents time when the job was acknowledged by the job controller. - // It is not guaranteed to be set in happens-before order across separate operations. - // It is represented in RFC3339 form and is in UTC. + // Represents time when the job controller started processing a job. When a + // Job is created in the suspended state, this field is not set until the + // first time it is resumed. This field is reset every time a Job is resumed + // from suspension. It is represented in RFC3339 form and is in UTC. // +optional StartTime *metav1.Time `json:"startTime,omitempty" protobuf:"bytes,2,opt,name=startTime"` @@ -219,6 +239,8 @@ type JobConditionType string // These are valid conditions of a job. const ( + // JobSuspended means the job has been suspended. + JobSuspended JobConditionType = "Suspended" // JobComplete means the job has completed its execution. JobComplete JobConditionType = "Complete" // JobFailed means the job has failed its execution. diff --git a/staging/src/k8s.io/api/batch/v1/types_swagger_doc_generated.go b/staging/src/k8s.io/api/batch/v1/types_swagger_doc_generated.go index 6fa0b76b6e60..78b41f6b169b 100644 --- a/staging/src/k8s.io/api/batch/v1/types_swagger_doc_generated.go +++ b/staging/src/k8s.io/api/batch/v1/types_swagger_doc_generated.go @@ -113,13 +113,14 @@ var map_JobSpec = map[string]string{ "": "JobSpec describes how the job execution will look like.", "parallelism": "Specifies the maximum desired number of pods the job should run at any given time. The actual number of pods running in steady state will be less than this number when ((.spec.completions - .status.successful) < .spec.parallelism), i.e. when the work left to do is less than max parallelism. More info: https://kubernetes.io/docs/concepts/workloads/controllers/jobs-run-to-completion/", "completions": "Specifies the desired number of successfully finished pods the job should be run with. Setting to nil means that the success of any pod signals the success of all pods, and allows parallelism to have any positive value. Setting to 1 means that parallelism is limited to 1 and the success of that pod signals the success of the job. More info: https://kubernetes.io/docs/concepts/workloads/controllers/jobs-run-to-completion/", - "activeDeadlineSeconds": "Specifies the duration in seconds relative to the startTime that the job may be active before the system tries to terminate it; value must be positive integer", + "activeDeadlineSeconds": "Specifies the duration in seconds relative to the startTime that the job may be continuously active before the system tries to terminate it; value must be positive integer. If a Job is suspended (at creation or through an update), this timer will effectively be stopped and reset when the Job is resumed again.", "backoffLimit": "Specifies the number of retries before marking this job failed. Defaults to 6", "selector": "A label query over pods that should match the pod count. Normally, the system sets this field for you. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors", "manualSelector": "manualSelector controls generation of pod labels and pod selectors. Leave `manualSelector` unset unless you are certain what you are doing. When false or unset, the system pick labels unique to this job and appends those labels to the pod template. When true, the user is responsible for picking unique labels and specifying the selector. Failure to pick a unique label may cause this and other jobs to not function correctly. However, You may see `manualSelector=true` in jobs that were created with the old `extensions/v1beta1` API. More info: https://kubernetes.io/docs/concepts/workloads/controllers/jobs-run-to-completion/#specifying-your-own-pod-selector", "template": "Describes the pod that will be created when executing a job. More info: https://kubernetes.io/docs/concepts/workloads/controllers/jobs-run-to-completion/", "ttlSecondsAfterFinished": "ttlSecondsAfterFinished limits the lifetime of a Job that has finished execution (either Complete or Failed). If this field is set, ttlSecondsAfterFinished after the Job finishes, it is eligible to be automatically deleted. When the Job is being deleted, its lifecycle guarantees (e.g. finalizers) will be honored. If this field is unset, the Job won't be automatically deleted. If this field is set to zero, the Job becomes eligible to be deleted immediately after it finishes. This field is alpha-level and is only honored by servers that enable the TTLAfterFinished feature.", "completionMode": "CompletionMode specifies how Pod completions are tracked. It can be `NonIndexed` (default) or `Indexed`.\n\n`NonIndexed` means that the Job is considered complete when there have been .spec.completions successfully completed Pods. Each Pod completion is homologous to each other.\n\n`Indexed` means that the Pods of a Job get an associated completion index from 0 to (.spec.completions - 1), available in the annotation batch.alpha.kubernetes.io/job-completion-index. The Job is considered complete when there is one successfully completed Pod for each index. When value is `Indexed`, .spec.completions must be specified and `.spec.parallelism` must be less than or equal to 10^5.\n\nThis field is alpha-level and is only honored by servers that enable the IndexedJob feature gate. More completion modes can be added in the future. If the Job controller observes a mode that it doesn't recognize, the controller skips updates for the Job.", + "suspend": "Suspend specifies whether the Job controller should create Pods or not. If a Job is created with suspend set to true, no Pods are created by the Job controller. If a Job is suspended after creation (i.e. the flag goes from false to true), the Job controller will delete all active Pods associated with this Job. Users must design their workload to gracefully handle this. Suspending a Job will reset the StartTime field of the Job, effectively resetting the ActiveDeadlineSeconds timer too. This is an alpha field and requires the SuspendJob feature gate to be enabled; otherwise this field may not be set to true. Defaults to false.", } func (JobSpec) SwaggerDoc() map[string]string { @@ -128,8 +129,8 @@ func (JobSpec) SwaggerDoc() map[string]string { var map_JobStatus = map[string]string{ "": "JobStatus represents the current state of a Job.", - "conditions": "The latest available observations of an object's current state. When a job fails, one of the conditions will have type == \"Failed\". More info: https://kubernetes.io/docs/concepts/workloads/controllers/jobs-run-to-completion/", - "startTime": "Represents time when the job was acknowledged by the job controller. It is not guaranteed to be set in happens-before order across separate operations. It is represented in RFC3339 form and is in UTC.", + "conditions": "The latest available observations of an object's current state. When a Job fails, one of the conditions will have type \"Failed\" and status true. When a Job is suspended, one of the conditions will have type \"Suspended\" and status true; when the Job is resumed, the status of this condition will become false. When a Job is completed, one of the conditions will have type \"Complete\" and status true. More info: https://kubernetes.io/docs/concepts/workloads/controllers/jobs-run-to-completion/", + "startTime": "Represents time when the job controller started processing a job. When a Job is created in the suspended state, this field is not set until the first time it is resumed. This field is reset every time a Job is resumed from suspension. It is represented in RFC3339 form and is in UTC.", "completionTime": "Represents time when the job was completed. It is not guaranteed to be set in happens-before order across separate operations. It is represented in RFC3339 form and is in UTC. The completion time is only set when the job finishes successfully.", "active": "The number of actively running pods.", "succeeded": "The number of pods which reached phase Succeeded.", diff --git a/staging/src/k8s.io/api/batch/v1/zz_generated.deepcopy.go b/staging/src/k8s.io/api/batch/v1/zz_generated.deepcopy.go index b86b6fc0f822..96f012532f5d 100644 --- a/staging/src/k8s.io/api/batch/v1/zz_generated.deepcopy.go +++ b/staging/src/k8s.io/api/batch/v1/zz_generated.deepcopy.go @@ -271,6 +271,11 @@ func (in *JobSpec) DeepCopyInto(out *JobSpec) { *out = new(int32) **out = **in } + if in.Suspend != nil { + in, out := &in.Suspend, &out.Suspend + *out = new(bool) + **out = **in + } return } diff --git a/staging/src/k8s.io/api/testdata/HEAD/batch.v1.CronJob.json b/staging/src/k8s.io/api/testdata/HEAD/batch.v1.CronJob.json index 5ac07d67576f..0fe4bd2c1d01 100644 --- a/staging/src/k8s.io/api/testdata/HEAD/batch.v1.CronJob.json +++ b/staging/src/k8s.io/api/testdata/HEAD/batch.v1.CronJob.json @@ -1569,11 +1569,12 @@ "setHostnameAsFQDN": false } }, - "ttlSecondsAfterFinished": -1285029915 + "ttlSecondsAfterFinished": -1285029915, + "suspend": true } }, - "successfulJobsHistoryLimit": -1887637570, - "failedJobsHistoryLimit": 1755548633 + "successfulJobsHistoryLimit": 1729066291, + "failedJobsHistoryLimit": -908823020 }, "status": { "active": [ @@ -1581,7 +1582,7 @@ "kind": "503", "namespace": "504", "name": "505", - "uid": "犓`ɜɅco\\穜T睭憲Ħ焵i,ŋŨN", + "uid": "`", "apiVersion": "506", "resourceVersion": "507", "fieldPath": "508" diff --git a/staging/src/k8s.io/api/testdata/HEAD/batch.v1.CronJob.pb b/staging/src/k8s.io/api/testdata/HEAD/batch.v1.CronJob.pb index 1d9e6af15be4..f016c27792b5 100644 Binary files a/staging/src/k8s.io/api/testdata/HEAD/batch.v1.CronJob.pb and b/staging/src/k8s.io/api/testdata/HEAD/batch.v1.CronJob.pb differ diff --git a/staging/src/k8s.io/api/testdata/HEAD/batch.v1.CronJob.yaml b/staging/src/k8s.io/api/testdata/HEAD/batch.v1.CronJob.yaml index 3c18b2d5b000..cb038c5dc26e 100644 --- a/staging/src/k8s.io/api/testdata/HEAD/batch.v1.CronJob.yaml +++ b/staging/src/k8s.io/api/testdata/HEAD/batch.v1.CronJob.yaml @@ -31,7 +31,7 @@ metadata: uid: "7" spec: concurrencyPolicy: Hr鯹)晿 0, nil + }), wait.ErrWaitTimeout) + + ginkgo.By("Checking Job status to observe Suspended state") + job, err = e2ejob.GetJob(f.ClientSet, f.Namespace.Name, job.Name) + framework.ExpectNoError(err, "failed to retrieve latest job object") + exists := false + for _, c := range job.Status.Conditions { + if c.Type == batchv1.JobSuspended { + exists = true + break + } + } + framework.ExpectEqual(exists, true) + + ginkgo.By("Updating the job with suspend=false") + job.Spec.Suspend = pointer.BoolPtr(false) + job, err = e2ejob.UpdateJob(f.ClientSet, f.Namespace.Name, job) + framework.ExpectNoError(err, "failed to update job in namespace: %s", f.Namespace.Name) + + ginkgo.By("Waiting for job to complete") + err = e2ejob.WaitForJobComplete(f.ClientSet, f.Namespace.Name, job.Name, completions) + framework.ExpectNoError(err, "failed to ensure job completion in namespace: %s", f.Namespace.Name) + }) + + // Requires the alpha level feature gate SuspendJob. This e2e test will not + // pass without the following flag being passed to kubetest: + // --test_args="--feature-gates=SuspendJob=true" + ginkgo.It("[Feature:SuspendJob] should delete pods when suspended", func() { + ginkgo.By("Creating a job with suspend=false") + job := e2ejob.NewTestJob("notTerminate", "suspend-false-to-true", v1.RestartPolicyNever, parallelism, completions, nil, backoffLimit) + job.Spec.Suspend = pointer.BoolPtr(false) + job, err := e2ejob.CreateJob(f.ClientSet, f.Namespace.Name, job) + framework.ExpectNoError(err, "failed to create job in namespace: %s", f.Namespace.Name) + + ginkgo.By("Ensure pods equal to paralellism count is attached to the job") + err = e2ejob.WaitForAllJobPodsRunning(f.ClientSet, f.Namespace.Name, job.Name, parallelism) + framework.ExpectNoError(err, "failed to ensure number of pods associated with job %s is equal to parallelism count in namespace: %s", job.Name, f.Namespace.Name) + + ginkgo.By("Updating the job with suspend=true") + job, err = e2ejob.GetJob(f.ClientSet, f.Namespace.Name, job.Name) + framework.ExpectNoError(err, "failed to retrieve latest job object") + job.Spec.Suspend = pointer.BoolPtr(true) + job, err = e2ejob.UpdateJob(f.ClientSet, f.Namespace.Name, job) + framework.ExpectNoError(err, "failed to update job in namespace: %s", f.Namespace.Name) + + ginkgo.By("Ensuring pods are deleted") + err = e2ejob.WaitForAllJobPodsGone(f.ClientSet, f.Namespace.Name, job.Name) + framework.ExpectNoError(err, "failed to ensure pods are deleted after suspend=true") + + ginkgo.By("Checking Job status to observe Suspended state") + job, err = e2ejob.GetJob(f.ClientSet, f.Namespace.Name, job.Name) + framework.ExpectNoError(err, "failed to retrieve latest job object") + exists := false + for _, c := range job.Status.Conditions { + if c.Type == batchv1.JobSuspended { + exists = true + break + } + } + framework.ExpectEqual(exists, true) + }) + /* Testcase: Ensure Pods of an Indexed Job get a unique index. Description: Create an Indexed Job, wait for completion, capture the output of the pods and verify that they contain the completion index. diff --git a/test/e2e/framework/job/rest.go b/test/e2e/framework/job/rest.go index e642fd50da0f..913c61e9f4bb 100644 --- a/test/e2e/framework/job/rest.go +++ b/test/e2e/framework/job/rest.go @@ -42,3 +42,9 @@ func GetJobPods(c clientset.Interface, ns, jobName string) (*v1.PodList, error) func CreateJob(c clientset.Interface, ns string, job *batchv1.Job) (*batchv1.Job, error) { return c.BatchV1().Jobs(ns).Create(context.TODO(), job, metav1.CreateOptions{}) } + +// CreateJob uses c to update a job in namespace ns. If the returned error is +// nil, the returned Job is valid and has been updated. +func UpdateJob(c clientset.Interface, ns string, job *batchv1.Job) (*batchv1.Job, error) { + return c.BatchV1().Jobs(ns).Update(context.TODO(), job, metav1.UpdateOptions{}) +}