diff --git a/internal/backend/local/test.go b/internal/backend/local/test.go index 4aa06c43dc..11b22434ea 100644 --- a/internal/backend/local/test.go +++ b/internal/backend/local/test.go @@ -62,6 +62,13 @@ type TestSuiteRunner struct { // Verbose tells the runner to print out plan files during each test run. Verbose bool + + // configProviders is a cache of config keys mapped to all the providers + // referenced by the given config. + // + // The config keys are globally unique across an entire test suite, so we + // store this at the suite runner level to get maximum efficiency. + configProviders map[string]map[string]bool } func (runner *TestSuiteRunner) Stop() { @@ -75,6 +82,9 @@ func (runner *TestSuiteRunner) Cancel() { func (runner *TestSuiteRunner) Test() (moduletest.Status, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics + // First thing, initialise the config providers map. + runner.configProviders = make(map[string]map[string]bool) + suite, suiteDiags := runner.collectTests() diags = diags.Append(suiteDiags) if suiteDiags.HasErrors() { @@ -349,7 +359,13 @@ func (runner *TestFileRunner) run(run *moduletest.Run, file *moduletest.File, st return state, false } - resetConfig, configDiags := configtest.TransformConfigForTest(config, run, file, runner.globalVariables) + key := MainStateIdentifier + if run.Config.ConfigUnderTest != nil { + key = run.Config.Module.Source.String() + } + runner.gatherProviders(key, config) + + resetConfig, configDiags := configtest.TransformConfigForTest(config, run, file, runner.globalVariables, runner.PriorStates, runner.Suite.configProviders[key]) defer resetConfig() run.Diagnostics = run.Diagnostics.Append(configDiags) @@ -372,7 +388,7 @@ func (runner *TestFileRunner) run(run *moduletest.Run, file *moduletest.File, st return state, false } - variables, variableDiags := runner.GetVariables(config, run, file, references) + variables, variableDiags := runner.GetVariables(config, run, references) run.Diagnostics = run.Diagnostics.Append(variableDiags) if variableDiags.HasErrors() { run.Status = moduletest.Error @@ -563,7 +579,7 @@ func (runner *TestFileRunner) destroy(config *configs.Config, state *states.Stat var diags tfdiags.Diagnostics - variables, variableDiags := runner.GetVariables(config, run, file, nil) + variables, variableDiags := runner.GetVariables(config, run, nil) diags = diags.Append(variableDiags) if diags.HasErrors() { @@ -845,7 +861,7 @@ func (runner *TestFileRunner) cleanup(file *moduletest.File) { 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))) } } else { - reset, configDiags := configtest.TransformConfigForTest(runner.Suite.Config, main.Run, file, runner.globalVariables) + reset, configDiags := configtest.TransformConfigForTest(runner.Suite.Config, main.Run, file, runner.globalVariables, runner.PriorStates, runner.Suite.configProviders[MainStateIdentifier]) diags = diags.Append(configDiags) if !configDiags.HasErrors() { @@ -920,7 +936,7 @@ func (runner *TestFileRunner) cleanup(file *moduletest.File) { var diags tfdiags.Diagnostics - reset, configDiags := configtest.TransformConfigForTest(state.Run.Config.ConfigUnderTest, state.Run, file, runner.globalVariables) + reset, configDiags := configtest.TransformConfigForTest(state.Run.Config.ConfigUnderTest, state.Run, file, runner.globalVariables, runner.PriorStates, runner.Suite.configProviders[state.Run.Config.Module.Source.String()]) diags = diags.Append(configDiags) updated := state.State @@ -951,7 +967,7 @@ func (runner *TestFileRunner) cleanup(file *moduletest.File) { // 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 (runner *TestFileRunner) GetVariables(config *configs.Config, run *moduletest.Run, file *moduletest.File, references []*addrs.Reference) (terraform.InputValues, tfdiags.Diagnostics) { +func (runner *TestFileRunner) GetVariables(config *configs.Config, run *moduletest.Run, references []*addrs.Reference) (terraform.InputValues, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics // relevantVariables contains the variables that are of interest to this @@ -1039,7 +1055,7 @@ func (runner *TestFileRunner) GetVariables(config *configs.Config, run *modulete variables[name] = value.Value } - ctx, ctxDiags := hcltest.EvalContext(exprs, variables, runner.PriorStates) + ctx, ctxDiags := hcltest.EvalContext(hcltest.TargetRunBlock, exprs, variables, runner.PriorStates) diags = diags.Append(ctxDiags) var failedContext bool @@ -1188,3 +1204,51 @@ func (runner *TestFileRunner) initVariables(file *moduletest.File) { runner.globalVariables[name] = unparsedTestVariableValue{expr} } } + +func (runner *TestFileRunner) gatherProviders(key string, config *configs.Config) { + if _, exists := runner.Suite.configProviders[key]; exists { + // Then we've processed this key before, so skip it. + return + } + + providers := make(map[string]bool) + + // First, let's look at the required providers first. + for _, provider := range config.Module.ProviderRequirements.RequiredProviders { + providers[provider.Name] = true + for _, alias := range provider.Aliases { + providers[alias.StringCompact()] = true + } + } + + // Second, we look at the defined provider configs. + for _, provider := range config.Module.ProviderConfigs { + providers[provider.Addr().StringCompact()] = true + } + + // Third, we look at the resources and data sources. + for _, resource := range config.Module.ManagedResources { + if resource.ProviderConfigRef != nil { + providers[resource.ProviderConfigRef.String()] = true + continue + } + providers[resource.Provider.Type] = true + } + for _, datasource := range config.Module.DataResources { + if datasource.ProviderConfigRef != nil { + providers[datasource.ProviderConfigRef.String()] = true + continue + } + providers[datasource.Provider.Type] = true + } + + // Finally, we look at any module calls to see if any providers are used + // in there. + for _, module := range config.Module.ModuleCalls { + for _, provider := range module.Providers { + providers[provider.InParent.String()] = true + } + } + + runner.Suite.configProviders[key] = providers +} diff --git a/internal/command/test_test.go b/internal/command/test_test.go index 82a331ab2a..6435c928fe 100644 --- a/internal/command/test_test.go +++ b/internal/command/test_test.go @@ -1146,8 +1146,12 @@ is declared in run block "test". run "finalise"... skip main.tftest.hcl... tearing down main.tftest.hcl... fail +providers.tftest.hcl... in progress + run "test"... fail +providers.tftest.hcl... tearing down +providers.tftest.hcl... fail -Failure! 1 passed, 1 failed, 1 skipped. +Failure! 1 passed, 2 failed, 1 skipped. ` actualOut := output.Stdout() if diff := cmp.Diff(actualOut, expectedOut); len(diff) > 0 { @@ -1169,9 +1173,9 @@ Error: Reference to unavailable run block on main.tftest.hcl line 16, in run "test": 16: input_two = run.finalise.response -The run block "finalise" is not available to the current run block. You can -only reference run blocks that are in the same test file and will execute -before the current run block. +The run block "finalise" has not executed yet. You can only reference run +blocks that are in the same test file and will execute before the current run +block. Error: Reference to unknown run block @@ -1181,6 +1185,15 @@ Error: Reference to unknown run block The run block "madeup" does not exist within this test file. You can only reference run blocks that are in the same test file and will execute before the current run block. + +Error: Reference to unavailable variable + + on providers.tftest.hcl line 3, in provider "test": + 3: resource_prefix = var.default + +The input variable "default" is not available to the current run block. You +can only reference variables defined at the file or global levels when +populating the variables block within a run block. ` actualErr := output.Stderr() if diff := cmp.Diff(actualErr, expectedErr); len(diff) > 0 { @@ -1690,3 +1703,156 @@ func TestTest_LongRunningTestJSON(t *testing.T) { t.Errorf("unexpected output\n\nexpected:\n%s\nactual:\n%s\ndiff:\n%s", strings.Join(expected, "\n"), strings.Join(messages, "\n"), diff) } } + +func TestTest_RunBlocksInProviders(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(path.Join("test", "provider_runs")), td) + defer testChdir(t, td)() + + provider := testing_command.NewProvider(nil) + + 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) + } + + test := &TestCommand{ + Meta: meta, + } + + code := test.Run([]string{"-no-color"}) + output := done(t) + + if code != 0 { + t.Errorf("expected status code 0 but got %d", code) + } + + expected := `main.tftest.hcl... in progress + run "setup"... pass + run "main"... pass +main.tftest.hcl... tearing down +main.tftest.hcl... pass + +Success! 2 passed, 0 failed. +` + actual := output.All() + if diff := cmp.Diff(actual, expected); len(diff) > 0 { + t.Errorf("output didn't match expected:\nexpected:\n%s\nactual:\n%s\ndiff:\n%s", expected, actual, diff) + } + + if provider.ResourceCount() > 0 { + t.Errorf("should have deleted all resources on completion but left %v", provider.ResourceString()) + } +} + +func TestTest_RunBlocksInProviders_BadReferences(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(path.Join("test", "provider_runs_invalid")), td) + defer testChdir(t, td)() + + provider := testing_command.NewProvider(nil) + + 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) + } + + test := &TestCommand{ + Meta: meta, + } + + code := test.Run([]string{"-no-color"}) + output := done(t) + + if code != 1 { + t.Errorf("expected status code 1 but got %d", code) + } + + expectedOut := `missing_run_block.tftest.hcl... in progress + run "main"... fail +missing_run_block.tftest.hcl... tearing down +missing_run_block.tftest.hcl... fail +unavailable_run_block.tftest.hcl... in progress + run "main"... fail +unavailable_run_block.tftest.hcl... tearing down +unavailable_run_block.tftest.hcl... fail +unused_provider.tftest.hcl... in progress + run "main"... pass +unused_provider.tftest.hcl... tearing down +unused_provider.tftest.hcl... pass + +Failure! 1 passed, 2 failed. +` + actualOut := output.Stdout() + if diff := cmp.Diff(actualOut, expectedOut); len(diff) > 0 { + t.Errorf("output didn't match expected:\nexpected:\n%s\nactual:\n%s\ndiff:\n%s", expectedOut, actualOut, diff) + } + + expectedErr := ` +Error: Reference to unknown run block + + on missing_run_block.tftest.hcl line 2, in provider "test": + 2: resource_prefix = run.missing.resource_directory + +The run block "missing" does not exist within this test file. You can only +reference run blocks that are in the same test file and will execute before +the provider is required. + +Error: Reference to unavailable run block + + on unavailable_run_block.tftest.hcl line 2, in provider "test": + 2: resource_prefix = run.main.resource_directory + +The run block "main" has not executed yet. You can only reference run blocks +that are in the same test file and will execute before the provider is +required. +` + actualErr := output.Stderr() + if diff := cmp.Diff(actualErr, expectedErr); len(diff) > 0 { + t.Errorf("output didn't match expected:\nexpected:\n%s\nactual:\n%s\ndiff:\n%s", expectedErr, actualErr, diff) + } + + if provider.ResourceCount() > 0 { + t.Errorf("should have deleted all resources on completion but left %v", provider.ResourceString()) + } +} diff --git a/internal/command/testdata/test/bad-references/providers.tftest.hcl b/internal/command/testdata/test/bad-references/providers.tftest.hcl new file mode 100644 index 0000000000..ee784c12a4 --- /dev/null +++ b/internal/command/testdata/test/bad-references/providers.tftest.hcl @@ -0,0 +1,6 @@ + +provider "test" { + resource_prefix = var.default +} + +run "test" {} diff --git a/internal/command/testdata/test/provider_runs/main.tf b/internal/command/testdata/test/provider_runs/main.tf new file mode 100644 index 0000000000..50a07d62f0 --- /dev/null +++ b/internal/command/testdata/test/provider_runs/main.tf @@ -0,0 +1 @@ +resource "test_resource" "foo" {} diff --git a/internal/command/testdata/test/provider_runs/main.tftest.hcl b/internal/command/testdata/test/provider_runs/main.tftest.hcl new file mode 100644 index 0000000000..2dd8c53663 --- /dev/null +++ b/internal/command/testdata/test/provider_runs/main.tftest.hcl @@ -0,0 +1,24 @@ +variables { + resource_directory = "resources" +} + +provider "test" { + alias = "setup" + resource_prefix = var.resource_directory +} + +run "setup" { + module { + source = "./setup" + } + + providers = { + test = test.setup + } +} + +provider "test" { + resource_prefix = run.setup.resource_directory +} + +run "main" {} diff --git a/internal/command/testdata/test/provider_runs/setup/main.tf b/internal/command/testdata/test/provider_runs/setup/main.tf new file mode 100644 index 0000000000..25d3e57e96 --- /dev/null +++ b/internal/command/testdata/test/provider_runs/setup/main.tf @@ -0,0 +1,11 @@ +variable "resource_directory" { + type = string +} + +resource "test_resource" "foo" { + value = var.resource_directory +} + +output "resource_directory" { + value = test_resource.foo.value +} diff --git a/internal/command/testdata/test/provider_runs_invalid/main.tf b/internal/command/testdata/test/provider_runs_invalid/main.tf new file mode 100644 index 0000000000..25d3e57e96 --- /dev/null +++ b/internal/command/testdata/test/provider_runs_invalid/main.tf @@ -0,0 +1,11 @@ +variable "resource_directory" { + type = string +} + +resource "test_resource" "foo" { + value = var.resource_directory +} + +output "resource_directory" { + value = test_resource.foo.value +} diff --git a/internal/command/testdata/test/provider_runs_invalid/missing_run_block.tftest.hcl b/internal/command/testdata/test/provider_runs_invalid/missing_run_block.tftest.hcl new file mode 100644 index 0000000000..bd88563d17 --- /dev/null +++ b/internal/command/testdata/test/provider_runs_invalid/missing_run_block.tftest.hcl @@ -0,0 +1,9 @@ +provider "test" { + resource_prefix = run.missing.resource_directory +} + +run "main" { + variables { + resource_directory = "resource" + } +} diff --git a/internal/command/testdata/test/provider_runs_invalid/unavailable_run_block.tftest.hcl b/internal/command/testdata/test/provider_runs_invalid/unavailable_run_block.tftest.hcl new file mode 100644 index 0000000000..ec39cd278a --- /dev/null +++ b/internal/command/testdata/test/provider_runs_invalid/unavailable_run_block.tftest.hcl @@ -0,0 +1,9 @@ +provider "test" { + resource_prefix = run.main.resource_directory +} + +run "main" { + variables { + resource_directory = "resource" + } +} diff --git a/internal/command/testdata/test/provider_runs_invalid/unused_provider.tftest.hcl b/internal/command/testdata/test/provider_runs_invalid/unused_provider.tftest.hcl new file mode 100644 index 0000000000..c0a4dec3bf --- /dev/null +++ b/internal/command/testdata/test/provider_runs_invalid/unused_provider.tftest.hcl @@ -0,0 +1,17 @@ +provider "test" { + resource_prefix = run.main.resource_directory +} + +provider "test" { + alias = "usable" +} + +run "main" { + providers = { + test = test.usable + } + + variables { + resource_directory = "resource" + } +} diff --git a/internal/moduletest/config/config.go b/internal/moduletest/config/config.go index 54cf110308..de793daf50 100644 --- a/internal/moduletest/config/config.go +++ b/internal/moduletest/config/config.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/moduletest" hcltest "github.com/hashicorp/terraform/internal/moduletest/hcl" + "github.com/hashicorp/terraform/internal/terraform" ) // TransformConfigForTest transforms the provided configuration ready for the @@ -25,7 +26,7 @@ import ( // We also return a reset function that should be called to return the // configuration to it's original state before the next run block or test file // needs to use it. -func TransformConfigForTest(config *configs.Config, run *moduletest.Run, file *moduletest.File, availableVariables map[string]backend.UnparsedVariableValue) (func(), hcl.Diagnostics) { +func TransformConfigForTest(config *configs.Config, run *moduletest.Run, file *moduletest.File, availableVariables map[string]backend.UnparsedVariableValue, availableRunBlocks map[string]*terraform.TestContext, requiredProviders map[string]bool) (func(), hcl.Diagnostics) { var diags hcl.Diagnostics // Currently, we only need to override the provider settings. @@ -63,7 +64,7 @@ func TransformConfigForTest(config *configs.Config, run *moduletest.Run, file *m next[key] = value } - if run != nil && len(run.Config.Providers) > 0 { + if len(run.Config.Providers) > 0 { // Then we'll only copy over and overwrite the specific providers asked // for by this run block. @@ -93,6 +94,7 @@ func TransformConfigForTest(config *configs.Config, run *moduletest.Run, file *m Original: testProvider.Config, ConfigVariables: config.Module.Variables, AvailableVariables: availableVariables, + AvailableRunBlocks: availableRunBlocks, }, DeclRange: testProvider.DeclRange, } @@ -102,6 +104,13 @@ func TransformConfigForTest(config *configs.Config, run *moduletest.Run, file *m // Otherwise, let's copy over and overwrite all providers specified by // the test file itself. for key, provider := range file.Config.Providers { + + if !requiredProviders[key] { + // Then we don't actually need this provider for this + // configuration, so skip it. + continue + } + next[key] = &configs.Provider{ Name: provider.Name, NameRange: provider.NameRange, @@ -112,6 +121,7 @@ func TransformConfigForTest(config *configs.Config, run *moduletest.Run, file *m Original: provider.Config, ConfigVariables: config.Module.Variables, AvailableVariables: availableVariables, + AvailableRunBlocks: availableRunBlocks, }, DeclRange: provider.DeclRange, } diff --git a/internal/moduletest/config/config_test.go b/internal/moduletest/config/config_test.go index 3dcca7b74b..b572bac508 100644 --- a/internal/moduletest/config/config_test.go +++ b/internal/moduletest/config/config_test.go @@ -208,6 +208,16 @@ func TestTransformForTest(t *testing.T) { "foo.secondary": "source = \"testfile\"", }, }, + "ignores unexpected providers in test file": { + configProviders: make(map[string]string), + fileProviders: map[string]string{ + "foo": "source = \"testfile\"", + "bar": "source = \"testfile\"", + }, + expectedProviders: map[string]string{ + "foo": "source = \"testfile\"", + }, + }, } for name, tc := range tcs { t.Run(name, func(t *testing.T) { @@ -229,7 +239,12 @@ func TestTransformForTest(t *testing.T) { }, } - reset, diags := TransformConfigForTest(config, run, file, nil) + availableProviders := make(map[string]bool, len(tc.expectedProviders)) + for provider := range tc.expectedProviders { + availableProviders[provider] = true + } + + reset, diags := TransformConfigForTest(config, run, file, nil, nil, availableProviders) var actualErrs []string for _, err := range diags.Errs() { diff --git a/internal/moduletest/hcl/context.go b/internal/moduletest/hcl/context.go index d0f74fc10d..058a51939a 100644 --- a/internal/moduletest/hcl/context.go +++ b/internal/moduletest/hcl/context.go @@ -17,6 +17,13 @@ import ( "github.com/hashicorp/terraform/internal/tfdiags" ) +type EvalContextTarget string + +const ( + TargetRunBlock EvalContextTarget = "run" + TargetProvider EvalContextTarget = "provider" +) + // EvalContext builds hcl.EvalContext objects for use directly within the // testing framework. // @@ -39,7 +46,7 @@ import ( // expressions to be evaluated will pass evaluation. Anything present in the // expressions argument will be validated to make sure the only reference the // availableVariables and availableRunBlocks. -func EvalContext(expressions []hcl.Expression, availableVariables map[string]cty.Value, availableRunBlocks map[string]*terraform.TestContext) (*hcl.EvalContext, tfdiags.Diagnostics) { +func EvalContext(target EvalContextTarget, expressions []hcl.Expression, availableVariables map[string]cty.Value, availableRunBlocks map[string]*terraform.TestContext) (*hcl.EvalContext, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics runs := make(map[string]cty.Value) @@ -96,31 +103,22 @@ func EvalContext(expressions []hcl.Expression, availableVariables map[string]cty for _, ref := range refs { if addr, ok := ref.Subject.(addrs.Run); ok { + ctx, exists := availableRunBlocks[addr.Name] - if availableRunBlocks == nil { - // Then run blocks are never available from this context. - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid reference", - Detail: "You cannot reference run blocks from within provider configurations. You can only reference run blocks from other run blocks that execute after them.", - Subject: ref.SourceRange.ToHCL().Ptr(), - }) - - continue + var diagPrefix string + switch target { + case TargetRunBlock: + diagPrefix = "You can only reference run blocks that are in the same test file and will execute before the current run block." + case TargetProvider: + diagPrefix = "You can only reference run blocks that are in the same test file and will execute before the provider is required." } - // For the error messages here, we know the reference is coming - // from a run block as that is the only place that reference - // other run blocks. - - ctx, exists := availableRunBlocks[addr.Name] - if !exists { // Then this is a made up run block. diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Reference to unknown run block", - Detail: fmt.Sprintf("The run block %q does not exist within this test file. You can only reference run blocks that are in the same test file and will execute before the current run block.", addr.Name), + Detail: fmt.Sprintf("The run block %q does not exist within this test file. %s", addr.Name, diagPrefix), Subject: ref.SourceRange.ToHCL().Ptr(), }) @@ -132,7 +130,7 @@ func EvalContext(expressions []hcl.Expression, availableVariables map[string]cty diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Reference to unavailable run block", - Detail: fmt.Sprintf("The run block %q is not available to the current run block. You can only reference run blocks that are in the same test file and will execute before the current run block.", addr.Name), + Detail: fmt.Sprintf("The run block %q has not executed yet. %s", addr.Name, diagPrefix), Subject: ref.SourceRange.ToHCL().Ptr(), }) diff --git a/internal/moduletest/hcl/provider.go b/internal/moduletest/hcl/provider.go index 919de4f203..272e28f19b 100644 --- a/internal/moduletest/hcl/provider.go +++ b/internal/moduletest/hcl/provider.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/lang" + "github.com/hashicorp/terraform/internal/terraform" ) var _ hcl.Body = (*ProviderConfig)(nil) @@ -28,6 +29,7 @@ type ProviderConfig struct { ConfigVariables map[string]*configs.Variable AvailableVariables map[string]backend.UnparsedVariableValue + AvailableRunBlocks map[string]*terraform.TestContext } func (p *ProviderConfig) Content(schema *hcl.BodySchema) (*hcl.BodyContent, hcl.Diagnostics) { @@ -51,7 +53,7 @@ func (p *ProviderConfig) PartialContent(schema *hcl.BodySchema) (*hcl.BodyConten Attributes: attrs, Blocks: p.transformBlocks(content.Blocks), MissingItemRange: content.MissingItemRange, - }, &ProviderConfig{rest, p.ConfigVariables, p.AvailableVariables}, diags + }, &ProviderConfig{rest, p.ConfigVariables, p.AvailableVariables, p.AvailableRunBlocks}, diags } func (p *ProviderConfig) JustAttributes() (hcl.Attributes, hcl.Diagnostics) { @@ -67,18 +69,29 @@ func (p *ProviderConfig) MissingItemRange() hcl.Range { func (p *ProviderConfig) transformAttributes(originals hcl.Attributes) (hcl.Attributes, hcl.Diagnostics) { var diags hcl.Diagnostics - relevantVariables := make(map[string]cty.Value) + availableVariables := make(map[string]cty.Value) var exprs []hcl.Expression for _, original := range originals { exprs = append(exprs, original.Expr) - // We revalidate this later, so we actually only care about the - // references we can extract. + // We also need to parse the variables we're going to use, so we extract + // the references from this expression now and see if they reference any + // input variables. If we find an input variable, we'll copy it into + // our availableVariables local. refs, _ := lang.ReferencesInExpr(addrs.ParseRefFromTestingScope, original.Expr) for _, ref := range refs { if addr, ok := ref.Subject.(addrs.InputVariable); ok { + if _, exists := availableVariables[addr.Name]; exists { + // Then we've processed this variable before. This just + // means it's referenced twice in this provider config - + // which is fine, we just don't need to do it again. + continue + } + if variable, exists := p.AvailableVariables[addr.Name]; exists { + // Then we have a value for this variable! So we think we'll + // be able to process it - let's parse it now. parsingMode := configs.VariableParseHCL if config, exists := p.ConfigVariables[addr.Name]; exists { @@ -88,17 +101,17 @@ func (p *ProviderConfig) transformAttributes(originals hcl.Attributes) (hcl.Attr value, valueDiags := variable.ParseVariableValue(parsingMode) diags = append(diags, valueDiags.ToHCL()...) if value != nil { - relevantVariables[addr.Name] = value.Value + availableVariables[addr.Name] = value.Value } } } } } - ctx, ctxDiags := EvalContext(exprs, relevantVariables, nil) + ctx, ctxDiags := EvalContext(TargetProvider, exprs, availableVariables, p.AvailableRunBlocks) diags = append(diags, ctxDiags.ToHCL()...) if ctxDiags.HasErrors() { - return originals, diags + return nil, diags } attrs := make(hcl.Attributes, len(originals)) @@ -106,7 +119,7 @@ func (p *ProviderConfig) transformAttributes(originals hcl.Attributes) (hcl.Attr value, valueDiags := attr.Expr.Value(ctx) diags = append(diags, valueDiags...) if valueDiags.HasErrors() { - attrs[name] = attr + continue } else { attrs[name] = &hcl.Attribute{ Name: name, @@ -125,7 +138,7 @@ func (p *ProviderConfig) transformBlocks(originals hcl.Blocks) hcl.Blocks { blocks[name] = &hcl.Block{ Type: block.Type, Labels: block.Labels, - Body: &ProviderConfig{block.Body, p.ConfigVariables, p.AvailableVariables}, + Body: &ProviderConfig{block.Body, p.ConfigVariables, p.AvailableVariables, p.AvailableRunBlocks}, DefRange: block.DefRange, TypeRange: block.TypeRange, LabelRanges: block.LabelRanges, diff --git a/internal/moduletest/hcl/provider_test.go b/internal/moduletest/hcl/provider_test.go new file mode 100644 index 0000000000..f2bf3e591c --- /dev/null +++ b/internal/moduletest/hcl/provider_test.go @@ -0,0 +1,272 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package hcl + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/terraform" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func TestProviderConfig(t *testing.T) { + + tcs := map[string]struct { + content string + schema *hcl.BodySchema + variables map[string]cty.Value + runBlockOutputs map[string]map[string]cty.Value + validate func(t *testing.T, content *hcl.BodyContent) + expectedErrors []string + }{ + "simple_no_vars": { + content: "attribute = \"string\"", + schema: &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "attribute", + }, + }, + }, + validate: func(t *testing.T, content *hcl.BodyContent) { + equals(t, content, "attribute", cty.StringVal("string")) + }, + }, + "simple_var_ref": { + content: "attribute = var.input", + schema: &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "attribute", + }, + }, + }, + variables: map[string]cty.Value{ + "input": cty.StringVal("string"), + }, + validate: func(t *testing.T, content *hcl.BodyContent) { + equals(t, content, "attribute", cty.StringVal("string")) + }, + }, + "missing_var_ref": { + content: "attribute = var.missing", + schema: &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "attribute", + }, + }, + }, + variables: map[string]cty.Value{ + "input": cty.StringVal("string"), + }, + expectedErrors: []string{ + "The input variable \"missing\" is not available to the current run block. You can only reference variables defined at the file or global levels when populating the variables block within a run block.", + }, + validate: func(t *testing.T, content *hcl.BodyContent) { + if len(content.Attributes) > 0 { + t.Errorf("should have excluded the invalid attribute but found %d", len(content.Attributes)) + } + }, + }, + "simple_run_block": { + content: "attribute = run.setup.value", + schema: &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "attribute", + }, + }, + }, + runBlockOutputs: map[string]map[string]cty.Value{ + "setup": { + "value": cty.StringVal("string"), + }, + }, + validate: func(t *testing.T, content *hcl.BodyContent) { + equals(t, content, "attribute", cty.StringVal("string")) + }, + }, + "missing_run_block": { + content: "attribute = run.missing.value", + schema: &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "attribute", + }, + }, + }, + runBlockOutputs: map[string]map[string]cty.Value{ + "setup": { + "value": cty.StringVal("string"), + }, + }, + expectedErrors: []string{ + "The run block \"missing\" does not exist within this test file. You can only reference run blocks that are in the same test file and will execute before the provider is required.", + }, + validate: func(t *testing.T, content *hcl.BodyContent) { + if len(content.Attributes) > 0 { + t.Errorf("should have excluded the invalid attribute but found %d", len(content.Attributes)) + } + }, + }, + "late_run_block": { + content: "attribute = run.setup.value", + schema: &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "attribute", + }, + }, + }, + runBlockOutputs: map[string]map[string]cty.Value{ + "setup": nil, + }, + expectedErrors: []string{ + "The run block \"setup\" has not executed yet. You can only reference run blocks that are in the same test file and will execute before the provider is required.", + }, + validate: func(t *testing.T, content *hcl.BodyContent) { + if len(content.Attributes) > 0 { + t.Errorf("should have excluded the invalid attribute but found %d", len(content.Attributes)) + } + }, + }, + "invalid_ref": { + content: "attribute = data.type.name.value", + schema: &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "attribute", + }, + }, + }, + runBlockOutputs: map[string]map[string]cty.Value{ + "setup": nil, + }, + expectedErrors: []string{ + "You can only reference earlier run blocks, file level, and global variables while defining variables from inside a run block.", + }, + validate: func(t *testing.T, content *hcl.BodyContent) { + if len(content.Attributes) > 0 { + t.Errorf("should have excluded the invalid attribute but found %d", len(content.Attributes)) + } + }, + }, + } + + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + + file, diags := hclsyntax.ParseConfig([]byte(tc.content), "main.tf", hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + t.Fatalf("failed to parse hcl: %s", diags.Error()) + } + + config := ProviderConfig{ + Original: file.Body, + ConfigVariables: func() map[string]*configs.Variable { + variables := make(map[string]*configs.Variable) + for variable := range tc.variables { + variables[variable] = &configs.Variable{ + Name: variable, + } + } + return variables + }(), + AvailableVariables: func() map[string]backend.UnparsedVariableValue { + variables := make(map[string]backend.UnparsedVariableValue) + for name, value := range tc.variables { + variables[name] = &variable{value} + } + return variables + }(), + AvailableRunBlocks: func() map[string]*terraform.TestContext { + statuses := make(map[string]*terraform.TestContext) + for name, values := range tc.runBlockOutputs { + if values == nil { + statuses[name] = nil + continue + } + + state := states.BuildState(func(state *states.SyncState) { + for name, value := range values { + state.SetOutputValue(addrs.AbsOutputValue{ + Module: addrs.RootModuleInstance, + OutputValue: addrs.OutputValue{ + Name: name, + }, + }, value, false) + } + }) + + config := &configs.Config{ + Module: &configs.Module{ + Outputs: func() map[string]*configs.Output { + outputs := make(map[string]*configs.Output) + for name := range values { + outputs[name] = &configs.Output{ + Name: name, + } + } + return outputs + }(), + }, + } + + statuses[name] = &terraform.TestContext{ + Config: config, + State: state, + } + } + return statuses + }(), + } + + content, diags := config.Content(tc.schema) + + var actualErrs []string + for _, diag := range diags { + actualErrs = append(actualErrs, diag.Detail) + } + if diff := cmp.Diff(actualErrs, tc.expectedErrors); len(diff) > 0 { + t.Errorf("unmatched errors\nexpected:\n%s\nactual:\n%s\ndiff:\n%s", strings.Join(tc.expectedErrors, "\n"), strings.Join(actualErrs, "\n"), diff) + } + + tc.validate(t, content) + }) + } +} + +func equals(t *testing.T, content *hcl.BodyContent, attribute string, expected cty.Value) { + value, diags := content.Attributes[attribute].Expr.Value(nil) + if diags.HasErrors() { + t.Errorf("failed to get value from attribute %s: %s", attribute, diags.Error()) + } + if !value.RawEquals(expected) { + t.Errorf("expected:\n%s\nbut got:\n%s", expected.GoString(), value.GoString()) + } +} + +var _ backend.UnparsedVariableValue = (*variable)(nil) + +type variable struct { + value cty.Value +} + +func (v *variable) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) { + return &terraform.InputValue{ + Value: v.value, + SourceType: terraform.ValueFromUnknown, + }, nil +}