Skip to content
all posts

The id in a Terraform state file is often not the one you import by

5 min readSubscribe via RSS

As often happens when Terraform organically grows, I found myself needing to build a module to make the code DRY.

Now when consuming those modules, if you are simply replacing the none-DRY code with the module, Terraform moved blocks will do the job.

It's not always that simple though, and if you are instead moving resources between state files but are not able to destroy the resources, you need to use a combination of removed and import blocks on the 2 states.

The obvious way to generate them is to walk the state file and copy each resource's id into a block, and for a lot of AWS types that gives you an id Terraform can't import.

Where the state id and the import id part ways

Each AWS resource type has its own import id format, documented on its page in the provider docs. The state id is whatever the provider's SetId call happened to store, and the two are the same string less often than you'd hope. A few examples, with the values from the tool's test fixture:

Type id in the state What terraform import wants
aws_route r-rtb-656C65616E6F721080289494 (a hash) rtb-656C65616E6F72_0.0.0.0/0
aws_security_group_rule sgrule-1859128000 (a hash) sg-6e616f6d69_ingress_tcp_8000_8000_10.0.3.0/24
aws_wafv2_web_acl a bare UUID <id>/<name>/<scope>
aws_ecs_service the service ARN prod-cluster/web

The first three are at least obviously wrong: a hash or a UUID can't be mistaken for an import id. The import ids are built from other attributes, six of them in the security group rule's case, with the ports read as numbers and the CIDRs as a list.

aws_ecs_service is the sneaky one. The import wants <cluster name>/<service name>, and neither name is in the state as a plain value: the id is the service's ARN and the cluster attribute is the cluster's ARN, not its name. The rule takes the name off the end of the cluster ARN and pairs it with the service's name. If cluster isn't set, which is allowed because a service defaults to the account's default cluster, it reads the cluster out of the service ARN instead. ARNs from before 2019 don't name a cluster at all, and in that case the rule returns nothing and flags the block rather than guess default.

Then there are the separators, which differ by type for no reason you could work out from the state:

Type Import id format
aws_eks_node_group cluster:node_group
aws_ecs_service cluster/service
aws_backup_selection plan_id and selection_id, joined with a pipe character
aws_ram_resource_association share_arn,resource_arn
aws_ec2_transit_gateway_route route_table_id_destination
aws_iam_role_policy_attachment role/policy_arn
aws_iam_role_policy role:name

None of that is guessable, and a tool that guesses writes a block that looks fine and fails hours later inside somebody else's terraform plan. Or succeeds against the wrong resource.

What the tool does

tf-import-blocks reads a state file (a raw .tfstate, terraform state pull output or terraform show -json output, detected rather than declared) and emits one import block per managed aws_ resource. The id comes from a per-type rule table in src/rules/, built from the Import section of each type's provider doc page, roughly 250 of them. It covers 234 types: 229 resolve and 5 are marked not importable because the provider publishes no import for them, aws_vpn_gateway_attachment for one.

terraform state pull > prod.tfstate
npx tf-import-blocks prod.tfstate > imports.tf

Only HCL goes to stdout, so the redirect is always safe. The summary goes to stderr and reports counts, types and addresses, never an id, because stderr is the thing that ends up pasted into a ticket. Run against the repo's test fixture, trimmed:

# account 111122223333 · region eu-west-1
import {
  to = aws_ecs_service.web
  id = "prod-cluster/web"
}

import {
  to = aws_route.default
  id = "rtb-656C65616E6F72_0.0.0.0/0"
}

# VERIFY: no rule for aws_s3_object — import id may not be the state id
import {
  to = aws_s3_object.weird
  id = "acme-assets-eu-west-1/reports/\"q1\"/$${env}/summary.txt"
}
tf-import-blocks: 15 blocks from 1 state file
  14 resolved by a rule
  1 fell back to the state id, flagged # VERIFY
    no rule: aws_s3_object
  skipped: 1 deposed instance, 1 data source, 1 non-aws resource

Two things in there I decided on purpose. A type with no rule still gets a block, built from the state id and flagged # VERIFY, because silently dropping a resource during a state move is the worst outcome: nothing manages it any more and you find out when something destroys it. And the composite rules have no fallback. If aws_route can't find a destination attribute it returns nothing and the block says so, rather than falling back to the hash. A flagged wrong id is a quick fix, and an unflagged one isn't.

The # account · region comment is there instead of a provider = argument. I can't know your provider alias names, Terraform rejects provider on an address inside a module anyway, and importing into the wrong account is silent and expensive.

--filter module.net. emits only one subtree, which is the common case for a move. Exit code 3 means two state files gave the same address and the output isn't valid HCL until you pick one.

It's on npm as tf-import-blocks, Node 20 or newer, no runtime dependencies. The source is on GitHub.

What it doesn't do

It writes import blocks and nothing else. Terraform can write the resource bodies for you, with terraform plan -generate-config-out=generated.tf, but only once the import blocks exist, because that is what it reads to know what to import. Nothing in Terraform writes the blocks themselves from a state file, and that is the part this covers.

AWS only. Data sources, deposed instances and resources from other providers are skipped and counted.

The blocks use the string id = "…" form only, not the newer identity = { … } form, which needs Terraform 1.12 and a provider that publishes an identity schema for that type. The provider docs now print the identity example first on every page, so if you're adding a rule, scroll past the first import { block. The source comments repeat that warning a lot.

aws_s3_object deliberately has no rule. It's the fixture's no-rule case and exercises the ${ escaping, so adding one would break the golden test for a reason that lives in a fixture. aws_s3_bucket_object, its deprecated predecessor, follows it. Both come out with a # VERIFY.

The fixture is synthetic. It has been run against a real state, which is messier than anything the tests see.