Compare commits

..
Author SHA1 Message Date
Matt Romanelli 125caec99f Added support to assert against exact instance IDs 2026-09-24 13:22:38 -05:00
Matt Romanelli 4d6d789de3 Added additional assertion. Confirmed tests still work 2026-09-24 11:09:48 -05:00
Matt Romanelli 6f0df81d2b Testing gaps 2026-09-23 15:47:34 -05:00
Matt Romanelli 65914e30b0 feat(tests) Add Terraform testing to support Cross-account discovery
**Problem**
* To support cross-account DHC discovery, we need to add terraform configurations and tests to test against live AWS data.
* However, there was nothing in place to support any AssumeRole testing, irrespective of cross-account.

**Solution**
* Added a new terraform file, iam_assume_role.tf, to contain the new terraform configurations for single-account AssumeRole and cross-role
* Added a second terraform file, iam_cross_account.tf, to contain the new terraform configurations for cross-account
* Refactored how the tests are organized. The previous static test cases used a system where a separate function was created for each test. This was moved to a dedicated file, e2e_host_static_test.go.
* For AssumeRole tests (single account, cross role, and cross account), this system wasn't ideal since there are so many test cases and they are all extremely similar. A table driven system was better that passed in a set of variables to shared test helpers. A dedicated file, e2e_host_assumerole_test.go, was created to contain the testing infrastructure for AssumeRole tests.
* e2e_host_test.go remains the main test runner that then calls e2e_host_assumerole_test.go and e2e_host_static_test.go
* The terraform tests work by setting environment variables for the AWS account that you want to configure the terraform for. The existing setup worked for single-account and cross-role. However, to support cross-account, support had to be added for a second set of credentials. The second set of environment variables can now be set with the TARGET_ prefix. Some configuration in testing.go had to be added to support creating/destroying multiple terraform setups.

**Testing**
* The single account tests pass as expected. However, a bug was discovered during this process that will be addressed later.
* The cross-role and cross-account tests failed as expected because this featured has not yet been delivered.
* It was noticed that some of the existing static tests were flaky. The problem and fix were identified and will be addressed later.
2026-09-21 16:58:22 -05:00
Matt Romanelli e484c09479 Merge pull request #83 from hashicorp/readme-updates
Updated the READMEs for cross-account discovery
2026-09-21 15:50:44 -05:00
Matt Romanelli 4c4a901d33 Updated the READMEs for cross-account discovery 2026-09-16 16:42:17 -05:00
10 changed files with 1555 additions and 451 deletions

No files matched your search

+2
View File
@@ -25,6 +25,8 @@ Credential rotation can be turned off by setting the
This plugin will attempt to assume a role when a `role_arn` is supplied through the
`attributes` object. More information about assume an AWS role can be found [here](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html). This feature only works when the plugin is running on a self managed Boundary [worker](https://developer.hashicorp.com/boundary/tutorials/hcp-administration/hcp-manage-workers).
To discover hosts across multiple AWS accounts, supply a `target_role_arn` in addition to `role_arn`. The plugin uses the principal credentials to assume the target role via a two-hop `AssumeRole` chain, enabling cross-account host discovery from a single worker. See [Attributes and Secrets](plugin/service/host/README.md#attributes-and-secrets) for the full list of `target_*` attributes.
### Environment Credentials
This plugin will attempt to retrieve credentials from environment variables. More
+14 -4
View File
@@ -109,13 +109,23 @@ The following `attributes` are valid on an AWS host catalog resource:
- `region` (string): The region to configure the host catalog for. All host sets
in this catalog will be configured for this region.
- `role_arn` (string): The AWS Role ARN used for `AssumeRole` authentication. If
provided, `disable_credential_rotation` must be also set to `true.
- `role_external_id` (string): The external id configured for the `AssumeRole`
provided, `disable_credential_rotation` must be also set to `true`.
- `role_external_id` (string): The external ID configured for the principal `AssumeRole`
provider.
- `role_session_name` (string): The session name configured for the `AssumeRole`
- `role_session_name` (string): The session name configured for the principal `AssumeRole`
provider.
- `role_tags` (object): The key-value pair tags configured for the `AssumeRole`
- `role_tags` (object): The key-value pair tags configured for the principal `AssumeRole`
provider.
- `target_role_arn` (string): The AWS role ARN of the target IAM role to assume for
cross-account discovery. Required if any other `target_*` attributes are configured.
- `target_region` (string): The region where the target hosts reside. Defaults to the
principal account `region` if omitted.
- `target_role_external_id` (string): The external ID configured for the target
`AssumeRole` provider.
- `target_role_session_name` (string): The session name configured for the target
`AssumeRole` provider.
- `target_role_tags` (object): The key-value pair tags configured for the target
`AssumeRole` provider.
The following `secrets` are required on an AWS host catalog resource:
+48 -48
View File
@@ -46,8 +46,6 @@ type hostSetQuery struct {
const defaultSessionName = "boundary-default"
const dryRunOperationErrorCode = "DryRunOperation"
const errBuildDescribeInstancesInput = "error building DescribeInstances input: %s"
const errDryRunFailed = "error performing DescribeInstances dry run: %s"
// Ensure that we are implementing HostPluginServiceServer
var _ pb.HostPluginServiceServer = (*HostPlugin)(nil)
@@ -99,18 +97,13 @@ func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalo
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
}
// perform dry run to ensure we can interact with AWS as expected.
opts := []ec2Option{}
if catalogAttributes.DualStack {
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
}
input, err := buildDescribeInstancesInput(&SetAttributes{}, true)
if err != nil {
return nil, status.Errorf(codes.Unknown, errBuildDescribeInstancesInput, err)
}
// Run a DescribeInstances request with DryRun set to true to ensure
// we can interact with AWS resources as expected.
if _, err := checkHosts(ctx, catalogState, input, opts, catalogAttributes.TargetAccount); err != nil {
return nil, fmt.Errorf(errDryRunFailed, err)
if st := dryRunValidation(ctx, catalogState, opts); st != nil {
return nil, st.Err()
}
persistedProto, err := catalogState.toProto()
@@ -193,19 +186,12 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo
if err != nil {
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
opts := []ec2Option{}
if newCatalogAttributes.DualStack {
opts = append(opts, WithDualStack(newCatalogAttributes.DualStack))
}
input, err := buildDescribeInstancesInput(&SetAttributes{}, true)
if err != nil {
return nil, status.Errorf(codes.Unknown, errBuildDescribeInstancesInput, err)
}
// Run a DescribeInstances request with DryRun set to true to ensure
// we can interact with AWS resources as expected.
if _, err := checkHosts(ctx, newCatalogState, input, opts, newCatalogAttributes.TargetAccount); err != nil {
return nil, fmt.Errorf(errDryRunFailed, err)
if st := dryRunValidation(ctx, newCatalogState, opts); st != nil {
return nil, st.Err()
}
// Replace the existing credential state.
@@ -245,18 +231,13 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
// perform dry run to ensure we can interact with AWS as expected.
opts := []ec2Option{}
if newCatalogAttributes.DualStack {
opts = append(opts, WithDualStack(newCatalogAttributes.DualStack))
}
input, err := buildDescribeInstancesInput(&SetAttributes{}, true)
if err != nil {
return nil, status.Errorf(codes.Unknown, errBuildDescribeInstancesInput, err)
}
// Run a DescribeInstances request with DryRun set to true to ensure
// we can interact with AWS resources as expected.
if _, err := checkHosts(ctx, catalogState, input, opts, newCatalogAttributes.TargetAccount); err != nil {
return nil, fmt.Errorf(errDryRunFailed, err)
if st := dryRunValidation(ctx, catalogState, opts); st != nil {
return nil, st.Err()
}
persistedProto, err := catalogState.toProto()
@@ -400,20 +381,18 @@ func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest
return nil, err
}
describeInstanceFilters, err := buildFilters(setAttrs)
if err != nil {
return nil, errors.BadRequestStatusf("error building set filters: %s", err)
}
opts := []ec2Option{}
if catalogAttributes.DualStack {
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
}
input, err := buildDescribeInstancesInput(setAttrs, true)
if err != nil {
return nil, status.Errorf(codes.Unknown, errBuildDescribeInstancesInput, err)
if st := dryRunValidation(ctx, catalogState, opts, describeInstanceFilters...); st != nil {
return nil, st.Err()
}
// Run a DescribeInstances request with DryRun set to true to ensure
// we can interact with AWS resources as expected.
if _, err := checkHosts(ctx, catalogState, input, opts, catalogAttributes.TargetAccount); err != nil {
return nil, fmt.Errorf(errDryRunFailed, err)
}
return &pb.OnCreateSetResponse{}, nil
}
@@ -467,18 +446,17 @@ func (p *HostPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest
return nil, err
}
describeInstanceFilters, err := buildFilters(setAttrs)
if err != nil {
return nil, errors.BadRequestStatusf("error building set filters: %s", err)
}
opts := []ec2Option{}
if catalogAttributes.DualStack {
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
}
input, err := buildDescribeInstancesInput(setAttrs, true)
if err != nil {
return nil, status.Errorf(codes.Unknown, errBuildDescribeInstancesInput, err)
}
// Run a DescribeInstances request with DryRun set to true to ensure
// we can interact with AWS resources as expected.
if _, err := checkHosts(ctx, catalogState, input, opts, catalogAttributes.TargetAccount); err != nil {
return nil, fmt.Errorf(errDryRunFailed, err)
if st := dryRunValidation(ctx, catalogState, opts, describeInstanceFilters...); st != nil {
return nil, st.Err()
}
return &pb.OnUpdateSetResponse{}, nil
@@ -633,11 +611,11 @@ func buildFilters(attrs *SetAttributes) ([]types.Filter, error) {
splitFilter := strings.Split(filterAttr, "=")
switch {
case len(splitFilter) != 2:
return nil, errors.BadRequestStatusf("expected filter %q to contain a single equal sign", filterAttr)
return nil, fmt.Errorf("expected filter %q to contain a single equal sign", filterAttr)
case len(splitFilter[0]) == 0:
return nil, errors.BadRequestStatusf("filter %q contains an empty filter key", filterAttr)
return nil, fmt.Errorf("filter %q contains an empty filter key", filterAttr)
case len(splitFilter[1]) == 0:
return nil, errors.BadRequestStatusf("filter %q contains an empty value", filterAttr)
return nil, fmt.Errorf("filter %q contains an empty value", filterAttr)
}
filterKey, filterValue := splitFilter[0], splitFilter[1]
@@ -668,7 +646,7 @@ func buildFilters(attrs *SetAttributes) ([]types.Filter, error) {
func buildDescribeInstancesInput(attrs *SetAttributes, dryRun bool) (*ec2.DescribeInstancesInput, error) {
filters, err := buildFilters(attrs)
if err != nil {
return nil, err
return nil, fmt.Errorf("error building filters: %w", err)
}
return &ec2.DescribeInstancesInput{
@@ -850,6 +828,28 @@ func ec2ClientForTarget(
return ec2.NewFromConfig(targetCfg, ec2Opts...), nil
}
// dryRunValidation performs an AWS DescribeInstances call to verify the state's
// credentials, the host listing functionality as well as the filters, if any
// are passed in. This function can therefore be used for both host catalog and
// host set validation.
func dryRunValidation(ctx context.Context, state *awsCatalogPersistedState, ec2Opts []ec2Option, filters ...types.Filter) *status.Status {
if state == nil {
return status.New(codes.InvalidArgument, "persisted state is required")
}
ec2Client, err := state.EC2Client(ctx, ec2Opts...)
if err != nil {
return status.New(codes.InvalidArgument, fmt.Sprintf("error getting EC2 client: %s", err))
}
_, err = ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{Filters: filters})
if err != nil {
return status.New(codes.FailedPrecondition, fmt.Sprintf("aws describe instances failed: %s", err))
}
return nil
}
// appendDistinct will append the elements to the slice
// if an element is not nil, empty, and does not exist in slice.
func appendDistinct(slice []string, elems ...*string) []string {
+63 -24
View File
@@ -742,11 +742,8 @@ func TestPluginOnCreateCatalogErr(t *testing.T) {
testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)),
)),
},
// checkHosts returns the DescribeInstances error as-is rather than
// wrapping it in a gRPC FailedPrecondition status like the old
// dryRunValidation did. A plain Go error maps to codes.Unknown.
expectedErrContains: testDescribeInstancesError,
expectedErrCode: codes.Unknown,
expectedErrContains: "aws describe instances failed: DescribeInstances error",
expectedErrCode: codes.FailedPrecondition,
},
}
@@ -997,11 +994,8 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) {
testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)),
)),
},
//checkHosts returns the DesribeInstances errror as-is rather than wrapping it
// in a gRpc FailedPrecodndition status like the old dryRunValidiation did.
// A plain Go error maps to codeds.Unknown.
expectedErrContains: testDescribeInstancesError,
expectedErrCode: codes.Unknown,
expectedErrContains: "aws describe instances failed: DescribeInstances error",
expectedErrCode: codes.FailedPrecondition,
},
{
name: "new incoming dynamic credential update dry run error",
@@ -1042,11 +1036,8 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) {
testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)),
)),
},
// checkHosts returns the DescribeInstances error as-is rather than wrapping it
// in a gRPC FailedPrecondition status like the old dryRunValidiation did.
// A plain Go error maps to codes.Unknown.
expectedErrContains: testDescribeInstancesError,
expectedErrCode: codes.Unknown,
expectedErrContains: "aws describe instances failed: DescribeInstances error",
expectedErrCode: codes.FailedPrecondition,
},
{
name: "replace creds error",
@@ -1220,11 +1211,8 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) {
testMockEC2WithDescribeInstancesError(fmt.Errorf("oops there was an error")),
)),
},
// checkHosts returns the DescribeInstances error as-is rather than wrapping it
// in a gRPC FailedPrecondition status like the old dryRunValidiation did.
// A plain Go error maps to codes.Unknown.
expectedErrContains: "oops there was an error",
expectedErrCode: codes.Unknown,
expectedErrContains: "aws describe instances failed: oops there was an error",
expectedErrCode: codes.FailedPrecondition,
},
}
@@ -1577,8 +1565,8 @@ func TestPluginOnCreateSetErr(t *testing.T) {
testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)),
)),
},
expectedErrContains: testDescribeInstancesError,
expectedErrCode: codes.Unknown,
expectedErrContains: fmt.Sprintf("aws describe instances failed: %s", testDescribeInstancesError),
expectedErrCode: codes.FailedPrecondition,
},
}
@@ -1840,8 +1828,8 @@ func TestPluginOnUpdateSetErr(t *testing.T) {
testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)),
)),
},
expectedErrContains: testDescribeInstancesError,
expectedErrCode: codes.Unknown,
expectedErrContains: fmt.Sprintf("aws describe instances failed: %s", testDescribeInstancesError),
expectedErrCode: codes.FailedPrecondition,
},
}
@@ -3083,3 +3071,54 @@ func TestAppendDistinct(t *testing.T) {
})
}
}
func TestDryRunValidation(t *testing.T) {
t.Run("nil credential state", func(t *testing.T) {
st := dryRunValidation(context.Background(), nil, nil)
require.NotNil(t, st)
require.Equal(t, codes.InvalidArgument.String(), st.Code().String())
require.Equal(t, "persisted state is required", st.Message())
})
t.Run("ec2ClientErr", func(t *testing.T) {
st := dryRunValidation(context.Background(), &awsCatalogPersistedState{
AwsCredentialPersistedState: &credential.AwsCredentialPersistedState{
CredentialsConfig: &awsutil.CredentialsConfig{},
},
testEC2APIFunc: func(...aws.Config) (EC2API, error) {
return nil, fmt.Errorf("oops ec2 client err")
},
}, []ec2Option{})
require.NotNil(t, st)
require.Equal(t, codes.InvalidArgument.String(), st.Code().String())
require.Equal(t, "error getting EC2 client: oops ec2 client err", st.Message())
})
t.Run("describeInstancesErr", func(t *testing.T) {
st := dryRunValidation(context.Background(), &awsCatalogPersistedState{
AwsCredentialPersistedState: &credential.AwsCredentialPersistedState{
CredentialsConfig: &awsutil.CredentialsConfig{
AccessKey: "AKIAfoo",
SecretKey: "baz",
},
},
testEC2APIFunc: newTestMockEC2(nil, testMockEC2WithDescribeInstancesError(fmt.Errorf("oops describe instances error"))),
}, []ec2Option{})
require.NotNil(t, st)
require.Equal(t, codes.FailedPrecondition.String(), st.Code().String())
require.Equal(t, "aws describe instances failed: oops describe instances error", st.Message())
})
t.Run("success", func(t *testing.T) {
st := dryRunValidation(context.Background(), &awsCatalogPersistedState{
AwsCredentialPersistedState: &credential.AwsCredentialPersistedState{
CredentialsConfig: &awsutil.CredentialsConfig{
AccessKey: "AKIAfoo",
SecretKey: "baz",
},
},
testEC2APIFunc: newTestMockEC2(nil, testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{})),
}, []ec2Option{})
require.Nil(t, st)
})
}
+304
View File
@@ -0,0 +1,304 @@
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package testing
// This file contains table-driven test infrastructure for AssumeRole and
// cross-role (two-hop) Dynamic Host Catalog scenarios. Each plugin operation
// has a corresponding case type and runner function. The orchestrator in
// e2e_host_test.go populates the case tables using the Terraform outputs.
import (
"context"
"fmt"
"strings"
"testing"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
"github.com/hashicorp/boundary-plugin-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"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
// ---------------------------------------------------------------------------
// Attr builders
// ---------------------------------------------------------------------------
// roleArnAttrs builds catalog attributes for a single-hop AssumeRole catalog.
func roleArnAttrs(region, roleArn string) map[string]any {
return map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: true,
credential.ConstRoleArn: roleArn,
}
}
// crossRoleAttrs builds catalog attributes for a two-hop cross-role catalog.
func crossRoleAttrs(region, principalRoleArn, targetRoleArn string) map[string]any {
return map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: true,
credential.ConstRoleArn: principalRoleArn,
credential.ConstTargetRoleArn: targetRoleArn,
}
}
// tagFilterSetAttrs builds set attributes with a single tag-key filter.
func tagFilterSetAttrs(tags []string) map[string]any {
return map[string]any{
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", strings.Join(tags, ","))},
}
}
// requireTagInstancesMatchProvisioned asserts that every instance ID in tagInstancesMap
// is present in provisionedIds (the raw []any slice from a Terraform output).
func requireTagInstancesMatchProvisioned(t *testing.T, provisionedIds []any, tagInstancesMap map[string][]string) {
t.Helper()
idSet := make(map[string]struct{}, len(provisionedIds))
for _, id := range provisionedIds {
idSet[id.(string)] = struct{}{}
}
for tag, ids := range tagInstancesMap {
for _, id := range ids {
require.Contains(t, idSet, id, "instance ID %s in tag bucket %q was not provisioned by Terraform", id, tag)
}
}
}
// buildExpectedTagInstancesMap builds a map of tag key to instance IDs from a Terraform instance_tags output map.
func buildExpectedTagInstancesMap(rawTags map[string]any, expectedTags []string) map[string][]string {
tagMap := make(map[string][]string)
for instanceId, instanceTags := range rawTags {
for tagKey := range instanceTags.(map[string]any) {
for _, expectedTag := range expectedTags {
if tagKey == expectedTag {
tagMap[tagKey] = append(tagMap[tagKey], instanceId)
}
}
}
}
return tagMap
}
// ---------------------------------------------------------------------------
// Table-driven runners
// ---------------------------------------------------------------------------
type onCreateCatalogCase struct {
name string
catalogAttrs map[string]any
wantErr string // empty = expect success with empty persisted secrets
}
func testOnCreateCatalogCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []onCreateCatalogCase) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Helper()
require := require.New(t)
attrs, err := structpb.NewStruct(tc.catalogAttrs)
require.NoError(err)
resp, err := p.OnCreateCatalog(ctx, &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: attrs},
},
})
if tc.wantErr != "" {
require.Error(err)
st, ok := status.FromError(err)
require.True(ok)
require.Contains(st.Message(), tc.wantErr)
return
}
require.NoError(err)
require.NotNil(resp)
// Dynamic credentials are never stored — persisted secrets should be empty.
require.Empty(resp.GetPersisted().GetSecrets().GetFields())
})
}
}
type onUpdateCatalogCase struct {
name string
catalogAttrs map[string]any // used for both current and new when currentAttrs/newAttrs are nil
currentAttrs map[string]any // optional: override current catalog attrs
newAttrs map[string]any // optional: override new catalog attrs
wantErr string
}
func testOnUpdateCatalogCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []onUpdateCatalogCase) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Helper()
require := require.New(t)
currentRaw := tc.catalogAttrs
if tc.currentAttrs != nil {
currentRaw = tc.currentAttrs
}
newRaw := tc.catalogAttrs
if tc.newAttrs != nil {
newRaw = tc.newAttrs
}
currentAttrs, err := structpb.NewStruct(currentRaw)
require.NoError(err)
newAttrs, err := structpb.NewStruct(newRaw)
require.NoError(err)
resp, err := p.OnUpdateCatalog(ctx, &pb.OnUpdateCatalogRequest{
CurrentCatalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: currentAttrs},
},
NewCatalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: newAttrs},
},
Persisted: &pb.HostCatalogPersisted{},
})
if tc.wantErr != "" {
require.Error(err)
st, ok := status.FromError(err)
require.True(ok)
require.Contains(st.Message(), tc.wantErr)
return
}
require.NoError(err)
require.NotNil(resp)
// Dynamic credentials are never stored — persisted secrets should be empty.
require.Empty(resp.GetPersisted().GetSecrets().GetFields())
})
}
}
type onSetCase struct {
name string
catalogAttrs map[string]any
tags []string // nil = no filter (empty set attrs)
wantErr string
}
func testOnCreateSetCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []onSetCase) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Helper()
require := require.New(t)
catalogAttrs, err := structpb.NewStruct(tc.catalogAttrs)
require.NoError(err)
setRaw := map[string]any{host.ConstDescribeInstancesFilters: []any{}}
if len(tc.tags) > 0 {
setRaw = tagFilterSetAttrs(tc.tags)
}
setAttrs, err := structpb.NewStruct(setRaw)
require.NoError(err)
resp, err := p.OnCreateSet(ctx, &pb.OnCreateSetRequest{
Catalog: &hostcatalogs.HostCatalog{Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: catalogAttrs}},
Set: &hostsets.HostSet{Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs}},
Persisted: &pb.HostCatalogPersisted{},
})
if tc.wantErr != "" {
require.Error(err)
st, ok := status.FromError(err)
require.True(ok)
require.Contains(st.Message(), tc.wantErr)
return
}
require.NoError(err)
require.NotNil(resp)
})
}
}
func testOnUpdateSetCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []onSetCase) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Helper()
require := require.New(t)
catalogAttrs, err := structpb.NewStruct(tc.catalogAttrs)
require.NoError(err)
setRaw := map[string]any{host.ConstDescribeInstancesFilters: []any{}}
if len(tc.tags) > 0 {
setRaw = tagFilterSetAttrs(tc.tags)
}
setAttrs, err := structpb.NewStruct(setRaw)
require.NoError(err)
set := &hostsets.HostSet{Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs}}
resp, err := p.OnUpdateSet(ctx, &pb.OnUpdateSetRequest{
Catalog: &hostcatalogs.HostCatalog{Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: catalogAttrs}},
CurrentSet: set,
NewSet: set,
Persisted: &pb.HostCatalogPersisted{},
})
if tc.wantErr != "" {
require.Error(err)
st, ok := status.FromError(err)
require.True(ok)
require.Contains(st.Message(), tc.wantErr)
return
}
require.NoError(err)
require.NotNil(resp)
})
}
}
type listHostsCase struct {
name string
catalogAttrs map[string]any
tags []string
expected map[string][]string // nil = don't assert instances (error cases)
wantErr string
}
func testListHostsCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []listHostsCase) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Helper()
require := require.New(t)
catalogAttrs, err := structpb.NewStruct(tc.catalogAttrs)
require.NoError(err)
sets := make([]*hostsets.HostSet, len(tc.tags))
for i, tag := range tc.tags {
setAttrs, err := structpb.NewStruct(map[string]any{
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", tag)},
})
require.NoError(err)
sets[i] = &hostsets.HostSet{
Id: fmt.Sprintf("hostset-%d", i),
Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs},
}
}
resp, err := p.ListHosts(ctx, &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: catalogAttrs}},
Sets: sets,
Persisted: &pb.HostCatalogPersisted{},
})
if tc.wantErr != "" {
require.Error(err)
st, ok := status.FromError(err)
require.True(ok)
require.Contains(st.Message(), tc.wantErr)
return
}
require.NoError(err)
require.NotNil(resp)
expectedInstances := make(map[string][]string)
for i, tag := range tc.tags {
for _, instanceId := range tc.expected[tag] {
expectedInstances[instanceId] = append(expectedInstances[instanceId], fmt.Sprintf("hostset-%d", i))
}
}
actualInstances := make(map[string][]string)
for _, h := range resp.GetHosts() {
actualInstances[h.ExternalId] = h.SetIds
}
require.Equal(expectedInstances, actualInstances)
t.Logf("success: %d hosts matched", len(actualInstances))
})
}
}
+396
View File
@@ -0,0 +1,396 @@
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package testing
// This file contains helper functions for the static-credential (access key /
// secret access key) test paths exercised by TestHostPlugin in e2e_host_test.go.
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
"github.com/hashicorp/boundary-plugin-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"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
)
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]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: !rotate,
})
require.NoError(err)
reqSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
})
require.NoError(err)
request := &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqAttrs,
},
Secrets: reqSecrets,
},
}
response, err := p.OnCreateCatalog(ctx, request)
require.NoError(err)
require.NotNil(response)
persisted := response.GetPersisted()
require.NotNil(persisted)
return validatePersistedSecrets(t, persisted.GetSecrets(), accessKeyId, secretAccessKey, rotate)
}
func testPluginOnUpdateCatalog(
ctx context.Context, t *testing.T, p *host.HostPlugin,
region, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey string,
rotated, rotate bool,
) (string, string) {
t.Helper()
t.Logf("testing OnUpdateCatalog (region=%s, newcreds=%t, rotated=%t, rotate=%t)", region, newAccessKeyId != "" && newSecretAccessKey != "", rotated, rotate)
require := require.New(t)
// Take a timestamp of the current time to get a point in time to
// reference, ensuring that we are updating credential rotation
// timestamps.
currentCredsLastRotatedTime := time.Now()
reqCurrentAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: !rotated,
})
require.NoError(err)
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]any{
credential.ConstAccessKeyId: newAccessKeyId,
credential.ConstSecretAccessKey: newSecretAccessKey,
})
require.NoError(err)
}
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: currentAccessKeyId,
credential.ConstSecretAccessKey: currentSecretAccessKey,
credential.ConstCredsLastRotatedTime: func() string {
if rotated {
return currentCredsLastRotatedTime.Format(time.RFC3339Nano)
}
return (time.Time{}).Format(time.RFC3339Nano)
}(),
})
require.NoError(err)
require.NotNil(reqPersistedSecrets)
request := &pb.OnUpdateCatalogRequest{
CurrentCatalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqCurrentAttrs,
},
},
NewCatalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqNewAttrs,
},
Secrets: reqSecrets,
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.OnUpdateCatalog(ctx, request)
require.NoError(err)
require.NotNil(response)
persisted := response.GetPersisted()
require.NotNil(persisted)
return validateUpdateSecrets(t, persisted.GetSecrets(), currentCredsLastRotatedTime, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey, rotated, rotate)
}
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]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: !rotated,
})
require.NoError(err)
reqSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
})
require.NoError(err)
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)
}
return (time.Time{}).Format(time.RFC3339Nano)
}(),
})
require.NoError(err)
request := &pb.OnDeleteCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqAttrs,
},
Secrets: reqSecrets,
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.OnDeleteCatalog(ctx, request)
require.NoError(err)
require.NotNil(response)
// We want to test the validity of the credentials post-deletion.
if rotated {
// The credentials should no longer be valid.
requireCredentialsInvalid(t, accessKeyId, secretAccessKey)
} else {
// The credentials should still be valid. Sleep 10s first just to
// be sure, since we're not rotating.
time.Sleep(time.Second * 10)
requireCredentialsValid(t, accessKeyId, secretAccessKey)
}
}
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]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]any{
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", strings.Join(tags, ","))},
})
require.NoError(err)
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{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
Set: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
createResponse, err := p.OnCreateSet(ctx, createRequest)
require.NoError(err)
require.NotNil(createResponse)
// Do an update test in the same function, as it's pretty much the
// same function right now.
t.Logf("testing OnUpdateSet (region=%s, tags=%v)", region, tags)
updateRequest := &pb.OnUpdateSetRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
CurrentSet: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
},
NewSet: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
updateResponse, err := p.OnUpdateSet(ctx, updateRequest)
require.NoError(err)
require.NotNil(updateResponse)
}
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]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]any{
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", tag)},
})
require.NoError(err)
sets[i] = &hostsets.HostSet{
Id: fmt.Sprintf("hostset-%d", i),
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
}
}
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{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
Sets: sets,
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.ListHosts(ctx, request)
require.NoError(err)
require.NotNil(response)
// Validate the returned instances by ID. Assemble the instance
// details from the expected set.
expectedInstances := make(map[string][]string)
for i, tag := range tags {
for _, instanceId := range expected[tag] {
expectedInstances[instanceId] = append(expectedInstances[instanceId], fmt.Sprintf("hostset-%d", i))
}
}
// Take the returned hosts by ID and create the same kind of map.
actualInstances := make(map[string][]string)
for _, host := range response.GetHosts() {
actualInstances[host.ExternalId] = host.SetIds
}
// Compare
require.Equal(expectedInstances, actualInstances)
// Success
t.Logf("testing ListHosts: success (region=%s, tags=%v, expected/actual=(len=%d, ids=%s))", region, tags, len(actualInstances), actualInstances)
}
func testInstanceAddressesOnlyListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin, tf *TestTerraformer, region, accessKeyId, secretAccessKey string) {
t.Helper()
t.Logf("testing testInstanceAddressesOnlyListHosts")
// Get the instance details from tf output
instanceID, err := tf.GetOutputString("multi_address_instance_id")
require.NoError(t, err)
primaryAddresses, err := tf.GetOutputMap("multi_address_primary_addresses")
require.NoError(t, err)
secondaryAddresses, err := tf.GetOutputMap("multi_address_secondary_addresses")
require.NoError(t, err)
var primary, allAddresses []string
for _, addr := range primaryAddresses {
primary = append(primary, addr.(string))
}
allAddresses = append(allAddresses, primary...)
for _, addr := range secondaryAddresses {
allAddresses = append(allAddresses, addr.(string))
}
cases := []struct {
name string
instanceAddressesOnly bool
expectedIPs []string
}{
{
name: "all addresses",
expectedIPs: allAddresses,
},
{
name: "instance addresses only",
instanceAddressesOnly: true,
expectedIPs: primary,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
catalogAttrMap := map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: true,
}
if tc.instanceAddressesOnly {
catalogAttrMap[host.ConstInstanceAddressesOnly] = true
}
catalogAttrs, err := structpb.NewStruct(catalogAttrMap)
require.NoError(err)
setAttrs, err := structpb.NewStruct(map[string]any{
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("instance-id=%s", instanceID)},
})
require.NoError(err)
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{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
Sets: []*hostsets.HostSet{{
Id: "hostset-0",
Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs},
}},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.ListHosts(ctx, request)
require.NoError(err)
require.Len(response.GetHosts(), 1)
hostResp := response.GetHosts()[0]
require.Equal(instanceID, hostResp.ExternalId)
require.ElementsMatch(hostResp.IpAddresses, tc.expectedIPs)
})
}
}
+171 -368
View File
@@ -3,21 +3,24 @@
package testing
// This file contains the TestHostPlugin orchestrator. It deploys the Terraform
// workspace, collects outputs, and calls the per-credential-type helper
// functions defined in e2e_host_static_test.go and e2e_host_assumerole_test.go.
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
"github.com/hashicorp/boundary-plugin-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"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
)
const (
envTargetAccessKeyId = "TARGET_AWS_ACCESS_KEY_ID"
envTargetSecretAccessKey = "TARGET_AWS_SECRET_ACCESS_KEY"
envTargetSessionToken = "TARGET_AWS_SESSION_TOKEN"
envTargetRegion = "TARGET_AWS_REGION"
)
const expectedEc2InstanceCount = 5
@@ -35,7 +38,29 @@ func TestHostPlugin(t *testing.T) {
}
require := require.New(t)
tf, err := NewTestTerraformer("testdata/host")
// Collect cross-account credentials up front so they can be passed as
// Terraform variables. The cross-account section is skipped (not failed)
// when these are absent, but we need the values available before Deploy.
targetAccessKeyId := os.Getenv(envTargetAccessKeyId)
targetSecretAccessKey := os.Getenv(envTargetSecretAccessKey)
targetSessionToken := os.Getenv(envTargetSessionToken)
targetRegion := os.Getenv(envTargetRegion)
if targetRegion == "" {
targetRegion = region
}
tfVars := map[string]string{}
if targetAccessKeyId != "" && targetSecretAccessKey != "" {
tfVars["target_access_key_id"] = targetAccessKeyId
tfVars["target_secret_access_key"] = targetSecretAccessKey
tfVars["target_region"] = targetRegion
if targetSessionToken != "" {
tfVars["target_session_token"] = targetSessionToken
}
}
tf, err := NewTestTerraformer("testdata/host", tfVars)
require.NoError(err)
require.NotNil(tf)
@@ -137,16 +162,9 @@ func TestHostPlugin(t *testing.T) {
// Reassign the keyid and secret first.
keyid, secret = iamAccessKeyIds[5].(string), iamSecretAccessKeys[5].(string)
// 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]any) {
for _, expectedTag := range expectedTags {
if tagKey == expectedTag {
expectedTagInstancesMap[tagKey] = append(expectedTagInstancesMap[tagKey], instanceId)
}
}
}
}
expectedTagInstancesMap := buildExpectedTagInstancesMap(ec2InstanceTags, expectedTags)
requireTagInstancesMatchProvisioned(t, ec2InstanceIds, expectedTagInstancesMap)
cases := [][]string{
{expectedTags[0]},
@@ -168,377 +186,162 @@ func TestHostPlugin(t *testing.T) {
// finally lets test instance_addresses_only catalog attribute correctly syncs only instance addresses
testInstanceAddressesOnlyListHosts(ctx, t, p, tf, region, keyid, secret)
}
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)
// ********************
// * AssumeRole - Happy Path & Error Cases
// ********************
assumeRoleArn, err := tf.GetOutputString("assume_role_arn")
require.NoError(err)
reqAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: !rotate,
noTrustRoleArn, err := tf.GetOutputString("assume_role_no_trust_arn")
require.NoError(err)
missingPermRoleArn, err := tf.GetOutputString("assume_role_no_ec2_permission_arn")
require.NoError(err)
testOnCreateCatalogCases(ctx, t, p, []onCreateCatalogCase{
{name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn)},
{name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), wantErr: "AccessDenied"},
{name: "assume role missing ec2 permission", catalogAttrs: roleArnAttrs(region, missingPermRoleArn), wantErr: "UnauthorizedOperation"},
})
require.NoError(err)
reqSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
testOnUpdateCatalogCases(ctx, t, p, []onUpdateCatalogCase{
{name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn)},
{name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), wantErr: "AccessDenied"},
{name: "assume role missing ec2 permission", currentAttrs: roleArnAttrs(region, assumeRoleArn), newAttrs: roleArnAttrs(region, missingPermRoleArn), wantErr: "UnauthorizedOperation"},
})
require.NoError(err)
request := &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqAttrs,
},
Secrets: reqSecrets,
},
}
response, err := p.OnCreateCatalog(ctx, request)
require.NoError(err)
require.NotNil(response)
persisted := response.GetPersisted()
require.NotNil(persisted)
return validatePersistedSecrets(t, persisted.GetSecrets(), accessKeyId, secretAccessKey, rotate)
}
func testPluginOnUpdateCatalog(
ctx context.Context, t *testing.T, p *host.HostPlugin,
region, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey string,
rotated, rotate bool,
) (string, string) {
t.Helper()
t.Logf("testing OnUpdateCatalog (region=%s, newcreds=%t, rotated=%t, rotate=%t)", region, newAccessKeyId != "" && newSecretAccessKey != "", rotated, rotate)
require := require.New(t)
// Take a timestamp of the current time to get a point in time to
// reference, ensuring that we are updating credential rotation
// timestamps.
currentCredsLastRotatedTime := time.Now()
reqCurrentAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: !rotated,
testOnCreateSetCases(ctx, t, p, []onSetCase{
{name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn), tags: cases[0]},
{name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), wantErr: "AccessDenied"},
{name: "assume role missing ec2 permission", catalogAttrs: roleArnAttrs(region, missingPermRoleArn), wantErr: "UnauthorizedOperation"},
})
require.NoError(err)
reqNewAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: !rotate,
testOnUpdateSetCases(ctx, t, p, []onSetCase{
{name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn), tags: cases[0]},
{name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), wantErr: "AccessDenied"},
{name: "assume role missing ec2 permission", catalogAttrs: roleArnAttrs(region, missingPermRoleArn), wantErr: "UnauthorizedOperation"},
})
require.NoError(err)
var reqSecrets *structpb.Struct
if newAccessKeyId != "" && newSecretAccessKey != "" {
reqSecrets, err = structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: newAccessKeyId,
credential.ConstSecretAccessKey: newSecretAccessKey,
})
require.NoError(err)
}
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: currentAccessKeyId,
credential.ConstSecretAccessKey: currentSecretAccessKey,
credential.ConstCredsLastRotatedTime: func() string {
if rotated {
return currentCredsLastRotatedTime.Format(time.RFC3339Nano)
}
return (time.Time{}).Format(time.RFC3339Nano)
}(),
testListHostsCases(ctx, t, p, []listHostsCase{
{name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn), tags: cases[0], expected: expectedTagInstancesMap},
{name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), tags: cases[0], wantErr: "AccessDenied"},
{name: "assume role missing ec2 permission", catalogAttrs: roleArnAttrs(region, missingPermRoleArn), tags: cases[0], wantErr: "UnauthorizedOperation"},
})
require.NoError(err)
require.NotNil(reqPersistedSecrets)
request := &pb.OnUpdateCatalogRequest{
CurrentCatalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqCurrentAttrs,
},
},
NewCatalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqNewAttrs,
},
Secrets: reqSecrets,
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.OnUpdateCatalog(ctx, request)
require.NoError(err)
require.NotNil(response)
persisted := response.GetPersisted()
require.NotNil(persisted)
return validateUpdateSecrets(t, persisted.GetSecrets(), currentCredsLastRotatedTime, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey, rotated, rotate)
}
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)
// ********************
// * Cross-Role AssumeRole - Happy Path & Error Cases
// ********************
// These tests exercise the two-hop role chain within a single AWS account.
// The principal role has no ec2:DescribeInstances; the target role
// (assume_role) does. The plugin must assume the principal first, then use
// those credentials to assume the target role before calling DescribeInstances.
// This validates the plugin's chained credential logic without requiring a
// second AWS account.
crossRolePrincipalArn, err := tf.GetOutputString("cross_role_principal_arn")
require.NoError(err)
reqAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: !rotated,
crossRolePrincipalNoTrustArn, err := tf.GetOutputString("cross_role_principal_no_trust_arn")
require.NoError(err)
crossRoleTargetNoTrustArn, err := tf.GetOutputString("cross_role_target_no_trust_arn")
require.NoError(err)
crossRoleTargetNoEc2PermissionArn, err := tf.GetOutputString("cross_role_target_no_ec2_permission_arn")
require.NoError(err)
// assume_role_arn is reused as the cross-role target — it has ec2:DescribeInstances
// and its trust policy allows the cross-role principal to assume it.
testOnCreateCatalogCases(ctx, t, p, []onCreateCatalogCase{
{name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn)},
{name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), wantErr: "AccessDenied"},
{name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), wantErr: "AccessDenied"},
{name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
})
require.NoError(err)
reqSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
testOnUpdateCatalogCases(ctx, t, p, []onUpdateCatalogCase{
{name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn)},
{name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), wantErr: "AccessDenied"},
{name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), wantErr: "AccessDenied"},
{name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
})
require.NoError(err)
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)
}
return (time.Time{}).Format(time.RFC3339Nano)
}(),
testOnCreateSetCases(ctx, t, p, []onSetCase{
{name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn), tags: cases[0]},
{name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), wantErr: "AccessDenied"},
{name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), wantErr: "AccessDenied"},
{name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
})
require.NoError(err)
request := &pb.OnDeleteCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqAttrs,
},
Secrets: reqSecrets,
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.OnDeleteCatalog(ctx, request)
require.NoError(err)
require.NotNil(response)
// We want to test the validity of the credentials post-deletion.
if rotated {
// The credentials should no longer be valid.
requireCredentialsInvalid(t, accessKeyId, secretAccessKey)
testOnUpdateSetCases(ctx, t, p, []onSetCase{
{name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn), tags: cases[0]},
{name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), wantErr: "AccessDenied"},
{name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), wantErr: "AccessDenied"},
{name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
})
testListHostsCases(ctx, t, p, []listHostsCase{
{name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn), tags: cases[0], expected: expectedTagInstancesMap},
{name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), tags: cases[0], wantErr: "AccessDenied"},
{name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), tags: cases[0], wantErr: "AccessDenied"},
{name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), tags: cases[0], wantErr: "UnauthorizedOperation"},
})
// ********************
// * Cross-Account AssumeRole - Happy Path & Error Cases
// ********************
// These tests require credentials for a second AWS account supplied via
// TARGET_AWS_ACCESS_KEY_ID, TARGET_AWS_SECRET_ACCESS_KEY, and (for
// temporary credentials) TARGET_AWS_SESSION_TOKEN. They are skipped
// (not failed) when the access key ID or secret access key are absent.
if targetAccessKeyId == "" || targetSecretAccessKey == "" {
t.Log("skipping cross-account tests: set TARGET_AWS_ACCESS_KEY_ID and TARGET_AWS_SECRET_ACCESS_KEY to enable")
} else {
// The credentials should still be valid. Sleep 10s first just to
// be sure, since we're not rotating.
time.Sleep(time.Second * 10)
requireCredentialsValid(t, accessKeyId, secretAccessKey)
}
}
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]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]any{
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", strings.Join(tags, ","))},
})
require.NoError(err)
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{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
Set: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
createResponse, err := p.OnCreateSet(ctx, createRequest)
require.NoError(err)
require.NotNil(createResponse)
// Do an update test in the same function, as it's pretty much the
// same function right now.
t.Logf("testing OnUpdateSet (region=%s, tags=%v)", region, tags)
updateRequest := &pb.OnUpdateSetRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
CurrentSet: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
},
NewSet: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
updateResponse, err := p.OnUpdateSet(ctx, updateRequest)
require.NoError(err)
require.NotNil(updateResponse)
}
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]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]any{
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", tag)},
})
crossAccountPrincipalArn, err := tf.GetOutputString("cross_account_principal_arn")
require.NoError(err)
sets[i] = &hostsets.HostSet{
Id: fmt.Sprintf("hostset-%d", i),
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
}
}
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{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
Sets: sets,
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.ListHosts(ctx, request)
require.NoError(err)
require.NotNil(response)
// Validate the returned instances by ID. Assemble the instance
// details from the expected set.
expectedInstances := make(map[string][]string)
for i, tag := range tags {
for _, instanceId := range expected[tag] {
expectedInstances[instanceId] = append(expectedInstances[instanceId], fmt.Sprintf("hostset-%d", i))
}
}
crossAccountPrincipalNoTrustArn, err := tf.GetOutputString("cross_account_principal_no_trust_arn")
require.NoError(err)
// Take the returned hosts by ID and create the same kind of map.
actualInstances := make(map[string][]string)
for _, host := range response.GetHosts() {
actualInstances[host.ExternalId] = host.SetIds
}
crossAccountTargetArn, err := tf.GetOutputString("cross_account_target_arn")
require.NoError(err)
// Compare
require.Equal(expectedInstances, actualInstances)
// Success
t.Logf("testing ListHosts: success (region=%s, tags=%v, expected/actual=(len=%d, ids=%s))", region, tags, len(actualInstances), actualInstances)
}
crossAccountTargetNoTrustArn, err := tf.GetOutputString("cross_account_target_no_trust_arn")
require.NoError(err)
func testInstanceAddressesOnlyListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin, tf *TestTerraformer, region, accessKeyId, secretAccessKey string) {
t.Helper()
t.Logf("testing testInstanceAddressesOnlyListHosts")
crossAccountTargetNoEc2PermissionArn, err := tf.GetOutputString("cross_account_target_no_ec2_permission_arn")
require.NoError(err)
// Get the instance details from tf output
instanceID, err := tf.GetOutputString("multi_address_instance_id")
require.NoError(t, err)
primaryAddresses, err := tf.GetOutputMap("multi_address_primary_addresses")
require.NoError(t, err)
secondaryAddresses, err := tf.GetOutputMap("multi_address_secondary_addresses")
require.NoError(t, err)
targetEc2InstanceIds, err := tf.GetOutputSlice("target_instance_ids")
require.NoError(err)
require.Len(targetEc2InstanceIds, expectedEc2InstanceCount)
var primary, allAddresses []string
for _, addr := range primaryAddresses {
primary = append(primary, addr.(string))
}
allAddresses = append(allAddresses, primary...)
for _, addr := range secondaryAddresses {
allAddresses = append(allAddresses, addr.(string))
}
targetEc2InstanceTags, err := tf.GetOutputMap("target_instance_tags")
require.NoError(err)
expectedTargetTagInstancesMap := buildExpectedTagInstancesMap(targetEc2InstanceTags, expectedTags)
cases := []struct {
name string
instanceAddressesOnly bool
expectedIPs []string
}{
{
name: "all addresses",
expectedIPs: allAddresses,
},
{
name: "instance addresses only",
instanceAddressesOnly: true,
expectedIPs: primary,
},
}
requireTagInstancesMatchProvisioned(t, targetEc2InstanceIds, expectedTargetTagInstancesMap)
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
catalogAttrMap := map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: true,
}
if tc.instanceAddressesOnly {
catalogAttrMap[host.ConstInstanceAddressesOnly] = true
}
catalogAttrs, err := structpb.NewStruct(catalogAttrMap)
require.NoError(err)
setAttrs, err := structpb.NewStruct(map[string]any{
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("instance-id=%s", instanceID)},
})
require.NoError(err)
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{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
Sets: []*hostsets.HostSet{{
Id: "hostset-0",
Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs},
}},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.ListHosts(ctx, request)
require.NoError(err)
require.Len(response.GetHosts(), 1)
hostResp := response.GetHosts()[0]
require.Equal(instanceID, hostResp.ExternalId)
require.ElementsMatch(hostResp.IpAddresses, tc.expectedIPs)
testOnCreateCatalogCases(ctx, t, p, []onCreateCatalogCase{
{name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn)},
{name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), wantErr: "AccessDenied"},
{name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), wantErr: "AccessDenied"},
{name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
})
testOnUpdateCatalogCases(ctx, t, p, []onUpdateCatalogCase{
{name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn)},
{name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), wantErr: "AccessDenied"},
{name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), wantErr: "AccessDenied"},
{name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
})
testOnCreateSetCases(ctx, t, p, []onSetCase{
{name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn), tags: cases[0]},
{name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), wantErr: "AccessDenied"},
{name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), wantErr: "AccessDenied"},
{name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
})
testOnUpdateSetCases(ctx, t, p, []onSetCase{
{name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn), tags: cases[0]},
{name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), wantErr: "AccessDenied"},
{name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), wantErr: "AccessDenied"},
{name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
})
testListHostsCases(ctx, t, p, []listHostsCase{
{name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn), tags: cases[0], expected: expectedTargetTagInstancesMap},
{name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), tags: cases[0], wantErr: "AccessDenied"},
{name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), tags: cases[0], wantErr: "AccessDenied"},
{name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), tags: cases[0], wantErr: "UnauthorizedOperation"},
})
}
}
+206
View File
@@ -0,0 +1,206 @@
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
resource "random_id" "assume_role_name" {
prefix = "BoundaryPluginAssumeRole"
byte_length = 4
}
resource "aws_iam_role" "assume_role" {
name = random_id.assume_role_name.dec
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Principal = {
AWS = data.aws_caller_identity.current.arn
}
},
# Also trusted by the cross-role principal role so it can be used
# as the target in two-hop AssumeRole tests.
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Principal = {
AWS = aws_iam_role.cross_role_principal.arn
}
},
]
})
tags = {
"boundary-demo" = local.hashicorp_email
}
}
resource "aws_iam_role_policy_attachment" "assume_role_ec2_describeinstances" {
role = aws_iam_role.assume_role.name
policy_arn = aws_iam_policy.ec2_describeinstances.arn
}
output "assume_role_arn" {
value = aws_iam_role.assume_role.arn
}
# Error case: role exists with ec2:DescribeInstances but trust policy
# does not allow the caller to assume it.
resource "random_id" "assume_role_no_trust_name" {
prefix = "BoundaryPluginNoTrust"
byte_length = 4
}
resource "aws_iam_role" "assume_role_no_trust" {
name = random_id.assume_role_no_trust_name.dec
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Deny"
Principal = {
AWS = data.aws_caller_identity.current.arn
}
},
]
})
tags = {
"boundary-demo" = local.hashicorp_email
}
}
output "assume_role_no_trust_arn" {
value = aws_iam_role.assume_role_no_trust.arn
}
# Error case: role exists and the caller can assume it, but has no
# ec2:DescribeInstances permission.
# Also trusted by cross_role_principal so this same role can be reused as the
# no-EC2-permission target in two-hop (cross-role) error case tests.
resource "random_id" "assume_role_no_ec2_permission_name" {
prefix = "BoundaryPluginNoEC2Perm"
byte_length = 4
}
resource "aws_iam_role" "assume_role_no_ec2_permission" {
name = random_id.assume_role_no_ec2_permission_name.dec
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Principal = {
AWS = data.aws_caller_identity.current.arn
}
},
# Also trusted by the cross-role principal so it can serve as the
# no-EC2-permission target in two-hop error case tests.
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Principal = {
AWS = aws_iam_role.cross_role_principal.arn
}
},
]
})
tags = {
"boundary-demo" = local.hashicorp_email
}
}
output "assume_role_no_ec2_permission_arn" {
value = aws_iam_role.assume_role_no_ec2_permission.arn
}
# Cross-role (two-hop) principal role.
# The test runner can assume this role. It has permission to AssumeRole into
# aws_iam_role.assume_role (the target role) but has no ec2:DescribeInstances
# of its own. This simulates the principal side of a two-hop role chain: the
# plugin must assume the principal first, then use those credentials to assume
# the target role before calling DescribeInstances.
# Note: both roles are in the same AWS account. This tests the plugin's
# two-hop credential chain logic without requiring a second account.
resource "random_id" "cross_role_principal_name" {
prefix = "BoundaryPluginCRPrincipal"
byte_length = 4
}
resource "aws_iam_role" "cross_role_principal" {
name = random_id.cross_role_principal_name.dec
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Principal = {
AWS = data.aws_caller_identity.current.arn
}
},
]
})
tags = {
"boundary-demo" = local.hashicorp_email
}
}
# Single policy granting cross_role_principal permission to assume all target
# roles it needs for both happy path and error case tests.
resource "aws_iam_policy" "cross_role_principal_assume_targets" {
name = "${random_id.cross_role_principal_name.dec}-assume-targets"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Resource = [
aws_iam_role.assume_role.arn,
aws_iam_role.assume_role_no_ec2_permission.arn,
]
},
]
})
}
resource "aws_iam_role_policy_attachment" "cross_role_principal_assume_targets" {
role = aws_iam_role.cross_role_principal.name
policy_arn = aws_iam_policy.cross_role_principal_assume_targets.arn
}
output "cross_role_principal_arn" {
value = aws_iam_role.cross_role_principal.arn
}
# Error case: caller cannot assume the principal role (first hop denied).
# Reuses assume_role_no_trust — it already has a Deny trust policy for the
# caller, so it also serves as an unassumable principal in two-hop tests.
output "cross_role_principal_no_trust_arn" {
value = aws_iam_role.assume_role_no_trust.arn
}
# Error case: principal can be assumed, but the target role's trust policy
# does not allow the principal to assume it (second hop denied).
# Reuses assume_role_no_trust as the target — its Deny trust policy blocks
# all callers including cross_role_principal.
output "cross_role_target_no_trust_arn" {
value = aws_iam_role.assume_role_no_trust.arn
}
# Error case: both hops succeed but the target has no ec2:DescribeInstances.
# Reuses assume_role_no_ec2_permission — its trust policy allows
# cross_role_principal (see above), and it has no EC2 permission.
output "cross_role_target_no_ec2_permission_arn" {
value = aws_iam_role.assume_role_no_ec2_permission.arn
}
+322
View File
@@ -0,0 +1,322 @@
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
# Cross-account Dynamic Host Catalog test infrastructure.
#
# This file provisions the IAM resources needed to test two-hop AssumeRole
# across two separate AWS accounts:
#
# Account A (principal account) — the default provider
# * cross_account_principal: the plugin assumes this role first.
# It has sts:AssumeRole permission on the target role in Account B.
#
# Account B (target account) — the "target" provider alias
# * cross_account_target: holds ec2:DescribeInstances; its trust policy
# allows the Account A principal role to assume it. (happy path)
# * cross_account_target_no_ec2_permission: same trust policy but no
# ec2:DescribeInstances. (error case)
# * cross_account_target_no_trust: exists in Account B but its trust
# policy does not allow the Account A principal. (error case)
#
# Prerequisites:
# TARGET_AWS_ACCESS_KEY_ID, TARGET_AWS_SECRET_ACCESS_KEY, and (for temporary
# credentials) TARGET_AWS_SESSION_TOKEN must be set to credentials that have
# sufficient IAM permissions in Account B. The test runner skips this
# section when the access key ID or secret access key are absent.
# ---------------------------------------------------------------------------
# Provider for Account B (target account)
# ---------------------------------------------------------------------------
provider "aws" {
alias = "target"
region = var.target_region
access_key = var.target_access_key_id
secret_key = var.target_secret_access_key
token = var.target_session_token
}
data "aws_caller_identity" "target" {
provider = aws.target
}
# ---------------------------------------------------------------------------
# Variables for Account B credentials and region
# ---------------------------------------------------------------------------
variable "target_access_key_id" {
description = "AWS access key ID for the target account (Account B)."
type = string
sensitive = true
default = ""
}
variable "target_secret_access_key" {
description = "AWS secret access key for the target account (Account B)."
type = string
sensitive = true
default = ""
}
variable "target_session_token" {
description = "AWS session token for the target account (Account B). Required when using temporary (STS) credentials."
type = string
sensitive = true
default = ""
}
variable "target_region" {
description = "AWS region for the target account (Account B). Defaults to the same region as Account A."
type = string
default = ""
}
# ---------------------------------------------------------------------------
# Account A: cross_account_principal role
# ---------------------------------------------------------------------------
resource "random_id" "cross_account_principal_name" {
prefix = "BoundaryPluginCAPrincipal"
byte_length = 4
}
resource "aws_iam_role" "cross_account_principal" {
name = random_id.cross_account_principal_name.dec
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Principal = { AWS = data.aws_caller_identity.current.arn }
},
]
})
tags = {
"boundary-demo" = local.hashicorp_email
}
}
# Allow the principal role to assume the happy-path and no-EC2-permission
# target roles in Account B.
resource "aws_iam_policy" "cross_account_principal_assume_targets" {
name = "${random_id.cross_account_principal_name.dec}-assume-targets"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Resource = [
aws_iam_role.cross_account_target.arn,
aws_iam_role.cross_account_target_no_ec2_permission.arn,
]
},
]
})
}
resource "aws_iam_role_policy_attachment" "cross_account_principal_assume_targets" {
role = aws_iam_role.cross_account_principal.name
policy_arn = aws_iam_policy.cross_account_principal_assume_targets.arn
}
output "cross_account_principal_arn" {
value = aws_iam_role.cross_account_principal.arn
}
# Error case: caller cannot assume the principal role (first hop denied).
# Reuses assume_role_no_trust from iam_assume_role.tf — its Deny trust policy
# also blocks the test runner from assuming it as a principal.
output "cross_account_principal_no_trust_arn" {
value = aws_iam_role.assume_role_no_trust.arn
}
# ---------------------------------------------------------------------------
# Account B: happy-path target role
# ---------------------------------------------------------------------------
resource "random_id" "cross_account_target_name" {
prefix = "BoundaryPluginCATarget"
byte_length = 4
}
resource "aws_iam_role" "cross_account_target" {
provider = aws.target
name = random_id.cross_account_target_name.dec
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Principal = { AWS = aws_iam_role.cross_account_principal.arn }
},
]
})
tags = {
"boundary-demo" = local.hashicorp_email
}
}
resource "aws_iam_policy" "cross_account_target_ec2_describeinstances" {
provider = aws.target
name = "${random_id.cross_account_target_name.dec}-ec2"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["ec2:DescribeInstances"]
Effect = "Allow"
Resource = "*"
},
]
})
}
resource "aws_iam_role_policy_attachment" "cross_account_target_ec2_describeinstances" {
provider = aws.target
role = aws_iam_role.cross_account_target.name
policy_arn = aws_iam_policy.cross_account_target_ec2_describeinstances.arn
}
output "cross_account_target_arn" {
value = aws_iam_role.cross_account_target.arn
}
# ---------------------------------------------------------------------------
# Account B: error case — both hops succeed but target has no ec2:DescribeInstances
# ---------------------------------------------------------------------------
resource "random_id" "cross_account_target_no_ec2_permission_name" {
prefix = "BoundaryPluginCATargetNoEC2"
byte_length = 4
}
resource "aws_iam_role" "cross_account_target_no_ec2_permission" {
provider = aws.target
name = random_id.cross_account_target_no_ec2_permission_name.dec
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Principal = { AWS = aws_iam_role.cross_account_principal.arn }
},
]
})
tags = {
"boundary-demo" = local.hashicorp_email
}
}
output "cross_account_target_no_ec2_permission_arn" {
value = aws_iam_role.cross_account_target_no_ec2_permission.arn
}
# ---------------------------------------------------------------------------
# Account B: error case — principal cannot assume this target (second hop denied)
# ---------------------------------------------------------------------------
resource "random_id" "cross_account_target_no_trust_name" {
prefix = "BoundaryPluginCATargetNoTrust"
byte_length = 4
}
resource "aws_iam_role" "cross_account_target_no_trust" {
provider = aws.target
name = random_id.cross_account_target_no_trust_name.dec
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Deny"
Principal = { AWS = aws_iam_role.cross_account_principal.arn }
},
]
})
tags = {
"boundary-demo" = local.hashicorp_email
}
}
output "cross_account_target_no_trust_arn" {
value = aws_iam_role.cross_account_target_no_trust.arn
}
# ---------------------------------------------------------------------------
# Account B: EC2 instance for cross-account host discovery validation
# ---------------------------------------------------------------------------
data "aws_availability_zones" "target_azs" {
count = var.target_access_key_id != "" ? 1 : 0
provider = aws.target
}
data "aws_ami" "target_ubuntu" {
count = var.target_access_key_id != "" ? 1 : 0
provider = aws.target
most_recent = true
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
owners = ["099720109477"] # Canonical
}
resource "aws_vpc" "target_vpc" {
count = var.target_access_key_id != "" ? 1 : 0
provider = aws.target
cidr_block = "10.1.0.0/16"
assign_generated_ipv6_cidr_block = true
}
resource "aws_subnet" "target_subnet" {
count = var.target_access_key_id != "" ? 1 : 0
provider = aws.target
vpc_id = aws_vpc.target_vpc[0].id
cidr_block = aws_vpc.target_vpc[0].cidr_block
ipv6_cidr_block = cidrsubnet(aws_vpc.target_vpc[0].ipv6_cidr_block, 8, 0)
availability_zone = data.aws_availability_zones.target_azs[0].names[0]
map_public_ip_on_launch = true
assign_ipv6_address_on_creation = true
}
resource "aws_instance" "target_instances" {
count = var.target_access_key_id != "" ? length(local.instance_tags) : 0
provider = aws.target
ami = data.aws_ami.target_ubuntu[0].id
instance_type = "t3.nano"
subnet_id = aws_subnet.target_subnet[0].id
tags = local.instance_tags[count.index]
}
output "target_instance_ids" {
value = aws_instance.target_instances.*.id
}
output "target_instance_tags" {
value = {
for _, r in aws_instance.target_instances : r.id => r.tags
}
}
+29 -7
View File
@@ -39,6 +39,10 @@ type TestTerraformer struct {
// The working directory for Terraform commands.
workDir string
// vars holds optional key/value pairs passed as -var flags to every
// Terraform command that accepts them (apply, destroy, plan).
vars map[string]string
// The Terraform state. Use the Output method to look up particular
// outputs from the Terraform state.
state *tfjson.State
@@ -53,8 +57,9 @@ type TestTerraformer struct {
// NewTestTerraformer initializes a Terraform directory. It just
// returns an initialized project, it does not perform any actions on
// it.
func NewTestTerraformer(workDir string) (*TestTerraformer, error) {
// it. An optional vars map may be provided; each entry is passed as a
// -var flag to Terraform commands.
func NewTestTerraformer(workDir string, vars ...map[string]string) (*TestTerraformer, error) {
tfPath, err := exec.LookPath("terraform")
if err != nil {
return nil, fmt.Errorf("LookPath error: %w", err)
@@ -64,10 +69,24 @@ func NewTestTerraformer(workDir string) (*TestTerraformer, error) {
return nil, errors.New("workDir is empty")
}
return &TestTerraformer{
tf := &TestTerraformer{
tfPath: tfPath,
workDir: workDir,
}, nil
}
if len(vars) > 0 {
tf.vars = vars[0]
}
return tf, nil
}
// varArgs converts the stored vars map into a slice of -var flags
// suitable for appending to a Terraform command's argument list.
func (t *TestTerraformer) varArgs() []string {
args := make([]string, 0, len(t.vars)*2)
for k, v := range t.vars {
args = append(args, "-var", fmt.Sprintf("%s=%s", k, v))
}
return args
}
// Deploy runs the following commands, in order: init, apply, plan,
@@ -214,7 +233,8 @@ func (t *TestTerraformer) init() error {
// apply runs "terraform apply" in the project's working directory.
func (t *TestTerraformer) apply() error {
cmd := exec.Command(t.tfPath, "apply", "-input=false", "-auto-approve")
args := append([]string{"apply", "-input=false", "-auto-approve"}, t.varArgs()...)
cmd := exec.Command(t.tfPath, args...)
cmd.Dir = t.workDir
stdoutStderr, err := cmd.CombinedOutput()
if err != nil {
@@ -226,7 +246,8 @@ func (t *TestTerraformer) apply() error {
// destroy runs "terraform destroy" in the project's working directory.
func (t *TestTerraformer) destroy() error {
cmd := exec.Command(t.tfPath, "destroy", "-input=false", "-auto-approve")
args := append([]string{"destroy", "-input=false", "-auto-approve"}, t.varArgs()...)
cmd := exec.Command(t.tfPath, args...)
cmd.Dir = t.workDir
stdoutStderr, err := cmd.CombinedOutput()
if err != nil {
@@ -242,7 +263,8 @@ func (t *TestTerraformer) destroy() error {
// it's run to get the data for for importing the state via
// terraform-json. The apply method ignores the plan generated here.
func (t *TestTerraformer) plan() error {
cmd := exec.Command(t.tfPath, "plan", "-input=false", "-out=plan.tfplan")
args := append([]string{"plan", "-input=false", "-out=plan.tfplan"}, t.varArgs()...)
cmd := exec.Command(t.tfPath, args...)
cmd.Dir = t.workDir
stdoutStderr, err := cmd.CombinedOutput()
if err != nil {