mirror of
https://github.com/hashicorp/terraform.git
synced 2026-03-21 18:10:30 -04:00
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.
376 lines
12 KiB
Go
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
|
|
}
|