Computed field goes default (empty string) when calling update

Hello !

I’ve recently been developing a custom provider for my own API and went through a problem that I can’t overcome. It happened when creating a resource, my resource structure has computed fields inside that are holding other resource ids. Everything looks good, the create, read and delete are working properly but when I want to update my resource these steps happen :

  • 1: my provider update the state whit the new values including the computed property id (which is set to empty string because it is not set in the .tf files as is is computed)
  • 2: it sends the update request to my API with an empty string instead of the id that was present in the state before the update
  • 3: my API answer an error because id is empty

I won’t provide you the whole resource schema as it is extremely huge but here is one of the problematic parts :

func resourceContentDataMarkdown() *schema.Resource {
	return &schema.Resource{
		Schema: map[string]*schema.Schema{
			"id": {
				Type:        schema.TypeString,
				Computed:    true,
				Description: "The id of the component",
			},
			"position": {
				Type:        schema.TypeInt,
				Required:    true,
				Description: "The position where the component will be rendered",
				ValidateFunc: func(i interface{}, s string) ([]string, []error) {
					if i.(int) < 0 {
						return nil, []error{fmt.Errorf("position must be a positive integer")}
					}
					return nil, nil
				},
			},
			"content": {
				Type:        schema.TypeString,
				Required:    true,
				Description: "The content of the markdown",
			},
		},
	}
}

This is just a part of a more complex schema but here you can find the id computed property.

Thank you by advance for your returns !

Roxxas96

To be clearer on what I want to do: I want to add a property on my resource that is updated only when calling read or after (and not before) calling create or update.

What I understood is that Terraform updates the state with your modifications before calling your UpdateContext function (causing the empty string on id field).

Hi @Roxxas96 :wave: Thank you for raising this and sorry you ran into trouble here. Are you able to show your Update function by chance and can you confirm that the resource does not have CustomizeDiff defined?

The terraform-plugin-sdk provider development framework should not be touching the id attribute value unless there is explicit logic which modifies it. It generally makes best effort attempts at copying any request information into the response to prevent data loss otherwise.

Just to ensure we’re on the same page, here is the default sequence of provider RPCs and their associated SDK locations when a resource is refreshed, planned, and applied with in-place update:

  • GetProviderSchema / helper/schema.Resource.Schema
  • ConfigureProvider / (provider level)
  • ValidateResourceConfig / helper/schema.Resource.Schema sub fields such as Required, ValidateFunc, etc.
  • ReadResource / helper/schema.Resource.ReadContext
  • PlanResourceChange / helper/schema.Resource.CustomizeDiff
  • ApplyResourceChange / helper/schema.Resource.UpdateContext

Additional Terraform documentation on the resource instance lifecycle can be found at: terraform/resource-instance-change-lifecycle.md at main · hashicorp/terraform · GitHub

Hi @bflad ! Thank you for you explanations, I confirm that I don’t have CustomizeDiff defined and here is my UpdateFunction :

func resourceContentUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
	var diags diag.Diagnostics

	c := m.(*pc.Client)

	childComponents, err := serializeChildComponents(d.Get("container.0").(map[string]interface{}), ctx)
	if err != nil {
		diags = append(diags, diag.Diagnostic{
			Severity: diag.Error,
			Summary:  "Unable to serialize child components",
			Detail:   fmt.Sprintf("Error when serializing child components: %s", err.Error()),
		})
		return diags
	}

	co := content.Content{
		ID:          d.Id(),
		Name:        d.Get("name").(string),
		Description: d.Get("description").(string),
		Type:        d.Get("type").(string),
		Reward:      int64(d.Get("reward").(int)),
		RootComponent: content.Component{
			ID:          d.Get("container.0.id").(string),
			Orientation: d.Get("container.0.orientation").(string),
			Type:        "container",
			Data: content.ComponentData{
				Components: childComponents,
			},
		},
		Data: content.ContentData{},
	}

	_, err = c.UpdateContent(co)
	if err != nil {
		diags = append(diags, diag.Diagnostic{
			Severity: diag.Error,
			Summary:  "Unable to update content",
			Detail:   fmt.Sprintf("Error when updating content: %s", err.Error()),
		})
		return diags
	}

	d.Set("last_update", d.Set("last_updated", time.Now().Format(time.RFC850)))

	tflog.Info(ctx, fmt.Sprintf("Updated Content %s", d.Id()))

	return resourceContentRead(ctx, d, m)
}

To completely understand it you’ll need the serializeChildComponents function which is a little bit complicated and translate the schema to a struct :

func serializeChildComponents(rootComponent map[string]interface{}, ctx context.Context) ([]content.Component, error) {
	length := 0
	for key, val := range rootComponent {
		if key == "markdown" || key == "editor" {
			length += len(val.(*schema.Set).List())
		}
		if key == "container" && val != nil {
			length += len(val.([]interface{}))
		}
	}

	tflog.Debug(ctx, fmt.Sprintf("Serializing root Component %s with %d child components", rootComponent["id"], length))

	childComponents := make([]content.Component, length)
	positions := make([]bool, length)

	for key, val := range rootComponent {
		switch key {
		case "markdown":
			for _, v := range val.(*schema.Set).List() {
				markdown := v.(map[string]interface{})

				position := markdown["position"].(int)
				if position > length {
					return nil, fmt.Errorf("position %d is greater than the number of child components %d", position, length)
				}
				positions[position-1] = true

				childComponents[position-1] = content.Component{
					ID:   markdown["id"].(string),
					Type: "markdown",
					Data: content.ComponentData{
						Markdown: markdown["content"].(string),
					},
				}
			}
		case "editor":
			for _, v := range val.(*schema.Set).List() {
				editor := v.(map[string]interface{})

				languages := make([]content.Language, 0)
				for _, language := range editor["language_settings"].(*schema.Set).List() {
					languages = append(languages, content.Language{
						DefaultCode: language.(map[string]interface{})["default_code"].(string),
						Language:    language.(map[string]interface{})["language"].(string),
						Version:     language.(map[string]interface{})["version"].(string),
					})
				}

				validators := make([]content.Validator, 0)
				for _, validator := range editor["validator"].(*schema.Set).List() {
					inputs := make([]string, len(validator.(map[string]interface{})["inputs"].([]interface{})))
					for key, val := range validator.(map[string]interface{})["inputs"].([]interface{}) {
						inputs[key] = val.(string)
					}
					outputs := make([]string, len(validator.(map[string]interface{})["outputs"].([]interface{})))
					for key, val := range validator.(map[string]interface{})["outputs"].([]interface{}) {
						outputs[key] = val.(string)
					}

					validators = append(validators, content.Validator{
						ID:       validator.(map[string]interface{})["id"].(string),
						IsHidden: validator.(map[string]interface{})["is_hidden"].(bool),
						Input: content.ValidatorInput{
							Stdin: inputs,
						},
						Output: content.ValidatorOutput{
							Stdout: outputs,
						},
					})
				}

				hints := make([]content.ItemIdentifier, 0)
				for _, item := range editor["hint"].([]interface{}) {
					hints = append(hints, content.ItemIdentifier{
						ID: item.(string),
					})
				}

				position := editor["position"].(int)
				if position > length {
					return nil, fmt.Errorf("position %d is greater than the number of child components %d", position, length)
				}
				positions[position-1] = true

				childComponents[position-1] = content.Component{
					ID:   editor["id"].(string),
					Type: "editor",
					Data: content.ComponentData{
						EditorSettings: content.EditorSettings{
							Languages: languages,
						},
						Validators: validators,
						Items:      hints,
					},
				}
			}
		case "container":
			for _, v := range val.([]interface{}) {
				container := v.(map[string]interface{})

				position := container["position"].(int)
				if position > length {
					return nil, fmt.Errorf("position %d is greater than the number of child components %d", position, length)
				}
				positions[position-1] = true

				containerChildComponents, err := serializeChildComponents(container, ctx)
				if err != nil {
					return nil, err
				}

				childComponents[position-1] = content.Component{
					ID:   container["id"].(string),
					Type: "container",
					Data: content.ComponentData{
						Components: containerChildComponents,
					},
					Orientation: container["orientation"].(string),
				}
			}
		}
	}

	for i, position := range positions {
		if !position {
			return nil, fmt.Errorf("child component at position %d is missing, this is probably due to duplicate position in the container, please check that your positions go from 1 to %d", i+1, length)
		}
	}

	return childComponents, nil
}

In fact, after some explanation, the problem that I’m facing may be related to this function instead of the Update process of Terraform.

Thank you for your help !

In fact, after some tests, it’s not my serialize function. The weird behavior is that the state sets id to empty string before calling Update (and so serialize too) so the problem is before :sweat_smile:

I’m running into this issue too. The computed id field of a nested object is unknown in the plan on update. The strange thing is, that this does work in my unit tests. It’s just failing in the acceptance tests.

The only obvious difference between them is, that I use a muxed provider in the acceptance tests.

I could fix this by adding:

				PlanModifiers: tfsdk.AttributePlanModifiers{
					resource.UseStateForUnknown(),
				},

Ho ok ! I see.
The solution I was able to find on my side is to override the state in my Update function by calling another read :

func resourceContentUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
        resourceContentRead()

	var diags diag.Diagnostics

	c := m.(*pc.Client)

	childComponents, err := serializeChildComponents(d.Get("container.0").(map[string]interface{}), ctx)

But this solution adds another call to the API and your’s is more optimized, thank you !