Skip to content

Commit 28cb130

Browse files
authored
Fix resource identity being dropped from state in certain cases (#37396)
* Test case for keeping identity on destroy error * Add identity to resource instance object on error * Add changelog * Test case for keeping identity on update without changes * Add identity to resource instance object on udpate
1 parent dcb0486 commit 28cb130

File tree

4 files changed

+197
-0
lines changed

4 files changed

+197
-0
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
kind: BUG FIXES
2+
body: Fixes resource identity being dropped from state in certain cases
3+
time: 2025-08-04T16:21:37.590435+02:00
4+
custom:
5+
Issue: "37396"

internal/terraform/context_apply2_test.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4082,3 +4082,192 @@ func TestContext2Apply_excludeListResources(t *testing.T) {
40824082
t.Fatal("expected error")
40834083
}
40844084
}
4085+
4086+
func TestContext2Apply_errorDestroyWithIdentity(t *testing.T) {
4087+
m := testModule(t, "empty")
4088+
p := testProvider("test")
4089+
4090+
p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&providerSchema{
4091+
ResourceTypes: map[string]*configschema.Block{
4092+
"test_resource": {
4093+
Attributes: map[string]*configschema.Attribute{
4094+
"id": {Type: cty.String, Optional: true},
4095+
},
4096+
},
4097+
},
4098+
IdentityTypes: map[string]*configschema.Object{
4099+
"test_resource": {
4100+
Attributes: map[string]*configschema.Attribute{
4101+
"id": {
4102+
Type: cty.String,
4103+
Required: true,
4104+
},
4105+
},
4106+
Nesting: configschema.NestingSingle,
4107+
},
4108+
},
4109+
})
4110+
p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse {
4111+
// Should actually be called for this test, because Terraform Core
4112+
// constructs the plan for a destroy operation itself.
4113+
return providers.PlanResourceChangeResponse{
4114+
PlannedState: req.ProposedNewState,
4115+
}
4116+
}
4117+
value := cty.ObjectVal(map[string]cty.Value{
4118+
"id": cty.StringVal("baz"),
4119+
})
4120+
identity := cty.ObjectVal(map[string]cty.Value{
4121+
"id": cty.StringVal("baz"),
4122+
})
4123+
p.ApplyResourceChangeFn = func(req providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse {
4124+
// The apply (in this case, a destroy) always fails, so we can verify
4125+
// that the object stays in the state after a destroy fails even though
4126+
// we aren't returning a new state object here.
4127+
return providers.ApplyResourceChangeResponse{
4128+
NewState: value,
4129+
NewIdentity: identity,
4130+
Diagnostics: tfdiags.Diagnostics(nil).Append(fmt.Errorf("failed")),
4131+
}
4132+
}
4133+
4134+
ctx := testContext2(t, &ContextOpts{
4135+
Providers: map[addrs.Provider]providers.Factory{
4136+
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
4137+
},
4138+
})
4139+
4140+
state := states.BuildState(func(ss *states.SyncState) {
4141+
ss.SetResourceInstanceCurrent(
4142+
addrs.Resource{
4143+
Mode: addrs.ManagedResourceMode,
4144+
Type: "test_resource",
4145+
Name: "test",
4146+
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
4147+
&states.ResourceInstanceObjectSrc{
4148+
Status: states.ObjectReady,
4149+
AttrsJSON: []byte(`{"id":"baz"}`),
4150+
IdentityJSON: []byte(`{"id":"baz"}`),
4151+
},
4152+
addrs.AbsProviderConfig{
4153+
Provider: addrs.NewDefaultProvider("test"),
4154+
Module: addrs.RootModule,
4155+
},
4156+
)
4157+
})
4158+
plan, diags := ctx.Plan(m, state, DefaultPlanOpts)
4159+
tfdiags.AssertNoErrors(t, diags)
4160+
4161+
state, diags = ctx.Apply(plan, m, nil)
4162+
if !diags.HasErrors() {
4163+
t.Fatal("should have error")
4164+
}
4165+
4166+
schema := p.GetProviderSchemaResponse.ResourceTypes["test_resource"]
4167+
resourceInstanceStateSrc := state.Modules[""].Resources["test_resource.test"].Instance(addrs.NoKey).Current
4168+
resourceInstanceState, err := resourceInstanceStateSrc.Decode(schema)
4169+
if err != nil {
4170+
t.Fatalf("failed to decode resource instance state: %s", err)
4171+
}
4172+
4173+
if !resourceInstanceState.Value.RawEquals(value) {
4174+
t.Fatalf("expected value to still be present in state, but got: %s", resourceInstanceState.Value.GoString())
4175+
}
4176+
if !resourceInstanceState.Identity.RawEquals(identity) {
4177+
t.Fatalf("expected identity to still be present in state, but got: %s", resourceInstanceState.Identity.GoString())
4178+
}
4179+
}
4180+
4181+
func TestContext2Apply_SensitivityChangeWithIdentity(t *testing.T) {
4182+
m := testModuleInline(t, map[string]string{
4183+
"main.tf": `
4184+
variable "sensitive_var" {
4185+
default = "hello"
4186+
sensitive = true
4187+
}
4188+
4189+
resource "test_resource" "foo" {
4190+
value = var.sensitive_var
4191+
}`,
4192+
})
4193+
4194+
p := testProvider("test")
4195+
p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&providerSchema{
4196+
ResourceTypes: map[string]*configschema.Block{
4197+
"test_resource": {
4198+
Attributes: map[string]*configschema.Attribute{
4199+
"id": {Type: cty.String, Computed: true},
4200+
"value": {Type: cty.String, Optional: true},
4201+
"sensitive_value": {Type: cty.String, Sensitive: true, Optional: true},
4202+
},
4203+
},
4204+
},
4205+
IdentityTypes: map[string]*configschema.Object{
4206+
"test_resource": {
4207+
Attributes: map[string]*configschema.Attribute{
4208+
"id": {
4209+
Type: cty.String,
4210+
Required: true,
4211+
},
4212+
},
4213+
Nesting: configschema.NestingSingle,
4214+
},
4215+
},
4216+
})
4217+
p.PlanResourceChangeFn = testDiffFn
4218+
4219+
ctx := testContext2(t, &ContextOpts{
4220+
Providers: map[addrs.Provider]providers.Factory{
4221+
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
4222+
},
4223+
})
4224+
4225+
state := states.BuildState(func(s *states.SyncState) {
4226+
s.SetResourceInstanceCurrent(
4227+
addrs.Resource{
4228+
Mode: addrs.ManagedResourceMode,
4229+
Type: "test_resource",
4230+
Name: "foo",
4231+
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
4232+
&states.ResourceInstanceObjectSrc{
4233+
Status: states.ObjectReady,
4234+
AttrsJSON: []byte(`{"id":"foo", "value":"hello"}`),
4235+
IdentityJSON: []byte(`{"id":"baz"}`),
4236+
// No AttrSensitivePaths present
4237+
},
4238+
addrs.AbsProviderConfig{
4239+
Provider: addrs.NewDefaultProvider("test"),
4240+
Module: addrs.RootModule,
4241+
},
4242+
)
4243+
})
4244+
4245+
plan, diags := ctx.Plan(m, state, SimplePlanOpts(plans.NormalMode, testInputValuesUnset(m.Module.Variables)))
4246+
tfdiags.AssertNoErrors(t, diags)
4247+
4248+
addr := mustResourceInstanceAddr("test_resource.foo")
4249+
4250+
state, diags = ctx.Apply(plan, m, nil)
4251+
tfdiags.AssertNoErrors(t, diags)
4252+
4253+
fooState := state.ResourceInstance(addr)
4254+
4255+
if len(fooState.Current.AttrSensitivePaths) != 2 {
4256+
t.Fatalf("wrong number of sensitive paths, expected 2, got, %v", len(fooState.Current.AttrSensitivePaths))
4257+
}
4258+
4259+
for _, path := range fooState.Current.AttrSensitivePaths {
4260+
switch {
4261+
case path.Equals(cty.GetAttrPath("value")):
4262+
case path.Equals(cty.GetAttrPath("sensitive_value")):
4263+
default:
4264+
t.Errorf("unexpected sensitive path: %#v", path)
4265+
return
4266+
}
4267+
}
4268+
4269+
expectedIdentity := `{"id":"baz"}`
4270+
if string(fooState.Current.IdentityJSON) != expectedIdentity {
4271+
t.Fatalf("missing identity in state, got %q", fooState.Current.IdentityJSON)
4272+
}
4273+
}

internal/terraform/context_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@ func testDiffFn(req providers.PlanResourceChangeRequest) (resp providers.PlanRes
443443
}
444444

445445
resp.PlannedState = cty.ObjectVal(planned)
446+
resp.PlannedIdentity = req.PriorIdentity
446447
return
447448
}
448449

internal/terraform/node_resource_abstract_instance.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2626,6 +2626,7 @@ func (n *NodeAbstractResourceInstance) apply(
26262626
Private: state.Private,
26272627
Status: state.Status,
26282628
Value: change.After,
2629+
Identity: change.AfterIdentity,
26292630
}
26302631
return newState, diags
26312632
}
@@ -2883,6 +2884,7 @@ func (n *NodeAbstractResourceInstance) apply(
28832884
Value: newVal,
28842885
Private: resp.Private,
28852886
CreateBeforeDestroy: createBeforeDestroy,
2887+
Identity: resp.NewIdentity,
28862888
}
28872889

28882890
// if the resource was being deleted, the dependencies are not going to

0 commit comments

Comments
 (0)