opentofu/plans/objchange/objchange.go
2020-10-15 21:21:14 -04:00

410 lines
14 KiB
Go

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 the config and prior are both null, return early here before
// populating the prior block. The prevents non-null blocks from appearing
// the proposed state value.
if config.IsNull() && prior.IsNull() {
return prior
}
if prior.IsNull() {
// In this case, we will construct a synthetic prior value that is
// similar to the result of decoding an empty configuration block,
// which simplifies our handling of the top-level attributes/blocks
// below by giving us one non-null level of object to pull values from.
prior = AllAttributesNull(schema)
}
return proposedNewObject(schema, prior, config)
}
// PlannedDataResourceObject is similar to ProposedNewObject but tailored for
// planning data resources in particular. Specifically, it replaces the values
// of any Computed attributes not set in the configuration with an unknown
// value, which serves as a placeholder for a value to be filled in by the
// provider when the data resource is finally read.
//
// Data resources are different because the planning of them is handled
// entirely within Terraform Core and not subject to customization by the
// provider. This function is, in effect, producing an equivalent result to
// passing the ProposedNewObject result into a provider's PlanResourceChange
// function, assuming a fixed implementation of PlanResourceChange that just
// fills in unknown values as needed.
func PlannedDataResourceObject(schema *configschema.Block, config cty.Value) cty.Value {
// Our trick here is to run the ProposedNewObject logic with an
// entirely-unknown prior value. Because of cty's unknown short-circuit
// behavior, any operation on prior returns another unknown, and so
// unknown values propagate into all of the parts of the resulting value
// that would normally be filled in by preserving the prior state.
prior := cty.UnknownVal(schema.ImpliedType())
return proposedNewObject(schema, prior, config)
}
func proposedNewObject(schema *configschema.Block, prior, config cty.Value) cty.Value {
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, configschema.NestingGroup:
newV = ProposedNewObject(&blockType.Block, priorV, configV)
case configschema.NestingList:
// Nested blocks are correlated by index.
configVLen := 0
if configV.IsKnown() && !configV.IsNull() {
configVLen = configV.LengthInt()
}
if configVLen > 0 {
newVals := make([]cty.Value, 0, configVLen)
for it := configV.ElementIterator(); it.Next(); {
idx, configEV := it.Element()
if priorV.IsKnown() && (priorV.IsNull() || !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)
}
// Despite the name, a NestingList might also be a tuple, if
// its nested schema contains dynamically-typed attributes.
if configV.Type().IsTupleType() {
newV = cty.TupleVal(newVals)
} else {
newV = cty.ListVal(newVals)
}
} else {
// Despite the name, a NestingList might also be a tuple, if
// its nested schema contains dynamically-typed attributes.
if configV.Type().IsTupleType() {
newV = cty.EmptyTupleVal
} else {
newV = cty.ListValEmpty(blockType.ImpliedType())
}
}
case configschema.NestingMap:
// Despite the name, a NestingMap may produce either a map or
// object value, depending on whether the nested schema contains
// dynamically-typed attributes.
if configV.Type().IsObjectType() {
// Nested blocks are correlated by key.
configVLen := 0
if configV.IsKnown() && !configV.IsNull() {
configVLen = configV.LengthInt()
}
if configVLen > 0 {
newVals := make(map[string]cty.Value, configVLen)
atys := configV.Type().AttributeTypes()
for name := range atys {
configEV := configV.GetAttr(name)
if !priorV.IsKnown() || priorV.IsNull() || !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
}
} else {
configVLen := 0
if configV.IsKnown() && !configV.IsNull() {
configVLen = configV.LengthInt()
}
if configVLen > 0 {
newVals := make(map[string]cty.Value, configVLen)
for it := configV.ElementIterator(); it.Next(); {
idx, configEV := it.Element()
k := idx.AsString()
if priorV.IsKnown() && (priorV.IsNull() || !priorV.HasIndex(idx).True()) {
// If there is no corresponding prior element then
// we just take the config value as-is.
newVals[k] = configEV
continue
}
priorEV := priorV.Index(idx)
newEV := ProposedNewObject(&blockType.Block, priorEV, configEV)
newVals[k] = newEV
}
newV = cty.MapVal(newVals)
} else {
newV = cty.MapValEmpty(blockType.ImpliedType())
}
}
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.
var cmpVals [][2]cty.Value
if priorV.IsKnown() && !priorV.IsNull() {
cmpVals = setElementCompareValues(&blockType.Block, priorV, false)
}
configVLen := 0
if configV.IsKnown() && !configV.IsNull() {
configVLen = configV.LengthInt()
}
if configVLen > 0 {
used := make([]bool, len(cmpVals)) // track used elements in case multiple have the same compare value
newVals := make([]cty.Value, 0, configVLen)
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 {
elementType := blockType.Block.ImpliedType()
switch blockType.Nesting {
case configschema.NestingSingle, configschema.NestingGroup:
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))
}
switch {
case 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)
// NestingList cases
case elementType.HasDynamicTypes():
attrs[name] = cty.TupleVal(elems)
default:
attrs[name] = cty.ListVal(elems)
}
} else {
switch {
case blockType.Nesting == configschema.NestingSet:
attrs[name] = cty.SetValEmpty(elementType)
// NestingList cases
case elementType.HasDynamicTypes():
attrs[name] = cty.EmptyTupleVal
default:
attrs[name] = cty.ListValEmpty(elementType)
}
}
case configschema.NestingMap:
cv := v.GetAttr(name)
if cv.IsNull() || !cv.IsKnown() || cv.LengthInt() == 0 {
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)
}
switch {
case elementType.HasDynamicTypes():
attrs[name] = cty.ObjectVal(elems)
default:
attrs[name] = cty.MapVal(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)
}