Request for testing: `removed` block

The most recent alpha release of Terraform, v1.7.0-alpha20231130, contains a new feature we’d like your feedback on: configuration-driven remove with the removed block.

This new block follows in the footsteps of moved, providing an alternative to terraform state rm that can remove multiple resources and modules from state at once, as a safe, plannable operation.

# tells Terraform that the aws_instance.example resource, removed from configuration,
# should not be destroyed, only removed from state
removed {
  from = aws_instance.example

  lifecycle {
    destroy = false
  }
}

This feature preview in the most recent alpha release, so it not ready for production.

You can download it via the releases website (see CHANGELOG).

An early draft can be found here. I’d love your feedback on the docs as well!

Please respond to this topic with any feedback or questions. Potential bugs can also be filed within our Github Issues.

Looks good tested on my end with a simple local resource.

Created a simple module which creates one file.

terraform {
  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "2.4.0"
    }
  }
}

resource "local_file" "file" {
  filename = "${path.module}/destination"
  source   = "${path.module}/source"
} 

Ran an init + apply.

local_file.swamp: Refreshing state... [id=5291767cb169477c0a03c53439ec85c8dc3aaf46]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # local_file.file will be created
  + resource "local_file" "file" {
      + content_base64sha256 = (known after apply)
      + content_base64sha512 = (known after apply)
      + content_md5          = (known after apply)
      + content_sha1         = (known after apply)
      + content_sha256       = (known after apply)
      + content_sha512       = (known after apply)
      + directory_permission = "0777"
      + file_permission      = "0777"
      + filename             = "./destination"
      + id                   = (known after apply)
      + source               = "./source"
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

local_file.file: Creating...
local_file.file: Creation complete after 0s [id=5291767cb169477c0a03c53439ec85c8dc3aaf46]

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

Updated the file to remove the local_file resource and added removed block. I’m guessing lifecycle + destroy is a new argument.

terraform {
  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "2.4.0"
    }
  }
}

# resource "local_file" "file" {
#   filename = "${path.module}/destination"
#   source   = "${path.module}/source"
# }

removed {
  from = local_file.file
  lifecycle {
    destroy = false
  }
}

Ran an apply and got the proper feedback.

local_file.file: Refreshing state... [id=5291767cb169477c0a03c53439ec85c8dc3aaf46]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:

Terraform will perform the following actions:

 # local_file.file will no longer be managed by Terraform, but will not be destroyed
 # (destroy = false is set in the configuration)
 . resource "local_file" "file" {
      - content_base64sha256 = "N+FgMd5soTK/NPAhWpf9OwVevfnr6DOVhD8CTislQ5s=" -> null
      - content_base64sha512 = "/uaHo4s7YXkWFCLPOobf2izKhUbZtBKmlE3fnEfjM5kFzN4MAC4e8IK+Yl6QlbuqBPnpiI36dGCgtCX/7lVBCw==" -> null
      - content_md5          = "f352cac61c815fe9d44770e65345367d" -> null
      - content_sha1         = "5291767cb169477c0a03c53439ec85c8dc3aaf46" -> null
      - content_sha256       = "37e16031de6ca132bf34f0215a97fd3b055ebdf9ebe83395843f024e2b25439b" -> null
      - content_sha512       = "fee687a38b3b6179161422cf3a86dfda2cca8546d9b412a6944ddf9c47e3339905ccde0c002e1ef082be625e9095bbaa04f9e9888dfa7460a0b425ffee55410b" -> null
      - directory_permission = "0777" -> null
      - file_permission      = "0777" -> null
      - filename             = "./destination" -> null
      - id                   = "5291767cb169477c0a03c53439ec85c8dc3aaf46" -> null
      - source               = "./source" -> null
    }

Plan: 0 to add, 0 to change, 0 to destroy.
╷
│ Warning: Some objects will no longer be managed by Terraform
│ 
│ If you apply this plan, Terraform will discard its tracking information for the following objects, but it will not delete them:
│  - local_file.file
│ 
│ After applying this plan, Terraform will no longer manage these objects. You will need to import them into Terraform to manage them again.
╵

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes


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

Very simple test, but might try more robust workings. Do other meta arguments work?
-provider, for_each, etc?

Update :

Did something a bit funkier and got a good result.
Tested creating an ECR resource via the AWS provider.
But then added a deleted block and an import block to the code.
Added a awscc_ecr_repository resource to see if I could migrate the resource from one provider to the next.

So config was :

terraform {
  required_providers {
    awscc = {
      source  = "hashicorp/awscc"
      version = "0.66.0"
    }
    aws = {
      source  = "hashicorp/aws"
      version = "5.29.0"
    }
  }
}

Had an initial ecr_repository :

resource "aws_ecr_repository" "this" {
  name                 = "delete-test"
  image_tag_mutability = "MUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }
}

After creating, I commented out the old aws_ecr_repository, added a removed block, added an import block, and then added an awscc_ecr_repository resource.

# resource "aws_ecr_repository" "this" {
#   name                 = "delete-test"
#   image_tag_mutability = "MUTABLE"

#   image_scanning_configuration {
#     scan_on_push = true
#   }
# }

removed {
  from = aws_ecr_repository.this
  lifecycle {
    destroy = false
  }
}

import {
  id = "delete-test"
  to = awscc_ecr_repository.this
}

resource "awscc_ecr_repository" "this" {
  repository_name      = "delete-test"
  image_tag_mutability = "MUTABLE"
  image_scanning_configuration = {
    scan_on_push = true
  }
  lifecycle {
    ignore_changes = [empty_on_delete, lifecycle_policy, repository_policy_text, tags]
  }
}

Ran smoothly on the state removal and import.

awscc_ecr_repository.this: Preparing import... [id=delete-test]
aws_ecr_repository.this: Refreshing state... [id=delete-test]
awscc_ecr_repository.this: Refreshing state... [id=delete-test]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

 # aws_ecr_repository.this will no longer be managed by Terraform, but will not be destroyed
 # (destroy = false is set in the configuration)
 . resource "aws_ecr_repository" "this" {
      - arn                  = "arn:aws:ecr:us-east-1:634211456147:repository/delete-test" -> null
      - id                   = "delete-test" -> null
      - image_tag_mutability = "MUTABLE" -> null
      - name                 = "delete-test" -> null
      - registry_id          = "634211456147" -> null
      - repository_url       = "634211456147.dkr.ecr.us-east-1.amazonaws.com/delete-test" -> null
      - tags                 = {} -> null
      - tags_all             = {} -> null

      - encryption_configuration {
          - encryption_type = "AES256" -> null
        }

      - image_scanning_configuration {
          - scan_on_push = true -> null
        }
    }

  # awscc_ecr_repository.this will be updated in-place
  # (imported from "delete-test")
  ~ resource "awscc_ecr_repository" "this" {
        arn                          = "arn:aws:ecr:us-east-1:634211456147:repository/delete-test"
      + empty_on_delete              = (known after apply)
        encryption_configuration     = {
            encryption_type = "AES256"
        }
        id                           = "delete-test"
        image_scanning_configuration = {
            scan_on_push = true
        }
        image_tag_mutability         = "MUTABLE"
      + lifecycle_policy             = (known after apply)
        repository_name              = "delete-test"
      + repository_policy_text       = (known after apply)
        repository_uri               = "634211456147.dkr.ecr.us-east-1.amazonaws.com/delete-test"
      + tags                         = (known after apply)
    }

Plan: 1 to import, 0 to add, 1 to change, 0 to destroy.
╷
│ Warning: Some objects will no longer be managed by Terraform
│ 
│ If you apply this plan, Terraform will discard its tracking information for the following objects, but it will not delete them:
│  - aws_ecr_repository.this
│ 
│ After applying this plan, Terraform will no longer manage these objects. You will need to import them into Terraform to manage them again.
╵

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

awscc_ecr_repository.this: Importing... [id=delete-test]
awscc_ecr_repository.this: Import complete [id=delete-test]

Hope this all helps.

Wrote up a quick post on it as well :

2 Likes

Thanks for testing, @benjamin.m.lykins!

One thing that occurs to me looking at the output you saw is that we might consider showing the inside of the block describing that it will “no longer be managed by Terraform” as if it were a no-op change, so that it would not suggest that all of the arguments of the resource instance are being set to null.

The thing we’re trying to communicate here is that Terraform is not going to change this remote object in any way, but the current presentation is a little ambiguous about that, making it look perhaps a little too much like the rendering for an object being destroyed. :thinking:

Another separate note:

The use of a pair of removed and import for migrating an object from one provider to another is neat, but I remember that we’ve also separately designed a way for providers to hand off ownership of objects between different resource types using a moved block and some special provider logic to translate between the two schemas:

# Not implemented yet; just an example
moved {
  from = aws_ecr_repository.this
  to   = awscc_ecr_repository.this
}

Although it’s a neat workaround to use forgetting and importing together for this, I hope that a real solution like I showed above will follow in the near future, because that will both allow Terraform to better communicate what’s going on and avoid the lossiness that often arises through importing, because the remote system cannot preserve Terraform metadata about an object, only its actual data.

That’s a different feature from removing though, and I guess it doesn’t hurt to use removing and importing together as a workaround in the meantime.

You are welcome, @apparentlymart! I always enjoy seeing new features in Terraform/Terraform Cloud.

Here are a couple of additional items for feedback:

  1. I agree that the use of null can be confusing, as removing a resource from the code looks very similar.

Perhaps something similar to how “moved” appears in the CLI could be implemented?

local_file.file: Refreshing state... [id=da39a3ee5e6b4b0d3255bfef95601890afd80709]

Terraform will perform the following actions:

// I updated the following two comments. 
  # local_file.file will no longer be managed by Terraform, but will not be destroyed
  # (destroy = false is set in the configuration)
    resource "local_file" "file" {
        id                   = "da39a3ee5e6b4b0d3255bfef95601890afd80709"
        # (10 unchanged attributes hidden)
    }

Plan: 0 to add, 0 to change, 0 to destroy.
  1. I did one more test this morning using destroy = true.

If sticking to the current output, there are differences between destroy = true and destroy = false.

When run with destroy = true:

removed {
  from = local_file.file
  lifecycle {
    destroy = true
  }
}

Here is what it currently says :

  # local_file.file will be destroyed
  # (because local_file.file is not in configuration)
  - resource "local_file" "file" {
      - content_base64sha256 = "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" -> null
      - content_base64sha512 = "z4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg/SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg==" -> null
      - content_md5          = "d41d8cd98f00b204e9800998ecf8427e" -> null
      - content_sha1         = "da39a3ee5e6b4b0d3255bfef95601890afd80709" -> null
      - content_sha256       = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" -> null
      - content_sha512       = "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e" -> null
      - directory_permission = "0777" -> null
      - file_permission      = "0777" -> null
      - filename             = "./destination" -> null
      - id                   = "da39a3ee5e6b4b0d3255bfef95601890afd80709" -> null
      - source               = "./source" -> null
    }

Plan: 0 to add, 0 to change, 1 to destroy.

In comparison, this is what it looked like when it was removed, but not destroyed. (i.e. destroy = false).

 # local_file.file will no longer be managed by Terraform, but will not be destroyed
 # (destroy = false is set in the configuration)

Perhaps update it for resources that will be removed and destroyed, something like this:

//From
 # local_file.file will be destroyed
 # (because local_file.file is not in configuration)

//To 
# local_file.file will no longer be managed by Terraform, and will be destroyed. 
# (destroy = true is set in the configuration)
  1. Since import, move, and soon-to-be-removed will be blocks, would it make sense to add a section in the plan and apply to describe these specific state changes?

For example :

Plan: 0 to add, 0 to change, 1 to destroy.
State: 1 to import, 1 to move, 1 to remove

Sorry if this is long winded, but hopefully it is decent feedback.

2 Likes

Thanks for testing, @benjamin.m.lykins. I like how you put it in your blog article - “a holy trinity of state blocks”. This was exactly the intent of this feature.

I agree with you and @apparentlymart on rendering remove-only changes as no-ops. It also makes the plan significantly shorter, as the “unchanged” attributes are collapsed. This should be updated in the next beta (1.7.0-beta2 tomorrow).

As for explicitly reminding the user that destroy = true is set in the configuration and that is why their resource will be deleted, this is a nice detail that I’d like to include. As you note in your article, the removed block is currently present in a very basic form and more arguments will be added in a later iteration. At the moment, a removed block with destroy = true is functionally a no-op if you’ve already removed the resource from configuration, since that’s already enough for Terraform to know it needs to delete it.

Regarding the “resource change comment”:

# local_file.file will be destroyed

vs

# local_file.file will no longer be managed by Terraform, and will be destroyed.

It’s always true that when Terraform destroys a resource, it also is no longer managing it. I do quite like the additional explicitness here, but I also wouldn’t want to imply that Terraform is doing anything out of the ordinary when it destroys a resource with destroy = true.

  1. Since import, move, and soon-to-be-removed will be blocks, would it make sense to add a section in the plan and apply to describe these specific state changes?

For example :

Plan: 0 to add, 0 to change, 1 to destroy.
State: 1 to import, 1 to move, 1 to remove

For legacy integration reasons changing this text is costly. We also try not to rely on users’ understanding of Terraform state and “state actions” unless they’re intentionally using advanced features such as terraform state rm. On the other hand I do think this nicely presents the distinction between planned actions that will result in provider requests during apply, and those which require no further requests and will just update the state. Interested to see if others have feedback on this output.

hopefully it is decent feedback.

Absolutely, and thanks again!

Is the lifecycle block required when destroy = "false" ? This seems like the main reason most terraform authors would go through the extra work of adding a removed block in the first place. If I want to remove a resource from state and have terraform destroy the underlying resource, I’m just going to delete the resource block. I can see why public module developers may want the extra documentation provided by an explicit removed{} block with destroy="true" (“You’re not remembering incorrectly; there used to be a resource here”), but requiring the lifecycle block in every removed block feels like requiring terraform authors to answer Yes to “Are you sure you want to save this file?” every time they hit the Save button.

Hi @jbeal_ramp,

A removed block for a resource supports a subset of the arguments that are valid in a resource block.

For this round, the subset is limited only to the newly-added destroy = false lifecycle option, but in future it might also grow to include some or all of the following:

  • provider arguments for specifying which provider configuration should destroy the object.
  • create_before_destroy to specify that the deletion should be subject to the same create/destroy graph ordering inversion that is used when that argument appears in a resource block. (This is sometimes important if the object is being destroyed in the same plan as a related object is being updated, so that the destroy and the update will happen in the correct relative order.)
  • precondition blocks defining conditions that must be true before the object can be destroyed.
  • provisioner blocks with when = destroy set, and connection blocks to configure how they should connect to the object being destroyed.

The arguments that belong in lifecycle in a resource block also belong in lifecycle in a removed block, so that the two are symmetrical.

For this initial round that only supports one argument it does of course seem overly verbose, but it’s designed this way to allow future expansion so that anything new added to resource blocks can, if it’s useful to do so, also be added to removed blocks that refer to resources.

I appreciate the explanation, and those all seem like good future extensions for a removed block. I guess the rationale for not defaulting destroy to false is that could conflict with some of these future use cases, so requiring it to be explicit helps to prevent some illogical configurations? (e.g. it would be illogical to specify – or default – destroy = false if also specifying a provider responsible for (not?) destroying the object.)

Indeed, the broad idea is that a removed block that refers to a resource is like a resource block with all of the desired state removed and only a subset of the metadata pieces left, but all of those metadata pieces should have equivalent behavior in removed as they did in resource so that an author doesn’t need to memorize a bunch of different defaults in this case.

Removing an item from the configuration without destroying it is a rare thing to do, and so it doesn’t seem justified to optimize the syntax used to declare it at the expense of inconsistency.

Subjectively, it also means that a module that is requesting that something ought not to be destroyed will always have destroy = false written in it, which is a much clearer statement (to an unfamiliar future reader) of “do not destroy this” than just an empty removed block would be. Explicit is better than implicit.

I tend to agree with @jbeal_ramp, and may go a bit further and suggest that a removed block can never delete a resource - similar to an import block can never create a resource.

Traditional resource blocks are the constructs for creating and deleting resources. import and removed blocks are the constructs for managing the state (creating and deleting) of a resource without the need of the CLI.

Looking at removed in this way would suggest the destroy option within the lifecycle block is unnecessary and should not be applicable.

The language design of removed blocks is effectively frozen at this point, because we’re at the last planned release candidate for Terraform v1.7.0 and so bugs notwithstanding the final v1.7.0 will exactly match v1.7.0-rc2. (We probably should’ve wound down this topic a few weeks ago when we hit v1.7.0-beta1, since we’ve only really been fixing bugs since then, but alas… :confounded:)

However, even if we could still change it I don’t feel convinced that we should. removed blocks as currently designed are intended to grow to be be parts of the solutions for a bunch of different issues, including but not limited to the following (which were the ones I was able to find quickly from memory while writing this comment):

I do acknowledge that because the folks who are gathered in this issue are primarily motivated by having a configuration-based alternative to terraform state rm it’s natural that this audience would prioritize the ergonomics of that in particular, but this capability is just the first of many things we’re intending to solve by fixing what is really a design flaw in today’s Terraform: that removing things from the configuration removes both the desired state and all of the metadata Terraform needs to actually destroy the removed object.

The way I suggest thinking of a removed block is as a record of something that used to be present and is now removed, along with the instructions to Terraform on how it should react if that object still exists in the prior state. There are various different ways Terraform could potentially react to that situation; deleting it from the state without taking any other action is the first new possibility we’re going to support here, but by the time this is done I expect it will be close to the least interesting of the things that removed can achieve.

Deleting something from the state without destroying it is a rare, “advanced user” sort of thing, which means that:

  1. It doesn’t really need a concise representation in configuration. If this is something you’re using a lot then you’re not really using Terraform in the way it’s intended to be used.

  2. It’s relatively unlikely that an average Terraform maintainer will have encountered it before, and so when someone does find it for the first time (e.g. in a Terraform module they’ve inherited from someone else who is long gone) we’d like for them to be able to make a confident guess as to what it represents.

    destroy = false seems a far clearer way to state “do not destroy this” than just removed alone, because “removed” is a statement about something that already happened (it was removed from the desired state) without any explicit statement about how Terraform should react to that situation.

(It also, as a nice consequence of treating all of these options as additive, solves the less-significant-but-still-important problem of allowing a parent module to override the decision made by a child module or allowing an override file to overrule a normal file, by explicitly setting destroy = true. That’s more just a nice consequence of the decision made for other reasons though; I expect we could’ve found other ways to solve it if we needed to.)


Another potential future plan in this area that will hopefully help to remove some friction are some new commands for manipulating the desired state (i.e. the configuration) instead of the latest state snapshot directly. These are intended as modern replacements for the various terraform state ... subcommands, correcting the historical design error of exposing direct state manipulation instead of that being a hidden side-effect of terraform apply.

These are not designed fully and so are just early sketches that might change, but consider:

  • terraform add -import=i-12345 aws_instance.example would ask the AWS provider to fetch EC2 instance i-12345, then generate a resource "aws_instance" "example" block based on that, and add the necessary import block so that you can subsequently run terraform apply to complete the addition.

  • terraform rm aws_instance.example would delete the resource "aws_instance" "example" block and replace it with a removed block that contains all of the relevant provisioner, connection, precondition, postcondition, etc settings from the resource block, so that a subsequent terraform apply will handle the removal as “delete from state” while still respecting all of the previously-configured meta-arguments.

    A -skip-destroy option to this command could potentially automatically add the destroy = false argument, overridding how destroy was set in the resource block, and thus terraform rm -skip-destroy aws_instance.example followed by terraform apply effectively becomes the new terraform state rm, but with the possibility of code-reviewing and merging the removal before applying it.

  • terraform mv aws_instance.example aws_instance.example2 would change the existing resource "aws_instance" "example" block to be resource "aws_instance" "example2" instead, and add a moved block commemorating that change so that a subsequent terraform apply can update the state to match.

I’ve described the above as CLI commands because that’s a concise way to get the idea across, but we also imagine exposing similar commands as part of the text editor integrations so that those who prefer to work in their text editors can perform these development-time tasks without dropping to a shell, and then check the results directly in the text editor pane before committing.

Either way, it would become Terraform’s job to write the required import, moved or removed blocks, which the human author can then review and alter if they have a different idea of how Terraform ought to treat the situation. After the change is committed, terraform apply updates the remote system and the state to match the new desired state, as usual.

1 Like

This feels to me like a statement that is only true because the current state of affairs makes it difficult to do. I hope to use this feature as soon as it is released to try to break some of our larger terraform cloud workspaces into smaller state files. This sort of refactoring would be very cumbersome to do today. With the ability to use removed blocks in the current module and import blocks in a refactored new module, we can migrate state management for a resource without modifying the underlying resource.

Edited: To be clear – not intended to challenge the language design at this point; I see the rationale.