Skip to content

Commit

Permalink
provider/terraform: Terraform-specific encoding functions
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
apparentlymart committed Feb 28, 2024
1 parent 42aa821 commit 573c2db
Show file tree
Hide file tree
Showing 9 changed files with 918 additions and 2 deletions.
177 changes: 177 additions & 0 deletions internal/builtin/providers/terraform/functions.go
Original file line number Diff line number Diff line change
@@ -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, "<tfvarsdecode argument>", 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
}
Loading

0 comments on commit 573c2db

Please sign in to comment.