terraform/internal/backend/local/test.go
Daniel Schmidt 026c935961 move UnparsedVariableValue from backendrun to arguments
This prevents a cyclic dependency and also makes sense semantically.
The arguments package will collect the unparsed variable values and
the backendrun helpers will work to collect the values and transform
them into terraform.InputValue.
2026-02-18 12:47:12 +01:00

376 lines
12 KiB
Go

// 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.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
}