mirror of https://github.com/hashicorp/terraform
Introduce `metadata functions` command (#32487)
* Add metadata functions command skeleton * Export functions as JSON via cli command * Add metadata command * Add tests to jsonfunction package * WIP: Add metadata functions test * Change return_type & type in JSON to json.RawMessage This enables easier deserialisation of types when parsing the JSON. * Skip is_nullable when false * Update cli docs with metadata command * Use tfdiags to report function marshal errors * Ignore map, list and type functions * Test Marshal function with diags * Test metadata functions command output * Simplify type marshaling by using cty.Type * Add static function signatures for can and try * Update internal/command/jsonfunction/function_test.go Co-authored-by: kmoe <5575356+kmoe@users.noreply.github.com> --------- Co-authored-by: kmoe <5575356+kmoe@users.noreply.github.com>pull/32683/head
parent
a62f4f0763
commit
4fa77727b5
@ -0,0 +1,145 @@
|
||||
package jsonfunction
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
"github.com/zclconf/go-cty/cty/function"
|
||||
)
|
||||
|
||||
// FormatVersion represents the version of the json format and will be
|
||||
// incremented for any change to this format that requires changes to a
|
||||
// consuming parser.
|
||||
const FormatVersion = "1.0"
|
||||
|
||||
// functions is the top-level object returned when exporting function signatures
|
||||
type functions struct {
|
||||
FormatVersion string `json:"format_version"`
|
||||
Signatures map[string]*FunctionSignature `json:"function_signatures,omitempty"`
|
||||
}
|
||||
|
||||
// FunctionSignature represents a function signature.
|
||||
type FunctionSignature struct {
|
||||
// Description is an optional human-readable description
|
||||
// of the function
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
// ReturnTypes is the ctyjson representation of the function's
|
||||
// return types based on supplying all parameters using
|
||||
// dynamic types. Functions can have dynamic return types.
|
||||
ReturnType cty.Type `json:"return_type"`
|
||||
|
||||
// Parameters describes the function's fixed positional parameters.
|
||||
Parameters []*parameter `json:"parameters,omitempty"`
|
||||
|
||||
// VariadicParameter describes the function's variadic
|
||||
// parameters, if any are supported.
|
||||
VariadicParameter *parameter `json:"variadic_parameter,omitempty"`
|
||||
}
|
||||
|
||||
func newFunctions() *functions {
|
||||
signatures := make(map[string]*FunctionSignature)
|
||||
return &functions{
|
||||
FormatVersion: FormatVersion,
|
||||
Signatures: signatures,
|
||||
}
|
||||
}
|
||||
|
||||
func Marshal(f map[string]function.Function) ([]byte, tfdiags.Diagnostics) {
|
||||
var diags tfdiags.Diagnostics
|
||||
signatures := newFunctions()
|
||||
|
||||
for name, v := range f {
|
||||
if name == "can" {
|
||||
signatures.Signatures[name] = marshalCan(v)
|
||||
} else if name == "try" {
|
||||
signatures.Signatures[name] = marshalTry(v)
|
||||
} else {
|
||||
signature, err := marshalFunction(v)
|
||||
if err != nil {
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
fmt.Sprintf("Failed to serialize function %q", name),
|
||||
err.Error(),
|
||||
))
|
||||
}
|
||||
signatures.Signatures[name] = signature
|
||||
}
|
||||
}
|
||||
|
||||
if diags.HasErrors() {
|
||||
return nil, diags
|
||||
}
|
||||
|
||||
ret, err := json.Marshal(signatures)
|
||||
if err != nil {
|
||||
diags = diags.Append(tfdiags.Sourceless(
|
||||
tfdiags.Error,
|
||||
"Failed to serialize functions",
|
||||
err.Error(),
|
||||
))
|
||||
return nil, diags
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func marshalFunction(f function.Function) (*FunctionSignature, error) {
|
||||
var err error
|
||||
var vp *parameter
|
||||
if f.VarParam() != nil {
|
||||
vp = marshalParameter(f.VarParam())
|
||||
}
|
||||
|
||||
var p []*parameter
|
||||
if len(f.Params()) > 0 {
|
||||
p = marshalParameters(f.Params())
|
||||
}
|
||||
|
||||
r, err := getReturnType(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &FunctionSignature{
|
||||
Description: f.Description(),
|
||||
ReturnType: r,
|
||||
Parameters: p,
|
||||
VariadicParameter: vp,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// marshalTry returns a static function signature for the try function.
|
||||
// We need this exception because the function implementation uses capsule
|
||||
// types that we can't marshal.
|
||||
func marshalTry(try function.Function) *FunctionSignature {
|
||||
return &FunctionSignature{
|
||||
Description: try.Description(),
|
||||
ReturnType: cty.DynamicPseudoType,
|
||||
VariadicParameter: ¶meter{
|
||||
Name: try.VarParam().Name,
|
||||
Description: try.VarParam().Description,
|
||||
IsNullable: try.VarParam().AllowNull,
|
||||
Type: cty.DynamicPseudoType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// marshalCan returns a static function signature for the can function.
|
||||
// We need this exception because the function implementation uses capsule
|
||||
// types that we can't marshal.
|
||||
func marshalCan(can function.Function) *FunctionSignature {
|
||||
return &FunctionSignature{
|
||||
Description: can.Description(),
|
||||
ReturnType: cty.Bool,
|
||||
Parameters: []*parameter{
|
||||
{
|
||||
Name: can.Params()[0].Name,
|
||||
Description: can.Params()[0].Description,
|
||||
IsNullable: can.Params()[0].AllowNull,
|
||||
Type: cty.DynamicPseudoType,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,134 @@
|
||||
package jsonfunction
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/zclconf/go-cty-debug/ctydebug"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
"github.com/zclconf/go-cty/cty/function"
|
||||
)
|
||||
|
||||
func TestMarshal(t *testing.T) {
|
||||
tests := []struct {
|
||||
Name string
|
||||
Input map[string]function.Function
|
||||
Want string
|
||||
WantErr string
|
||||
}{
|
||||
{
|
||||
"minimal function",
|
||||
map[string]function.Function{
|
||||
"fun": function.New(&function.Spec{
|
||||
Type: function.StaticReturnType(cty.Bool),
|
||||
}),
|
||||
},
|
||||
`{"format_version":"1.0","function_signatures":{"fun":{"return_type":"bool"}}}`,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"function with description",
|
||||
map[string]function.Function{
|
||||
"fun": function.New(&function.Spec{
|
||||
Description: "`timestamp` returns a UTC timestamp string.",
|
||||
Type: function.StaticReturnType(cty.String),
|
||||
}),
|
||||
},
|
||||
"{\"format_version\":\"1.0\",\"function_signatures\":{\"fun\":{\"description\":\"`timestamp` returns a UTC timestamp string.\",\"return_type\":\"string\"}}}",
|
||||
"",
|
||||
},
|
||||
{
|
||||
"function with parameters",
|
||||
map[string]function.Function{
|
||||
"fun": function.New(&function.Spec{
|
||||
Params: []function.Parameter{
|
||||
{
|
||||
Name: "timestamp",
|
||||
Description: "timestamp text",
|
||||
Type: cty.String,
|
||||
},
|
||||
{
|
||||
Name: "duration",
|
||||
Description: "duration text",
|
||||
Type: cty.String,
|
||||
},
|
||||
},
|
||||
Type: function.StaticReturnType(cty.String),
|
||||
}),
|
||||
},
|
||||
`{"format_version":"1.0","function_signatures":{"fun":{"return_type":"string","parameters":[{"name":"timestamp","description":"timestamp text","type":"string"},{"name":"duration","description":"duration text","type":"string"}]}}}`,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"function with variadic parameter",
|
||||
map[string]function.Function{
|
||||
"fun": function.New(&function.Spec{
|
||||
VarParam: &function.Parameter{
|
||||
Name: "default",
|
||||
Description: "default description",
|
||||
Type: cty.DynamicPseudoType,
|
||||
AllowUnknown: true,
|
||||
AllowDynamicType: true,
|
||||
AllowNull: true,
|
||||
AllowMarked: true,
|
||||
},
|
||||
Type: function.StaticReturnType(cty.DynamicPseudoType),
|
||||
}),
|
||||
},
|
||||
`{"format_version":"1.0","function_signatures":{"fun":{"return_type":"dynamic","variadic_parameter":{"name":"default","description":"default description","is_nullable":true,"type":"dynamic"}}}}`,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"function with list types",
|
||||
map[string]function.Function{
|
||||
"fun": function.New(&function.Spec{
|
||||
Params: []function.Parameter{
|
||||
{
|
||||
Name: "list",
|
||||
Type: cty.List(cty.String),
|
||||
},
|
||||
},
|
||||
Type: function.StaticReturnType(cty.List(cty.String)),
|
||||
}),
|
||||
},
|
||||
`{"format_version":"1.0","function_signatures":{"fun":{"return_type":["list","string"],"parameters":[{"name":"list","type":["list","string"]}]}}}`,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"returns diagnostics on failure",
|
||||
map[string]function.Function{
|
||||
"fun": function.New(&function.Spec{
|
||||
Params: []function.Parameter{},
|
||||
Type: func(args []cty.Value) (ret cty.Type, err error) {
|
||||
return cty.DynamicPseudoType, fmt.Errorf("error")
|
||||
},
|
||||
}),
|
||||
},
|
||||
"",
|
||||
"Failed to serialize function \"fun\": error",
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
t.Run(fmt.Sprintf("%d-%s", i, test.Name), func(t *testing.T) {
|
||||
got, diags := Marshal(test.Input)
|
||||
if test.WantErr != "" {
|
||||
if !diags.HasErrors() {
|
||||
t.Fatal("expected error, got none")
|
||||
}
|
||||
if diags.Err().Error() != test.WantErr {
|
||||
t.Fatalf("expected error %q, got %q", test.WantErr, diags.Err())
|
||||
}
|
||||
} else {
|
||||
if diags.HasErrors() {
|
||||
t.Fatal(diags)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(test.Want, string(got), ctydebug.CmpOptions); diff != "" {
|
||||
t.Fatalf("mismatch of function signature: %s", diff)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package jsonfunction
|
||||
|
||||
import (
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
"github.com/zclconf/go-cty/cty/function"
|
||||
)
|
||||
|
||||
// parameter represents a parameter to a function.
|
||||
type parameter struct {
|
||||
// Name is an optional name for the argument.
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// Description is an optional human-readable description
|
||||
// of the argument
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
// IsNullable is true if null is acceptable value for the argument
|
||||
IsNullable bool `json:"is_nullable,omitempty"`
|
||||
|
||||
// A type that any argument for this parameter must conform to.
|
||||
Type cty.Type `json:"type"`
|
||||
}
|
||||
|
||||
func marshalParameter(p *function.Parameter) *parameter {
|
||||
if p == nil {
|
||||
return ¶meter{}
|
||||
}
|
||||
|
||||
return ¶meter{
|
||||
Name: p.Name,
|
||||
Description: p.Description,
|
||||
IsNullable: p.AllowNull,
|
||||
Type: p.Type,
|
||||
}
|
||||
}
|
||||
|
||||
func marshalParameters(parameters []function.Parameter) []*parameter {
|
||||
ret := make([]*parameter, len(parameters))
|
||||
for k, p := range parameters {
|
||||
ret[k] = marshalParameter(&p)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
package jsonfunction
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/zclconf/go-cty-debug/ctydebug"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
"github.com/zclconf/go-cty/cty/function"
|
||||
)
|
||||
|
||||
func TestMarshalParameter(t *testing.T) {
|
||||
tests := []struct {
|
||||
Name string
|
||||
Input *function.Parameter
|
||||
Want *parameter
|
||||
}{
|
||||
{
|
||||
"call with nil",
|
||||
nil,
|
||||
¶meter{},
|
||||
},
|
||||
{
|
||||
"parameter with description",
|
||||
&function.Parameter{
|
||||
Name: "timestamp",
|
||||
Description: "`timestamp` returns a UTC timestamp string in [RFC 3339]",
|
||||
Type: cty.String,
|
||||
},
|
||||
¶meter{
|
||||
Name: "timestamp",
|
||||
Description: "`timestamp` returns a UTC timestamp string in [RFC 3339]",
|
||||
Type: cty.String,
|
||||
},
|
||||
},
|
||||
{
|
||||
"parameter with additional properties",
|
||||
&function.Parameter{
|
||||
Name: "value",
|
||||
Type: cty.DynamicPseudoType,
|
||||
AllowUnknown: true,
|
||||
AllowNull: true,
|
||||
AllowMarked: true,
|
||||
AllowDynamicType: true,
|
||||
},
|
||||
¶meter{
|
||||
Name: "value",
|
||||
Type: cty.DynamicPseudoType,
|
||||
IsNullable: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
t.Run(fmt.Sprintf("%d-%s", i, test.Name), func(t *testing.T) {
|
||||
got := marshalParameter(test.Input)
|
||||
|
||||
if diff := cmp.Diff(test.Want, got, ctydebug.CmpOptions); diff != "" {
|
||||
t.Fatalf("mismatch of parameter signature: %s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package jsonfunction
|
||||
|
||||
import (
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
"github.com/zclconf/go-cty/cty/function"
|
||||
)
|
||||
|
||||
func getReturnType(f function.Function) (cty.Type, error) {
|
||||
args := make([]cty.Type, 0)
|
||||
for _, param := range f.Params() {
|
||||
args = append(args, param.Type)
|
||||
}
|
||||
if f.VarParam() != nil {
|
||||
args = append(args, f.VarParam().Type)
|
||||
}
|
||||
|
||||
return f.ReturnType(args)
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mitchellh/cli"
|
||||
)
|
||||
|
||||
// MetadataCommand is a Command implementation that just shows help for
|
||||
// the subcommands nested below it.
|
||||
type MetadataCommand struct {
|
||||
Meta
|
||||
}
|
||||
|
||||
func (c *MetadataCommand) Run(args []string) int {
|
||||
return cli.RunResultHelp
|
||||
}
|
||||
|
||||
func (c *MetadataCommand) Help() string {
|
||||
helpText := `
|
||||
Usage: terraform [global options] metadata <subcommand> [options] [args]
|
||||
|
||||
This command has subcommands for metadata related purposes.
|
||||
|
||||
`
|
||||
return strings.TrimSpace(helpText)
|
||||
}
|
||||
|
||||
func (c *MetadataCommand) Synopsis() string {
|
||||
return "Metadata related commands"
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/terraform/internal/command/jsonfunction"
|
||||
"github.com/hashicorp/terraform/internal/lang"
|
||||
"github.com/zclconf/go-cty/cty/function"
|
||||
)
|
||||
|
||||
var (
|
||||
ignoredFunctions = []string{"map", "list"}
|
||||
)
|
||||
|
||||
// MetadataFunctionsCommand is a Command implementation that prints out information
|
||||
// about the available functions in Terraform.
|
||||
type MetadataFunctionsCommand struct {
|
||||
Meta
|
||||
}
|
||||
|
||||
func (c *MetadataFunctionsCommand) Help() string {
|
||||
return metadataFunctionsCommandHelp
|
||||
}
|
||||
|
||||
func (c *MetadataFunctionsCommand) Synopsis() string {
|
||||
return "Show signatures and descriptions for the available functions"
|
||||
}
|
||||
|
||||
func (c *MetadataFunctionsCommand) Run(args []string) int {
|
||||
args = c.Meta.process(args)
|
||||
cmdFlags := c.Meta.defaultFlagSet("metadata functions")
|
||||
var jsonOutput bool
|
||||
cmdFlags.BoolVar(&jsonOutput, "json", false, "produce JSON output")
|
||||
|
||||
cmdFlags.Usage = func() { c.Ui.Error(c.Help()) }
|
||||
if err := cmdFlags.Parse(args); err != nil {
|
||||
c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error()))
|
||||
return 1
|
||||
}
|
||||
|
||||
if !jsonOutput {
|
||||
c.Ui.Error(
|
||||
"The `terraform metadata functions` command requires the `-json` flag.\n")
|
||||
cmdFlags.Usage()
|
||||
return 1
|
||||
}
|
||||
|
||||
scope := &lang.Scope{}
|
||||
funcs := scope.Functions()
|
||||
filteredFuncs := make(map[string]function.Function)
|
||||
for k, v := range funcs {
|
||||
if isIgnoredFunction(k) {
|
||||
continue
|
||||
}
|
||||
filteredFuncs[k] = v
|
||||
}
|
||||
|
||||
jsonFunctions, marshalDiags := jsonfunction.Marshal(filteredFuncs)
|
||||
if marshalDiags.HasErrors() {
|
||||
c.showDiagnostics(marshalDiags)
|
||||
return 1
|
||||
}
|
||||
c.Ui.Output(string(jsonFunctions))
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
const metadataFunctionsCommandHelp = `
|
||||
Usage: terraform [global options] metadata functions -json
|
||||
|
||||
Prints out a json representation of the available function signatures.
|
||||
`
|
||||
|
||||
func isIgnoredFunction(name string) bool {
|
||||
for _, i := range ignoredFunctions {
|
||||
if i == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mitchellh/cli"
|
||||
)
|
||||
|
||||
func TestMetadataFunctions_error(t *testing.T) {
|
||||
ui := new(cli.MockUi)
|
||||
c := &MetadataFunctionsCommand{
|
||||
Meta: Meta{
|
||||
Ui: ui,
|
||||
},
|
||||
}
|
||||
|
||||
// This test will always error because it's missing the -json flag
|
||||
if code := c.Run(nil); code != 1 {
|
||||
t.Fatalf("expected error, got:\n%s", ui.OutputWriter.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataFunctions_output(t *testing.T) {
|
||||
ui := new(cli.MockUi)
|
||||
m := Meta{Ui: ui}
|
||||
c := &MetadataFunctionsCommand{Meta: m}
|
||||
|
||||
if code := c.Run([]string{"-json"}); code != 0 {
|
||||
t.Fatalf("wrong exit status %d; want 0\nstderr: %s", code, ui.ErrorWriter.String())
|
||||
}
|
||||
|
||||
var got functions
|
||||
gotString := ui.OutputWriter.String()
|
||||
err := json.Unmarshal([]byte(gotString), &got)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(got.Signatures) < 100 {
|
||||
t.Fatalf("expected at least 100 function signatures, got %d", len(got.Signatures))
|
||||
}
|
||||
|
||||
// check if one particular stable function is correct
|
||||
gotMax, ok := got.Signatures["max"]
|
||||
wantMax := "{\"description\":\"`max` takes one or more numbers and returns the greatest number from the set.\",\"return_type\":\"number\",\"variadic_parameter\":{\"name\":\"numbers\",\"type\":\"number\"}}"
|
||||
if !ok {
|
||||
t.Fatal(`missing function signature for "max"`)
|
||||
}
|
||||
if string(gotMax) != wantMax {
|
||||
t.Fatalf("wrong function signature for \"max\":\ngot: %q\nwant: %q", gotMax, wantMax)
|
||||
}
|
||||
|
||||
stderr := ui.ErrorWriter.String()
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got:\n%s", stderr)
|
||||
}
|
||||
|
||||
// test that ignored functions are not part of the json
|
||||
for _, v := range ignoredFunctions {
|
||||
_, ok := got.Signatures[v]
|
||||
if ok {
|
||||
t.Fatalf("found ignored function %q inside output", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type functions struct {
|
||||
FormatVersion string `json:"format_version"`
|
||||
Signatures map[string]json.RawMessage `json:"function_signatures,omitempty"`
|
||||
}
|
||||
Loading…
Reference in new issue