| // Copyright (c) The OpenTofu Authors | |
| // SPDX-License-Identifier: MPL-2.0 | |
| // Copyright (c) 2023 HashiCorp, Inc. | |
| // SPDX-License-Identifier: MPL-2.0 | |
| package exprs_test | |
| import ( | |
| "context" | |
| "fmt" | |
| "strings" | |
| "github.com/davecgh/go-spew/spew" | |
| "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/zclconf/go-cty/cty/convert" | |
| "github.com/zclconf/go-cty/cty/function" | |
| "github.com/opentofu/opentofu/internal/addrs" | |
| "github.com/opentofu/opentofu/internal/configs/configschema" | |
| "github.com/opentofu/opentofu/internal/lang/exprs" | |
| "github.com/opentofu/opentofu/internal/tfdiags" | |
| ) | |
| // The main code in this package is intentionally completely unaware of | |
| // any specific symbol table structures in the language, with that logic | |
| // living in concrete implementations of [Scope], [SymbolTable] and [Valuer] | |
| // in other language-specific packages, but for testing purposes here we have a | |
| // contrived "mini-language" that is intentionally shaped like a subset of the | |
| // OpenTofu module language to prove that this design is sufficient to handle | |
| // that and to act as a relatively-concise overview of how a "real" use of this | |
| // package might look. | |
| // | |
| // If a real implementation _were_ shaped like this then all of the types | |
| // defined below would belong to some other package that implements the | |
| // OpenTofu planning phase. Variations of this could also appear in a package | |
| // that implements the validation phase, but in that case it would deal only | |
| // in unexpanded modules and resources. In both cases the types implementing | |
| // [Scope] and [Valuer] would ideally also implement all of the other business | |
| // logic related to whatever they represent to keep e.g. all of the logic | |
| // related to resource evaluation together in one place, but package exprs only | |
| // cares about their implementations of its interfaces. | |
| // | |
| // This example implementation has the significant limitation that it doesn't | |
| // have any way of detecting and reporting reference cycles. If any appear then | |
| // it'll just attempt infinite recursion and smash the stack. A real | |
| // implementation would need to somehow detect and report cyclic references, | |
| // e.g. by internally doing something like what this package does: | |
| // https://pkg.go.dev/github.com/apparentlymart/go-workgraph/workgraph | |
| func Example_simple() { | |
| varDefs := exampleMustParseTfvars(` | |
| name = "stephen" | |
| `) | |
| modInst := exampleMustParseModule(` | |
| variable "name" {} | |
| resource "example" "foo" { | |
| name = var.name | |
| } | |
| resource "example" "bar" { | |
| name = example.foo.name | |
| } | |
| `, varDefs) | |
| barR := modInst.Resource(addrs.Resource{ | |
| Mode: addrs.ManagedResourceMode, | |
| Type: "example", | |
| Name: "bar", | |
| }) | |
| barV, diags := barR.Value(context.Background()) | |
| if diags.HasErrors() { | |
| panic(spew.Sdump(diags.ForRPC())) | |
| } | |
| fmt.Println(ctydebug.ValueString(barV)) | |
| // Output: | |
| // cty.ObjectVal(map[string]cty.Value{ | |
| // "name": cty.StringVal("stephen"), | |
| // }) | |
| } | |
| // testResource represents a module instance, implementing [Scope]. | |
| type testModuleInstance struct { | |
| variables map[addrs.InputVariable]*testInputVariable | |
| resources map[addrs.Resource]*testResource | |
| } | |
| var _ exprs.Scope = (*testModuleInstance)(nil) | |
| // Resource returns the resource with the given address, or nil if there is | |
| // no such resource declared in the module. | |
| func (t *testModuleInstance) Resource(addr addrs.Resource) *testResource { | |
| return t.resources[addr] | |
| } | |
| // HandleInvalidStep implements Scope. | |
| func (t *testModuleInstance) HandleInvalidStep(rng tfdiags.SourceRange) tfdiags.Diagnostics { | |
| // NOTE: It isn't possible to get here in practice because we only use | |
| // this as a top-level scope and HCL's parser only allows TraverseRoot | |
| // at the start of a reference anyway, so we could only get in here | |
| // if an [Evalable.References] implementation returns something odd. | |
| var diags tfdiags.Diagnostics | |
| diags = diags.Append(&hcl.Diagnostic{ | |
| Severity: hcl.DiagError, | |
| Summary: "Invalid reference", | |
| Detail: "Expected the name of a top-level symbol.", | |
| Subject: rng.ToHCL().Ptr(), | |
| }) | |
| return diags | |
| } | |
| // ResolveAttr implements Scope. | |
| func (t *testModuleInstance) ResolveAttr(ref hcl.TraverseAttr) (exprs.Attribute, tfdiags.Diagnostics) { | |
| // Note that handling this as part of the implementation a module, rather | |
| // than separately in package addrs, makes it easier for the resolution | |
| // rules to vary depending on which language edition and language | |
| // experiments the module is using, because in a real implementation this | |
| // object would have access to the module configuration. | |
| // | |
| // The extra symbols supported in .tftest.hcl files can also be handled | |
| // by having the test scenario type also implement Scope, handle the | |
| // test-language-specific symbols first, and then delegate to a wrapped | |
| // module object for everything else. | |
| switch ref.Name { | |
| case "var": | |
| return exprs.NestedSymbolTable(testInputVariables(t.variables)), nil | |
| case "resource": | |
| return exprs.NestedSymbolTable(&testResourcesOfMode{ | |
| mode: addrs.ManagedResourceMode, | |
| allResources: t.resources, | |
| }), nil | |
| case "data": | |
| return exprs.NestedSymbolTable(&testResourcesOfMode{ | |
| mode: addrs.DataResourceMode, | |
| allResources: t.resources, | |
| }), nil | |
| case "ephemeral": | |
| return exprs.NestedSymbolTable(&testResourcesOfMode{ | |
| mode: addrs.EphemeralResourceMode, | |
| allResources: t.resources, | |
| }), nil | |
| default: | |
| return exprs.NestedSymbolTable(&testResourcesOfType{ | |
| mode: addrs.ManagedResourceMode, | |
| typeName: ref.Name, | |
| allResources: t.resources, | |
| }), nil | |
| } | |
| } | |
| // ResolveFunc implements Scope. | |
| func (t *testModuleInstance) ResolveFunc(_ context.Context, call *hcl.StaticCall) (function.Function, tfdiags.Diagnostics) { | |
| // A real implementation of this would probably look the function name up | |
| // in a map built elsewhere, rather than like this. | |
| switch call.Name { | |
| case "upper": | |
| return function.New(&function.Spec{ | |
| Params: []function.Parameter{ | |
| { | |
| Name: "str", | |
| Type: cty.String, | |
| }, | |
| }, | |
| Type: function.StaticReturnType(cty.String), | |
| Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { | |
| // NOTE: This is not a robust implementation of "upper", just | |
| // a placeholder for the sake of this example. | |
| return cty.StringVal(strings.ToUpper(args[0].AsString())), nil | |
| }, | |
| }), nil | |
| default: | |
| var diags tfdiags.Diagnostics | |
| diags = diags.Append(&hcl.Diagnostic{ | |
| Severity: hcl.DiagError, | |
| Summary: "Call to unknown function", | |
| Detail: fmt.Sprintf("There is no function named %q.", call.Name), | |
| Subject: &call.NameRange, | |
| }) | |
| return function.Function{}, diags | |
| } | |
| } | |
| // testInputVariables is an intermediate [SymbolTable] dealing with the | |
| // symbols under "var.". | |
| type testInputVariables map[addrs.InputVariable]*testInputVariable | |
| var _ exprs.SymbolTable = testInputVariables(nil) | |
| // HandleInvalidStep implements exprs.SymbolTable. | |
| func (t testInputVariables) HandleInvalidStep(rng tfdiags.SourceRange) tfdiags.Diagnostics { | |
| var diags tfdiags.Diagnostics | |
| diags = diags.Append(&hcl.Diagnostic{ | |
| Severity: hcl.DiagError, | |
| Summary: "Invalid reference to input variable", | |
| Detail: "Expected an attribute name matching an input variable declared in this module.", | |
| Subject: rng.ToHCL().Ptr(), | |
| }) | |
| return diags | |
| } | |
| // ResolveAttr implements exprs.SymbolTable. | |
| func (t testInputVariables) ResolveAttr(ref hcl.TraverseAttr) (exprs.Attribute, tfdiags.Diagnostics) { | |
| var diags tfdiags.Diagnostics | |
| iv, ok := t[addrs.InputVariable{Name: ref.Name}] | |
| if !ok { | |
| diags = diags.Append(&hcl.Diagnostic{ | |
| Severity: hcl.DiagError, | |
| Summary: "Reference to undeclared input variable", | |
| Detail: fmt.Sprintf("There is no input variable named %q declared in this module.", ref.Name), | |
| Subject: &ref.SrcRange, | |
| }) | |
| return nil, diags | |
| } | |
| return exprs.ValueOf(iv), diags | |
| } | |
| type testInputVariable struct { | |
| addr addrs.InputVariable | |
| targetType cty.Type | |
| rawVal cty.Value | |
| valRange tfdiags.SourceRange | |
| } | |
| var _ exprs.Valuer = (*testInputVariable)(nil) | |
| // StaticCheckTraversal implements exprs.Valuer. | |
| func (t *testInputVariable) TypeConstraint() cty.Type { | |
| // An input variable's "type" is a target type for conversion rather than | |
| // just a type constraint, so we need to discard any optional attribute | |
| // information to get a plain type constraint. | |
| return t.targetType.WithoutOptionalAttributesDeep() | |
| } | |
| // StaticCheckTraversal implements exprs.Valuer. | |
| func (t *testInputVariable) StaticCheckTraversal(traversal hcl.Traversal) tfdiags.Diagnostics { | |
| return exprs.StaticCheckTraversalThroughType(traversal, t.TypeConstraint()) | |
| } | |
| // Value implements exprs.Valuer. | |
| func (t *testInputVariable) Value(ctx context.Context) (cty.Value, tfdiags.Diagnostics) { | |
| var diags tfdiags.Diagnostics | |
| // In a real implementation this type would probably not have the value | |
| // directly and would instead have an expression from an argument in | |
| // the calling "module" block, but we'll keep this relatively simple | |
| // for the sake of example. | |
| v, err := convert.Convert(t.rawVal, t.targetType) | |
| if err != nil { | |
| diags = diags.Append(&hcl.Diagnostic{ | |
| Severity: hcl.DiagError, | |
| Summary: "Invalid value for input variable", | |
| Detail: fmt.Sprintf("Unsuitable value for input variable %q: %s.", t.addr.Name, err), | |
| Subject: t.valRange.ToHCL().Ptr(), | |
| }) | |
| v = cty.UnknownVal(t.TypeConstraint()) | |
| } | |
| return v, diags | |
| } | |
| // ValueSourceRange implements exprs.Valuer. | |
| func (t *testInputVariable) ValueSourceRange() *tfdiags.SourceRange { | |
| return &t.valRange | |
| } | |
| // testInputVariables is an intermediate [SymbolTable] implementation dealing | |
| // with symbols under "resource.", "data.", and "ephemeral.". | |
| type testResourcesOfMode struct { | |
| mode addrs.ResourceMode | |
| allResources map[addrs.Resource]*testResource | |
| } | |
| var _ exprs.SymbolTable = (*testResourcesOfMode)(nil) | |
| // HandleInvalidStep implements exprs.SymbolTable. | |
| func (t *testResourcesOfMode) HandleInvalidStep(rng tfdiags.SourceRange) tfdiags.Diagnostics { | |
| var diags tfdiags.Diagnostics | |
| diags = diags.Append(&hcl.Diagnostic{ | |
| Severity: hcl.DiagError, | |
| Summary: "Invalid reference to resource", | |
| Detail: "Expected an attribute name matching the type of the resource to refer to.", | |
| Subject: rng.ToHCL().Ptr(), | |
| }) | |
| return diags | |
| } | |
| // ResolveAttr implements exprs.SymbolTable. | |
| func (t *testResourcesOfMode) ResolveAttr(ref hcl.TraverseAttr) (exprs.Attribute, tfdiags.Diagnostics) { | |
| // For now we'll just accept anything here and wait until we've collected | |
| // enough steps to form a complete addrs.Resource value. | |
| return exprs.NestedSymbolTable(&testResourcesOfType{ | |
| mode: t.mode, | |
| typeName: ref.Name, | |
| allResources: t.allResources, | |
| }), nil | |
| } | |
| // testInputVariables is an intermediate [SymbolTable] implementation dealing | |
| // with symbols under "resource.ANYTHING.", "data.ANYTHING.", | |
| // "ephemeral.ANYTHING.", and "ANYTHING.". | |
| type testResourcesOfType struct { | |
| mode addrs.ResourceMode | |
| typeName string | |
| allResources map[addrs.Resource]*testResource | |
| } | |
| var _ exprs.SymbolTable = (*testResourcesOfType)(nil) | |
| // HandleInvalidStep implements exprs.SymbolTable. | |
| func (t *testResourcesOfType) HandleInvalidStep(rng tfdiags.SourceRange) tfdiags.Diagnostics { | |
| var diags tfdiags.Diagnostics | |
| diags = diags.Append(&hcl.Diagnostic{ | |
| Severity: hcl.DiagError, | |
| Summary: "Invalid reference to resource", | |
| Detail: "Expected an attribute name matching the name of the resource to refer to.", | |
| Subject: rng.ToHCL().Ptr(), | |
| }) | |
| return diags | |
| } | |
| // ResolveAttr implements exprs.SymbolTable. | |
| func (t *testResourcesOfType) ResolveAttr(ref hcl.TraverseAttr) (exprs.Attribute, tfdiags.Diagnostics) { | |
| var diags tfdiags.Diagnostics | |
| // Once we reach this step we've collected enough information to | |
| // form a resource address. | |
| addr := addrs.Resource{ | |
| Mode: t.mode, | |
| Type: t.typeName, | |
| Name: ref.Name, | |
| } | |
| rsrc, ok := t.allResources[addr] | |
| if !ok { | |
| diags = diags.Append(&hcl.Diagnostic{ | |
| Severity: hcl.DiagError, | |
| Summary: "Reference to undeclared resource", | |
| Detail: fmt.Sprintf("There is no resource %s declared in this module.", addr), | |
| Subject: &ref.SrcRange, | |
| }) | |
| return nil, diags | |
| } | |
| return exprs.ValueOf(rsrc), diags | |
| } | |
| // testResource represents a resource, implementing [Valuer]. | |
| // | |
| // A real implementation of this would need to deal with multi-instance resources | |
| // using count/for_each too, probably delegating to another type representing | |
| // each individual resource instance, but we ignore that here because that | |
| // complexity is an implementation detail irrelevant to package exprs. | |
| type testResource struct { | |
| // config is the [Valuer] for the resource's configuration body. | |
| // | |
| // In practice this is an [*exprs.Closure] associating the actual HCL body | |
| // with the module instance where it was declared, but that is a concern | |
| // only for the code that constructs this object; the testResource | |
| // implementation only knows that it can obtain a value from here when | |
| // needed, without worrying about how that is achieved. | |
| // | |
| // (in a real implementation that supported multiple resource instances | |
| // we'd need to delay constructing the exprs.Valuer until constructing | |
| // individual resource instances, because in that case the resource instance | |
| // configs must close over a child scope that also has instance-specific | |
| // each.key/each.value/count.index in it, but this example is already | |
| // complicated enough so we'll skip that here.) | |
| config exprs.Valuer | |
| } | |
| var _ exprs.Valuer = (*testResource)(nil) | |
| // StaticCheckTraversal implements exprs.Valuer. | |
| func (t *testResource) StaticCheckTraversal(traversal hcl.Traversal) tfdiags.Diagnostics { | |
| return t.config.StaticCheckTraversal(traversal) | |
| } | |
| // Value implements exprs.Valuer. | |
| func (t *testResource) Value(ctx context.Context) (cty.Value, tfdiags.Diagnostics) { | |
| return t.config.Value(ctx) | |
| } | |
| // ValueSourceRange implements exprs.Valuer. | |
| func (t *testResource) ValueSourceRange() *tfdiags.SourceRange { | |
| return t.config.ValueSourceRange() | |
| } | |
| // exampleMustParseTfvars is a helper function just to make these contrived | |
| // examples a little more concise, which tries to interpret the given string | |
| // in a similar way to how OpenTofu would normally deal with a ".tfvars" file. | |
| func exampleMustParseTfvars(src string) map[string]variableDef { | |
| f, hclDiags := hclsyntax.ParseConfig([]byte(src), "example.tfvars", hcl.InitialPos) | |
| if hclDiags.HasErrors() { | |
| panic(fmt.Sprintf("invalid tfvars: %s", hclDiags.Error())) | |
| } | |
| attrs, hclDiags := f.Body.JustAttributes() | |
| if hclDiags.HasErrors() { | |
| panic(fmt.Sprintf("invalid tfvars: %s", hclDiags.Error())) | |
| } | |
| ret := make(map[string]variableDef, len(attrs)) | |
| for name, attr := range attrs { | |
| v, hclDiags := attr.Expr.Value(nil) | |
| if hclDiags.HasErrors() { | |
| panic(fmt.Sprintf("invalid tfvars: %s", hclDiags.Error())) | |
| } | |
| ret[name] = variableDef{ | |
| val: v, | |
| rng: tfdiags.SourceRangeFromHCL(attr.Expr.Range()), | |
| } | |
| } | |
| return ret | |
| } | |
| // exampleMustParseTfvars is a helper function just to make these contrived | |
| // examples a little more concise, which tries to interpret the given string | |
| // as the mini-language implemented in this example. | |
| func exampleMustParseModule(src string, inputVals map[string]variableDef) *testModuleInstance { | |
| f, hclDiags := hclsyntax.ParseConfig([]byte(src), "config.minitofu", hcl.InitialPos) | |
| if hclDiags.HasErrors() { | |
| panic(fmt.Sprintf("invalid module: %s", hclDiags.Error())) | |
| } | |
| rootSchema := hcl.BodySchema{ | |
| Blocks: []hcl.BlockHeaderSchema{ | |
| {Type: "variable", LabelNames: []string{"name"}}, | |
| {Type: "resource", LabelNames: []string{"type", "name"}}, | |
| {Type: "data", LabelNames: []string{"type", "name"}}, | |
| {Type: "ephemeral", LabelNames: []string{"type", "name"}}, | |
| }, | |
| } | |
| content, hclDiags := f.Body.Content(&rootSchema) | |
| if hclDiags.HasErrors() { | |
| panic(fmt.Sprintf("invalid module: %s", hclDiags.Error())) | |
| } | |
| modInst := &testModuleInstance{ | |
| variables: make(map[addrs.InputVariable]*testInputVariable), | |
| resources: make(map[addrs.Resource]*testResource), | |
| } | |
| for _, block := range content.Blocks { | |
| switch block.Type { | |
| case "variable": | |
| addr := addrs.InputVariable{Name: block.Labels[0]} | |
| def, ok := inputVals[addr.Name] | |
| if !ok { | |
| panic(fmt.Sprintf("no value for input variable %q", addr.Name)) | |
| } | |
| modInst.variables[addr] = &testInputVariable{ | |
| addr: addr, | |
| targetType: cty.String, // only strings to keep this example simpler | |
| rawVal: def.val, | |
| valRange: def.rng, | |
| } | |
| case "resource", "data", "ephemeral": | |
| addr := addrs.Resource{ | |
| Mode: map[string]addrs.ResourceMode{ | |
| "resource": addrs.ManagedResourceMode, | |
| "data": addrs.DataResourceMode, | |
| "ephemeral": addrs.EphemeralResourceMode, | |
| }[block.Type], | |
| Type: block.Labels[0], | |
| Name: block.Labels[1], | |
| } | |
| typeAddr := resourceType{ | |
| Mode: addr.Mode, | |
| Name: addr.Type, | |
| } | |
| schema, ok := resourceTypes[typeAddr] | |
| if !ok { | |
| panic(fmt.Sprintf("unsupported resource type %#v", typeAddr)) | |
| } | |
| modInst.resources[addr] = &testResource{ | |
| config: exprs.NewClosure( | |
| exprs.EvalableHCLBody(block.Body, schema.DecoderSpec()), | |
| modInst, | |
| ), | |
| } | |
| } | |
| } | |
| return modInst | |
| } | |
| type variableDef struct { | |
| val cty.Value | |
| rng tfdiags.SourceRange | |
| } | |
| type resourceType struct { | |
| Mode addrs.ResourceMode | |
| Name string | |
| } | |
| var resourceTypes = map[resourceType]*configschema.Block{ | |
| resourceType{addrs.ManagedResourceMode, "example"}: &configschema.Block{ | |
| Attributes: map[string]*configschema.Attribute{ | |
| "name": &configschema.Attribute{ | |
| Type: cty.String, | |
| Required: true, | |
| }, | |
| }, | |
| }, | |
| } |