diff --git a/commands.go b/commands.go index 2641673b2bb4..aadfc51b3114 100644 --- a/commands.go +++ b/commands.go @@ -209,6 +209,18 @@ func initCommands( }, nil }, + "metadata": func() (cli.Command, error) { + return &command.MetadataCommand{ + Meta: meta, + }, nil + }, + + "metadata functions": func() (cli.Command, error) { + return &command.MetadataFunctionsCommand{ + Meta: meta, + }, nil + }, + "output": func() (cli.Command, error) { return &command.OutputCommand{ Meta: meta, diff --git a/internal/command/jsonfunction/function.go b/internal/command/jsonfunction/function.go new file mode 100644 index 000000000000..99a78c92cf14 --- /dev/null +++ b/internal/command/jsonfunction/function.go @@ -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, + }, + }, + } +} diff --git a/internal/command/jsonfunction/function_test.go b/internal/command/jsonfunction/function_test.go new file mode 100644 index 000000000000..9618876479e8 --- /dev/null +++ b/internal/command/jsonfunction/function_test.go @@ -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) + } + } + }) + } +} diff --git a/internal/command/jsonfunction/parameter.go b/internal/command/jsonfunction/parameter.go new file mode 100644 index 000000000000..d388f6d257eb --- /dev/null +++ b/internal/command/jsonfunction/parameter.go @@ -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 +} diff --git a/internal/command/jsonfunction/parameter_test.go b/internal/command/jsonfunction/parameter_test.go new file mode 100644 index 000000000000..b3a70d268546 --- /dev/null +++ b/internal/command/jsonfunction/parameter_test.go @@ -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) + } + }) + } +} diff --git a/internal/command/jsonfunction/return_type.go b/internal/command/jsonfunction/return_type.go new file mode 100644 index 000000000000..9f30bad2ea2c --- /dev/null +++ b/internal/command/jsonfunction/return_type.go @@ -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) +} diff --git a/internal/command/metadata_command.go b/internal/command/metadata_command.go new file mode 100644 index 000000000000..dd13b199a767 --- /dev/null +++ b/internal/command/metadata_command.go @@ -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 [options] [args] + + This command has subcommands for metadata related purposes. + +` + return strings.TrimSpace(helpText) +} + +func (c *MetadataCommand) Synopsis() string { + return "Metadata related commands" +} diff --git a/internal/command/metadata_functions.go b/internal/command/metadata_functions.go new file mode 100644 index 000000000000..43609cc16b18 --- /dev/null +++ b/internal/command/metadata_functions.go @@ -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 +} diff --git a/internal/command/metadata_functions_test.go b/internal/command/metadata_functions_test.go new file mode 100644 index 000000000000..05d48639af31 --- /dev/null +++ b/internal/command/metadata_functions_test.go @@ -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"` +} diff --git a/website/docs/cli/commands/index.mdx b/website/docs/cli/commands/index.mdx index 45d8637d2f15..88675220cc09 100644 --- a/website/docs/cli/commands/index.mdx +++ b/website/docs/cli/commands/index.mdx @@ -43,6 +43,7 @@ All other commands: import Associate existing infrastructure with a Terraform resource login Obtain and save credentials for a remote host logout Remove locally-stored credentials for a remote host + metadata Metadata related commands output Show output values from your root module providers Show the providers required for this configuration refresh Update the state to match remote systems