Skip to content

Commit 83ec285

Browse files
fix(job): preserve SAO field values on CI/Merge jobs to match plan
CI on PR #670 caught a plan/apply consistency failure in TestAccDbtCloudJobCIWithCostOptimizationFeatures: .cost_optimization_features: planned set element cty.StringVal("state_aware_orchestration") does not correlate with any element in actual. The previous commit correctly skipped the SAO fields at the API boundary for CI/Merge job types (to avoid the 405), but then still overwrote plan.CostOptimizationFeatures from the API response. The API doesn't store the field for those job types, so it derives back to empty — but the user's plan still says ["state_aware_orchestration"], which the framework flags as inconsistent. Fix: for CI/Merge jobs, preserve whatever the user planned for force_node_selection and cost_optimization_features rather than deriving from the API response. The user's value is benign (we never POST it for those job types) and keeps plan==apply consistent. For all other job types the API response is still authoritative. Apply the same logic in Read so that subsequent plans don't show perpetual drift, with a fallback to empty/null on fresh imports so ImportStateVerify still round-trips cleanly. Verified locally with TF_ACC=1 against the dbt Cloud test account: - TestAccDbtCloudJobCIWithCostOptimizationFeatures: PASS - TestAccDbtCloudJobCIWithDeferringEnvironment: PASS - TestAccDbtCloudJobMergeWithDeferringEnvironment: PASS - TestAccDbtCloudJobCostOptimizationFeatures: SKIP (as designed) - TestAccDbtCloudJobResource, JobCISettings, JobResourceIntervalCron, JobResourceJobTypeAndCompareChanges, JobResourceExecuteStepsValid: PASS Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 69cfd38 commit 83ec285

1 file changed

Lines changed: 57 additions & 18 deletions

File tree

pkg/framework/objects/job/resource.go

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -408,18 +408,31 @@ func (j *jobResource) Create(ctx context.Context, req resource.CreateRequest, re
408408
plan.JobType = types.StringNull()
409409
}
410410

411-
// Populate force_node_selection from API response
412-
if createdJob.ForceNodeSelection != nil {
413-
plan.ForceNodeSelection = types.BoolValue(*createdJob.ForceNodeSelection)
411+
// SAO fields: for CI/Merge jobs we never POST force_node_selection or
412+
// cost_optimization_features (the API rejects them with 405). To keep plan
413+
// and apply consistent, preserve whatever the user planned rather than
414+
// deriving from the API response. The user's value is benign because we
415+
// never actually sent it to the API. For other job types the API response
416+
// is authoritative.
417+
createdJobIsCIOrMerge := createdJob.JobType == JobTypeCI || createdJob.JobType == JobTypeMerge
418+
if !createdJobIsCIOrMerge {
419+
if createdJob.ForceNodeSelection != nil {
420+
plan.ForceNodeSelection = types.BoolValue(*createdJob.ForceNodeSelection)
421+
} else if plan.ForceNodeSelection.IsNull() || plan.ForceNodeSelection.IsUnknown() {
422+
plan.ForceNodeSelection = types.BoolNull()
423+
}
424+
plan.CostOptimizationFeatures = costOptimizationFeaturesFromForceNodeSelection(createdJob.ForceNodeSelection)
414425
} else {
415-
// If not set in config and API doesn't return it, keep it null
416-
if plan.ForceNodeSelection.IsNull() {
426+
// CI/Merge: collapse any unknown plan values to known empty/null so the
427+
// framework's plan-apply consistency check has concrete values to compare.
428+
if plan.ForceNodeSelection.IsUnknown() {
417429
plan.ForceNodeSelection = types.BoolNull()
418430
}
431+
if plan.CostOptimizationFeatures.IsUnknown() {
432+
plan.CostOptimizationFeatures = types.SetValueMust(types.StringType, []attr.Value{})
433+
}
419434
}
420435

421-
plan.CostOptimizationFeatures = costOptimizationFeaturesFromForceNodeSelection(createdJob.ForceNodeSelection)
422-
423436
jobIDStr := strconv.FormatInt(int64(*createdJob.ID), 10)
424437

425438
// Check if DeferringJobId is set and matches this job's ID for self-deferring
@@ -633,14 +646,29 @@ func (j *jobResource) Read(ctx context.Context, req resource.ReadRequest, resp *
633646
state.RunLint = types.BoolValue(retrievedJob.RunLint)
634647
state.ErrorsOnLintFailure = types.BoolValue(retrievedJob.ErrorsOnLintFailure)
635648

636-
if retrievedJob.ForceNodeSelection != nil {
637-
state.ForceNodeSelection = types.BoolValue(*retrievedJob.ForceNodeSelection)
649+
// SAO fields: for CI/Merge jobs the API does not honor force_node_selection /
650+
// cost_optimization_features (we never POST them for those job types), so the
651+
// API value is not authoritative — preserve the existing state. On fresh
652+
// imports (no prior state) collapse to known empty/null values that match
653+
// what Create writes, so that ImportStateVerify round-trips cleanly. For
654+
// other job types the API response is authoritative.
655+
retrievedJobIsCIOrMerge := retrievedJob.JobType == JobTypeCI || retrievedJob.JobType == JobTypeMerge
656+
if !retrievedJobIsCIOrMerge {
657+
if retrievedJob.ForceNodeSelection != nil {
658+
state.ForceNodeSelection = types.BoolValue(*retrievedJob.ForceNodeSelection)
659+
} else {
660+
state.ForceNodeSelection = types.BoolNull()
661+
}
662+
state.CostOptimizationFeatures = costOptimizationFeaturesFromForceNodeSelection(retrievedJob.ForceNodeSelection)
638663
} else {
639-
state.ForceNodeSelection = types.BoolNull()
664+
if state.ForceNodeSelection.IsUnknown() {
665+
state.ForceNodeSelection = types.BoolNull()
666+
}
667+
if state.CostOptimizationFeatures.IsNull() || state.CostOptimizationFeatures.IsUnknown() {
668+
state.CostOptimizationFeatures = types.SetValueMust(types.StringType, []attr.Value{})
669+
}
640670
}
641671

642-
state.CostOptimizationFeatures = costOptimizationFeaturesFromForceNodeSelection(retrievedJob.ForceNodeSelection)
643-
644672
if retrievedJob.JobType != "" {
645673
state.JobType = types.StringValue(retrievedJob.JobType)
646674
} else {
@@ -925,15 +953,26 @@ func (j *jobResource) Update(ctx context.Context, req resource.UpdateRequest, re
925953
plan.TimeoutSeconds = types.Int64Value(int64(updatedJob.Execution.TimeoutSeconds))
926954
}
927955

928-
// Populate force_node_selection from API response
929-
if updatedJob.ForceNodeSelection != nil {
930-
plan.ForceNodeSelection = types.BoolValue(*updatedJob.ForceNodeSelection)
956+
// SAO fields: for CI/Merge jobs we never POST force_node_selection or
957+
// cost_optimization_features, so preserve whatever the user planned (see the
958+
// matching block in Create for the rationale).
959+
updatedJobIsCIOrMerge := updatedJob.JobType == JobTypeCI || updatedJob.JobType == JobTypeMerge
960+
if !updatedJobIsCIOrMerge {
961+
if updatedJob.ForceNodeSelection != nil {
962+
plan.ForceNodeSelection = types.BoolValue(*updatedJob.ForceNodeSelection)
963+
} else {
964+
plan.ForceNodeSelection = types.BoolNull()
965+
}
966+
plan.CostOptimizationFeatures = costOptimizationFeaturesFromForceNodeSelection(updatedJob.ForceNodeSelection)
931967
} else {
932-
plan.ForceNodeSelection = types.BoolNull()
968+
if plan.ForceNodeSelection.IsUnknown() {
969+
plan.ForceNodeSelection = types.BoolNull()
970+
}
971+
if plan.CostOptimizationFeatures.IsUnknown() {
972+
plan.CostOptimizationFeatures = types.SetValueMust(types.StringType, []attr.Value{})
973+
}
933974
}
934975

935-
plan.CostOptimizationFeatures = costOptimizationFeaturesFromForceNodeSelection(updatedJob.ForceNodeSelection)
936-
937976
// For CI/Merge jobs, preserve deferring_environment_id from the API response
938977
// so it is not lost in state when the plan did not explicitly configure it.
939978
if updatedJob.DeferringEnvironmentId != nil {

0 commit comments

Comments
 (0)