diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e44b39a54..5b0713437a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) - 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)) - 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)) diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 2edc04740a..b31b1abafa 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -297,7 +297,9 @@ type Operation struct { // Injected by the command creating the operation (plan/apply/refresh/etc...) Variables map[string]UnparsedVariableValue 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 // 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 diff --git a/internal/backend/local/backend_local.go b/internal/backend/local/backend_local.go index 7cf81cd9f7..fc35d96955 100644 --- a/internal/backend/local/backend_local.go +++ b/internal/backend/local/backend_local.go @@ -213,6 +213,11 @@ func (b *Local) localRunDirect(ctx context.Context, op *backend.Operation, run * } 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 // snapshot, from the previous run. state := s.State() @@ -282,7 +287,10 @@ func (b *Local) localRunForPlanFile(ctx context.Context, op *backend.Operation, diags = diags.Append(undeclaredDiags) declaredVars, declaredDiags := backend.ParseDeclaredVariableValues(op.Variables, config.Module.Variables) 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 // configuration snapshot, rather than the lock snapshot in the plan file, diff --git a/internal/command/apply.go b/internal/command/apply.go index ae0c7dd73e..f07d5706ad 100644 --- a/internal/command/apply.go +++ b/internal/command/apply.go @@ -112,7 +112,7 @@ func (c *ApplyCommand) Run(rawArgs []string) int { } // 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) // Before we delegate to the backend, we'll print any warning diagnostics @@ -254,10 +254,8 @@ func (c *ApplyCommand) OperationRequest( ctx context.Context, be backend.Enhanced, view views.Apply, - viewType arguments.ViewType, + applyArgs *arguments.Apply, planFile *planfile.WrappedPlanFile, - args *arguments.Operation, - autoApprove bool, enc encryption.Encryption, ) (*backend.Operation, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics @@ -268,16 +266,17 @@ func (c *ApplyCommand) OperationRequest( diags = diags.Append(c.providerDevOverrideRuntimeWarnings()) // Build the operation - opReq := c.Operation(ctx, be, viewType, enc) - opReq.AutoApprove = autoApprove + opReq := c.Operation(ctx, be, applyArgs.ViewType, enc) + opReq.AutoApprove = applyArgs.AutoApprove + opReq.SuppressForgetErrorsDuringDestroy = applyArgs.SuppressForgetErrorsDuringDestroy opReq.ConfigDir = "." - opReq.PlanMode = args.PlanMode + opReq.PlanMode = applyArgs.Operation.PlanMode opReq.Hooks = view.Hooks() opReq.PlanFile = planFile - opReq.PlanRefresh = args.Refresh - opReq.Targets = args.Targets - opReq.Excludes = args.Excludes - opReq.ForceReplace = args.ForceReplace + opReq.PlanRefresh = applyArgs.Operation.Refresh + opReq.Targets = applyArgs.Operation.Targets + opReq.Excludes = applyArgs.Operation.Excludes + opReq.ForceReplace = applyArgs.Operation.ForceReplace opReq.Type = backend.OperationTypeApply opReq.View = view.Operation() @@ -385,6 +384,10 @@ Options: -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. This flag can be set multiple times. @@ -424,6 +427,12 @@ Usage: tofu [global options] destroy [options] This command is a convenience alias for: 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 the tofu plan command. For more information on those options, run: tofu plan -help diff --git a/internal/command/apply_destroy_test.go b/internal/command/apply_destroy_test.go index 6060d0bc58..305f58d537 100644 --- a/internal/command/apply_destroy_test.go +++ b/internal/command/apply_destroy_test.go @@ -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. // 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) { diff --git a/internal/command/arguments/apply.go b/internal/command/arguments/apply.go index 3baa1498cf..35668ede94 100644 --- a/internal/command/arguments/apply.go +++ b/internal/command/arguments/apply.go @@ -34,6 +34,10 @@ type Apply struct { // ShowSensitive is used to display the value of variables marked as sensitive. 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. @@ -51,6 +55,7 @@ func ParseApply(args []string) (*Apply, tfdiags.Diagnostics) { cmdFlags.BoolVar(&apply.AutoApprove, "auto-approve", false, "auto-approve") cmdFlags.BoolVar(&apply.InputEnabled, "input", true, "input") 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 cmdFlags.BoolVar(&json, "json", false, "json") diff --git a/internal/tofu/context_apply.go b/internal/tofu/context_apply.go index ca07122994..abde542fea 100644 --- a/internal/tofu/context_apply.go +++ b/internal/tofu/context_apply.go @@ -37,6 +37,10 @@ type ApplyOpts struct { // in Context#mergePlanAndApplyVariables, the merging of this with the plan variable values // follows the same logic and rules of the validation mentioned above. 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 @@ -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 // 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.", - )) + suppressError := opts != nil && opts.SuppressForgetErrorsDuringDestroy + if !suppressError { + 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. +To suppress this error for the future 'destroy' runs, you can add the CLI flag "-suppress-forget-errors".`, + )) + } } } diff --git a/internal/tofu/skip_destroy_test.go b/internal/tofu/skip_destroy_test.go index 46b1ef351d..d9f942fd7d 100644 --- a/internal/tofu/skip_destroy_test.go +++ b/internal/tofu/skip_destroy_test.go @@ -39,6 +39,7 @@ type skipDestroyTestCase struct { runApply bool expectApplyError bool expectEmptyState bool + applyOpts *ApplyOpts } 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) 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 !applyDiags.HasErrors() { @@ -367,6 +368,30 @@ func TestSkipDestroy_DestroyMode_ErrorOnForgotten(t *testing.T) { expectApplyError: false, 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 { diff --git a/website/docs/cli/commands/destroy.mdx b/website/docs/cli/commands/destroy.mdx index 8f9efa93fc..dc539817a0 100644 --- a/website/docs/cli/commands/destroy.mdx +++ b/website/docs/cli/commands/destroy.mdx @@ -59,6 +59,8 @@ When resources are forgotten: 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. +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 The `destroy` attribute is persisted in the state file, even when resources are removed from the configuration.