mirror of
https://github.com/opentofu/opentofu.git
synced 2025-02-25 18:45:20 -06:00
[Testing Framework] Adds TestContext for evaluating test assertions (#33326)
* Add test structure to views package for rendering test output * Add test file HCL configuration and parser functionality * Adds a TestContext structure for evaluating assertions against the state and plan
This commit is contained in:
parent
d49e991c3c
commit
3732bffe13
@ -7,11 +7,12 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/configs/configschema"
|
||||
"github.com/hashicorp/terraform/internal/lang/globalref"
|
||||
"github.com/hashicorp/terraform/internal/states"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
)
|
||||
|
||||
// Plan is the top-level type representing a planned set of changes.
|
||||
@ -88,6 +89,29 @@ type Plan struct {
|
||||
PrevRunState *states.State
|
||||
PriorState *states.State
|
||||
|
||||
// PlannedState is the temporary planned state that was created during the
|
||||
// graph walk that generated this plan.
|
||||
//
|
||||
// This is required by the testing framework when evaluating run blocks
|
||||
// executing in plan mode. The graph updates the state with certain values
|
||||
// that are difficult to retrieve later, such as local values that reference
|
||||
// updated resources. It is easier to build the testing scope with access
|
||||
// to same temporary state the plan used/built.
|
||||
//
|
||||
// This is never recorded outside of Terraform. It is not written into the
|
||||
// binary plan file, and it is not written into the JSON structured outputs.
|
||||
// The testing framework never writes the plans out but holds everything in
|
||||
// memory as it executes, so there is no need to add any kind of
|
||||
// serialization for this field. This does mean that you shouldn't rely on
|
||||
// this field existing unless you have just generated the plan.
|
||||
PlannedState *states.State
|
||||
|
||||
// ExternalReferences are references that are being made to resources within
|
||||
// the plan from external sources. As with PlannedState this is used by the
|
||||
// terraform testing framework, and so isn't written into any external
|
||||
// representation of the plan.
|
||||
ExternalReferences []*addrs.Reference
|
||||
|
||||
// Timestamp is the record of truth for when the plan happened.
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
@ -7,12 +7,13 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/configs"
|
||||
"github.com/hashicorp/terraform/internal/plans"
|
||||
"github.com/hashicorp/terraform/internal/states"
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
)
|
||||
|
||||
// Apply performs the actions described by the given Plan object and returns
|
||||
@ -176,6 +177,7 @@ func (c *Context) applyGraph(plan *plans.Plan, config *configs.Config, validate
|
||||
Targets: plan.TargetAddrs,
|
||||
ForceReplace: plan.ForceReplaceAddrs,
|
||||
Operation: operation,
|
||||
ExternalReferences: plan.ExternalReferences,
|
||||
}).Build(addrs.RootModuleInstance)
|
||||
diags = diags.Append(moreDiags)
|
||||
if moreDiags.HasErrors() {
|
||||
|
@ -2162,3 +2162,85 @@ import {
|
||||
t.Errorf("expected addr to be %s, but was %s", wantAddr, addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext2Apply_noExternalReferences(t *testing.T) {
|
||||
m := testModuleInline(t, map[string]string{
|
||||
"main.tf": `
|
||||
resource "test_object" "a" {
|
||||
test_string = "foo"
|
||||
}
|
||||
|
||||
locals {
|
||||
local_value = test_object.a.test_string
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
p := simpleMockProvider()
|
||||
ctx := testContext2(t, &ContextOpts{
|
||||
Providers: map[addrs.Provider]providers.Factory{
|
||||
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
|
||||
},
|
||||
})
|
||||
|
||||
plan, diags := ctx.Plan(m, states.NewState(), nil)
|
||||
if diags.HasErrors() {
|
||||
t.Errorf("expected no errors, but got %s", diags)
|
||||
}
|
||||
|
||||
state, diags := ctx.Apply(plan, m)
|
||||
if diags.HasErrors() {
|
||||
t.Errorf("expected no errors, but got %s", diags)
|
||||
}
|
||||
|
||||
// We didn't specify any external references, so the unreferenced local
|
||||
// value should have been tidied up and never made it into the state.
|
||||
module := state.RootModule()
|
||||
if len(module.LocalValues) > 0 {
|
||||
t.Errorf("expected no local values in the state but found %d", len(module.LocalValues))
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext2Apply_withExternalReferences(t *testing.T) {
|
||||
m := testModuleInline(t, map[string]string{
|
||||
"main.tf": `
|
||||
resource "test_object" "a" {
|
||||
test_string = "foo"
|
||||
}
|
||||
|
||||
locals {
|
||||
local_value = test_object.a.test_string
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
p := simpleMockProvider()
|
||||
ctx := testContext2(t, &ContextOpts{
|
||||
Providers: map[addrs.Provider]providers.Factory{
|
||||
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
|
||||
},
|
||||
})
|
||||
|
||||
plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{
|
||||
Mode: plans.NormalMode,
|
||||
ExternalReferences: []*addrs.Reference{
|
||||
mustReference("local.local_value"),
|
||||
},
|
||||
})
|
||||
if diags.HasErrors() {
|
||||
t.Errorf("expected no errors, but got %s", diags)
|
||||
}
|
||||
|
||||
state, diags := ctx.Apply(plan, m)
|
||||
if diags.HasErrors() {
|
||||
t.Errorf("expected no errors, but got %s", diags)
|
||||
}
|
||||
|
||||
// We did specify the local value in the external references, so it should
|
||||
// have been preserved even though it is not referenced by anything directly
|
||||
// in the config.
|
||||
module := state.RootModule()
|
||||
if module.LocalValues["local_value"].AsString() != "foo" {
|
||||
t.Errorf("expected local value to be \"foo\" but was \"%s\"", module.LocalValues["local_value"].AsString())
|
||||
}
|
||||
}
|
||||
|
@ -74,6 +74,11 @@ type PlanOpts struct {
|
||||
// fully-functional new object.
|
||||
ForceReplace []addrs.AbsResourceInstance
|
||||
|
||||
// ExternalReferences allows the external caller to pass in references to
|
||||
// nodes that should not be pruned even if they are not referenced within
|
||||
// the actual graph.
|
||||
ExternalReferences []*addrs.Reference
|
||||
|
||||
// ImportTargets is a list of target resources to import. These resources
|
||||
// will be added to the plan graph.
|
||||
ImportTargets []*ImportTarget
|
||||
@ -644,13 +649,15 @@ func (c *Context) planWalk(config *configs.Config, prevRunState *states.State, o
|
||||
diags = diags.Append(driftDiags)
|
||||
|
||||
plan := &plans.Plan{
|
||||
UIMode: opts.Mode,
|
||||
Changes: changes,
|
||||
DriftedResources: driftedResources,
|
||||
PrevRunState: prevRunState,
|
||||
PriorState: priorState,
|
||||
Checks: states.NewCheckResults(walker.Checks),
|
||||
Timestamp: timestamp,
|
||||
UIMode: opts.Mode,
|
||||
Changes: changes,
|
||||
DriftedResources: driftedResources,
|
||||
PrevRunState: prevRunState,
|
||||
PriorState: priorState,
|
||||
PlannedState: walker.State.Close(),
|
||||
ExternalReferences: opts.ExternalReferences,
|
||||
Checks: states.NewCheckResults(walker.Checks),
|
||||
Timestamp: timestamp,
|
||||
|
||||
// Other fields get populated by Context.Plan after we return
|
||||
}
|
||||
@ -670,6 +677,7 @@ func (c *Context) planGraph(config *configs.Config, prevRunState *states.State,
|
||||
skipRefresh: opts.SkipRefresh,
|
||||
preDestroyRefresh: opts.PreDestroyRefresh,
|
||||
Operation: walkPlan,
|
||||
ExternalReferences: opts.ExternalReferences,
|
||||
ImportTargets: opts.ImportTargets,
|
||||
GenerateConfigPath: opts.GenerateConfigPath,
|
||||
}).Build(addrs.RootModuleInstance)
|
||||
@ -684,6 +692,7 @@ func (c *Context) planGraph(config *configs.Config, prevRunState *states.State,
|
||||
skipRefresh: opts.SkipRefresh,
|
||||
skipPlanChanges: true, // this activates "refresh only" mode.
|
||||
Operation: walkPlan,
|
||||
ExternalReferences: opts.ExternalReferences,
|
||||
}).Build(addrs.RootModuleInstance)
|
||||
return graph, walkPlan, diags
|
||||
case plans.DestroyMode:
|
||||
|
@ -13,6 +13,8 @@ import (
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/checks"
|
||||
"github.com/hashicorp/terraform/internal/configs/configschema"
|
||||
@ -21,7 +23,6 @@ import (
|
||||
"github.com/hashicorp/terraform/internal/providers"
|
||||
"github.com/hashicorp/terraform/internal/states"
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
)
|
||||
|
||||
func TestContext2Plan_removedDuringRefresh(t *testing.T) {
|
||||
@ -4888,3 +4889,54 @@ import {
|
||||
t.Errorf("got:\n%s\nwant:\n%s\ndiff:\n%s", got, want, diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext2Plan_plannedState(t *testing.T) {
|
||||
addr := mustResourceInstanceAddr("test_object.a")
|
||||
m := testModuleInline(t, map[string]string{
|
||||
"main.tf": `
|
||||
resource "test_object" "a" {
|
||||
test_string = "foo"
|
||||
}
|
||||
|
||||
locals {
|
||||
local_value = test_object.a.test_string
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
p := simpleMockProvider()
|
||||
ctx := testContext2(t, &ContextOpts{
|
||||
Providers: map[addrs.Provider]providers.Factory{
|
||||
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
|
||||
},
|
||||
})
|
||||
|
||||
state := states.NewState()
|
||||
plan, diags := ctx.Plan(m, state, nil)
|
||||
if diags.HasErrors() {
|
||||
t.Errorf("expected no errors, but got %s", diags)
|
||||
}
|
||||
|
||||
module := state.RootModule()
|
||||
|
||||
// So, the original state shouldn't have been updated at all.
|
||||
if len(module.LocalValues) > 0 {
|
||||
t.Errorf("expected no local values in the state but found %d", len(module.LocalValues))
|
||||
}
|
||||
|
||||
if len(module.Resources) > 0 {
|
||||
t.Errorf("expected no resources in the state but found %d", len(module.LocalValues))
|
||||
}
|
||||
|
||||
// But, this makes it hard for the testing framework to valid things about
|
||||
// the returned plan. So, the plan contains the planned state:
|
||||
module = plan.PlannedState.RootModule()
|
||||
|
||||
if module.LocalValues["local_value"].AsString() != "foo" {
|
||||
t.Errorf("expected local value to be \"foo\" but was \"%s\"", module.LocalValues["local_value"].AsString())
|
||||
}
|
||||
|
||||
if module.ResourceInstance(addr.Resource).Current.Status != states.ObjectPlanned {
|
||||
t.Errorf("expected resource to be in planned state")
|
||||
}
|
||||
}
|
||||
|
@ -11,13 +11,13 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/agext/levenshtein"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/configs"
|
||||
"github.com/hashicorp/terraform/internal/configs/configschema"
|
||||
"github.com/hashicorp/terraform/internal/didyoumean"
|
||||
"github.com/hashicorp/terraform/internal/instances"
|
||||
"github.com/hashicorp/terraform/internal/lang"
|
||||
"github.com/hashicorp/terraform/internal/lang/marks"
|
||||
@ -229,7 +229,7 @@ func (d *evaluationStateData) GetInputVariable(addr addrs.InputVariable, rng tfd
|
||||
for k := range moduleConfig.Module.Variables {
|
||||
suggestions = append(suggestions, k)
|
||||
}
|
||||
suggestion := nameSuggestion(addr.Name, suggestions)
|
||||
suggestion := didyoumean.NameSuggestion(addr.Name, suggestions)
|
||||
if suggestion != "" {
|
||||
suggestion = fmt.Sprintf(" Did you mean %q?", suggestion)
|
||||
} else {
|
||||
@ -325,7 +325,7 @@ func (d *evaluationStateData) GetLocalValue(addr addrs.LocalValue, rng tfdiags.S
|
||||
for k := range moduleConfig.Module.Locals {
|
||||
suggestions = append(suggestions, k)
|
||||
}
|
||||
suggestion := nameSuggestion(addr.Name, suggestions)
|
||||
suggestion := didyoumean.NameSuggestion(addr.Name, suggestions)
|
||||
if suggestion != "" {
|
||||
suggestion = fmt.Sprintf(" Did you mean %q?", suggestion)
|
||||
}
|
||||
@ -624,7 +624,7 @@ func (d *evaluationStateData) GetPathAttr(addr addrs.PathAttr, rng tfdiags.Sourc
|
||||
return cty.StringVal(filepath.ToSlash(sourceDir)), diags
|
||||
|
||||
default:
|
||||
suggestion := nameSuggestion(addr.Name, []string{"cwd", "module", "root"})
|
||||
suggestion := didyoumean.NameSuggestion(addr.Name, []string{"cwd", "module", "root"})
|
||||
if suggestion != "" {
|
||||
suggestion = fmt.Sprintf(" Did you mean %q?", suggestion)
|
||||
}
|
||||
@ -940,25 +940,6 @@ func (d *evaluationStateData) GetTerraformAttr(addr addrs.TerraformAttr, rng tfd
|
||||
}
|
||||
}
|
||||
|
||||
// nameSuggestion tries to find a name from the given slice of suggested names
|
||||
// that is close to the given name and returns it if found. If no suggestion
|
||||
// is close enough, returns the empty string.
|
||||
//
|
||||
// The suggestions are tried in order, so earlier suggestions take precedence
|
||||
// if the given string is similar to two or more suggestions.
|
||||
//
|
||||
// This function is intended to be used with a relatively-small number of
|
||||
// suggestions. It's not optimized for hundreds or thousands of them.
|
||||
func nameSuggestion(given string, suggestions []string) string {
|
||||
for _, suggestion := range suggestions {
|
||||
dist := levenshtein.Distance(given, suggestion, nil)
|
||||
if dist < 3 { // threshold determined experimentally
|
||||
return suggestion
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// moduleDisplayAddr returns a string describing the given module instance
|
||||
// address that is appropriate for returning to users in situations where the
|
||||
// root module is possible. Specifically, it returns "the root module" if the
|
||||
|
@ -52,6 +52,11 @@ type ApplyGraphBuilder struct {
|
||||
|
||||
// Plan Operation this graph will be used for.
|
||||
Operation walkOperation
|
||||
|
||||
// ExternalReferences allows the external caller to pass in references to
|
||||
// nodes that should not be pruned even if they are not referenced within
|
||||
// the actual graph.
|
||||
ExternalReferences []*addrs.Reference
|
||||
}
|
||||
|
||||
// See GraphBuilder
|
||||
@ -144,6 +149,11 @@ func (b *ApplyGraphBuilder) Steps() []GraphTransformer {
|
||||
// objects that can belong to modules.
|
||||
&ModuleExpansionTransformer{Config: b.Config},
|
||||
|
||||
// Plug in any external references.
|
||||
&ExternalReferenceTransformer{
|
||||
ExternalReferences: b.ExternalReferences,
|
||||
},
|
||||
|
||||
// Connect references so ordering is correct
|
||||
&ReferenceTransformer{},
|
||||
&AttachDependenciesTransformer{},
|
||||
|
@ -73,6 +73,11 @@ type PlanGraphBuilder struct {
|
||||
// Plan Operation this graph will be used for.
|
||||
Operation walkOperation
|
||||
|
||||
// ExternalReferences allows the external caller to pass in references to
|
||||
// nodes that should not be pruned even if they are not referenced within
|
||||
// the actual graph.
|
||||
ExternalReferences []*addrs.Reference
|
||||
|
||||
// ImportTargets are the list of resources to import.
|
||||
ImportTargets []*ImportTarget
|
||||
|
||||
@ -193,6 +198,11 @@ func (b *PlanGraphBuilder) Steps() []GraphTransformer {
|
||||
// objects that can belong to modules.
|
||||
&ModuleExpansionTransformer{Concrete: b.ConcreteModule, Config: b.Config},
|
||||
|
||||
// Plug in any external references.
|
||||
&ExternalReferenceTransformer{
|
||||
ExternalReferences: b.ExternalReferences,
|
||||
},
|
||||
|
||||
&ReferenceTransformer{},
|
||||
|
||||
&AttachDependenciesTransformer{},
|
||||
|
31
internal/terraform/node_external_reference.go
Normal file
31
internal/terraform/node_external_reference.go
Normal file
@ -0,0 +1,31 @@
|
||||
package terraform
|
||||
|
||||
import "github.com/hashicorp/terraform/internal/addrs"
|
||||
|
||||
// nodeExternalReference allows external callers (such as the testing framework)
|
||||
// to provide the list of references they are making into the graph. This
|
||||
// ensures that Terraform will not remove any nodes from the graph that might
|
||||
// not be referenced from within a module but are referenced by the currently
|
||||
// executing test file.
|
||||
//
|
||||
// This should only be added to the graph if we are executing the
|
||||
// `terraform test` command.
|
||||
type nodeExternalReference struct {
|
||||
ExternalReferences []*addrs.Reference
|
||||
}
|
||||
|
||||
var (
|
||||
_ GraphNodeReferencer = (*nodeExternalReference)(nil)
|
||||
)
|
||||
|
||||
// GraphNodeModulePath
|
||||
func (n *nodeExternalReference) ModulePath() addrs.Module {
|
||||
// The external references are always made from test files, which currently
|
||||
// execute as if they are in the root module.
|
||||
return addrs.RootModule
|
||||
}
|
||||
|
||||
// GraphNodeReferencer
|
||||
func (n *nodeExternalReference) References() []*addrs.Reference {
|
||||
return n.ExternalReferences
|
||||
}
|
@ -135,7 +135,7 @@ func testModuleInline(t *testing.T, sources map[string]string) *configs.Config {
|
||||
t.Fatalf("failed to refresh modules after installation: %s", err)
|
||||
}
|
||||
|
||||
config, diags := loader.LoadConfig(cfgPath)
|
||||
config, diags := loader.LoadConfigWithTests(cfgPath, "tests")
|
||||
if diags.HasErrors() {
|
||||
t.Fatal(diags.Error())
|
||||
}
|
||||
@ -232,6 +232,14 @@ func mustProviderConfig(s string) addrs.AbsProviderConfig {
|
||||
return p
|
||||
}
|
||||
|
||||
func mustReference(s string) *addrs.Reference {
|
||||
p, diags := addrs.ParseRefStr(s)
|
||||
if diags.HasErrors() {
|
||||
panic(diags.Err())
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// HookRecordApplyOrder is a test hook that records the order of applies
|
||||
// by recording the PreApply event.
|
||||
type HookRecordApplyOrder struct {
|
||||
|
176
internal/terraform/test_context.go
Normal file
176
internal/terraform/test_context.go
Normal file
@ -0,0 +1,176 @@
|
||||
package terraform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
"github.com/zclconf/go-cty/cty/convert"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/configs"
|
||||
"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/tfdiags"
|
||||
)
|
||||
|
||||
// TestContext wraps a Context, and adds in direct values for the current state,
|
||||
// most recent plan, and configuration.
|
||||
//
|
||||
// This combination allows functions called on the TestContext to create a
|
||||
// complete scope to evaluate test assertions.
|
||||
type TestContext struct {
|
||||
*Context
|
||||
|
||||
Config *configs.Config
|
||||
State *states.State
|
||||
Plan *plans.Plan
|
||||
Variables InputValues
|
||||
}
|
||||
|
||||
// TestContext creates a TestContext structure that can evaluate test assertions
|
||||
// against the provided state and plan.
|
||||
func (c *Context) TestContext(config *configs.Config, state *states.State, plan *plans.Plan, variables InputValues) *TestContext {
|
||||
return &TestContext{
|
||||
Context: c,
|
||||
Config: config,
|
||||
State: state,
|
||||
Plan: plan,
|
||||
Variables: variables,
|
||||
}
|
||||
}
|
||||
|
||||
// EvaluateAgainstState processes the assertions inside the provided
|
||||
// configs.TestRun against the embedded state.
|
||||
//
|
||||
// The provided plan is import as it is needed to evaluate the `plantimestamp`
|
||||
// function, but no data or changes from the embedded plan is referenced in
|
||||
// this function.
|
||||
func (ctx *TestContext) EvaluateAgainstState(run *moduletest.Run) {
|
||||
defer ctx.acquireRun("evaluate")()
|
||||
ctx.evaluate(ctx.State.SyncWrapper(), plans.NewChanges().SyncWrapper(), run, walkApply)
|
||||
}
|
||||
|
||||
// EvaluateAgainstPlan processes the assertions inside the provided
|
||||
// configs.TestRun against the embedded plan and state.
|
||||
func (ctx *TestContext) EvaluateAgainstPlan(run *moduletest.Run) {
|
||||
defer ctx.acquireRun("evaluate")()
|
||||
ctx.evaluate(ctx.State.SyncWrapper(), ctx.Plan.Changes.SyncWrapper(), run, walkPlan)
|
||||
}
|
||||
|
||||
func (ctx *TestContext) evaluate(state *states.SyncState, changes *plans.ChangesSync, run *moduletest.Run, operation walkOperation) {
|
||||
data := &evaluationStateData{
|
||||
Evaluator: &Evaluator{
|
||||
Operation: operation,
|
||||
Meta: ctx.meta,
|
||||
Config: ctx.Config,
|
||||
Plugins: ctx.plugins,
|
||||
State: state,
|
||||
Changes: changes,
|
||||
VariableValues: func() map[string]map[string]cty.Value {
|
||||
variables := map[string]map[string]cty.Value{
|
||||
addrs.RootModule.String(): make(map[string]cty.Value),
|
||||
}
|
||||
for name, variable := range ctx.Variables {
|
||||
variables[addrs.RootModule.String()][name] = variable.Value
|
||||
}
|
||||
return variables
|
||||
}(),
|
||||
VariableValuesLock: new(sync.Mutex),
|
||||
PlanTimestamp: ctx.Plan.Timestamp,
|
||||
},
|
||||
ModulePath: nil, // nil for the root module
|
||||
InstanceKeyData: EvalDataForNoInstanceKey,
|
||||
Operation: operation,
|
||||
}
|
||||
|
||||
scope := &lang.Scope{
|
||||
Data: data,
|
||||
BaseDir: ".",
|
||||
PureOnly: operation != walkApply,
|
||||
PlanTimestamp: ctx.Plan.Timestamp,
|
||||
}
|
||||
|
||||
// We're going to assume the run has passed, and then if anything fails this
|
||||
// value will be updated.
|
||||
run.Status = run.Status.Merge(moduletest.Pass)
|
||||
|
||||
// Now validate all the assertions within this run block.
|
||||
for _, rule := range run.Config.CheckRules {
|
||||
refs, moreDiags := lang.ReferencesInExpr(rule.Condition)
|
||||
run.Diagnostics = run.Diagnostics.Append(moreDiags)
|
||||
moreRefs, moreDiags := lang.ReferencesInExpr(rule.ErrorMessage)
|
||||
run.Diagnostics = run.Diagnostics.Append(moreDiags)
|
||||
refs = append(refs, moreRefs...)
|
||||
|
||||
hclCtx, moreDiags := scope.EvalContext(refs)
|
||||
run.Diagnostics = run.Diagnostics.Append(moreDiags)
|
||||
|
||||
errorMessage, moreDiags := evalCheckErrorMessage(rule.ErrorMessage, hclCtx)
|
||||
run.Diagnostics = run.Diagnostics.Append(moreDiags)
|
||||
|
||||
runVal, hclDiags := rule.Condition.Value(hclCtx)
|
||||
run.Diagnostics = run.Diagnostics.Append(hclDiags)
|
||||
|
||||
if run.Diagnostics.HasErrors() {
|
||||
run.Status = run.Status.Merge(moduletest.Error)
|
||||
continue
|
||||
}
|
||||
|
||||
if runVal.IsNull() {
|
||||
run.Status = run.Status.Merge(moduletest.Error)
|
||||
run.Diagnostics = run.Diagnostics.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid condition run",
|
||||
Detail: "Condition expression must return either true or false, not null.",
|
||||
Subject: rule.Condition.Range().Ptr(),
|
||||
Expression: rule.Condition,
|
||||
EvalContext: hclCtx,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if !runVal.IsKnown() {
|
||||
run.Status = run.Status.Merge(moduletest.Error)
|
||||
run.Diagnostics = run.Diagnostics.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Unknown condition run",
|
||||
Detail: "Condition expression could not be evaluated at this time.",
|
||||
Subject: rule.Condition.Range().Ptr(),
|
||||
Expression: rule.Condition,
|
||||
EvalContext: hclCtx,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
var err error
|
||||
if runVal, err = convert.Convert(runVal, cty.Bool); err != nil {
|
||||
run.Status = run.Status.Merge(moduletest.Error)
|
||||
run.Diagnostics = run.Diagnostics.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid condition run",
|
||||
Detail: fmt.Sprintf("Invalid condition run value: %s.", tfdiags.FormatError(err)),
|
||||
Subject: rule.Condition.Range().Ptr(),
|
||||
Expression: rule.Condition,
|
||||
EvalContext: hclCtx,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if runVal.False() {
|
||||
run.Status = run.Status.Merge(moduletest.Fail)
|
||||
run.Diagnostics = run.Diagnostics.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Test assertion failed",
|
||||
Detail: errorMessage,
|
||||
Subject: rule.Condition.Range().Ptr(),
|
||||
Expression: rule.Condition,
|
||||
EvalContext: hclCtx,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
467
internal/terraform/test_context_test.go
Normal file
467
internal/terraform/test_context_test.go
Normal file
@ -0,0 +1,467 @@
|
||||
package terraform
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
ctyjson "github.com/zclconf/go-cty/cty/json"
|
||||
ctymsgpack "github.com/zclconf/go-cty/cty/msgpack"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/addrs"
|
||||
"github.com/hashicorp/terraform/internal/configs/configschema"
|
||||
"github.com/hashicorp/terraform/internal/moduletest"
|
||||
"github.com/hashicorp/terraform/internal/plans"
|
||||
"github.com/hashicorp/terraform/internal/providers"
|
||||
"github.com/hashicorp/terraform/internal/states"
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
)
|
||||
|
||||
func TestTestContext_EvaluateAgainstState(t *testing.T) {
|
||||
tcs := map[string]struct {
|
||||
configs map[string]string
|
||||
state *states.State
|
||||
variables InputValues
|
||||
provider *MockProvider
|
||||
|
||||
expectedDiags []tfdiags.Description
|
||||
expectedStatus moduletest.Status
|
||||
}{
|
||||
"basic_passing": {
|
||||
configs: map[string]string{
|
||||
"main.tf": `
|
||||
resource "test_resource" "a" {
|
||||
value = "Hello, world!"
|
||||
}
|
||||
`,
|
||||
"main.tftest": `
|
||||
run "test_case" {
|
||||
assert {
|
||||
condition = test_resource.a.value == "Hello, world!"
|
||||
error_message = "invalid value"
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
state: states.BuildState(func(state *states.SyncState) {
|
||||
state.SetResourceInstanceCurrent(
|
||||
addrs.Resource{
|
||||
Mode: addrs.ManagedResourceMode,
|
||||
Type: "test_resource",
|
||||
Name: "a",
|
||||
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
||||
&states.ResourceInstanceObjectSrc{
|
||||
Status: states.ObjectReady,
|
||||
AttrsJSON: encodeCtyValue(t, cty.ObjectVal(map[string]cty.Value{
|
||||
"value": cty.StringVal("Hello, world!"),
|
||||
})),
|
||||
},
|
||||
addrs.AbsProviderConfig{
|
||||
Module: addrs.RootModule,
|
||||
Provider: addrs.NewDefaultProvider("test"),
|
||||
})
|
||||
}),
|
||||
provider: &MockProvider{
|
||||
GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
|
||||
ResourceTypes: map[string]providers.Schema{
|
||||
"test_resource": {
|
||||
Block: &configschema.Block{
|
||||
Attributes: map[string]*configschema.Attribute{
|
||||
"value": {
|
||||
Type: cty.String,
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedStatus: moduletest.Pass,
|
||||
},
|
||||
"with_variables": {
|
||||
configs: map[string]string{
|
||||
"main.tf": `
|
||||
variable "value" {
|
||||
type = string
|
||||
}
|
||||
|
||||
resource "test_resource" "a" {
|
||||
value = var.value
|
||||
}
|
||||
`,
|
||||
"main.tftest": `
|
||||
variables {
|
||||
value = "Hello, world!"
|
||||
}
|
||||
|
||||
run "test_case" {
|
||||
assert {
|
||||
condition = test_resource.a.value == var.value
|
||||
error_message = "invalid value"
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
state: states.BuildState(func(state *states.SyncState) {
|
||||
state.SetResourceInstanceCurrent(
|
||||
addrs.Resource{
|
||||
Mode: addrs.ManagedResourceMode,
|
||||
Type: "test_resource",
|
||||
Name: "a",
|
||||
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
||||
&states.ResourceInstanceObjectSrc{
|
||||
Status: states.ObjectReady,
|
||||
AttrsJSON: encodeCtyValue(t, cty.ObjectVal(map[string]cty.Value{
|
||||
"value": cty.StringVal("Hello, world!"),
|
||||
})),
|
||||
},
|
||||
addrs.AbsProviderConfig{
|
||||
Module: addrs.RootModule,
|
||||
Provider: addrs.NewDefaultProvider("test"),
|
||||
})
|
||||
}),
|
||||
variables: InputValues{
|
||||
"value": {
|
||||
Value: cty.StringVal("Hello, world!"),
|
||||
},
|
||||
},
|
||||
provider: &MockProvider{
|
||||
GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
|
||||
ResourceTypes: map[string]providers.Schema{
|
||||
"test_resource": {
|
||||
Block: &configschema.Block{
|
||||
Attributes: map[string]*configschema.Attribute{
|
||||
"value": {
|
||||
Type: cty.String,
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedStatus: moduletest.Pass,
|
||||
},
|
||||
"basic_failing": {
|
||||
configs: map[string]string{
|
||||
"main.tf": `
|
||||
resource "test_resource" "a" {
|
||||
value = "Hello, world!"
|
||||
}
|
||||
`,
|
||||
"main.tftest": `
|
||||
run "test_case" {
|
||||
assert {
|
||||
condition = test_resource.a.value == "incorrect!"
|
||||
error_message = "invalid value"
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
state: states.BuildState(func(state *states.SyncState) {
|
||||
state.SetResourceInstanceCurrent(
|
||||
addrs.Resource{
|
||||
Mode: addrs.ManagedResourceMode,
|
||||
Type: "test_resource",
|
||||
Name: "a",
|
||||
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
||||
&states.ResourceInstanceObjectSrc{
|
||||
Status: states.ObjectReady,
|
||||
AttrsJSON: encodeCtyValue(t, cty.ObjectVal(map[string]cty.Value{
|
||||
"value": cty.StringVal("Hello, world!"),
|
||||
})),
|
||||
},
|
||||
addrs.AbsProviderConfig{
|
||||
Module: addrs.RootModule,
|
||||
Provider: addrs.NewDefaultProvider("test"),
|
||||
})
|
||||
}),
|
||||
provider: &MockProvider{
|
||||
GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
|
||||
ResourceTypes: map[string]providers.Schema{
|
||||
"test_resource": {
|
||||
Block: &configschema.Block{
|
||||
Attributes: map[string]*configschema.Attribute{
|
||||
"value": {
|
||||
Type: cty.String,
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedStatus: moduletest.Fail,
|
||||
expectedDiags: []tfdiags.Description{
|
||||
{
|
||||
Summary: "Test assertion failed",
|
||||
Detail: "invalid value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for name, tc := range tcs {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
config := testModuleInline(t, tc.configs)
|
||||
ctx := testContext2(t, &ContextOpts{
|
||||
Providers: map[addrs.Provider]providers.Factory{
|
||||
addrs.NewDefaultProvider("test"): testProviderFuncFixed(tc.provider),
|
||||
},
|
||||
})
|
||||
|
||||
run := moduletest.Run{
|
||||
Config: config.Module.Tests["main.tftest"].Runs[0],
|
||||
Name: "test_case",
|
||||
}
|
||||
|
||||
tctx := ctx.TestContext(config, tc.state, &plans.Plan{}, tc.variables)
|
||||
tctx.EvaluateAgainstState(&run)
|
||||
|
||||
if expected, actual := tc.expectedStatus, run.Status; expected != actual {
|
||||
t.Errorf("expected status \"%s\" but got \"%s\"", expected, actual)
|
||||
}
|
||||
|
||||
compareDiagnosticsFromTestResult(t, tc.expectedDiags, run.Diagnostics)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestContext_EvaluateAgainstPlan(t *testing.T) {
|
||||
tcs := map[string]struct {
|
||||
configs map[string]string
|
||||
state *states.State
|
||||
plan *plans.Plan
|
||||
variables InputValues
|
||||
provider *MockProvider
|
||||
|
||||
expectedDiags []tfdiags.Description
|
||||
expectedStatus moduletest.Status
|
||||
}{
|
||||
"basic_passing": {
|
||||
configs: map[string]string{
|
||||
"main.tf": `
|
||||
resource "test_resource" "a" {
|
||||
value = "Hello, world!"
|
||||
}
|
||||
`,
|
||||
"main.tftest": `
|
||||
run "test_case" {
|
||||
assert {
|
||||
condition = test_resource.a.value == "Hello, world!"
|
||||
error_message = "invalid value"
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
state: states.BuildState(func(state *states.SyncState) {
|
||||
state.SetResourceInstanceCurrent(
|
||||
addrs.Resource{
|
||||
Mode: addrs.ManagedResourceMode,
|
||||
Type: "test_resource",
|
||||
Name: "a",
|
||||
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
||||
&states.ResourceInstanceObjectSrc{
|
||||
Status: states.ObjectPlanned,
|
||||
AttrsJSON: encodeCtyValue(t, cty.NullVal(cty.Object(map[string]cty.Type{
|
||||
"value": cty.String,
|
||||
}))),
|
||||
},
|
||||
addrs.AbsProviderConfig{
|
||||
Module: addrs.RootModule,
|
||||
Provider: addrs.NewDefaultProvider("test"),
|
||||
})
|
||||
}),
|
||||
plan: &plans.Plan{
|
||||
Changes: &plans.Changes{
|
||||
Resources: []*plans.ResourceInstanceChangeSrc{
|
||||
{
|
||||
Addr: addrs.Resource{
|
||||
Mode: addrs.ManagedResourceMode,
|
||||
Type: "test_resource",
|
||||
Name: "a",
|
||||
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
||||
ProviderAddr: addrs.AbsProviderConfig{
|
||||
Module: addrs.RootModule,
|
||||
Provider: addrs.NewDefaultProvider("test"),
|
||||
},
|
||||
ChangeSrc: plans.ChangeSrc{
|
||||
Action: plans.Create,
|
||||
Before: nil,
|
||||
After: encodeDynamicValue(t, cty.ObjectVal(map[string]cty.Value{
|
||||
"value": cty.StringVal("Hello, world!"),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: &MockProvider{
|
||||
GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
|
||||
ResourceTypes: map[string]providers.Schema{
|
||||
"test_resource": {
|
||||
Block: &configschema.Block{
|
||||
Attributes: map[string]*configschema.Attribute{
|
||||
"value": {
|
||||
Type: cty.String,
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedStatus: moduletest.Pass,
|
||||
},
|
||||
"basic_failing": {
|
||||
configs: map[string]string{
|
||||
"main.tf": `
|
||||
resource "test_resource" "a" {
|
||||
value = "Hello, world!"
|
||||
}
|
||||
`,
|
||||
"main.tftest": `
|
||||
run "test_case" {
|
||||
assert {
|
||||
condition = test_resource.a.value == "incorrect!"
|
||||
error_message = "invalid value"
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
state: states.BuildState(func(state *states.SyncState) {
|
||||
state.SetResourceInstanceCurrent(
|
||||
addrs.Resource{
|
||||
Mode: addrs.ManagedResourceMode,
|
||||
Type: "test_resource",
|
||||
Name: "a",
|
||||
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
||||
&states.ResourceInstanceObjectSrc{
|
||||
Status: states.ObjectPlanned,
|
||||
AttrsJSON: encodeCtyValue(t, cty.NullVal(cty.Object(map[string]cty.Type{
|
||||
"value": cty.String,
|
||||
}))),
|
||||
},
|
||||
addrs.AbsProviderConfig{
|
||||
Module: addrs.RootModule,
|
||||
Provider: addrs.NewDefaultProvider("test"),
|
||||
})
|
||||
}),
|
||||
plan: &plans.Plan{
|
||||
Changes: &plans.Changes{
|
||||
Resources: []*plans.ResourceInstanceChangeSrc{
|
||||
{
|
||||
Addr: addrs.Resource{
|
||||
Mode: addrs.ManagedResourceMode,
|
||||
Type: "test_resource",
|
||||
Name: "a",
|
||||
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
|
||||
ProviderAddr: addrs.AbsProviderConfig{
|
||||
Module: addrs.RootModule,
|
||||
Provider: addrs.NewDefaultProvider("test"),
|
||||
},
|
||||
ChangeSrc: plans.ChangeSrc{
|
||||
Action: plans.Create,
|
||||
Before: nil,
|
||||
After: encodeDynamicValue(t, cty.ObjectVal(map[string]cty.Value{
|
||||
"value": cty.StringVal("Hello, world!"),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: &MockProvider{
|
||||
GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
|
||||
ResourceTypes: map[string]providers.Schema{
|
||||
"test_resource": {
|
||||
Block: &configschema.Block{
|
||||
Attributes: map[string]*configschema.Attribute{
|
||||
"value": {
|
||||
Type: cty.String,
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedStatus: moduletest.Fail,
|
||||
expectedDiags: []tfdiags.Description{
|
||||
{
|
||||
Summary: "Test assertion failed",
|
||||
Detail: "invalid value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for name, tc := range tcs {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
config := testModuleInline(t, tc.configs)
|
||||
ctx := testContext2(t, &ContextOpts{
|
||||
Providers: map[addrs.Provider]providers.Factory{
|
||||
addrs.NewDefaultProvider("test"): testProviderFuncFixed(tc.provider),
|
||||
},
|
||||
})
|
||||
|
||||
run := moduletest.Run{
|
||||
Config: config.Module.Tests["main.tftest"].Runs[0],
|
||||
Name: "test_case",
|
||||
}
|
||||
|
||||
tctx := ctx.TestContext(config, tc.state, tc.plan, tc.variables)
|
||||
tctx.EvaluateAgainstPlan(&run)
|
||||
|
||||
if expected, actual := tc.expectedStatus, run.Status; expected != actual {
|
||||
t.Errorf("expected status \"%s\" but got \"%s\"", expected, actual)
|
||||
}
|
||||
|
||||
compareDiagnosticsFromTestResult(t, tc.expectedDiags, run.Diagnostics)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func compareDiagnosticsFromTestResult(t *testing.T, expected []tfdiags.Description, actual tfdiags.Diagnostics) {
|
||||
if len(expected) != len(actual) {
|
||||
t.Errorf("found invalid number of diagnostics, expected %d but found %d", len(expected), len(actual))
|
||||
}
|
||||
|
||||
length := len(expected)
|
||||
if len(actual) > length {
|
||||
length = len(actual)
|
||||
}
|
||||
|
||||
for ix := 0; ix < length; ix++ {
|
||||
if ix >= len(expected) {
|
||||
t.Errorf("found extra diagnostic at %d:\n%v", ix, actual[ix].Description())
|
||||
} else if ix >= len(actual) {
|
||||
t.Errorf("missing diagnostic at %d:\n%v", ix, expected[ix])
|
||||
} else {
|
||||
expected := expected[ix]
|
||||
actual := actual[ix].Description()
|
||||
if diff := cmp.Diff(expected, actual); len(diff) > 0 {
|
||||
t.Errorf("found different diagnostics at %d:\nexpected:\n%s\nactual:\n%s\ndiff:%s", ix, expected, actual, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func encodeDynamicValue(t *testing.T, value cty.Value) []byte {
|
||||
data, err := ctymsgpack.Marshal(value, value.Type())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal JSON: %s", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func encodeCtyValue(t *testing.T, value cty.Value) []byte {
|
||||
data, err := ctyjson.Marshal(value, value.Type())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal JSON: %s", err)
|
||||
}
|
||||
return data
|
||||
}
|
23
internal/terraform/transform_external_reference.go
Normal file
23
internal/terraform/transform_external_reference.go
Normal file
@ -0,0 +1,23 @@
|
||||
package terraform
|
||||
|
||||
import "github.com/hashicorp/terraform/internal/addrs"
|
||||
|
||||
// ExternalReferenceTransformer will add a GraphNodeReferencer into the graph
|
||||
// that makes no changes to the graph itself but, by referencing the addresses
|
||||
// within ExternalReferences, ensures that any temporary nodes that are required
|
||||
// by an external caller, such as the terraform testing framework, are not
|
||||
// skipped because they are not referenced from within the module.
|
||||
type ExternalReferenceTransformer struct {
|
||||
ExternalReferences []*addrs.Reference
|
||||
}
|
||||
|
||||
func (t *ExternalReferenceTransformer) Transform(g *Graph) error {
|
||||
if len(t.ExternalReferences) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
g.Add(&nodeExternalReference{
|
||||
ExternalReferences: t.ExternalReferences,
|
||||
})
|
||||
return nil
|
||||
}
|
Loading…
Reference in New Issue
Block a user