diff --git a/plugin/service/host/attributes.go b/plugin/service/host/attributes.go index 12b443b..e3160a7 100644 --- a/plugin/service/host/attributes.go +++ b/plugin/service/host/attributes.go @@ -61,8 +61,7 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) { // SetAttributes defines attributes fro the host set type SetAttributes struct { - Filters []string `mapstructure:"filters"` - InstanceGroup string `mapstructure:"instance_group"` + Filters []string `mapstructure:"filters"` } func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) { @@ -78,7 +77,6 @@ func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) { } delete(unknownFields, ConstListInstancesFilter) - delete(unknownFields, ConstInstanceGroup) for a := range unknownFields { badFields[fmt.Sprintf("attributes.%s", a)] = "unrecognized field" @@ -98,13 +96,6 @@ func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) { } } - if instanceGroupRaw, ok := inMap[ConstInstanceGroup]; ok { - switch instanceGroupValue := instanceGroupRaw.(type) { - case string: - inMap[ConstInstanceGroup] = string(instanceGroupValue) - } - } - if err := mapstructure.Decode(inMap, &setAttrs); err != nil { return nil, status.Errorf(codes.InvalidArgument, "error decoding set attributes: %s", err) } @@ -112,31 +103,21 @@ func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) { return &setAttrs, nil } -func buildListInstancesRequest(attributes *SetAttributes, catalog *CatalogAttributes) *computepb.ListInstancesRequest { +func buildListInstancesRequest(attributes *SetAttributes, catalog *CatalogAttributes) (*computepb.ListInstancesRequest, error) { request := &computepb.ListInstancesRequest{ Project: catalog.ProjectId, Zone: catalog.Zone, } - if len(attributes.Filters) > 1 { - filters := strings.Join(attributes.Filters, " ") + filters, err := buildFilters(attributes) + if err != nil { + return nil, fmt.Errorf("error building filters: %w", err) + } + + if len(filters) > 1 { + filters := strings.Join(filters, " AND ") request.Filter = &filters } - return request -} - -func buildListInstanceGroupsRequest(attributes *SetAttributes, catalog *CatalogAttributes) *computepb.ListInstancesInstanceGroupsRequest { - request := &computepb.ListInstancesInstanceGroupsRequest{ - InstanceGroup: attributes.InstanceGroup, - Project: catalog.ProjectId, - Zone: catalog.Zone, - } - - if len(attributes.Filters) > 1 { - filters := strings.Join(attributes.Filters, " ") - request.Filter = &filters - } - - return request + return request, nil } diff --git a/plugin/service/host/attributes_test.go b/plugin/service/host/attributes_test.go index 898366f..fec58c3 100644 --- a/plugin/service/host/attributes_test.go +++ b/plugin/service/host/attributes_test.go @@ -95,15 +95,6 @@ func TestGetSetAttributes(t *testing.T) { Filters: []string{"tags.items=my-tag AND -tags.items=my-other-tag) OR tags.items=alternative-tag"}, }, }, - { - name: "valid example instance group", - in: map[string]any{ - ConstInstanceGroup: "test", - }, - expected: &SetAttributes{ - InstanceGroup: "test", - }, - }, { name: "unknown fields", in: map[string]any{ diff --git a/plugin/service/host/client.go b/plugin/service/host/client.go index 74f198f..22cb75d 100644 --- a/plugin/service/host/client.go +++ b/plugin/service/host/client.go @@ -6,9 +6,8 @@ package host import ( "context" "errors" - "path" + "strconv" - compute "cloud.google.com/go/compute/apiv1" computepb "cloud.google.com/go/compute/apiv1/computepb" pb "github.com/hashicorp/boundary/sdk/pbs/plugin" "google.golang.org/api/iterator" @@ -16,66 +15,25 @@ import ( "google.golang.org/grpc/status" ) -const ( - NumberMaxResults = uint32(100) -) - -// GoogleClient contains clients and attributes for authenticating to Google Cloud -// and finding instances -type GoogleClient struct { - InstancesClient *compute.InstancesClient - InstanceGroupClient *compute.InstanceGroupsClient - Context context.Context - Project string - Zone string -} - -func (c *GoogleClient) getInstances(request *computepb.ListInstancesRequest) ([]*computepb.Instance, error) { +func getInstances(ctx context.Context, instancesClient InstancesAPI, request *computepb.ListInstancesRequest) ([]*computepb.Instance, error) { hosts := []*computepb.Instance{} - it := c.InstancesClient.List(c.Context, request) + it := instancesClient.List(ctx, request) for { resp, err := it.Next() if err == iterator.Done { break } if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error listing instances: %s", err) + return nil, status.Errorf(codes.Unknown, "error listing instances: %s", err) } hosts = append(hosts, resp) } return hosts, nil } -func (c *GoogleClient) getInstancesForInstanceGroup(request *computepb.ListInstancesInstanceGroupsRequest) ([]*computepb.Instance, error) { - hosts := []*computepb.Instance{} - instances := c.InstanceGroupClient.ListInstances(c.Context, request) - - for { - resp, err := instances.Next() - if err == iterator.Done { - break - } - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error listing instances for instance group %s: %s", request.InstanceGroup, err) - } - - instance, err := c.InstancesClient.Get(c.Context, &computepb.GetInstanceRequest{ - Instance: path.Base(resp.GetInstance()), - Project: request.Project, - Zone: request.Zone, - }) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error getting instance %s for instance group %s: %s", resp.GetInstance(), request.InstanceGroup, err) - } - - hosts = append(hosts, instance) - } - return hosts, nil -} - func instanceToHost(instance *computepb.Instance) (*pb.ListHostsResponseHost, error) { - if instance.GetSelfLink() == "" { - return nil, errors.New("response integrity error: missing instance self-link") + if instance.GetId() == 0 { + return nil, errors.New("response integrity error: missing instance id") } if instance.GetName() == "" { @@ -84,22 +42,33 @@ func instanceToHost(instance *computepb.Instance) (*pb.ListHostsResponseHost, er result := new(pb.ListHostsResponseHost) - result.ExternalId = instance.GetSelfLink() + result.ExternalId = strconv.FormatUint(instance.GetId(), 10) result.ExternalName = instance.GetName() + // Internal DNS name is the hostname of the instance. + // https://cloud.google.com/compute/docs/networking/using-internal-dns + dnsName := instance.GetHostname() + result.DnsNames = appendDistinct(result.DnsNames, &dnsName) + // Now go through all of the interfaces and log the IP address of // every interface. for _, iface := range instance.GetNetworkInterfaces() { - // Populate default IP addresses/DNS name similar to how we do + // Populate default IP addresses name similar to how we do // for the entire instance. result.IpAddresses = appendDistinct(result.IpAddresses, iface.NetworkIP) for _, external := range iface.AccessConfigs { result.IpAddresses = appendDistinct(result.IpAddresses, external.NatIP) + result.IpAddresses = appendDistinct(result.IpAddresses, external.ExternalIpv6) } // Add the IPv6 addresses. result.IpAddresses = appendDistinct(result.IpAddresses, iface.Ipv6Address) + if iface.Ipv6AccessConfigs != nil { + for _, external := range iface.Ipv6AccessConfigs { + result.IpAddresses = appendDistinct(result.IpAddresses, external.ExternalIpv6) + } + } } // Done diff --git a/plugin/service/host/client_test.go b/plugin/service/host/client_test.go index 10de4f3..193f967 100644 --- a/plugin/service/host/client_test.go +++ b/plugin/service/host/client_test.go @@ -4,15 +4,19 @@ package host import ( + "context" + "errors" + "strconv" "testing" "cloud.google.com/go/compute/apiv1/computepb" + "github.com/hashicorp/boundary-plugin-gcp/internal/credential" pb "github.com/hashicorp/boundary/sdk/pbs/plugin" "github.com/stretchr/testify/require" ) func TestInstanceToHost(t *testing.T) { - exampleId := "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-central1-a/instances/test-instance" + exampleId := uint64(123456789) examplePrivateIp := "10.0.0.1" examplePrivateIp2 := "10.0.0.2" examplePublicIp := "1.1.1.1" @@ -27,24 +31,24 @@ func TestInstanceToHost(t *testing.T) { expectedErr string }{ { - name: "missing instance self-link", + name: "missing instance id", instance: &computepb.Instance{ Name: &exampleName, }, - expectedErr: "response integrity error: missing instance self-link", + expectedErr: "response integrity error: missing instance id", }, { name: "missing instance name", instance: &computepb.Instance{ - SelfLink: &exampleId, + Id: &exampleId, }, expectedErr: "response integrity error: missing instance name", }, { name: "good, single private IP with public IP address", instance: &computepb.Instance{ - SelfLink: &exampleId, - Name: &exampleName, + Id: &exampleId, + Name: &exampleName, NetworkInterfaces: []*computepb.NetworkInterface{ { NetworkIP: &examplePrivateIp, @@ -57,7 +61,7 @@ func TestInstanceToHost(t *testing.T) { }, }, expected: &pb.ListHostsResponseHost{ - ExternalId: exampleId, + ExternalId: strconv.FormatUint(exampleId, 10), ExternalName: exampleName, IpAddresses: []string{examplePrivateIp, examplePublicIp}, }, @@ -65,8 +69,8 @@ func TestInstanceToHost(t *testing.T) { { name: "good, single private IP address", instance: &computepb.Instance{ - SelfLink: &exampleId, - Name: &exampleName, + Id: &exampleId, + Name: &exampleName, NetworkInterfaces: []*computepb.NetworkInterface{ { NetworkIP: &examplePrivateIp, @@ -75,7 +79,7 @@ func TestInstanceToHost(t *testing.T) { }, }, expected: &pb.ListHostsResponseHost{ - ExternalId: exampleId, + ExternalId: strconv.FormatUint(exampleId, 10), ExternalName: exampleName, IpAddresses: []string{examplePrivateIp}, }, @@ -83,8 +87,8 @@ func TestInstanceToHost(t *testing.T) { { name: "good, multiple interfaces", instance: &computepb.Instance{ - SelfLink: &exampleId, - Name: &exampleName, + Id: &exampleId, + Name: &exampleName, NetworkInterfaces: []*computepb.NetworkInterface{ { NetworkIP: &examplePrivateIp, @@ -105,7 +109,7 @@ func TestInstanceToHost(t *testing.T) { }, }, expected: &pb.ListHostsResponseHost{ - ExternalId: exampleId, + ExternalId: strconv.FormatUint(exampleId, 10), ExternalName: exampleName, IpAddresses: []string{examplePrivateIp, examplePublicIp, examplePrivateIp2, examplePublicIp2}, }, @@ -113,8 +117,8 @@ func TestInstanceToHost(t *testing.T) { { name: "good, single private IP address with IPv6", instance: &computepb.Instance{ - SelfLink: &exampleId, - Name: &exampleName, + Id: &exampleId, + Name: &exampleName, NetworkInterfaces: []*computepb.NetworkInterface{ { NetworkIP: &examplePrivateIp, @@ -124,7 +128,7 @@ func TestInstanceToHost(t *testing.T) { }, }, expected: &pb.ListHostsResponseHost{ - ExternalId: exampleId, + ExternalId: strconv.FormatUint(exampleId, 10), ExternalName: exampleName, IpAddresses: []string{examplePrivateIp, exampleIPv6}, }, @@ -146,3 +150,88 @@ func TestInstanceToHost(t *testing.T) { }) } } + +func TestGetInstances(t *testing.T) { + ctx := context.Background() + + exampleId := uint64(123456789) + exampleName := "test-instance" + exampleInstance := &computepb.Instance{ + Id: &exampleId, + Name: &exampleName, + } + + cases := []struct { + name string + instancesClient InstancesAPI + request *computepb.ListInstancesRequest + expected []*computepb.Instance + expectedErr string + }{ + { + name: "successful retrieval", + instancesClient: func() InstancesAPI { + fn := newTestMockInstances(ctx, + nil, + testMockInstancesWithListInstancesOutput(&computepb.InstanceList{ + Items: []*computepb.Instance{exampleInstance}, + }), + testMockInstancesWithListInstancesError(nil), + ) + client, err := fn(&credential.Config{}) + require.NoError(t, err) + return client + }(), + request: &computepb.ListInstancesRequest{}, + expected: []*computepb.Instance{exampleInstance}, + }, + { + name: "error during listing", + instancesClient: func() InstancesAPI { + fn := newTestMockInstances(ctx, + nil, + testMockInstancesWithListInstancesOutput(&computepb.InstanceList{}), + testMockInstancesWithListInstancesError(errors.New("internal error")), + ) + client, err := fn(&credential.Config{}) + require.NoError(t, err) + return client + }(), + request: &computepb.ListInstancesRequest{}, + expectedErr: "error listing instances", + }, + { + name: "no instances", + instancesClient: func() InstancesAPI { + fn := newTestMockInstances(ctx, + nil, + testMockInstancesWithListInstancesOutput(&computepb.InstanceList{}), + testMockInstancesWithListInstancesError(nil), + ) + client, err := fn(&credential.Config{}) + require.NoError(t, err) + return client + }(), + request: &computepb.ListInstancesRequest{}, + expected: []*computepb.Instance{}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require := require.New(t) + actual, err := getInstances(ctx, tc.instancesClient, tc.request) + if tc.expectedErr != "" { + require.ErrorContains(err, tc.expectedErr) + return + } + + require.NoError(err) + require.Len(actual, len(tc.expected)) + if len(actual) > 0 { + require.Equal(tc.expected[0].Name, actual[0].Name) + require.Equal(tc.expected[0].Id, actual[0].Id) + } + }) + } +} diff --git a/plugin/service/host/plugin.go b/plugin/service/host/plugin.go index bf91f56..48dc475 100644 --- a/plugin/service/host/plugin.go +++ b/plugin/service/host/plugin.go @@ -9,7 +9,6 @@ import ( "regexp" "strings" - compute "cloud.google.com/go/compute/apiv1" computepb "cloud.google.com/go/compute/apiv1/computepb" "github.com/hashicorp/boundary-plugin-gcp/internal/credential" errors "github.com/hashicorp/boundary-plugin-gcp/internal/errors" @@ -38,6 +37,13 @@ var ( _ pb.HostPluginServiceServer = (*HostPlugin)(nil) ) +type hostSetQuery struct { + Id string + InputInstances *computepb.ListInstancesRequest + Output []*computepb.Instance + OutputHosts []*pb.ListHostsResponseHost +} + const ( // defaultStatusFilter is the default filter to apply to instances // if no status filter is provided. @@ -380,10 +386,64 @@ func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest } // OnUpdateSet is called when a dynamic host set is updated. -func (p *HostPlugin) OnUpdateSet(_ context.Context, req *pb.OnUpdateSetRequest) (*pb.OnUpdateSetResponse, error) { - if err := validateSet(req.GetNewSet()); err != nil { +func (p *HostPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest) (*pb.OnUpdateSetResponse, 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.GetNewSet() + if set == nil { + return nil, status.Error(codes.InvalidArgument, "set is required") + } + if err := validateSet(set); 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.OnUpdateSetResponse{}, nil } @@ -409,21 +469,27 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (* 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) + } + sets := req.GetSets() if sets == nil { return nil, status.Error(codes.InvalidArgument, "sets is nil") } - type hostSetQuery struct { - Id string - InputInstances *computepb.ListInstancesRequest - InputGroups *computepb.ListInstancesInstanceGroupsRequest - Project string - Zone string - Output []*computepb.Instance - OutputHosts []*pb.ListHostsResponseHost - } - queries := make([]hostSetQuery, len(sets)) for i, set := range sets { // Validate Id since we use it in output @@ -432,56 +498,35 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (* } if set.GetAttributes() == nil { - return nil, status.Error(codes.InvalidArgument, "set missing attributes") + return nil, status.Error(codes.InvalidArgument, fmt.Sprintf("set %s missing attributes", set.GetId())) } setAttrs, err := getSetAttributes(set.GetAttributes()) if err != nil { return nil, err } - if setAttrs.InstanceGroup != "" { - queries[i] = hostSetQuery{ - Id: set.GetId(), - InputGroups: buildListInstanceGroupsRequest(setAttrs, catalogAttributes), - } - } else { - queries[i] = hostSetQuery{ - Id: set.GetId(), - InputInstances: buildListInstancesRequest(setAttrs, catalogAttributes), - } + input, err := buildListInstancesRequest(setAttrs, catalogAttributes) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error building list instances request: %s", err) + } + + queries[i] = hostSetQuery{ + Id: set.GetId(), + InputInstances: input, } } - instancesClient, err := compute.NewInstancesRESTClient(ctx) + instancesClient, err := catalogState.InstancesClient(ctx, p.testGCPClientOpts...) if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error creating NewInstancesRESTClient: %s", err) - } - - instanceGroupsClient, err := compute.NewInstanceGroupsRESTClient(ctx) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error creating NewInstanceGroupsRESTClient: %s", err) - } - - gclient := GoogleClient{ - InstancesClient: instancesClient, - InstanceGroupClient: instanceGroupsClient, - Context: ctx, + return nil, status.Errorf(codes.InvalidArgument, "error getting instances client: %s", err) } // Run all queries now and assemble output. var maxLen int for i, query := range queries { - var output []*computepb.Instance - if query.InputGroups != nil { - output, err = gclient.getInstancesForInstanceGroup(query.InputGroups) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error running getInstancesForInstanceGroup for host set id %q: %s", query.Id, err) - } - } else { - output, err = gclient.getInstances(query.InputInstances) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error running getInstances for host set id %q: %s", query.Id, err) - } + output, err := getInstances(ctx, instancesClient, query.InputInstances) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error running list instances for host set id %q: %s", query.Id, err) } queries[i].Output = output @@ -547,8 +592,6 @@ 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.Filters) == 0 { badFields[fmt.Sprintf("attributes.%s", ConstListInstancesFilter)] = "must not be empty" } diff --git a/plugin/service/host/plugin_test.go b/plugin/service/host/plugin_test.go index 95876cd..c62d94e 100644 --- a/plugin/service/host/plugin_test.go +++ b/plugin/service/host/plugin_test.go @@ -5,10 +5,9 @@ package host import ( "context" + "errors" "fmt" "net/http" - "os" - "path/filepath" "testing" "time" @@ -21,7 +20,6 @@ import ( "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets" pb "github.com/hashicorp/boundary/sdk/pbs/plugin" - "github.com/hashicorp/go-secure-stdlib/parseutil" "github.com/stretchr/testify/require" "google.golang.org/api/option" "google.golang.org/grpc" @@ -41,54 +39,351 @@ func wrapMap(t *testing.T, in map[string]interface{}) *structpb.Struct { } func TestListHosts(t *testing.T) { - t.Skip("TODO: this needs a secrets file to run - maybe only manually?") ctx := context.Background() - p := &HostPlugin{} - - wd, err := os.Getwd() - require.NoError(t, err) - require.NotEmpty(t, wd) - project, err := parseutil.ParsePath("file://" + filepath.Join(wd, "secrets", "project")) - require.NoError(t, err) - zone, err := parseutil.ParsePath("file://" + filepath.Join(wd, "secrets", "zone")) - require.NoError(t, err) - - hostCatalogAttributes := &hostcatalogs.HostCatalog_Attributes{ - Attributes: wrapMap(t, map[string]interface{}{ - cred.ConstProjectId: project, - cred.ConstZone: zone, - }), - } cases := []struct { - name string - req *pb.ListHostsRequest - expected []*pb.ListHostsResponseHost - expectedErr string + name string + req *pb.ListHostsRequest + catalogOpts []gcpCatalogPersistedStateOption + gcpOptions []option.ClientOption + expected []*pb.ListHostsResponseHost + expectedErr string + expectedErrCode codes.Code }{ { - name: "nil catalog", - req: &pb.ListHostsRequest{}, - expectedErr: "catalog is nil", + name: "nil catalog", + req: &pb.ListHostsRequest{}, + expectedErr: "catalog is nil", + expectedErrCode: codes.InvalidArgument, }, { - name: "project not defined", + name: "nil catalog attributes", + req: &pb.ListHostsRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Secrets: new(structpb.Struct), + }, + }, + expectedErr: "catalog missing attributes", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "zone not defined", + req: &pb.ListHostsRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Secrets: new(structpb.Struct), + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: new(structpb.Struct), + }, + }, + }, + expectedErr: "attributes.zone: missing required value \"zone\"", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "project_id not defined", req: &pb.ListHostsRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: wrapMap(t, map[string]interface{}{ - cred.ConstZone: zone, + cred.ConstZone: "us-central1-c", }), }, }, }, - expectedErr: "attributes.project: missing required value \"project\"", + expectedErr: "attributes.project_id: missing required value \"project_id\"", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "persisted state setup error", + req: &pb.ListHostsRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstProjectId: structpb.NewStringValue("test-project-id"), + cred.ConstZone: structpb.NewStringValue("us-central1-c"), + }, + }, + }, + }, + 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 errors.New("error loading persisted state") + }, + }, + expectedErr: "error loading persisted state", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "set missing id", + req: &pb.ListHostsRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstProjectId: structpb.NewStringValue("test-project-id"), + cred.ConstZone: structpb.NewStringValue("us-central1-c"), + }, + }, + }, + }, + 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"), + }, + }, + }, + Sets: []*hostsets.HostSet{{}}, + }, + expectedErr: "set missing id", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "set missing attributes", + req: &pb.ListHostsRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstProjectId: structpb.NewStringValue("test-project-id"), + cred.ConstZone: structpb.NewStringValue("us-central1-c"), + }, + }, + }, + }, + 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"), + }, + }, + }, + Sets: []*hostsets.HostSet{ + { + Id: "foobar", + }, + }, + }, + expectedErr: "set foobar missing attributes", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "set attribute load error", + req: &pb.ListHostsRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstProjectId: structpb.NewStringValue("test-project-id"), + cred.ConstZone: structpb.NewStringValue("us-central1-c"), + }, + }, + }, + }, + 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"), + }, + }, + }, + Sets: []*hostsets.HostSet{ + { + Id: "foobar", + Attrs: &hostsets.HostSet_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "foo": structpb.NewBoolValue(true), + "bar": structpb.NewBoolValue(true), + }, + }, + }, + }, + }, + }, + expectedErr: "attributes.bar: unrecognized field, attributes.foo: unrecognized field", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "client load error", + req: &pb.ListHostsRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstProjectId: structpb.NewStringValue("test-project-id"), + cred.ConstZone: structpb.NewStringValue("us-central1-c"), + }, + }, + }, + }, + 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"), + }, + }, + }, + Sets: []*hostsets.HostSet{ + { + Id: "foobar", + 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"), + }, + }, + ), + }, + }, + }, + }, + }, + }, + gcpOptions: []option.ClientOption{ + option.WithHTTPClient(&http.Client{}), + }, + expectedErr: "error getting instances client", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "List instances error", + req: &pb.ListHostsRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstProjectId: structpb.NewStringValue("test-project-id"), + cred.ConstZone: structpb.NewStringValue("us-central1-c"), + }, + }, + }, + }, + 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"), + }, + }, + }, + Sets: []*hostsets.HostSet{ + { + Id: "foobar", + 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, + testMockInstancesWithListInstancesError(fmt.Errorf("error running DescribeInstances for host set id \"foobar\"")))), + }, + expectedErr: "error running list instances for host set id \"foobar\"", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "instanceToHost error", + req: &pb.ListHostsRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + cred.ConstProjectId: structpb.NewStringValue("test-project-id"), + cred.ConstZone: structpb.NewStringValue("us-central1-c"), + }, + }, + }, + }, + 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"), + }, + }, + }, + Sets: []*hostsets.HostSet{ + { + Id: "foobar", + 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{ + Items: []*computepb.Instance{ + { + Name: pointer("boundary-0"), + }, + }, + }), + testMockInstancesWithListInstancesError(nil), + )), + }, + expectedErr: "error processing host results for host set id \"foobar\": response integrity error: missing instance id", + expectedErrCode: codes.InvalidArgument, }, { name: "get all three instances", req: &pb.ListHostsRequest{ Catalog: &hostcatalogs.HostCatalog{ - Attrs: hostCatalogAttributes, + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: wrapMap(t, map[string]interface{}{ + cred.ConstZone: "us-central1-c", + cred.ConstProjectId: "test-project", + }), + }, }, Sets: []*hostsets.HostSet{ { @@ -99,15 +394,90 @@ func TestListHosts(t *testing.T) { }, }, }, + catalogOpts: []gcpCatalogPersistedStateOption{ + withTestInstancesAPIFunc(newTestMockInstances(ctx, + nil, + testMockInstancesWithListInstancesOutput(&computepb.InstanceList{ + Items: []*computepb.Instance{ + { + Name: pointer("boundary-0"), + Id: pointer(uint64(1)), + NetworkInterfaces: []*computepb.NetworkInterface{ + { + AccessConfigs: []*computepb.AccessConfig{ + { + NatIP: pointer("101.1.1.1"), + ExternalIpv6: pointer("2001:db8::1"), + }, + }, + Ipv6AccessConfigs: []*computepb.AccessConfig{ + { + ExternalIpv6: pointer("2001:db8::1"), + }, + }, + }, + }, + }, + { + Name: pointer("boundary-1"), + Id: pointer(uint64(2)), + NetworkInterfaces: []*computepb.NetworkInterface{ + { + AccessConfigs: []*computepb.AccessConfig{ + { + NatIP: pointer("102.1.1.1"), + }, + }, + Ipv6AccessConfigs: []*computepb.AccessConfig{ + { + ExternalIpv6: pointer("2001:db8::2"), + }, + }, + }, + }, + }, + { + Name: pointer("boundary-2"), + Id: pointer(uint64(3)), + NetworkInterfaces: []*computepb.NetworkInterface{ + { + AccessConfigs: []*computepb.AccessConfig{ + { + NatIP: pointer("103.1.1.1"), + ExternalIpv6: pointer("2001:db8::3"), + }, + }, + Ipv6AccessConfigs: []*computepb.AccessConfig{ + { + ExternalIpv6: pointer("2001:db8::3"), + }, + }, + }, + }, + }, + }, + }), + testMockInstancesWithListInstancesError(nil), + )), + }, expected: []*pb.ListHostsResponseHost{ { - Name: "boundary-0", + SetIds: []string{"get-all-instances"}, + ExternalName: "boundary-0", + ExternalId: "1", + IpAddresses: []string{"101.1.1.1", "2001:db8::1"}, }, { - Name: "boundary-1", + SetIds: []string{"get-all-instances"}, + ExternalName: "boundary-1", + ExternalId: "2", + IpAddresses: []string{"102.1.1.1", "2001:db8::2"}, }, { - Name: "boundary-2", + SetIds: []string{"get-all-instances"}, + ExternalName: "boundary-2", + ExternalId: "3", + IpAddresses: []string{"103.1.1.1", "2001:db8::3"}, }, }, }, @@ -115,7 +485,12 @@ func TestListHosts(t *testing.T) { name: "get one instance by name", req: &pb.ListHostsRequest{ Catalog: &hostcatalogs.HostCatalog{ - Attrs: hostCatalogAttributes, + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: wrapMap(t, map[string]interface{}{ + cred.ConstZone: "us-central1-c", + cred.ConstProjectId: "test-project", + }), + }, }, Sets: []*hostsets.HostSet{ { @@ -128,38 +503,44 @@ func TestListHosts(t *testing.T) { }, }, }, - expected: []*pb.ListHostsResponseHost{ - { - Name: "boundary-1", - }, - }, - }, - { - name: "get instance group", - req: &pb.ListHostsRequest{ - Catalog: &hostcatalogs.HostCatalog{ - Attrs: hostCatalogAttributes, - }, - Sets: []*hostsets.HostSet{ - { - Id: "get-instance-group", - Attrs: &hostsets.HostSet_Attributes{ - Attributes: wrapMap(t, map[string]interface{}{ - ConstInstanceGroup: "boundary-servers", - }), + catalogOpts: []gcpCatalogPersistedStateOption{ + withTestInstancesAPIFunc(newTestMockInstances(ctx, + nil, + testMockInstancesWithListInstancesOutput(&computepb.InstanceList{ + Items: []*computepb.Instance{ + { + Name: pointer("boundary-1"), + Id: pointer(uint64(1)), + NetworkInterfaces: []*computepb.NetworkInterface{ + { + AccessConfigs: []*computepb.AccessConfig{ + { + NatIP: pointer("102.1.1.1"), + ExternalIpv6: pointer("2001:db8::1"), + }, + }, + Ipv6AccessConfigs: []*computepb.AccessConfig{ + { + ExternalIpv6: pointer("2001:db8::1"), + }, + }, + }, + }, + }, }, - }, - }, + }), + testMockInstancesWithListInstancesError(nil), + )), }, expected: []*pb.ListHostsResponseHost{ { - Name: "boundary-0", - }, - { - Name: "boundary-1", - }, - { - Name: "boundary-2", + SetIds: []string{"get-one-instance-by-name"}, + ExternalName: "boundary-1", + ExternalId: "1", + IpAddresses: []string{ + "102.1.1.1", + "2001:db8::1", + }, }, }, }, @@ -167,7 +548,12 @@ func TestListHosts(t *testing.T) { name: "get two specific instances with two host sets", req: &pb.ListHostsRequest{ Catalog: &hostcatalogs.HostCatalog{ - Attrs: hostCatalogAttributes, + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: wrapMap(t, map[string]interface{}{ + cred.ConstZone: "us-central1-c", + cred.ConstProjectId: "test-project", + }), + }, }, Sets: []*hostsets.HostSet{ { @@ -188,86 +574,70 @@ func TestListHosts(t *testing.T) { }, }, }, - expected: []*pb.ListHostsResponseHost{ - { - Name: "boundary-0", - }, - { - Name: "boundary-2", - }, - }, - }, - { - name: "get one instance and an instance group in two host sets", - req: &pb.ListHostsRequest{ - Catalog: &hostcatalogs.HostCatalog{ - Attrs: hostCatalogAttributes, - }, - Sets: []*hostsets.HostSet{ - { - Id: "get-one-instance-by-name", - Attrs: &hostsets.HostSet_Attributes{ - Attributes: wrapMap(t, map[string]interface{}{ - ConstListInstancesFilter: "name = boundary-1", - }), + catalogOpts: []gcpCatalogPersistedStateOption{ + withTestInstancesAPIFunc(newTestMockInstances(ctx, + nil, + testMockInstancesWithListInstancesOutput(&computepb.InstanceList{ + Items: []*computepb.Instance{ + { + Name: pointer("boundary-1"), + Id: pointer(uint64(1)), + NetworkInterfaces: []*computepb.NetworkInterface{ + { + AccessConfigs: []*computepb.AccessConfig{ + { + NatIP: pointer("102.1.1.1"), + }, + }, + }, + }, + }, + { + Name: pointer("boundary-2"), + Id: pointer(uint64(2)), + NetworkInterfaces: []*computepb.NetworkInterface{ + { + AccessConfigs: []*computepb.AccessConfig{ + { + NatIP: pointer("103.1.1.1"), + }, + }, + Ipv6AccessConfigs: []*computepb.AccessConfig{ + { + ExternalIpv6: pointer("2001:db8::1"), + }, + }, + }, + }, + }, }, - }, - { - Id: "get-instance-group", - Attrs: &hostsets.HostSet_Attributes{ - Attributes: wrapMap(t, map[string]interface{}{ - ConstInstanceGroup: "boundary-servers", - }), - }, - }, - }, + }), + testMockInstancesWithListInstancesError(nil), + )), }, expected: []*pb.ListHostsResponseHost{ { - Name: "boundary-0", - }, - { - Name: "boundary-1", - }, - { - Name: "boundary-2", - }, - }, - }, - { - name: "get an instance group and one instance in two host sets", - req: &pb.ListHostsRequest{ - Catalog: &hostcatalogs.HostCatalog{ - Attrs: hostCatalogAttributes, - }, - Sets: []*hostsets.HostSet{ - { - Id: "get-instance-group", - Attrs: &hostsets.HostSet_Attributes{ - Attributes: wrapMap(t, map[string]interface{}{ - ConstInstanceGroup: "boundary-servers", - }), - }, + SetIds: []string{ + "get-one-instance-by-name-0", + "get-one-instance-by-name-2", }, - { - Id: "get-one-instance-by-name", - Attrs: &hostsets.HostSet_Attributes{ - Attributes: wrapMap(t, map[string]interface{}{ - ConstListInstancesFilter: "name = boundary-2", - }), - }, + ExternalId: "1", + ExternalName: "boundary-1", + IpAddresses: []string{ + "102.1.1.1", }, }, - }, - expected: []*pb.ListHostsResponseHost{ { - Name: "boundary-0", - }, - { - Name: "boundary-1", - }, - { - Name: "boundary-2", + SetIds: []string{ + "get-one-instance-by-name-0", + "get-one-instance-by-name-2", + }, + ExternalId: "2", + ExternalName: "boundary-2", + IpAddresses: []string{ + "103.1.1.1", + "2001:db8::1", + }, }, }, }, @@ -275,7 +645,12 @@ func TestListHosts(t *testing.T) { name: "invalid filter", req: &pb.ListHostsRequest{ Catalog: &hostcatalogs.HostCatalog{ - Attrs: hostCatalogAttributes, + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: wrapMap(t, map[string]interface{}{ + cred.ConstZone: "us-central1-c", + cred.ConstProjectId: "test-project", + }), + }, }, Sets: []*hostsets.HostSet{ { @@ -288,7 +663,8 @@ func TestListHosts(t *testing.T) { }, }, }, - expectedErr: "Invalid list filter expression", + expectedErr: "error building filters: filter \"not-a-filter\" contains invalid operator", + expectedErrCode: codes.InvalidArgument, }, } @@ -297,14 +673,20 @@ func TestListHosts(t *testing.T) { t.Run(tc.name, func(t *testing.T) { require := require.New(t) + p := &HostPlugin{ + testGCPClientOpts: tc.gcpOptions, + testCatalogStateOpts: tc.catalogOpts, + } + actual, err := p.ListHosts(ctx, tc.req) if tc.expectedErr != "" { require.Contains(err.Error(), tc.expectedErr) + require.Equal(status.Code(err).String(), tc.expectedErrCode.String()) return } - require.NoError(err) require.Equal(len(tc.expected), len(actual.GetHosts())) + require.Equal(tc.expected, actual.GetHosts()) }) } } @@ -1307,17 +1689,41 @@ func TestCreateSet(t *testing.T) { func TestUpdateSet(t *testing.T) { ctx := context.Background() - p := &HostPlugin{} cases := []struct { - name string - req *pb.OnUpdateSetRequest - expectedErr string + name string + req *pb.OnUpdateSetRequest + catalogOpts []gcpCatalogPersistedStateOption + gcpOptions []option.ClientOption + expectedErr string + expectedErrCode codes.Code }{ { - name: "nil set", - req: &pb.OnUpdateSetRequest{}, - expectedErr: "set is nil", + name: "nil host catalog", + req: &pb.OnUpdateSetRequest{}, + expectedErr: "catalog is required", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "nil host catalog attributes", + req: &pb.OnUpdateSetRequest{ + Catalog: &hostcatalogs.HostCatalog{}, + }, + expectedErr: "catalog attributes are required", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "error reading catalog attributes", + req: &pb.OnUpdateSetRequest{ + 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", @@ -1333,11 +1739,116 @@ func TestUpdateSet(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.OnUpdateSetRequest{ + 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.OnUpdateSetRequest{ + 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"), + }, + }, + }, + NewSet: &hostsets.HostSet{}, + }, + expectedErr: "set attributes are required", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "set attribute load error", + req: &pb.OnUpdateSetRequest{ + 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"), + }, + }, + }, + NewSet: &hostsets.HostSet{ + Attrs: &hostsets.HostSet_Attributes{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "foo": structpb.NewBoolValue(true), + "bar": structpb.NewBoolValue(true), + }, + }, + }, + }, + }, + expectedErr: "attributes.bar: unrecognized field, attributes.foo: unrecognized field", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "invalid filter", + req: &pb.OnUpdateSetRequest{ + 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"), + }, + }, + }, + }, NewSet: &hostsets.HostSet{ Attrs: &hostsets.HostSet_Attributes{ Attributes: &structpb.Struct{ @@ -1345,59 +1856,153 @@ func TestUpdateSet(t *testing.T) { ConstListInstancesFilter: structpb.NewListValue( &structpb.ListValue{ Values: []*structpb.Value{ - structpb.NewStringValue("status=RUNNING"), + structpb.NewStringValue("invalid"), }, }, ), - ConstInstanceGroup: structpb.NewStringValue("test"), }, }, }, }, }, - expectedErr: "attributes: must set instance group or filter", + expectedErr: "error building filters", + expectedErrCode: codes.InvalidArgument, }, { - name: "empty instance group", + name: "client load error", req: &pb.OnUpdateSetRequest{ + 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"), + }, + }, + }, NewSet: &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.OnUpdateSetRequest{ + 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"), + }, + }, + }, NewSet: &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.OnUpdateSetRequest{ - NewSet: &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"), + }, + }, + }, + NewSet: &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)), + ), }, }, } @@ -1407,9 +2012,30 @@ func TestUpdateSet(t *testing.T) { t.Run(tc.name, func(t *testing.T) { require := require.New(t) - _, err := p.OnUpdateSet(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.OnUpdateSet(ctx, tc.req) if tc.expectedErr != "" { require.Contains(err.Error(), tc.expectedErr) + require.Equal(status.Code(err).String(), tc.expectedErrCode.String()) return } diff --git a/plugin/service/host/state.go b/plugin/service/host/state.go index 5b7fae0..abf7448 100644 --- a/plugin/service/host/state.go +++ b/plugin/service/host/state.go @@ -17,10 +17,6 @@ import ( "google.golang.org/protobuf/types/known/structpb" ) -type Iterator interface { - Next() (*computepb.Instance, error) -} - type InstancesAPI interface { List(ctx context.Context, req *computepb.ListInstancesRequest, opts ...gax.CallOption) *compute.InstanceIterator }