Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changes/v1.15/BUG FIXES-20251213-120000.yaml
Original file line number Diff line number Diff line change
@@ -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"
59 changes: 59 additions & 0 deletions internal/terraform/context_plan2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions internal/terraform/eval_context_builtin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading