* feat: enhance service account key rotation with validation callback - add validation callback will enable the host service to validate the newly created key before the deleting the old key - wrapped both the call to validate permissions and validate callback in a retry because sometimes it takes some time for the newly created key to be active on GCP services. This throws an error and prevents host catalog creation
732 lines
24 KiB
Go
732 lines
24 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package host
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
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"
|
|
"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/api/iterator"
|
|
"google.golang.org/api/option"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/proto"
|
|
"google.golang.org/protobuf/types/known/structpb"
|
|
)
|
|
|
|
// HostPlugin implements the HostPluginServiceServer interface for the
|
|
// GCP host service plugin.
|
|
type HostPlugin struct {
|
|
pb.UnimplementedHostPluginServiceServer
|
|
// testGCPClientOpts are passed in to the GCP client to control test behavior
|
|
testGCPClientOpts []option.ClientOption
|
|
// testCatalogStateOpts are passed in to the stored state to control test behavior
|
|
testCatalogStateOpts []gcpCatalogPersistedStateOption
|
|
}
|
|
|
|
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.
|
|
// 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()
|
|
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")
|
|
}
|
|
|
|
catalogAttributes, err := getCatalogAttributes(attrs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
credConfig, err := credential.GetCredentialsConfig(catalog.GetSecrets(), catalogAttributes.CredentialAttributes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
credState, err := credential.NewPersistedState([]credential.Option{
|
|
credential.WithCredentialsConfig(credConfig),
|
|
}...,
|
|
)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "error creating new persisted state: %s", err)
|
|
}
|
|
|
|
permissions := []string{
|
|
credential.ComputeInstancesListPermission,
|
|
credential.IAMServiceAccountKeysCreatePermission,
|
|
credential.IAMServiceAccountKeysDeletePermission,
|
|
}
|
|
|
|
if !credState.CredentialsConfig.IsRotatable() && !catalogAttributes.DisableCredentialRotation {
|
|
return nil, status.Error(codes.InvalidArgument, "cannot rotate credentials for non-rotatable credentials")
|
|
}
|
|
|
|
if credState.CredentialsConfig.IsRotatable() && !catalogAttributes.DisableCredentialRotation {
|
|
state, err := newGCPCatalogPersistedState(
|
|
append([]gcpCatalogPersistedStateOption{
|
|
withCredentials(credState),
|
|
}, p.testCatalogStateOpts...)...,
|
|
)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "error setting up persisted state: %s", err)
|
|
}
|
|
validateCredsCallback := state.ValidateCredsCallbackFn(ctx, p.testGCPClientOpts...)
|
|
|
|
if err := credState.RotateCreds(ctx, permissions, validateCredsCallback, p.testGCPClientOpts...); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
catalogState, err := newGCPCatalogPersistedState(
|
|
append([]gcpCatalogPersistedStateOption{
|
|
withCredentials(credState),
|
|
}, p.testCatalogStateOpts...)...,
|
|
)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "error setting up persisted state: %s", err)
|
|
}
|
|
|
|
if st := dryRunValidation(ctx, catalogState, nil, p.testGCPClientOpts...); st != nil {
|
|
return nil, st.Err()
|
|
}
|
|
|
|
persistedProto, err := catalogState.toProto()
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "error converting state to proto: %s", err)
|
|
}
|
|
|
|
return &pb.OnCreateCatalogResponse{
|
|
Persisted: persistedProto,
|
|
}, nil
|
|
}
|
|
|
|
// OnUpdateCatalog is called when a dynamic host catalog is updated.
|
|
func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalogRequest) (*pb.OnUpdateCatalogResponse, error) {
|
|
currentCatalog := req.GetCurrentCatalog()
|
|
if currentCatalog == nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "current catalog is required")
|
|
}
|
|
newCatalog := req.GetNewCatalog()
|
|
if newCatalog == nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "new catalog is required")
|
|
}
|
|
|
|
oldAttrs := currentCatalog.GetAttributes()
|
|
if oldAttrs == nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "old catalog attributes are required")
|
|
}
|
|
oldCatalogAttributes, err := getCatalogAttributes(oldAttrs)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "error getting old catalog attributes: %s", err)
|
|
}
|
|
newAttrs := newCatalog.GetAttributes()
|
|
if newAttrs == nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "new catalog attributes are required")
|
|
}
|
|
newCatalogAttributes, err := getCatalogAttributes(newAttrs)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "error getting new catalog attributes: %s", err)
|
|
}
|
|
|
|
credState, err := credential.PersistedStateFromProto(
|
|
req.GetPersisted().GetSecrets(),
|
|
oldCatalogAttributes.CredentialAttributes,
|
|
)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "error getting persisted state from proto: %s", err)
|
|
}
|
|
|
|
// We will be updating credentials when one of two changes occur:
|
|
// 1. The new catalog has a service account key, in which case we need to delete the old service account key credentials
|
|
// 2. The target service account ID has changed. This indicates a change of credential type to use service account impersonation
|
|
if newCatalog.GetSecrets() != nil || newCatalogAttributes.TargetServiceAccountId != oldCatalogAttributes.TargetServiceAccountId {
|
|
updatedCredentials, err := credential.GetCredentialsConfig(newCatalog.GetSecrets(), newCatalogAttributes.CredentialAttributes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
newCredState, err := credential.NewPersistedState([]credential.Option{
|
|
credential.WithCredentialsConfig(updatedCredentials),
|
|
}...,
|
|
)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, fmt.Sprintf("error creating new persisted state: %s", err))
|
|
}
|
|
newCatalogState, err := newGCPCatalogPersistedState(
|
|
append([]gcpCatalogPersistedStateOption{
|
|
withCredentials(newCredState),
|
|
}, p.testCatalogStateOpts...)...,
|
|
)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "error setting up persisted state: %s", err)
|
|
}
|
|
if st := dryRunValidation(ctx, newCatalogState, nil, p.testGCPClientOpts...); st != nil {
|
|
return nil, st.Err()
|
|
}
|
|
|
|
// Replace the existing credential state.
|
|
// This checks the timestamp on the last rotation time as well
|
|
// and deletes the credentials if we are managing them
|
|
// (ie: if we've rotated them before).
|
|
if err := credState.ReplaceCreds(ctx, updatedCredentials, p.testGCPClientOpts...); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// This is a validate check to make sure that we aren't trying to
|
|
// rotate credentials that are not rotatable. For example,
|
|
// Application Default Credentials (ADC) are not rotatable.
|
|
if !credState.CredentialsConfig.IsRotatable() && !newCatalogAttributes.DisableCredentialRotation {
|
|
return nil, status.Error(codes.InvalidArgument, "cannot rotate credentials for non-rotatable credentials")
|
|
}
|
|
|
|
if credState.CredentialsConfig.IsRotatable() {
|
|
// This is a validate check to make sure that we aren't disabling
|
|
// rotation for credentials currently being managed by rotation.
|
|
// This is not allowed.
|
|
if newCatalogAttributes.DisableCredentialRotation && newCatalog.GetSecrets() == nil {
|
|
if !credState.CredsLastRotatedTime.IsZero() {
|
|
return nil, status.Error(codes.InvalidArgument, "cannot disable credential rotation for credentials currently being rotated")
|
|
}
|
|
}
|
|
|
|
permissions := []string{
|
|
credential.ComputeInstancesListPermission,
|
|
credential.IAMServiceAccountKeysCreatePermission,
|
|
credential.IAMServiceAccountKeysDeletePermission,
|
|
}
|
|
|
|
// If we're enabling rotation now but didn't before, or have
|
|
// freshly replaced credentials, we can rotate here.
|
|
if !newCatalogAttributes.DisableCredentialRotation && credState.CredsLastRotatedTime.IsZero() {
|
|
state, err := newGCPCatalogPersistedState(
|
|
append([]gcpCatalogPersistedStateOption{
|
|
withCredentials(credState),
|
|
}, p.testCatalogStateOpts...)...,
|
|
)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "error setting up persisted state: %s", err)
|
|
}
|
|
validateCredsCallback := state.ValidateCredsCallbackFn(ctx, p.testGCPClientOpts...)
|
|
|
|
if err := credState.RotateCreds(ctx, permissions, validateCredsCallback, p.testGCPClientOpts...); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
catalogState, err := newGCPCatalogPersistedState(
|
|
append([]gcpCatalogPersistedStateOption{
|
|
withCredentials(credState),
|
|
}, p.testCatalogStateOpts...)...,
|
|
)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "error setting up persisted state: %s", err)
|
|
}
|
|
|
|
// perform dry run to ensure we can interact with GCP as expected.
|
|
if st := dryRunValidation(ctx, catalogState, nil, p.testGCPClientOpts...); st != nil {
|
|
return nil, st.Err()
|
|
}
|
|
|
|
persistedProto, err := catalogState.toProto()
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "error converting state to proto: %s", err)
|
|
}
|
|
|
|
return &pb.OnUpdateCatalogResponse{
|
|
Persisted: persistedProto,
|
|
}, nil
|
|
}
|
|
|
|
// OnDeleteCatalog is called when a dynamic host catalog is deleted.
|
|
func (p *HostPlugin) 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")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
_, err = newGCPCatalogPersistedState(
|
|
append([]gcpCatalogPersistedStateOption{
|
|
withCredentials(credState),
|
|
}, p.testCatalogStateOpts...)...,
|
|
)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
|
}
|
|
|
|
// try to delete static credentials for dynamic and static credentials
|
|
if credState.CredentialsConfig.IsRotatable() {
|
|
if !credState.CredsLastRotatedTime.IsZero() {
|
|
// Delete old/existing credentials. This is done with the same
|
|
// credentials to ensure that it has the proper permissions to do
|
|
// it.
|
|
if err := credState.DeleteCreds(ctx, p.testGCPClientOpts...); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
return &pb.OnDeleteCatalogResponse{}, nil
|
|
}
|
|
|
|
// NormalizeCatalogData is called to normalize the catalog data.
|
|
func (p *HostPlugin) NormalizeCatalogData(ctx context.Context, req *pb.NormalizeCatalogDataRequest) (*pb.NormalizeCatalogDataResponse, error) {
|
|
// No-op, Catalog fields do not require any normalization
|
|
return &pb.NormalizeCatalogDataResponse{Attributes: req.Attributes}, nil
|
|
}
|
|
|
|
// NormalizeSetData currently ensures that "filters" is an array value, even
|
|
// though it's accepted as a string value for CLI UX reasons
|
|
func (p *HostPlugin) NormalizeSetData(ctx context.Context, req *pb.NormalizeSetDataRequest) (*pb.NormalizeSetDataResponse, error) {
|
|
if req.Attributes == nil {
|
|
return &pb.NormalizeSetDataResponse{}, nil
|
|
}
|
|
|
|
val := req.Attributes.Fields["filters"]
|
|
if val == nil {
|
|
return &pb.NormalizeSetDataResponse{Attributes: req.Attributes}, nil
|
|
}
|
|
|
|
switch val.GetKind().(type) {
|
|
case *structpb.Value_ListValue:
|
|
return &pb.NormalizeSetDataResponse{Attributes: req.Attributes}, nil
|
|
case *structpb.Value_StringValue:
|
|
stringVal := val.Kind.(*structpb.Value_StringValue)
|
|
retAttrs := proto.Clone(req.Attributes).(*structpb.Struct)
|
|
retAttrs.Fields["filters"] = structpb.NewListValue(
|
|
&structpb.ListValue{
|
|
Values: []*structpb.Value{
|
|
structpb.NewStringValue(stringVal.StringValue),
|
|
},
|
|
})
|
|
return &pb.NormalizeSetDataResponse{Attributes: retAttrs}, nil
|
|
default:
|
|
return &pb.NormalizeSetDataResponse{}, status.Error(codes.InvalidArgument, "attribute.filters must be a string or list")
|
|
}
|
|
}
|
|
|
|
// OnCreateSet is called when a dynamic host set is created.
|
|
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
|
|
}
|
|
|
|
// OnUpdateSet is called when a dynamic host set is updated.
|
|
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
|
|
}
|
|
|
|
// OnDeleteSet is called when a dynamic host set is deleted.
|
|
func (p *HostPlugin) OnDeleteSet(ctx context.Context, req *pb.OnDeleteSetRequest) (*pb.OnDeleteSetResponse, error) {
|
|
return &pb.OnDeleteSetResponse{}, nil
|
|
}
|
|
|
|
// ListHosts returns the list of instances based on instance filter.
|
|
func (p *HostPlugin) 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
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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, fmt.Sprintf("set %s missing attributes", set.GetId()))
|
|
}
|
|
setAttrs, err := getSetAttributes(set.GetAttributes())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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 := catalogState.InstancesClient(ctx, p.testGCPClientOpts...)
|
|
if err != nil {
|
|
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 {
|
|
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
|
|
|
|
// 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]
|
|
|
|
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"
|
|
}
|
|
}
|
|
|
|
if len(badFields) > 0 {
|
|
return errors.InvalidArgumentError("Invalid arguments in the new set", badFields)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// dryRunValidation performs an GCP List Instances call to verify the state's
|
|
// credentials. If the call fails, the error is returned.
|
|
func dryRunValidation(
|
|
ctx context.Context,
|
|
state *gcpCatalogPersistedState,
|
|
listFilters []string,
|
|
clientOptions ...option.ClientOption,
|
|
) *status.Status {
|
|
if state == nil {
|
|
return status.New(codes.InvalidArgument, "persisted state is required")
|
|
}
|
|
|
|
instancesClient, err := state.InstancesClient(ctx, clientOptions...)
|
|
if err != nil {
|
|
return status.New(codes.InvalidArgument, fmt.Sprintf("error getting instances client: %s", err))
|
|
}
|
|
|
|
var filters string
|
|
if len(listFilters) > 0 {
|
|
filters = strings.Join(listFilters, " AND ")
|
|
}
|
|
|
|
it := instancesClient.List(ctx, &computepb.ListInstancesRequest{
|
|
Project: state.CredentialsConfig.ProjectId,
|
|
Zone: state.CredentialsConfig.Zone,
|
|
Filter: &filters,
|
|
})
|
|
|
|
_, err = it.Next()
|
|
if err != nil && err != iterator.Done {
|
|
return status.New(codes.FailedPrecondition, fmt.Sprintf("gcp list instances failed: %s", err))
|
|
}
|
|
|
|
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, _, value, valid := extractFilterValue(filterAttr)
|
|
switch {
|
|
case !valid:
|
|
return nil, fmt.Errorf("invalid filter %q. Ensure the operator is one of =, !=, >, <, <=, >=, :, eq, ne", filterAttr)
|
|
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)([^=><!:\s]+)\s*(=|!=|>|<|<=|>=|:|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
|
|
}
|