plans/objchange: logic for merging prior state with config

This produces a "proposed new state", which already has prior computed
values propagated into it (since that behavior is standard for all
resource types) but could be customized further by the provider to make
the "_planned_ new state".

In the process of implementing this it became clear that our configschema
DecoderSpec behavior is incorrect, since it's producing list values for
NestingList and map values for NestingMap. While that seems like it should
be right, we should actually be using tuple and object types respectively
to allow each block to have a different runtime type in situations where
an attribute is given the type cty.DynamicPseudoType. That's not fixed
here, and so without a further fix list and map blocks will panic here.
The DecoderSpec implementation will be fixed in a subsequent commit.
This commit is contained in:
Martin Atkins 2018-08-20 18:45:34 -07:00
parent dfe0988f10
commit 0317da9911
5 changed files with 700 additions and 0 deletions

View File

@ -76,6 +76,15 @@ func (b *Block) internalValidate(prefix string, err error) error {
if blockS.MinItems > blockS.MaxItems && blockS.MaxItems != 0 {
err = multierror.Append(err, fmt.Errorf("%s%s: MinItems must be less than or equal to MaxItems in %s mode", prefix, name, blockS.Nesting))
}
if blockS.Nesting == NestingSet {
ety := blockS.Block.ImpliedType()
if ety.HasDynamicTypes() {
// This is not permitted because the HCL (cty) set implementation
// needs to know the exact type of set elements in order to
// properly hash them, and so can't support mixed types.
err = multierror.Append(err, fmt.Errorf("%s%s: NestingSet blocks may not contain attributes of cty.DynamicPseudoType", prefix, name))
}
}
case NestingMap:
if blockS.MinItems != 0 || blockS.MaxItems != 0 {
err = multierror.Append(err, fmt.Errorf("%s%s: MinItems and MaxItems must both be 0 in NestingMap mode", prefix, name))

View File

@ -187,6 +187,42 @@ func TestBlockInternalValidate(t *testing.T) {
},
1, // nested_bad is both required and optional
},
"nested list block with dynamically-typed attribute": {
&Block{
BlockTypes: map[string]*NestedBlock{
"bad": &NestedBlock{
Nesting: NestingList,
Block: Block{
Attributes: map[string]*Attribute{
"nested_bad": &Attribute{
Type: cty.DynamicPseudoType,
Optional: true,
},
},
},
},
},
},
0,
},
"nested set block with dynamically-typed attribute": {
&Block{
BlockTypes: map[string]*NestedBlock{
"bad": &NestedBlock{
Nesting: NestingSet,
Block: Block{
Attributes: map[string]*Attribute{
"nested_bad": &Attribute{
Type: cty.DynamicPseudoType,
Optional: true,
},
},
},
},
},
},
1, // NestingSet blocks may not contain attributes of cty.DynamicPseudoType
},
"nil": {
nil,
1, // block is nil

4
plans/objchange/doc.go Normal file
View File

@ -0,0 +1,4 @@
// Package objchange deals with the business logic of taking a prior state
// value and a config value and producing a proposed new merged value, along
// with other related rules in this domain.
package objchange

View File

@ -0,0 +1,315 @@
package objchange
import (
"fmt"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/configs/configschema"
)
// ProposedNewObject constructs a proposed new object value by combining the
// computed attribute values from "prior" with the configured attribute values
// from "config".
//
// Both value must conform to the given schema's implied type, or this function
// will panic.
//
// The prior value must be wholly known, but the config value may be unknown
// or have nested unknown values.
//
// The merging of the two objects includes the attributes of any nested blocks,
// which will be correlated in a manner appropriate for their nesting mode.
// Note in particular that the correlation for blocks backed by sets is a
// heuristic based on matching non-computed attribute values and so it may
// produce strange results with more "extreme" cases, such as a nested set
// block where _all_ attributes are computed.
func ProposedNewObject(schema *configschema.Block, prior, config cty.Value) cty.Value {
if prior.IsNull() {
// This is the easy case... no prior value to merge, so we can just
// return the config as-is.
return config
}
if config.IsNull() || !config.IsKnown() {
// This is a weird situation, but we'll allow it anyway to free
// callers from needing to specifically check for these cases.
return prior
}
if (!prior.Type().IsObjectType()) || (!config.Type().IsObjectType()) {
panic("ProposedNewObject only supports object-typed values")
}
// From this point onwards, we can assume that both values are non-null
// object types, and that the config value itself is known (though it
// may contain nested values that are unknown.)
newAttrs := map[string]cty.Value{}
for name, attr := range schema.Attributes {
priorV := prior.GetAttr(name)
configV := config.GetAttr(name)
var newV cty.Value
switch {
case attr.Computed && attr.Optional:
// This is the trickiest scenario: we want to keep the prior value
// if the config isn't overriding it. Note that due to some
// ambiguity here, setting an optional+computed attribute from
// config and then later switching the config to null in a
// subsequent change causes the initial config value to be "sticky"
// unless the provider specifically overrides it during its own
// plan customization step.
if configV.IsNull() {
newV = priorV
} else {
newV = configV
}
case attr.Computed:
// configV will always be null in this case, by definition.
// priorV may also be null, but that's okay.
newV = priorV
default:
// For non-computed attributes, we always take the config value,
// even if it is null. If it's _required_ then null values
// should've been caught during an earlier validation step, and
// so we don't really care about that here.
newV = configV
}
newAttrs[name] = newV
}
// Merging nested blocks is a little more complex, since we need to
// correlate blocks between both objects and then recursively propose
// a new object for each. The correlation logic depends on the nesting
// mode for each block type.
for name, blockType := range schema.BlockTypes {
priorV := prior.GetAttr(name)
configV := config.GetAttr(name)
var newV cty.Value
switch blockType.Nesting {
case configschema.NestingSingle:
newV = ProposedNewObject(&blockType.Block, priorV, configV)
case configschema.NestingList:
if !configV.Type().IsTupleType() {
// Despite the name, we expect NestingList to produce a tuple
// type so that different elements may have dynamically-typed
// attributes that have a different actual type.
panic("configschema.NestingList value is not a tuple as expected")
}
// Nested blocks are correlated by index.
if l := configV.LengthInt(); l > 0 {
newVals := make([]cty.Value, 0, l)
for it := configV.ElementIterator(); it.Next(); {
idx, configEV := it.Element()
if !priorV.HasIndex(idx).True() {
// If there is no corresponding prior element then
// we just take the config value as-is.
newVals = append(newVals, configEV)
continue
}
priorEV := priorV.Index(idx)
newEV := ProposedNewObject(&blockType.Block, priorEV, configEV)
newVals = append(newVals, newEV)
}
// Although we call the nesting mode "list", we actually use
// tuple values so that elements might have different types
// in case of dynamically-typed attributes.
newV = cty.TupleVal(newVals)
} else {
newV = cty.EmptyTupleVal
}
case configschema.NestingMap:
if !configV.Type().IsObjectType() {
// Despite the name, we expect NestingMap to produce an object
// type so that different elements may have dynamically-typed
// attributes that have a different actual type.
panic("configschema.NestingMap value is not an object as expected")
}
// Nested blocks are correlated by key.
if l := configV.LengthInt(); l > 0 {
newVals := make(map[string]cty.Value, l)
atys := configV.Type().AttributeTypes()
for name := range atys {
configEV := configV.GetAttr(name)
if !priorV.Type().HasAttribute(name) {
// If there is no corresponding prior element then
// we just take the config value as-is.
newVals[name] = configEV
continue
}
priorEV := priorV.GetAttr(name)
newEV := ProposedNewObject(&blockType.Block, priorEV, configEV)
newVals[name] = newEV
}
// Although we call the nesting mode "map", we actually use
// object values so that elements might have different types
// in case of dynamically-typed attributes.
newV = cty.ObjectVal(newVals)
} else {
newV = cty.EmptyObjectVal
}
case configschema.NestingSet:
if !configV.Type().IsSetType() {
panic("configschema.NestingSet value is not a set as expected")
}
// Nested blocks are correlated by comparing the element values
// after eliminating all of the computed attributes. In practice,
// this means that any config change produces an entirely new
// nested object, and we only propagate prior computed values
// if the non-computed attribute values are identical.
cmpVals := setElementCompareValues(&blockType.Block, priorV, false)
if l := configV.LengthInt(); l > 0 {
used := make([]bool, len(cmpVals)) // track used elements in case multiple have the same compare value
newVals := make([]cty.Value, 0, l)
for it := configV.ElementIterator(); it.Next(); {
_, configEV := it.Element()
var priorEV cty.Value
for i, cmp := range cmpVals {
if used[i] {
continue
}
if cmp[1].RawEquals(configEV) {
priorEV = cmp[0]
used[i] = true // we can't use this value on a future iteration
break
}
}
if priorEV == cty.NilVal {
priorEV = cty.NullVal(blockType.ImpliedType())
}
newEV := ProposedNewObject(&blockType.Block, priorEV, configEV)
newVals = append(newVals, newEV)
}
newV = cty.SetVal(newVals)
} else {
newV = cty.SetValEmpty(blockType.Block.ImpliedType())
}
default:
// Should never happen, since the above cases are comprehensive.
panic(fmt.Sprintf("unsupported block nesting mode %s", blockType.Nesting))
}
newAttrs[name] = newV
}
return cty.ObjectVal(newAttrs)
}
// setElementCompareValues takes a known, non-null value of a cty.Set type and
// returns a table -- constructed of two-element arrays -- that maps original
// set element values to corresponding values that have all of the computed
// values removed, making them suitable for comparison with values obtained
// from configuration. The element type of the set must conform to the implied
// type of the given schema, or this function will panic.
//
// In the resulting slice, the zeroth element of each array is the original
// value and the one-indexed element is the corresponding "compare value".
//
// This is intended to help correlate prior elements with configured elements
// in ProposedNewObject. The result is a heuristic rather than an exact science,
// since e.g. two separate elements may reduce to the same value through this
// process. The caller must therefore be ready to deal with duplicates.
func setElementCompareValues(schema *configschema.Block, set cty.Value, isConfig bool) [][2]cty.Value {
ret := make([][2]cty.Value, 0, set.LengthInt())
for it := set.ElementIterator(); it.Next(); {
_, ev := it.Element()
ret = append(ret, [2]cty.Value{ev, setElementCompareValue(schema, ev, isConfig)})
}
return ret
}
// setElementCompareValue creates a new value that has all of the same
// non-computed attribute values as the one given but has all computed
// attribute values forced to null.
//
// If isConfig is true then non-null Optional+Computed attribute values will
// be preserved. Otherwise, they will also be set to null.
//
// The input value must conform to the schema's implied type, and the return
// value is guaranteed to conform to it.
func setElementCompareValue(schema *configschema.Block, v cty.Value, isConfig bool) cty.Value {
if v.IsNull() || !v.IsKnown() {
return v
}
attrs := map[string]cty.Value{}
for name, attr := range schema.Attributes {
switch {
case attr.Computed && attr.Optional:
if isConfig {
attrs[name] = v.GetAttr(name)
} else {
attrs[name] = cty.NullVal(attr.Type)
}
case attr.Computed:
attrs[name] = cty.NullVal(attr.Type)
default:
attrs[name] = v.GetAttr(name)
}
}
for name, blockType := range schema.BlockTypes {
switch blockType.Nesting {
case configschema.NestingSingle:
attrs[name] = setElementCompareValue(&blockType.Block, v.GetAttr(name), isConfig)
case configschema.NestingList, configschema.NestingSet:
cv := v.GetAttr(name)
if cv.IsNull() || !cv.IsKnown() {
attrs[name] = cv
continue
}
if l := cv.LengthInt(); l > 0 {
elems := make([]cty.Value, 0, l)
for it := cv.ElementIterator(); it.Next(); {
_, ev := it.Element()
elems = append(elems, setElementCompareValue(&blockType.Block, ev, isConfig))
}
if blockType.Nesting == configschema.NestingSet {
// SetValEmpty would panic if given elements that are not
// all of the same type, but that's guaranteed not to
// happen here because our input value was _already_ a
// set and we've not changed the types of any elements here.
attrs[name] = cty.SetVal(elems)
} else {
attrs[name] = cty.TupleVal(elems)
}
} else {
if blockType.Nesting == configschema.NestingSet {
attrs[name] = cty.SetValEmpty(blockType.Block.ImpliedType())
} else {
attrs[name] = cty.EmptyTupleVal
}
}
case configschema.NestingMap:
cv := v.GetAttr(name)
if cv.IsNull() || !cv.IsKnown() {
attrs[name] = cv
continue
}
elems := make(map[string]cty.Value)
for it := cv.ElementIterator(); it.Next(); {
kv, ev := it.Element()
elems[kv.AsString()] = setElementCompareValue(&blockType.Block, ev, isConfig)
}
attrs[name] = cty.ObjectVal(elems)
default:
// Should never happen, since the above cases are comprehensive.
panic(fmt.Sprintf("unsupported block nesting mode %s", blockType.Nesting))
}
}
return cty.ObjectVal(attrs)
}

View File

@ -0,0 +1,336 @@
package objchange
import (
"testing"
"github.com/apparentlymart/go-dump/dump"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/configs/configschema"
)
func TestProposedNewObject(t *testing.T) {
tests := map[string]struct {
Schema *configschema.Block
Prior cty.Value
Config cty.Value
Want cty.Value
}{
"empty": {
&configschema.Block{},
cty.EmptyObjectVal,
cty.EmptyObjectVal,
cty.EmptyObjectVal,
},
"no prior": {
&configschema.Block{
Attributes: map[string]*configschema.Attribute{
"foo": {
Type: cty.String,
Optional: true,
},
"bar": {
Type: cty.String,
Computed: true,
},
},
BlockTypes: map[string]*configschema.NestedBlock{
"baz": {
Nesting: configschema.NestingSingle,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"boz": {
Type: cty.String,
Optional: true,
Computed: true,
},
},
},
},
},
},
cty.NullVal(cty.DynamicPseudoType),
cty.ObjectVal(map[string]cty.Value{
"foo": cty.StringVal("hello"),
"bar": cty.NullVal(cty.String),
"baz": cty.ObjectVal(map[string]cty.Value{
"boz": cty.StringVal("world"),
}),
}),
cty.ObjectVal(map[string]cty.Value{
"foo": cty.StringVal("hello"),
"bar": cty.NullVal(cty.String),
"baz": cty.ObjectVal(map[string]cty.Value{
"boz": cty.StringVal("world"),
}),
}),
},
"prior attributes": {
&configschema.Block{
Attributes: map[string]*configschema.Attribute{
"foo": {
Type: cty.String,
Optional: true,
},
"bar": {
Type: cty.String,
Computed: true,
},
"baz": {
Type: cty.String,
Optional: true,
Computed: true,
},
"boz": {
Type: cty.String,
Optional: true,
Computed: true,
},
},
},
cty.ObjectVal(map[string]cty.Value{
"foo": cty.StringVal("bonjour"),
"bar": cty.StringVal("petit dejeuner"),
"baz": cty.StringVal("grande dejeuner"),
"boz": cty.StringVal("a la monde"),
}),
cty.ObjectVal(map[string]cty.Value{
"foo": cty.StringVal("hello"),
"bar": cty.NullVal(cty.String),
"baz": cty.NullVal(cty.String),
"boz": cty.StringVal("world"),
}),
cty.ObjectVal(map[string]cty.Value{
"foo": cty.StringVal("hello"),
"bar": cty.StringVal("petit dejeuner"),
"baz": cty.StringVal("grande dejeuner"),
"boz": cty.StringVal("world"),
}),
},
"prior nested single": {
&configschema.Block{
BlockTypes: map[string]*configschema.NestedBlock{
"foo": {
Nesting: configschema.NestingSingle,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"bar": {
Type: cty.String,
Optional: true,
Computed: true,
},
"baz": {
Type: cty.String,
Optional: true,
Computed: true,
},
},
},
},
},
},
cty.ObjectVal(map[string]cty.Value{
"foo": cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("beep"),
"baz": cty.StringVal("boop"),
}),
}),
cty.ObjectVal(map[string]cty.Value{
"foo": cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("bap"),
"baz": cty.NullVal(cty.String),
}),
}),
cty.ObjectVal(map[string]cty.Value{
"foo": cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("bap"),
"baz": cty.StringVal("boop"),
}),
}),
},
"prior nested list": {
&configschema.Block{
BlockTypes: map[string]*configschema.NestedBlock{
"foo": {
Nesting: configschema.NestingList,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"bar": {
Type: cty.String,
Optional: true,
Computed: true,
},
"baz": {
Type: cty.String,
Optional: true,
Computed: true,
},
},
},
},
},
},
cty.ObjectVal(map[string]cty.Value{
"foo": cty.TupleVal([]cty.Value{
cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("beep"),
"baz": cty.StringVal("boop"),
}),
}),
}),
cty.ObjectVal(map[string]cty.Value{
"foo": cty.TupleVal([]cty.Value{
cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("bap"),
"baz": cty.NullVal(cty.String),
}),
cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("blep"),
"baz": cty.NullVal(cty.String),
}),
}),
}),
cty.ObjectVal(map[string]cty.Value{
"foo": cty.TupleVal([]cty.Value{
cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("bap"),
"baz": cty.StringVal("boop"),
}),
cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("blep"),
"baz": cty.NullVal(cty.String),
}),
}),
}),
},
"prior nested map": {
&configschema.Block{
BlockTypes: map[string]*configschema.NestedBlock{
"foo": {
Nesting: configschema.NestingMap,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"bar": {
Type: cty.String,
Optional: true,
Computed: true,
},
"baz": {
Type: cty.String,
Optional: true,
Computed: true,
},
},
},
},
},
},
cty.ObjectVal(map[string]cty.Value{
"foo": cty.ObjectVal(map[string]cty.Value{
"a": cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("beep"),
"baz": cty.StringVal("boop"),
}),
"b": cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("blep"),
"baz": cty.StringVal("boot"),
}),
}),
}),
cty.ObjectVal(map[string]cty.Value{
"foo": cty.ObjectVal(map[string]cty.Value{
"a": cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("bap"),
"baz": cty.NullVal(cty.String),
}),
"c": cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("bosh"),
"baz": cty.NullVal(cty.String),
}),
}),
}),
cty.ObjectVal(map[string]cty.Value{
"foo": cty.ObjectVal(map[string]cty.Value{
"a": cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("bap"),
"baz": cty.StringVal("boop"),
}),
"c": cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("bosh"),
"baz": cty.NullVal(cty.String),
}),
}),
}),
},
"prior nested set": {
&configschema.Block{
BlockTypes: map[string]*configschema.NestedBlock{
"foo": {
Nesting: configschema.NestingSet,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"bar": {
// This non-computed attribute will serve
// as our matching key for propagating
// "baz" from elements in the prior value.
Type: cty.String,
Optional: true,
},
"baz": {
Type: cty.String,
Optional: true,
Computed: true,
},
},
},
},
},
},
cty.ObjectVal(map[string]cty.Value{
"foo": cty.SetVal([]cty.Value{
cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("beep"),
"baz": cty.StringVal("boop"),
}),
cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("blep"),
"baz": cty.StringVal("boot"),
}),
}),
}),
cty.ObjectVal(map[string]cty.Value{
"foo": cty.SetVal([]cty.Value{
cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("beep"),
"baz": cty.NullVal(cty.String),
}),
cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("bosh"),
"baz": cty.NullVal(cty.String),
}),
}),
}),
cty.ObjectVal(map[string]cty.Value{
"foo": cty.SetVal([]cty.Value{
cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("beep"),
"baz": cty.StringVal("boop"),
}),
cty.ObjectVal(map[string]cty.Value{
"bar": cty.StringVal("bosh"),
"baz": cty.NullVal(cty.String),
}),
}),
}),
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
got := ProposedNewObject(test.Schema, test.Prior, test.Config)
if !got.RawEquals(test.Want) {
t.Errorf("wrong result\ngot: %swant: %s", dump.Value(got), dump.Value(test.Want))
}
})
}
}