From e4099a5f64f913918fe6a16729be6c6d28241e2c Mon Sep 17 00:00:00 2001 From: Elim Tsiagbey Date: Fri, 18 Oct 2024 10:35:05 -0400 Subject: [PATCH] feat: onCreateSet Add support for onCreateSet with validation. - If a filter is not provided or a status is not set, a default filter of `status = running` is used. - update `filters` field to be a slice instead of a string - add dryRunValidation call to validate fields --- plugin/service/host/attributes.go | 17 +- plugin/service/host/attributes_test.go | 2 +- plugin/service/host/const.go | 2 +- plugin/service/host/doc.go | 33 +++ plugin/service/host/plugin.go | 145 +++++++++++- plugin/service/host/plugin_test.go | 311 +++++++++++++++++++++---- 6 files changed, 451 insertions(+), 59 deletions(-) create mode 100644 plugin/service/host/doc.go diff --git a/plugin/service/host/attributes.go b/plugin/service/host/attributes.go index 4a7a485..12b443b 100644 --- a/plugin/service/host/attributes.go +++ b/plugin/service/host/attributes.go @@ -5,6 +5,7 @@ package host import ( "fmt" + "strings" computepb "cloud.google.com/go/compute/apiv1/computepb" cred "github.com/hashicorp/boundary-plugin-gcp/internal/credential" @@ -60,8 +61,8 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) { // SetAttributes defines attributes fro the host set type SetAttributes struct { - Filter string `mapstructure:"filter"` - InstanceGroup string `mapstructure:"instance_group"` + Filters []string `mapstructure:"filters"` + InstanceGroup string `mapstructure:"instance_group"` } func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) { @@ -93,7 +94,7 @@ func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) { if filtersRaw, ok := inMap[ConstListInstancesFilter]; ok { switch filterVal := filtersRaw.(type) { case string: - inMap[ConstListInstancesFilter] = string(filterVal) + inMap[ConstListInstancesFilter] = []string{filterVal} } } @@ -117,8 +118,9 @@ func buildListInstancesRequest(attributes *SetAttributes, catalog *CatalogAttrib Zone: catalog.Zone, } - if len(attributes.Filter) > 1 { - request.Filter = &attributes.Filter + if len(attributes.Filters) > 1 { + filters := strings.Join(attributes.Filters, " ") + request.Filter = &filters } return request @@ -131,8 +133,9 @@ func buildListInstanceGroupsRequest(attributes *SetAttributes, catalog *CatalogA Zone: catalog.Zone, } - if len(attributes.Filter) > 1 { - request.Filter = &attributes.Filter + if len(attributes.Filters) > 1 { + filters := strings.Join(attributes.Filters, " ") + request.Filter = &filters } return request diff --git a/plugin/service/host/attributes_test.go b/plugin/service/host/attributes_test.go index c6f7fda..898366f 100644 --- a/plugin/service/host/attributes_test.go +++ b/plugin/service/host/attributes_test.go @@ -92,7 +92,7 @@ func TestGetSetAttributes(t *testing.T) { ConstListInstancesFilter: "tags.items=my-tag AND -tags.items=my-other-tag) OR tags.items=alternative-tag", }, expected: &SetAttributes{ - Filter: "tags.items=my-tag AND -tags.items=my-other-tag) OR tags.items=alternative-tag", + Filters: []string{"tags.items=my-tag AND -tags.items=my-other-tag) OR tags.items=alternative-tag"}, }, }, { diff --git a/plugin/service/host/const.go b/plugin/service/host/const.go index 161c92a..96270e1 100644 --- a/plugin/service/host/const.go +++ b/plugin/service/host/const.go @@ -5,7 +5,7 @@ package host const ( // ConstListInstancesFilter refers to a Google Cloud SDK filter to search for instances - ConstListInstancesFilter = "filter" + ConstListInstancesFilter = "filters" // ConstInstanceGroup refers to the name of a Google Cloud instance group ConstInstanceGroup = "instance_group" diff --git a/plugin/service/host/doc.go b/plugin/service/host/doc.go new file mode 100644 index 0000000..d18d895 --- /dev/null +++ b/plugin/service/host/doc.go @@ -0,0 +1,33 @@ +package host + +// Package host provides a plugin for Boundary that retrieves host information from (GCP) Google Cloud Platform. +// +// A host catalog is a resource that contains hosts and host sets. +// +// A host is a resource that represents a computing element with a network address reachable from Boundary. +// A host belongs to a host catalog. Hosts can only be associated with host sets from the same host catalog as the host. +// +// A host set is a resource that represents a collection of hosts which are considered equivalent for the purposes of access control. +// +// The plugin supports authentication using GCP service account keys, impersonation of service accounts +// and Application Default Credentials. +// +// Host plugin supports credential rotation for GCP service account keys and impersonation of service accounts (only for base account) +// when the `disable_credential_rotation` attribute is set to false. +// +// The plugin implements Boundary's HostPluginService interface: +// https://github.com/hashicorp/boundary-enterprise/blob/cb9ce07d6907cbda6ce82e82627de372f18ba7e1/internal/proto/plugin/v1/host_plugin_service.proto#L15. +// +// # Filters +// Host Set filters are used to narrow down the list of hosts returned by the plugin. +// The filter string is expected to be in the format "key operator value". +// The operator is expected to be one of =, !=, >, <, <=, >=, :, eq, ne. +// as per GCP API documentation: +// https://cloud.google.com/compute/docs/reference/rest/v1/instances/list#filter +// +// If there is no explicit instance status filter, the default status will be set to +// `status = "running"`. This ensures that we filter on running instances only, +// saving time when processing results. +// +// Host Set filters are represented as a list of strings. When multiple filters are provided, +// the plugin will apply a logical `AND` operation to the filters. diff --git a/plugin/service/host/plugin.go b/plugin/service/host/plugin.go index a4a4821..bf91f56 100644 --- a/plugin/service/host/plugin.go +++ b/plugin/service/host/plugin.go @@ -6,6 +6,8 @@ package host import ( "context" "fmt" + "regexp" + "strings" compute "cloud.google.com/go/compute/apiv1" computepb "cloud.google.com/go/compute/apiv1/computepb" @@ -36,6 +38,13 @@ var ( _ pb.HostPluginServiceServer = (*HostPlugin)(nil) ) +const ( + // defaultStatusFilter is the default filter to apply to instances + // if no status filter is provided. + // This is used to filter on running instances only. + defaultStatusFilter = "status = running" +) + // OnCreateCatalog is called when a dynamic host catalog is created. func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalogRequest) (*pb.OnCreateCatalogResponse, error) { catalog := req.GetCatalog() @@ -87,7 +96,7 @@ func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalo return nil, status.Errorf(codes.Internal, "error setting up persisted state: %s", err) } - if st := dryRunValidation(ctx, catalogState, p.testGCPClientOpts...); st != nil { + if st := dryRunValidation(ctx, catalogState, nil, p.testGCPClientOpts...); st != nil { return nil, st.Err() } @@ -158,7 +167,7 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo if err != nil { return nil, status.Errorf(codes.Internal, "error setting up persisted state: %s", err) } - if st := dryRunValidation(ctx, newCatalogState); st != nil { + if st := dryRunValidation(ctx, newCatalogState, nil, p.testGCPClientOpts...); st != nil { return nil, st.Err() } @@ -210,7 +219,7 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo } // perform dry run to ensure we can interact with GCP as expected. - if st := dryRunValidation(ctx, catalogState, p.testGCPClientOpts...); st != nil { + if st := dryRunValidation(ctx, catalogState, nil, p.testGCPClientOpts...); st != nil { return nil, st.Err() } @@ -309,10 +318,64 @@ func (p *HostPlugin) NormalizeSetData(ctx context.Context, req *pb.NormalizeSetD } // OnCreateSet is called when a dynamic host set is created. -func (p *HostPlugin) OnCreateSet(_ context.Context, req *pb.OnCreateSetRequest) (*pb.OnCreateSetResponse, error) { +func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest) (*pb.OnCreateSetResponse, error) { + catalog := req.GetCatalog() + if catalog == nil { + return nil, status.Error(codes.InvalidArgument, "catalog is required") + } + + attrs := catalog.GetAttributes() + if attrs == nil { + return nil, status.Error(codes.InvalidArgument, "catalog attributes are required") + } + + catalogAttributes, err := getCatalogAttributes(attrs) + if err != nil { + return nil, err + } + + credState, err := credential.PersistedStateFromProto( + req.GetPersisted().GetSecrets(), + catalogAttributes.CredentialAttributes) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error getting persisted state from proto: %s", err) + } + + catalogState, err := newGCPCatalogPersistedState( + append([]gcpCatalogPersistedStateOption{ + withCredentials(credState), + }, p.testCatalogStateOpts...)..., + ) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) + } + + set := req.GetSet() + if set == nil { + return nil, status.Error(codes.InvalidArgument, "set is required") + } if err := validateSet(req.GetSet()); err != nil { return nil, err } + + if set.GetAttributes() == nil { + return nil, status.Error(codes.InvalidArgument, "set attributes are required") + } + setAttrs, err := getSetAttributes(set.GetAttributes()) + if err != nil { + return nil, err + } + + listInstanceFilters, err := buildFilters(setAttrs) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error building filters: %s", err) + } + + // perform dry run to ensure we can interact with GCP as expected. + if st := dryRunValidation(ctx, catalogState, listInstanceFilters, p.testGCPClientOpts...); st != nil { + return nil, st.Err() + } + return &pb.OnCreateSetResponse{}, nil } @@ -485,14 +548,14 @@ func validateSet(s *hostsets.HostSet) error { if instanceGroupSet && filterSet { badFields["attributes"] = "must set instance group or filter, cannot set both" } else if instanceGroupSet && len(attrs.InstanceGroup) == 0 { - badFields[fmt.Sprintf("attributes.%s", ConstInstanceGroup)] = "must not be empty." - } else if filterSet && len(attrs.Filter) == 0 { - badFields[fmt.Sprintf("attributes.%s", ConstListInstancesFilter)] = "must not be empty." + badFields[fmt.Sprintf("attributes.%s", ConstInstanceGroup)] = "must not be empty" + } else if filterSet && len(attrs.Filters) == 0 { + badFields[fmt.Sprintf("attributes.%s", ConstListInstancesFilter)] = "must not be empty" } for f := range attrMap { if _, ok := allowedSetFields[f]; !ok { - badFields[fmt.Sprintf("attributes.%s", f)] = "Unrecognized field." + badFields[fmt.Sprintf("attributes.%s", f)] = "unrecognized field" } } @@ -507,6 +570,7 @@ func validateSet(s *hostsets.HostSet) error { func dryRunValidation( ctx context.Context, state *gcpCatalogPersistedState, + listFilters []string, clientOptions ...option.ClientOption, ) *status.Status { if state == nil { @@ -518,9 +582,15 @@ func dryRunValidation( return status.New(codes.InvalidArgument, fmt.Sprintf("error getting instances client: %s", err)) } + var filters string + if len(listFilters) > 0 { + filters = strings.Join(listFilters, " ") + } + it := instancesClient.List(ctx, &computepb.ListInstancesRequest{ Project: state.CredentialsConfig.ProjectId, Zone: state.CredentialsConfig.Zone, + Filter: &filters, }) _, err = it.Next() @@ -530,3 +600,62 @@ func dryRunValidation( return nil } + +// buildFilters constructs a list of filters to be used in a ListInstancesRequest. +// If no filters are provided, a default filter is added to filter on running instances. +func buildFilters(attrs *SetAttributes) ([]string, error) { + var filters []string + var foundStateFilter bool + // If filters are provided, validate them + if len(attrs.Filters) != 0 { + for _, filterAttr := range attrs.Filters { + key, operator, value, valid := extractFilterValue(filterAttr) + switch { + case !valid: + return nil, fmt.Errorf("filter %q contains invalid operator: %s. Use one of =, !=, >, <, <=, >=, :, eq, ne", filterAttr, operator) + case len(strings.TrimSpace(key)) == 0: + return nil, fmt.Errorf("filter %q contains an empty filter key", filterAttr) + case len(strings.TrimSpace(value)) == 0: + return nil, fmt.Errorf("filter %q contains an empty value", filterAttr) + } + + if key == "status" { + foundStateFilter = true + } + + filters = append(filters, filterAttr) + } + } + + if !foundStateFilter { + // if there is no explicit instance state filter, add the + // status = "running" to the filter set. This + // ensures that we filter on running instances only at the API + // side, saving time when processing results. + filters = append(filters, defaultStatusFilter) + } + + return filters, nil +} + +// extractFilterValue extracts the key and value from a filter string. +// The filter string is expected to be in the format "key operator value". +// The key and value are returned as strings, along with a boolean indicating +// whether the extraction was successful. +// The operator is expected to be one of =, !=, >, <, <=, >=, :, eq, ne. +// as per GCP API documentation: +// https://cloud.google.com/compute/docs/reference/rest/v1/instances/list#filter +func extractFilterValue(s string) (string, string, string, bool) { + re := regexp.MustCompile(`(?i)([^=>|<|<=|>=|:|eq|ne)\s*([^,]+)`) + + matches := re.FindStringSubmatch(s) + + // Check if we have the expected number of matches + if len(matches) == 4 { + key := matches[1] + operator := matches[2] + value := matches[3] + return key, operator, value, true + } + return "", "", "", false +} diff --git a/plugin/service/host/plugin_test.go b/plugin/service/host/plugin_test.go index 74e973e..95876cd 100644 --- a/plugin/service/host/plugin_test.go +++ b/plugin/service/host/plugin_test.go @@ -6,6 +6,7 @@ package host import ( "context" "fmt" + "net/http" "os" "path/filepath" "testing" @@ -949,17 +950,41 @@ func TestDeleteCatalog(t *testing.T) { func TestCreateSet(t *testing.T) { ctx := context.Background() - p := &HostPlugin{} cases := []struct { - name string - req *pb.OnCreateSetRequest - expectedErr string + name string + req *pb.OnCreateSetRequest + catalogOpts []gcpCatalogPersistedStateOption + gcpOptions []option.ClientOption + expectedErr string + expectedErrCode codes.Code }{ { - name: "nil set", - req: &pb.OnCreateSetRequest{}, - expectedErr: "set is nil", + name: "nil host catalog", + req: &pb.OnCreateSetRequest{}, + expectedErr: "catalog is required", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "nil host catalog attributes", + req: &pb.OnCreateSetRequest{ + Catalog: &hostcatalogs.HostCatalog{}, + }, + expectedErr: "catalog attributes are required", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "error reading catalog attributes", + req: &pb.OnCreateSetRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Secrets: new(structpb.Struct), + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: new(structpb.Struct), + }, + }, + }, + expectedErr: "missing required value \"project_id\"", + expectedErrCode: codes.InvalidArgument, }, { name: "nil set", @@ -975,80 +1000,270 @@ func TestCreateSet(t *testing.T) { }, }, }, - expectedErr: "set is nil", + expectedErr: "set is required", + expectedErrCode: codes.InvalidArgument, }, { - name: "only allow instance group or filter, not both", + name: "persisted state setup error", req: &pb.OnCreateSetRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstProjectId: structpb.NewStringValue("test-project"), + cred.ConstZone: structpb.NewStringValue("us-central1-a"), + }, + }, + }, + }, + Persisted: &pb.HostCatalogPersisted{ + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"), + cred.ConstPrivateKey: structpb.NewStringValue("test-private-key"), + cred.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, + }, + }, + catalogOpts: []gcpCatalogPersistedStateOption{ + func(s *gcpCatalogPersistedState) error { + return fmt.Errorf("error loading persisted state") + }, + }, + expectedErr: "error loading persisted state", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "nil attributes in set", + req: &pb.OnCreateSetRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstProjectId: structpb.NewStringValue("test-project"), + cred.ConstZone: structpb.NewStringValue("us-central1-a"), + }, + }, + }, + }, + Persisted: &pb.HostCatalogPersisted{ + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"), + cred.ConstPrivateKey: structpb.NewStringValue("test-private-key"), + cred.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, + }, + Set: &hostsets.HostSet{}, + }, + expectedErr: "set attributes are required", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "set attribute load error", + req: &pb.OnCreateSetRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstProjectId: structpb.NewStringValue("test-project"), + cred.ConstZone: structpb.NewStringValue("us-central1-a"), + }, + }, + }, + }, + Persisted: &pb.HostCatalogPersisted{ + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"), + cred.ConstPrivateKey: structpb.NewStringValue("test-private-key"), + cred.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, + }, Set: &hostsets.HostSet{ Attrs: &hostsets.HostSet_Attributes{ Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ - ConstListInstancesFilter: structpb.NewStringValue("status=RUNNING"), - ConstInstanceGroup: structpb.NewStringValue("test"), + "foo": structpb.NewBoolValue(true), + "bar": structpb.NewBoolValue(true), }, }, }, }, }, - expectedErr: "attributes: must set instance group or filter", + expectedErr: "attributes.bar: unrecognized field, attributes.foo: unrecognized field", + expectedErrCode: codes.InvalidArgument, }, { - name: "empty filter", + name: "invalid filter", req: &pb.OnCreateSetRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstProjectId: structpb.NewStringValue("test-project"), + cred.ConstZone: structpb.NewStringValue("us-central1-a"), + }, + }, + }, + }, Set: &hostsets.HostSet{ Attrs: &hostsets.HostSet_Attributes{ Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ - ConstListInstancesFilter: structpb.NewStringValue(""), + ConstListInstancesFilter: structpb.NewListValue( + &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue("invalid"), + }, + }, + ), }, }, }, }, }, - expectedErr: "attributes.filter: must not be empty", + expectedErr: "error building filters", + expectedErrCode: codes.InvalidArgument, }, { - name: "empty instance group", + name: "client load error", req: &pb.OnCreateSetRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstProjectId: structpb.NewStringValue("test-project"), + cred.ConstZone: structpb.NewStringValue("us-central1-a"), + }, + }, + }, + }, + Persisted: &pb.HostCatalogPersisted{ + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"), + cred.ConstPrivateKey: structpb.NewStringValue("test-private-key"), + cred.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, + }, Set: &hostsets.HostSet{ Attrs: &hostsets.HostSet_Attributes{ Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ - ConstInstanceGroup: structpb.NewStringValue(""), + ConstListInstancesFilter: structpb.NewListValue( + &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue("tag-key=foo"), + }, + }, + ), }, }, }, }, }, - expectedErr: "attributes.instance_group: must not be empty", + gcpOptions: []option.ClientOption{ + option.WithHTTPClient(&http.Client{}), + }, + expectedErr: "error getting instances client", + expectedErrCode: codes.InvalidArgument, }, { - name: "good filter", + name: "List instances error", req: &pb.OnCreateSetRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstProjectId: structpb.NewStringValue("test-project"), + cred.ConstZone: structpb.NewStringValue("us-central1-a"), + }, + }, + }, + }, + Persisted: &pb.HostCatalogPersisted{ + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"), + cred.ConstPrivateKey: structpb.NewStringValue("test-private-key"), + cred.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, + }, Set: &hostsets.HostSet{ Attrs: &hostsets.HostSet_Attributes{ Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ - ConstInstanceGroup: structpb.NewStringValue("status=RUNNING"), + ConstListInstancesFilter: structpb.NewListValue( + &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue("tag-key=foo"), + }, + }, + ), }, }, }, }, }, + catalogOpts: []gcpCatalogPersistedStateOption{ + withTestInstancesAPIFunc(newTestMockInstances(ctx, + nil, + testMockInstancesWithListInstancesError(fmt.Errorf("failed to list instances")), + )), + }, + expectedErr: "gcp list instances failed", + expectedErrCode: codes.FailedPrecondition, }, { - name: "good instance group", + name: "success", req: &pb.OnCreateSetRequest{ - Set: &hostsets.HostSet{ - Attrs: &hostsets.HostSet_Attributes{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ - ConstInstanceGroup: structpb.NewStringValue("test"), + cred.ConstProjectId: structpb.NewStringValue("test-project"), + cred.ConstZone: structpb.NewStringValue("us-central1-a"), }, }, }, }, + Persisted: &pb.HostCatalogPersisted{ + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"), + cred.ConstPrivateKey: structpb.NewStringValue("test-private-key"), + cred.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, + }, + Set: &hostsets.HostSet{ + Attrs: &hostsets.HostSet_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + ConstListInstancesFilter: structpb.NewListValue( + &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue("tag-key=foo"), + }, + }, + ), + }, + }, + }, + }, + }, + catalogOpts: []gcpCatalogPersistedStateOption{ + withTestInstancesAPIFunc(newTestMockInstances(ctx, + nil, + testMockInstancesWithListInstancesOutput(&computepb.InstanceList{}), + testMockInstancesWithListInstancesError(nil)), + ), }, }, } @@ -1058,9 +1273,30 @@ func TestCreateSet(t *testing.T) { t.Run(tc.name, func(t *testing.T) { require := require.New(t) - _, err := p.OnCreateSet(ctx, tc.req) + testIAMAdminServer := cred.NewTestIAMAdminServer(nil, nil) + + gsrv := cred.NewGRPCServer() + adminpb.RegisterIAMServer(gsrv.Server, testIAMAdminServer) + addr, err := gsrv.Start() + require.NoError(err) + + clientOptions := []option.ClientOption{ + option.WithEndpoint(addr), + option.WithoutAuthentication(), + option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())), + option.WithTokenSource(nil), + } + + clientOptions = append(clientOptions, tc.gcpOptions...) + + p := &HostPlugin{ + testGCPClientOpts: clientOptions, + testCatalogStateOpts: tc.catalogOpts, + } + _, err = p.OnCreateSet(ctx, tc.req) if tc.expectedErr != "" { require.Contains(err.Error(), tc.expectedErr) + require.Equal(status.Code(err).String(), tc.expectedErrCode.String()) return } @@ -1106,8 +1342,14 @@ func TestUpdateSet(t *testing.T) { Attrs: &hostsets.HostSet_Attributes{ Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ - ConstListInstancesFilter: structpb.NewStringValue("status=RUNNING"), - ConstInstanceGroup: structpb.NewStringValue("test"), + ConstListInstancesFilter: structpb.NewListValue( + &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue("status=RUNNING"), + }, + }, + ), + ConstInstanceGroup: structpb.NewStringValue("test"), }, }, }, @@ -1115,21 +1357,6 @@ func TestUpdateSet(t *testing.T) { }, expectedErr: "attributes: must set instance group or filter", }, - { - name: "empty filter", - req: &pb.OnUpdateSetRequest{ - NewSet: &hostsets.HostSet{ - Attrs: &hostsets.HostSet_Attributes{ - Attributes: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - ConstListInstancesFilter: structpb.NewStringValue(""), - }, - }, - }, - }, - }, - expectedErr: "attributes.filter: must not be empty", - }, { name: "empty instance group", req: &pb.OnUpdateSetRequest{