diff --git a/.changes/v1.12/BUG FIXES-20250331-170607.yaml b/.changes/v1.12/BUG FIXES-20250331-170607.yaml new file mode 100644 index 000000000000..2579a8c44eab --- /dev/null +++ b/.changes/v1.12/BUG FIXES-20250331-170607.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: replace-triggered-by must reference a valid resource or resource attribute +time: 2025-03-31T17:06:07.04296+02:00 +custom: + Issue: "36740" diff --git a/internal/terraform/context_plan2_test.go b/internal/terraform/context_plan2_test.go index fb1451879781..86b6baeafd3e 100644 --- a/internal/terraform/context_plan2_test.go +++ b/internal/terraform/context_plan2_test.go @@ -4399,6 +4399,51 @@ resource "test_object" "b" { } } +func TestContext2Plan_triggeredByInvalid(t *testing.T) { + m := testModuleInline(t, map[string]string{ + "main.tf": ` + resource "test_object" "a" { + count = 1 + test_string = "new" + } + resource "test_object" "b" { + count = 1 + test_string = test_object.a[count.index].test_string + lifecycle { + # reference to an invalid attribute "yikes" should cause an error + replace_triggered_by = [ test_object.a[count.index].yikes ] + } + } +`, + }) + + p := simpleMockProvider() + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + state := states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent( + mustResourceInstanceAddr("test_object.a[0]"), + &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"test_string":"old"}`), + Status: states.ObjectReady, + }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), + ) + }) + + _, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + }) + if !diags.HasErrors() { + t.Fatalf("expected errors, got none") + } +} + func TestContext2Plan_dataSchemaChange(t *testing.T) { // We can't decode the prior state when a data source upgrades the schema // in an incompatible way. Since prior state for data sources is purely diff --git a/internal/terraform/eval_context_builtin.go b/internal/terraform/eval_context_builtin.go index c9950f608d1b..8a035b553f4e 100644 --- a/internal/terraform/eval_context_builtin.go +++ b/internal/terraform/eval_context_builtin.go @@ -407,10 +407,7 @@ func (ctx *BuiltinEvalContext) EvaluateReplaceTriggeredBy(expr hcl.Expression, r return nil, false, diags } - path, _ := traversalToPath(ref.Remaining) - attrBefore, _ := path.Apply(change.Before) - attrAfter, _ := path.Apply(change.After) - + attrBefore, attrAfter, diags := evalTriggeredByRefPath(ref, change) if attrBefore == cty.NilVal || attrAfter == cty.NilVal { replace := attrBefore != attrAfter return ref, replace, diags @@ -421,6 +418,42 @@ func (ctx *BuiltinEvalContext) EvaluateReplaceTriggeredBy(expr hcl.Expression, r return ref, replace, diags } +// evalTriggeredByRefPath evaluates the attribute reference path in the context of the +// resource change objects. It returns the before and after values of the attribute +// and any diagnostics that occurred during evaluation. +func evalTriggeredByRefPath(ref *addrs.Reference, change *plans.ResourceInstanceChange) (cty.Value, cty.Value, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + path, key := traversalToPath(ref.Remaining) + + applyPath := func(value cty.Value) (cty.Value, tfdiags.Diagnostics) { + attr, err := path.Apply(value) + if err != nil { + return cty.NilVal, tfdiags.Diagnostics{}.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Invalid attribute path", + fmt.Sprintf( + "The specified path %q could not be applied to the object specified in replace_triggered_by:\n"+ + "Path: %s\n"+ + "Error: %s\n"+ + "Please check your configuration and ensure the path is valid.", + key, ref.DisplayString(), err.Error(), + ), + path, + )) + } + return attr, nil + } + + // Apply the path to the "before" and "after" states + attrBefore, beforeDiags := applyPath(change.Before) + diags = diags.AppendWithoutDuplicates(beforeDiags...) + + attrAfter, afterDiags := applyPath(change.After) + diags = diags.AppendWithoutDuplicates(afterDiags...) + + return attrBefore, attrAfter, diags +} + func (ctx *BuiltinEvalContext) EvaluationScope(self addrs.Referenceable, source addrs.Referenceable, keyData InstanceKeyEvalData) *lang.Scope { switch scope := ctx.scope.(type) { case evalContextModuleInstance: