Force new resource based on API/Read difference

I’m working on a Terraform 1.0 provider plugin with terraform-plugin-sdk v1.17.2, and have a situation where the remote resource can have its status change to “gone”, meaning it’s been deleted outside of Terraform, and so would like to make Terraform replace it with a new resource, without performing terraform state rm first.

I’ve started down the path of writing a CustomizeDiff function:

func resourceFooBarCustomizeDiff(ctx context.Context, diff *schema.ResourceDiff, v interface{}) error {
	oldStatus, newStatus := diff.GetChange("status")

	// When status is "gone", assume the resource is deleted and force recreation.
	if oldStatus != newStatus && newStatus == "gone" {
		if err := diff.ForceNew("status"); err != nil {
			return fmt.Errorf("Error forcing foo bar resource: %s", err)
		}
	}

	return nil
}

For the “resource is gone” condition, this results in the plan recognizing the diff, but not actually forcing a new resource:

Terraform detected the following changes made outside of Terraform since the
last "terraform apply":

  # foo_bar.default has been changed
  ~ resource "foo_bar" "default" {
        id                         = "123456789"
        name                       = "test-instance"
      ~ status                     = "active" -> "gone"
        # (14 unchanged attributes hidden)
    }

Unless you have made equivalent changes to your configuration, or ignored the
relevant attributes using ignore_changes, the following plan may include
actions to undo or respond to these changes.


─────────────────────────────────────────────────────────────────────────────

No changes. Your infrastructure matches the configuration.

Your configuration already matches the changes detected above. If you'd like
to update the Terraform state to match, create and apply a refresh-only plan:
  terraform apply -refresh-only

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

How could I make this attribute change (config drift) actually force a new resource? Am I way off-base with this approach, or does this just need some tweaking?

Thanks for any guidance you can offer :blush:
*Mars

Returning to the simplest solution, I removed all the CustomizeDiff logic, and simply added ForceNew: true to the schema for this attribute:

			"status": {
				Type:     schema.TypeString,
				Computed: true,
				ForceNew: true,
			},

It once again detects a change, but does not actually seem to force a recreation:

 # foo_bar.default has been changed
  ~ resource "foo_bar" "default" {
        id                         = "123456789"
        name                       = "test-instance"
      ~ status                     = "active" -> "gone"
        # (14 unchanged attributes hidden)
    }

Unless you have made equivalent changes to your configuration, or ignored the
relevant attributes using ignore_changes, the following plan may include
actions to undo or respond to these changes.

─────────────────────────────────────────────────────────────────────────────

No changes. Your infrastructure matches the configuration.

Your configuration already matches the changes detected above. If you'd like
to update the Terraform state to match, create and apply a refresh-only plan:
  terraform apply -refresh-only

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Why doesn’t the change to the ForceNew: true attribute actually cause recreation of the resource? Is it because it’s computed?

Hi @mars,

Based on what you’ve described here, I’m assuming that this status attribute is one whose value is chosen by the provider rather than by the user, and so there isn’t any explicit status = "active" written in the configuration. Instead, that value was chosen by the provider during the previous run, and Terraform is preserving it in the state and returning it back to the provider during the refresh step.

In that case, I think one problem with the logic you presented is the test for oldStatus != newStatus, because by the time we’re working on planning (when CustomizeDiff will be called) the “old status” has already been updated to match the result of your “read” function, and so oldStatus will already be "gone". newStatus doesn’t really have any useful meaning here, if this is a value not chosen in the configuration, because “new” values always come from the configuration. (The primary purpose of planning is to decide how to react to differences between configuration and current state.)

Given that, I think I’d start here by removing that condition and instead only checking if oldStatus == "gone" as the predicate for deciding to call diff.ForceNew.

However, it seems like that alone would just cause the provider to return that the planned new object is identical to the prior object (unless the user happened to have changed the configuration somehow) and so you might also need to change the status value in order to make Terraform see that a change is needed. It doesn’t really matter what you set it to, so I’d probably use SetNewComputed to indicate that the new value isn’t known yet:

diff.SetNewComputed("status")

The way Terraform Core handles replacement is a bit special when compared to other planned actions: after your provider signals that the object must be replaced, Terraform will then call your provider’s planning function a second time while pretending that this is a new object being created, and so that second call is the one that will actually provide the “new” values to be shown in the diff, and thus the status change Terraform presents will show the status that the new object will have, rather than the status of the old object.

The “requires replace” mechanism was mainly designed to respond to configuration changes rather than remote changes, so things do admittedly get a bit clunky here when you’re trying to signal “requires replace” for something the provider is entirely controlling itself. However, I think it should be possible with an approach like I’ve described above.

I hope this helps!

diff.SetNewComputed() solved the problem :tada:

Thank you @apparentlymart for the thoughtful & helpful reply :raised_hands::grinning_face_with_smiling_eyes:


Here’s exactly how that solved it…

With the status attribute schema set to:

"status": {
	Type:     schema.TypeString,
	Computed: true,
	ForceNew: true,
},

…now the CustomizeDiff function looks like this:

func resourceFooBarCustomizeDiff(ctx context.Context, diff *schema.ResourceDiff, v interface{}) error {
	_, newStatus := diff.GetChange("status")

	// When status is "gone", assume the resource is deleted and force recreation.
	// Workaround that ForceNew causes a second diff, where this change would no longer be present.
	if newStatus == "gone" {
		diff.SetNewComputed("status")
	}

	return nil
}

Great! I’m glad it worked out.

I think what worked for you here is honestly more an accident of the implementation than intentional design – ForceNew is intended for treating changes to the configuration as requiring replacement – but I guess it’s working because the SDK happens to run the ForceNew logic after the CustomizeDiff call, and so it is able to react to the fact that status has a change "gone" -> (known after apply) even though that change was caused by the CustomizeDiff function, rather than by the configuration.

In any event, that ended up even simpler than I expected, so thanks for sharing the final solution!

1 Like

Just a follow-up, since I no-longer seem to be able to edit my post:

This was using terraform-plugin-sdk v2.7.0, not an earlier v1 as I originally indicated.