Loading...
Loading...
### Terraform Version ```shell Terraform v1.16.0 on linux_amd64 Also reproduced on v1.17.0-alpha20260827 Last good version: v1.15.9 ``` ### Terraform Configuration Files ```bash #!/usr/bin/env bash # Run this in an EMPTY directory, with the Terraform binary under test on PATH. # No providers are needed: terraform_data is built in, so init downloads nothing. set -euo pipefail cat > main.tf <<'EOF' resource "terraform_data" "r3" { triggers_replace = ["v1"] } EOF terraform init -input=false >/dev/null terraform apply -auto-approve -input=false >/dev/null cat > main.tf <<'EOF' resource "terraform_data" "r1" {} # The trigger. BOTH the lifecycle block and the depends_on are required. resource "terraform_data" "r2" { lifecycle { create_before_destroy = true } depends_on = [terraform_data.r1] } # Carries NO lifecycle block. This is the resource whose destroy is lost. resource "terraform_data" "r3" { triggers_replace = ["v2"] # forces replacement input = terraform_data.r1.id # depends on r1 } EOF terraform version terraform plan -input=false -no-color terraform apply -auto-approve -input=false -no-color ``` ### Debug Output `TF_LOG=trace` for the whole reproduction (both applies): **https://gist.github.com/Nilsils/fb3fc71e22c9a15ca35badafcf46707a** The apply graph is built at lines 2257–2270. `terraform_data.r3` declares no `lifecycle` block and its planned action is a plain replacement, but its destroy node is forced into create-before-destroy anyway, and the forcing then propagates to the creator: ``` 2257 ForcedCBDTransformer: "terraform_data.r3 (destroy)" has CBD descendant "terraform_data.r2" 2258 ForcedCBDTransformer: forcing create_before_destroy for "terraform_data.r3 (destroy)" (*terraform.NodeDestroyResourceInstance) 2266 ForcedCBDTransformer: "terraform_data.r3" depends on CBD destroy node "terraform_data.r3 (destroy)" 2267 ForcedCBDTransformer: forcing create_before_destroy for "terraform_data.r3" (*terraform.NodeApplyableResourceInstance) 2269 CBDEdgeTransformer: reversing edge terraform_data.r1 -> terraform_data.r3 (destroy) 2270 CBDEdgeTransformer: reversing edge terraform_data.r3 -> terraform_data.r3 (destroy) ``` The create then runs first and deposes the prior object, and the destroy node visits without ever calling the provider: ``` 2349 managedResourceExecute: prior object for terraform_data.r3 now deposed with key ac05aa22 2402 vertex "terraform_data.r3 (destroy)": starting visit (*terraform.NodeDestroyResourceInstance) 2404 vertex "terraform_data.r3 (destroy)": visit complete ``` There is no `Destroying...` line anywhere in the log, and no provider `Delete` call. For contrast, v1.15.9 on the identical configuration produces a byte-identical plan and reaches the opposite decision: ``` ForcedCBDTransformer: "terraform_data.r3 (destroy)" has no CBD descendant, so skipping terraform_data.r3: Destroying... [id=...] ``` ### Expected Behavior `terraform_data.r3` is planned as a plain replacement. The plan renders it as `-/+ destroy and then create replacement` and the summary says `1 to destroy`, so the apply should destroy the prior object and then create the new one: ``` terraform_data.r3: Destroying... [id=...] terraform_data.r3: Destruction complete after 0s terraform_data.r3: Creating... terraform_data.r3: Creation complete after 0s [id=...] Apply complete! Resources: 3 added, 0 changed, 1 destroyed. ``` This is what v1.15.9 and earlier do on the identical configuration. More generally: whatever ordering Terraform intends to use, the plan and the apply should agree. If `terraform_data.r3` is going to be replaced create-before-destroy, the plan should say so. It renders `+/-` for a genuine create-before-destroy replacement, so a reviewer approving the plan would see the ordering that will actually be used. ### Actual Behavior The create runs and the destroy never does. The apply summary contradicts the plan: ``` Plan: 3 to add, 0 to change, 1 to destroy. ... terraform_data.r1: Creating... terraform_data.r1: Creation complete after 0s [id=df687166-...] terraform_data.r3: Creating... terraform_data.r2: Creating... terraform_data.r2: Creation complete after 0s [id=af34d12b-...] terraform_data.r3: Creation complete after 0s [id=d01d2a14-...] Apply complete! Resources: 3 added, 0 changed, 0 destroyed. ``` No `Destroying...` line, no provider `Delete` call, exit code 0. A subsequent `terraform plan -detailed-exitcode` returns `0`, so Terraform believes it has converged. Applying a **saved plan file** (`terraform plan -out=tfplan && terraform apply tfplan`) behaves identically, so this is not a re-planning artefact: the graph that runs is not the graph that was shown. `terraform_data.r3` carries no `lifecycle` block. Removing either the `create_before_destroy` or the `depends_on` from `terraform_data.r2` makes it replace correctly. **What happens to the prior object is decided by map iteration order, so it varies between runs.** `ForcedCBDTransformer.Transform` is a single pass over `g.Vertices()` with no fixpoint, and it makes two decisions in that one pass where the second depends on the first. `terraform_data.r3 (destroy)` is always forced create-before-destroy; the creator `terraform_data.r3` is forced only if `hasCBDDescendant` happens to reach it *after* the destroy node was already forced. Over 20 runs of the configuration above: | order the loop happens to take | creator forced | prior object | runs | |---|---|---|---| | creator visited first | no | dropped from state, no deposed entry | 19 | | destroy node visited first | yes | left in state as deposed | 1 | In the common case the object is unrecoverable: it is gone from state, no future plan mentions it, and a third `apply` reports `0 added, 0 changed, 0 destroyed` while the object still exists. In the other case a later apply does clean it up. The attached trace captures the second, less common branch. Which node is reported as the CBD descendant also varies between runs (`terraform_data.r1` or `terraform_data.r2`) for the same reason. The missed destroy itself is not flaky: it happened in all 20 runs, and in all 20 runs of the `hashicorp/local` variant below. `terraform_data` manages nothing, so nothing is lost there. With a provider that manages real objects the consequence is concrete. With `hashicorp/local`, replacing a `local_file` leaves the old file on disk (20 runs: 18 orphaned, 2 deposed, 0 correct): ``` $ ls out/ r1.txt r2.txt r3-A.txt r3-B.txt # r3-A.txt should have been deleted ``` And where the old and new object cannot coexist, the apply fails outright. The same three-resource shape against `hashicorp/tfcoremock` v0.5.0, which stores one file per resource id: ``` Error: failed to write resource with tfcoremock_dynamic_resource.r3, resource with the specified id likely already exists ``` That last case is the practical impact. `create_before_destroy` is opt-in precisely because, as the `lifecycle` documentation puts it, "many remote object types have unique name requirements or other constraints that must be accommodated for both a new and an old object to exist concurrently". Here it is applied to a resource that never opted in. ### Steps to Reproduce 1. Save the script from **Terraform Configuration Files** above as `repro.sh`. 2. Run it in an **empty** directory, with the Terraform binary under test on `PATH`: ``` $ mkdir /tmp/repro && cd /tmp/repro $ bash repro.sh ``` 3. Compare the two summary lines it prints: ``` Plan: 3 to add, 0 to change, 1 to destroy. Apply complete! Resources: 3 added, 0 changed, 0 destroyed. ``` ### Additional Context Nothing atypical: no wrapper script, no CI, no unusual flags or environment variables. Official `linux_amd64` release binaries, run by hand in an empty directory. Last good version is **1.16.0-alpha20260708**, first bad is **1.16.0-alpha20260715**. Still present on 1.17.0-alpha20260827 and on `main` at 7f2c2291b4. `git bisect run` over the 28 commits between those two alphas points at: ``` 038c6f722695fa7e2cff75270ac1cba975bd5b00 "Missing check for orphaned CBD node", 2026-07-07, merged in #38840 ``` ### How it was found By an automated metamorphic testing pipeline that generates equivalent Terraform programs and compares engine output across them. The configuration above is the minimised form, reduced from a generated ten-resource program. ### References - #38840 the PR containing 038c6f72, the commit this bisects to - #36154 another failure of propagated `create_before_destroy`. Different symptom (a cycle error rather than a silently skipped destroy) but the same transformer - #33449 existing request for a warning when a resource is forced into `create_before_destroy` by its dependencies. Relevant because here the forcing is not just unannounced, it changes what the apply does relative to the plan ### Generative AI / LLM assisted development? The configuration was not written by an LLM. It was produced by an automated metamorphic testing pipeline (an e-graph based generator of equivalent Terraform programs), then minimised to the form above. Claude was used to assist with the writing of this report, and with the version sweep, the bisect and the reading of the trace. Every claim in it was verified by running the configurations: the version table by running each official release binary, the bisect by `git bisect run` building each candidate, and the run counts by repetition.
Click on a version to see all relevant bugs
Terraform Integration
Learn more about where this data comes from
BugZero Plan
Streamline upgrades with automated vendor bug scrubs
BugZero Prevent
Wish you caught this bug sooner? Get proactive today.