Skip to content

Commit 64f2b5d

Browse files
authored
Merge pull request #37886 from hashicorp/jbardin/state-deep-equal
Implement equality for state objects
2 parents 4048233 + f78c64e commit 64f2b5d

File tree

7 files changed

+249
-41
lines changed

7 files changed

+249
-41
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: A refresh-only plan could result in a non-zero exit code with no changes
3+
time: 2025-11-10T12:09:21.029489-05:00
4+
custom:
5+
Issue: "37406"

internal/states/checks.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
package states
55

66
import (
7+
"slices"
8+
79
"github.com/hashicorp/terraform/internal/addrs"
810
"github.com/hashicorp/terraform/internal/checks"
911
)
@@ -169,6 +171,45 @@ func (r *CheckResults) DeepCopy() *CheckResults {
169171
return ret
170172
}
171173

174+
func (r *CheckResults) Equal(other *CheckResults) bool {
175+
if r == other {
176+
return true
177+
}
178+
if r == nil || other == nil {
179+
return false
180+
}
181+
182+
if r.ConfigResults.Len() != other.ConfigResults.Len() {
183+
return false
184+
}
185+
for key, elem := range r.ConfigResults.Iter() {
186+
otherElem := other.ConfigResults.Get(key)
187+
if otherElem == nil {
188+
return false
189+
}
190+
if elem.Status != otherElem.Status {
191+
return false
192+
}
193+
if elem.ObjectResults.Len() != otherElem.ObjectResults.Len() {
194+
return false
195+
}
196+
for key, res := range elem.ObjectResults.Iter() {
197+
otherRes := otherElem.ObjectResults.Get(key)
198+
if otherRes == nil {
199+
return false
200+
}
201+
if res.Status != otherRes.Status {
202+
return false
203+
}
204+
if !slices.Equal(res.FailureMessages, otherRes.FailureMessages) {
205+
return false
206+
}
207+
}
208+
}
209+
210+
return true
211+
}
212+
172213
// ObjectAddrsKnown determines whether the set of objects recorded in this
173214
// aggregate is accurate (true) or if it's incomplete as a result of the
174215
// run being interrupted before instance expansion.

internal/states/instance_object_src.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
package states
55

66
import (
7+
"bytes"
78
"fmt"
9+
"reflect"
810

911
"github.com/zclconf/go-cty/cty"
1012
ctyjson "github.com/zclconf/go-cty/cty/json"
@@ -168,3 +170,48 @@ func (os *ResourceInstanceObjectSrc) CompleteIdentityUpgrade(newAttrs cty.Value,
168170
new.IdentitySchemaVersion = uint64(schema.IdentityVersion)
169171
return new, nil
170172
}
173+
174+
// Equal compares two ResourceInstanceObjectSrc objects for equality, skipping
175+
// any internal fields which are not stored to the final serialized state.
176+
func (os *ResourceInstanceObjectSrc) Equal(other *ResourceInstanceObjectSrc) bool {
177+
if os == other {
178+
return true
179+
}
180+
if os == nil || other == nil {
181+
return false
182+
}
183+
184+
if os.SchemaVersion != other.SchemaVersion {
185+
return false
186+
}
187+
if os.IdentitySchemaVersion != other.IdentitySchemaVersion {
188+
return false
189+
}
190+
if os.Status != other.Status {
191+
return false
192+
}
193+
if os.CreateBeforeDestroy != other.CreateBeforeDestroy {
194+
return false
195+
}
196+
197+
if !bytes.Equal(os.AttrsJSON, other.AttrsJSON) {
198+
return false
199+
}
200+
if !bytes.Equal(os.IdentityJSON, other.IdentityJSON) {
201+
return false
202+
}
203+
if !bytes.Equal(os.Private, other.Private) {
204+
return false
205+
}
206+
207+
// Compare legacy AttrsFlat maps. We shouldn't see this ever being used, but
208+
// deal with in just in case until we remove it entirely. These are all
209+
// simple maps of strings, so DeepEqual is perfectly fine here.
210+
if !reflect.DeepEqual(os.AttrsFlat, other.AttrsFlat) {
211+
return false
212+
}
213+
214+
// We skip fields that have no functional impact on resource state.
215+
216+
return true
217+
}

internal/states/output_value.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,19 @@ type OutputValue struct {
1717
Value cty.Value
1818
Sensitive bool
1919
}
20+
21+
func (o *OutputValue) Equal(other *OutputValue) bool {
22+
if o == other {
23+
return true
24+
}
25+
if o == nil || other == nil {
26+
return false
27+
}
28+
if !o.Addr.Equal(other.Addr) {
29+
return false
30+
}
31+
if o.Sensitive != other.Sensitive {
32+
return false
33+
}
34+
return o.Value.RawEquals(other.Value)
35+
}

internal/states/resource.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,64 @@ func NewDeposedKey() DeposedKey {
177177
func ParseDeposedKey(raw string) (DeposedKey, error) {
178178
return addrs.ParseDeposedKey(raw)
179179
}
180+
181+
// Equal compares Resource objects for equality, comparing all the current and
182+
// deposed instances.
183+
func (rs *Resource) Equal(other *Resource) bool {
184+
if rs == other {
185+
return true
186+
}
187+
if rs == nil || other == nil {
188+
return false
189+
}
190+
191+
if !rs.Addr.Equal(other.Addr) {
192+
return false
193+
}
194+
if !rs.ProviderConfig.Equal(other.ProviderConfig) {
195+
return false
196+
}
197+
198+
if len(rs.Instances) != len(other.Instances) {
199+
return false
200+
}
201+
for k, inst := range rs.Instances {
202+
otherInst, ok := other.Instances[k]
203+
if !ok {
204+
return false
205+
}
206+
if !inst.Equal(otherInst) {
207+
return false
208+
}
209+
}
210+
211+
return true
212+
}
213+
214+
func (i *ResourceInstance) Equal(other *ResourceInstance) bool {
215+
if i == other {
216+
return true
217+
}
218+
if i == nil || other == nil {
219+
return false
220+
}
221+
222+
if !i.Current.Equal(other.Current) {
223+
return false
224+
}
225+
226+
if len(i.Deposed) != len(other.Deposed) {
227+
return false
228+
}
229+
for k, dep := range i.Deposed {
230+
otherObj, ok := other.Deposed[k]
231+
if !ok {
232+
return false
233+
}
234+
if !dep.Equal(otherObj) {
235+
return false
236+
}
237+
}
238+
239+
return true
240+
}

internal/states/state_equal.go

Lines changed: 48 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,29 @@
33

44
package states
55

6-
import (
7-
"reflect"
8-
9-
"github.com/hashicorp/terraform/internal/addrs"
10-
)
11-
126
// Equal returns true if the receiver is functionally equivalent to other,
137
// including any ephemeral portions of the state that would not be included
148
// if the state were saved to files.
159
//
1610
// To test only the persistent portions of two states for equality, instead
1711
// use statefile.StatesMarshalEqual.
1812
func (s *State) Equal(other *State) bool {
19-
// For the moment this is sufficient, but we may need to do something
20-
// more elaborate in future if we have any portions of state that require
21-
// more sophisticated comparisons.
22-
return reflect.DeepEqual(s, other)
13+
if s == other {
14+
return true
15+
}
16+
if s == nil || other == nil {
17+
return false
18+
}
19+
20+
if !s.RootOutputValuesEqual(other) {
21+
return false
22+
}
23+
24+
if !s.CheckResults.Equal(other.CheckResults) {
25+
return false
26+
}
27+
28+
return s.ManagedResourcesEqual(other)
2329
}
2430

2531
// ManagedResourcesEqual returns true if all of the managed resources tracked
@@ -33,9 +39,14 @@ func (s *State) ManagedResourcesEqual(other *State) bool {
3339
// First, some accommodations for situations where one of the objects is
3440
// nil, for robustness since we sometimes use a nil state to represent
3541
// a prior state being entirely absent.
36-
if s == nil && other == nil {
42+
if s == other {
43+
// covers both states being nil, or both states being the exact same
44+
// object.
3745
return true
3846
}
47+
48+
// Managed resources are technically equal if one state is nil while the
49+
// other has no resources.
3950
if s == nil {
4051
return !other.HasManagedResourceInstanceObjects()
4152
}
@@ -45,29 +56,37 @@ func (s *State) ManagedResourcesEqual(other *State) bool {
4556

4657
// If we get here then both states are non-nil.
4758

48-
// sameManagedResources tests that its second argument has all the
49-
// resources that the first one does, so we'll call it twice with the
50-
// arguments inverted to ensure that we'll also catch situations where
51-
// the second has resources that the first does not.
52-
return sameManagedResources(s, other) && sameManagedResources(other, s)
53-
}
59+
if len(s.Modules) != len(other.Modules) {
60+
return false
61+
}
62+
63+
for key, sMod := range s.Modules {
64+
otherMod, ok := other.Modules[key]
65+
if !ok {
66+
return false
67+
}
68+
// Something else is wrong if the addresses don't match, but they are
69+
// definitely not equal
70+
if !sMod.Addr.Equal(otherMod.Addr) {
71+
return false
72+
}
73+
74+
if len(sMod.Resources) != len(otherMod.Resources) {
75+
return false
76+
}
5477

55-
func sameManagedResources(s1, s2 *State) bool {
56-
for _, ms := range s1.Modules {
57-
for _, rs := range ms.Resources {
58-
addr := rs.Addr
59-
if addr.Resource.Mode != addrs.ManagedResourceMode {
60-
continue
78+
for key, sRes := range sMod.Resources {
79+
otherRes, ok := otherMod.Resources[key]
80+
if !ok {
81+
return false
6182
}
62-
otherRS := s2.Resource(addr)
63-
if !reflect.DeepEqual(rs, otherRS) {
83+
if !sRes.Equal(otherRes) {
6484
return false
6585
}
6686
}
6787
}
6888

6989
return true
70-
7190
}
7291

7392
// RootOutputValuesEqual returns true if the root output values tracked in the
@@ -77,25 +96,14 @@ func (s *State) RootOutputValuesEqual(s2 *State) bool {
7796
if s == nil && s2 == nil {
7897
return true
7998
}
80-
if s == nil {
81-
return !s2.HasRootOutputValues()
82-
}
83-
if s2 == nil {
84-
return !s.HasRootOutputValues()
85-
}
86-
87-
return sameRootOutputValues(s, s2)
88-
}
8999

90-
// sameRootOutputValues returns true if the two states have the same root output values.
91-
func sameRootOutputValues(s1, s2 *State) bool {
92-
if len(s1.RootOutputValues) != len(s2.RootOutputValues) {
100+
if len(s.RootOutputValues) != len(s2.RootOutputValues) {
93101
return false
94102
}
95103

96-
for k, v1 := range s1.RootOutputValues {
104+
for k, v1 := range s2.RootOutputValues {
97105
v2, ok := s2.RootOutputValues[k]
98-
if !ok || !reflect.DeepEqual(v1, v2) {
106+
if !ok || !v1.Equal(v2) {
99107
return false
100108
}
101109
}

internal/states/state_test.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,13 @@ func TestStateDeepCopy(t *testing.T) {
203203
Private: []byte("private data"),
204204
Dependencies: []addrs.ConfigResource{},
205205
CreateBeforeDestroy: true,
206+
207+
// these may or may not be copied, but should not affect equality of
208+
// the resources.
209+
decodeValueCache: cty.ObjectVal(map[string]cty.Value{
210+
"woozles": cty.StringVal("confuzles"),
211+
}),
212+
decodeIdentityCache: cty.DynamicVal,
206213
},
207214
addrs.AbsProviderConfig{
208215
Provider: addrs.NewDefaultProvider("test"),
@@ -242,11 +249,34 @@ func TestStateDeepCopy(t *testing.T) {
242249
)
243250

244251
state.EnsureModule(addrs.RootModuleInstance.Child("child", addrs.NoKey))
245-
246252
stateCopy := state.DeepCopy()
247253
if !state.Equal(stateCopy) {
248254
t.Fatalf("\nexpected:\n%q\ngot:\n%q\n", state, stateCopy)
249255
}
256+
257+
// this is implied by the above, but has previously used a different
258+
// codepath for comparison.
259+
if !state.ManagedResourcesEqual(stateCopy) {
260+
t.Fatalf("\nexpected managed resources to be equal:\n%q\ngot:\n%q\n", state, stateCopy)
261+
}
262+
263+
// remove the cached values and ensure equality still holds
264+
for _, mod := range stateCopy.Modules {
265+
for _, res := range mod.Resources {
266+
for _, inst := range res.Instances {
267+
inst.Current.decodeValueCache = cty.NilVal
268+
inst.Current.decodeIdentityCache = cty.NilVal
269+
}
270+
}
271+
}
272+
273+
if !state.Equal(stateCopy) {
274+
t.Fatalf("\nexpected:\n%q\ngot:\n%q\n", state, stateCopy)
275+
}
276+
277+
if !state.ManagedResourcesEqual(stateCopy) {
278+
t.Fatalf("\nexpected managed resources to be equal:\n%q\ngot:\n%q\n", state, stateCopy)
279+
}
250280
}
251281

252282
func TestStateHasResourceInstanceObjects(t *testing.T) {

0 commit comments

Comments
 (0)