From a834a147850bbde6100aaa7b0e692812cda4f2e8 Mon Sep 17 00:00:00 2001 From: Jeff Mitchell Date: Sun, 26 Sep 2021 11:48:43 -0400 Subject: [PATCH] Add attribute flag building and parsing (#1562) This adds a few command sketches but that's not the main point of the PR and filling those out will come in a different PR. This adds a new flag type that can combine inputs to a single target slice, retaining the ability to distinguish where it came from but also keep ordering across different flag names. It also implements a function that does the heuristic parsing with fallback. --- api/hostsets/option.gen.go | 12 + internal/cmd/base/base.go | 4 + internal/cmd/base/flags.go | 118 +++++ internal/cmd/commands.go | 12 + .../commands/hostcatalogscmd/plugin_funcs.go | 33 ++ .../plugin_hostcatalogs.gen.go | 260 +++++++++++ .../commands/hostcatalogscmd/static_funcs.go | 2 +- internal/cmd/common/flags.go | 244 +++++++++++ internal/cmd/common/flags_test.go | 405 ++++++++++++++++++ internal/cmd/gencli/input.go | 19 + internal/cmd/gencli/templates.go | 30 +- internal/host/plugin/repository_host_set.go | 16 +- .../host/plugin/repository_host_set_test.go | 16 +- .../api/resources/hostsets/v1/host_set.proto | 2 +- .../handlers/targets/target_service_test.go | 4 +- .../api/resources/hostsets/host_set.pb.go | 25 +- 16 files changed, 1166 insertions(+), 36 deletions(-) create mode 100644 internal/cmd/commands/hostcatalogscmd/plugin_funcs.go create mode 100644 internal/cmd/commands/hostcatalogscmd/plugin_hostcatalogs.gen.go create mode 100644 internal/cmd/common/flags_test.go diff --git a/api/hostsets/option.gen.go b/api/hostsets/option.gen.go index 317b79b83..eafe5b7fa 100644 --- a/api/hostsets/option.gen.go +++ b/api/hostsets/option.gen.go @@ -74,6 +74,18 @@ func WithFilter(filter string) Option { } } +func WithAttributes(inAttributes map[string]interface{}) Option { + return func(o *options) { + o.postMap["attributes"] = inAttributes + } +} + +func DefaultAttributes() Option { + return func(o *options) { + o.postMap["attributes"] = nil + } +} + func WithDescription(inDescription string) Option { return func(o *options) { o.postMap["description"] = inDescription diff --git a/internal/cmd/base/base.go b/internal/cmd/base/base.go index 0738b5ec6..826c7e2f2 100644 --- a/internal/cmd/base/base.go +++ b/internal/cmd/base/base.go @@ -83,6 +83,10 @@ type Command struct { FlagRecursive bool FlagFilter string + // Attribute values + FlagAttributes string + FlagAttrs []CombinedSliceFlagValue + client *api.Client } diff --git a/internal/cmd/base/flags.go b/internal/cmd/base/flags.go index 63efaf7ad..fef530e7e 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/commands.go b/internal/cmd/commands.go index 270e41562..8cd7d9cc0 100644 --- a/internal/cmd/commands.go +++ b/internal/cmd/commands.go @@ -512,6 +512,12 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { Func: "create", }, nil }, + "host-catalogs create plugin": func() (cli.Command, error) { + return &hostcatalogscmd.PluginCommand{ + Command: base.NewCommand(ui), + Func: "create", + }, nil + }, "host-catalogs update": func() (cli.Command, error) { return &hostcatalogscmd.Command{ Command: base.NewCommand(ui), @@ -524,6 +530,12 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { Func: "update", }, nil }, + "host-catalogs update plugin": func() (cli.Command, error) { + return &hostcatalogscmd.PluginCommand{ + Command: base.NewCommand(ui), + Func: "update", + }, nil + }, "host-sets": func() (cli.Command, error) { return &hostsetscmd.Command{ diff --git a/internal/cmd/commands/hostcatalogscmd/plugin_funcs.go b/internal/cmd/commands/hostcatalogscmd/plugin_funcs.go new file mode 100644 index 000000000..5bf4ac915 --- /dev/null +++ b/internal/cmd/commands/hostcatalogscmd/plugin_funcs.go @@ -0,0 +1,33 @@ +package hostcatalogscmd + +import ( + "github.com/hashicorp/boundary/internal/cmd/base" +) + +func (c *PluginCommand) extraPluginHelpFunc(helpMap map[string]func() string) string { + var helpStr string + switch c.Func { + case "create": + helpStr = base.WrapForHelpText([]string{ + "Usage: boundary host-catalogs create plugin [options] [args]", + "", + " Create a plugin-type host catalog. Example:", + "", + ` $ boundary host-catalogs create plugin -scope-id p_1234567890 -name prodops -description "Plugin host-catalog for ProdOps"`, + "", + "", + }) + + case "update": + helpStr = base.WrapForHelpText([]string{ + "Usage: boundary host-catalogs update plugin [options] [args]", + "", + " Update a plugin-type host catalog given its ID. Example:", + "", + ` $ boundary host-catalogs update plugin -id hc_1234567890 -name "devops" -description "Plugin host-catalog for DevOps"`, + "", + "", + }) + } + return helpStr + c.Flags().Help() +} diff --git a/internal/cmd/commands/hostcatalogscmd/plugin_hostcatalogs.gen.go b/internal/cmd/commands/hostcatalogscmd/plugin_hostcatalogs.gen.go new file mode 100644 index 000000000..223ee3026 --- /dev/null +++ b/internal/cmd/commands/hostcatalogscmd/plugin_hostcatalogs.gen.go @@ -0,0 +1,260 @@ +// Code generated by "make cli"; DO NOT EDIT. +package hostcatalogscmd + +import ( + "errors" + "fmt" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/hostcatalogs" + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/hashicorp/boundary/internal/cmd/common" + "github.com/hashicorp/go-secure-stdlib/strutil" + "github.com/mitchellh/cli" + "github.com/posener/complete" +) + +func initPluginFlags() { + flagsOnce.Do(func() { + extraFlags := extraPluginActionsFlagsMapFunc() + for k, v := range extraFlags { + flagsPluginMap[k] = append(flagsPluginMap[k], v...) + } + }) +} + +var ( + _ cli.Command = (*PluginCommand)(nil) + _ cli.CommandAutocomplete = (*PluginCommand)(nil) +) + +type PluginCommand struct { + *base.Command + + Func string + + plural string +} + +func (c *PluginCommand) AutocompleteArgs() complete.Predictor { + initPluginFlags() + return complete.PredictAnything +} + +func (c *PluginCommand) AutocompleteFlags() complete.Flags { + initPluginFlags() + return c.Flags().Completions() +} + +func (c *PluginCommand) Synopsis() string { + if extra := extraPluginSynopsisFunc(c); extra != "" { + return extra + } + + synopsisStr := "host catalog" + + synopsisStr = fmt.Sprintf("%s %s", "plugin-type", synopsisStr) + + return common.SynopsisFunc(c.Func, synopsisStr) +} + +func (c *PluginCommand) Help() string { + initPluginFlags() + + var helpStr string + helpMap := common.HelpMap("host catalog") + + switch c.Func { + default: + + helpStr = c.extraPluginHelpFunc(helpMap) + } + + // Keep linter from complaining if we don't actually generate code using it + _ = helpMap + return helpStr +} + +var flagsPluginMap = map[string][]string{ + + "create": {"scope-id", "name", "description", "attributes", "attr", "string-attr", "bool-attr", "num-attr"}, + + "update": {"id", "name", "description", "version", "attributes", "attr", "string-attr", "bool-attr", "num-attr"}, +} + +func (c *PluginCommand) Flags() *base.FlagSets { + if len(flagsPluginMap[c.Func]) == 0 { + return c.FlagSet(base.FlagSetNone) + } + + set := c.FlagSet(base.FlagSetHTTP | base.FlagSetClient | base.FlagSetOutputFormat) + f := set.NewFlagSet("Command Options") + common.PopulateCommonFlags(c.Command, f, "plugin-type host catalog", flagsPluginMap, c.Func) + + f = set.NewFlagSet("Attribute Options") + common.PopulateAttributeFlags(c.Command, f, flagsPluginMap, c.Func) + + extraPluginFlagsFunc(c, set, f) + + return set +} + +func (c *PluginCommand) Run(args []string) int { + initPluginFlags() + + switch c.Func { + case "": + return cli.RunResultHelp + } + + c.plural = "plugin-type host catalog" + switch c.Func { + case "list": + c.plural = "plugin-type host catalogs" + } + + f := c.Flags() + + if err := f.Parse(args); err != nil { + c.PrintCliError(err) + return base.CommandUserError + } + + if strutil.StrListContains(flagsPluginMap[c.Func], "id") && c.FlagId == "" { + c.PrintCliError(errors.New("ID is required but not passed in via -id")) + return base.CommandUserError + } + + var opts []hostcatalogs.Option + + if strutil.StrListContains(flagsPluginMap[c.Func], "scope-id") { + switch c.Func { + case "create": + if c.FlagScopeId == "" { + c.PrintCliError(errors.New("Scope ID must be passed in via -scope-id or BOUNDARY_SCOPE_ID")) + return base.CommandUserError + } + } + } + + client, err := c.Client() + if err != nil { + c.PrintCliError(fmt.Errorf("Error creating API client: %s", err.Error())) + return base.CommandCliError + } + hostcatalogsClient := hostcatalogs.NewClient(client) + + switch c.FlagName { + case "": + case "null": + opts = append(opts, hostcatalogs.DefaultName()) + default: + opts = append(opts, hostcatalogs.WithName(c.FlagName)) + } + + switch c.FlagDescription { + case "": + case "null": + opts = append(opts, hostcatalogs.DefaultDescription()) + default: + opts = append(opts, hostcatalogs.WithDescription(c.FlagDescription)) + } + + switch c.FlagRecursive { + case true: + opts = append(opts, hostcatalogs.WithRecursive(true)) + } + + if c.FlagFilter != "" { + opts = append(opts, hostcatalogs.WithFilter(c.FlagFilter)) + } + + var version uint32 + + switch c.Func { + case "update": + switch c.FlagVersion { + case 0: + opts = append(opts, hostcatalogs.WithAutomaticVersioning(true)) + default: + version = uint32(c.FlagVersion) + } + } + + if err := common.HandleAttributeFlags( + c.Command, + "attr", + func() { + hostcatalogs.DefaultAttributes() + }, + func(in map[string]interface{}) { + hostcatalogs.WithAttributes(in) + }); err != nil { + c.PrintCliError(fmt.Errorf("Error evaluating attribute flags to: %s", err.Error())) + return base.CommandCliError + } + + if ok := extraPluginFlagsHandlingFunc(c, f, &opts); !ok { + return base.CommandUserError + } + + var result api.GenericResult + + switch c.Func { + + case "create": + result, err = hostcatalogsClient.Create(c.Context, "plugin", c.FlagScopeId, opts...) + + case "update": + result, err = hostcatalogsClient.Update(c.Context, c.FlagId, version, opts...) + + } + + result, err = executeExtraPluginActions(c, result, err, hostcatalogsClient, version, opts) + + if err != nil { + if apiErr := api.AsServerError(err); apiErr != nil { + var opts []base.Option + + c.PrintApiError(apiErr, fmt.Sprintf("Error from controller when performing %s on %s", c.Func, c.plural), opts...) + return base.CommandApiError + } + c.PrintCliError(fmt.Errorf("Error trying to %s %s: %s", c.Func, c.plural, err.Error())) + return base.CommandCliError + } + + output, err := printCustomPluginActionOutput(c) + if err != nil { + c.PrintCliError(err) + return base.CommandUserError + } + if output { + return base.CommandSuccess + } + + switch c.Func { + } + + switch base.Format(c.UI) { + case "table": + c.UI.Output(printItemTable(result)) + + case "json": + if ok := c.PrintJsonItem(result); !ok { + return base.CommandCliError + } + } + + return base.CommandSuccess +} + +var ( + extraPluginActionsFlagsMapFunc = func() map[string][]string { return nil } + extraPluginSynopsisFunc = func(*PluginCommand) string { return "" } + extraPluginFlagsFunc = func(*PluginCommand, *base.FlagSets, *base.FlagSet) {} + extraPluginFlagsHandlingFunc = func(*PluginCommand, *base.FlagSets, *[]hostcatalogs.Option) bool { return true } + executeExtraPluginActions = func(_ *PluginCommand, inResult api.GenericResult, inErr error, _ *hostcatalogs.Client, _ uint32, _ []hostcatalogs.Option) (api.GenericResult, error) { + return inResult, inErr + } + printCustomPluginActionOutput = func(*PluginCommand) (bool, error) { return false, nil } +) diff --git a/internal/cmd/commands/hostcatalogscmd/static_funcs.go b/internal/cmd/commands/hostcatalogscmd/static_funcs.go index e0a571c1d..48d5daee8 100644 --- a/internal/cmd/commands/hostcatalogscmd/static_funcs.go +++ b/internal/cmd/commands/hostcatalogscmd/static_funcs.go @@ -13,7 +13,7 @@ func (c *StaticCommand) extraStaticHelpFunc(helpMap map[string]func() string) st "", " Create a static-type host catalog. Example:", "", - ` $ boundary host-catalogs create static -name prodops -description "Static host-catalog for ProdOps"`, + ` $ boundary host-catalogs create static -scope-id p_1234567890 -name prodops -description "Static host-catalog for ProdOps"`, "", "", }) diff --git a/internal/cmd/common/flags.go b/internal/cmd/common/flags.go index 5ab75fc46..df15751d9 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" ) @@ -93,3 +100,240 @@ 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 attribute map. Usually this will be soured 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 attribute 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 attribute 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 attribute 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 attribute 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 string, defaultFunc func(), setFunc func(map[string]interface{})) error { + // If we were given -attributes, use that as-is + switch c.FlagAttributes { + case "": + // Nothing, continue on + case "null": + defaultFunc() + return nil + default: + parsedString, err := parseutil.ParsePath(c.FlagAttributes) + if err != nil && !errors.Is(err, parseutil.ErrNotAUrl) { + return fmt.Errorf("error parsing attributes flag as a URL: %w", 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 attributes flag as JSON: %w", err) + } + setFunc(setMap) + return nil + } + + setMap := map[string]interface{}{} + + for _, attr := range c.FlagAttrs { + if len(attr.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 attr.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(attr.Value, ".") { + val, err = strconv.ParseFloat(attr.Value, 64) + if err != nil { + return fmt.Errorf("error parsing attribute %q as a float: %w", attr.Value, err) + } + } else { + val, err = strconv.ParseInt(attr.Value, 10, 64) + if err != nil { + return fmt.Errorf("error parsing attribute %q as an integer: %w", attr.Value, err) + } + } + + case "string-" + suffix: + val = strings.Trim(attr.Value, `"`) + + case "bool-" + suffix: + switch attr.Value { + case "true": + val = true + case "false": + val = false + default: + return fmt.Errorf("error parsing attribute %q as a bool", attr.Value) + } + + case suffix: + // In this case, use heuristics to just do the right thing the vast + // majority of the time + switch { + case attr.Value == "null": // Explicit null, we want to set to a null value to clear it + val = nil + + case attr.Value == "true": // bool true + val = true + + case attr.Value == "false": // bool false + val = false + + case strings.HasPrefix(attr.Value, `"`): // explicitly quoted string + val = strings.Trim(attr.Value, `"`) + + case jsonNumberRegex.Match([]byte(strings.Trim(attr.Value, `"`))): // number + // Same logic as above + if strings.Contains(attr.Value, ".") { + val, err = strconv.ParseFloat(attr.Value, 64) + if err != nil { + return fmt.Errorf("error parsing attribute %q as a float: %w", attr.Value, err) + } + } else { + val, err = strconv.ParseInt(attr.Value, 10, 64) + if err != nil { + return fmt.Errorf("error parsing attribute %q as an integer: %w", attr.Value, err) + } + } + + case strings.HasPrefix(attr.Value, "["): // serialized JSON array + var s []interface{} + u := json.NewDecoder(bytes.NewBufferString(attr.Value)) + u.UseNumber() + if err := u.Decode(&s); err != nil { + return fmt.Errorf("error parsing attribute %q as a json array: %w", attr.Value, err) + } + val = s + + case strings.HasPrefix(attr.Value, "{"): // serialized JSON map + var m map[string]interface{} + u := json.NewDecoder(bytes.NewBufferString(attr.Value)) + u.UseNumber() + if err := u.Decode(&m); err != nil { + return fmt.Errorf("error parsing attribute %q as a json map: %w", attr.Value, err) + } + val = m + + default: + // Default is to treat as a string value + val = attr.Value + } + default: + return fmt.Errorf("unknown attribute flag %q", attr.Name) + } + + // Now we have to insert it in the right position in the attribute map + currMap := setMap + for i, segment := range attr.Keys { + if segment == "" { + return fmt.Errorf("key segment %q for value %q is empty", segment, attr.Value) + } + + switch { + case i == len(attr.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 000000000..b4e3bd4d6 --- /dev/null +++ b/internal/cmd/common/flags_test.go @@ -0,0 +1,405 @@ +package common + +import ( + "encoding/json" + "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-attr", + Keys: []string{"foo"}, + Value: "bar", + }, + { + Name: "string-attr", + Keys: []string{"bar"}, + Value: `"baz"`, + }, + }, + expectedMap: map[string]interface{}{ + "foo": "bar", + "bar": "baz", + }, + }, + { + name: "nums-only", + args: []base.CombinedSliceFlagValue{ + { + Name: "num-attr", + Keys: []string{"foo"}, + Value: "-1.2", + }, + { + Name: "num-attr", + 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-attr", + Keys: []string{"foo"}, + Value: "-15d.2", + }, + }, + expectedErr: "as a float", + }, + { + name: "bad-int-num", + args: []base.CombinedSliceFlagValue{ + { + Name: "num-attr", + Keys: []string{"foo"}, + Value: "-15d3", + }, + }, + expectedErr: "as an int", + }, + { + name: "bools-only", + args: []base.CombinedSliceFlagValue{ + { + Name: "bool-attr", + Keys: []string{"foo"}, + Value: "true", + }, + { + Name: "bool-attr", + Keys: []string{"bar"}, + Value: "false", + }, + }, + expectedMap: map[string]interface{}{ + "foo": true, + "bar": false, + }, + }, + { + name: "bad-bool", + args: []base.CombinedSliceFlagValue{ + { + Name: "bool-attr", + Keys: []string{"foo"}, + Value: "t", + }, + }, + expectedErr: "as a bool", + }, + { + name: "attr-only", + args: []base.CombinedSliceFlagValue{ + { + Name: "attr", + Keys: []string{"b1"}, + Value: "true", + }, + { + Name: "attr", + Keys: []string{"b2"}, + Value: "false", + }, + { + Name: "attr", + Keys: []string{"s1"}, + Value: "scoopde", + }, + { + Name: "attr", + Keys: []string{"s2"}, + Value: `"woop"`, + }, + { + Name: "attr", + Keys: []string{"n1"}, + Value: "-1.2", + }, + { + Name: "attr", + Keys: []string{"n2"}, + Value: "5", + }, + { + Name: "attr", + Keys: []string{"a"}, + Value: `["foo", 1.5, true, ["bar"], {"hip": "hop"}]`, + }, + { + Name: "attr", + Keys: []string{"nil"}, + Value: "null", + }, + { + Name: "attr", + 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: "attr", + Keys: []string{"bools"}, + Value: "true", + }, + { + Name: "attr", + Keys: []string{"bools"}, + Value: "false", + }, + { + Name: "attr", + Keys: []string{"strings", "s1"}, + Value: "scoopde", + }, + { + Name: "attr", + Keys: []string{"strings", "s2"}, + Value: `"woop"`, + }, + { + Name: "attr", + Keys: []string{"numbers", "reps"}, + Value: "-1.2", + }, + { + Name: "attr", + Keys: []string{"numbers", "reps"}, + Value: "5", + }, + { + Name: "attr", + 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 { + 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) + c.FlagAttrs = tt.args + var outMap map[string]interface{} + + err := HandleAttributeFlags(c, "attr", 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 8a044fecc..e88fdefd1 100644 --- a/internal/cmd/gencli/input.go +++ b/internal/cmd/gencli/input.go @@ -68,6 +68,10 @@ 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 } var inputStructs = map[string][]*cmdInfo{ @@ -254,6 +258,21 @@ var inputStructs = map[string][]*cmdInfo{ VersionedActions: []string{"update"}, NeedsSubtypeInCreate: true, }, + { + ResourceType: resource.HostCatalog.String(), + Pkg: "hostcatalogs", + StdActions: []string{"create", "update"}, + SubActionPrefix: "plugin", + SkipNormalHelp: true, + HasExtraHelpFunc: true, + HasId: true, + HasName: true, + HasDescription: true, + Container: "Scope", + VersionedActions: []string{"update"}, + NeedsSubtypeInCreate: true, + HasGenericAttributes: true, + }, }, "hostsets": { { diff --git a/internal/cmd/gencli/templates.go b/internal/cmd/gencli/templates.go index a6851506e..22d591a02 100644 --- a/internal/cmd/gencli/templates.go +++ b/internal/cmd/gencli/templates.go @@ -169,15 +169,16 @@ 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\"" }} + {{ 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.HasGenericAttributes }} {{ $attrFlags }} {{ 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 }} }, {{ end }} {{ if eq $action "delete" }} "delete": {"id"}, @@ -186,6 +187,7 @@ var flags{{ camelCase .SubActionPrefix }}Map = map[string][]string{ "list": { "{{ kebabCase $input.Container }}-id", "filter" {{ if (eq $input.Container "Scope") }}, "recursive"{{ end }} }, {{ end }} {{ end }} + {{ end }} } func (c *{{ camelCase .SubActionPrefix }}Command) Flags() *base.FlagSets { @@ -197,6 +199,11 @@ 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 }} + extra{{ camelCase .SubActionPrefix }}FlagsFunc(c, set, f) return set @@ -323,7 +330,22 @@ 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", + func() { + {{ .Pkg }}.DefaultAttributes() + }, + func(in map[string]interface{}) { + {{ .Pkg }}.WithAttributes(in) + }); err != nil { + c.PrintCliError(fmt.Errorf("Error evaluating attribute flags to: %s", err.Error())) + return base.CommandCliError + } + {{ end }} + + if ok := extra{{ camelCase .SubActionPrefix }}FlagsHandlingFunc(c, f, &opts, ); !ok { return base.CommandUserError } diff --git a/internal/host/plugin/repository_host_set.go b/internal/host/plugin/repository_host_set.go index 74c798a72..3abda7fc7 100644 --- a/internal/host/plugin/repository_host_set.go +++ b/internal/host/plugin/repository_host_set.go @@ -373,14 +373,14 @@ func (r *Repository) Endpoints(ctx context.Context, setIds []string) ([]*host.En type setInfo struct { preferredEndpoint endpoint.Option - plgSet *pb.HostSet + plgSet *pb.HostSet } - type catalogInfo struct{ - publicId string - plg plgpb.HostPluginServiceServer - setInfos map[string]*setInfo - plgCat *hcpb.HostCatalog + type catalogInfo struct { + publicId string + plg plgpb.HostPluginServiceServer + setInfos map[string]*setInfo + plgCat *hcpb.HostCatalog persisted *plgpb.HostCatalogPersisted } @@ -458,10 +458,10 @@ func (r *Repository) Endpoints(ctx context.Context, setIds []string) ([]*host.En sets = append(sets, si.plgSet) } - resp, err := ci.plg.ListHosts(ctx, &plgpb.ListHostsRequest{ + resp, err := ci.plg.ListHosts(ctx, &plgpb.ListHostsRequest{ Catalog: ci.plgCat, Sets: sets, - //Persisted: ci.persisted, + // Persisted: ci.persisted, }) if err != nil { return nil, errors.Wrap(ctx, err, op) diff --git a/internal/host/plugin/repository_host_set_test.go b/internal/host/plugin/repository_host_set_test.go index d875ca999..3327ab13b 100644 --- a/internal/host/plugin/repository_host_set_test.go +++ b/internal/host/plugin/repository_host_set_test.go @@ -389,7 +389,7 @@ func TestRepository_Endpoints(t *testing.T) { } return &plgpb.ListHostsResponse{Hosts: []*plgpb.ListHostsResponseHost{ { - SetIds: setIds, + SetIds: setIds, ExternalId: "test", IpAddresses: []string{"10.0.0.5", "192.168.0.5"}, DnsNames: nil, @@ -407,7 +407,7 @@ func TestRepository_Endpoints(t *testing.T) { tests := []struct { name string - setIds []string + setIds []string want []*host.Endpoint wantIsErr errors.Code }{ @@ -416,7 +416,7 @@ func TestRepository_Endpoints(t *testing.T) { wantIsErr: errors.InvalidParameter, }, { - name: "with-set10", + name: "with-set10", setIds: []string{hostSet10.GetPublicId()}, want: []*host.Endpoint{ { @@ -431,7 +431,7 @@ func TestRepository_Endpoints(t *testing.T) { }, }, { - name: "with-different-set", + name: "with-different-set", setIds: []string{hostSet192.GetPublicId()}, want: []*host.Endpoint{ { @@ -446,14 +446,14 @@ func TestRepository_Endpoints(t *testing.T) { }, }, { - name: "with-all-addresses-filtered-set", + name: "with-all-addresses-filtered-set", setIds: []string{hostSet100.GetPublicId()}, - want: nil, + want: nil, }, { - name: "with-no-hosts-from-plugin", + name: "with-no-hosts-from-plugin", setIds: []string{hostlessSet.GetPublicId()}, - want: nil, + want: nil, }, } diff --git a/internal/proto/local/controller/api/resources/hostsets/v1/host_set.proto b/internal/proto/local/controller/api/resources/hostsets/v1/host_set.proto index 2fcdba617..cb28046e0 100644 --- a/internal/proto/local/controller/api/resources/hostsets/v1/host_set.proto +++ b/internal/proto/local/controller/api/resources/hostsets/v1/host_set.proto @@ -52,7 +52,7 @@ message HostSet { repeated string preferred_endpoints = 101 [json_name="preferred_endpoints", (custom_options.v1.generate_sdk_option) = true]; // The attributes that are applicable for the specific Host Set type. - google.protobuf.Struct attributes = 110; + google.protobuf.Struct attributes = 110 [(custom_options.v1.generate_sdk_option) = true]; // Output only. The available actions on this resource for this user. repeated string authorized_actions = 300 [json_name="authorized_actions"]; diff --git a/internal/servers/controller/handlers/targets/target_service_test.go b/internal/servers/controller/handlers/targets/target_service_test.go index 7b6e19936..8595a420c 100644 --- a/internal/servers/controller/handlers/targets/target_service_test.go +++ b/internal/servers/controller/handlers/targets/target_service_test.go @@ -2599,12 +2599,12 @@ func TestAuthorizeSession(t *testing.T) { } return &plgpb.ListHostsResponse{Hosts: []*plgpb.ListHostsResponseHost{ { - SetIds: setIds, + SetIds: setIds, ExternalId: "test", IpAddresses: []string{"10.0.0.1", "192.168.0.1"}, }, { - SetIds: setIds, + SetIds: setIds, ExternalId: "test2", IpAddresses: []string{"10.1.1.1", "192.168.1.1"}, }, diff --git a/sdk/pbs/controller/api/resources/hostsets/host_set.pb.go b/sdk/pbs/controller/api/resources/hostsets/host_set.pb.go index 787136971..ff4bcb274 100644 --- a/sdk/pbs/controller/api/resources/hostsets/host_set.pb.go +++ b/sdk/pbs/controller/api/resources/hostsets/host_set.pb.go @@ -217,7 +217,7 @@ var file_controller_api_resources_hostsets_v1_host_set_proto_rawDesc = []byte{ 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x2a, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x6f, 0x70, 0x74, 0x69, 0x6f, - 0x6e, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xe9, 0x05, 0x0a, 0x07, 0x48, 0x6f, 0x73, + 0x6e, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xef, 0x05, 0x0a, 0x07, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x28, 0x0a, 0x0f, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x14, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x68, @@ -257,19 +257,20 @@ var file_controller_api_resources_hostsets_v1_host_set_proto_rawDesc = []byte{ 0x64, 0x73, 0x12, 0x36, 0x0a, 0x13, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x65, 0x20, 0x03, 0x28, 0x09, 0x42, 0x04, 0xa0, 0xda, 0x29, 0x01, 0x52, 0x13, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, - 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x37, 0x0a, 0x0a, 0x61, 0x74, + 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x3d, 0x0a, 0x0a, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x18, 0x6e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, - 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x0a, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, - 0x74, 0x65, 0x73, 0x12, 0x2f, 0x0a, 0x12, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, - 0x64, 0x5f, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0xac, 0x02, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x12, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x5f, 0x61, 0x63, 0x74, - 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x52, 0x5a, 0x50, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, - 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x62, 0x6f, 0x75, - 0x6e, 0x64, 0x61, 0x72, 0x79, 0x2f, 0x73, 0x64, 0x6b, 0x2f, 0x70, 0x62, 0x73, 0x2f, 0x63, 0x6f, - 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2f, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x65, 0x74, 0x73, 0x3b, - 0x68, 0x6f, 0x73, 0x74, 0x73, 0x65, 0x74, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x42, 0x04, 0xa0, 0xda, 0x29, 0x01, 0x52, 0x0a, 0x61, + 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x12, 0x2f, 0x0a, 0x12, 0x61, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x5f, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, + 0xac, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x12, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x64, 0x5f, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x52, 0x5a, 0x50, 0x67, 0x69, + 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, + 0x72, 0x70, 0x2f, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x72, 0x79, 0x2f, 0x73, 0x64, 0x6b, 0x2f, + 0x70, 0x62, 0x73, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, 0x61, + 0x70, 0x69, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2f, 0x68, 0x6f, 0x73, + 0x74, 0x73, 0x65, 0x74, 0x73, 0x3b, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x65, 0x74, 0x73, 0x62, 0x06, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var (