From 0aea998ff6289bdfa1e5d7bd94c3012d07218734 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Thu, 22 Feb 2024 11:51:06 -0800 Subject: [PATCH] provider/terraform: Terraform-specific encoding functions Using the new possibility of provider-contributed functions, this introduces three new functions which live in the terraform.io/builtin/terraform provider, rather than being language builtins, due to their Terraform-domain-specific nature. The three new functions are: - tfvarsencode: takes a mapping value and tries to transform it into Terraform CLI's "tfvars" syntax, which is a small subset of HCL that only supports key/value pairs with constant values. - tfvarsdecode: takes a string containing content that could potentially appear in a "tfvars" file and returns an object representing the raw variable values defined inside. - exprencode: takes an arbitrary Terraform value and produces a string that would yield a similar value if parsed as a Terraform expression. All three of these are very specialized, of use only in unusual situations where someone is "gluing together" different Terraform configurations etc when the usual strategies such as data sources are not suitable. There's more information on the motivations for (and limitations of) each function in the included documentation. --- .../builtin/providers/terraform/functions.go | 177 +++++++++ .../providers/terraform/functions_test.go | 345 ++++++++++++++++++ .../builtin/providers/terraform/provider.go | 60 ++- .../e2etest/terraform_provider_funcs_test.go | 78 ++++ .../terraform-provider-funcs.tf | 35 ++ website/data/language-nav-data.json | 11 + .../functions/terraform-exprencode.mdx | 72 ++++ .../functions/terraform-tfvarsdecode.mdx | 70 ++++ .../functions/terraform-tfvarsencode.mdx | 72 ++++ 9 files changed, 918 insertions(+), 2 deletions(-) create mode 100644 internal/builtin/providers/terraform/functions.go create mode 100644 internal/builtin/providers/terraform/functions_test.go create mode 100644 internal/command/e2etest/terraform_provider_funcs_test.go create mode 100644 internal/command/e2etest/testdata/terraform-provider-funcs/terraform-provider-funcs.tf create mode 100644 website/docs/language/functions/terraform-exprencode.mdx create mode 100644 website/docs/language/functions/terraform-tfvarsdecode.mdx create mode 100644 website/docs/language/functions/terraform-tfvarsencode.mdx diff --git a/internal/builtin/providers/terraform/functions.go b/internal/builtin/providers/terraform/functions.go new file mode 100644 index 000000000000..3a6171e39e15 --- /dev/null +++ b/internal/builtin/providers/terraform/functions.go @@ -0,0 +1,177 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "bytes" + "fmt" + "sort" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +var functions = map[string]func([]cty.Value) (cty.Value, error){ + "tfvarsencode": tfvarsencodeFunc, + "tfvarsdecode": tfvarsdecodeFunc, + "exprencode": exprencodeFunc, +} + +func tfvarsencodeFunc(args []cty.Value) (cty.Value, error) { + // These error checks should not be hit in practice because the language + // runtime should check them before calling, so this is just for robustness + // and completeness. + if len(args) > 1 { + return cty.NilVal, function.NewArgErrorf(1, "too many arguments; only one expected") + } + if len(args) == 0 { + return cty.NilVal, fmt.Errorf("exactly one argument is required") + } + + v := args[0] + ty := v.Type() + + if v.IsNull() { + // Our functions schema does not say we allow null values, so we should + // not get to this error message if the caller respects the schema. + return cty.NilVal, function.NewArgErrorf(1, "cannot encode a null value in tfvars syntax") + } + if !v.IsWhollyKnown() { + return cty.UnknownVal(cty.String).RefineNotNull(), nil + } + + var keys []string + switch { + case ty.IsObjectType(): + atys := ty.AttributeTypes() + keys = make([]string, 0, len(atys)) + for key := range atys { + keys = append(keys, key) + } + case ty.IsMapType(): + keys = make([]string, 0, v.LengthInt()) + for it := v.ElementIterator(); it.Next(); { + k, _ := it.Element() + keys = append(keys, k.AsString()) + } + default: + return cty.NilVal, function.NewArgErrorf(1, "invalid value to encode: must be an object whose attribute names will become the encoded variable names") + } + sort.Strings(keys) + + f := hclwrite.NewEmptyFile() + body := f.Body() + for _, key := range keys { + if !hclsyntax.ValidIdentifier(key) { + // We can only encode valid identifiers as tfvars keys, since + // the HCL argument grammar requires them to be identifiers. + return cty.NilVal, function.NewArgErrorf(1, "invalid variable name %q: must be a valid identifier, per Terraform's rules for input variable declarations", key) + } + + // This index should not fail because we know that "key" is a valid + // index from the logic above. + v, _ := hcl.Index(v, cty.StringVal(key), nil) + body.SetAttributeValue(key, v) + } + + result := f.Bytes() + return cty.StringVal(string(result)), nil +} + +func tfvarsdecodeFunc(args []cty.Value) (cty.Value, error) { + // These error checks should not be hit in practice because the language + // runtime should check them before calling, so this is just for robustness + // and completeness. + if len(args) > 1 { + return cty.NilVal, function.NewArgErrorf(1, "too many arguments; only one expected") + } + if len(args) == 0 { + return cty.NilVal, fmt.Errorf("exactly one argument is required") + } + if args[0].Type() != cty.String { + return cty.NilVal, fmt.Errorf("argument must be a string") + } + if args[0].IsNull() { + return cty.NilVal, fmt.Errorf("cannot decode tfvars from a null value") + } + if !args[0].IsKnown() { + // If our input isn't known then we can't even predict the result + // type, since it will be an object type decided based on which + // arguments and values we find in the string. + return cty.DynamicVal, nil + } + + // If we get here then we know that: + // - there's exactly one element in args + // - it's a string + // - it is known and non-null + // So therefore the following is guaranteed to succeed. + src := []byte(args[0].AsString()) + + // As usual when we wrap HCL stuff up in functions, we end up needing to + // stuff HCL diagnostics into plain string error messages. This produces + // a non-ideal result but is still better than hiding the HCL-provided + // diagnosis altogether. + f, hclDiags := hclsyntax.ParseConfig(src, "", hcl.InitialPos) + if hclDiags.HasErrors() { + return cty.NilVal, fmt.Errorf("invalid tfvars syntax: %s", hclDiags.Error()) + } + attrs, hclDiags := f.Body.JustAttributes() + if hclDiags.HasErrors() { + return cty.NilVal, fmt.Errorf("invalid tfvars content: %s", hclDiags.Error()) + } + retAttrs := make(map[string]cty.Value, len(attrs)) + for name, attr := range attrs { + // Evaluating the expression with no EvalContext achieves the same + // interpretation as Terraform CLI makes of .tfvars files, rejecting + // any function calls or references to symbols. + v, hclDiags := attr.Expr.Value(nil) + if hclDiags.HasErrors() { + return cty.NilVal, fmt.Errorf("invalid expression for variable %q: %s", name, hclDiags.Error()) + } + retAttrs[name] = v + } + + return cty.ObjectVal(retAttrs), nil +} + +func exprencodeFunc(args []cty.Value) (cty.Value, error) { + // These error checks should not be hit in practice because the language + // runtime should check them before calling, so this is just for robustness + // and completeness. + if len(args) > 1 { + return cty.NilVal, function.NewArgErrorf(1, "too many arguments; only one expected") + } + if len(args) == 0 { + return cty.NilVal, fmt.Errorf("exactly one argument is required") + } + + v := args[0] + if !v.IsWhollyKnown() { + ret := cty.UnknownVal(cty.String).RefineNotNull() + // For some types we can refine further due to the HCL grammar, + // as long as w eknow the value isn't null. + if !v.Range().CouldBeNull() { + ty := v.Type() + switch { + case ty.IsObjectType() || ty.IsMapType(): + ret = ret.Refine().StringPrefixFull("{").NewValue() + case ty.IsTupleType() || ty.IsListType() || ty.IsSetType(): + ret = ret.Refine().StringPrefixFull("[").NewValue() + case ty == cty.String: + ret = ret.Refine().StringPrefixFull(`"`).NewValue() + } + } + return ret, nil + } + + // This bytes.TrimSpace is to ensure that future changes to HCL, that + // might for some reason add extra spaces before the expression (!) + // can't invalidate our unknown value prefix refinements above. + src := bytes.TrimSpace(hclwrite.TokensForValue(v).Bytes()) + return cty.StringVal(string(src)), nil +} diff --git a/internal/builtin/providers/terraform/functions_test.go b/internal/builtin/providers/terraform/functions_test.go new file mode 100644 index 000000000000..9f8def9d57ac --- /dev/null +++ b/internal/builtin/providers/terraform/functions_test.go @@ -0,0 +1,345 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/zclconf/go-cty-debug/ctydebug" + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/internal/providers" +) + +func TestTfvarsencode(t *testing.T) { + tableTestFunction(t, "tfvarsencode", []functionTest{ + { + Input: cty.ObjectVal(map[string]cty.Value{ + "string": cty.StringVal("hello"), + "number": cty.NumberIntVal(5), + "bool": cty.True, + "set": cty.SetVal([]cty.Value{cty.StringVal("beep"), cty.StringVal("boop")}), + "list": cty.SetVal([]cty.Value{cty.StringVal("bleep"), cty.StringVal("bloop")}), + "tuple": cty.SetVal([]cty.Value{cty.StringVal("bibble"), cty.StringVal("wibble")}), + "map": cty.MapVal(map[string]cty.Value{"one": cty.NumberIntVal(1)}), + "object": cty.ObjectVal(map[string]cty.Value{"one": cty.NumberIntVal(1), "true": cty.True}), + "null": cty.NullVal(cty.String), + }), + Want: cty.StringVal( + `bool = true +list = ["bleep", "bloop"] +map = { + one = 1 +} +null = null +number = 5 +object = { + one = 1 + true = true +} +set = ["beep", "boop"] +string = "hello" +tuple = ["bibble", "wibble"] +`), + }, + { + Input: cty.EmptyObjectVal, + Want: cty.StringVal(``), + }, + { + Input: cty.MapVal(map[string]cty.Value{ + "one": cty.NumberIntVal(1), + "two": cty.NumberIntVal(2), + "three": cty.NumberIntVal(3), + }), + Want: cty.StringVal( + `one = 1 +three = 3 +two = 2 +`), + }, + { + Input: cty.MapValEmpty(cty.String), + Want: cty.StringVal(``), + }, + { + Input: cty.UnknownVal(cty.EmptyObject), + Want: cty.UnknownVal(cty.String).RefineNotNull(), + }, + { + Input: cty.UnknownVal(cty.Map(cty.String)), + Want: cty.UnknownVal(cty.String).RefineNotNull(), + }, + { + Input: cty.ObjectVal(map[string]cty.Value{ + "string": cty.UnknownVal(cty.String), + }), + Want: cty.UnknownVal(cty.String).RefineNotNull(), + }, + { + Input: cty.MapVal(map[string]cty.Value{ + "string": cty.UnknownVal(cty.String), + }), + Want: cty.UnknownVal(cty.String).RefineNotNull(), + }, + { + Input: cty.NullVal(cty.EmptyObject), + WantErr: `cannot encode a null value in tfvars syntax`, + }, + { + Input: cty.NullVal(cty.Map(cty.String)), + WantErr: `cannot encode a null value in tfvars syntax`, + }, + { + Input: cty.StringVal("nope"), + WantErr: `invalid value to encode: must be an object whose attribute names will become the encoded variable names`, + }, + { + Input: cty.Zero, + WantErr: `invalid value to encode: must be an object whose attribute names will become the encoded variable names`, + }, + { + Input: cty.False, + WantErr: `invalid value to encode: must be an object whose attribute names will become the encoded variable names`, + }, + { + Input: cty.ListValEmpty(cty.String), + WantErr: `invalid value to encode: must be an object whose attribute names will become the encoded variable names`, + }, + { + Input: cty.SetValEmpty(cty.String), + WantErr: `invalid value to encode: must be an object whose attribute names will become the encoded variable names`, + }, + { + Input: cty.EmptyTupleVal, + WantErr: `invalid value to encode: must be an object whose attribute names will become the encoded variable names`, + }, + { + Input: cty.ObjectVal(map[string]cty.Value{ + "not valid identifier": cty.StringVal("!"), + }), + WantErr: `invalid variable name "not valid identifier": must be a valid identifier, per Terraform's rules for input variable declarations`, + }, + }) +} + +func TestTfvarsdecode(t *testing.T) { + tableTestFunction(t, "tfvarsdecode", []functionTest{ + { + Input: cty.StringVal(`string = "hello" +number = 2`), + Want: cty.ObjectVal(map[string]cty.Value{ + "string": cty.StringVal("hello"), + "number": cty.NumberIntVal(2), + }), + }, + { + Input: cty.StringVal(``), + Want: cty.EmptyObjectVal, + }, + { + Input: cty.UnknownVal(cty.String), + Want: cty.UnknownVal(cty.DynamicPseudoType), + }, + { + Input: cty.NullVal(cty.String), + WantErr: `cannot decode tfvars from a null value`, + }, + { + Input: cty.StringVal(`not valid syntax`), + // This is actually not a very good diagnosis for this error, + // since we're expecting HCL arguments rather than HCL blocks, + // but that's something we'd need to address in HCL. + WantErr: `invalid tfvars syntax: :1,17-17: Invalid block definition; Either a quoted string block label or an opening brace ("{") is expected here.`, + }, + { + Input: cty.StringVal(`foo = not valid syntax`), + WantErr: `invalid tfvars syntax: :1,11-16: Missing newline after argument; An argument definition must end with a newline.`, + }, + { + Input: cty.StringVal(`foo = var.whatever`), + WantErr: `invalid expression for variable "foo": :1,7-10: Variables not allowed; Variables may not be used here.`, + }, + { + Input: cty.StringVal(`foo = whatever()`), + WantErr: `invalid expression for variable "foo": :1,7-17: Function calls not allowed; Functions may not be called here.`, + }, + }) +} + +func TestExprencode(t *testing.T) { + tableTestFunction(t, "exprencode", []functionTest{ + { + Input: cty.StringVal("hello"), + Want: cty.StringVal(`"hello"`), + }, + { + Input: cty.StringVal("hello\nworld\n"), + Want: cty.StringVal(`"hello\nworld\n"`), + // NOTE: If HCL changes the above to be a heredoc in future (which + // would make this test fail) then our function's refinement + // that unknown strings encode with the prefix " will become + // invalid, and should be removed. + }, + { + Input: cty.StringVal("hel${lo"), + Want: cty.StringVal(`"hel$${lo"`), // Escape template interpolation sequence + }, + { + Input: cty.StringVal("hel%{lo"), + Want: cty.StringVal(`"hel%%{lo"`), // Escape template control sequence + }, + { + Input: cty.StringVal(`boop\boop`), + Want: cty.StringVal(`"boop\\boop"`), // Escape literal backslash + }, + { + Input: cty.StringVal(""), + Want: cty.StringVal(`""`), + }, + { + Input: cty.NumberIntVal(2), + Want: cty.StringVal(`2`), + }, + { + Input: cty.True, + Want: cty.StringVal(`true`), + }, + { + Input: cty.False, + Want: cty.StringVal(`false`), + }, + { + Input: cty.EmptyObjectVal, + Want: cty.StringVal(`{}`), + }, + { + Input: cty.ObjectVal(map[string]cty.Value{ + "number": cty.NumberIntVal(5), + "string": cty.StringVal("..."), + }), + Want: cty.StringVal(`{ + number = 5 + string = "..." +}`), + }, + { + Input: cty.MapVal(map[string]cty.Value{ + "one": cty.NumberIntVal(1), + "two": cty.NumberIntVal(2), + }), + Want: cty.StringVal(`{ + one = 1 + two = 2 +}`), + }, + { + Input: cty.EmptyTupleVal, + Want: cty.StringVal(`[]`), + }, + { + Input: cty.TupleVal([]cty.Value{ + cty.NumberIntVal(5), + cty.StringVal("..."), + }), + Want: cty.StringVal(`[5, "..."]`), + }, + { + Input: cty.SetVal([]cty.Value{ + cty.NumberIntVal(1), + cty.NumberIntVal(5), + cty.NumberIntVal(20), + cty.NumberIntVal(55), + }), + Want: cty.StringVal(`[1, 5, 20, 55]`), + }, + { + Input: cty.DynamicVal, + Want: cty.UnknownVal(cty.String).RefineNotNull(), + }, + { + Input: cty.UnknownVal(cty.Number).RefineNotNull(), + Want: cty.UnknownVal(cty.String).RefineNotNull(), + }, + { + Input: cty.UnknownVal(cty.String).RefineNotNull(), + Want: cty.UnknownVal(cty.String).Refine(). + NotNull(). + StringPrefixFull(`"`). + NewValue(), + }, + { + Input: cty.UnknownVal(cty.EmptyObject).RefineNotNull(), + Want: cty.UnknownVal(cty.String).Refine(). + NotNull(). + StringPrefixFull(`{`). + NewValue(), + }, + { + Input: cty.UnknownVal(cty.Map(cty.String)).RefineNotNull(), + Want: cty.UnknownVal(cty.String).Refine(). + NotNull(). + StringPrefixFull(`{`). + NewValue(), + }, + { + Input: cty.UnknownVal(cty.EmptyTuple).RefineNotNull(), + Want: cty.UnknownVal(cty.String).Refine(). + NotNull(). + StringPrefixFull(`[`). + NewValue(), + }, + { + Input: cty.UnknownVal(cty.List(cty.String)).RefineNotNull(), + Want: cty.UnknownVal(cty.String).Refine(). + NotNull(). + StringPrefixFull(`[`). + NewValue(), + }, + { + Input: cty.UnknownVal(cty.Set(cty.String)).RefineNotNull(), + Want: cty.UnknownVal(cty.String).Refine(). + NotNull(). + StringPrefixFull(`[`). + NewValue(), + }, + }) +} + +type functionTest struct { + Input cty.Value + Want cty.Value + WantErr string +} + +func tableTestFunction(t *testing.T, functionName string, tests []functionTest) { + t.Helper() + + provider := NewProvider() + for _, test := range tests { + t.Run(test.Input.GoString(), func(t *testing.T) { + resp := provider.CallFunction(providers.CallFunctionRequest{ + FunctionName: functionName, + Arguments: []cty.Value{test.Input}, + }) + if test.WantErr != "" { + err := resp.Err + if err == nil { + t.Fatalf("unexpected success for %#v; want error\ngot: %#v", test.Input, resp.Result) + } + if err.Error() != test.WantErr { + t.Errorf("wrong error\ngot: %s\nwant: %s", err.Error(), test.WantErr) + } + return + } + if resp.Err != nil { + t.Fatalf("unexpected error: %s", resp.Err) + } + if diff := cmp.Diff(test.Want, resp.Result, ctydebug.CmpOptions); diff != "" { + t.Errorf("wrong result for %#v\n%s", test.Input, diff) + } + }) + } +} diff --git a/internal/builtin/providers/terraform/provider.go b/internal/builtin/providers/terraform/provider.go index 2720b259d9f9..55b5ec52bc61 100644 --- a/internal/builtin/providers/terraform/provider.go +++ b/internal/builtin/providers/terraform/provider.go @@ -7,6 +7,8 @@ import ( "fmt" "log" + "github.com/zclconf/go-cty/cty" + "github.com/hashicorp/terraform/internal/providers" ) @@ -27,6 +29,37 @@ func (p *Provider) GetProviderSchema() providers.GetProviderSchemaResponse { ResourceTypes: map[string]providers.Schema{ "terraform_data": dataStoreResourceSchema(), }, + Functions: map[string]providers.FunctionDecl{ + "tfvarsencode": { + Parameters: []providers.FunctionParam{ + { + Name: "value", + Type: cty.DynamicPseudoType, + AllowUnknownValues: true, // to perform refinements + }, + }, + ReturnType: cty.String, + }, + "tfvarsdecode": { + Parameters: []providers.FunctionParam{ + { + Name: "src", + Type: cty.String, + }, + }, + ReturnType: cty.DynamicPseudoType, + }, + "exprencode": { + Parameters: []providers.FunctionParam{ + { + Name: "value", + Type: cty.DynamicPseudoType, + AllowUnknownValues: true, // to perform refinements + }, + }, + ReturnType: cty.String, + }, + }, } } @@ -143,8 +176,31 @@ func (p *Provider) ValidateResourceConfig(req providers.ValidateResourceConfigRe // CallFunction would call a function contributed by this provider, but this // provider has no functions and so this function just panics. -func (p *Provider) CallFunction(providers.CallFunctionRequest) providers.CallFunctionResponse { - panic("unimplemented - terraform.io/builtin/terraform provider has no functions") +func (p *Provider) CallFunction(req providers.CallFunctionRequest) providers.CallFunctionResponse { + fn, ok := functions[req.FunctionName] + if !ok { + // Should not get here if the caller is behaving correctly, because + // we don't declare any functions in our schema that we don't have + // implementations for. + return providers.CallFunctionResponse{ + Err: fmt.Errorf("provider has no function named %q", req.FunctionName), + } + } + + // NOTE: We assume that none of the arguments can be marked, because we're + // expecting to be called from logic in Terraform Core that strips marks + // before calling a provider-contributed function, and then reapplies them + // afterwards. + + result, err := fn(req.Arguments) + if err != nil { + return providers.CallFunctionResponse{ + Err: err, + } + } + return providers.CallFunctionResponse{ + Result: result, + } } // Close is a noop for this provider, since it's run in-process. diff --git a/internal/command/e2etest/terraform_provider_funcs_test.go b/internal/command/e2etest/terraform_provider_funcs_test.go new file mode 100644 index 000000000000..7b6d4ac65e59 --- /dev/null +++ b/internal/command/e2etest/terraform_provider_funcs_test.go @@ -0,0 +1,78 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package e2etest + +import ( + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/zclconf/go-cty-debug/ctydebug" + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/internal/e2e" +) + +func TestTerraformProviderFunctions(t *testing.T) { + // This test ensures that the terraform.io/builtin/terraform provider + // remains available and that its three functions are available to be + // called. This test is here because builtin providers are a bit of a + // special case in the CLI layer which could in principle get accidentally + // broken there even with deeper tests in the provider package itself + // still passing. + // + // The tests in the provider's own package are authoritative for the + // expected behavior of the functions. This test is focused on whether + // the functions can be called at all, though it does some very light + // testing of results for one specific input each. If the functions + // are intentionally changed to produce different results for those + // inputs in future then it may be appropriate to just update these + // tests to match. + + t.Parallel() + fixturePath := filepath.Join("testdata", "terraform-provider-funcs") + tf := e2e.NewBinary(t, terraformBin, fixturePath) + + //// INIT + _, stderr, err := tf.Run("init") + if err != nil { + t.Fatalf("unexpected init error: %s\nstderr:\n%s", err, stderr) + } + + //// PLAN + _, stderr, err = tf.Run("plan", "-out=tfplan") + if err != nil { + t.Fatalf("unexpected plan error: %s\nstderr:\n%s", err, stderr) + } + + // The saved plan should include three planned output values containing + // results from our function calls. + plan, err := tf.Plan("tfplan") + if err != nil { + t.Fatalf("can't reload saved plan: %s", err) + } + + gotOutputs := make(map[string]cty.Value, 3) + for _, outputSrc := range plan.Changes.Outputs { + output, err := outputSrc.Decode() + if err != nil { + t.Fatalf("can't decode planned change for %s: %s", outputSrc.Addr, err) + } + gotOutputs[output.Addr.String()] = output.After + } + wantOutputs := map[string]cty.Value{ + "output.exprencode": cty.StringVal(`[1, 2, 3]`), + "output.tfvarsdecode": cty.ObjectVal(map[string]cty.Value{ + "baaa": cty.StringVal("🐑"), + "boop": cty.StringVal("👃"), + }), + "output.tfvarsencode": cty.StringVal(`a = "👋" +b = "🐝" +c = "👓" +`), + } + if diff := cmp.Diff(wantOutputs, gotOutputs, ctydebug.CmpOptions); diff != "" { + t.Errorf("wrong output values\n%s", diff) + } +} diff --git a/internal/command/e2etest/testdata/terraform-provider-funcs/terraform-provider-funcs.tf b/internal/command/e2etest/testdata/terraform-provider-funcs/terraform-provider-funcs.tf new file mode 100644 index 000000000000..7d07f8a0219d --- /dev/null +++ b/internal/command/e2etest/testdata/terraform-provider-funcs/terraform-provider-funcs.tf @@ -0,0 +1,35 @@ +# This test fixture is here primarily just to make sure that the +# terraform.io/builtin/terraform functions remain available for use. The +# actual behavior of these functions is the responsibility of +# ./internal/builtin/providers/terraform, and so it has more detailed tests +# whereas this one is focused largely just on whether these functions are +# callable at all. + +terraform { + required_providers { + terraform = { + source = "terraform.io/builtin/terraform" + } + } +} + +output "tfvarsencode" { + value = provider::terraform::tfvarsencode({ + a = "👋" + b = "🐝" + c = "👓" + }) +} + +output "tfvarsdecode" { + value = provider::terraform::tfvarsdecode( + <<-EOT + boop = "👃" + baaa = "🐑" + EOT + ) +} + +output "exprencode" { + value = provider::terraform::exprencode([1, 2, 3]) +} diff --git a/website/data/language-nav-data.json b/website/data/language-nav-data.json index abd7d366eba1..416e28452740 100644 --- a/website/data/language-nav-data.json +++ b/website/data/language-nav-data.json @@ -730,6 +730,14 @@ { "title": "type", "href": "/language/functions/type" } ] }, + { + "title": "Terraform-specific Functions", + "routes": [ + { "title": "provider::terraform::tfvarsencode", "href": "/language/functions/terraform-tfvarsencode" }, + { "title": "provider::terraform::tfvarsdecode", "href": "/language/functions/terraform-tfvarsdecode" }, + { "title": "provider::terraform::exprencode", "href": "/language/functions/terraform-exprencode" } + ] + }, { "title": "abs", "path": "functions/abs", "hidden": true }, { "title": "abspath", "path": "functions/abspath", "hidden": true }, { "title": "alltrue", "path": "functions/alltrue", "hidden": true }, @@ -878,6 +886,9 @@ "path": "functions/templatefile", "hidden": true }, + { "title": "terraform-tfvarsencode", "path": "functions/terraform-tfvarsencode", "hidden": true }, + { "title": "terraform-tfvarsdecode", "path": "functions/terraform-tfvarsdecode", "hidden": true }, + { "title": "terraform-exprencode", "path": "functions/terraform-exprencode", "hidden": true }, { "title": "textdecodebase64", "path": "functions/textdecodebase64", diff --git a/website/docs/language/functions/terraform-exprencode.mdx b/website/docs/language/functions/terraform-exprencode.mdx new file mode 100644 index 000000000000..f7491b68d8b8 --- /dev/null +++ b/website/docs/language/functions/terraform-exprencode.mdx @@ -0,0 +1,72 @@ +--- +page_title: provider::terraform::exprencode - Functions - Configuration Language +description: >- + The exprencode function produces a string representation of an arbitrary value + using Terraform expression syntax. +--- + +# `provider::terraform::exprencode` Function + +-> **Note:** This function is supported only in Terraform v1.8 and later. + +`provider::terraform::exprencode` is a rarely-needed function which takes +any value and produces a string containing Terraform language expression syntax +approximating that value. + +To use this function, your module must declare a dependency on the built-in +`terraform` provider, which contains this function: + +```hcl +terraform { + required_providers { + terraform = { + source = "terraform.io/builtin/terraform" + } + } +} +``` + +The primary use for this function is in conjunction with the `hashicorp/tfe` +provider's resource type +[`tfe_variable`](https://registry.terraform.io/providers/hashicorp/tfe/latest/docs/resources/variable), +which expects variable values to be provided in Terraform expression syntax. + +For example, the following concisely declares multiple input variables for +a particular Terraform Cloud workspace: + +```hcl +locals { + workspace_vars = { + example1 = "Hello" + example2 = ["A", "B"] + } +} + +resource "tfe_variable" "test" { + for_each = local.workspace_vars + + category = "terraform" + workspace_id = tfe_workspace.example.id + + key = each.key + value = provider::terraform::exprencode(each.value) + hcl = true +} +``` + +When using this pattern, always set `hcl = true` in the resource declaration +to ensure that Terraform Cloud will expect `value` to be given as Terraform +expression syntax. + +We do not recommend using this function in any other situation. + +~> **Warning:** The exact syntax used to encode certain values may change +in future versions of Terraform to follow idiomatic style. Avoid using the +results of this function in any context where such changes might be disruptive +when upgrading Terraform in future. + +## Related Functions + +* [`tfvarsencode`](/terraform/language/functions/terraform-tfvarsencode) + produces expression strings for many different values at once, in `.tfvars` + syntax. diff --git a/website/docs/language/functions/terraform-tfvarsdecode.mdx b/website/docs/language/functions/terraform-tfvarsdecode.mdx new file mode 100644 index 000000000000..fb3a93a541d9 --- /dev/null +++ b/website/docs/language/functions/terraform-tfvarsdecode.mdx @@ -0,0 +1,70 @@ +--- +page_title: provider::terraform::tfvarsdecode - Functions - Configuration Language +description: >- + The tfvarsencode function parses a string containing syntax like that used + in a ".tfvars" file. +--- + +# `provider::terraform::tfvarsdecode` Function + +-> **Note:** This function is supported only in Terraform v1.8 and later. + +`provider::terraform::tfvarsdecode` is a rarely-needed function which takes +a string containing the content of a +[`.tfvars` file](/terraform/language/values/variables#variable-definitions-tfvars-files) +and returns an object describing the raw variable values it defines. + +To use this function, your module must declare a dependency on the built-in +`terraform` provider, which contains this function: + +```hcl +terraform { + required_providers { + terraform = { + source = "terraform.io/builtin/terraform" + } + } +} +``` + +Elsewhere in your module you can then call this function: + +```hcl +provider::terraform::tfvarsdecode( + <- + The tfvarsencode function produces a string representation of an object + using the same syntax as for ".tfvars" files used in Terraform CLI. +--- + +# `provider::terraform::tfvarsencode` Function + +-> **Note:** This function is supported only in Terraform v1.8 and later. + +`provider::terraform::tfvarsencode` is a rarely-needed function which takes +an object value and produces a string containing a description of that object +using the same syntax as Terraform CLI would expect in a +[`.tfvars` file](/terraform/language/values/variables#variable-definitions-tfvars-files). + +In most cases it's better to pass data between Terraform configurations using +[Data Sources](/terraform/language/data-sources), +instead of writing generated `.tfvars` files to disk. Use this function only as +a last resort. + +To use this function, your module must declare a dependency on the built-in +`terraform` provider, which contains this function: + +```hcl +terraform { + required_providers { + terraform = { + source = "terraform.io/builtin/terraform" + } + } +} +``` + +Elsewhere in your module you can then call this function: + +```hcl +provider::terraform::tfvarsencode({ + example = "Hello!" +}) +``` + +The call above would produce the following result: + +```hcl +example = "Hello!" +``` + +Due to Terraform's requirements for the `.tfvars` format, all of the attributes +of the given object must be valid Terraform variable names, as would be +accepted in an +[input variable declaration](/terraform/language/values/variables#declaring-an-input-variable). + +The `.tfvars` format is specific to Terraform and so we do not recommend using +it as a general serialization format. +Use [`jsonencode`](/terraform/language/functions/jsonencode) or +[`yamlencode`](/terraform/language/functions/yamlencode) instead to produce +formats that are supported by other software. + +~> **Warning:** The exact syntax used to encode certain values may change +in future versions of Terraform to follow idiomatic style. Avoid using the +results of this function in any context where such changes might be disruptive +when upgrading Terraform in future. + +## Related Functions + +* [`tfvarsdecode`](/terraform/language/functions/terraform-tfvarsdecode) + performs the opposite operation: parsing `.tfvars` content to obtain + the variable values declared inside. +* [`exprencode`](/terraform/language/functions/terraform-exprdecode) + encodes a single value as a plain expression, without the `.tfvars` + container around it.