Hedronite · Dev Lesson · Polyglot-Dev / Rust · Sat 2026-09-26

Rust serde over terraform show -json the replace that hides in two actions

Make the verbs an enum, make the pairs variants, and gate on the replace that destroys first.

Lesson Class: Dev (T1 · Rust-around-TF)
Language Idiom: serde enums · exhaustive match · Result collect · borrowed rows
Verified: real terraform 1.14.3 plan · cargo test 3 passed · cargo run exit 2
Lag rule: TRPL through ch15 only · no threads · no PyO3
Paired Ops: ECR registry scanning + Rust census
Paired Cert: Type constraints, optional(), nullable
Closed Verb
No catch-all variant: an unknown action fails the parse.
Borrowed Row
Rows borrow from the plan; the compiler keeps them from outliving it.
Two strings in an array decide whether the old object dies first.

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

A replacement in plan JSON is two verbs in an array, and their order decides whether the old object dies first. Make the verbs an enum, make the pairs variants, and gate on the dangerous one.

§I. Frame

terraform show -json tfplan prints the plan as a document. Each entry in resource_changes carries change.actions, a list of verbs. Most entries hold one verb. A replacement holds two, and the order matters:

A string check such as "delete" in actions flags both as destructive and cannot tell them apart. Today's crate, plan_gate, parses the plan into Rust types, classifies each change, and exits 2 when any replacement destroys first.

Everything below ran on the lab Mac. The plan is real, the tests are real, and the output is pasted as printed.

§II. A real plan to parse

fixture/main.tf uses only terraform_data, a resource built into Terraform, so it needs no cloud provider and no credentials. Phase 1 was applied into local state. Phase 2 changes values to force one of each change kind:

resource "terraform_data" "repo" {
  triggers_replace = local.v2 ? "kms" : "aes256"
}

resource "terraform_data" "pull_through" {
  triggers_replace = local.v2 ? "ecr-public-v2" : "ecr-public-v1"
  lifecycle { create_before_destroy = true }
}

The other four resources cover a no-op, an in-place update, a count that drops to 0, and a count that rises to 1. Then:

$ terraform apply -auto-approve
Apply complete! Resources: 5 added, 0 changed, 0 destroyed.
$ terraform plan -var phase=2 -out=tfplan
Plan: 3 to add, 1 to change, 3 to destroy.
$ terraform show -json tfplan > plan.json

The summary line already hides the distinction. "3 to destroy" counts the two replacements and the dropped count instance together. The JSON keeps it: repo has ["delete","create"], pull_through has ["create","delete"], and both carry action_reason: "replace_because_cannot_update" with replace_paths: [["triggers_replace"]]. The dropped instance carries delete_because_count_index.

§III. The types

src/lib.rs models only the fields the gate needs. serde skips every other key in the 7.6 KB document:

#[derive(Debug, Deserialize)]
pub struct ResourceChange {
    pub address: String,
    pub change: Change,
    pub action_reason: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct Change {
    pub actions: Vec<Action>,
    #[serde(default)]
    pub replace_paths: Vec<Vec<serde_json::Value>>,
}

#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Action {
    NoOp,
    Create,
    Read,
    Update,
    Delete,
    Forget,
}

Closed Verb (named technique). rename_all = "kebab-case" maps NoOp to the JSON string "no-op" and the rest to their lowercase names. Because the enum has no catch-all variant, a verb Terraform adds in a future release fails deserialization instead of sliding through as an unknown string. The test unknown_verb_is_a_parse_error_not_a_guess feeds ["create","obliterate"] and asserts the parse fails. Forget is present because Terraform 1.7+ emits it for removed blocks with destroy = false.

Option<String> on action_reason matches the format: the key is absent on no-op, create and update entries, and serde reads a missing key as None for an Option field. #[serde(default)] on replace_paths does the same job for a Vec.

§IV. From verbs to kinds

A second enum names what a change means, not what the JSON says:

pub fn classify(actions: &[Action]) -> Result<Kind, String> {
    use Action::*;
    match actions.len() {
        1 => Ok(match actions[0] {
            NoOp => Kind::NoOp,
            Create => Kind::Create,
            Read => Kind::Read,
            Update => Kind::Update,
            Delete => Kind::Delete,
            Forget => Kind::Forget,
        }),
        2 => match (actions[0], actions[1]) {
            (Delete, Create) => Ok(Kind::ReplaceDestroyFirst),
            (Create, Delete) => Ok(Kind::ReplaceCreateFirst),
            other => Err(format!("unexpected action pair {other:?}")),
        },
        n => Err(format!("unexpected action list of length {n}")),
    }
}

The inner single-verb match is exhaustive (ch6). Add a seventh Action variant and this function stops compiling until someone decides what it means. The pair match works on a tuple of two Copy values, so no slice patterns are needed. Anything outside the two known pairs becomes an Err, never a guess.

rows turns the whole plan into classified rows with one iterator chain:

pub struct Row<'a> {
    pub address: &'a str,
    pub kind: Kind,
    pub reason: Option<&'a str>,
}

pub fn rows(plan: &Plan) -> Result<Vec<Row<'_>>, String> {
    plan.resource_changes
        .iter()
        .map(|rc| {
            Ok(Row {
                address: &rc.address,
                kind: classify(&rc.change.actions)?,
                reason: rc.action_reason.as_deref(),
            })
        })
        .collect()
}

Borrowed Row. Row<'a> holds &str slices into the parsed Plan instead of cloning strings (ch10 lifetimes in structs). The compiler will not let a Row outlive the Plan it points into. Collecting an iterator of Result<Row, String> into Result<Vec<Row>, String> stops at the first Err, so one malformed entry fails the whole report.

§V. The binary and the gate

src/main.rs follows the ch12 shape: main handles exit codes, run returns a Result:

fn run(path: &str) -> Result<bool, Box<dyn Error>> {
    let plan: Plan = serde_json::from_str(&fs::read_to_string(path)?)?;
    println!("terraform {} / format {}", plan.terraform_version, plan.format_version);
    let rows = rows(&plan)?;
    for r in &rows {
        println!("{:<22} {:<34} {}", format!("{:?}", r.kind), r.address, r.reason.unwrap_or("-"));
    }
    let blocked: Vec<_> = rows.iter().filter(|r| r.kind == Kind::ReplaceDestroyFirst).collect();
    for r in &blocked {
        eprintln!("BLOCK {}: destroy runs before create", r.address);
    }
    Ok(!blocked.is_empty())
}

main maps Ok(true) to exit 2, Ok(false) to 0 and Err to 1. That matches the exit-code convention of terraform plan -detailed-exitcode, where 2 means "look at this". The ? on rows(&plan) converts the String error into Box<dyn Error>, which ch9 covers.

§VI. Real runs

$ cargo test
running 3 tests
test tests::order_decides_which_replace ... ok
test tests::three_verbs_is_refused ... ok
test tests::unknown_verb_is_a_parse_error_not_a_guess ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

$ cargo run -q -- ../fixture/plan.json
terraform 1.14.3 / format 1.2
Delete                 terraform_data.legacy_mirror[0]    delete_because_count_index
ReplaceCreateFirst     terraform_data.pull_through        replace_because_cannot_update
NoOp                   terraform_data.registry_policy     -
Create                 terraform_data.replication[0]      -
ReplaceDestroyFirst    terraform_data.repo                replace_because_cannot_update
Update                 terraform_data.tag_rule            -
BLOCK terraform_data.repo: destroy runs before create
$ echo $?
2

Built with cargo 1.96.0 against serde 1.0.229 and serde_json 1.0.151 (versions pinned in crate/Cargo.lock). pull_through and repo share a reason and a replace path. Only the order of two strings separates them, and only repo blocks.

§VII. Boundaries

§VIII. Close instruction

Add lifecycle { create_before_destroy = true } to terraform_data.repo in fixture/main.tf, re-plan, regenerate plan.json, and predict the exit code before running plan_gate. Then add a test asserting that a ["no-op"] entry with no action_reason key parses to Kind::NoOp with reason == None.

Related