feat: package structure (#2)

Update structure to match other Boundary plugins which groups packages by service
This commit is contained in:
Elim Tsiagbey authored and GitHub committed 2024-09-16 14:28:54 -04:00
1 parent cd73a74e1e
commit 6588e10934
8 files changed
+309 -280

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package host
import (
"fmt"
computepb "cloud.google.com/go/compute/apiv1/computepb"
cred "github.com/hashicorp/boundary-plugin-gcp/internal/credential"
"github.com/hashicorp/boundary-plugin-gcp/internal/errors"
"github.com/hashicorp/boundary-plugin-gcp/internal/values"
"github.com/mitchellh/mapstructure"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
// CatalogAttributes defines a set of attributes for the host catalog
type CatalogAttributes struct {
*cred.CredentialAttributes
}
func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
unknownFields := values.StructFields(in)
badFields := make(map[string]string)
var err error
credAttributes, err := cred.GetCredentialAttributes(in)
if err != nil {
return nil, err
}
for s := range unknownFields {
switch s {
// Ignore knownFields from CredentialAttributes
case cred.ConstProject:
continue
case cred.ConstZone:
continue
default:
badFields[fmt.Sprintf("attributes.%s", s)] = "unrecognized field"
}
}
if len(badFields) > 0 {
return nil, errors.InvalidArgumentError("Invalid arguments in catalog attributes", badFields)
}
return &CatalogAttributes{
CredentialAttributes: credAttributes,
}, nil
}
// SetAttributes defines attributes fro the host set
type SetAttributes struct {
Filter string `mapstructure:"filter"`
InstanceGroup string `mapstructure:"instance_group"`
}
func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) {
var setAttrs SetAttributes
badFields := make(map[string]string)
unknownFields := values.StructFields(in)
if _, filter := unknownFields[ConstListInstancesFilter]; filter {
if _, ig := unknownFields[ConstInstanceGroup]; ig {
return nil, errors.InvalidArgumentError("Error in the attributes provided, cannot define both filter and instance group", nil)
}
}
delete(unknownFields, ConstListInstancesFilter)
delete(unknownFields, ConstInstanceGroup)
for a := range unknownFields {
badFields[fmt.Sprintf("attributes.%s", a)] = "unrecognized field"
}
if len(badFields) > 0 {
return nil, errors.InvalidArgumentError("Error in the attributes provided", badFields)
}
// Mapstructure complains if it expects a slice as output and sees a scalar
// value. Rather than use WeakDecode and risk unintended consequences, I'm
// manually making this change if necessary.
inMap := in.AsMap()
if filtersRaw, ok := inMap[ConstListInstancesFilter]; ok {
switch filterVal := filtersRaw.(type) {
case string:
inMap[ConstListInstancesFilter] = string(filterVal)
}
}
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)
}
return &setAttrs, nil
}
func buildListInstancesRequest(attributes *SetAttributes, catalog *CatalogAttributes) *computepb.ListInstancesRequest {
request := &computepb.ListInstancesRequest{
Project: catalog.Project,
Zone: catalog.Zone,
}
if len(attributes.Filter) > 1 {
request.Filter = &attributes.Filter
}
return request
}
func buildListInstanceGroupsRequest(attributes *SetAttributes, catalog *CatalogAttributes) *computepb.ListInstancesInstanceGroupsRequest {
request := &computepb.ListInstancesInstanceGroupsRequest{
InstanceGroup: attributes.InstanceGroup,
Project: catalog.Project,
Zone: catalog.Zone,
}
if len(attributes.Filter) > 1 {
request.Filter = &attributes.Filter
}
return request
}
+145
View File
@@ -0,0 +1,145 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package host
import (
"testing"
cred "github.com/hashicorp/boundary-plugin-gcp/internal/credential"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
func TestGetCatalogAttributes(t *testing.T) {
cases := []struct {
name string
in *structpb.Struct
expected *CatalogAttributes
expectedErrContains string
}{
{
name: "missing project and zone",
in: &structpb.Struct{
Fields: make(map[string]*structpb.Value),
},
expectedErrContains: "attributes.project: missing required value \"project\", attributes.zone: missing required value \"zone\"",
},
{
name: "unknown fields",
in: &structpb.Struct{
Fields: map[string]*structpb.Value{
"project": structpb.NewStringValue("test-12345"),
"zone": structpb.NewStringValue("us-central-1a"),
"foo": structpb.NewBoolValue(true),
"bar": structpb.NewBoolValue(true),
},
},
expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field",
},
{
name: "valid project and zone",
in: &structpb.Struct{
Fields: map[string]*structpb.Value{
"project": structpb.NewStringValue("test-12345"),
"zone": structpb.NewStringValue("us-central-1a"),
},
},
expected: &CatalogAttributes{&cred.CredentialAttributes{
Project: "test-12345",
Zone: "us-central-1a",
}},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
actual, err := getCatalogAttributes(tc.in)
if tc.expectedErrContains != "" {
require.Error(err)
require.Contains(err.Error(), tc.expectedErrContains)
require.Equal(status.Code(err), codes.InvalidArgument)
return
}
require.NoError(err)
require.Equal(tc.expected, actual)
})
}
}
func TestGetSetAttributes(t *testing.T) {
cases := []struct {
name string
in map[string]any
normalized map[string]any
expected *SetAttributes
expectedErrContains string
}{
{
name: "missing",
in: map[string]any{},
expected: &SetAttributes{},
},
{
name: "valid example filter",
in: map[string]any{
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",
},
},
{
name: "valid example instance group",
in: map[string]any{
ConstInstanceGroup: "test",
},
expected: &SetAttributes{
InstanceGroup: "test",
},
},
{
name: "unknown fields",
in: map[string]any{
"foo": true,
"bar": true,
},
expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field",
},
{
name: "invalid, cannot have both instance filter and group defined",
in: map[string]any{
ConstListInstancesFilter: "test",
ConstInstanceGroup: "test",
},
expectedErrContains: "Error in the attributes provided, cannot define both filter and instance group",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
input, err := structpb.NewStruct(tc.in)
require.NoError(err)
actual, err := getSetAttributes(input)
if tc.expectedErrContains != "" {
require.Error(err)
require.Contains(err.Error(), tc.expectedErrContains)
require.Equal(status.Code(err), codes.InvalidArgument)
return
}
require.NoError(err)
require.Equal(tc.expected, actual)
})
}
}
+128
View File
@@ -0,0 +1,128 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package host
import (
"context"
"errors"
"path"
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"
"google.golang.org/grpc/codes"
"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) {
hosts := []*computepb.Instance{}
it := c.InstancesClient.List(c.Context, 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)
}
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.GetName() == "" {
return nil, errors.New("response integrity error: missing instance name")
}
result := new(pb.ListHostsResponseHost)
result.ExternalId = instance.GetSelfLink()
result.ExternalName = instance.GetName()
// 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
// for the entire instance.
result.IpAddresses = appendDistinct(result.IpAddresses, iface.NetworkIP)
for _, external := range iface.AccessConfigs {
result.IpAddresses = appendDistinct(result.IpAddresses, external.NatIP)
}
// Add the IPv6 addresses.
result.IpAddresses = appendDistinct(result.IpAddresses, iface.Ipv6Address)
}
// Done
return result, nil
}
// appendDistinct will append the elements to the slice
// if an element is not nil, empty, and does not exist in slice.
func appendDistinct(slice []string, elems ...*string) []string {
for _, e := range elems {
if e == nil || *e == "" || stringInSlice(slice, *e) {
continue
}
slice = append(slice, *e)
}
return slice
}
func stringInSlice(s []string, x string) bool {
for _, y := range s {
if x == y {
return true
}
}
return false
}
+148
View File
@@ -0,0 +1,148 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package host
import (
"testing"
"cloud.google.com/go/compute/apiv1/computepb"
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"
examplePrivateIp := "10.0.0.1"
examplePrivateIp2 := "10.0.0.2"
examplePublicIp := "1.1.1.1"
examplePublicIp2 := "1.1.1.2"
exampleIPv6 := "some::fake::address"
exampleName := "test-instance"
cases := []struct {
name string
instance *computepb.Instance
expected *pb.ListHostsResponseHost
expectedErr string
}{
{
name: "missing instance self-link",
instance: &computepb.Instance{
Name: &exampleName,
},
expectedErr: "response integrity error: missing instance self-link",
},
{
name: "missing instance name",
instance: &computepb.Instance{
SelfLink: &exampleId,
},
expectedErr: "response integrity error: missing instance name",
},
{
name: "good, single private IP with public IP address",
instance: &computepb.Instance{
SelfLink: &exampleId,
Name: &exampleName,
NetworkInterfaces: []*computepb.NetworkInterface{
{
NetworkIP: &examplePrivateIp,
AccessConfigs: []*computepb.AccessConfig{
{
NatIP: &examplePublicIp,
},
},
},
},
},
expected: &pb.ListHostsResponseHost{
ExternalId: exampleId,
ExternalName: exampleName,
IpAddresses: []string{examplePrivateIp, examplePublicIp},
},
},
{
name: "good, single private IP address",
instance: &computepb.Instance{
SelfLink: &exampleId,
Name: &exampleName,
NetworkInterfaces: []*computepb.NetworkInterface{
{
NetworkIP: &examplePrivateIp,
AccessConfigs: []*computepb.AccessConfig{},
},
},
},
expected: &pb.ListHostsResponseHost{
ExternalId: exampleId,
ExternalName: exampleName,
IpAddresses: []string{examplePrivateIp},
},
},
{
name: "good, multiple interfaces",
instance: &computepb.Instance{
SelfLink: &exampleId,
Name: &exampleName,
NetworkInterfaces: []*computepb.NetworkInterface{
{
NetworkIP: &examplePrivateIp,
AccessConfigs: []*computepb.AccessConfig{
{
NatIP: &examplePublicIp,
},
},
},
{
NetworkIP: &examplePrivateIp2,
AccessConfigs: []*computepb.AccessConfig{
{
NatIP: &examplePublicIp2,
},
},
},
},
},
expected: &pb.ListHostsResponseHost{
ExternalId: exampleId,
ExternalName: exampleName,
IpAddresses: []string{examplePrivateIp, examplePublicIp, examplePrivateIp2, examplePublicIp2},
},
},
{
name: "good, single private IP address with IPv6",
instance: &computepb.Instance{
SelfLink: &exampleId,
Name: &exampleName,
NetworkInterfaces: []*computepb.NetworkInterface{
{
NetworkIP: &examplePrivateIp,
Ipv6Address: &exampleIPv6,
AccessConfigs: []*computepb.AccessConfig{},
},
},
},
expected: &pb.ListHostsResponseHost{
ExternalId: exampleId,
ExternalName: exampleName,
IpAddresses: []string{examplePrivateIp, exampleIPv6},
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
actual, err := instanceToHost(tc.instance)
if tc.expectedErr != "" {
require.EqualError(err, tc.expectedErr)
return
}
require.NoError(err)
require.Equal(tc.expected, actual)
})
}
}
+17
View File
@@ -0,0 +1,17 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package host
const (
// ConstListInstancesFilter refers to a Google Cloud SDK filter to search for instances
ConstListInstancesFilter = "filter"
// ConstInstanceGroup refers to the name of a Google Cloud instance group
ConstInstanceGroup = "instance_group"
)
var allowedSetFields = map[string]struct{}{
ConstListInstancesFilter: {},
ConstInstanceGroup: {},
}
+285
View File
@@ -0,0 +1,285 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package host
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"
)
// HostPlugin implements the HostPluginServiceServer interface for the
// GCP host service plugin.
type HostPlugin struct {
pb.UnimplementedHostPluginServiceServer
}
var (
_ pb.HostPluginServiceServer = (*HostPlugin)(nil)
)
// OnCreateCatalog is called when a dynamic host catalog is created.
func (p *HostPlugin) 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 *HostPlugin) 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 *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")
}
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 *HostPlugin) 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 *HostPlugin) 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 *HostPlugin) 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 *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
}
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
}
+701
View File
@@ -0,0 +1,701 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package host
import (
"context"
"os"
"path/filepath"
"testing"
cred "github.com/hashicorp/boundary-plugin-gcp/internal/credential"
"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/protobuf/types/known/structpb"
)
func wrapMap(t *testing.T, in map[string]interface{}) *structpb.Struct {
t.Helper()
out, err := structpb.NewStruct(in)
require.NoError(t, err)
return out
}
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.ConstProject: project,
cred.ConstZone: zone,
}),
}
cases := []struct {
name string
req *pb.ListHostsRequest
expected []*pb.ListHostsResponseHost
expectedErr string
}{
{
name: "nil catalog",
req: &pb.ListHostsRequest{},
expectedErr: "catalog is nil",
},
{
name: "project not defined",
req: &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: wrapMap(t, map[string]interface{}{
cred.ConstZone: zone,
}),
},
},
},
expectedErr: "attributes.project: missing required value \"project\"",
},
{
name: "get all three instances",
req: &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: hostCatalogAttributes,
},
Sets: []*hostsets.HostSet{
{
Id: "get-all-instances",
Attrs: &hostsets.HostSet_Attributes{
Attributes: wrapMap(t, map[string]interface{}{}),
},
},
},
},
expected: []*pb.ListHostsResponseHost{
{
Name: "boundary-0",
},
{
Name: "boundary-1",
},
{
Name: "boundary-2",
},
},
},
{
name: "get one instance by name",
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",
}),
},
},
},
},
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",
}),
},
},
},
},
expected: []*pb.ListHostsResponseHost{
{
Name: "boundary-0",
},
{
Name: "boundary-1",
},
{
Name: "boundary-2",
},
},
},
{
name: "get two specific instances with two host sets",
req: &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: hostCatalogAttributes,
},
Sets: []*hostsets.HostSet{
{
Id: "get-one-instance-by-name-0",
Attrs: &hostsets.HostSet_Attributes{
Attributes: wrapMap(t, map[string]interface{}{
ConstListInstancesFilter: "name = boundary-0",
}),
},
},
{
Id: "get-one-instance-by-name-2",
Attrs: &hostsets.HostSet_Attributes{
Attributes: wrapMap(t, map[string]interface{}{
ConstListInstancesFilter: "name = boundary-2",
}),
},
},
},
},
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",
}),
},
},
{
Id: "get-instance-group",
Attrs: &hostsets.HostSet_Attributes{
Attributes: wrapMap(t, map[string]interface{}{
ConstInstanceGroup: "boundary-servers",
}),
},
},
},
},
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",
}),
},
},
{
Id: "get-one-instance-by-name",
Attrs: &hostsets.HostSet_Attributes{
Attributes: wrapMap(t, map[string]interface{}{
ConstListInstancesFilter: "name = boundary-2",
}),
},
},
},
},
expected: []*pb.ListHostsResponseHost{
{
Name: "boundary-0",
},
{
Name: "boundary-1",
},
{
Name: "boundary-2",
},
},
},
{
name: "invalid filter",
req: &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: hostCatalogAttributes,
},
Sets: []*hostsets.HostSet{
{
Id: "invalid-filter",
Attrs: &hostsets.HostSet_Attributes{
Attributes: wrapMap(t, map[string]interface{}{
ConstListInstancesFilter: "not-a-filter",
}),
},
},
},
},
expectedErr: "Invalid list filter expression",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
actual, err := p.ListHosts(ctx, tc.req)
if tc.expectedErr != "" {
require.Contains(err.Error(), tc.expectedErr)
return
}
require.NoError(err)
require.Equal(len(tc.expected), len(actual.GetHosts()))
})
}
}
func TestCreateCatalog(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.ConstProject: project,
cred.ConstZone: zone,
}),
}
require.NoError(t, err)
cases := []struct {
name string
req *pb.OnCreateCatalogRequest
expected *pb.HostCatalogPersisted
expectedErr string
}{
{
name: "nil catalog",
req: &pb.OnCreateCatalogRequest{},
expectedErr: "catalog is nil",
},
{
name: "nil attributes",
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{},
},
expectedErr: "attributes are required",
},
{
name: "error reading attributes",
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: new(structpb.Struct),
},
},
},
expectedErr: "attributes.project: missing required value \"project\"",
},
{
name: "do not persist secrets, use gcloud application default creds",
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: hostCatalogAttributes,
},
},
expected: &pb.HostCatalogPersisted{
Secrets: nil,
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
actual, err := p.OnCreateCatalog(ctx, tc.req)
if tc.expectedErr != "" {
require.Contains(err.Error(), tc.expectedErr)
return
}
require.NoError(err)
require.Nil(actual.GetPersisted().Secrets)
})
}
}
func TestUpdateCatalog(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.ConstProject: project,
cred.ConstZone: zone,
}),
}
require.NoError(t, err)
cases := []struct {
name string
req *pb.OnCreateCatalogRequest
expected *pb.HostCatalogPersisted
expectedErr string
}{
{
name: "nil catalog",
req: &pb.OnCreateCatalogRequest{},
expectedErr: "catalog is nil",
},
{
name: "nil attributes",
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{},
},
expectedErr: "attributes are required",
},
{
name: "error reading attributes",
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: new(structpb.Struct),
},
},
},
expectedErr: "attributes.project: missing required value \"project\"",
},
{
name: "do not persist secrets, use gcloud ADC",
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: hostCatalogAttributes,
},
},
expected: &pb.HostCatalogPersisted{
Secrets: nil,
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
actual, err := p.OnCreateCatalog(ctx, tc.req)
if tc.expectedErr != "" {
require.Contains(err.Error(), tc.expectedErr)
return
}
require.NoError(err)
require.Equal(actual.GetPersisted().Secrets, tc.expected.GetSecrets())
})
}
}
func TestCreateSet(t *testing.T) {
ctx := context.Background()
p := &HostPlugin{}
cases := []struct {
name string
req *pb.OnCreateSetRequest
expectedErr string
}{
{
name: "nil set",
req: &pb.OnCreateSetRequest{},
expectedErr: "set is nil",
},
{
name: "nil set",
req: &pb.OnCreateSetRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
cred.ConstProject: structpb.NewStringValue("test-project"),
cred.ConstZone: structpb.NewStringValue("us-central1-a"),
},
},
},
},
},
expectedErr: "set is nil",
},
{
name: "only allow instance group or filter, not both",
req: &pb.OnCreateSetRequest{
Set: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstListInstancesFilter: structpb.NewStringValue("status=RUNNING"),
ConstInstanceGroup: structpb.NewStringValue("test"),
},
},
},
},
},
expectedErr: "attributes: must set instance group or filter",
},
{
name: "empty filter",
req: &pb.OnCreateSetRequest{
Set: &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.OnCreateSetRequest{
Set: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstInstanceGroup: structpb.NewStringValue(""),
},
},
},
},
},
expectedErr: "attributes.instance_group: must not be empty",
},
{
name: "good filter",
req: &pb.OnCreateSetRequest{
Set: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstInstanceGroup: structpb.NewStringValue("status=RUNNING"),
},
},
},
},
},
},
{
name: "good instance group",
req: &pb.OnCreateSetRequest{
Set: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstInstanceGroup: structpb.NewStringValue("test"),
},
},
},
},
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
_, err := p.OnCreateSet(ctx, tc.req)
if tc.expectedErr != "" {
require.Contains(err.Error(), tc.expectedErr)
return
}
require.NoError(err)
})
}
}
func TestUpdateSet(t *testing.T) {
ctx := context.Background()
p := &HostPlugin{}
cases := []struct {
name string
req *pb.OnUpdateSetRequest
expectedErr string
}{
{
name: "nil set",
req: &pb.OnUpdateSetRequest{},
expectedErr: "set is nil",
},
{
name: "nil set",
req: &pb.OnUpdateSetRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
cred.ConstProject: structpb.NewStringValue("test-project"),
cred.ConstZone: structpb.NewStringValue("us-central1-a"),
},
},
},
},
},
expectedErr: "set is nil",
},
{
name: "only allow instance group or filter, not both",
req: &pb.OnUpdateSetRequest{
NewSet: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstListInstancesFilter: structpb.NewStringValue("status=RUNNING"),
ConstInstanceGroup: structpb.NewStringValue("test"),
},
},
},
},
},
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{
NewSet: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstInstanceGroup: structpb.NewStringValue(""),
},
},
},
},
},
expectedErr: "attributes.instance_group: must not be empty",
},
{
name: "good filter",
req: &pb.OnUpdateSetRequest{
NewSet: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstInstanceGroup: structpb.NewStringValue("status=RUNNING"),
},
},
},
},
},
},
{
name: "good instance group",
req: &pb.OnUpdateSetRequest{
NewSet: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstInstanceGroup: structpb.NewStringValue("test"),
},
},
},
},
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
_, err := p.OnUpdateSet(ctx, tc.req)
if tc.expectedErr != "" {
require.Contains(err.Error(), tc.expectedErr)
return
}
require.NoError(err)
})
}
}