// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package plugin import ( "context" "fmt" compute "cloud.google.com/go/compute/apiv1" computepb "cloud.google.com/go/compute/apiv1/computepb" errors "github.com/hashicorp/boundary-plugin-gcp/internal/errors" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets" pb "github.com/hashicorp/boundary/sdk/pbs/plugin" "github.com/mitchellh/mapstructure" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) // GooglePlugin implements the GooglePluginServiceServer interface for the // Google host service plugin. type GooglePlugin struct { pb.UnimplementedHostPluginServiceServer } var ( _ pb.HostPluginServiceServer = (*GooglePlugin)(nil) ) // OnCreateCatalog is called when a dynamic host catalog is created. func (p *GooglePlugin) OnCreateCatalog(_ context.Context, req *pb.OnCreateCatalogRequest) (*pb.OnCreateCatalogResponse, error) { catalog := req.GetCatalog() if catalog == nil { return nil, status.Error(codes.InvalidArgument, "catalog is nil") } attrs := catalog.GetAttributes() if attrs == nil { return nil, status.Error(codes.InvalidArgument, "attributes are required") } if _, err := getCatalogAttributes(attrs); err != nil { return nil, err } return &pb.OnCreateCatalogResponse{ Persisted: &pb.HostCatalogPersisted{ Secrets: nil, }, }, nil } // OnUpdateCatalog is called when a dynamic host catalog is updated. func (p *GooglePlugin) OnUpdateCatalog(_ context.Context, req *pb.OnUpdateCatalogRequest) (*pb.OnUpdateCatalogResponse, error) { currentCatalog := req.GetCurrentCatalog() if currentCatalog == nil { return nil, status.Error(codes.FailedPrecondition, "current catalog is nil") } secrets := req.GetNewCatalog().GetSecrets() if secrets == nil { // If new secrets weren't passed in, don't rotate what we have on // update. return &pb.OnUpdateCatalogResponse{}, nil } return &pb.OnUpdateCatalogResponse{ Persisted: &pb.HostCatalogPersisted{ Secrets: nil, }, }, nil } // OnDeleteCatalog is called when a dynamic host catalog is deleted. func (p *GooglePlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalogRequest) (*pb.OnDeleteCatalogResponse, error) { catalog := req.GetCatalog() if catalog == nil { return nil, status.Error(codes.InvalidArgument, "new catalog is nil") } attrs := catalog.GetAttributes() if attrs == nil { return nil, status.Error(codes.InvalidArgument, "new catalog missing attributes") } if _, err := getCatalogAttributes(attrs); err != nil { return nil, err } return &pb.OnDeleteCatalogResponse{}, nil } // OnCreateSet is called when a dynamic host set is created. func (p *GooglePlugin) OnCreateSet(_ context.Context, req *pb.OnCreateSetRequest) (*pb.OnCreateSetResponse, error) { if err := validateSet(req.GetSet()); err != nil { return nil, err } return &pb.OnCreateSetResponse{}, nil } // OnUpdateSet is called when a dynamic host set is updated. func (p *GooglePlugin) OnUpdateSet(_ context.Context, req *pb.OnUpdateSetRequest) (*pb.OnUpdateSetResponse, error) { if err := validateSet(req.GetNewSet()); err != nil { return nil, err } return &pb.OnUpdateSetResponse{}, nil } // OnDeleteSet is called when a dynamic host set is deleted. func (p *GooglePlugin) OnDeleteSet(ctx context.Context, req *pb.OnDeleteSetRequest) (*pb.OnDeleteSetResponse, error) { return &pb.OnDeleteSetResponse{}, nil } // ListHosts returns the list of instances based on filter or instance group name. func (p *GooglePlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*pb.ListHostsResponse, error) { catalog := req.GetCatalog() if catalog == nil { return nil, status.Error(codes.InvalidArgument, "catalog is nil") } catalogAttrsRaw := catalog.GetAttributes() if catalogAttrsRaw == nil { return nil, status.Error(codes.InvalidArgument, "catalog missing attributes") } catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw) if err != nil { return nil, 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 if set.GetId() == "" { return nil, status.Error(codes.InvalidArgument, "set missing id") } if set.GetAttributes() == nil { return nil, status.Error(codes.InvalidArgument, "set missing attributes") } 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), } } } instancesClient, err := compute.NewInstancesRESTClient(ctx) 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, } // 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) } } queries[i].Output = output // Process the output here, we will normalize this into a single // set of hosts afterwards (possibly removing duplicates). for _, instance := range output { host, err := instanceToHost(instance) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "error processing host results for host set id %q: %s", query.Id, err) } queries[i].OutputHosts = append(queries[i].OutputHosts, host) maxLen++ } } // Now de-duplicate the hosts for the output. Maintain two sets: // * A slice of hosts that will be used in the output // * A map of hosts indexed by their external ID // // The map will is used in de-duplication to determine whether or // not we've seen the host before to simply add the query's set ID // to the list of set IDs that the host was seen in. hostResultSlice := make([]*pb.ListHostsResponseHost, 0, maxLen) hostResultMap := make(map[string]*pb.ListHostsResponseHost) for _, query := range queries { for _, host := range query.OutputHosts { if existingHost, ok := hostResultMap[host.ExternalId]; ok { // Existing host, just add the set ID to the list of seen IDs // and continue existingHost.SetIds = append(existingHost.SetIds, query.Id) continue } // This will be the first seen entry, so append the set ID to // this host, and add it. host.SetIds = append(host.SetIds, query.Id) hostResultSlice = append(hostResultSlice, host) hostResultMap[host.ExternalId] = host } } return &pb.ListHostsResponse{ Hosts: hostResultSlice, }, nil } func validateSet(s *hostsets.HostSet) error { if s == nil { return status.Error(codes.InvalidArgument, "set is nil") } var attrs SetAttributes attrMap := s.GetAttributes().AsMap() if err := mapstructure.Decode(attrMap, &attrs); err != nil { return status.Errorf(codes.InvalidArgument, "error decoding set attributes: %s", err) } badFields := make(map[string]string) _, filterSet := attrMap[ConstListInstancesFilter] _, instanceGroupSet := attrMap[ConstInstanceGroup] 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." } for f := range attrMap { if _, ok := allowedSetFields[f]; !ok { badFields[fmt.Sprintf("attributes.%s", f)] = "Unrecognized field." } } if len(badFields) > 0 { return errors.InvalidArgumentError("Invalid arguments in the new set", badFields) } return nil }