config/hcl2shim: ValuesSDKEquivalent function

This is a first pass of an "approximately equal" function that tries to
mimic the reduced precision caused by the field reader abstraction in
helper/schema so that we can distinguish between meaningful changes to
the proposed new state and incidental ones that just result from the loss
of precision in the SDK implementation.
This commit is contained in:
Martin Atkins 2019-01-22 14:51:11 -08:00
parent f341624df7
commit 6b03c81375
2 changed files with 584 additions and 0 deletions

View File

@ -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
}

View File

@ -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)
})
}
}
}