diff --git a/.changes/v1.14/BUG FIXES-20251213-120000.yaml b/.changes/v1.14/BUG FIXES-20251213-120000.yaml new file mode 100644 index 000000000000..3a9f4347531a --- /dev/null +++ b/.changes/v1.14/BUG FIXES-20251213-120000.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: 'lifecycle: `replace_triggered_by` now reports an error when given an invalid attribute reference that does not exist in the target resource' +time: 2025-12-13T12:00:00.000000Z +custom: + Issue: "36740" diff --git a/internal/terraform/context_plan2_test.go b/internal/terraform/context_plan2_test.go index 5d20742144ca..5fda2f4dc843 100644 --- a/internal/terraform/context_plan2_test.go +++ b/internal/terraform/context_plan2_test.go @@ -4399,6 +4399,65 @@ resource "test_object" "b" { } } +func TestContext2Plan_triggeredByInvalidAttribute(t *testing.T) { + // This test verifies that referencing a non-existent attribute in + // replace_triggered_by produces an error. + m := testModuleInline(t, map[string]string{ + "main.tf": ` +resource "test_object" "a" { + test_string = "new" +} +resource "test_object" "b" { + test_string = "value" + lifecycle { + replace_triggered_by = [ test_object.a.nonexistent_attribute ] + } +} +`, + }) + + 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"), + &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"test_string":"old"}`), + Status: states.ObjectReady, + }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), + ) + s.SetResourceInstanceCurrent( + mustResourceInstanceAddr("test_object.b"), + &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"test_string":"value"}`), + Status: states.ObjectReady, + }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), + ) + }) + + _, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + }) + if !diags.HasErrors() { + t.Fatal("expected errors for invalid attribute reference in replace_triggered_by") + } + + // Check that the error message is about the invalid attribute reference. + // StaticValidateTraversal returns "Unsupported attribute" errors. + errMsg := diags.Err().Error() + if !strings.Contains(errMsg, "Unsupported attribute") && !strings.Contains(errMsg, "nonexistent_attribute") { + t.Fatalf("unexpected error message: %s", errMsg) + } +} + 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 2cfeb216e1f4..2fc9fdeecd5c 100644 --- a/internal/terraform/eval_context_builtin.go +++ b/internal/terraform/eval_context_builtin.go @@ -426,6 +426,27 @@ func (ctx *BuiltinEvalContext) EvaluateReplaceTriggeredBy(expr hcl.Expression, r return nil, false, diags } + // Validate the attribute reference against the target resource's schema. + // We use schema-based validation rather than value-based validation because + // resources may contain dynamically-typed attributes (DynamicPseudoType) whose + // actual type can change between plans. Schema validation ensures we only + // error on truly invalid attribute references. + // We use change.ProviderAddr rather than resolving from config because + // the provider configuration may not be local to the current module. + providerSchema, err := ctx.ProviderSchema(change.ProviderAddr) + if err == nil { + schema := providerSchema.SchemaForResourceType(resCfg.Mode, resCfg.Type) + if schema.Body != nil { + moreDiags := schema.Body.StaticValidateTraversal(ref.Remaining) + diags = diags.Append(moreDiags) + if diags.HasErrors() { + return nil, false, diags + } + } + } + // If we couldn't get the schema, we skip validation and let the value + // comparison below handle it. This is a graceful degradation for edge cases. + path, _ := traversalToPath(ref.Remaining) attrBefore, _ := path.Apply(change.Before) attrAfter, _ := path.Apply(change.After)