From a5aa6cc5b777195ea5d3721c272b6d071e3cdf4e Mon Sep 17 00:00:00 2001 From: Varun Chawla <34209028+veeceey@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:15:37 -0800 Subject: [PATCH] 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 --------- Co-authored-by: Claude Opus 4.6 --- .changes/v1.15/BUG FIXES-20260214-120000.yaml | 5 + internal/states/state_equal.go | 2 +- internal/states/state_test.go | 104 ++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 .changes/v1.15/BUG FIXES-20260214-120000.yaml diff --git a/.changes/v1.15/BUG FIXES-20260214-120000.yaml b/.changes/v1.15/BUG FIXES-20260214-120000.yaml new file mode 100644 index 0000000000..f4f4a12a54 --- /dev/null +++ b/.changes/v1.15/BUG FIXES-20260214-120000.yaml @@ -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" diff --git a/internal/states/state_equal.go b/internal/states/state_equal.go index 97414311c0..6ed37ff257 100644 --- a/internal/states/state_equal.go +++ b/internal/states/state_equal.go @@ -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 diff --git a/internal/states/state_test.go b/internal/states/state_test.go index dae25f6cd5..458a29f874 100644 --- a/internal/states/state_test.go +++ b/internal/states/state_test.go @@ -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()