-
Notifications
You must be signed in to change notification settings - Fork 9.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Plan correct optional and computed attributes in nested objects and sets #32536
Changes from all commits
93f739e
e661e91
986127e
ac4f5fe
048ee34
470ed22
7ca9abe
e16b848
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -110,7 +110,6 @@ func proposedNewNestedBlock(schema *configschema.NestedBlock, prior, config cty. | |
var newV cty.Value | ||
|
||
switch schema.Nesting { | ||
|
||
case configschema.NestingSingle: | ||
// A NestingSingle configuration block value can be null, and since it | ||
// cannot be computed we can always take the configuration value. | ||
|
@@ -234,7 +233,7 @@ func proposedNewNestedBlock(schema *configschema.NestedBlock, prior, config cty. | |
// if the non-computed attribute values are identical. | ||
var cmpVals [][2]cty.Value | ||
if prior.IsKnown() && !prior.IsNull() { | ||
cmpVals = setElementCompareValues(&schema.Block, prior, false) | ||
cmpVals = setElementCompareValues(&schema.Block, prior) | ||
} | ||
configVLen := 0 | ||
if config.IsKnown() && !config.IsNull() { | ||
|
@@ -250,6 +249,7 @@ func proposedNewNestedBlock(schema *configschema.NestedBlock, prior, config cty. | |
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 | ||
|
@@ -286,64 +286,58 @@ func proposedNewAttributes(attrs map[string]*configschema.Attribute, prior, conf | |
} | ||
|
||
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: | ||
// required isn't considered when constructing the plan, so attributes | ||
// are essentially either computed or not computed. In the case of | ||
// optional+computed, they are only computed when there is no | ||
// configuration. | ||
case attr.Computed && configV.IsNull(): | ||
// configV will always be null in this case, by definition. | ||
// priorV may also be null, but that's okay. | ||
newV = priorV | ||
case attr.NestedType != nil: | ||
// For non-computed NestedType attributes, we need to descend | ||
// into the individual nested attributes to build the final | ||
// value, unless the entire nested attribute is unknown. | ||
newV = proposedNewNestedType(attr.NestedType, priorV, configV) | ||
default: | ||
if attr.NestedType != nil { | ||
// For non-computed NestedType attributes, we need to descend | ||
// into the individual nested attributes to build the final | ||
// value, unless the entire nested attribute is unknown. | ||
if !configV.IsKnown() { | ||
newV = configV | ||
} else { | ||
newV = proposedNewNestedType(attr.NestedType, priorV, configV) | ||
} | ||
} else { | ||
// 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 | ||
} | ||
// 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 | ||
} | ||
return newAttrs | ||
} | ||
|
||
func proposedNewNestedType(schema *configschema.Object, prior, config cty.Value) cty.Value { | ||
// If the config is null or empty, we will be using this default value. | ||
// if the config isn't known at all, then we must use that value | ||
if !config.IsNull() && !config.IsKnown() { | ||
return config | ||
} | ||
|
||
// Even if the config is null or empty, we will be using this default value. | ||
newV := config | ||
|
||
switch schema.Nesting { | ||
case configschema.NestingSingle: | ||
if !config.IsNull() { | ||
newV = cty.ObjectVal(proposedNewAttributes(schema.Attributes, prior, config)) | ||
} else { | ||
newV = cty.NullVal(config.Type()) | ||
// If the config is null, we already have our value. If the attribute | ||
// is optional+computed, we won't reach this branch with a null value | ||
// since the computed case would have been taken. | ||
if config.IsNull() { | ||
break | ||
} | ||
|
||
newV = cty.ObjectVal(proposedNewAttributes(schema.Attributes, prior, config)) | ||
|
||
case configschema.NestingList: | ||
// Nested blocks are correlated by index. | ||
configVLen := 0 | ||
if config.IsKnown() && !config.IsNull() { | ||
if !config.IsNull() { | ||
configVLen = config.LengthInt() | ||
} | ||
|
||
|
@@ -434,7 +428,7 @@ func proposedNewNestedType(schema *configschema.Object, prior, config cty.Value) | |
// if the non-computed attribute values are identical. | ||
var cmpVals [][2]cty.Value | ||
if prior.IsKnown() && !prior.IsNull() { | ||
cmpVals = setElementCompareValuesFromObject(schema, prior) | ||
cmpVals = setElementCompareValues(schema, prior) | ||
} | ||
configVLen := 0 | ||
if config.IsKnown() && !config.IsNull() { | ||
|
@@ -484,165 +478,43 @@ func proposedNewNestedType(schema *configschema.Object, prior, config cty.Value) | |
// in proposedNewBlock. 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 { | ||
func setElementCompareValues(schema attrPath, set cty.Value) [][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)}) | ||
ret = append(ret, [2]cty.Value{ev, setElementComputedAsNull(schema, ev)}) | ||
} | ||
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 | ||
} | ||
type attrPath interface { | ||
AttributeByPath(cty.Path) *configschema.Attribute | ||
} | ||
|
||
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.ImpliedType()) | ||
} | ||
case attr.Computed: | ||
attrs[name] = cty.NullVal(attr.ImpliedType()) | ||
default: | ||
attrs[name] = v.GetAttr(name) | ||
func setElementComputedAsNull(schema attrPath, elem cty.Value) cty.Value { | ||
elem, _ = cty.Transform(elem, func(path cty.Path, v cty.Value) (cty.Value, error) { | ||
if v.IsNull() || !v.IsKnown() { | ||
return v, nil | ||
} | ||
} | ||
|
||
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)) | ||
attr := schema.AttributeByPath(path) | ||
if attr == nil { | ||
// we must be at a nested block rather than an attribute, continue | ||
return v, nil | ||
} | ||
} | ||
|
||
return cty.ObjectVal(attrs) | ||
} | ||
|
||
// 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 proposedNewBlock. 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 setElementCompareValuesFromObject(schema *configschema.Object, set cty.Value) [][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, setElementCompareValueFromObject(schema, ev)}) | ||
} | ||
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. | ||
// | ||
// The input value must conform to the schema's implied type, and the return | ||
// value is guaranteed to conform to it. | ||
func setElementCompareValueFromObject(schema *configschema.Object, v cty.Value) cty.Value { | ||
if v.IsNull() || !v.IsKnown() { | ||
return v | ||
} | ||
attrs := map[string]cty.Value{} | ||
|
||
for name, attr := range schema.Attributes { | ||
attrV := v.GetAttr(name) | ||
switch { | ||
case attr.Computed: | ||
attrs[name] = cty.NullVal(attr.Type) | ||
default: | ||
attrs[name] = attrV | ||
if attr.Computed { | ||
// If this could have been computed, we always return null in order | ||
// to compare config and state values. | ||
// | ||
// The only way to determine if an optional+computed value is | ||
// optional or computed is see if it is set in the config, however | ||
// for set elements we don't yet know which element value | ||
// corresponds to the configuration. | ||
return cty.NullVal(v.Type()), nil | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The code just returns null for any computed value with disregard whether it's specified in config or not (in case it's both computed and optional). That means that the comparison with config returns false and the proposed new state is set to config instead of the prior state ( There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That is intended. This transformation is used to create value to attempt and correlate config with prior state within a set, it is not directly used as a plan value. Because we don't know what the original config value was, we cannot tell if the prior value was computed or not, so we must assume it was computed. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It leads to dirty plans for unchanged configurations with sets that have underlying computed and optional attributes, so sets cannot be used in these cases. At least with the current framework implementation. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That is correct. If there are no stable attributes which can correlate configuration to prior state, then you are not going to get a very useful proposed plan. Set elements are identified by their value, so if a set element could have all computed attributes there's no way to determine if it could have originated from the given configuration or not (the simpler example is where an optional+computed value is removed from the configuration and Terraform cannot tell if it was originally computed or not, but made worse by being included within a set). Using computed values at all within a set would be unusual unless you have very specific needs and fully understand all the implications when trying to plan changes to the values. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, extra changes are expected, but this should not cause "dirty plans" when there are no changes. I thought I confirmed tests for the no-changes case, but let me double check on that, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The test fails if we make the optional attribute to be both optional and computed - There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @dimuon, yes, that's what I meant above by there being no non-computed attributes which can be used to correlate config and state values. This is just inherent in trying to use optional+computed values within a set, because we cannot always determine whether a value was generated from config or not. There is no correct value to send in this case, and either will result in false positives, but we happen to send the config when it exists (in most cases, I have some The specific behavior BTW is captured in this test. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jbardin , I see your point. IMO the current design means that set works as expected only if it contains no more than one computed and optional underlying attributes. Once set contains 2 or more such attributes, it can lead to "dirty plan" for configurations with no change (e.g. when configuration defines one of these optional and computed fields and doesn't set any value for the second and state contains a computed value the second attribute). Please correct that if my conclusion is wrong. Regarding fixing the behavior in the provider. It looks like the default way to do this is plan modifiers. But if the resource is big, the provider has to provide plan modifies for every computed field (or a plan modifier for the whole resource) but writing a plan modifier can be tricky for complex resources because plan modifier should understand relationship between different fields to make a correct decision (e.g. whether it should use the current state of fallback to I'm not sure whether the implementation can be improved though. Can we skip nullifying computed attributes before comparing them to config but not take computed attribute into comparison if state has some value for it and config doesn't? Anyway, @jbardin , thank you for the interesting discussion. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @dimuon, IMHO optional+computed should be used sparingly, and in hindsight probably should not have been allowed within set types, but that was decided long ago ;) The problem with your suggestion lies in the fact that sets are unordered data types indexed only by the element value itself, so when the value in config and the value in state are not exactly equal, we cannot tell "if state has some value for it and config doesn't", because we don't know if those were logically the same value in the first place. I do have another idea for a comparison method I'm going test out later (this PR was mainly aimed at fixing errors in the current behavior, not changing the behavior). While it's not a solvable problem, we may be able to do the comparison using the lifecycle rules that a provider must abide by to try and determine which values may share a common lineage. I don't know yet if that will result in plans looking more closely like the common cases providers expect, or if we're going to generate different changes breaking existing providers. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FYI most of this PR will be superseded by a later PR that is going to test a new set comparison method which can check for most of these edge cases. |
||
} | ||
} | ||
return v, nil | ||
}) | ||
|
||
return cty.ObjectVal(attrs) | ||
return elem | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This refers to "non-computed NestedType attributes", but the case statement doesn't test for
attr.Computed
. Am I misunderstanding or should this readcase !attr.Computed && attr.NestedType != nil
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's effectively non-computed because the config must be non-null, which is check in the case above. If it were optional+computed being treated as computed it's still caught in the first case.