Terraform import and moved blocks — GCS bucket into a child module
State already knows the object path. Today you teach it which resource address owns an existing bucket, then move that address into a module without a destroy.
<!-- hal:authoritative:yaml -->
State already knows the object path. Today you teach it which resource address owns an existing bucket, then move that address into a module without a destroy.
§I — Frame
Friday this arc filled S3 bucket/key/region at terraform init time. Tuesday put check-block assumptions on AWS resources. Saturday taught terraform_remote_state across stacks. Those spends stay filed. They answer where state lives and how stacks read each other.
Today the track is Terraform on GCP. The concrete referent is a google_storage_bucket that already exists in a project: console-born, CLI-born, or left behind by another tool. You need Terraform to manage it. Then you need to pull that resource into module.logs without Terraform proposing destroy and create.
Lab 03 and Lab 11 name the drill on AWS S3. The mechanics travel. Declarative import attaches world to address. moved renames the address when the configuration shape changes. The success criterion is a clean no-op plan.
Brikman states the trap in Valid Plans Can Fail: terraform plan only sees what is already in state. An out-of-band bucket is invisible until you import it. Import never writes configuration for you. You write the resource block first. Then you bind an ID.
§II — Foundations: three rules
Fact one. Import binds an existing object to a configuration address.
resource "google_storage_bucket" "logs" {
name = "acme-app-logs-prod"
location = "US"
project = var.project_id
uniform_bucket_level_access = true
}
import {
to = google_storage_bucket.logs
id = "acme-app-logs-prod"
}
The to address must match a resource block that already exists in code. The id is provider-specific. For google_storage_bucket, the import ID is the bucket name (see provider docs). Lab 03's AWS shape uses the bucket name the same way. Apply (or plan+apply, depending on your workflow version) records the binding in state. After a successful import, a plan that matches reality reports no changes for that address.
Fact two. Moved preserves identity when the address changes.
module "logs" {
source = "./modules/logs"
name = "acme-app-logs-prod"
# ...
}
moved {
from = google_storage_bucket.logs
to = module.logs.google_storage_bucket.this
}
Without moved, deleting the root resource and adding the module resource looks like destroy plus create. With moved, Terraform rewrites the state address and keeps the same remote object. Lab 11's target end state is exactly that: managed in the refactored structure, state continuity preserved, final plan a no-op.
Fact three. The finish line is no-op, not "import succeeded."
Import can succeed while attributes still drift. Uniform bucket-level access, versioning, lifecycle rules, labels: if code disagrees with the live bucket, the next plan proposes updates. Lab 03 and Lab 11 both grade on plan outcome. Align configuration to the real bucket (or deliberately change the bucket) until the plan is empty. That empty plan is the proof.
§III — Worked path: brownfield GCS into modules/logs
Assume the bucket acme-app-logs-prod already exists. Root starts flat (Lab 03 posture).
Step 1. Commit a matching google_storage_bucket.logs block. Match name, location, and the attributes you intend Terraform to own. Do not invent a second bucket.
Step 2. Add the import block with to = google_storage_bucket.logs and id = "acme-app-logs-prod". Run plan. Confirm the import action appears for that address and that no accidental create of a different name appears.
Step 3. Apply. Confirm state lists google_storage_bucket.logs with the live ID. Remove the import block after the binding is durable if your team prefers import blocks as one-shot records (many teams leave them briefly, then delete once state is trusted).
Step 4. Extract ./modules/logs with an internal google_storage_bucket.this (or whatever name the module uses). Wire module "logs" at the root. Add the moved block from the old root address to the module address.
Step 5. Plan again. Expect move (or no-op if already applied) without destroy/create of the bucket. Fix residual attribute drift until Lab 11's success criterion holds: clean no-op.
Optional CI gate: save terraform plan -out=tfplan and let the paired Python census classify import, move, replace, and residual updates. Humans still own the no-op call.
During the attribute-alignment pass, prefer reading the live bucket once with gcloud storage buckets describe gs://acme-app-logs-prod --format=json (or the paired Go census) and copying only the fields your module will own. Leave unmanaged fields out of the resource block when the provider permits omission. Fighting every console default is how import afternoons turn into accidental updates on labels nobody asked Terraform to own.
Provider versions matter for import-block timing. Modern Terraform applies import blocks during plan/apply as first-class configuration. Older muscle memory still reaches for terraform import ADDRESS ID on the CLI. Both end at the same state binding. Prefer the block in shared modules so the intent is reviewable in pull requests, the same way Lab 11 expects the refactor to live in code.
When the module already existed in another stack, do not cross-import into two states. One remote object, one authoritative address. If a second root still holds the old address, state rm or a removed block (Lab 26 adjacency; not today's spine) belongs in that other root before you claim ownership here.
§IV — Failure modes
Importing without a resource block. Terraform has nowhere to attach the ID. Write the block first.
Wrong import ID. GCS bucket IDs are names, globally unique. Project number mistakes belong to other google resources. Read the resource's Import section in the provider docs every time.
Moving without matching provider configuration. The module must resolve the same provider (and aliases, if any) that already manage the bucket. A second google provider with a different project is a different world.
Treating import as code generation. Brikman points at terraformer-class tools for bulk brownfield. Today's lesson stays on one bucket and honest HCL. -generate-config-out is an exam-adjacent helper, not a substitute for reviewing attributes.
Forgetting that backend init is prior art. Partial backend config (09-11) still decides which state file receives the import. Importing into the wrong key is a silent second ledger. Init first. Import second.
§V — Connection to prior lessons
09-11 taught init-time injection of the state object path. 09-05 taught reading another stack's outputs. 07-31 taught GCS backend prefixes and promotion boundaries on GCP. Today sits beside those: once the ledger file is chosen, import and moved edit which addresses that ledger believes it owns. Check blocks (09-08) remain the place for assumptions after the address is stable.
§VI — Closing
Write the resource. Import the existing GCS bucket by name. Extract the module. Ship a moved block. Demand a no-op plan before you call the refactor done. Examine your roots: every console-born bucket you pretend Terraform already owns is a latent create waiting for the wrong afternoon.
Related
- Paired Dev (Python):
- Paired Cert:
- Paired Go:
- Prior Ops (partial backend):
- Prior Ops (GCS workspaces):
- Lab 03:
- Lab 11:
- Language hub: