Repository: hashicorp/terraform (Go)

Issue:
prevent lifecycle destroy breaks a plan output, rendering diagnosis very difficult.
Terraform 0.13.7 - but I believe it affects all later versions.

I did some state file moves to prepare for a refactor moving resources from one module to another, and now when I plan, I see this error:

```
Resource
module.aws_account["foo"].aws_organizations_account.this
has lifecycle.prevent_destroy set, but the plan calls for this resource to be
destroyed. To avoid this error and continue with the plan, either disable
lifecycle.prevent_destroy or reduce the scope of the plan using the -target
flag.
```

This is an error which prevents the displaying of a plan altogether, stopping the plan in its tracks. This is unfortunately because if I have made mistakes in my state resources moves, and for some reason they don't match the codebase, I now have no way of working out what the problem is on account of the prevent lifecycle destroy.

What I would need in this situation is for terraform to actually output the plan so I can see if and where there is a mismatch between the actual resources and the renamed resources in the statefile. I am unable to remove the lifecycle prevent_destroy stanzas because it would require an apply to fix, and this would require a plan, and the plan wont output because of this error. 

I am left to my own devices to figure out where/how I have a mismatch, as far as I can tell my codebase should produce an identically named resource so this shouldnt be occurring, but since terraform errors, I know this isnt the case, but there is no way of finding out why, other than analysing the codebase in minute detail.

What I would like to see
------------------------

1. Prevent destroy causing an error at runtime and not at plantime - OR
2. Errors at plantime still resulting in a readable plan, albeit one that cant be applied - to better aid diagnosis.

Thanks.

Terraform plan doesn't give changes going to get applied and just failed when resource is about to be replaced due to lifecycle.prevent_destroy
### Terraform Version

```shell
Terraform v1.5.5
```


### Terraform Configuration Files

```terraform
...terraform config...
```
Refer to step to reproduce

### Debug Output

Terraform error out like below
```
azurerm_resource_group.rg: Refreshing state... [id=/subscriptions/<subscriptionsid>/resourceGroups/<rgname>]
azurerm_storage_account.example: Refreshing state... [id=/subscriptions/<subscriptionsid>/resourceGroups/<rgname>/providers/Microsoft.Storage/storageAccounts/salifecycleissuetest]

Planning failed. Terraform encountered an error while generating this plan.

╷
│ Error: Instance cannot be destroyed
│
│   on main.tf line 53:
│   53: resource "azurerm_storage_account" "example" {
│
│ Resource azurerm_storage_account.example has lifecycle.prevent_destroy set, but the plan calls for this resource to be destroyed. To avoid this error and continue with the plan,       
│ either disable lifecycle.prevent_destroy or reduce the scope of the plan using the -target flag.
```

### Expected Behavior

Terraform plan should give output on why the resource is about to get destroyed.

### Actual Behavior

Terraform error out like below

```
azurerm_resource_group.rg: Refreshing state... [id=/subscriptions/<subscriptionsid>/resourceGroups/<rgname>]
azurerm_storage_account.example: Refreshing state... [id=/subscriptions/<subscriptionsid>/resourceGroups/<rgname>/providers/Microsoft.Storage/storageAccounts/salifecycleissuetest]

Planning failed. Terraform encountered an error while generating this plan.

╷
│ Error: Instance cannot be destroyed
│
│   on main.tf line 53:
│   53: resource "azurerm_storage_account" "example" {
│
│ Resource azurerm_storage_account.example has lifecycle.prevent_destroy set, but the plan calls for this resource to be destroyed. To avoid this error and continue with the plan,       
│ either disable lifecycle.prevent_destroy or reduce the scope of the plan using the -target flag.
```

### Steps to Reproduce

Run ```terraform init``` and ```terraform apply``` for below terraform code

```
terraform {
  backend "local" {
  }

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "3.44.1"
    }
  }

}

provider "azurerm" {
  features {
    key_vault {
      purge_soft_delete_on_destroy    = true
      recover_soft_deleted_key_vaults = true
    }
    resource_group {
      prevent_deletion_if_contains_resources = false
    }
  }
}

locals {
  default_tags = {
    tag='tagvalue'
  }

  location            = "eastus"
  resource_group_name = "rgname"
  
}

resource "azurerm_resource_group" "rg" {
  name     = local.resource_group_name
  location = local.location

  tags = local.default_tags
}

resource "azurerm_storage_account" "example" {
  name                     = "salifecycleissuetest"
  resource_group_name      = azurerm_resource_group.rg.name
  location                 = azurerm_resource_group.rg.location
  account_tier             = "Standard"
  account_replication_type = "GRS"

  lifecycle {
    prevent_destroy = true
  }

  tags = local.default_tags
}
```

Once resource is created add below line for ```azurerm_storage_account```

  infrastructure_encryption_enabled = true

Run ```terrafrom apply``` and you will get below error


### Additional Context

nothing

### References

_No response_


Discussion:
What I need to answer is the question WHY to this part of the error:

```
but the plan calls for this resource to be destroyed
```

Why is the plan in my case calling for the resource to be destroyed - is it because the resource is named something different? Or because an attribute is forcing a recreation which is forcing a destroy+create, which is blocked by the prevent_destroy?

There is no way of knowing from terraform. I have to puzzle it out myself by directly manually comparing state file with codebase.
Thanks for the request! I've added it to the list to triage.  
Hi @gtmtech! Thanks for sharing this feedback.

I think what you've described here is the same problem that #16392 was describing. That's a pretty old issue at this point, but it does describe the same problem you've described here.

It has honestly been a while since we spent time on the `prevent_destroy` feature. Indeed, it hasn't really changed significantly since we originally added it, and I think we've come to recognize is as a design mistake and in retrospect we regret not spending more time designing it before adding it.

We do agree that there's a real use-case here for declaring that a particular object ought to have some additional "friction" when deleting it, such as if it's a stateful object that would be difficult or impossible to recreate, but it also seems clear that the `prevent_destroy` doesn't really meet that need, and that we probably need an entirely new feature that is better suited to the use-case. (Lots of folks have shared possible ideas in that direction in the other issue, though most of them seem to have other drawbacks; it doesn't seem like there's an "easy answer".)

We'll continue to use that other issue to represent that need, and post updates there when we have them, though I want to be up front that nobody is currently working on design work there and we'll likely need to do some non-trivial R&D before we'd be able to pick that up again, because it's been a long time.

In the meantime, we've typically been suggesting that folks rely instead on features of their underlying cloud platform to meet similar use-cases. For example, for an object in AWS it's typically possible to write an IAM policy that allows for modification but not for destruction of a particular object, in which case the additional "friction" to delete comes in the form of first changing the policy to permit the deletion. That approach is also superior to `prevent_destroy` in a number of other ways; for example, because it's being enforced by the remote system rather than by Terraform itself it can make the stronger guarantee of not deleting the object even if the entire `resource` block were moved from the Terraform configuration, whereas with `prevent_destroy` that would also simultaneously remove that argument and therefore defeat the mechanism altogether.

---

_With all of that said_ though, I think you're right that it would be reasonable for Terraform to produce at least a partial plan result even when encountering this error, and so we can use this particular issue to represent that shorter-term idea, just in case implementing that is more straightforward than all of what I described above.

I suspect that to be true, but we'll still need to do some more research to be sure. In particular, Terraform Core is currently built in such a way that _any_ error immediately halts the planning process without actually producing a plan, and so unfortunately to achieve this I think we'll need to rework how exactly this particular error condition works. Perhaps Terraform Core would no longer treat it as an error diagnostic at all and instead somehow mark _the specific actions in the plan_ as invalid (in a machine-readable way), leaving the frontend UI to be responsible for annotating the invalid actions for the human user, and the apply step to be responsible for noticing the invalid actions and refusing to take any of the actions described in the plan.

+1
At some point in the last few minor releases -- unfortunately I don't recall which -- we changed `terraform plan` and `terraform apply` to be able to return a partial plan if the planning process produced an error partway through, at least in most cases where Terraform Core is still able to proceed far enough to produce a valid partial plan data structure.

This specific error seems like one that should be supported under that new behavior. I would expect the output in this case, for recent Terraform versions, to start by saying something like "Terraform planned the following actions, but then encountered an error:" and then list out whatever it was able to successfully plan before reaching the resource that had `prevent_destroy` set, and then it will append the error.

That behavior was one of the outcomes listed under "What I would like to see", so if we can reproduce that it is indeed behaving that way in modern Terraform then perhaps this issue is complete. If not, then hopefully we can figure out what's preventing Terraform from using that behavior for this particular error message and fix it so that it will behave as I described; the necessary mechanics should already be in place so I would expect this to only change the fine details.

I am also having issues with lifecycle event like below
https://github.com/hashicorp/terraform/issues/34305
I'm going to lock this issue because it has been closed for _30 days_ ⏳. This helps our maintainers find and focus on the active issues.
If you have found a problem that seems similar to this, please open a new issue and complete the issue template so we can capture all the details necessary to investigate further.

Duplicate of #30271
I'm going to lock this issue because it has been closed for _30 days_ ⏳. This helps our maintainers find and focus on the active issues.
If you have found a problem that seems similar to this, please open a new issue and complete the issue template so we can capture all the details necessary to investigate further.



Patch:
diff --git a/internal/terraform/node_resource_abstract_instance.go b/internal/terraform/node_resource_abstract_instance.go
index 71018dd5d853..d3e969d6296e 100644
--- a/internal/terraform/node_resource_abstract_instance.go
+++ b/internal/terraform/node_resource_abstract_instance.go
@@ -177,7 +177,7 @@ func (n *NodeAbstractResourceInstance) readDiff(ctx EvalContext, providerSchema
 	return change, nil
 }
 
-func (n *NodeAbstractResourceInstance) checkPreventDestroy(change *plans.ResourceInstanceChange) error {
+func (n *NodeAbstractResourceInstance) checkPreventDestroy(change *plans.ResourceInstanceChange) tfdiags.Diagnostics {
 	if change == nil || n.Config == nil || n.Config.Managed == nil {
 		return nil
 	}
@@ -190,12 +190,12 @@ func (n *NodeAbstractResourceInstance) checkPreventDestroy(change *plans.Resourc
 			Severity: hcl.DiagError,
 			Summary:  "Instance cannot be destroyed",
 			Detail: fmt.Sprintf(
-				"Resource %s has lifecycle.prevent_destroy set, but the plan calls for this resource to be destroyed. To avoid this error and continue with the plan, either disable lifecycle.prevent_destroy or reduce the scope of the plan using the -target flag.",
+				"Resource %s has lifecycle.prevent_destroy set, but the plan calls for this resource to be destroyed. To avoid this error and continue with the plan, either disable lifecycle.prevent_destroy or reduce the scope of the plan using the -target option.",
 				n.Addr.String(),
 			),
 			Subject: &n.Config.DeclRange,
 		})
-		return diags.Err()
+		return diags
 	}
 
 	return nil
diff --git a/internal/terraform/node_resource_plan_destroy.go b/internal/terraform/node_resource_plan_destroy.go
index 5c94325e8f27..51cfd64c0987 100644
--- a/internal/terraform/node_resource_plan_destroy.go
+++ b/internal/terraform/node_resource_plan_destroy.go
@@ -96,12 +96,18 @@ func (n *NodePlanDestroyableResourceInstance) managedResourceExecute(ctx EvalCon
 		return diags
 	}
 
+	// We intentionally write the change before the subsequent checks, because
+	// all of the checks below this point are for problems caused by the
+	// context surrounding the change, rather than the change itself, and
+	// so it's helpful to still include the valid-in-isolation change as
+	// part of the plan as additional context in our error output.
+	diags = diags.Append(n.writeChange(ctx, change, ""))
+
 	diags = diags.Append(n.checkPreventDestroy(change))
 	if diags.HasErrors() {
 		return diags
 	}
 
-	diags = diags.Append(n.writeChange(ctx, change, ""))
 	return diags
 }
 
diff --git a/internal/terraform/node_resource_plan_instance.go b/internal/terraform/node_resource_plan_instance.go
index 55997650b4c3..6322097d5ae4 100644
--- a/internal/terraform/node_resource_plan_instance.go
+++ b/internal/terraform/node_resource_plan_instance.go
@@ -286,11 +286,12 @@ func (n *NodePlannableResourceInstance) managedResourceExecute(ctx EvalContext)
 			change.ActionReason = plans.ResourceInstanceReplaceByTriggers
 		}
 
-		diags = diags.Append(n.checkPreventDestroy(change))
-		if diags.HasErrors() {
-			return diags
-		}
-
+		// We intentionally write the change before the subsequent checks, because
+		// all of the checks below this point are for problems caused by the
+		// context surrounding the change, rather than the change itself, and
+		// so it's helpful to still include the valid-in-isolation change as
+		// part of the plan as additional context in our error output.
+		//
 		// FIXME: it is currently important that we write resource changes to
 		// the plan (n.writeChange) before we write the corresponding state
 		// (n.writeResourceInstanceState).
@@ -306,12 +307,19 @@ func (n *NodePlannableResourceInstance) managedResourceExecute(ctx EvalContext)
 		// update these two data structures incorrectly through any objects
 		// reachable via the terraform.EvalContext API.
 		diags = diags.Append(n.writeChange(ctx, change, ""))
-
+		if diags.HasErrors() {
+			return diags
+		}
 		diags = diags.Append(n.writeResourceInstanceState(ctx, instancePlanState, workingState))
 		if diags.HasErrors() {
 			return diags
 		}
 
+		diags = diags.Append(n.checkPreventDestroy(change))
+		if diags.HasErrors() {
+			return diags
+		}
+
 		// If this plan resulted in a NoOp, then apply won't have a chance to make
 		// any changes to the stored dependencies. Since this is a NoOp we know
 		// that the stored dependencies will have no effect during apply, and we can
diff --git a/internal/terraform/node_resource_plan_orphan.go b/internal/terraform/node_resource_plan_orphan.go
index 3f1fc3903d98..6a6b5535b994 100644
--- a/internal/terraform/node_resource_plan_orphan.go
+++ b/internal/terraform/node_resource_plan_orphan.go
@@ -162,8 +162,6 @@ func (n *NodePlannableResourceInstanceOrphan) managedResourceExecute(ctx EvalCon
 		if diags.HasErrors() {
 			return diags
 		}
-
-		diags = diags.Append(n.checkPreventDestroy(change))
 	}
 	if diags.HasErrors() {
 		return diags
@@ -174,11 +172,23 @@ func (n *NodePlannableResourceInstanceOrphan) managedResourceExecute(ctx EvalCon
 	// sometimes not have a reason.)
 	change.ActionReason = n.deleteActionReason(ctx)
 
+	// We intentionally write the change before the subsequent checks, because
+	// all of the checks below this point are for problems caused by the
+	// context surrounding the change, rather than the change itself, and
+	// so it's helpful to still include the valid-in-isolation change as
+	// part of the plan as additional context in our error output.
 	diags = diags.Append(n.writeChange(ctx, change, ""))
 	if diags.HasErrors() {
 		return diags
 	}
 
+	if !forget {
+		diags = diags.Append(n.checkPreventDestroy(change))
+		if diags.HasErrors() {
+			return diags
+		}
+	}
+
 	return diags.Append(n.writeResourceInstanceState(ctx, nil, workingState))
 }
 


Test patch:
diff --git a/internal/terraform/context_plan2_test.go b/internal/terraform/context_plan2_test.go
index b9d4f59b6d19..6177883c29c1 100644
--- a/internal/terraform/context_plan2_test.go
+++ b/internal/terraform/context_plan2_test.go
@@ -2898,7 +2898,7 @@ resource "test_resource" "a" {
 			resp.LegacyTypeSystem = true
 			return resp
 		}
-		_, diags := ctx.Plan(m, states.NewState(), &PlanOpts{
+		plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{
 			Mode: plans.NormalMode,
 			SetVariables: InputValues{
 				"boop": &InputValue{
@@ -2916,6 +2916,27 @@ resource "test_resource" "a" {
 		if !p.PlanResourceChangeCalled {
 			t.Errorf("Provider's PlanResourceChange wasn't called; should've been")
 		}
+
+		if !plan.Errored {
+			t.Errorf("plan is not marked as errored")
+		}
+
+		// The plan should still include a proposed change for the resource
+		// instance whose postcondition failed, since its plan is valid in
+		// isolation even though the postcondition prevents any further
+		// planning downstream. This gives the UI the option of describing
+		// the planned change as additional context alongside the error
+		// message, although it's up to the UI to decide whether and how to
+		// do that.
+		changes := plan.Changes
+		changeSrc := changes.ResourceInstance(mustResourceInstanceAddr("test_resource.a"))
+		if changeSrc != nil {
+			if got, want := changeSrc.Action, plans.Create; got != want {
+				t.Errorf("wrong proposed change action\ngot:  %s\nwant: %s", got, want)
+			}
+		} else {
+			t.Errorf("no planned change for test_resource.a")
+		}
 	})
 
 	t.Run("postcondition fail refresh-only", func(t *testing.T) {
diff --git a/internal/terraform/context_plan_test.go b/internal/terraform/context_plan_test.go
index 9ac1c394cafe..52456e3f4100 100644
--- a/internal/terraform/context_plan_test.go
+++ b/internal/terraform/context_plan_test.go
@@ -1390,6 +1390,30 @@ func TestContext2Plan_preventDestroy_bad(t *testing.T) {
 		}
 		t.Fatalf("expected err would contain %q\nerr: %s", expectedErr, err)
 	}
+
+	// The plan should still include the proposal to replace the object
+	// that cannot be destroyed, since the change is valid in isolation,
+	// and this then allows Terraform CLI and Terraform Cloud to still
+	// show the problematic change that caused the error as additional
+	// context.
+	changes := plan.Changes
+	changeSrc := changes.ResourceInstance(mustResourceInstanceAddr("aws_instance.foo"))
+	if changeSrc != nil {
+		if got, want := changeSrc.Action, plans.DeleteThenCreate; got != want {
+			t.Errorf("wrong proposed change action\ngot:  %s\nwant: %s", got, want)
+		}
+		gotReqRep := changeSrc.RequiredReplace
+		if !gotReqRep.Has(cty.GetAttrPath("require_new")) {
+			t.Errorf("plan does not indicate that the require_new change forced replacement")
+		}
+	} else {
+		t.Errorf("no planned change for aws_instance.foo")
+	}
+	// The plan must also be marked as errored, so that Terraform will reject
+	// any attempts to apply the plan with the forbidden replace action.
+	if got, want := plan.Errored, true; got != want {
+		t.Errorf("plan is not marked as errored\ngot:  %#v\nwant: %#v", got, want)
+	}
 }
 
 func TestContext2Plan_preventDestroy_good(t *testing.T) {