github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/plans/objchange/objchange.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package objchange
     5  
     6  import (
     7  	"errors"
     8  	"fmt"
     9  
    10  	"github.com/zclconf/go-cty/cty"
    11  
    12  	"github.com/terramate-io/tf/configs/configschema"
    13  )
    14  
    15  // ProposedNew constructs a proposed new object value by combining the
    16  // computed attribute values from "prior" with the configured attribute values
    17  // from "config".
    18  //
    19  // Both value must conform to the given schema's implied type, or this function
    20  // will panic.
    21  //
    22  // The prior value must be wholly known, but the config value may be unknown
    23  // or have nested unknown values.
    24  //
    25  // The merging of the two objects includes the attributes of any nested blocks,
    26  // which will be correlated in a manner appropriate for their nesting mode.
    27  // Note in particular that the correlation for blocks backed by sets is a
    28  // heuristic based on matching non-computed attribute values and so it may
    29  // produce strange results with more "extreme" cases, such as a nested set
    30  // block where _all_ attributes are computed.
    31  func ProposedNew(schema *configschema.Block, prior, config cty.Value) cty.Value {
    32  	// If the config and prior are both null, return early here before
    33  	// populating the prior block. The prevents non-null blocks from appearing
    34  	// the proposed state value.
    35  	if config.IsNull() && prior.IsNull() {
    36  		return prior
    37  	}
    38  
    39  	if prior.IsNull() {
    40  		// In this case, we will construct a synthetic prior value that is
    41  		// similar to the result of decoding an empty configuration block,
    42  		// which simplifies our handling of the top-level attributes/blocks
    43  		// below by giving us one non-null level of object to pull values from.
    44  		//
    45  		// "All attributes null" happens to be the definition of EmptyValue for
    46  		// a Block, so we can just delegate to that
    47  		prior = schema.EmptyValue()
    48  	}
    49  	return proposedNew(schema, prior, config)
    50  }
    51  
    52  // PlannedDataResourceObject is similar to proposedNewBlock but tailored for
    53  // planning data resources in particular. Specifically, it replaces the values
    54  // of any Computed attributes not set in the configuration with an unknown
    55  // value, which serves as a placeholder for a value to be filled in by the
    56  // provider when the data resource is finally read.
    57  //
    58  // Data resources are different because the planning of them is handled
    59  // entirely within Terraform Core and not subject to customization by the
    60  // provider. This function is, in effect, producing an equivalent result to
    61  // passing the proposedNewBlock result into a provider's PlanResourceChange
    62  // function, assuming a fixed implementation of PlanResourceChange that just
    63  // fills in unknown values as needed.
    64  func PlannedDataResourceObject(schema *configschema.Block, config cty.Value) cty.Value {
    65  	// Our trick here is to run the proposedNewBlock logic with an
    66  	// entirely-unknown prior value. Because of cty's unknown short-circuit
    67  	// behavior, any operation on prior returns another unknown, and so
    68  	// unknown values propagate into all of the parts of the resulting value
    69  	// that would normally be filled in by preserving the prior state.
    70  	prior := cty.UnknownVal(schema.ImpliedType())
    71  	return proposedNew(schema, prior, config)
    72  }
    73  
    74  func proposedNew(schema *configschema.Block, prior, config cty.Value) cty.Value {
    75  	if config.IsNull() || !config.IsKnown() {
    76  		// A block config should never be null at this point. The only nullable
    77  		// block type is NestingSingle, which will return early before coming
    78  		// back here. We'll allow the null here anyway to free callers from
    79  		// needing to specifically check for these cases, and any mismatch will
    80  		// be caught in validation, so just take the prior value rather than
    81  		// the invalid null.
    82  		return prior
    83  	}
    84  
    85  	if (!prior.Type().IsObjectType()) || (!config.Type().IsObjectType()) {
    86  		panic("ProposedNew only supports object-typed values")
    87  	}
    88  
    89  	// From this point onwards, we can assume that both values are non-null
    90  	// object types, and that the config value itself is known (though it
    91  	// may contain nested values that are unknown.)
    92  	newAttrs := proposedNewAttributes(schema.Attributes, prior, config)
    93  
    94  	// Merging nested blocks is a little more complex, since we need to
    95  	// correlate blocks between both objects and then recursively propose
    96  	// a new object for each. The correlation logic depends on the nesting
    97  	// mode for each block type.
    98  	for name, blockType := range schema.BlockTypes {
    99  		priorV := prior.GetAttr(name)
   100  		configV := config.GetAttr(name)
   101  		newAttrs[name] = proposedNewNestedBlock(blockType, priorV, configV)
   102  	}
   103  
   104  	return cty.ObjectVal(newAttrs)
   105  }
   106  
   107  // proposedNewBlockOrObject dispatched the schema to either ProposedNew or
   108  // proposedNewObjectAttributes depending on the given type.
   109  func proposedNewBlockOrObject(schema nestedSchema, prior, config cty.Value) cty.Value {
   110  	switch schema := schema.(type) {
   111  	case *configschema.Block:
   112  		return ProposedNew(schema, prior, config)
   113  	case *configschema.Object:
   114  		return proposedNewObjectAttributes(schema, prior, config)
   115  	default:
   116  		panic(fmt.Sprintf("unexpected schema type %T", schema))
   117  	}
   118  }
   119  
   120  func proposedNewNestedBlock(schema *configschema.NestedBlock, prior, config cty.Value) cty.Value {
   121  	// The only time we should encounter an entirely unknown block is from the
   122  	// use of dynamic with an unknown for_each expression.
   123  	if !config.IsKnown() {
   124  		return config
   125  	}
   126  
   127  	newV := config
   128  
   129  	switch schema.Nesting {
   130  	case configschema.NestingSingle:
   131  		// A NestingSingle configuration block value can be null, and since it
   132  		// cannot be computed we can always take the configuration value.
   133  		if config.IsNull() {
   134  			break
   135  		}
   136  
   137  		// Otherwise use the same assignment rules as NestingGroup
   138  		fallthrough
   139  	case configschema.NestingGroup:
   140  		newV = ProposedNew(&schema.Block, prior, config)
   141  
   142  	case configschema.NestingList:
   143  		newV = proposedNewNestingList(&schema.Block, prior, config)
   144  
   145  	case configschema.NestingMap:
   146  		newV = proposedNewNestingMap(&schema.Block, prior, config)
   147  
   148  	case configschema.NestingSet:
   149  		newV = proposedNewNestingSet(&schema.Block, prior, config)
   150  
   151  	default:
   152  		// Should never happen, since the above cases are comprehensive.
   153  		panic(fmt.Sprintf("unsupported block nesting mode %s", schema.Nesting))
   154  	}
   155  
   156  	return newV
   157  }
   158  
   159  func proposedNewNestedType(schema *configschema.Object, prior, config cty.Value) cty.Value {
   160  	// if the config isn't known at all, then we must use that value
   161  	if !config.IsKnown() {
   162  		return config
   163  	}
   164  
   165  	// Even if the config is null or empty, we will be using this default value.
   166  	newV := config
   167  
   168  	switch schema.Nesting {
   169  	case configschema.NestingSingle:
   170  		// If the config is null, we already have our value. If the attribute
   171  		// is optional+computed, we won't reach this branch with a null value
   172  		// since the computed case would have been taken.
   173  		if config.IsNull() {
   174  			break
   175  		}
   176  
   177  		newV = proposedNewObjectAttributes(schema, prior, config)
   178  
   179  	case configschema.NestingList:
   180  		newV = proposedNewNestingList(schema, prior, config)
   181  
   182  	case configschema.NestingMap:
   183  		newV = proposedNewNestingMap(schema, prior, config)
   184  
   185  	case configschema.NestingSet:
   186  		newV = proposedNewNestingSet(schema, prior, config)
   187  
   188  	default:
   189  		// Should never happen, since the above cases are comprehensive.
   190  		panic(fmt.Sprintf("unsupported attribute nesting mode %s", schema.Nesting))
   191  	}
   192  
   193  	return newV
   194  }
   195  
   196  func proposedNewNestingList(schema nestedSchema, prior, config cty.Value) cty.Value {
   197  	newV := config
   198  
   199  	// Nested blocks are correlated by index.
   200  	configVLen := 0
   201  	if !config.IsNull() {
   202  		configVLen = config.LengthInt()
   203  	}
   204  	if configVLen > 0 {
   205  		newVals := make([]cty.Value, 0, configVLen)
   206  		for it := config.ElementIterator(); it.Next(); {
   207  			idx, configEV := it.Element()
   208  			if prior.IsKnown() && (prior.IsNull() || !prior.HasIndex(idx).True()) {
   209  				// If there is no corresponding prior element then
   210  				// we just take the config value as-is.
   211  				newVals = append(newVals, configEV)
   212  				continue
   213  			}
   214  			priorEV := prior.Index(idx)
   215  
   216  			newVals = append(newVals, proposedNewBlockOrObject(schema, priorEV, configEV))
   217  		}
   218  		// Despite the name, a NestingList might also be a tuple, if
   219  		// its nested schema contains dynamically-typed attributes.
   220  		if config.Type().IsTupleType() {
   221  			newV = cty.TupleVal(newVals)
   222  		} else {
   223  			newV = cty.ListVal(newVals)
   224  		}
   225  	}
   226  
   227  	return newV
   228  }
   229  
   230  func proposedNewNestingMap(schema nestedSchema, prior, config cty.Value) cty.Value {
   231  	newV := config
   232  
   233  	newVals := map[string]cty.Value{}
   234  
   235  	if config.IsNull() || !config.IsKnown() || config.LengthInt() == 0 {
   236  		// We already assigned newVal and there's nothing to compare in
   237  		// config.
   238  		return newV
   239  	}
   240  	cfgMap := config.AsValueMap()
   241  
   242  	// prior may be null or empty
   243  	priorMap := map[string]cty.Value{}
   244  	if !prior.IsNull() && prior.IsKnown() && prior.LengthInt() > 0 {
   245  		priorMap = prior.AsValueMap()
   246  	}
   247  
   248  	for name, configEV := range cfgMap {
   249  		priorEV, inPrior := priorMap[name]
   250  		if !inPrior {
   251  			// If there is no corresponding prior element then
   252  			// we just take the config value as-is.
   253  			newVals[name] = configEV
   254  			continue
   255  		}
   256  
   257  		newVals[name] = proposedNewBlockOrObject(schema, priorEV, configEV)
   258  	}
   259  
   260  	// The value must leave as the same type it came in as
   261  	switch {
   262  	case config.Type().IsObjectType():
   263  		// Although we call the nesting mode "map", we actually use
   264  		// object values so that elements might have different types
   265  		// in case of dynamically-typed attributes.
   266  		newV = cty.ObjectVal(newVals)
   267  	default:
   268  		newV = cty.MapVal(newVals)
   269  	}
   270  
   271  	return newV
   272  }
   273  
   274  func proposedNewNestingSet(schema nestedSchema, prior, config cty.Value) cty.Value {
   275  	if !config.Type().IsSetType() {
   276  		panic("configschema.NestingSet value is not a set as expected")
   277  	}
   278  
   279  	newV := config
   280  	if !config.IsKnown() || config.IsNull() || config.LengthInt() == 0 {
   281  		return newV
   282  	}
   283  
   284  	var priorVals []cty.Value
   285  	if prior.IsKnown() && !prior.IsNull() {
   286  		priorVals = prior.AsValueSlice()
   287  	}
   288  
   289  	var newVals []cty.Value
   290  	// track which prior elements have been used
   291  	used := make([]bool, len(priorVals))
   292  
   293  	for _, configEV := range config.AsValueSlice() {
   294  		var priorEV cty.Value
   295  		for i, priorCmp := range priorVals {
   296  			if used[i] {
   297  				continue
   298  			}
   299  
   300  			// It is possible that multiple prior elements could be valid
   301  			// matches for a configuration value, in which case we will end up
   302  			// picking the first match encountered (but it will always be
   303  			// consistent due to cty's iteration order). Because configured set
   304  			// elements must also be entirely unique in order to be included in
   305  			// the set, these matches either will not matter because they only
   306  			// differ by computed values, or could not have come from a valid
   307  			// config with all unique set elements.
   308  			if validPriorFromConfig(schema, priorCmp, configEV) {
   309  				priorEV = priorCmp
   310  				used[i] = true
   311  				break
   312  			}
   313  		}
   314  
   315  		if priorEV == cty.NilVal {
   316  			priorEV = cty.NullVal(config.Type().ElementType())
   317  		}
   318  
   319  		newVals = append(newVals, proposedNewBlockOrObject(schema, priorEV, configEV))
   320  	}
   321  
   322  	return cty.SetVal(newVals)
   323  }
   324  
   325  func proposedNewObjectAttributes(schema *configschema.Object, prior, config cty.Value) cty.Value {
   326  	if config.IsNull() {
   327  		return config
   328  	}
   329  
   330  	return cty.ObjectVal(proposedNewAttributes(schema.Attributes, prior, config))
   331  }
   332  
   333  func proposedNewAttributes(attrs map[string]*configschema.Attribute, prior, config cty.Value) map[string]cty.Value {
   334  	newAttrs := make(map[string]cty.Value, len(attrs))
   335  	for name, attr := range attrs {
   336  		var priorV cty.Value
   337  		if prior.IsNull() {
   338  			priorV = cty.NullVal(prior.Type().AttributeType(name))
   339  		} else {
   340  			priorV = prior.GetAttr(name)
   341  		}
   342  
   343  		configV := config.GetAttr(name)
   344  
   345  		var newV cty.Value
   346  		switch {
   347  		// required isn't considered when constructing the plan, so attributes
   348  		// are essentially either computed or not computed. In the case of
   349  		// optional+computed, they are only computed when there is no
   350  		// configuration.
   351  		case attr.Computed && configV.IsNull():
   352  			// configV will always be null in this case, by definition.
   353  			// priorV may also be null, but that's okay.
   354  			newV = priorV
   355  
   356  			// the exception to the above is that if the config is optional and
   357  			// the _prior_ value contains non-computed values, we can infer
   358  			// that the config must have been non-null previously.
   359  			if optionalValueNotComputable(attr, priorV) {
   360  				newV = configV
   361  			}
   362  
   363  		case attr.NestedType != nil:
   364  			// For non-computed NestedType attributes, we need to descend
   365  			// into the individual nested attributes to build the final
   366  			// value, unless the entire nested attribute is unknown.
   367  			newV = proposedNewNestedType(attr.NestedType, priorV, configV)
   368  		default:
   369  			// For non-computed attributes, we always take the config value,
   370  			// even if it is null. If it's _required_ then null values
   371  			// should've been caught during an earlier validation step, and
   372  			// so we don't really care about that here.
   373  			newV = configV
   374  		}
   375  		newAttrs[name] = newV
   376  	}
   377  	return newAttrs
   378  }
   379  
   380  // nestedSchema is used as a generic container for either a
   381  // *configschema.Object, or *configschema.Block.
   382  type nestedSchema interface {
   383  	AttributeByPath(cty.Path) *configschema.Attribute
   384  }
   385  
   386  // optionalValueNotComputable is used to check if an object in state must
   387  // have at least partially come from configuration. If the prior value has any
   388  // non-null attributes which are not computed in the schema, then we know there
   389  // was previously a configuration value which set those.
   390  //
   391  // This is used when the configuration contains a null optional+computed value,
   392  // and we want to know if we should plan to send the null value or the prior
   393  // state.
   394  func optionalValueNotComputable(schema *configschema.Attribute, val cty.Value) bool {
   395  	if !schema.Optional {
   396  		return false
   397  	}
   398  
   399  	// We must have a NestedType for complex nested attributes in order
   400  	// to find nested computed values in the first place.
   401  	if schema.NestedType == nil {
   402  		return false
   403  	}
   404  
   405  	foundNonComputedAttr := false
   406  	cty.Walk(val, func(path cty.Path, v cty.Value) (bool, error) {
   407  		if v.IsNull() {
   408  			return true, nil
   409  		}
   410  
   411  		attr := schema.NestedType.AttributeByPath(path)
   412  		if attr == nil {
   413  			return true, nil
   414  		}
   415  
   416  		if !attr.Computed {
   417  			foundNonComputedAttr = true
   418  			return false, nil
   419  		}
   420  		return true, nil
   421  	})
   422  
   423  	return foundNonComputedAttr
   424  }
   425  
   426  // validPriorFromConfig returns true if the prior object could have been
   427  // derived from the configuration. We do this by walking the prior value to
   428  // determine if it is a valid superset of the config, and only computable
   429  // values have been added. This function is only used to correlated
   430  // configuration with possible valid prior values within sets.
   431  func validPriorFromConfig(schema nestedSchema, prior, config cty.Value) bool {
   432  	if unrefinedValue(config).RawEquals(unrefinedValue(prior)) {
   433  		return true
   434  	}
   435  
   436  	// error value to halt the walk
   437  	stop := errors.New("stop")
   438  
   439  	valid := true
   440  	cty.Walk(prior, func(path cty.Path, priorV cty.Value) (bool, error) {
   441  		configV, err := path.Apply(config)
   442  		if err != nil {
   443  			// most likely dynamic objects with different types
   444  			valid = false
   445  			return false, stop
   446  		}
   447  
   448  		// we don't need to know the schema if both are equal
   449  		if unrefinedValue(configV).RawEquals(unrefinedValue(priorV)) {
   450  			// we know they are equal, so no need to descend further
   451  			return false, nil
   452  		}
   453  
   454  		// We can't descend into nested sets to correlate configuration, so the
   455  		// overall values must be equal.
   456  		if configV.Type().IsSetType() {
   457  			valid = false
   458  			return false, stop
   459  		}
   460  
   461  		attr := schema.AttributeByPath(path)
   462  		if attr == nil {
   463  			// Not at a schema attribute, so we can continue until we find leaf
   464  			// attributes.
   465  			return true, nil
   466  		}
   467  
   468  		// If we have nested object attributes we'll be descending into those
   469  		// to compare the individual values and determine why this level is not
   470  		// equal
   471  		if attr.NestedType != nil {
   472  			return true, nil
   473  		}
   474  
   475  		// This is a leaf attribute, so it must be computed in order to differ
   476  		// from config.
   477  		if !attr.Computed {
   478  			valid = false
   479  			return false, stop
   480  		}
   481  
   482  		// And if it is computed, the config must be null to allow a change.
   483  		if !configV.IsNull() {
   484  			valid = false
   485  			return false, stop
   486  		}
   487  
   488  		// We sill stop here. The cty value could be far larger, but this was
   489  		// the last level of prescribed schema.
   490  		return false, nil
   491  	})
   492  
   493  	return valid
   494  }