Lab 01 — Terraform State Engine
Phase: 02 — Terraform, CDKTF & IaC State Internals | Difficulty: ⭐⭐⭐☆☆ | Time: 3–4 hours
ARM (Phase 01) is stateless: every
PUTre-derives reality from the resource provider, so there is no file to drift. Terraform is stateful: it remembers what it created in aterraform.tfstatefile, and the entire tool is the diff between the world you wrote (config) and the world it remembers (state). This lab builds that engine — the per-resource diff, theForceNew"replace not update" decision that destroys real infrastructure, the dependency ordering that flips for deletes, and the drift detector that catches the portal edit nobody told Terraform about.
What you build
A miniature Terraform plan/apply engine over three artifacts — config (desired), state (recorded reality), plan (the diff):
Resource—address("azurerm_storage_account.main"=type.name, the state key),attributes(a flat dict), and the set ofForceNewattribute names.diff_attributes(desired, current)— the leaf comparison: attribute →(old, new)for every changed / added / removed key.plan_resource(desired, current, force_new)— the four-way decision:create(no state) ·no-op(identical) ·update(only mutable attrs moved) ·replace(aForceNewattr moved → destroy-then-create).plan(config, state, force_new_by_type, deps)— the whole diff: classify every resource, mark orphaned state fordelete, and order it — creates/updates/replaces in topological order, deletes in reverse topological order. Returns aPlanwith.actionsand a.summarycount.apply(plan, config, state)— execute the plan, return the new state without mutating the input (the propertyterraform applydepends on to be re-runnable).detect_drift(state, real)—address → {attr: (state_val, real_val)}for resources whose real attributes diverge from recorded state: the out-of-band changeterraform plansurfaces after its refresh.
Key concepts
| Concept | What to understand |
|---|---|
| Three artifacts | config = desired, state = recorded reality, plan = the diff; apply executes the plan |
| The plan algorithm | per resource: compare config vs state → create / update / replace / delete / no-op |
ForceNew → replace | an immutable attribute changed ⇒ destroy-then-create (-/+), not in-place |
| Dependency DAG | build from references; apply in topo order, destroy in reverse topo order |
| Drift | real ≠ state (out-of-band change); refresh surfaces it; state is a claim, not truth |
| Idempotency | re-planning a converged config is all no-op; apply never mutates the input |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers and signatures in place |
solution.py | complete reference; python solution.py runs a worked plan + drift example |
test_lab.py | the proof — 28 tests; run them red, make them green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # the worked example
Success criteria
- All 28 tests pass against your implementation.
- You can explain why
test_plan_resource_force_new_boundaryis the crux of the lab: the same diff is anupdatewith an emptyforce_newand areplacethe instant one changed attribute is immutable — and areplacedestroys real infrastructure. - You can explain why
test_plan_deletes_orphaned_state_in_reverse_orderreverses the order: you cannot delete a subnet a NIC still sits in, so dependents die first. - You can explain why
applymust not mutate its inputstate(a partially-applied run must leave the on-disk state recoverable; tests assert it stays untouched). - You can read a
terraform planand instantly flag every-/+(replace) line as a potential outage before you typeyes.
How this maps to real Azure (and real Terraform)
| This lab | Real Terraform / AzureRM |
|---|---|
state dict (address → attributes) | terraform.tfstate JSON, stored in an Azure Storage blob backend |
detect_drift | the refresh phase of terraform plan (reads each resource via the AzureRM provider, compares to state) |
plan_resource → replace | the provider schema's ForceNew: true on an attribute (e.g. a storage account's location, account_kind) |
deps map | edges Terraform infers from ${azurerm_x.y.id} references + explicit depends_on |
| reverse-order deletes | Terraform walks the dependency graph backwards to destroy |
apply returns a new state | terraform apply writes the updated state file (and takes a blob lease lock first so a teammate can't apply concurrently) |
Two things the miniature deliberately omits but you must know:
- Locking. Real remote state takes a lease on the Azure Storage blob for the
duration of an apply; a second
applygetsError acquiring the state lock. Without it, two concurrent applies corrupt state. (WARMUP Ch. 6.) create_before_destroy. A replace is destroy-then-create by default, which means downtime; thecreate_before_destroylifecycle flag inverts the order to create the new resource first — but only works when names/addresses don't collide. (WARMUP Ch. 4.)
Extensions (for your own subscription / deeper study)
- Add replace ordering. Make
planemit areplaceas an explicit(address, "destroy")+(address, "create")pair, and support a per-resourcecreate_before_destroyflag that flips their order. Test that a CBD replace creates before it destroys. - Add
terraform import. A function that takes an unmanaged real resource and writes it into state (address → attributes) so the next plan isno-opinstead ofcreate— the real tool's answer to "ClickOps already made this." - Render the plan. Emit the
+ / ~ / -/+ / -symbols and a colored diff per attribute so your output reads like realterraform plan— then diff it against actual Terraform on a throwaway resource group. - Author it in CDKTF. Re-express the worked-example config in CDKTF (Python),
cdktf synthit tocdk.tf.json, and confirm the synthesized JSON feeds the same plan/apply engine — the whole point of CDKTF.