core: Minimal working implementation of provider-contributed functions

This establishes all of the wiring necessary for a provider's declared
functions to appear in the hcl.EvalContext when we evaluate expressions
inside a module that depends on a provider that contributes functions.

This does not yet make any attempts to guarantee that the
provider-contributed functions correctly honor the contracts such as
behaving as pure functions. Properly checking this is important because
if a function doesn't uphold Terraform's expectations then it will cause
confusing errors reported downstream, incorrectly blaming other
components for the inconsistency.
pull/34394/head
Martin Atkins 3 years ago committed by James Bardin
parent bc559c1775
commit 8b76d5052f

@ -2515,3 +2515,363 @@ resource "aws_instance" "follow" {
t.Fatal(diags.ErrWithWarnings())
}
}
func TestContext2Validate_providerContributedFunctions(t *testing.T) {
mockProvider := func() *MockProvider {
p := testProvider("test")
p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{
Functions: map[string]providers.FunctionDecl{
"count_e": {
ReturnType: cty.Number,
Parameters: []providers.FunctionParam{
{
Name: "string",
Type: cty.String,
},
},
},
},
}
p.CallFunctionFn = func(req providers.CallFunctionRequest) (resp providers.CallFunctionResponse) {
if req.FunctionName != "count_e" {
resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("incorrect function name %q", req.FunctionName))
return resp
}
if len(req.Arguments) != 1 {
resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("wrong number of arguments %d", len(req.Arguments)))
return resp
}
if req.Arguments[0].Type() != cty.String {
resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("wrong argument type %#v", req.Arguments[0].Type()))
return resp
}
if !req.Arguments[0].IsKnown() {
resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("argument is unknown"))
return resp
}
if req.Arguments[0].IsNull() {
resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("argument is null"))
return resp
}
str := req.Arguments[0].AsString()
count := strings.Count(str, "e")
resp.Result = cty.NumberIntVal(int64(count))
return resp
}
return p
}
t.Run("valid", func(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
terraform {
required_providers {
test = {
source = "hashicorp/test"
}
}
}
locals {
result = provider::test::count_e("cheese")
}
output "result" {
value = local.result
precondition {
condition = (local.result == 3)
error_message = "Wrong number of Es in my cheese."
}
}
`,
})
p := mockProvider()
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})
diags := ctx.Validate(m)
if diags.HasErrors() {
t.Fatal(diags.ErrWithWarnings())
}
if !p.CallFunctionCalled {
t.Fatal("CallFunction was not called")
}
})
t.Run("wrong name", func(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
terraform {
required_providers {
test = {
source = "hashicorp/test"
}
}
}
output "result" {
value = provider::test::cout_e("cheese")
}
`,
})
p := mockProvider()
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})
diags := ctx.Validate(m)
if p.CallFunctionCalled {
t.Error("CallFunction was called, but should not have been")
}
if !diags.HasErrors() {
t.Fatal("unexpected success")
}
if got, want := diags.Err().Error(), "Call to unknown function: There is no function named \"cout_e\" in namespace provider::test::."; !strings.Contains(got, want) {
t.Errorf("wrong error message\nwant substring: %s\ngot: %s", want, got)
}
})
t.Run("wrong namespace", func(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
terraform {
required_providers {
test = {
source = "hashicorp/test"
}
}
}
output "result" {
value = provider::toast::count_e("cheese")
}
`,
})
p := mockProvider()
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})
diags := ctx.Validate(m)
if p.CallFunctionCalled {
t.Error("CallFunction was called, but should not have been")
}
if !diags.HasErrors() {
t.Fatal("unexpected success")
}
if got, want := diags.Err().Error(), "Call to unknown function: There are no functions in namespace \"provider::toast::\"."; !strings.Contains(got, want) {
t.Errorf("wrong error message\nwant substring: %s\ngot: %s", want, got)
}
})
t.Run("wrong argument type", func(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
terraform {
required_providers {
test = {
source = "hashicorp/test"
}
}
}
output "result" {
value = provider::test::count_e([])
}
`,
})
p := mockProvider()
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})
diags := ctx.Validate(m)
if p.CallFunctionCalled {
t.Error("CallFunction was called, but should not have been")
}
if !diags.HasErrors() {
t.Fatal("unexpected success")
}
if got, want := diags.Err().Error(), "Invalid function argument: Invalid value for \"string\" parameter: string required."; !strings.Contains(got, want) {
t.Errorf("wrong error message\nwant substring: %s\ngot: %s", want, got)
}
})
t.Run("insufficient arguments", func(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
terraform {
required_providers {
test = {
source = "hashicorp/test"
}
}
}
output "result" {
value = provider::test::count_e()
}
`,
})
p := mockProvider()
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})
diags := ctx.Validate(m)
if p.CallFunctionCalled {
t.Error("CallFunction was called, but should not have been")
}
if !diags.HasErrors() {
t.Fatal("unexpected success")
}
if got, want := diags.Err().Error(), "Not enough function arguments: Function \"provider::test::count_e\" expects 1 argument(s). Missing value for \"string\"."; !strings.Contains(got, want) {
t.Errorf("wrong error message\nwant substring: %s\ngot: %s", want, got)
}
})
t.Run("too many arguments", func(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
terraform {
required_providers {
test = {
source = "hashicorp/test"
}
}
}
output "result" {
value = provider::test::count_e("cheese", "louise")
}
`,
})
p := mockProvider()
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})
diags := ctx.Validate(m)
if p.CallFunctionCalled {
t.Error("CallFunction was called, but should not have been")
}
if !diags.HasErrors() {
t.Fatal("unexpected success")
}
if got, want := diags.Err().Error(), "Too many function arguments: Function \"provider::test::count_e\" expects only 1 argument(s)."; !strings.Contains(got, want) {
t.Errorf("wrong error message\nwant substring: %s\ngot: %s", want, got)
}
})
t.Run("unexpected null argument", func(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
terraform {
required_providers {
test = {
source = "hashicorp/test"
}
}
}
output "result" {
value = provider::test::count_e(null)
}
`,
})
p := mockProvider()
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})
diags := ctx.Validate(m)
if p.CallFunctionCalled {
t.Error("CallFunction was called, but should not have been")
}
if !diags.HasErrors() {
t.Fatal("unexpected success")
}
if got, want := diags.Err().Error(), "Invalid function argument: Invalid value for \"string\" parameter: argument must not be null."; !strings.Contains(got, want) {
t.Errorf("wrong error message\nwant substring: %s\ngot: %s", want, got)
}
})
t.Run("unhandled unknown argument", func(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
terraform {
required_providers {
test = {
source = "hashicorp/test"
}
}
}
output "result" {
value = provider::test::count_e(timestamp())
}
`,
})
p := mockProvider()
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})
// For this case, validation should succeed without calling the
// function yet, because the function doesn't declare that it handles
// unknown values and so we must defer validation until a later phase.
diags := ctx.Validate(m)
if p.CallFunctionCalled {
t.Error("CallFunction was called, but should not have been")
}
if diags.HasErrors() {
t.Fatal(diags.ErrWithWarnings())
}
})
t.Run("provider not declared", func(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
terraform {
required_providers {
# Intentionally no declaration of local name "test" here
}
}
output "result" {
value = provider::test::count_e("cheese")
}
`,
})
p := mockProvider()
ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})
diags := ctx.Validate(m)
if p.CallFunctionCalled {
t.Error("CallFunction was called, but should not have been")
}
if !diags.HasErrors() {
t.Fatal("unexpected success")
}
// Module author must declare a provider requirement in order to
// import a provider's functions.
if got, want := diags.Err().Error(), "Call to unknown function: There are no functions in namespace \"provider::test::\"."; !strings.Contains(got, want) {
t.Errorf("wrong error message\nwant substring: %s\ngot: %s", want, got)
}
})
}

@ -443,7 +443,7 @@ func (ctx *BuiltinEvalContext) EvaluationScope(self addrs.Referenceable, source
InstanceKeyData: keyData,
Operation: ctx.Evaluator.Operation,
}
scope := ctx.Evaluator.Scope(data, self, source, lang.ExternalFuncs{})
scope := ctx.Evaluator.Scope(data, self, source, ctx.evaluationExternalFunctions())
// ctx.PathValue is the path of the module that contains whatever
// expression the caller will be trying to evaluate, so this will

@ -529,7 +529,7 @@ func (p *MockProvider) CallFunction(r providers.CallFunctionRequest) providers.C
p.CallFunctionCalled = true
p.CallFunctionRequest = r
if p.ReadDataSourceFn != nil {
if p.CallFunctionFn != nil {
return p.CallFunctionFn(r)
}

Loading…
Cancel
Save