Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/v1.13/BUG FIXES-20250804-162137.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: BUG FIXES
body: Fixes resource identity being dropped from state in certain cases
time: 2025-08-04T16:21:37.590435+02:00
custom:
Issue: "37396"
189 changes: 189 additions & 0 deletions internal/terraform/context_apply2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4082,3 +4082,192 @@ func TestContext2Apply_excludeListResources(t *testing.T) {
t.Fatal("expected error")
}
}

func TestContext2Apply_errorDestroyWithIdentity(t *testing.T) {
m := testModule(t, "empty")
p := testProvider("test")

p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&providerSchema{
ResourceTypes: map[string]*configschema.Block{
"test_resource": {
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Optional: true},
},
},
},
IdentityTypes: map[string]*configschema.Object{
"test_resource": {
Attributes: map[string]*configschema.Attribute{
"id": {
Type: cty.String,
Required: true,
},
},
Nesting: configschema.NestingSingle,
},
},
})
p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse {
// Should actually be called for this test, because Terraform Core
// constructs the plan for a destroy operation itself.
return providers.PlanResourceChangeResponse{
PlannedState: req.ProposedNewState,
}
}
value := cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("baz"),
})
identity := cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("baz"),
})
p.ApplyResourceChangeFn = func(req providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse {
// The apply (in this case, a destroy) always fails, so we can verify
// that the object stays in the state after a destroy fails even though
// we aren't returning a new state object here.
return providers.ApplyResourceChangeResponse{
NewState: value,
NewIdentity: identity,
Diagnostics: tfdiags.Diagnostics(nil).Append(fmt.Errorf("failed")),
}
}

ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})

state := states.BuildState(func(ss *states.SyncState) {
ss.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_resource",
Name: "test",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
AttrsJSON: []byte(`{"id":"baz"}`),
IdentityJSON: []byte(`{"id":"baz"}`),
},
addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("test"),
Module: addrs.RootModule,
},
)
})
plan, diags := ctx.Plan(m, state, DefaultPlanOpts)
tfdiags.AssertNoErrors(t, diags)

state, diags = ctx.Apply(plan, m, nil)
if !diags.HasErrors() {
t.Fatal("should have error")
}

schema := p.GetProviderSchemaResponse.ResourceTypes["test_resource"]
resourceInstanceStateSrc := state.Modules[""].Resources["test_resource.test"].Instance(addrs.NoKey).Current
resourceInstanceState, err := resourceInstanceStateSrc.Decode(schema)
if err != nil {
t.Fatalf("failed to decode resource instance state: %s", err)
}

if !resourceInstanceState.Value.RawEquals(value) {
t.Fatalf("expected value to still be present in state, but got: %s", resourceInstanceState.Value.GoString())
}
if !resourceInstanceState.Identity.RawEquals(identity) {
t.Fatalf("expected identity to still be present in state, but got: %s", resourceInstanceState.Identity.GoString())
}
}

func TestContext2Apply_SensitivityChangeWithIdentity(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
variable "sensitive_var" {
default = "hello"
sensitive = true
}

resource "test_resource" "foo" {
value = var.sensitive_var
}`,
})

p := testProvider("test")
p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&providerSchema{
ResourceTypes: map[string]*configschema.Block{
"test_resource": {
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Computed: true},
"value": {Type: cty.String, Optional: true},
"sensitive_value": {Type: cty.String, Sensitive: true, Optional: true},
},
},
},
IdentityTypes: map[string]*configschema.Object{
"test_resource": {
Attributes: map[string]*configschema.Attribute{
"id": {
Type: cty.String,
Required: true,
},
},
Nesting: configschema.NestingSingle,
},
},
})
p.PlanResourceChangeFn = testDiffFn

ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})

state := states.BuildState(func(s *states.SyncState) {
s.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_resource",
Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
AttrsJSON: []byte(`{"id":"foo", "value":"hello"}`),
IdentityJSON: []byte(`{"id":"baz"}`),
// No AttrSensitivePaths present
},
addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("test"),
Module: addrs.RootModule,
},
)
})

plan, diags := ctx.Plan(m, state, SimplePlanOpts(plans.NormalMode, testInputValuesUnset(m.Module.Variables)))
tfdiags.AssertNoErrors(t, diags)

addr := mustResourceInstanceAddr("test_resource.foo")

state, diags = ctx.Apply(plan, m, nil)
tfdiags.AssertNoErrors(t, diags)

fooState := state.ResourceInstance(addr)

if len(fooState.Current.AttrSensitivePaths) != 2 {
t.Fatalf("wrong number of sensitive paths, expected 2, got, %v", len(fooState.Current.AttrSensitivePaths))
}

for _, path := range fooState.Current.AttrSensitivePaths {
switch {
case path.Equals(cty.GetAttrPath("value")):
case path.Equals(cty.GetAttrPath("sensitive_value")):
default:
t.Errorf("unexpected sensitive path: %#v", path)
return
}
}

expectedIdentity := `{"id":"baz"}`
if string(fooState.Current.IdentityJSON) != expectedIdentity {
t.Fatalf("missing identity in state, got %q", fooState.Current.IdentityJSON)
}
}
1 change: 1 addition & 0 deletions internal/terraform/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@ func testDiffFn(req providers.PlanResourceChangeRequest) (resp providers.PlanRes
}

resp.PlannedState = cty.ObjectVal(planned)
resp.PlannedIdentity = req.PriorIdentity
return
}

Expand Down
2 changes: 2 additions & 0 deletions internal/terraform/node_resource_abstract_instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -2626,6 +2626,7 @@ func (n *NodeAbstractResourceInstance) apply(
Private: state.Private,
Status: state.Status,
Value: change.After,
Identity: change.AfterIdentity,
}
return newState, diags
}
Expand Down Expand Up @@ -2883,6 +2884,7 @@ func (n *NodeAbstractResourceInstance) apply(
Value: newVal,
Private: resp.Private,
CreateBeforeDestroy: createBeforeDestroy,
Identity: resp.NewIdentity,
}

// if the resource was being deleted, the dependencies are not going to
Expand Down
Loading