From 01d4383c1bf2f44b481a3b14fb6461b49030240c Mon Sep 17 00:00:00 2001 From: Jeff Mitchell Date: Mon, 20 Sep 2021 12:48:38 -0400 Subject: [PATCH] Add preferred endpoints logic to API/handler/DB and some CLI bits (#1529) --- api/hostsets/host_set.gen.go | 25 +- api/hostsets/option.gen.go | 12 + globals/fields.go | 1 + internal/cmd/commands.go | 12 + internal/cmd/commands/hostsetscmd/funcs.go | 3 + .../cmd/commands/hostsetscmd/plugin_funcs.go | 97 +++++++ .../hostsetscmd/plugin_hostsets.gen.go | 241 ++++++++++++++++++ internal/cmd/gencli/input.go | 14 + .../postgres/17/05_preferred_endpoints.up.sql | 73 ++++++ .../postgres/17/preferred_endpoints_test.go | 166 ++++++++++++ internal/db/schema/postgres_migration.gen.go | 73 +++++- internal/gen/controller.swagger.json | 7 + internal/host/options.go | 46 ++++ internal/host/options_test.go | 34 +++ internal/host/plugin/host_catalog_secret.go | 2 +- .../host/plugin/host_catalog_secret_test.go | 2 + internal/host/plugin/host_catalog_test.go | 2 +- internal/host/plugin/host_set.go | 14 +- internal/host/plugin/host_set_test.go | 16 ++ internal/host/plugin/immutable_fields_test.go | 6 +- internal/host/plugin/options.go | 18 +- internal/host/plugin/options_test.go | 8 +- internal/host/plugin/repository.go | 14 +- .../host/plugin/repository_host_catalog.go | 19 +- .../plugin/repository_host_catalog_test.go | 2 +- internal/host/plugin/repository_host_set.go | 217 ++++++++++++++-- .../host/plugin/repository_host_set_test.go | 82 +++--- internal/host/plugin/repository_test.go | 5 +- internal/host/plugin/store/host.pb.go | 65 +++-- internal/host/plugin/testing.go | 31 +-- internal/host/plugin/testing_test.go | 4 +- internal/host/preferred_endpoint.go | 87 +++++++ internal/host/preferred_endpoint_test.go | 219 ++++++++++++++++ internal/host/store/host.pb.go | 109 +++++++- internal/libs/endpoint/preferencer.go | 2 +- .../api/resources/hostsets/v1/host_set.proto | 4 + .../storage/host/plugin/store/v1/host.proto | 8 +- .../storage/host/store/v1/host.proto | 14 + internal/servers/controller/controller.go | 6 +- .../host_catalogs/host_catalog_service.go | 2 +- .../host_catalog_service_test.go | 4 +- .../handlers/host_sets/host_set_service.go | 50 +++- .../host_sets/host_set_service_test.go | 88 +++++-- .../handlers/targets/target_service.go | 10 +- .../handlers/targets/target_service_test.go | 12 +- .../api/resources/hostsets/host_set.pb.go | 42 ++- 46 files changed, 1737 insertions(+), 231 deletions(-) create mode 100644 internal/cmd/commands/hostsetscmd/plugin_funcs.go create mode 100644 internal/cmd/commands/hostsetscmd/plugin_hostsets.gen.go create mode 100644 internal/db/schema/migrations/postgres/17/05_preferred_endpoints.up.sql create mode 100644 internal/db/schema/migrations/postgres/17/preferred_endpoints_test.go create mode 100644 internal/host/options.go create mode 100644 internal/host/options_test.go create mode 100644 internal/host/preferred_endpoint.go create mode 100644 internal/host/preferred_endpoint_test.go diff --git a/api/hostsets/host_set.gen.go b/api/hostsets/host_set.gen.go index d316e87cb..58fceb29c 100644 --- a/api/hostsets/host_set.gen.go +++ b/api/hostsets/host_set.gen.go @@ -13,18 +13,19 @@ import ( ) type HostSet struct { - Id string `json:"id,omitempty"` - HostCatalogId string `json:"host_catalog_id,omitempty"` - Scope *scopes.ScopeInfo `json:"scope,omitempty"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - CreatedTime time.Time `json:"created_time,omitempty"` - UpdatedTime time.Time `json:"updated_time,omitempty"` - Version uint32 `json:"version,omitempty"` - Type string `json:"type,omitempty"` - HostIds []string `json:"host_ids,omitempty"` - Attributes map[string]interface{} `json:"attributes,omitempty"` - AuthorizedActions []string `json:"authorized_actions,omitempty"` + Id string `json:"id,omitempty"` + HostCatalogId string `json:"host_catalog_id,omitempty"` + Scope *scopes.ScopeInfo `json:"scope,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + CreatedTime time.Time `json:"created_time,omitempty"` + UpdatedTime time.Time `json:"updated_time,omitempty"` + Version uint32 `json:"version,omitempty"` + Type string `json:"type,omitempty"` + HostIds []string `json:"host_ids,omitempty"` + PreferredEndpoints []string `json:"preferred_endpoints,omitempty"` + Attributes map[string]interface{} `json:"attributes,omitempty"` + AuthorizedActions []string `json:"authorized_actions,omitempty"` response *api.Response } diff --git a/api/hostsets/option.gen.go b/api/hostsets/option.gen.go index f27578eb9..317b79b83 100644 --- a/api/hostsets/option.gen.go +++ b/api/hostsets/option.gen.go @@ -97,3 +97,15 @@ func DefaultName() Option { o.postMap["name"] = nil } } + +func WithPreferredEndpoints(inPreferredEndpoints []string) Option { + return func(o *options) { + o.postMap["preferred_endpoints"] = inPreferredEndpoints + } +} + +func DefaultPreferredEndpoints() Option { + return func(o *options) { + o.postMap["preferred_endpoints"] = nil + } +} diff --git a/globals/fields.go b/globals/fields.go index 692c8ddb9..00f8b898d 100644 --- a/globals/fields.go +++ b/globals/fields.go @@ -58,4 +58,5 @@ const ( ApplicationCredentialLibrariesField = "application_credential_libraries" ApplicationCredentialSourceIdsField = "application_credential_source_ids" ApplicationCredentialSourcesField = "application_credential_sources" + PreferredEndpointsField = "preferred_endpoints" ) diff --git a/internal/cmd/commands.go b/internal/cmd/commands.go index 1776da276..270e41562 100644 --- a/internal/cmd/commands.go +++ b/internal/cmd/commands.go @@ -560,6 +560,12 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { Func: "create", }, nil }, + "host-sets create plugin": func() (cli.Command, error) { + return &hostsetscmd.PluginCommand{ + Command: base.NewCommand(ui), + Func: "create", + }, nil + }, "host-sets update": func() (cli.Command, error) { return &hostsetscmd.Command{ Command: base.NewCommand(ui), @@ -572,6 +578,12 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { Func: "update", }, nil }, + "host-sets update plugin": func() (cli.Command, error) { + return &hostsetscmd.PluginCommand{ + Command: base.NewCommand(ui), + Func: "update", + }, nil + }, "host-sets add-hosts": func() (cli.Command, error) { return &hostsetscmd.Command{ Command: base.NewCommand(ui), diff --git a/internal/cmd/commands/hostsetscmd/funcs.go b/internal/cmd/commands/hostsetscmd/funcs.go index b3064c8af..933396332 100644 --- a/internal/cmd/commands/hostsetscmd/funcs.go +++ b/internal/cmd/commands/hostsetscmd/funcs.go @@ -253,6 +253,9 @@ func printItemTable(result api.GenericResult) string { if item.HostCatalogId != "" { nonAttributeMap["Host Catalog ID"] = item.HostCatalogId } + if item.PreferredEndpoints != nil { + nonAttributeMap["Preferred Endpoints"] = item.PreferredEndpoints + } maxLength := base.MaxAttributesLength(nonAttributeMap, item.Attributes, keySubstMap) diff --git a/internal/cmd/commands/hostsetscmd/plugin_funcs.go b/internal/cmd/commands/hostsetscmd/plugin_funcs.go new file mode 100644 index 000000000..fca90f444 --- /dev/null +++ b/internal/cmd/commands/hostsetscmd/plugin_funcs.go @@ -0,0 +1,97 @@ +package hostsetscmd + +import ( + "fmt" + + "github.com/hashicorp/boundary/api/hostsets" + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/hashicorp/boundary/internal/libs/endpoint" +) + +func init() { + extraPluginActionsFlagsMapFunc = extraPluginActionsFlagsMapFuncImpl + extraPluginFlagsFunc = extraPluginFlagsFuncImpl + extraPluginFlagsHandlingFunc = extraPluginFlagsHandlingFuncImpl +} + +type extraPluginCmdVars struct { + flagPreferredEndpoints []string +} + +func extraPluginActionsFlagsMapFuncImpl() map[string][]string { + return map[string][]string{ + "create": {"preferred-endpoint"}, + "update": {"preferred-endpoint"}, + } +} + +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-sets create plugin [options] [args]", + "", + " Create a host set of a type provided by a plugin. Example:", + "", + ` $ boundary host-sets create plugin -plugin-id plg_1234567890 -name prodops -description "Plugin host-set for ProdOps"`, + "", + "", + }) + + case "update": + helpStr = base.WrapForHelpText([]string{ + "Usage: boundary host-sets update plugin [options] [args]", + "", + " Update a host set of a type provided by a plugin given its ID. Example:", + "", + ` $ boundary host-sets update plugin -id hsplg_1234567890 -name "devops" -description "Plugin host-set for DevOps"`, + "", + "", + }) + default: + helpStr = helpMap[c.Func]() + } + return helpStr + c.Flags().Help() +} + +func extraPluginFlagsFuncImpl(c *PluginCommand, set *base.FlagSets, f *base.FlagSet) { + fs := set.NewFlagSet("Plugin Host-Set Options") + + for _, name := range flagsPluginMap[c.Func] { + switch name { + case "preferred-endpoint": + fs.StringSliceVar(&base.StringSliceVar{ + Name: "preferred-endpoint", + Target: &c.flagPreferredEndpoints, + Usage: `An endpoint preference, specified by "cidr:" ` + + `or "dns:", specifying which IP address or DNS name out ` + + `of a host's available possibilities should be preferred. May be specified ` + + `multiple times, which will build up an in-order set of preferences. ` + + `If no preferences are specified, a value will be chosen _at random_ from ` + + `all available values. May not be valid for all plugin types.`, + }) + } + } +} + +func extraPluginFlagsHandlingFuncImpl(c *PluginCommand, _ *base.FlagSets, opts *[]hostsets.Option) bool { + switch len(c.flagPreferredEndpoints) { + case 0: + case 1: + if c.flagPreferredEndpoints[0] == "null" { + *opts = append(*opts, hostsets.DefaultPreferredEndpoints()) + break + } + fallthrough + + default: + if _, err := endpoint.NewPreferencer(c.Context, endpoint.WithPreferenceOrder(c.flagPreferredEndpoints)); err != nil { + c.UI.Error(fmt.Sprintf("Unable to successfully validate preferred endpoints: %s", err)) + return false + } + *opts = append(*opts, hostsets.WithPreferredEndpoints(c.flagPreferredEndpoints)) + } + + return true +} diff --git a/internal/cmd/commands/hostsetscmd/plugin_hostsets.gen.go b/internal/cmd/commands/hostsetscmd/plugin_hostsets.gen.go new file mode 100644 index 000000000..f47e6cc76 --- /dev/null +++ b/internal/cmd/commands/hostsetscmd/plugin_hostsets.gen.go @@ -0,0 +1,241 @@ +// Code generated by "make cli"; DO NOT EDIT. +package hostsetscmd + +import ( + "errors" + "fmt" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/hostsets" + "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 + + extraPluginCmdVars +} + +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 set" + + 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 set") + + 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": {"host-catalog-id", "name", "description"}, + + "update": {"id", "name", "description", "version"}, +} + +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 set", 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 set" + switch c.Func { + case "list": + c.plural = "plugin-type host sets" + } + + 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 []hostsets.Option + + if strutil.StrListContains(flagsPluginMap[c.Func], "host-catalog-id") { + switch c.Func { + case "create": + if c.FlagHostCatalogId == "" { + c.PrintCliError(errors.New("HostCatalog ID must be passed in via -host-catalog-id or BOUNDARY_HOST_CATALOG_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 + } + hostsetsClient := hostsets.NewClient(client) + + switch c.FlagName { + case "": + case "null": + opts = append(opts, hostsets.DefaultName()) + default: + opts = append(opts, hostsets.WithName(c.FlagName)) + } + + switch c.FlagDescription { + case "": + case "null": + opts = append(opts, hostsets.DefaultDescription()) + default: + opts = append(opts, hostsets.WithDescription(c.FlagDescription)) + } + + if c.FlagFilter != "" { + opts = append(opts, hostsets.WithFilter(c.FlagFilter)) + } + + var version uint32 + + switch c.Func { + case "update": + switch c.FlagVersion { + case 0: + opts = append(opts, hostsets.WithAutomaticVersioning(true)) + default: + version = uint32(c.FlagVersion) + } + } + + if ok := extraPluginFlagsHandlingFunc(c, f, &opts); !ok { + return base.CommandUserError + } + + var result api.GenericResult + + switch c.Func { + + case "create": + result, err = hostsetsClient.Create(c.Context, c.FlagHostCatalogId, opts...) + + case "update": + result, err = hostsetsClient.Update(c.Context, c.FlagId, version, opts...) + + } + + result, err = executeExtraPluginActions(c, result, err, hostsetsClient, 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, *[]hostsets.Option) bool { return true } + executeExtraPluginActions = func(_ *PluginCommand, inResult api.GenericResult, inErr error, _ *hostsets.Client, _ uint32, _ []hostsets.Option) (api.GenericResult, error) { + return inResult, inErr + } + printCustomPluginActionOutput = func(*PluginCommand) (bool, error) { return false, nil } +) diff --git a/internal/cmd/gencli/input.go b/internal/cmd/gencli/input.go index a2640456e..8a044fecc 100644 --- a/internal/cmd/gencli/input.go +++ b/internal/cmd/gencli/input.go @@ -282,6 +282,20 @@ var inputStructs = map[string][]*cmdInfo{ HasDescription: true, VersionedActions: []string{"update"}, }, + { + ResourceType: resource.HostSet.String(), + Pkg: "hostsets", + StdActions: []string{"create", "update"}, + SubActionPrefix: "plugin", + HasExtraCommandVars: true, + SkipNormalHelp: true, + HasExtraHelpFunc: true, + HasId: true, + HasName: true, + Container: "HostCatalog", + HasDescription: true, + VersionedActions: []string{"update"}, + }, }, "hosts": { { diff --git a/internal/db/schema/migrations/postgres/17/05_preferred_endpoints.up.sql b/internal/db/schema/migrations/postgres/17/05_preferred_endpoints.up.sql new file mode 100644 index 000000000..b1478e9d5 --- /dev/null +++ b/internal/db/schema/migrations/postgres/17/05_preferred_endpoints.up.sql @@ -0,0 +1,73 @@ +begin; + +create table host_set_preferred_endpoint ( + create_time wt_timestamp, + host_set_id wt_public_id not null + constraint host_set_fkey + references host_set(public_id) + on delete cascade + on update cascade, + priority int not null + constraint priority_must_be_greater_than_zero + check(priority > 0), + condition text not null + constraint condition_must_not_be_too_short + check(length(trim(condition)) > 4) -- minimum is 'dns:*' + constraint condition_must_not_be_too_long + check(length(trim(condition)) < 255) + constraint condition_has_valid_prefix + check( + left(trim(condition), 4) = 'dns:' + or + left(trim(condition), 5) = 'cidr:' + ) + constraint condition_does_not_contain_invalid_chars + check( + position('|' in trim(condition)) = 0 + and + position('=' in trim(condition)) = 0 + ), + primary key(host_set_id, priority), + constraint host_set_preferred_endpoint_host_set_id_condition_uq + unique(host_set_id, condition) +); + +-- host_set_immutable_preferred_endpoint() ensures that endpoint conditions +-- assigned to host sets are immutable. +create function + host_set_immutable_preferred_endpoint() + returns trigger +as $$ +begin + raise exception 'preferred endpoints are immutable'; +end; +$$ language plpgsql; + +create trigger immutable_preferred_endpoint + before update on host_set_preferred_endpoint + for each row execute procedure host_set_immutable_preferred_endpoint(); + +-- host_plugin_host_set_with_value_obj is useful for reading a plugin host set with its +-- associated value objects (preferred endpoints) as columns with delimited +-- values. The delimiter depends on the value objects (e.g. if they need +-- ordering). +create view host_plugin_host_set_with_value_obj as +select + hs.public_id, + hs.catalog_id, + hs.name, + hs.description, + hs.create_time, + hs.update_time, + hs.version, + hs.attributes, + -- the string_agg(..) column will be null if there are no associated value objects + string_agg(distinct concat_ws('=', hspe.priority, hspe.condition), '|') as preferred_endpoints +from + host_plugin_set hs + left outer join host_set_preferred_endpoint hspe on hs.public_id = hspe.host_set_id +group by hs.public_id; +comment on view host_plugin_host_set_with_value_obj is +'host plugin host set with its associated value objects'; + +commit; \ No newline at end of file diff --git a/internal/db/schema/migrations/postgres/17/preferred_endpoints_test.go b/internal/db/schema/migrations/postgres/17/preferred_endpoints_test.go new file mode 100644 index 000000000..fed33e577 --- /dev/null +++ b/internal/db/schema/migrations/postgres/17/preferred_endpoints_test.go @@ -0,0 +1,166 @@ +package migration + +import ( + "context" + "testing" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/host" + "github.com/hashicorp/boundary/internal/host/static" + "github.com/hashicorp/boundary/internal/iam" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_PreferredEndpointTable(t *testing.T) { + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + rootWrapper := db.TestWrapper(t) + _, proj := iam.TestScopes(t, iam.TestRepo(t, conn, rootWrapper)) + hc := static.TestCatalogs(t, conn, proj.PublicId, 1)[0] + hs := static.TestSets(t, conn, hc.PublicId, 1)[0] + + type testCondition struct { + condition string + priority uint32 + } + insertTests := []struct { + testName string + hostSetId string + conditions []testCondition + wantErrContains string + }{ + { + testName: "invalid host set id", + hostSetId: "hsst_1234567890", + conditions: []testCondition{ + { + priority: 1, + condition: "dns:abcd", + }, + { + priority: 2, + condition: "cidr:1.2.3.4", + }, + }, + wantErrContains: "host_set_fkey", + }, + { + testName: "invalid condition prefix", + hostSetId: hs.PublicId, + conditions: []testCondition{ + { + priority: 1, + condition: "dsn:abcd", + }, + }, + wantErrContains: "condition_has_valid_prefix", + }, + { + testName: "invalid condition length", + hostSetId: hs.PublicId, + conditions: []testCondition{ + { + priority: 1, + condition: "dns:", + }, + }, + wantErrContains: "condition_must_not_be_too_short", + }, + { + testName: "invalid priority", + hostSetId: hs.PublicId, + conditions: []testCondition{ + { + priority: 0, + condition: "dns:abcd", + }, + }, + wantErrContains: "priority must not be empty", + }, + { + testName: "duplicate priority", + hostSetId: hs.PublicId, + conditions: []testCondition{ + { + priority: 1, + condition: "dns:abcd", + }, + { + priority: 1, + condition: "cidr:1.2.3.4", + }, + }, + wantErrContains: "host_set_preferred_endpoint_pkey", + }, + { + testName: "invalid char 1", + hostSetId: hs.PublicId, + conditions: []testCondition{ + { + priority: 1, + condition: "dns:ab|cd", + }, + { + priority: 1, + condition: "cidr:1.2.3.4", + }, + }, + wantErrContains: "condition_does_not_contain_invalid_chars", + }, + { + testName: "invalid char 2", + hostSetId: hs.PublicId, + conditions: []testCondition{ + { + priority: 1, + condition: "dns:abcd", + }, + { + priority: 1, + condition: "cidr:1.2=.3.4", + }, + }, + wantErrContains: "condition_does_not_contain_invalid_chars", + }, + { + testName: "valid", + hostSetId: hs.PublicId, + conditions: []testCondition{ + { + priority: 1, + condition: "dns:abcd", + }, + { + priority: 2, + condition: "cidr:1.2.3.4", + }, + }, + }, + } + for _, tt := range insertTests { + t.Run("insert: "+tt.testName, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + _, err := rw.Exec(ctx, "delete from host_set_preferred_endpoint where host_set_id = ?", []interface{}{tt.hostSetId}) + require.NoError(err) + + // Add items to insert + var items []interface{} + for _, cond := range tt.conditions { + ep := host.AllocPreferredEndpoint() + ep.HostSetId = tt.hostSetId + ep.Condition = cond.condition + ep.Priority = cond.priority + items = append(items, ep) + } + err = rw.CreateItems(ctx, items) + if tt.wantErrContains != "" { + require.Error(err) + assert.Contains(err.Error(), tt.wantErrContains) + return + } + require.NoError(err) + }) + } +} diff --git a/internal/db/schema/postgres_migration.gen.go b/internal/db/schema/postgres_migration.gen.go index 1929a5e72..81c48f577 100644 --- a/internal/db/schema/postgres_migration.gen.go +++ b/internal/db/schema/postgres_migration.gen.go @@ -4,7 +4,7 @@ package schema func init() { migrationStates["postgres"] = migrationState{ - binarySchemaVersion: 17004, + binarySchemaVersion: 17005, upMigrations: map[int][]byte{ 1: []byte(` create domain wt_public_id as text @@ -7851,6 +7851,77 @@ alter table wh_session_connection_accumulating_fact ('host_plugin_catalog', 1), ('host_plugin_catalog_secret', 1), ('host_plugin_set', 1); +`), + 17005: []byte(` +create table host_set_preferred_endpoint ( + create_time wt_timestamp, + host_set_id wt_public_id not null + constraint host_set_fkey + references host_set(public_id) + on delete cascade + on update cascade, + priority int not null + constraint priority_must_be_greater_than_zero + check(priority > 0), + condition text not null + constraint condition_must_not_be_empty + check(length(trim(condition)) > 4) -- minimum is 'dns:*' + constraint condition_must_not_be_too_long + check(length(trim(condition)) < 255) + constraint condition_has_valid_prefix + check( + left(trim(condition), 4) = 'dns:' + or + left(trim(condition), 5) = 'cidr:' + ) + constraint condition_does_not_contain_invalid_chars + check( + position('|' in trim(condition)) = 0 + and + position('=' in trim(condition)) = 0 + ), + primary key(host_set_id, priority), + constraint host_set_preferred_endpoint_host_set_id_condition_uq + unique(host_set_id, condition) +); + +-- host_set_immutable_preferred_endpoint() ensures that endpoint conditions +-- assigned to host sets are immutable. +create function + host_set_immutable_preferred_endpoint() + returns trigger +as $$ +begin + raise exception 'preferred endpoints are immutable'; +end; +$$ language plpgsql; + +create trigger immutable_preferred_endpoint + before update on host_set_preferred_endpoint + for each row execute procedure host_set_immutable_preferred_endpoint(); + +-- host_plugin_host_set_with_value_obj is useful for reading a plugin host set with its +-- associated value objects (preferred endpoints) as columns with delimited +-- values. The delimiter depends on the value objects (e.g. if they need +-- ordering). +create view host_plugin_host_set_with_value_obj as +select + hs.public_id, + hs.catalog_id, + hs.name, + hs.description, + hs.create_time, + hs.update_time, + hs.version, + hs.attributes, + -- the string_agg(..) column will be null if there are no associated value objects + string_agg(distinct concat_ws('=', hspe.priority, hspe.condition), '|') as preferred_endpoints +from + host_plugin_set hs + left outer join host_set_preferred_endpoint hspe on hs.public_id = hspe.host_set_id +group by hs.public_id; +comment on view host_plugin_host_set_with_value_obj is +'host plugin host set with its associated value objects'; `), 2001: []byte(` -- log_migration entries represent logs generated during migrations diff --git a/internal/gen/controller.swagger.json b/internal/gen/controller.swagger.json index ba95cd61e..708807b19 100644 --- a/internal/gen/controller.swagger.json +++ b/internal/gen/controller.swagger.json @@ -3863,6 +3863,13 @@ "description": "Output only. A list of Hosts in this Host Set.", "readOnly": true }, + "preferred_endpoints": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An ordered list of endpoint preferences used to choose from among\nmultiple possible endpoints for a host." + }, "attributes": { "type": "object", "description": "The attributes that are applicable for the specific Host Set type." diff --git a/internal/host/options.go b/internal/host/options.go new file mode 100644 index 000000000..6e2afe4d6 --- /dev/null +++ b/internal/host/options.go @@ -0,0 +1,46 @@ +package host + +// GetOpts - iterate the inbound Options and return a struct +func GetOpts(opt ...Option) (options, error) { + opts := getDefaultOptions() + for _, o := range opt { + if err := o(&opts); err != nil { + return opts, err + } + } + return opts, nil +} + +// Option - how Options are passed as arguments. +type Option func(*options) error + +// options = how options are represented +type options struct { + WithLimit int + WithOrderByCreateTime bool + Ascending bool +} + +func getDefaultOptions() options { + return options{} +} + +// WithLimit provides an option to provide a limit. Intentionally allowing +// negative integers. If WithLimit < 0, then unlimited results are +// returned. If WithLimit == 0, then default limits are used for results. +func WithLimit(l int) Option { + return func(o *options) error { + o.WithLimit = l + return nil + } +} + +// WithOrderByCreateTime provides an option to specify ordering by the +// CreateTime field. +func WithOrderByCreateTime(ascending bool) Option { + return func(o *options) error { + o.WithOrderByCreateTime = true + o.Ascending = ascending + return nil + } +} diff --git a/internal/host/options_test.go b/internal/host/options_test.go new file mode 100644 index 000000000..b55d9148c --- /dev/null +++ b/internal/host/options_test.go @@ -0,0 +1,34 @@ +package host + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_GetOpts(t *testing.T) { + t.Parallel() + t.Run("WithLimit", func(t *testing.T) { + opts, err := GetOpts(WithLimit(1)) + require.NoError(t, err) + testOpts := getDefaultOptions() + testOpts.WithLimit = 1 + assert.Equal(t, opts, testOpts) + }) + t.Run("WithOrderByCreateTime-desc", func(t *testing.T) { + opts, err := GetOpts(WithOrderByCreateTime(false)) + require.NoError(t, err) + testOpts := getDefaultOptions() + testOpts.WithOrderByCreateTime = true + assert.Equal(t, opts, testOpts) + }) + t.Run("WithOrderByCreateTime-asc", func(t *testing.T) { + opts, err := GetOpts(WithOrderByCreateTime(true)) + require.NoError(t, err) + testOpts := getDefaultOptions() + testOpts.WithOrderByCreateTime = true + testOpts.Ascending = true + assert.Equal(t, opts, testOpts) + }) +} diff --git a/internal/host/plugin/host_catalog_secret.go b/internal/host/plugin/host_catalog_secret.go index 37b8652a3..494e478c4 100644 --- a/internal/host/plugin/host_catalog_secret.go +++ b/internal/host/plugin/host_catalog_secret.go @@ -70,7 +70,7 @@ func (c *HostCatalogSecret) SetTableName(n string) { func (c *HostCatalogSecret) encrypt(ctx context.Context, cipher wrapping.Wrapper) error { const op = "plugin.(HostCatalogSecret).encrypt" if len(c.Secret) == 0 { - errors.New(ctx, errors.InvalidParameter, op, "no attributes defined") + return errors.New(ctx, errors.InvalidParameter, op, "no attributes defined") } if err := structwrapping.WrapStruct(ctx, cipher, c.HostCatalogSecret, nil); err != nil { return errors.Wrap(ctx, err, op, errors.WithCode(errors.Encrypt)) diff --git a/internal/host/plugin/host_catalog_secret_test.go b/internal/host/plugin/host_catalog_secret_test.go index 6b8364824..0d2137c08 100644 --- a/internal/host/plugin/host_catalog_secret_test.go +++ b/internal/host/plugin/host_catalog_secret_test.go @@ -122,6 +122,7 @@ func TestHostCatalogSecret_Custom_Queries(t *testing.T) { plg := host.TestPlugin(t, conn, "test", "prefix") cat := TestCatalog(t, conn, prj.GetPublicId(), plg.GetPublicId()) databaseWrapper, err := kkms.GetWrapper(ctx, prj.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) hcs, err := newHostCatalogSecret(ctx, cat.GetPublicId(), map[string]interface{}{"foo": "bar"}) require.NoError(t, err) @@ -131,6 +132,7 @@ func TestHostCatalogSecret_Custom_Queries(t *testing.T) { assert.NoError(t, err) found, err := newHostCatalogSecret(ctx, cat.GetPublicId(), nil) + require.NoError(t, err) assert.NoError(t, rw.LookupWhere(ctx, found, "catalog_id=?", found.GetCatalogId())) require.NoError(t, found.decrypt(ctx, databaseWrapper)) require.NoError(t, hcs.decrypt(ctx, databaseWrapper)) diff --git a/internal/host/plugin/host_catalog_test.go b/internal/host/plugin/host_catalog_test.go index dac4a9a52..ab882498a 100644 --- a/internal/host/plugin/host_catalog_test.go +++ b/internal/host/plugin/host_catalog_test.go @@ -186,7 +186,7 @@ func TestHostCatalog_Create_DuplicateNames(t *testing.T) { require.NoError(t, err) got.PublicId, err = newHostCatalogId(ctx, plg.GetIdPrefix()) require.NoError(t, err) - w.Create(ctx, got) + require.NoError(t, w.Create(ctx, got)) // Can't create another resource with the same name in the same scope got.PublicId, err = newHostCatalogId(ctx, plg.GetIdPrefix()) diff --git a/internal/host/plugin/host_set.go b/internal/host/plugin/host_set.go index a60c6f5fb..2785fe814 100644 --- a/internal/host/plugin/host_set.go +++ b/internal/host/plugin/host_set.go @@ -16,17 +16,18 @@ type HostSet struct { tableName string `gorm:"-"` } -// NewHostSet creates a new in memory HostSet assigned to catalogId. -// Attributes, name, and description are the only valid options. All other -// options are ignored. +// NewHostSet creates a new in memory HostSet assigned to catalogId. Attributes, +// name, description, and preferred endpoints are the only valid options. All +// other options are ignored. func NewHostSet(ctx context.Context, catalogId string, opt ...Option) (*HostSet, error) { const op = "plugin.NewHostSet" opts := getOpts(opt...) set := &HostSet{ HostSet: &store.HostSet{ - CatalogId: catalogId, - Name: opts.withName, - Description: opts.withDescription, + CatalogId: catalogId, + Name: opts.withName, + Description: opts.withDescription, + PreferredEndpoints: opts.withPreferredEndpoints, }, } @@ -37,6 +38,7 @@ func NewHostSet(ctx context.Context, catalogId string, opt ...Option) (*HostSet, } set.Attributes = attrs } + return set, nil } diff --git a/internal/host/plugin/host_set_test.go b/internal/host/plugin/host_set_test.go index 7b2ad4129..a5fbdf6f0 100644 --- a/internal/host/plugin/host_set_test.go +++ b/internal/host/plugin/host_set_test.go @@ -133,6 +133,22 @@ func TestHostSet_Create(t *testing.T) { }, }, }, + { + name: "valid-with-preferred-endpoints", + args: args{ + catalogId: cat.GetPublicId(), + opts: []Option{ + WithPreferredEndpoints([]string{"cidr:1.2.3.4"}), + }, + }, + want: &HostSet{ + HostSet: &store.HostSet{ + CatalogId: cat.GetPublicId(), + PreferredEndpoints: []string{"cidr:1.2.3.4"}, + Attributes: []byte("{}"), + }, + }, + }, } for _, tt := range tests { diff --git a/internal/host/plugin/immutable_fields_test.go b/internal/host/plugin/immutable_fields_test.go index 0db20a72a..e854711bd 100644 --- a/internal/host/plugin/immutable_fields_test.go +++ b/internal/host/plugin/immutable_fields_test.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/boundary/internal/db/timestamp" "github.com/hashicorp/boundary/internal/host/plugin/store" "github.com/hashicorp/boundary/internal/iam" + "github.com/hashicorp/boundary/internal/kms" "github.com/hashicorp/boundary/internal/plugin/host" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -94,12 +95,13 @@ func TestPluginSet_ImmutableFields(t *testing.T) { conn, _ := db.TestSetup(t, "postgres") w := db.New(conn) wrapper := db.TestWrapper(t) + kmsCache := kms.TestKms(t, conn, wrapper) ts := timestamp.Timestamp{Timestamp: ×tamppb.Timestamp{Seconds: 0, Nanos: 0}} _, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) plg := host.TestPlugin(t, conn, "test", "prefix") cat := TestCatalog(t, conn, prj.PublicId, plg.GetPublicId()) - new := TestSet(t, conn, cat.GetPublicId()) + new := TestSet(t, conn, kmsCache, cat) tests := []struct { name string @@ -125,7 +127,7 @@ func TestPluginSet_ImmutableFields(t *testing.T) { fieldMask: []string{"CreateTime"}, }, { - name: "plugin_host_catalog_id", + name: "host_plugin_catalog_id", update: func() *HostSet { c := new.testCloneHostSet() c.CatalogId = "stc_01234567890" diff --git a/internal/host/plugin/options.go b/internal/host/plugin/options.go index 894ff6ef6..4c3e260d1 100644 --- a/internal/host/plugin/options.go +++ b/internal/host/plugin/options.go @@ -14,11 +14,11 @@ type Option func(*options) // options = how options are represented type options struct { - withName string - withDescription string - withLimit int - withAttributes map[string]interface{} - withSecrets map[string]interface{} + withName string + withDescription string + withAttributes map[string]interface{} + withSecrets map[string]interface{} + withPreferredEndpoints []string } func getDefaultOptions() options { @@ -57,11 +57,9 @@ func WithSecrets(secrets map[string]interface{}) Option { } } -// WithLimit provides an option to provide a limit. Intentionally allowing -// negative integers. If WithLimit < 0, then unlimited results are -// returned. If WithLimit == 0, then default limits are used for results. -func WithLimit(l int) Option { +// WithPreferredEndpoints provides an optional preferred endpoints field. +func WithPreferredEndpoints(with []string) Option { return func(o *options) { - o.withLimit = l + o.withPreferredEndpoints = with } } diff --git a/internal/host/plugin/options_test.go b/internal/host/plugin/options_test.go index 4cda5a9ff..aab698c2a 100644 --- a/internal/host/plugin/options_test.go +++ b/internal/host/plugin/options_test.go @@ -20,10 +20,10 @@ func Test_GetOpts(t *testing.T) { testOpts.withDescription = "test desc" assert.Equal(t, opts, testOpts) }) - t.Run("WithLimit", func(t *testing.T) { - opts := getOpts(WithLimit(5)) + t.Run("WithPreferredEndpoints", func(t *testing.T) { + opts := getOpts(WithPreferredEndpoints([]string{"foo"})) testOpts := getDefaultOptions() - testOpts.withLimit = 5 - assert.Equal(t, opts, testOpts) + testOpts.withPreferredEndpoints = []string{"foo"} + assert.EqualValues(t, opts, testOpts) }) } diff --git a/internal/host/plugin/repository.go b/internal/host/plugin/repository.go index 5c009df96..09508d1a6 100644 --- a/internal/host/plugin/repository.go +++ b/internal/host/plugin/repository.go @@ -6,6 +6,7 @@ package plugin import ( "github.com/hashicorp/boundary/internal/db" "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/host" "github.com/hashicorp/boundary/internal/kms" ) @@ -24,7 +25,7 @@ type Repository struct { // only be used for one transaction and it is not safe for concurrent go // routines to access it. WithLimit option is used as a repo wide default // limit applied to all ListX methods. -func NewRepository(r db.Reader, w db.Writer, kms *kms.Kms, opt ...Option) (*Repository, error) { +func NewRepository(r db.Reader, w db.Writer, kms *kms.Kms, opt ...host.Option) (*Repository, error) { const op = "plugin.NewRepository" switch { case r == nil: @@ -35,16 +36,19 @@ func NewRepository(r db.Reader, w db.Writer, kms *kms.Kms, opt ...Option) (*Repo return nil, errors.NewDeprecated(errors.InvalidParameter, op, "kms") } - opts := getOpts(opt...) - if opts.withLimit == 0 { + opts, err := host.GetOpts(opt...) + if err != nil { + return nil, errors.WrapDeprecated(err, op) + } + if opts.WithLimit == 0 { // zero signals the boundary defaults should be used. - opts.withLimit = db.DefaultLimit + opts.WithLimit = db.DefaultLimit } return &Repository{ reader: r, writer: w, kms: kms, - defaultLimit: opts.withLimit, + defaultLimit: opts.WithLimit, }, nil } diff --git a/internal/host/plugin/repository_host_catalog.go b/internal/host/plugin/repository_host_catalog.go index fe6daa029..269d67c2c 100644 --- a/internal/host/plugin/repository_host_catalog.go +++ b/internal/host/plugin/repository_host_catalog.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/boundary/internal/db" "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/host" "github.com/hashicorp/boundary/internal/kms" "github.com/hashicorp/boundary/internal/oplog" hostplg "github.com/hashicorp/boundary/internal/plugin/host" @@ -75,7 +76,9 @@ func (r *Repository) CreateCatalog(ctx context.Context, c *HostCatalog, _ ...Opt if err != nil { return nil, errors.Wrap(ctx, err, op) } - hcSecret.encrypt(ctx, dbWrapper) + if err := hcSecret.encrypt(ctx, dbWrapper); err != nil { + return nil, errors.Wrap(ctx, err, op) + } } var newHostCatalog *HostCatalog @@ -146,20 +149,22 @@ func (r *Repository) LookupCatalog(ctx context.Context, id string, _ ...Option) } // ListCatalogs returns a slice of HostCatalogs for the scope IDs. WithLimit is the only option supported. -func (r *Repository) ListCatalogs(ctx context.Context, scopeIds []string, opt ...Option) ([]*HostCatalog, error) { +func (r *Repository) ListCatalogs(ctx context.Context, scopeIds []string, opt ...host.Option) ([]*HostCatalog, error) { const op = "plugin.(Repository).ListCatalogs" if len(scopeIds) == 0 { return nil, errors.New(ctx, errors.InvalidParameter, op, "no scope id") } - opts := getOpts(opt...) + opts, err := host.GetOpts(opt...) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } limit := r.defaultLimit - if opts.withLimit != 0 { + if opts.WithLimit != 0 { // non-zero signals an override of the default limit for the repo. - limit = opts.withLimit + limit = opts.WithLimit } var hostCatalogs []*HostCatalog - err := r.reader.SearchWhere(ctx, &hostCatalogs, "scope_id in (?)", []interface{}{scopeIds}, db.WithLimit(limit)) - if err != nil { + if err := r.reader.SearchWhere(ctx, &hostCatalogs, "scope_id in (?)", []interface{}{scopeIds}, db.WithLimit(limit)); err != nil { return nil, errors.Wrap(ctx, err, op) } return hostCatalogs, nil diff --git a/internal/host/plugin/repository_host_catalog_test.go b/internal/host/plugin/repository_host_catalog_test.go index 20270c98c..642295750 100644 --- a/internal/host/plugin/repository_host_catalog_test.go +++ b/internal/host/plugin/repository_host_catalog_test.go @@ -204,7 +204,7 @@ func TestRepository_CreateCatalog(t *testing.T) { dbWrapper, err := kmsCache.GetWrapper(ctx, got.GetScopeId(), kms.KeyPurposeDatabase) require.NoError(t, err) - cSecret.decrypt(ctx, dbWrapper) + require.NoError(t, cSecret.decrypt(ctx, dbWrapper)) assert.Equal(string(tt.wantSecret), string(cSecret.Secret)) }) } diff --git a/internal/host/plugin/repository_host_set.go b/internal/host/plugin/repository_host_set.go index 3145e7ea3..cc00d3085 100644 --- a/internal/host/plugin/repository_host_set.go +++ b/internal/host/plugin/repository_host_set.go @@ -3,9 +3,14 @@ package plugin import ( "context" "fmt" + "sort" + "strconv" + "strings" "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/db/timestamp" "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/host" "github.com/hashicorp/boundary/internal/kms" "github.com/hashicorp/boundary/internal/oplog" hostplg "github.com/hashicorp/boundary/internal/plugin/host" @@ -65,14 +70,51 @@ func (r *Repository) CreateSet(ctx context.Context, scopeId string, s *HostSet, return nil, errors.Wrap(ctx, err, op, errors.WithMsg("unable to get oplog wrapper")) } - var newHostSet *HostSet - _, err = r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, - func(_ db.Reader, w db.Writer) error { - newHostSet = s.clone() - err := w.Create(ctx, newHostSet, db.WithOplog(oplogWrapper, s.oplog(oplog.OpType_OP_TYPE_CREATE))) + var preferredEndpoints []interface{} + if s.PreferredEndpoints != nil { + preferredEndpoints = make([]interface{}, 0, len(s.PreferredEndpoints)) + for i, e := range s.PreferredEndpoints { + obj, err := host.NewPreferredEndpoint(ctx, s.PublicId, uint32(i+1), e) if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + preferredEndpoints = append(preferredEndpoints, obj) + } + } + + var returnedHostSet *HostSet + _, err = r.writer.DoTx( + ctx, + db.StdRetryCnt, + db.ExpBackoff{}, + func(_ db.Reader, w db.Writer) error { + msgs := make([]*oplog.Message, 0, len(preferredEndpoints)+2) + ticket, err := w.GetTicket(s) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("unable to get ticket")) + } + + returnedHostSet = s.clone() + + var hsOplogMsg oplog.Message + if err := w.Create(ctx, returnedHostSet, db.NewOplogMsg(&hsOplogMsg)); err != nil { return errors.Wrap(ctx, err, op) } + msgs = append(msgs, &hsOplogMsg) + + if len(preferredEndpoints) > 0 { + peOplogMsgs := make([]*oplog.Message, 0, len(preferredEndpoints)) + if err := w.CreateItems(ctx, preferredEndpoints, db.NewOplogMsgs(&peOplogMsgs)); err != nil { + return err + } + msgs = append(msgs, peOplogMsgs...) + } + + metadata := s.oplog(oplog.OpType_OP_TYPE_CREATE) + if err := w.WriteOplogEntryWith(ctx, oplogWrapper, ticket, metadata, msgs); err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("unable to write oplog")) + } + return nil }, ) @@ -83,49 +125,172 @@ func (r *Repository) CreateSet(ctx context.Context, scopeId string, s *HostSet, } return nil, errors.Wrap(ctx, err, op, errors.WithMsg(fmt.Sprintf("in catalog: %s", s.CatalogId))) } - return newHostSet, nil + return returnedHostSet, nil } // LookupSet will look up a host set in the repository and return the host // set. If the host set is not found, it will return nil, nil. // All options are ignored. -func (r *Repository) LookupSet(ctx context.Context, publicId string, _ ...Option) (*HostSet, error) { +func (r *Repository) LookupSet(ctx context.Context, publicId string, opt ...host.Option) (*HostSet, error) { const op = "plugin.(Repository).LookupSet" if publicId == "" { return nil, errors.New(ctx, errors.InvalidParameter, op, "no public id") } - s := allocHostSet() - s.PublicId = publicId - - err := r.reader.LookupByPublicId(ctx, s) - if errors.IsNotFoundError(err) { - return nil, nil - } + sets, err := r.getSets(ctx, publicId, "", opt...) if err != nil { - return nil, errors.Wrap(ctx, err, op, errors.WithMsg(fmt.Sprintf("in %s", s.PublicId))) + return nil, errors.Wrap(ctx, err, op) } - return s, nil + switch { + case len(sets) == 0: + return nil, nil // not an error to return no rows for a "lookup" + case len(sets) > 1: + return nil, errors.New(ctx, errors.NotSpecificIntegrity, op, fmt.Sprintf("%s matched more than 1 ", publicId)) + default: + return sets[0], nil + } } // ListSets returns a slice of HostSets for the catalogId. WithLimit is the // only option supported. -func (r *Repository) ListSets(ctx context.Context, catalogId string, opt ...Option) ([]*HostSet, error) { +func (r *Repository) ListSets(ctx context.Context, catalogId string, opt ...host.Option) ([]*HostSet, error) { const op = "plugin.(Repository).ListSets" if catalogId == "" { - return nil, errors.New(ctx, errors.InvalidParameter, op, "no catalog id") + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing catalog id") } - opts := getOpts(opt...) - limit := r.defaultLimit - if opts.withLimit != 0 { - // non-zero signals an override of the default limit for the repo. - limit = opts.withLimit - } - var sets []*HostSet - err := r.reader.SearchWhere(ctx, &sets, "catalog_id = ?", []interface{}{catalogId}, db.WithLimit(limit)) + + sets, err := r.getSets(ctx, "", catalogId, opt...) if err != nil { return nil, errors.Wrap(ctx, err, op) } return sets, nil } + +func (r *Repository) getSets(ctx context.Context, publicId string, catalogId string, opt ...host.Option) ([]*HostSet, error) { + const op = "plugin.(Repository).getSets" + const aggregateDelimiter = "|" + const priorityDelimiter = "=" + + if publicId == "" && catalogId == "" { + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing search criteria: both host set id and catalog id are empty") + } + if publicId != "" && catalogId != "" { + return nil, errors.New(ctx, errors.InvalidParameter, op, "searching for both a host set id and a catalog id is not supported") + } + + opts, err := host.GetOpts(opt...) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + limit := r.defaultLimit + if opts.WithLimit != 0 { + // non-zero signals an override of the default limit for the repo. + limit = opts.WithLimit + } + + args := make([]interface{}, 0, 1) + var where string + + switch { + case publicId != "": + where, args = "public_id = ?", append(args, publicId) + default: + where, args = "catalog_id = ?", append(args, catalogId) + } + + dbArgs := []db.Option{db.WithLimit(limit)} + + if opts.WithOrderByCreateTime { + if opts.Ascending { + dbArgs = append(dbArgs, db.WithOrder("create_time asc")) + } else { + dbArgs = append(dbArgs, db.WithOrder("create_time")) + } + } + + var aggHostSets []*hostSetAgg + if err := r.reader.SearchWhere(ctx, &aggHostSets, where, args, dbArgs...); err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg(fmt.Sprintf("in %s", publicId))) + } + + if len(aggHostSets) == 0 { + return nil, nil + } + + sets := make([]*HostSet, 0, len(aggHostSets)) + for _, agg := range aggHostSets { + hs := allocHostSet() + hs.PublicId = agg.PublicId + hs.CatalogId = agg.CatalogId + hs.Name = agg.Name + hs.Description = agg.Description + hs.CreateTime = agg.CreateTime + hs.UpdateTime = agg.UpdateTime + hs.Version = agg.Version + hs.Attributes = agg.Attributes + if agg.PreferredEndpoints != "" { + eps := strings.Split(agg.PreferredEndpoints, aggregateDelimiter) + if len(eps) > 0 { + // We want to protect against someone messing with the DB + // and not panic, so we do a bit of a dance here + var sortErr error + sort.Slice(eps, func(i, j int) bool { + epi := strings.Split(eps[i], priorityDelimiter) + if len(epi) != 2 { + sortErr = errors.New(ctx, errors.NotSpecificIntegrity, op, fmt.Sprintf("preferred endpoint %s had unexpected fields", eps[i])) + return false + } + epj := strings.Split(eps[j], priorityDelimiter) + if len(epj) != 2 { + sortErr = errors.New(ctx, errors.NotSpecificIntegrity, op, fmt.Sprintf("preferred endpoint %s had unexpected fields", eps[j])) + return false + } + indexi, err := strconv.Atoi(epi[0]) + if err != nil { + sortErr = errors.Wrap(ctx, err, op) + return false + } + indexj, err := strconv.Atoi(epj[0]) + if err != nil { + sortErr = errors.Wrap(ctx, err, op) + return false + } + return indexi < indexj + }) + if sortErr != nil { + return nil, sortErr + } + for i, ep := range eps { + // At this point they're in the correct order, but we still + // have to strip off the priority + eps[i] = strings.Split(ep, priorityDelimiter)[1] + } + + hs.PreferredEndpoints = eps + } + } + sets = append(sets, hs) + } + + return sets, nil +} + +// hostSetAgg is a view that aggregates the host set's value objects in to +// string fields delimited with the aggregateDelimiter of "|" +type hostSetAgg struct { + PublicId string `gorm:"primary_key"` + CatalogId string + PluginId string + Name string + Description string + CreateTime *timestamp.Timestamp + UpdateTime *timestamp.Timestamp + Version uint32 + Attributes []byte + PreferredEndpoints string +} + +// TableName returns the table name for gorm +func (agg *hostSetAgg) TableName() string { return "host_plugin_host_set_with_value_obj" } diff --git a/internal/host/plugin/repository_host_set_test.go b/internal/host/plugin/repository_host_set_test.go index bae081b41..b09655aab 100644 --- a/internal/host/plugin/repository_host_set_test.go +++ b/internal/host/plugin/repository_host_set_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/boundary/internal/db" "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/host" "github.com/hashicorp/boundary/internal/host/plugin/store" "github.com/hashicorp/boundary/internal/iam" "github.com/hashicorp/boundary/internal/kms" @@ -60,8 +61,8 @@ func TestRepository_CreateSet(t *testing.T) { name: "invalid-public-id-set", in: &HostSet{ HostSet: &store.HostSet{ - CatalogId: catalog.PublicId, - PublicId: "abcd_OOOOOOOOOO", + CatalogId: catalog.PublicId, + PublicId: "abcd_OOOOOOOOOO", Attributes: attrs, }, }, @@ -80,30 +81,47 @@ func TestRepository_CreateSet(t *testing.T) { name: "valid-no-options", in: &HostSet{ HostSet: &store.HostSet{ - CatalogId: catalog.PublicId, + CatalogId: catalog.PublicId, Attributes: attrs, }, }, want: &HostSet{ HostSet: &store.HostSet{ - CatalogId: catalog.PublicId, + CatalogId: catalog.PublicId, Attributes: attrs, }, }, }, + { + name: "valid-preferred-endpoints", + in: &HostSet{ + HostSet: &store.HostSet{ + CatalogId: catalog.PublicId, + Attributes: attrs, + PreferredEndpoints: []string{"cidr:1.2.3.4/32", "dns:a.b.c"}, + }, + }, + want: &HostSet{ + HostSet: &store.HostSet{ + CatalogId: catalog.PublicId, + Attributes: attrs, + PreferredEndpoints: []string{"cidr:1.2.3.4/32", "dns:a.b.c"}, + }, + }, + }, { name: "valid-with-name", in: &HostSet{ HostSet: &store.HostSet{ - CatalogId: catalog.PublicId, - Name: "test-name-repo", + CatalogId: catalog.PublicId, + Name: "test-name-repo", Attributes: attrs, }, }, want: &HostSet{ HostSet: &store.HostSet{ - CatalogId: catalog.PublicId, - Name: "test-name-repo", + CatalogId: catalog.PublicId, + Name: "test-name-repo", Attributes: attrs, }, }, @@ -114,14 +132,14 @@ func TestRepository_CreateSet(t *testing.T) { HostSet: &store.HostSet{ CatalogId: catalog.PublicId, Description: ("test-description-repo"), - Attributes: attrs, + Attributes: attrs, }, }, want: &HostSet{ HostSet: &store.HostSet{ CatalogId: catalog.PublicId, Description: ("test-description-repo"), - Attributes: attrs, + Attributes: attrs, }, }, }, @@ -163,8 +181,8 @@ func TestRepository_CreateSet(t *testing.T) { in := &HostSet{ HostSet: &store.HostSet{ - CatalogId: catalog.PublicId, - Name: "test-name-repo", + CatalogId: catalog.PublicId, + Name: "test-name-repo", Attributes: []byte("{}"), }, } @@ -195,7 +213,7 @@ func TestRepository_CreateSet(t *testing.T) { in := &HostSet{ HostSet: &store.HostSet{ - Name: "test-name-repo", + Name: "test-name-repo", Attributes: []byte("{}"), }, } @@ -234,7 +252,7 @@ func TestRepository_LookupSet(t *testing.T) { plg := hostplg.TestPlugin(t, conn, "lookup", "lookup") catalog := TestCatalog(t, conn, prj.PublicId, plg.GetPublicId()) - hostSet := TestSet(t, conn, catalog.PublicId) + hostSet := TestSet(t, conn, kms, catalog) hostSetId, err := newHostSetId(ctx, plg.GetIdPrefix()) require.NoError(t, err) @@ -273,7 +291,9 @@ func TestRepository_LookupSet(t *testing.T) { return } require.NoError(err) - assert.EqualValues(tt.want, got) + if tt.want != nil { + assert.Empty(cmp.Diff(got, tt.want, protocmp.Transform()), "LookupSet(%q) got response %q, wanted %q", tt.in, got, tt.want) + } }) } } @@ -291,15 +311,15 @@ func TestRepository_ListSets(t *testing.T) { catalogB := TestCatalog(t, conn, prj.PublicId, plg.GetPublicId()) hostSets := []*HostSet{ - TestSet(t, conn, catalogA.PublicId), - TestSet(t, conn, catalogA.PublicId), - TestSet(t, conn, catalogA.PublicId), + TestSet(t, conn, kms, catalogA), + TestSet(t, conn, kms, catalogA), + TestSet(t, conn, kms, catalogA), } tests := []struct { name string in string - opts []Option + opts []host.Option want []*HostSet wantIsErr errors.Code }{ @@ -310,7 +330,7 @@ func TestRepository_ListSets(t *testing.T) { { name: "Catalog-with-no-host-sets", in: catalogB.PublicId, - want: []*HostSet{}, + want: nil, }, { name: "Catalog-with-host-sets", @@ -355,13 +375,13 @@ func TestRepository_ListSets_Limits(t *testing.T) { count := 10 var hostSets []*HostSet for i := 0; i < count; i++ { - hostSets = append(hostSets, TestSet(t, conn, catalog.PublicId)) + hostSets = append(hostSets, TestSet(t, conn, kms, catalog)) } tests := []struct { name string - repoOpts []Option - listOpts []Option + repoOpts []host.Option + listOpts []host.Option wantLen int }{ { @@ -370,34 +390,34 @@ func TestRepository_ListSets_Limits(t *testing.T) { }, { name: "With repo limit", - repoOpts: []Option{WithLimit(3)}, + repoOpts: []host.Option{host.WithLimit(3)}, wantLen: 3, }, { name: "With negative repo limit", - repoOpts: []Option{WithLimit(-1)}, + repoOpts: []host.Option{host.WithLimit(-1)}, wantLen: count, }, { name: "With List limit", - listOpts: []Option{WithLimit(3)}, + listOpts: []host.Option{host.WithLimit(3)}, wantLen: 3, }, { name: "With negative List limit", - listOpts: []Option{WithLimit(-1)}, + listOpts: []host.Option{host.WithLimit(-1)}, wantLen: count, }, { name: "With repo smaller than list limit", - repoOpts: []Option{WithLimit(2)}, - listOpts: []Option{WithLimit(6)}, + repoOpts: []host.Option{host.WithLimit(2)}, + listOpts: []host.Option{host.WithLimit(6)}, wantLen: 6, }, { name: "With repo larger than list limit", - repoOpts: []Option{WithLimit(6)}, - listOpts: []Option{WithLimit(2)}, + repoOpts: []host.Option{host.WithLimit(6)}, + listOpts: []host.Option{host.WithLimit(2)}, wantLen: 2, }, } diff --git a/internal/host/plugin/repository_test.go b/internal/host/plugin/repository_test.go index 6ef12a17e..51d03d338 100644 --- a/internal/host/plugin/repository_test.go +++ b/internal/host/plugin/repository_test.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/boundary/internal/db" "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/host" "github.com/hashicorp/boundary/internal/kms" ) @@ -21,7 +22,7 @@ func TestRepository_New(t *testing.T) { r db.Reader w db.Writer kms *kms.Kms - opts []Option + opts []host.Option } tests := []struct { @@ -50,7 +51,7 @@ func TestRepository_New(t *testing.T) { r: rw, w: rw, kms: kmsCache, - opts: []Option{WithLimit(5)}, + opts: []host.Option{host.WithLimit(5)}, }, want: &Repository{ reader: rw, diff --git a/internal/host/plugin/store/host.pb.go b/internal/host/plugin/store/host.pb.go index c9a0ea5db..17a7cd29c 100644 --- a/internal/host/plugin/store/host.pb.go +++ b/internal/host/plugin/store/host.pb.go @@ -183,9 +183,12 @@ type HostSet struct { // version allows optimistic locking of the resource // @inject_tag: `gorm:"default:null"` Version uint32 `protobuf:"varint,7,opt,name=version,proto3" json:"version,omitempty" gorm:"default:null"` - // attributes is a jsonb formatted field. + // attributes is a byte field containing marshaled JSON data // @inject_tag: `gorm:"default:null"` Attributes []byte `protobuf:"bytes,8,opt,name=attributes,proto3" json:"attributes,omitempty" gorm:"default:null"` + // preferred_endpoints stores string preference values + // @inject_tag: `gorm:"-"` + PreferredEndpoints []string `protobuf:"bytes,9,rep,name=preferred_endpoints,json=preferredEndpoints,proto3" json:"preferred_endpoints,omitempty" gorm:"-"` } func (x *HostSet) Reset() { @@ -276,6 +279,13 @@ func (x *HostSet) GetAttributes() []byte { return nil } +func (x *HostSet) GetPreferredEndpoints() []string { + if x != nil { + return x.PreferredEndpoints + } + return nil +} + type HostCatalogSecret struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -419,7 +429,7 @@ var file_controller_storage_host_plugin_store_v1_host_proto_rawDesc = []byte{ 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x61, - 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x22, 0x81, 0x03, 0x0a, 0x07, 0x48, 0x6f, + 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x22, 0xb2, 0x03, 0x0a, 0x07, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x64, 0x12, 0x4b, 0x0a, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x69, 0x6d, @@ -443,30 +453,33 @@ var file_controller_storage_host_plugin_store_v1_host_proto_rawDesc = []byte{ 0x67, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, - 0x0c, 0x52, 0x0a, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x22, 0x98, 0x02, - 0x0a, 0x11, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x53, 0x65, 0x63, - 0x72, 0x65, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, - 0x49, 0x64, 0x12, 0x4b, 0x0a, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x69, 0x6d, - 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, - 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2e, 0x74, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, - 0x61, 0x6d, 0x70, 0x52, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x12, - 0x4b, 0x0a, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, - 0x72, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2e, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, - 0x61, 0x6d, 0x70, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, - 0x52, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, - 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x73, 0x65, - 0x63, 0x72, 0x65, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x74, 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, - 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x63, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, - 0x74, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x49, 0x64, 0x42, 0x40, 0x5a, 0x3e, 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, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, - 0x61, 0x6c, 0x2f, 0x68, 0x6f, 0x73, 0x74, 0x2f, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2f, 0x73, - 0x74, 0x6f, 0x72, 0x65, 0x3b, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, + 0x0c, 0x52, 0x0a, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x12, 0x2f, 0x0a, + 0x13, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x5f, 0x65, 0x6e, 0x64, 0x70, 0x6f, + 0x69, 0x6e, 0x74, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x09, 0x52, 0x12, 0x70, 0x72, 0x65, 0x66, + 0x65, 0x72, 0x72, 0x65, 0x64, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x22, 0x98, + 0x02, 0x0a, 0x11, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x53, 0x65, + 0x63, 0x72, 0x65, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, + 0x67, 0x49, 0x64, 0x12, 0x4b, 0x0a, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x69, + 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, + 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2e, 0x74, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, + 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, + 0x12, 0x4b, 0x0a, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, + 0x65, 0x72, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2e, 0x74, 0x69, 0x6d, 0x65, 0x73, + 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, + 0x70, 0x52, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x16, 0x0a, + 0x06, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x73, + 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x74, 0x5f, 0x73, 0x65, 0x63, 0x72, + 0x65, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x63, 0x74, 0x53, 0x65, 0x63, 0x72, + 0x65, 0x74, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x49, 0x64, 0x42, 0x40, 0x5a, 0x3e, 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, 0x69, 0x6e, 0x74, 0x65, 0x72, + 0x6e, 0x61, 0x6c, 0x2f, 0x68, 0x6f, 0x73, 0x74, 0x2f, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2f, + 0x73, 0x74, 0x6f, 0x72, 0x65, 0x3b, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, } var ( diff --git a/internal/host/plugin/testing.go b/internal/host/plugin/testing.go index 5b11d999f..1a860399d 100644 --- a/internal/host/plugin/testing.go +++ b/internal/host/plugin/testing.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/kms" "github.com/hashicorp/boundary/internal/plugin/host" "github.com/jinzhu/gorm" "github.com/stretchr/testify/assert" @@ -39,29 +40,29 @@ func TestCatalog(t *testing.T, conn *gorm.DB, scopeId, pluginId string, opt ...O // TestSet creates a plugin host sets in the provided DB // with the provided catalog id. The catalog must have been created // previously. The test will fail if any errors are encountered. -func TestSet(t *testing.T, conn *gorm.DB, catalogId string, opt ...Option) *HostSet { +func TestSet(t *testing.T, conn *gorm.DB, kmsCache *kms.Kms, hc *HostCatalog, opt ...Option) *HostSet { t.Helper() + require := require.New(t) ctx := context.Background() - w := db.New(conn) + rw := db.New(conn) - assert := assert.New(t) - set, err := NewHostSet(ctx, catalogId, opt...) - require.NoError(t, err) - assert.NotNil(set) + repo, err := NewRepository(rw, rw, kmsCache) + require.NoError(err) - cg := allocHostCatalog() - cg.PublicId = catalogId - require.NoError(t, w.LookupByPublicId(ctx, cg)) + set, err := NewHostSet(ctx, hc.PublicId, opt...) + require.NoError(err) + require.NotNil(set) plg := host.NewPlugin("", "") - plg.PublicId = cg.GetPluginId() - require.NoError(t, w.LookupByPublicId(ctx, plg)) + plg.PublicId = hc.GetPluginId() + require.NoError(rw.LookupByPublicId(ctx, plg)) id, err := newHostSetId(ctx, plg.GetIdPrefix()) - assert.NoError(err) - assert.NotEmpty(id) - set.PublicId = id + require.NoError(err) + require.NotEmpty(id) + + set, err = repo.CreateSet(ctx, hc.ScopeId, set, opt...) + require.NoError(err) - require.NoError(t, w.Create(ctx, set)) return set } diff --git a/internal/host/plugin/testing_test.go b/internal/host/plugin/testing_test.go index 7c11e9688..50419aef7 100644 --- a/internal/host/plugin/testing_test.go +++ b/internal/host/plugin/testing_test.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/boundary/internal/db" "github.com/hashicorp/boundary/internal/iam" + "github.com/hashicorp/boundary/internal/kms" "github.com/hashicorp/boundary/internal/plugin/host" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -35,6 +36,7 @@ func Test_TestSet(t *testing.T) { assert, require := assert.New(t), require.New(t) conn, _ := db.TestSetup(t, "postgres") wrapper := db.TestWrapper(t) + kmsCache := kms.TestKms(t, conn, wrapper) _, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) require.NotNil(prj) assert.NotEmpty(prj.GetPublicId()) @@ -44,7 +46,7 @@ func Test_TestSet(t *testing.T) { assert.NotEmpty(plg.GetPublicId()) c := TestCatalog(t, conn, prj.GetPublicId(), plg.GetPublicId()) - set := TestSet(t, conn, c.GetPublicId(), WithName("foo"), WithDescription("bar")) + set := TestSet(t, conn, kmsCache, c, WithName("foo"), WithDescription("bar")) assert.NotEmpty(set.GetPublicId()) assert.True(strings.HasPrefix(set.GetPublicId(), fmt.Sprintf("%s_%s", HostSetPrefix, "prefix"))) assert.Equal("foo", set.GetName()) diff --git a/internal/host/preferred_endpoint.go b/internal/host/preferred_endpoint.go new file mode 100644 index 000000000..9dc32dd5f --- /dev/null +++ b/internal/host/preferred_endpoint.go @@ -0,0 +1,87 @@ +package host + +import ( + "context" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/host/store" + "github.com/hashicorp/boundary/internal/libs/endpoint" + "google.golang.org/protobuf/proto" +) + +// defaultPreferredEndpointTableName defines the default table name for a +// PreferredEndpoint +const defaultPreferredEndpointTableName = "host_set_preferred_endpoint" + +// PreferredEndpoint defines an endpoint condition for a host set. +// PreferredEndpoints are value objects of an HostSet, therefore there's no need +// for oplog metadata, since only the HostSet will have metadata because it's +// the root aggregate. +type PreferredEndpoint struct { + *store.PreferredEndpoint + tableName string +} + +// NewPreferredEndpoint creates a new in memory preferred endpoint assigned to a +// HostSet. It supports no options. +func NewPreferredEndpoint(ctx context.Context, hostSetId string, priority uint32, condition string) (*PreferredEndpoint, error) { + const op = "host.NewPreferredEndpoint" + + pe := &PreferredEndpoint{ + PreferredEndpoint: &store.PreferredEndpoint{ + HostSetId: hostSetId, + Priority: priority, + Condition: condition, + }, + } + if err := pe.validate(ctx, op); err != nil { + return nil, err // intentionally not wrapped + } + return pe, nil +} + +// validate the PreferredEndpoint. On success, it will return nil. +func (pe *PreferredEndpoint) validate(ctx context.Context, caller errors.Op) error { + if pe.HostSetId == "" { + return errors.New(ctx, errors.InvalidParameter, caller, "missing host set id") + } + if pe.Priority < 1 { + return errors.New(ctx, errors.InvalidParameter, caller, "invalid priority value") + } + if pe.Condition == "" { + return errors.New(ctx, errors.InvalidParameter, caller, "missing condition") + } + if _, err := endpoint.NewPreferencer(ctx, endpoint.WithPreferenceOrder([]string{pe.Condition})); err != nil { + return errors.Wrap(ctx, err, caller) + } + + return nil +} + +// AllocPreferredEndpoint makes an empty one in-memory +func AllocPreferredEndpoint() PreferredEndpoint { + return PreferredEndpoint{ + PreferredEndpoint: &store.PreferredEndpoint{}, + } +} + +// Clone a PreferredEndpoint +func (c *PreferredEndpoint) Clone() *PreferredEndpoint { + cp := proto.Clone(c.PreferredEndpoint) + return &PreferredEndpoint{ + PreferredEndpoint: cp.(*store.PreferredEndpoint), + } +} + +// TableName returns the table name. +func (c *PreferredEndpoint) TableName() string { + if c.tableName != "" { + return c.tableName + } + return defaultPreferredEndpointTableName +} + +// SetTableName sets the table name. +func (c *PreferredEndpoint) SetTableName(n string) { + c.tableName = n +} diff --git a/internal/host/preferred_endpoint_test.go b/internal/host/preferred_endpoint_test.go new file mode 100644 index 000000000..a74310f9d --- /dev/null +++ b/internal/host/preferred_endpoint_test.go @@ -0,0 +1,219 @@ +package host_test + +import ( + "context" + "testing" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/host" + "github.com/hashicorp/boundary/internal/host/plugin" + "github.com/hashicorp/boundary/internal/iam" + "github.com/hashicorp/boundary/internal/kms" + hostplg "github.com/hashicorp/boundary/internal/plugin/host" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPreferredEndpoint_Create(t *testing.T) { + t.Parallel() + ctx := context.TODO() + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + rw := db.New(conn) + kmsCache := kms.TestKms(t, conn, wrapper) + + _, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + plg := hostplg.TestPlugin(t, conn, "create", "create") + catalog := plugin.TestCatalog(t, conn, prj.PublicId, plg.GetPublicId()) + set := plugin.TestSet(t, conn, kmsCache, catalog) + + type args struct { + hostSetId string + priority uint32 + condition string + } + tests := []struct { + name string + args args + want *host.PreferredEndpoint + wantErr bool + wantIsErr errors.Code + create bool + wantCreateErr bool + wantCreateIsErr errors.Code + }{ + { + name: "valid", + args: args{ + hostSetId: set.PublicId, + priority: 1, + condition: "cidr:1.2.3.4", + }, + create: true, + want: func() *host.PreferredEndpoint { + want := host.AllocPreferredEndpoint() + want.HostSetId = set.PublicId + want.Condition = "cidr:1.2.3.4" + want.Priority = 1 + return &want + }(), + }, + { + name: "dup", // must follow "valid" test. Url must be be unique for an OidcMethodId + args: args{ + hostSetId: set.PublicId, + priority: 1, + condition: "cidr:1.2.3.4", + }, + create: true, + want: func() *host.PreferredEndpoint { + want := host.AllocPreferredEndpoint() + want.HostSetId = set.PublicId + want.Condition = "cidr:1.2.3.4" + want.Priority = 1 + return &want + }(), + wantCreateErr: true, + wantCreateIsErr: errors.NotUnique, + }, + { + name: "bad-condition", + args: args{ + hostSetId: set.PublicId, + priority: 1, + condition: "foobar:1.2.3.4", + }, + wantErr: true, + wantIsErr: errors.InvalidParameter, + }, + { + name: "empty-host-set", + args: args{ + hostSetId: "", + }, + wantErr: true, + wantIsErr: errors.InvalidParameter, + }, + { + name: "invalid-priority", + args: args{ + hostSetId: set.PublicId, + priority: 0, + }, + wantErr: true, + wantIsErr: errors.InvalidParameter, + }, + { + name: "empty-condition", + args: args{ + hostSetId: set.PublicId, + priority: 1, + }, + wantErr: true, + wantIsErr: errors.InvalidParameter, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := host.NewPreferredEndpoint(ctx, tt.args.hostSetId, tt.args.priority, tt.args.condition) + if tt.wantErr { + require.Error(err) + assert.True(errors.Match(errors.T(tt.wantIsErr), err)) + return + } + require.NoError(err) + assert.Equal(tt.want, got) + if tt.create { + ctx := context.Background() + err = rw.Create(ctx, got) + if tt.wantCreateErr { + assert.Error(err) + assert.True(errors.Match(errors.T(tt.wantCreateIsErr), err)) + return + } else { + assert.NoError(err) + } + found := host.AllocPreferredEndpoint() + require.NoError(rw.LookupWhere(ctx, &found, "host_set_id = ? and priority = ?", tt.args.hostSetId, tt.args.priority)) + assert.Equal(got, &found) + } + }) + } +} + +func TestPreferredEndpoint_Delete(t *testing.T) { + t.Parallel() + ctx := context.TODO() + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + rw := db.New(conn) + kmsCache := kms.TestKms(t, conn, wrapper) + + _, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + plg := hostplg.TestPlugin(t, conn, "create", "create") + catalog := plugin.TestCatalog(t, conn, prj.PublicId, plg.GetPublicId()) + set := plugin.TestSet(t, conn, kmsCache, catalog) + + peFunc := func(priority uint32, condition string) *host.PreferredEndpoint { + ep, err := host.NewPreferredEndpoint(ctx, set.PublicId, priority, condition) + require.NoError(t, err) + return ep + } + + tests := []struct { + pes []*host.PreferredEndpoint + name string + overrides func(*host.PreferredEndpoint) + wantErr bool + wantRowsDeleted int + }{ + { + name: "valid-single", + pes: []*host.PreferredEndpoint{peFunc(1, "cidr:1.2.3.4")}, + wantErr: false, + wantRowsDeleted: 1, + }, + { + name: "bad-host-set-id", + pes: []*host.PreferredEndpoint{peFunc(1, "cidr:1.2.3.4")}, + overrides: func(pe *host.PreferredEndpoint) { pe.HostSetId = "bad-id" }, + wantErr: false, + wantRowsDeleted: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + ctx := context.Background() + var eput *host.PreferredEndpoint + for _, pe := range tt.pes { + cloned := pe.Clone() + if eput == nil { + eput = cloned + } + require.NoError(rw.Create(ctx, &cloned)) + } + if tt.overrides != nil { + tt.overrides(eput) + } + + deletedRows, err := rw.Delete(ctx, &eput, db.WithWhere("host_set_id = ?", eput.HostSetId)) + if tt.wantErr { + require.Error(err) + return + } + + require.NoError(err) + if tt.wantRowsDeleted == 0 { + assert.Equal(tt.wantRowsDeleted, deletedRows) + return + } + require.Equal(tt.wantRowsDeleted, deletedRows) + found := host.AllocPreferredEndpoint() + err = rw.LookupWhere(ctx, &found, "host_set_id = ?", set.PublicId) + assert.True(errors.IsNotFoundError(err)) + }) + } +} diff --git a/internal/host/store/host.pb.go b/internal/host/store/host.pb.go index 25d7e3b0e..a781d8082 100644 --- a/internal/host/store/host.pb.go +++ b/internal/host/store/host.pb.go @@ -199,6 +199,75 @@ func (x *Set) GetCatalogId() string { return "" } +type PreferredEndpoint struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The ID of the host set this belongs to. + // @inject_tag: `gorm:"primary_key"` + HostSetId string `protobuf:"bytes,1,opt,name=host_set_id,json=hostSetId,proto3" json:"host_set_id,omitempty" gorm:"primary_key"` + // The priority order, zero-indexed + // @inject_tag: `gorm:"primary_key"` + Priority uint32 `protobuf:"varint,2,opt,name=priority,proto3" json:"priority,omitempty" gorm:"primary_key"` + // The string text of the preference condition + // @inject_tag: `gorm:"not_null"` + Condition string `protobuf:"bytes,3,opt,name=condition,proto3" json:"condition,omitempty" gorm:"not_null"` +} + +func (x *PreferredEndpoint) Reset() { + *x = PreferredEndpoint{} + if protoimpl.UnsafeEnabled { + mi := &file_controller_storage_host_store_v1_host_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PreferredEndpoint) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PreferredEndpoint) ProtoMessage() {} + +func (x *PreferredEndpoint) ProtoReflect() protoreflect.Message { + mi := &file_controller_storage_host_store_v1_host_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PreferredEndpoint.ProtoReflect.Descriptor instead. +func (*PreferredEndpoint) Descriptor() ([]byte, []int) { + return file_controller_storage_host_store_v1_host_proto_rawDescGZIP(), []int{3} +} + +func (x *PreferredEndpoint) GetHostSetId() string { + if x != nil { + return x.HostSetId + } + return "" +} + +func (x *PreferredEndpoint) GetPriority() uint32 { + if x != nil { + return x.Priority + } + return 0 +} + +func (x *PreferredEndpoint) GetCondition() string { + if x != nil { + return x.Condition + } + return "" +} + var File_controller_storage_host_store_v1_host_proto protoreflect.FileDescriptor var file_controller_storage_host_store_v1_host_proto_rawDesc = []byte{ @@ -219,11 +288,18 @@ var file_controller_storage_host_store_v1_host_proto_rawDesc = []byte{ 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, - 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x49, 0x64, 0x42, 0x39, 0x5a, 0x37, 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, 0x69, 0x6e, 0x74, 0x65, 0x72, - 0x6e, 0x61, 0x6c, 0x2f, 0x68, 0x6f, 0x73, 0x74, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x3b, 0x73, - 0x74, 0x6f, 0x72, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x63, 0x61, 0x74, 0x61, 0x6c, 0x6f, 0x67, 0x49, 0x64, 0x22, 0x6d, 0x0a, 0x11, 0x50, 0x72, 0x65, + 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x1e, + 0x0a, 0x0b, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x73, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x68, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x74, 0x49, 0x64, 0x12, 0x1a, + 0x0a, 0x08, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, + 0x52, 0x08, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6f, + 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, + 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x39, 0x5a, 0x37, 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, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, + 0x61, 0x6c, 0x2f, 0x68, 0x6f, 0x73, 0x74, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x3b, 0x73, 0x74, + 0x6f, 0x72, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -238,11 +314,12 @@ func file_controller_storage_host_store_v1_host_proto_rawDescGZIP() []byte { return file_controller_storage_host_store_v1_host_proto_rawDescData } -var file_controller_storage_host_store_v1_host_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_controller_storage_host_store_v1_host_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_controller_storage_host_store_v1_host_proto_goTypes = []interface{}{ - (*Catalog)(nil), // 0: controller.storage.host.store.v1.Catalog - (*Host)(nil), // 1: controller.storage.host.store.v1.Host - (*Set)(nil), // 2: controller.storage.host.store.v1.Set + (*Catalog)(nil), // 0: controller.storage.host.store.v1.Catalog + (*Host)(nil), // 1: controller.storage.host.store.v1.Host + (*Set)(nil), // 2: controller.storage.host.store.v1.Set + (*PreferredEndpoint)(nil), // 3: controller.storage.host.store.v1.PreferredEndpoint } var file_controller_storage_host_store_v1_host_proto_depIdxs = []int32{ 0, // [0:0] is the sub-list for method output_type @@ -294,6 +371,18 @@ func file_controller_storage_host_store_v1_host_proto_init() { return nil } } + file_controller_storage_host_store_v1_host_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PreferredEndpoint); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } type x struct{} out := protoimpl.TypeBuilder{ @@ -301,7 +390,7 @@ func file_controller_storage_host_store_v1_host_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_controller_storage_host_store_v1_host_proto_rawDesc, NumEnums: 0, - NumMessages: 3, + NumMessages: 4, NumExtensions: 0, NumServices: 0, }, diff --git a/internal/libs/endpoint/preferencer.go b/internal/libs/endpoint/preferencer.go index 17be0893a..44619fd80 100644 --- a/internal/libs/endpoint/preferencer.go +++ b/internal/libs/endpoint/preferencer.go @@ -21,7 +21,7 @@ func NewPreferencer(ctx context.Context, opt ...Option) (*preferencer, error) { const op = "endpoint.NewPreferencer" opts, err := getOpts(opt...) if err != nil { - return nil, errors.Wrap(ctx, err, op) + return nil, errors.Wrap(ctx, err, op, errors.WithCode(errors.InvalidParameter)) } pref := &preferencer{ matchers: opts.withMatchers, 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 add5017ee..28f4d204b 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 @@ -43,6 +43,10 @@ message HostSet { // Output only. A list of Hosts in this Host Set. repeated string host_ids = 100 [json_name="host_ids"]; + // An ordered list of endpoint preferences used to choose from among + // multiple possible endpoints for a host. + 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; diff --git a/internal/proto/local/controller/storage/host/plugin/store/v1/host.proto b/internal/proto/local/controller/storage/host/plugin/store/v1/host.proto index e9cb28b64..d5c8b9626 100644 --- a/internal/proto/local/controller/storage/host/plugin/store/v1/host.proto +++ b/internal/proto/local/controller/storage/host/plugin/store/v1/host.proto @@ -78,9 +78,13 @@ message HostSet { // @inject_tag: `gorm:"default:null"` uint32 version = 7; - // attributes is a jsonb formatted field. + // attributes is a byte field containing marshaled JSON data // @inject_tag: `gorm:"default:null"` bytes attributes = 8; + + // preferred_endpoints stores string preference values + // @inject_tag: `gorm:"-"` + repeated string preferred_endpoints = 9; } message HostCatalogSecret { @@ -109,4 +113,4 @@ message HostCatalogSecret { // It must be set. // @inject_tag: `gorm:"not_null"` string key_id = 6; -} +} \ No newline at end of file diff --git a/internal/proto/local/controller/storage/host/store/v1/host.proto b/internal/proto/local/controller/storage/host/store/v1/host.proto index 31a75bf05..e6123406c 100644 --- a/internal/proto/local/controller/storage/host/store/v1/host.proto +++ b/internal/proto/local/controller/storage/host/store/v1/host.proto @@ -33,3 +33,17 @@ message Set { // @inject_tag: `gorm:"not_null"` string catalog_id = 2; } + +message PreferredEndpoint { + // The ID of the host set this belongs to. + // @inject_tag: `gorm:"primary_key"` + string host_set_id = 1; + + // The priority order, zero-indexed + // @inject_tag: `gorm:"primary_key"` + uint32 priority = 2; + + // The string text of the preference condition + // @inject_tag: `gorm:"not_null"` + string condition = 3; +} \ No newline at end of file diff --git a/internal/servers/controller/controller.go b/internal/servers/controller/controller.go index 48941bc30..850216571 100644 --- a/internal/servers/controller/controller.go +++ b/internal/servers/controller/controller.go @@ -12,7 +12,7 @@ import ( "github.com/hashicorp/boundary/internal/cmd/config" "github.com/hashicorp/boundary/internal/credential/vault" "github.com/hashicorp/boundary/internal/db" - "github.com/hashicorp/boundary/internal/host/plugin" + hostplugin "github.com/hashicorp/boundary/internal/host/plugin" "github.com/hashicorp/boundary/internal/host/static" "github.com/hashicorp/boundary/internal/iam" "github.com/hashicorp/boundary/internal/kms" @@ -135,8 +135,8 @@ func New(ctx context.Context, conf *Config) (*Controller, error) { c.StaticHostRepoFn = func() (*static.Repository, error) { return static.NewRepository(dbase, dbase, c.kms) } - c.PluginHostRepoFn = func() (*plugin.Repository, error) { - return plugin.NewRepository(dbase, dbase, c.kms) + c.PluginHostRepoFn = func() (*hostplugin.Repository, error) { + return hostplugin.NewRepository(dbase, dbase, c.kms) } c.HostPluginRepoFn = func() (*host.Repository, error) { return host.NewRepository(dbase, dbase, c.kms) diff --git a/internal/servers/controller/handlers/host_catalogs/host_catalog_service.go b/internal/servers/controller/handlers/host_catalogs/host_catalog_service.go index 7edd7a1e7..6479c12eb 100644 --- a/internal/servers/controller/handlers/host_catalogs/host_catalog_service.go +++ b/internal/servers/controller/handlers/host_catalogs/host_catalog_service.go @@ -67,7 +67,7 @@ type Service struct { staticRepoFn common.StaticRepoFactory pluginHostRepoFn common.PluginHostRepoFactory - pluginRepoFn common.HostPluginRepoFactory + pluginRepoFn common.HostPluginRepoFactory iamRepoFn common.IamRepoFactory } diff --git a/internal/servers/controller/handlers/host_catalogs/host_catalog_service_test.go b/internal/servers/controller/handlers/host_catalogs/host_catalog_service_test.go index d4ece68b9..e3a684b1f 100644 --- a/internal/servers/controller/handlers/host_catalogs/host_catalog_service_test.go +++ b/internal/servers/controller/handlers/host_catalogs/host_catalog_service_test.go @@ -272,7 +272,7 @@ func TestList(t *testing.T) { var testPluginCatalogs []*pb.HostCatalog name := "test" plg := host.TestPlugin(t, conn, name, name) - for i := 0 ; i < 3; i++ { + for i := 0; i < 3; i++ { hc := plugin.TestCatalog(t, conn, pWithCatalogs.GetPublicId(), plg.GetPublicId()) cat := &pb.HostCatalog{ Id: hc.GetPublicId(), @@ -306,7 +306,7 @@ func TestList(t *testing.T) { name = "different" plg = host.TestPlugin(t, conn, name, name) - for i := 0 ; i < 3; i++ { + for i := 0; i < 3; i++ { hc := plugin.TestCatalog(t, conn, pWithOtherCatalogs.GetPublicId(), plg.GetPublicId()) wantOtherCatalogs = append(wantOtherCatalogs, &pb.HostCatalog{ Id: hc.GetPublicId(), diff --git a/internal/servers/controller/handlers/host_sets/host_set_service.go b/internal/servers/controller/handlers/host_sets/host_set_service.go index 84965f865..f4498fa65 100644 --- a/internal/servers/controller/handlers/host_sets/host_set_service.go +++ b/internal/servers/controller/handlers/host_sets/host_set_service.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/boundary/internal/host/plugin" "github.com/hashicorp/boundary/internal/host/static" "github.com/hashicorp/boundary/internal/host/static/store" + "github.com/hashicorp/boundary/internal/libs/endpoint" "github.com/hashicorp/boundary/internal/perms" "github.com/hashicorp/boundary/internal/requests" "github.com/hashicorp/boundary/internal/servers/controller/auth" @@ -80,6 +81,10 @@ func NewService(staticRepoFn common.StaticRepoFactory, pluginRepoFn common.Plugi } func (s Service) ListHostSets(ctx context.Context, req *pbs.ListHostSetsRequest) (*pbs.ListHostSetsResponse, error) { + return s.ListHostSetsWithOptions(ctx, req) +} + +func (s Service) ListHostSetsWithOptions(ctx context.Context, req *pbs.ListHostSetsRequest, opt ...host.Option) (*pbs.ListHostSetsResponse, error) { if err := validateListRequest(req); err != nil { return nil, err } @@ -87,7 +92,7 @@ func (s Service) ListHostSets(ctx context.Context, req *pbs.ListHostSetsRequest) if authResults.Error != nil { return nil, authResults.Error } - hl, err := s.listFromRepo(ctx, req.GetHostCatalogId()) + hl, err := s.listFromRepo(ctx, req.GetHostCatalogId(), opt...) if err != nil { return nil, err } @@ -177,7 +182,7 @@ func (s Service) GetHostSet(ctx context.Context, req *pbs.GetHostSetRequest) (*p func (s Service) CreateHostSet(ctx context.Context, req *pbs.CreateHostSetRequest) (*pbs.CreateHostSetResponse, error) { const op = "host_sets.(Service).CreateHostSet" - if err := validateCreateRequest(req); err != nil { + if err := validateCreateRequest(ctx, req); err != nil { return nil, err } _, authResults := s.parentAndAuthResult(ctx, req.GetItem().GetHostCatalogId(), action.Create) @@ -218,7 +223,7 @@ func (s Service) CreateHostSet(ctx context.Context, req *pbs.CreateHostSetReques func (s Service) UpdateHostSet(ctx context.Context, req *pbs.UpdateHostSetRequest) (*pbs.UpdateHostSetResponse, error) { const op = "host_sets.(Service).UpdateHostSet" - if err := validateUpdateRequest(req); err != nil { + if err := validateUpdateRequest(ctx, req); err != nil { return nil, err } cat, authResults := s.parentAndAuthResult(ctx, req.GetId(), action.Update) @@ -444,6 +449,9 @@ func (s Service) createInRepo(ctx context.Context, scopeId, catalogId string, it hSet = out case plugin.Subtype: h, err := toStoragePluginSet(ctx, catalogId, item) + if err != nil { + return nil, err + } repo, err := s.pluginRepoFn() if err != nil { return nil, err @@ -505,7 +513,7 @@ func (s Service) deleteFromRepo(ctx context.Context, scopeId, id string) (bool, return rows > 0, nil } -func (s Service) listFromRepo(ctx context.Context, catalogId string) ([]host.Set, error) { +func (s Service) listFromRepo(ctx context.Context, catalogId string, opt ...host.Option) ([]host.Set, error) { const op = "host_sets.(Service).listFromRepo" var sets []host.Set switch host.SubtypeFromId(catalogId) { @@ -526,7 +534,7 @@ func (s Service) listFromRepo(ctx context.Context, catalogId string) ([]host.Set if err != nil { return nil, err } - sl, err := repo.ListSets(ctx, catalogId) + sl, err := repo.ListSets(ctx, catalogId, opt...) if err != nil { return nil, errors.Wrap(ctx, err, op) } @@ -742,9 +750,12 @@ func toProto(ctx context.Context, in host.Set, hosts []host.Host, opt ...handler } } - if outputFields.Has(globals.AttributesField) { - switch h := in.(type) { - case *plugin.HostSet: + switch h := in.(type) { + case *plugin.HostSet: + if outputFields.Has(globals.PreferredEndpointsField) { + out.PreferredEndpoints = h.GetPreferredEndpoints() + } + if outputFields.Has(globals.AttributesField) { attrs := map[string]interface{}{} err := json.Unmarshal(h.Attributes, &attrs) if err != nil { @@ -790,6 +801,9 @@ func toStoragePluginSet(ctx context.Context, catalogId string, item *pb.HostSet) if item.GetAttributes() != nil { opts = append(opts, plugin.WithAttributes(item.GetAttributes().AsMap())) } + if item.GetPreferredEndpoints() != nil { + opts = append(opts, plugin.WithPreferredEndpoints(item.GetPreferredEndpoints())) + } hs, err := plugin.NewHostSet(ctx, catalogId, opts...) if err != nil { return nil, errors.Wrap(ctx, err, op, errors.WithMsg("Unable to build host set for creation")) @@ -814,7 +828,7 @@ func validateGetRequest(req *pbs.GetHostSetRequest) error { return handlers.ValidateGetRequest(handlers.NoopValidatorFn, req, static.HostSetPrefix, pluginPrefix) } -func validateCreateRequest(req *pbs.CreateHostSetRequest) error { +func validateCreateRequest(ctx context.Context, req *pbs.CreateHostSetRequest) error { return handlers.ValidateCreateRequest(req.GetItem(), func() map[string]string { badFields := map[string]string{} pluginPrefix := plugin.HostCatalogPrefix @@ -826,6 +840,12 @@ func validateCreateRequest(req *pbs.CreateHostSetRequest) error { if !handlers.ValidId(handlers.Id(req.GetItem().GetHostCatalogId()), static.HostCatalogPrefix, pluginPrefix) { badFields["host_catalog_id"] = "The field is incorrectly formatted." } + if len(req.GetItem().GetPreferredEndpoints()) > 0 { + _, err := endpoint.NewPreferencer(ctx, endpoint.WithPreferenceOrder(req.GetItem().GetPreferredEndpoints())) + if err != nil { + badFields[globals.PreferredEndpointsField] = fmt.Errorf("Error parsing preferred endpoints: %w.", err).Error() + } + } switch host.SubtypeFromId(req.GetItem().GetHostCatalogId()) { case static.Subtype: if req.GetItem().GetType() != "" && req.GetItem().GetType() != static.Subtype.String() { @@ -850,17 +870,23 @@ func validateCreateRequest(req *pbs.CreateHostSetRequest) error { }) } -func validateUpdateRequest(req *pbs.UpdateHostSetRequest) error { +func validateUpdateRequest(ctx context.Context, req *pbs.UpdateHostSetRequest) error { return handlers.ValidateUpdateRequest(req, req.GetItem(), func() map[string]string { badFields := map[string]string{} + if len(req.GetItem().GetPreferredEndpoints()) > 0 { + _, err := endpoint.NewPreferencer(ctx, endpoint.WithPreferenceOrder(req.GetItem().GetPreferredEndpoints())) + if err != nil { + badFields[globals.PreferredEndpointsField] = fmt.Errorf("Error parsing preferred endpoints: %w.", err).Error() + } + } switch host.SubtypeFromId(req.GetId()) { case static.Subtype: if req.GetItem().GetType() != "" && req.GetItem().GetType() != static.Subtype.String() { - badFields["type"] = "Cannot modify the resource type." + badFields[globals.TypeField] = "Cannot modify the resource type." } } return badFields - }, static.HostSetPrefix) + }, static.HostSetPrefix, plugin.HostSetPrefix) } func validateDeleteRequest(req *pbs.DeleteHostSetRequest) error { diff --git a/internal/servers/controller/handlers/host_sets/host_set_service_test.go b/internal/servers/controller/handlers/host_sets/host_set_service_test.go index 355e709c8..7722b5efe 100644 --- a/internal/servers/controller/handlers/host_sets/host_set_service_test.go +++ b/internal/servers/controller/handlers/host_sets/host_set_service_test.go @@ -11,11 +11,12 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/boundary/internal/db" pbs "github.com/hashicorp/boundary/internal/gen/controller/api/services" + "github.com/hashicorp/boundary/internal/host" "github.com/hashicorp/boundary/internal/host/plugin" "github.com/hashicorp/boundary/internal/host/static" "github.com/hashicorp/boundary/internal/iam" "github.com/hashicorp/boundary/internal/kms" - "github.com/hashicorp/boundary/internal/plugin/host" + hostplugin "github.com/hashicorp/boundary/internal/plugin/host" "github.com/hashicorp/boundary/internal/servers/controller/auth" "github.com/hashicorp/boundary/internal/servers/controller/handlers" "github.com/hashicorp/boundary/internal/servers/controller/handlers/host_sets" @@ -150,20 +151,22 @@ func TestGet_Plugin(t *testing.T) { } name := "test" - plg := host.TestPlugin(t, conn, name, name) + prefEndpoints := []string{"cidr:1.2.3.4", "cidr:2.3.4.5/24"} + plg := hostplugin.TestPlugin(t, conn, name, name) hc := plugin.TestCatalog(t, conn, proj.GetPublicId(), plg.GetPublicId()) - hs := plugin.TestSet(t, conn, hc.GetPublicId()) + hs := plugin.TestSet(t, conn, kms, hc, plugin.WithPreferredEndpoints(prefEndpoints)) toMerge := &pbs.GetHostSetRequest{} pHost := &pb.HostSet{ - HostCatalogId: hc.GetPublicId(), - Id: hs.GetPublicId(), - CreatedTime: hs.CreateTime.GetTimestamp(), - UpdatedTime: hs.UpdateTime.GetTimestamp(), - Type: name, - Scope: &scopes.ScopeInfo{Id: proj.GetPublicId(), Type: scope.Project.String(), ParentScopeId: org.GetPublicId()}, - AuthorizedActions: testAuthorizedActions, + HostCatalogId: hc.GetPublicId(), + Id: hs.GetPublicId(), + CreatedTime: hs.CreateTime.GetTimestamp(), + UpdatedTime: hs.UpdateTime.GetTimestamp(), + Type: name, + Scope: &scopes.ScopeInfo{Id: proj.GetPublicId(), Type: scope.Project.String(), ParentScopeId: org.GetPublicId()}, + PreferredEndpoints: prefEndpoints, + AuthorizedActions: testAuthorizedActions, } cases := []struct { @@ -179,7 +182,7 @@ func TestGet_Plugin(t *testing.T) { }, { name: "Get a non existing Host Set", - req: &pbs.GetHostSetRequest{Id: static.HostSetPrefix + "_DoesntExis"}, + req: &pbs.GetHostSetRequest{Id: plugin.HostSetPrefix + "_DoesntExis"}, res: nil, err: handlers.ApiErrorWithCode(codes.NotFound), }, @@ -191,7 +194,7 @@ func TestGet_Plugin(t *testing.T) { }, { name: "space in id", - req: &pbs.GetHostSetRequest{Id: static.HostPrefix + "_1 23456789"}, + req: &pbs.GetHostSetRequest{Id: plugin.HostSetPrefix + "_1 23456789"}, res: nil, err: handlers.ApiErrorWithCode(codes.InvalidArgument), }, @@ -339,22 +342,24 @@ func TestList_Plugin(t *testing.T) { return plugin.NewRepository(rw, rw, kms) } name := "test" - plg := host.TestPlugin(t, conn, name, name) + plg := hostplugin.TestPlugin(t, conn, name, name) hc := plugin.TestCatalog(t, conn, proj.GetPublicId(), plg.GetPublicId()) hcNoHosts := plugin.TestCatalog(t, conn, proj.GetPublicId(), plg.GetPublicId()) + preferredEndpoints := []string{"cidr:1.2.3.4", "dns:*.foobar.com"} var wantHs []*pb.HostSet for i := 0; i < 10; i++ { - h := plugin.TestSet(t, conn, hc.GetPublicId()) + h := plugin.TestSet(t, conn, kms, hc, plugin.WithPreferredEndpoints(preferredEndpoints)) wantHs = append(wantHs, &pb.HostSet{ - Id: h.GetPublicId(), - HostCatalogId: h.GetCatalogId(), - Scope: &scopes.ScopeInfo{Id: proj.GetPublicId(), Type: scope.Project.String(), ParentScopeId: org.GetPublicId()}, - CreatedTime: h.GetCreateTime().GetTimestamp(), - UpdatedTime: h.GetUpdateTime().GetTimestamp(), - Version: h.GetVersion(), - Type: name, - AuthorizedActions: testAuthorizedActions, + Id: h.GetPublicId(), + HostCatalogId: h.GetCatalogId(), + Scope: &scopes.ScopeInfo{Id: proj.GetPublicId(), Type: scope.Project.String(), ParentScopeId: org.GetPublicId()}, + CreatedTime: h.GetCreateTime().GetTimestamp(), + UpdatedTime: h.GetUpdateTime().GetTimestamp(), + Version: h.GetVersion(), + Type: name, + AuthorizedActions: testAuthorizedActions, + PreferredEndpoints: preferredEndpoints, }) } @@ -397,7 +402,7 @@ func TestList_Plugin(t *testing.T) { require.NoError(err, "Couldn't create new host set service.") // Test with non-anon user - got, gErr := s.ListHostSets(auth.DisabledAuthTestContext(iamRepoFn, proj.GetPublicId()), tc.req) + got, gErr := s.ListHostSetsWithOptions(auth.DisabledAuthTestContext(iamRepoFn, proj.GetPublicId()), tc.req, host.WithOrderByCreateTime(false)) if tc.err != nil { require.Error(gErr) assert.True(errors.Is(gErr, tc.err), "ListHostSets(%q) got error %v, wanted %v", tc.req, gErr, tc.err) @@ -699,7 +704,7 @@ func TestCreate_Plugin(t *testing.T) { return plugin.NewRepository(rw, rw, kms) } name := "test" - plg := host.TestPlugin(t, conn, name, name) + plg := hostplugin.TestPlugin(t, conn, name, name) hc := plugin.TestCatalog(t, conn, proj.GetPublicId(), plg.GetPublicId()) testAttrs, err := structpb.NewStruct(map[string]interface{}{ @@ -724,6 +729,8 @@ func TestCreate_Plugin(t *testing.T) { }) require.NoError(t, err) + prefEndpoints := []string{"cidr:1.2.3.4", "cidr:2.3.4.5/24"} + defaultHcCreated := hc.GetCreateTime().GetTimestamp().AsTime() cases := []struct { @@ -774,6 +781,39 @@ func TestCreate_Plugin(t *testing.T) { }, }, }, + { + name: "With Preferred Endpoints", + req: &pbs.CreateHostSetRequest{Item: &pb.HostSet{ + HostCatalogId: hc.GetPublicId(), + Name: &wrappers.StringValue{Value: "name"}, + Description: &wrappers.StringValue{Value: "desc"}, + Type: name, + PreferredEndpoints: prefEndpoints, + }}, + res: &pbs.CreateHostSetResponse{ + Uri: fmt.Sprintf("host-sets/%s_%s_", plugin.HostSetPrefix, name), + Item: &pb.HostSet{ + HostCatalogId: hc.GetPublicId(), + Scope: &scopes.ScopeInfo{Id: proj.GetPublicId(), Type: scope.Project.String(), ParentScopeId: org.GetPublicId()}, + Name: &wrappers.StringValue{Value: "name"}, + Description: &wrappers.StringValue{Value: "desc"}, + Type: name, + PreferredEndpoints: prefEndpoints, + AuthorizedActions: testAuthorizedActions, + }, + }, + }, + { + name: "Bad Preferred Endpoints", + req: &pbs.CreateHostSetRequest{Item: &pb.HostSet{ + HostCatalogId: hc.GetPublicId(), + Name: &wrappers.StringValue{Value: "name"}, + Description: &wrappers.StringValue{Value: "desc"}, + Type: name, + PreferredEndpoints: append(prefEndpoints, "foobar:1.2.3.4"), + }}, + err: handlers.ApiErrorWithCode(codes.InvalidArgument), + }, { name: "Create with mismatched type/name", req: &pbs.CreateHostSetRequest{Item: &pb.HostSet{ diff --git a/internal/servers/controller/handlers/targets/target_service.go b/internal/servers/controller/handlers/targets/target_service.go index db043e18f..d28371373 100644 --- a/internal/servers/controller/handlers/targets/target_service.go +++ b/internal/servers/controller/handlers/targets/target_service.go @@ -1731,7 +1731,7 @@ func validateSetSetsRequest(req *pbs.SetTargetHostSetsRequest) error { badFields[globals.VersionField] = "Required field." } for _, id := range req.GetHostSetIds() { - if !handlers.ValidId(handlers.Id(id), static.HostSetPrefix) && + if !handlers.ValidId(handlers.Id(id), static.HostSetPrefix) && !strings.HasPrefix(id, fmt.Sprintf("%s_", plugin.HostSetPrefix)) { badFields[globals.HostSetIdsField] = fmt.Sprintf("Incorrectly formatted host set identifier %q.", id) break @@ -1755,7 +1755,7 @@ func validateRemoveSetsRequest(req *pbs.RemoveTargetHostSetsRequest) error { badFields[globals.HostSetIdsField] = "Must be non-empty." } for _, id := range req.GetHostSetIds() { - if !handlers.ValidId(handlers.Id(id), static.HostSetPrefix) && + if !handlers.ValidId(handlers.Id(id), static.HostSetPrefix) && !strings.HasPrefix(id, fmt.Sprintf("%s_", plugin.HostSetPrefix)) { badFields[globals.HostSetIdsField] = fmt.Sprintf("Incorrectly formatted host set identifier %q.", id) break @@ -1779,7 +1779,7 @@ func validateAddHostSourcesRequest(req *pbs.AddTargetHostSourcesRequest) error { badFields[globals.HostSourceIdsField] = "Must be non-empty." } for _, id := range req.GetHostSourceIds() { - if !handlers.ValidId(handlers.Id(id), static.HostSetPrefix) && + if !handlers.ValidId(handlers.Id(id), static.HostSetPrefix) && !strings.HasPrefix(id, fmt.Sprintf("%s_", plugin.HostSetPrefix)) { badFields[globals.HostSourceIdsField] = fmt.Sprintf("Incorrectly formatted host source identifier %q.", id) break @@ -1800,7 +1800,7 @@ func validateSetHostSourcesRequest(req *pbs.SetTargetHostSourcesRequest) error { badFields[globals.VersionField] = "Required field." } for _, id := range req.GetHostSourceIds() { - if !handlers.ValidId(handlers.Id(id), static.HostSetPrefix) && + if !handlers.ValidId(handlers.Id(id), static.HostSetPrefix) && !strings.HasPrefix(id, fmt.Sprintf("%s_", plugin.HostSetPrefix)) { badFields[globals.HostSourceIdsField] = fmt.Sprintf("Incorrectly formatted host source identifier %q.", id) break @@ -1824,7 +1824,7 @@ func validateRemoveHostSourcesRequest(req *pbs.RemoveTargetHostSourcesRequest) e badFields[globals.HostSourceIdsField] = "Must be non-empty." } for _, id := range req.GetHostSourceIds() { - if !handlers.ValidId(handlers.Id(id), static.HostSetPrefix) && + if !handlers.ValidId(handlers.Id(id), static.HostSetPrefix) && !strings.HasPrefix(id, fmt.Sprintf("%s_", plugin.HostSetPrefix)) { badFields[globals.HostSourceIdsField] = fmt.Sprintf("Incorrectly formatted host source identifier %q.", id) break diff --git a/internal/servers/controller/handlers/targets/target_service_test.go b/internal/servers/controller/handlers/targets/target_service_test.go index b776a69b0..ac7d36e02 100644 --- a/internal/servers/controller/handlers/targets/target_service_test.go +++ b/internal/servers/controller/handlers/targets/target_service_test.go @@ -983,7 +983,7 @@ func TestAddTargetHostSets(t *testing.T) { plg := host.TestPlugin(t, conn, "test", "test") pluginHc := plugin.TestCatalog(t, conn, proj.GetPublicId(), plg.GetPublicId()) - pluginHs := plugin.TestSet(t, conn, pluginHc.GetPublicId()) + pluginHs := plugin.TestSet(t, conn, kms, pluginHc) addCases := []struct { name string @@ -1115,7 +1115,7 @@ func TestSetTargetHostSets(t *testing.T) { plg := host.TestPlugin(t, conn, "test", "test") pluginHc := plugin.TestCatalog(t, conn, proj.GetPublicId(), plg.GetPublicId()) - pluginHs := plugin.TestSet(t, conn, pluginHc.GetPublicId()) + pluginHs := plugin.TestSet(t, conn, kms, pluginHc) setCases := []struct { name string @@ -1241,7 +1241,7 @@ func TestRemoveTargetHostSets(t *testing.T) { plg := host.TestPlugin(t, conn, "test", "test") pluginHc := plugin.TestCatalog(t, conn, proj.GetPublicId(), plg.GetPublicId()) - pluginHs := plugin.TestSet(t, conn, pluginHc.GetPublicId()) + pluginHs := plugin.TestSet(t, conn, kms, pluginHc) removeCases := []struct { name string @@ -1379,7 +1379,7 @@ func TestAddTargetHostSources(t *testing.T) { plg := host.TestPlugin(t, conn, "test", "test") pluginHc := plugin.TestCatalog(t, conn, proj.GetPublicId(), plg.GetPublicId()) - pluginHs := plugin.TestSet(t, conn, pluginHc.GetPublicId()) + pluginHs := plugin.TestSet(t, conn, kms, pluginHc) addCases := []struct { name string @@ -1511,7 +1511,7 @@ func TestSetTargetHostSources(t *testing.T) { plg := host.TestPlugin(t, conn, "test", "test") pluginHc := plugin.TestCatalog(t, conn, proj.GetPublicId(), plg.GetPublicId()) - pluginHs := plugin.TestSet(t, conn, pluginHc.GetPublicId()) + pluginHs := plugin.TestSet(t, conn, kms, pluginHc) setCases := []struct { name string @@ -1631,7 +1631,7 @@ func TestRemoveTargetHostSources(t *testing.T) { plg := host.TestPlugin(t, conn, "test", "test") pluginHc := plugin.TestCatalog(t, conn, proj.GetPublicId(), plg.GetPublicId()) - pluginHs := plugin.TestSet(t, conn, pluginHc.GetPublicId()) + pluginHs := plugin.TestSet(t, conn, kms, pluginHc) removeCases := []struct { name string 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 04b03c2b1..153dbdf1f 100644 --- a/sdk/pbs/controller/api/resources/hostsets/host_set.pb.go +++ b/sdk/pbs/controller/api/resources/hostsets/host_set.pb.go @@ -52,6 +52,9 @@ type HostSet struct { Type string `protobuf:"bytes,90,opt,name=type,proto3" json:"type,omitempty"` // Output only. A list of Hosts in this Host Set. HostIds []string `protobuf:"bytes,100,rep,name=host_ids,proto3" json:"host_ids,omitempty"` + // An ordered list of endpoint preferences used to choose from among + // multiple possible endpoints for a host. + PreferredEndpoints []string `protobuf:"bytes,101,rep,name=preferred_endpoints,proto3" json:"preferred_endpoints,omitempty"` // The attributes that are applicable for the specific Host Set type. Attributes *structpb.Struct `protobuf:"bytes,110,opt,name=attributes,proto3" json:"attributes,omitempty"` // Output only. The available actions on this resource for this user. @@ -160,6 +163,13 @@ func (x *HostSet) GetHostIds() []string { return nil } +func (x *HostSet) GetPreferredEndpoints() []string { + if x != nil { + return x.PreferredEndpoints + } + return nil +} + func (x *HostSet) GetAttributes() *structpb.Struct { if x != nil { return x.Attributes @@ -194,7 +204,7 @@ var file_controller_api_resources_hostsets_v1_host_set_proto_rawDesc = []byte{ 0x63, 0x6f, 0x70, 0x65, 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, 0xe8, 0x04, 0x0a, 0x07, 0x48, 0x6f, 0x73, 0x74, 0x53, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xa0, 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, 0x6f, 0x73, @@ -226,19 +236,23 @@ var file_controller_api_resources_hostsets_v1_host_set_proto_rawDesc = []byte{ 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x5a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x64, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x68, - 0x6f, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x73, 0x12, 0x37, 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, + 0x6f, 0x73, 0x74, 0x5f, 0x69, 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, 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, } var (