Import blocks, and the resources we had been pretending to manage

Fourteen resources created by hand during an incident in 2019 and referenced in the configuration by hard-coded identifier ever since. The infrastructure code described a system it did not fully manage, and everybody knew it and nobody had a safe way to fix it.

The symptom

# what the configuration looked like
resource "aws_instance" "app" {
  subnet_id = "subnet-0a4f8c2e1d7b"   # created 2019-08, by hand
  vpc_security_group_ids = [
    "sg-08c1f4a7e9b2",                # ditto
  ]
}

# 14 of these. every one a value that terraform cannot
# see, cannot verify, and cannot recreate.
what that meant: a plan could not detect drift on them,
a rebuild in a new region was impossible, the disaster
recovery document said "recreate the security groups
from the screenshots", and one of them had a rule added
in 2021 that nobody could account for.

Why it happens

An incident creates a resource by hand because that is faster, and the intention is always to bring it under management afterwards. The old import path was a command run against state, unreviewable and irreversible, so nobody wanted to be the one to run it.

The fix

An import block, in a plan you can read

import {
  to = aws_security_group.app
  id = "sg-08c1f4a7e9b2"
}

resource "aws_security_group" "app" {
  name   = "app-sg"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port       = 443
    to_port         = 443
    protocol        = "tcp"
    security_groups = [aws_security_group.lb.id]
  }
}
$ terraform plan

Terraform will perform the following actions:

  # aws_security_group.app will be imported
    resource "aws_security_group" "app" {
        id = "sg-08c1f4a7e9b2"
    }

  # aws_security_group.app will be updated in-place
  ~ resource "aws_security_group" "app" {
      - ingress {
          - from_port = 22
          - cidr_blocks = ["0.0.0.0/0"]
        }
    }

Plan: 1 to import, 1 to change.

The plan shows the import and the drift in one output, which the old command could not — it imported, and the next plan told you what was wrong, in a separate step nobody connected to the import. The SSH-from-anywhere rule in that diff is the 2021 addition nobody could account for.

Generating configuration, and reading it critically

$ terraform plan -generate-config-out=generated.tf

$ wc -l generated.tf
412

# for 14 resources. it emits every attribute, including
# every default, including the computed ones.

$ grep -c 'arns*=' generated.tf
14        # every one of which must be deleted: computed

The generated file is a first draft and reviewing it down to what is intentional is the actual work — four hundred lines became ninety. Leaving the computed attributes in produces a configuration that fights the provider on every plan, which is a worse state than not managing the resource at all.

The three that had drifted

  the SSH rule       0.0.0.0/0 on port 22, added during a
    2021 incident → removed; the bastion path had existed
    since 2020

  a bucket lifecycle   the configuration said 90 days, the
    bucket said 30. somebody changed it in the console to
    reduce a bill → 90 days, and the bill discussed

  an instance type   t3.medium in code, t3.large in
    reality — scaled up during a launch, never recorded
    → t3.large, and the plan stops proposing a shrink

The instance type is the one that would have caused an outage: bringing it under management without noticing would have produced a plan that resized production down, and applying it during a routine change would have been a surprise capacity reduction.

Check blocks for the runbook assertions

check "app_health" {
  data "http" "app" {
    url = "https://${var.hostname}/health"
  }

  assert {
    condition     = data.http.app.status_code == 200
    error_message = "health returned ${data.http.app.status_code}"
  }
}

check "no_public_ssh" {
  assert {
    condition = !contains(
      flatten(aws_security_group.app.ingress[*].cidr_blocks),
      "0.0.0.0/0"
    )
    error_message = "a security group allows ingress from anywhere"
  }
}

The second check encodes the thing we had just found, so it cannot come back silently. A failing check is a warning rather than an error and the exit code stays zero, which means CI has to grep for it — that is a rough edge and it is the price of checks not blocking an otherwise successful apply.

Removing the import blocks afterwards

an import block is idempotent: once the resource is in
state, it is a no-op. so leaving them costs nothing
except clutter.

we removed them after every environment had applied,
which took a fortnight and a checklist, because a block
removed before an environment applies means that
environment tries to CREATE the resource.

the check that made this safe:
  terraform state list | grep -c aws_security_group.app
  in each environment, before removing anything.

Verifying it worked

$ terraform plan
No changes. Your infrastructure matches the configuration.

$ terraform plan      # a week later, unchanged
No changes.

$ grep -cE '"(sg|subnet|vpc)-[0-9a-f]+"' *.tf
0                     # was 14 hard-coded identifiers

$ terraform plan -var-file=dr.tfvars -state=/dev/null 2>&1 | tail -1
Plan: 41 to add, 0 to change, 0 to destroy.
# the whole stack is now reproducible in a second region

The last one is the outcome that justified the exercise: a plan against an empty state proposing to create everything means the disaster recovery document can stop referring to screenshots. Two consecutive no-change plans a week apart is the assertion that nothing is fighting the provider.

What this costs

Configuration generated by a tool and owned by us. Ninety lines that nobody wrote from scratch, describing resources whose original intent is lost — the review was careful and it is still a description reverse-engineered from a running system rather than a statement of what was meant.

Bringing resources under management also means a plan can now destroy them. Fourteen resources that were previously immune to a bad plan are now within reach of one, which is the trade: manageable and therefore breakable, against unmanaged and therefore irreproducible.