Skip to content

Commit 1605356

Browse files
trouzeclaude
andcommitted
fix: refine dbtcloud_job type transition rules to avoid unnecessary replacement (closes #666)
Only CI<->non-CI and Adaptive<->non-Adaptive job type transitions now trigger resource replacement. Scheduled<->Other and Merge<->Other/Scheduled transitions are handled in-place. Adds requiresJobTypeReplacement() helper and two new acceptance test functions. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 17814e7 commit 1605356

3 files changed

Lines changed: 314 additions & 74 deletions

File tree

pkg/framework/objects/job/resource.go

Lines changed: 45 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -36,44 +36,40 @@ type jobResource struct {
3636
client *dbt_cloud.Client
3737
}
3838

39-
// validateJobTypeChange validates if a job type transition is allowed.
40-
// This mirrors the server-side validation in _validate_job_type_change.
41-
func validateJobTypeChange(prevJobType, newJobType string) error {
42-
// If no change, always allowed
43-
if prevJobType == newJobType {
44-
return nil
39+
// requiresJobTypeReplacement reports whether changing from oldType to newType
40+
// requires the resource to be destroyed and recreated.
41+
// Only transitions involving CI or Adaptive require replacement; all other
42+
// transitions (scheduled <-> other <-> merge) can be performed in-place.
43+
func requiresJobTypeReplacement(oldType, newType string) bool {
44+
if oldType == newType {
45+
return false
46+
}
47+
if oldType == JobTypeCI || newType == JobTypeCI {
48+
return true
4549
}
50+
if oldType == JobTypeAdaptive || newType == JobTypeAdaptive {
51+
return true
52+
}
53+
return false
54+
}
4655

47-
// If previous type is empty (not set), any new type is allowed
48-
if prevJobType == "" {
56+
// validateJobTypeChange returns an error if the in-place job_type transition is
57+
// not permitted by the API. CI and Adaptive transitions are handled upstream by
58+
// requiresJobTypeReplacement (forced replace); this function guards the remaining
59+
// invalid combinations as a safety net.
60+
func validateJobTypeChange(prevJobType, newJobType string) error {
61+
if prevJobType == newJobType || prevJobType == "" {
4962
return nil
5063
}
51-
52-
// CI jobs can only stay CI
5364
if prevJobType == JobTypeCI && newJobType != JobTypeCI {
5465
return fmt.Errorf("the job type field for this job can only be set to 'ci'")
5566
}
56-
57-
// Adaptive jobs can only stay adaptive
5867
if prevJobType == JobTypeAdaptive && newJobType != JobTypeAdaptive {
5968
return fmt.Errorf("the job type field for this job can only be set to 'adaptive'")
6069
}
61-
62-
// Scheduled jobs can only change to scheduled or other
63-
if prevJobType == JobTypeScheduled && (newJobType == JobTypeCI || newJobType == JobTypeAdaptive) {
64-
return fmt.Errorf("the job type field for this job can only be set to 'scheduled' or 'other'")
65-
}
66-
67-
// Other jobs can only change to scheduled or other
68-
if prevJobType == JobTypeOther && (newJobType == JobTypeCI || newJobType == JobTypeAdaptive) {
69-
return fmt.Errorf("the job type field for this job can only be set to 'scheduled' or 'other'")
70+
if newJobType == JobTypeCI || newJobType == JobTypeAdaptive {
71+
return fmt.Errorf("cannot change job_type to '%s' from '%s'", newJobType, prevJobType)
7072
}
71-
72-
// Merge jobs - treating similar to CI (cannot change away from merge)
73-
if prevJobType == JobTypeMerge && newJobType != JobTypeMerge {
74-
return fmt.Errorf("the job type field for this job can only be set to 'merge'")
75-
}
76-
7773
return nil
7874
}
7975

@@ -108,50 +104,40 @@ func (j *jobResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanReq
108104
return
109105
}
110106

111-
// Skip checks if necessary fields are null
112107
if plan.Triggers == nil || state.Triggers == nil {
113108
return
114109
}
115110

116-
// if we change the job type (CI, merge or "empty"), we need to recreate the job as dbt Cloud doesn't allow updating them
117-
// the job type is determined by the triggers
118-
if plan.Triggers != nil && state.Triggers != nil {
119-
oldCI := state.Triggers.GithubWebhook.ValueBool() || state.Triggers.GitProviderWebhook.ValueBool()
120-
oldOnMerge := state.Triggers.OnMerge.ValueBool()
121-
122-
oldType := ""
123-
if oldCI {
124-
oldType = "ci"
125-
} else if oldOnMerge {
126-
oldType = "merge"
127-
}
128-
129-
newCI := plan.Triggers.GithubWebhook.ValueBool() || plan.Triggers.GitProviderWebhook.ValueBool()
130-
newOnMerge := plan.Triggers.OnMerge.ValueBool()
111+
oldCI := state.Triggers.GithubWebhook.ValueBool() || state.Triggers.GitProviderWebhook.ValueBool()
112+
oldOnMerge := state.Triggers.OnMerge.ValueBool()
113+
oldType := JobTypeOther
114+
if oldCI {
115+
oldType = JobTypeCI
116+
} else if oldOnMerge {
117+
oldType = JobTypeMerge
118+
}
131119

132-
newType := ""
133-
if newCI {
134-
newType = "ci"
135-
} else if newOnMerge {
136-
newType = "merge"
137-
}
120+
newCI := plan.Triggers.GithubWebhook.ValueBool() || plan.Triggers.GitProviderWebhook.ValueBool()
121+
newOnMerge := plan.Triggers.OnMerge.ValueBool()
122+
newType := JobTypeOther
123+
if newCI {
124+
newType = JobTypeCI
125+
} else if newOnMerge {
126+
newType = JobTypeMerge
127+
}
138128

139-
if oldType != newType {
140-
resp.RequiresReplace = append(resp.RequiresReplace, path.Root("triggers"))
141-
}
129+
if requiresJobTypeReplacement(oldType, newType) {
130+
resp.RequiresReplace = append(resp.RequiresReplace, path.Root("triggers"))
142131
}
143132

144-
// Validate job_type field changes if the field is being explicitly set
145-
// Note: If plan.JobType is set but state.JobType is null (first time setting it),
146-
// the validation will happen in Update against the actual server value
147-
// Skip validation if either value is empty (empty means "not explicitly set")
148133
if !plan.JobType.IsNull() && !state.JobType.IsNull() {
149134
prevJobType := state.JobType.ValueString()
150135
newJobType := plan.JobType.ValueString()
151136

152-
// Only validate if both values are non-empty (explicitly set)
153-
if prevJobType != "" && newJobType != "" {
154-
if err := validateJobTypeChange(prevJobType, newJobType); err != nil {
137+
if prevJobType != "" && newJobType != "" && prevJobType != newJobType {
138+
if requiresJobTypeReplacement(prevJobType, newJobType) {
139+
resp.RequiresReplace = append(resp.RequiresReplace, path.Root("job_type"))
140+
} else if err := validateJobTypeChange(prevJobType, newJobType); err != nil {
155141
resp.Diagnostics.AddError(
156142
"Invalid job_type change",
157143
fmt.Sprintf("Cannot change job_type from '%s' to '%s': %s", prevJobType, newJobType, err.Error()),

pkg/framework/objects/job/resource_acceptance_job_type_test.go

Lines changed: 88 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -120,64 +120,83 @@ func TestValidateJobTypeChange(t *testing.T) {
120120
errorSubstr: "can only be set to 'adaptive'",
121121
},
122122

123-
// Merge job type transitions - only merge allowed
123+
// Merge job type transitions - merge, scheduled, and other are all allowed in-place;
124+
// transitions to CI or Adaptive are blocked (those would require replacement at the
125+
// ModifyPlan level but validateJobTypeChange still guards them).
124126
{
125127
name: "merge to ci - not allowed",
126128
prevType: JobTypeMerge,
127129
newType: JobTypeCI,
128130
expectError: true,
129-
errorSubstr: "can only be set to 'merge'",
131+
errorSubstr: "cannot change job_type to 'ci'",
130132
},
131133
{
132-
name: "merge to scheduled - not allowed",
134+
name: "merge to scheduled - allowed in-place",
133135
prevType: JobTypeMerge,
134136
newType: JobTypeScheduled,
135-
expectError: true,
136-
errorSubstr: "can only be set to 'merge'",
137+
expectError: false,
138+
},
139+
{
140+
name: "merge to other - allowed in-place",
141+
prevType: JobTypeMerge,
142+
newType: JobTypeOther,
143+
expectError: false,
137144
},
138145

139-
// Scheduled job type transitions - scheduled or other allowed
146+
// Scheduled job type transitions - scheduled, other, and merge are all allowed in-place
140147
{
141148
name: "scheduled to other - allowed",
142149
prevType: JobTypeScheduled,
143150
newType: JobTypeOther,
144151
expectError: false,
145152
},
153+
{
154+
name: "scheduled to merge - allowed in-place",
155+
prevType: JobTypeScheduled,
156+
newType: JobTypeMerge,
157+
expectError: false,
158+
},
146159
{
147160
name: "scheduled to ci - not allowed",
148161
prevType: JobTypeScheduled,
149162
newType: JobTypeCI,
150163
expectError: true,
151-
errorSubstr: "can only be set to 'scheduled' or 'other'",
164+
errorSubstr: "cannot change job_type to 'ci'",
152165
},
153166
{
154167
name: "scheduled to adaptive - not allowed",
155168
prevType: JobTypeScheduled,
156169
newType: JobTypeAdaptive,
157170
expectError: true,
158-
errorSubstr: "can only be set to 'scheduled' or 'other'",
171+
errorSubstr: "cannot change job_type to 'adaptive'",
159172
},
160173

161-
// Other job type transitions - scheduled or other allowed
174+
// Other job type transitions - scheduled, other, and merge are all allowed in-place
162175
{
163176
name: "other to scheduled - allowed",
164177
prevType: JobTypeOther,
165178
newType: JobTypeScheduled,
166179
expectError: false,
167180
},
181+
{
182+
name: "other to merge - allowed in-place",
183+
prevType: JobTypeOther,
184+
newType: JobTypeMerge,
185+
expectError: false,
186+
},
168187
{
169188
name: "other to ci - not allowed",
170189
prevType: JobTypeOther,
171190
newType: JobTypeCI,
172191
expectError: true,
173-
errorSubstr: "can only be set to 'scheduled' or 'other'",
192+
errorSubstr: "cannot change job_type to 'ci'",
174193
},
175194
{
176195
name: "other to adaptive - not allowed",
177196
prevType: JobTypeOther,
178197
newType: JobTypeAdaptive,
179198
expectError: true,
180-
errorSubstr: "can only be set to 'scheduled' or 'other'",
199+
errorSubstr: "cannot change job_type to 'adaptive'",
181200
},
182201
}
183202

@@ -228,20 +247,20 @@ func TestValidateJobTypeChange_AllTransitions(t *testing.T) {
228247
JobTypeMerge: {
229248
JobTypeCI: false,
230249
JobTypeMerge: true,
231-
JobTypeScheduled: false,
232-
JobTypeOther: false,
250+
JobTypeScheduled: true, // in-place transition allowed
251+
JobTypeOther: true, // in-place transition allowed
233252
JobTypeAdaptive: false,
234253
},
235254
JobTypeScheduled: {
236255
JobTypeCI: false,
237-
JobTypeMerge: true, // merge is allowed from scheduled (not in original server code but reasonable)
256+
JobTypeMerge: true, // in-place transition allowed
238257
JobTypeScheduled: true,
239258
JobTypeOther: true,
240259
JobTypeAdaptive: false,
241260
},
242261
JobTypeOther: {
243262
JobTypeCI: false,
244-
JobTypeMerge: true, // merge is allowed from other (not in original server code but reasonable)
263+
JobTypeMerge: true, // in-place transition allowed
245264
JobTypeScheduled: true,
246265
JobTypeOther: true,
247266
JobTypeAdaptive: false,
@@ -305,3 +324,57 @@ func TestJobTypeConstants(t *testing.T) {
305324
}
306325
}
307326
}
327+
328+
func TestRequiresJobTypeReplacement(t *testing.T) {
329+
t.Parallel()
330+
331+
tests := []struct {
332+
name string
333+
oldType string
334+
newType string
335+
expected bool
336+
}{
337+
// Same type — never requires replacement
338+
{name: "ci to ci", oldType: JobTypeCI, newType: JobTypeCI, expected: false},
339+
{name: "scheduled to scheduled", oldType: JobTypeScheduled, newType: JobTypeScheduled, expected: false},
340+
{name: "other to other", oldType: JobTypeOther, newType: JobTypeOther, expected: false},
341+
{name: "merge to merge", oldType: JobTypeMerge, newType: JobTypeMerge, expected: false},
342+
{name: "adaptive to adaptive", oldType: JobTypeAdaptive, newType: JobTypeAdaptive, expected: false},
343+
344+
// CI transitions — always require replacement
345+
{name: "ci to scheduled", oldType: JobTypeCI, newType: JobTypeScheduled, expected: true},
346+
{name: "ci to other", oldType: JobTypeCI, newType: JobTypeOther, expected: true},
347+
{name: "ci to merge", oldType: JobTypeCI, newType: JobTypeMerge, expected: true},
348+
{name: "ci to adaptive", oldType: JobTypeCI, newType: JobTypeAdaptive, expected: true},
349+
{name: "scheduled to ci", oldType: JobTypeScheduled, newType: JobTypeCI, expected: true},
350+
{name: "other to ci", oldType: JobTypeOther, newType: JobTypeCI, expected: true},
351+
{name: "merge to ci", oldType: JobTypeMerge, newType: JobTypeCI, expected: true},
352+
353+
// Adaptive transitions — always require replacement
354+
{name: "adaptive to scheduled", oldType: JobTypeAdaptive, newType: JobTypeScheduled, expected: true},
355+
{name: "adaptive to other", oldType: JobTypeAdaptive, newType: JobTypeOther, expected: true},
356+
{name: "adaptive to merge", oldType: JobTypeAdaptive, newType: JobTypeMerge, expected: true},
357+
{name: "scheduled to adaptive", oldType: JobTypeScheduled, newType: JobTypeAdaptive, expected: true},
358+
{name: "other to adaptive", oldType: JobTypeOther, newType: JobTypeAdaptive, expected: true},
359+
{name: "merge to adaptive", oldType: JobTypeMerge, newType: JobTypeAdaptive, expected: true},
360+
361+
// In-place transitions (scheduled / other / merge)
362+
{name: "scheduled to other", oldType: JobTypeScheduled, newType: JobTypeOther, expected: false},
363+
{name: "scheduled to merge", oldType: JobTypeScheduled, newType: JobTypeMerge, expected: false},
364+
{name: "other to scheduled", oldType: JobTypeOther, newType: JobTypeScheduled, expected: false},
365+
{name: "other to merge", oldType: JobTypeOther, newType: JobTypeMerge, expected: false},
366+
{name: "merge to scheduled", oldType: JobTypeMerge, newType: JobTypeScheduled, expected: false},
367+
{name: "merge to other", oldType: JobTypeMerge, newType: JobTypeOther, expected: false},
368+
}
369+
370+
for _, tc := range tests {
371+
tc := tc
372+
t.Run(tc.name, func(t *testing.T) {
373+
t.Parallel()
374+
got := requiresJobTypeReplacement(tc.oldType, tc.newType)
375+
if got != tc.expected {
376+
t.Errorf("requiresJobTypeReplacement(%q, %q) = %v, want %v", tc.oldType, tc.newType, got, tc.expected)
377+
}
378+
})
379+
}
380+
}

0 commit comments

Comments
 (0)