diff --git a/helper/plugin/grpc_provider.go b/helper/plugin/grpc_provider.go index b1b4b5d3d..85e1a3d75 100644 --- a/helper/plugin/grpc_provider.go +++ b/helper/plugin/grpc_provider.go @@ -1125,22 +1125,34 @@ func normalizeFlatmapContainers(prior map[string]string, attrs map[string]string // the Private/Meta fields. we need to copy those values into the planned state // so that core doesn't see a perpetual diff with the timeout block. func copyTimeoutValues(to cty.Value, from cty.Value) cty.Value { - // if `from` is null, then there are no attributes, and if `to` is null we - // are planning to remove it altogether. - if from.IsNull() || to.IsNull() { + // if `to` is null we are planning to remove it altogether. + if to.IsNull() { return to } + toAttrs := to.AsValueMap() + // We need to remove the key since the hcl2shims will add a non-null block + // because we can't determine if a single block was null from the flatmapped + // values. This needs to conform to the correct schema for marshaling, so + // change the value to null rather than deleting it from the object map. + timeouts, ok := toAttrs[schema.TimeoutsConfigKey] + if ok { + toAttrs[schema.TimeoutsConfigKey] = cty.NullVal(timeouts.Type()) + } + + // if from is null then there are no timeouts to copy + if from.IsNull() { + return cty.ObjectVal(toAttrs) + } fromAttrs := from.AsValueMap() - timeouts, ok := fromAttrs[schema.TimeoutsConfigKey] + timeouts, ok = fromAttrs[schema.TimeoutsConfigKey] - // no timeouts to copy - // timeouts shouldn't be unknown, but don't copy possibly invalid values + // timeouts shouldn't be unknown, but don't copy possibly invalid values either if !ok || timeouts.IsNull() || !timeouts.IsWhollyKnown() { - return to + // no timeouts block to copy + return cty.ObjectVal(toAttrs) } - toAttrs := to.AsValueMap() toAttrs[schema.TimeoutsConfigKey] = timeouts return cty.ObjectVal(toAttrs) diff --git a/plans/objchange/objchange.go b/plans/objchange/objchange.go index 15565a400..ed0f4ba5e 100644 --- a/plans/objchange/objchange.go +++ b/plans/objchange/objchange.go @@ -25,6 +25,13 @@ import ( // 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, diff --git a/plans/objchange/objchange_test.go b/plans/objchange/objchange_test.go index 91f9df1f2..97f749aca 100644 --- a/plans/objchange/objchange_test.go +++ b/plans/objchange/objchange_test.go @@ -83,6 +83,45 @@ func TestProposedNewObject(t *testing.T) { }), }), }, + "null block remains null": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: 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("bar"), + "baz": cty.NullVal(cty.Object(map[string]cty.Type{ + "boz": cty.String, + })), + }), + // The baz block does not exist in the config, and therefore + // shouldn't be planned. + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + "baz": cty.NullVal(cty.Object(map[string]cty.Type{ + "boz": cty.String, + })), + }), + }, "no prior with set": { // This one is here because our handling of sets is more complex // than others (due to the fuzzy correlation heuristic) and