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.
<!-- 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:
["delete", "create"]: the old object is destroyed, then the new one is created. There is a gap in between.["create", "delete"]: the new object comes up first, then the old one goes. That is whatlifecycle { create_before_destroy = true }produces.
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
- The gate reads a saved plan. It does not run Terraform. Pipe
terraform show -json tfplanto a file first, as Brikman does before handing the same JSON to OPA (PDF pp. 548-549). - Blocking every destroy-first replace is a policy choice. A stateless resource may tolerate the gap. The fix is
create_before_destroywhere the provider allows two objects to coexist, which tfpro Lab 22 drills, or an explicit allowlist in the gate. - No threads, no async. One file, one pass. Parallel reading waits until Duha ships ch16.
§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
- Ops: ECR registry scanning + Rust census (same trio)
- Cert: type constraints, optional(), nullable (same trio)
- Prior Dev: Python resource_changes census
- Prior Dev: Python import/move/no-op census
- Duha: ch14 Cargo