diff --git a/plans/objchange/compatible.go b/plans/objchange/compatible.go new file mode 100644 index 000000000..8ae438f26 --- /dev/null +++ b/plans/objchange/compatible.go @@ -0,0 +1,232 @@ +package objchange + +import ( + "fmt" + "strconv" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" + + "github.com/hashicorp/terraform/configs/configschema" +) + +// AssertObjectCompatible checks whether the given "actual" value is a valid +// completion of the possibly-partially-unknown "planned" value. +// +// This means that any known leaf value in "planned" must be equal to the +// corresponding value in "actual", and various other similar constraints. +// +// Any inconsistencies are reported by returning a non-zero number of errors. +// These errors are usually (but not necessarily) cty.PathError values +// referring to a particular nested value within the "actual" value. +// +// The two values must have types that conform to the given schema's implied +// type, or this function will panic. +func AssertObjectCompatible(schema *configschema.Block, planned, actual cty.Value) []error { + return assertObjectCompatible(schema, planned, actual, nil) +} + +func assertObjectCompatible(schema *configschema.Block, planned, actual cty.Value, path cty.Path) []error { + var errs []error + if planned.IsNull() && !actual.IsNull() { + errs = append(errs, path.NewErrorf("was absent, but now present")) + return errs + } + if actual.IsNull() && !planned.IsNull() { + errs = append(errs, path.NewErrorf("was present, but now absent")) + return errs + } + if planned.IsNull() { + // No further checks possible if both values are null + return errs + } + + for name := range schema.Attributes { + plannedV := planned.GetAttr(name) + actualV := actual.GetAttr(name) + + path := append(path, cty.GetAttrStep{Name: name}) + moreErrs := assertValueCompatible(plannedV, actualV, path) + errs = append(errs, moreErrs...) + } + for name, blockS := range schema.BlockTypes { + plannedV := planned.GetAttr(name) + actualV := actual.GetAttr(name) + + // As a special case, we permit a "planned" block with exactly one + // element where all of the "leaf" values are unknown, since that's + // what HCL's dynamic block extension generates if the for_each + // expression is itself unknown and thus it cannot predict how many + // child blocks will get created. + switch blockS.Nesting { + case configschema.NestingSingle: + if allLeafValuesUnknown(plannedV) && !plannedV.IsNull() { + return errs + } + case configschema.NestingList, configschema.NestingMap, configschema.NestingSet: + if plannedV.LengthInt() == 1 { + elemVs := plannedV.AsValueSlice() + if allLeafValuesUnknown(elemVs[0]) { + return errs + } + } + default: + panic(fmt.Sprintf("unsupported nesting mode %s", blockS.Nesting)) + } + + path := append(path, cty.GetAttrStep{Name: name}) + switch blockS.Nesting { + case configschema.NestingSingle: + moreErrs := assertObjectCompatible(&blockS.Block, plannedV, actualV, path) + errs = append(errs, moreErrs...) + case configschema.NestingList: + plannedL := plannedV.LengthInt() + actualL := actualV.LengthInt() + if plannedL != actualL { + errs = append(errs, path.NewErrorf("block count changed from %d to %d", plannedL, actualL)) + continue + } + case configschema.NestingMap: + plannedAtys := plannedV.Type().AttributeTypes() + actualAtys := actualV.Type().AttributeTypes() + for k := range plannedAtys { + if _, ok := actualAtys[k]; !ok { + errs = append(errs, path.NewErrorf("block key %q has vanished", k)) + continue + } + + plannedEV := plannedV.GetAttr(k) + actualEV := actualV.GetAttr(k) + moreErrs := assertObjectCompatible(&blockS.Block, plannedEV, actualEV, append(path, cty.GetAttrStep{Name: k})) + errs = append(errs, moreErrs...) + } + for k := range actualAtys { + if _, ok := plannedAtys[k]; !ok { + errs = append(errs, path.NewErrorf("new block key %q has appeared", k)) + continue + } + } + case configschema.NestingSet: + // We can't do any reasonable matching of set elements since their + // content is also their key, and so we have no way to correlate + // them. Because of this, we simply verify that we still have the + // same number of elements. + plannedL := plannedV.LengthInt() + actualL := actualV.LengthInt() + if plannedL < actualL { + errs = append(errs, path.NewErrorf("block set length changed from %d to %d", plannedL, actualL)) + } + default: + panic(fmt.Sprintf("unsupported nesting mode %s", blockS.Nesting)) + } + } + return errs +} + +func assertValueCompatible(planned, actual cty.Value, path cty.Path) []error { + // NOTE: We don't normally use the GoString rendering of cty.Value in + // user-facing error messages as a rule, but we make an exception + // for this function because we expect the user to pass this message on + // verbatim to the provider development team and so more detail is better. + + var errs []error + if planned.Type() == cty.DynamicPseudoType { + // Anything goes, then + return errs + } + if problems := planned.Type().TestConformance(actual.Type()); len(problems) > 0 { + errs = append(errs, path.NewErrorf("wrong final value type: %s", convert.MismatchMessage(actual.Type(), planned.Type()))) + // If the types don't match then we can't do any other comparisons, + // so we bail early. + return errs + } + + ty := planned.Type() + switch { + + case ty == cty.DynamicPseudoType || !planned.IsKnown(): + // We didn't know what were going to end up with during plan, so + // anything goes during apply. + return errs + + case !actual.IsKnown(): + errs = append(errs, path.NewErrorf("was known, but now unknown")) + + case ty.IsPrimitiveType(): + if !actual.Equals(planned).True() { + errs = append(errs, path.NewErrorf("was %#v, but now %#v", planned, actual)) + } + + case ty.IsListType() || ty.IsMapType() || ty.IsTupleType(): + for it := planned.ElementIterator(); it.Next(); { + k, plannedV := it.Element() + if !actual.HasIndex(k).True() { + errs = append(errs, path.NewErrorf("element %s has vanished", indexStrForErrors(k))) + continue + } + + actualV := actual.Index(k) + moreErrs := assertValueCompatible(plannedV, actualV, append(path, cty.IndexStep{Key: k})) + errs = append(errs, moreErrs...) + } + + for it := actual.ElementIterator(); it.Next(); { + k, _ := it.Element() + if !planned.HasIndex(k).True() { + errs = append(errs, path.NewErrorf("new element %s has appeared", indexStrForErrors(k))) + } + } + + case ty.IsObjectType(): + atys := ty.AttributeTypes() + for name := range atys { + // Because we already tested that the two values have the same type, + // we can assume that the same attributes are present in both and + // focus just on testing their values. + plannedV := planned.GetAttr(name) + actualV := actual.GetAttr(name) + moreErrs := assertValueCompatible(plannedV, actualV, append(path, cty.GetAttrStep{Name: name})) + errs = append(errs, moreErrs...) + } + + case ty.IsSetType(): + // We can't really do anything useful for sets here because changing + // an unknown element to known changes the identity of the element, and + // so we can't correlate them properly. However, we will at least check + // to ensure that the number of elements is consistent, along with + // the general type-match checks we ran earlier in this function. + plannedL := planned.LengthInt() + actualL := actual.LengthInt() + if plannedL < actualL { + errs = append(errs, path.NewErrorf("length changed from %d to %d", plannedL, actualL)) + } + } + + return errs +} + +func indexStrForErrors(v cty.Value) string { + switch v.Type() { + case cty.Number: + return v.AsBigFloat().Text('f', -1) + case cty.String: + return strconv.Quote(v.AsString()) + default: + // Should be impossible, since no other index types are allowed! + return fmt.Sprintf("%#v", v) + } +} + +func allLeafValuesUnknown(v cty.Value) bool { + seenKnownValue := false + cty.Walk(v, func(path cty.Path, cv cty.Value) (bool, error) { + if cv.IsNull() { + seenKnownValue = true + } + if cv.Type().IsPrimitiveType() && cv.IsKnown() { + seenKnownValue = true + } + return true, nil + }) + return !seenKnownValue +} diff --git a/plans/objchange/compatible_test.go b/plans/objchange/compatible_test.go new file mode 100644 index 000000000..26fdfa951 --- /dev/null +++ b/plans/objchange/compatible_test.go @@ -0,0 +1,878 @@ +package objchange + +import ( + "fmt" + "testing" + + "github.com/apparentlymart/go-dump/dump" + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/configs/configschema" + "github.com/hashicorp/terraform/tfdiags" +) + +func TestAssertObjectCompatible(t *testing.T) { + tests := []struct { + Schema *configschema.Block + Planned cty.Value + Actual cty.Value + WantErrs []string + }{ + { + &configschema.Block{}, + cty.EmptyObjectVal, + cty.EmptyObjectVal, + nil, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "name": { + Type: cty.String, + Required: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "name": cty.StringVal("thingy"), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "name": cty.StringVal("thingy"), + }), + nil, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "name": { + Type: cty.String, + Required: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "name": cty.UnknownVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "name": cty.StringVal("thingy"), + }), + nil, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "name": { + Type: cty.String, + Required: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "name": cty.StringVal("wotsit"), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "name": cty.StringVal("thingy"), + }), + []string{ + `.name: was cty.StringVal("wotsit"), but now cty.StringVal("thingy")`, + }, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "stuff": { + Type: cty.DynamicPseudoType, + Required: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "stuff": cty.DynamicVal, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "stuff": cty.StringVal("thingy"), + }), + []string{}, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "stuff": { + Type: cty.DynamicPseudoType, + Required: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "stuff": cty.StringVal("wotsit"), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "stuff": cty.StringVal("thingy"), + }), + []string{ + `.stuff: was cty.StringVal("wotsit"), but now cty.StringVal("thingy")`, + }, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "stuff": { + Type: cty.DynamicPseudoType, + Required: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "stuff": cty.StringVal("true"), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "stuff": cty.True, + }), + []string{ + `.stuff: wrong final value type: string required`, + }, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "stuff": { + Type: cty.DynamicPseudoType, + Required: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "stuff": cty.DynamicVal, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "stuff": cty.EmptyObjectVal, + }), + nil, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "stuff": { + Type: cty.DynamicPseudoType, + Required: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "stuff": cty.ObjectVal(map[string]cty.Value{ + "nonsense": cty.StringVal("yup"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "stuff": cty.EmptyObjectVal, + }), + []string{ + `.stuff: wrong final value type: attribute "nonsense" is required`, + }, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "tags": { + Type: cty.Map(cty.String), + Optional: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "tags": cty.MapVal(map[string]cty.Value{ + "Name": cty.StringVal("thingy"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "tags": cty.MapVal(map[string]cty.Value{ + "Name": cty.StringVal("thingy"), + }), + }), + nil, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "tags": { + Type: cty.Map(cty.String), + Optional: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "tags": cty.MapVal(map[string]cty.Value{ + "Name": cty.UnknownVal(cty.String), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "tags": cty.MapVal(map[string]cty.Value{ + "Name": cty.StringVal("thingy"), + }), + }), + nil, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "tags": { + Type: cty.Map(cty.String), + Optional: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "tags": cty.MapVal(map[string]cty.Value{ + "Name": cty.StringVal("wotsit"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "tags": cty.MapVal(map[string]cty.Value{ + "Name": cty.StringVal("thingy"), + }), + }), + []string{ + `.tags["Name"]: was cty.StringVal("wotsit"), but now cty.StringVal("thingy")`, + }, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "tags": { + Type: cty.Map(cty.String), + Optional: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "tags": cty.MapVal(map[string]cty.Value{ + "Name": cty.StringVal("thingy"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "tags": cty.MapVal(map[string]cty.Value{ + "Name": cty.StringVal("thingy"), + "Env": cty.StringVal("production"), + }), + }), + []string{ + `.tags: new element "Env" has appeared`, + }, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "tags": { + Type: cty.Map(cty.String), + Optional: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "tags": cty.MapVal(map[string]cty.Value{ + "Name": cty.StringVal("thingy"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "tags": cty.MapValEmpty(cty.String), + }), + []string{ + `.tags: element "Name" has vanished`, + }, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "tags": { + Type: cty.Map(cty.String), + Optional: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "tags": cty.MapVal(map[string]cty.Value{ + "Name": cty.UnknownVal(cty.String), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "tags": cty.MapVal(map[string]cty.Value{ + "Name": cty.NullVal(cty.String), + }), + }), + nil, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "zones": { + Type: cty.Set(cty.String), + Optional: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "zones": cty.SetVal([]cty.Value{ + cty.StringVal("thingy"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "zones": cty.SetVal([]cty.Value{ + cty.StringVal("thingy"), + }), + }), + nil, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "zones": { + Type: cty.Set(cty.String), + Optional: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "zones": cty.SetVal([]cty.Value{ + cty.StringVal("thingy"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "zones": cty.SetVal([]cty.Value{ + cty.StringVal("thingy"), + cty.StringVal("wotsit"), + }), + }), + []string{ + `.zones: length changed from 1 to 2`, + }, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "zones": { + Type: cty.Set(cty.String), + Optional: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "zones": cty.SetVal([]cty.Value{ + cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "zones": cty.SetVal([]cty.Value{ + // Imagine that both of our unknown values ultimately resolved to "thingy", + // causing them to collapse into a single element. That's valid, + // even though it's also a little confusing and counter-intuitive. + cty.StringVal("thingy"), + }), + }), + nil, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "names": { + Type: cty.List(cty.String), + Optional: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "names": cty.ListVal([]cty.Value{ + cty.StringVal("thingy"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "names": cty.ListVal([]cty.Value{ + cty.StringVal("thingy"), + }), + }), + nil, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "names": { + Type: cty.List(cty.String), + Optional: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "names": cty.UnknownVal(cty.List(cty.String)), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "names": cty.ListVal([]cty.Value{ + cty.StringVal("thingy"), + }), + }), + nil, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "names": { + Type: cty.List(cty.String), + Optional: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "names": cty.ListVal([]cty.Value{ + cty.UnknownVal(cty.String), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "names": cty.ListVal([]cty.Value{ + cty.StringVal("thingy"), + }), + }), + nil, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "names": { + Type: cty.List(cty.String), + Optional: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "names": cty.ListVal([]cty.Value{ + cty.StringVal("thingy"), + cty.UnknownVal(cty.String), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "names": cty.ListVal([]cty.Value{ + cty.StringVal("thingy"), + cty.StringVal("wotsit"), + }), + }), + nil, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "names": { + Type: cty.List(cty.String), + Optional: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "names": cty.ListVal([]cty.Value{ + cty.UnknownVal(cty.String), + cty.StringVal("thingy"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "names": cty.ListVal([]cty.Value{ + cty.StringVal("thingy"), + cty.StringVal("wotsit"), + }), + }), + []string{ + `.names[1]: was cty.StringVal("thingy"), but now cty.StringVal("wotsit")`, + }, + }, + { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "names": { + Type: cty.List(cty.String), + Optional: true, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "names": cty.ListVal([]cty.Value{ + cty.UnknownVal(cty.String), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "names": cty.ListVal([]cty.Value{ + cty.StringVal("thingy"), + cty.StringVal("wotsit"), + }), + }), + []string{ + `.names: new element 1 has appeared`, + }, + }, + + // NestingSingle blocks + { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "key": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{}, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "key": cty.EmptyObjectVal, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "key": cty.EmptyObjectVal, + }), + nil, + }, + { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "key": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{}, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "key": cty.UnknownVal(cty.EmptyObject), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "key": cty.EmptyObjectVal, + }), + nil, + }, + { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "key": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "key": cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + })), + }), + cty.ObjectVal(map[string]cty.Value{ + "key": cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("hello"), + }), + }), + []string{ + `.key: was absent, but now present`, + }, + }, + { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "key": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "key": cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("hello"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "key": cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + })), + }), + []string{ + `.key: was present, but now absent`, + }, + }, + { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "key": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "key": cty.ObjectVal(map[string]cty.Value{ + // One wholly unknown block is what "dynamic" blocks + // generate when the for_each expression is unknown. + "foo": cty.UnknownVal(cty.String), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "key": cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + })), + }), + nil, + }, + + // NestingList blocks + { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "key": { + Nesting: configschema.NestingList, + Block: configschema.Block{}, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "key": cty.ListVal([]cty.Value{ + cty.EmptyObjectVal, + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "key": cty.ListVal([]cty.Value{ + cty.EmptyObjectVal, + }), + }), + nil, + }, + { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "key": { + Nesting: configschema.NestingList, + Block: configschema.Block{}, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "key": cty.TupleVal([]cty.Value{ + cty.EmptyObjectVal, + cty.EmptyObjectVal, + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "key": cty.TupleVal([]cty.Value{ + cty.EmptyObjectVal, + }), + }), + []string{ + `.key: block count changed from 2 to 1`, + }, + }, + { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "key": { + Nesting: configschema.NestingList, + Block: configschema.Block{}, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "key": cty.TupleVal([]cty.Value{}), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "key": cty.TupleVal([]cty.Value{ + cty.EmptyObjectVal, + cty.EmptyObjectVal, + }), + }), + []string{ + `.key: block count changed from 0 to 2`, + }, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("%#v and %#v", test.Planned, test.Actual), func(t *testing.T) { + errs := AssertObjectCompatible(test.Schema, test.Planned, test.Actual) + + wantErrs := make(map[string]struct{}) + gotErrs := make(map[string]struct{}) + for _, err := range errs { + gotErrs[tfdiags.FormatError(err)] = struct{}{} + } + for _, msg := range test.WantErrs { + wantErrs[msg] = struct{}{} + } + + t.Logf("\nplanned: %sactual: %s", dump.Value(test.Planned), dump.Value(test.Actual)) + for msg := range wantErrs { + if _, ok := gotErrs[msg]; !ok { + t.Errorf("missing expected error: %s", msg) + } + } + for msg := range gotErrs { + if _, ok := wantErrs[msg]; !ok { + t.Errorf("unexpected extra error: %s", msg) + } + } + }) + } +}