hashicorp / hashicorp/terraform-plugin-framework

Run plan modifiers from bottom to top for nested objects

Open
#974 4 comments 0 reactions 0 assignees View on GitHub
bug
Dominant language
Go
Stars
384
Forks
107
Avg merge
3m
Merged PRs (30d)
1

Description

### Module version

```
v1.7.0
```

### Use-cases
Consider following schema.
```terraform
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
"name": schema.StringAttribute{
Required: true,
},
"cities": schema.SingleNestedAttribute{
Required: true,
PlanModifiers: []planmodifier.Object{objectplanmodifier.UseStateForUnknown(), objectplanmodifier.RequiresReplace()},
Attributes: map[string]schema.Attribute{
"season": schema.StringAttribute{
Required: true,
},
"computed": schema.StringAttribute{
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
Computed: true,
},
},
},
},
}
```

After apply of following config
```terraform
resource "copperfield_tour" "smth" {
name = "john"
cities = {
season = "spring"
}
}
```

I get state:
```terraform
resource "copperfield_tour" "smth" {
id = "123"
name = "john"
cities = {
season = "spring"
computed = "true"
}
}
```

Now I want to update resource with following config:
```terraform
resource "copperfield_tour" "smth" {
name = "bob"
cities = {
season = "spring"
}
}
```

And now terraform wants to **recreate** this reource because of unknown value for computed, however I configured field computed to **use state for unknow**. That is a bit frustrating and unexpected behaviour.
The key is in ths function https://github.com/hashicorp/terraform-plugin-framework/blob/main/internal/fwserver/attribute_plan_modification.go#L85 What it does is that it runs plan modifier for top level object and only after that for nested object, but at that time objectplanmodifier.RequiresReplace() has already marked resource for recreate.

### Attempted Solutions
I would suggest to modify function AttributeModifyPlan so it checks if this object has nested objects and if true applies plan modifiers for nested objects, and only after that proceeds to it's own plan modifiers.

### Proposal

Basically I suggest to put switch case after nested attribute object

```go
func AttributeModifyPlan(ctx context.Context, a fwschema.Attribute, req ModifyAttributePlanRequest, resp *ModifyAttributePlanResponse) {
ctx = logging.FrameworkWithAttributePath(ctx, req.AttributePath.String())

if req.Private != nil {
resp.Private = req.Private
}

// Null and unknown values should not have nested schema to modify.
if nestedAttribute, ok := a.(fwschema.NestedAttribute); ok && !resp.AttributePlan.IsNull() && !resp.AttributePlan.IsUnknown() {

nestedAttributeObject := nestedAttribute.GetNestedObject()

nm := nestedAttribute.GetNestingMode()
switch nm {
case fwschema.NestingModeList:
configList, diags := coerceListValue(ctx, req.AttributePath, req.AttributeConfig)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

// Use response as the planned value may have been modified with list
// plan modifiers.
planListValuable, diags := coerceListValuable(ctx, req.AttributePath, resp.AttributePlan)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

typable, diags := coerceListTypable(ctx, req.AttributePath, planListValuable)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

planList, diags := planListValuable.ToListValue(ctx)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

stateList, diags := coerceListValue(ctx, req.AttributePath, req.AttributeState)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

planElements := planList.Elements()

for idx, planElem := range planElements {
attrPath := req.AttributePath.AtListIndex(idx)

configObject, diags := listElemObject(ctx, attrPath, configList, idx, fwschemadata.DataDescriptionConfiguration)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

planObject, diags := coerceObjectValue(ctx, attrPath, planElem)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

planObjectValuable, diags := coerceObjectValuable(ctx, attrPath, planElem)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

typable, diags := coerceObjectTypable(ctx, attrPath, planObjectValuable)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

stateObject, diags := listElemObject(ctx, attrPath, stateList, idx, fwschemadata.DataDescriptionState)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

objectReq := planmodifier.ObjectRequest{
Config: req.Config,
ConfigValue: configObject,
Path: attrPath,
PathExpression: attrPath.Expression(),
Plan: req.Plan,
PlanValue: planObject,
Private: resp.Private,
State: req.State,
StateValue: stateObject,
}
objectResp := &ModifyAttributePlanResponse{
AttributePlan: objectReq.PlanValue,
Private: objectReq.Private,
}

NestedAttributeObjectPlanModify(ctx, nestedAttributeObject, objectReq, objectResp)

respValue, diags := coerceObjectValue(ctx, attrPath, objectResp.AttributePlan)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

// A custom value type must be returned in the final response to prevent
// later correctness errors.
// Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/821
respValuable, diags := typable.ValueFromObject(ctx, respValue)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

planElements[idx] = respValuable
resp.Diagnostics.Append(objectResp.Diagnostics...)
resp.Private = objectResp.Private
resp.RequiresReplace.Append(objectResp.RequiresReplace...)
}

respValue, diags := types.ListValue(planList.ElementType(ctx), planElements)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

// A custom value type must be returned in the final response to prevent
// later correctness errors.
// Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/754
respValuable, diags := typable.ValueFromList(ctx, respValue)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

resp.AttributePlan = respValuable
case fwschema.NestingModeSet:
configSet, diags := coerceSetValue(ctx, req.AttributePath, req.AttributeConfig)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

// Use response as the planned value may have been modified with set
// plan modifiers.
planSetValuable, diags := coerceSetValuable(ctx, req.AttributePath, resp.AttributePlan)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

typable, diags := coerceSetTypable(ctx, req.AttributePath, planSetValuable)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

planSet, diags := planSetValuable.ToSetValue(ctx)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

stateSet, diags := coerceSetValue(ctx, req.AttributePath, req.AttributeState)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

planElements := planSet.Elements()

for idx, planElem := range planElements {
attrPath := req.AttributePath.AtSetValue(planElem)

configObject, diags := setElemObject(ctx, attrPath, configSet, idx, fwschemadata.DataDescriptionConfiguration)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

planObject, diags := coerceObjectValue(ctx, attrPath, planElem)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

planObjectValuable, diags := coerceObjectValuable(ctx, attrPath, planElem)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

typable, diags := coerceObjectTypable(ctx, attrPath, planObjectValuable)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

stateObject, diags := setElemObject(ctx, attrPath, stateSet, idx, fwschemadata.DataDescriptionState)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

objectReq := planmodifier.ObjectRequest{
Config: req.Config,
ConfigValue: configObject,
Path: attrPath,
PathExpression: attrPath.Expression(),
Plan: req.Plan,
PlanValue: planObject,
Private: resp.Private,
State: req.State,
StateValue: stateObject,
}
objectResp := &ModifyAttributePlanResponse{
AttributePlan: objectReq.PlanValue,
Private: objectReq.Private,
}

NestedAttributeObjectPlanModify(ctx, nestedAttributeObject, objectReq, objectResp)

respValue, diags := coerceObjectValue(ctx, attrPath, objectResp.AttributePlan)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

// A custom value type must be returned in the final response to prevent
// later correctness errors.
// Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/821
respValuable, diags := typable.ValueFromObject(ctx, respValue)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

planElements[idx] = respValuable
resp.Diagnostics.Append(objectResp.Diagnostics...)
resp.Private = objectResp.Private
resp.RequiresReplace.Append(objectResp.RequiresReplace...)
}

respValue, diags := types.SetValue(planSet.ElementType(ctx), planElements)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

// A custom value type must be returned in the final response to prevent
// later correctness errors.
// Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/754
respValuable, diags := typable.ValueFromSet(ctx, respValue)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

resp.AttributePlan = respValuable
case fwschema.NestingModeMap:
configMap, diags := coerceMapValue(ctx, req.AttributePath, req.AttributeConfig)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

// Use response as the planned value may have been modified with map
// plan modifiers.
planMapValuable, diags := coerceMapValuable(ctx, req.AttributePath, resp.AttributePlan)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

typable, diags := coerceMapTypable(ctx, req.AttributePath, planMapValuable)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

planMap, diags := planMapValuable.ToMapValue(ctx)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

stateMap, diags := coerceMapValue(ctx, req.AttributePath, req.AttributeState)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

planElements := planMap.Elements()

for key, planElem := range planElements {
attrPath := req.AttributePath.AtMapKey(key)

configObject, diags := mapElemObject(ctx, attrPath, configMap, key, fwschemadata.DataDescriptionConfiguration)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

planObject, diags := coerceObjectValue(ctx, attrPath, planElem)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

planObjectValuable, diags := coerceObjectValuable(ctx, attrPath, planElem)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

typable, diags := coerceObjectTypable(ctx, attrPath, planObjectValuable)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

stateObject, diags := mapElemObject(ctx, attrPath, stateMap, key, fwschemadata.DataDescriptionState)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

objectReq := planmodifier.ObjectRequest{
Config: req.Config,
ConfigValue: configObject,
Path: attrPath,
PathExpression: attrPath.Expression(),
Plan: req.Plan,
PlanValue: planObject,
Private: resp.Private,
State: req.State,
StateValue: stateObject,
}
objectResp := &ModifyAttributePlanResponse{
AttributePlan: objectReq.PlanValue,
Private: objectReq.Private,
}

NestedAttributeObjectPlanModify(ctx, nestedAttributeObject, objectReq, objectResp)

respValue, diags := coerceObjectValue(ctx, attrPath, objectResp.AttributePlan)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

// A custom value type must be returned in the final response to prevent
// later correctness errors.
// Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/821
respValuable, diags := typable.ValueFromObject(ctx, respValue)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

planElements[key] = respValuable
resp.Diagnostics.Append(objectResp.Diagnostics...)
resp.Private = objectResp.Private
resp.RequiresReplace.Append(objectResp.RequiresReplace...)
}

respValue, diags := types.MapValue(planMap.ElementType(ctx), planElements)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

// A custom value type must be returned in the final response to prevent
// later correctness errors.
// Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/754
respValuable, diags := typable.ValueFromMap(ctx, respValue)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

resp.AttributePlan = respValuable
case fwschema.NestingModeSingle:
configObject, diags := coerceObjectValue(ctx, req.AttributePath, req.AttributeConfig)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

// Use response as the planned value may have been modified with object
// plan modifiers.
planObjectValuable, diags := coerceObjectValuable(ctx, req.AttributePath, resp.AttributePlan)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

typable, diags := coerceObjectTypable(ctx, req.AttributePath, planObjectValuable)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

planObject, diags := planObjectValuable.ToObjectValue(ctx)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

stateObject, diags := coerceObjectValue(ctx, req.AttributePath, req.AttributeState)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

objectReq := planmodifier.ObjectRequest{
Config: req.Config,
ConfigValue: configObject,
Path: req.AttributePath,
PathExpression: req.AttributePathExpression,
Plan: req.Plan,
PlanValue: planObject,
Private: resp.Private,
State: req.State,
StateValue: stateObject,
}
objectResp := &ModifyAttributePlanResponse{
AttributePlan: objectReq.PlanValue,
Private: objectReq.Private,
}

NestedAttributeObjectPlanModify(ctx, nestedAttributeObject, objectReq, objectResp)

resp.Diagnostics.Append(objectResp.Diagnostics...)
resp.Private = objectResp.Private
resp.RequiresReplace.Append(objectResp.RequiresReplace...)

respValue, diags := coerceObjectValue(ctx, req.AttributePath, objectResp.AttributePlan)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

// A custom value type must be returned in the final response to prevent
// later correctness errors.
// Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/754
respValuable, diags := typable.ValueFromObject(ctx, respValue)

resp.Diagnostics.Append(diags...)

if resp.Diagnostics.HasError() {
return
}

resp.AttributePlan = respValuable
default:
err := fmt.Errorf("unknown attribute nesting mode (%T: %v) at path: %s", nm, nm, req.AttributePath)
resp.Diagnostics.AddAttributeError(
req.AttributePath,
"Attribute Plan Modification Error",
"Attribute plan modifier cannot walk schema. Report this to the provider developer:\n\n"+err.Error(),
)

return
}
}

switch attributeWithPlanModifiers := a.(type) {
case fwxschema.AttributeWithBoolPlanModifiers:
AttributePlanModifyBool(ctx, attributeWithPlanModifiers, req, resp)
case fwxschema.AttributeWithFloat64PlanModifiers:
AttributePlanModifyFloat64(ctx, attributeWithPlanModifiers, req, resp)
case fwxschema.AttributeWithInt64PlanModifiers:
AttributePlanModifyInt64(ctx, attributeWithPlanModifiers, req, resp)
case fwxschema.AttributeWithListPlanModifiers:
AttributePlanModifyList(ctx, attributeWithPlanModifiers, req, resp)
case fwxschema.AttributeWithMapPlanModifiers:
AttributePlanModifyMap(ctx, attributeWithPlanModifiers, req, resp)
case fwxschema.AttributeWithNumberPlanModifiers:
AttributePlanModifyNumber(ctx, attributeWithPlanModifiers, req, resp)
case fwxschema.AttributeWithObjectPlanModifiers:
AttributePlanModifyObject(ctx, attributeWithPlanModifiers, req, resp)
case fwxschema.AttributeWithSetPlanModifiers:
AttributePlanModifySet(ctx, attributeWithPlanModifiers, req, resp)
case fwxschema.AttributeWithStringPlanModifiers:
AttributePlanModifyString(ctx, attributeWithPlanModifiers, req, resp)
case fwxschema.AttributeWithDynamicPlanModifiers:
AttributePlanModifyDynamic(ctx, attributeWithPlanModifiers, req, resp)
}

if resp.Diagnostics.HasError() {
return
}
}
```
### References

- #[644](https://github.com/hashicorp/terraform-plugin-framework/issues/644)

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.