diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..dbfc449 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,18 @@ +name: test + +on: + - workflow_dispatch + - push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0 + - name: Set up Go + uses: actions/setup-go@6edd4406fa81c3da01a34fa6f6343087c207a568 # v3.5.0 + with: + go-version-file: 'go.mod' + cache: true + - name: Run Tests + run: go test -v ./plugin/... diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f58c333 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# Contributing + +Create a new package when adding support to a new service in the aws plugin. The package name should be named after the service name found [here](https://github.com/hashicorp/boundary/tree/main/internal/proto/plugin/v1). + +## Service Packages + +- A service package should contain an `attributes.go` file, which contains all attributes associated to the service in addition to `CredentialAttributes`, which is defined in `internal/credential/attributes.go`. +- A service package should contain an `state.go` file, which contains the logic and definition of the service's persisted state. +- A service package should contain an `plugin.go` file, which contains the implemented method defined the the services proto definition found [here](https://github.com/hashicorp/boundary/tree/main/internal/proto/plugin/v1). + +## Credential Package + +This package contains all logic used for authorizing aws sessions. Any changes applied to this package will affect how all services handle aws sessions. + +## Plugin.go + +Found in `plugin/plugin.go`. This file contains the AwsPlugin struct definition, which contains the struct composition of all services that the plugin supports. Add your newly defined plugin service struct as a struct composition to the AwsPlugins struct. + +## Testing + +Please add end to end tests here in `testing/e2e_{service}_test.go`. Any aws terraform files should be stored in `testing/testdata/{service}/*.tf` \ No newline at end of file diff --git a/README.md b/README.md index 30605af..75eae22 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,23 @@ -# AWS Host Plugin for HashiCorp Boundary +# AWS Plugin for HashiCorp Boundary -This repo contains a Host-type plugin for [HashiCorp -Boundary](https://www.boundaryproject.io/) allowing dynamically sourcing hosts -from Amazon EC2. +This repo contains the aws plugin for [HashiCorp +Boundary](https://www.boundaryproject.io/). + +## Credential Rotation + +Although credentials are stored encrypted within Boundary, by default this +plugin will attempt to rotate credentials when they are supplied through the +`secrets` object on a create or update call to the host catalog resource. The +given credentials will be used to create a new credential, and then the given +credential will be revoked. In this way, after rotation, only Boundary knows the +client secret in use by this plugin. + +Credential rotation can be turned off by setting the +`disable_credential_rotation` attribute to `true`. + +## Dynamic Hosts + +This plugin supports dynamically sourcing hosts from Amazon EC2. Host sets created with this plugin define filters which select and group like instances within AWS; these host sets can in turn be added to targets within @@ -17,143 +32,4 @@ The plugin fetches hosts through the [DescribeInstances](https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeInstances.html) call. -## Credential Rotation - -Although credentials are stored encrypted within Boundary, by default this -plugin will attempt to rotate credentials when they are supplied through the -`secrets` object on a create or update call to the host catalog resource. The -given credentials will be used to create a new credential, and then the given -credential will be revoked. In this way, after rotation, only Boundary knows the -client secret in use by this plugin. - -Credential rotation can be turned off by setting the -`disable_credential_rotation` attribute to `true`. - -## Getting Started - -Refer to [Attributes and Secrets](#attributes-and-secrets) for more detail on -configuration options for host catalogs and sets. - -To create a host catalog (using default scope created by `boundary dev`): - -``` -boundary host-catalogs create plugin \ - -scope-id p_1234567890 \ - -name "Example Plugin-Based Host Catalog" \ - -description "Description for plugin-based host catalog" \ - -plugin-name aws \ - -attr region=REGION \ - -secret access_key_id='KEY' \ - -secret secret_access_key='SECRET' -``` - -To create a host set, filtering the host set based on tag keys `foo` or `bar` -(either tag can be present), ensuring that any targets set to this host set only -connect to external addresses in the `54.0.0.0/8` class A subnet: - -``` -boundary host-sets create plugin \ - -host-catalog-id HOST_CATALOG_ID \ - -name "Example Plugin-Based Host Set" \ - -description "Description for plugin-based host set" \ - -attr filters=tag-key=foo,bar \ - -preferred-endpoint "cidr:54.0.0.0/8" -``` - -As above, but instances must have both tags (both `foo` and `bar` *must* be -present): - -``` -boundary host-sets create plugin \ - -host-catalog-id HOST_CATALOG_ID \ - -name "Example Plugin-Based Host Set" \ - -description "Description for plugin-based host set" \ - -attr filters=tag-key=foo \ - -attr filters=tag-key=bar \ - -preferred-endpoint "cidr:54.0.0.0/8" -``` - -As above, but matching on tag key named "development" with value "prod" or "dev" -and launch date: - -``` -boundary host-sets create plugin \ - -host-catalog-id HOST_CATALOG_ID \ - -name "Example Plugin-Based Host Set" \ - -description "Description for plugin-based host set" \ - -attr filters=tag:development=prod,dev \ - -attr filters=launch-time=2022-01-04T* \ - -preferred-endpoint "cidr:54.0.0.0/8" -``` - -## Required IAM Privileges - -The following IAM privileges, at the very least, are required to be attached to -a configured IAM user for this provider: - -* `ec2:DescribeInstances`, configured to `*` (`DescribeInstances` cannot be - scoped to a resource ARN). Example policy: - -``` -{ - "Version": "2012-10-17", - "Statement": [ - { - "Action": [ - "ec2:DescribeInstances" - ], - "Effect": "Allow", - "Resource": "*" - } - ] -} -``` - -* `iam:GetUser`, `iam:CreateAccessKey`, and `iam:DeleteAccessKey`, configured to - the IAM user to allow credential rotation. Example policy: - -``` -{ - "Version": "2012-10-17", - "Statement": [ - { - "Action": [ - "iam:DeleteAccessKey", - "iam:GetUser", - "iam:CreateAccessKey" - ], - "Effect": "Allow", - "Resource": "arn:aws:iam::123456789012:user/JohnDoe" - } - ] -} -``` - -## Attributes and Secrets - -### Host Catalog - -The following `attributes` are valid on an AWS host catalog resource: - -- `disable_credential_rotation` (bool): If `true`, credential rotation will not - be performed. See the [Credential Rotation](#credential-rotation) section - above. -- `region` (string): The region to configure the host catalog for. All host sets - in this catalog will be configured for this region. - -The following `secrets` are valid on an AWS host catalog resource: - -- `access_key_id` (string): The access key ID for the IAM user to use with this - host catalog. -- `secret_access_key` (string): The secret access key for the IAM user to use - with this host catalog. - -### Host Set - -The following attributes are valid on an AWS host Set resource: - -- `filters` (array of strings): An array of string filters in the format - `key=val1,val2`. The key corresponds to a filter option, and the value(s) are - a comma-separated list. For a list of filter options, check out - [describe-instances in the AWS CLI - reference](https://docs.aws.amazon.com/cli/latest/reference/ec2/describe-instances.html). +[Getting Started](https://github.com/hashicorp/boundary-plugin-aws/plugin/service/host/README.md) diff --git a/internal/credential/attributes.go b/internal/credential/attributes.go new file mode 100644 index 0000000..3ec6ee1 --- /dev/null +++ b/internal/credential/attributes.go @@ -0,0 +1,93 @@ +package credential + +import ( + "fmt" + + "github.com/aws/aws-sdk-go/aws/endpoints" + "github.com/hashicorp/boundary-plugin-host-aws/internal/errors" + "github.com/hashicorp/boundary-plugin-host-aws/internal/values" + "github.com/hashicorp/go-secure-stdlib/awsutil" + "google.golang.org/protobuf/types/known/structpb" +) + +// CredentialAttributes contains attributes used for AWS credentials +type CredentialAttributes struct { + // Region is the region associated with the aws credentials + Region string + + // DisableCredentialRotation disables the rotation of aws secrets associated with the plugin + DisableCredentialRotation bool +} + +// GetCredentialsConfig parses values out of a protobuf struct input and returns a +// CredentialsConfig used for configuring an AWS session. An error is returned if +// any of the following fields are missing from the protobuf struct input or have +// invalid value types: access_key_id, secret_access_key. An error is returned if +// any unrecognized fields are found in the protobuf struct input. +func GetCredentialsConfig(in *structpb.Struct, region string) (*awsutil.CredentialsConfig, error) { + unknownFields := values.StructFields(in) + badFields := make(map[string]string) + + accessKey, err := values.GetStringValue(in, ConstAccessKeyId, true) + if err != nil { + badFields[fmt.Sprintf("secrets.%s", ConstAccessKeyId)] = err.Error() + } + delete(unknownFields, ConstAccessKeyId) + + secretKey, err := values.GetStringValue(in, ConstSecretAccessKey, true) + if err != nil { + badFields[fmt.Sprintf("secrets.%s", ConstSecretAccessKey)] = err.Error() + } + delete(unknownFields, ConstSecretAccessKey) + + if region == "" { + badFields[fmt.Sprintf("attributes.%s", ConstRegion)] = err.Error() + } + + for s := range unknownFields { + badFields[fmt.Sprintf("secrets.%s", s)] = "unrecognized field" + } + + if len(badFields) > 0 { + return nil, errors.InvalidArgumentError("Error in the secrets provided", badFields) + } + + return awsutil.NewCredentialsConfig( + awsutil.WithAccessKey(accessKey), + awsutil.WithSecretKey(secretKey), + awsutil.WithRegion(region), + ) +} + +// GetCredentialAttributes parses values out of a protobuf struct input and returns a +// CredentialAttributes used for configuring an AWS session. An error is returned if +// any of the following fields are missing from the protobuf struct input or have +// invalid value types: region, disableCredentialRotation +func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error) { + badFields := make(map[string]string) + + region, err := values.GetStringValue(in, ConstRegion, true) + if err != nil { + badFields[fmt.Sprintf("attributes.%s", ConstRegion)] = err.Error() + } + + if region != "" { + if _, found := endpoints.PartitionForRegion(endpoints.DefaultPartitions(), region); !found { + badFields[fmt.Sprintf("attributes.%s", ConstRegion)] = fmt.Sprintf("not a valid region: %s", region) + } + } + + disableCredentialRotation, err := values.GetBoolValue(in, ConstDisableCredentialRotation, false) + if err != nil { + badFields[fmt.Sprintf("attributes.%s", ConstDisableCredentialRotation)] = err.Error() + } + + if len(badFields) > 0 { + return nil, errors.InvalidArgumentError("Error in the attributes provided", badFields) + } + + return &CredentialAttributes{ + Region: region, + DisableCredentialRotation: disableCredentialRotation, + }, nil +} diff --git a/internal/credential/attributes_test.go b/internal/credential/attributes_test.go new file mode 100644 index 0000000..27aecfc --- /dev/null +++ b/internal/credential/attributes_test.go @@ -0,0 +1,159 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package credential + +import ( + "testing" + + "github.com/hashicorp/go-secure-stdlib/awsutil" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestGetCredentialAttributes(t *testing.T) { + cases := []struct { + name string + in map[string]any + expected *CredentialAttributes + expectedErrContains string + }{ + { + name: "missing region", + in: map[string]any{}, + expectedErrContains: "missing required value \"region\"", + }, + { + name: "invalid aws region value", + in: map[string]any{ + ConstRegion: "dne", + }, + expectedErrContains: "not a valid region", + }, + { + name: "bad value for disable_credential_rotation", + in: map[string]any{ + ConstRegion: "us-west-2", + ConstDisableCredentialRotation: "sure", + }, + expectedErrContains: "unexpected type for value \"disable_credential_rotation\": want bool, got string", + }, + { + name: "default", + in: map[string]any{ + ConstRegion: "us-west-2", + }, + expected: &CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: false, + }, + }, + { + name: "with disable_credential_rotation", + in: map[string]any{ + ConstRegion: "us-west-2", + ConstDisableCredentialRotation: true, + }, + expected: &CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: true, + }, + }, + } + + 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 := GetCredentialAttributes(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.EqualValues(tc.expected.Region, actual.Region) + require.EqualValues(tc.expected.DisableCredentialRotation, actual.DisableCredentialRotation) + }) + } +} + +func TestGetCredentialsConfig(t *testing.T) { + cases := []struct { + name string + in map[string]any + region string + expected *awsutil.CredentialsConfig + expectedErrContains string + }{ + { + name: "missing access_key_id", + in: map[string]any{}, + region: "us-west-2", + expectedErrContains: "missing required value \"access_key_id\"", + }, + { + name: "missing secret_access_key", + in: map[string]any{ + ConstAccessKeyId: "foobar", + }, + region: "us-west-2", + expectedErrContains: "missing required value \"secret_access_key\"", + }, + { + name: "unknown fields", + in: map[string]any{ + ConstAccessKeyId: "foobar", + ConstSecretAccessKey: "bazqux", + "foo": true, + "bar": true, + }, + region: "us-west-2", + expectedErrContains: "secrets.bar: unrecognized field, secrets.foo: unrecognized field", + }, + { + name: "good", + in: map[string]any{ + ConstAccessKeyId: "foobar", + ConstSecretAccessKey: "bazqux", + }, + region: "us-west-2", + expected: &awsutil.CredentialsConfig{ + AccessKey: "foobar", + SecretKey: "bazqux", + Region: "us-west-2", + }, + }, + } + + 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 := GetCredentialsConfig(input, tc.region) + 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.AccessKey, actual.AccessKey) + require.Equal(tc.expected.SecretKey, actual.SecretKey) + require.Equal(tc.expected.Region, actual.Region) + }) + } +} diff --git a/internal/credential/const.go b/internal/credential/const.go new file mode 100644 index 0000000..7311d75 --- /dev/null +++ b/internal/credential/const.go @@ -0,0 +1,18 @@ +package credential + +const ( + // ConstAccessKey is the key for the region in the aws credentials. + ConstRegion = "region" + + // ConstAccessKey is the key for the access key id in the aws credentials. + ConstAccessKeyId = "access_key_id" + + // ConstSecretAccessKey is the key for the secret access key in the aws credentials. + ConstSecretAccessKey = "secret_access_key" + + // ConstDisableCredentialRotation is the key for the disable credential rotation in the aws credentials. + ConstDisableCredentialRotation = "disable_credential_rotation" + + // ConstCredsLastRotatedTime is the key for the last rotated time in the aws credentials. + ConstCredsLastRotatedTime = "creds_last_rotated_time" +) diff --git a/plugin/state.go b/internal/credential/state.go similarity index 52% rename from plugin/state.go rename to internal/credential/state.go index 233f7d5..94e9ae4 100644 --- a/plugin/state.go +++ b/internal/credential/state.go @@ -1,50 +1,55 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package plugin +package credential import ( "errors" "fmt" - "net" - "net/http" "time" - "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/ec2" - "github.com/aws/aws-sdk-go/service/ec2/ec2iface" "github.com/aws/aws-sdk-go/service/iam" - pb "github.com/hashicorp/boundary/sdk/pbs/plugin" + "github.com/hashicorp/boundary-plugin-host-aws/internal/values" "github.com/hashicorp/go-secure-stdlib/awsutil" "google.golang.org/protobuf/types/known/structpb" ) -type ec2APIFunc func(*session.Session, ...*aws.Config) (ec2iface.EC2API, error) +// rotationWaitTimeout controls the time we wait for credential rotation to +// succeed. This is important to ensure that rotated credentials can be used +// right away. +const rotationWaitTimeout = time.Second * 30 -type awsCatalogPersistedStateOption func(s *awsCatalogPersistedState) error +type AwsCredentialPersistedStateOption func(s *AwsCredentialPersistedState) error -func withStateTestOpts(opts []awsutil.Option) awsCatalogPersistedStateOption { - return func(s *awsCatalogPersistedState) error { +// AwsCredentialPersistedState is the persisted state for the AWS credential. +type AwsCredentialPersistedState struct { + // AccessKeyId is the access key id for the AWS credential. + AccessKeyId string + // SecretAccessKey is the secret access key for the AWS credential. + SecretAccessKey string + // CredsLastRotatedTime is the last rotation of aws secrets for the AWS credential. + CredsLastRotatedTime time.Time + + // testOpts are options that should be used for testing only + testOpts []awsutil.Option + + // Region is not a part of persisted state but if set is passed to clients + // created with this state + region string +} + +// WithStateTestOpts enables unit testing different edge cases +// when using CredentialsConfig. This should never be used in +// production code. This should only be used in unit tests. +func WithStateTestOpts(opts []awsutil.Option) AwsCredentialPersistedStateOption { + return func(s *AwsCredentialPersistedState) error { s.testOpts = opts return nil } } -func withTestEC2APIFunc(f ec2APIFunc) awsCatalogPersistedStateOption { - return func(s *awsCatalogPersistedState) error { - if s.testEC2APIFunc != nil { - return errors.New("test API function already set") - } - - s.testEC2APIFunc = f - return nil - } -} - -func withAccessKeyId(x string) awsCatalogPersistedStateOption { - return func(s *awsCatalogPersistedState) error { +// WithAccessKeyId sets the value for AccesskeyId in the storage persisted state. +func WithAccessKeyId(x string) AwsCredentialPersistedStateOption { + return func(s *AwsCredentialPersistedState) error { if s.AccessKeyId != "" { return errors.New("access key id already set") } @@ -54,8 +59,9 @@ func withAccessKeyId(x string) awsCatalogPersistedStateOption { } } -func withSecretAccessKey(x string) awsCatalogPersistedStateOption { - return func(s *awsCatalogPersistedState) error { +// WithSecretAccessKey sets the value for SecretAccessKey in the storage persisted state. +func WithSecretAccessKey(x string) AwsCredentialPersistedStateOption { + return func(s *AwsCredentialPersistedState) error { if s.SecretAccessKey != "" { return errors.New("secret access key already set") } @@ -65,8 +71,9 @@ func withSecretAccessKey(x string) awsCatalogPersistedStateOption { } } -func withCredsLastRotatedTime(t time.Time) awsCatalogPersistedStateOption { - return func(s *awsCatalogPersistedState) error { +// WithCredsLastRotatedTime sets the value for CredsLastRotatedTime in the storage persisted state. +func WithCredsLastRotatedTime(t time.Time) AwsCredentialPersistedStateOption { + return func(s *AwsCredentialPersistedState) error { if !s.CredsLastRotatedTime.IsZero() { return errors.New("last rotation time already set") } @@ -76,8 +83,9 @@ func withCredsLastRotatedTime(t time.Time) awsCatalogPersistedStateOption { } } -func withRegion(with string) awsCatalogPersistedStateOption { - return func(s *awsCatalogPersistedState) error { +// WithRegion sets the value for region in the storage persisted state. +func WithRegion(with string) AwsCredentialPersistedStateOption { + return func(s *AwsCredentialPersistedState) error { if s.region != "" { return errors.New("region already set") } @@ -87,24 +95,11 @@ func withRegion(with string) awsCatalogPersistedStateOption { } } -type awsCatalogPersistedState struct { - AccessKeyId string - SecretAccessKey string - CredsLastRotatedTime time.Time - - // testOpts are options that should be used for testing only - testOpts []awsutil.Option - - // testEC2APIFunc provides a way to provide a factory for a mock EC2 client - testEC2APIFunc ec2APIFunc - - // Region is not a part of persisted state but if set is passed to clients - // created with this state - region string -} - -func newAwsCatalogPersistedState(opts ...awsCatalogPersistedStateOption) (*awsCatalogPersistedState, error) { - s := new(awsCatalogPersistedState) +// NewAwsCredentialPersistedState returns a AwsCredentialPersistedState. +// Supported options include: WithAccessKeyId, WithSecretAccessKey +// WithCredsLastRotatedTime, & WithRegion. +func NewAwsCredentialPersistedState(opts ...AwsCredentialPersistedStateOption) (*AwsCredentialPersistedState, error) { + s := new(AwsCredentialPersistedState) for _, opt := range opts { if err := opt(s); err != nil { return nil, err @@ -114,53 +109,10 @@ func newAwsCatalogPersistedState(opts ...awsCatalogPersistedStateOption) (*awsCa return s, nil } -func awsCatalogPersistedStateFromProto(in *pb.HostCatalogPersisted, opts ...awsCatalogPersistedStateOption) (*awsCatalogPersistedState, error) { - data := in.GetSecrets() - if data == nil { - return nil, errors.New("missing persisted secrets") - } - - accessKeyId, err := getStringValue(data, constAccessKeyId, true) - if err != nil { - return nil, fmt.Errorf("persisted state integrity error: %w", err) - } - - secretAccessKey, err := getStringValue(data, constSecretAccessKey, true) - if err != nil { - return nil, fmt.Errorf("persisted state integrity error: %w", err) - } - - credsLastRotatedTime, err := getTimeValue(data, constCredsLastRotatedTime) - if err != nil { - return nil, fmt.Errorf("persisted state integrity error: %w", err) - } - - s, err := newAwsCatalogPersistedState(opts...) - if err != nil { - return nil, err - } - - s.AccessKeyId = accessKeyId - s.SecretAccessKey = secretAccessKey - s.CredsLastRotatedTime = credsLastRotatedTime - - return s, nil -} - -func (s *awsCatalogPersistedState) ToProto() (*pb.HostCatalogPersisted, error) { - data, err := structpb.NewStruct(map[string]interface{}{ - constAccessKeyId: s.AccessKeyId, - constSecretAccessKey: s.SecretAccessKey, - constCredsLastRotatedTime: s.CredsLastRotatedTime.Format(time.RFC3339Nano), - }) - if err != nil { - return nil, fmt.Errorf("error converting state to structpb.Struct: %w", err) - } - - return &pb.HostCatalogPersisted{Secrets: data}, nil -} - -func (s *awsCatalogPersistedState) ValidateCreds() error { +// ValidateCreds takes the access key and secret key from the persisted state +// and runs sts.GetCallerIdentity for the current credentials, which is done +// to check that the credentials are valid. +func (s *AwsCredentialPersistedState) ValidateCreds() error { c, err := awsutil.NewCredentialsConfig(append([]awsutil.Option{ awsutil.WithAccessKey(s.AccessKeyId), awsutil.WithSecretKey(s.SecretAccessKey), @@ -177,7 +129,12 @@ func (s *awsCatalogPersistedState) ValidateCreds() error { return nil } -func (s *awsCatalogPersistedState) RotateCreds() error { +// RotateCreds takes the access key and secret key from the persisted state and creates a new access/secret key, +// then deletes the old access key. If deletion of the old access key is successful, the new access key/secret key are +// written into the credentials config and the persisted state. On any error, the old credentials are not overwritten. +// This ensures that any generated new secret key never leaves this function in case of an error, even though it will +// still result in an extraneous access key existing. +func (s *AwsCredentialPersistedState) RotateCreds() error { c, err := awsutil.NewCredentialsConfig(append([]awsutil.Option{ awsutil.WithAccessKey(s.AccessKeyId), awsutil.WithSecretKey(s.SecretAccessKey), @@ -203,7 +160,7 @@ func (s *awsCatalogPersistedState) RotateCreds() error { // ReplaceCreds replaces the access key in the state with a new key. // If the existing key was rotated at any point in time, it is // deleted first, otherwise it's left alone. -func (s *awsCatalogPersistedState) ReplaceCreds(accessKeyId, secretAccessKey string) error { +func (s *AwsCredentialPersistedState) ReplaceCreds(accessKeyId, secretAccessKey string) error { if accessKeyId == "" { return errors.New("access key id cannot be empty") } @@ -235,7 +192,7 @@ func (s *awsCatalogPersistedState) ReplaceCreds(accessKeyId, secretAccessKey str // DeleteCreds deletes the credentials in the state. The access key // ID, secret access key, and rotation time fields are zeroed out in // the state just to ensure that they cannot be re-used after. -func (s *awsCatalogPersistedState) DeleteCreds() error { +func (s *AwsCredentialPersistedState) DeleteCreds() error { c, err := awsutil.NewCredentialsConfig(append([]awsutil.Option{ awsutil.WithAccessKey(s.AccessKeyId), awsutil.WithSecretKey(s.SecretAccessKey), @@ -270,7 +227,7 @@ func (s *awsCatalogPersistedState) DeleteCreds() error { // GetSession returns a configured AWS session for the credentials in // the state. -func (s *awsCatalogPersistedState) GetSession() (*session.Session, error) { +func (s *AwsCredentialPersistedState) GetSession() (*session.Session, error) { c, err := awsutil.NewCredentialsConfig(append([]awsutil.Option{ awsutil.WithAccessKey(s.AccessKeyId), awsutil.WithSecretKey(s.SecretAccessKey), @@ -283,45 +240,50 @@ func (s *awsCatalogPersistedState) GetSession() (*session.Session, error) { return c.GetSession() } -var customClient = &http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - DualStack: true, - }).DialContext, - }, +// ToMap returns a map of the credentials stored in the persisted state, +// which includes the following keys: access_key_id, secret_access_key, +// & creds_last_rotated_time +func (s *AwsCredentialPersistedState) ToMap() map[string]any { + return map[string]any{ + ConstAccessKeyId: s.AccessKeyId, + ConstSecretAccessKey: s.SecretAccessKey, + ConstCredsLastRotatedTime: s.CredsLastRotatedTime.Format(time.RFC3339Nano), + } } -// EC2Client returns a configured EC2 client based on the session -// information stored in the state. -func (s *awsCatalogPersistedState) EC2Client(opt ...Option) (ec2iface.EC2API, error) { - opts, err := getOpts(opt...) +// AwsCredentialPersistedStateFromProto parses values out of a protobuf struct input +// and returns a AwsCredentialPersistedState used for configuring an AWS session. +// An error is returned if any of the following fields are missing from the protobuf +// struct input or have invalid value types: access_key_id, secret_access_key, & +// creds_last_rotated_time. +func AwsCredentialPersistedStateFromProto(in *structpb.Struct, opts ...AwsCredentialPersistedStateOption) (*AwsCredentialPersistedState, error) { + if in == nil { + return nil, errors.New("missing persisted secrets") + } + + accessKeyId, err := values.GetStringValue(in, ConstAccessKeyId, true) if err != nil { - return nil, fmt.Errorf("error parsing options when fetching EC2 client: %w", err) + return nil, fmt.Errorf("persisted state integrity error: %w", err) } - region := opts.withRegion - if region == "" { - region = s.region - } - - sess, err := s.GetSession() + secretAccessKey, err := values.GetStringValue(in, ConstSecretAccessKey, true) if err != nil { - return nil, fmt.Errorf("error getting AWS session when fetching EC2 client: %w", err) + return nil, fmt.Errorf("persisted state integrity error: %w", err) } - if s.testEC2APIFunc != nil { - return s.testEC2APIFunc(sess, aws.NewConfig().WithRegion(region)) + credsLastRotatedTime, err := values.GetTimeValue(in, ConstCredsLastRotatedTime) + if err != nil { + return nil, fmt.Errorf("persisted state integrity error: %w", err) } - cfg := aws.NewConfig() - if region != "" { - cfg = cfg.WithRegion(region) + s, err := NewAwsCredentialPersistedState(opts...) + if err != nil { + return nil, err } - cfg.HTTPClient = customClient + s.AccessKeyId = accessKeyId + s.SecretAccessKey = secretAccessKey + s.CredsLastRotatedTime = credsLastRotatedTime - return ec2.New(sess, cfg), nil + return s, nil } diff --git a/plugin/state_test.go b/internal/credential/state_test.go similarity index 60% rename from plugin/state_test.go rename to internal/credential/state_test.go index b9979a4..ae5115d 100644 --- a/plugin/state_test.go +++ b/internal/credential/state_test.go @@ -1,7 +1,4 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package plugin +package credential import ( "errors" @@ -12,31 +9,24 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/service/iam" - pb "github.com/hashicorp/boundary/sdk/pbs/plugin" "github.com/hashicorp/go-secure-stdlib/awsutil" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" ) -const ( - testOptionErr = "test option error" - testGetCallerIdentityErr = "test error for GetCallerIdentity" - testGetUserErr = "test error for GetUser" - testDeleteAccessKeyErr = "test error for DeleteAccessKey" -) - -func TestNewAwsCatalogPersistedState(t *testing.T) { +func TestNewAwsCredentialPersistedState(t *testing.T) { staticTime := time.Now() cases := []struct { name string - opts []awsCatalogPersistedStateOption - expected *awsCatalogPersistedState + opts []AwsCredentialPersistedStateOption + expected *AwsCredentialPersistedState expectedErr string }{ { name: "error", - opts: []awsCatalogPersistedStateOption{ - func(s *awsCatalogPersistedState) error { + opts: []AwsCredentialPersistedStateOption{ + func(s *AwsCredentialPersistedState) error { return errors.New(testOptionErr) }, }, @@ -44,69 +34,69 @@ func TestNewAwsCatalogPersistedState(t *testing.T) { }, { name: "access key id", - opts: []awsCatalogPersistedStateOption{ - withAccessKeyId("foobar"), + opts: []AwsCredentialPersistedStateOption{ + WithAccessKeyId("foobar"), }, - expected: &awsCatalogPersistedState{ + expected: &AwsCredentialPersistedState{ AccessKeyId: "foobar", }, }, { name: "secret access key", - opts: []awsCatalogPersistedStateOption{ - withSecretAccessKey("bazqux"), + opts: []AwsCredentialPersistedStateOption{ + WithSecretAccessKey("bazqux"), }, - expected: &awsCatalogPersistedState{ + expected: &AwsCredentialPersistedState{ SecretAccessKey: "bazqux", }, }, { name: "rotation time", - opts: []awsCatalogPersistedStateOption{ - withCredsLastRotatedTime(staticTime), + opts: []AwsCredentialPersistedStateOption{ + WithCredsLastRotatedTime(staticTime), }, - expected: &awsCatalogPersistedState{ + expected: &AwsCredentialPersistedState{ CredsLastRotatedTime: staticTime, }, }, { name: "region key", - opts: []awsCatalogPersistedStateOption{ - withRegion("foobar"), + opts: []AwsCredentialPersistedStateOption{ + WithRegion("foobar"), }, - expected: &awsCatalogPersistedState{ + expected: &AwsCredentialPersistedState{ region: "foobar", }, }, { name: "double set access key id", - opts: []awsCatalogPersistedStateOption{ - withAccessKeyId("foobar"), - withAccessKeyId("onetwo"), + opts: []AwsCredentialPersistedStateOption{ + WithAccessKeyId("foobar"), + WithAccessKeyId("onetwo"), }, expectedErr: "access key id already set", }, { name: "double set secret access key", - opts: []awsCatalogPersistedStateOption{ - withSecretAccessKey("bazqux"), - withSecretAccessKey("threefour"), + opts: []AwsCredentialPersistedStateOption{ + WithSecretAccessKey("bazqux"), + WithSecretAccessKey("threefour"), }, expectedErr: "secret access key already set", }, { name: "double set rotation time", - opts: []awsCatalogPersistedStateOption{ - withCredsLastRotatedTime(staticTime), - withCredsLastRotatedTime(time.Now()), + opts: []AwsCredentialPersistedStateOption{ + WithCredsLastRotatedTime(staticTime), + WithCredsLastRotatedTime(time.Now()), }, expectedErr: "last rotation time already set", }, { name: "double set region", - opts: []awsCatalogPersistedStateOption{ - withRegion("foobar"), - withRegion("barfoo"), + opts: []AwsCredentialPersistedStateOption{ + WithRegion("foobar"), + WithRegion("barfoo"), }, expectedErr: "region already set", }, @@ -116,7 +106,7 @@ func TestNewAwsCatalogPersistedState(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { require := require.New(t) - actual, err := newAwsCatalogPersistedState(tc.opts...) + actual, err := NewAwsCredentialPersistedState(tc.opts...) if tc.expectedErr != "" { require.EqualError(err, tc.expectedErr) return @@ -128,63 +118,49 @@ func TestNewAwsCatalogPersistedState(t *testing.T) { } } -func TestAwsCatalogPersistedStateFromProto(t *testing.T) { +func TestAwsCredentialPersistedStateFromProto(t *testing.T) { cases := []struct { name string - in *pb.HostCatalogPersisted - opts []awsCatalogPersistedStateOption - expected *awsCatalogPersistedState + in map[string]any + opts []AwsCredentialPersistedStateOption + expected *AwsCredentialPersistedState expectedErr string }{ { - name: "no secrets", - in: &pb.HostCatalogPersisted{ - Secrets: nil, - }, + name: "no secrets", + in: nil, expectedErr: "missing persisted secrets", }, { - name: "missing access key ID", - in: &pb.HostCatalogPersisted{ - // Nil should be the same as empty map here - Secrets: mustStruct(nil), - }, + name: "missing access key ID", + in: map[string]any{}, expectedErr: "persisted state integrity error: missing required value \"access_key_id\"", }, { name: "missing secret access key", - in: &pb.HostCatalogPersisted{ - // Nil should be the same as empty map here - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - }), + in: map[string]any{ + ConstAccessKeyId: "foobar", }, expectedErr: "persisted state integrity error: missing required value \"secret_access_key\"", }, { name: "bad last rotated time", - in: &pb.HostCatalogPersisted{ - // Nil should be the same as empty map here - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "notatime", - }), + in: map[string]any{ + ConstAccessKeyId: "foobar", + ConstSecretAccessKey: "bazqux", + ConstCredsLastRotatedTime: "notatime", }, expectedErr: "persisted state integrity error: could not parse time in value \"creds_last_rotated_time\": parsing time \"notatime\" as \"2006-01-02T15:04:05.999999999Z07:00\": cannot parse \"notatime\" as \"2006\"", }, { name: "option error", - in: &pb.HostCatalogPersisted{ - // Nil should be the same as empty map here - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + in: map[string]any{ + ConstAccessKeyId: "foobar", + ConstSecretAccessKey: "bazqux", + ConstCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", }, - opts: []awsCatalogPersistedStateOption{ - func(s *awsCatalogPersistedState) error { + opts: []AwsCredentialPersistedStateOption{ + func(s *AwsCredentialPersistedState) error { return errors.New(testOptionErr) }, }, @@ -192,15 +168,12 @@ func TestAwsCatalogPersistedStateFromProto(t *testing.T) { }, { name: "good with non-zero timestamp", - in: &pb.HostCatalogPersisted{ - // Nil should be the same as empty map here - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + in: map[string]any{ + ConstAccessKeyId: "foobar", + ConstSecretAccessKey: "bazqux", + ConstCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", }, - expected: &awsCatalogPersistedState{ + expected: &AwsCredentialPersistedState{ AccessKeyId: "foobar", SecretAccessKey: "bazqux", CredsLastRotatedTime: func() time.Time { @@ -208,22 +181,18 @@ func TestAwsCatalogPersistedStateFromProto(t *testing.T) { if err != nil { panic(err) } - return t }(), }, }, { name: "good with zero timestamp", - in: &pb.HostCatalogPersisted{ - // Nil should be the same as empty map here - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), - }), + in: map[string]any{ + ConstAccessKeyId: "foobar", + ConstSecretAccessKey: "bazqux", + ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), }, - expected: &awsCatalogPersistedState{ + expected: &AwsCredentialPersistedState{ AccessKeyId: "foobar", SecretAccessKey: "bazqux", CredsLastRotatedTime: time.Time{}, @@ -231,23 +200,20 @@ func TestAwsCatalogPersistedStateFromProto(t *testing.T) { }, { name: "good (ignoring non-test options)", - in: &pb.HostCatalogPersisted{ - // Nil should be the same as empty map here - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), - }), + in: map[string]any{ + ConstAccessKeyId: "foobar", + ConstSecretAccessKey: "bazqux", + ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), }, - opts: []awsCatalogPersistedStateOption{ - withAccessKeyId("ignored"), - withRegion("someregion"), + opts: []AwsCredentialPersistedStateOption{ + WithAccessKeyId("ignored"), + WithRegion("us-west-2"), }, - expected: &awsCatalogPersistedState{ + expected: &AwsCredentialPersistedState{ AccessKeyId: "foobar", SecretAccessKey: "bazqux", CredsLastRotatedTime: time.Time{}, - region: "someregion", + region: "us-west-2", }, }, } @@ -256,76 +222,26 @@ func TestAwsCatalogPersistedStateFromProto(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { require := require.New(t) - actual, err := awsCatalogPersistedStateFromProto(tc.in, tc.opts...) + + var ( + input *structpb.Struct + err error + ) + if tc.in != nil { + input, err = structpb.NewStruct(tc.in) + require.NoError(err) + } + + actual, err := AwsCredentialPersistedStateFromProto(input, tc.opts...) if tc.expectedErr != "" { require.EqualError(err, tc.expectedErr) return } require.NoError(err) - require.Equal(tc.expected, actual) - }) - } -} - -func TestAwsCatalogPersistedStateToProto(t *testing.T) { - cases := []struct { - name string - in *awsCatalogPersistedState - expected *pb.HostCatalogPersisted - expectedErr string // NOTE: Here for scaffolding but hard to test deep structpb err right now - }{ - { - name: "good with non-zero timestamp", - in: &awsCatalogPersistedState{ - AccessKeyId: "foobar", - SecretAccessKey: "bazqux", - CredsLastRotatedTime: func() time.Time { - t, err := time.Parse(time.RFC3339, "2006-01-02T15:04:05+07:00") - if err != nil { - panic(err) - } - - return t - }(), - }, - expected: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), - }, - }, - { - name: "good with zero timestamp", - in: &awsCatalogPersistedState{ - AccessKeyId: "foobar", - SecretAccessKey: "bazqux", - CredsLastRotatedTime: time.Time{}, - }, - expected: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), - }), - }, - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - require := require.New(t) - actual, err := tc.in.ToProto() - if tc.expectedErr != "" { - require.EqualError(err, tc.expectedErr) - return - } - - require.NoError(err) - require.Equal(tc.expected, actual) + require.Equal(tc.expected.AccessKeyId, actual.AccessKeyId) + require.Equal(tc.expected.SecretAccessKey, actual.SecretAccessKey) + require.Equal(tc.expected.region, actual.region) }) } } @@ -333,26 +249,26 @@ func TestAwsCatalogPersistedStateToProto(t *testing.T) { func TestAwsCatalogPersistedStateValidateCreds(t *testing.T) { cases := []struct { name string - in *awsCatalogPersistedState + in *AwsCredentialPersistedState expectedErr string }{ { name: "could not load credentials", - in: &awsCatalogPersistedState{ + in: &AwsCredentialPersistedState{ testOpts: []awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))}, }, expectedErr: fmt.Sprintf("error loading credentials: error reading options in NewCredentialsConfig: %s", testOptionErr), }, { name: "validation error", - in: &awsCatalogPersistedState{ + in: &AwsCredentialPersistedState{ testOpts: []awsutil.Option{awsutil.WithSTSAPIFunc(awsutil.NewMockSTS(awsutil.WithGetCallerIdentityError(errors.New(testGetCallerIdentityErr))))}, }, expectedErr: fmt.Sprintf("error validating credentials: %s", testGetCallerIdentityErr), }, { name: "good", - in: &awsCatalogPersistedState{ + in: &AwsCredentialPersistedState{ AccessKeyId: "foobar", SecretAccessKey: "bazqux", testOpts: []awsutil.Option{awsutil.WithSTSAPIFunc(awsutil.NewMockSTS())}, @@ -378,7 +294,7 @@ func TestAwsCatalogPersistedStateValidateCreds(t *testing.T) { func TestAwsCatalogPersistedStateRotateCreds(t *testing.T) { cases := []struct { name string - in *awsCatalogPersistedState + in *AwsCredentialPersistedState expectedAccessKeyId string expectedSecretAccessKey string expectedNonZeroRotatedTime bool @@ -386,14 +302,14 @@ func TestAwsCatalogPersistedStateRotateCreds(t *testing.T) { }{ { name: "could not load credentials", - in: &awsCatalogPersistedState{ + in: &AwsCredentialPersistedState{ testOpts: []awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))}, }, expectedErr: fmt.Sprintf("error loading credentials: error reading options in NewCredentialsConfig: %s", testOptionErr), }, { name: "rotation error", - in: &awsCatalogPersistedState{ + in: &AwsCredentialPersistedState{ AccessKeyId: "foobar", SecretAccessKey: "bazqux", testOpts: []awsutil.Option{ @@ -408,7 +324,7 @@ func TestAwsCatalogPersistedStateRotateCreds(t *testing.T) { }, { name: "good", - in: &awsCatalogPersistedState{ + in: &AwsCredentialPersistedState{ AccessKeyId: "foobar", SecretAccessKey: "bazqux", testOpts: []awsutil.Option{ @@ -468,7 +384,7 @@ func TestAwsCatalogPersistedStateReplaceCreds(t *testing.T) { state := new(testMockIAMState) cases := []struct { name string - in *awsCatalogPersistedState + in *AwsCredentialPersistedState accessKeyId string secretAccessKey string expectedAccessKeyId string @@ -478,18 +394,18 @@ func TestAwsCatalogPersistedStateReplaceCreds(t *testing.T) { }{ { name: "missing access key id", - in: &awsCatalogPersistedState{}, + in: &AwsCredentialPersistedState{}, expectedErr: "access key id cannot be empty", }, { name: "missing access key id", - in: &awsCatalogPersistedState{}, + in: &AwsCredentialPersistedState{}, accessKeyId: "one", expectedErr: "secret access key cannot be empty", }, { name: "identical access key id", - in: &awsCatalogPersistedState{ + in: &AwsCredentialPersistedState{ AccessKeyId: "foobar", SecretAccessKey: "bazqux", }, @@ -499,7 +415,7 @@ func TestAwsCatalogPersistedStateReplaceCreds(t *testing.T) { }, { name: "deletion error", - in: &awsCatalogPersistedState{ + in: &AwsCredentialPersistedState{ AccessKeyId: "foobar", SecretAccessKey: "bazqux", CredsLastRotatedTime: time.Now(), @@ -517,7 +433,7 @@ func TestAwsCatalogPersistedStateReplaceCreds(t *testing.T) { }, { name: "good with delete of old rotated", - in: &awsCatalogPersistedState{ + in: &AwsCredentialPersistedState{ AccessKeyId: "foobar", SecretAccessKey: "bazqux", CredsLastRotatedTime: time.Now(), @@ -535,7 +451,7 @@ func TestAwsCatalogPersistedStateReplaceCreds(t *testing.T) { }, { name: "good without delete of old rotated", - in: &awsCatalogPersistedState{ + in: &AwsCredentialPersistedState{ AccessKeyId: "foobar", SecretAccessKey: "bazqux", testOpts: []awsutil.Option{ @@ -575,19 +491,19 @@ func TestAwsCatalogPersistedStateReplaceCreds(t *testing.T) { func TestAwsCatalogPersistedStateDeleteCreds(t *testing.T) { cases := []struct { name string - in *awsCatalogPersistedState + in *AwsCredentialPersistedState expectedErr string }{ { name: "could not load credentials", - in: &awsCatalogPersistedState{ + in: &AwsCredentialPersistedState{ testOpts: []awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))}, }, expectedErr: fmt.Sprintf("error loading credentials: error reading options in NewCredentialsConfig: %s", testOptionErr), }, { name: "deletion error", - in: &awsCatalogPersistedState{ + in: &AwsCredentialPersistedState{ AccessKeyId: "foobar", SecretAccessKey: "bazqux", testOpts: []awsutil.Option{ @@ -602,7 +518,7 @@ func TestAwsCatalogPersistedStateDeleteCreds(t *testing.T) { }, { name: "deletion error, but OK because key was just gone", - in: &awsCatalogPersistedState{ + in: &AwsCredentialPersistedState{ AccessKeyId: "foobar", SecretAccessKey: "bazqux", testOpts: []awsutil.Option{ @@ -618,7 +534,7 @@ func TestAwsCatalogPersistedStateDeleteCreds(t *testing.T) { }, { name: "good", - in: &awsCatalogPersistedState{ + in: &AwsCredentialPersistedState{ AccessKeyId: "foobar", SecretAccessKey: "bazqux", testOpts: []awsutil.Option{ @@ -650,37 +566,10 @@ func TestAwsCatalogPersistedStateDeleteCreds(t *testing.T) { func TestAwsCatalogPersistedStateGetSessionErr(t *testing.T) { require := require.New(t) - state := &awsCatalogPersistedState{ + state := &AwsCredentialPersistedState{ testOpts: []awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))}, } _, err := state.GetSession() require.EqualError(err, fmt.Sprintf("error reading options in NewCredentialsConfig: %s", testOptionErr)) } - -func TestAwsCatalogPersistedStateEC2ClientErr(t *testing.T) { - require := require.New(t) - state, err := newAwsCatalogPersistedState( - withStateTestOpts([]awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))}), - ) - require.NoError(err) - - _, err = state.EC2Client(WithRegion("someregion")) - require.EqualError(err, fmt.Sprintf("error getting AWS session when fetching EC2 client: error reading options in NewCredentialsConfig: %s", testOptionErr)) -} - -func TestAwsCatalogPersistedStateEC2ClientGood(t *testing.T) { - require := require.New(t) - state, err := newAwsCatalogPersistedState( - withTestEC2APIFunc(newTestMockEC2(nil)), - ) - require.NoError(err) - - clientRaw, err := state.EC2Client(WithRegion("someregion")) - require.NoError(err) - require.NotNil(clientRaw) - client, ok := clientRaw.(*testMockEC2) - require.True(ok) - require.NotNil(client) - require.Equal("someregion", client.Region) -} diff --git a/internal/credential/testing.go b/internal/credential/testing.go new file mode 100644 index 0000000..2eeb688 --- /dev/null +++ b/internal/credential/testing.go @@ -0,0 +1,47 @@ +package credential + +import ( + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/aws/aws-sdk-go/service/iam/iamiface" + "github.com/hashicorp/go-secure-stdlib/awsutil" +) + +const ( + testOptionErr = "test option error" + testGetCallerIdentityErr = "test error for GetCallerIdentity" + testGetUserErr = "test error for GetUser" + testDeleteAccessKeyErr = "test error for DeleteAccessKey" +) + +type testMockIAMState struct { + DeleteAccessKeyCalled bool +} + +func (s *testMockIAMState) Reset() { + s.DeleteAccessKeyCalled = false +} + +type testMockIAM struct { + iamiface.IAMAPI + + State *testMockIAMState +} + +func (m *testMockIAM) DeleteAccessKey(input *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) { + m.State.DeleteAccessKeyCalled = true + return m.IAMAPI.DeleteAccessKey(input) +} + +func newTestMockIAM(state *testMockIAMState, opts ...awsutil.MockIAMOption) awsutil.IAMAPIFunc { + return func(sess *session.Session) (iamiface.IAMAPI, error) { + m := &testMockIAM{ + State: state, + } + f := awsutil.NewMockIAM(opts...) + var err error + + m.IAMAPI, err = f(sess) + return m, err + } +} diff --git a/internal/errors/error.go b/internal/errors/error.go new file mode 100644 index 0000000..b3998ec --- /dev/null +++ b/internal/errors/error.go @@ -0,0 +1,23 @@ +package errors + +import ( + "fmt" + "sort" + "strings" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// InvalidArgumentError returns an grpc invalid argument status error. +func InvalidArgumentError(msg string, f map[string]string) error { + var fieldMsgs []string + for field, val := range f { + fieldMsgs = append(fieldMsgs, fmt.Sprintf("%s: %s", field, val)) + } + if len(fieldMsgs) > 0 { + sort.Strings(fieldMsgs) + msg = fmt.Sprintf("%s: [%s]", msg, strings.Join(fieldMsgs, ", ")) + } + return status.Error(codes.InvalidArgument, msg) +} diff --git a/internal/values/proto.go b/internal/values/proto.go new file mode 100644 index 0000000..fa5cebf --- /dev/null +++ b/internal/values/proto.go @@ -0,0 +1,93 @@ +package values + +import ( + "fmt" + "time" + + "google.golang.org/protobuf/types/known/structpb" +) + +// GetStringValue returns a string value and no error if the given key +// is found in the provided proto struct input. An error is returned +// if the key is not found or the value type is not a string. An error is +// returned if the parameter argument "required" is set to true +// and the value from the proto struct input is empty. +func GetStringValue(in *structpb.Struct, k string, required bool) (string, error) { + mv := in.GetFields() + v, ok := mv[k] + if !ok { + if required { + return "", fmt.Errorf("missing required value %q", k) + } + + return "", nil + } + + s, ok := v.AsInterface().(string) + if !ok { + return "", fmt.Errorf("unexpected type for value %q: want string, got %T", k, v.AsInterface()) + } + + if s == "" && required { + return "", fmt.Errorf("value %q cannot be empty", k) + } + + return s, nil +} + +// GetBoolValue returns a boolean value and no error if the given key +// is found in the provided proto struct input. An error is returned +// if the key is not found or the value type is not a bool. +func GetBoolValue(in *structpb.Struct, k string, required bool) (bool, error) { + mv := in.GetFields() + v, ok := mv[k] + if !ok { + if required { + return false, fmt.Errorf("missing required value %q", k) + } + + return false, nil + } + + b, ok := v.AsInterface().(bool) + if !ok { + return false, fmt.Errorf("unexpected type for value %q: want bool, got %T", k, v.AsInterface()) + } + + return b, nil +} + +// GetTimeValue returns a time.Time value and no error if the given key +// is found in the provided proto struct input. An error is returned +// if the key is not found or the value type is not a parsable. The +// expected time layout is RFC3339Nano. +func GetTimeValue(in *structpb.Struct, k string) (time.Time, error) { + mv := in.GetFields() + v, ok := mv[k] + if !ok { + return time.Time{}, nil + } + + tRaw, ok := v.AsInterface().(string) + if !ok { + return time.Time{}, fmt.Errorf("unexpected type for value %q: want string, got %T", k, v.AsInterface()) + } + + t, err := time.Parse(time.RFC3339Nano, tRaw) + if err != nil { + return time.Time{}, fmt.Errorf("could not parse time in value %q: %w", k, err) + } + + return t, nil +} + +// StructFields returns a map[string]struct{} of the +// proto struct input. +func StructFields(s *structpb.Struct) map[string]struct{} { + m := make(map[string]struct{}, len(s.GetFields())) + for k := range s.GetFields() { + m[k] = struct{}{} + } + + return m +} diff --git a/internal/values/proto_test.go b/internal/values/proto_test.go new file mode 100644 index 0000000..5b1bbad --- /dev/null +++ b/internal/values/proto_test.go @@ -0,0 +1,207 @@ +package values + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestGetStringValue(t *testing.T) { + cases := []struct { + name string + in map[string]any + key string + required bool + expected string + expectedErr string + }{ + { + name: "required missing", + in: map[string]any{}, + key: "foo", + required: true, + expectedErr: "missing required value \"foo\"", + }, + { + name: "optional missing", + in: map[string]any{}, + key: "foo", + expected: "", + }, + { + name: "non-string value", + in: map[string]any{ + "foo": 1, + }, + key: "foo", + expectedErr: "unexpected type for value \"foo\": want string, got float64", + }, + { + name: "required empty", + in: map[string]any{ + "foo": "", + }, + key: "foo", + required: true, + expectedErr: "value \"foo\" cannot be empty", + }, + { + name: "good", + in: map[string]any{ + "foo": "bar", + }, + key: "foo", + expected: "bar", + }, + } + + 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 := GetStringValue(input, tc.key, tc.required) + if tc.expectedErr != "" { + require.EqualError(err, tc.expectedErr) + return + } + + require.NoError(err) + require.Equal(tc.expected, actual) + }) + } +} + +func TestGetBoolValue(t *testing.T) { + cases := []struct { + name string + in map[string]any + key string + required bool + expected bool + expectedErr string + }{ + { + name: "required missing", + in: map[string]any{}, + key: "foo", + required: true, + expectedErr: "missing required value \"foo\"", + }, + { + name: "optional missing", + in: map[string]any{}, + key: "foo", + expected: false, + }, + { + name: "non-bool value", + in: map[string]any{ + "foo": "bar", + }, + key: "foo", + expectedErr: "unexpected type for value \"foo\": want bool, got string", + }, + { + name: "good", + in: map[string]any{ + "foo": true, + }, + key: "foo", + expected: true, + }, + } + + 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 := GetBoolValue(input, tc.key, tc.required) + if tc.expectedErr != "" { + require.EqualError(err, tc.expectedErr) + return + } + + require.NoError(err) + require.Equal(tc.expected, actual) + }) + } +} + +func TestGetTimeValue(t *testing.T) { + staticTime := time.Now() + + cases := []struct { + name string + in map[string]any + key string + expected time.Time + expectedErr string + }{ + { + name: "missing", + in: map[string]any{}, + key: "foo", + expected: time.Time{}, + }, + { + name: "non-time value", + in: map[string]any{ + "foo": 1, + }, + key: "foo", + expectedErr: "unexpected type for value \"foo\": want string, got float64", + }, + { + name: "bad parse", + in: map[string]any{ + "foo": "bar", + }, + key: "foo", + expectedErr: "could not parse time in value \"foo\": parsing time \"bar\" as \"2006-01-02T15:04:05.999999999Z07:00\": cannot parse \"bar\" as \"2006\"", + }, + { + name: "good", + in: map[string]any{ + "foo": staticTime.Format(time.RFC3339Nano), + }, + key: "foo", + expected: func() time.Time { + u, err := time.Parse(time.RFC3339Nano, staticTime.Format(time.RFC3339Nano)) + if err != nil { + panic(err) + } + + return u + }(), + }, + } + + 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 := GetTimeValue(input, tc.key) + if tc.expectedErr != "" { + require.EqualError(err, tc.expectedErr) + return + } + + require.NoError(err) + require.Equal(tc.expected, actual) + }) + } +} diff --git a/plugin/attributes.go b/plugin/attributes.go deleted file mode 100644 index ce97af4..0000000 --- a/plugin/attributes.go +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package plugin - -import ( - "fmt" - "sort" - "strings" - "time" - - "github.com/mitchellh/mapstructure" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/structpb" -) - -func invalidArgumentError(msg string, f map[string]string) error { - var fieldMsgs []string - for field, val := range f { - fieldMsgs = append(fieldMsgs, fmt.Sprintf("%s: %s", field, val)) - } - if len(fieldMsgs) > 0 { - sort.Strings(fieldMsgs) - msg = fmt.Sprintf("%s: [%s]", msg, strings.Join(fieldMsgs, ", ")) - } - return status.Error(codes.InvalidArgument, msg) -} - -// CatalogAttributes is a Go-native representation of the Attributes -// map. -type CatalogAttributes struct { - Region string - DisableCredentialRotation bool -} - -func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) { - unknownFields := structFields(in) - result := new(CatalogAttributes) - badFields := make(map[string]string) - - var err error - result.Region, err = getStringValue(in, constRegion, true) - if err != nil { - badFields[fmt.Sprintf("attributes.%s", constRegion)] = err.Error() - } - delete(unknownFields, constRegion) - - result.DisableCredentialRotation, err = getBoolValue(in, constDisableCredentialRotation, false) - if err != nil { - badFields[fmt.Sprintf("attributes.%s", constDisableCredentialRotation)] = err.Error() - } - delete(unknownFields, constDisableCredentialRotation) - - for s := range unknownFields { - badFields[fmt.Sprintf("attributes.%s", s)] = "unrecognized field" - } - - if len(badFields) > 0 { - return nil, invalidArgumentError("Invalid arguments in catalog attributes", badFields) - } - - return result, nil -} - -// CatalogSecrets is a Go-native representation of the Secrets map. -type CatalogSecrets struct { - AccessKeyId string - SecretAccessKey string -} - -func getCatalogSecrets(in *structpb.Struct) (*CatalogSecrets, error) { - unknownFields := structFields(in) - result := new(CatalogSecrets) - badFields := make(map[string]string) - - var err error - result.AccessKeyId, err = getStringValue(in, constAccessKeyId, true) - if err != nil { - badFields[fmt.Sprintf("secrets.%s", constAccessKeyId)] = err.Error() - } - delete(unknownFields, constAccessKeyId) - - result.SecretAccessKey, err = getStringValue(in, constSecretAccessKey, true) - if err != nil { - badFields[fmt.Sprintf("secrets.%s", constSecretAccessKey)] = err.Error() - } - delete(unknownFields, constSecretAccessKey) - - for s := range unknownFields { - badFields[fmt.Sprintf("secrets.%s", s)] = "unrecognized field" - } - - if len(badFields) > 0 { - return nil, invalidArgumentError("Error in the secrets provided", badFields) - } - - return result, nil -} - -// SetAttributes is a Go-native representation of the Attributes map that can be -// used for decoding the incoming map via mapstructure. -type SetAttributes struct { - Filters []string -} - -func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) { - var setAttrs SetAttributes - - // Quick validation to ensure that there's no non-filter attributes - // here for now. Make this more complex if we add more attributes - // to host sets. - unknownFields := structFields(in) - badFields := make(map[string]string) - delete(unknownFields, constDescribeInstancesFilters) - for a := range unknownFields { - badFields[fmt.Sprintf("attributes.%s", a)] = "unrecognized field" - } - if len(badFields) > 0 { - return nil, 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[constDescribeInstancesFilters]; ok { - switch filterVal := filtersRaw.(type) { - case string: - inMap[constDescribeInstancesFilters] = []string{filterVal} - } - } - - if err := mapstructure.Decode(inMap, &setAttrs); err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error decoding set attributes: %s", err) - } - - return &setAttrs, nil -} - -func getStringValue(in *structpb.Struct, k string, required bool) (string, error) { - mv := in.GetFields() - v, ok := mv[k] - if !ok { - if required { - return "", fmt.Errorf("missing required value %q", k) - } - - return "", nil - } - - s, ok := v.AsInterface().(string) - if !ok { - return "", fmt.Errorf("unexpected type for value %q: want string, got %T", k, v.AsInterface()) - } - - if s == "" && required { - return "", fmt.Errorf("value %q cannot be empty", k) - } - - return s, nil -} - -func getBoolValue(in *structpb.Struct, k string, required bool) (bool, error) { - mv := in.GetFields() - v, ok := mv[k] - if !ok { - if required { - return false, fmt.Errorf("missing required value %q", k) - } - - return false, nil - } - - b, ok := v.AsInterface().(bool) - if !ok { - return false, fmt.Errorf("unexpected type for value %q: want bool, got %T", k, v.AsInterface()) - } - - return b, nil -} - -func getTimeValue(in *structpb.Struct, k string) (time.Time, error) { - mv := in.GetFields() - v, ok := mv[k] - if !ok { - return time.Time{}, nil - } - - tRaw, ok := v.AsInterface().(string) - if !ok { - return time.Time{}, fmt.Errorf("unexpected type for value %q: want string, got %T", k, v.AsInterface()) - } - - t, err := time.Parse(time.RFC3339Nano, tRaw) - if err != nil { - return time.Time{}, fmt.Errorf("could not parse time in value %q: %w", k, err) - } - - return t, nil -} - -func structFields(s *structpb.Struct) map[string]struct{} { - m := make(map[string]struct{}, len(s.GetFields())) - for k := range s.GetFields() { - m[k] = struct{}{} - } - - return m -} - -func keysAsString(m map[string]struct{}) string { - s := make([]string, 0, len(m)) - for k := range m { - s = append(s, k) - } - - sort.Strings(s) - return strings.Join(s, ", ") -} diff --git a/plugin/attributes_test.go b/plugin/attributes_test.go deleted file mode 100644 index bef01ce..0000000 --- a/plugin/attributes_test.go +++ /dev/null @@ -1,456 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package plugin - -import ( - "context" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "github.com/hashicorp/boundary/sdk/pbs/plugin" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/testing/protocmp" - "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 region", - in: mustStruct(map[string]interface{}{}), - expectedErrContains: "missing required value \"region\"", - }, - { - name: "bad value for disable_credential_rotation", - in: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - constDisableCredentialRotation: "sure", - }), - expectedErrContains: "unexpected type for value \"disable_credential_rotation\": want bool, got string", - }, - { - name: "unknown fields", - in: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - "foo": true, - "bar": true, - }), - expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field", - }, - { - name: "default", - in: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), - expected: &CatalogAttributes{ - Region: "us-west-2", - DisableCredentialRotation: false, - }, - }, - { - name: "with disable_credential_rotation", - in: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - constDisableCredentialRotation: true, - }), - expected: &CatalogAttributes{ - Region: "us-west-2", - DisableCredentialRotation: true, - }, - }, - } - - 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 TestGetCatalogSecrets(t *testing.T) { - cases := []struct { - name string - in *structpb.Struct - expected *CatalogSecrets - expectedErrContains string - }{ - { - name: "missing access_key_id", - in: mustStruct(map[string]interface{}{}), - expectedErrContains: "missing required value \"access_key_id\"", - }, - { - name: "missing secret_access_key", - in: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - }), - expectedErrContains: "missing required value \"secret_access_key\"", - }, - { - name: "unknown fields", - in: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - "foo": true, - "bar": true, - }), - expectedErrContains: "secrets.bar: unrecognized field, secrets.foo: unrecognized field", - }, - { - name: "good", - in: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - }), - expected: &CatalogSecrets{ - AccessKeyId: "foobar", - SecretAccessKey: "bazqux", - }, - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - require := require.New(t) - actual, err := getCatalogSecrets(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 *structpb.Struct - normalized *structpb.Struct - expected *SetAttributes - expectedErrContains string - }{ - { - name: "missing", - in: mustStruct(map[string]interface{}{}), - expected: &SetAttributes{}, - }, - { - name: "non-string-slice value", - in: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: "zip=foo,bar", - }), - expected: &SetAttributes{ - Filters: []string{"zip=foo,bar"}, - }, - }, - { - name: "bad filter element value", - in: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{1}, - }), - expectedErrContains: "expected type 'string', got unconvertible type 'float64'", - }, - { - name: "unknown fields", - in: mustStruct(map[string]interface{}{ - "foo": true, - "bar": true, - }), - expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field", - }, - { - name: "good", - in: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{ - "foo=bar", - "zip=zap", - }, - }), - expected: &SetAttributes{ - Filters: []string{"foo=bar", "zip=zap"}, - }, - }, - { - name: "good with filter transform", - in: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: "foo=bar", - }), - normalized: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{"foo=bar"}, - }), - expected: &SetAttributes{ - Filters: []string{"foo=bar"}, - }, - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - require := require.New(t) - p := new(AwsPlugin) - normalizedOut, err := p.NormalizeSetData(context.Background(), &plugin.NormalizeSetDataRequest{Attributes: tc.in}) - require.NoError(err) - if tc.normalized != nil { - require.Empty(cmp.Diff(tc.normalized, normalizedOut.Attributes, protocmp.Transform())) - } - tc.in = normalizedOut.Attributes - actual, err := getSetAttributes(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 TestGetStringValue(t *testing.T) { - cases := []struct { - name string - in *structpb.Struct - key string - required bool - expected string - expectedErr string - }{ - { - name: "required missing", - in: mustStruct(map[string]interface{}{}), - key: "foo", - required: true, - expectedErr: "missing required value \"foo\"", - }, - { - name: "optional missing", - in: mustStruct(map[string]interface{}{}), - key: "foo", - expected: "", - }, - { - name: "non-string value", - in: mustStruct(map[string]interface{}{ - "foo": 1, - }), - key: "foo", - expectedErr: "unexpected type for value \"foo\": want string, got float64", - }, - { - name: "required empty", - in: mustStruct(map[string]interface{}{ - "foo": "", - }), - key: "foo", - required: true, - expectedErr: "value \"foo\" cannot be empty", - }, - { - name: "good", - in: mustStruct(map[string]interface{}{ - "foo": "bar", - }), - key: "foo", - expected: "bar", - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - require := require.New(t) - actual, err := getStringValue(tc.in, tc.key, tc.required) - if tc.expectedErr != "" { - require.EqualError(err, tc.expectedErr) - return - } - - require.NoError(err) - require.Equal(tc.expected, actual) - }) - } -} - -func TestGetBoolValue(t *testing.T) { - cases := []struct { - name string - in *structpb.Struct - key string - required bool - expected bool - expectedErr string - }{ - { - name: "required missing", - in: mustStruct(map[string]interface{}{}), - key: "foo", - required: true, - expectedErr: "missing required value \"foo\"", - }, - { - name: "optional missing", - in: mustStruct(map[string]interface{}{}), - key: "foo", - expected: false, - }, - { - name: "non-bool value", - in: mustStruct(map[string]interface{}{ - "foo": "bar", - }), - key: "foo", - expectedErr: "unexpected type for value \"foo\": want bool, got string", - }, - { - name: "good", - in: mustStruct(map[string]interface{}{ - "foo": true, - }), - key: "foo", - expected: true, - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - require := require.New(t) - actual, err := getBoolValue(tc.in, tc.key, tc.required) - if tc.expectedErr != "" { - require.EqualError(err, tc.expectedErr) - return - } - - require.NoError(err) - require.Equal(tc.expected, actual) - }) - } -} - -func TestGetTimeValue(t *testing.T) { - staticTime := time.Now() - - cases := []struct { - name string - in *structpb.Struct - key string - expected time.Time - expectedErr string - }{ - { - name: "missing", - in: mustStruct(map[string]interface{}{}), - key: "foo", - expected: time.Time{}, - }, - { - name: "non-time value", - in: mustStruct(map[string]interface{}{ - "foo": 1, - }), - key: "foo", - expectedErr: "unexpected type for value \"foo\": want string, got float64", - }, - { - name: "bad parse", - in: mustStruct(map[string]interface{}{ - "foo": "bar", - }), - key: "foo", - expectedErr: "could not parse time in value \"foo\": parsing time \"bar\" as \"2006-01-02T15:04:05.999999999Z07:00\": cannot parse \"bar\" as \"2006\"", - }, - { - name: "good", - in: mustStruct(map[string]interface{}{ - "foo": staticTime.Format(time.RFC3339Nano), - }), - key: "foo", - expected: func() time.Time { - u, err := time.Parse(time.RFC3339Nano, staticTime.Format(time.RFC3339Nano)) - if err != nil { - panic(err) - } - - return u - }(), - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - require := require.New(t) - actual, err := getTimeValue(tc.in, tc.key) - if tc.expectedErr != "" { - require.EqualError(err, tc.expectedErr) - return - } - - require.NoError(err) - require.Equal(tc.expected, actual) - }) - } -} - -func TestValidateRegion(t *testing.T) { - cases := []struct { - name string - in string - expectedErr string - }{ - { - name: "invalid region", - in: "foobar", - expectedErr: "not a valid region: foobar", - }, - { - name: "good", - in: "us-west-2", - expectedErr: "", - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - require := require.New(t) - err := validateRegion(tc.in) - if tc.expectedErr != "" { - require.Contains(err.Error(), tc.expectedErr) - require.Equal(status.Code(err), codes.InvalidArgument) - return - } - - require.NoError(err) - }) - } -} diff --git a/plugin/const.go b/plugin/const.go deleted file mode 100644 index 25587ab..0000000 --- a/plugin/const.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package plugin - -const ( - constRegion = "region" - constAccessKeyId = "access_key_id" - constSecretAccessKey = "secret_access_key" - constDisableCredentialRotation = "disable_credential_rotation" - constCredsLastRotatedTime = "creds_last_rotated_time" - constDescribeInstancesFilters = "filters" -) diff --git a/plugin/option.go b/plugin/option.go deleted file mode 100644 index 7ec5e25..0000000 --- a/plugin/option.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package plugin - -// getOpts iterates the inbound Options and returns a struct -func getOpts(opt ...Option) (options, error) { - opts := getDefaultOptions() - for _, o := range opt { - if o == nil { - continue - } - if err := o(&opts); err != nil { - return options{}, err - } - } - return opts, nil -} - -// Option - how Options are passed as arguments -type Option func(*options) error - -// options = how options are represented -type options struct { - withRegion string -} - -func getDefaultOptions() options { - return options{} -} - -// WithRegion contains the region to use -func WithRegion(with string) Option { - return func(o *options) error { - o.withRegion = with - return nil - } -} diff --git a/plugin/plugin.go b/plugin/plugin.go index 45988ec..c21e514 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -1,695 +1,20 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - package plugin import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/awserr" - "github.com/aws/aws-sdk-go/aws/endpoints" - "github.com/aws/aws-sdk-go/service/ec2" + "github.com/hashicorp/boundary-plugin-host-aws/plugin/service/host" pb "github.com/hashicorp/boundary/sdk/pbs/plugin" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/structpb" ) -const ( - // rotationWaitTimeout controls the time we wait for credential rotation to - // succeed. This is important to ensure that rotated credentials can be used - // right away. - rotationWaitTimeout = time.Second * 30 - - // instanceNameTagKey denotes the special AWS tag key that contains an EC2 - // instance's name. This field is to be matched literally. - instanceNameTagKey = "Name" -) - -// AwsPlugin implements the HostPluginServiceServer interface for the -// AWS plugin. -type AwsPlugin struct { - pb.UnimplementedHostPluginServiceServer - - // testStateOpts are passed in to the stored state to control test behavior - testStateOpts []awsCatalogPersistedStateOption -} - -// Ensure that we are implementing HostPluginServiceServer +// Ensure that AWSPlugin implementings the following services: +// +// HostPluginServiceServer var ( - _ pb.HostPluginServiceServer = (*AwsPlugin)(nil) + _ pb.HostPluginServiceServer = (*host.HostPlugin)(nil) ) -func (p *AwsPlugin) 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") - } - - secrets := catalog.GetSecrets() - if secrets == nil { - return nil, status.Error(codes.InvalidArgument, "secrets are required") - } - - 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 - } - catalogSecrets, err := getCatalogSecrets(secrets) - if err != nil { - return nil, err - } - - if err := validateRegion(catalogAttributes.Region); err != nil { - return nil, err - } - - // Set up the creds in our persisted state. - state, err := newAwsCatalogPersistedState(append([]awsCatalogPersistedStateOption{ - withAccessKeyId(catalogSecrets.AccessKeyId), - withSecretAccessKey(catalogSecrets.SecretAccessKey), - withRegion(catalogAttributes.Region), - }, p.testStateOpts...)...) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error setting up persisted state: %s", err) - } - - // Try to rotate the credentials if we're not skipping them. - if !catalogAttributes.DisableCredentialRotation { - if err := state.RotateCreds(); err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err) - } - } else { - // Simply validate if we aren't rotating. - if err := state.ValidateCreds(); err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error during credential validation: %s", err) - } - } - - persistedProto, err := state.ToProto() - if err != nil { - return nil, status.Error(codes.InvalidArgument, err.Error()) - } - - return &pb.OnCreateCatalogResponse{ - Persisted: persistedProto, - }, nil -} - -func (p *AwsPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalogRequest) (*pb.OnUpdateCatalogResponse, error) { - // For host catalogs we only need to really validate what is - // currently in the new copy of the host catalog data, so skip - // fetching the new copy to keep things a little less messy. - catalog := req.GetNewCatalog() - if catalog == nil { - return nil, status.Error(codes.InvalidArgument, "new catalog is nil") - } - - var updateSecrets bool - secrets := catalog.GetSecrets() - if secrets != nil { - // We will be updating secrets this run, but what exactly that - // means will be determined later. - updateSecrets = true - } - - 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 - } - - if err := validateRegion(catalogAttributes.Region); err != nil { - return nil, err - } - - // Get the persisted data. - // NOTE: We might need to change this at a later time. I'm not too - // sure *exactly* what scenarios we might encounter that would - // ultimately mean that we would have to handle an empty or missing - // state in update, but what we are ultimately assuming here - // (implicitly through awsCatalogPersistedStateFromProto) is that - // the state will exist and be populated. Personally I think this - // is fine and important, but this may change in the future. - state, err := awsCatalogPersistedStateFromProto(req.GetPersisted(), append(p.testStateOpts, withRegion(catalogAttributes.Region))...) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) - } - - if catalogAttributes.DisableCredentialRotation && !updateSecrets { - // 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 !state.CredsLastRotatedTime.IsZero() { - return nil, status.Error(codes.FailedPrecondition, "cannot disable rotation for already-rotated credentials") - } - } - - if updateSecrets { - catalogSecrets, err := getCatalogSecrets(secrets) - if err != nil { - return nil, err - } - - // Replace the credentials. 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 := state.ReplaceCreds(catalogSecrets.AccessKeyId, catalogSecrets.SecretAccessKey); err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error attempting to replace credentials: %s", err) - } - } - - if !catalogAttributes.DisableCredentialRotation && state.CredsLastRotatedTime.IsZero() { - // If we're enabling rotation now but didn't before, or have - // freshly replaced credentials, we can rotate here. - if err := state.RotateCreds(); err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err) - } - } - - // That's it! - persistedProto, err := state.ToProto() - if err != nil { - return nil, status.Error(codes.InvalidArgument, err.Error()) - } - - return &pb.OnUpdateCatalogResponse{ - Persisted: persistedProto, - }, nil -} - -func (p *AwsPlugin) 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 - } - - if err := validateRegion(catalogAttributes.Region); err != nil { - return nil, err - } - // Get the persisted data. - // NOTE: We return on error here, blocking the delete. This may or - // may not be an overzealous approach to maintaining database/state - // integrity. May need to be changed at later time if there are - // scenarios where we might be deleting things and any secret state - // may be corrupt/and or legitimately missing. - state, err := awsCatalogPersistedStateFromProto(req.GetPersisted(), append(p.testStateOpts, withRegion(catalogAttributes.Region))...) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) - } - - if !state.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 := state.DeleteCreds(); err != nil { - return nil, status.Errorf(codes.Aborted, "error removing rotated credentials during catalog deletion: %s", err) - } - } - - return &pb.OnDeleteCatalogResponse{}, 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 *AwsPlugin) NormalizeSetData(ctx context.Context, req *pb.NormalizeSetDataRequest) (*pb.NormalizeSetDataResponse, error) { - if req.Attributes == nil { - return new(pb.NormalizeSetDataResponse), nil - } - - val := req.Attributes.Fields["filters"] - if val == nil { - return &pb.NormalizeSetDataResponse{Attributes: req.Attributes}, nil - } - stringVal, ok := val.Kind.(*structpb.Value_StringValue) - if !ok { - return &pb.NormalizeSetDataResponse{Attributes: req.Attributes}, nil - } - - 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 -} - -func (p *AwsPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest) (*pb.OnCreateSetResponse, 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 - } - - if err := validateRegion(catalogAttributes.Region); err != nil { - return nil, err - } - - state, err := awsCatalogPersistedStateFromProto(req.GetPersisted(), append(p.testStateOpts, withRegion(catalogAttributes.Region))...) - 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 nil") - } - - if set.GetAttributes() == nil { - return nil, status.Error(codes.InvalidArgument, "set missing attributes") - } - setAttrs, err := getSetAttributes(set.GetAttributes()) - if err != nil { - return nil, err - } - - ec2Client, err := state.EC2Client(WithRegion(catalogAttributes.Region)) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err) - } - - input, err := buildDescribeInstancesInput(setAttrs, true) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters: %s", err) - } - - _, err = ec2Client.DescribeInstances(input) - if err == nil { - return nil, status.Error(codes.FailedPrecondition, "query error: DescribeInstances DryRun should have returned error, but none was found") - } - - if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == "DryRunOperation" { - // Success - return &pb.OnCreateSetResponse{}, nil - } - - return nil, status.Errorf(codes.InvalidArgument, "error performing dry run of DescribeInstances: %s", err) -} - -func (p *AwsPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest) (*pb.OnUpdateSetResponse, 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 - } - - if err := validateRegion(catalogAttributes.Region); err != nil { - return nil, err - } - - state, err := awsCatalogPersistedStateFromProto(req.GetPersisted(), append(p.testStateOpts, withRegion(catalogAttributes.Region))...) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) - } - - // As with catalog, we don't need to really look at the old host - // set here, just need to work off of/validate the new set config - set := req.GetNewSet() - if set == nil { - return nil, status.Error(codes.InvalidArgument, "new set is nil") - } - - if set.GetAttributes() == nil { - return nil, status.Error(codes.InvalidArgument, "new set missing attributes") - } - setAttrs, err := getSetAttributes(set.GetAttributes()) - if err != nil { - return nil, err - } - - ec2Client, err := state.EC2Client(WithRegion(catalogAttributes.Region)) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err) - } - - input, err := buildDescribeInstancesInput(setAttrs, true) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters: %s", err) - } - - _, err = ec2Client.DescribeInstances(input) - if err == nil { - return nil, status.Error(codes.FailedPrecondition, "query error: DescribeInstances DryRun should have returned error, but none was found") - } - - if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == "DryRunOperation" { - // Success - return &pb.OnUpdateSetResponse{}, nil - } - - return nil, status.Errorf(codes.InvalidArgument, "error performing dry run of DescribeInstances: %s", err) -} - -func (p *AwsPlugin) OnDeleteSet(ctx context.Context, req *pb.OnDeleteSetRequest) (*pb.OnDeleteSetResponse, error) { - // No-op, AWS host set does not maintain anything that requires - // cleanup - return &pb.OnDeleteSetResponse{}, nil -} - -func (p *AwsPlugin) 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 - } - - if err := validateRegion(catalogAttributes.Region); err != nil { - return nil, err - } - - state, err := awsCatalogPersistedStateFromProto(req.GetPersisted(), append(p.testStateOpts, withRegion(catalogAttributes.Region))...) - 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") - } - - // Build all the queries in advance. - type hostSetQuery struct { - Id string - Input *ec2.DescribeInstancesInput - Output *ec2.DescribeInstancesOutput - 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 - } - - input, err := buildDescribeInstancesInput(setAttrs, false) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters for host set id %q: %s", set.GetId(), err) - } - queries[i] = hostSetQuery{ - Id: set.GetId(), - Input: input, - } - } - - ec2Client, err := state.EC2Client() - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err) - } - - // Run all queries now and assemble output. - var maxLen int - for i, query := range queries { - output, err := ec2Client.DescribeInstances(query.Input) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error running DescribeInstances 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 _, reservation := range output.Reservations { - for _, instance := range reservation.Instances { - host, err := awsInstanceToHost(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++ // Increment maximum counter for allocation later - } - } - } - - // 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 - } - } - - // Done! - return &pb.ListHostsResponse{ - Hosts: hostResultSlice, - }, nil -} - -func validateRegion(region string) error { - _, found := endpoints.PartitionForRegion(endpoints.DefaultPartitions(), region) - if !found { - return status.Errorf(codes.InvalidArgument, "not a valid region: %s", region) - } - - return nil -} - -func buildFilters(attrs *SetAttributes) ([]*ec2.Filter, error) { - var filters []*ec2.Filter - var foundStateFilter bool - for _, filterAttr := range attrs.Filters { - // Each filter arrives in "k=v1,v2" format. First, split on equal sign. - splitFilter := strings.Split(filterAttr, "=") - switch { - case len(splitFilter) != 2: - return nil, fmt.Errorf("expected filter %q to contain a single equal sign", filterAttr) - case len(splitFilter[0]) == 0: - return nil, fmt.Errorf("filter %q contains an empty filter key", filterAttr) - case len(splitFilter[1]) == 0: - return nil, fmt.Errorf("filter %q contains an empty value", filterAttr) - } - - filterKey, filterValue := splitFilter[0], splitFilter[1] - if filterKey == "instance-state-code" || filterKey == "instance-state-name" { - foundStateFilter = true - } - - var filterValues []*string - for _, val := range strings.Split(filterValue, ",") { - filterValues = append(filterValues, aws.String(val)) - } - - filters = append(filters, &ec2.Filter{ - Name: aws.String(filterKey), - Values: filterValues, - }) - } - - if !foundStateFilter { - // if there is no explicit instance state filter, add the - // instance-state-name = ["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, &ec2.Filter{ - Name: aws.String("instance-state-name"), - Values: aws.StringSlice([]string{ec2.InstanceStateNameRunning}), - }) - } - - return filters, nil -} - -func buildDescribeInstancesInput(attrs *SetAttributes, dryRun bool) (*ec2.DescribeInstancesInput, error) { - filters, err := buildFilters(attrs) - if err != nil { - return nil, fmt.Errorf("error building filters: %w", err) - } - - return &ec2.DescribeInstancesInput{ - DryRun: aws.Bool(dryRun), - Filters: filters, - }, nil -} - -// awsInstanceToHost processes data from an ec2.Instance and returns -// a ListHostsResponseHost with the ID and network addresses -// populated. -func awsInstanceToHost(instance *ec2.Instance) (*pb.ListHostsResponseHost, error) { - // Integrity check: some fields should always be non-nil. Check - // those. - if instance == nil { - return nil, errors.New("response integrity error: missing instance entry") - } - - if aws.StringValue(instance.InstanceId) == "" { - return nil, errors.New("response integrity error: missing instance id") - } - - result := new(pb.ListHostsResponseHost) - - // External ID is the instance ID. - result.ExternalId = aws.StringValue(instance.InstanceId) - - // External Name is the AWS "Name" tag, if it exists. - for _, t := range instance.Tags { - if aws.StringValue(t.Key) == instanceNameTagKey { - result.ExternalName = aws.StringValue(t.Value) - break - } - } - - // First IP address/dns name are always the private fields if they - // are populated - if aws.StringValue(instance.PrivateIpAddress) != "" { - result.IpAddresses = append(result.IpAddresses, aws.StringValue(instance.PrivateIpAddress)) - } - if aws.StringValue(instance.PrivateDnsName) != "" { - result.DnsNames = append(result.DnsNames, aws.StringValue(instance.PrivateDnsName)) - } - - // Public IP address/dns names are next - if aws.StringValue(instance.PublicIpAddress) != "" { - result.IpAddresses = append(result.IpAddresses, aws.StringValue(instance.PublicIpAddress)) - } - if aws.StringValue(instance.PublicDnsName) != "" { - result.DnsNames = append(result.DnsNames, aws.StringValue(instance.PublicDnsName)) - } - - // Now go through all of the interfaces and log the IP address of - // every interface. - for _, iface := range instance.NetworkInterfaces { - if iface == nil { - // Probably will never happen, but just in case - return nil, errors.New("response integrity error: interface entry is nil") - } - - // Populate default IP addresses/DNS name similar to how we do - // for the entire instance. - if aws.StringValue(iface.PrivateIpAddress) != "" && !stringInSlice(result.IpAddresses, aws.StringValue(iface.PrivateIpAddress)) { - result.IpAddresses = append(result.IpAddresses, aws.StringValue(iface.PrivateIpAddress)) - } - if aws.StringValue(iface.PrivateDnsName) != "" && !stringInSlice(result.DnsNames, aws.StringValue(iface.PrivateDnsName)) { - result.DnsNames = append(result.DnsNames, aws.StringValue(iface.PrivateDnsName)) - } - - // Iterate through the private IP addresses and log the - // information. - for _, addr := range iface.PrivateIpAddresses { - if addr == nil { - return nil, errors.New("response integrity error: interface address entry is nil") - } - - // Add private address/dns name if they have not been added yet - if aws.StringValue(addr.PrivateIpAddress) != "" && !stringInSlice(result.IpAddresses, aws.StringValue(addr.PrivateIpAddress)) { - result.IpAddresses = append(result.IpAddresses, aws.StringValue(addr.PrivateIpAddress)) - } - if aws.StringValue(addr.PrivateDnsName) != "" && !stringInSlice(result.DnsNames, aws.StringValue(addr.PrivateDnsName)) { - result.DnsNames = append(result.DnsNames, aws.StringValue(addr.PrivateDnsName)) - } - - // Add public address/dns name if they have not been added yet - if addr.Association != nil && aws.StringValue(addr.Association.PublicIp) != "" && !stringInSlice(result.IpAddresses, aws.StringValue(addr.Association.PublicIp)) { - result.IpAddresses = append(result.IpAddresses, aws.StringValue(addr.Association.PublicIp)) - } - if addr.Association != nil && aws.StringValue(addr.Association.PublicDnsName) != "" && !stringInSlice(result.DnsNames, aws.StringValue(addr.Association.PublicDnsName)) { - result.DnsNames = append(result.DnsNames, aws.StringValue(addr.Association.PublicDnsName)) - } - } - - // Add the IPv6 addresses. - for _, addr := range iface.Ipv6Addresses { - if addr == nil { - continue - } - - if addr.Ipv6Address != nil && aws.StringValue(addr.Ipv6Address) != "" { - result.IpAddresses = append(result.IpAddresses, aws.StringValue(addr.Ipv6Address)) - } - } - } - - // Done - return result, nil -} - -func stringInSlice(s []string, x string) bool { - for _, y := range s { - if x == y { - return true - } - } - - return false +// AwsPlugin contains a collection of all aws plugin services. +type AwsPlugin struct { + // HostPlugin implements the HostPluginServiceServer interface for + // supporting dynamically sourcing hosts from Amazon EC2 + *host.HostPlugin } diff --git a/plugin/service/host/README.md b/plugin/service/host/README.md new file mode 100644 index 0000000..459e5ce --- /dev/null +++ b/plugin/service/host/README.md @@ -0,0 +1,128 @@ +## Getting Started + +Refer to [Attributes and Secrets](#attributes-and-secrets) for more detail on +configuration options for host catalogs and sets. + +To create a host catalog (using default scope created by `boundary dev`): + +``` +boundary host-catalogs create plugin \ + -scope-id p_1234567890 \ + -name "Example Plugin-Based Host Catalog" \ + -description "Description for plugin-based host catalog" \ + -plugin-name aws \ + -attr region=REGION \ + -secret access_key_id='KEY' \ + -secret secret_access_key='SECRET' +``` + +To create a host set, filtering the host set based on tag keys `foo` or `bar` +(either tag can be present), ensuring that any targets set to this host set only +connect to external addresses in the `54.0.0.0/8` class A subnet: + +``` +boundary host-sets create plugin \ + -host-catalog-id HOST_CATALOG_ID \ + -name "Example Plugin-Based Host Set" \ + -description "Description for plugin-based host set" \ + -attr filters=tag-key=foo,bar \ + -preferred-endpoint "cidr:54.0.0.0/8" +``` + +As above, but instances must have both tags (both `foo` and `bar` *must* be +present): + +``` +boundary host-sets create plugin \ + -host-catalog-id HOST_CATALOG_ID \ + -name "Example Plugin-Based Host Set" \ + -description "Description for plugin-based host set" \ + -attr filters=tag-key=foo \ + -attr filters=tag-key=bar \ + -preferred-endpoint "cidr:54.0.0.0/8" +``` + +As above, but matching on tag key named "development" with value "prod" or "dev" +and launch date: + +``` +boundary host-sets create plugin \ + -host-catalog-id HOST_CATALOG_ID \ + -name "Example Plugin-Based Host Set" \ + -description "Description for plugin-based host set" \ + -attr filters=tag:development=prod,dev \ + -attr filters=launch-time=2022-01-04T* \ + -preferred-endpoint "cidr:54.0.0.0/8" +``` + +## Required IAM Privileges + +The following IAM privileges, at the very least, are required to be attached to +a configured IAM user for this provider: + +* `ec2:DescribeInstances`, configured to `*` (`DescribeInstances` cannot be + scoped to a resource ARN). Example policy: + +``` +{ + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "ec2:DescribeInstances" + ], + "Effect": "Allow", + "Resource": "*" + } + ] +} +``` + +* `iam:GetUser`, `iam:CreateAccessKey`, and `iam:DeleteAccessKey`, configured to + the IAM user to allow credential rotation. Example policy: + +``` +{ + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "iam:DeleteAccessKey", + "iam:GetUser", + "iam:CreateAccessKey" + ], + "Effect": "Allow", + "Resource": "arn:aws:iam::123456789012:user/JohnDoe" + } + ] +} +``` + +## Attributes and Secrets + +### Host Catalog + +The following `attributes` are valid on an AWS host catalog resource: + +- `disable_credential_rotation` (bool): If `true`, credential rotation will not + be performed. See the [Credential Rotation](#credential-rotation) section + above. +- `region` (string): The region to configure the host catalog for. All host sets + in this catalog will be configured for this region. + +The following `secrets` are valid on an AWS host catalog resource: + +- `access_key_id` (string): The access key ID for the IAM user to use with this + host catalog. +- `secret_access_key` (string): The secret access key for the IAM user to use + with this host catalog. + +### Host Set + +The following attributes are valid on an AWS host Set resource: + +- `filters` (array of strings): An array of string filters in the format + `key=val1,val2`. The key corresponds to a filter option, and the value(s) are + a comma-separated list. For a list of filter options, check out + [describe-instances in the AWS CLI + reference](https://docs.aws.amazon.com/cli/latest/reference/ec2/describe-instances.html). \ No newline at end of file diff --git a/plugin/service/host/attributes.go b/plugin/service/host/attributes.go new file mode 100644 index 0000000..013a630 --- /dev/null +++ b/plugin/service/host/attributes.go @@ -0,0 +1,90 @@ +package host + +import ( + "fmt" + + cred "github.com/hashicorp/boundary-plugin-host-aws/internal/credential" + "github.com/hashicorp/boundary-plugin-host-aws/internal/errors" + "github.com/hashicorp/boundary-plugin-host-aws/internal/values" + "github.com/mitchellh/mapstructure" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +// CatalogAttributes is a Go-native representation of the Attributes +// map. +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.ConstRegion: + continue + case cred.ConstDisableCredentialRotation: + 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 is a Go-native representation of the Attributes map that can be +// used for decoding the incoming map via mapstructure. +type SetAttributes struct { + Filters []string +} + +func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) { + var setAttrs SetAttributes + + // Quick validation to ensure that there's no non-filter attributes + // here for now. Make this more complex if we add more attributes + // to host sets. + unknownFields := values.StructFields(in) + badFields := make(map[string]string) + delete(unknownFields, ConstDescribeInstancesFilters) + 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[ConstDescribeInstancesFilters]; ok { + switch filterVal := filtersRaw.(type) { + case string: + inMap[ConstDescribeInstancesFilters] = []string{filterVal} + } + } + + if err := mapstructure.Decode(inMap, &setAttrs); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error decoding set attributes: %s", err) + } + + return &setAttrs, nil +} diff --git a/plugin/service/host/attributes_test.go b/plugin/service/host/attributes_test.go new file mode 100644 index 0000000..243afcb --- /dev/null +++ b/plugin/service/host/attributes_test.go @@ -0,0 +1,188 @@ +package host + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/boundary-plugin-host-aws/internal/credential" + "github.com/hashicorp/boundary/sdk/pbs/plugin" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/testing/protocmp" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestGetCatalogAttributes(t *testing.T) { + cases := []struct { + name string + in map[string]any + expected *CatalogAttributes + expectedErrContains string + }{ + { + name: "missing region", + in: map[string]any{}, + expectedErrContains: "missing required value \"region\"", + }, + { + name: "bad value for disable_credential_rotation", + in: map[string]any{ + credential.ConstRegion: "us-west-2", + credential.ConstDisableCredentialRotation: "sure", + }, + expectedErrContains: "unexpected type for value \"disable_credential_rotation\": want bool, got string", + }, + { + name: "unknown fields", + in: map[string]any{ + credential.ConstRegion: "us-west-2", + "foo": true, + "bar": true, + }, + expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field", + }, + { + name: "default", + in: map[string]any{ + credential.ConstRegion: "us-west-2", + }, + expected: &CatalogAttributes{ + CredentialAttributes: &credential.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: false, + }, + }, + }, + { + name: "with disable_credential_rotation", + in: map[string]any{ + credential.ConstRegion: "us-west-2", + credential.ConstDisableCredentialRotation: true, + }, + expected: &CatalogAttributes{ + CredentialAttributes: &credential.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: true, + }, + }, + }, + } + + 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 := getCatalogAttributes(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) + }) + } +} + +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: "non-string-slice value", + in: map[string]any{ + ConstDescribeInstancesFilters: "zip=foo,bar", + }, + expected: &SetAttributes{ + Filters: []string{"zip=foo,bar"}, + }, + }, + { + name: "bad filter element value", + in: map[string]any{ + ConstDescribeInstancesFilters: []any{1}, + }, + expectedErrContains: "expected type 'string', got unconvertible type 'float64'", + }, + { + name: "unknown fields", + in: map[string]any{ + "foo": true, + "bar": true, + }, + expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field", + }, + { + name: "good", + in: map[string]any{ + ConstDescribeInstancesFilters: []any{ + "foo=bar", + "zip=zap", + }, + }, + expected: &SetAttributes{ + Filters: []string{"foo=bar", "zip=zap"}, + }, + }, + { + name: "good with filter transform", + in: map[string]any{ + ConstDescribeInstancesFilters: "foo=bar", + }, + normalized: map[string]any{ + ConstDescribeInstancesFilters: []any{"foo=bar"}, + }, + expected: &SetAttributes{ + Filters: []string{"foo=bar"}, + }, + }, + } + + 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) + + p := new(HostPlugin) + normalizedOut, err := p.NormalizeSetData(context.Background(), &plugin.NormalizeSetDataRequest{Attributes: input}) + require.NoError(err) + + if tc.normalized != nil { + normalized, err := structpb.NewStruct(tc.normalized) + require.NoError(err) + require.Empty(cmp.Diff(normalized, normalizedOut.Attributes, protocmp.Transform())) + } + input = normalizedOut.Attributes + 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) + }) + } +} diff --git a/plugin/service/host/const.go b/plugin/service/host/const.go new file mode 100644 index 0000000..ac97c24 --- /dev/null +++ b/plugin/service/host/const.go @@ -0,0 +1,10 @@ +package host + +const ( + // ConstDescribeInstancesFilters is the key for the filter in the dynamic host set. + ConstDescribeInstancesFilters = "filters" + + // ConstInstanceNameTagKey denotes the special AWS tag key that contains an EC2 + // instance's name. This field is to be matched literally. + ConstInstanceNameTagKey = "Name" +) diff --git a/plugin/service/host/plugin.go b/plugin/service/host/plugin.go new file mode 100644 index 0000000..d2a77ac --- /dev/null +++ b/plugin/service/host/plugin.go @@ -0,0 +1,725 @@ +package host + +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/ec2" + pb "github.com/hashicorp/boundary/sdk/pbs/plugin" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/hashicorp/boundary-plugin-host-aws/internal/credential" + cred "github.com/hashicorp/boundary-plugin-host-aws/internal/credential" +) + +// HostPlugin implements the HostPluginServiceServer interface for the +// AWS host service plugin. +type HostPlugin struct { + pb.UnimplementedHostPluginServiceServer + + // testCredStateOpts are passed in to the stored state to control test behavior + testCredStateOpts []credential.AwsCredentialPersistedStateOption + + // testCatalogStateOpts are passed in to the stored state to control test behavior + testCatalogStateOpts []awsCatalogPersistedStateOption +} + +// Ensure that we are implementing HostPluginServiceServer +var _ pb.HostPluginServiceServer = (*HostPlugin)(nil) + +// 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") + } + + secrets := catalog.GetSecrets() + if secrets == nil { + return nil, status.Error(codes.InvalidArgument, "secrets are required") + } + + 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 := cred.GetCredentialsConfig(secrets, catalogAttributes.Region) + if err != nil { + return nil, err + } + + // Set up the creds in our persisted state. + credState, err := credential.NewAwsCredentialPersistedState( + append([]credential.AwsCredentialPersistedStateOption{ + credential.WithAccessKeyId(credConfig.AccessKey), + credential.WithSecretAccessKey(credConfig.SecretKey), + credential.WithRegion(credConfig.Region), + }, p.testCredStateOpts...)..., + ) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error setting up persisted state: %s", err) + } + + catalogState, err := newAwsCatalogPersistedState( + append([]awsCatalogPersistedStateOption{ + withCredentials(credState), + }, p.testCatalogStateOpts...)..., + ) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error setting up persisted state: %s", err) + } + + // Try to rotate the credentials if we're not skipping them. + if !catalogAttributes.DisableCredentialRotation { + if err := credState.RotateCreds(); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err) + } + } else { + // Simply validate if we aren't rotating. + if err := credState.ValidateCreds(); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error during credential validation: %s", err) + } + } + + persistedProto, err := catalogState.toProto() + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + 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) { + // For host catalogs we only need to really validate what is + // currently in the new copy of the host catalog data, so skip + // fetching the new copy to keep things a little less messy. + catalog := req.GetNewCatalog() + if catalog == nil { + return nil, status.Error(codes.InvalidArgument, "new catalog is nil") + } + + var updateSecrets bool + secrets := catalog.GetSecrets() + if secrets != nil { + // We will be updating secrets this run, but what exactly that + // means will be determined later. + updateSecrets = true + } + + 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 + } + + // Get the persisted data. + // NOTE: We might need to change this at a later time. I'm not too + // sure *exactly* what scenarios we might encounter that would + // ultimately mean that we would have to handle an empty or missing + // state in update, but what we are ultimately assuming here + // (implicitly through awsCatalogPersistedStateFromProto) is that + // the state will exist and be populated. Personally I think this + // is fine and important, but this may change in the future. + credState, err := credential.AwsCredentialPersistedStateFromProto( + req.GetPersisted().GetSecrets(), + append(p.testCredStateOpts, credential.WithRegion(catalogAttributes.Region))...) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) + } + + catalogState, err := newAwsCatalogPersistedState( + append([]awsCatalogPersistedStateOption{ + withCredentials(credState), + }, p.testCatalogStateOpts...)..., + ) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) + } + + if catalogAttributes.DisableCredentialRotation && !updateSecrets { + // 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 !credState.CredsLastRotatedTime.IsZero() { + return nil, status.Error(codes.FailedPrecondition, "cannot disable rotation for already-rotated credentials") + } + } + + if updateSecrets { + catalogSecrets, err := credential.GetCredentialsConfig(secrets, catalogAttributes.Region) + if err != nil { + return nil, err + } + + // Replace the credentials. 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(catalogSecrets.AccessKey, catalogSecrets.SecretKey); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error attempting to replace credentials: %s", err) + } + } + + if !catalogAttributes.DisableCredentialRotation && credState.CredsLastRotatedTime.IsZero() { + // If we're enabling rotation now but didn't before, or have + // freshly replaced credentials, we can rotate here. + if err := credState.RotateCreds(); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err) + } + } + + // That's it! + persistedProto, err := catalogState.toProto() + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + 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 + } + + // Get the persisted data. + // NOTE: We return on error here, blocking the delete. This may or + // may not be an overzealous approach to maintaining database/state + // integrity. May need to be changed at later time if there are + // scenarios where we might be deleting things and any secret state + // may be corrupt/and or legitimately missing. + credState, err := credential.AwsCredentialPersistedStateFromProto( + req.GetPersisted().GetSecrets(), + append(p.testCredStateOpts, credential.WithRegion(catalogAttributes.Region))...) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) + } + + _, err = newAwsCatalogPersistedState( + append([]awsCatalogPersistedStateOption{ + withCredentials(credState), + }, p.testCatalogStateOpts...)..., + ) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) + } + + 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(); err != nil { + return nil, status.Errorf(codes.Aborted, "error removing rotated credentials during catalog deletion: %s", err) + } + } + + return &pb.OnDeleteCatalogResponse{}, 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 new(pb.NormalizeSetDataResponse), nil + } + + val := req.Attributes.Fields["filters"] + if val == nil { + return &pb.NormalizeSetDataResponse{Attributes: req.Attributes}, nil + } + stringVal, ok := val.Kind.(*structpb.Value_StringValue) + if !ok { + return &pb.NormalizeSetDataResponse{Attributes: req.Attributes}, nil + } + + 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 +} + +// 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 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.AwsCredentialPersistedStateFromProto( + req.GetPersisted().GetSecrets(), + append(p.testCredStateOpts, credential.WithRegion(catalogAttributes.Region))...) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) + } + + catalogState, err := newAwsCatalogPersistedState( + append([]awsCatalogPersistedStateOption{ + 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 nil") + } + + if set.GetAttributes() == nil { + return nil, status.Error(codes.InvalidArgument, "set missing attributes") + } + setAttrs, err := getSetAttributes(set.GetAttributes()) + if err != nil { + return nil, err + } + + ec2Client, err := catalogState.EC2Client(catalogAttributes.Region) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err) + } + + input, err := buildDescribeInstancesInput(setAttrs, true) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters: %s", err) + } + + _, err = ec2Client.DescribeInstances(input) + if err == nil { + return nil, status.Error(codes.FailedPrecondition, "query error: DescribeInstances DryRun should have returned error, but none was found") + } + + if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == "DryRunOperation" { + // Success + return &pb.OnCreateSetResponse{}, nil + } + + return nil, status.Errorf(codes.InvalidArgument, "error performing dry run of DescribeInstances: %s", err) +} + +// 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 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.AwsCredentialPersistedStateFromProto( + req.GetPersisted().GetSecrets(), + append(p.testCredStateOpts, credential.WithRegion(catalogAttributes.Region))...) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) + } + + catalogState, err := newAwsCatalogPersistedState( + append([]awsCatalogPersistedStateOption{ + withCredentials(credState), + }, p.testCatalogStateOpts...)..., + ) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) + } + + // As with catalog, we don't need to really look at the old host + // set here, just need to work off of/validate the new set config + set := req.GetNewSet() + if set == nil { + return nil, status.Error(codes.InvalidArgument, "new set is nil") + } + + if set.GetAttributes() == nil { + return nil, status.Error(codes.InvalidArgument, "new set missing attributes") + } + setAttrs, err := getSetAttributes(set.GetAttributes()) + if err != nil { + return nil, err + } + + ec2Client, err := catalogState.EC2Client(catalogAttributes.Region) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err) + } + + input, err := buildDescribeInstancesInput(setAttrs, true) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters: %s", err) + } + + _, err = ec2Client.DescribeInstances(input) + if err == nil { + return nil, status.Error(codes.FailedPrecondition, "query error: DescribeInstances DryRun should have returned error, but none was found") + } + + if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == "DryRunOperation" { + // Success + return &pb.OnUpdateSetResponse{}, nil + } + + return nil, status.Errorf(codes.InvalidArgument, "error performing dry run of DescribeInstances: %s", err) +} + +// OnDeleteSet is called when a dynamic host set is deleted. +func (p *HostPlugin) OnDeleteSet(ctx context.Context, req *pb.OnDeleteSetRequest) (*pb.OnDeleteSetResponse, error) { + // No-op, AWS host set does not maintain anything that requires + // cleanup + return &pb.OnDeleteSetResponse{}, nil +} + +// ListHosts returns the list of ec2 hosts and their descriptions. +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.AwsCredentialPersistedStateFromProto(req.GetPersisted().GetSecrets(), p.testCredStateOpts...) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) + } + + catalogState, err := newAwsCatalogPersistedState( + append([]awsCatalogPersistedStateOption{ + 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") + } + + // Build all the queries in advance. + type hostSetQuery struct { + Id string + Input *ec2.DescribeInstancesInput + Output *ec2.DescribeInstancesOutput + 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 + } + + input, err := buildDescribeInstancesInput(setAttrs, false) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters for host set id %q: %s", set.GetId(), err) + } + queries[i] = hostSetQuery{ + Id: set.GetId(), + Input: input, + } + } + + ec2Client, err := catalogState.EC2Client(catalogAttributes.Region) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err) + } + + // Run all queries now and assemble output. + var maxLen int + for i, query := range queries { + output, err := ec2Client.DescribeInstances(query.Input) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error running DescribeInstances 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 _, reservation := range output.Reservations { + for _, instance := range reservation.Instances { + host, err := awsInstanceToHost(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++ // Increment maximum counter for allocation later + } + } + } + + // 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 + } + } + + // Done! + return &pb.ListHostsResponse{ + Hosts: hostResultSlice, + }, nil +} + +func buildFilters(attrs *SetAttributes) ([]*ec2.Filter, error) { + var filters []*ec2.Filter + var foundStateFilter bool + for _, filterAttr := range attrs.Filters { + // Each filter arrives in "k=v1,v2" format. First, split on equal sign. + splitFilter := strings.Split(filterAttr, "=") + switch { + case len(splitFilter) != 2: + return nil, fmt.Errorf("expected filter %q to contain a single equal sign", filterAttr) + case len(splitFilter[0]) == 0: + return nil, fmt.Errorf("filter %q contains an empty filter key", filterAttr) + case len(splitFilter[1]) == 0: + return nil, fmt.Errorf("filter %q contains an empty value", filterAttr) + } + + filterKey, filterValue := splitFilter[0], splitFilter[1] + if filterKey == "instance-state-code" || filterKey == "instance-state-name" { + foundStateFilter = true + } + + var filterValues []*string + for _, val := range strings.Split(filterValue, ",") { + filterValues = append(filterValues, aws.String(val)) + } + + filters = append(filters, &ec2.Filter{ + Name: aws.String(filterKey), + Values: filterValues, + }) + } + + if !foundStateFilter { + // if there is no explicit instance state filter, add the + // instance-state-name = ["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, &ec2.Filter{ + Name: aws.String("instance-state-name"), + Values: aws.StringSlice([]string{ec2.InstanceStateNameRunning}), + }) + } + + return filters, nil +} + +func buildDescribeInstancesInput(attrs *SetAttributes, dryRun bool) (*ec2.DescribeInstancesInput, error) { + filters, err := buildFilters(attrs) + if err != nil { + return nil, fmt.Errorf("error building filters: %w", err) + } + + return &ec2.DescribeInstancesInput{ + DryRun: aws.Bool(dryRun), + Filters: filters, + }, nil +} + +// awsInstanceToHost processes data from an ec2.Instance and returns +// a ListHostsResponseHost with the ID and network addresses +// populated. +func awsInstanceToHost(instance *ec2.Instance) (*pb.ListHostsResponseHost, error) { + // Integrity check: some fields should always be non-nil. Check + // those. + if instance == nil { + return nil, errors.New("response integrity error: missing instance entry") + } + + if aws.StringValue(instance.InstanceId) == "" { + return nil, errors.New("response integrity error: missing instance id") + } + + result := new(pb.ListHostsResponseHost) + + // External ID is the instance ID. + result.ExternalId = aws.StringValue(instance.InstanceId) + + // External Name is the AWS "Name" tag, if it exists. + for _, t := range instance.Tags { + if aws.StringValue(t.Key) == ConstInstanceNameTagKey { + result.ExternalName = aws.StringValue(t.Value) + break + } + } + + // First IP address/dns name are always the private fields if they + // are populated + if aws.StringValue(instance.PrivateIpAddress) != "" { + result.IpAddresses = append(result.IpAddresses, aws.StringValue(instance.PrivateIpAddress)) + } + if aws.StringValue(instance.PrivateDnsName) != "" { + result.DnsNames = append(result.DnsNames, aws.StringValue(instance.PrivateDnsName)) + } + + // Public IP address/dns names are next + if aws.StringValue(instance.PublicIpAddress) != "" { + result.IpAddresses = append(result.IpAddresses, aws.StringValue(instance.PublicIpAddress)) + } + if aws.StringValue(instance.PublicDnsName) != "" { + result.DnsNames = append(result.DnsNames, aws.StringValue(instance.PublicDnsName)) + } + + // Now go through all of the interfaces and log the IP address of + // every interface. + for _, iface := range instance.NetworkInterfaces { + if iface == nil { + // Probably will never happen, but just in case + return nil, errors.New("response integrity error: interface entry is nil") + } + + // Populate default IP addresses/DNS name similar to how we do + // for the entire instance. + if aws.StringValue(iface.PrivateIpAddress) != "" && !stringInSlice(result.IpAddresses, aws.StringValue(iface.PrivateIpAddress)) { + result.IpAddresses = append(result.IpAddresses, aws.StringValue(iface.PrivateIpAddress)) + } + if aws.StringValue(iface.PrivateDnsName) != "" && !stringInSlice(result.DnsNames, aws.StringValue(iface.PrivateDnsName)) { + result.DnsNames = append(result.DnsNames, aws.StringValue(iface.PrivateDnsName)) + } + + // Iterate through the private IP addresses and log the + // information. + for _, addr := range iface.PrivateIpAddresses { + if addr == nil { + return nil, errors.New("response integrity error: interface address entry is nil") + } + + // Add private address/dns name if they have not been added yet + if aws.StringValue(addr.PrivateIpAddress) != "" && !stringInSlice(result.IpAddresses, aws.StringValue(addr.PrivateIpAddress)) { + result.IpAddresses = append(result.IpAddresses, aws.StringValue(addr.PrivateIpAddress)) + } + if aws.StringValue(addr.PrivateDnsName) != "" && !stringInSlice(result.DnsNames, aws.StringValue(addr.PrivateDnsName)) { + result.DnsNames = append(result.DnsNames, aws.StringValue(addr.PrivateDnsName)) + } + + // Add public address/dns name if they have not been added yet + if addr.Association != nil && aws.StringValue(addr.Association.PublicIp) != "" && !stringInSlice(result.IpAddresses, aws.StringValue(addr.Association.PublicIp)) { + result.IpAddresses = append(result.IpAddresses, aws.StringValue(addr.Association.PublicIp)) + } + if addr.Association != nil && aws.StringValue(addr.Association.PublicDnsName) != "" && !stringInSlice(result.DnsNames, aws.StringValue(addr.Association.PublicDnsName)) { + result.DnsNames = append(result.DnsNames, aws.StringValue(addr.Association.PublicDnsName)) + } + } + + // Add the IPv6 addresses. + for _, addr := range iface.Ipv6Addresses { + if addr == nil { + continue + } + + if addr.Ipv6Address != nil && aws.StringValue(addr.Ipv6Address) != "" { + result.IpAddresses = append(result.IpAddresses, aws.StringValue(addr.Ipv6Address)) + } + } + } + + // Done + return result, nil +} + +func stringInSlice(s []string, x string) bool { + for _, y := range s { + if x == y { + return true + } + } + + return false +} diff --git a/plugin/plugin_test.go b/plugin/service/host/plugin_test.go similarity index 59% rename from plugin/plugin_test.go rename to plugin/service/host/plugin_test.go index 0b6ffff..eacb3f8 100644 --- a/plugin/plugin_test.go +++ b/plugin/service/host/plugin_test.go @@ -1,7 +1,4 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package plugin +package host import ( "context" @@ -12,6 +9,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" + "github.com/hashicorp/boundary-plugin-host-aws/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" @@ -26,7 +24,8 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { cases := []struct { name string req *pb.OnCreateCatalogRequest - opts []awsCatalogPersistedStateOption + credOpts []credential.AwsCredentialPersistedStateOption + catalogOpts []awsCatalogPersistedStateOption expectedErrContains string expectedErrCode codes.Code }{ @@ -73,9 +72,11 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { Catalog: &hostcatalogs.HostCatalog{ Secrets: new(structpb.Struct), Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, }, @@ -86,14 +87,17 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { name: "invalid region", req: &pb.OnCreateCatalogRequest{ Catalog: &hostcatalogs.HostCatalog{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + }}, Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "foobar", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("foobar"), + }, + }, }, }, }, @@ -104,18 +108,21 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { name: "persisted state setup error", req: &pb.OnCreateCatalogRequest{ Catalog: &hostcatalogs.HostCatalog{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + }}, Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, }, - opts: []awsCatalogPersistedStateOption{ + catalogOpts: []awsCatalogPersistedStateOption{ func(s *awsCatalogPersistedState) error { return errors.New(testOptionErr) }, @@ -127,19 +134,22 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { name: "rotation error", req: &pb.OnCreateCatalogRequest{ Catalog: &hostcatalogs.HostCatalog{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + }}, Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, }, - opts: []awsCatalogPersistedStateOption{ - withStateTestOpts([]awsutil.Option{ + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts([]awsutil.Option{ awsutil.WithIAMAPIFunc( awsutil.NewMockIAM( awsutil.WithGetUserError(errors.New(testGetUserErr)), @@ -154,20 +164,23 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { name: "validation error", req: &pb.OnCreateCatalogRequest{ Catalog: &hostcatalogs.HostCatalog{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + }}, Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - constDisableCredentialRotation: true, - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, }, }, }, - opts: []awsCatalogPersistedStateOption{ - withStateTestOpts([]awsutil.Option{ + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts([]awsutil.Option{ awsutil.WithSTSAPIFunc( awsutil.NewMockSTS( awsutil.WithGetCallerIdentityError(errors.New(testGetCallerIdentityErr)), @@ -184,8 +197,9 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { require := require.New(t) - p := &AwsPlugin{ - testStateOpts: tc.opts, + p := &HostPlugin{ + testCredStateOpts: tc.credOpts, + testCatalogStateOpts: tc.catalogOpts, } _, err := p.OnCreateCatalog(context.Background(), tc.req) require.Contains(err.Error(), tc.expectedErrContains) @@ -198,7 +212,8 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) { cases := []struct { name string req *pb.OnUpdateCatalogRequest - opts []awsCatalogPersistedStateOption + credOpts []credential.AwsCredentialPersistedStateOption + catalogOpts []awsCatalogPersistedStateOption expectedErrContains string expectedErrCode codes.Code }{ @@ -236,9 +251,11 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) { req: &pb.OnUpdateCatalogRequest{ NewCatalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "foobar", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("foobar"), + }, + }, }, }, }, @@ -250,20 +267,24 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) { req: &pb.OnUpdateCatalogRequest{ NewCatalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, }, - opts: []awsCatalogPersistedStateOption{ + catalogOpts: []awsCatalogPersistedStateOption{ func(s *awsCatalogPersistedState) error { return errors.New(testOptionErr) }, @@ -276,18 +297,22 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) { req: &pb.OnUpdateCatalogRequest{ NewCatalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - constDisableCredentialRotation: true, - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, }, expectedErrContains: "cannot disable rotation for already-rotated credentials", @@ -299,17 +324,21 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) { NewCatalog: &hostcatalogs.HostCatalog{ Secrets: new(structpb.Struct), Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, }, expectedErrContains: "missing required value \"access_key_id\"", @@ -319,27 +348,33 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) { name: "updating secrets, replace error", req: &pb.OnUpdateCatalogRequest{ NewCatalog: &hostcatalogs.HostCatalog{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "onetwo", - constSecretAccessKey: "threefour", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("onetwo"), + credential.ConstSecretAccessKey: structpb.NewStringValue("threefour"), + }, + }, Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - constDisableCredentialRotation: true, - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, }, - opts: []awsCatalogPersistedStateOption{ - withStateTestOpts([]awsutil.Option{ + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts([]awsutil.Option{ awsutil.WithIAMAPIFunc( awsutil.NewMockIAM( awsutil.WithDeleteAccessKeyError(errors.New(testDeleteAccessKeyErr)), @@ -355,20 +390,24 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) { req: &pb.OnUpdateCatalogRequest{ NewCatalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + }, + }, }, }, - opts: []awsCatalogPersistedStateOption{ - withStateTestOpts([]awsutil.Option{ + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts([]awsutil.Option{ awsutil.WithIAMAPIFunc( awsutil.NewMockIAM( awsutil.WithGetUserError(errors.New(testGetUserErr)), @@ -385,8 +424,9 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { require := require.New(t) - p := &AwsPlugin{ - testStateOpts: tc.opts, + p := &HostPlugin{ + testCredStateOpts: tc.credOpts, + testCatalogStateOpts: tc.catalogOpts, } _, err := p.OnUpdateCatalog(context.Background(), tc.req) require.Contains(err.Error(), tc.expectedErrContains) @@ -399,7 +439,8 @@ func TestPluginOnDeleteCatalogErr(t *testing.T) { cases := []struct { name string req *pb.OnDeleteCatalogRequest - opts []awsCatalogPersistedStateOption + credOpts []credential.AwsCredentialPersistedStateOption + catalogOpts []awsCatalogPersistedStateOption expectedErrContains string expectedErrCode codes.Code }{ @@ -408,20 +449,24 @@ func TestPluginOnDeleteCatalogErr(t *testing.T) { req: &pb.OnDeleteCatalogRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, }, - opts: []awsCatalogPersistedStateOption{ + catalogOpts: []awsCatalogPersistedStateOption{ func(s *awsCatalogPersistedState) error { return errors.New(testOptionErr) }, @@ -434,21 +479,25 @@ func TestPluginOnDeleteCatalogErr(t *testing.T) { req: &pb.OnDeleteCatalogRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, }, - opts: []awsCatalogPersistedStateOption{ - withStateTestOpts([]awsutil.Option{ + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts([]awsutil.Option{ awsutil.WithIAMAPIFunc( awsutil.NewMockIAM( awsutil.WithDeleteAccessKeyError(errors.New(testDeleteAccessKeyErr)), @@ -465,8 +514,9 @@ func TestPluginOnDeleteCatalogErr(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { require := require.New(t) - p := &AwsPlugin{ - testStateOpts: tc.opts, + p := &HostPlugin{ + testCredStateOpts: tc.credOpts, + testCatalogStateOpts: tc.catalogOpts, } _, err := p.OnDeleteCatalog(context.Background(), tc.req) require.Contains(err.Error(), tc.expectedErrContains) @@ -479,7 +529,8 @@ func TestPluginOnCreateSetErr(t *testing.T) { cases := []struct { name string req *pb.OnCreateSetRequest - opts []awsCatalogPersistedStateOption + credOpts []credential.AwsCredentialPersistedStateOption + catalogOpts []awsCatalogPersistedStateOption expectedErrContains string expectedErrCode codes.Code }{ @@ -517,9 +568,11 @@ func TestPluginOnCreateSetErr(t *testing.T) { req: &pb.OnCreateSetRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "foobar", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("foobar"), + }, + }, }, }, }, @@ -531,20 +584,24 @@ func TestPluginOnCreateSetErr(t *testing.T) { req: &pb.OnCreateSetRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, }, - opts: []awsCatalogPersistedStateOption{ + catalogOpts: []awsCatalogPersistedStateOption{ func(s *awsCatalogPersistedState) error { return errors.New(testOptionErr) }, @@ -557,17 +614,21 @@ func TestPluginOnCreateSetErr(t *testing.T) { req: &pb.OnCreateSetRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, }, expectedErrContains: "set is nil", @@ -578,17 +639,21 @@ func TestPluginOnCreateSetErr(t *testing.T) { req: &pb.OnCreateSetRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, Set: &hostsets.HostSet{}, }, @@ -600,24 +665,30 @@ func TestPluginOnCreateSetErr(t *testing.T) { req: &pb.OnCreateSetRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, Set: &hostsets.HostSet{ Attrs: &hostsets.HostSet_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - "foo": true, - "bar": true, - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "foo": structpb.NewBoolValue(true), + "bar": structpb.NewBoolValue(true), + }, + }, }, }, }, @@ -629,28 +700,40 @@ func TestPluginOnCreateSetErr(t *testing.T) { req: &pb.OnCreateSetRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, Set: &hostsets.HostSet{ Attrs: &hostsets.HostSet_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{"tag-key=foo"}, - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + ConstDescribeInstancesFilters: structpb.NewListValue( + &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue("tag-key=foo"), + }, + }, + ), + }, + }, }, }, }, - opts: []awsCatalogPersistedStateOption{ - withStateTestOpts([]awsutil.Option{ + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts([]awsutil.Option{ awsutil.MockOptionErr(errors.New(testOptionErr)), }), }, @@ -662,27 +745,39 @@ func TestPluginOnCreateSetErr(t *testing.T) { req: &pb.OnCreateSetRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, Set: &hostsets.HostSet{ Attrs: &hostsets.HostSet_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{"tag-key=foo"}, - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + ConstDescribeInstancesFilters: structpb.NewListValue( + &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue("tag-key=foo"), + }, + }, + ), + }, + }, }, }, }, - opts: []awsCatalogPersistedStateOption{ + catalogOpts: []awsCatalogPersistedStateOption{ withTestEC2APIFunc(newTestMockEC2( nil, testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)), @@ -696,27 +791,39 @@ func TestPluginOnCreateSetErr(t *testing.T) { req: &pb.OnCreateSetRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, Set: &hostsets.HostSet{ Attrs: &hostsets.HostSet_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{"tag-key=foo"}, - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + ConstDescribeInstancesFilters: structpb.NewListValue( + &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue("tag-key=foo"), + }, + }, + ), + }, + }, }, }, }, - opts: []awsCatalogPersistedStateOption{ + catalogOpts: []awsCatalogPersistedStateOption{ withTestEC2APIFunc(newTestMockEC2( nil, )), @@ -729,27 +836,39 @@ func TestPluginOnCreateSetErr(t *testing.T) { req: &pb.OnCreateSetRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, Set: &hostsets.HostSet{ Attrs: &hostsets.HostSet_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{"tag-key=foo"}, - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + ConstDescribeInstancesFilters: structpb.NewListValue( + &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue("tag-key=foo"), + }, + }, + ), + }, + }, }, }, }, - opts: []awsCatalogPersistedStateOption{ + catalogOpts: []awsCatalogPersistedStateOption{ withTestEC2APIFunc(newTestMockEC2( nil, )), @@ -763,8 +882,9 @@ func TestPluginOnCreateSetErr(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { require := require.New(t) - p := &AwsPlugin{ - testStateOpts: tc.opts, + p := &HostPlugin{ + testCredStateOpts: tc.credOpts, + testCatalogStateOpts: tc.catalogOpts, } _, err := p.OnCreateSet(context.Background(), tc.req) require.Contains(err.Error(), tc.expectedErrContains) @@ -777,7 +897,8 @@ func TestPluginOnUpdateSetErr(t *testing.T) { cases := []struct { name string req *pb.OnUpdateSetRequest - opts []awsCatalogPersistedStateOption + credOpts []credential.AwsCredentialPersistedStateOption + catalogOpts []awsCatalogPersistedStateOption expectedErrContains string expectedErrCode codes.Code }{ @@ -816,9 +937,11 @@ func TestPluginOnUpdateSetErr(t *testing.T) { Catalog: &hostcatalogs.HostCatalog{ Secrets: new(structpb.Struct), Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "foobar", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("foobar"), + }, + }, }, }, }, @@ -830,20 +953,24 @@ func TestPluginOnUpdateSetErr(t *testing.T) { req: &pb.OnUpdateSetRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, }, - opts: []awsCatalogPersistedStateOption{ + catalogOpts: []awsCatalogPersistedStateOption{ func(s *awsCatalogPersistedState) error { return errors.New(testOptionErr) }, @@ -856,17 +983,21 @@ func TestPluginOnUpdateSetErr(t *testing.T) { req: &pb.OnUpdateSetRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, }, expectedErrContains: "new set is nil", @@ -877,17 +1008,21 @@ func TestPluginOnUpdateSetErr(t *testing.T) { req: &pb.OnUpdateSetRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, NewSet: &hostsets.HostSet{}, }, @@ -899,24 +1034,30 @@ func TestPluginOnUpdateSetErr(t *testing.T) { req: &pb.OnUpdateSetRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, NewSet: &hostsets.HostSet{ Attrs: &hostsets.HostSet_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - "foo": true, - "bar": true, - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "foo": structpb.NewBoolValue(true), + "bar": structpb.NewBoolValue(true), + }, + }, }, }, }, @@ -928,28 +1069,40 @@ func TestPluginOnUpdateSetErr(t *testing.T) { req: &pb.OnUpdateSetRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, NewSet: &hostsets.HostSet{ Attrs: &hostsets.HostSet_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{"tag-key=foo"}, - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + ConstDescribeInstancesFilters: structpb.NewListValue( + &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue("tag-key=foo"), + }, + }, + ), + }, + }, }, }, }, - opts: []awsCatalogPersistedStateOption{ - withStateTestOpts([]awsutil.Option{ + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts([]awsutil.Option{ awsutil.MockOptionErr(errors.New(testOptionErr)), }), }, @@ -961,27 +1114,39 @@ func TestPluginOnUpdateSetErr(t *testing.T) { req: &pb.OnUpdateSetRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, NewSet: &hostsets.HostSet{ Attrs: &hostsets.HostSet_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{"tag-key=foo"}, - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + ConstDescribeInstancesFilters: structpb.NewListValue( + &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue("tag-key=foo"), + }, + }, + ), + }, + }, }, }, }, - opts: []awsCatalogPersistedStateOption{ + catalogOpts: []awsCatalogPersistedStateOption{ withTestEC2APIFunc(newTestMockEC2( nil, testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)), @@ -995,27 +1160,39 @@ func TestPluginOnUpdateSetErr(t *testing.T) { req: &pb.OnUpdateSetRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, NewSet: &hostsets.HostSet{ Attrs: &hostsets.HostSet_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{"tag-key=foo"}, - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + ConstDescribeInstancesFilters: structpb.NewListValue( + &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue("tag-key=foo"), + }, + }, + ), + }, + }, }, }, }, - opts: []awsCatalogPersistedStateOption{ + catalogOpts: []awsCatalogPersistedStateOption{ withTestEC2APIFunc(newTestMockEC2( nil, )), @@ -1029,8 +1206,9 @@ func TestPluginOnUpdateSetErr(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { require := require.New(t) - p := &AwsPlugin{ - testStateOpts: tc.opts, + p := &HostPlugin{ + testCredStateOpts: tc.credOpts, + testCatalogStateOpts: tc.catalogOpts, } _, err := p.OnUpdateSet(context.Background(), tc.req) require.Contains(err.Error(), tc.expectedErrContains) @@ -1043,7 +1221,8 @@ func TestPluginListHostsErr(t *testing.T) { cases := []struct { name string req *pb.ListHostsRequest - opts []awsCatalogPersistedStateOption + credOpts []credential.AwsCredentialPersistedStateOption + catalogOpts []awsCatalogPersistedStateOption expectedErrContains string expectedErrCode codes.Code }{ @@ -1082,9 +1261,11 @@ func TestPluginListHostsErr(t *testing.T) { Catalog: &hostcatalogs.HostCatalog{ Secrets: new(structpb.Struct), Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "foobar", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("foobar"), + }, + }, }, }, }, @@ -1096,20 +1277,24 @@ func TestPluginListHostsErr(t *testing.T) { req: &pb.ListHostsRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, }, - opts: []awsCatalogPersistedStateOption{ + catalogOpts: []awsCatalogPersistedStateOption{ func(s *awsCatalogPersistedState) error { return errors.New(testOptionErr) }, @@ -1122,17 +1307,21 @@ func TestPluginListHostsErr(t *testing.T) { req: &pb.ListHostsRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, }, expectedErrContains: "sets is nil", @@ -1143,17 +1332,21 @@ func TestPluginListHostsErr(t *testing.T) { req: &pb.ListHostsRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, Sets: []*hostsets.HostSet{{}}, }, @@ -1165,17 +1358,21 @@ func TestPluginListHostsErr(t *testing.T) { req: &pb.ListHostsRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, Sets: []*hostsets.HostSet{ { @@ -1191,26 +1388,32 @@ func TestPluginListHostsErr(t *testing.T) { req: &pb.ListHostsRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, Sets: []*hostsets.HostSet{ { Id: "foobar", Attrs: &hostsets.HostSet_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - "foo": true, - "bar": true, - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "foo": structpb.NewBoolValue(true), + "bar": structpb.NewBoolValue(true), + }, + }, }, }, }, @@ -1223,31 +1426,43 @@ func TestPluginListHostsErr(t *testing.T) { req: &pb.ListHostsRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, Sets: []*hostsets.HostSet{ { Id: "foobar", Attrs: &hostsets.HostSet_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{"tag-key=foo"}, - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + ConstDescribeInstancesFilters: structpb.NewListValue( + &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue("tag-key=foo"), + }, + }, + ), + }, + }, }, }, }, }, - opts: []awsCatalogPersistedStateOption{ - withStateTestOpts([]awsutil.Option{ + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts([]awsutil.Option{ awsutil.MockOptionErr(errors.New(testOptionErr)), }), }, @@ -1259,30 +1474,42 @@ func TestPluginListHostsErr(t *testing.T) { req: &pb.ListHostsRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, Sets: []*hostsets.HostSet{ { Id: "foobar", Attrs: &hostsets.HostSet_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{"tag-key=foo"}, - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + ConstDescribeInstancesFilters: structpb.NewListValue( + &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue("tag-key=foo"), + }, + }, + ), + }, + }, }, }, }, }, - opts: []awsCatalogPersistedStateOption{ + catalogOpts: []awsCatalogPersistedStateOption{ withTestEC2APIFunc(newTestMockEC2( nil, testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)), @@ -1296,30 +1523,42 @@ func TestPluginListHostsErr(t *testing.T) { req: &pb.ListHostsRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constRegion: "us-west-2", - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, }, }, Persisted: &pb.HostCatalogPersisted{ - Secrets: mustStruct(map[string]interface{}{ - constAccessKeyId: "foobar", - constSecretAccessKey: "bazqux", - constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", - }), + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), + }, + }, }, Sets: []*hostsets.HostSet{ { Id: "foobar", Attrs: &hostsets.HostSet_Attributes{ - Attributes: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{"tag-key=foo"}, - }), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + ConstDescribeInstancesFilters: structpb.NewListValue( + &structpb.ListValue{ + Values: []*structpb.Value{ + structpb.NewStringValue("tag-key=foo"), + }, + }, + ), + }, + }, }, }, }, }, - opts: []awsCatalogPersistedStateOption{ + catalogOpts: []awsCatalogPersistedStateOption{ withTestEC2APIFunc(newTestMockEC2( nil, testMockEC2WithDescribeInstancesOutput( @@ -1346,8 +1585,9 @@ func TestPluginListHostsErr(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { require := require.New(t) - p := &AwsPlugin{ - testStateOpts: tc.opts, + p := &HostPlugin{ + testCredStateOpts: tc.credOpts, + testCatalogStateOpts: tc.catalogOpts, } _, err := p.ListHosts(context.Background(), tc.req) require.Contains(err.Error(), tc.expectedErrContains) @@ -1359,17 +1599,15 @@ func TestPluginListHostsErr(t *testing.T) { func TestBuildFilters(t *testing.T) { cases := []struct { name string - in *structpb.Struct + in map[string]any expected []*ec2.Filter expectedErrContains string }{ { name: "good without instance-state-name", - in: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{ - "foo=bar", - }, - }), + in: map[string]any{ + ConstDescribeInstancesFilters: []any{"foo=bar"}, + }, expected: []*ec2.Filter{ { Name: aws.String("foo"), @@ -1383,12 +1621,12 @@ func TestBuildFilters(t *testing.T) { }, { name: "good with instance-state-name", - in: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{ + in: map[string]any{ + ConstDescribeInstancesFilters: []any{ "foo=bar", "instance-state-name=static", }, - }), + }, expected: []*ec2.Filter{ { Name: aws.String("foo"), @@ -1402,11 +1640,11 @@ func TestBuildFilters(t *testing.T) { }, { name: "good with multiple values", - in: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{ + in: map[string]any{ + ConstDescribeInstancesFilters: []any{ "foo=bar,baz", }, - }), + }, expected: []*ec2.Filter{ { Name: aws.String("foo"), @@ -1420,9 +1658,9 @@ func TestBuildFilters(t *testing.T) { }, { name: "empty filter set", - in: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{}, - }), + in: map[string]any{ + ConstDescribeInstancesFilters: []any{}, + }, expected: []*ec2.Filter{ { Name: aws.String("instance-state-name"), @@ -1436,7 +1674,11 @@ func TestBuildFilters(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { require := require.New(t) - attrs, err := getSetAttributes(tc.in) + + input, err := structpb.NewStruct(tc.in) + require.NoError(err) + + attrs, err := getSetAttributes(input) require.NoError(err) actual, err := buildFilters(attrs) if tc.expectedErrContains != "" { @@ -1454,18 +1696,18 @@ func TestBuildFilters(t *testing.T) { func TestBuildDescribeInstancesInput(t *testing.T) { cases := []struct { name string - in *structpb.Struct + in map[string]any dryRun bool expected *ec2.DescribeInstancesInput expectedErr string }{ { name: "good, dry run", - in: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{ + in: map[string]any{ + ConstDescribeInstancesFilters: []any{ "foo=bar", }, - }), + }, dryRun: true, expected: &ec2.DescribeInstancesInput{ DryRun: aws.Bool(true), @@ -1483,11 +1725,11 @@ func TestBuildDescribeInstancesInput(t *testing.T) { }, { name: "good, real run", - in: mustStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{ + in: map[string]any{ + ConstDescribeInstancesFilters: []any{ "foo=bar", }, - }), + }, dryRun: false, expected: &ec2.DescribeInstancesInput{ DryRun: aws.Bool(false), @@ -1509,7 +1751,11 @@ func TestBuildDescribeInstancesInput(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { require := require.New(t) - attrs, err := getSetAttributes(tc.in) + + input, err := structpb.NewStruct(tc.in) + require.NoError(err) + + attrs, err := getSetAttributes(input) require.NoError(err) actual, err := buildDescribeInstancesInput(attrs, tc.dryRun) if tc.expectedErr != "" { @@ -1806,6 +2052,43 @@ func TestAwsInstanceToHost(t *testing.T) { DnsNames: []string{"test.example.internal", "test.example.com"}, }, }, + { + name: "good, single IP w/public addr and external name", + instance: &ec2.Instance{ + InstanceId: aws.String("foobar"), + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("test.example.internal"), + PublicIpAddress: aws.String("1.1.1.1"), + PublicDnsName: aws.String("test.example.com"), + Tags: []*ec2.Tag{ + {Key: aws.String("Name"), Value: aws.String("test-instance-actual-name")}, // The tag name is "Name", not "name". + {Key: aws.String("name"), Value: aws.String("test-instance-fake-name")}, + {Key: aws.String("contains-Name"), Value: aws.String("test-instance-contains-name")}, + }, + NetworkInterfaces: []*ec2.InstanceNetworkInterface{ + { + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("test.example.internal"), + PrivateIpAddresses: []*ec2.InstancePrivateIpAddress{ + { + Association: &ec2.InstanceNetworkInterfaceAssociation{ + PublicIp: aws.String("1.1.1.1"), + PublicDnsName: aws.String("test.example.com"), + }, + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("test.example.internal"), + }, + }, + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: "foobar", + ExternalName: "test-instance-actual-name", + IpAddresses: []string{"10.0.0.1", "1.1.1.1"}, + DnsNames: []string{"test.example.internal", "test.example.com"}, + }, + }, } for _, tc := range cases { diff --git a/plugin/service/host/state.go b/plugin/service/host/state.go new file mode 100644 index 0000000..41157b6 --- /dev/null +++ b/plugin/service/host/state.go @@ -0,0 +1,101 @@ +package host + +import ( + "errors" + "fmt" + "net" + "net/http" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/aws/aws-sdk-go/service/ec2/ec2iface" + "github.com/hashicorp/boundary-plugin-host-aws/internal/credential" + pb "github.com/hashicorp/boundary/sdk/pbs/plugin" + "google.golang.org/protobuf/types/known/structpb" +) + +var customClient = &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + }).DialContext, + }, +} + +type ec2APIFunc func(*session.Session, ...*aws.Config) (ec2iface.EC2API, error) + +type awsCatalogPersistedStateOption func(s *awsCatalogPersistedState) error + +type awsCatalogPersistedState struct { + *credential.AwsCredentialPersistedState + + // testEC2APIFunc provides a way to provide a factory for a mock EC2 client + testEC2APIFunc ec2APIFunc +} + +func withTestEC2APIFunc(f ec2APIFunc) awsCatalogPersistedStateOption { + return func(s *awsCatalogPersistedState) error { + if s.testEC2APIFunc != nil { + return errors.New("test API function already set") + } + + s.testEC2APIFunc = f + return nil + } +} + +func withCredentials(x *credential.AwsCredentialPersistedState) awsCatalogPersistedStateOption { + return func(s *awsCatalogPersistedState) error { + if s.AwsCredentialPersistedState != nil { + return errors.New("aws credentials already set") + } + + s.AwsCredentialPersistedState = x + return nil + } +} + +func newAwsCatalogPersistedState(opts ...awsCatalogPersistedStateOption) (*awsCatalogPersistedState, error) { + s := new(awsCatalogPersistedState) + for _, opt := range opts { + if err := opt(s); err != nil { + return nil, err + } + } + return s, nil +} + +func (s *awsCatalogPersistedState) 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 +} + +// EC2Client returns a configured EC2 client based on the session +// information stored in the state. +func (s *awsCatalogPersistedState) EC2Client(region string) (ec2iface.EC2API, error) { + sess, err := s.GetSession() + if err != nil { + return nil, fmt.Errorf("error getting AWS session when fetching EC2 client: %w", err) + } + + if s.testEC2APIFunc != nil { + return s.testEC2APIFunc(sess, aws.NewConfig().WithRegion(region)) + } + + cfg := aws.NewConfig() + if region != "" { + cfg = cfg.WithRegion(region) + } + + cfg.HTTPClient = customClient + + return ec2.New(sess, cfg), nil +} diff --git a/plugin/service/host/state_test.go b/plugin/service/host/state_test.go new file mode 100644 index 0000000..da4b7b4 --- /dev/null +++ b/plugin/service/host/state_test.go @@ -0,0 +1,32 @@ +package host + +import ( + "testing" + + "github.com/hashicorp/boundary-plugin-host-aws/internal/credential" + "github.com/stretchr/testify/require" +) + +func TestAwsCatalogPersistedStateEC2Client(t *testing.T) { + require := require.New(t) + cred, err := credential.NewAwsCredentialPersistedState( + credential.WithRegion("us-west-2"), + credential.WithAccessKeyId("foobar"), + credential.WithSecretAccessKey("barfoo"), + ) + require.NoError(err) + + state, err := newAwsCatalogPersistedState( + withCredentials(cred), + withTestEC2APIFunc(newTestMockEC2(nil)), + ) + require.NoError(err) + + clientRaw, err := state.EC2Client("us-west-2") + require.NoError(err) + require.NotNil(clientRaw) + client, ok := clientRaw.(*testMockEC2) + require.True(ok) + require.NotNil(client) + require.Equal("us-west-2", client.Region) +} diff --git a/plugin/helper_test.go b/plugin/service/host/testing.go similarity index 63% rename from plugin/helper_test.go rename to plugin/service/host/testing.go index 72b31d5..19bce96 100644 --- a/plugin/helper_test.go +++ b/plugin/service/host/testing.go @@ -1,52 +1,19 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package plugin +package host import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2/ec2iface" - "github.com/aws/aws-sdk-go/service/iam" - "github.com/aws/aws-sdk-go/service/iam/iamiface" - "github.com/hashicorp/go-secure-stdlib/awsutil" - "google.golang.org/protobuf/types/known/structpb" ) -const testDescribeInstancesError = "DescribeInstances error" - -type testMockIAMState struct { - DeleteAccessKeyCalled bool -} - -func (s *testMockIAMState) Reset() { - s.DeleteAccessKeyCalled = false -} - -type testMockIAM struct { - iamiface.IAMAPI - - State *testMockIAMState -} - -func newTestMockIAM(state *testMockIAMState, opts ...awsutil.MockIAMOption) awsutil.IAMAPIFunc { - return func(sess *session.Session) (iamiface.IAMAPI, error) { - m := &testMockIAM{ - State: state, - } - f := awsutil.NewMockIAM(opts...) - var err error - - m.IAMAPI, err = f(sess) - return m, err - } -} - -func (m *testMockIAM) DeleteAccessKey(input *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) { - m.State.DeleteAccessKeyCalled = true - return m.IAMAPI.DeleteAccessKey(input) -} +const ( + testOptionErr = "test option error" + testGetUserErr = "test error for GetUser" + testGetCallerIdentityErr = "test error for GetCallerIdentity" + testDeleteAccessKeyErr = "test error for DeleteAccessKey" + testDescribeInstancesError = "DescribeInstances error" +) type testMockEC2State struct { DescribeInstancesCalled bool @@ -117,15 +84,6 @@ func (m *testMockEC2) DescribeInstances(input *ec2.DescribeInstancesInput) (*ec2 return m.DescribeInstancesOutput, nil } -func mustStruct(in map[string]interface{}) *structpb.Struct { - out, err := structpb.NewStruct(in) - if err != nil { - panic(err) - } - - return out -} - type ec2FilterSorter []*ec2.Filter func (s ec2FilterSorter) Len() int { return len(s) } diff --git a/plugin/e2e_test.go b/testing/e2e_host_test.go similarity index 81% rename from plugin/e2e_test.go rename to testing/e2e_host_test.go index 887f848..4369ee9 100644 --- a/plugin/e2e_test.go +++ b/testing/e2e_host_test.go @@ -1,7 +1,7 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 -package plugin +package testing import ( "context" @@ -12,7 +12,9 @@ import ( "time" "github.com/aws/aws-sdk-go/aws/awserr" - pluginTesting "github.com/hashicorp/boundary-plugin-host-aws/testing" + "github.com/hashicorp/boundary-plugin-host-aws/internal/credential" + "github.com/hashicorp/boundary-plugin-host-aws/internal/values" + "github.com/hashicorp/boundary-plugin-host-aws/plugin/service/host" "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" @@ -41,7 +43,7 @@ func TestPlugin(t *testing.T) { } require := require.New(t) - tf, err := pluginTesting.NewTestTerraformer("testdata/basic") + tf, err := NewTestTerraformer("testdata/host") require.NoError(err) require.NotNil(tf) @@ -88,7 +90,7 @@ func TestPlugin(t *testing.T) { // will cause the state to go out of drift above in the sense that // the access key ID/secret access key will no longer be valid. We // will assert this through the returned state. - p := new(AwsPlugin) + p := new(host.HostPlugin) ctx := context.Background() // ******************** @@ -137,7 +139,7 @@ func TestPlugin(t *testing.T) { // Process the collection of instances and index by expected tag names. expectedTagInstancesMap := make(map[string][]string) for instanceId, instanceTags := range ec2InstanceTags { - for tagKey := range instanceTags.(map[string]interface{}) { + for tagKey := range instanceTags.(map[string]any) { for _, expectedTag := range expectedTags { if tagKey == expectedTag { expectedTagInstancesMap[tagKey] = append(expectedTagInstancesMap[tagKey], instanceId) @@ -165,19 +167,19 @@ func TestPlugin(t *testing.T) { } } -func testPluginOnCreateCatalog(ctx context.Context, t *testing.T, p *AwsPlugin, region, accessKeyId, secretAccessKey string, rotate bool) (string, string) { +func testPluginOnCreateCatalog(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, rotate bool) (string, string) { t.Helper() t.Logf("testing OnCreateCatalog (region=%s, rotate=%t)", region, rotate) require := require.New(t) - reqAttrs, err := structpb.NewStruct(map[string]interface{}{ - constRegion: region, - constDisableCredentialRotation: !rotate, + reqAttrs, err := structpb.NewStruct(map[string]any{ + credential.ConstRegion: region, + credential.ConstDisableCredentialRotation: !rotate, }) require.NoError(err) - reqSecrets, err := structpb.NewStruct(map[string]interface{}{ - constAccessKeyId: accessKeyId, - constSecretAccessKey: secretAccessKey, + reqSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstAccessKeyId: accessKeyId, + credential.ConstSecretAccessKey: secretAccessKey, }) require.NoError(err) request := &pb.OnCreateCatalogRequest{ @@ -195,7 +197,7 @@ func testPluginOnCreateCatalog(ctx context.Context, t *testing.T, p *AwsPlugin, require.NotNil(persisted) persistedSecrets := persisted.GetSecrets() require.NotNil(persistedSecrets) - persistedAccessKeyId, err := getStringValue(persistedSecrets, constAccessKeyId, true) + persistedAccessKeyId, err := values.GetStringValue(persistedSecrets, credential.ConstAccessKeyId, true) require.NoError(err) require.NotZero(persistedAccessKeyId) if rotate { @@ -204,7 +206,7 @@ func testPluginOnCreateCatalog(ctx context.Context, t *testing.T, p *AwsPlugin, require.Equal(accessKeyId, persistedAccessKeyId) } - persistedSecretAccessKey, err := getStringValue(persistedSecrets, constSecretAccessKey, true) + persistedSecretAccessKey, err := values.GetStringValue(persistedSecrets, credential.ConstSecretAccessKey, true) require.NoError(err) require.NotZero(persistedSecretAccessKey) if rotate { @@ -213,7 +215,7 @@ func testPluginOnCreateCatalog(ctx context.Context, t *testing.T, p *AwsPlugin, require.Equal(secretAccessKey, persistedSecretAccessKey) } - persistedCredsLastRotatedTime, err := getTimeValue(persistedSecrets, constCredsLastRotatedTime) + persistedCredsLastRotatedTime, err := values.GetTimeValue(persistedSecrets, credential.ConstCredsLastRotatedTime) require.NoError(err) if rotate { require.NotZero(persistedCredsLastRotatedTime) @@ -226,7 +228,7 @@ func testPluginOnCreateCatalog(ctx context.Context, t *testing.T, p *AwsPlugin, } func testPluginOnUpdateCatalog( - ctx context.Context, t *testing.T, p *AwsPlugin, + ctx context.Context, t *testing.T, p *host.HostPlugin, region, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey string, rotated, rotate bool, ) (string, string) { @@ -239,28 +241,28 @@ func testPluginOnUpdateCatalog( // timestamps. currentCredsLastRotatedTime := time.Now() - reqCurrentAttrs, err := structpb.NewStruct(map[string]interface{}{ - constRegion: region, - constDisableCredentialRotation: !rotated, + reqCurrentAttrs, err := structpb.NewStruct(map[string]any{ + credential.ConstRegion: region, + credential.ConstDisableCredentialRotation: !rotated, }) require.NoError(err) - reqNewAttrs, err := structpb.NewStruct(map[string]interface{}{ - constRegion: region, - constDisableCredentialRotation: !rotate, + reqNewAttrs, err := structpb.NewStruct(map[string]any{ + credential.ConstRegion: region, + credential.ConstDisableCredentialRotation: !rotate, }) require.NoError(err) var reqSecrets *structpb.Struct if newAccessKeyId != "" && newSecretAccessKey != "" { - reqSecrets, err = structpb.NewStruct(map[string]interface{}{ - constAccessKeyId: newAccessKeyId, - constSecretAccessKey: newSecretAccessKey, + reqSecrets, err = structpb.NewStruct(map[string]any{ + credential.ConstAccessKeyId: newAccessKeyId, + credential.ConstSecretAccessKey: newSecretAccessKey, }) require.NoError(err) } - reqPersistedSecrets, err := structpb.NewStruct(map[string]interface{}{ - constAccessKeyId: currentAccessKeyId, - constSecretAccessKey: currentSecretAccessKey, - constCredsLastRotatedTime: func() string { + reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstAccessKeyId: currentAccessKeyId, + credential.ConstSecretAccessKey: currentSecretAccessKey, + credential.ConstCredsLastRotatedTime: func() string { if rotated { return currentCredsLastRotatedTime.Format(time.RFC3339Nano) } @@ -295,13 +297,13 @@ func testPluginOnUpdateCatalog( require.NotNil(persistedSecrets) // Complex checks based on the scenarios. - persistedAccessKeyId, err := getStringValue(persistedSecrets, constAccessKeyId, true) + persistedAccessKeyId, err := values.GetStringValue(persistedSecrets, credential.ConstAccessKeyId, true) require.NoError(err) require.NotZero(persistedAccessKeyId) - persistedSecretAccessKey, err := getStringValue(persistedSecrets, constSecretAccessKey, true) + persistedSecretAccessKey, err := values.GetStringValue(persistedSecrets, credential.ConstSecretAccessKey, true) require.NoError(err) require.NotZero(persistedSecretAccessKey) - persistedCredsLastRotatedTime, err := getTimeValue(persistedSecrets, constCredsLastRotatedTime) + persistedCredsLastRotatedTime, err := values.GetTimeValue(persistedSecrets, credential.ConstCredsLastRotatedTime) require.NoError(err) // Our test scenarios are complex due the multi-dimensional nature @@ -403,25 +405,25 @@ func testPluginOnUpdateCatalog( return persistedAccessKeyId, persistedSecretAccessKey } -func testPluginOnDeleteCatalog(ctx context.Context, t *testing.T, p *AwsPlugin, region, accessKeyId, secretAccessKey string, rotated bool) { +func testPluginOnDeleteCatalog(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, rotated bool) { t.Helper() t.Logf("testing OnDeleteCatalog (region=%s, rotated=%t)", region, rotated) require := require.New(t) - reqAttrs, err := structpb.NewStruct(map[string]interface{}{ - constRegion: region, - constDisableCredentialRotation: !rotated, + reqAttrs, err := structpb.NewStruct(map[string]any{ + credential.ConstRegion: region, + credential.ConstDisableCredentialRotation: !rotated, }) require.NoError(err) - reqSecrets, err := structpb.NewStruct(map[string]interface{}{ - constAccessKeyId: accessKeyId, - constSecretAccessKey: secretAccessKey, + reqSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstAccessKeyId: accessKeyId, + credential.ConstSecretAccessKey: secretAccessKey, }) require.NoError(err) - reqPersistedSecrets, err := structpb.NewStruct(map[string]interface{}{ - constAccessKeyId: accessKeyId, - constSecretAccessKey: secretAccessKey, - constCredsLastRotatedTime: func() string { + reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstAccessKeyId: accessKeyId, + credential.ConstSecretAccessKey: secretAccessKey, + credential.ConstCredsLastRotatedTime: func() string { if rotated { return time.Now().Format(time.RFC3339Nano) } @@ -457,23 +459,23 @@ func testPluginOnDeleteCatalog(ctx context.Context, t *testing.T, p *AwsPlugin, } } -func testPluginOnCreateUpdateSet(ctx context.Context, t *testing.T, p *AwsPlugin, region, accessKeyId, secretAccessKey string, tags []string) { +func testPluginOnCreateUpdateSet(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, tags []string) { t.Helper() t.Logf("testing OnCreateSet (region=%s, tags=%v)", region, tags) require := require.New(t) - catalogAttrs, err := structpb.NewStruct(map[string]interface{}{ - constRegion: region, - constDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests + catalogAttrs, err := structpb.NewStruct(map[string]any{ + credential.ConstRegion: region, + credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests }) require.NoError(err) - setAttrs, err := structpb.NewStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{fmt.Sprintf("tag-key=%s", strings.Join(tags, ","))}, + setAttrs, err := structpb.NewStruct(map[string]any{ + host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", strings.Join(tags, ","))}, }) require.NoError(err) - reqPersistedSecrets, err := structpb.NewStruct(map[string]interface{}{ - constAccessKeyId: accessKeyId, - constSecretAccessKey: secretAccessKey, - constCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), + reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstAccessKeyId: accessKeyId, + credential.ConstSecretAccessKey: secretAccessKey, + credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), }) require.NoError(err) createRequest := &pb.OnCreateSetRequest{ @@ -523,19 +525,19 @@ func testPluginOnCreateUpdateSet(ctx context.Context, t *testing.T, p *AwsPlugin require.NotNil(updateResponse) } -func testPluginListHosts(ctx context.Context, t *testing.T, p *AwsPlugin, region, accessKeyId, secretAccessKey string, tags []string, expected map[string][]string) { +func testPluginListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, tags []string, expected map[string][]string) { t.Helper() t.Logf("testing ListHosts (region=%s, tags=%v)", region, tags) require := require.New(t) - catalogAttrs, err := structpb.NewStruct(map[string]interface{}{ - constRegion: region, - constDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests + catalogAttrs, err := structpb.NewStruct(map[string]any{ + credential.ConstRegion: region, + credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests }) require.NoError(err) sets := make([]*hostsets.HostSet, len(tags)) for i, tag := range tags { - setAttrs, err := structpb.NewStruct(map[string]interface{}{ - constDescribeInstancesFilters: []interface{}{fmt.Sprintf("tag-key=%s", tag)}, + setAttrs, err := structpb.NewStruct(map[string]any{ + host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", tag)}, }) require.NoError(err) sets[i] = &hostsets.HostSet{ @@ -545,10 +547,10 @@ func testPluginListHosts(ctx context.Context, t *testing.T, p *AwsPlugin, region }, } } - reqPersistedSecrets, err := structpb.NewStruct(map[string]interface{}{ - constAccessKeyId: accessKeyId, - constSecretAccessKey: secretAccessKey, - constCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), + reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstAccessKeyId: accessKeyId, + credential.ConstSecretAccessKey: secretAccessKey, + credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), }) require.NoError(err) request := &pb.ListHostsRequest{ diff --git a/plugin/testdata/basic/iam.tf b/testing/testdata/host/iam.tf similarity index 95% rename from plugin/testdata/basic/iam.tf rename to testing/testdata/host/iam.tf index 221d84c..9af32f6 100644 --- a/plugin/testdata/basic/iam.tf +++ b/testing/testdata/host/iam.tf @@ -19,8 +19,6 @@ resource "aws_iam_user" "user" { count = var.iam_user_count name = random_id.aws_iam_user_name[count.index].dec force_destroy = true - - tags = var.project_path_tags } resource "aws_iam_access_key" "user_initial_key" { @@ -45,8 +43,6 @@ resource "aws_iam_policy" "ec2_describeinstances" { ] } EOF - - tags = var.project_path_tags } resource "aws_iam_user_policy_attachment" "user_ec2_describeinstances_attachment" { @@ -75,8 +71,6 @@ resource "aws_iam_policy" "user_self_manage_policy" { ] } EOF - - tags = var.project_path_tags } resource "aws_iam_user_policy_attachment" "user_self_manage_policy_attachment" { @@ -101,4 +95,4 @@ output "iam_access_key_ids" { output "iam_secret_access_keys" { value = aws_iam_access_key.user_initial_key.*.secret sensitive = true -} +} \ No newline at end of file diff --git a/plugin/testdata/basic/main.tf b/testing/testdata/host/main.tf similarity index 60% rename from plugin/testdata/basic/main.tf rename to testing/testdata/host/main.tf index 9ef1261..8c1541c 100644 --- a/plugin/testdata/basic/main.tf +++ b/testing/testdata/host/main.tf @@ -9,9 +9,3 @@ terraform { } } } - -variable "project_path_tags" { - default = { - "project_path" = "hashicorp/boundary-plugin-host-aws/test/basic" - } -} diff --git a/plugin/testdata/basic/vpc_ec2.tf b/testing/testdata/host/vpc_ec2.tf similarity index 90% rename from plugin/testdata/basic/vpc_ec2.tf rename to testing/testdata/host/vpc_ec2.tf index 9619304..a0c8f15 100644 --- a/plugin/testdata/basic/vpc_ec2.tf +++ b/testing/testdata/host/vpc_ec2.tf @@ -43,16 +43,12 @@ data "aws_ami" "ubuntu" { resource "aws_vpc" "vpc" { cidr_block = "10.0.0.0/16" - - tags = var.project_path_tags } resource "aws_subnet" "subnet" { vpc_id = aws_vpc.vpc.id cidr_block = aws_vpc.vpc.cidr_block availability_zone = data.aws_availability_zones.azs.names[0] - - tags = var.project_path_tags } resource "aws_instance" "instances" { @@ -61,7 +57,7 @@ resource "aws_instance" "instances" { instance_type = "t3.nano" subnet_id = aws_subnet.subnet.id - tags = merge(var.instance_tags[count.index], var.project_path_tags) + tags = var.instance_tags[count.index] } output "instance_ids" { @@ -78,4 +74,4 @@ output "instance_tags" { value = { for _, r in aws_instance.instances : r.id => r.tags } -} +} \ No newline at end of file diff --git a/testing/testing.go b/testing/testing.go index d37507d..1c35a2e 100644 --- a/testing/testing.go +++ b/testing/testing.go @@ -108,7 +108,7 @@ func (t *TestTerraformer) Destroy() error { // // Outputs for the root module are supported only. It's an error to // run this when no state exists. -func (t *TestTerraformer) GetOutput(key string) (interface{}, error) { +func (t *TestTerraformer) GetOutput(key string) (any, error) { if key == "" { return nil, errors.New("key must not be empty") } @@ -155,13 +155,13 @@ func (t *TestTerraformer) GetOutputString(key string) (string, error) { // GetOutputMap wraps GetOutput and returns the value as a map. It's an // error if the return value is actually not a map. -func (t *TestTerraformer) GetOutputMap(key string) (map[string]interface{}, error) { +func (t *TestTerraformer) GetOutputMap(key string) (map[string]any, error) { value, err := t.GetOutput(key) if err != nil { return nil, err } - m, ok := value.(map[string]interface{}) + m, ok := value.(map[string]any) if !ok { return nil, fmt.Errorf("expected output value at key %q to be map, got %T", key, value) } @@ -171,13 +171,13 @@ func (t *TestTerraformer) GetOutputMap(key string) (map[string]interface{}, erro // GetOutputSlice wraps GetOutput and returns the value as a slice. It's an // error if the return value is actually not a slice. -func (t *TestTerraformer) GetOutputSlice(key string) ([]interface{}, error) { +func (t *TestTerraformer) GetOutputSlice(key string) ([]any, error) { value, err := t.GetOutput(key) if err != nil { return nil, err } - m, ok := value.([]interface{}) + m, ok := value.([]any) if !ok { return nil, fmt.Errorf("expected output value at key %q to be slice, got %T", key, value) }