Hedronite · Dev Lesson · Polyglot-Dev / Rust · Mon 2026-10-05

Rust serde over terraform plan JSON resource_drift and relevant_attributes

Drift is what refresh saw. Relevance is which attributes fed the plan.

Lesson Class: Dev (T1 · Rust-around-TF)
Language Idiom: serde enums · HashSet relevance index · lib+bin CLI
Verified: real terraform 1.14.3 resource_drift · cargo test 4 passed
Lag rule: TRPL through ch21 fair game · Duha ch21 shipped this morning
Paired Ops: Azure storage management policy + Rust census
Paired Cert: refresh-only plans and drift handling
Relevance Index
Empty index means Unmarked, not Irrelevant.
Closed Verb
Unknown action verbs fail at parse time.
Unmarked drift is still drift. Do not invent relevance.

<!-- hal:authoritative:yaml -->

Drift is what refresh saw. Relevance is which drifted attributes fed the plan. Classify both before you gate CI.

§I. Frame

09-26 taught resource_changes and the replace that hides in two actions. 09-29 taught apply round-trips. Leave those shelves.

Tonight tf_day_dev_counter is 22 (1 mod 3): Rust-around-TF again. The plan JSON fields are resource_drift and relevant_attributes. HashiCorp documents them together: drift lists external changes detected during refresh; relevant attributes list resource/attribute paths that contributed to planned changes, so you can filter which drift may have shaped the plan.

Crate name: drift_census. It parses a plan, classifies each drift row, and exits 2 when any row is Relevant.

§II. Three classes of drift

Relevance Index (named technique). Before you treat every drift as a pager, ask whether the plan emitted a relevance index.

ClassMeaning
RelevantAddress appears in relevant_attributes
IrrelevantDrift present, index present, address absent
UnmarkedIndex omitted or empty; drift still real, relevance unknown

Terraform may emit resource_drift without relevant_attributes. That is Unmarked, not Irrelevant. Do not invent relevance when the index is missing.

§III. A real plan with drift

fixture/main.tf uses hashicorp/local local_file plus terraform_data. No cloud credentials.

Sequence on the lab Mac (Terraform 1.14.3):

  1. terraform apply with phase=1 (marker content alpha).
  2. Hand-edit marker.txt to beta-external (outside Terraform).
  3. terraform plan -var=phase=2 -out=tfplan then terraform show -json tfplan > plan.json.

Result: real resource_drift for local_file.marker with actions ["delete"] (local provider treats content/id mismatch as gone). resource_changes includes a create for the phase-2 content. This run did not emit relevant_attributes.

unmarked	local_file.marker	Delete
summary drift=1 relevant=0 irrelevant=0 unmarked=1 planned_changes=3

fixture/plan-refresh-only.json is the same hand-edit under terraform plan -refresh-only: drift present, resource_changes empty. Cert owns the refresh-only CLI story; Dev only needs the JSON shape.

§IV. Teaching twin for the relevance index

Because the real local_file plan omitted relevant_attributes, fixture/plan-with-relevant.json keeps the real marker drift object, adds a synthetic terraform_data.noise update drift, and injects:

"relevant_attributes": [
  {"resource": "local_file.marker", "attribute": ["content"]}
]

That lets the census print Relevant vs Irrelevant on a format-faithful document. fixture/README.md records which rows are real and which are teaching.

RELEVANT	local_file.marker	Delete
irrelevant	terraform_data.noise	Update
summary drift=2 relevant=1 irrelevant=1 unmarked=0 planned_changes=3

Exit code 2 when relevant > 0.

§V. The types

#[derive(Debug, Deserialize)]
pub struct Plan {
    pub format_version: String,
    pub terraform_version: String,
    #[serde(default)]
    pub resource_drift: Vec<ResourceChange>,
    #[serde(default)]
    pub resource_changes: Vec<ResourceChange>,
    #[serde(default)]
    pub relevant_attributes: Vec<RelevantAttribute>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DriftClass { Relevant, Irrelevant, Unmarked }

Action is a closed kebab-case enum (no-op, create, read, update, delete, forget). An unknown verb fails at parse time, same discipline as 09-26.

classify_drift builds a set of addresses from relevant_attributes. Empty index → every drift row is Unmarked. Non-empty index → membership decides Relevant vs Irrelevant.

§VI. Tests and run

cd crate
cargo test          # 4 passed
cargo run -- ../fixture/plan.json
cargo run -- ../fixture/plan-with-relevant.json   # exit 2

Tests cover: real plan unmarked marker drift; teaching fixture split; empty plan quiet; unknown action parse error.

§VI.b. Why local_file drift looked like delete

The real fixture used hashicorp/local local_file. That provider keys id off content hashes. When you hand-edit the file, Read no longer finds the object Terraform remembered, so plan JSON records drift actions ["delete"] and a normal phase-2 plan may show a matching create. That is still drift: the remote (the file) changed outside apply.

Cloud providers more often emit ["update"] drift for in-place attribute edits. The census does not special-case delete-shaped drift. It only asks: is there a relevance index, and does this address appear in it?

Paired Cert shows the same hand-edit under -refresh-only, where resource_changes stays empty and state update is the only apply goal.

§VII. Close

09-26 gated on destroy-first replace inside resource_changes. Tonight gates on Relevant drift when the plan supplies a relevance index, and refuses to pretend Unmarked is Irrelevant. Ops declares Azure lifecycle policy. Cert teaches refresh-only as the CLI that updates state for the same drift story.

Related