diff --git a/docs/resources/job_v1.md b/docs/resources/job_v1.md index 32296bb876..7cd6cb91e5 100644 --- a/docs/resources/job_v1.md +++ b/docs/resources/job_v1.md @@ -63,6 +63,7 @@ Optional: - `parallelism` (Number) 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/ - `pod_failure_policy` (Block List, Max: 1) 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/ (see [below for nested schema](#nestedblock--spec--pod_failure_policy)) - `selector` (Block List, Max: 1) A label query over volumes to consider for binding. (see [below for nested schema](#nestedblock--spec--selector)) +- `suspend` (Boolean) Tells the controller to suspend subsequent executions, and terminate all active executions. Defaults to false. More info: https://kubernetes.io/docs/concepts/workloads/controllers/job/#suspending-a-job - `ttl_seconds_after_finished` (String) 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. diff --git a/kubernetes/resource_kubernetes_job_v1.go b/kubernetes/resource_kubernetes_job_v1.go index 572635b340..01860c915d 100644 --- a/kubernetes/resource_kubernetes_job_v1.go +++ b/kubernetes/resource_kubernetes_job_v1.go @@ -21,6 +21,10 @@ import ( "k8s.io/client-go/kubernetes" ) +const ( + waitForCompletionSuspendError = `cannot set both wait_for_completion and spec.suspend to true` +) + func resourceKubernetesJobV1() *schema.Resource { return &schema.Resource{ Description: "A Job creates one or more Pods and ensures that a specified number of them successfully terminate. As pods successfully complete, the Job tracks the successful completions. When a specified number of successful completions is reached, the task (ie, Job) is complete. Deleting a Job will clean up the Pods it created. A simple case is to create one Job object in order to reliably run one Pod to completion. The Job object will start a new Pod if the first Pod fails or is deleted (for example due to a node hardware failure or a node reboot. You can also use a Job to run multiple Pods in parallel. ", @@ -45,6 +49,16 @@ func resourceKubernetesJobV1() *schema.Resource { Delete: schema.DefaultTimeout(1 * time.Minute), }, Schema: resourceKubernetesJobV1Schema(), + CustomizeDiff: func(ctx context.Context, diff *schema.ResourceDiff, meta interface{}) error { + // wait_for_completion and suspend cannot be both set to true + if !diff.HasChange("wait_for_completion") && !diff.HasChange("spec.0.suspend") { + return nil + } + if diff.Get("wait_for_completion").(bool) && diff.Get("spec.0.suspend").(bool) { + return fmt.Errorf(waitForCompletionSuspendError) + } + return nil + }, } } diff --git a/kubernetes/resource_kubernetes_job_v1_test.go b/kubernetes/resource_kubernetes_job_v1_test.go index 396777de9b..78a5f6cc09 100644 --- a/kubernetes/resource_kubernetes_job_v1_test.go +++ b/kubernetes/resource_kubernetes_job_v1_test.go @@ -6,6 +6,7 @@ package kubernetes import ( "context" "fmt" + "regexp" "testing" "time" @@ -42,6 +43,7 @@ func TestAccKubernetesJobV1_wait_for_completion(t *testing.T) { testAccCheckJobV1Waited(time.Duration(10)*time.Second), testAccCheckKubernetesJobV1Exists(resourceName, &conf), resource.TestCheckResourceAttr(resourceName, "wait_for_completion", "true"), + resource.TestCheckResourceAttr(resourceName, "spec.0.suspend", "false"), ), }, }, @@ -90,6 +92,7 @@ func TestAccKubernetesJobV1_basic(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "spec.0.pod_failure_policy.0.rule.1.action", "Ignore"), resource.TestCheckResourceAttr(resourceName, "spec.0.pod_failure_policy.0.rule.1.on_pod_condition.0.type", "DisruptionTarget"), resource.TestCheckResourceAttr(resourceName, "spec.0.pod_failure_policy.0.rule.1.on_pod_condition.0.status", "False"), + resource.TestCheckResourceAttr(resourceName, "spec.0.suspend", "false"), ), }, { @@ -110,6 +113,7 @@ func TestAccKubernetesJobV1_basic(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "spec.0.manual_selector", "true"), resource.TestCheckResourceAttr(resourceName, "spec.0.template.0.spec.0.container.0.name", "hello"), resource.TestCheckResourceAttr(resourceName, "spec.0.template.0.spec.0.container.0.image", imageName), + resource.TestCheckResourceAttr(resourceName, "spec.0.suspend", "false"), resource.TestCheckResourceAttr(resourceName, "wait_for_completion", "false"), ), }, @@ -157,10 +161,11 @@ func TestAccKubernetesJobV1_update(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "spec.0.pod_failure_policy.0.rule.1.action", "Ignore"), resource.TestCheckResourceAttr(resourceName, "spec.0.pod_failure_policy.0.rule.1.on_pod_condition.0.type", "DisruptionTarget"), resource.TestCheckResourceAttr(resourceName, "spec.0.pod_failure_policy.0.rule.1.on_pod_condition.0.status", "False"), + resource.TestCheckResourceAttr(resourceName, "spec.0.suspend", "false"), ), }, { - Config: testAccKubernetesJobV1Config_updateMutableFields(name, imageName, "121", "4", "false", "2"), + Config: testAccKubernetesJobV1Config_updateMutableFields(name, imageName, "121", "4", "false", "2", "false"), Check: resource.ComposeAggregateTestCheckFunc( testAccCheckKubernetesJobV1Exists(resourceName, &conf2), resource.TestCheckResourceAttr(resourceName, "spec.0.active_deadline_seconds", "121"), @@ -168,7 +173,7 @@ func TestAccKubernetesJobV1_update(t *testing.T) { ), }, { - Config: testAccKubernetesJobV1Config_updateMutableFields(name, imageName, "121", "5", "false", "2"), + Config: testAccKubernetesJobV1Config_updateMutableFields(name, imageName, "121", "5", "false", "2", "false"), Check: resource.ComposeAggregateTestCheckFunc( testAccCheckKubernetesJobV1Exists(resourceName, &conf2), resource.TestCheckResourceAttr(resourceName, "spec.0.backoff_limit", "5"), @@ -176,7 +181,7 @@ func TestAccKubernetesJobV1_update(t *testing.T) { ), }, { - Config: testAccKubernetesJobV1Config_updateMutableFields(name, imageName, "121", "5", "true", "2"), + Config: testAccKubernetesJobV1Config_updateMutableFields(name, imageName, "121", "5", "true", "2", "false"), Check: resource.ComposeAggregateTestCheckFunc( testAccCheckKubernetesJobV1Exists(resourceName, &conf2), resource.TestCheckResourceAttr(resourceName, "spec.0.manual_selector", "true"), @@ -184,13 +189,21 @@ func TestAccKubernetesJobV1_update(t *testing.T) { ), }, { - Config: testAccKubernetesJobV1Config_updateMutableFields(name, imageName, "121", "5", "true", "3"), + Config: testAccKubernetesJobV1Config_updateMutableFields(name, imageName, "121", "5", "true", "3", "false"), Check: resource.ComposeAggregateTestCheckFunc( testAccCheckKubernetesJobV1Exists(resourceName, &conf2), resource.TestCheckResourceAttr(resourceName, "spec.0.parallelism", "3"), testAccCheckKubernetesJobV1ForceNew(&conf1, &conf2, false), ), }, + { + Config: testAccKubernetesJobV1Config_updateMutableFields(name, imageName, "121", "5", "true", "3", "true"), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckKubernetesJobV1Exists(resourceName, &conf2), + resource.TestCheckResourceAttr(resourceName, "spec.0.suspend", "true"), + testAccCheckKubernetesJobV1ForceNew(&conf1, &conf2, false), + ), + }, { Config: testAccKubernetesJobV1Config_updateImmutableFields(name, imageName, "6"), Check: resource.ComposeAggregateTestCheckFunc( @@ -237,6 +250,68 @@ func TestAccKubernetesJobV1_ttl_seconds_after_finished(t *testing.T) { }) } +func TestAccKubernetesJobV1_suspend(t *testing.T) { + var conf batchv1.Job + name := fmt.Sprintf("tf-acc-test-%s", acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)) + imageName := busyboxImage + resourceName := "kubernetes_job_v1.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + skipIfClusterVersionLessThan(t, "1.24.0") + }, + IDRefreshName: resourceName, + IDRefreshIgnore: []string{"metadata.0.resource_version"}, + ProviderFactories: testAccProviderFactories, + CheckDestroy: testAccCheckKubernetesJobV1Destroy, + Steps: []resource.TestStep{ + { + Config: testAccKubernetesJobV1Config_suspend(name, imageName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckKubernetesJobV1Exists(resourceName, &conf), + resource.TestCheckResourceAttr(resourceName, "spec.0.suspend", "true"), + ), + }, + { + Config: testAccKubernetesJobV1Config_wait_for_completion(name, imageName), + Check: resource.ComposeAggregateTestCheckFunc( + // NOTE this is to check that Terraform actually waited for the Job to complete + // before considering the Job resource as created + testAccCheckJobV1Waited(time.Duration(10)*time.Second), + testAccCheckKubernetesJobV1Exists(resourceName, &conf), + resource.TestCheckResourceAttr(resourceName, "wait_for_completion", "true"), + resource.TestCheckResourceAttr(resourceName, "spec.0.suspend", "false"), + ), + }, + }, + }) +} + +func TestAccKubernetesJobV1_suspendExpectErrors(t *testing.T) { + name := fmt.Sprintf("tf-acc-test-%s", acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)) + imageName := busyboxImage + resourceName := "kubernetes_job_v1.test" + wantError := waitForCompletionSuspendError + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + skipIfClusterVersionLessThan(t, "1.24.0") + }, + IDRefreshName: resourceName, + IDRefreshIgnore: []string{"metadata.0.resource_version"}, + ProviderFactories: testAccProviderFactories, + CheckDestroy: testAccCheckKubernetesJobV1Destroy, + Steps: []resource.TestStep{ + { // Expect an error when both `wait_for_completion` and `suspend` are set to true. + Config: testAccKubernetesJobV1Config_suspendExpectErrors(name, imageName), + ExpectError: regexp.MustCompile(wantError), + }, + }, + }) +} + func testAccCheckJobV1Waited(minDuration time.Duration) func(*terraform.State) error { // NOTE this works because this function is called when setting up the test // and the function it returns is called after the resource has been created @@ -366,7 +441,7 @@ func testAccKubernetesJobV1Config_basic(name, imageName string) string { }`, name, imageName) } -func testAccKubernetesJobV1Config_updateMutableFields(name, imageName, activeDeadlineSeconds, backoffLimit, manualSelector, parallelism string) string { +func testAccKubernetesJobV1Config_updateMutableFields(name, imageName, activeDeadlineSeconds, backoffLimit, manualSelector, parallelism, suspend string) string { return fmt.Sprintf(`resource "kubernetes_job_v1" "test" { metadata { name = "%s" @@ -377,6 +452,7 @@ func testAccKubernetesJobV1Config_updateMutableFields(name, imageName, activeDea completions = 4 manual_selector = %s parallelism = %s + suspend = %s pod_failure_policy { rule { action = "FailJob" @@ -407,7 +483,7 @@ func testAccKubernetesJobV1Config_updateMutableFields(name, imageName, activeDea } wait_for_completion = false -}`, name, activeDeadlineSeconds, backoffLimit, manualSelector, parallelism, imageName) +}`, name, activeDeadlineSeconds, backoffLimit, manualSelector, parallelism, suspend, imageName) } func testAccKubernetesJobV1Config_updateImmutableFields(name, imageName, completions string) string { @@ -457,6 +533,60 @@ func testAccKubernetesJobV1Config_ttl_seconds_after_finished(name, imageName str }`, name, imageName) } +func testAccKubernetesJobV1Config_suspend(name, imageName string) string { + return fmt.Sprintf(`resource "kubernetes_job_v1" "test" { + metadata { + name = "%s" + } + spec { + suspend = true + template { + metadata { + name = "wait-test" + } + spec { + container { + name = "wait-test" + image = "%s" + command = ["sleep", "10"] + } + } + } + } + wait_for_completion = false + timeouts { + create = "1m" + } +}`, name, imageName) +} + +func testAccKubernetesJobV1Config_suspendExpectErrors(name, imageName string) string { + return fmt.Sprintf(`resource "kubernetes_job_v1" "test" { + metadata { + name = "%s" + } + spec { + suspend = true + template { + metadata { + name = "wait-test" + } + spec { + container { + name = "wait-test" + image = "%s" + command = ["sleep", "10"] + } + } + } + } + wait_for_completion = true + timeouts { + create = "1m" + } +}`, name, imageName) +} + func testAccKubernetesJobV1Config_wait_for_completion(name, imageName string) string { return fmt.Sprintf(`resource "kubernetes_job_v1" "test" { metadata { diff --git a/kubernetes/schema_job_spec.go b/kubernetes/schema_job_spec.go index 1e7157bab3..9ae6db591b 100644 --- a/kubernetes/schema_job_spec.go +++ b/kubernetes/schema_job_spec.go @@ -220,6 +220,12 @@ func jobSpecFields(specUpdatable bool) map[string]*schema.Schema { }, }, }, + "suspend": { + Type: schema.TypeBool, + Optional: true, + ForceNew: false, + Description: "Tells the controller to suspend subsequent executions, and terminate all active executions. Defaults to false. More info: https://kubernetes.io/docs/concepts/workloads/controllers/job/#suspending-a-job", + }, // PodTemplate fields are immutable in Jobs. "template": { Type: schema.TypeList, diff --git a/kubernetes/structure_job.go b/kubernetes/structure_job.go index 5cdd657241..2944292f57 100644 --- a/kubernetes/structure_job.go +++ b/kubernetes/structure_job.go @@ -55,6 +55,10 @@ func flattenJobV1Spec(in batchv1.JobSpec, d *schema.ResourceData, meta interface att["selector"] = flattenLabelSelector(in.Selector) } + if in.Suspend != nil { + att["suspend"] = *in.Suspend + } + removeGeneratedLabels(in.Template.ObjectMeta.Labels) podSpec, err := flattenPodTemplateSpec(in.Template) @@ -120,6 +124,10 @@ func expandJobV1Spec(j []interface{}) (batchv1.JobSpec, error) { obj.Selector = expandLabelSelector(v) } + if v, ok := in["suspend"].(bool); ok { + obj.Suspend = ptr.To(v) + } + template, err := expandPodTemplate(in["template"].([]interface{})) if err != nil { return obj, err @@ -330,6 +338,14 @@ func patchJobV1Spec(pathPrefix, prefix string, d *schema.ResourceData) PatchOper }) } + if d.HasChange(prefix + "suspend") { + v := d.Get(prefix + "suspend").(bool) + ops = append(ops, &ReplaceOperation{ + Path: pathPrefix + "/suspend", + Value: v, + }) + } + return ops }