diff --git a/CHANGELOG.md b/CHANGELOG.md index fd2453b1b6..9d95a5a13f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ENHANCEMENTS: * Made `tofu plan` with `generate-config-out` flag replace JSON strings with `jsonencode` functions calls. ([#1595](https://github.com/opentofu/opentofu/pull/1595)) * Make state persistence interval configurable via `TF_STATE_PERSIST_INTERVAL` environment variable ([#1591](https://github.com/opentofu/opentofu/pull/1591)) * Improved performance of writing state files and reduced their size using compact json encoding. ([#1647](https://github.com/opentofu/opentofu/pull/1647)) +* Allow to reference variable inside the `variables` block of a test file. ([1488](https://github.com/opentofu/opentofu/pull/1488)) BUG FIXES: * Fixed crash in gcs backend when using certain commands ([#1618](https://github.com/opentofu/opentofu/pull/1618)) diff --git a/internal/command/test.go b/internal/command/test.go index f2f7cef1fe..a3d695f5cd 100644 --- a/internal/command/test.go +++ b/internal/command/test.go @@ -668,7 +668,10 @@ func (runner *TestFileRunner) destroy(config *configs.Config, state *states.Stat var diags tfdiags.Diagnostics - variables, variableDiags := buildInputVariablesForTest(run, file, config, runner.Suite.GlobalVariables, runner.States) + evalCtx, ctxDiags := getEvalContextForTest(runner.States, config, runner.Suite.GlobalVariables) + diags = diags.Append(ctxDiags) + + variables, variableDiags := buildInputVariablesForTest(run, file, config, runner.Suite.GlobalVariables, evalCtx) diags = diags.Append(variableDiags) if diags.HasErrors() { @@ -731,7 +734,10 @@ 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, runner.States) + evalCtx, ctxDiags := getEvalContextForTest(runner.States, config, runner.Suite.GlobalVariables) + diags = diags.Append(ctxDiags) + + variables, variableDiags := buildInputVariablesForTest(run, file, config, runner.Suite.GlobalVariables, evalCtx) diags = diags.Append(variableDiags) if diags.HasErrors() { @@ -1000,9 +1006,8 @@ func (runner *TestFileRunner) Cleanup(file *moduletest.File) { // 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, states map[string]*TestFileState) (tofu.InputValues, tfdiags.Diagnostics) { +func buildInputVariablesForTest(run *moduletest.Run, file *moduletest.File, config *configs.Config, globals map[string]backend.UnparsedVariableValue, evalCtx *hcl.EvalContext) (tofu.InputValues, tfdiags.Diagnostics) { variables := make(map[string]backend.UnparsedVariableValue) - evalCtx := getEvalContextFromStates(states) for name := range config.Module.Variables { if run != nil { if expr, exists := run.Config.Variables[name]; exists { @@ -1019,9 +1024,10 @@ func buildInputVariablesForTest(run *moduletest.Run, file *moduletest.File, conf if file != nil { if expr, exists := file.Config.Variables[name]; exists { // If it's not set locally, it maybe set for the entire file. - variables[name] = unparsedVariableValueExpression{ + variables[name] = testVariableValueExpression{ expr: expr, sourceType: tofu.ValueFromConfig, + ctx: evalCtx, } continue } @@ -1042,17 +1048,12 @@ func buildInputVariablesForTest(run *moduletest.Run, file *moduletest.File, conf return backend.ParseVariableValues(variables, config.Module.Variables) } -// getEvalContextFromStates constructs an hcl.EvalContext based on the provided map -// of TestFileState instances. It extracts the relevant information from the -// states to create a context suitable for HCL evaluation, including the output -// values of modules. -// -// Parameters: -// - states: A map of TestFileState instances containing the state information. -// -// Returns: -// - *hcl.EvalContext: The constructed HCL evaluation context. -func getEvalContextFromStates(states map[string]*TestFileState) *hcl.EvalContext { +// getEvalContextForTest constructs an hcl.EvalContext based on the provided map of +// TestFileState instances, configuration and global variables. +// It extracts the relevant information from the input parameters to create a +// context suitable for HCL evaluation. +func getEvalContextForTest(states map[string]*TestFileState, config *configs.Config, globals map[string]backend.UnparsedVariableValue) (*hcl.EvalContext, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics runCtx := make(map[string]cty.Value) for _, state := range states { if state.Run == nil { @@ -1065,9 +1066,24 @@ func getEvalContextFromStates(states map[string]*TestFileState) *hcl.EvalContext } runCtx[state.Run.Name] = cty.ObjectVal(outputs) } - ctx := &hcl.EvalContext{Variables: map[string]cty.Value{"run": cty.ObjectVal(runCtx)}} - return ctx + // If the variable is referenced in the tfvars file or TF_VAR_ environment variable, then lookup the value + // in global variables; otherwise, assign the default value. + inputValues, diags := parseAndApplyDefaultValues(globals, config.Module.Variables) + diags.Append(diags) + + varCtx := make(map[string]cty.Value) + for name, val := range inputValues { + varCtx[name] = val.Value + } + + ctx := &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "run": cty.ObjectVal(runCtx), + "var": cty.ObjectVal(varCtx), + }, + } + return ctx, diags } type testVariableValueExpression struct { @@ -1090,6 +1106,40 @@ func (v testVariableValueExpression) ParseVariableValue(mode configs.VariablePar }, diags } +// parseAndApplyDefaultValues parses the given unparsed variables into tofu.InputValues +// and applies default values from the configuration variables where applicable. +// This ensures all variables are correctly initialized and returns the resulting tofu.InputValues. +func parseAndApplyDefaultValues(unparsedVariables map[string]backend.UnparsedVariableValue, configVariables map[string]*configs.Variable) (tofu.InputValues, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + inputs := make(tofu.InputValues, len(unparsedVariables)) + for name, variable := range unparsedVariables { + value, valueDiags := variable.ParseVariableValue(configs.VariableParseLiteral) + diags = diags.Append(valueDiags) + inputs[name] = value + } + + // Now, we're going to apply any default values from the configuration. + // We do this after the conversion into tofu.InputValues, as the + // defaults have already been converted into cty.Value objects. + for name, variable := range configVariables { + if _, exists := unparsedVariables[name]; exists { + // Then we don't want to apply the default for this variable as we + // already have a value. + continue + } + + if variable.Default != cty.NilVal { + inputs[name] = &tofu.InputValue{ + Value: variable.Default, + SourceType: tofu.ValueFromConfig, + SourceRange: tfdiags.SourceRangeFromHCL(variable.DeclRange), + } + } + } + + return inputs, diags +} + // prepareInputVariablesForAssertions creates a tofu.InputValues mapping // that contains all the variables defined for a given run and file, alongside // any unset variables that have defaults within the provided config. @@ -1107,7 +1157,9 @@ func (v testVariableValueExpression) ParseVariableValue(mode configs.VariablePar // 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 (runner *TestFileRunner) prepareInputVariablesForAssertions(config *configs.Config, run *moduletest.Run, file *moduletest.File, globals map[string]backend.UnparsedVariableValue) (tofu.InputValues, func(), tfdiags.Diagnostics) { - ctx := getEvalContextFromStates(runner.States) + var diags tfdiags.Diagnostics + ctx, ctxDiags := getEvalContextForTest(runner.States, config, globals) + diags = diags.Append(ctxDiags) variables := make(map[string]backend.UnparsedVariableValue) @@ -1148,34 +1200,9 @@ func (runner *TestFileRunner) prepareInputVariablesForAssertions(config *configs // We've gathered all the values we have, let's convert them into // tofu.InputValues so they can be passed into the OpenTofu graph. - - inputs := make(tofu.InputValues, len(variables)) - var diags tfdiags.Diagnostics - for name, variable := range variables { - value, valueDiags := variable.ParseVariableValue(configs.VariableParseLiteral) - diags = diags.Append(valueDiags) - inputs[name] = value - } - - // Next, we're going to apply any default values from the configuration. - // We do this after the conversion into tofu.InputValues, as the - // defaults have already been converted into cty.Value objects. - - for name, variable := range config.Module.Variables { - if _, exists := variables[name]; exists { - // Then we don't want to apply the default for this variable as we - // already have a value. - continue - } - - if variable.Default != cty.NilVal { - inputs[name] = &tofu.InputValue{ - Value: variable.Default, - SourceType: tofu.ValueFromConfig, - SourceRange: tfdiags.SourceRangeFromHCL(variable.DeclRange), - } - } - } + // Also, apply default values from the configuration variables where applicable. + inputs, valDiags := parseAndApplyDefaultValues(variables, config.Module.Variables) + diags.Append(valDiags) // Finally, we're going to do a some modifications to the config. // If we have got variable values from the test file we need to make sure diff --git a/internal/command/test_test.go b/internal/command/test_test.go index 9365ac398c..a3908e0693 100644 --- a/internal/command/test_test.go +++ b/internal/command/test_test.go @@ -1132,48 +1132,13 @@ Condition expression could not be evaluated at this time. } func TestTest_LocalVariables(t *testing.T) { - td := t.TempDir() - testCopyDir(t, testFixturePath(path.Join("test", "pass_with_local_variable")), 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) - } - - c := &TestCommand{ - Meta: meta, - } - code := c.Run([]string{"-verbose", "-no-color"}) - output := done(t) - - if code != 0 { - t.Errorf("expected status code 0 but got %d", code) - } - - expected := `tests/test.tftest.hcl... pass + tcs := map[string]struct { + expected string + code int + skip bool + }{ + "pass_with_local_variable": { + expected: `tests/test.tftest.hcl... pass run "first"... pass @@ -1188,16 +1153,91 @@ OpenTofu has compared your real infrastructure against your configuration and found no differences, so no changes are needed. Success! 2 passed, 0 failed. -` +`, + code: 0, + }, + "pass_var_inside_variables": { + expected: `main.tftest.hcl... pass + run "first"... pass - 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) +Outputs: + +sss = "false" + +Success! 1 passed, 0 failed. +`, + code: 0, + }, + "pass_var_with_default_value_inside_variables": { + expected: `main.tftest.hcl... pass + run "first"... pass + + +Outputs: + +sss = "true" + +Success! 1 passed, 0 failed. +`, + code: 0, + }, } - if provider.ResourceCount() > 0 { - t.Errorf("should have deleted all resources on completion but left %v", provider.ResourceString()) + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + if tc.skip { + t.Skip() + } + + file := name + + td := t.TempDir() + testCopyDir(t, testFixturePath(path.Join("test", file)), td) + defer testChdir(t, td)() + + provider := testing_command.NewProvider(nil) + providerSource, providerClose := newMockProviderSource(t, map[string][]string{ + "test": {"1.0.0"}, + }) + defer providerClose() + + 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) + } + + command := &TestCommand{ + Meta: meta, + } + + code := command.Run([]string{"-verbose", "-no-color"}) + output := done(t) + + if code != tc.code { + t.Errorf("expected status code %d but got %d: %s", tc.code, code, output.All()) + } + + actual := output.All() + + if diff := cmp.Diff(actual, tc.expected); len(diff) > 0 { + t.Errorf("output didn't match expected:\nexpected:\n%s\nactual:\n%s\ndiff:\n%s", tc.expected, actual, diff) + } + }) } } diff --git a/internal/command/testdata/test/pass_var_inside_variables/main.tf b/internal/command/testdata/test/pass_var_inside_variables/main.tf new file mode 100644 index 0000000000..6a9de968de --- /dev/null +++ b/internal/command/testdata/test/pass_var_inside_variables/main.tf @@ -0,0 +1,10 @@ +variable "var1" { + default = true +} + +variable "var2" { +} + +output "sss" { + value = var.var2 +} \ No newline at end of file diff --git a/internal/command/testdata/test/pass_var_inside_variables/main.tftest.hcl b/internal/command/testdata/test/pass_var_inside_variables/main.tftest.hcl new file mode 100644 index 0000000000..292b7a29f3 --- /dev/null +++ b/internal/command/testdata/test/pass_var_inside_variables/main.tftest.hcl @@ -0,0 +1,10 @@ +variables { + var2 = var.var1 ? "true" : "false" +} + +run "first" { + assert { + condition = output.sss == "false" + error_message = "Should work" + } +} \ No newline at end of file diff --git a/internal/command/testdata/test/pass_var_inside_variables/terraform.tfvars b/internal/command/testdata/test/pass_var_inside_variables/terraform.tfvars new file mode 100644 index 0000000000..fbd49b9879 --- /dev/null +++ b/internal/command/testdata/test/pass_var_inside_variables/terraform.tfvars @@ -0,0 +1 @@ +var1 = false \ No newline at end of file diff --git a/internal/command/testdata/test/pass_var_with_default_value_inside_variables/main.tf b/internal/command/testdata/test/pass_var_with_default_value_inside_variables/main.tf new file mode 100644 index 0000000000..6a9de968de --- /dev/null +++ b/internal/command/testdata/test/pass_var_with_default_value_inside_variables/main.tf @@ -0,0 +1,10 @@ +variable "var1" { + default = true +} + +variable "var2" { +} + +output "sss" { + value = var.var2 +} \ No newline at end of file diff --git a/internal/command/testdata/test/pass_var_with_default_value_inside_variables/main.tftest.hcl b/internal/command/testdata/test/pass_var_with_default_value_inside_variables/main.tftest.hcl new file mode 100644 index 0000000000..a469b7c171 --- /dev/null +++ b/internal/command/testdata/test/pass_var_with_default_value_inside_variables/main.tftest.hcl @@ -0,0 +1,10 @@ +variables { + var2 = var.var1 ? "true" : "false" +} + +run "first" { + assert { + condition = output.sss == "true" + error_message = "Should work" + } +} \ No newline at end of file