diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d0dc540..1f7a0eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ IMPROVEMENTS: * data source/nomad_node_pool: Added the `node_identity_ttl` argument ([#569](https://github.com/hashicorp/terraform-provider-nomad/pull/569)) * resource/nomad_node_pool: Added the `node_identity_ttl` argument to configure the node identity TTL for nodes in the pool ([#569](https://github.com/hashicorp/terraform-provider-nomad/pull/569)) * resource/nomad_quota_specification: add support for all `QuotaResources` fields including `secrets_mb`, `devices`, `numa`, and `storage` ([#584](https://github.com/hashicorp/terraform-provider-nomad/pull/584)) +* resource/nomad_job, data source/nomad_job: Added and aligned computed job and task group attributes, including periodic and update strategy fields, to match the `nomad_job` implementation and documentation ([#585](https://github.com/hashicorp/terraform-provider-nomad/pull/585)) ## 2.5.2 (November 13, 2025) diff --git a/nomad/datasource_nomad_job.go b/nomad/datasource_nomad_job.go index 3b984c6c..0b8fcbbe 100644 --- a/nomad/datasource_nomad_job.go +++ b/nomad/datasource_nomad_job.go @@ -132,6 +132,7 @@ func dataSourceJob() *schema.Resource { }, }, }, + "update_strategy": updateStrategySchema(), "periodic_config": { Description: "Job Periodic Configuration", Computed: true, @@ -204,29 +205,13 @@ func dataSourceJobRead(d *schema.ResourceData, meta interface{}) error { d.Set("stop", job.Stop) d.Set("priority", job.Priority) d.Set("parent_id", job.ParentID) + d.Set("task_groups", jobTaskGroupsRaw(job.TaskGroups)) d.Set("stable", job.Stable) d.Set("all_at_once", job.AllAtOnce) d.Set("constraints", job.Constraints) - if job.Periodic != nil { - periodic := map[string]interface{}{} - if job.Periodic.Enabled != nil { - periodic["enabled"] = *job.Periodic.Enabled - } - if job.Periodic.Spec != nil { - periodic["spec"] = *job.Periodic.Spec - } - if job.Periodic.SpecType != nil { - periodic["spec_type"] = *job.Periodic.SpecType - } - if job.Periodic.ProhibitOverlap != nil { - periodic["prohibit_overlap"] = *job.Periodic.ProhibitOverlap - } - if job.Periodic.TimeZone != nil { - periodic["timezone"] = *job.Periodic.TimeZone - } - d.Set("periodic_config", []map[string]interface{}{periodic}) - } + d.Set("update_strategy", flattenUpdateStrategy(job.Update)) + d.Set("periodic_config", flattenPeriodicConfig(job.Periodic)) return nil } diff --git a/nomad/datasource_nomad_job_test.go b/nomad/datasource_nomad_job_test.go index 4bb455e2..8f8628e8 100644 --- a/nomad/datasource_nomad_job_test.go +++ b/nomad/datasource_nomad_job_test.go @@ -32,6 +32,22 @@ func TestAccDataSourceNomadJob_Basic(t *testing.T) { "data.nomad_job.test-job", "priority", "50"), resource.TestCheckResourceAttr( "data.nomad_job.test-job", "namespace", "default"), + resource.TestCheckResourceAttr( + "data.nomad_job.test-job", "update_strategy.#", "1"), + resource.TestCheckResourceAttr( + "data.nomad_job.test-job", "update_strategy.0.max_parallel", "2"), + resource.TestCheckResourceAttr( + "data.nomad_job.test-job", "task_groups.0.update_strategy.#", "1"), + resource.TestCheckResourceAttr( + "data.nomad_job.test-job", "task_groups.0.update_strategy.0.max_parallel", "2"), + resource.TestCheckResourceAttr( + "data.nomad_job.test-job", "task_groups.0.update_strategy.0.min_healthy_time", "11s"), + resource.TestCheckResourceAttr( + "data.nomad_job.test-job", "task_groups.0.update_strategy.0.healthy_deadline", "6m0s"), + resource.TestCheckResourceAttr( + "data.nomad_job.test-job", "task_groups.0.update_strategy.0.auto_revert", "true"), + resource.TestCheckResourceAttr( + "data.nomad_job.test-job", "task_groups.0.update_strategy.0.canary", "1"), ), }, }, diff --git a/nomad/resource_job.go b/nomad/resource_job.go index 38f33775..bac88ec1 100644 --- a/nomad/resource_job.go +++ b/nomad/resource_job.go @@ -16,7 +16,7 @@ import ( "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/jobspec2" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "golang.org/x/exp/maps" @@ -153,6 +153,122 @@ func resourceJob() *schema.Resource { Type: schema.TypeString, }, + "status_description": { + Description: "The status description of the job.", + Computed: true, + Type: schema.TypeString, + }, + + "version": { + Description: "The version of the job.", + Computed: true, + Type: schema.TypeInt, + }, + + "submit_time": { + Description: "The time the job was submitted.", + Computed: true, + Type: schema.TypeInt, + }, + + "create_index": { + Description: "The creation index of the job.", + Computed: true, + Type: schema.TypeInt, + }, + + "stop": { + Description: "Whether the job is stopped.", + Computed: true, + Type: schema.TypeBool, + }, + + "priority": { + Description: "The priority of the job for scheduling and resource access.", + Computed: true, + Type: schema.TypeInt, + }, + + "parent_id": { + Description: "The parent job ID, if applicable.", + Computed: true, + Type: schema.TypeString, + }, + + "stable": { + Description: "Whether the job is stable.", + Computed: true, + Type: schema.TypeBool, + }, + + "all_at_once": { + Description: "Whether the scheduler can make partial placements on oversubscribed nodes.", + Computed: true, + Type: schema.TypeBool, + }, + + "constraints": { + Description: "The job constraints.", + Computed: true, + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "ltarget": { + Description: "The attribute being constrained.", + Type: schema.TypeString, + Computed: true, + }, + "rtarget": { + Description: "The constraint value.", + Type: schema.TypeString, + Computed: true, + }, + "operand": { + Description: "The operator used to compare the attribute to the constraint.", + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, + + "update_strategy": updateStrategySchema(), + + "periodic_config": { + Description: "The job's periodic configuration for time-based scheduling.", + Computed: true, + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Description: "Whether the periodic job is enabled. When disabled, scheduled runs and force launches are prevented.", + Type: schema.TypeBool, + Computed: true, + }, + "spec": { + Description: "Cron expression configuring the interval at which the job is launched.", + Type: schema.TypeString, + Computed: true, + }, + "spec_type": { + Description: "Type of periodic specification, such as cron.", + Type: schema.TypeString, + Computed: true, + }, + "prohibit_overlap": { + Description: "Whether this job should wait until previous instances of the same job have completed before launching again.", + Type: schema.TypeBool, + Computed: true, + }, + "timezone": { + Description: "Time zone used to evaluate the next launch interval.", + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, + "region": { Description: "The target region for the job, as derived from the jobspec.", Computed: true, @@ -218,6 +334,7 @@ func taskGroupSchema() *schema.Schema { Computed: true, Type: schema.TypeInt, }, + "update_strategy": updateStrategySchema(), // "scaling": { // Computed: true, // Type: schema.TypeList, @@ -303,6 +420,53 @@ func taskGroupSchema() *schema.Schema { } } +func updateStrategySchema() *schema.Schema { + return &schema.Schema{ + Description: "The update strategy for rolling updates and canary deployments.", + Computed: true, + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "stagger": { + Description: "Delay between each set of max_parallel updates when updating system jobs.", + Type: schema.TypeString, + Computed: true, + }, + "max_parallel": { + Description: "Number of allocations within a task group that can be destructively updated at the same time. Setting 0 forces updates instead of deployments.", + Type: schema.TypeInt, + Computed: true, + }, + "health_check": { + Description: "Mechanism used to determine allocation health: checks, task_states, or manual.", + Type: schema.TypeString, + Computed: true, + }, + "min_healthy_time": { + Description: "Minimum time the allocation must be in the healthy state before further updates can proceed.", + Type: schema.TypeString, + Computed: true, + }, + "healthy_deadline": { + Description: "Deadline by which the allocation must become healthy before it is marked unhealthy.", + Type: schema.TypeString, + Computed: true, + }, + "auto_revert": { + Description: "Whether the job should automatically revert to the last stable job on deployment failure.", + Type: schema.TypeBool, + Computed: true, + }, + "canary": { + Description: "Number of canary allocations created before destructive updates continue.", + Type: schema.TypeInt, + Computed: true, + }, + }, + }, + } +} + // JobParserConfig stores configuration options for how to parse the jobspec. type JobParserConfig struct { JSON JSONJobParserConfig @@ -422,7 +586,7 @@ func resourceJobRegister(d *schema.ResourceData, meta interface{}) error { // if they result in a deployment, monitors that deployment until completion. func monitorDeployment(client *api.Client, timeout time.Duration, namespace string, initialEvalID string) (*api.Deployment, error) { - stateConf := &resource.StateChangeConf{ + stateConf := &retry.StateChangeConf{ Pending: []string{MonitoringEvaluation}, Target: []string{EvaluationComplete}, Refresh: evaluationStateRefreshFunc(client, namespace, initialEvalID), @@ -431,7 +595,7 @@ func monitorDeployment(client *api.Client, timeout time.Duration, namespace stri MinTimeout: 3 * time.Second, } - state, err := stateConf.WaitForState() + state, err := stateConf.WaitForStateContext(context.Background()) if err != nil { return nil, fmt.Errorf("error waiting for evaluation: %s", err) } @@ -442,7 +606,7 @@ func monitorDeployment(client *api.Client, timeout time.Duration, namespace stri return nil, nil } - stateConf = &resource.StateChangeConf{ + stateConf = &retry.StateChangeConf{ Pending: []string{MonitoringDeployment}, Target: []string{DeploymentSuccessful}, Refresh: deploymentStateRefreshFunc(client, namespace, evaluation.DeploymentID), @@ -451,16 +615,16 @@ func monitorDeployment(client *api.Client, timeout time.Duration, namespace stri MinTimeout: 5 * time.Second, } - state, err = stateConf.WaitForState() + state, err = stateConf.WaitForStateContext(context.Background()) if err != nil { return nil, fmt.Errorf("error waiting for evaluation: %s", err) } return state.(*api.Deployment), nil } -// evaluationStateRefreshFunc returns a resource.StateRefreshFunc that is used to watch +// evaluationStateRefreshFunc returns a retry.StateRefreshFunc that is used to watch // the evaluation(s) from a job create/update -func evaluationStateRefreshFunc(client *api.Client, namespace string, initialEvalID string) resource.StateRefreshFunc { +func evaluationStateRefreshFunc(client *api.Client, namespace string, initialEvalID string) retry.StateRefreshFunc { // evalID is the evaluation that we are currently monitoring. This will change // along with follow-up evaluations. @@ -498,9 +662,9 @@ func evaluationStateRefreshFunc(client *api.Client, namespace string, initialEva } } -// deploymentStateRefreshFunc returns a resource.StateRefreshFunc that is used to watch +// deploymentStateRefreshFunc returns a retry.StateRefreshFunc that is used to watch // the deployment from a job create/update -func deploymentStateRefreshFunc(client *api.Client, namespace string, deploymentID string) resource.StateRefreshFunc { +func deploymentStateRefreshFunc(client *api.Client, namespace string, deploymentID string) retry.StateRefreshFunc { return func() (interface{}, string, error) { // monitor the deployment var state string @@ -588,7 +752,6 @@ func resourceJobRead(d *schema.ResourceData, meta interface{}) error { d.Set("type", job.Type) d.Set("region", job.Region) d.Set("datacenters", job.Datacenters) - d.Set("task_groups", jobTaskGroupsRaw(job.TaskGroups)) d.Set("namespace", job.Namespace) if job.JobModifyIndex != nil { d.Set("modify_index", strconv.FormatUint(*job.JobModifyIndex, 10)) @@ -596,6 +759,20 @@ func resourceJobRead(d *schema.ResourceData, meta interface{}) error { d.Set("modify_index", "0") } d.Set("status", job.Status) + d.Set("status_description", job.StatusDescription) + d.Set("version", job.Version) + d.Set("submit_time", job.SubmitTime) + d.Set("create_index", job.CreateIndex) + d.Set("stop", job.Stop) + d.Set("priority", job.Priority) + d.Set("parent_id", job.ParentID) + d.Set("stable", job.Stable) + d.Set("all_at_once", job.AllAtOnce) + d.Set("constraints", flattenJobConstraints(job.Constraints)) + d.Set("update_strategy", flattenUpdateStrategy(job.Update)) + d.Set("periodic_config", flattenPeriodicConfig(job.Periodic)) + + d.Set("task_groups", jobTaskGroupsRaw(job.TaskGroups)) if d.Get("read_allocation_ids").(bool) { allocStubs, _, err := client.Jobs().Allocations(id, false, opts) @@ -682,6 +859,18 @@ func resourceJobCustomizeDiff(_ context.Context, d *schema.ResourceDiff, meta in d.SetNewComputed("deployment_id") d.SetNewComputed("deployment_status") d.SetNewComputed("status") + d.SetNewComputed("status_description") + d.SetNewComputed("version") + d.SetNewComputed("submit_time") + d.SetNewComputed("create_index") + d.SetNewComputed("stop") + d.SetNewComputed("priority") + d.SetNewComputed("parent_id") + d.SetNewComputed("stable") + d.SetNewComputed("all_at_once") + d.SetNewComputed("constraints") + d.SetNewComputed("update_strategy") + d.SetNewComputed("periodic_config") return nil } @@ -734,6 +923,7 @@ func resourceJobCustomizeDiff(_ context.Context, d *schema.ResourceDiff, meta in d.SetNew("region", job.Region) d.SetNew("datacenters", job.Datacenters) d.SetNew("status", job.Status) + d.SetNew("periodic_config", flattenPeriodicConfig(job.Periodic)) // If the identity has changed and the config asks us to deregister on identity // change then the id field "forces new resource". @@ -776,12 +966,62 @@ func resourceJobCustomizeDiff(_ context.Context, d *schema.ResourceDiff, meta in d.SetNewComputed("modify_index") // similarly, we won't know the allocation ids until after the job registration eval d.SetNewComputed("allocation_ids") - - d.SetNew("task_groups", jobTaskGroupsRaw(job.TaskGroups)) + canonicalizeTaskGroupUpdateStrategies(job) + plannedTaskGroups := jobTaskGroupsRaw(job.TaskGroups) + d.SetNew("task_groups", plannedTaskGroups) return nil } +func canonicalizeTaskGroupUpdateStrategies(job *api.Job) { + if job == nil { + return + } + + for _, taskGroup := range job.TaskGroups { + if taskGroup == nil { + continue + } + + if jobUpdate, taskGroupUpdate := job.Update != nil, taskGroup.Update != nil; jobUpdate && taskGroupUpdate { + merged := job.Update.Copy() + merged.Merge(taskGroup.Update) + taskGroup.Update = merged + } else if jobUpdate && !job.Update.Empty() { + taskGroup.Update = job.Update.Copy() + } + + if taskGroup.Update != nil { + taskGroup.Update.Canonicalize() + } + } +} + +func flattenPeriodicConfig(periodic *api.PeriodicConfig) []map[string]interface{} { + if periodic == nil { + return nil + } + + flattened := map[string]interface{}{} + if periodic.Enabled != nil { + flattened["enabled"] = *periodic.Enabled + } + if periodic.Spec != nil { + flattened["spec"] = *periodic.Spec + } + if periodic.SpecType != nil { + flattened["spec_type"] = *periodic.SpecType + } + if periodic.ProhibitOverlap != nil { + flattened["prohibit_overlap"] = *periodic.ProhibitOverlap + } + if periodic.TimeZone != nil { + flattened["timezone"] = *periodic.TimeZone + } + + return []map[string]interface{}{flattened} +} + func parseJobParserConfig(d ResourceFieldGetter) (JobParserConfig, error) { config := JobParserConfig{} @@ -916,8 +1156,10 @@ func jobTaskGroupsRaw(tgs []*api.TaskGroup) []interface{} { for _, tg := range tgs { tgM := make(map[string]interface{}) + tgName := "" if tg.Name != nil { - tgM["name"] = *tg.Name + tgName = *tg.Name + tgM["name"] = tgName } else { tgM["name"] = "" } @@ -931,6 +1173,9 @@ func jobTaskGroupsRaw(tgs []*api.TaskGroup) []interface{} { } else { tgM["meta"] = make(map[string]interface{}) } + if tg.Update != nil { + tgM["update_strategy"] = flattenUpdateStrategy(tg.Update) + } tasksI := make([]interface{}, 0, len(tg.Tasks)) for _, task := range tg.Tasks { @@ -984,6 +1229,47 @@ func jobTaskGroupsRaw(tgs []*api.TaskGroup) []interface{} { return ret } +func flattenJobConstraints(constraints []*api.Constraint) []map[string]interface{} { + result := make([]map[string]interface{}, 0, len(constraints)) + for _, c := range constraints { + result = append(result, map[string]interface{}{ + "ltarget": c.LTarget, + "rtarget": c.RTarget, + "operand": c.Operand, + }) + } + return result +} + +func flattenUpdateStrategy(update *api.UpdateStrategy) []map[string]interface{} { + if update == nil { + return nil + } + u := map[string]interface{}{} + if update.Stagger != nil { + u["stagger"] = update.Stagger.String() + } + if update.MaxParallel != nil { + u["max_parallel"] = *update.MaxParallel + } + if update.HealthCheck != nil { + u["health_check"] = *update.HealthCheck + } + if update.MinHealthyTime != nil { + u["min_healthy_time"] = update.MinHealthyTime.String() + } + if update.HealthyDeadline != nil { + u["healthy_deadline"] = update.HealthyDeadline.String() + } + if update.AutoRevert != nil { + u["auto_revert"] = *update.AutoRevert + } + if update.Canary != nil { + u["canary"] = *update.Canary + } + return []map[string]interface{}{u} +} + // jobspecDiffSuppress is the DiffSuppressFunc used by the schema to // check if two jobspecs are equal. func jobspecDiffSuppress(k, old, new string, d *schema.ResourceData) bool { diff --git a/nomad/resource_job_test.go b/nomad/resource_job_test.go index dc83ad8a..0e89f68e 100644 --- a/nomad/resource_job_test.go +++ b/nomad/resource_job_test.go @@ -217,6 +217,8 @@ func TestResourceJob_serviceWithoutDeployment(t *testing.T) { Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr(resourceName, "deployment_id", ""), resource.TestCheckResourceAttr(resourceName, "deployment_status", ""), + resource.TestCheckResourceAttr(resourceName, "update_strategy.#", "1"), + resource.TestCheckResourceAttr(resourceName, "update_strategy.0.max_parallel", "0"), ), }, }, @@ -224,6 +226,27 @@ func TestResourceJob_serviceWithoutDeployment(t *testing.T) { }) } +func TestResourceJob_periodicConfig(t *testing.T) { + resourceName := "nomad_job.periodic" + r.Test(t, r.TestCase{ + Providers: testProviders, + PreCheck: func() { testAccPreCheck(t) }, + Steps: []r.TestStep{ + { + Config: testResourceJob_periodicConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "periodic_config.#", "1"), + resource.TestCheckResourceAttr(resourceName, "periodic_config.0.enabled", "true"), + resource.TestCheckResourceAttr(resourceName, "periodic_config.0.spec", "*/1 * * * * *"), + resource.TestCheckResourceAttr(resourceName, "periodic_config.0.prohibit_overlap", "true"), + resource.TestCheckResourceAttr(resourceName, "periodic_config.0.timezone", "UTC"), + ), + }, + }, + CheckDestroy: testResourceJob_checkDestroy("foo-periodic"), + }) +} + func TestResourceJob_multiregion(t *testing.T) { r.Test(t, r.TestCase{ Providers: testProviders, @@ -2760,10 +2783,10 @@ resource "nomad_job" "service" { job "foo-service-without-deployment" { type = "service" datacenters = ["dc1"] + update { + max_parallel = 0 + } group "service" { - update { - max_parallel = 0 - } task "sleep" { driver = "raw_exec" env { @@ -2798,6 +2821,34 @@ job "foo-batch" { EOT }` +var testResourceJob_periodicConfig = ` +resource "nomad_job" "periodic" { + jobspec = <