| // Copyright IBM Corp. 2014, 2026 | |
| // SPDX-License-Identifier: BUSL-1.1 | |
| package local | |
| import ( | |
| "context" | |
| "fmt" | |
| "log" | |
| "maps" | |
| "path/filepath" | |
| "slices" | |
| "github.com/hashicorp/terraform/internal/backend" | |
| "github.com/hashicorp/terraform/internal/command/arguments" | |
| "github.com/hashicorp/terraform/internal/command/junit" | |
| "github.com/hashicorp/terraform/internal/command/views" | |
| "github.com/hashicorp/terraform/internal/configs" | |
| "github.com/hashicorp/terraform/internal/moduletest" | |
| "github.com/hashicorp/terraform/internal/moduletest/graph" | |
| teststates "github.com/hashicorp/terraform/internal/moduletest/states" | |
| "github.com/hashicorp/terraform/internal/terraform" | |
| "github.com/hashicorp/terraform/internal/tfdiags" | |
| ) | |
| type TestSuiteRunner struct { | |
| Config *configs.Config | |
| // BackendFactory is used to enable initializing multiple backend types, | |
| // depending on which backends are used in a test suite. | |
| // | |
| // Note: This is currently necessary because the source of the init functions, | |
| // the backend/init package, experiences import cycles if used in other test-related | |
| // packages. We set this field on a TestSuiteRunner when making runners in the | |
| // command package, which is the main place where backend/init has previously been used. | |
| BackendFactory func(string) backend.InitFn | |
| TestingDirectory string | |
| // Global variables comes from the main configuration directory, | |
| // and the Global Test Variables are loaded from the test directory. | |
| GlobalVariables map[string]arguments.UnparsedVariableValue | |
| GlobalTestVariables map[string]arguments.UnparsedVariableValue | |
| Opts *terraform.ContextOpts | |
| View views.Test | |
| JUnit junit.JUnit | |
| // Stopped and Cancelled track whether the user requested the testing | |
| // process to be interrupted. Stopped is a nice graceful exit, we'll still | |
| // tidy up any state that was created and mark the tests with relevant | |
| // `skipped` status updates. Cancelled is a hard stop right now exit, we | |
| // won't attempt to clean up any state left hanging, and tests will just | |
| // be left showing `pending` as the status. We will still print out the | |
| // destroy summary diagnostics that tell the user what state has been left | |
| // behind and needs manual clean up. | |
| Stopped bool | |
| Cancelled bool | |
| // StoppedCtx and CancelledCtx allow in progress Terraform operations to | |
| // respond to external calls from the test command. | |
| StoppedCtx context.Context | |
| CancelledCtx context.Context | |
| // Filter restricts exactly which test files will be executed. | |
| Filter []string | |
| // Verbose tells the runner to print out plan files during each test run. | |
| Verbose bool | |
| Concurrency int | |
| DeferralAllowed bool | |
| CommandMode moduletest.CommandMode | |
| // Repair is used to indicate whether the test cleanup command should run in | |
| // "repair" mode. In this mode, the cleanup command will only remove state | |
| // files that are a result of failed destroy operations, leaving any | |
| // state due to skip_cleanup in place. | |
| Repair bool | |
| } | |
| func (runner *TestSuiteRunner) Stop() { | |
| runner.Stopped = true | |
| } | |
| func (runner *TestSuiteRunner) IsStopped() bool { | |
| return runner.Stopped | |
| } | |
| func (runner *TestSuiteRunner) Cancel() { | |
| runner.Cancelled = true | |
| } | |
| func (runner *TestSuiteRunner) Test(experimentsAllowed bool) (moduletest.Status, tfdiags.Diagnostics) { | |
| var diags tfdiags.Diagnostics | |
| if runner.Concurrency < 1 { | |
| runner.Concurrency = 10 | |
| } | |
| suite, suiteDiags := runner.collectTests() | |
| diags = diags.Append(suiteDiags) | |
| if suiteDiags.HasErrors() { | |
| return moduletest.Error, diags | |
| } | |
| manifest, err := teststates.LoadManifest(".", experimentsAllowed) | |
| if err != nil { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Failed to open state manifest", | |
| fmt.Sprintf("The test state manifest file could not be opened: %s.", err))) | |
| } | |
| runner.View.Abstract(suite) | |
| // We have two sets of variables that are available to different test files. | |
| // Test files in the root directory have access to the GlobalVariables only, | |
| // while test files in the test directory have access to the union of | |
| // GlobalVariables and GlobalTestVariables. | |
| testDirectoryGlobalVariables := make(map[string]arguments.UnparsedVariableValue) | |
| maps.Copy(testDirectoryGlobalVariables, runner.GlobalVariables) | |
| // We're okay to overwrite the global variables in case of name | |
| // collisions, as the test directory variables should take precedence. | |
| maps.Copy(testDirectoryGlobalVariables, runner.GlobalTestVariables) | |
| suite.Status = moduletest.Pass | |
| for _, name := range slices.Sorted(maps.Keys(suite.Files)) { | |
| if runner.Cancelled { | |
| return moduletest.Error, diags | |
| } | |
| file := suite.Files[name] | |
| fileRunner := &TestFileRunner{ | |
| Suite: runner, | |
| TestDirectoryGlobalVariables: testDirectoryGlobalVariables, | |
| Manifest: manifest, | |
| } | |
| runner.View.File(file, moduletest.Starting) | |
| fileRunner.Test(file) | |
| runner.View.File(file, moduletest.Complete) | |
| suite.Status = suite.Status.Merge(file.Status) | |
| } | |
| if err := manifest.Save(experimentsAllowed); err != nil { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Failed to save state manifest", | |
| fmt.Sprintf("The test state manifest file could not be saved: %s.", err))) | |
| } | |
| runner.View.Conclusion(suite) | |
| if runner.JUnit != nil { | |
| artifactDiags := runner.JUnit.Save(suite) | |
| diags = diags.Append(artifactDiags) | |
| if artifactDiags.HasErrors() { | |
| return moduletest.Error, diags | |
| } | |
| } | |
| return suite.Status, diags | |
| } | |
| func (runner *TestSuiteRunner) collectTests() (*moduletest.Suite, tfdiags.Diagnostics) { | |
| runCount := 0 | |
| fileCount := 0 | |
| var diags tfdiags.Diagnostics | |
| suite := &moduletest.Suite{ | |
| Status: moduletest.Pending, | |
| CommandMode: runner.CommandMode, | |
| Files: func() map[string]*moduletest.File { | |
| files := make(map[string]*moduletest.File) | |
| if len(runner.Filter) > 0 { | |
| for _, name := range runner.Filter { | |
| file, ok := runner.Config.Module.Tests[name] | |
| if !ok { | |
| // If the filter is invalid, we'll simply skip this | |
| // entry and print a warning. But we could still execute | |
| // any other tests within the filter. | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Warning, | |
| "Unknown test file", | |
| fmt.Sprintf("The specified test file, %s, could not be found.", name))) | |
| continue | |
| } | |
| fileCount++ | |
| var runs []*moduletest.Run | |
| for ix, run := range file.Runs { | |
| config := runner.Config | |
| if run.ConfigUnderTest != nil { | |
| config = run.ConfigUnderTest | |
| } | |
| runs = append(runs, moduletest.NewRun(run, config, ix)) | |
| } | |
| runCount += len(runs) | |
| files[name] = moduletest.NewFile(name, file, runs) | |
| } | |
| return files | |
| } | |
| // Otherwise, we'll just do all the tests in the directory! | |
| for name, file := range runner.Config.Module.Tests { | |
| fileCount++ | |
| var runs []*moduletest.Run | |
| for ix, run := range file.Runs { | |
| config := runner.Config | |
| if run.ConfigUnderTest != nil { | |
| config = run.ConfigUnderTest | |
| } | |
| runs = append(runs, moduletest.NewRun(run, config, ix)) | |
| } | |
| runCount += len(runs) | |
| files[name] = moduletest.NewFile(name, file, runs) | |
| } | |
| return files | |
| }(), | |
| } | |
| log.Printf("[DEBUG] TestSuiteRunner: found %d files with %d run blocks", fileCount, runCount) | |
| return suite, diags | |
| } | |
| type TestFileRunner struct { | |
| // Suite contains all the helpful metadata about the test that we need | |
| // during the execution of a file. | |
| Suite *TestSuiteRunner | |
| TestDirectoryGlobalVariables map[string]arguments.UnparsedVariableValue | |
| Manifest *teststates.TestManifest | |
| } | |
| func (runner *TestFileRunner) Test(file *moduletest.File) { | |
| log.Printf("[TRACE] TestFileRunner: executing test file %s", file.Name) | |
| // The file validation only returns warnings so we'll just add them without | |
| // checking anything about them. | |
| file.Diagnostics = file.Diagnostics.Append(file.Config.Validate(runner.Suite.Config)) | |
| states, stateDiags := runner.Manifest.LoadStates(file, runner.Suite.BackendFactory) | |
| file.Diagnostics = file.Diagnostics.Append(stateDiags) | |
| if stateDiags.HasErrors() { | |
| file.Status = moduletest.Error | |
| } | |
| if runner.Suite.CommandMode != moduletest.CleanupMode { | |
| // then we can't have any state files pending cleanup | |
| for _, state := range states { | |
| if state.Manifest.Reason != teststates.StateReasonNone { | |
| file.Diagnostics = file.Diagnostics.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "State manifest not empty", | |
| fmt.Sprintf("The state manifest for %s should be empty before running tests. This could be due to a previous test run not cleaning up after itself. Please ensure that all state files are cleaned up before running tests.", file.Name))) | |
| file.Status = moduletest.Error | |
| } | |
| } | |
| } | |
| // We'll execute the tests in the file. First, mark the overall status as | |
| // being skipped. This will ensure that if we've cancelled and the files not | |
| // going to do anything it'll be marked as skipped. | |
| file.Status = file.Status.Merge(moduletest.Skip) | |
| if len(file.Runs) == 0 { | |
| // If we have zero run blocks then we'll just mark the file as passed. | |
| file.Status = file.Status.Merge(moduletest.Pass) | |
| return | |
| } else if runner.Suite.CommandMode == moduletest.CleanupMode { | |
| // In cleanup mode, we don't actually execute the run blocks so we'll | |
| // start with the assumption they have all passed. | |
| file.Status = file.Status.Merge(moduletest.Pass) | |
| } | |
| currentGlobalVariables := runner.Suite.GlobalVariables | |
| if filepath.Dir(file.Name) == runner.Suite.TestingDirectory { | |
| // If the file is in the test directory, we'll use the union of the | |
| // global variables and the global test variables. | |
| currentGlobalVariables = runner.TestDirectoryGlobalVariables | |
| } | |
| evalCtx := graph.NewEvalContext(graph.EvalContextOpts{ | |
| Config: runner.Suite.Config, | |
| CancelCtx: runner.Suite.CancelledCtx, | |
| StopCtx: runner.Suite.StoppedCtx, | |
| Verbose: runner.Suite.Verbose, | |
| Render: runner.Suite.View, | |
| UnparsedVariables: currentGlobalVariables, | |
| FileStates: states, | |
| Concurrency: runner.Suite.Concurrency, | |
| DeferralAllowed: runner.Suite.DeferralAllowed, | |
| Mode: runner.Suite.CommandMode, | |
| Repair: runner.Suite.Repair, | |
| }) | |
| // Build the graph for the file. | |
| b := graph.TestGraphBuilder{ | |
| Config: runner.Suite.Config, | |
| File: file, | |
| ContextOpts: runner.Suite.Opts, | |
| CommandMode: runner.Suite.CommandMode, | |
| } | |
| g, diags := b.Build() | |
| file.Diagnostics = file.Diagnostics.Append(diags) | |
| if walkCancelled := runner.renderPreWalkDiags(file); walkCancelled { | |
| return | |
| } | |
| // walk and execute the graph | |
| diags = diags.Append(graph.Walk(g, evalCtx)) | |
| // save any dangling state files. we'll check all the states we have in | |
| // memory, and if any are skipped or errored it means we might want to do | |
| // a cleanup command in the future. this means we need to save the other | |
| // state files as dependencies in case they are needed during the cleanup. | |
| saveDependencies := false | |
| for _, state := range states { | |
| if state.Manifest.Reason == teststates.StateReasonSkip || state.Manifest.Reason == teststates.StateReasonError { | |
| saveDependencies = true // at least one state file does have resources left over | |
| break | |
| } | |
| } | |
| if saveDependencies { | |
| for _, state := range states { | |
| if state.Manifest.Reason == teststates.StateReasonNone { | |
| // any states that have no reason to be saved, will be updated | |
| // to the dependency reason and this will tell the manifest to | |
| // save those state files as well. | |
| state.Manifest.Reason = teststates.StateReasonDep | |
| } | |
| } | |
| } | |
| diags = diags.Append(runner.Manifest.SaveStates(file, states)) | |
| // If the graph walk was terminated, we don't want to add the diagnostics. | |
| // The error the user receives will just be: | |
| // Failure! 0 passed, 1 failed. | |
| // exit status 1 | |
| if evalCtx.Cancelled() { | |
| file.UpdateStatus(moduletest.Error) | |
| log.Printf("[TRACE] TestFileRunner: graph walk terminated for %s", file.Name) | |
| return | |
| } | |
| file.Diagnostics = file.Diagnostics.Append(diags) | |
| } | |
| func (runner *TestFileRunner) renderPreWalkDiags(file *moduletest.File) (walkCancelled bool) { | |
| errored := file.Diagnostics.HasErrors() | |
| // Some runs may have errored during the graph build, but we didn't fail immediately | |
| // as we still wanted to gather all the diagnostics. | |
| // Now we go through the runs and if there are any errors, we'll update the | |
| // file status to be errored. | |
| for _, run := range file.Runs { | |
| if run.Status == moduletest.Error { | |
| errored = true | |
| runner.Suite.View.Run(run, file, moduletest.Complete, 0) | |
| } | |
| } | |
| if errored { | |
| // print a teardown message even though there was no teardown to run | |
| runner.Suite.View.File(file, moduletest.TearDown) | |
| file.Status = file.Status.Merge(moduletest.Error) | |
| return true | |
| } | |
| return false | |
| } |