From 217adfc4d2f721202d5632aa6eb4145cbdb12364 Mon Sep 17 00:00:00 2001 From: Jeff Mitchell Date: Thu, 7 Oct 2021 15:30:39 -0400 Subject: [PATCH] Pull over CombinedSliceFlagValue from WIP plugin-hostcatalogs (#1582) I've pulled over files as-is for the most part so this also pulls over some common attribute/secret flags but it's not worth dealing with the merge later... --- internal/cmd/base/base.go | 10 + internal/cmd/base/flags.go | 118 +++++++++ internal/cmd/common/flags.go | 308 ++++++++++++++++++++++ internal/cmd/common/flags_test.go | 413 ++++++++++++++++++++++++++++++ internal/cmd/gencli/input.go | 11 + internal/cmd/gencli/templates.go | 69 ++++- 6 files changed, 925 insertions(+), 4 deletions(-) create mode 100644 internal/cmd/common/flags_test.go diff --git a/internal/cmd/base/base.go b/internal/cmd/base/base.go index 0738b5ec62..4c8205bf7b 100644 --- a/internal/cmd/base/base.go +++ b/internal/cmd/base/base.go @@ -73,6 +73,8 @@ type Command struct { FlagScopeId string FlagScopeName string + FlagPluginId string + FlagPluginName string FlagId string FlagName string FlagDescription string @@ -83,6 +85,14 @@ type Command struct { FlagRecursive bool FlagFilter string + // Attribute values + FlagAttributes string + FlagAttrs []CombinedSliceFlagValue + + // Secret values + FlagSecrets string + FlagScrts []CombinedSliceFlagValue + client *api.Client } diff --git a/internal/cmd/base/flags.go b/internal/cmd/base/flags.go index 63efaf7adc..fef530e7ec 100644 --- a/internal/cmd/base/flags.go +++ b/internal/cmd/base/flags.go @@ -4,11 +4,14 @@ import ( "flag" "fmt" "os" + "regexp" "sort" "strconv" "strings" "time" + "github.com/hashicorp/go-secure-stdlib/parseutil" + "github.com/kr/pretty" "github.com/posener/complete" ) @@ -748,3 +751,118 @@ func (f *FlagSet) Var(value flag.Value, name, usage string) { f.mainSet.Var(value, name, usage) f.flagSet.Var(value, name, usage) } + +// CombinationSliceVar uses a wrapped value to allow storing values from +// different flags in one slice. This is useful if you need ordering to be +// maintained across flags. It does not currently support env vars. +// +// If KvSplit is set true, each value will be split on the first = into Key and +// Value parts so that validation can happen at parsing time. If you don't want +// this kind of behavior, simply combine them, or set KvSplit to false. +// +// If KeyDelimiter is non-nil (along with KvSplit being true), the string will +// be used to split the key. Otherwise, the Keys will be a single-element slice +// containing the full value. +// +// If ProtoCompat is true, the key will be validated against proto3 syntax +// requirements for identifiers. If the string is split via KeyDelimiter, each +// segment will be evaluated independently. +type CombinationSliceVar struct { + Name string + Aliases []string + Usage string + Hidden bool + Target *[]CombinedSliceFlagValue + Completion complete.Predictor + KvSplit bool + KeyDelimiter *string + ProtoCompatKey bool +} + +func (f *FlagSet) CombinationSliceVar(i *CombinationSliceVar) { + f.VarFlag(&VarFlag{ + Name: i.Name, + Aliases: i.Aliases, + Usage: i.Usage, + Value: newCombinedSliceValue(i.Name, i.Target, i.Hidden, i.KvSplit, i.KeyDelimiter, i.ProtoCompatKey), + Completion: i.Completion, + }) +} + +// CombinedSliceValue holds the raw value (as a string) and the name of the flag +// that added it. +type CombinedSliceFlagValue struct { + Name string + Keys []string + Value string +} + +type combinedSliceValue struct { + name string + hidden bool + kvSplit bool + keyDelimiter *string + protoCompatKey bool + target *[]CombinedSliceFlagValue +} + +func newCombinedSliceValue(name string, target *[]CombinedSliceFlagValue, hidden, kvSplit bool, keyDelimiter *string, protoCompatKey bool) *combinedSliceValue { + return &combinedSliceValue{ + name: name, + hidden: hidden, + kvSplit: kvSplit, + keyDelimiter: keyDelimiter, + protoCompatKey: protoCompatKey, + target: target, + } +} + +var protoIdentifierRegex = regexp.MustCompile("^[a-zA-Z][A-Za-z0-9_]*$") + +func (c *combinedSliceValue) Set(val string) error { + ret := CombinedSliceFlagValue{ + Name: c.name, + Value: strings.TrimSpace(val), + } + + if c.kvSplit { + kv := strings.SplitN(ret.Value, "=", 2) + switch len(kv) { + case 0: + case 1: + ret.Value = strings.TrimSpace(kv[0]) + default: + ret.Keys = []string{kv[0]} + if c.keyDelimiter != nil { + ret.Keys = strings.Split(kv[0], *c.keyDelimiter) + } + ret.Value = strings.TrimSpace(kv[1]) + } + } + + // Trim keys + for i, key := range ret.Keys { + ret.Keys[i] = strings.TrimSpace(key) + } + + if c.protoCompatKey { + for _, key := range ret.Keys { + if !protoIdentifierRegex.Match([]byte(key)) { + return fmt.Errorf("key segment %q is invalid", key) + } + } + } + + var err error + if ret.Value, err = parseutil.ParsePath(ret.Value); err != nil && err != parseutil.ErrNotAUrl { + return fmt.Errorf("error checking if value is a path: %w", err) + } + + *c.target = append(*c.target, ret) + return nil +} + +func (c *combinedSliceValue) Get() interface{} { return *c.target } +func (c *combinedSliceValue) String() string { return pretty.Sprint(*c.target) } +func (c *combinedSliceValue) Example() string { return "" } +func (c *combinedSliceValue) Hidden() bool { return c.hidden } diff --git a/internal/cmd/common/flags.go b/internal/cmd/common/flags.go index 5ab75fc463..f7b1978964 100644 --- a/internal/cmd/common/flags.go +++ b/internal/cmd/common/flags.go @@ -1,9 +1,16 @@ package common import ( + "bytes" + "encoding/json" + "errors" "fmt" + "regexp" + "strconv" + "strings" "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/hashicorp/go-secure-stdlib/parseutil" "github.com/posener/complete" ) @@ -27,6 +34,20 @@ func PopulateCommonFlags(c *base.Command, f *base.FlagSet, resourceType string, Completion: complete.PredictAnything, Usage: `Scope in which to make the request, identified by name.`, }) + case "plugin-id": + f.StringVar(&base.StringVar{ + Name: "plugin-id", + Target: &c.FlagPluginId, + Completion: complete.PredictAnything, + Usage: `ID of a plugin being referenced in the request.`, + }) + case "plugin-name": + f.StringVar(&base.StringVar{ + Name: "plugin-name", + Target: &c.FlagPluginName, + Completion: complete.PredictAnything, + Usage: `Name of a plugin being referenced in the request.`, + }) case "id": f.StringVar(&base.StringVar{ Name: "id", @@ -93,3 +114,290 @@ func PopulateCommonFlags(c *base.Command, f *base.FlagSet, resourceType string, } } } + +func PopulateAttributeFlags(c *base.Command, f *base.FlagSet, flagNames map[string][]string, command string) { + keyDelimiter := "." + for _, name := range flagNames[command] { + switch name { + case "attributes": + f.StringVar(&base.StringVar{ + Name: "attributes", + Target: &c.FlagAttributes, + Usage: `A JSON map value to use as the entirety of the request's attributes map. Usually this will be sourced from a file via "file://" syntax.`, + }) + case "attr": + f.CombinationSliceVar(&base.CombinationSliceVar{ + Name: "attr", + Target: &c.FlagAttrs, + KvSplit: true, + KeyDelimiter: &keyDelimiter, + ProtoCompatKey: true, + Usage: `A key=value attribute to add to the request's attributes map. The type is automatically inferred. Use -string-attr, -bool-attr, or -num-attr if the type needs to be overridden. Can be specified multiple times. Supports sourcing values from files via "file://" and env vars via "env://"`, + }) + case "string-attr": + f.CombinationSliceVar(&base.CombinationSliceVar{ + Name: "string-attr", + Target: &c.FlagAttrs, + KvSplit: true, + KeyDelimiter: &keyDelimiter, + ProtoCompatKey: true, + Usage: `A key=value string attribute to add to the request's attributes map. Can be specified multiple times. Supports sourcing values from files via "file://" and env vars via "env://"`, + }) + case "bool-attr": + f.CombinationSliceVar(&base.CombinationSliceVar{ + Name: "bool-attr", + Target: &c.FlagAttrs, + KvSplit: true, + KeyDelimiter: &keyDelimiter, + ProtoCompatKey: true, + Usage: `A key=value bool attribute to add to the request's attributes map. Can be specified multiple times. Supports sourcing values from files via "file://" and env vars via "env://"`, + }) + case "num-attr": + f.CombinationSliceVar(&base.CombinationSliceVar{ + Name: "num-attr", + Target: &c.FlagAttrs, + KvSplit: true, + KeyDelimiter: &keyDelimiter, + ProtoCompatKey: true, + Usage: `A key=value numeric attribute to add to the request's attributes map. Can be specified multiple times. Supports sourcing values from files via "file://" and env vars via "env://"`, + }) + } + } +} + +func PopulateSecretFlags(c *base.Command, f *base.FlagSet, flagNames map[string][]string, command string) { + keyDelimiter := "." + for _, name := range flagNames[command] { + switch name { + case "secrets": + f.StringVar(&base.StringVar{ + Name: "secrets", + Target: &c.FlagSecrets, + Usage: `A JSON map value to use as the entirety of the request's secrets map. Usually this will be sourced from a file via "file://" syntax.`, + }) + case "secret": + f.CombinationSliceVar(&base.CombinationSliceVar{ + Name: "secret", + Target: &c.FlagScrts, + KvSplit: true, + KeyDelimiter: &keyDelimiter, + ProtoCompatKey: true, + Usage: `A key=value secret to add to the request's secrets map. The type is automatically inferred. Use -string-secret, -bool-secret, or -num-secret if the type needs to be overridden. Can be specified multiple times. Supports sourcing values from files via "file://" and env vars via "env://"`, + }) + case "string-secret": + f.CombinationSliceVar(&base.CombinationSliceVar{ + Name: "string-secret", + Target: &c.FlagScrts, + KvSplit: true, + KeyDelimiter: &keyDelimiter, + ProtoCompatKey: true, + Usage: `A key=value string secret to add to the request's secrets map. Can be specified multiple times. Supports sourcing values from files via "file://" and env vars via "env://"`, + }) + case "bool-secret": + f.CombinationSliceVar(&base.CombinationSliceVar{ + Name: "bool-secret", + Target: &c.FlagScrts, + KvSplit: true, + KeyDelimiter: &keyDelimiter, + ProtoCompatKey: true, + Usage: `A key=value bool secret to add to the request's secrets map. Can be specified multiple times. Supports sourcing values from files via "file://" and env vars via "env://"`, + }) + case "num-secret": + f.CombinationSliceVar(&base.CombinationSliceVar{ + Name: "num-secret", + Target: &c.FlagScrts, + KvSplit: true, + KeyDelimiter: &keyDelimiter, + ProtoCompatKey: true, + Usage: `A key=value numeric secret to add to the request's secrets map. Can be specified multiple times. Supports sourcing values from files via "file://" and env vars via "env://"`, + }) + } + } +} + +// From https://stackoverflow.com/a/13340826, modified to remove exponents +var jsonNumberRegex = regexp.MustCompile(`^-?(?:0|[1-9]\d*)(?:\.\d+)?$`) + +// HandleAttributeFlags takes in a command and a func to call for default (that +// is, set to nil) and non-default values. Suffix can be used to allow this +// logic to be used for various needs, e.g. -attr vs -secret. +func HandleAttributeFlags(c *base.Command, suffix, fullField string, sepFields []base.CombinedSliceFlagValue, defaultFunc func(), setFunc func(map[string]interface{})) error { + // If we were given a fullly defined field, use that as-is + switch fullField { + case "": + // Nothing, continue on + case "null": + defaultFunc() + return nil + default: + parsedString, err := parseutil.ParsePath(fullField) + if err != nil && !errors.Is(err, parseutil.ErrNotAUrl) { + return fmt.Errorf("error parsing %s flag as a URL: %w", suffix, err) + } + // We should be able to parse the string as a JSON object + var setMap map[string]interface{} + if err := json.Unmarshal([]byte(parsedString), &setMap); err != nil { + return fmt.Errorf("error parsing %s flag as JSON: %w", suffix, err) + } + setFunc(setMap) + return nil + } + + setMap := map[string]interface{}{} + + for _, field := range sepFields { + if len(field.Keys) == 0 { + // No idea why this would happen, but skip it + continue + } + + var val interface{} + var err error + + // First, perform any needed parsing if we are given the type + switch field.Name { + case "num-" + suffix: + // JSON treats all numbers equally, however, we will try to be a + // little better so that we don't include decimals if we don't need + // to (and don't have to worry about precision if not necessary) + if strings.Contains(field.Value, ".") { + val, err = strconv.ParseFloat(field.Value, 64) + if err != nil { + return fmt.Errorf("error parsing value %q as a float: %w", field.Value, err) + } + } else { + val, err = strconv.ParseInt(field.Value, 10, 64) + if err != nil { + return fmt.Errorf("error parsing value %q as an integer: %w", field.Value, err) + } + } + + case "string-" + suffix: + val = strings.Trim(field.Value, `"`) + + case "bool-" + suffix: + switch field.Value { + case "true": + val = true + case "false": + val = false + default: + return fmt.Errorf("error parsing value %q as a bool", field.Value) + } + + case suffix: + // In this case, use heuristics to just do the right thing the vast + // majority of the time + switch { + case field.Value == "null": // Explicit null, we want to set to a null value to clear it + val = nil + + case field.Value == "true": // bool true + val = true + + case field.Value == "false": // bool false + val = false + + case strings.HasPrefix(field.Value, `"`): // explicitly quoted string + val = strings.Trim(field.Value, `"`) + + case jsonNumberRegex.Match([]byte(strings.Trim(field.Value, `"`))): // number + // Same logic as above + if strings.Contains(field.Value, ".") { + val, err = strconv.ParseFloat(field.Value, 64) + if err != nil { + return fmt.Errorf("error parsing value %q as a float: %w", field.Value, err) + } + } else { + val, err = strconv.ParseInt(field.Value, 10, 64) + if err != nil { + return fmt.Errorf("error parsing value %q as an integer: %w", field.Value, err) + } + } + + case strings.HasPrefix(field.Value, "["): // serialized JSON array + var s []interface{} + u := json.NewDecoder(bytes.NewBufferString(field.Value)) + u.UseNumber() + if err := u.Decode(&s); err != nil { + return fmt.Errorf("error parsing value %q as a json array: %w", field.Value, err) + } + val = s + + case strings.HasPrefix(field.Value, "{"): // serialized JSON map + var m map[string]interface{} + u := json.NewDecoder(bytes.NewBufferString(field.Value)) + u.UseNumber() + if err := u.Decode(&m); err != nil { + return fmt.Errorf("error parsing value %q as a json map: %w", field.Value, err) + } + val = m + + default: + // Default is to treat as a string value + val = field.Value + } + default: + return fmt.Errorf("unknown flag %q", field.Name) + } + + // Now we have to insert it in the right position in the final map + currMap := setMap + for i, segment := range field.Keys { + if segment == "" { + return fmt.Errorf("key segment %q for value %q is empty", segment, field.Value) + } + + switch { + case i == len(field.Keys)-1: + // If we get an explicit "null" override whatever is currently + // there + if val == nil { + currMap[segment] = nil + break + } + // We're at the last hop, do the actual insertion + switch t := currMap[segment].(type) { + case nil: + // Nothing currently exists + currMap[segment] = val + + case []interface{}: + // It's already a slice, so just append + currMap[segment] = append(t, val) + + default: + // It's not a slice, so create a new slice with the + // exisitng and new values + currMap[segment] = []interface{}{t, val} + } + + default: + // We need to keep traversing + switch t := currMap[segment].(type) { + case nil: + // We haven't hit this segment before, so create a new + // object leading off of it and set it to current + newMap := map[string]interface{}{} + currMap[segment] = newMap + currMap = newMap + + case map[string]interface{}: + // We've seen this before and already have a map so just set + // that as our new location + currMap = t + + default: + // We should only ever be seeing maps if we're not at the + // final location + return fmt.Errorf("unexpected type for key segment %q: %T", segment, t) + } + } + } + } + + if len(setMap) > 0 { + setFunc(setMap) + } + return nil +} diff --git a/internal/cmd/common/flags_test.go b/internal/cmd/common/flags_test.go new file mode 100644 index 0000000000..6d41bab66a --- /dev/null +++ b/internal/cmd/common/flags_test.go @@ -0,0 +1,413 @@ +package common + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPopulateAttrFlags tests common patterns we'll actually be using. Note +// that this is not an exhaustive test of the full CombinationSliceVar +// functionality, it's a bit higher level test based on what we'll actually need +// and what we'll actually have set. +func TestPopulateAttrFlags(t *testing.T) { + tests := []struct { + name string + args []string + expected []base.CombinedSliceFlagValue + expectedErr string + }{ + { + name: "strings-only", + args: []string{"-string-attr", "foo=bar", "-string-attr", `bar="baz"`}, + expected: []base.CombinedSliceFlagValue{ + { + Name: "string-attr", + Keys: []string{"foo"}, + Value: "bar", + }, + { + Name: "string-attr", + Keys: []string{"bar"}, + Value: `"baz"`, + }, + }, + }, + { + name: "nums-only", + args: []string{"-num-attr", "foo=-1.2", "-num-attr", "bar=5"}, + expected: []base.CombinedSliceFlagValue{ + { + Name: "num-attr", + Keys: []string{"foo"}, + Value: "-1.2", + }, + { + Name: "num-attr", + Keys: []string{"bar"}, + Value: "5", + }, + }, + }, + { + name: "bools-only", + args: []string{"-bool-attr", "foo=true", "-bool-attr", "bar=false"}, + expected: []base.CombinedSliceFlagValue{ + { + Name: "bool-attr", + Keys: []string{"foo"}, + Value: "true", + }, + { + Name: "bool-attr", + Keys: []string{"bar"}, + Value: "false", + }, + }, + }, + { + name: "mixed", + args: []string{"-num-attr", "foo=9820", "-string-attr", "bar=9820", "-attr", "baz=9820"}, + expected: []base.CombinedSliceFlagValue{ + { + Name: "num-attr", + Keys: []string{"foo"}, + Value: "9820", + }, + { + Name: "string-attr", + Keys: []string{"bar"}, + Value: "9820", + }, + { + Name: "attr", + Keys: []string{"baz"}, + Value: "9820", + }, + }, + }, + { + name: "mixed-segments", + args: []string{"-num-attr", "foo.bar.baz=9820", "-string-attr", "bar.baz.foo=9820", "-attr", "baz.foo.bar=9820"}, + expected: []base.CombinedSliceFlagValue{ + { + Name: "num-attr", + Keys: []string{"foo", "bar", "baz"}, + Value: "9820", + }, + { + Name: "string-attr", + Keys: []string{"bar", "baz", "foo"}, + Value: "9820", + }, + { + Name: "attr", + Keys: []string{"baz", "foo", "bar"}, + Value: "9820", + }, + }, + }, + { + name: "bad-key-name", + args: []string{"-num-attr", "fo-oo=5"}, + expectedErr: "invalid value", + }, + { + name: "bad-key-name-in-segment", + args: []string{"-num-attr", "fo.oo-o.o=5"}, + expectedErr: "invalid value", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + // Note: we do the setup on each run to make sure we aren't carrying + // state over; just like in the real CLI where each run would have + // pristine state. + c := new(base.Command) + flagSet := c.FlagSet(base.FlagSetNone) + f := flagSet.NewFlagSet("Attribute Options") + cmd := "create" + flagNames := map[string][]string{ + cmd: { + "attributes", + "attr", + "string-attr", + "bool-attr", + "num-attr", + }, + } + + PopulateAttributeFlags(c, f, flagNames, cmd) + err := flagSet.Parse(tt.args) + if tt.expectedErr != "" { + require.Error(err) + assert.Contains(err.Error(), tt.expectedErr) + return + } + require.NoError(err) + assert.Equal(tt.expected, c.FlagAttrs) + }) + } +} + +// TestHandleAttributeFlags tests the function that parses types based on +// incoming data. It assumes we're coming in with CombinedSliceFlagValues and +// validates what comes out -- whether nil func was called or the map function +// was called (and its contents). +func TestHandleAttributeFlags(t *testing.T) { + tests := []struct { + name string + args []base.CombinedSliceFlagValue + expectedMap map[string]interface{} + expectedErr string + }{ + { + name: "strings-only", + args: []base.CombinedSliceFlagValue{ + { + Name: "string-%s", + Keys: []string{"foo"}, + Value: "bar", + }, + { + Name: "string-%s", + Keys: []string{"bar"}, + Value: `"baz"`, + }, + }, + expectedMap: map[string]interface{}{ + "foo": "bar", + "bar": "baz", + }, + }, + { + name: "nums-only", + args: []base.CombinedSliceFlagValue{ + { + Name: "num-%s", + Keys: []string{"foo"}, + Value: "-1.2", + }, + { + Name: "num-%s", + Keys: []string{"bar"}, + Value: "5", + }, + }, + expectedMap: map[string]interface{}{ + "foo": float64(-1.2), + "bar": int64(5), + }, + }, + { + name: "bad-float-num", + args: []base.CombinedSliceFlagValue{ + { + Name: "num-%s", + Keys: []string{"foo"}, + Value: "-15d.2", + }, + }, + expectedErr: "as a float", + }, + { + name: "bad-int-num", + args: []base.CombinedSliceFlagValue{ + { + Name: "num-%s", + Keys: []string{"foo"}, + Value: "-15d3", + }, + }, + expectedErr: "as an int", + }, + { + name: "bools-only", + args: []base.CombinedSliceFlagValue{ + { + Name: "bool-%s", + Keys: []string{"foo"}, + Value: "true", + }, + { + Name: "bool-%s", + Keys: []string{"bar"}, + Value: "false", + }, + }, + expectedMap: map[string]interface{}{ + "foo": true, + "bar": false, + }, + }, + { + name: "bad-bool", + args: []base.CombinedSliceFlagValue{ + { + Name: "bool-%s", + Keys: []string{"foo"}, + Value: "t", + }, + }, + expectedErr: "as a bool", + }, + { + name: "attr-only", + args: []base.CombinedSliceFlagValue{ + { + Name: "%s", + Keys: []string{"b1"}, + Value: "true", + }, + { + Name: "%s", + Keys: []string{"b2"}, + Value: "false", + }, + { + Name: "%s", + Keys: []string{"s1"}, + Value: "scoopde", + }, + { + Name: "%s", + Keys: []string{"s2"}, + Value: `"woop"`, + }, + { + Name: "%s", + Keys: []string{"n1"}, + Value: "-1.2", + }, + { + Name: "%s", + Keys: []string{"n2"}, + Value: "5", + }, + { + Name: "%s", + Keys: []string{"a"}, + Value: `["foo", 1.5, true, ["bar"], {"hip": "hop"}]`, + }, + { + Name: "%s", + Keys: []string{"nil"}, + Value: "null", + }, + { + Name: "%s", + Keys: []string{"m"}, + Value: `{"b": true, "n": 6, "s": "scoopde", "a": ["bar"], "m": {"hip": "hop"}}`, + }, + }, + expectedMap: map[string]interface{}{ + "b1": true, + "b2": false, + "s1": "scoopde", + "s2": "woop", + "n1": float64(-1.2), + "n2": int64(5), + "a": []interface{}{ + "foo", + json.Number("1.5"), + true, + []interface{}{"bar"}, + map[string]interface{}{"hip": "hop"}, + }, + "m": map[string]interface{}{ + "b": true, + "n": json.Number("6"), + "s": "scoopde", + "a": []interface{}{"bar"}, + "m": map[string]interface{}{"hip": "hop"}, + }, + "nil": nil, + }, + }, + { + name: "map-array-structure", + args: []base.CombinedSliceFlagValue{ + { + Name: "%s", + Keys: []string{"bools"}, + Value: "true", + }, + { + Name: "%s", + Keys: []string{"bools"}, + Value: "false", + }, + { + Name: "%s", + Keys: []string{"strings", "s1"}, + Value: "scoopde", + }, + { + Name: "%s", + Keys: []string{"strings", "s2"}, + Value: `"woop"`, + }, + { + Name: "%s", + Keys: []string{"numbers", "reps"}, + Value: "-1.2", + }, + { + Name: "%s", + Keys: []string{"numbers", "reps"}, + Value: "5", + }, + { + Name: "%s", + Keys: []string{"strings", "s2"}, // This will overwrite above! + Value: "null", + }, + }, + expectedMap: map[string]interface{}{ + "bools": []interface{}{true, false}, + "strings": map[string]interface{}{ + "s1": "scoopde", + "s2": nil, + }, + "numbers": map[string]interface{}{ + "reps": []interface{}{float64(-1.2), int64(5)}, + }, + }, + }, + } + for _, tt := range tests { + for _, typ := range []string{"attr", "secret"} { + t.Run(fmt.Sprintf("%s-%s", tt.name, typ), func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + // Note: we do the setup on each run to make sure we aren't carrying + // state over; just like in the real CLI where each run would have + // pristine state. + c := new(base.Command) + var outMap map[string]interface{} + + args := make([]base.CombinedSliceFlagValue, 0, len(tt.args)) + for _, arg := range tt.args { + arg.Name = fmt.Sprintf(arg.Name, typ) + args = append(args, arg) + } + + err := HandleAttributeFlags(c, typ, "", args, func() {}, func(in map[string]interface{}) { outMap = in }) + if tt.expectedErr != "" { + require.Error(err) + assert.Contains(err.Error(), tt.expectedErr) + return + } + + require.NoError(err) + assert.Equal(tt.expectedMap, outMap) + }) + } + } +} diff --git a/internal/cmd/gencli/input.go b/internal/cmd/gencli/input.go index a2640456e0..041274460a 100644 --- a/internal/cmd/gencli/input.go +++ b/internal/cmd/gencli/input.go @@ -68,6 +68,17 @@ type cmdInfo struct { // This allows the flags to be defined differently from the the attribute // names in the API. PrefixAttributeFieldErrorsWithSubactionPrefix bool + + // HasGenericAttributes controls whether to generate flags for -attributes, + // -attr, -string-attr, etc. + HasGenericAttributes bool + + // HasGenericSecrets controls whether to generate flags for -secrets, + // -secret, -string-secret, etc. + HasGenericSecrets bool + + // IsPluginType controls whether standard plugin flags are generated + IsPluginType bool } var inputStructs = map[string][]*cmdInfo{ diff --git a/internal/cmd/gencli/templates.go b/internal/cmd/gencli/templates.go index a6851506e6..5c8f08854d 100644 --- a/internal/cmd/gencli/templates.go +++ b/internal/cmd/gencli/templates.go @@ -169,15 +169,17 @@ func (c *{{ camelCase .SubActionPrefix }}Command) Help() string { } var flags{{ camelCase .SubActionPrefix }}Map = map[string][]string{ - {{ range $i, $action := .StdActions }} + {{ with $attrFlags := ", \"attributes\", \"attr\", \"string-attr\", \"bool-attr\", \"num-attr\"" }} + {{ with $secretFlags := ", \"secrets\", \"secret\", \"string-secret\", \"bool-secret\", \"num-secret\"" }} + {{ range $i, $action := $input.StdActions }} {{ if eq $action "create" }} - "create": { "{{ kebabCase $input.Container }}-id", "name", "description" }, + "create": { "{{ kebabCase $input.Container }}-id", "name", "description" {{ if $input.IsPluginType }} , "plugin-id", "plugin-name" {{ end }} {{ if $input.HasGenericAttributes }} {{ $attrFlags }} {{ end }} {{ if $input.HasGenericSecrets }} {{ $secretFlags }} {{ end }} }, {{ end }} {{ if eq $action "read" }} "read": {"id"}, {{ end }} {{ if eq $action "update" }} - "update": {"id", "name", "description" {{ if hasAction $input.VersionedActions "update" }}, "version" {{ end }} }, + "update": {"id", "name", "description" {{ if hasAction $input.VersionedActions "update" }}, "version" {{ end }} {{ if $input.HasGenericAttributes }} {{ $attrFlags }} {{ end }} {{ if $input.HasGenericSecrets }} {{ $secretFlags }} {{ end }} }, {{ end }} {{ if eq $action "delete" }} "delete": {"id"}, @@ -186,6 +188,8 @@ var flags{{ camelCase .SubActionPrefix }}Map = map[string][]string{ "list": { "{{ kebabCase $input.Container }}-id", "filter" {{ if (eq $input.Container "Scope") }}, "recursive"{{ end }} }, {{ end }} {{ end }} + {{ end }} + {{ end }} } func (c *{{ camelCase .SubActionPrefix }}Command) Flags() *base.FlagSets { @@ -197,6 +201,16 @@ func (c *{{ camelCase .SubActionPrefix }}Command) Flags() *base.FlagSets { f := set.NewFlagSet("Command Options") common.PopulateCommonFlags(c.Command, f, "{{ if .SubActionPrefix }}{{ .SubActionPrefix }}-type {{ end }}{{ lowerSpaceCase .ResourceType }}", flags{{ camelCase .SubActionPrefix }}Map, c.Func) + {{ if .HasGenericAttributes }} + f = set.NewFlagSet("Attribute Options") + common.PopulateAttributeFlags(c.Command, f, flags{{ camelCase .SubActionPrefix }}Map, c.Func) + {{ end }} + + {{ if .HasGenericSecrets }} + f = set.NewFlagSet("Secrets Options") + common.PopulateSecretFlags(c.Command, f, flags{{ camelCase .SubActionPrefix }}Map, c.Func) + {{ end }} + extra{{ camelCase .SubActionPrefix }}FlagsFunc(c, set, f) return set @@ -307,6 +321,19 @@ func (c *{{ camelCase .SubActionPrefix }}Command) Run(args []string) int { opts = append(opts, {{ .Pkg }}.WithScopeName(c.FlagScopeName)) } {{ end }} + + {{ if .IsPluginType }} + switch c.FlagPluginId { + case "": + default: + opts = append(opts, {{ .Pkg }}.WithPluginId(c.FlagPluginId)) + } + switch c.FlagPluginName { + case "": + default: + opts = append(opts, {{ .Pkg }}.WithPluginName(c.FlagPluginName)) + } + {{ end }} var version uint32 {{ if .VersionedActions }} @@ -323,7 +350,41 @@ func (c *{{ camelCase .SubActionPrefix }}Command) Run(args []string) int { } {{ end }} - if ok := extra{{ camelCase .SubActionPrefix }}FlagsHandlingFunc(c, f, &opts); !ok { + {{ if .HasGenericAttributes }} + if err := common.HandleAttributeFlags( + c.Command, + "attr", + c.FlagAttributes, + c.FlagAttrs, + func() { + opts = append(opts, {{ .Pkg }}.DefaultAttributes()) + }, + func(in map[string]interface{}) { + opts = append(opts, {{ .Pkg }}.WithAttributes(in)) + }); err != nil { + c.PrintCliError(fmt.Errorf("Error evaluating attribute flags to: %s", err.Error())) + return base.CommandCliError + } + {{ end }} + + {{ if .HasGenericSecrets }} + if err := common.HandleAttributeFlags( + c.Command, + "secret", + c.FlagSecrets, + c.FlagScrts, + func() { + opts = append(opts, {{ .Pkg }}.DefaultSecrets()) + }, + func(in map[string]interface{}) { + opts = append(opts, {{ .Pkg }}.WithSecrets(in)) + }); err != nil { + c.PrintCliError(fmt.Errorf("Error evaluating secret flags to: %s", err.Error())) + return base.CommandCliError + } + {{ end }} + + if ok := extra{{ camelCase .SubActionPrefix }}FlagsHandlingFunc(c, f, &opts, ); !ok { return base.CommandUserError }