feat: OnCreateCatalog (#8)
* feat: OnCreateCatalog - Update onCreateCatalog to support credential rotation. Service authentication type creds will be rotated since we will have the credentials for the base service account without sufficient permissions. - `onCreateCatalog` will also do a dry run validation to validate the credentials. - The secrets and last rotated time will be returned
This commit is contained in:
1 parent
636432f2fb
commit
6b917b170d
14 files changed
+992
-90
No files matched your search
@@ -5,6 +5,8 @@ go 1.21
|
||||
require (
|
||||
cloud.google.com/go/iam v1.2.0
|
||||
cloud.google.com/go/resourcemanager v1.10.1
|
||||
github.com/google/go-cmp v0.6.0
|
||||
github.com/googleapis/gax-go/v2 v2.13.0
|
||||
github.com/hashicorp/boundary/sdk v0.0.47
|
||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8
|
||||
golang.org/x/oauth2 v0.22.0
|
||||
@@ -23,7 +25,6 @@ require (
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/s2a-go v0.1.8 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.3 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.13.0 // indirect
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
|
||||
github.com/hashicorp/go-sockaddr v1.0.5 // indirect
|
||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||
|
||||
@@ -72,3 +72,70 @@ func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error)
|
||||
TargetServiceAccountId: targetServiceAccountId,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCredentialsConfig parses values out of a protobuf struct secrets and returns a
|
||||
// Config used for configuring an GCP session. An status error is returned
|
||||
// with an InvalidArgument code if any unrecognized fields are found in the protobuf
|
||||
// struct input.
|
||||
func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes) (*Config, error) {
|
||||
// initialize secrets if it is nil
|
||||
// secrets can be nil if the authentication type is Application Default Credential(ADC)
|
||||
if secrets == nil {
|
||||
secrets = &structpb.Struct{
|
||||
Fields: make(map[string]*structpb.Value),
|
||||
}
|
||||
}
|
||||
|
||||
// initialize attrs if it is nil
|
||||
// attributes can be nil if the authentication type is Application Default Credential(ADC)
|
||||
// where the credentials are inferred from the environment and no fields are provided
|
||||
// https://cloud.google.com/docs/authentication/application-default-credentials
|
||||
if attrs == nil {
|
||||
attrs = &CredentialAttributes{}
|
||||
}
|
||||
|
||||
unknownFields := values.StructFields(secrets)
|
||||
badFields := make(map[string]string)
|
||||
|
||||
privateKeyId, err := values.GetStringValue(secrets, ConstPrivateKeyId, false)
|
||||
if err != nil {
|
||||
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKeyId)] = err.Error()
|
||||
}
|
||||
delete(unknownFields, ConstPrivateKeyId)
|
||||
|
||||
privateKey, err := values.GetStringValue(secrets, ConstPrivateKey, false)
|
||||
if err != nil {
|
||||
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKey)] = err.Error()
|
||||
}
|
||||
delete(unknownFields, ConstPrivateKey)
|
||||
|
||||
delete(unknownFields, ConstCredsLastRotatedTime)
|
||||
for s := range unknownFields {
|
||||
badFields[fmt.Sprintf("secrets.%s", s)] = "unrecognized field"
|
||||
}
|
||||
|
||||
switch {
|
||||
// dynamic credentials requires the target service account id, cl and private key
|
||||
case attrs.TargetServiceAccountId != "" && (privateKey == "" || attrs.ClientEmail == ""):
|
||||
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKey)] = "must not be empty when target service account id is set"
|
||||
badFields[fmt.Sprintf("attributes.%s", ConstClientEmail)] = "must not be empty when target service account id is set"
|
||||
// static credentials requires the private key and client email
|
||||
case privateKey != "" && attrs.ClientEmail == "":
|
||||
badFields[fmt.Sprintf("attributes.%s", ConstClientEmail)] = "must not be empty when private key is set"
|
||||
case attrs.ClientEmail != "" && privateKey == "":
|
||||
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKey)] = "must not be empty when client email is set"
|
||||
}
|
||||
|
||||
if len(badFields) > 0 {
|
||||
return nil, errors.InvalidArgumentError("Error in the secrets provided", badFields)
|
||||
}
|
||||
|
||||
return &Config{
|
||||
ProjectId: attrs.ProjectId,
|
||||
ClientEmail: attrs.ClientEmail,
|
||||
Zone: attrs.Zone,
|
||||
PrivateKey: privateKey,
|
||||
PrivateKeyId: privateKeyId,
|
||||
TargetServiceAccountId: attrs.TargetServiceAccountId,
|
||||
}, nil
|
||||
}
|
||||
@@ -104,3 +104,166 @@ func TestGetCredentialAttributes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCredentialsConfig(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
secrets map[string]any
|
||||
attrs *CredentialAttributes
|
||||
expected *Config
|
||||
expectedErrContains string
|
||||
}{
|
||||
{
|
||||
name: "nil secrets",
|
||||
attrs: &CredentialAttributes{
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
expected: &Config{
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil attributes",
|
||||
attrs: nil,
|
||||
expected: &Config{},
|
||||
},
|
||||
{
|
||||
name: "unknown fields in secrets",
|
||||
secrets: map[string]any{
|
||||
"unknown_field": "value",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
expectedErrContains: "secrets.unknown_field: unrecognized field",
|
||||
},
|
||||
{
|
||||
name: "valid ignore creds_last_rotated_time",
|
||||
secrets: map[string]any{
|
||||
ConstPrivateKeyId: "test-private-key-id",
|
||||
ConstPrivateKey: "test-private-key",
|
||||
ConstCredsLastRotatedTime: "2006-01-02T15:04:05+07:00",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
ClientEmail: "test@test.com",
|
||||
},
|
||||
expected: &Config{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
ClientEmail: "test@test.com",
|
||||
PrivateKey: "test-private-key",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing private key with target service account id",
|
||||
secrets: map[string]any{
|
||||
ConstClientEmail: "test@test.com",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
TargetServiceAccountId: "test-target-service-account",
|
||||
},
|
||||
expectedErrContains: "must not be empty when target service account id is set",
|
||||
},
|
||||
{
|
||||
name: "missing client email with target service account id",
|
||||
secrets: map[string]any{
|
||||
ConstPrivateKey: "private-key",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
TargetServiceAccountId: "test-target-service-account",
|
||||
},
|
||||
expectedErrContains: "must not be empty when target service account id is set",
|
||||
},
|
||||
{
|
||||
name: "missing client email with private key",
|
||||
secrets: map[string]any{
|
||||
ConstPrivateKeyId: "test-private-key-id",
|
||||
ConstPrivateKey: "test-private-key",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
expectedErrContains: "must not be empty when private key is set",
|
||||
},
|
||||
{
|
||||
name: "missing private key with client email",
|
||||
secrets: map[string]any{},
|
||||
attrs: &CredentialAttributes{
|
||||
ClientEmail: "test@test.com",
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
expectedErrContains: "must not be empty when client email is set",
|
||||
},
|
||||
{
|
||||
name: "valid static credentials",
|
||||
secrets: map[string]any{
|
||||
ConstPrivateKeyId: "test-private-key-id",
|
||||
ConstPrivateKey: "test-private-key",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ClientEmail: "test@test.com",
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
expected: &Config{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
PrivateKey: "test-private-key",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
ClientEmail: "test@test.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid dynamic credentials",
|
||||
secrets: map[string]any{
|
||||
ConstPrivateKeyId: "test-private-key-id",
|
||||
ConstPrivateKey: "test-private-key",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ClientEmail: "test@test.com",
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
TargetServiceAccountId: "test-target-service-account",
|
||||
},
|
||||
expected: &Config{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
PrivateKey: "test-private-key",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
ClientEmail: "test@test.com",
|
||||
TargetServiceAccountId: "test-target-service-account",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
secrets, err := structpb.NewStruct(tc.secrets)
|
||||
require.NoError(err)
|
||||
|
||||
actual, err := GetCredentialsConfig(secrets, tc.attrs)
|
||||
if tc.expectedErrContains != "" {
|
||||
require.Error(err)
|
||||
require.Contains(err.Error(), tc.expectedErrContains)
|
||||
require.Equal(status.Code(err), codes.InvalidArgument)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.EqualValues(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ type Config struct {
|
||||
ProjectId string
|
||||
PrivateKey string
|
||||
PrivateKeyId string
|
||||
Zone string
|
||||
ClientEmail string
|
||||
TargetServiceAccountId string
|
||||
Scopes []string
|
||||
@@ -166,6 +167,22 @@ func (c *Config) credentialsFromADC(ctx context.Context) (*google.Credentials, e
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// IsRotatable returns a boolean indicating if the credentials are rotatable.
|
||||
func (c *Config) IsRotatable() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(c.TargetServiceAccountId) > 0 && len(c.PrivateKey) > 0 && len(c.ClientEmail) > 0:
|
||||
return true
|
||||
case len(c.PrivateKey) > 0 && len(c.ClientEmail) > 0:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// impersonateServiceAccount impersonates the service account.
|
||||
// This is used to impersonate the service account to generate an access token.
|
||||
func impersonateServiceAccount(
|
||||
|
||||
@@ -340,3 +340,46 @@ func TestClone(t *testing.T) {
|
||||
require.NotEqual(t, originalConfig.TargetServiceAccountId, clonedConfig.TargetServiceAccountId)
|
||||
require.NotEqual(t, originalConfig.Scopes, clonedConfig.Scopes)
|
||||
}
|
||||
|
||||
func TestIsRotatable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "Nil Config",
|
||||
config: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Service Account Impersonation Config",
|
||||
config: &Config{
|
||||
PrivateKey: "fake-private-key",
|
||||
ClientEmail: "test-email@example.com",
|
||||
TargetServiceAccountId: "target-service-account@example.com",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Static GCP Config",
|
||||
config: &Config{
|
||||
PrivateKey: "fake-private-key",
|
||||
ClientEmail: "test-email@example.com",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Default Config",
|
||||
config: &Config{},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.config.IsRotatable()
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,4 +21,10 @@ const (
|
||||
|
||||
// ConstTargetServiceAccountID is the unique identifier for the service account that will be impersonated.
|
||||
ConstTargetServiceAccountID = "target_service_account_id"
|
||||
|
||||
// ConstPrivateKeyId is the private key id associated with the service account
|
||||
ConstPrivateKeyId = "private_key_id"
|
||||
|
||||
// ConstPrivateKey is the private key associated with the service account
|
||||
ConstPrivateKey = "private_key"
|
||||
)
|
||||
@@ -6,8 +6,6 @@ package credential
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"cloud.google.com/go/iam/admin/apiv1/adminpb"
|
||||
@@ -19,7 +17,6 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
func TestRotateServiceAccountKey(t *testing.T) {
|
||||
@@ -106,10 +103,10 @@ func TestRotateServiceAccountKey(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gsrv := newGRPCServer()
|
||||
gsrv := NewGRPCServer()
|
||||
adminpb.RegisterIAMServer(gsrv.Server, testIAMAdminServer)
|
||||
resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer)
|
||||
addr, err := gsrv.serve()
|
||||
addr, err := gsrv.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -245,9 +242,9 @@ func TestValidateIamPermissions(t *testing.T) {
|
||||
testResourceServer.testIamPermissionsResponse = tt.testIamResponse
|
||||
testResourceServer.testIamPermissionsError = tt.testIamPermissionsError
|
||||
|
||||
gsrv := newGRPCServer()
|
||||
gsrv := NewGRPCServer()
|
||||
resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer)
|
||||
addr, err := gsrv.serve()
|
||||
addr, err := gsrv.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
testOptions := []googleOption.ClientOption{
|
||||
@@ -267,52 +264,3 @@ func TestValidateIamPermissions(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type testIAMAdminServer struct {
|
||||
adminpb.UnimplementedIAMServer
|
||||
testCreateServiceAccountKeyError error
|
||||
testDeleteServiceAccountKeyError error
|
||||
}
|
||||
|
||||
func (f *testIAMAdminServer) CreateServiceAccountKey(ctx context.Context, req *adminpb.CreateServiceAccountKeyRequest) (*adminpb.ServiceAccountKey, error) {
|
||||
resp := &adminpb.ServiceAccountKey{
|
||||
Name: "projects/-/serviceAccounts/1234/keys/updated-private-key-id",
|
||||
PrivateKeyData: []byte("updated-private-key"),
|
||||
}
|
||||
return resp, f.testCreateServiceAccountKeyError
|
||||
}
|
||||
|
||||
func (f *testIAMAdminServer) DeleteServiceAccountKey(ctx context.Context, req *adminpb.DeleteServiceAccountKeyRequest) (*emptypb.Empty, error) {
|
||||
return &emptypb.Empty{}, f.testDeleteServiceAccountKeyError
|
||||
}
|
||||
|
||||
type testResourceServer struct {
|
||||
resourcemanagerpb.UnimplementedProjectsServer
|
||||
testIamPermissionsResponse *iampb.TestIamPermissionsResponse
|
||||
testIamPermissionsError error
|
||||
}
|
||||
|
||||
func (f *testResourceServer) TestIamPermissions(context.Context, *iampb.TestIamPermissionsRequest) (*iampb.TestIamPermissionsResponse, error) {
|
||||
return f.testIamPermissionsResponse, f.testIamPermissionsError
|
||||
}
|
||||
|
||||
type grpcServer struct {
|
||||
*grpc.Server
|
||||
}
|
||||
|
||||
func newGRPCServer() *grpcServer {
|
||||
return &grpcServer{Server: grpc.NewServer()}
|
||||
}
|
||||
|
||||
func (s *grpcServer) serve() (string, error) {
|
||||
l, err := net.Listen("tcp", "localhost:0")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to listen: %v", err)
|
||||
}
|
||||
go func() {
|
||||
if err := s.Serve(l); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
return l.Addr().String(), nil
|
||||
}
|
||||
@@ -28,3 +28,14 @@ func NewPersistedState(opt ...Option) (*PersistedState, error) {
|
||||
s.CredsLastRotatedTime = opts.WithCredsLastRotatedTime
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ToMap returns a map of the credentials stored in the persisted state.
|
||||
// ToMap will return a map for long-term credentials with following keys:
|
||||
// private_key_id, private_key & creds_last_rotated_time
|
||||
func (s *PersistedState) ToMap() map[string]any {
|
||||
return map[string]any{
|
||||
ConstPrivateKey: s.CredentialsConfig.PrivateKey,
|
||||
ConstPrivateKeyId: s.CredentialsConfig.PrivateKeyId,
|
||||
ConstCredsLastRotatedTime: s.CredsLastRotatedTime.Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"cloud.google.com/go/iam/admin/apiv1/adminpb"
|
||||
"cloud.google.com/go/iam/apiv1/iampb"
|
||||
"cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
type testIAMAdminServer struct {
|
||||
adminpb.UnimplementedIAMServer
|
||||
testCreateServiceAccountKeyError error
|
||||
testDeleteServiceAccountKeyError error
|
||||
}
|
||||
|
||||
func NewTestIAMAdminServer(createServiceAccountKeyError error, deleteServiceAccountKeyError error) *testIAMAdminServer {
|
||||
return &testIAMAdminServer{
|
||||
testCreateServiceAccountKeyError: createServiceAccountKeyError,
|
||||
testDeleteServiceAccountKeyError: deleteServiceAccountKeyError,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *testIAMAdminServer) CreateServiceAccountKey(ctx context.Context, req *adminpb.CreateServiceAccountKeyRequest) (*adminpb.ServiceAccountKey, error) {
|
||||
resp := &adminpb.ServiceAccountKey{
|
||||
Name: "projects/-/serviceAccounts/1234/keys/updated-private-key-id",
|
||||
PrivateKeyData: []byte("updated-private-key"),
|
||||
}
|
||||
return resp, f.testCreateServiceAccountKeyError
|
||||
}
|
||||
|
||||
func (f *testIAMAdminServer) DeleteServiceAccountKey(ctx context.Context, req *adminpb.DeleteServiceAccountKeyRequest) (*emptypb.Empty, error) {
|
||||
return &emptypb.Empty{}, f.testDeleteServiceAccountKeyError
|
||||
}
|
||||
|
||||
type testResourceServer struct {
|
||||
resourcemanagerpb.UnimplementedProjectsServer
|
||||
testIamPermissionsResponse *iampb.TestIamPermissionsResponse
|
||||
testIamPermissionsError error
|
||||
}
|
||||
|
||||
func NewTestResourceServer(testIamPermissionsResponse *iampb.TestIamPermissionsResponse, testIamPermissionsError error) *testResourceServer {
|
||||
return &testResourceServer{
|
||||
testIamPermissionsResponse: testIamPermissionsResponse,
|
||||
testIamPermissionsError: testIamPermissionsError,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *testResourceServer) TestIamPermissions(context.Context, *iampb.TestIamPermissionsRequest) (*iampb.TestIamPermissionsResponse, error) {
|
||||
return f.testIamPermissionsResponse, f.testIamPermissionsError
|
||||
}
|
||||
|
||||
type grpcServer struct {
|
||||
*grpc.Server
|
||||
}
|
||||
|
||||
func NewGRPCServer() *grpcServer {
|
||||
return &grpcServer{Server: grpc.NewServer()}
|
||||
}
|
||||
|
||||
func (s *grpcServer) Start() (string, error) {
|
||||
l, err := net.Listen("tcp", "localhost:0")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to listen: %v", err)
|
||||
}
|
||||
go func() {
|
||||
if err := s.Serve(l); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
return l.Addr().String(), nil
|
||||
}
|
||||
@@ -9,10 +9,13 @@ import (
|
||||
|
||||
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"
|
||||
"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"
|
||||
)
|
||||
@@ -21,6 +24,10 @@ import (
|
||||
// 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 (
|
||||
@@ -28,7 +35,7 @@ var (
|
||||
)
|
||||
|
||||
// OnCreateCatalog is called when a dynamic host catalog is created.
|
||||
func (p *HostPlugin) OnCreateCatalog(_ context.Context, req *pb.OnCreateCatalogRequest) (*pb.OnCreateCatalogResponse, error) {
|
||||
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")
|
||||
@@ -39,14 +46,56 @@ func (p *HostPlugin) OnCreateCatalog(_ context.Context, req *pb.OnCreateCatalogR
|
||||
return nil, status.Error(codes.InvalidArgument, "attributes are required")
|
||||
}
|
||||
|
||||
if _, err := getCatalogAttributes(attrs); err != nil {
|
||||
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 {
|
||||
if err := credState.CredentialsConfig.RotateServiceAccountKey(ctx, permissions, 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, 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: &pb.HostCatalogPersisted{
|
||||
Secrets: nil,
|
||||
},
|
||||
Persisted: persistedProto,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -283,3 +332,32 @@ func validateSet(s *hostsets.HostSet) error {
|
||||
}
|
||||
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,
|
||||
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))
|
||||
}
|
||||
|
||||
it := instancesClient.List(ctx, &computepb.ListInstancesRequest{
|
||||
Project: state.CredentialsConfig.ProjectId,
|
||||
Zone: state.CredentialsConfig.Zone,
|
||||
})
|
||||
|
||||
_, err = it.Next()
|
||||
if err != nil && err != iterator.Done {
|
||||
return status.New(codes.FailedPrecondition, fmt.Sprintf("gcp list instances failed: %s", err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -9,12 +9,22 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"cloud.google.com/go/compute/apiv1/computepb"
|
||||
"cloud.google.com/go/iam/admin/apiv1/adminpb"
|
||||
"cloud.google.com/go/iam/apiv1/iampb"
|
||||
"cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
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/api/option"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"google.golang.org/protobuf/testing/protocmp"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
@@ -295,32 +305,15 @@ func TestListHosts(t *testing.T) {
|
||||
}
|
||||
|
||||
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.ConstProjectId: project,
|
||||
cred.ConstZone: zone,
|
||||
}),
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req *pb.OnCreateCatalogRequest
|
||||
expected *pb.HostCatalogPersisted
|
||||
catalogOpts []gcpCatalogPersistedStateOption
|
||||
expectedErr string
|
||||
expectedRsp *pb.OnCreateCatalogResponse
|
||||
}{
|
||||
{
|
||||
name: "nil catalog",
|
||||
@@ -343,34 +336,200 @@ func TestCreateCatalog(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "attributes.project: missing required value \"project\"",
|
||||
expectedErr: "attributes.project_id: missing required value \"project_id\"",
|
||||
},
|
||||
{
|
||||
name: "do not persist secrets, use gcloud application default creds",
|
||||
name: "using static credentials",
|
||||
req: &pb.OnCreateCatalogRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: hostCatalogAttributes,
|
||||
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"),
|
||||
cred.ConstClientEmail: structpb.NewStringValue("test-client-email@email.com"),
|
||||
cred.ConstDisableCredentialRotation: structpb.NewBoolValue(true),
|
||||
},
|
||||
},
|
||||
},
|
||||
Secrets: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
cred.ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"),
|
||||
cred.ConstPrivateKey: structpb.NewStringValue("test-private-key"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
catalogOpts: []gcpCatalogPersistedStateOption{
|
||||
withTestInstancesAPIFunc(newTestMockInstances(ctx,
|
||||
nil,
|
||||
testMockInstancesWithListInstancesOutput(&computepb.InstanceList{
|
||||
Items: []*computepb.Instance{
|
||||
{
|
||||
Name: pointer("boundary-0"),
|
||||
NetworkInterfaces: []*computepb.NetworkInterface{
|
||||
{
|
||||
AccessConfigs: []*computepb.AccessConfig{
|
||||
{
|
||||
NatIP: pointer("102.1.1.1"),
|
||||
ExternalIpv6: pointer("2001:db8::1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
testMockInstancesWithListInstancesError(nil),
|
||||
)),
|
||||
},
|
||||
expectedRsp: &pb.OnCreateCatalogResponse{
|
||||
Persisted: &pb.HostCatalogPersisted{
|
||||
Secrets: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
cred.ConstPrivateKey: structpb.NewStringValue("test-private-key"),
|
||||
cred.ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "using rotated static credentials",
|
||||
req: &pb.OnCreateCatalogRequest{
|
||||
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"),
|
||||
cred.ConstClientEmail: structpb.NewStringValue("test-client-email@email.com"),
|
||||
cred.ConstDisableCredentialRotation: structpb.NewBoolValue(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
Secrets: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
cred.ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"),
|
||||
cred.ConstPrivateKey: structpb.NewStringValue("test-private-key"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
catalogOpts: []gcpCatalogPersistedStateOption{
|
||||
withTestInstancesAPIFunc(newTestMockInstances(ctx,
|
||||
nil,
|
||||
testMockInstancesWithListInstancesOutput(&computepb.InstanceList{
|
||||
Items: []*computepb.Instance{
|
||||
{
|
||||
Name: pointer("boundary-0"),
|
||||
NetworkInterfaces: []*computepb.NetworkInterface{
|
||||
{
|
||||
AccessConfigs: []*computepb.AccessConfig{
|
||||
{
|
||||
NatIP: pointer("102.1.1.1"),
|
||||
ExternalIpv6: pointer("2001:db8::1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
testMockInstancesWithListInstancesError(nil),
|
||||
)),
|
||||
},
|
||||
expectedRsp: &pb.OnCreateCatalogResponse{
|
||||
Persisted: &pb.HostCatalogPersisted{
|
||||
Secrets: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
cred.ConstPrivateKey: structpb.NewStringValue("updated-private-key"),
|
||||
cred.ConstPrivateKeyId: structpb.NewStringValue("updated-private-key-id"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "using impersonated credentials",
|
||||
req: &pb.OnCreateCatalogRequest{
|
||||
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"),
|
||||
cred.ConstClientEmail: structpb.NewStringValue("test-client-email@email.com"),
|
||||
cred.ConstTargetServiceAccountID: structpb.NewStringValue("test-target-service-account-id"),
|
||||
cred.ConstDisableCredentialRotation: structpb.NewBoolValue(true),
|
||||
},
|
||||
},
|
||||
},
|
||||
Secrets: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
cred.ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"),
|
||||
cred.ConstPrivateKey: structpb.NewStringValue("test-private-key"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
catalogOpts: []gcpCatalogPersistedStateOption{
|
||||
withTestInstancesAPIFunc(newTestMockInstances(ctx,
|
||||
nil,
|
||||
testMockInstancesWithListInstancesOutput(&computepb.InstanceList{}),
|
||||
testMockInstancesWithListInstancesError(nil),
|
||||
)),
|
||||
},
|
||||
expectedRsp: &pb.OnCreateCatalogResponse{
|
||||
Persisted: &pb.HostCatalogPersisted{
|
||||
Secrets: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
cred.ConstPrivateKey: structpb.NewStringValue("test-private-key"),
|
||||
cred.ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"),
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: &pb.HostCatalogPersisted{
|
||||
Secrets: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
testIAMAdminServer := cred.NewTestIAMAdminServer(nil, nil)
|
||||
testResourceServer := cred.NewTestResourceServer(&iampb.TestIamPermissionsResponse{
|
||||
Permissions: []string{
|
||||
cred.ComputeInstancesListPermission,
|
||||
cred.IAMServiceAccountKeysCreatePermission,
|
||||
cred.IAMServiceAccountKeysDeletePermission,
|
||||
},
|
||||
}, nil)
|
||||
|
||||
gsrv := cred.NewGRPCServer()
|
||||
adminpb.RegisterIAMServer(gsrv.Server, testIAMAdminServer)
|
||||
resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer)
|
||||
addr, err := gsrv.Start()
|
||||
require.NoError(err)
|
||||
|
||||
p := &HostPlugin{
|
||||
testGCPClientOpts: []option.ClientOption{
|
||||
option.WithEndpoint(addr),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
|
||||
option.WithTokenSource(nil),
|
||||
},
|
||||
testCatalogStateOpts: tc.catalogOpts,
|
||||
}
|
||||
|
||||
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)
|
||||
delete(actual.GetPersisted().GetSecrets().GetFields(), cred.ConstCredsLastRotatedTime)
|
||||
require.Empty(cmp.Diff(tc.expectedRsp, actual, protocmp.Transform()))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -443,7 +602,6 @@ func TestUpdateCatalog(t *testing.T) {
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
compute "cloud.google.com/go/compute/apiv1"
|
||||
computepb "cloud.google.com/go/compute/apiv1/computepb"
|
||||
"github.com/googleapis/gax-go/v2"
|
||||
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
|
||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||
"google.golang.org/api/option"
|
||||
"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
|
||||
}
|
||||
|
||||
type gcpCatalogPersistedState struct {
|
||||
// CredentialsConfig is the configuration for the GCP credentials
|
||||
*credential.PersistedState
|
||||
// testInstancesAPIFunc provides a way to provide a factory for a mock Instances client
|
||||
testInstancesAPIFunc instancesAPIFunc
|
||||
}
|
||||
|
||||
type instancesAPIFunc func(...*credential.Config) (InstancesAPI, error)
|
||||
|
||||
type gcpCatalogPersistedStateOption func(s *gcpCatalogPersistedState) error
|
||||
|
||||
func withCredentials(x *credential.PersistedState) gcpCatalogPersistedStateOption {
|
||||
return func(s *gcpCatalogPersistedState) error {
|
||||
if s.PersistedState != nil {
|
||||
return errors.New("gcp credentials already set")
|
||||
}
|
||||
|
||||
s.PersistedState = x
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func withTestInstancesAPIFunc(f instancesAPIFunc) gcpCatalogPersistedStateOption {
|
||||
return func(s *gcpCatalogPersistedState) error {
|
||||
if s.testInstancesAPIFunc != nil {
|
||||
return errors.New("test API function already set")
|
||||
}
|
||||
|
||||
s.testInstancesAPIFunc = f
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newGCPCatalogPersistedState(opts ...gcpCatalogPersistedStateOption) (*gcpCatalogPersistedState, error) {
|
||||
s := new(gcpCatalogPersistedState)
|
||||
for _, opt := range opts {
|
||||
if err := opt(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ToProto converts the state to HostCatalogPersisted proto message.
|
||||
func (s *gcpCatalogPersistedState) toProto() (*pb.HostCatalogPersisted, error) {
|
||||
data, err := structpb.NewStruct(s.ToMap())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error converting state to structpb.Struct: %w", err)
|
||||
}
|
||||
return &pb.HostCatalogPersisted{Secrets: data}, nil
|
||||
}
|
||||
|
||||
// InstancesClient returns a configured Instances client based on the session
|
||||
// information stored in the state.
|
||||
func (s *gcpCatalogPersistedState) InstancesClient(
|
||||
ctx context.Context,
|
||||
opts ...option.ClientOption,
|
||||
) (InstancesAPI, error) {
|
||||
if s.testInstancesAPIFunc != nil {
|
||||
return s.testInstancesAPIFunc(s.CredentialsConfig)
|
||||
}
|
||||
creds, err := s.CredentialsConfig.GenerateCredentials(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error generation GCP credentials: %w", err)
|
||||
}
|
||||
if creds == nil {
|
||||
return nil, fmt.Errorf("nil gcp credentials")
|
||||
}
|
||||
clientOptions := []option.ClientOption{option.WithTokenSource(creds.TokenSource)}
|
||||
clientOptions = append(clientOptions, opts...)
|
||||
|
||||
client, err := compute.NewInstancesRESTClient(ctx, clientOptions...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating instances client: %w", err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/api/option"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
func TestWithCredentials(t *testing.T) {
|
||||
cred := &credential.PersistedState{}
|
||||
opt := withCredentials(cred)
|
||||
|
||||
state := &gcpCatalogPersistedState{}
|
||||
err := opt(state)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cred, state.PersistedState)
|
||||
|
||||
// Test setting credentials again should return an error
|
||||
err = opt(state)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "gcp credentials already set", err.Error())
|
||||
}
|
||||
|
||||
func TestNewGCPCatalogPersistedState(t *testing.T) {
|
||||
cred := &credential.PersistedState{}
|
||||
opt := withCredentials(cred)
|
||||
|
||||
state, err := newGCPCatalogPersistedState(opt)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cred, state.PersistedState)
|
||||
}
|
||||
|
||||
func TestToProto(t *testing.T) {
|
||||
lastRotatedTime := time.Now()
|
||||
state := &gcpCatalogPersistedState{PersistedState: &credential.PersistedState{
|
||||
CredentialsConfig: &credential.Config{
|
||||
PrivateKey: "test-private-key",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
},
|
||||
CredsLastRotatedTime: lastRotatedTime,
|
||||
},
|
||||
}
|
||||
|
||||
proto, err := state.toProto()
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedData, err := structpb.NewStruct(map[string]any{
|
||||
"private_key": "test-private-key",
|
||||
"private_key_id": "test-private-key-id",
|
||||
"creds_last_rotated_time": lastRotatedTime.Format(time.RFC3339Nano),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedData, proto.Secrets)
|
||||
}
|
||||
|
||||
func TestInstancesClient(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cred := &credential.PersistedState{
|
||||
CredentialsConfig: &credential.Config{},
|
||||
}
|
||||
state := &gcpCatalogPersistedState{PersistedState: cred}
|
||||
|
||||
t.Run("GenerateCredentials error", func(t *testing.T) {
|
||||
_, err := state.InstancesClient(ctx)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "error generation GCP credentials")
|
||||
})
|
||||
|
||||
t.Run("NewInstancesRESTClient error", func(t *testing.T) {
|
||||
state.CredentialsConfig = &credential.Config{
|
||||
ProjectId: "test-project-id",
|
||||
PrivateKey: "test-private-key",
|
||||
ClientEmail: "test@example.com",
|
||||
}
|
||||
_, err := state.InstancesClient(ctx, option.WithoutAuthentication())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "error creating instances client")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
compute "cloud.google.com/go/compute/apiv1"
|
||||
"cloud.google.com/go/compute/apiv1/computepb"
|
||||
"github.com/googleapis/gax-go/v2"
|
||||
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
type testHTTPServer struct {
|
||||
server *httptest.Server
|
||||
listInstancesResponse *computepb.InstanceList
|
||||
listInstancesError error
|
||||
}
|
||||
|
||||
func (s *testHTTPServer) start() *httptest.Server {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var resp any
|
||||
if strings.Contains(r.URL.Path, "/compute/v1/projects") {
|
||||
if s.listInstancesError != nil {
|
||||
http.Error(w, "error listing instances: "+s.listInstancesError.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resp = s.listInstancesResponse
|
||||
}
|
||||
if resp == nil {
|
||||
http.Error(w, "unknown path: "+r.URL.Path, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
b, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to marshal request: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, err = w.Write(b)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to write response: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}))
|
||||
s.server = ts
|
||||
return ts
|
||||
}
|
||||
|
||||
func pointer[T any](input T) *T {
|
||||
ret := input
|
||||
return &ret
|
||||
}
|
||||
|
||||
type testMockInstancesState struct {
|
||||
ListInstancesCalled bool
|
||||
ListInstancesInputParams *computepb.ListInstancesRequest
|
||||
}
|
||||
|
||||
func (s *testMockInstancesState) Reset() {
|
||||
s.ListInstancesCalled = false
|
||||
s.ListInstancesInputParams = nil
|
||||
}
|
||||
|
||||
type testMockInstances struct {
|
||||
InstancesAPI
|
||||
httpServer *testHTTPServer
|
||||
State *testMockInstancesState
|
||||
client *compute.InstancesClient
|
||||
ListInstancesOutput *computepb.InstanceList
|
||||
ListInstancesError error
|
||||
}
|
||||
|
||||
type testMockInstancesOption func(m *testMockInstances) error
|
||||
|
||||
func testMockInstancesWithListInstancesOutput(o *computepb.InstanceList) testMockInstancesOption {
|
||||
return func(m *testMockInstances) error {
|
||||
m.ListInstancesOutput = o
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func testMockInstancesWithListInstancesError(e error) testMockInstancesOption {
|
||||
return func(m *testMockInstances) error {
|
||||
m.ListInstancesError = e
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newTestMockInstances(ctx context.Context, state *testMockInstancesState, opts ...testMockInstancesOption) instancesAPIFunc {
|
||||
return func(cfgs ...*credential.Config) (InstancesAPI, error) {
|
||||
m := &testMockInstances{
|
||||
State: state,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
if err := opt(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
m.httpServer = &testHTTPServer{
|
||||
listInstancesResponse: m.ListInstancesOutput,
|
||||
listInstancesError: m.ListInstancesError,
|
||||
}
|
||||
m.httpServer.start()
|
||||
endpoint := m.httpServer.server.URL
|
||||
|
||||
if m.client == nil {
|
||||
client, err := compute.NewInstancesRESTClient(ctx,
|
||||
option.WithoutAuthentication(),
|
||||
option.WithEndpoint(endpoint),
|
||||
option.WithTokenSource(nil),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.client = client
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *testMockInstances) List(ctx context.Context, input *computepb.ListInstancesRequest, opts ...gax.CallOption) *compute.InstanceIterator {
|
||||
if m.State != nil {
|
||||
m.State.ListInstancesCalled = true
|
||||
m.State.ListInstancesInputParams = input
|
||||
}
|
||||
|
||||
return m.client.List(ctx, input)
|
||||
}
|
||||
Reference in new issue
Block a user