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 }