Integrate provider functions (#1439)

Signed-off-by: Christian Mesh <christianmesh1@gmail.com>
This commit is contained in:
Christian Mesh 2024-04-10 08:04:08 -04:00 committed by GitHub
parent 2d373f16fa
commit b868012192
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 790 additions and 59 deletions

View File

@ -15,6 +15,7 @@ STATE ENCRYPTION
NEW FEATURES:
* Add support for a `removed` block that allows users to remove resources or modules from the state without destroying them. ([#1158](https://github.com/opentofu/opentofu/pull/1158))
* Provider-defined functions are now available. They may be referenced via `provider::<provider_name>::<funcname>(args)`. ([#1439](https://github.com/opentofu/opentofu/pull/1439))
ENHANCEMENTS:
* Added support to use `.tfvars` files from tests folder. ([#1386](https://github.com/opentofu/opentofu/pull/1386))

3
go.mod
View File

@ -51,7 +51,7 @@ require (
github.com/hashicorp/go-uuid v1.0.3
github.com/hashicorp/go-version v1.6.0
github.com/hashicorp/hcl v1.0.0
github.com/hashicorp/hcl/v2 v2.19.1
github.com/hashicorp/hcl/v2 v2.20.1
github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d
github.com/hashicorp/terraform-svchost v0.1.1
github.com/jmespath/go-jmespath v0.4.0
@ -230,7 +230,6 @@ require (
github.com/rivo/uniseg v0.2.0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/samber/lo v1.37.0 // indirect
github.com/sergi/go-diff v1.2.0 // indirect
github.com/shopspring/decimal v1.3.1 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cobra v1.6.1 // indirect

6
go.sum
View File

@ -716,8 +716,8 @@ github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/hcl/v2 v2.19.1 h1://i05Jqznmb2EXqa39Nsvyan2o5XyMowW5fnCKW5RPI=
github.com/hashicorp/hcl/v2 v2.19.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
github.com/hashicorp/hcl/v2 v2.20.1 h1:M6hgdyz7HYt1UN9e61j+qKJBqR3orTWbI1HKBJEdxtc=
github.com/hashicorp/hcl/v2 v2.20.1/go.mod h1:TZDqQ4kNKCbh1iJp99FdPiUaVDDUPivbqxZulxDYqL4=
github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d h1:9ARUJJ1VVynB176G1HCwleORqCaXm/Vx0uUi0dL26I0=
github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d/go.mod h1:Yog5+CPEM3c99L1CL2CFCYoSzgWm5vTU58idbRUaLik=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
@ -981,8 +981,6 @@ github.com/samber/lo v1.37.0 h1:XjVcB8g6tgUp8rsPsJ2CvhClfImrpL04YpQHXeHPhRw=
github.com/samber/lo v1.37.0/go.mod h1:9vaz2O4o8oOnK23pd2TrXufcbdbJIa3b6cstBWKpopA=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=

View File

@ -9,6 +9,7 @@ import (
"encoding/json"
"fmt"
"github.com/opentofu/opentofu/internal/lang"
"github.com/opentofu/opentofu/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
@ -57,9 +58,9 @@ func Marshal(f map[string]function.Function) ([]byte, tfdiags.Diagnostics) {
signatures := newFunctions()
for name, v := range f {
if name == "can" {
if name == "can" || name == lang.CoreNamespace+"can" {
signatures.Signatures[name] = marshalCan(v)
} else if name == "try" {
} else if name == "try" || name == lang.CoreNamespace+"try" {
signatures.Signatures[name] = marshalTry(v)
} else {
signature, err := marshalFunction(v)

View File

@ -79,7 +79,7 @@ Usage: tofu [global options] metadata functions -json
func isIgnoredFunction(name string) bool {
for _, i := range ignoredFunctions {
if i == name {
if i == name || lang.CoreNamespace+i == name {
return true
}
}

View File

@ -7,10 +7,13 @@ package lang
import (
"fmt"
"regexp"
"strings"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/ext/dynblock"
"github.com/hashicorp/hcl/v2/hcldec"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
@ -71,7 +74,7 @@ func (s *Scope) EvalBlock(body hcl.Body, schema *configschema.Block) (cty.Value,
body = blocktoattr.FixUpBlockAttrs(body, schema)
val, evalDiags := hcldec.Decode(body, spec, ctx)
diags = diags.Append(evalDiags)
diags = diags.Append(s.enhanceFunctionDiags(evalDiags))
return val, diags
}
@ -149,7 +152,7 @@ func (s *Scope) EvalSelfBlock(body hcl.Body, self cty.Value, schema *configschem
}
val, decDiags := hcldec.Decode(body, schema.DecoderSpec(), ctx)
diags = diags.Append(decDiags)
diags = diags.Append(s.enhanceFunctionDiags(decDiags))
return val, diags
}
@ -175,7 +178,7 @@ func (s *Scope) EvalExpr(expr hcl.Expression, wantType cty.Type) (cty.Value, tfd
}
val, evalDiags := expr.Value(ctx)
diags = diags.Append(evalDiags)
diags = diags.Append(s.enhanceFunctionDiags(evalDiags))
if wantType != cty.DynamicPseudoType {
var convErr error
@ -196,6 +199,62 @@ func (s *Scope) EvalExpr(expr hcl.Expression, wantType cty.Type) (cty.Value, tfd
return val, diags
}
// Common provider function namespace form
var providerFuncNamespace = regexp.MustCompile("^([^:]*)::([^:]*)::$")
// Identify and enhance any function related dialogs produced by a hcl.EvalContext
func (s *Scope) enhanceFunctionDiags(diags hcl.Diagnostics) hcl.Diagnostics {
out := make(hcl.Diagnostics, len(diags))
for i, diag := range diags {
out[i] = diag
if funcExtra, ok := diag.Extra.(hclsyntax.FunctionCallUnknownDiagExtra); ok {
funcName := funcExtra.CalledFunctionName()
// prefix::stuff::
fullNamespace := funcExtra.CalledFunctionNamespace()
if !strings.Contains(fullNamespace, "::") {
// Not a namespaced function, no enhancements nessesary
continue
}
// Insert the enhanced copy of diag into diags
enhanced := *diag
out[i] = &enhanced
// Update enhanced with additional details
if fullNamespace == CoreNamespace {
// Error is in core namespace, mirror non-core equivalent
enhanced.Summary = "Call to unknown function"
enhanced.Detail = fmt.Sprintf("There is no builtin (%s) function named %q.", CoreNamespace, funcName)
continue
}
match := providerFuncNamespace.FindSubmatch([]byte(fullNamespace))
if match == nil || string(match[1]) != "provider" {
// complete mismatch or invalid prefix
enhanced.Summary = "Invalid function format"
enhanced.Detail = fmt.Sprintf("Expected provider::<provider_name>::<function_name>, instead found \"%s%s\"", fullNamespace, funcName)
continue
}
providerName := string(match[2])
addr, ok := s.ProviderNames[providerName]
if !ok {
// Provider not registered
enhanced.Summary = "Unknown function provider"
enhanced.Detail = fmt.Sprintf("Provider %q does not exist within the required_providers of this module", providerName)
} else {
// Func not in provider
enhanced.Summary = "Function not found in provider"
enhanced.Detail = fmt.Sprintf("Function %q was not registered by provider named %q of type %q", funcName, providerName, addr)
}
}
}
return out
}
// EvalReference evaluates the given reference in the receiving scope and
// returns the resulting value. The value will be converted to the given type before
// it is returned if possible, or else an error diagnostic will be produced

View File

@ -15,6 +15,7 @@ import (
"github.com/opentofu/opentofu/internal/instances"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hcldec"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/zclconf/go-cty/cty"
@ -850,3 +851,102 @@ func TestScopeEvalSelfBlock(t *testing.T) {
})
}
}
func Test_enhanceFunctionDiags(t *testing.T) {
tests := []struct {
Name string
Config string
Summary string
Detail string
}{
{
"Missing builtin function",
"attr = missing_function(54)",
"Call to unknown function",
"There is no function named \"missing_function\".",
},
{
"Missing core function",
"attr = core::missing_function(54)",
"Call to unknown function",
"There is no builtin (core::) function named \"missing_function\".",
},
{
"Invalid prefix",
"attr = magic::missing_function(54)",
"Invalid function format",
"Expected provider::<provider_name>::<function_name>, instead found \"magic::missing_function\"",
},
{
"Broken prefix",
"attr = magic::foo::bar::extra::missing_function(54)",
"Invalid function format",
"Expected provider::<provider_name>::<function_name>, instead found \"magic::foo::bar::extra::missing_function\"",
},
{
"Missing provider",
"attr = provider::unknown::func(54)",
"Unknown function provider",
"Provider \"unknown\" does not exist within the required_providers of this module",
},
{
"Missing function",
"attr = provider::known::func(54)",
"Function not found in provider",
"Function \"func\" was not registered by provider named \"known\" of type \"hostname/namespace/type\"",
},
}
schema := &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"attr": {
Type: cty.String,
},
},
}
spec := schema.DecoderSpec()
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
file, parseDiags := hclsyntax.ParseConfig([]byte(test.Config), "", hcl.Pos{Line: 1, Column: 1})
if len(parseDiags) != 0 {
t.Errorf("unexpected diagnostics during parse")
for _, diag := range parseDiags {
t.Errorf("- %s", diag)
}
return
}
body := file.Body
scope := &Scope{
ProviderNames: map[string]addrs.Provider{
"known": addrs.Provider{
Type: "type",
Namespace: "namespace",
Hostname: "hostname",
},
},
}
ctx, ctxDiags := scope.EvalContext(nil)
if ctxDiags.HasErrors() {
t.Fatalf("Unexpected ctxDiags, %#v", ctxDiags)
}
_, evalDiags := hcldec.Decode(body, spec, ctx)
diags := scope.enhanceFunctionDiags(evalDiags)
if len(diags) != 1 {
t.Fatalf("Expected 1 diag, got %d", len(diags))
}
diag := diags[0]
if diag.Summary != test.Summary {
t.Fatalf("Expected Summary %q, got %q", test.Summary, diag.Summary)
}
if diag.Detail != test.Detail {
t.Fatalf("Expected Detail %q, got %q", test.Detail, diag.Detail)
}
})
}
}

View File

@ -24,6 +24,8 @@ var impureFunctions = []string{
"uuid",
}
const CoreNamespace = "core::"
// Functions returns the set of functions that should be used to when evaluating
// expressions in the receiving scope.
func (s *Scope) Functions() map[string]function.Function {
@ -189,12 +191,22 @@ func (s *Scope) Functions() map[string]function.Function {
}
}
coreNames := make([]string, 0)
// Add a description to each function and parameter based on the
// contents of descriptionList.
// One must create a matching description entry whenever a new
// function is introduced.
for name, f := range s.funcs {
s.funcs[name] = funcs.WithDescription(name, f)
coreNames = append(coreNames, name)
}
// Copy all stdlib funcs into core:: namespace
for _, name := range coreNames {
s.funcs[CoreNamespace+name] = s.funcs[name]
}
for name, f := range s.ProviderFunctions {
s.funcs[name] = f
}
}
s.funcsLock.Unlock()

View File

@ -6,6 +6,7 @@
package lang
import (
"strings"
"testing"
"github.com/opentofu/opentofu/internal/lang/funcs"
@ -20,14 +21,15 @@ func TestFunctionDescriptions(t *testing.T) {
allFunctions := scope.Functions()
// plantimestamp isn't available with ConsoleMode: true
expectedFunctionCount := len(funcs.DescriptionList) - 1
// THis also includes the core:: prefixed functions
expectedFunctionCount := (len(funcs.DescriptionList) - 1) * 2
if len(allFunctions) != expectedFunctionCount {
t.Errorf("DescriptionList length expected: %d, got %d", len(allFunctions), expectedFunctionCount)
}
for name := range allFunctions {
_, ok := funcs.DescriptionList[name]
_, ok := funcs.DescriptionList[strings.TrimPrefix(name, CoreNamespace)]
if !ok {
t.Errorf("missing DescriptionList entry for function %q", name)
}

View File

@ -9,6 +9,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
@ -1246,9 +1247,10 @@ func TestFunctions(t *testing.T) {
// suitable type.
for _, impureFunc := range impureFunctions {
delete(allFunctions, impureFunc)
delete(allFunctions, CoreNamespace+impureFunc)
}
for f := range scope.Functions() {
if _, ok := tests[f]; !ok {
if _, ok := tests[strings.TrimPrefix(f, CoreNamespace)]; !ok {
t.Errorf("Missing test for function %s\n", f)
}
}

View File

@ -71,6 +71,9 @@ type Scope struct {
// PlanTimestamp is a timestamp representing when the plan was made. It will
// either have been generated during this operation or read from the plan.
PlanTimestamp time.Time
ProviderFunctions map[string]function.Function
ProviderNames map[string]addrs.Provider
}
// SetActiveExperiments allows a caller to declare that a set of experiments

View File

@ -718,7 +718,8 @@ func (p *GRPCProvider) CallFunction(r providers.CallFunctionRequest) (resp provi
// Translate the arguments
// As this is functionality is always sitting behind cty/function.Function, we skip some validation
// checks of from the function and param spec. We still include basic validation to prevent panics,
// just in case there are bugs in cty
// just in case there are bugs in cty. See context_functions_test.go for explicit testing of argument
// handling and short-circuiting.
if len(r.Arguments) < len(spec.Parameters) {
// This should be unreachable
resp.Error = fmt.Errorf("invalid CallFunctionRequest: function %s expected %d parameters and got %d instead", r.Name, len(spec.Parameters), len(r.Arguments))

View File

@ -707,7 +707,8 @@ func (p *GRPCProvider) CallFunction(r providers.CallFunctionRequest) (resp provi
// Translate the arguments
// As this is functionality is always sitting behind cty/function.Function, we skip some validation
// checks of from the function and param spec. We still include basic validation to prevent panics,
// just in case there are bugs in cty
// just in case there are bugs in cty. See context_functions_test.go for explicit testing of argument
// handling and short-circuiting.
if len(r.Arguments) < len(spec.Parameters) {
// This should be unreachable
resp.Error = fmt.Errorf("invalid CallFunctionRequest: function %s expected %d parameters and got %d instead", r.Name, len(spec.Parameters), len(r.Arguments))

View File

@ -169,7 +169,7 @@ type FunctionParameterSpec struct {
Type cty.Type
// Null values alowed for the parameter
AllowNullValue bool
// Unknown Values allowd for the parameter
// Unknown Values allowed for the parameter
// Implies the Return type of the function is also Unknown
AllowUnknownValues bool
// Human-readable documentation for the parameter

View File

@ -134,7 +134,10 @@ func NewContext(opts *ContextOpts) (*Context, tfdiags.Diagnostics) {
par = 10
}
plugins := newContextPlugins(opts.Providers, opts.Provisioners)
plugins, err := newContextPlugins(opts.Providers, opts.Provisioners)
if err != nil {
return nil, diags.Append(err)
}
log.Printf("[TRACE] tofu.NewContext: complete")

View File

@ -0,0 +1,109 @@
package tofu
import (
"errors"
"fmt"
"log"
"sync"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/providers"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
)
// Lazily creates a single instance of a provider for repeated use.
// Concurrency safe
func lazyProviderInstance(addr addrs.Provider, factory providers.Factory) providers.Factory {
var provider providers.Interface
var providerLock sync.Mutex
var err error
return func() (providers.Interface, error) {
providerLock.Lock()
defer providerLock.Unlock()
if provider == nil {
log.Printf("[TRACE] tofu.contextFunctions: Initializing function provider %q", addr)
provider, err = factory()
}
return provider, err
}
}
// Loop through all functions specified and build a map of name -> function.
// All functions will use the same lazily initialized provider instance.
// This instance will run until the application is terminated.
func providerFunctions(addr addrs.Provider, funcSpecs map[string]providers.FunctionSpec, factory providers.Factory) map[string]function.Function {
lazy := lazyProviderInstance(addr, factory)
functions := make(map[string]function.Function)
for name, spec := range funcSpecs {
log.Printf("[TRACE] tofu.contextFunctions: Registering function %q in provider type %q", name, addr)
if _, ok := functions[name]; ok {
panic(fmt.Sprintf("broken provider %q: multiple functions registered under name %q", addr, name))
}
functions[name] = providerFunction(name, spec, lazy)
}
return functions
}
// Turn a provider function spec into a cty callable function
// This will use the instance factory to get a provider to support the
// function call.
func providerFunction(name string, spec providers.FunctionSpec, instance providers.Factory) function.Function {
params := make([]function.Parameter, len(spec.Parameters))
for i, param := range spec.Parameters {
params[i] = providerFunctionParameter(param)
}
var varParam *function.Parameter
if spec.VariadicParameter != nil {
value := providerFunctionParameter(*spec.VariadicParameter)
varParam = &value
}
impl := func(args []cty.Value, retType cty.Type) (cty.Value, error) {
provider, err := instance()
if err != nil {
// Incredibly unlikely
return cty.UnknownVal(retType), err
}
resp := provider.CallFunction(providers.CallFunctionRequest{
Name: name,
Arguments: args,
})
if argError, ok := resp.Error.(*providers.CallFunctionArgumentError); ok {
// Convert ArgumentError to cty error
return resp.Result, function.NewArgError(argError.FunctionArgument, errors.New(argError.Text))
}
return resp.Result, resp.Error
}
return function.New(&function.Spec{
Description: spec.Summary,
Params: params,
VarParam: varParam,
Type: function.StaticReturnType(spec.Return),
Impl: impl,
})
}
// Simple mapping of function parameter spec to function parameter
func providerFunctionParameter(spec providers.FunctionParameterSpec) function.Parameter {
return function.Parameter{
Name: spec.Name,
Description: spec.Description,
Type: spec.Type,
AllowNull: spec.AllowNullValue,
AllowUnknown: spec.AllowUnknownValues,
// I don't believe this is allowable for provider functions
AllowDynamicType: false,
// force cty to strip marks ahead of time and re-add them to the resulting object
// GRPC: failed: value has marks, so it cannot be serialized.
AllowMarked: false,
}
}

View File

@ -0,0 +1,290 @@
package tofu
import (
"fmt"
"strings"
"testing"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/lang/marks"
"github.com/opentofu/opentofu/internal/providers"
"github.com/zclconf/go-cty/cty"
)
func TestFunctions(t *testing.T) {
mockProvider := &MockProvider{
GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
Provider: providers.Schema{},
Functions: map[string]providers.FunctionSpec{
"echo": providers.FunctionSpec{
Parameters: []providers.FunctionParameterSpec{providers.FunctionParameterSpec{
Name: "input",
Type: cty.String,
AllowNullValue: false,
AllowUnknownValues: false,
}},
Return: cty.String,
},
"concat": providers.FunctionSpec{
Parameters: []providers.FunctionParameterSpec{providers.FunctionParameterSpec{
Name: "input",
Type: cty.String,
AllowNullValue: false,
AllowUnknownValues: false,
}},
VariadicParameter: &providers.FunctionParameterSpec{
Name: "vary",
Type: cty.String,
AllowNullValue: false,
},
Return: cty.String,
},
"coalesce": providers.FunctionSpec{
Parameters: []providers.FunctionParameterSpec{providers.FunctionParameterSpec{
Name: "input1",
Type: cty.String,
AllowNullValue: true,
AllowUnknownValues: false,
}, providers.FunctionParameterSpec{
Name: "input2",
Type: cty.String,
AllowNullValue: false,
AllowUnknownValues: false,
}},
Return: cty.String,
},
"unknown_param": providers.FunctionSpec{
Parameters: []providers.FunctionParameterSpec{providers.FunctionParameterSpec{
Name: "input",
Type: cty.String,
AllowNullValue: false,
AllowUnknownValues: true,
}},
Return: cty.String,
},
"error_param": providers.FunctionSpec{
Parameters: []providers.FunctionParameterSpec{providers.FunctionParameterSpec{
Name: "input",
Type: cty.String,
AllowNullValue: false,
AllowUnknownValues: false,
}},
Return: cty.String,
},
},
},
}
mockProvider.CallFunctionFn = func(req providers.CallFunctionRequest) (resp providers.CallFunctionResponse) {
switch req.Name {
case "echo":
resp.Result = req.Arguments[0]
case "concat":
str := ""
for _, arg := range req.Arguments {
str += arg.AsString()
}
resp.Result = cty.StringVal(str)
case "coalesce":
resp.Result = req.Arguments[0]
if resp.Result.IsNull() {
resp.Result = req.Arguments[1]
}
case "unknown_param":
resp.Result = cty.StringVal("knownvalue")
case "error_param":
resp.Error = &providers.CallFunctionArgumentError{
Text: "my error text",
FunctionArgument: 0,
}
default:
panic("Invalid function")
}
return resp
}
// Initial call to getSchema
expectProviderInit := true
mockFactory := func() (providers.Interface, error) {
if !expectProviderInit {
return nil, fmt.Errorf("Unexpected call to provider init!")
}
expectProviderInit = false
return mockProvider, nil
}
addr := addrs.NewDefaultProvider("mock")
plugins := newContextPluginsForTest(map[addrs.Provider]providers.Factory{
addr: mockFactory,
}, t)
t.Run("empty names map", func(t *testing.T) {
res := plugins.Functions(map[string]addrs.Provider{})
if len(res.ProviderNames) != 0 {
t.Error("did not expect any names")
}
if len(res.Functions) != 0 {
t.Error("did not expect any functions")
}
})
t.Run("broken names map", func(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Errorf("Expected panic due to broken configuration")
}
}()
res := plugins.Functions(map[string]addrs.Provider{
"borky": addrs.NewDefaultProvider("my_borky"),
})
if len(res.ProviderNames) != 0 {
t.Error("did not expect any names")
}
if len(res.Functions) != 0 {
t.Error("did not expect any functions")
}
})
res := plugins.Functions(map[string]addrs.Provider{
"mockname": addr,
})
if res.ProviderNames["mockname"] != addr {
t.Errorf("expected names %q, got %q", addr, res.ProviderNames["mockname"])
}
ctx := &hcl.EvalContext{
Functions: res.Functions,
Variables: map[string]cty.Value{
"unknown_value": cty.UnknownVal(cty.String),
"sensitive_value": cty.StringVal("sensitive!").Mark(marks.Sensitive),
},
}
evaluate := func(exprStr string) (cty.Value, hcl.Diagnostics) {
expr, diags := hclsyntax.ParseExpression([]byte(exprStr), "exprtest", hcl.InitialPos)
if diags.HasErrors() {
t.Fatal(diags)
}
return expr.Value(ctx)
}
t.Run("echo function", func(t *testing.T) {
// These are all assumptions that the provider implementation should not have to worry about:
t.Log("Checking not enough arguments")
_, diags := evaluate("provider::mockname::echo()")
if !strings.Contains(diags.Error(), `Not enough function arguments; Function "provider::mockname::echo" expects 1 argument(s). Missing value for "input"`) {
t.Error(diags.Error())
}
t.Log("Checking too many arguments")
_, diags = evaluate(`provider::mockname::echo("1", "2", "3")`)
if !strings.Contains(diags.Error(), `Too many function arguments; Function "provider::mockname::echo" expects only 1 argument(s)`) {
t.Error(diags.Error())
}
t.Log("Checking null argument")
_, diags = evaluate(`provider::mockname::echo(null)`)
if !strings.Contains(diags.Error(), `Invalid function argument; Invalid value for "input" parameter: argument must not be null`) {
t.Error(diags.Error())
}
t.Log("Checking unknown argument")
val, diags := evaluate(`provider::mockname::echo(unknown_value)`)
if diags.HasErrors() {
t.Error(diags.Error())
}
if !val.RawEquals(cty.UnknownVal(cty.String)) {
t.Error(val.AsString())
}
// Actually test the function implementation
// Do this a few times but only expect a single init()
expectProviderInit = true
for i := 0; i < 5; i++ {
t.Log("Checking valid argument")
val, diags = evaluate(`provider::mockname::echo("hello functions!")`)
if diags.HasErrors() {
t.Error(diags.Error())
}
if !val.RawEquals(cty.StringVal("hello functions!")) {
t.Error(val.AsString())
}
if expectProviderInit {
t.Error("Expected provider init to have been called")
}
}
t.Log("Checking sensitive argument")
val, diags = evaluate(`provider::mockname::echo(sensitive_value)`)
if diags.HasErrors() {
t.Error(diags.Error())
}
if !val.RawEquals(cty.StringVal("sensitive!").Mark(marks.Sensitive)) {
t.Error(val.AsString())
}
})
t.Run("concat function", func(t *testing.T) {
// Make sure varargs are handled properly
// Single
val, diags := evaluate(`provider::mockname::concat("foo")`)
if diags.HasErrors() {
t.Error(diags.Error())
}
if !val.RawEquals(cty.StringVal("foo")) {
t.Error(val.AsString())
}
// Multi
val, diags = evaluate(`provider::mockname::concat("foo", "bar", "baz")`)
if diags.HasErrors() {
t.Error(diags.Error())
}
if !val.RawEquals(cty.StringVal("foobarbaz")) {
t.Error(val.AsString())
}
})
t.Run("coalesce function", func(t *testing.T) {
val, diags := evaluate(`provider::mockname::coalesce("first", "second")`)
if diags.HasErrors() {
t.Error(diags.Error())
}
if !val.RawEquals(cty.StringVal("first")) {
t.Error(val.AsString())
}
val, diags = evaluate(`provider::mockname::coalesce(null, "second")`)
if diags.HasErrors() {
t.Error(diags.Error())
}
if !val.RawEquals(cty.StringVal("second")) {
t.Error(val.AsString())
}
})
t.Run("unknown_param function", func(t *testing.T) {
val, diags := evaluate(`provider::mockname::unknown_param(unknown_value)`)
if diags.HasErrors() {
t.Error(diags.Error())
}
if !val.RawEquals(cty.StringVal("knownvalue")) {
t.Error(val.AsString())
}
})
t.Run("error_param function", func(t *testing.T) {
_, diags := evaluate(`provider::mockname::error_param("foo")`)
if !strings.Contains(diags.Error(), `Invalid function argument; Invalid value for "input" parameter: my error text.`) {
t.Error(diags.Error())
}
})
}

View File

@ -13,6 +13,7 @@ import (
"github.com/opentofu/opentofu/internal/configs/configschema"
"github.com/opentofu/opentofu/internal/providers"
"github.com/opentofu/opentofu/internal/provisioners"
"github.com/zclconf/go-cty/cty/function"
)
// contextPlugins represents a library of available plugins (providers and
@ -21,15 +22,50 @@ import (
// about the providers for performance reasons.
type contextPlugins struct {
providerFactories map[addrs.Provider]providers.Factory
providerFunctions map[addrs.Provider]map[string]function.Function
provisionerFactories map[string]provisioners.Factory
}
func newContextPlugins(providerFactories map[addrs.Provider]providers.Factory, provisionerFactories map[string]provisioners.Factory) *contextPlugins {
func newContextPlugins(providerFactories map[addrs.Provider]providers.Factory, provisionerFactories map[string]provisioners.Factory) (*contextPlugins, error) {
ret := &contextPlugins{
providerFactories: providerFactories,
provisionerFactories: provisionerFactories,
}
return ret
// This is a bit convoluted as we need to use the ProviderSchema function call below to
// validate and initialize the provider schemas. Long term the whole provider abstraction
// needs to be re-thought.
var err error
ret.providerFunctions, err = ret.buildProviderFunctions()
if err != nil {
return nil, err
}
return ret, nil
}
// Loop through all of the providerFactories and build a map of addr -> functions
// As a side effect, this initialzes the schema cache if not already initialized, with the proper validation path.
func (cp *contextPlugins) buildProviderFunctions() (map[addrs.Provider]map[string]function.Function, error) {
funcs := make(map[addrs.Provider]map[string]function.Function)
// Pull all functions out of given providers
for addr, factory := range cp.providerFactories {
addr := addr
factory := factory
// Before functions, the provider schemas were already pre-loaded and cached. That initial caching
// has been moved here. When the provider abstraction layers are refactored, this could instead
// expose and use provider.GetFunctions instead of needing to load and cache the whole schema.
// However, at the time of writing there is no benefit to defer caching these schemas in code
// paths which build a tofu.Context.
schema, err := cp.ProviderSchema(addr)
if err != nil {
return nil, err
}
funcs[addr] = providerFunctions(addr, schema.Functions, factory)
}
return funcs, nil
}
func (cp *contextPlugins) HasProvider(addr addrs.Provider) bool {
@ -77,6 +113,7 @@ func (cp *contextPlugins) ProviderSchema(addr addrs.Provider) (providers.Provide
// That is because we're checking *prior* to the provider's instantiation.
// GetProviderSchemaOptional only says that *if we instantiate a provider*,
// then we need to run the get schema call at least once.
// BUG This SHORT CIRCUITS the logic below and is not the only code which inserts provider schemas into the cache!!
schemas, ok := providers.SchemaCache.Get(addr)
if ok {
log.Printf("[TRACE] tofu.contextPlugins: Serving provider %q schema from global schema cache", addr)
@ -179,3 +216,26 @@ func (cp *contextPlugins) ProvisionerSchema(typ string) (*configschema.Block, er
return resp.Provisioner, nil
}
type ProviderFunctions struct {
ProviderNames map[string]addrs.Provider
Functions map[string]function.Function
}
// Functions provides a map of provider::<provider_name>::<function> for a given provider type.
// All providers of a given type use the same functions and provider instance and
// additional names do not incur any performance penalty.
func (cp *contextPlugins) Functions(names map[string]addrs.Provider) *ProviderFunctions {
providerFuncs := &ProviderFunctions{
ProviderNames: names,
Functions: make(map[string]function.Function),
}
for name, addr := range names {
funcs := cp.providerFunctions[addr]
for fn_name, fn := range funcs {
providerFuncs.Functions[fmt.Sprintf("provider::%s::%s", name, fn_name)] = fn
}
}
return providerFuncs
}

View File

@ -6,6 +6,8 @@
package tofu
import (
"testing"
"github.com/zclconf/go-cty/cty"
"github.com/opentofu/opentofu/internal/addrs"
@ -85,3 +87,11 @@ func simpleTestSchema() *configschema.Block {
},
}
}
func newContextPluginsForTest(providerFactories map[addrs.Provider]providers.Factory, t *testing.T) *contextPlugins {
plugins, err := newContextPlugins(providerFactories, nil)
if err != nil {
t.Fatal(err.Error())
}
return plugins
}

View File

@ -69,6 +69,8 @@ type BuiltinEvalContext struct {
ProviderLock *sync.Mutex
ProvisionerCache map[string]provisioners.Interface
ProvisionerLock *sync.Mutex
FunctionCache *ProviderFunctions
FunctionLock sync.Mutex
ChangesValue *plans.ChangesSync
StateValue *states.SyncState
ChecksValue *checks.State
@ -87,6 +89,8 @@ func (ctx *BuiltinEvalContext) WithPath(path addrs.ModuleInstance) EvalContext {
newCtx := *ctx
newCtx.pathSet = true
newCtx.PathValue = path
newCtx.FunctionCache = nil
newCtx.FunctionLock = sync.Mutex{}
return &newCtx
}
@ -403,7 +407,6 @@ func (ctx *BuiltinEvalContext) EvaluationScope(self addrs.Referenceable, source
InstanceKeyData: keyData,
Operation: ctx.Evaluator.Operation,
}
scope := ctx.Evaluator.Scope(data, self, source)
// ctx.PathValue is the path of the module that contains whatever
// expression the caller will be trying to evaluate, so this will
@ -412,9 +415,28 @@ func (ctx *BuiltinEvalContext) EvaluationScope(self addrs.Referenceable, source
// package itself works. The nil check here is for robustness in
// incompletely-mocked testing situations; mc should never be nil in
// real situations.
if mc := ctx.Evaluator.Config.DescendentForInstance(ctx.PathValue); mc != nil {
scope.SetActiveExperiments(mc.Module.ActiveExperiments)
mc := ctx.Evaluator.Config.DescendentForInstance(ctx.PathValue)
if mc == nil || mc.Module.ProviderRequirements == nil {
return ctx.Evaluator.Scope(data, self, source, nil)
}
ctx.FunctionLock.Lock()
defer ctx.FunctionLock.Unlock()
if ctx.FunctionCache == nil {
names := make(map[string]addrs.Provider)
// Providers must exist within required_providers to register their functions
for name, provider := range mc.Module.ProviderRequirements.RequiredProviders {
// Functions are only registered under their name, not their type name
names[name] = provider.Type
}
ctx.FunctionCache = ctx.Plugins.Functions(names)
}
scope := ctx.Evaluator.Scope(data, self, source, ctx.FunctionCache)
scope.SetActiveExperiments(mc.Module.ActiveExperiments)
return scope
}

View File

@ -64,9 +64,9 @@ func TestBuildingEvalContextInitProvider(t *testing.T) {
ctx = ctx.WithPath(addrs.RootModuleInstance).(*BuiltinEvalContext)
ctx.ProviderLock = &lock
ctx.ProviderCache = make(map[string]providers.Interface)
ctx.Plugins = newContextPlugins(map[addrs.Provider]providers.Factory{
ctx.Plugins = newContextPluginsForTest(map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): providers.FactoryFixed(testP),
}, nil)
}, t)
providerAddrDefault := addrs.AbsProviderConfig{
Module: addrs.RootModule,

View File

@ -76,7 +76,10 @@ type Evaluator struct {
// If the "self" argument is nil then the "self" object is not available
// in evaluated expressions. Otherwise, it behaves as an alias for the given
// address.
func (e *Evaluator) Scope(data lang.Data, self addrs.Referenceable, source addrs.Referenceable) *lang.Scope {
func (e *Evaluator) Scope(data lang.Data, self addrs.Referenceable, source addrs.Referenceable, functions *ProviderFunctions) *lang.Scope {
if functions == nil {
functions = new(ProviderFunctions)
}
return &lang.Scope{
Data: data,
ParseRef: addrs.ParseRef,
@ -85,6 +88,9 @@ func (e *Evaluator) Scope(data lang.Data, self addrs.Referenceable, source addrs
PureOnly: e.Operation != walkApply && e.Operation != walkDestroy && e.Operation != walkEval,
BaseDir: ".", // Always current working directory for now.
PlanTimestamp: e.PlanTimestamp,
// Can't pass the object directly as it would cause an import loop
ProviderNames: functions.ProviderNames,
ProviderFunctions: functions.Functions,
}
}

View File

@ -31,7 +31,7 @@ func TestEvaluatorGetTerraformAttr(t *testing.T) {
data := &evaluationStateData{
Evaluator: evaluator,
}
scope := evaluator.Scope(data, nil, nil)
scope := evaluator.Scope(data, nil, nil, nil)
t.Run("workspace", func(t *testing.T) {
want := cty.StringVal("foo")
@ -61,7 +61,7 @@ func TestEvaluatorGetPathAttr(t *testing.T) {
data := &evaluationStateData{
Evaluator: evaluator,
}
scope := evaluator.Scope(data, nil, nil)
scope := evaluator.Scope(data, nil, nil, nil)
t.Run("module", func(t *testing.T) {
want := cty.StringVal("bar/baz")
@ -127,7 +127,7 @@ func TestEvaluatorGetOutputValue(t *testing.T) {
data := &evaluationStateData{
Evaluator: evaluator,
}
scope := evaluator.Scope(data, nil, nil)
scope := evaluator.Scope(data, nil, nil, nil)
want := cty.StringVal("first").Mark(marks.Sensitive)
got, diags := scope.Data.GetOutput(addrs.OutputValue{
@ -194,7 +194,7 @@ func TestEvaluatorGetInputVariable(t *testing.T) {
data := &evaluationStateData{
Evaluator: evaluator,
}
scope := evaluator.Scope(data, nil, nil)
scope := evaluator.Scope(data, nil, nil, nil)
want := cty.StringVal("bar").Mark(marks.Sensitive)
got, diags := scope.Data.GetInputVariable(addrs.InputVariable{
@ -338,13 +338,13 @@ func TestEvaluatorGetResource(t *testing.T) {
},
},
},
}),
}, t),
}
data := &evaluationStateData{
Evaluator: evaluator,
}
scope := evaluator.Scope(data, nil, nil)
scope := evaluator.Scope(data, nil, nil, nil)
want := cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("foo"),
@ -504,13 +504,13 @@ func TestEvaluatorGetResource_changes(t *testing.T) {
},
},
State: stateSync,
Plugins: schemaOnlyProvidersForTesting(schemas.Providers),
Plugins: schemaOnlyProvidersForTesting(schemas.Providers, t),
}
data := &evaluationStateData{
Evaluator: evaluator,
}
scope := evaluator.Scope(data, nil, nil)
scope := evaluator.Scope(data, nil, nil, nil)
want := cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("foo"),
@ -545,7 +545,7 @@ func TestEvaluatorGetModule(t *testing.T) {
data := &evaluationStateData{
Evaluator: evaluator,
}
scope := evaluator.Scope(data, nil, nil)
scope := evaluator.Scope(data, nil, nil, nil)
want := cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("bar").Mark(marks.Sensitive)})
got, diags := scope.Data.GetModule(addrs.ModuleCall{
Name: "mod",
@ -573,7 +573,7 @@ func TestEvaluatorGetModule(t *testing.T) {
data = &evaluationStateData{
Evaluator: evaluator,
}
scope = evaluator.Scope(data, nil, nil)
scope = evaluator.Scope(data, nil, nil, nil)
want = cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("baz").Mark(marks.Sensitive)})
got, diags = scope.Data.GetModule(addrs.ModuleCall{
Name: "mod",
@ -591,7 +591,7 @@ func TestEvaluatorGetModule(t *testing.T) {
data = &evaluationStateData{
Evaluator: evaluator,
}
scope = evaluator.Scope(data, nil, nil)
scope = evaluator.Scope(data, nil, nil, nil)
want = cty.ObjectVal(map[string]cty.Value{"out": cty.StringVal("baz").Mark(marks.Sensitive)})
got, diags = scope.Data.GetModule(addrs.ModuleCall{
Name: "mod",

View File

@ -114,7 +114,7 @@ For example, to correlate with indices of a referring resource, use:
},
},
},
}),
}, t),
}
for _, test := range tests {

View File

@ -732,9 +732,9 @@ func TestApplyGraphBuilder_withChecks(t *testing.T) {
},
}
plugins := newContextPlugins(map[addrs.Provider]providers.Factory{
plugins := newContextPluginsForTest(map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("aws"): providers.FactoryFixed(awsProvider),
}, nil)
}, t)
b := &ApplyGraphBuilder{
Config: testModule(t, "apply-with-checks"),

View File

@ -33,10 +33,10 @@ func TestPlanGraphBuilder(t *testing.T) {
},
}
openstackProvider := mockProviderWithResourceTypeSchema("openstack_floating_ip", simpleTestSchema())
plugins := newContextPlugins(map[addrs.Provider]providers.Factory{
plugins := newContextPluginsForTest(map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("aws"): providers.FactoryFixed(awsProvider),
addrs.NewDefaultProvider("openstack"): providers.FactoryFixed(openstackProvider),
}, nil)
}, t)
b := &PlanGraphBuilder{
Config: testModule(t, "graph-builder-plan-basic"),
@ -77,9 +77,9 @@ func TestPlanGraphBuilder_dynamicBlock(t *testing.T) {
},
},
})
plugins := newContextPlugins(map[addrs.Provider]providers.Factory{
plugins := newContextPluginsForTest(map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): providers.FactoryFixed(provider),
}, nil)
}, t)
b := &PlanGraphBuilder{
Config: testModule(t, "graph-builder-plan-dynblock"),
@ -133,9 +133,9 @@ func TestPlanGraphBuilder_attrAsBlocks(t *testing.T) {
},
},
})
plugins := newContextPlugins(map[addrs.Provider]providers.Factory{
plugins := newContextPluginsForTest(map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): providers.FactoryFixed(provider),
}, nil)
}, t)
b := &PlanGraphBuilder{
Config: testModule(t, "graph-builder-plan-attr-as-blocks"),
@ -198,9 +198,9 @@ func TestPlanGraphBuilder_targetModule(t *testing.T) {
func TestPlanGraphBuilder_forEach(t *testing.T) {
awsProvider := mockProviderWithResourceTypeSchema("aws_instance", simpleTestSchema())
plugins := newContextPlugins(map[addrs.Provider]providers.Factory{
plugins := newContextPluginsForTest(map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("aws"): providers.FactoryFixed(awsProvider),
}, nil)
}, t)
b := &PlanGraphBuilder{
Config: testModule(t, "plan-for-each"),

View File

@ -87,6 +87,11 @@ type MockProvider struct {
ReadDataSourceRequest providers.ReadDataSourceRequest
ReadDataSourceFn func(providers.ReadDataSourceRequest) providers.ReadDataSourceResponse
CallFunctionCalled bool
CallFunctionResponse *providers.CallFunctionResponse
CallFunctionRequest providers.CallFunctionRequest
CallFunctionFn func(providers.CallFunctionRequest) providers.CallFunctionResponse
CloseCalled bool
CloseError error
}
@ -516,8 +521,21 @@ func (p *MockProvider) ReadDataSource(r providers.ReadDataSourceRequest) (resp p
return resp
}
func (p *MockProvider) CallFunction(r providers.CallFunctionRequest) providers.CallFunctionResponse {
panic("Not Implemented")
func (p *MockProvider) CallFunction(r providers.CallFunctionRequest) (resp providers.CallFunctionResponse) {
p.Lock()
defer p.Unlock()
p.CallFunctionCalled = true
p.CallFunctionRequest = r
if p.CallFunctionFn != nil {
return p.CallFunctionFn(r)
}
if p.CallFunctionResponse != nil {
resp = *p.CallFunctionResponse
}
return resp
}
func (p *MockProvider) Close() error {

View File

@ -6,6 +6,8 @@
package tofu
import (
"testing"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/configs/configschema"
"github.com/opentofu/opentofu/internal/providers"
@ -33,7 +35,7 @@ func simpleTestSchemas() *Schemas {
// The intended use for this is in testing components that use schemas to
// drive other behavior, such as reference analysis during graph construction,
// but that don't actually need to interact with providers otherwise.
func schemaOnlyProvidersForTesting(schemas map[addrs.Provider]providers.ProviderSchema) *contextPlugins {
func schemaOnlyProvidersForTesting(schemas map[addrs.Provider]providers.ProviderSchema, t *testing.T) *contextPlugins {
factories := make(map[addrs.Provider]providers.Factory, len(schemas))
for providerAddr, schema := range schemas {
@ -48,5 +50,5 @@ func schemaOnlyProvidersForTesting(schemas map[addrs.Provider]providers.Provider
}
}
return newContextPlugins(factories, nil)
return newContextPluginsForTest(factories, t)
}

View File

@ -55,7 +55,7 @@ func TestTransitiveReductionTransformer(t *testing.T) {
},
},
},
}),
}, t),
}
if err := transform.Transform(&g); err != nil {
t.Fatalf("err: %s", err)

View File

@ -1,10 +1,12 @@
---
description: >-
An introduction to the built-in functions that you can use to transform and
An introduction to the functions which can be used to transform and
combine values in expressions.
---
# Built-in Functions
# Functions
## Built-in Functions
The OpenTofu language includes a number of built-in functions that you can
call from within expressions to transform and combine values. The general
@ -19,9 +21,6 @@ For more details on syntax, see
[_Function Calls_](/docs/language/expressions/function-calls)
in the Expressions section.
The OpenTofu language does not support user-defined functions, and so only
the functions built in to the language are available for use. The documentation includes a page for all of the available built-in functions.
You can experiment with the behavior of OpenTofu's built-in functions from
the OpenTofu expression console, by running
[the `tofu console` command](/docs/cli/commands/console):
@ -33,3 +32,36 @@ the OpenTofu expression console, by running
The examples in the documentation for each function use console output to
illustrate the result of calling the function with different parameters.
## Provider-defined Functions
As of OpenTofu 1.7.0, providers may define their own functions to be available during
execution.
OpenTofu iterates through the [required_providers](/docs/language/providers/requirements/) block
and queries the specified providers for any functions they wish to register. Functions are
added to the current module's context under `provider::<provider_name>::<function_name>`.
Functions are scoped to the module that requires the provider and are not inherited by child modules.
### Example:
```hcl
terraform {
required_providers {
myhelper = {
# yantrio/helpers registers a function named "echo"
source = "yantrio/helpers"
}
}
}
locals {
myval = provider::myhelper::echo("Hello Functions!")
}
```
### Notes for Provider Authors:
* Support for functions was added in protocol version 5.5 and 6.5
* OpenTofu's provider protocol is compatible with Terraform's provider protocol
* CallFunction is executed on an *Unconfigured* instance of the provider