Lab 11 — Click-ops → IaC Migration: Import a Running Resource to Zero Drift¶
Type 12 · Migration / Brownfield. ← Back to the module concept
Type 12 · Migration / Brownfield. You take a resource that already exists — created by hand, not by
Terraform — and adopt it under IaC incrementally, without breaking it: import it into state, write
the matching HCL, reconcile the post-import diff to zero drift, and prove the resource served
throughout with a rollback available at every step. The deliverable is the migration runbook + the
zero-drift plan proof + a rollback note — not a writeup. No grader; you verify your own work against
the observable success criteria below.
Setup¶
Lab env to be built at promotion — this module is the curriculum's first Type 12 and has no existing
plaintext-labsdirectory yet. The shape below is the spec (see the Lab-env spec at the end of this file); it matches the OpenTofu +local-provider pattern of Modules 02/03, so it stands up with zero cloud credentials and zero cost. Until the env exists, you can run the entire lab against the local provider yourself by following the steps — every command below is real and runs on a laptop withopentofuinstalled.
git clone https://github.com/plaintext-security/plaintext-labs
cd plaintext-labs/automation/<NN>-clickops-iac-migration
make up # OpenTofu in Docker
make setup # creates the "click-ops" resources OUT OF BAND (no Terraform) — the brownfield baseline
make plan # the zero-drift check: passes (exit 0) only once import + reconcile is complete
make shell # drop into the container to run tofu commands
make down
make setup is the stand-in for "someone clicked this into existence": a small script writes real files
on the container filesystem (and registers a local-provider-managed artifact) without any
Terraform — so when you start, the resource is running and the Terraform state is empty. That gap is
the whole lab. (A cloud variant of the same shape — a security group / bucket created via the AWS CLI,
then imported — ships commented as a reference; the local provider teaches the identical mechanics for
free.)
Authorization note: importing and reconciling against a cloud account reads and rebinds real, billable resources. In this lab only the
localprovider is active, so nothing is billable and the "live resource" is a file. The moment you point any of this at a real account: only ever import infrastructure you own or have written permission to manage, and read every post-importplandiff before you reconcile — and neverapplya change to the resource you're trying to leave untouched.
Scenario¶
An estate was built by hand in a console before anyone on the team wrote IaC: a couple of resources that
are live, serving, and reproducible by exactly nobody. You've been told to "put it under Terraform" —
and the one rule is no outage. You cannot tear it down and rebuild it from clean HCL; it has to keep
serving while you adopt it. So you migrate the strangler-fig way: import one resource at a time, write
the code to match the reality that's already running, prove plan shows zero drift, and keep a one-line
rollback at every step. The old path serves the entire time; you're wrapping it in code, not replacing
it.
The rhythm of each slice: describe → import (state only) → plan (read the drift) → reconcile the
code to match → plan again → No changes.
Do¶
Migrate the brownfield estate under IaC, one resource at a time, proving no outage and zero drift.
Establish the brownfield baseline (the resource exists; the code does not)
1. [ ] make setup — create the click-ops resources out of band, then confirm they're live (the files
exist, the artifact is serving) and that tofu plan against your empty config shows Terraform
knows nothing about them. Record this starting state: running resource, empty state — that gap is
what you're closing.
2. [ ] Prove the trap before you avoid it (predict, then confirm). What happens if you "just write
the IaC"? Write a fresh resource block describing the existing resource and run tofu plan without
importing. Read the diff: Terraform plans to + create it — i.e. produce a duplicate or, on a
name collision, a destroy-and-recreate. Do not apply this. This is the outage the big-bang move
causes; seeing it on the plan (not in production) is the point.
Migrate slice 1 — import, reconcile to zero drift
3. [ ] Write the matching resource block first. For the first resource, author the HCL resource
block (correct type + a label you choose) — or declare an import {} block and draft a first pass
with tofu plan -generate-config-out=generated.tf, then review and correct every line (it's a
starting point, not trusted output).
4. [ ] Import — state only. Bind the real resource to your code's address: tofu import
<type>.<label> <real-id> (or apply the import {} block). Confirm the resource is untouched —
it's still serving, byte-for-byte identical; only the state file changed (tofu state list now shows
the address).
5. [ ] Read the post-import drift. Run tofu plan. It will almost certainly show a diff — attributes
the real resource has that your HCL doesn't yet name. Each line is a gap between code and reality.
6. [ ] Reconcile by editing the CODE, never the resource. Close every diff line by adding/adjusting
attributes in your HCL to match what the resource actually has — not by applying changes to the
resource. Re-run tofu plan. Iterate until it reports No changes. Your infrastructure matches the
configuration. That line is slice 1's done signal: zero drift.
7. [ ] Confirm no outage + capture the rollback. Confirm the resource served continuously (it was
never destroyed/recreated — import touches only state). Write the one-line rollback for this slice and
verify it: tofu state rm <type>.<label> forgets the resource without touching it; confirm the
resource is still live and plan is back to "Terraform knows nothing." Then re-import to restore.
Migrate slice 2 — same loop, prove the rest still works
8. [ ] Repeat steps 3–7 for the second resource. The strangler-fig constraint: while you migrate slice 2,
prove slice 1 is still under management and still serving (its plan stays No changes) and the
un-migrated remainder is still live. You shift one slice at a time; nothing else moves.
Prove the whole estate is migrated with zero drift
9. [ ] With every resource imported and reconciled, run tofu plan over the full config and confirm a
single No changes across the whole estate. The click-ops estate is now governed code: the
imported reality equals the HCL, end to end. Save this plan output — it's your proof.
Success criteria — you're done when¶
- [ ] You can show the starting gap: the resource was live with an empty Terraform state, and a
no-import
planproves Terraform would have created a duplicate (the trap you avoided). - [ ] Every resource is bound to code via
import(state changed; the resource was never destroyed/recreated — verified, not assumed). - [ ] The post-import diff was closed by editing the HCL to match reality, and you can point to at least one attribute you added because the real resource had it and your first draft didn't.
- [ ]
tofu planover the full migrated estate reportsNo changes. Your infrastructure matches the configuration.— zero drift, captured as output. - [ ] You have a per-slice rollback (
tofu state rm) that you ran at least once and proved leaves the resource serving — the old path never went down.
Deliverables¶
Commit to your portfolio repo:
- migration-runbook.md — the ordered, strangler-fig runbook: per slice, the resource, the import
command/import {} block used, the attributes you had to add to reconcile, and the order you migrated
in (and why that order — least-risky first).
- *.tf — your final, reconciled HCL for the whole estate (the code that now equals reality).
- zero-drift-proof.md — the terminal capture of the full-estate tofu plan showing No changes, plus
the before state (no-import plan showing a + create for the same resource) so the migration is
visible as a transition.
- rollback-note.md — the per-slice tofu state rm rollback, with the one capture proving the resource
stayed live after a rollback.
Do not commit: terraform.tfstate (it now contains the real resource bindings — treat it as the
credential Module 02 taught it is), .terraform/, generated.tf scratch output, or the seeded
make setup artifacts (they live in the lab repo, not yours).
Automate & own it¶
Required — this is the migration's safety surface made into a habit. The zero-drift plan is only
useful if it can't silently regress. Build make plan into a drift gate: a small script
(drift-check.sh) that runs tofu plan -detailed-exitcode over the migrated estate and asserts the
exit code is 0 (no changes) — failing loudly on exit 2 (drift present) and distinguishing it
from exit 1 (a tooling error, which is not a clean pass). Wire it as the make plan target so
"prove the imported reality still equals the code" is one command anyone can run after the migration.
Have a model draft the exit-code logic; review every line — confirm a crashed plan (1) can never
read as zero-drift (0), and that the gate fails on real drift, not on noise. This is the same
detailed-exitcode pattern Module 03's gate uses, pointed at drift instead of misconfig. (AI drafts; you
prove the signal is honest and you own it.)
AI acceleration¶
Ask a model to write the matching HCL for a resource you describe — then refuse to trust it: import,
plan, and read the diff line by line, because the model guessed at attributes the real resource may or
may not have. The transferable skill is reconciling that diff by editing the code, and catching the
model's most dangerous instinct: when you say "make the plan clean," it will suggest apply-ing its
guesses onto the live resource — the exact move that mutates the thing you're migrating. Make it
draft; you decide, per diff line, "edit the code to match" versus "never apply this to the resource."
Then ask it: "what attributes does this resource type usually have that my config is missing?" — and
verify each against the actual plan, not the model's claim.
Connects forward¶
This closes the loop opened in Module 02 (build greenfield IaC) and Module 03 (gate it): now that a
brownfield resource is imported and at zero drift, it is finally governed code — and therefore
scannable by the Module 03 checkov/tfsec gate, which could see nothing while the resource lived only
in the console. The same import→reconcile→zero-drift loop is the foundation for Module 04's drift
detection (Type 16): once everything is under management, tofu plan (or a config-management
--check) becomes the steady-state drift meter, and reconciliation is the loop you run on a schedule.
Brownfield adoption is the one-time migration; drift detection is the ongoing version of the same
zero-drift discipline.
Marketable proof¶
"I migrate brownfield infrastructure under IaC without downtime — strangler-fig, one resource at a time:
terraform importto adopt the running resource into state, write the matching HCL, and reconcile the post-import diff by editing the code untilplanreports zero drift. The resource serves throughout, every slice has astate rmrollback, and once imported it's finally under the same security-scan gate as everything else. I can explain why an empty state makes Terraform try to create a duplicate, and why you reconcile the code to the resource, never the resource to the code."
Stretch¶
- Prefer the
import {}block over the CLI command for the whole migration, so each import is a reviewable line inplan(the modern, code-reviewable form) — and discuss why that's better for a team migration than ad-hoctofu importruns. - Reconcile a deliberately-wrong draft: have a model generate HCL with a couple of wrong attribute
values, import, and use the
plandiff to find and fix them — proving the diff (not the draft) is the source of truth. - Order-of-operations under dependencies: add a second resource that depends on the first (a file referencing another's path) and migrate them in the correct order, showing why strangler-fig sequencing matters when resources reference each other.
Lab-env spec (to be built at promotion)¶
This module has no plaintext-labs directory yet; build it at promotion under
plaintext-labs/automation/<NN>-clickops-iac-migration/ (final <NN> per the placement decision —
insert-after-03-and-renumber, or append-as-11). Match the OpenTofu + local-provider shape of Modules
02/03 so it runs with zero cloud cost. It must contain:
setup.sh(driven bymake setup) — creates the brownfield baseline out of band, with no Terraform: writes 2–3 real files on the container filesystem (and, where thelocalprovider is the managed type, alocal_file-style artifact created directly so it exists before any state does). Idempotent; re-runnable. This is the "someone clicked it into existence" stand-in — the resources must be live with an empty Terraform state at lab start.- A starter HCL skeleton —
provider "local"configured and an empty (or commented) resource area, so step 2's "just write the IaC" no-importplandemonstrably shows a+ createfor an already-existing resource (the trap). The full reconciled*.tfis the learner's deliverable, not shipped. - An import target reference — the real resource ID/path for each baseline resource (so the learner
can run
tofu import <addr> <id>), documented in the lab README, plus animport {}-block example for the stretch. make plan→ the zero-drift gate — runstofu plan -detailed-exitcodeover the migrated estate and asserts exit 0 == zero drift, distinguishing exit2(drift) from exit1(error). This is the env'sdemoequivalent and the success signal; it should fail before migration and pass after.Makefile—up/setup/shell/plan/down(+ aresetthat re-runssetupfrom clean). The container shipsopentofuinstalled, matching Modules 02/03.- A commented cloud reference — the same migration against a real AWS resource (e.g. a security group
or S3 bucket created via the CLI, then
tofu import-ed), shipped commented as the bridge to real accounts — never run in CI (no.ci-demo; it needs credentials and is a learner/cloud exercise). - CI note: the local-provider path is CI-runnable — add
.ci-demoonly oncemake up && make setup && make plan(fail-before / pass-after the learner completes the import) is green on a Linux runner; sincemake planis expected to fail until the learner finishes, the marker likely stays off (it's a learner-exercise lab, like Module 03's gate).
Comments
Sign in with GitHub to comment. Choose the type: Feedback (errors or suggestions on this page) · Hints (help for fellow learners — no spoilers) · General (anything else).