| // Copyright (c) The OpenTofu Authors | |
| // SPDX-License-Identifier: MPL-2.0 | |
| // Copyright (c) 2023 HashiCorp, Inc. | |
| // SPDX-License-Identifier: MPL-2.0 | |
| package tofu | |
| import ( | |
| "context" | |
| "errors" | |
| "reflect" | |
| "testing" | |
| "github.com/google/go-cmp/cmp" | |
| "github.com/hashicorp/hcl/v2" | |
| "github.com/hashicorp/hcl/v2/hclsyntax" | |
| "github.com/zclconf/go-cty-debug/ctydebug" | |
| "github.com/zclconf/go-cty/cty" | |
| "github.com/opentofu/opentofu/internal/addrs" | |
| "github.com/opentofu/opentofu/internal/checks" | |
| "github.com/opentofu/opentofu/internal/configs" | |
| "github.com/opentofu/opentofu/internal/configs/configschema" | |
| "github.com/opentofu/opentofu/internal/plans" | |
| "github.com/opentofu/opentofu/internal/plugins" | |
| "github.com/opentofu/opentofu/internal/providers" | |
| "github.com/opentofu/opentofu/internal/states" | |
| "github.com/opentofu/opentofu/internal/tfdiags" | |
| ) | |
| func TestNodeModuleVariablePath(t *testing.T) { | |
| n := &nodeModuleVariable{ | |
| Addr: addrs.RootModuleInstance.InputVariable("foo"), | |
| Config: &configs.Variable{ | |
| Name: "foo", | |
| Type: cty.String, | |
| ConstraintType: cty.String, | |
| }, | |
| } | |
| want := addrs.RootModuleInstance | |
| got := n.Path() | |
| if got.String() != want.String() { | |
| t.Fatalf("wrong module address %s; want %s", got, want) | |
| } | |
| } | |
| func TestNodeModuleVariableReferenceableName(t *testing.T) { | |
| n := &nodeExpandModuleVariable{ | |
| Addr: addrs.InputVariable{Name: "foo"}, | |
| Config: &configs.Variable{ | |
| Name: "foo", | |
| Type: cty.String, | |
| ConstraintType: cty.String, | |
| }, | |
| } | |
| { | |
| expected := []addrs.Referenceable{ | |
| addrs.InputVariable{Name: "foo"}, | |
| } | |
| actual := n.ReferenceableAddrs() | |
| if !reflect.DeepEqual(actual, expected) { | |
| t.Fatalf("%#v != %#v", actual, expected) | |
| } | |
| } | |
| { | |
| gotSelfPath, gotReferencePath := n.ReferenceOutside() | |
| wantSelfPath := addrs.RootModuleInstance | |
| wantReferencePath := addrs.RootModuleInstance | |
| if got, want := gotSelfPath.String(), wantSelfPath.String(); got != want { | |
| t.Errorf("wrong self path\ngot: %s\nwant: %s", got, want) | |
| } | |
| if got, want := gotReferencePath.String(), wantReferencePath.String(); got != want { | |
| t.Errorf("wrong reference path\ngot: %s\nwant: %s", got, want) | |
| } | |
| } | |
| } | |
| func TestNodeModuleVariableReference(t *testing.T) { | |
| n := &nodeExpandModuleVariable{ | |
| Addr: addrs.InputVariable{Name: "foo"}, | |
| Module: addrs.RootModule.Child("bar"), | |
| Config: &configs.Variable{ | |
| Name: "foo", | |
| Type: cty.String, | |
| ConstraintType: cty.String, | |
| }, | |
| Expr: &hclsyntax.ScopeTraversalExpr{ | |
| Traversal: hcl.Traversal{ | |
| hcl.TraverseRoot{Name: "var"}, | |
| hcl.TraverseAttr{Name: "foo"}, | |
| }, | |
| }, | |
| } | |
| want := []*addrs.Reference{ | |
| { | |
| Subject: addrs.InputVariable{Name: "foo"}, | |
| }, | |
| } | |
| got := n.References() | |
| if diff := cmp.Diff(want, got, addrs.CmpOptionsForTesting); diff != "" { | |
| t.Error("wrong references\n" + diff) | |
| } | |
| } | |
| func TestNodeModuleVariableReference_grandchild(t *testing.T) { | |
| n := &nodeExpandModuleVariable{ | |
| Addr: addrs.InputVariable{Name: "foo"}, | |
| Module: addrs.RootModule.Child("bar"), | |
| Config: &configs.Variable{ | |
| Name: "foo", | |
| Type: cty.String, | |
| ConstraintType: cty.String, | |
| }, | |
| Expr: &hclsyntax.ScopeTraversalExpr{ | |
| Traversal: hcl.Traversal{ | |
| hcl.TraverseRoot{Name: "var"}, | |
| hcl.TraverseAttr{Name: "foo"}, | |
| }, | |
| }, | |
| } | |
| want := []*addrs.Reference{ | |
| { | |
| Subject: addrs.InputVariable{Name: "foo"}, | |
| }, | |
| } | |
| got := n.References() | |
| if diff := cmp.Diff(want, got, addrs.CmpOptionsForTesting); diff != "" { | |
| t.Error("wrong references\n" + diff) | |
| } | |
| } | |
| func TestNodeModuleVariableConstraints(t *testing.T) { | |
| SkipExperimental(t, ExperimentalBugReferenceProvider) | |
| // This is a little extra convoluted to poke at some edge cases that have cropped up in the past around | |
| // evaluating dependent nodes between the plan -> apply and destroy cycle. | |
| m := testModuleInline(t, map[string]string{ | |
| "main.tf": ` | |
| variable "input" { | |
| type = string | |
| validation { | |
| condition = var.input != "" | |
| error_message = "Input must not be empty." | |
| } | |
| } | |
| module "child" { | |
| source = "./child" | |
| input = var.input | |
| } | |
| provider "test" { | |
| alias = "secondary" | |
| test_string = module.child.output | |
| } | |
| resource "test_object" "resource" { | |
| provider = test.secondary | |
| test_string = "test string" | |
| } | |
| `, | |
| "child/main.tf": ` | |
| variable "input" { | |
| type = string | |
| validation { | |
| condition = var.input != "" | |
| error_message = "Input must not be empty." | |
| } | |
| } | |
| provider "test" { | |
| test_string = "foo" | |
| } | |
| resource "test_object" "resource" { | |
| test_string = var.input | |
| } | |
| output "output" { | |
| value = test_object.resource.id | |
| } | |
| `, | |
| }) | |
| checkableObjects := []addrs.Checkable{ | |
| addrs.InputVariable{Name: "input"}.Absolute(addrs.RootModuleInstance), | |
| addrs.InputVariable{Name: "input"}.Absolute(addrs.RootModuleInstance.Child("child", addrs.NoKey)), | |
| } | |
| p := &MockProvider{ | |
| GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ | |
| Provider: providers.Schema{Block: simpleTestSchema()}, | |
| ResourceTypes: map[string]providers.Schema{ | |
| "test_object": providers.Schema{Block: &configschema.Block{ | |
| Attributes: map[string]*configschema.Attribute{ | |
| "id": { | |
| Type: cty.String, | |
| Computed: true, | |
| }, | |
| "test_string": { | |
| Type: cty.String, | |
| Required: true, | |
| }, | |
| }, | |
| }}, | |
| }, | |
| }, | |
| } | |
| p.ConfigureProviderFn = func(req providers.ConfigureProviderRequest) (resp providers.ConfigureProviderResponse) { | |
| if req.Config.GetAttr("test_string").IsNull() { | |
| resp.Diagnostics = resp.Diagnostics.Append(errors.New("missing test_string value")) | |
| } | |
| return resp | |
| } | |
| ctxOpts := &ContextOpts{ | |
| Plugins: plugins.NewLibrary(map[addrs.Provider]providers.Factory{ | |
| addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), | |
| }, nil), | |
| } | |
| t.Run("pass", func(t *testing.T) { | |
| ctx := testContext2(t, ctxOpts) | |
| plan, diags := ctx.Plan(context.Background(), m, states.NewState(), &PlanOpts{ | |
| Mode: plans.NormalMode, | |
| SetVariables: InputValues{ | |
| "input": &InputValue{ | |
| Value: cty.StringVal("beep"), | |
| SourceType: ValueFromCLIArg, | |
| }, | |
| }, | |
| }) | |
| assertNoDiagnostics(t, diags) | |
| for _, addr := range checkableObjects { | |
| SkipExperimental(t, ExperimentalFeatureChecks) | |
| result := plan.Checks.GetObjectResult(addr) | |
| if result == nil { | |
| t.Fatalf("no check result for %s in the plan", addr) | |
| } | |
| if got, want := result.Status, checks.StatusPass; got != want { | |
| t.Fatalf("wrong check status for %s during planning\ngot: %s\nwant: %s", addr, got, want) | |
| } | |
| } | |
| state, diags := ctx.Apply(context.Background(), plan, m, nil) | |
| assertNoDiagnostics(t, diags) | |
| for _, addr := range checkableObjects { | |
| result := state.CheckResults.GetObjectResult(addr) | |
| if result == nil { | |
| t.Fatalf("no check result for %s in the final state", addr) | |
| } | |
| if got, want := result.Status, checks.StatusPass; got != want { | |
| t.Errorf("wrong check status for %s after apply\ngot: %s\nwant: %s", addr, got, want) | |
| } | |
| } | |
| plan, diags = ctx.Plan(context.Background(), m, state, &PlanOpts{ | |
| Mode: plans.DestroyMode, | |
| SetVariables: InputValues{ | |
| "input": &InputValue{ | |
| Value: cty.StringVal("beep"), | |
| SourceType: ValueFromCLIArg, | |
| }, | |
| }, | |
| }) | |
| assertNoDiagnostics(t, diags) | |
| state, diags = ctx.Apply(context.Background(), plan, m, nil) | |
| assertNoDiagnostics(t, diags) | |
| for _, addr := range checkableObjects { | |
| result := state.CheckResults.GetObjectResult(addr) | |
| if result == nil { | |
| t.Fatalf("no check result for %s in the final state", addr) | |
| } | |
| if got, want := result.Status, checks.StatusPass; got != want { | |
| t.Errorf("wrong check status for %s after apply\ngot: %s\nwant: %s", addr, got, want) | |
| } | |
| } | |
| }) | |
| t.Run("fail", func(t *testing.T) { | |
| ctx := testContext2(t, ctxOpts) | |
| _, diags := ctx.Plan(context.Background(), m, states.NewState(), &PlanOpts{ | |
| Mode: plans.NormalMode, | |
| SetVariables: InputValues{ | |
| "input": &InputValue{ | |
| Value: cty.StringVal(""), | |
| SourceType: ValueFromCLIArg, | |
| }, | |
| }, | |
| }) | |
| if !diags.HasErrors() { | |
| t.Fatalf("succeeded; want error") | |
| } | |
| const wantSummary = "Invalid value for variable" | |
| found := false | |
| for _, diag := range diags { | |
| if diag.Severity() == tfdiags.Error && diag.Description().Summary == wantSummary { | |
| found = true | |
| break | |
| } | |
| } | |
| if !found { | |
| SkipExperimental(t, ExperimentalChangeDiagWording) | |
| t.Fatalf("missing expected error\nwant summary: %s\ngot: %s", wantSummary, diags.Err().Error()) | |
| } | |
| }) | |
| } | |
| func TestNodeModuleVariable_warningDiags(t *testing.T) { | |
| t.Run("unused object attribute", func(t *testing.T) { | |
| n := &nodeModuleVariable{ | |
| Addr: addrs.InputVariable{Name: "foo"}.Absolute(addrs.RootModuleInstance), | |
| Config: &configs.Variable{ | |
| Name: "foo", | |
| ConstraintType: cty.Object(map[string]cty.Type{ | |
| "foo": cty.String, | |
| "bar": cty.Object(map[string]cty.Type{"nested": cty.EmptyObject}), | |
| }), | |
| }, | |
| Expr: &hclsyntax.ObjectConsExpr{ | |
| SrcRange: hcl.Range{Filename: "context1.tofu"}, | |
| Items: []hclsyntax.ObjectConsItem{ | |
| { | |
| KeyExpr: &hclsyntax.LiteralValueExpr{ | |
| Val: cty.StringVal("baz"), | |
| SrcRange: hcl.Range{ | |
| Filename: "test1.tofu", | |
| }, | |
| }, | |
| ValueExpr: &hclsyntax.LiteralValueExpr{ | |
| Val: cty.StringVal("..."), | |
| }, | |
| }, | |
| { | |
| KeyExpr: &hclsyntax.LiteralValueExpr{ | |
| Val: cty.StringVal("bar"), | |
| SrcRange: hcl.Range{ | |
| Filename: "test.tofu", | |
| }, | |
| }, | |
| ValueExpr: &hclsyntax.ObjectConsExpr{ | |
| SrcRange: hcl.Range{Filename: "context2.tofu"}, | |
| Items: []hclsyntax.ObjectConsItem{ | |
| { | |
| KeyExpr: &hclsyntax.LiteralValueExpr{ | |
| Val: cty.StringVal("beep"), | |
| SrcRange: hcl.Range{ | |
| Filename: "test2.tofu", | |
| }, | |
| }, | |
| ValueExpr: &hclsyntax.LiteralValueExpr{ | |
| Val: cty.StringVal("..."), | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| ModuleInstance: addrs.RootModuleInstance, | |
| } | |
| // We use the "ForRPC" representation of the diagnostics just because | |
| // it's more friendly for comparison and we care only about the | |
| // user-facing information in the diagnostics, not their concrete types. | |
| gotDiags := n.warningDiags().ForRPC() | |
| var wantDiags tfdiags.Diagnostics | |
| wantDiags = wantDiags.Append(&hcl.Diagnostic{ | |
| Severity: hcl.DiagWarning, | |
| Summary: "Object attribute is ignored", | |
| Detail: `The object type for input variable "foo" does not include an attribute named "baz", so this definition is unused. Did you mean to set attribute "bar" instead?`, | |
| Subject: &hcl.Range{ | |
| Filename: "test1.tofu", // from synthetic source range in constructed expression above | |
| }, | |
| Context: &hcl.Range{ | |
| Filename: "context1.tofu", // from synthetic source range in constructed expression above | |
| }, | |
| }) | |
| wantDiags = wantDiags.Append(&hcl.Diagnostic{ | |
| Severity: hcl.DiagWarning, | |
| Summary: "Object attribute is ignored", | |
| Detail: `The object type for input variable "foo" nested value .bar does not include an attribute named "beep", so this definition is unused.`, | |
| Subject: &hcl.Range{ | |
| Filename: "test2.tofu", // from synthetic source range in constructed expression above | |
| }, | |
| Context: &hcl.Range{ | |
| Filename: "context2.tofu", // from synthetic source range in constructed expression above | |
| }, | |
| }) | |
| wantDiags = wantDiags.ForRPC() | |
| if diff := cmp.Diff(wantDiags, gotDiags, ctydebug.CmpOptions); diff != "" { | |
| t.Error("wrong diagnostics\n" + diff) | |
| } | |
| }) | |
| } |