From 7a7661f264fbbb9d18e8a593ff329846180e6257 Mon Sep 17 00:00:00 2001 From: Adam Snyder Date: Thu, 14 May 2026 01:16:06 -0700 Subject: [PATCH] fix: Fix panic on deposed object with precondition and nop change --- .changes/v1.16/BUG FIXES-20260514-081915.yaml | 5 ++ internal/terraform/context_apply2_test.go | 77 +++++++++++++++++++ internal/terraform/transform_diff.go | 8 +- 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 .changes/v1.16/BUG FIXES-20260514-081915.yaml diff --git a/.changes/v1.16/BUG FIXES-20260514-081915.yaml b/.changes/v1.16/BUG FIXES-20260514-081915.yaml new file mode 100644 index 000000000000..18556fd99083 --- /dev/null +++ b/.changes/v1.16/BUG FIXES-20260514-081915.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: Fix a `terraform apply` panic when the plan contained a no-op change for a deposed object on a resource whose configuration declared a `lifecycle.precondition` or `lifecycle.postcondition` +time: 2026-05-14T08:19:15+00:00 +custom: + Issue: "38586" diff --git a/internal/terraform/context_apply2_test.go b/internal/terraform/context_apply2_test.go index 022ae53d28f5..3f086de1e49b 100644 --- a/internal/terraform/context_apply2_test.go +++ b/internal/terraform/context_apply2_test.go @@ -4953,3 +4953,80 @@ resource "test_resource" "bar" { _, diags = ctx.Apply(plan, m, nil) tfdiags.AssertNoErrors(t, diags) } + +// TestContext2Apply_deposedNoLongerExists_withConditions is a regression test +// for a panic that occurred when applying a plan that contained a NoOp change +// for a deposed object on a resource whose config declared a precondition. +func TestContext2Apply_deposedNoLongerExists_withConditions(t *testing.T) { + // count = 0 so the configuration declares no current instances, but the + // resource block (with its precondition) is still present in the module. + // The precondition must reference something else in configuration; we + // reference path.module, which is always defined. + m := testModuleInline(t, map[string]string{ + "main.tf": ` +resource "test_object" "a" { + count = 0 + test_string = "ok" + lifecycle { + create_before_destroy = true + precondition { + condition = path.module != "/dev/null" + error_message = "never fires" + } + } +} +`, + }) + + p := simpleMockProvider() + // Pretend the deposed object has been deleted out-of-band. + p.ReadResourceFn = func(req providers.ReadResourceRequest) providers.ReadResourceResponse { + return providers.ReadResourceResponse{ + NewState: cty.NullVal(req.PriorState.Type()), + } + } + + state := states.NewState() + root := state.EnsureModule(addrs.RootModuleInstance) + root.SetResourceInstanceDeposed( + mustResourceInstanceAddr("test_object.a[0]").Resource, + states.DeposedKey("deadbeef"), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"test_string":"old"}`), + Dependencies: []addrs.ConfigResource{}, + }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), + ) + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, DefaultPlanOpts) + if diags.HasErrors() { + t.Fatalf("plan: %s", diags.Err()) + } + + // Sanity check: the plan should contain a NoOp change for the deposed + // object and nothing else for this address. + addr := mustResourceInstanceAddr("test_object.a[0]") + deposedChange := plan.Changes.ResourceInstanceDeposed(addr, states.DeposedKey("deadbeef")) + if deposedChange == nil { + t.Fatalf("expected a deposed change for %s, got none", addr) + } + if deposedChange.Action != plans.NoOp { + t.Fatalf("expected NoOp deposed change for %s, got %s", addr, deposedChange.Action) + } + if got := plan.Changes.ResourceInstance(addr); got != nil { + t.Fatalf("expected no non-deposed change for %s, got %s", addr, got.Action) + } + + // Apply must not panic. + _, diags = ctx.Apply(plan, m, nil) + if diags.HasErrors() { + t.Fatalf("apply: %s", diags.Err()) + } +} diff --git a/internal/terraform/transform_diff.go b/internal/terraform/transform_diff.go index bc17068f11cd..9e23f29afdfc 100644 --- a/internal/terraform/transform_diff.go +++ b/internal/terraform/transform_diff.go @@ -95,8 +95,12 @@ func (t *DiffTransformer) Transform(g *Graph) error { // For a no-op change we don't take any action but we still // run any condition checks associated with the object, to // make sure that they still hold when considering the - // results of other changes. - update = t.hasConfigConditions(addr) + // results of other changes. However, if the object is also + // deposed, then the instance no longer exists and there is + // no reason to process conditions. + if dk == states.NotDeposed { + update = t.hasConfigConditions(addr) + } case plans.Delete: delete = true case plans.DeleteThenCreate, plans.CreateThenDelete: