states: fix RootOutputValuesEqual comparing s2 to itself (#38181)

* states: fix RootOutputValuesEqual comparing s2 to itself

RootOutputValuesEqual had a copy-paste bug where it iterated over
s2.RootOutputValues instead of s.RootOutputValues, effectively
comparing s2 against itself rather than comparing the receiver (s)
against the argument (s2). This meant the function would always
return true as long as both states had the same number of output
values, regardless of whether the actual values differed.

This bug was introduced in #37886 and affects refresh-only plan mode,
where RootOutputValuesEqual is used to determine if root output values
changed during refresh, which controls whether the plan is considered
applyable.

* add changelog entry for RootOutputValuesEqual fix

* Update changelog wording per reviewer suggestion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Varun Chawla 2026-02-18 03:15:37 -08:00 committed by GitHub
parent 8ab5ded5b9
commit a5aa6cc5b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 110 additions and 1 deletions

View file

@ -0,0 +1,5 @@
kind: BUG FIXES
body: 'states: fixed a bug that caused Terraform to be unable to identify when two states had different output values. This may have caused issues in specific circumstances like backend migrations.'
time: 2026-02-14T12:00:00.000000+00:00
custom:
Issue: "38181"

View file

@ -101,7 +101,7 @@ func (s *State) RootOutputValuesEqual(s2 *State) bool {
return false
}
for k, v1 := range s2.RootOutputValues {
for k, v1 := range s.RootOutputValues {
v2, ok := s2.RootOutputValues[k]
if !ok || !v1.Equal(v2) {
return false

View file

@ -474,6 +474,110 @@ func TestStateHasRootOutputValues(t *testing.T) {
}
func TestStateRootOutputValuesEqual(t *testing.T) {
tests := map[string]struct {
SetupA func(ss *SyncState)
SetupB func(ss *SyncState)
Want bool
}{
"both empty": {
func(ss *SyncState) {},
func(ss *SyncState) {},
true,
},
"identical outputs": {
func(ss *SyncState) {
ss.SetOutputValue(
addrs.OutputValue{Name: "foo"}.Absolute(addrs.RootModuleInstance),
cty.StringVal("bar"), false,
)
},
func(ss *SyncState) {
ss.SetOutputValue(
addrs.OutputValue{Name: "foo"}.Absolute(addrs.RootModuleInstance),
cty.StringVal("bar"), false,
)
},
true,
},
"different values same key": {
func(ss *SyncState) {
ss.SetOutputValue(
addrs.OutputValue{Name: "foo"}.Absolute(addrs.RootModuleInstance),
cty.StringVal("bar"), false,
)
},
func(ss *SyncState) {
ss.SetOutputValue(
addrs.OutputValue{Name: "foo"}.Absolute(addrs.RootModuleInstance),
cty.StringVal("baz"), false,
)
},
false,
},
"different sensitivity same value": {
func(ss *SyncState) {
ss.SetOutputValue(
addrs.OutputValue{Name: "foo"}.Absolute(addrs.RootModuleInstance),
cty.StringVal("bar"), false,
)
},
func(ss *SyncState) {
ss.SetOutputValue(
addrs.OutputValue{Name: "foo"}.Absolute(addrs.RootModuleInstance),
cty.StringVal("bar"), true,
)
},
false,
},
"different keys same count": {
func(ss *SyncState) {
ss.SetOutputValue(
addrs.OutputValue{Name: "foo"}.Absolute(addrs.RootModuleInstance),
cty.StringVal("val"), false,
)
},
func(ss *SyncState) {
ss.SetOutputValue(
addrs.OutputValue{Name: "bar"}.Absolute(addrs.RootModuleInstance),
cty.StringVal("val"), false,
)
},
false,
},
"different count": {
func(ss *SyncState) {
ss.SetOutputValue(
addrs.OutputValue{Name: "foo"}.Absolute(addrs.RootModuleInstance),
cty.StringVal("val"), false,
)
},
func(ss *SyncState) {
ss.SetOutputValue(
addrs.OutputValue{Name: "foo"}.Absolute(addrs.RootModuleInstance),
cty.StringVal("val"), false,
)
ss.SetOutputValue(
addrs.OutputValue{Name: "bar"}.Absolute(addrs.RootModuleInstance),
cty.StringVal("val2"), false,
)
},
false,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
stateA := BuildState(test.SetupA)
stateB := BuildState(test.SetupB)
got := stateA.RootOutputValuesEqual(stateB)
if got != test.Want {
t.Errorf("wrong result for stateA.RootOutputValuesEqual(stateB)\ngot: %t\nwant: %t", got, test.Want)
}
})
}
}
func TestState_MoveAbsResource(t *testing.T) {
// Set up a starter state for the embedded tests, which should start from a copy of this state.
state := NewState()