blob: 06db0804ada68c864a6c026453c9b49ed5c88d4b [file] [edit]
// 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,
},
},
},
}