diff --git a/config/hcl2shim/values_equiv.go b/config/hcl2shim/values_equiv.go new file mode 100644 index 000000000..ab437de80 --- /dev/null +++ b/config/hcl2shim/values_equiv.go @@ -0,0 +1,177 @@ +package hcl2shim + +import ( + "github.com/zclconf/go-cty/cty" +) + +// ValuesSDKEquivalent returns true if both of the given values seem equivalent +// as far as the legacy SDK diffing code would be concerned. +// +// Since SDK diffing is a fuzzy, inexact operation, this function is also +// fuzzy and inexact. It will err on the side of returning false if it +// encounters an ambiguous situation. Ambiguity is most common in the presence +// of sets because in practice it is impossible to exactly correlate +// nonequal-but-equivalent set elements because they have no identity separate +// from their value. +// +// This must be used _only_ for comparing values for equivalence within the +// SDK planning code. It is only meaningful to compare the "prior state" +// provided by Terraform Core with the "planned new state" produced by the +// legacy SDK code via shims. In particular it is not valid to use this +// function with their the config value or the "proposed new state" value +// because they contain only the subset of data that Terraform Core itself is +// able to determine. +func ValuesSDKEquivalent(a, b cty.Value) bool { + if a == cty.NilVal || b == cty.NilVal { + // We don't generally expect nils to appear, but we'll allow them + // for robustness since the data structures produced by legacy SDK code + // can sometimes be non-ideal. + return a == b // equivalent if they are _both_ nil + } + if a.RawEquals(b) { + // Easy case. We use RawEquals because we want two unknowns to be + // considered equal here, whereas "Equals" would return unknown. + return true + } + if !a.IsKnown() || !b.IsKnown() { + // Two unknown values are equivalent regardless of type. A known is + // never equivalent to an unknown. + return a.IsKnown() == b.IsKnown() + } + if aZero, bZero := valuesSDKEquivalentIsNullOrZero(a), valuesSDKEquivalentIsNullOrZero(b); aZero || bZero { + // Two null/zero values are equivalent regardless of type. A non-zero is + // never equivalent to a zero. + return aZero == bZero + } + + // If we get down here then we are guaranteed that both a and b are known, + // non-null values. + + aTy := a.Type() + bTy := b.Type() + switch { + case aTy.IsSetType() && bTy.IsSetType(): + return valuesSDKEquivalentSets(a, b) + case aTy.IsListType() && bTy.IsListType(): + return valuesSDKEquivalentSequences(a, b) + case aTy.IsTupleType() && bTy.IsTupleType(): + return valuesSDKEquivalentSequences(a, b) + case aTy.IsMapType() && bTy.IsMapType(): + return valuesSDKEquivalentMappings(a, b) + case aTy.IsObjectType() && bTy.IsObjectType(): + return valuesSDKEquivalentMappings(a, b) + default: + // We've now covered all the interesting cases, so anything that falls + // down here cannot be equivalent. + return false + } +} + +// valuesSDKEquivalentIsNullOrZero returns true if the given value is either +// null or is the "zero value" (in the SDK/Go sense) for its type. +func valuesSDKEquivalentIsNullOrZero(v cty.Value) bool { + if v == cty.NilVal { + return true + } + + ty := v.Type() + switch { + case !v.IsKnown(): + return false + case v.IsNull(): + return true + + // After this point, v is always known and non-null + case ty.IsListType() || ty.IsSetType() || ty.IsMapType() || ty.IsObjectType() || ty.IsTupleType(): + return v.LengthInt() == 0 + case ty == cty.String: + return v.RawEquals(cty.StringVal("")) + case ty == cty.Number: + return v.RawEquals(cty.Zero) + case ty == cty.Bool: + return v.RawEquals(cty.False) + default: + // The above is exhaustive, but for robustness we'll consider anything + // else to _not_ be zero unless it is null. + return false + } +} + +// valuesSDKEquivalentSets returns true only if each of the elements in a can +// be correlated with at least one equivalent element in b and vice-versa. +// This is a fuzzy operation that prefers to signal non-equivalence if it cannot +// be certain that all elements are accounted for. +func valuesSDKEquivalentSets(a, b cty.Value) bool { + if aLen, bLen := a.LengthInt(), b.LengthInt(); aLen != bLen { + return false + } + + // Our methodology here is a little tricky, to deal with the fact that + // it's impossible to directly correlate two non-equal set elements because + // they don't have identities separate from their values. + // The approach is to count the number of equivalent elements each element + // of a has in b and vice-versa, and then return true only if each element + // in both sets has at least one equivalent. + as := a.AsValueSlice() + bs := b.AsValueSlice() + aeqs := make([]bool, len(as)) + beqs := make([]bool, len(bs)) + for ai, av := range as { + for bi, bv := range bs { + if ValuesSDKEquivalent(av, bv) { + aeqs[ai] = true + beqs[bi] = true + } + } + } + + for _, eq := range aeqs { + if !eq { + return false + } + } + for _, eq := range beqs { + if !eq { + return false + } + } + return true +} + +// valuesSDKEquivalentSequences decides equivalence for two sequence values +// (lists or tuples). +func valuesSDKEquivalentSequences(a, b cty.Value) bool { + as := a.AsValueSlice() + bs := b.AsValueSlice() + if len(as) != len(bs) { + return false + } + + for i := range as { + if !ValuesSDKEquivalent(as[i], bs[i]) { + return false + } + } + return true +} + +// valuesSDKEquivalentMappings decides equivalence for two mapping values +// (maps or objects). +func valuesSDKEquivalentMappings(a, b cty.Value) bool { + as := a.AsValueMap() + bs := b.AsValueMap() + if len(as) != len(bs) { + return false + } + + for k, av := range as { + bv, ok := bs[k] + if !ok { + return false + } + if !ValuesSDKEquivalent(av, bv) { + return false + } + } + return true +} diff --git a/config/hcl2shim/values_equiv_test.go b/config/hcl2shim/values_equiv_test.go new file mode 100644 index 000000000..443eb6b7d --- /dev/null +++ b/config/hcl2shim/values_equiv_test.go @@ -0,0 +1,407 @@ +package hcl2shim + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestValuesSDKEquivalent(t *testing.T) { + tests := []struct { + A, B cty.Value + Want bool + }{ + // Strings + { + cty.StringVal("hello"), + cty.StringVal("hello"), + true, + }, + { + cty.StringVal("hello"), + cty.StringVal("world"), + false, + }, + { + cty.StringVal("hello"), + cty.StringVal(""), + false, + }, + { + cty.NullVal(cty.String), + cty.StringVal(""), + true, + }, + + // Numbers + { + cty.NumberIntVal(1), + cty.NumberIntVal(1), + true, + }, + { + cty.NumberIntVal(1), + cty.NumberIntVal(2), + false, + }, + { + cty.NumberIntVal(1), + cty.Zero, + false, + }, + { + cty.NullVal(cty.Number), + cty.Zero, + true, + }, + + // Bools + { + cty.True, + cty.True, + true, + }, + { + cty.True, + cty.False, + false, + }, + { + cty.NullVal(cty.Bool), + cty.False, + true, + }, + + // Mixed primitives + { + cty.StringVal("hello"), + cty.False, + false, + }, + { + cty.StringVal(""), + cty.False, + true, + }, + { + cty.NumberIntVal(0), + cty.False, + true, + }, + { + cty.StringVal(""), + cty.NumberIntVal(0), + true, + }, + { + cty.NullVal(cty.Bool), + cty.NullVal(cty.Number), + true, + }, + { + cty.StringVal(""), + cty.NullVal(cty.Number), + true, + }, + + // Lists + { + cty.ListValEmpty(cty.String), + cty.ListValEmpty(cty.String), + true, + }, + { + cty.ListValEmpty(cty.String), + cty.NullVal(cty.List(cty.String)), + true, + }, + { + cty.ListVal([]cty.Value{cty.StringVal("hello")}), + cty.ListVal([]cty.Value{cty.StringVal("hello"), cty.StringVal("hello")}), + false, + }, + { + cty.ListVal([]cty.Value{cty.StringVal("hello")}), + cty.ListValEmpty(cty.String), + false, + }, + { + cty.ListVal([]cty.Value{cty.StringVal("hello")}), + cty.ListVal([]cty.Value{cty.StringVal("hello")}), + true, + }, + { + cty.ListVal([]cty.Value{cty.StringVal("hello")}), + cty.ListVal([]cty.Value{cty.StringVal("world")}), + false, + }, + { + cty.ListVal([]cty.Value{cty.NullVal(cty.String)}), + cty.ListVal([]cty.Value{cty.StringVal("")}), + true, + }, + + // Tuples + { + cty.EmptyTupleVal, + cty.EmptyTupleVal, + true, + }, + { + cty.EmptyTupleVal, + cty.NullVal(cty.EmptyTuple), + true, + }, + { + cty.TupleVal([]cty.Value{cty.StringVal("hello")}), + cty.TupleVal([]cty.Value{cty.StringVal("hello"), cty.StringVal("hello")}), + false, + }, + { + cty.TupleVal([]cty.Value{cty.StringVal("hello")}), + cty.EmptyTupleVal, + false, + }, + { + cty.TupleVal([]cty.Value{cty.StringVal("hello")}), + cty.TupleVal([]cty.Value{cty.StringVal("hello")}), + true, + }, + { + cty.TupleVal([]cty.Value{cty.StringVal("hello")}), + cty.TupleVal([]cty.Value{cty.StringVal("world")}), + false, + }, + { + cty.TupleVal([]cty.Value{cty.NullVal(cty.String)}), + cty.TupleVal([]cty.Value{cty.StringVal("")}), + true, + }, + + // Sets + { + cty.SetValEmpty(cty.String), + cty.SetValEmpty(cty.String), + true, + }, + { + cty.SetValEmpty(cty.String), + cty.NullVal(cty.Set(cty.String)), + true, + }, + { + cty.SetVal([]cty.Value{cty.StringVal("hello")}), + cty.SetValEmpty(cty.String), + false, + }, + { + cty.SetVal([]cty.Value{cty.StringVal("hello")}), + cty.SetVal([]cty.Value{cty.StringVal("hello")}), + true, + }, + { + cty.SetVal([]cty.Value{cty.StringVal("hello")}), + cty.SetVal([]cty.Value{cty.StringVal("world")}), + false, + }, + { + cty.SetVal([]cty.Value{cty.NullVal(cty.String)}), + cty.SetVal([]cty.Value{cty.StringVal("")}), + true, + }, + { + cty.SetVal([]cty.Value{ + cty.NullVal(cty.String), + cty.StringVal(""), + }), + cty.SetVal([]cty.Value{ + cty.NullVal(cty.String), + }), + false, // because the element count is different + }, + { + cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal(""), + "b": cty.StringVal(""), + }), + cty.ObjectVal(map[string]cty.Value{ + "a": cty.NullVal(cty.String), + "b": cty.StringVal(""), + }), + }), + cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal(""), + "b": cty.StringVal(""), + }), + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal(""), + "b": cty.NullVal(cty.String), + }), + }), + true, + }, + { + cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("boop"), + "b": cty.StringVal(""), + }), + cty.ObjectVal(map[string]cty.Value{ + "a": cty.NullVal(cty.String), + "b": cty.StringVal(""), + }), + }), + cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("beep"), + "b": cty.StringVal(""), + }), + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal(""), + "b": cty.NullVal(cty.String), + }), + }), + false, + }, + { + cty.SetVal([]cty.Value{cty.ObjectVal(map[string]cty.Value{ + "list": cty.ListValEmpty(cty.String), + "list_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "unused": cty.StringVal(""), + }), + }), + })}), + cty.SetVal([]cty.Value{cty.ObjectVal(map[string]cty.Value{ + "list": cty.ListValEmpty(cty.String), + "list_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "unused": cty.NullVal(cty.String), + }), + }), + })}), + true, + }, + + // Maps + { + cty.MapValEmpty(cty.String), + cty.MapValEmpty(cty.String), + true, + }, + { + cty.MapValEmpty(cty.String), + cty.NullVal(cty.Map(cty.String)), + true, + }, + { + cty.MapVal(map[string]cty.Value{"hi": cty.StringVal("hello")}), + cty.MapVal(map[string]cty.Value{"hi": cty.StringVal("hello"), "hey": cty.StringVal("hello")}), + false, + }, + { + cty.MapVal(map[string]cty.Value{"hi": cty.StringVal("hello")}), + cty.MapValEmpty(cty.String), + false, + }, + { + cty.MapVal(map[string]cty.Value{"hi": cty.StringVal("hello")}), + cty.MapVal(map[string]cty.Value{"hi": cty.StringVal("hello")}), + true, + }, + { + cty.MapVal(map[string]cty.Value{"hi": cty.StringVal("hello")}), + cty.MapVal(map[string]cty.Value{"hi": cty.StringVal("world")}), + false, + }, + { + cty.MapVal(map[string]cty.Value{"hi": cty.NullVal(cty.String)}), + cty.MapVal(map[string]cty.Value{"hi": cty.StringVal("")}), + true, + }, + + // Objects + { + cty.EmptyObjectVal, + cty.EmptyObjectVal, + true, + }, + { + cty.EmptyObjectVal, + cty.NullVal(cty.EmptyObject), + true, + }, + { + cty.ObjectVal(map[string]cty.Value{"hi": cty.StringVal("hello")}), + cty.ObjectVal(map[string]cty.Value{"hi": cty.StringVal("hello"), "hey": cty.StringVal("hello")}), + false, + }, + { + cty.ObjectVal(map[string]cty.Value{"hi": cty.StringVal("hello")}), + cty.EmptyObjectVal, + false, + }, + { + cty.ObjectVal(map[string]cty.Value{"hi": cty.StringVal("hello")}), + cty.ObjectVal(map[string]cty.Value{"hi": cty.StringVal("hello")}), + true, + }, + { + cty.ObjectVal(map[string]cty.Value{"hi": cty.StringVal("hello")}), + cty.ObjectVal(map[string]cty.Value{"hi": cty.StringVal("world")}), + false, + }, + { + cty.ObjectVal(map[string]cty.Value{"hi": cty.NullVal(cty.String)}), + cty.ObjectVal(map[string]cty.Value{"hi": cty.StringVal("")}), + true, + }, + + // Unknown values + { + cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String), + true, + }, + { + cty.StringVal("hello"), + cty.UnknownVal(cty.String), + false, + }, + { + cty.StringVal(""), + cty.UnknownVal(cty.String), + false, + }, + { + cty.NullVal(cty.String), + cty.UnknownVal(cty.String), + false, + }, + } + + run := func(t *testing.T, a, b cty.Value, want bool) { + got := ValuesSDKEquivalent(a, b) + + if got != want { + t.Errorf("wrong result\nfor: %#v ≈ %#v\ngot %#v, but want %#v", a, b, got, want) + } + } + + for _, test := range tests { + t.Run(fmt.Sprintf("%#v ≈ %#v", test.A, test.B), func(t *testing.T) { + run(t, test.A, test.B, test.Want) + }) + // This function is symmetrical, so we'll also test in reverse so + // we don't need to manually copy all of the test cases. (But this does + // mean that one failure normally becomes two, of course!) + if !test.A.RawEquals(test.B) { + t.Run(fmt.Sprintf("%#v ≈ %#v", test.B, test.A), func(t *testing.T) { + run(t, test.B, test.A, test.Want) + }) + } + } +}