Mission 1.3 · Stage 1 — State
Drift, Refresh-Only Plans & Importing Existing Resources
Goal: Detect and accept (or revert) changes made outside Terraform, and bring a log group a teammate created by hand under Terraform management with an import block.
By the end of this mission
- Tell drift apart from code changes in a plan
- Use
plan -refresh-onlyto accept intentional outside changes - Import an existing resource with an
importblock - Generate starter config with
-generate-config-out
Part 1
Understand it first
Drift and what to do about it
DRIFT is any difference between real infrastructure and what state last recorded, caused by changes outside Terraform: a console edit during an incident, an AWS-side default change, another tool touching the same resource. A normal plan detects drift during refresh and proposes changing reality back to match your code.
Sometimes the outside change is correct and should be kept. Then you either update the code to match (so the plan shows nothing), or, when the attribute is legitimately managed elsewhere, tell Terraform to ignore it (Stage 4's ignore_changes).
Refresh-only mode
terraform plan -refresh-only reads reality and shows how STATE would be updated to match, with no proposed changes to infrastructure. terraform apply -refresh-only writes that into state. Use it to deliberately acknowledge drift, for example after an emergency change you'll codify next, and to see drift on its own without it being mixed in with code changes.
It replaces the old terraform refresh command, which updated state silently without showing you anything first.
Importing: adopting resources Terraform didn't create
Resources created by hand, by another tool, or before your team used Terraform aren't in state, so Terraform doesn't know they exist. If you write a resource block for one and apply, Terraform tries to CREATE it and fails with 'already exists'. IMPORT records an existing object in state under an address, so from then on Terraform manages it like anything it created.
Since Terraform 1.5, imports are declared in code with an import block (to = address, id = the provider-specific import ID listed on each resource's Registry page). Unlike the old terraform import CLI command, the block goes through plan and review like any other change, and it can generate a first draft of the resource config for you.
Part 2
Your project after this mission · 2 files change
- infra/
- .terraform.lock.hcl
- backend.tf
- imports.tfnew
- locals.tf
- logs.tfnew
- main.tf
- outputs.tf
- providers.tf
- terraform.tfvars
- variables.tf
- versions.tf
- .gitignore
Part 3
Build it, step by step
- 1
Create some drift and look at it with refresh-only
Simulate an on-call engineer who turned off versioning on the assets bucket from the console during an incident. Then look only at the drift:
-refresh-onlyshows 'Objects have changed outside of Terraform' and proposes to update state, not infrastructure.terminal$ BUCKET=$(terraform output -raw assets_bucket_name)aws s3api put-bucket-versioning --bucket "$BUCKET" --versioning-configuration Status=Suspendedterraform plan -refresh-only── expected output ──aws_s3_bucket_versioning.assets: Refreshing state... [id=shoplite-dev-assets-3f9a]Note: Objects have changed outside of TerraformTerraform detected the following changes made outside of Terraform since thelast "terraform apply" which may have affected this plan:# aws_s3_bucket_versioning.assets has changed~ resource "aws_s3_bucket_versioning" "assets" {id = "shoplite-dev-assets-3f9a"~ versioning_configuration {~ status = "Enabled" -> "Suspended"# (1 unchanged attribute hidden)}}This is a refresh-only plan, so Terraform will not take any actions to undothese. If you were expecting these changes then you can apply this plan torecord the updated values in the Terraform state without changing any remoteobjects. - 2
Decide: revert the drift
Suspending versioning on the assets bucket wasn't a change you want to keep. A normal plan shows the corrective update, and applying it puts the bucket back the way the code says. Whenever you see drift, first decide whether the code or reality is right; don't apply either way on autopilot.
terminal$ terraform apply── expected output ──# aws_s3_bucket_versioning.assets will be updated in-place~ resource "aws_s3_bucket_versioning" "assets" {~ versioning_configuration {~ status = "Suspended" -> "Enabled"}}Plan: 0 to add, 1 to change, 0 to destroy....Apply complete! Resources: 0 added, 1 changed, 0 destroyed. - 3
Simulate a hand-made resource
A teammate created ShopLite's API log group in the console last week, with no retention setting (logs kept forever, which is a classic cost leak). ECS will write to it in Stage 3, so Terraform should own it.
terminal$ aws logs create-log-group --log-group-name /shoplite/dev/apiaws logs describe-log-groups --log-group-name-prefix /shoplite --query 'logGroups[].[logGroupName,retentionInDays]'── expected output ──[["/shoplite/dev/api",null]] - 4
Declare the import
The
importblock says: 'the existing object with ID/shoplite/dev/apishould be tracked at addressaws_cloudwatch_log_group.api'. For log groups the import ID is the name. Other resources use other IDs, such as a VPC ID or an ARN, and each Registry page has an 'Import' section that tells you which.infra/imports.tfwhole filehcl import { to = aws_cloudwatch_log_group.api id = "/shoplite/dev/api" } - 5
Let Terraform draft the config
There's no resource block yet.
-generate-config-outwrites one from the real object's attributes. Treat it as a draft: it includes every attribute, including defaults you'd never write by hand.terminal$ terraform plan -generate-config-out=generated.tfcat generated.tf── expected output ──aws_cloudwatch_log_group.api: Preparing import... [id=/shoplite/dev/api]aws_cloudwatch_log_group.api: Refreshing state... [id=/shoplite/dev/api]# aws_cloudwatch_log_group.api will be imported# (config will be generated)resource "aws_cloudwatch_log_group" "api" {name = "/shoplite/dev/api"retention_in_days = 0...}Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.# __generated__ by Terraformresource "aws_cloudwatch_log_group" "api" {kms_key_id = nulllog_group_class = "STANDARD"name = "/shoplite/dev/api"name_prefix = nullretention_in_days = 0skip_destroy = falsetags = {}tags_all = {}} - 6
Clean it up into real code, with the fix you actually want
Move it into
logs.tf, keep only what matters, and set a 14-day retention.tags_allis computed and can't be set. Deletegenerated.tf. Now the plan says1 to import, 1 to change: adopt the log group, then fix its retention, all in one reviewable plan.infra/logs.tfwhole filehcl resource "aws_cloudwatch_log_group" "api" { name = "/${var.project}/${var.environment}/api" retention_in_days = 14 }terminal$ rm generated.tfterraform apply── expected output ──# aws_cloudwatch_log_group.api will be updated in-place# (imported from "/shoplite/dev/api")~ resource "aws_cloudwatch_log_group" "api" {name = "/shoplite/dev/api"~ retention_in_days = 0 -> 14~ tags = {} -> null~ tags_all = {} -> {+ "Environment" = "dev"+ "ManagedBy" = "terraform"+ "Project" = "shoplite"...}}Plan: 1 to import, 0 to add, 1 to change, 0 to destroy....Apply complete! Resources: 1 imported, 0 added, 1 changed, 0 destroyed. - 7
Remove the import block
After a successful apply, the import block has done its job; once the resource is in state, leaving it is harmless but clutters the code. Delete
imports.tfand confirm a clean plan.terminal$ rm imports.tf && terraform plan── expected output ──No changes. Your infrastructure matches the configuration.
Checkpoint — you should now have
- ✓You reverted versioning drift after reviewing it with
plan -refresh-only. - ✓
aws_cloudwatch_log_group.apiis in state (terraform state list) with 14-day retention and default tags. - ✓
generated.tfandimports.tfare deleted;logs.tfis committed. - ✓
terraform planshows no changes.
Part 4
Break it on purpose
Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.
Break #1
Create a resource that already exists
Delete the log group from state without deleting it from AWS: terraform state rm aws_cloudwatch_log_group.api. Then run terraform apply.
Break #2
Import with the wrong ID
Put an import block back with id = "/shoplite/dev/apii" (a typo) and run terraform plan.
Part 5
Interview questions from this mission
What is drift and how do you detect it?
How do you bring existing, manually created resources under Terraform management?
What's the difference between terraform state rm and deleting a resource block?
When would you run apply -refresh-only?