Hedronite · Dev Lesson · Polyglot-Dev / Rust · Tue 2026-09-29

Rust TF integration tests std::process, serde_json, and the apply round-trip

Parsing a checked-in plan proves types. Spawning terraform proves the module still applies.

Lesson Class: Dev (Rust TF integration tests)
Topic: T1 Systems core
Counter seat: tf_day_dev_counter 20 ≡ 2 mod 3
Crates: serde_json (std::process for spawn)
Lag rule: through Duha ch16 shared-state; no async
Verification: cargo test 5 passed · Terraform 1.14.3
Paired Ops: Artifact Registry cleanup census
Scratch Dir
One disposable workdir per test; never share state.
JSON Surfaces
validate -json, show -json, plan show -json.
Destroy before you leave the workdir.

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

Parsing a checked-in plan JSON proves your types. Spawning terraform against a disposable fixture proves the module still applies. Tonight does the second.

§I. Frame

09-26 shipped plan_gate: serde over a fixture plan.json that was produced once by hand. That is Rust-around-TF (counter mod 3 = 1). Counter 20 is mod 3 = 2: integration tests. The Go tool people used for this was terratest. The Rust replacement is std::process::Command plus serde_json over the CLI's -json surfaces.

Lag rule: Duha shipped ch16 shared-state (Send/Sync) this morning. Async/await (Topics #20) is still ahead. Tonight stays on threads-free process spawn. No Tokio in the Dev crate.

§II. The fixture

pkg/fixture/main.tf uses only terraform_data, a resource built into Terraform. No Google provider. No credentials.

variable "phase" {
  type    = string
  default = "1"
}

resource "terraform_data" "registry" {
  input = var.phase == "1" ? "apps-v1" : "apps-v2"
}

resource "terraform_data" "reader" {
  input = "roles/artifactregistry.reader"
}

The names echo tonight's Ops referent (Artifact Registry + reader IAM) without importing the Google provider. Phase 2 flips the registry input so a later plan shows an update.

§III. The library

Crate tf_roundtrip under pkg/:

pub fn run_tf(dir: &Path, args: &[&str], expect_ok: bool) -> Output {
    let output = Command::new(terraform_bin())
        .args(args)
        .current_dir(dir)
        .output()
        .expect("spawn terraform");
    // panic with stdout/stderr when expect_ok && !success
    output
}

pub fn state_addresses(show: &Value) -> Vec<String> { /* /values/root_module/resources[].address */ }

pub fn scratch_from_fixture(fixture: &Path) -> PathBuf {
    // copy main.tf into a unique temp dir so tests never share local state
}

Each integration test gets its own scratch directory. Parallel cargo test must not fight over one terraform.tfstate.

§IV. The three integration tests

Under pkg/tests/integration.rs:

  1. **validate_json_reports_valid** — init -backend=false, then validate -json. Assert valid == true.
  2. **apply_show_destroy_round_trip** — apply, show -json, assert both terraform_data.registry and terraform_data.reader addresses exist, destroy, assert the address list is empty.
  3. **plan_after_phase_change_lists_update** — apply phase 1, plan -var=phase=2 -out=tfplan, show -json tfplan, assert the registry change actions include update.

Unit tests in lib.rs cover state_addresses on a hand-built JSON document so the pointer logic fails fast without spawning terraform.

§V. What passed on the lab Mac

$ cd pkg && cargo test
running 2 tests
test unit::state_addresses_empty_when_missing ... ok
test unit::state_addresses_reads_root_module ... ok

running 3 tests
test validate_json_reports_valid ... ok
test apply_show_destroy_round_trip ... ok
test plan_after_phase_change_lists_update ... ok

test result: ok. 5 passed; 0 failed

Terraform 1.14.3 on PATH. No cloud calls. Wall clock is dominated by provider-free init (seconds, not minutes).

§VI. Why this is not 09-26 again

09-26 plan_gateTonight tf_roundtrip
Counter seatRust-around-TF (mod 1)Integration tests (mod 2)
InputChecked-in plan.jsonLive terraform CLI
AssertsEnum of actions / replace ordervalid flag, state addresses, plan update
Cleanupnonedestroy + temp dir remove

Keep both. Parse-only gates are fast in CI. Round-trip tests catch a broken required_version, a bad expression, or a fixture that no longer applies.

§VII. Close

Integration tests for Terraform in Rust are process discipline: unique workdirs, -json everywhere you assert, destroy before you leave. The Ops Artifact Registry module still needs a Google provider and credentials to apply. Tonight's crate proves the test spine without that cost.

Related