// Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 package graph import ( "fmt" "log" "time" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/moduletest" teststates "github.com/hashicorp/terraform/internal/moduletest/states" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" ) var ( _ GraphNodeExecutable = (*NodeStateCleanup)(nil) ) // NodeStateCleanup is responsible for cleaning up the state of resources // defined in the state file. It uses the provided stateKey to identify the // specific state to clean up and opts for additional configuration options. type NodeStateCleanup struct { stateKey string opts *graphOptions } func (n *NodeStateCleanup) Name() string { return fmt.Sprintf("cleanup.%s", n.stateKey) } // Execute destroys the resources created in the state file. func (n *NodeStateCleanup) Execute(evalCtx *EvalContext) { file := n.opts.File state := evalCtx.GetState(n.stateKey) log.Printf("[TRACE] TestStateManager: cleaning up state for %s", file.Name) if evalCtx.Cancelled() { // Don't try and clean anything up if the execution has been cancelled. log.Printf("[DEBUG] TestStateManager: skipping state cleanup for %s due to cancellation", file.Name) return } if emptyState(state.State) { // The state can be empty for a run block that just executed a plan // command, or a run block that only read data sources. We'll just // skip empty run blocks. We do reset the state reason to None for this // just to make sure we are indicating externally this state file // doesn't need to be saved. evalCtx.SetFileState(n.stateKey, state.Run, state.State, teststates.StateReasonNone) return } if state.Run == nil { log.Printf("[ERROR] TestFileRunner: found inconsistent run block and state file in %s for module %s", file.Name, n.stateKey) // The state can have a nil run block if it only executed a plan // command. In which case, we shouldn't have reached here as the // state should also have been empty and this will have been skipped // above. If we do reach here, then something has gone badly wrong // and we can't really recover from it. diags := tfdiags.Diagnostics{tfdiags.Sourceless(tfdiags.Error, "Inconsistent state", fmt.Sprintf("Found inconsistent state while cleaning up %s. This is a bug in Terraform - please report it", file.Name))} file.UpdateStatus(moduletest.Error) evalCtx.Renderer().DestroySummary(diags, nil, file, state.State) return } updated := state.State startTime := time.Now().UTC() waiter := NewOperationWaiter(nil, evalCtx, file, state.Run, moduletest.Running, startTime.UnixMilli()) var destroyDiags tfdiags.Diagnostics evalCtx.Renderer().Run(state.Run, file, moduletest.TearDown, 0) cancelled := waiter.Run(func() { if state.RestoreState { updated, destroyDiags = n.restore(evalCtx, file.Config, state.Run.Config, state.Run.ModuleConfig, updated, waiter) } else { updated, destroyDiags = n.destroy(evalCtx, file.Config, state.Run.Config, state.Run.ModuleConfig, updated, waiter) updated.RootOutputValues = state.State.RootOutputValues // we're going to preserve the output values in case we need to tidy up } }) if cancelled { destroyDiags = destroyDiags.Append(tfdiags.Sourceless(tfdiags.Error, "Test interrupted", "The test operation could not be completed due to an interrupt signal. Please read the remaining diagnostics carefully for any sign of failed state cleanup or dangling resources.")) } switch { case destroyDiags.HasErrors(): file.UpdateStatus(moduletest.Error) evalCtx.SetFileState(n.stateKey, state.Run, updated, teststates.StateReasonError) case state.Run.Config.Backend != nil: evalCtx.SetFileState(n.stateKey, state.Run, updated, teststates.StateReasonNone) case state.RestoreState: evalCtx.SetFileState(n.stateKey, state.Run, updated, teststates.StateReasonSkip) case !emptyState(updated): file.UpdateStatus(moduletest.Error) evalCtx.SetFileState(n.stateKey, state.Run, updated, teststates.StateReasonError) default: evalCtx.SetFileState(n.stateKey, state.Run, updated, teststates.StateReasonNone) } evalCtx.Renderer().DestroySummary(destroyDiags, state.Run, file, updated) } func (n *NodeStateCleanup) restore(ctx *EvalContext, file *configs.TestFile, run *configs.TestRun, module *configs.Config, state *states.State, waiter *operationWaiter) (*states.State, tfdiags.Diagnostics) { log.Printf("[TRACE] TestFileRunner: called restore for %s", run.Name) variables, diags := GetVariables(ctx, run, module, false) if diags.HasErrors() { return state, diags } // we ignore the diagnostics from here, because we will have reported them // during the initial execution of the run block and we would not have // executed the run block if there were any errors. providers, _, _ := getProviders(ctx, file, run, module) // During the destroy operation, we don't add warnings from this operation. // Anything that would have been reported here was already reported during // the original plan, and a successful destroy operation is the only thing // we care about. setVariables, _, _ := FilterVariablesToModule(module, variables) planOpts := &terraform.PlanOpts{ Mode: plans.NormalMode, SetVariables: setVariables, Overrides: ctx.GetOverrides(run.Name), ExternalProviders: providers, SkipRefresh: true, OverridePreventDestroy: true, DeferralAllowed: ctx.deferralAllowed, AllowRootEphemeralOutputs: true, } tfCtx, _ := terraform.NewContext(n.opts.ContextOpts) waiter.update(tfCtx, moduletest.TearDown, nil) plan, planDiags := tfCtx.Plan(module, state, planOpts) diags = diags.Append(planDiags) if diags.HasErrors() || plan.Errored { return state, diags } if !plan.Complete { diags = diags.Append(tfdiags.Sourceless( tfdiags.Warning, "Incomplete restore plan", fmt.Sprintf("The restore plan for %s was reported as incomplete."+ " This means some of the cleanup operations were deferred due to unknown values, please check the rest of the output to see which resources could not be reverted.", run.Name))) } _, updated, applyDiags := apply(tfCtx, run, module, plan, moduletest.TearDown, variables, providers, waiter) diags = diags.Append(applyDiags) return updated, diags } func (n *NodeStateCleanup) destroy(ctx *EvalContext, file *configs.TestFile, run *configs.TestRun, module *configs.Config, state *states.State, waiter *operationWaiter) (*states.State, tfdiags.Diagnostics) { log.Printf("[TRACE] TestFileRunner: called destroy for %s", run.Name) variables, diags := GetVariables(ctx, run, module, false) if diags.HasErrors() { return state, diags } // we ignore the diagnostics from here, because we will have reported them // during the initial execution of the run block and we would not have // executed the run block if there were any errors. providers, _, _ := getProviders(ctx, file, run, module) // During the destroy operation, we don't add warnings from this operation. // Anything that would have been reported here was already reported during // the original plan, and a successful destroy operation is the only thing // we care about. setVariables, _, _ := FilterVariablesToModule(module, variables) planOpts := &terraform.PlanOpts{ Mode: plans.DestroyMode, SetVariables: setVariables, Overrides: ctx.GetOverrides(run.Name), ExternalProviders: providers, SkipRefresh: true, OverridePreventDestroy: true, DeferralAllowed: ctx.deferralAllowed, AllowRootEphemeralOutputs: true, } tfCtx, _ := terraform.NewContext(n.opts.ContextOpts) waiter.update(tfCtx, moduletest.TearDown, nil) plan, planDiags := tfCtx.Plan(module, state, planOpts) diags = diags.Append(planDiags) if diags.HasErrors() || plan.Errored { return state, diags } if !plan.Complete { diags = diags.Append(tfdiags.Sourceless( tfdiags.Warning, "Incomplete destroy plan", fmt.Sprintf("The destroy plan for %s was reported as incomplete."+ " This means some of the cleanup operations were deferred due to unknown values, please check the rest of the output to see which resources could not be destroyed.", run.Name))) } _, updated, applyDiags := apply(tfCtx, run, module, plan, moduletest.TearDown, variables, providers, waiter) diags = diags.Append(applyDiags) return updated, diags } func emptyState(state *states.State) bool { if state.Empty() { return true } for _, module := range state.Modules { for _, resource := range module.Resources { if resource.Addr.Resource.Mode == addrs.ManagedResourceMode { return false } } } return true }