Command Palette

Search for a command to run...

Hectal

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.

35 min Free — an empty log group costs nothing 7 steps 2 break-it drills

By the end of this mission

  • Tell drift apart from code changes in a plan
  • Use plan -refresh-only to accept intentional outside changes
  • Import an existing resource with an import block
  • 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.

Three ways reality and code can disagreediagram
Rendering diagram…

Part 2

Your project after this mission · 2 files change

shoplite/
  • 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. 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-only shows '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=Suspended
    terraform plan -refresh-only
    ── expected output ──
    aws_s3_bucket_versioning.assets: Refreshing state... [id=shoplite-dev-assets-3f9a]
     
    Note: Objects have changed outside of Terraform
     
    Terraform detected the following changes made outside of Terraform since the
    last "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 undo
    these. If you were expecting these changes then you can apply this plan to
    record the updated values in the Terraform state without changing any remote
    objects.
  2. 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. 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/api
    aws logs describe-log-groups --log-group-name-prefix /shoplite --query 'logGroups[].[logGroupName,retentionInDays]'
    ── expected output ──
    [
    [
    "/shoplite/dev/api",
    null
    ]
    ]
  4. 4

    Declare the import

    The import block says: 'the existing object with ID /shoplite/dev/api should be tracked at address aws_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. 5

    Let Terraform draft the config

    There's no resource block yet. -generate-config-out writes 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.tf
    cat 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 Terraform
    resource "aws_cloudwatch_log_group" "api" {
    kms_key_id = null
    log_group_class = "STANDARD"
    name = "/shoplite/dev/api"
    name_prefix = null
    retention_in_days = 0
    skip_destroy = false
    tags = {}
    tags_all = {}
    }
  6. 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_all is computed and can't be set. Delete generated.tf. Now the plan says 1 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.tf
    terraform 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. 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.tf and 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.api is in state (terraform state list) with 14-day retention and default tags.
  • ✓generated.tf and imports.tf are deleted; logs.tf is committed.
  • ✓terraform plan shows 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.

terminal
$ terraform apply
── what you'll see ──
# aws_cloudwatch_log_group.api will be created
+ resource "aws_cloudwatch_log_group" "api" {
...
aws_cloudwatch_log_group.api: Creating...
╷
│ Error: creating CloudWatch Logs Log Group (/shoplite/dev/api): operation
│ error CloudWatch Logs: CreateLogGroup, https response error StatusCode: 400,
│ RequestID: 3e1..., ResourceAlreadyExistsException: The specified log group
│ already exists
│
│ with aws_cloudwatch_log_group.api,
│ on logs.tf line 1, in resource "aws_cloudwatch_log_group" "api":
│ 1: resource "aws_cloudwatch_log_group" "api" {
╵

Break #2

Import with the wrong ID

Put an import block back with id = "/shoplite/dev/apii" (a typo) and run terraform plan.

terminal
$ terraform plan
── what you'll see ──
╷
│ Error: Cannot import non-existent remote object
│
│ While attempting to import an existing object to
│ "aws_cloudwatch_log_group.api", the provider detected that no object exists
│ with the given id. Only pre-existing objects can be imported; check that the
│ id is correct and that it is associated with the provider's configured
│ region or endpoint, or use "terraform apply" to create a new remote object
│ for this resource.
╵

Part 5

Interview questions from this mission

01

What is drift and how do you detect it?

02

How do you bring existing, manually created resources under Terraform management?

03

What's the difference between terraform state rm and deleting a resource block?

04

When would you run apply -refresh-only?

0/4 · 0%