From 9742f22c4ee51cd3e78b3c64dd0a51b32cf1dc43 Mon Sep 17 00:00:00 2001 From: Liam Cervante Date: Wed, 16 Aug 2023 11:06:00 +0200 Subject: [PATCH] Introduce 'run' keyword for referencing outputs from earlier run blocks (#33683) * introduce 'run' keyword for referencing outputs from earlier run blocks * fix code consistency --- internal/addrs/parse_ref.go | 8 + internal/addrs/parse_ref_test.go | 62 +++- internal/addrs/run.go | 27 ++ internal/command/meta_vars.go | 20 ++ internal/command/test.go | 147 +++++++--- internal/command/test_test.go | 38 ++- .../testdata/test/shared_state/main.tf | 12 + .../test/shared_state/main.tftest.hcl | 37 +++ .../testdata/test/shared_state/setup/main.tf | 12 + .../testdata/test/shared_state_object/main.tf | 12 + .../test/shared_state_object/main.tftest.hcl | 37 +++ .../test/shared_state_object/setup/main.tf | 12 + internal/lang/data.go | 1 + internal/lang/data_test.go | 5 + internal/lang/eval.go | 17 +- internal/lang/eval_test.go | 264 ++++++++++++------ internal/terraform/evaluate.go | 34 +++ internal/terraform/evaluate_test.go | 92 ++++++ internal/terraform/evaluate_valid.go | 31 ++ internal/terraform/evaluate_valid_test.go | 23 +- internal/terraform/test_context.go | 50 ++-- internal/terraform/test_context_test.go | 114 +++++++- 22 files changed, 888 insertions(+), 167 deletions(-) create mode 100644 internal/addrs/run.go create mode 100644 internal/command/testdata/test/shared_state/main.tf create mode 100644 internal/command/testdata/test/shared_state/main.tftest.hcl create mode 100644 internal/command/testdata/test/shared_state/setup/main.tf create mode 100644 internal/command/testdata/test/shared_state_object/main.tf create mode 100644 internal/command/testdata/test/shared_state_object/main.tftest.hcl create mode 100644 internal/command/testdata/test/shared_state_object/setup/main.tf diff --git a/internal/addrs/parse_ref.go b/internal/addrs/parse_ref.go index 3f8221e0da..28f8cb2fd1 100644 --- a/internal/addrs/parse_ref.go +++ b/internal/addrs/parse_ref.go @@ -111,6 +111,14 @@ func ParseRefFromTestingScope(traversal hcl.Traversal) (*Reference, tfdiags.Diag Remaining: remain, } diags = checkDiags + case "run": + name, rng, remain, runDiags := parseSingleAttrRef(traversal) + reference = &Reference{ + Subject: Run{Name: name}, + SourceRange: tfdiags.SourceRangeFromHCL(rng), + Remaining: remain, + } + diags = runDiags } if reference != nil { diff --git a/internal/addrs/parse_ref_test.go b/internal/addrs/parse_ref_test.go index 38642d4992..1441c64ac9 100644 --- a/internal/addrs/parse_ref_test.go +++ b/internal/addrs/parse_ref_test.go @@ -67,6 +67,51 @@ func TestParseRefInTestingScope(t *testing.T) { nil, `The "check" object does not support this operation.`, }, + { + `run.zero`, + &Reference{ + Subject: Run{ + Name: "zero", + }, + SourceRange: tfdiags.SourceRange{ + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 9, Byte: 8}, + }, + }, + ``, + }, + { + `run.zero.value`, + &Reference{ + Subject: Run{ + Name: "zero", + }, + SourceRange: tfdiags.SourceRange{ + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 9, Byte: 8}, + }, + Remaining: hcl.Traversal{ + hcl.TraverseAttr{ + Name: "value", + SrcRange: hcl.Range{ + Start: hcl.Pos{Line: 1, Column: 9, Byte: 8}, + End: hcl.Pos{Line: 1, Column: 15, Byte: 14}, + }, + }, + }, + }, + ``, + }, + { + `run`, + nil, + `The "run" object cannot be accessed directly. Instead, access one of its attributes.`, + }, + { + `run["foo"]`, + nil, + `The "run" object does not support this operation.`, + }, // Sanity check at least one of the others works to verify it does // fall through to the core function. @@ -827,7 +872,7 @@ func TestParseRef(t *testing.T) { `A reference to a resource type must be followed by at least one attribute access, specifying the resource name.`, }, - // Should interpret checks and outputs as resource types. + // Should interpret checks, outputs, and runs as resource types. { `output.value`, &Reference{ @@ -858,6 +903,21 @@ func TestParseRef(t *testing.T) { }, ``, }, + { + `run.zero`, + &Reference{ + Subject: Resource{ + Mode: ManagedResourceMode, + Type: "run", + Name: "zero", + }, + SourceRange: tfdiags.SourceRange{ + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 9, Byte: 8}, + }, + }, + ``, + }, } for _, test := range tests { diff --git a/internal/addrs/run.go b/internal/addrs/run.go new file mode 100644 index 0000000000..c32ea34c47 --- /dev/null +++ b/internal/addrs/run.go @@ -0,0 +1,27 @@ +package addrs + +import "fmt" + +// Run is the address of a run block within a testing file. +// +// Run blocks are only accessible from within the same testing file, and they +// do not support any meta-arguments like "count" or "for_each". So this address +// uniquely describes a run block from within a single testing file. +type Run struct { + referenceable + Name string +} + +func (r Run) String() string { + return fmt.Sprintf("run.%s", r.Name) +} + +func (r Run) Equal(run Run) bool { + return r.Name == run.Name +} + +func (r Run) UniqueKey() UniqueKey { + return r // A Run is its own UniqueKey +} + +func (r Run) uniqueKeySigil() {} diff --git a/internal/command/meta_vars.go b/internal/command/meta_vars.go index b541e4f0e0..97be5f4374 100644 --- a/internal/command/meta_vars.go +++ b/internal/command/meta_vars.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" hcljson "github.com/hashicorp/hcl/v2/json" + "github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/terraform" @@ -266,3 +267,22 @@ func (v unparsedVariableValueString) ParseVariableValue(mode configs.VariablePar SourceType: v.sourceType, }, diags } + +type unparsedTestVariableValue struct { + expr hcl.Expression + ctx *hcl.EvalContext +} + +func (v unparsedTestVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + val, hclDiags := v.expr.Value(v.ctx) // nil because no function calls or variable references are allowed here + diags = diags.Append(hclDiags) + + rng := tfdiags.SourceRangeFromHCL(v.expr.Range()) + + return &terraform.InputValue{ + Value: val, + SourceType: terraform.ValueFromConfig, // Test variables always come from config. + SourceRange: rng, + }, diags +} diff --git a/internal/command/test.go b/internal/command/test.go index 72742f900b..2d604ef426 100644 --- a/internal/command/test.go +++ b/internal/command/test.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/logging" "github.com/hashicorp/terraform/internal/moduletest" "github.com/hashicorp/terraform/internal/plans" @@ -251,7 +252,7 @@ func (c *TestCommand) Run(rawArgs []string) int { defer stop() defer cancel() - runner.Start(variables) + runner.Start() }() // Wait for the operation to complete, or for an interrupt to occur. @@ -299,8 +300,10 @@ func (c *TestCommand) Run(rawArgs []string) int { return 0 } -// test runner - +// TestSuiteRunner executes an entire set of Terraform test files. +// +// It contains all shared information needed by all the test files, like the +// main configuration and the global variable values. type TestSuiteRunner struct { command *TestCommand @@ -332,7 +335,7 @@ type TestSuiteRunner struct { Verbose bool } -func (runner *TestSuiteRunner) Start(globals map[string]backend.UnparsedVariableValue) { +func (runner *TestSuiteRunner) Start() { var files []string for name := range runner.Suite.Files { files = append(files, name) @@ -349,12 +352,13 @@ func (runner *TestSuiteRunner) Start(globals map[string]backend.UnparsedVariable fileRunner := &TestFileRunner{ Suite: runner, - States: map[string]*TestFileState{ + RelevantStates: map[string]*TestFileState{ MainStateIdentifier: { Run: nil, State: states.NewState(), }, }, + PriorStates: make(map[string]*terraform.TestContext), } fileRunner.ExecuteTestFile(file) @@ -364,11 +368,29 @@ func (runner *TestSuiteRunner) Start(globals map[string]backend.UnparsedVariable } type TestFileRunner struct { + // Suite contains all the helpful metadata about the test that we need + // during the execution of a file. Suite *TestSuiteRunner - States map[string]*TestFileState + // RelevantStates is a mapping of module keys to it's last applied state + // file. + // + // This is used to clean up the infrastructure created during the test after + // the test has finished. + RelevantStates map[string]*TestFileState + + // PriorStates is mapping from run block names to the TestContexts that were + // created when that run block executed. + // + // This is used to allow run blocks to refer back to the output values of + // previous run blocks. It is passed into the Evaluate functions that + // validate the test assertions, and used when calculating values for + // variables within run blocks. + PriorStates map[string]*terraform.TestContext } +// TestFileState is a helper struct that just maps a run block to the state that +// was produced by the execution of that run block. type TestFileState struct { Run *moduletest.Run State *states.State @@ -424,22 +446,22 @@ func (runner *TestFileRunner) ExecuteTestFile(file *moduletest.File) { continue // Abort! } - if _, exists := runner.States[key]; !exists { - runner.States[key] = &TestFileState{ + if _, exists := runner.RelevantStates[key]; !exists { + runner.RelevantStates[key] = &TestFileState{ Run: nil, State: states.NewState(), } } } - state, updatedState := runner.ExecuteTestRun(run, file, runner.States[key].State, config) + state, updatedState := runner.ExecuteTestRun(run, file, runner.RelevantStates[key].State, config) if updatedState { // 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. - runner.States[key].State = state - runner.States[key].Run = run + runner.RelevantStates[key].State = state + runner.RelevantStates[key].Run = run } file.Status = file.Status.Merge(run.Status) @@ -499,7 +521,7 @@ func (runner *TestFileRunner) ExecuteTestRun(run *moduletest.Run, file *modulete return state, false } - variables, resetVariables, variableDiags := prepareInputVariablesForAssertions(config, run, file, runner.Suite.GlobalVariables) + variables, resetVariables, variableDiags := runner.prepareInputVariablesForAssertions(config, run, file) defer resetVariables() run.Diagnostics = run.Diagnostics.Append(variableDiags) @@ -533,7 +555,19 @@ func (runner *TestFileRunner) ExecuteTestRun(run *moduletest.Run, file *modulete run.Diagnostics = run.Diagnostics.Append(diags) } - planCtx.TestContext(config, plan.PlannedState, plan, variables).Evaluate(run) + // First, make the test context we can use to validate the assertions + // of the + ctx := planCtx.TestContext(run, config, plan.PlannedState, plan, variables) + + // Second, evaluate the run block directly. We also pass in all the + // previous contexts so this run block can refer to outputs from + // previous run blocks. + ctx.Evaluate(runner.PriorStates) + + // Now we've successfully validated this run block, lets add it into + // our prior states so future run blocks can access it. + runner.PriorStates[run.Name] = ctx + return state, false } @@ -572,7 +606,7 @@ func (runner *TestFileRunner) ExecuteTestRun(run *moduletest.Run, file *modulete return updated, true } - variables, resetVariables, variableDiags := prepareInputVariablesForAssertions(config, run, file, runner.Suite.GlobalVariables) + variables, resetVariables, variableDiags := runner.prepareInputVariablesForAssertions(config, run, file) defer resetVariables() run.Diagnostics = run.Diagnostics.Append(variableDiags) @@ -606,7 +640,19 @@ func (runner *TestFileRunner) ExecuteTestRun(run *moduletest.Run, file *modulete run.Diagnostics = run.Diagnostics.Append(diags) } - applyCtx.TestContext(config, updated, plan, variables).Evaluate(run) + // First, make the test context we can use to validate the assertions + // of the + ctx := applyCtx.TestContext(run, config, updated, plan, variables) + + // Second, evaluate the run block directly. We also pass in all the + // previous contexts so this run block can refer to outputs from + // previous run blocks. + ctx.Evaluate(runner.PriorStates) + + // Now we've successfully validated this run block, lets add it into + // our prior states so future run blocks can access it. + runner.PriorStates[run.Name] = ctx + return updated, true } @@ -655,7 +701,7 @@ func (runner *TestFileRunner) destroy(config *configs.Config, state *states.Stat var diags tfdiags.Diagnostics - variables, variableDiags := buildInputVariablesForTest(run, file, config, runner.Suite.GlobalVariables) + variables, variableDiags := runner.buildInputVariablesForTest(run, file, config) diags = diags.Append(variableDiags) if diags.HasErrors() { @@ -717,7 +763,7 @@ func (runner *TestFileRunner) plan(config *configs.Config, state *states.State, references, referenceDiags := run.GetReferences() diags = diags.Append(referenceDiags) - variables, variableDiags := buildInputVariablesForTest(run, file, config, runner.Suite.GlobalVariables) + variables, variableDiags := runner.buildInputVariablesForTest(run, file, config) diags = diags.Append(variableDiags) if diags.HasErrors() { @@ -844,8 +890,8 @@ func (runner *TestFileRunner) wait(ctx *terraform.Context, runningCtx context.Co log.Printf("[DEBUG] TestFileRunner: test execution cancelled during %s", identifier) states := make(map[*moduletest.Run]*states.State) - states[nil] = runner.States[MainStateIdentifier].State - for key, module := range runner.States { + states[nil] = runner.RelevantStates[MainStateIdentifier].State + for key, module := range runner.RelevantStates { if key == MainStateIdentifier { continue } @@ -902,7 +948,7 @@ func (runner *TestFileRunner) Cleanup(file *moduletest.File) { } // First, we'll clean up the main state. - main := runner.States[MainStateIdentifier] + main := runner.RelevantStates[MainStateIdentifier] var diags tfdiags.Diagnostics updated := main.State @@ -931,7 +977,7 @@ func (runner *TestFileRunner) Cleanup(file *moduletest.File) { } var states []*TestFileState - for key, state := range runner.States { + for key, state := range runner.RelevantStates { if key == MainStateIdentifier { // We processed the main state above. continue @@ -995,23 +1041,21 @@ func (runner *TestFileRunner) Cleanup(file *moduletest.File) { } } -// helper functions - // buildInputVariablesForTest creates a terraform.InputValues mapping for // variable values that are relevant to the config being tested. // // Crucially, it differs from prepareInputVariablesForAssertions in that it only // includes variables that are reference by the config and not everything that // is defined within the test run block and test file. -func buildInputVariablesForTest(run *moduletest.Run, file *moduletest.File, config *configs.Config, globals map[string]backend.UnparsedVariableValue) (terraform.InputValues, tfdiags.Diagnostics) { +func (runner *TestFileRunner) buildInputVariablesForTest(run *moduletest.Run, file *moduletest.File, config *configs.Config) (terraform.InputValues, tfdiags.Diagnostics) { variables := make(map[string]backend.UnparsedVariableValue) for name := range config.Module.Variables { if run != nil { if expr, exists := run.Config.Variables[name]; exists { // Local variables take precedence. - variables[name] = unparsedVariableValueExpression{ - expr: expr, - sourceType: terraform.ValueFromConfig, + variables[name] = unparsedTestVariableValue{ + expr: expr, + ctx: runner.EvalCtx(), } continue } @@ -1028,10 +1072,10 @@ func buildInputVariablesForTest(run *moduletest.Run, file *moduletest.File, conf } } - if globals != nil { + if runner.Suite.GlobalVariables != nil { // If it's not set locally or at the file level, maybe it was // defined globally. - if variable, exists := globals[name]; exists { + if variable, exists := runner.Suite.GlobalVariables[name]; exists { variables[name] = variable } } @@ -1055,14 +1099,14 @@ func buildInputVariablesForTest(run *moduletest.Run, file *moduletest.File, conf // In addition, it modifies the provided config so that any variables that are // available are also defined in the config. It returns a function that resets // the config which must be called so the config can be reused going forward. -func prepareInputVariablesForAssertions(config *configs.Config, run *moduletest.Run, file *moduletest.File, globals map[string]backend.UnparsedVariableValue) (terraform.InputValues, func(), tfdiags.Diagnostics) { +func (runner *TestFileRunner) prepareInputVariablesForAssertions(config *configs.Config, run *moduletest.Run, file *moduletest.File) (terraform.InputValues, func(), tfdiags.Diagnostics) { variables := make(map[string]backend.UnparsedVariableValue) if run != nil { for name, expr := range run.Config.Variables { - variables[name] = unparsedVariableValueExpression{ - expr: expr, - sourceType: terraform.ValueFromConfig, + variables[name] = unparsedTestVariableValue{ + expr: expr, + ctx: runner.EvalCtx(), } } } @@ -1081,7 +1125,7 @@ func prepareInputVariablesForAssertions(config *configs.Config, run *moduletest. } } - for name, variable := range globals { + for name, variable := range runner.Suite.GlobalVariables { if _, exists := variables[name]; exists { // Then this value was already defined at either the run level // or the file level, and we want those values to take @@ -1157,3 +1201,38 @@ func prepareInputVariablesForAssertions(config *configs.Config, run *moduletest. config.Module.Variables = currentVars }, diags } + +// EvalCtx returns an hcl.EvalContext that allows the variables blocks within +// run blocks to evaluate references to the outputs from other run blocks. +func (runner *TestFileRunner) EvalCtx() *hcl.EvalContext { + return &hcl.EvalContext{ + Variables: func() map[string]cty.Value { + blocks := make(map[string]cty.Value) + for run, ctx := range runner.PriorStates { + + outputs := make(map[string]cty.Value) + for _, output := range ctx.Config.Module.Outputs { + value := ctx.State.OutputValue(addrs.AbsOutputValue{ + Module: addrs.RootModuleInstance, + OutputValue: addrs.OutputValue{ + Name: output.Name, + }, + }) + + if value.Sensitive { + outputs[output.Name] = value.Value.Mark(marks.Sensitive) + continue + } + + outputs[output.Name] = value.Value + } + + blocks[run] = cty.ObjectVal(outputs) + } + + return map[string]cty.Value{ + "run": cty.ObjectVal(blocks), + } + }(), + } +} diff --git a/internal/command/test_test.go b/internal/command/test_test.go index aaa80185b5..338264d553 100644 --- a/internal/command/test_test.go +++ b/internal/command/test_test.go @@ -132,6 +132,14 @@ func TestTest(t *testing.T) { expected: "1 passed, 0 failed.", code: 0, }, + "shared_state": { + expected: "2 passed, 0 failed.", + code: 0, + }, + "shared_state_object": { + expected: "2 passed, 0 failed.", + code: 0, + }, } for name, tc := range tcs { t.Run(name, func(t *testing.T) { @@ -149,13 +157,33 @@ func TestTest(t *testing.T) { defer testChdir(t, td)() provider := testing_command.NewProvider(nil) - view, done := testView(t) + providerSource, close := newMockProviderSource(t, map[string][]string{ + "test": {"1.0.0"}, + }) + defer close() + + streams, done := terminal.StreamsForTesting(t) + view := views.NewView(streams) + ui := new(cli.MockUi) + + meta := Meta{ + testingOverrides: metaOverridesForProvider(provider.Provider), + Ui: ui, + View: view, + Streams: streams, + ProviderSource: providerSource, + } + + init := &InitCommand{ + Meta: meta, + } + + if code := init.Run(nil); code != 0 { + t.Fatalf("expected status code 0 but got %d: %s", code, ui.ErrorWriter) + } c := &TestCommand{ - Meta: Meta{ - testingOverrides: metaOverridesForProvider(provider.Provider), - View: view, - }, + Meta: meta, } code := c.Run(tc.args) diff --git a/internal/command/testdata/test/shared_state/main.tf b/internal/command/testdata/test/shared_state/main.tf new file mode 100644 index 0000000000..ada34cf2db --- /dev/null +++ b/internal/command/testdata/test/shared_state/main.tf @@ -0,0 +1,12 @@ + +variable "input" { + type = string +} + +resource "test_resource" "foo" { + value = var.input +} + +output "value" { + value = test_resource.foo.value +} diff --git a/internal/command/testdata/test/shared_state/main.tftest.hcl b/internal/command/testdata/test/shared_state/main.tftest.hcl new file mode 100644 index 0000000000..9ee5ee20ef --- /dev/null +++ b/internal/command/testdata/test/shared_state/main.tftest.hcl @@ -0,0 +1,37 @@ + +variables { + foo = "foo" +} + + +run "setup" { + module { + source = "./setup" + } + + variables { + input = "foo" + } + + assert { + condition = output.value == var.foo + error_message = "bad" + } +} + +run "test" { + + variables { + input = run.setup.value + } + + assert { + condition = output.value == var.foo + error_message = "double bad" + } + + assert { + condition = run.setup.value == var.foo + error_message = "triple bad" + } +} diff --git a/internal/command/testdata/test/shared_state/setup/main.tf b/internal/command/testdata/test/shared_state/setup/main.tf new file mode 100644 index 0000000000..ada34cf2db --- /dev/null +++ b/internal/command/testdata/test/shared_state/setup/main.tf @@ -0,0 +1,12 @@ + +variable "input" { + type = string +} + +resource "test_resource" "foo" { + value = var.input +} + +output "value" { + value = test_resource.foo.value +} diff --git a/internal/command/testdata/test/shared_state_object/main.tf b/internal/command/testdata/test/shared_state_object/main.tf new file mode 100644 index 0000000000..ada34cf2db --- /dev/null +++ b/internal/command/testdata/test/shared_state_object/main.tf @@ -0,0 +1,12 @@ + +variable "input" { + type = string +} + +resource "test_resource" "foo" { + value = var.input +} + +output "value" { + value = test_resource.foo.value +} diff --git a/internal/command/testdata/test/shared_state_object/main.tftest.hcl b/internal/command/testdata/test/shared_state_object/main.tftest.hcl new file mode 100644 index 0000000000..3fb3e975f9 --- /dev/null +++ b/internal/command/testdata/test/shared_state_object/main.tftest.hcl @@ -0,0 +1,37 @@ + +variables { + foo = "foo" +} + + +run "setup" { + module { + source = "./setup" + } + + variables { + input = "foo" + } + + assert { + condition = output.value == var.foo + error_message = "bad" + } +} + +run "test" { + + variables { + input = run.setup.value + } + + assert { + condition = output.value == var.foo + error_message = "double bad" + } + + assert { + condition = run.setup == { value : "foo" } + error_message = "triple bad" + } +} diff --git a/internal/command/testdata/test/shared_state_object/setup/main.tf b/internal/command/testdata/test/shared_state_object/setup/main.tf new file mode 100644 index 0000000000..ada34cf2db --- /dev/null +++ b/internal/command/testdata/test/shared_state_object/setup/main.tf @@ -0,0 +1,12 @@ + +variable "input" { + type = string +} + +resource "test_resource" "foo" { + value = var.input +} + +output "value" { + value = test_resource.foo.value +} diff --git a/internal/lang/data.go b/internal/lang/data.go index 964c8a95f6..fe2c205a3e 100644 --- a/internal/lang/data.go +++ b/internal/lang/data.go @@ -36,4 +36,5 @@ type Data interface { GetInputVariable(addrs.InputVariable, tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) GetOutput(addrs.OutputValue, tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) GetCheckBlock(addrs.Check, tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) + GetRunBlock(addrs.Run, tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) } diff --git a/internal/lang/data_test.go b/internal/lang/data_test.go index 32569beb1d..398bc8b757 100644 --- a/internal/lang/data_test.go +++ b/internal/lang/data_test.go @@ -21,6 +21,7 @@ type dataForTests struct { TerraformAttrs map[string]cty.Value InputVariables map[string]cty.Value CheckBlocks map[string]cty.Value + RunBlocks map[string]cty.Value } var _ Data = &dataForTests{} @@ -74,3 +75,7 @@ func (d *dataForTests) GetOutput(addr addrs.OutputValue, rng tfdiags.SourceRange func (d *dataForTests) GetCheckBlock(addr addrs.Check, rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) { return d.CheckBlocks[addr.Name], nil } + +func (d *dataForTests) GetRunBlock(addr addrs.Run, rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) { + return d.RunBlocks[addr.Name], nil +} diff --git a/internal/lang/eval.go b/internal/lang/eval.go index be8077169f..0efb838e5e 100644 --- a/internal/lang/eval.go +++ b/internal/lang/eval.go @@ -288,6 +288,7 @@ func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceabl countAttrs := map[string]cty.Value{} forEachAttrs := map[string]cty.Value{} checkBlocks := map[string]cty.Value{} + runBlocks := map[string]cty.Value{} var self cty.Value for _, ref := range refs { @@ -416,7 +417,12 @@ func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceabl case addrs.Check: val, valDiags := normalizeRefValue(s.Data.GetCheckBlock(subj, rng)) diags = diags.Append(valDiags) - outputValues[subj.Name] = val + checkBlocks[subj.Name] = val + + case addrs.Run: + val, valDiags := normalizeRefValue(s.Data.GetRunBlock(subj, rng)) + diags = diags.Append(valDiags) + runBlocks[subj.Name] = val default: // Should never happen @@ -443,8 +449,9 @@ func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceabl vals["count"] = cty.ObjectVal(countAttrs) vals["each"] = cty.ObjectVal(forEachAttrs) - // Checks and outputs are conditionally included in the available scope, so - // we'll only write out their values if we actually have something for them. + // Checks, outputs, and run blocks are conditionally included in the + // available scope, so we'll only write out their values if we actually have + // something for them. if len(checkBlocks) > 0 { vals["check"] = cty.ObjectVal(checkBlocks) } @@ -453,6 +460,10 @@ func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceabl vals["output"] = cty.ObjectVal(outputValues) } + if len(runBlocks) > 0 { + vals["run"] = cty.ObjectVal(runBlocks) + } + if self != cty.NilVal { vals["self"] = self } diff --git a/internal/lang/eval_test.go b/internal/lang/eval_test.go index 6c7bb94376..798db880ec 100644 --- a/internal/lang/eval_test.go +++ b/internal/lang/eval_test.go @@ -6,6 +6,7 @@ package lang import ( "bytes" "encoding/json" + "fmt" "testing" "github.com/hashicorp/terraform/internal/addrs" @@ -73,51 +74,70 @@ func TestScopeEvalContext(t *testing.T) { InputVariables: map[string]cty.Value{ "baz": cty.StringVal("boop"), }, + OutputValues: map[string]cty.Value{ + "rootoutput0": cty.StringVal("rootbar0"), + "rootoutput1": cty.StringVal("rootbar1"), + }, + CheckBlocks: map[string]cty.Value{ + "check0": cty.ObjectVal(map[string]cty.Value{ + "status": cty.StringVal("pass"), + }), + "check1": cty.ObjectVal(map[string]cty.Value{ + "status": cty.StringVal("fail"), + }), + }, + RunBlocks: map[string]cty.Value{ + "zero": cty.ObjectVal(map[string]cty.Value{ + "run0output0": cty.StringVal("run0bar0"), + "run0output1": cty.StringVal("run0bar1"), + }), + }, } tests := []struct { - Expr string - Want map[string]cty.Value + Expr string + Want map[string]cty.Value + TestingOnly bool }{ { - `12`, - map[string]cty.Value{}, + Expr: `12`, + Want: map[string]cty.Value{}, }, { - `count.index`, - map[string]cty.Value{ + Expr: `count.index`, + Want: map[string]cty.Value{ "count": cty.ObjectVal(map[string]cty.Value{ "index": cty.NumberIntVal(0), }), }, }, { - `each.key`, - map[string]cty.Value{ + Expr: `each.key`, + Want: map[string]cty.Value{ "each": cty.ObjectVal(map[string]cty.Value{ "key": cty.StringVal("a"), }), }, }, { - `each.value`, - map[string]cty.Value{ + Expr: `each.value`, + Want: map[string]cty.Value{ "each": cty.ObjectVal(map[string]cty.Value{ "value": cty.NumberIntVal(1), }), }, }, { - `local.foo`, - map[string]cty.Value{ + Expr: `local.foo`, + Want: map[string]cty.Value{ "local": cty.ObjectVal(map[string]cty.Value{ "foo": cty.StringVal("bar"), }), }, }, { - `null_resource.foo`, - map[string]cty.Value{ + Expr: `null_resource.foo`, + Want: map[string]cty.Value{ "null_resource": cty.ObjectVal(map[string]cty.Value{ "foo": cty.ObjectVal(map[string]cty.Value{ "attr": cty.StringVal("bar"), @@ -133,8 +153,8 @@ func TestScopeEvalContext(t *testing.T) { }, }, { - `null_resource.foo.attr`, - map[string]cty.Value{ + Expr: `null_resource.foo.attr`, + Want: map[string]cty.Value{ "null_resource": cty.ObjectVal(map[string]cty.Value{ "foo": cty.ObjectVal(map[string]cty.Value{ "attr": cty.StringVal("bar"), @@ -150,8 +170,8 @@ func TestScopeEvalContext(t *testing.T) { }, }, { - `null_resource.multi`, - map[string]cty.Value{ + Expr: `null_resource.multi`, + Want: map[string]cty.Value{ "null_resource": cty.ObjectVal(map[string]cty.Value{ "multi": cty.TupleVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ @@ -178,8 +198,8 @@ func TestScopeEvalContext(t *testing.T) { }, { // at this level, all instance references return the entire resource - `null_resource.multi[1]`, - map[string]cty.Value{ + Expr: `null_resource.multi[1]`, + Want: map[string]cty.Value{ "null_resource": cty.ObjectVal(map[string]cty.Value{ "multi": cty.TupleVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ @@ -206,8 +226,8 @@ func TestScopeEvalContext(t *testing.T) { }, { // at this level, all instance references return the entire resource - `null_resource.each["each1"]`, - map[string]cty.Value{ + Expr: `null_resource.each["each1"]`, + Want: map[string]cty.Value{ "null_resource": cty.ObjectVal(map[string]cty.Value{ "each": cty.ObjectVal(map[string]cty.Value{ "each0": cty.ObjectVal(map[string]cty.Value{ @@ -234,8 +254,8 @@ func TestScopeEvalContext(t *testing.T) { }, { // at this level, all instance references return the entire resource - `null_resource.each["each1"].attr`, - map[string]cty.Value{ + Expr: `null_resource.each["each1"].attr`, + Want: map[string]cty.Value{ "null_resource": cty.ObjectVal(map[string]cty.Value{ "each": cty.ObjectVal(map[string]cty.Value{ "each0": cty.ObjectVal(map[string]cty.Value{ @@ -261,8 +281,8 @@ func TestScopeEvalContext(t *testing.T) { }, }, { - `foo(null_resource.multi, null_resource.multi[1])`, - map[string]cty.Value{ + Expr: `foo(null_resource.multi, null_resource.multi[1])`, + Want: map[string]cty.Value{ "null_resource": cty.ObjectVal(map[string]cty.Value{ "multi": cty.TupleVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ @@ -288,8 +308,8 @@ func TestScopeEvalContext(t *testing.T) { }, }, { - `data.null_data_source.foo`, - map[string]cty.Value{ + Expr: `data.null_data_source.foo`, + Want: map[string]cty.Value{ "data": cty.ObjectVal(map[string]cty.Value{ "null_data_source": cty.ObjectVal(map[string]cty.Value{ "foo": cty.ObjectVal(map[string]cty.Value{ @@ -300,8 +320,8 @@ func TestScopeEvalContext(t *testing.T) { }, }, { - `module.foo`, - map[string]cty.Value{ + Expr: `module.foo`, + Want: map[string]cty.Value{ "module": cty.ObjectVal(map[string]cty.Value{ "foo": cty.ObjectVal(map[string]cty.Value{ "output0": cty.StringVal("bar0"), @@ -312,8 +332,8 @@ func TestScopeEvalContext(t *testing.T) { }, // any module reference returns the entire module { - `module.foo.output1`, - map[string]cty.Value{ + Expr: `module.foo.output1`, + Want: map[string]cty.Value{ "module": cty.ObjectVal(map[string]cty.Value{ "foo": cty.ObjectVal(map[string]cty.Value{ "output0": cty.StringVal("bar0"), @@ -323,98 +343,158 @@ func TestScopeEvalContext(t *testing.T) { }, }, { - `path.module`, - map[string]cty.Value{ + Expr: `path.module`, + Want: map[string]cty.Value{ "path": cty.ObjectVal(map[string]cty.Value{ "module": cty.StringVal("foo/bar"), }), }, }, { - `self.baz`, - map[string]cty.Value{ + Expr: `self.baz`, + Want: map[string]cty.Value{ "self": cty.ObjectVal(map[string]cty.Value{ "attr": cty.StringVal("multi1"), }), }, }, { - `terraform.workspace`, - map[string]cty.Value{ + Expr: `terraform.workspace`, + Want: map[string]cty.Value{ "terraform": cty.ObjectVal(map[string]cty.Value{ "workspace": cty.StringVal("default"), }), }, }, { - `var.baz`, - map[string]cty.Value{ + Expr: `var.baz`, + Want: map[string]cty.Value{ "var": cty.ObjectVal(map[string]cty.Value{ "baz": cty.StringVal("boop"), }), }, }, + { + Expr: "run.zero", + Want: map[string]cty.Value{ + "run": cty.ObjectVal(map[string]cty.Value{ + "zero": cty.ObjectVal(map[string]cty.Value{ + "run0output0": cty.StringVal("run0bar0"), + "run0output1": cty.StringVal("run0bar1"), + }), + }), + }, + TestingOnly: true, + }, + { + Expr: "run.zero.run0output0", + Want: map[string]cty.Value{ + "run": cty.ObjectVal(map[string]cty.Value{ + "zero": cty.ObjectVal(map[string]cty.Value{ + "run0output0": cty.StringVal("run0bar0"), + "run0output1": cty.StringVal("run0bar1"), + }), + }), + }, + TestingOnly: true, + }, + { + Expr: "output.rootoutput0", + Want: map[string]cty.Value{ + "output": cty.ObjectVal(map[string]cty.Value{ + "rootoutput0": cty.StringVal("rootbar0"), + }), + }, + TestingOnly: true, + }, + { + Expr: "check.check0", + Want: map[string]cty.Value{ + "check": cty.ObjectVal(map[string]cty.Value{ + "check0": cty.ObjectVal(map[string]cty.Value{ + "status": cty.StringVal("pass"), + }), + }), + }, + TestingOnly: true, + }, } - for _, test := range tests { - t.Run(test.Expr, func(t *testing.T) { - expr, parseDiags := hclsyntax.ParseExpression([]byte(test.Expr), "", hcl.Pos{Line: 1, Column: 1}) - if len(parseDiags) != 0 { - t.Errorf("unexpected diagnostics during parse") - for _, diag := range parseDiags { - t.Errorf("- %s", diag) - } - return - } - - refs, refsDiags := ReferencesInExpr(addrs.ParseRef, expr) - if refsDiags.HasErrors() { - t.Fatal(refsDiags.Err()) + exec := func(t *testing.T, parseRef ParseRef, test struct { + Expr string + Want map[string]cty.Value + TestingOnly bool + }) { + expr, parseDiags := hclsyntax.ParseExpression([]byte(test.Expr), "", hcl.Pos{Line: 1, Column: 1}) + if len(parseDiags) != 0 { + t.Errorf("unexpected diagnostics during parse") + for _, diag := range parseDiags { + t.Errorf("- %s", diag) } - - scope := &Scope{ - Data: data, - ParseRef: addrs.ParseRef, - - // "self" will just be an arbitrary one of the several resource - // instances we have in our test dataset. - SelfAddr: addrs.ResourceInstance{ - Resource: addrs.Resource{ - Mode: addrs.ManagedResourceMode, - Type: "null_resource", - Name: "multi", - }, - Key: addrs.IntKey(1), + return + } + + refs, refsDiags := ReferencesInExpr(parseRef, expr) + if refsDiags.HasErrors() { + t.Fatal(refsDiags.Err()) + } + + scope := &Scope{ + Data: data, + ParseRef: parseRef, + + // "self" will just be an arbitrary one of the several resource + // instances we have in our test dataset. + SelfAddr: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "null_resource", + Name: "multi", }, + Key: addrs.IntKey(1), + }, + } + ctx, ctxDiags := scope.EvalContext(refs) + if ctxDiags.HasErrors() { + t.Fatal(ctxDiags.Err()) + } + + // For easier test assertions we'll just remove any top-level + // empty objects from our variables map. + for k, v := range ctx.Variables { + if v.RawEquals(cty.EmptyObjectVal) { + delete(ctx.Variables, k) } - ctx, ctxDiags := scope.EvalContext(refs) - if ctxDiags.HasErrors() { - t.Fatal(ctxDiags.Err()) - } - - // For easier test assertions we'll just remove any top-level - // empty objects from our variables map. - for k, v := range ctx.Variables { - if v.RawEquals(cty.EmptyObjectVal) { - delete(ctx.Variables, k) - } - } + } + + gotVal := cty.ObjectVal(ctx.Variables) + wantVal := cty.ObjectVal(test.Want) + + if !gotVal.RawEquals(wantVal) { + // We'll JSON-ize our values here just so it's easier to + // read them in the assertion output. + gotJSON := formattedJSONValue(gotVal) + wantJSON := formattedJSONValue(wantVal) + + t.Errorf( + "wrong result\nexpr: %s\ngot: %s\nwant: %s", + test.Expr, gotJSON, wantJSON, + ) + } + } - gotVal := cty.ObjectVal(ctx.Variables) - wantVal := cty.ObjectVal(test.Want) + for _, test := range tests { - if !gotVal.RawEquals(wantVal) { - // We'll JSON-ize our values here just so it's easier to - // read them in the assertion output. - gotJSON := formattedJSONValue(gotVal) - wantJSON := formattedJSONValue(wantVal) + if !test.TestingOnly { + t.Run(test.Expr, func(t *testing.T) { + exec(t, addrs.ParseRef, test) + }) + } - t.Errorf( - "wrong result\nexpr: %s\ngot: %s\nwant: %s", - test.Expr, gotJSON, wantJSON, - ) - } + t.Run(fmt.Sprintf("%s-testing", test.Expr), func(t *testing.T) { + exec(t, addrs.ParseRefFromTestingScope, test) }) + } } diff --git a/internal/terraform/evaluate.go b/internal/terraform/evaluate.go index 55aa8e8d77..0884da2f6b 100644 --- a/internal/terraform/evaluate.go +++ b/internal/terraform/evaluate.go @@ -65,6 +65,13 @@ type Evaluator struct { // ensures they can be safely accessed and modified concurrently. Changes *plans.ChangesSync + // AlternateStates allows callers to reference states from outside this + // evaluator. + // + // The main use case here is for the testing framework to call into other + // run blocks. + AlternateStates map[string]*evaluationStateData + PlanTimestamp time.Time } @@ -1005,6 +1012,33 @@ func (d *evaluationStateData) GetCheckBlock(addr addrs.Check, rng tfdiags.Source return cty.NilVal, diags } +func (d *evaluationStateData) GetRunBlock(run addrs.Run, rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + data, exists := d.Evaluator.AlternateStates[run.Name] + if !exists { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Reference to unavailable run block", + Detail: fmt.Sprintf("The current test file either contains no %s, or hasn't executed it yet.", run), + Subject: rng.ToHCL().Ptr(), + }) + return cty.DynamicVal, diags + } + + outputs := make(map[string]cty.Value) + for _, outputCfg := range data.Evaluator.Config.Module.Outputs { + output, outputDiags := data.GetOutput(outputCfg.Addr(), rng) + diags = diags.Append(outputDiags) + if outputDiags.HasErrors() { + continue + } + outputs[outputCfg.Name] = output + } + + return cty.ObjectVal(outputs), diags +} + // 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 diff --git a/internal/terraform/evaluate_test.go b/internal/terraform/evaluate_test.go index f84c400557..9c9dd2c84e 100644 --- a/internal/terraform/evaluate_test.go +++ b/internal/terraform/evaluate_test.go @@ -634,3 +634,95 @@ func evaluatorForModule(stateSync *states.SyncState, changesSync *plans.ChangesS Changes: changesSync, } } + +func TestGetRunBlocks(t *testing.T) { + evaluator := &Evaluator{ + AlternateStates: map[string]*evaluationStateData{ + "zero": { + Evaluator: &Evaluator{ + State: states.BuildState(func(state *states.SyncState) { + state.SetOutputValue(addrs.AbsOutputValue{ + Module: addrs.RootModuleInstance, + OutputValue: addrs.OutputValue{ + Name: "value", + }, + }, cty.StringVal("Hello, world!"), false) + }).SyncWrapper(), + Config: &configs.Config{ + Module: &configs.Module{ + Outputs: map[string]*configs.Output{ + "value": { + Name: "value", + }, + }, + }, + }, + }, + }, + "one": { + Evaluator: &Evaluator{ + State: states.BuildState(func(state *states.SyncState) { + state.SetOutputValue(addrs.AbsOutputValue{ + Module: addrs.RootModuleInstance, + OutputValue: addrs.OutputValue{ + Name: "value", + }, + }, cty.StringVal("Hello, universe!"), false) + }).SyncWrapper(), + Config: &configs.Config{ + Module: &configs.Module{ + Outputs: map[string]*configs.Output{ + "value": { + Name: "value", + }, + }, + }, + }, + }, + }, + }, + } + + data := &evaluationStateData{ + Evaluator: evaluator, + ModulePath: nil, + InstanceKeyData: EvalDataForNoInstanceKey, + } + + scope := evaluator.Scope(data, nil, nil) + got, diags := scope.Data.GetRunBlock(addrs.Run{Name: "zero"}, tfdiags.SourceRange{}) + want := cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal("Hello, world!"), + }) + + if diags.HasErrors() { + t.Fatalf("unexpected diagnostics: %s", diags.Err()) + } + + if !got.RawEquals(want) { + t.Errorf("\ngot: %#v\nwant: %#v", got, want) + } + + got, diags = scope.Data.GetRunBlock(addrs.Run{Name: "one"}, tfdiags.SourceRange{}) + want = cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal("Hello, universe!"), + }) + + if diags.HasErrors() { + t.Fatalf("unexpected diagnostics: %s", diags.Err()) + } + + if !got.RawEquals(want) { + t.Errorf("\ngot: %#v\nwant: %#v", got, want) + } + + _, diags = scope.Data.GetRunBlock(addrs.Run{Name: "two"}, tfdiags.SourceRange{}) + + if !diags.HasErrors() { + t.Fatalf("expected some diags but got none") + } + + if diags[0].Description().Summary != "Reference to unavailable run block" { + t.Errorf("retrieved unexpected diagnostic: %s", diags[0].Description().Summary) + } +} diff --git a/internal/terraform/evaluate_valid.go b/internal/terraform/evaluate_valid.go index 1b0dc22a4c..8e48f60357 100644 --- a/internal/terraform/evaluate_valid.go +++ b/internal/terraform/evaluate_valid.go @@ -111,6 +111,10 @@ func (d *evaluationStateData) staticValidateReference(ref *addrs.Reference, self } return d.staticValidateModuleCallReference(modCfg, addr.Call.Call, remain, ref.SourceRange) + // We can also validate any run blocks that are referenced actually exist. + case addrs.Run: + return d.staticValidateRunBlockReference(addr, ref.Remaining, ref.SourceRange) + default: // Anything else we'll just permit through without any static validation // and let it be caught during dynamic evaluation, in evaluate.go . @@ -315,6 +319,33 @@ func (d *evaluationStateData) staticValidateModuleCallReference(modCfg *configs. return diags } +func (d *evaluationStateData) staticValidateRunBlockReference(addr addrs.Run, remain hcl.Traversal, rng tfdiags.SourceRange) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + _, exists := d.Evaluator.AlternateStates[addr.Name] + if !exists { + var suggestions []string + for name := range d.Evaluator.AlternateStates { + suggestions = append(suggestions, name) + } + sort.Strings(suggestions) + suggestion := didyoumean.NameSuggestion(addr.Name, suggestions) + if suggestion != "" { + suggestion = fmt.Sprintf(" Did you mean %q?", suggestion) + } + + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Reference to unavailable run block`, + Detail: fmt.Sprintf(`The run block named %q is not available, either it does not exist or has not yet been executed.%s`, addr.Name, suggestion), + Subject: rng.ToHCL().Ptr(), + }) + return diags + } + + return diags +} + // moduleConfigDisplayAddr returns a string describing the given module // address that is appropriate for returning to users in situations where the // root module is possible. Specifically, it returns "the root module" if the diff --git a/internal/terraform/evaluate_valid_test.go b/internal/terraform/evaluate_valid_test.go index e21d92a2be..800a552cbb 100644 --- a/internal/terraform/evaluate_valid_test.go +++ b/internal/terraform/evaluate_valid_test.go @@ -18,9 +18,10 @@ import ( func TestStaticValidateReferences(t *testing.T) { tests := []struct { - Ref string - Src addrs.Referenceable - WantErr string + Ref string + Src addrs.Referenceable + ParseRef lang.ParseRef + WantErr string }{ { Ref: "aws_instance.no_count", @@ -79,6 +80,15 @@ For example, to correlate with indices of a referring resource, use: WantErr: ``, Src: addrs.Check{Name: "foo"}, }, + { + Ref: "run.zero", + WantErr: `Reference to undeclared resource: A managed resource "run" "zero" has not been declared in the root module.`, + }, + { + Ref: "run.zero", + ParseRef: addrs.ParseRefFromTestingScope, + WantErr: `Reference to unavailable run block: The run block named "zero" is not available, either it does not exist or has not yet been executed.`, + }, } cfg := testModule(t, "static-validate-refs") @@ -122,7 +132,12 @@ For example, to correlate with indices of a referring resource, use: t.Fatal(hclDiags.Error()) } - refs, diags := lang.References(addrs.ParseRef, []hcl.Traversal{traversal}) + parseRef := addrs.ParseRef + if test.ParseRef != nil { + parseRef = test.ParseRef + } + + refs, diags := lang.References(parseRef, []hcl.Traversal{traversal}) if diags.HasErrors() { t.Fatal(diags.Err()) } diff --git a/internal/terraform/test_context.go b/internal/terraform/test_context.go index 8fe1c19eb9..649b5069a9 100644 --- a/internal/terraform/test_context.go +++ b/internal/terraform/test_context.go @@ -25,6 +25,7 @@ import ( type TestContext struct { *Context + Run *moduletest.Run Config *configs.Config State *states.State Plan *plans.Plan @@ -33,9 +34,10 @@ type TestContext struct { // 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 { +func (c *Context) TestContext(run *moduletest.Run, config *configs.Config, state *states.State, plan *plans.Plan, variables InputValues) *TestContext { return &TestContext{ Context: c, + Run: run, Config: config, State: state, Plan: plan, @@ -43,29 +45,27 @@ func (c *Context) TestContext(config *configs.Config, state *states.State, plan } } -// Evaluate processes the assertions inside the provided configs.TestRun against -// the embedded state. -func (ctx *TestContext) Evaluate(run *moduletest.Run) { - defer ctx.acquireRun("evaluate")() - switch run.Config.Command { +func (ctx *TestContext) evaluationStateData(alternateStates map[string]*evaluationStateData) *evaluationStateData { + + var operation walkOperation + switch ctx.Run.Config.Command { case configs.PlanTestCommand: - ctx.evaluate(ctx.State.SyncWrapper(), ctx.Plan.Changes.SyncWrapper(), run, walkPlan) + operation = walkPlan case configs.ApplyTestCommand: - ctx.evaluate(ctx.State.SyncWrapper(), ctx.Plan.Changes.SyncWrapper(), run, walkApply) + operation = walkApply default: - panic(fmt.Errorf("unrecognized TestCommand: %q", run.Config.Command)) + panic(fmt.Errorf("unrecognized TestCommand: %q", ctx.Run.Config.Command)) } -} -func (ctx *TestContext) evaluate(state *states.SyncState, changes *plans.ChangesSync, run *moduletest.Run, operation walkOperation) { - data := &evaluationStateData{ + return &evaluationStateData{ Evaluator: &Evaluator{ - Operation: operation, - Meta: ctx.meta, - Config: ctx.Config, - Plugins: ctx.plugins, - State: state, - Changes: changes, + Operation: operation, + Meta: ctx.meta, + Config: ctx.Config, + Plugins: ctx.plugins, + State: ctx.State.SyncWrapper(), + Changes: ctx.Plan.Changes.SyncWrapper(), + AlternateStates: alternateStates, VariableValues: func() map[string]map[string]cty.Value { variables := map[string]map[string]cty.Value{ addrs.RootModule.String(): make(map[string]cty.Value), @@ -82,16 +82,28 @@ func (ctx *TestContext) evaluate(state *states.SyncState, changes *plans.Changes InstanceKeyData: EvalDataForNoInstanceKey, Operation: operation, } +} + +// Evaluate processes the assertions inside the provided configs.TestRun against +// the embedded state. +func (ctx *TestContext) Evaluate(priorContexts map[string]*TestContext) { + + alternateStates := make(map[string]*evaluationStateData) + for name, priorContext := range priorContexts { + alternateStates[name] = priorContext.evaluationStateData(nil) + } + data := ctx.evaluationStateData(alternateStates) scope := &lang.Scope{ Data: data, BaseDir: ".", - PureOnly: operation != walkApply, + PureOnly: data.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 := ctx.Run run.Status = run.Status.Merge(moduletest.Pass) // Now validate all the assertions within this run block. diff --git a/internal/terraform/test_context_test.go b/internal/terraform/test_context_test.go index 20ad2dd4f1..c755ec3c41 100644 --- a/internal/terraform/test_context_test.go +++ b/internal/terraform/test_context_test.go @@ -9,6 +9,7 @@ import ( ctymsgpack "github.com/zclconf/go-cty/cty/msgpack" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/moduletest" "github.com/hashicorp/terraform/internal/plans" @@ -19,11 +20,12 @@ import ( func TestTestContext_Evaluate(t *testing.T) { tcs := map[string]struct { - configs map[string]string - state *states.State - plan *plans.Plan - variables InputValues - provider *MockProvider + configs map[string]string + state *states.State + plan *plans.Plan + variables InputValues + provider *MockProvider + priorStates map[string]func(config *configs.Config) *TestContext expectedDiags []tfdiags.Description expectedStatus moduletest.Status @@ -532,6 +534,94 @@ run "test_case" { }, }, }, + "with_prior_state": { + configs: map[string]string{ + "main.tf": ` +resource "test_resource" "a" { + value = "Hello, world!" +} +`, + "main.tftest.hcl": ` +run "setup" {} + +run "test_case" { + assert { + condition = test_resource.a.value == run.setup.value + error_message = "invalid value" + } +} +`, + }, + plan: &plans.Plan{ + Changes: plans.NewChanges(), + }, + 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"), + }) + }), + priorStates: map[string]func(config *configs.Config) *TestContext{ + "setup": func(config *configs.Config) *TestContext { + return &TestContext{ + Context: &Context{}, + Run: &moduletest.Run{ + Config: config.Module.Tests["main.tftest.hcl"].Runs[0], + Name: "setup", + }, + Config: &configs.Config{ + Module: &configs.Module{ + Outputs: map[string]*configs.Output{ + "value": { + Name: "value", + }, + }, + }, + }, + Plan: &plans.Plan{ + Changes: plans.NewChanges(), + }, + State: states.BuildState(func(state *states.SyncState) { + state.SetOutputValue(addrs.AbsOutputValue{ + Module: addrs.RootModuleInstance, + OutputValue: addrs.OutputValue{ + Name: "value", + }, + }, cty.StringVal("Hello, world!"), false) + }), + } + }, + }, + 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, + }, } for name, tc := range tcs { t.Run(name, func(t *testing.T) { @@ -542,13 +632,19 @@ run "test_case" { }, }) + priorStates := make(map[string]*TestContext) + for run, ps := range tc.priorStates { + priorStates[run] = ps(config) + } + + file := config.Module.Tests["main.tftest.hcl"] run := moduletest.Run{ - Config: config.Module.Tests["main.tftest.hcl"].Runs[0], - Name: "test_case", + Config: file.Runs[len(file.Runs)-1], // We always simulate the last run block. + Name: "test_case", // and it should be named test_case } - tctx := ctx.TestContext(config, tc.state, tc.plan, tc.variables) - tctx.Evaluate(&run) + tctx := ctx.TestContext(&run, config, tc.state, tc.plan, tc.variables) + tctx.Evaluate(priorStates) if expected, actual := tc.expectedStatus, run.Status; expected != actual { t.Errorf("expected status \"%s\" but got \"%s\"", expected, actual)