mirror of
https://github.com/opentffoundation/opentf.git
synced 2025-12-21 10:47:34 -05:00
In "package tofu" today we try to do everything using a generic acyclic graph model and generic graph walk, which _works_ but tends to make every other part of the problem very hard to follow because we rely a lot on sidecar shared mutable data structures to propagate results between the isolated operations. This is the beginning of an experimental new way to do it where the "graph" is implied by a model that more closely represents how the language itself works, with explicit modelling of the relationships between different types of objects and letting results flow directly from one object to another without any big shared mutable state. There's still a lot to do before this is actually complete enough to evaluate whether it's a viable new design, but I'm considering this a good starting checkpoint since there's enough here to run a simple test of propagating data all the way from input variables to output values via intermediate local values. Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
506 lines
17 KiB
Go
506 lines
17 KiB
Go
// 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(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,
|
|
},
|
|
},
|
|
},
|
|
}
|