Skip to content

Commit c36c814

Browse files
authored
fix: validate replace_triggered_by attribute references (#38010)
* fix: use schema-based validation for replace_triggered_by Fixes #36740 * fix: use change.ProviderAddr for schema lookup
1 parent f88d095 commit c36c814

File tree

3 files changed

+85
-0
lines changed

3 files changed

+85
-0
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
kind: BUG FIXES
2+
body: 'lifecycle: `replace_triggered_by` now reports an error when given an invalid attribute reference that does not exist in the target resource'
3+
time: 2025-12-13T12:00:00.000000Z
4+
custom:
5+
Issue: "36740"

internal/terraform/context_plan2_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4399,6 +4399,65 @@ resource "test_object" "b" {
43994399
}
44004400
}
44014401

4402+
func TestContext2Plan_triggeredByInvalidAttribute(t *testing.T) {
4403+
// This test verifies that referencing a non-existent attribute in
4404+
// replace_triggered_by produces an error.
4405+
m := testModuleInline(t, map[string]string{
4406+
"main.tf": `
4407+
resource "test_object" "a" {
4408+
test_string = "new"
4409+
}
4410+
resource "test_object" "b" {
4411+
test_string = "value"
4412+
lifecycle {
4413+
replace_triggered_by = [ test_object.a.nonexistent_attribute ]
4414+
}
4415+
}
4416+
`,
4417+
})
4418+
4419+
p := simpleMockProvider()
4420+
4421+
ctx := testContext2(t, &ContextOpts{
4422+
Providers: map[addrs.Provider]providers.Factory{
4423+
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
4424+
},
4425+
})
4426+
4427+
state := states.BuildState(func(s *states.SyncState) {
4428+
s.SetResourceInstanceCurrent(
4429+
mustResourceInstanceAddr("test_object.a"),
4430+
&states.ResourceInstanceObjectSrc{
4431+
AttrsJSON: []byte(`{"test_string":"old"}`),
4432+
Status: states.ObjectReady,
4433+
},
4434+
mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`),
4435+
)
4436+
s.SetResourceInstanceCurrent(
4437+
mustResourceInstanceAddr("test_object.b"),
4438+
&states.ResourceInstanceObjectSrc{
4439+
AttrsJSON: []byte(`{"test_string":"value"}`),
4440+
Status: states.ObjectReady,
4441+
},
4442+
mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`),
4443+
)
4444+
})
4445+
4446+
_, diags := ctx.Plan(m, state, &PlanOpts{
4447+
Mode: plans.NormalMode,
4448+
})
4449+
if !diags.HasErrors() {
4450+
t.Fatal("expected errors for invalid attribute reference in replace_triggered_by")
4451+
}
4452+
4453+
// Check that the error message is about the invalid attribute reference.
4454+
// StaticValidateTraversal returns "Unsupported attribute" errors.
4455+
errMsg := diags.Err().Error()
4456+
if !strings.Contains(errMsg, "Unsupported attribute") && !strings.Contains(errMsg, "nonexistent_attribute") {
4457+
t.Fatalf("unexpected error message: %s", errMsg)
4458+
}
4459+
}
4460+
44024461
func TestContext2Plan_dataSchemaChange(t *testing.T) {
44034462
// We can't decode the prior state when a data source upgrades the schema
44044463
// in an incompatible way. Since prior state for data sources is purely

internal/terraform/eval_context_builtin.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,27 @@ func (ctx *BuiltinEvalContext) EvaluateReplaceTriggeredBy(expr hcl.Expression, r
426426
return nil, false, diags
427427
}
428428

429+
// Validate the attribute reference against the target resource's schema.
430+
// We use schema-based validation rather than value-based validation because
431+
// resources may contain dynamically-typed attributes (DynamicPseudoType) whose
432+
// actual type can change between plans. Schema validation ensures we only
433+
// error on truly invalid attribute references.
434+
// We use change.ProviderAddr rather than resolving from config because
435+
// the provider configuration may not be local to the current module.
436+
providerSchema, err := ctx.ProviderSchema(change.ProviderAddr)
437+
if err == nil {
438+
schema := providerSchema.SchemaForResourceType(resCfg.Mode, resCfg.Type)
439+
if schema.Body != nil {
440+
moreDiags := schema.Body.StaticValidateTraversal(ref.Remaining)
441+
diags = diags.Append(moreDiags)
442+
if diags.HasErrors() {
443+
return nil, false, diags
444+
}
445+
}
446+
}
447+
// If we couldn't get the schema, we skip validation and let the value
448+
// comparison below handle it. This is a graceful degradation for edge cases.
449+
429450
path, _ := traversalToPath(ref.Remaining)
430451
attrBefore, _ := path.Apply(change.Before)
431452
attrAfter, _ := path.Apply(change.After)

0 commit comments

Comments
 (0)