Chapter 4: State on GCS
Topic 25

Importing Existing Infrastructure

Import

Plenty of GCP resources already exist before Terraform shows up — a bucket clicked into the Console, a project created by gcloud, a service account someone made by hand. The import capability brings them under Terraform without destroying and recreating them, recording the live resource into state so the next plan sees it as managed rather than missing.

The modern way to do this is the declarative import block, which you write in your config and can pair with -generate-config-out to have Terraform draft the matching HCL for you. It replaces the older terraform import CLI command for everything but quick one-offs, because a block is reviewable in a pull request and runnable in CI.

The import Block

An import block declares that a resource address maps to an existing object. You name the to address — the resource in your config — and the id of the live GCP object. On the next plan, Terraform reads that live resource into state, and the apply records it. No resource is destroyed or recreated; the object is adopted in place.

Adopting a Console-built bucket
import {
  to = google_storage_bucket.raw
  id = "hatch-pipeline-dev/hatch-events-raw"   # <project>/<name> for a bucket
}

resource "google_storage_bucket" "raw" {
  name     = "hatch-events-raw"
  location = "US"
}

The block and a matching resource block work together: the import tells Terraform which live object the address refers to, and the resource describes the desired config for it going forward. After the apply records the import, the import block has done its job and can be removed.

GCP Import ID Formats

The id is provider-specific and varies per resource type. A bucket wants <project>/<name>, a project wants its project id, and an IAM-related resource may want a self-link or a projects/.../roles/... form. There is no single universal shape — the import ID for each resource is documented on that resource's Registry page, and getting it wrong is the usual first failure.

The failure is also easy to misread. Hand the provider a bucket's gs:// URL or self-link where it wants <project>/<name>, and you get an opaque "resource not found" that is really an ID-shape error, not a missing resource. Check the resource's import docs on the Registry before writing the block, and the first attempt usually works.

Generating Config

You do not have to hand-transcribe every attribute of the resource you are importing. Run terraform plan -generate-config-out=generated.tf and Terraform writes a draft HCL block for each imported resource, populated from the live object. For a bucket with a dozen settings, that draft saves real typing and avoids transcription mistakes.

Drafting config for an import
# with the import block in place, generate a draft resource
terraform plan -generate-config-out=generated.tf

The output is a starting point, not finished config. It emits read-only and default attributes — fields the provider computes or that already hold their defaults — which produce plan noise or outright errors if left in. Treat generated.tf as a draft to prune: keep the attributes that define the resource, delete the computed ones, and move what remains into your real config.

Import Then Reconcile

After importing, run a plan before you apply anything further. If that plan shows changes, your HCL does not yet match the live resource, and applying would silently mutate the thing you just adopted to match your half-written config — exactly the opposite of a clean adoption. The discipline is import, read the plan, converge the config, then apply.

A successful import is one where the post-import plan shows no changes. That no-change plan is the proof your config faithfully describes the imported reality. Only once you reach it have you actually adopted the resource as it stands, rather than adopted-and-then-modified it on the first apply.

Adopting a live resource
write import block
plan reads resource
converge HCL
apply

Bulk and Bootstrap Imports

Adopting a whole Console-built environment means many imports — one per resource. You can write many import blocks, or fall back to the older terraform import CLI run once per resource, but the blocks win for anything beyond a one-off. They are reviewable in a pull request, so a teammate can check the address-to-id mapping before it runs, and they execute in CI like the rest of the config.

This is the difference that matters when you are bringing a real environment under management. The imperative command does the import on one machine and leaves no record; the declarative blocks are the record, version-controlled and replayable. For the Hatch pipeline's existing Console-built buckets and accounts, a set of reviewed import blocks is how the adoption stays auditable.

Common Mistakes
  • Getting the import ID format wrong — using a bucket's gs:// URL or self-link where the provider wants <project>/<name> — and reading an opaque "resource not found" that is really an ID-shape error; check the resource's Registry import docs.
  • Importing a resource and applying immediately without reading the plan, so the first apply silently changes the live resource to match a half-written config — import, then converge the HCL, then apply.
  • Trusting -generate-config-out output verbatim — it emits read-only and default attributes that produce plan noise or errors; treat it as a draft to prune, not finished config.
  • Recreating instead of importing — deleting a Console-built bucket and re-adding it in Terraform — when it holds data, losing every object, where an import would have adopted it in place.
  • Using the imperative terraform import command on a shared config and leaving no record, so the adoption is invisible to teammates and never runs in CI.
Best Practices
  • Use declarative import blocks (not the legacy CLI command) so adoptions are reviewable in a pull request and runnable in CI.
  • Look up each resource's import ID format on its Registry page before writing the block, since the shape differs per resource.
  • Run -generate-config-out to draft config, then prune read-only fields and run a clean plan before applying.
  • Always reach a no-change plan after import, confirming your HCL matches the live resource, before the first apply touches it.
  • Remove the import block once the apply has recorded the adoption, leaving only the resource block that now manages it.
Comparable tools AWS provider identical import block and terraform import Config Connector the acquire annotation adopts existing resources gcloud can read a resource's config to crib from, but cannot import it into state

Knowledge Check

What does an import block do when you apply it?

  • It records an existing GCP resource into state at the named address, adopting it in place without recreating it
  • It destroys the existing live resource and recreates a byte-identical replacement under Terraform management
  • It copies the resource's full configuration into a freshly provisioned bucket in the same project
  • It exports the resource's live attributes out to a local HCL file without ever touching state

You import a bucket and the very next plan shows changes. What is the correct interpretation?

  • Your HCL doesn't yet match the live resource; converge the config to a no-change plan before applying
  • The import silently failed and the whole block must be re-run from a clean state
  • Terraform always shows harmless phantom changes right after any import, so you just apply once and they clear themselves with no effect on the live bucket
  • The bucket was deleted during the import step and now needs to be recreated from the config

Why must -generate-config-out output be edited before use?

  • It emits read-only and default attributes that produce plan noise or errors, so it is a draft to prune
  • It is emitted in an encrypted, non-human-readable form that must first be decrypted with the state bucket's CMEK key before you can open and edit it
  • It omits the resource name and address entirely, which you must fill in by hand first
  • It generates the config as raw JSON, which the Terraform parser cannot read directly

Why are declarative import blocks preferred over the legacy terraform import command for a real environment?

  • They are reviewable in a pull request and runnable in CI, leaving an auditable record of each adoption
  • They import noticeably faster because the block skips the live read during plan
  • They are the only supported way to import a bucket that already holds object data, since the legacy command refuses to adopt any non-empty resource
  • They automatically detect and fix any mismatched or malformed import ID format for you

You got correct