Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/Changes-20260409-195801.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Changes
body: Add new `dbtcloud_job_completion_trigger` resource to manage job-chaining triggers independently from `dbtcloud_job`, resolving circular dependency issues
time: 2026-04-09T19:58:01.000000+00:00
27 changes: 16 additions & 11 deletions pkg/framework/objects/job/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -582,18 +582,23 @@ func (j *jobResource) Read(ctx context.Context, req resource.ReadRequest, resp *

state.TriggersOnDraftPr = types.BoolValue(retrievedJob.TriggersOnDraftPR)
if retrievedJob.JobCompletionTrigger != nil {
statusesStr := make([]types.String, 0)
for _, status := range retrievedJob.JobCompletionTrigger.Condition.Statuses {
statusStr := utils.JobCompletionTriggerConditionsMappingCodeHuman[status].(string)
statusesStr = append(statusesStr, types.StringValue(statusStr))
}
// Only sync from API if this resource is managing job_completion_trigger_condition.
// If prior state had it nil, a separate dbtcloud_job_completion_trigger resource
// may own the trigger — leave state untouched to avoid spurious drift.
if len(state.JobCompletionTriggerCondition) > 0 {
statusesStr := make([]types.String, 0)
for _, status := range retrievedJob.JobCompletionTrigger.Condition.Statuses {
statusStr := utils.JobCompletionTriggerConditionsMappingCodeHuman[status].(string)
statusesStr = append(statusesStr, types.StringValue(statusStr))
}

state.JobCompletionTriggerCondition = []*JobCompletionTriggerCondition{
{
JobID: types.Int64Value(int64(retrievedJob.JobCompletionTrigger.Condition.JobID)),
ProjectID: types.Int64Value(int64(retrievedJob.JobCompletionTrigger.Condition.ProjectID)),
Statuses: statusesStr,
},
state.JobCompletionTriggerCondition = []*JobCompletionTriggerCondition{
{
JobID: types.Int64Value(int64(retrievedJob.JobCompletionTrigger.Condition.JobID)),
ProjectID: types.Int64Value(int64(retrievedJob.JobCompletionTrigger.Condition.ProjectID)),
Statuses: statusesStr,
},
}
}
} else {
state.JobCompletionTriggerCondition = nil
Expand Down
14 changes: 14 additions & 0 deletions pkg/framework/objects/job_completion_trigger/model.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package job_completion_trigger

import (
"github.com/hashicorp/terraform-plugin-framework/types"
)

type JobCompletionTriggerResourceModel struct {
ID types.Int64 `tfsdk:"id"`
JobID types.Int64 `tfsdk:"job_id"`
TriggerJobID types.Int64 `tfsdk:"trigger_job_id"`
ProjectID types.Int64 `tfsdk:"project_id"`
Statuses types.Set `tfsdk:"statuses"`
ResourceMetadata types.Dynamic `tfsdk:"resource_metadata"`
}
254 changes: 254 additions & 0 deletions pkg/framework/objects/job_completion_trigger/resource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
package job_completion_trigger

import (
"context"
"fmt"
"strconv"

"github.com/dbt-labs/terraform-provider-dbtcloud/pkg/dbt_cloud"
"github.com/dbt-labs/terraform-provider-dbtcloud/pkg/helper"
"github.com/dbt-labs/terraform-provider-dbtcloud/pkg/utils"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/types"
)

var (
_ resource.Resource = &jobCompletionTriggerResource{}
_ resource.ResourceWithConfigure = &jobCompletionTriggerResource{}
_ resource.ResourceWithImportState = &jobCompletionTriggerResource{}
)

func JobCompletionTriggerResource() resource.Resource {
return &jobCompletionTriggerResource{}
}

type jobCompletionTriggerResource struct {
client *dbt_cloud.Client
}

func (r *jobCompletionTriggerResource) Metadata(
_ context.Context,
req resource.MetadataRequest,
resp *resource.MetadataResponse,
) {
resp.TypeName = req.ProviderTypeName + "_job_completion_trigger"
}

func (r *jobCompletionTriggerResource) Schema(
_ context.Context,
_ resource.SchemaRequest,
resp *resource.SchemaResponse,
) {
resp.Schema = resourceSchema
}

func (r *jobCompletionTriggerResource) Configure(
_ context.Context,
req resource.ConfigureRequest,
_ *resource.ConfigureResponse,
) {
if req.ProviderData == nil {
return
}
r.client = req.ProviderData.(*dbt_cloud.Client)
}

func (r *jobCompletionTriggerResource) Create(
ctx context.Context,
req resource.CreateRequest,
resp *resource.CreateResponse,
) {
var plan JobCompletionTriggerResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}

jobID := plan.JobID.ValueInt64()
job, err := r.client.GetJob(strconv.FormatInt(jobID, 10))
if err != nil {
resp.Diagnostics.AddError("Error fetching job", err.Error())
return
}

statuses, diags := buildStatusInts(plan.Statuses)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

job.JobCompletionTrigger = &dbt_cloud.JobCompletionTrigger{
Condition: dbt_cloud.JobCompletionTriggerCondition{
JobID: int(plan.TriggerJobID.ValueInt64()),
ProjectID: int(plan.ProjectID.ValueInt64()),
Statuses: statuses,
},
}

updatedJob, err := r.client.UpdateJob(strconv.FormatInt(jobID, 10), *job)
if err != nil {
resp.Diagnostics.AddError("Error setting job completion trigger", err.Error())
return
}

plan.ID = types.Int64Value(int64(*updatedJob.ID))
plan.JobID = types.Int64Value(int64(*updatedJob.ID))

resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}

func (r *jobCompletionTriggerResource) Read(
ctx context.Context,
req resource.ReadRequest,
resp *resource.ReadResponse,
) {
var state JobCompletionTriggerResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}

jobID := state.JobID.ValueInt64()
job, err := r.client.GetJob(strconv.FormatInt(jobID, 10))
if err != nil {
if helper.HandleResourceNotFound(ctx, err, &resp.Diagnostics, &resp.State, "job_completion_trigger") {
return
}
resp.Diagnostics.AddError("Error fetching job", err.Error())
return
}

if job.JobCompletionTrigger == nil {
resp.State.RemoveResource(ctx)
return
}

cond := job.JobCompletionTrigger.Condition
state.TriggerJobID = types.Int64Value(int64(cond.JobID))
state.ProjectID = types.Int64Value(int64(cond.ProjectID))

statusStrings := make([]string, 0, len(cond.Statuses))
for _, s := range cond.Statuses {
name, ok := utils.JobCompletionTriggerConditionsMappingCodeHuman[s]
if !ok {
resp.Diagnostics.AddError("Unexpected status value", fmt.Sprintf("Unknown status int %d returned from API", s))
return
}
statusStrings = append(statusStrings, name.(string))
}
statusSet, setDiags := types.SetValueFrom(ctx, types.StringType, statusStrings)
resp.Diagnostics.Append(setDiags...)
if resp.Diagnostics.HasError() {
return
}
state.Statuses = statusSet

resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}

func (r *jobCompletionTriggerResource) Update(
ctx context.Context,
req resource.UpdateRequest,
resp *resource.UpdateResponse,
) {
var plan JobCompletionTriggerResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}

jobID := plan.JobID.ValueInt64()
job, err := r.client.GetJob(strconv.FormatInt(jobID, 10))
if err != nil {
resp.Diagnostics.AddError("Error fetching job", err.Error())
return
}

statuses, diags := buildStatusInts(plan.Statuses)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

job.JobCompletionTrigger = &dbt_cloud.JobCompletionTrigger{
Condition: dbt_cloud.JobCompletionTriggerCondition{
JobID: int(plan.TriggerJobID.ValueInt64()),
ProjectID: int(plan.ProjectID.ValueInt64()),
Statuses: statuses,
},
}

_, err = r.client.UpdateJob(strconv.FormatInt(jobID, 10), *job)
if err != nil {
resp.Diagnostics.AddError("Error updating job completion trigger", err.Error())
return
}

resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}

func (r *jobCompletionTriggerResource) Delete(
ctx context.Context,
req resource.DeleteRequest,
resp *resource.DeleteResponse,
) {
var state JobCompletionTriggerResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}

jobID := state.JobID.ValueInt64()
job, err := r.client.GetJob(strconv.FormatInt(jobID, 10))
if err != nil {
if helper.HandleResourceNotFound(ctx, err, &resp.Diagnostics, &resp.State, "job_completion_trigger") {
return
}
resp.Diagnostics.AddError("Error fetching job", err.Error())
return
}

job.JobCompletionTrigger = nil

_, err = r.client.UpdateJob(strconv.FormatInt(jobID, 10), *job)
if err != nil {
resp.Diagnostics.AddError("Error removing job completion trigger", err.Error())
return
}
}

func (r *jobCompletionTriggerResource) ImportState(
ctx context.Context,
req resource.ImportStateRequest,
resp *resource.ImportStateResponse,
) {
jobID, err := strconv.ParseInt(req.ID, 10, 64)
if err != nil {
resp.Diagnostics.AddError("Error parsing job ID for import", err.Error())
return
}
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), jobID)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("job_id"), jobID)...)
}

// buildStatusInts converts the plan's statuses set to a slice of API integer codes
// using the shared utils.JobCompletionTriggerConditionsMappingHumanCode mapping.
func buildStatusInts(statusSet types.Set) ([]int, diag.Diagnostics) {
statusStrings := helper.StringSetToStringSlice(statusSet)
statuses := make([]int, 0, len(statusStrings))
var diags diag.Diagnostics
for _, s := range statusStrings {
v, ok := utils.JobCompletionTriggerConditionsMappingHumanCode[s]
if !ok {
diags.AddError(
"Invalid status value",
fmt.Sprintf("Invalid status %q: must be one of success, error, canceled", s),
)
return nil, diags
}
statuses = append(statuses, v)
}
return statuses, diags
}
Loading
Loading