@@ -11,6 +11,7 @@ import (
1111 "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/dbt_cloud"
1212 "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/helper"
1313 "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/utils"
14+ "github.com/hashicorp/terraform-plugin-framework/attr"
1415 "github.com/hashicorp/terraform-plugin-framework/path"
1516 "github.com/hashicorp/terraform-plugin-framework/resource"
1617 "github.com/hashicorp/terraform-plugin-framework/types"
@@ -277,6 +278,11 @@ func (j *jobResource) Create(ctx context.Context, req resource.CreateRequest, re
277278 forceNodeSelection = & fns
278279 }
279280
281+ var costOptimizationFeatures []string
282+ if ! plan .CostOptimizationFeatures .IsNull () && ! plan .CostOptimizationFeatures .IsUnknown () {
283+ costOptimizationFeatures = helper .StringSetToStringSlice (plan .CostOptimizationFeatures )
284+ }
285+
280286 var jobCompletionTriggerCondition map [string ]any
281287
282288 if len (plan .JobCompletionTriggerCondition ) != 0 {
@@ -334,6 +340,7 @@ func (j *jobResource) Create(ctx context.Context, req resource.CreateRequest, re
334340 jobType ,
335341 compareChangesFlags ,
336342 forceNodeSelection ,
343+ costOptimizationFeatures ,
337344 )
338345 if err != nil {
339346 resp .Diagnostics .AddError (
@@ -391,6 +398,9 @@ func (j *jobResource) Create(ctx context.Context, req resource.CreateRequest, re
391398 }
392399 }
393400
401+ // Populate cost_optimization_features from API response
402+ plan .CostOptimizationFeatures = sliceStringToTypesSet (createdJob .CostOptimizationFeatures )
403+
394404 jobIDStr := strconv .FormatInt (int64 (* createdJob .ID ), 10 )
395405
396406 // Check if DeferringJobId is set and matches this job's ID for self-deferring
@@ -610,6 +620,9 @@ func (j *jobResource) Read(ctx context.Context, req resource.ReadRequest, resp *
610620 state .ForceNodeSelection = types .BoolNull ()
611621 }
612622
623+ // Populate cost_optimization_features from API response
624+ state .CostOptimizationFeatures = sliceStringToTypesSet (retrievedJob .CostOptimizationFeatures )
625+
613626 if retrievedJob .JobType != "" {
614627 state .JobType = types .StringValue (retrievedJob .JobType )
615628 } else {
@@ -732,7 +745,11 @@ func (j *jobResource) Update(ctx context.Context, req resource.UpdateRequest, re
732745 }
733746
734747 if plan .DeferringEnvironmentID .IsNull () || plan .DeferringEnvironmentID .ValueInt64 () == 0 {
735- job .DeferringEnvironmentId = nil
748+ // For CI and Merge jobs, preserve the API's deferring_environment_id when the plan doesn't set it.
749+ // Other job types clear it when not specified.
750+ if job .JobType != JobTypeCI && job .JobType != JobTypeMerge {
751+ job .DeferringEnvironmentId = nil
752+ }
736753 } else {
737754 deferringEnvId := int (plan .DeferringEnvironmentID .ValueInt64 ())
738755 job .DeferringEnvironmentId = & deferringEnvId
@@ -784,11 +801,25 @@ func (j *jobResource) Update(ctx context.Context, req resource.UpdateRequest, re
784801 job .ErrorsOnLintFailure = plan .ErrorsOnLintFailure .ValueBool ()
785802 job .CompareChangesFlags = plan .CompareChangesFlags .ValueString ()
786803
787- if plan .ForceNodeSelection .IsNull () {
804+ // CI and Merge jobs do not support SAO features (force_node_selection / cost_optimization_features).
805+ // Sending these fields for those job types results in a 405 from the API, so we skip them.
806+ isCIOrMerge := job .JobType == JobTypeCI || job .JobType == JobTypeMerge
807+ if isCIOrMerge {
788808 job .ForceNodeSelection = nil
809+ job .CostOptimizationFeatures = nil
789810 } else {
790- fns := plan .ForceNodeSelection .ValueBool ()
791- job .ForceNodeSelection = & fns
811+ if plan .ForceNodeSelection .IsNull () {
812+ job .ForceNodeSelection = nil
813+ } else {
814+ fns := plan .ForceNodeSelection .ValueBool ()
815+ job .ForceNodeSelection = & fns
816+ }
817+
818+ if ! plan .CostOptimizationFeatures .IsNull () && ! plan .CostOptimizationFeatures .IsUnknown () {
819+ job .CostOptimizationFeatures = helper .StringSetToStringSlice (plan .CostOptimizationFeatures )
820+ } else {
821+ job .CostOptimizationFeatures = nil
822+ }
792823 }
793824
794825 // Handle job_type updates with validation
@@ -873,6 +904,17 @@ func (j *jobResource) Update(ctx context.Context, req resource.UpdateRequest, re
873904 plan .ForceNodeSelection = types .BoolNull ()
874905 }
875906
907+ // Populate cost_optimization_features from API response
908+ plan .CostOptimizationFeatures = sliceStringToTypesSet (updatedJob .CostOptimizationFeatures )
909+
910+ // For CI/Merge jobs, preserve deferring_environment_id from the API response
911+ // so it is not lost in state when the plan did not explicitly configure it.
912+ if updatedJob .DeferringEnvironmentId != nil {
913+ plan .DeferringEnvironmentID = types .Int64Value (int64 (* updatedJob .DeferringEnvironmentId ))
914+ } else if plan .DeferringEnvironmentID .IsNull () || plan .DeferringEnvironmentID .ValueInt64 () == 0 {
915+ plan .DeferringEnvironmentID = types .Int64Null ()
916+ }
917+
876918 updatedJobIDStr := strconv .FormatInt (jobID , 10 )
877919 updatedSelfDeferring := updatedJob .DeferringJobId != nil && strconv .Itoa (* updatedJob .DeferringJobId ) == updatedJobIDStr
878920 plan .SelfDeferring = types .BoolValue (updatedSelfDeferring )
@@ -884,6 +926,18 @@ func (j *jobResource) Update(ctx context.Context, req resource.UpdateRequest, re
884926 }
885927}
886928
929+ // sliceStringToTypesSet returns a types.Set of strings; an empty set for nil/empty input.
930+ func sliceStringToTypesSet (values []string ) types.Set {
931+ if len (values ) == 0 {
932+ return types .SetValueMust (types .StringType , []attr.Value {})
933+ }
934+ elems := make ([]attr.Value , len (values ))
935+ for i , v := range values {
936+ elems [i ] = types .StringValue (v )
937+ }
938+ return types .SetValueMust (types .StringType , elems )
939+ }
940+
887941func (j * jobResource ) validateExecuteSteps (executeSteps []string ) error {
888942 dbt_flags := []string {
889943 "--warn-error" ,
0 commit comments