Retaining resources during destruction - New flag -suppress-forget-errors (#3588)

Signed-off-by: Ilia Gogotchuri <ilia.gogotchuri0@gmail.com>
This commit is contained in:
Ilia Gogotchuri
2025-12-16 15:41:03 +04:00
committed by GitHub
parent 0256de5c4d
commit 1eacb9a046
9 changed files with 149 additions and 19 deletions

View File

@@ -10,6 +10,7 @@ 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)) - `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)) - 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))
- New `-suppress-forget-errors` flag for the `tofu destroy` command to suppress errors and exit with a zero status code when resources are forgotten during destroy operations. ([#3588](https://github.com/opentofu/opentofu/issues/3588))
- 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)) - 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)) - 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)) - `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

@@ -297,7 +297,9 @@ type Operation struct {
// Injected by the command creating the operation (plan/apply/refresh/etc...) // Injected by the command creating the operation (plan/apply/refresh/etc...)
Variables map[string]UnparsedVariableValue Variables map[string]UnparsedVariableValue
RootCall configs.StaticModuleCall RootCall configs.StaticModuleCall
// SuppressForgetErrorsDuringDestroy suppresses the error that occurs when a
// destroy operation completes successfully but leaves forgotten instances behind.
SuppressForgetErrorsDuringDestroy bool
// Some operations use root module variables only opportunistically or // Some operations use root module variables only opportunistically or
// don't need them at all. If this flag is set, the backend must treat // don't need them at all. If this flag is set, the backend must treat
// all variables as optional and provide an unknown value for any required // all variables as optional and provide an unknown value for any required

View File

@@ -213,6 +213,11 @@ func (b *Local) localRunDirect(ctx context.Context, op *backend.Operation, run *
} }
run.PlanOpts = planOpts run.PlanOpts = planOpts
// Set ApplyOpts for direct runs to pass through the CLI flag
run.ApplyOpts = &tofu.ApplyOpts{
SuppressForgetErrorsDuringDestroy: op.SuppressForgetErrorsDuringDestroy,
}
// For a "direct" local run, the input state is the most recently stored // For a "direct" local run, the input state is the most recently stored
// snapshot, from the previous run. // snapshot, from the previous run.
state := s.State() state := s.State()
@@ -282,7 +287,10 @@ func (b *Local) localRunForPlanFile(ctx context.Context, op *backend.Operation,
diags = diags.Append(undeclaredDiags) diags = diags.Append(undeclaredDiags)
declaredVars, declaredDiags := backend.ParseDeclaredVariableValues(op.Variables, config.Module.Variables) declaredVars, declaredDiags := backend.ParseDeclaredVariableValues(op.Variables, config.Module.Variables)
diags = diags.Append(declaredDiags) diags = diags.Append(declaredDiags)
run.ApplyOpts = &tofu.ApplyOpts{SetVariables: declaredVars} run.ApplyOpts = &tofu.ApplyOpts{
SetVariables: declaredVars,
SuppressForgetErrorsDuringDestroy: op.SuppressForgetErrorsDuringDestroy,
}
// NOTE: We're intentionally comparing the current locks with the // NOTE: We're intentionally comparing the current locks with the
// configuration snapshot, rather than the lock snapshot in the plan file, // configuration snapshot, rather than the lock snapshot in the plan file,

View File

@@ -112,7 +112,7 @@ func (c *ApplyCommand) Run(rawArgs []string) int {
} }
// Build the operation request // Build the operation request
opReq, opDiags := c.OperationRequest(ctx, be, view, args.ViewType, planFile, args.Operation, args.AutoApprove, enc) opReq, opDiags := c.OperationRequest(ctx, be, view, args, planFile, enc)
diags = diags.Append(opDiags) diags = diags.Append(opDiags)
// Before we delegate to the backend, we'll print any warning diagnostics // Before we delegate to the backend, we'll print any warning diagnostics
@@ -254,10 +254,8 @@ func (c *ApplyCommand) OperationRequest(
ctx context.Context, ctx context.Context,
be backend.Enhanced, be backend.Enhanced,
view views.Apply, view views.Apply,
viewType arguments.ViewType, applyArgs *arguments.Apply,
planFile *planfile.WrappedPlanFile, planFile *planfile.WrappedPlanFile,
args *arguments.Operation,
autoApprove bool,
enc encryption.Encryption, enc encryption.Encryption,
) (*backend.Operation, tfdiags.Diagnostics) { ) (*backend.Operation, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics var diags tfdiags.Diagnostics
@@ -268,16 +266,17 @@ func (c *ApplyCommand) OperationRequest(
diags = diags.Append(c.providerDevOverrideRuntimeWarnings()) diags = diags.Append(c.providerDevOverrideRuntimeWarnings())
// Build the operation // Build the operation
opReq := c.Operation(ctx, be, viewType, enc) opReq := c.Operation(ctx, be, applyArgs.ViewType, enc)
opReq.AutoApprove = autoApprove opReq.AutoApprove = applyArgs.AutoApprove
opReq.SuppressForgetErrorsDuringDestroy = applyArgs.SuppressForgetErrorsDuringDestroy
opReq.ConfigDir = "." opReq.ConfigDir = "."
opReq.PlanMode = args.PlanMode opReq.PlanMode = applyArgs.Operation.PlanMode
opReq.Hooks = view.Hooks() opReq.Hooks = view.Hooks()
opReq.PlanFile = planFile opReq.PlanFile = planFile
opReq.PlanRefresh = args.Refresh opReq.PlanRefresh = applyArgs.Operation.Refresh
opReq.Targets = args.Targets opReq.Targets = applyArgs.Operation.Targets
opReq.Excludes = args.Excludes opReq.Excludes = applyArgs.Operation.Excludes
opReq.ForceReplace = args.ForceReplace opReq.ForceReplace = applyArgs.Operation.ForceReplace
opReq.Type = backend.OperationTypeApply opReq.Type = backend.OperationTypeApply
opReq.View = view.Operation() opReq.View = view.Operation()
@@ -385,6 +384,10 @@ Options:
-show-sensitive If specified, sensitive values will be displayed. -show-sensitive If specified, sensitive values will be displayed.
-suppress-forget-errors Suppress the error that occurs when a destroy
operation completes successfully but leaves
forgotten instances behind.
-var 'foo=bar' Set a variable in the OpenTofu configuration. -var 'foo=bar' Set a variable in the OpenTofu configuration.
This flag can be set multiple times. This flag can be set multiple times.
@@ -424,6 +427,12 @@ Usage: tofu [global options] destroy [options]
This command is a convenience alias for: This command is a convenience alias for:
tofu apply -destroy tofu apply -destroy
Options:
-suppress-forget-errors Suppress the error that occurs when a destroy
operation completes successfully but leaves
forgotten instances behind.
This command also accepts many of the plan-customization options accepted by This command also accepts many of the plan-customization options accepted by
the tofu plan command. For more information on those options, run: the tofu plan command. For more information on those options, run:
tofu plan -help tofu plan -help

View File

@@ -460,6 +460,76 @@ func TestApply_destroySkipInConfigAndState(t *testing.T) {
} }
} }
// TestApply_destroySkipWithSuppressFlag tests that the -suppress-forget-errors
// flag suppresses the error when destroy mode leaves forgotten instances behind.
func TestApply_destroySkipWithSuppressFlag(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 with SkipDestroy set
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,
},
}
// with the suppress flag, the destroy should succeed even with forgotten instances
args := []string{
"-suppress-forget-errors",
"-state", statePath,
}
code := c.Run(args)
output := done(t)
if code != 0 {
t.Log(output.Stdout())
t.Fatalf("expected success with -suppress-forget-errors, but got: %d\n\n%s", code, 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")
}
// state should be empty after the destroy
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. // 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 // 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) { func TestApply_destroySkipInStateNotInConfig(t *testing.T) {

View File

@@ -34,6 +34,10 @@ type Apply struct {
// ShowSensitive is used to display the value of variables marked as sensitive. // ShowSensitive is used to display the value of variables marked as sensitive.
ShowSensitive bool ShowSensitive bool
// SuppressForgetErrorsDuringDestroy suppresses the error that occurs when a
// destroy operation completes successfully but leaves forgotten instances behind.
SuppressForgetErrorsDuringDestroy bool
} }
// ParseApply processes CLI arguments, returning an Apply value and errors. // ParseApply processes CLI arguments, returning an Apply value and errors.
@@ -51,6 +55,7 @@ func ParseApply(args []string) (*Apply, tfdiags.Diagnostics) {
cmdFlags.BoolVar(&apply.AutoApprove, "auto-approve", false, "auto-approve") cmdFlags.BoolVar(&apply.AutoApprove, "auto-approve", false, "auto-approve")
cmdFlags.BoolVar(&apply.InputEnabled, "input", true, "input") cmdFlags.BoolVar(&apply.InputEnabled, "input", true, "input")
cmdFlags.BoolVar(&apply.ShowSensitive, "show-sensitive", false, "displays sensitive values") cmdFlags.BoolVar(&apply.ShowSensitive, "show-sensitive", false, "displays sensitive values")
cmdFlags.BoolVar(&apply.SuppressForgetErrorsDuringDestroy, "suppress-forget-errors", false, "suppress errors in destroy mode due to resources being forgotten")
var json bool var json bool
cmdFlags.BoolVar(&json, "json", false, "json") cmdFlags.BoolVar(&json, "json", false, "json")

View File

@@ -37,6 +37,10 @@ type ApplyOpts struct {
// in Context#mergePlanAndApplyVariables, the merging of this with the plan variable values // in Context#mergePlanAndApplyVariables, the merging of this with the plan variable values
// follows the same logic and rules of the validation mentioned above. // follows the same logic and rules of the validation mentioned above.
SetVariables InputValues SetVariables InputValues
// SuppressForgetErrorsDuringDestroy suppresses the error that would otherwise
// be raised when a destroy operation completes with forgotten instances remaining.
SuppressForgetErrorsDuringDestroy bool
} }
// Apply performs the actions described by the given Plan object and returns // Apply performs the actions described by the given Plan object and returns
@@ -150,11 +154,15 @@ func (c *Context) Apply(ctx context.Context, plan *plans.Plan, config *configs.C
// Even though this was the intended outcome, some automations may depend on the success of destroy operation // Even though this was the intended outcome, some automations may depend on the success of destroy operation
// to indicate the complete removal of resources // to indicate the complete removal of resources
if forgetCount > 0 { if forgetCount > 0 {
diags = diags.Append(tfdiags.Sourceless( suppressError := opts != nil && opts.SuppressForgetErrorsDuringDestroy
tfdiags.Error, if !suppressError {
"Destroy was successful but left behind forgotten instances", diags = diags.Append(tfdiags.Sourceless(
"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.", 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.
To suppress this error for the future 'destroy' runs, you can add the CLI flag "-suppress-forget-errors".`,
))
}
} }
} }

View File

@@ -39,6 +39,7 @@ type skipDestroyTestCase struct {
runApply bool runApply bool
expectApplyError bool expectApplyError bool
expectEmptyState bool expectEmptyState bool
applyOpts *ApplyOpts
} }
func setupSkipTestState(t *testing.T, instances []skipStateInstance) *states.State { func setupSkipTestState(t *testing.T, instances []skipStateInstance) *states.State {
@@ -128,7 +129,7 @@ func runSkipDestroyTestCase(t *testing.T, tc skipDestroyTestCase) {
verifySkipPlanChanges(t, plan, tc.expectedChanges) verifySkipPlanChanges(t, plan, tc.expectedChanges)
if tc.runApply { if tc.runApply {
appliedState, applyDiags := ctx.Apply(t.Context(), plan, m, nil) appliedState, applyDiags := ctx.Apply(t.Context(), plan, m, tc.applyOpts)
if tc.expectApplyError { if tc.expectApplyError {
if !applyDiags.HasErrors() { if !applyDiags.HasErrors() {
@@ -367,6 +368,30 @@ func TestSkipDestroy_DestroyMode_ErrorOnForgotten(t *testing.T) {
expectApplyError: false, expectApplyError: false,
expectEmptyState: true, expectEmptyState: true,
}, },
{
// Identical to the first test and no error because of the suppress flag below
name: "NoErrorOnForgotten_WithSuppressFlag",
config: `
resource "aws_instance" "foo" {
lifecycle {
destroy = false
}
}
`,
stateInstances: []skipStateInstance{
{addr: "aws_instance.foo", skipDestroy: true},
},
planMode: plans.DestroyMode,
expectedChanges: []skipExpectedChange{
{addr: "aws_instance.foo", action: plans.Forget},
},
runApply: true,
expectApplyError: false,
expectEmptyState: true,
applyOpts: &ApplyOpts{
SuppressForgetErrorsDuringDestroy: true,
},
},
} }
for _, tc := range tc { for _, tc := range tc {

View File

@@ -59,6 +59,8 @@ When resources are forgotten:
This exit code behavior might be important for automation and CI/CD pipelines, as it 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. signals that the destroy operation did not complete as a typical destroy would.
In case you want `tofu destroy` to not emit errors and exit with zero status code when resources are forgotten,
you can use the `-suppress-forget-errors` flag.
:::warning :::warning
The `destroy` attribute is persisted in the state file, even when resources are removed from the configuration. The `destroy` attribute is persisted in the state file, even when resources are removed from the configuration.