mirror of https://github.com/hashicorp/terraform
Terraform test: Consolidate test execution procedure (#36459)
parent
166434811e
commit
2e91113224
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,11 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
test = {
|
||||
source = "hashicorp/test"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "test_resource" "foo" {
|
||||
nein = "foo"
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
|
||||
run "test" {}
|
||||
@ -0,0 +1,17 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
test = {
|
||||
source = "hashicorp/test"
|
||||
configuration_aliases = [ test.secondary, test ]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "test_resource" "primary" {
|
||||
value = "foo"
|
||||
}
|
||||
|
||||
resource "test_resource" "secondary" {
|
||||
provider = test.secondary
|
||||
value = "bar"
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
|
||||
# provider "test" {}
|
||||
|
||||
# provider "test" {
|
||||
# alias = "secondary"
|
||||
# }
|
||||
|
||||
run "passes_validation" {
|
||||
|
||||
// references a provider that is not defined in the test file
|
||||
providers = {
|
||||
test = test
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = test_resource.primary.value == "foo"
|
||||
error_message = "primary contains invalid value"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = test_resource.secondary.value == "bar"
|
||||
error_message = "secondary contains invalid value"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,196 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package graph
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/lang"
|
||||
"github.com/hashicorp/terraform/internal/moduletest"
|
||||
"github.com/hashicorp/terraform/internal/plans"
|
||||
"github.com/hashicorp/terraform/internal/states"
|
||||
"github.com/hashicorp/terraform/internal/terraform"
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
)
|
||||
|
||||
func (n *NodeTestRun) testApply(ctx *EvalContext, variables terraform.InputValues, waiter *operationWaiter) {
|
||||
file, run := n.File(), n.run
|
||||
config := run.ModuleConfig
|
||||
key := n.run.GetStateKey()
|
||||
|
||||
// FilterVariablesToModule only returns warnings, so we don't check the
|
||||
// returned diags for errors.
|
||||
setVariables, testOnlyVariables, setVariableDiags := n.FilterVariablesToModule(variables)
|
||||
run.Diagnostics = run.Diagnostics.Append(setVariableDiags)
|
||||
|
||||
// ignore diags because validate has covered it
|
||||
tfCtx, _ := terraform.NewContext(n.opts.ContextOpts)
|
||||
|
||||
// execute the terraform plan operation
|
||||
_, plan, planDiags := n.plan(ctx, tfCtx, setVariables, waiter)
|
||||
|
||||
// Any error during the planning prevents our apply from
|
||||
// continuing which is an error.
|
||||
planDiags = run.ExplainExpectedFailures(planDiags)
|
||||
run.Diagnostics = run.Diagnostics.Append(planDiags)
|
||||
if planDiags.HasErrors() {
|
||||
run.Status = moduletest.Error
|
||||
return
|
||||
}
|
||||
|
||||
// Since we're carrying on an executing the apply operation as well, we're
|
||||
// just going to do some post processing of the diagnostics. We remove the
|
||||
// warnings generated from check blocks, as the apply operation will either
|
||||
// reproduce them or fix them and we don't want fixed diagnostics to be
|
||||
// reported and we don't want duplicates either.
|
||||
var filteredDiags tfdiags.Diagnostics
|
||||
for _, diag := range run.Diagnostics {
|
||||
if rule, ok := addrs.DiagnosticOriginatesFromCheckRule(diag); ok && rule.Container.CheckableKind() == addrs.CheckableCheck {
|
||||
continue
|
||||
}
|
||||
filteredDiags = filteredDiags.Append(diag)
|
||||
}
|
||||
run.Diagnostics = filteredDiags
|
||||
|
||||
// execute the apply operation
|
||||
applyScope, updated, applyDiags := n.apply(tfCtx, plan, moduletest.Running, variables, waiter)
|
||||
|
||||
// Remove expected diagnostics, and add diagnostics in case anything that should have failed didn't.
|
||||
// We'll also update the run status based on the presence of errors or missing expected failures.
|
||||
failOrErr := n.checkForMissingExpectedFailures(run, applyDiags)
|
||||
if failOrErr {
|
||||
// Even though the apply operation failed, the graph may have done
|
||||
// partial updates and the returned state should reflect this.
|
||||
ctx.SetFileState(key, &TestFileState{
|
||||
Run: run,
|
||||
State: updated,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
n.AddVariablesToConfig(variables)
|
||||
|
||||
if ctx.Verbose() {
|
||||
schemas, diags := tfCtx.Schemas(config, updated)
|
||||
|
||||
// If we're going to fail to render the plan, let's not fail the overall
|
||||
// test. It can still have succeeded. So we'll add the diagnostics, but
|
||||
// still report the test status as a success.
|
||||
if diags.HasErrors() {
|
||||
// This is very unlikely.
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Warning,
|
||||
"Failed to print verbose output",
|
||||
fmt.Sprintf("Terraform failed to print the verbose output for %s, other diagnostics will contain more details as to why.", filepath.Join(file.Name, run.Name))))
|
||||
} else {
|
||||
run.Verbose = &moduletest.Verbose{
|
||||
Plan: nil, // We don't have a plan to show in apply mode.
|
||||
State: updated,
|
||||
Config: config,
|
||||
Providers: schemas.Providers,
|
||||
Provisioners: schemas.Provisioners,
|
||||
}
|
||||
}
|
||||
|
||||
run.Diagnostics = run.Diagnostics.Append(diags)
|
||||
}
|
||||
|
||||
// Evaluate the run block directly in the graph context to validate the assertions
|
||||
// of the run. We also pass in all the
|
||||
// previous contexts so this run block can refer to outputs from
|
||||
// previous run blocks.
|
||||
newStatus, outputVals, moreDiags := ctx.EvaluateRun(run, applyScope, testOnlyVariables)
|
||||
run.Status = newStatus
|
||||
run.Diagnostics = run.Diagnostics.Append(moreDiags)
|
||||
|
||||
// Now we've successfully validated this run block, lets add it into
|
||||
// our prior run outputs so future run blocks can access it.
|
||||
ctx.SetOutput(run, outputVals)
|
||||
|
||||
// Only update the most recent run and state if the state was
|
||||
// actually updated by this change. We want to use the run that
|
||||
// most recently updated the tracked state as the cleanup
|
||||
// configuration.
|
||||
ctx.SetFileState(key, &TestFileState{
|
||||
Run: run,
|
||||
State: updated,
|
||||
})
|
||||
}
|
||||
|
||||
func (n *NodeTestRun) apply(tfCtx *terraform.Context, plan *plans.Plan, progress moduletest.Progress, variables terraform.InputValues, waiter *operationWaiter) (*lang.Scope, *states.State, tfdiags.Diagnostics) {
|
||||
run := n.run
|
||||
file := n.File()
|
||||
log.Printf("[TRACE] TestFileRunner: called apply for %s/%s", file.Name, run.Name)
|
||||
|
||||
var diags tfdiags.Diagnostics
|
||||
config := run.ModuleConfig
|
||||
|
||||
// If things get cancelled while we are executing the apply operation below
|
||||
// we want to print out all the objects that we were creating so the user
|
||||
// can verify we managed to tidy everything up possibly.
|
||||
//
|
||||
// Unfortunately, this creates a race condition as the apply operation can
|
||||
// edit the plan (by removing changes once they are applied) while at the
|
||||
// same time our cancellation process will try to read the plan.
|
||||
//
|
||||
// We take a quick copy of the changes we care about here, which will then
|
||||
// be used in place of the plan when we print out the objects to be created
|
||||
// as part of the cancellation process.
|
||||
var created []*plans.ResourceInstanceChangeSrc
|
||||
for _, change := range plan.Changes.Resources {
|
||||
if change.Action != plans.Create {
|
||||
continue
|
||||
}
|
||||
created = append(created, change)
|
||||
}
|
||||
|
||||
// We only need to pass ephemeral variables to the apply operation, as the
|
||||
// plan has already been evaluated with the full set of variables.
|
||||
ephemeralVariables := make(terraform.InputValues)
|
||||
for k, v := range config.Root.Module.Variables {
|
||||
if v.EphemeralSet {
|
||||
if value, ok := variables[k]; ok {
|
||||
ephemeralVariables[k] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyOpts := &terraform.ApplyOpts{
|
||||
SetVariables: ephemeralVariables,
|
||||
}
|
||||
|
||||
waiter.update(tfCtx, progress, created)
|
||||
log.Printf("[DEBUG] TestFileRunner: starting apply for %s/%s", file.Name, run.Name)
|
||||
updated, newScope, applyDiags := tfCtx.ApplyAndEval(plan, config, applyOpts)
|
||||
log.Printf("[DEBUG] TestFileRunner: completed apply for %s/%s", file.Name, run.Name)
|
||||
diags = diags.Append(applyDiags)
|
||||
|
||||
return newScope, updated, diags
|
||||
}
|
||||
|
||||
// checkForMissingExpectedFailures checks for missing expected failures in the diagnostics.
|
||||
// It updates the run status based on the presence of errors or missing expected failures.
|
||||
func (n *NodeTestRun) checkForMissingExpectedFailures(run *moduletest.Run, diags tfdiags.Diagnostics) (failOrErr bool) {
|
||||
// Retrieve and append diagnostics that are either unrelated to expected failures
|
||||
// or report missing expected failures.
|
||||
unexpectedDiags := run.ValidateExpectedFailures(diags)
|
||||
run.Diagnostics = run.Diagnostics.Append(unexpectedDiags)
|
||||
for _, diag := range unexpectedDiags {
|
||||
// // If any diagnostic indicates a missing expected failure, set the run status to fail.
|
||||
if ok := moduletest.DiagnosticFromMissingExpectedFailure(diag); ok {
|
||||
run.Status = run.Status.Merge(moduletest.Fail)
|
||||
continue
|
||||
}
|
||||
|
||||
// upgrade the run status to error if there still are other errors in the diagnostics
|
||||
if diag.Severity() == tfdiags.Error {
|
||||
run.Status = run.Status.Merge(moduletest.Error)
|
||||
break
|
||||
}
|
||||
}
|
||||
return run.Status > moduletest.Pass
|
||||
}
|
||||
@ -0,0 +1,150 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package graph
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/moduletest"
|
||||
"github.com/hashicorp/terraform/internal/moduletest/mocking"
|
||||
"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)
|
||||
)
|
||||
|
||||
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) tfdiags.Diagnostics {
|
||||
var diags tfdiags.Diagnostics
|
||||
file := n.opts.File
|
||||
state := evalCtx.GetFileState(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 diags
|
||||
}
|
||||
|
||||
empty := true
|
||||
for _, module := range state.State.Modules {
|
||||
for _, resource := range module.Resources {
|
||||
if resource.Addr.Resource.Mode == addrs.ManagedResourceMode {
|
||||
empty = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if empty {
|
||||
// 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.
|
||||
return diags
|
||||
}
|
||||
|
||||
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 = diags.Append(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 diags
|
||||
}
|
||||
TransformConfigForRun(evalCtx, state.Run, file)
|
||||
|
||||
runNode := &NodeTestRun{run: state.Run, opts: n.opts}
|
||||
updated := state.State
|
||||
startTime := time.Now().UTC()
|
||||
waiter := NewOperationWaiter(nil, evalCtx, runNode, moduletest.Running, startTime.UnixMilli())
|
||||
var destroyDiags tfdiags.Diagnostics
|
||||
cancelled := waiter.Run(func() {
|
||||
updated, destroyDiags = n.destroy(evalCtx, runNode, waiter)
|
||||
diags = diags.Append(destroyDiags)
|
||||
})
|
||||
if cancelled {
|
||||
diags = diags.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."))
|
||||
}
|
||||
|
||||
if !updated.Empty() {
|
||||
// Then we failed to adequately clean up the state, so mark success
|
||||
// as false.
|
||||
file.UpdateStatus(moduletest.Error)
|
||||
}
|
||||
evalCtx.Renderer().DestroySummary(diags, state.Run, file, updated)
|
||||
return diags
|
||||
}
|
||||
|
||||
func (n *NodeStateCleanup) destroy(ctx *EvalContext, runNode *NodeTestRun, waiter *operationWaiter) (*states.State, tfdiags.Diagnostics) {
|
||||
file := n.opts.File
|
||||
fileState := ctx.GetFileState(n.stateKey)
|
||||
state := fileState.State
|
||||
run := runNode.run
|
||||
log.Printf("[TRACE] TestFileRunner: called destroy for %s/%s", file.Name, run.Name)
|
||||
|
||||
if state.Empty() {
|
||||
// Nothing to do!
|
||||
return state, nil
|
||||
}
|
||||
|
||||
var diags tfdiags.Diagnostics
|
||||
variables, variableDiags := runNode.GetVariables(ctx, false)
|
||||
diags = diags.Append(variableDiags)
|
||||
|
||||
if diags.HasErrors() {
|
||||
return state, diags
|
||||
}
|
||||
|
||||
// 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, _, _ := runNode.FilterVariablesToModule(variables)
|
||||
|
||||
planOpts := &terraform.PlanOpts{
|
||||
Mode: plans.DestroyMode,
|
||||
SetVariables: setVariables,
|
||||
Overrides: mocking.PackageOverrides(run.Config, file.Config, run.ModuleConfig),
|
||||
}
|
||||
|
||||
tfCtx, ctxDiags := terraform.NewContext(n.opts.ContextOpts)
|
||||
diags = diags.Append(ctxDiags)
|
||||
if ctxDiags.HasErrors() {
|
||||
return state, diags
|
||||
}
|
||||
ctx.Renderer().Run(run, file, moduletest.TearDown, 0)
|
||||
|
||||
waiter.update(tfCtx, moduletest.TearDown, nil)
|
||||
plan, planDiags := tfCtx.Plan(run.ModuleConfig, state, planOpts)
|
||||
diags = diags.Append(planDiags)
|
||||
if diags.HasErrors() {
|
||||
return state, diags
|
||||
}
|
||||
|
||||
_, updated, applyDiags := runNode.apply(tfCtx, plan, moduletest.TearDown, variables, waiter)
|
||||
diags = diags.Append(applyDiags)
|
||||
return updated, diags
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package graph
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/configs"
|
||||
"github.com/hashicorp/terraform/internal/lang"
|
||||
"github.com/hashicorp/terraform/internal/moduletest"
|
||||
"github.com/hashicorp/terraform/internal/moduletest/mocking"
|
||||
"github.com/hashicorp/terraform/internal/plans"
|
||||
"github.com/hashicorp/terraform/internal/terraform"
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
)
|
||||
|
||||
func (n *NodeTestRun) testPlan(ctx *EvalContext, variables terraform.InputValues, waiter *operationWaiter) {
|
||||
file, run := n.File(), n.run
|
||||
config := run.ModuleConfig
|
||||
|
||||
// FilterVariablesToModule only returns warnings, so we don't check the
|
||||
// returned diags for errors.
|
||||
setVariables, testOnlyVariables, setVariableDiags := n.FilterVariablesToModule(variables)
|
||||
run.Diagnostics = run.Diagnostics.Append(setVariableDiags)
|
||||
|
||||
// ignore diags because validate has covered it
|
||||
tfCtx, _ := terraform.NewContext(n.opts.ContextOpts)
|
||||
|
||||
// execute the terraform plan operation
|
||||
planScope, plan, planDiags := n.plan(ctx, tfCtx, setVariables, waiter)
|
||||
// We exclude the diagnostics that are expected to fail from the plan
|
||||
// diagnostics, and if an expected failure is not found, we add a new error diagnostic.
|
||||
planDiags = run.ValidateExpectedFailures(planDiags)
|
||||
run.Diagnostics = run.Diagnostics.Append(planDiags)
|
||||
if planDiags.HasErrors() {
|
||||
run.Status = moduletest.Error
|
||||
return
|
||||
}
|
||||
|
||||
n.AddVariablesToConfig(variables)
|
||||
|
||||
if ctx.Verbose() {
|
||||
schemas, diags := tfCtx.Schemas(config, plan.PriorState)
|
||||
|
||||
// If we're going to fail to render the plan, let's not fail the overall
|
||||
// test. It can still have succeeded. So we'll add the diagnostics, but
|
||||
// still report the test status as a success.
|
||||
if diags.HasErrors() {
|
||||
// This is very unlikely.
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Warning,
|
||||
"Failed to print verbose output",
|
||||
fmt.Sprintf("Terraform failed to print the verbose output for %s, other diagnostics will contain more details as to why.", filepath.Join(file.Name, run.Name))))
|
||||
} else {
|
||||
run.Verbose = &moduletest.Verbose{
|
||||
Plan: plan,
|
||||
State: nil, // We don't have a state to show in plan mode.
|
||||
Config: config,
|
||||
Providers: schemas.Providers,
|
||||
Provisioners: schemas.Provisioners,
|
||||
}
|
||||
}
|
||||
|
||||
run.Diagnostics = run.Diagnostics.Append(diags)
|
||||
}
|
||||
|
||||
// Evaluate the run block directly in the graph context to validate the assertions
|
||||
// of the run. We also pass in all the
|
||||
// previous contexts so this run block can refer to outputs from
|
||||
// previous run blocks.
|
||||
newStatus, outputVals, moreDiags := ctx.EvaluateRun(run, planScope, testOnlyVariables)
|
||||
run.Status = newStatus
|
||||
run.Diagnostics = run.Diagnostics.Append(moreDiags)
|
||||
|
||||
// Now we've successfully validated this run block, lets add it into
|
||||
// our prior run outputs so future run blocks can access it.
|
||||
ctx.SetOutput(run, outputVals)
|
||||
}
|
||||
|
||||
func (n *NodeTestRun) plan(ctx *EvalContext, tfCtx *terraform.Context, variables terraform.InputValues, waiter *operationWaiter) (*lang.Scope, *plans.Plan, tfdiags.Diagnostics) {
|
||||
file, run := n.File(), n.run
|
||||
config := run.ModuleConfig
|
||||
log.Printf("[TRACE] TestFileRunner: called plan for %s/%s", file.Name, run.Name)
|
||||
|
||||
var diags tfdiags.Diagnostics
|
||||
|
||||
targets, targetDiags := run.GetTargets()
|
||||
diags = diags.Append(targetDiags)
|
||||
|
||||
replaces, replaceDiags := run.GetReplaces()
|
||||
diags = diags.Append(replaceDiags)
|
||||
|
||||
if diags.HasErrors() {
|
||||
return nil, nil, diags
|
||||
}
|
||||
|
||||
planOpts := &terraform.PlanOpts{
|
||||
Mode: func() plans.Mode {
|
||||
switch run.Config.Options.Mode {
|
||||
case configs.RefreshOnlyTestMode:
|
||||
return plans.RefreshOnlyMode
|
||||
default:
|
||||
return plans.NormalMode
|
||||
}
|
||||
}(),
|
||||
Targets: targets,
|
||||
ForceReplace: replaces,
|
||||
SkipRefresh: !run.Config.Options.Refresh,
|
||||
SetVariables: variables,
|
||||
ExternalReferences: n.References(),
|
||||
Overrides: mocking.PackageOverrides(run.Config, file.Config, config),
|
||||
}
|
||||
|
||||
waiter.update(tfCtx, moduletest.Running, nil)
|
||||
log.Printf("[DEBUG] TestFileRunner: starting plan for %s/%s", file.Name, run.Name)
|
||||
state := ctx.GetFileState(run.GetStateKey()).State
|
||||
plan, planScope, planDiags := tfCtx.PlanAndEval(config, state, planOpts)
|
||||
log.Printf("[DEBUG] TestFileRunner: completed plan for %s/%s", file.Name, run.Name)
|
||||
diags = diags.Append(planDiags)
|
||||
|
||||
return planScope, plan, diags
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package graph
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/dag"
|
||||
"github.com/hashicorp/terraform/internal/moduletest"
|
||||
"github.com/hashicorp/terraform/internal/terraform"
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
)
|
||||
|
||||
// TestStateCleanupTransformer is a GraphTransformer that adds a cleanup node
|
||||
// for each state that is created by the test runs.
|
||||
type TestStateCleanupTransformer struct {
|
||||
opts *graphOptions
|
||||
}
|
||||
|
||||
func (t *TestStateCleanupTransformer) Transform(g *terraform.Graph) error {
|
||||
cleanupMap := make(map[string]*NodeStateCleanup)
|
||||
|
||||
for _, v := range g.Vertices() {
|
||||
node, ok := v.(*NodeTestRun)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key := node.run.GetStateKey()
|
||||
if _, exists := cleanupMap[key]; !exists {
|
||||
cleanupMap[key] = &NodeStateCleanup{stateKey: key, opts: t.opts}
|
||||
g.Add(cleanupMap[key])
|
||||
}
|
||||
|
||||
// Connect the cleanup node to the test run node.
|
||||
g.Connect(dag.BasicEdge(cleanupMap[key], node))
|
||||
}
|
||||
|
||||
// Add a root cleanup node that runs before cleanup nodes for each state.
|
||||
// Right now it just simply renders a teardown summary, so as to maintain
|
||||
// existing CLI output.
|
||||
rootCleanupNode := t.addRootCleanupNode(g)
|
||||
|
||||
for _, v := range g.Vertices() {
|
||||
switch node := v.(type) {
|
||||
case *NodeTestRun:
|
||||
// All the runs that share the same state, must share the same cleanup node,
|
||||
// which only executes once after all the dependent runs have completed.
|
||||
g.Connect(dag.BasicEdge(rootCleanupNode, node))
|
||||
}
|
||||
}
|
||||
|
||||
// connect all cleanup nodes in reverse-sequential order of run index to
|
||||
// preserve existing behavior, starting from the root cleanup node.
|
||||
// TODO: Parallelize cleanup nodes execution instead of sequential.
|
||||
added := make(map[string]bool)
|
||||
var prev dag.Vertex
|
||||
for _, v := range slices.Backward(t.opts.File.Runs) {
|
||||
key := v.GetStateKey()
|
||||
if _, exists := added[key]; !exists {
|
||||
node := cleanupMap[key]
|
||||
if prev != nil {
|
||||
g.Connect(dag.BasicEdge(node, prev))
|
||||
}
|
||||
prev = node
|
||||
added[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TestStateCleanupTransformer) addRootCleanupNode(g *terraform.Graph) *dynamicNode {
|
||||
rootCleanupNode := &dynamicNode{
|
||||
eval: func(ctx *EvalContext) tfdiags.Diagnostics {
|
||||
var diags tfdiags.Diagnostics
|
||||
ctx.Renderer().File(t.opts.File, moduletest.TearDown)
|
||||
return diags
|
||||
},
|
||||
}
|
||||
g.Add(rootCleanupNode)
|
||||
return rootCleanupNode
|
||||
}
|
||||
@ -0,0 +1,239 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package graph
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/configs"
|
||||
"github.com/hashicorp/terraform/internal/lang/langrefs"
|
||||
hcltest "github.com/hashicorp/terraform/internal/moduletest/hcl"
|
||||
"github.com/hashicorp/terraform/internal/terraform"
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
)
|
||||
|
||||
// GetVariables builds the terraform.InputValues required for the provided run
|
||||
// block. It pulls the relevant variables (ie. the variables needed for the
|
||||
// run block) from the total pool of all available variables, and converts them
|
||||
// into input values.
|
||||
//
|
||||
// As a run block can reference variables defined within the file and are not
|
||||
// actually defined within the configuration, this function actually returns
|
||||
// more variables than are required by the config. FilterVariablesToConfig
|
||||
// should be called before trying to use these variables within a Terraform
|
||||
// plan, apply, or destroy operation.
|
||||
func (n *NodeTestRun) GetVariables(ctx *EvalContext, includeWarnings bool) (terraform.InputValues, tfdiags.Diagnostics) {
|
||||
var diags tfdiags.Diagnostics
|
||||
run := n.run
|
||||
// relevantVariables contains the variables that are of interest to this
|
||||
// run block. This is a combination of the variables declared within the
|
||||
// configuration for this run block, and the variables referenced by the
|
||||
// run block assertions.
|
||||
relevantVariables := make(map[string]bool)
|
||||
|
||||
// First, we'll check to see which variables the run block assertions
|
||||
// reference.
|
||||
for _, reference := range n.References() {
|
||||
if addr, ok := reference.Subject.(addrs.InputVariable); ok {
|
||||
relevantVariables[addr.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// And check to see which variables the run block configuration references.
|
||||
for name := range run.ModuleConfig.Module.Variables {
|
||||
relevantVariables[name] = true
|
||||
}
|
||||
|
||||
// We'll put the parsed values into this map.
|
||||
values := make(terraform.InputValues)
|
||||
|
||||
// First, let's step through the expressions within the run block and work
|
||||
// them out.
|
||||
for name, expr := range run.Config.Variables {
|
||||
requiredValues := make(map[string]cty.Value)
|
||||
|
||||
refs, refDiags := langrefs.ReferencesInExpr(addrs.ParseRefFromTestingScope, expr)
|
||||
for _, ref := range refs {
|
||||
if addr, ok := ref.Subject.(addrs.InputVariable); ok {
|
||||
cache := ctx.GetCache(run)
|
||||
|
||||
value, valueDiags := cache.GetFileVariable(addr.Name)
|
||||
diags = diags.Append(valueDiags)
|
||||
if value != nil {
|
||||
requiredValues[addr.Name] = value.Value
|
||||
continue
|
||||
}
|
||||
|
||||
// Otherwise, it might be a global variable.
|
||||
value, valueDiags = cache.GetGlobalVariable(addr.Name)
|
||||
diags = diags.Append(valueDiags)
|
||||
if value != nil {
|
||||
requiredValues[addr.Name] = value.Value
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
diags = diags.Append(refDiags)
|
||||
|
||||
ctx, ctxDiags := hcltest.EvalContext(hcltest.TargetRunBlock, map[string]hcl.Expression{name: expr}, requiredValues, ctx.GetOutputs())
|
||||
diags = diags.Append(ctxDiags)
|
||||
|
||||
value := cty.DynamicVal
|
||||
if !ctxDiags.HasErrors() {
|
||||
var valueDiags hcl.Diagnostics
|
||||
value, valueDiags = expr.Value(ctx)
|
||||
diags = diags.Append(valueDiags)
|
||||
}
|
||||
|
||||
// We do this late on so we still validate whatever it was that the user
|
||||
// wrote in the variable expression. But, we don't want to actually use
|
||||
// it if it's not actually relevant.
|
||||
if _, exists := relevantVariables[name]; !exists {
|
||||
// Do not display warnings during cleanup phase
|
||||
if includeWarnings {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagWarning,
|
||||
Summary: "Value for undeclared variable",
|
||||
Detail: fmt.Sprintf("The module under test does not declare a variable named %q, but it is declared in run block %q.", name, run.Name),
|
||||
Subject: expr.Range().Ptr(),
|
||||
})
|
||||
}
|
||||
continue // Don't add it to our final set of variables.
|
||||
}
|
||||
|
||||
values[name] = &terraform.InputValue{
|
||||
Value: value,
|
||||
SourceType: terraform.ValueFromConfig,
|
||||
SourceRange: tfdiags.SourceRangeFromHCL(expr.Range()),
|
||||
}
|
||||
}
|
||||
|
||||
for variable := range relevantVariables {
|
||||
if _, exists := values[variable]; exists {
|
||||
// Then we've already got a value for this variable.
|
||||
continue
|
||||
}
|
||||
|
||||
// Otherwise, we'll get it from the cache as a file-level or global
|
||||
// variable.
|
||||
cache := ctx.GetCache(run)
|
||||
|
||||
value, valueDiags := cache.GetFileVariable(variable)
|
||||
diags = diags.Append(valueDiags)
|
||||
if value != nil {
|
||||
values[variable] = value
|
||||
continue
|
||||
}
|
||||
|
||||
value, valueDiags = cache.GetGlobalVariable(variable)
|
||||
diags = diags.Append(valueDiags)
|
||||
if value != nil {
|
||||
values[variable] = value
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, we check the configuration again. This is where we'll discover
|
||||
// if there's any missing variables and fill in any optional variables that
|
||||
// don't have a value already.
|
||||
|
||||
for name, variable := range run.ModuleConfig.Module.Variables {
|
||||
if _, exists := values[name]; exists {
|
||||
// Then we've provided a variable for this. It's all good.
|
||||
continue
|
||||
}
|
||||
|
||||
// Otherwise, we're going to give these variables a value. They'll be
|
||||
// processed by the Terraform graph and provided a default value later
|
||||
// if they have one.
|
||||
|
||||
if variable.Required() {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "No value for required variable",
|
||||
Detail: fmt.Sprintf("The module under test for run block %q has a required variable %q with no set value. Use a -var or -var-file command line argument or add this variable into a \"variables\" block within the test file or run block.",
|
||||
run.Name, variable.Name),
|
||||
Subject: variable.DeclRange.Ptr(),
|
||||
})
|
||||
|
||||
values[name] = &terraform.InputValue{
|
||||
Value: cty.DynamicVal,
|
||||
SourceType: terraform.ValueFromConfig,
|
||||
SourceRange: tfdiags.SourceRangeFromHCL(variable.DeclRange),
|
||||
}
|
||||
} else {
|
||||
values[name] = &terraform.InputValue{
|
||||
Value: cty.NilVal,
|
||||
SourceType: terraform.ValueFromConfig,
|
||||
SourceRange: tfdiags.SourceRangeFromHCL(variable.DeclRange),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values, diags
|
||||
}
|
||||
|
||||
// FilterVariablesToModule splits the provided values into two disjoint maps:
|
||||
// moduleVars contains the ones that correspond with declarations in the root
|
||||
// module of the given configuration, while testOnlyVars contains any others
|
||||
// that are presumably intended only for use in the test configuration file.
|
||||
//
|
||||
// This function is essentially the opposite of AddVariablesToConfig which
|
||||
// makes the config match the variables rather than the variables match the
|
||||
// config.
|
||||
//
|
||||
// This function can only return warnings, and the callers can rely on this so
|
||||
// please check the callers of this function if you add any error diagnostics.
|
||||
func (n *NodeTestRun) FilterVariablesToModule(values terraform.InputValues) (moduleVars, testOnlyVars terraform.InputValues, diags tfdiags.Diagnostics) {
|
||||
moduleVars = make(terraform.InputValues)
|
||||
testOnlyVars = make(terraform.InputValues)
|
||||
for name, value := range values {
|
||||
_, exists := n.run.ModuleConfig.Module.Variables[name]
|
||||
if !exists {
|
||||
// If it's not in the configuration then it's a test-only variable.
|
||||
testOnlyVars[name] = value
|
||||
continue
|
||||
}
|
||||
|
||||
moduleVars[name] = value
|
||||
}
|
||||
return moduleVars, testOnlyVars, diags
|
||||
}
|
||||
|
||||
// AddVariablesToConfig extends the provided config to ensure it has definitions
|
||||
// for all specified variables.
|
||||
//
|
||||
// This function is essentially the opposite of FilterVariablesToConfig which
|
||||
// makes the variables match the config rather than the config match the
|
||||
// variables.
|
||||
func (n *NodeTestRun) AddVariablesToConfig(variables terraform.InputValues) {
|
||||
run := n.run
|
||||
// If we have got variable values from the test file we need to make sure
|
||||
// they have an equivalent entry in the configuration. We're going to do
|
||||
// that dynamically here.
|
||||
|
||||
// First, take a backup of the existing configuration so we can easily
|
||||
// restore it later.
|
||||
currentVars := make(map[string]*configs.Variable)
|
||||
for name, variable := range run.ModuleConfig.Module.Variables {
|
||||
currentVars[name] = variable
|
||||
}
|
||||
|
||||
for name, value := range variables {
|
||||
if _, exists := run.ModuleConfig.Module.Variables[name]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
run.ModuleConfig.Module.Variables[name] = &configs.Variable{
|
||||
Name: name,
|
||||
Type: value.Value.Type(),
|
||||
ConstraintType: value.Value.Type(),
|
||||
DeclRange: value.SourceRange.ToHCL(),
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,163 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/command/views"
|
||||
"github.com/hashicorp/terraform/internal/moduletest"
|
||||
"github.com/hashicorp/terraform/internal/plans"
|
||||
"github.com/hashicorp/terraform/internal/states"
|
||||
"github.com/hashicorp/terraform/internal/terraform"
|
||||
)
|
||||
|
||||
// operationWaiter waits for an operation within
|
||||
// a test run execution to complete.
|
||||
type operationWaiter struct {
|
||||
ctx *terraform.Context
|
||||
runningCtx context.Context
|
||||
run *moduletest.Run
|
||||
file *moduletest.File
|
||||
created []*plans.ResourceInstanceChangeSrc
|
||||
progress atomicProgress[moduletest.Progress]
|
||||
start int64
|
||||
identifier string
|
||||
finished bool
|
||||
evalCtx *EvalContext
|
||||
renderer views.Test
|
||||
}
|
||||
|
||||
type atomicProgress[T moduletest.Progress] struct {
|
||||
internal atomic.Value
|
||||
}
|
||||
|
||||
func (a *atomicProgress[T]) Load() T {
|
||||
return a.internal.Load().(T)
|
||||
}
|
||||
|
||||
func (a *atomicProgress[T]) Store(progress T) {
|
||||
a.internal.Store(progress)
|
||||
}
|
||||
|
||||
// NewOperationWaiter creates a new operation waiter.
|
||||
func NewOperationWaiter(ctx *terraform.Context, evalCtx *EvalContext, n *NodeTestRun,
|
||||
progress moduletest.Progress, start int64) *operationWaiter {
|
||||
identifier := "validate"
|
||||
if n.File() != nil {
|
||||
identifier = n.File().Name
|
||||
if n.run != nil {
|
||||
identifier = fmt.Sprintf("%s/%s", identifier, n.run.Name)
|
||||
}
|
||||
}
|
||||
|
||||
p := atomicProgress[moduletest.Progress]{}
|
||||
p.Store(progress)
|
||||
|
||||
return &operationWaiter{
|
||||
ctx: ctx,
|
||||
run: n.run,
|
||||
file: n.File(),
|
||||
progress: p,
|
||||
start: start,
|
||||
identifier: identifier,
|
||||
evalCtx: evalCtx,
|
||||
renderer: evalCtx.Renderer(),
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes the given function in a goroutine and waits for it to finish.
|
||||
// If the function finishes, it returns false. If the function is cancelled or
|
||||
// interrupted, it returns true.
|
||||
func (w *operationWaiter) Run(fn func()) bool {
|
||||
runningCtx, doneRunning := context.WithCancel(context.Background())
|
||||
w.runningCtx = runningCtx
|
||||
|
||||
go func() {
|
||||
fn()
|
||||
doneRunning()
|
||||
}()
|
||||
|
||||
// either the function finishes or a cancel/stop signal is received
|
||||
return w.wait()
|
||||
}
|
||||
|
||||
func (w *operationWaiter) wait() bool {
|
||||
log.Printf("[TRACE] TestFileRunner: waiting for execution during %s", w.identifier)
|
||||
|
||||
for !w.finished {
|
||||
select {
|
||||
case <-time.After(2 * time.Second):
|
||||
w.updateProgress()
|
||||
case <-w.evalCtx.stopContext.Done():
|
||||
// Soft cancel - wait for completion or hard cancel
|
||||
for !w.finished {
|
||||
select {
|
||||
case <-time.After(2 * time.Second):
|
||||
w.updateProgress()
|
||||
case <-w.evalCtx.cancelContext.Done():
|
||||
return w.handleCancelled()
|
||||
case <-w.runningCtx.Done():
|
||||
w.finished = true
|
||||
}
|
||||
}
|
||||
case <-w.evalCtx.cancelContext.Done():
|
||||
return w.handleCancelled()
|
||||
case <-w.runningCtx.Done():
|
||||
w.finished = true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// update refreshes the operationWaiter with the latest terraform context, progress, and any newly created resources.
|
||||
// This should be called before starting a new Terraform operation.
|
||||
func (w *operationWaiter) update(ctx *terraform.Context, progress moduletest.Progress, created []*plans.ResourceInstanceChangeSrc) {
|
||||
w.ctx = ctx
|
||||
w.progress.Store(progress)
|
||||
w.created = created
|
||||
}
|
||||
|
||||
func (w *operationWaiter) updateProgress() {
|
||||
now := time.Now().UTC().UnixMilli()
|
||||
progress := w.progress.Load()
|
||||
w.renderer.Run(w.run, w.file, progress, now-w.start)
|
||||
}
|
||||
|
||||
// handleCancelled is called when the test execution is hard cancelled.
|
||||
func (w *operationWaiter) handleCancelled() bool {
|
||||
log.Printf("[DEBUG] TestFileRunner: test execution cancelled during %s", w.identifier)
|
||||
states := make(map[*moduletest.Run]*states.State)
|
||||
mainKey := moduletest.MainStateIdentifier
|
||||
states[nil] = w.evalCtx.GetFileState(mainKey).State
|
||||
for key, module := range w.evalCtx.FileStates {
|
||||
if key == mainKey {
|
||||
continue
|
||||
}
|
||||
states[module.Run] = module.State
|
||||
}
|
||||
w.renderer.FatalInterruptSummary(w.run, w.file, states, w.created)
|
||||
|
||||
go func() {
|
||||
if w.ctx != nil {
|
||||
w.ctx.Stop()
|
||||
}
|
||||
}()
|
||||
|
||||
for !w.finished {
|
||||
select {
|
||||
case <-time.After(2 * time.Second):
|
||||
w.updateProgress()
|
||||
case <-w.runningCtx.Done():
|
||||
w.finished = true
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
Loading…
Reference in new issue