* 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.
305 lines
10 KiB
Go
305 lines
10 KiB
Go
// 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))
|
|
})
|
|
}
|
|
}
|
|
|