Retain resource instances with a new lifecycle argument - destroy (#3409)

Signed-off-by: Ilia Gogotchuri <ilia.gogotchuri0@gmail.com>
Co-authored-by: Andrei Ciobanu <andrei.ciobanu@opentofu.org>
This commit is contained in:
Ilia Gogotchuri
2025-12-04 18:49:57 +04:00
committed by GitHub
parent c3fe83a177
commit fd19a3763f
35 changed files with 2122 additions and 116 deletions

View File

@@ -9,6 +9,7 @@ UPGRADE NOTES:
ENHANCEMENTS:
- `prevent_destroy` arguments in the `lifecycle` block for managed resources can now use references to other symbols in the same module, such as to a module's input variables. ([#3474](https://github.com/opentofu/opentofu/issues/3474), [#3507](https://github.com/opentofu/opentofu/issues/3507))
- New `lifecycle` meta-argument `destroy` for altering resource destruction behavior. When set to `false` OpenTofu will not retain resources when they are planned for destruction. ([#3409](https://github.com/opentofu/opentofu/pull/3409))
- OpenTofu now uses the `BROWSER` environment variable when launching a web browser on Unix platforms, as long as it's set to a single command that can accept a URL to open as its first and only argument. ([#3456](https://github.com/opentofu/opentofu/issues/3456))
- Improve performance around provider checking and schema management. ([#2730](https://github.com/opentofu/opentofu/pull/2730))
- `tofu init` now fetches providers and their metadata in parallel. Depending on provider size and network properties, this can reduce provider installation and checking time. ([#2729](https://github.com/opentofu/opentofu/pull/2729))

View File

@@ -391,6 +391,219 @@ func TestApply_destroyPath(t *testing.T) {
}
}
func TestApply_destroySkipInConfigAndState(t *testing.T) {
// Create a temporary working directory that is empty
td := t.TempDir()
testCopyDir(t, testFixturePath("skip-destroy"), td)
t.Chdir(td)
// Create some existing state
originalState := states.BuildState(func(s *states.SyncState) {
s.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_instance",
Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
AttrsJSON: []byte(`{"id":"baz"}`),
Status: states.ObjectReady,
SkipDestroy: true,
},
addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("test"),
Module: addrs.RootModule,
},
addrs.NoKey,
)
})
statePath := testStateFile(t, originalState)
p := applyFixtureProvider()
view, done := testView(t)
c := &ApplyCommand{
Destroy: true,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
View: view,
},
}
args := []string{
"-state", statePath,
}
code := c.Run(args)
output := done(t)
if code != 1 {
t.Log(output.Stdout())
t.Fatalf("bad: %d\n\n%s", code, output.Stderr())
}
if !strings.Contains(output.Stderr(), "OpenTofu has not deleted some remote objects") {
t.Fatalf("did not expect skip-destroy message in output:\n\n%s", output.Stderr())
}
if _, err := os.Stat(statePath); err != nil {
t.Fatalf("err: %s", err)
}
state := testStateRead(t, statePath)
if state == nil {
t.Fatal("state should not be nil")
}
actualStr := strings.TrimSpace(state.String())
expectedStr := strings.TrimSpace(testApplyDestroyStr)
if actualStr != expectedStr {
t.Fatalf("bad:\n\n%s\n\n%s", actualStr, expectedStr)
}
}
// In this case, the user has removed skip-destroy from config, but it's still set in state.
// We will plan a new state first, which will remove the skip-destroy attribute from state and then proceed to destroy the resource
func TestApply_destroySkipInStateNotInConfig(t *testing.T) {
// Create a temporary working directory that is empty
td := t.TempDir()
testCopyDir(t, testFixturePath("skip-destroy/no-skip-in-config"), td)
t.Chdir(td)
// Create some existing state
originalState := states.BuildState(func(s *states.SyncState) {
s.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_instance",
Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
AttrsJSON: []byte(`{"id":"baz"}`),
Status: states.ObjectReady,
SkipDestroy: true,
},
addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("test"),
Module: addrs.RootModule,
},
addrs.NoKey,
)
})
statePath := testStateFile(t, originalState)
p := applyFixtureProvider()
view, done := testView(t)
c := &ApplyCommand{
Destroy: true,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
View: view,
},
}
args := []string{
"-state", statePath,
}
code := c.Run(args)
output := done(t)
if code != 0 {
t.Log(output.Stdout())
t.Fatalf("bad: %d\n\n%s", code, output.Stderr())
}
// We will be destroying the resource above
if !strings.Contains(output.Stdout(), "1 destroyed") {
t.Fatalf("resource should be destroyed, output:\n\n%s", output.Stdout())
}
if _, err := os.Stat(statePath); err != nil {
t.Fatalf("err: %s", err)
}
state := testStateRead(t, statePath)
if state == nil {
t.Fatal("state should not be nil")
}
actualStr := strings.TrimSpace(state.String())
expectedStr := strings.TrimSpace(testApplyDestroyStr)
if actualStr != expectedStr {
t.Fatalf("bad:\n\n%s\n\n%s", actualStr, expectedStr)
}
}
func TestApply_destroySkipInStateOrphaned(t *testing.T) {
// Create a temporary working directory that is empty
td := t.TempDir()
testCopyDir(t, testFixturePath("skip-destroy/empty"), td)
t.Chdir(td)
// Create some existing state
originalState := states.BuildState(func(s *states.SyncState) {
s.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_instance",
Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
AttrsJSON: []byte(`{"id":"baz"}`),
Status: states.ObjectReady,
SkipDestroy: true,
},
addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("test"),
Module: addrs.RootModule,
},
addrs.NoKey,
)
})
statePath := testStateFile(t, originalState)
p := applyFixtureProvider()
view, done := testView(t)
c := &ApplyCommand{
Destroy: true,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
View: view,
},
}
args := []string{
"-state", statePath,
}
code := c.Run(args)
output := done(t)
if code != 1 {
t.Log(output.Stdout())
t.Fatalf("bad: %d\n\n%s", code, output.Stderr())
}
if !strings.Contains(output.Stderr(), "OpenTofu has not deleted some remote objects") {
t.Fatalf("did not expect skip-destroy message in output:\n\n%s", output.Stderr())
}
// Check action reason - we must clarify to user that the attribute is stored in state even if not in config
if !strings.Contains(output.Stdout(), "lifecycle.destroy = false") {
t.Fatalf("did not find expected lifecycle.destroy reason in output:\n\n%s", output.Stdout())
}
if _, err := os.Stat(statePath); err != nil {
t.Fatalf("err: %s", err)
}
state := testStateRead(t, statePath)
if state == nil {
t.Fatal("state should not be nil")
}
actualStr := strings.TrimSpace(state.String())
expectedStr := strings.TrimSpace(testApplyDestroyStr)
if actualStr != expectedStr {
t.Fatalf("bad:\n\n%s\n\n%s", actualStr, expectedStr)
}
}
func TestApply_targetedDestroy(t *testing.T) {
testCases := []struct {
name string

View File

@@ -89,6 +89,8 @@ func DiffActionSymbol(action plans.Action) string {
return "[red]-[reset]/[green]+[reset]"
case plans.CreateThenDelete:
return "[green]+[reset]/[red]-[reset]"
case plans.ForgetThenCreate:
return " [red].[reset]/[green]+[reset]"
case plans.Create:
return " [green]+[reset]"
case plans.Delete:

View File

@@ -89,7 +89,7 @@ func (plan Plan) renderHuman(renderer Renderer, mode plans.Mode, opts ...plans.Q
importingCount++
}
if action == plans.Forget {
if action == plans.Forget || action == plans.ForgetThenCreate {
forgettingCount++
}
@@ -214,6 +214,9 @@ func (plan Plan) renderHuman(renderer Renderer, mode plans.Mode, opts ...plans.Q
if counts[plans.Read] > 0 {
renderer.Streams.Println(renderer.Colorize.Color(actionDescription(plans.Read)))
}
if counts[plans.ForgetThenCreate] > 0 {
renderer.Streams.Println(renderer.Colorize.Color(actionDescription(plans.ForgetThenCreate)))
}
if counts[plans.Forget] > 0 {
renderer.Streams.Println(renderer.Colorize.Color(actionDescription(plans.Forget)))
}
@@ -234,36 +237,43 @@ func (plan Plan) renderHuman(renderer Renderer, mode plans.Mode, opts ...plans.Q
}
}
toAdd := counts[plans.Create] + counts[plans.DeleteThenCreate] + counts[plans.CreateThenDelete] + counts[plans.ForgetThenCreate]
toDestroy := counts[plans.Delete] + counts[plans.DeleteThenCreate] + counts[plans.CreateThenDelete]
if importingCount > 0 {
if forgettingCount > 0 {
renderer.Streams.Printf(
renderer.Colorize.Color("\n[bold]Plan:[reset] %d to import, %d to add, %d to change, %d to destroy, %d to forget.\n"),
importingCount,
counts[plans.Create]+counts[plans.DeleteThenCreate]+counts[plans.CreateThenDelete],
toAdd,
counts[plans.Update],
counts[plans.Delete]+counts[plans.DeleteThenCreate]+counts[plans.CreateThenDelete],
forgettingCount)
toDestroy,
forgettingCount,
)
} else {
renderer.Streams.Printf(
renderer.Colorize.Color("\n[bold]Plan:[reset] %d to import, %d to add, %d to change, %d to destroy.\n"),
importingCount,
counts[plans.Create]+counts[plans.DeleteThenCreate]+counts[plans.CreateThenDelete],
toAdd,
counts[plans.Update],
counts[plans.Delete]+counts[plans.DeleteThenCreate]+counts[plans.CreateThenDelete])
toDestroy,
)
}
} else if forgettingCount > 0 {
renderer.Streams.Printf(
renderer.Colorize.Color("\n[bold]Plan:[reset] %d to add, %d to change, %d to destroy, %d to forget.\n"),
counts[plans.Create]+counts[plans.DeleteThenCreate]+counts[plans.CreateThenDelete],
toAdd,
counts[plans.Update],
counts[plans.Delete]+counts[plans.DeleteThenCreate]+counts[plans.CreateThenDelete],
forgettingCount)
toDestroy,
forgettingCount,
)
} else {
renderer.Streams.Printf(
renderer.Colorize.Color("\n[bold]Plan:[reset] %d to add, %d to change, %d to destroy.\n"),
counts[plans.Create]+counts[plans.DeleteThenCreate]+counts[plans.CreateThenDelete],
toAdd,
counts[plans.Update],
counts[plans.Delete]+counts[plans.DeleteThenCreate]+counts[plans.CreateThenDelete])
toDestroy,
)
}
}
@@ -504,6 +514,39 @@ func resourceChangeComment(resource jsonplan.ResourceChange, action plans.Action
case plans.Forget:
buf.WriteString(fmt.Sprintf("[bold] # %s[reset] will be removed from the OpenTofu state [bold][red]but will not be destroyed[reset]", dispAddr))
// We need to identify a special case where the resource is being forgotten instead of destroyed due to lifecycle.destroy attribute
// There are two cases where this can happen: when lifecycle.destroy = false is set in the configuration, and when lifecycle.destroy = false is persisted in state.
//
// Since the attribute might no longer be present in the configuration, and we need to inform the user to avoid confusion.
// We don't need to separate this case in ForgetThenCreate, because the message already contains this information.
// Note that ForgetThenCreate is only used when lifecycle.destroy is set to false and the resource needs to be replaced.
switch resource.ActionReason {
case jsonplan.ResourceInstanceForgotBecauseOfLifecycleDestroyInState:
buf.WriteString(" \n (because [bold]lifecycle.destroy = false[reset] was configured before this resource was removed from the configuration)")
case jsonplan.ResourceInstanceForgotBecauseOfLifecycleDestroyInConfig:
buf.WriteString(" \n (because [bold]lifecycle.destroy = false[reset] is set in the configuration)")
}
if len(resource.Deposed) != 0 {
// In the case where we partially failed to replace a resource
// configured with 'create_before_destroy' in a previous apply and
// the deposed instance is still in the state, we give some extra
// context about this unusual situation.
buf.WriteString("\n # (left over from a partially-failed replacement of this instance)")
}
case plans.ForgetThenCreate:
switch resource.ActionReason {
case jsonplan.ResourceInstanceReplaceBecauseTainted:
buf.WriteString(fmt.Sprintf("[bold] # %s[reset] is tainted, so it must be [bold][red]replaced[reset]", dispAddr))
case jsonplan.ResourceInstanceReplaceByRequest:
buf.WriteString(fmt.Sprintf("[bold] # %s[reset] will be [bold][red]replaced[reset], as requested", dispAddr))
case jsonplan.ResourceInstanceReplaceByTriggers:
buf.WriteString(fmt.Sprintf("[bold] # %s[reset] will be [bold][red]replaced[reset] due to changes in replace_triggered_by", dispAddr))
default:
buf.WriteString(fmt.Sprintf("[bold] # %s[reset] must be [bold][red]replaced[reset]", dispAddr))
}
buf.WriteString(" - [yellow]older instance will [bold]not[reset][yellow] be destroyed [reset]([bold]lifecycle.destroy = false[reset])")
if len(resource.Deposed) != 0 {
// In the case where we partially failed to replace a resource
// configured with 'create_before_destroy' in a previous apply and
@@ -577,6 +620,8 @@ func actionDescription(action plans.Action) string {
return "[green]+[reset]/[red]-[reset] create replacement and then destroy"
case plans.DeleteThenCreate:
return "[red]-[reset]/[green]+[reset] destroy and then create replacement"
case plans.ForgetThenCreate:
return "[red].[reset]/[green]+[reset] forget and then create replacement"
case plans.Read:
return " [cyan]<=[reset] read (data resources)"
case plans.Forget:

View File

@@ -34,20 +34,22 @@ import (
const (
FormatVersion = "1.2"
ResourceInstanceReplaceBecauseCannotUpdate = "replace_because_cannot_update"
ResourceInstanceReplaceBecauseTainted = "replace_because_tainted"
ResourceInstanceReplaceByRequest = "replace_by_request"
ResourceInstanceReplaceByTriggers = "replace_by_triggers"
ResourceInstanceDeleteBecauseNoResourceConfig = "delete_because_no_resource_config"
ResourceInstanceDeleteBecauseWrongRepetition = "delete_because_wrong_repetition"
ResourceInstanceDeleteBecauseCountIndex = "delete_because_count_index"
ResourceInstanceDeleteBecauseEnabledFalse = "delete_because_enabled_false"
ResourceInstanceDeleteBecauseEachKey = "delete_because_each_key"
ResourceInstanceDeleteBecauseNoModule = "delete_because_no_module"
ResourceInstanceDeleteBecauseNoMoveTarget = "delete_because_no_move_target"
ResourceInstanceReadBecauseConfigUnknown = "read_because_config_unknown"
ResourceInstanceReadBecauseDependencyPending = "read_because_dependency_pending"
ResourceInstanceReadBecauseCheckNested = "read_because_check_nested"
ResourceInstanceReplaceBecauseCannotUpdate = "replace_because_cannot_update"
ResourceInstanceReplaceBecauseTainted = "replace_because_tainted"
ResourceInstanceReplaceByRequest = "replace_by_request"
ResourceInstanceReplaceByTriggers = "replace_by_triggers"
ResourceInstanceDeleteBecauseNoResourceConfig = "delete_because_no_resource_config"
ResourceInstanceDeleteBecauseWrongRepetition = "delete_because_wrong_repetition"
ResourceInstanceDeleteBecauseCountIndex = "delete_because_count_index"
ResourceInstanceDeleteBecauseEnabledFalse = "delete_because_enabled_false"
ResourceInstanceDeleteBecauseEachKey = "delete_because_each_key"
ResourceInstanceDeleteBecauseNoModule = "delete_because_no_module"
ResourceInstanceDeleteBecauseNoMoveTarget = "delete_because_no_move_target"
ResourceInstanceReadBecauseConfigUnknown = "read_because_config_unknown"
ResourceInstanceReadBecauseDependencyPending = "read_because_dependency_pending"
ResourceInstanceReadBecauseCheckNested = "read_because_check_nested"
ResourceInstanceForgotBecauseOfLifecycleDestroyInState = "forgot_because_of_lifecycle_destroy_in_state"
ResourceInstanceForgotBecauseOfLifecycleDestroyInConfig = "forgot_because_of_lifecycle_destroy_in_config"
)
// Plan is the top-level representation of the json format of a plan. It includes
@@ -573,6 +575,10 @@ func MarshalResourceChanges(resources []*plans.ResourceInstanceChangeSrc, schema
r.ActionReason = ResourceInstanceReadBecauseDependencyPending
case plans.ResourceInstanceReadBecauseCheckNested:
r.ActionReason = ResourceInstanceReadBecauseCheckNested
case plans.ResourceInstanceForgotBecauseOfLifecycleDestroyInState:
r.ActionReason = ResourceInstanceForgotBecauseOfLifecycleDestroyInState
case plans.ResourceInstanceForgotBecauseOfLifecycleDestroyInConfig:
r.ActionReason = ResourceInstanceForgotBecauseOfLifecycleDestroyInConfig
default:
return nil, fmt.Errorf("resource %s has an unsupported action reason %s", r.Address, rc.ActionReason)
}
@@ -896,6 +902,8 @@ func actionString(action string) []string {
return []string{"update"}
case "CreateThenDelete":
return []string{"create", "delete"}
case "ForgetThenCreate":
return []string{"forget", "create"}
case "Read":
return []string{"read"}
case "DeleteThenCreate":
@@ -919,6 +927,10 @@ func UnmarshalActions(actions []string) plans.Action {
if actions[0] == "delete" && actions[1] == "create" {
return plans.DeleteThenCreate
}
if actions[0] == "forget" && actions[1] == "create" {
return plans.ForgetThenCreate
}
}
if len(actions) == 1 {

View File

@@ -0,0 +1,7 @@
resource "test_instance" "foo" {
id = "baz"
lifecycle {
destroy = false
}
}

View File

@@ -0,0 +1,3 @@
resource "test_instance" "foo" {
id = "baz"
}

View File

@@ -249,6 +249,9 @@ func (v *OperationJSON) Plan(plan *plans.Plan, schemas *tofu.Schemas) {
case plans.CreateThenDelete, plans.DeleteThenCreate:
cs.Add++
cs.Remove++
case plans.ForgetThenCreate:
cs.Add++
cs.Forget++
case plans.Forget:
cs.Forget++
}

View File

@@ -265,6 +265,11 @@ func (r *Resource) merge(or *Resource, rps map[string]*RequiredProvider) hcl.Dia
if or.Managed.PreventDestroy != nil {
r.Managed.PreventDestroy = or.Managed.PreventDestroy
}
if or.Managed.Destroy != nil {
r.Managed.Destroy = or.Managed.Destroy
}
if len(or.Managed.Provisioners) != 0 {
r.Managed.Provisioners = or.Managed.Provisioners
}

View File

@@ -71,8 +71,13 @@ type ManagedResource struct {
CreateBeforeDestroy bool
PreventDestroy hcl.Expression
IgnoreChanges []hcl.Traversal
IgnoreAllChanges bool
// Destroy attribute indicates if the resource should be destroy once it is planned for destruction. This attribute corresponds to the `lifecycle.destroy` attribute.
// The default behavior is to destroy the resource when it is planned for destruction, so the value of false will skip destroying the resource.
// Note that the resource will still be removed from the state file even if Destroy is set to false but won't call the underlying provider for destruction.
// This field will accept only constant boolean expressions. This is of type hcl.Expression to make future extensions of dynamic evaluation easier.
Destroy hcl.Expression
IgnoreChanges []hcl.Traversal
IgnoreAllChanges bool
CreateBeforeDestroySet bool
}
@@ -211,6 +216,10 @@ func decodeResourceBlock(block *hcl.Block, override bool) (*Resource, hcl.Diagno
r.Managed.PreventDestroy = attr.Expr
}
if attr, exists := lcContent.Attributes["destroy"]; exists {
r.Managed.Destroy = attr.Expr
}
if attr, exists := lcContent.Attributes["replace_triggered_by"]; exists {
exprs, hclDiags := decodeReplaceTriggeredBy(attr.Expr)
diags = diags.Extend(hclDiags)
@@ -683,6 +692,9 @@ func decodeEphemeralBlock(block *hcl.Block, override bool) (*Resource, hcl.Diagn
if _, exists := lcContent.Attributes["create_before_destroy"]; exists {
diags = append(diags, invalidEphemeralLifecycleAttributeDiag("create_before_destroy", block.DefRange))
}
if _, exists := lcContent.Attributes["destroy"]; exists {
diags = append(diags, invalidEphemeralLifecycleAttributeDiag("destroy", block.DefRange))
}
if _, exists := lcContent.Attributes["prevent_destroy"]; exists {
diags = append(diags, invalidEphemeralLifecycleAttributeDiag("prevent_destroy", block.DefRange))
}
@@ -1127,6 +1139,9 @@ var resourceLifecycleBlockSchema = &hcl.BodySchema{
{
Name: "prevent_destroy",
},
{
Name: "destroy",
},
{
Name: "ignore_changes",
},

View File

@@ -16,6 +16,7 @@ const (
CreateThenDelete Action = '±'
Delete Action = '-'
Forget Action = '.'
ForgetThenCreate Action = '⊘'
Open Action = '⁐'
// NOTE: Renew and Close missing on purpose.
// Those are not meant to be stored in the plan.

View File

@@ -16,6 +16,7 @@ func _() {
_ = x[CreateThenDelete-177]
_ = x[Delete-45]
_ = x[Forget-46]
_ = x[ForgetThenCreate-8856]
_ = x[Open-8272]
}
@@ -28,6 +29,7 @@ const (
_Action_name_5 = "Open"
_Action_name_6 = "Read"
_Action_name_7 = "DeleteThenCreate"
_Action_name_8 = "ForgetThenCreate"
)
var (
@@ -53,6 +55,8 @@ func (i Action) String() string {
return _Action_name_6
case i == 8723:
return _Action_name_7
case i == 8856:
return _Action_name_8
default:
return "Action(" + strconv.FormatInt(int64(i), 10) + ")"
}

View File

@@ -364,7 +364,7 @@ func (rc *ResourceInstanceChange) Simplify(destroying bool) *ResourceInstanceCha
GeneratedConfig: rc.GeneratedConfig,
},
}
case CreateThenDelete, DeleteThenCreate:
case CreateThenDelete, DeleteThenCreate, ForgetThenCreate:
return &ResourceInstanceChange{
Addr: rc.Addr,
DeposedKey: rc.DeposedKey,
@@ -487,6 +487,18 @@ const (
// a check block and when the check assertions execute we want them to use
// the most up-to-date data.
ResourceInstanceReadBecauseCheckNested ResourceInstanceChangeActionReason = '#'
// ResourceInstanceForgotBecauseOfLifecycleDestroyInState indicates that the resource
// instance is being forgotten because the resource has a lifecycle configuration with "destroy" set to false in the state.
// This is used to avoid confusion for users not having the "lifecycle.destroy" attribute set in the current configuration (or the resource has been removed entirely).
// Used to differentiate between the different origins of the `forgot` action.
// However, we still have the attribute in the state to avoid deleting resources that should have been retained.
ResourceInstanceForgotBecauseOfLifecycleDestroyInState ResourceInstanceChangeActionReason = 'X'
// ResourceInstanceForgotBecauseOfLifecycleDestroyInConfig indicates that the resource
// instance is being forgotten because the resource has a lifecycle configuration with "destroy" set to false.
// Used to differentiate between the different origins of the `forgot` action.
ResourceInstanceForgotBecauseOfLifecycleDestroyInConfig ResourceInstanceChangeActionReason = 'Y'
)
// OutputChange describes a change to an output value.

View File

@@ -23,6 +23,8 @@ func _() {
_ = x[ResourceInstanceReadBecauseConfigUnknown-63]
_ = x[ResourceInstanceReadBecauseDependencyPending-33]
_ = x[ResourceInstanceReadBecauseCheckNested-35]
_ = x[ResourceInstanceForgotBecauseOfLifecycleDestroyInState-88]
_ = x[ResourceInstanceForgotBecauseOfLifecycleDestroyInConfig-89]
}
const (
@@ -35,12 +37,13 @@ const (
_ResourceInstanceChangeActionReason_name_6 = "ResourceInstanceDeleteBecauseEnabledFalseResourceInstanceDeleteBecauseNoModuleResourceInstanceDeleteBecauseNoResourceConfig"
_ResourceInstanceChangeActionReason_name_7 = "ResourceInstanceReplaceByRequest"
_ResourceInstanceChangeActionReason_name_8 = "ResourceInstanceReplaceBecauseTainted"
_ResourceInstanceChangeActionReason_name_9 = "ResourceInstanceDeleteBecauseWrongRepetition"
_ResourceInstanceChangeActionReason_name_9 = "ResourceInstanceDeleteBecauseWrongRepetitionResourceInstanceForgotBecauseOfLifecycleDestroyInStateResourceInstanceForgotBecauseOfLifecycleDestroyInConfig"
)
var (
_ResourceInstanceChangeActionReason_index_5 = [...]uint8{0, 39, 72, 108, 150}
_ResourceInstanceChangeActionReason_index_6 = [...]uint8{0, 41, 78, 123}
_ResourceInstanceChangeActionReason_index_9 = [...]uint8{0, 44, 98, 153}
)
func (i ResourceInstanceChangeActionReason) String() string {
@@ -65,8 +68,9 @@ func (i ResourceInstanceChangeActionReason) String() string {
return _ResourceInstanceChangeActionReason_name_7
case i == 84:
return _ResourceInstanceChangeActionReason_name_8
case i == 87:
return _ResourceInstanceChangeActionReason_name_9
case 87 <= i && i <= 89:
i -= 87
return _ResourceInstanceChangeActionReason_name_9[_ResourceInstanceChangeActionReason_index_9[i]:_ResourceInstanceChangeActionReason_index_9[i+1]]
default:
return "ResourceInstanceChangeActionReason(" + strconv.FormatInt(int64(i), 10) + ")"
}

View File

@@ -50,6 +50,8 @@ type ResourceInstanceObject struct {
// destroy operations, we need to record the status to ensure a resource
// removed from the config will still be destroyed in the same manner.
CreateBeforeDestroy bool
SkipDestroy bool
}
// ObjectStatus represents the status of a RemoteObject.
@@ -148,6 +150,7 @@ func (o *ResourceInstanceObject) Encode(ty cty.Type, schemaVersion uint64) (*Res
Status: o.Status,
Dependencies: dependencies,
CreateBeforeDestroy: o.CreateBeforeDestroy,
SkipDestroy: o.SkipDestroy,
}, nil
}

View File

@@ -72,6 +72,7 @@ type ResourceInstanceObjectSrc struct {
Status ObjectStatus
Dependencies []addrs.ConfigResource
CreateBeforeDestroy bool
SkipDestroy bool
}
// Compare two lists using an given element equal function, ignoring order and duplicates
@@ -161,6 +162,10 @@ func (os *ResourceInstanceObjectSrc) Equal(other *ResourceInstanceObjectSrc) boo
return false
}
if os.SkipDestroy != other.SkipDestroy {
return false
}
return true
}
@@ -211,6 +216,7 @@ func (os *ResourceInstanceObjectSrc) Decode(ty cty.Type) (*ResourceInstanceObjec
Dependencies: os.Dependencies,
Private: os.Private,
CreateBeforeDestroy: os.CreateBeforeDestroy,
SkipDestroy: os.SkipDestroy,
}, nil
}

View File

@@ -187,6 +187,7 @@ func (os *ResourceInstanceObjectSrc) DeepCopy() *ResourceInstanceObjectSrc {
TransientPathValueMarks: allAttrPaths,
Dependencies: dependencies,
CreateBeforeDestroy: os.CreateBeforeDestroy,
SkipDestroy: os.SkipDestroy,
}
}
@@ -223,6 +224,7 @@ func (o *ResourceInstanceObject) DeepCopy() *ResourceInstanceObject {
Private: private,
Dependencies: dependencies,
CreateBeforeDestroy: o.CreateBeforeDestroy,
SkipDestroy: o.SkipDestroy,
}
}

View File

@@ -182,6 +182,7 @@ func prepareStateV4(sV4 *stateV4) (*File, tfdiags.Diagnostics) {
obj := &states.ResourceInstanceObjectSrc{
SchemaVersion: isV4.SchemaVersion,
CreateBeforeDestroy: isV4.CreateBeforeDestroy,
SkipDestroy: isV4.SkipDestroy,
}
{
@@ -588,6 +589,7 @@ func appendInstanceObjectStateV4(rs *states.Resource, is *states.ResourceInstanc
PrivateRaw: privateRaw,
Dependencies: deps,
CreateBeforeDestroy: obj.CreateBeforeDestroy,
SkipDestroy: obj.SkipDestroy,
}), diags
}
@@ -805,6 +807,7 @@ type instanceObjectStateV4 struct {
Dependencies []string `json:"dependencies,omitempty"`
CreateBeforeDestroy bool `json:"create_before_destroy,omitempty"`
SkipDestroy bool `json:"skip_destroy,omitempty"`
}
type checkResultsV4 struct {

View File

@@ -71,6 +71,8 @@ func (c *Context) Apply(ctx context.Context, plan *plans.Plan, config *configs.C
return nil, diags
}
var forgetCount int
for _, rc := range plan.Changes.Resources {
// Import is a no-op change during an apply (all the real action happens during the plan) but we'd
// like to show some helpful output that mirrors the way we show other changes.
@@ -91,6 +93,7 @@ func (c *Context) Apply(ctx context.Context, plan *plans.Plan, config *configs.C
// Following the same logic, we want to show helpful output for forget operations as well.
if rc.Action == plans.Forget {
forgetCount++
for _, h := range c.hooks {
_, err := h.PreApplyForget(rc.Addr)
if err != nil {
@@ -141,6 +144,18 @@ func (c *Context) Apply(ctx context.Context, plan *plans.Plan, config *configs.C
// unconditionally here, but we historically didn't and haven't yet
// verified that it'd be safe to do so.
newState.PruneResourceHusks()
// If this was a destroy operation, and everything else succeeded, but
// there are instances that were forgotten (not destroyed).
// Even though this was the intended outcome, some automations may depend on the success of destroy operation
// to indicate the complete removal of resources
if forgetCount > 0 {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Destroy was successful but left behind forgotten instances",
"As requested, OpenTofu has not deleted some remote objects that are no longer managed by this configuration. Those objects continue to exist in their remote system and so may continue to incur charges. Refer to the original plan for more information.",
))
}
}
if len(plan.TargetAddrs) > 0 || len(plan.ExcludeAddrs) > 0 {

View File

@@ -20,6 +20,7 @@ import (
"github.com/opentofu/opentofu/internal/lang"
"github.com/opentofu/opentofu/internal/states"
"github.com/opentofu/opentofu/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
)
// traceNameValidateResource is a standardized trace span name we use for the
@@ -697,6 +698,49 @@ func (n *NodeAbstractResourceInstance) readResourceInstanceStateDeposed(ctx cont
return obj, diags
}
// checkSkipDestroy checks if the resource should be forgotten instead of destroyed
func (n *NodeAbstractResource) shouldSkipDestroy() (bool, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
if n.Config == nil || n.Config.Managed == nil {
return false, diags
}
skipDestroy, skipDestroyDiags := skipDestroyValueFromConstantExpression(n.Config.Managed.Destroy)
diags = diags.Append(skipDestroyDiags)
if diags.HasErrors() {
return false, diags
}
return skipDestroy, diags
}
// skipDestroyValueFromConstantExpression evaluates (lifecycle.)destroy expression coming from the config and returns !destroy (Corresponding to SkipDestroy)
// As of now, this can only be a constant expression of a boolean type. We will likely extend this in the future to make dynamic values possible
func skipDestroyValueFromConstantExpression(destroyExpr hcl.Expression) (bool, hcl.Diagnostics) {
var diags hcl.Diagnostics
// If destroyExpr is nil, we do not need to set SkipDestroy, as its zero value is false, which results in the desired default behavior (of not skipping destruction)
if destroyExpr == nil {
return false, diags
}
destroyVal, valDiags := destroyExpr.Value(nil)
if diags.HasErrors() {
return false, valDiags
}
if destroyVal.Type() != cty.Bool {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid lifecycle destroy expression",
Detail: "The lifecycle destroy expression must be a boolean constant.",
Subject: destroyExpr.Range().Ptr(),
})
return false, diags
}
return destroyVal.False(), diags
}
// graphNodesAreResourceInstancesInDifferentInstancesOfSameModule is an
// annoyingly-task-specific helper function that returns true if and only if
// the following conditions hold:

View File

@@ -1256,6 +1256,13 @@ func (n *NodeAbstractResourceInstance) plan(
return nil, nil, keyData, diags
}
skipDestroy, skipDiags := n.shouldSkipDestroy()
diags = diags.Append(skipDiags)
if diags.HasErrors() {
return nil, nil, keyData, diags
}
log.Printf("[TRACE] plan: %s lifecycle.destroy evaluation result: skipDestroy=%t", n.Addr, skipDestroy)
resp := provider.PlanResourceChange(ctx, providers.PlanResourceChangeRequest{
TypeName: n.Addr.Resource.Resource.Type,
Config: unmarkedConfigVal,
@@ -1566,6 +1573,15 @@ func (n *NodeAbstractResourceInstance) plan(
actionReason = plans.ResourceInstanceReplaceBecauseTainted
}
// We check here if user declared lifecycle destroy attribute as false, intending to retain this resource even if
// so far we thought the action was "replace".
// As mentioned above, we are not concerned with the "delete" action in this flow; the pure delete is handled elsewhere
if action.IsReplace() && skipDestroy {
// We alter the action to "forget" and "create" to not trigger resource destruction
action = plans.ForgetThenCreate
log.Printf("[DEBUG] plan: %s changing action from %s to ForgetThenCreate due to lifecycle.destroy=false", n.Addr, action)
}
// compare the marks between the prior and the new value, there may have been a change of sensitivity
// in the new value that requires an update
_, plannedNewValMarks := plannedNewVal.UnmarkDeepWithPaths()
@@ -1629,9 +1645,10 @@ func (n *NodeAbstractResourceInstance) plan(
// must _also_ record the returned change in the active plan,
// which the expression evaluator will use in preference to this
// incomplete value recorded in the state.
Status: states.ObjectPlanned,
Value: plannedNewVal,
Private: plannedPrivate,
Status: states.ObjectPlanned,
Value: plannedNewVal,
Private: plannedPrivate,
SkipDestroy: skipDestroy,
}
return plan, state, keyData, diags
@@ -2941,6 +2958,7 @@ func (n *NodeAbstractResourceInstance) apply(
// Copy the previous state, changing only the value
newState := &states.ResourceInstanceObject{
CreateBeforeDestroy: state.CreateBeforeDestroy,
SkipDestroy: state.SkipDestroy,
Dependencies: state.Dependencies,
Private: state.Private,
Status: state.Status,
@@ -3063,6 +3081,10 @@ func (n *NodeAbstractResourceInstance) apply(
newVal = cty.UnknownAsNull(newVal)
}
skipDestroy, skipDiags := n.shouldSkipDestroy()
diags = diags.Append(skipDiags)
log.Printf("[TRACE] apply: %s lifecycle.destroy evaluation result: skipDestroy=%t", n.Addr, skipDestroy)
if change.Action != plans.Delete && !diags.HasErrors() {
// Only values that were marked as unknown in the planned value are allowed
// to change during the apply operation. (We do this after the unknown-ness
@@ -3155,6 +3177,7 @@ func (n *NodeAbstractResourceInstance) apply(
Value: newVal,
Private: resp.Private,
CreateBeforeDestroy: createBeforeDestroy,
SkipDestroy: state.SkipDestroy,
}
// if the resource was being deleted, the dependencies are not going to
@@ -3172,11 +3195,12 @@ func (n *NodeAbstractResourceInstance) apply(
Value: newVal,
Private: resp.Private,
CreateBeforeDestroy: createBeforeDestroy,
SkipDestroy: skipDestroy,
}
return newState, diags
default:
// Non error case, were the object was deleted
// Non-error case, where the object was deleted
return nil, diags
}
}

View File

@@ -481,6 +481,10 @@ func (n *NodeApplyableResourceInstance) checkPlannedChange(evalCtx EvalContext,
if plannedChange.Action != actualChange.Action {
switch {
case plannedChange.Action == plans.ForgetThenCreate && actualChange.Action == plans.Create:
// This is an expected alteration of the action, since we are, first - forgetting the resource and then calling
// the diffApply plan, with no state for the resource, we are generating the Create action instead of ForgetThenCreate
log.Printf("[DEBUG] For apply the action ForgetThenCreate was changed to Create for resource %s", absAddr)
case plannedChange.Action == plans.Update && actualChange.Action == plans.NoOp:
// It's okay for an update to become a NoOp once we've filled in
// all of the unknown values, since the final values might actually

View File

@@ -162,30 +162,44 @@ func (n *NodePlanDeposedResourceInstanceObject) Execute(ctx context.Context, eva
if !n.skipPlanChanges {
var change *plans.ResourceInstanceChange
var planDiags tfdiags.Diagnostics
skipDestroy, skipDiags := n.shouldSkipDestroy()
diags = diags.Append(skipDiags)
if diags.HasErrors() {
return diags
}
shouldForget := false
shouldDestroy := false
// We skip destroy for a depose instance in 2 cases:
// 1) Resource had lifecycle attribute destroy explicitly set to false
// 2) Removed block is declared to remove the resource from the state without it's destroy set to true
// For every other case, we should destroy the resource
// If the deposed instance has skip_destroy set in state, we skip destroying
shouldDestroy := !skipDestroy && !state.SkipDestroy
log.Printf("[TRACE] NodePlanDeposedResourceInstanceObject.Execute: %s (deposed %s): skipDestroy based on config=%t; based on state.SkipDestroy=%t; shouldDestroy=%t", n.Addr, n.DeposedKey, skipDestroy, state.SkipDestroy, shouldDestroy)
// Note that removed statements take precedence, since it is the latest intent the user declared
// As opposed to the lifecycle attribute, which might have been altered after the resource got deposed
for _, rs := range n.RemoveStatements {
if rs.From.TargetContains(n.Addr) {
shouldForget = true
shouldDestroy = rs.Destroy
log.Printf("[DEBUG] NodePlanDeposedResourceInstanceObject.Execute: %s (deposed %s) removed block found, overriding shouldDestroy to %t", n.Addr, n.DeposedKey, shouldDestroy)
}
}
if shouldForget {
if shouldDestroy {
change, planDiags = n.planDestroy(ctx, evalCtx, state, n.DeposedKey)
} else {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Resource going to be removed from the state",
Detail: fmt.Sprintf("After this plan gets applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", n.Addr),
})
change = n.planForget(ctx, evalCtx, state, n.DeposedKey)
}
} else {
if shouldDestroy {
change, planDiags = n.planDestroy(ctx, evalCtx, state, n.DeposedKey)
} else {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Resource will be removed from the state",
Detail: fmt.Sprintf("After this plan is applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", n.Addr),
})
log.Printf("[DEBUG] NodePlanDeposedResourceInstanceObject.Execute: %s (deposed %s) planning forget instead of destroy", n.Addr, n.DeposedKey)
change = n.planForget(ctx, evalCtx, state, n.DeposedKey)
if skipDestroy {
change.ActionReason = plans.ResourceInstanceForgotBecauseOfLifecycleDestroyInConfig
} else if state.SkipDestroy {
change.ActionReason = plans.ResourceInstanceForgotBecauseOfLifecycleDestroyInState
}
}
diags = diags.Append(planDiags)

View File

@@ -66,8 +66,8 @@ func TestNodePlanDeposedResourceInstanceObject_Execute(t *testing.T) {
wantAction: plans.Forget,
wantDiags: tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Resource going to be removed from the state",
Detail: fmt.Sprintf("After this plan gets applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "test_instance.foo"),
Summary: "Resource will be removed from the state",
Detail: fmt.Sprintf("After this plan is applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "test_instance.foo"),
}),
},
{
@@ -92,8 +92,8 @@ func TestNodePlanDeposedResourceInstanceObject_Execute(t *testing.T) {
wantAction: plans.Forget,
wantDiags: tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Resource going to be removed from the state",
Detail: fmt.Sprintf("After this plan gets applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "test_instance.foo[1]"),
Summary: "Resource will be removed from the state",
Detail: fmt.Sprintf("After this plan is applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "test_instance.foo[1]"),
}),
},
{
@@ -118,8 +118,8 @@ func TestNodePlanDeposedResourceInstanceObject_Execute(t *testing.T) {
wantAction: plans.Forget,
wantDiags: tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Resource going to be removed from the state",
Detail: fmt.Sprintf("After this plan gets applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "module.boop.test_instance.foo"),
Summary: "Resource will be removed from the state",
Detail: fmt.Sprintf("After this plan is applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "module.boop.test_instance.foo"),
}),
},
{
@@ -155,8 +155,8 @@ func TestNodePlanDeposedResourceInstanceObject_Execute(t *testing.T) {
wantAction: plans.Forget,
wantDiags: tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Resource going to be removed from the state",
Detail: fmt.Sprintf("After this plan gets applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "module.boop[1].test_instance.foo[1]"),
Summary: "Resource will be removed from the state",
Detail: fmt.Sprintf("After this plan is applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "module.boop[1].test_instance.foo[1]"),
}),
},
{
@@ -170,8 +170,8 @@ func TestNodePlanDeposedResourceInstanceObject_Execute(t *testing.T) {
wantAction: plans.Forget,
wantDiags: tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Resource going to be removed from the state",
Detail: fmt.Sprintf("After this plan gets applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "module.boop.test_instance.foo"),
Summary: "Resource will be removed from the state",
Detail: fmt.Sprintf("After this plan is applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "module.boop.test_instance.foo"),
}),
},
{
@@ -185,8 +185,8 @@ func TestNodePlanDeposedResourceInstanceObject_Execute(t *testing.T) {
wantAction: plans.Forget,
wantDiags: tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Resource going to be removed from the state",
Detail: fmt.Sprintf("After this plan gets applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "module.boop[1].test_instance.foo"),
Summary: "Resource will be removed from the state",
Detail: fmt.Sprintf("After this plan is applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "module.boop[1].test_instance.foo"),
}),
},
}

View File

@@ -132,8 +132,24 @@ func (n *NodePlanDestroyableResourceInstance) managedResourceExecute(ctx context
}
}
change, destroyPlanDiags := n.planDestroy(ctx, evalCtx, state, "")
diags = diags.Append(destroyPlanDiags)
var planDiags tfdiags.Diagnostics
skipDestroy, skipDiags := n.shouldSkipDestroy()
diags = diags.Append(skipDiags)
if diags.HasErrors() {
return diags
}
if skipDestroy {
log.Printf("[DEBUG] NodePlanDestroyableResourceInstance.managedResourceExecute: %s planning forget instead of destroy due to lifecycle.destroy=false in configuration", addr)
change = n.planForget(ctx, evalCtx, state, "")
change.ActionReason = plans.ResourceInstanceForgotBecauseOfLifecycleDestroyInConfig
} else if state.SkipDestroy {
log.Printf("[DEBUG] NodePlanDestroyableResourceInstance.managedResourceExecute: %s planning forget instead of destroy due to lifecycle.destroy=false in state", addr)
change = n.planForget(ctx, evalCtx, state, "")
change.ActionReason = plans.ResourceInstanceForgotBecauseOfLifecycleDestroyInState
} else {
change, planDiags = n.planDestroy(ctx, evalCtx, state, "")
}
diags = diags.Append(planDiags)
if diags.HasErrors() {
return diags
}
@@ -142,7 +158,6 @@ func (n *NodePlanDestroyableResourceInstance) managedResourceExecute(ctx context
if diags.HasErrors() {
return diags
}
diags = diags.Append(n.checkPreventDestroy(ctx, evalCtx, change))
return diags
}

View File

@@ -320,18 +320,28 @@ func (n *NodePlannableResourceInstance) managedResourceExecute(ctx context.Conte
} else {
if instanceRefreshState != nil {
prevCreateBeforeDestroy := instanceRefreshState.CreateBeforeDestroy
prevSkipDestroy := instanceRefreshState.SkipDestroy
// This change is usually written to the refreshState and then
// updated value used for further graph execution. However, with
// "refresh=false", refreshState is not being written and then
// "refresh=false", refreshState is not being written, and then
// some resources with updated configuration could be detached
// due to missaligned create_before_destroy in different graph nodes.
// due to misaligned create_before_destroy and skip_destroy in different graph nodes.
instanceRefreshState.CreateBeforeDestroy = n.Config.Managed.CreateBeforeDestroy || n.ForceCreateBeforeDestroy
// Destroy coming from the config is an hcl.Expression, so we need to evaluate it here, currently this only supports constant booleans
skipDestroy, skipDiags := n.shouldSkipDestroy()
diags = diags.Append(skipDiags)
if diags.HasErrors() {
return diags
}
instanceRefreshState.SkipDestroy = skipDestroy
if prevCreateBeforeDestroy != instanceRefreshState.CreateBeforeDestroy && n.skipRefresh {
diags = diags.Append(n.writeResourceInstanceState(ctx, evalCtx, instanceRefreshState, refreshState))
if diags.HasErrors() {
return diags
if n.skipRefresh {
if prevCreateBeforeDestroy != instanceRefreshState.CreateBeforeDestroy || prevSkipDestroy != instanceRefreshState.SkipDestroy {
diags = diags.Append(n.writeResourceInstanceState(ctx, evalCtx, instanceRefreshState, refreshState))
if diags.HasErrors() {
return diags
}
}
}
}

View File

@@ -187,29 +187,45 @@ func (n *NodePlannableResourceInstanceOrphan) managedResourceExecute(ctx context
var change *plans.ResourceInstanceChange
var planDiags tfdiags.Diagnostics
shouldForget := false
shouldDestroy := false // NOTE: false for backwards compatibility. This is not the same behavior that the other system is having.
skipDestroy, skipDiags := n.shouldSkipDestroy()
diags = diags.Append(skipDiags)
if diags.HasErrors() {
return diags
}
// We skip destroy for an orphaned resource instance in 2 cases:
// 1) Resource had lifecycle attribute destroy explicitly set to false (either in config or in state)
// Config case in case of orphans only applies for multi-instance resources (count/for_each)
// 2) Removed block is declared to remove the resource from the state without it's destroy set to true
// For every other case, we should destroy the resource
// If the orphan instance has skip_destroy set in state, we skip destroying
shouldDestroy := !skipDestroy && !oldState.SkipDestroy
log.Printf("[TRACE] NodePlannableResourceInstanceOrphan.managedResourceExecute: %s (orphan): shouldDestroy=%t (based on config)", n.Addr, shouldDestroy)
// Note that removed statements take precedence, since it is the latest intent the user declared
// As opposed to the lifecycle attribute, which was the previous intention declared on the orphaned resource
for _, rs := range n.RemoveStatements {
if rs.From.TargetContains(n.Addr) {
shouldForget = true
shouldDestroy = rs.Destroy
log.Printf("[DEBUG] NodePlannableResourceInstanceOrphan.managedResourceExecute: %s (orphan) removed block found, overriding shouldDestroy to %t", addr, shouldDestroy)
}
}
if shouldForget {
if shouldDestroy {
change, planDiags = n.planDestroy(ctx, evalCtx, oldState, "")
} else {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Resource going to be removed from the state",
Detail: fmt.Sprintf("After this plan gets applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", n.Addr),
})
change = n.planForget(ctx, evalCtx, oldState, "")
}
} else {
if shouldDestroy {
change, planDiags = n.planDestroy(ctx, evalCtx, oldState, "")
} else {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Resource will be removed from the state",
Detail: fmt.Sprintf("After this plan is applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", n.Addr),
})
log.Printf("[DEBUG] NodePlannableResourceInstanceOrphan.managedResourceExecute: %s (orphan) planning forget instead of destroy", addr)
change = n.planForget(ctx, evalCtx, oldState, "")
if skipDestroy {
change.ActionReason = plans.ResourceInstanceForgotBecauseOfLifecycleDestroyInConfig
} else if oldState.SkipDestroy {
change.ActionReason = plans.ResourceInstanceForgotBecauseOfLifecycleDestroyInState
}
}
diags = diags.Append(planDiags)

View File

@@ -60,8 +60,8 @@ func TestNodeResourcePlanOrphan_Execute(t *testing.T) {
wantAction: plans.Forget,
wantDiags: tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Resource going to be removed from the state",
Detail: fmt.Sprintf("After this plan gets applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "test_instance.foo"),
Summary: "Resource will be removed from the state",
Detail: fmt.Sprintf("After this plan is applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "test_instance.foo"),
}),
},
{
@@ -84,8 +84,8 @@ func TestNodeResourcePlanOrphan_Execute(t *testing.T) {
wantAction: plans.Forget,
wantDiags: tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Resource going to be removed from the state",
Detail: fmt.Sprintf("After this plan gets applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "test_instance.foo[1]"),
Summary: "Resource will be removed from the state",
Detail: fmt.Sprintf("After this plan is applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "test_instance.foo[1]"),
}),
},
{
@@ -108,8 +108,8 @@ func TestNodeResourcePlanOrphan_Execute(t *testing.T) {
wantAction: plans.Forget,
wantDiags: tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Resource going to be removed from the state",
Detail: fmt.Sprintf("After this plan gets applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "module.boop.test_instance.foo"),
Summary: "Resource will be removed from the state",
Detail: fmt.Sprintf("After this plan is applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "module.boop.test_instance.foo"),
}),
},
{
@@ -143,8 +143,8 @@ func TestNodeResourcePlanOrphan_Execute(t *testing.T) {
wantAction: plans.Forget,
wantDiags: tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Resource going to be removed from the state",
Detail: fmt.Sprintf("After this plan gets applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "module.boop[1].test_instance.foo[1]"),
Summary: "Resource will be removed from the state",
Detail: fmt.Sprintf("After this plan is applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "module.boop[1].test_instance.foo[1]"),
}),
},
{
@@ -156,8 +156,8 @@ func TestNodeResourcePlanOrphan_Execute(t *testing.T) {
wantAction: plans.Forget,
wantDiags: tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Resource going to be removed from the state",
Detail: fmt.Sprintf("After this plan gets applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "module.boop.test_instance.foo"),
Summary: "Resource will be removed from the state",
Detail: fmt.Sprintf("After this plan is applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "module.boop.test_instance.foo"),
}),
},
{
@@ -169,8 +169,8 @@ func TestNodeResourcePlanOrphan_Execute(t *testing.T) {
wantAction: plans.Forget,
wantDiags: tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Resource going to be removed from the state",
Detail: fmt.Sprintf("After this plan gets applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "module.boop[1].test_instance.foo"),
Summary: "Resource will be removed from the state",
Detail: fmt.Sprintf("After this plan is applied, the resource %s will not be managed anymore by OpenTofu.\n\nIn case you want to manage the resource again, you will have to import it.", "module.boop[1].test_instance.foo"),
}),
},
}

File diff suppressed because it is too large Load Diff

2
internal/tofu/testdata/simple/main.tf vendored Normal file
View File

@@ -0,0 +1,2 @@
resource "aws_instance" "foo" {
}

View File

@@ -0,0 +1,5 @@
resource "aws_instance" "foo" {
lifecycle {
destroy = false
}
}

View File

@@ -105,6 +105,9 @@ func (t *DiffTransformer) Transform(_ context.Context, g *Graph) error {
delete = true
case plans.Forget:
forget = true
case plans.ForgetThenCreate:
update = true
forget = true
case plans.DeleteThenCreate, plans.CreateThenDelete:
update = true
delete = true
@@ -239,6 +242,13 @@ func (t *DiffTransformer) Transform(_ context.Context, g *Graph) error {
for _, rsrcNode := range resourceNodes[rsrcAddr] {
g.Connect(dag.BasicEdge(node, rsrcNode))
}
if forget {
// We need to first forget the resource instance and make anew
forgetNode := produceForgetNode(addr, dk)
g.Add(forgetNode)
g.Connect(dag.BasicEdge(node, forgetNode))
}
}
if delete {
@@ -269,24 +279,8 @@ func (t *DiffTransformer) Transform(_ context.Context, g *Graph) error {
g.Add(node)
}
if forget {
var node GraphNodeResourceInstance
abstract := NewNodeAbstractResourceInstance(addr)
if dk == states.NotDeposed {
node = &NodeForgetResourceInstance{
NodeAbstractResourceInstance: abstract,
DeposedKey: dk,
}
log.Printf("[TRACE] DiffTransformer: %s will be represented for removal from the state by %s", addr, dag.VertexName(node))
} else {
node = &NodeForgetDeposedResourceInstanceObject{
NodeAbstractResourceInstance: abstract,
DeposedKey: dk,
}
log.Printf("[TRACE] DiffTransformer: %s deposed object %s will be represented for removal from the state by %s", addr, dk, dag.VertexName(node))
}
g.Add(node)
if forget && !update {
g.Add(produceForgetNode(addr, dk))
}
}
@@ -295,3 +289,22 @@ func (t *DiffTransformer) Transform(_ context.Context, g *Graph) error {
return diags.Err()
}
func produceForgetNode(addr addrs.AbsResourceInstance, deposedKey states.DeposedKey) GraphNodeResourceInstance {
var node GraphNodeResourceInstance
abstract := NewNodeAbstractResourceInstance(addr)
if deposedKey == states.NotDeposed {
node = &NodeForgetResourceInstance{
NodeAbstractResourceInstance: abstract,
DeposedKey: deposedKey,
}
log.Printf("[TRACE] DiffTransformer: %s will be represented for removal from the state by %s", addr, dag.VertexName(node))
} else {
node = &NodeForgetDeposedResourceInstanceObject{
NodeAbstractResourceInstance: abstract,
DeposedKey: deposedKey,
}
log.Printf("[TRACE] DiffTransformer: %s deposed object %s will be represented for removal from the state by %s", addr, deposedKey, dag.VertexName(node))
}
return node
}

View File

@@ -39,3 +39,32 @@ tofu plan -destroy
This will run [`tofu plan`](plan.mdx) in _destroy_ mode, showing
you the proposed destroy changes without executing them.
## Forgotten Resources
<span id="forgotten-resources"></span>
When you run `tofu destroy`, OpenTofu will attempt to destroy all resources
managed by the configuration. However, if any resources have the
[`lifecycle.destroy`](../../language/resources/behavior.mdx#lifecycle-customizations)
meta-argument set to `false`, those resources will be "forgotten" instead of
destroyed.
When resources are forgotten:
- They are removed from the OpenTofu state file
- The actual infrastructure objects remain intact in your cloud provider or remote system
- The `tofu destroy` command exits with a non-zero status code to indicate
that not all resources were fully removed
This exit code behavior might be important for automation and CI/CD pipelines, as it
signals that the destroy operation did not complete as a typical destroy would.
:::warning
The `destroy` attribute is persisted in the state file, even when resources are removed from the configuration.
Meaning that resources will **not** be destroyed even when removed from the configuration.
If you want to fully destroy such resources (still in the state), you must first add the resource configuration back and
remove the `lifecycle.destroy` attribute (or set it to `true`).
Alternatively, you can add the `removed` block for the removed resource with `lifecycle.destroy = true`, which will override the `destroy` attribute in the state file.
:::

View File

@@ -162,6 +162,63 @@ block for a managed resource:
case, the `prevent_destroy` setting is removed along with it, and so OpenTofu
would allow the destroy operation to succeed.
In case you want to retain the infrastructure object even after removing the
`resource` block from configuration, consider using the `destroy` argument
documented below instead. Note that, if you set `destroy = false`, OpenTofu will
change the `destroy` actions to a variant of `forget` action, and `prevent_destroy`
will not have any effect.
* `destroy` (bool) - By default, when a resource is removed from the configuration,
requires replacement, or is explicitly destroyed using the `tofu destroy` command,
OpenTofu will destroy the corresponding infrastructure object. Setting this
meta-argument to `false` changes this behavior so that OpenTofu will instead
"forget" the resource instance, removing it from the state without destroying the actual
infrastructure object.
:::note
`lifecycle.destroy` only accepts constant boolean values (`true` or `false`).
:::
When a resource with `destroy = false` is removed from the configuration or requires replacement,
OpenTofu will plan to **forget** it rather than **destroy** it. The resource will
be removed from the state file, but the actual infrastructure object will
remain unchanged in your cloud provider or other remote system. If the resource requires replacement,
OpenTofu will then create a new resource instance as per the current configuration.
This is useful when you want to keep the resource in configuration but alter its destruction behavior. Incomplete list of use cases includes:
- Retain critical or compliance resources even when destroying the rest of the environment, without manually removing them from configuration beforehand.
Enables more automation with reviewable configuration changes.
- Retain old resource instances when performing resource replacements or infrastructure upgrades,
avoiding downtime or data loss. Making potential rollbacks faster and easier.
- Certain resource instances might be impossible or impractical to destroy due to external dependencies or constraints.
Note that this argument is **persisted in the state**, once you set and apply `destroy = false` for a resource
OpenTofu will not plan the resource destruction unless you explicitly change it back to `true` or remove the option from the corresponding resource configuration block.
OpenTofu errs on the side of caution and avoids destroying resources that were marked with `destroy = false` in the last applied configuration for the resource instance.
If you are using single instance resources (no count or for_each), you can override this attribute in the state by writing explicit `removed` block for the resource instance with `destroy = true` option.
:::note
This argument can also be used in [`removed` blocks](syntax.mdx#removing-resources)
to control whether resources should be destroyed or forgotten when explicitly
removing them from OpenTofu management. The behavior is identical in both contexts.
Generally, prefer to use `removed` blocks when you want to remove resources from your configurations as a method of refactoring.
Use `destroy` lifecycle argument when you want to control the destruction behavior of resources that are still present in your configuration.
:::
The `destroy` argument also applies when using the `tofu destroy` command.
Resources with `destroy = false` will be forgotten rather than destroyed,
and the command will exit with a non-zero status code to indicate that some
resources were not fully removed. See the [`tofu destroy` command documentation](../../cli/commands/destroy.mdx#forgotten-resources)
for more details.
:::warning
Once a resource is forgotten (removed from state), OpenTofu will no longer
track it. If you later add the resource back to your configuration with the
same address, OpenTofu will attempt to create a new resource, which may fail
if the infrastructure object still exists. You may need to import the existing
resource or use a different resource address.
:::
* <span id="ignore_changes">`ignore_changes`</span> (list of attribute names) - By default, OpenTofu detects
any difference in the current settings of a real infrastructure object
and plans to update the remote object to match configuration.