Skip to main content
RunBook Academy

← All break/fix scenarios in Terraform

intermediateterraform-import~20 min

Import Rejected: Attribute Mismatch

Reported symptoms

  • ●The adoption plan aborts: six of thirty-four import blocks error, the other twenty-eight are reported as imports
  • ●The six failures carry two different messages - an argument the provider says is not expected, and an argument the provider says is required and missing
  • ●Every one of the thirty-four objects is visibly present in the cloud console, and the IDs were copied from there and rechecked
  • ●One engineer runs the same plan on their laptop and sees none of the errors
  • ●Deleting a rejected argument clears that error and produces a different one from the same resource block
  • ●The twenty-eight blocks that do import produce a plan with changes in it, three of them replacements

Evidence

  • · The plan job output, which names the file and line of every rejected argument
  • · `.terraform.lock.hcl` in the repository, and the provider version the CI job actually loaded
  • · `terraform providers` on the laptop that disagrees, showing what is in its `.terraform` directory
  • · The pull request diff, showing where the six resource blocks were copied from
  • · The `required_providers` constraint in the repository the blocks were copied from
  • · The `Plan:` summary line for the twenty-eight blocks that did import
  • · `terraform plan -generate-config-out` output for one failing address, next to the hand-written block
Diagnosis and resolutionclick to reveal

Root cause

The resource blocks were not derived from the objects being adopted. They were copied from a module in another repository, and that module is written against an older major version of the provider than this repository pins. The two error shapes are the two directions of the same schema drift: an argument that the older major version accepted and the current one no longer declares, and an argument the current major version made required that the older one did not. Neither is an import problem. Terraform decodes and validates the configuration against the schema of the provider it has loaded before it walks the graph, so the plan aborts before any import block is honoured and no object is ever read - which is why the objects being demonstrably present in the console changed nothing. The twenty-eight blocks that did import are the same defect wearing a success: they parsed against the current schema, so the import ran, and then the plan proposed changes because the values in those blocks describe the other repository's environment rather than these objects. The laptop that saw no errors had an older provider left in its `.terraform` directory from a previous branch and had not been re-initialised, so it was validating the copied blocks against the schema they were written for.

Remediation

Stop editing the six blocks. Every deletion of a rejected argument produces the next error from the same file because the file describes a different schema, and an attribute removed to silence a message is an attribute the reader can no longer see. Instead, derive the configuration from the objects. For each failing address, remove the hand-written resource block, leave the import block in place, and run `terraform plan -generate-config-out` to have the provider write a configuration that matches its own current schema and the live object. Read every generated block before adopting it - generated configuration carries naming, tagging and structure that will not match house conventions, and it is a starting point rather than an artefact. Then treat the twenty-eight that imported with exactly the same suspicion: an import that produced a non-empty plan is not adopted, it is queued for a change nobody asked for, and three of them are queued for a replacement. Do not apply any part of this pull request until the whole plan is empty.

Verification

The proof is an empty plan across all thirty-four addresses, taken with `terraform plan -detailed-exitcode` and read by exit code rather than by eye: 0 means no changes, 2 means changes were found and the adoption is not finished. A plan that is empty for the six repaired addresses but still shows changes for the other twenty-eight is a partial result and must not be applied, because apply executes the whole plan. Beyond the exit code, confirm each object independently through the provider's own CLI rather than through Terraform's read path, so that a provider read which silently returns a partial object is caught; confirm the CI job and a freshly initialised working directory resolve the same provider version as `.terraform.lock.hcl`; and confirm the import blocks have been removed once consumed, since a leftover import block fails every subsequent plan.

Prevention

Configuration for an adoption is derived from the object, never from another repository. A copied block is a description of some other estate that happens to compile, and compiling is not evidence. Where the provider can generate the configuration, let it, and spend the review effort on reading the generated block rather than on guessing attributes. Pin the provider with a constraint and commit `.terraform.lock.hcl`, so that the schema a block is validated against is a property of the repository rather than of whichever laptop ran the plan; make the CI plan job run `terraform init` from a clean directory so it cannot inherit a stale plugin. Treat the empty plan as the definition of a finished import - the import command's exit status, a new state entry, and a readable `terraform state show` are all equally true of an import whose configuration does not describe reality at all. And adopt in small batches: thirty-four objects in one pull request means thirty-four unverified claims arriving at once, and the six that failed loudly were the cheapest part of it.

Reported symptoms

A platform team is adopting an account that was built by hand three years ago. Thirty-four objects — a VPC, its subnets and route tables, nine security groups, four S3 buckets, and a set of IAM roles — are to come under Terraform management in one pull request. The pull request contains thirty-four import blocks and thirty-four matching resource blocks.

The CI plan job aborts. Six of the thirty-four import blocks produce an error; the plan never prints a Plan: line at all.

The six errors are not the same error. Four of them say an argument is not expected in the resource block. Two say an argument is required and was not found. That split is what made the ticket read like two bugs, and it is why the first four hours went into the wrong question.

What the team checked and ruled out:

  • Not the resource IDs. Every ID was copied from the console and rechecked against the provider CLI. All thirty-four objects exist.
  • Not permissions. The plan role can describe all thirty-four objects; the same credentials were used to reread them by hand.
  • Not the backend. The state is readable, the lock acquires and releases, and an unrelated plan in the same repository runs clean.
  • Not the Terraform version. The CI image pins a single Terraform release and has not changed in six weeks.
  • Not a provider outage. Nothing in the account is degraded, and the provider CLI answers every call in milliseconds.

One engineer then reported that the plan ran without errors on their laptop, which the team recorded as “intermittent” and set aside. It was the most useful observation anyone made and it was the one that got filed.

Evidence provided

Read-only / Safetwo error shapes, both about the shape of the block rather than the object
$ terraform plan -no-color 2>&1 | grep -A4 '^Error:' | head -20
Error: Unsupported argument

on adopt_buckets.tf line 14, in resource "aws_s3_bucket" "audit_logs":
14:   acl = "log-delivery-write"

An argument named "acl" is not expected here.

Error: Missing required argument

on adopt_roles.tf line 31, in resource "aws_iam_role" "deploy":
31: resource "aws_iam_role" "deploy" {

The argument "assume_role_policy" is required, but no definition was found.

Illustrative output

Read-only / Safewhat this repository pins
$ terraform providers
Providers required by configuration:
.
└── provider[registry.terraform.io/hashicorp/aws] ~> 5.80

Providers required by state:

  provider[registry.terraform.io/hashicorp/aws]

Illustrative output

git log shows every one of the six blocks arriving in a single commit, and the pull request description names the source: they were lifted from acme/legacy-network, a repository that has not been touched in two years. That repository’s required_providers block reads:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.74"
    }
  }
}

The engineer whose laptop ran clean checked their working directory:

Read-only / Safethe laptop is not running the provider this repository pins
$ terraform version
Terraform v1.9.8
on linux_amd64
+ provider registry.terraform.io/hashicorp/aws v3.76.1

Your version of Terraform is out of date! ...

Illustrative output

And the twenty-eight blocks that were reported as imports, taken on their own by commenting the six failures out:

Read-only / Safetwenty-eight adoptions that would change nineteen objects and replace three
$ terraform plan -no-color | tail -3
Plan: 28 to import, 3 to add, 19 to change, 3 to destroy.

Illustrative output

Work the evidence before reading on

The six failures name a file, a line, and an argument. None of them names an object, an ID, or an API call.

  1. Missing required argument was raised for a resource that is about to be imported, and the object in the account plainly has an assume-role policy attached. Why does Terraform want that value written in the configuration when it is about to read it from the object?
  2. Two engineers, one repository, one commit, two different results. What is different between the two machines, and which of the two is telling you about production?
  3. The four Unsupported argument failures and the two Missing required argument failures were treated as separate bugs. What single change to a configuration produces both messages at once?

Before continuing: the twenty-eight blocks that “worked” produced a plan that changes nineteen objects and destroys three. Those blocks came out of the same commit as the six that failed. What does that tell you about the six, and about the twenty-eight?

Root cause

1. Import reads the object. It never reads your configuration

terraform import, and the import block that replaced it for reviewable work, does exactly one thing: it asks the provider to read a real object and writes what comes back into state. It does not create the object, it does not modify it, and — the part that matters here — it does not write or correct the configuration. The configuration is the operator’s responsibility, before and after.

So an import can only ever prove that an object exists and could be read. It cannot prove that the resource block sitting next to it describes that object. The only thing that proves that is a plan with nothing in it.

2. The configuration is decoded against the loaded schema, before the graph runs

Terraform loads the provider, asks it for its schema, and decodes every resource block against that schema. Arguments that the schema does not declare are rejected as Unsupported argument. Arguments the schema marks required and the block omits are rejected as Missing required argument. Both are configuration-decoding errors, and both are raised before Terraform walks the graph — which is why the plan aborted without printing a Plan: line and without a single import having been attempted.

That is the answer to the first question. Terraform never got as far as reading the IAM role, so the fact that the role has an assume-role policy in the account was never consulted. A required argument is required in configuration whether or not the object being adopted already has a value for it.

3. Both messages come from one change: the wrong major version

The six blocks were copied from a repository pinned to the 3.x line of the provider. This repository pins 5.x. Across a provider major version, arguments get removed, split into separate resource types, or promoted from optional to required — that is what a major version is for, and the provider’s own upgrade guide is the record of it.

Run a 3.x-shaped block through a 5.x schema and you get exactly the two messages the team saw:

  • an argument that 3.x declared and 5.x does not → Unsupported argument
  • an argument that 5.x requires and 3.x did not → Missing required argument

They are not two bugs. They are the two directions of one schema drift, and the split into two messages is the tell, not the noise.

4. The laptop was validating against the schema the blocks were written for

The engineer whose plan ran clean had an older provider still sitting in their .terraform directory from an earlier branch, and had not re-initialised. Terraform validates against the plugin it has loaded, not against the constraint in the file, so their working directory was checking 3.x-shaped blocks against a 3.x schema and finding nothing wrong.

That result was correct and completely uninformative. The CI job, which initialises from a clean directory and resolves the lock file, was the one describing production.

5. The twenty-eight are the same defect, and they are worse

The blocks that decoded cleanly did so because their arguments happen to exist in both schemas. Nothing about that makes their values right: they are still another account’s CIDR, another team’s tag set, another environment’s bucket policy. The import ran, so the state now holds the live object, and the plan is comparing that object against a description of somewhere else. Nineteen in-place changes and three replacements is what that comparison produced, and a forced replacement applied to an object that has been serving production for three years destroys it and builds a new one.

The six loud failures cost four hours. The twenty-eight quiet ones were one terraform apply away from an outage.

Resolution

  1. Stop editing the six blocks. Each deletion produces the next error from the same file, because the file describes a different schema; the loop has no end and every pass through it removes information.
  2. Freeze the pull request. Nothing here is applied until the whole plan is empty, because terraform apply executes the entire plan and there is no way to apply the six repaired addresses without also applying the nineteen changes and three replacements.
  3. For each failing address, delete the hand-written resource block and leave the import block in place. -generate-config-out writes configuration only for import targets that have no resource block, so the hand-written one has to go first.
  4. Run terraform plan -generate-config-out=generated.tf and read the result. The provider has written a block against its own current schema, populated from the object it just read. This is the first configuration in the incident that was derived from the thing it describes.
  5. Review every generated block by hand before adopting it. Generated configuration is correct about the object and indifferent to house style: names, tags, ordering and the use of variables will all need editing, and each edit has to survive the next check.
  6. Re-plan after each edit and keep going until the plan is empty for that address. An edit that reintroduces a diff has reintroduced the original defect in miniature.
  7. Apply the same treatment to the twenty-eight that imported. Take the nineteen changes one at a time and decide, per attribute, whether the object is right or the configuration is; the answer is almost always the object, because the object is production.
  8. Treat the three replacements as a separate, escalated question with a named owner. A forced replacement during an adoption is Terraform proposing to destroy a running production object in order to make it match a file copied from a dead repository, and it should not be resolved by whoever is holding the pull request.
  9. Remove every import block once it has been consumed - a block left in configuration makes the next plan attempt the import again against an address already in state, and fails from then on - and record in the pull request which addresses were generated and which were hand-written, so the reviewer can see where each block came from rather than only what it says.

Verification

  1. The whole plan is empty, read by exit code and not by eye: terraform plan -detailed-exitcode returning 0 means no changes; 2 means changes were found and the adoption is unfinished. Reading a long plan and concluding "nothing important" is the failure this check exists to prevent.
  2. Every object answers independently through the provider CLI, not through Terraform. the Terraform read and the plan both go through the same provider read path, so a provider that returns a partial object satisfies both; an out-of-band describe call is the only cross-check.
  3. A freshly initialised directory reproduces the result. Delete .terraform, run terraform init, and re-plan: this is the check the disagreeing laptop would have failed, and it is the difference between validating against your history and validating against the repository.
  4. The provider version the plan resolved matches .terraform.lock.hcl, in CI and locally. terraform providers and the lock file must name the same version, or the schema being validated against is an accident.
  5. No import block survives in the configuration. Grep for import { after the apply; a leftover block breaks every subsequent plan and the error it produces looks nothing like its cause.
  6. A second plan, run after the apply and after a deliberate wait, is still empty. An adoption that is empty immediately and non-empty an hour later has adopted an attribute that the provider computes rather than stores.

Prevention

  • Derive configuration from the object. Copying a block from another repository produces a description of that repository’s estate which happens to compile here, and compiling is not evidence about anything. Use -generate-config-out where the provider supports it and spend the saved effort on reading what it wrote.
  • Pin the provider and commit the lock file. The schema a block is checked against should be a property of the repository, not of whichever machine ran the plan. Without the lock file, “works on my laptop” is not a joke, it is a description of the validation model.
  • Initialise CI from a clean directory. A cached .terraform lets the plan job inherit whichever plugin the last branch left behind, and silently converts the most authoritative check in the pipeline into the least.
  • Define a finished import as an empty plan, and adopt in small batches. The command’s exit status, a new state entry and a readable terraform state show are all equally true of an import whose configuration describes nothing that exists; and thirty-four unverified claims arriving together are reviewed by someone who cannot hold thirty-four objects in their head.
  • Read the provider’s upgrade guide before reusing anything written against an older major version. The removals and the newly-required arguments are documented; the six failures were a published list nobody had read.