Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f81151b58c | ||
|
|
e484c09479 | ||
|
|
4126a0dbfc | ||
|
|
09dd7f8a4d | ||
|
|
4c4a901d33 |
No files matched your search
@@ -14,7 +14,7 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/boundary/sdk v0.0.59
|
||||
github.com/hashicorp/go-multierror v1.1.1
|
||||
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.1.3
|
||||
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.1.2
|
||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0
|
||||
github.com/hashicorp/terraform-json v0.23.0
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
|
||||
@@ -8,8 +8,12 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
|
||||
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
|
||||
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE=
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.28.5 h1:Za41twdCXbuyyWv9LndXxZZv3QhTG1DinqlFsSuvtI0=
|
||||
@@ -18,26 +22,42 @@ github.com/aws/aws-sdk-go-v2/credentials v1.17.46 h1:AU7RcriIo2lXjUfHFnFKYsLCwgb
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.46/go.mod h1:1FmYyLGL08KQXQ6mcTlifyFXfJVCNJTVGuQP4m0d/UA=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 h1:sDSXIrlsFSFJtWKLQS4PUWRvrT580rrnuLydJrCQ/yA=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20/go.mod h1:WZ/c+w0ofps+/OUqMwWgnfrgzZH1DZO1RIkktICsqnY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.8 h1:1/bT9kDdLQzfZ1e6J6hpW+SfNDd6xrV8F3M2CuGyUz8=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.8/go.mod h1:RbdwTONAIi59ej/+1H+QzZORt5bcyAtbrS7FQb2pvz0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.254.0 h1:fTLR6dLDTGChAjecRPlVrKeznT0rVdzR4yn9Z68MTGk=
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.254.0/go.mod h1:V0jbRy1/IPapnkqgXSwVOFB+u5pnCwd9S+R3pKWULC4=
|
||||
github.com/aws/aws-sdk-go-v2/service/iam v1.47.6 h1:EWehQXACWr+6hzfZPwZChlfoVhiUCfLHE0Xh3kAfzWQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/iam v1.47.6/go.mod h1:qRXgEBWPIltrWHQwU+HkyBvwh1QgeigFcaCGCIVrWk0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.8 h1:tIN8MFT1z5STK5kTdOT1TCfMN/bn5fSEnlKsTL8qBOU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.8/go.mod h1:VKS56txtNWjKI8FqD/hliL0BcshyF4ZaLBa1rm2Y+5s=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.8 h1:AgYCo1Rb8XChJXA871BXHDNxNWOTAr6V5YdsRIBbgv0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.8/go.mod h1:Au9dvIGm1Hbqnt29d3VakOCQuN9l0WrkDDTRq8biWS4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.2 h1:T7b3qniouutV5Wwa9B1q7gW+Y8s1B3g9RE9qa7zLBIM=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.2/go.mod h1:tW9TsLb6t1eaTdBE6LITyJW1m/+DjQPU78Q/jT2FJu8=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 h1:3zu537oLmsPfDMyjnUS2g+F2vITgy5pB74tHI+JBNoM=
|
||||
@@ -46,6 +66,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 h1:K0OQAsDywb0ltlFrZm0JHPY3
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5/go.mod h1:ORITg+fyuMoeiQFiVGoqB3OydVTLkClw/ljbblMq6Cc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0=
|
||||
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
|
||||
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
||||
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
|
||||
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY=
|
||||
@@ -94,8 +116,8 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA=
|
||||
github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
|
||||
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.1.3 h1:mLe7IMysLSedhuu00W7AXsbfb90AA7UKfZmw3OkJ8n4=
|
||||
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.1.3/go.mod h1:6+rVulOPNCQbL3Xv2iLCqM0JmU2WO2wRzP1C6hBKeB8=
|
||||
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.1.2 h1:KlDrZWx/wYpnUcFPB0s9uhPjyNZoeIfhrV2MN/Ly76s=
|
||||
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.1.2/go.mod h1:6+rVulOPNCQbL3Xv2iLCqM0JmU2WO2wRzP1C6hBKeB8=
|
||||
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 h1:ET4pqyjiGmY09R5y+rSd70J2w45CtbWDNvGqWp/R3Ng=
|
||||
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw=
|
||||
github.com/hashicorp/go-secure-stdlib/configutil/v2 v2.0.13 h1:TayxZ5drfMP0G6T++WvnLESGLOWeHtdWDWWTAi2e3Qk=
|
||||
|
||||
@@ -112,35 +112,10 @@ func NewAwsCredentialPersistedState(opts ...AwsCredentialPersistedStateOption) (
|
||||
// and runs sts.GetCallerIdentity for the current credentials, which is done
|
||||
// to check that the credentials are valid. This method returns a status error
|
||||
// with PluginError details.
|
||||
//
|
||||
// When RoleARN is set and no static key is present (ambient role path),
|
||||
// credentials are eagerly retrieved before calling GetCallerIdentity. This
|
||||
// forces sts:AssumeRole to be called immediately so that any role errors
|
||||
// (e.g. no such role, access denied) surface here rather than being deferred
|
||||
// to the first real AWS API call.
|
||||
func (s *AwsCredentialPersistedState) ValidateCreds(ctx context.Context) error {
|
||||
if s.CredentialsConfig == nil {
|
||||
return errors.BadRequestStatus("missing credentials config")
|
||||
}
|
||||
|
||||
// For the ambient role path (RoleARN set, no static key), eagerly resolve
|
||||
// the credential chain so that sts:AssumeRole is called now. Without this,
|
||||
// the AssumeRoleProvider inside the chain is lazy and GetCallerIdentity
|
||||
// would succeed even with an invalid role ARN (it uses the mock/ambient
|
||||
// identity directly rather than the assumed one).
|
||||
cc := s.CredentialsConfig
|
||||
if cc.RoleARN != "" && cc.AccessKey == "" {
|
||||
cfg, err := s.GenerateCredentialChain(ctx)
|
||||
if err != nil {
|
||||
st, _ := errors.ParseAWSError("validating credentials", err)
|
||||
return st.Err()
|
||||
}
|
||||
if _, err := cfg.Credentials.Retrieve(ctx); err != nil {
|
||||
st, _ := errors.ParseAWSError("validating credentials", err)
|
||||
return st.Err()
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.CredentialsConfig.GetCallerIdentity(ctx, s.testOpts...); err != nil {
|
||||
st, _ := errors.ParseAWSError("validating credentials", err)
|
||||
return st.Err()
|
||||
|
||||
@@ -23,17 +23,17 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// AwsErrorAuthFailure is returned by EC2 when credentials exist in IAM
|
||||
// but have not yet propagated to EC2. This is a transient eventual
|
||||
// consistency issue that can resolve itself by retrying the request.
|
||||
AwsErrorAuthFailure = "AuthFailure"
|
||||
|
||||
// awsErrorAccessDenied is returned when the credentials do not have
|
||||
// the required permissions to do a specific action on a resource.
|
||||
// This error will persist until the IAM Role Policy is updated by
|
||||
// the owner. This error cannot be resolved by retrying the request.
|
||||
awsErrorAccessDenied = "AccessDenied"
|
||||
|
||||
// awsErrorUnauthorizedOperation is returned by EC2 when the caller is not
|
||||
// authorized to perform the operation. This includes DryRun permission
|
||||
// checks that fail (the counterpart to DryRunOperation success).
|
||||
awsErrorUnauthorizedOperation = "UnauthorizedOperation"
|
||||
|
||||
// awsErrorInvalidAccessKeyId is returned when the credential does
|
||||
// not exist. This error will persist until the credentials attached
|
||||
// to the storage bucket is updated. This error can occur when the
|
||||
@@ -71,26 +71,8 @@ const (
|
||||
|
||||
// RequestTimeoutException is returned when an http request takes longer than allowed
|
||||
awsErrorRequestTimeoutException = "RequestTimeoutException"
|
||||
|
||||
// dryRunOperationErrorCode is returned by AWS when a DryRun request would have
|
||||
// succeeded. It is a success signal disguised as an API error (typically HTTP 412).
|
||||
dryRunOperationErrorCode = "DryRunOperation"
|
||||
)
|
||||
|
||||
// IsDryRunSuccess reports whether err is the AWS DryRunOperation success signal.
|
||||
// A true result means the dry-run validated credentials, permissions, and parameters;
|
||||
// it must not be treated as a failure.
|
||||
func IsDryRunSuccess(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var apiErr smithy.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return false
|
||||
}
|
||||
return apiErr.ErrorCode() == dryRunOperationErrorCode
|
||||
}
|
||||
|
||||
// InvalidArgumentError returns an grpc invalid argument status error.
|
||||
func InvalidArgumentError(msg string, f map[string]string) error {
|
||||
var fieldMsgs []string
|
||||
@@ -195,8 +177,6 @@ func ParseAWSError(op string, err error) (st *status.Status, permission *pb.Perm
|
||||
switch apiErr.ErrorCode() {
|
||||
case awsErrorAccessDenied:
|
||||
fallthrough
|
||||
case awsErrorUnauthorizedOperation:
|
||||
fallthrough
|
||||
case awsErrorInvalidAccessKeyId:
|
||||
fallthrough
|
||||
case awsErrorExpiredToken:
|
||||
|
||||
@@ -300,17 +300,6 @@ func Test_ParseAWSError(t *testing.T) {
|
||||
CheckedAt: timestamppb.Now(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "aws-request-unauthorized-operation",
|
||||
err: TestAwsError(awsErrorUnauthorizedOperation, "You are not authorized to perform this operation."),
|
||||
expectedStatusCode: codes.PermissionDenied,
|
||||
expectedStatusMsg: "aws service unknown: invalid credentials: test",
|
||||
expectedPermission: &pb.Permission{
|
||||
State: pb.StateType_STATE_TYPE_ERROR,
|
||||
ErrorDetails: "You are not authorized to perform this operation.",
|
||||
CheckedAt: timestamppb.Now(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "aws-request-invalid-access-key-id",
|
||||
err: TestAwsError(awsErrorInvalidAccessKeyId, "The AWS Access Key Id you provided does not exist in our records."),
|
||||
@@ -401,46 +390,6 @@ func Test_ParseAWSError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_IsDryRunSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "plain error",
|
||||
err: fmt.Errorf("not an aws error"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "other api error",
|
||||
err: TestAwsError(awsErrorAccessDenied, "not authorized"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "dry run operation",
|
||||
err: TestAwsError(dryRunOperationErrorCode, "Request would have succeeded, but DryRun flag is set"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wrapped dry run operation",
|
||||
err: fmt.Errorf("describe instances: %w", TestAwsError(dryRunOperationErrorCode, "Request would have succeeded, but DryRun flag is set")),
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, IsDryRunSuccess(tt.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_BadRequestStatusf(t *testing.T) {
|
||||
require, assert := require.New(t), assert.New(t)
|
||||
err := BadRequestStatusf("test: %s", "hello world")
|
||||
|
||||
+94
-106
@@ -5,8 +5,10 @@ package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
goerrors "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
|
||||
"github.com/aws/aws-sdk-go-v2/service/sts"
|
||||
stsTypes "github.com/aws/aws-sdk-go-v2/service/sts/types"
|
||||
"github.com/aws/smithy-go"
|
||||
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
||||
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
|
||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||
@@ -42,8 +45,8 @@ type hostSetQuery struct {
|
||||
OutputHosts []*pb.ListHostsResponseHost
|
||||
}
|
||||
|
||||
const errBuildDescribeInstancesInput = "error building DescribeInstances input: %s"
|
||||
const errDryRunFailed = "error performing DescribeInstances dry run: %s"
|
||||
const defaultSessionName = "boundary-default"
|
||||
const dryRunOperationErrorCode = "DryRunOperation"
|
||||
|
||||
// Ensure that we are implementing HostPluginServiceServer
|
||||
var _ pb.HostPluginServiceServer = (*HostPlugin)(nil)
|
||||
@@ -86,15 +89,6 @@ func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalo
|
||||
}
|
||||
}
|
||||
|
||||
// Validate assumed-role credentials at catalog-creation time so that an
|
||||
// invalid role_arn is rejected immediately rather than silently falling
|
||||
// back to ambient credentials.
|
||||
if credential.GetCredentialType(credState.CredentialsConfig) == credential.DynamicAWS {
|
||||
if err := credState.ValidateCreds(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
catalogState, err := newAwsCatalogPersistedState(
|
||||
append([]awsCatalogPersistedStateOption{
|
||||
withCredentials(credState),
|
||||
@@ -104,21 +98,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 {
|
||||
if _, ok := status.FromError(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf(errDryRunFailed, err)
|
||||
if st := dryRunValidation(ctx, catalogState, opts); st != nil {
|
||||
return nil, st.Err()
|
||||
}
|
||||
|
||||
persistedProto, err := catalogState.toProto()
|
||||
@@ -193,15 +179,6 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo
|
||||
if err != nil {
|
||||
return nil, errors.BadRequestStatusf("error setting up new credential persisted state: %s", err)
|
||||
}
|
||||
|
||||
// Validate assumed-role credentials when the role_arn changes so that
|
||||
// an invalid ARN is caught before replacing the existing credential state.
|
||||
if credential.GetCredentialType(newCredState.CredentialsConfig) == credential.DynamicAWS {
|
||||
if err := newCredState.ValidateCreds(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
newCatalogState, err := newAwsCatalogPersistedState(
|
||||
append([]awsCatalogPersistedStateOption{
|
||||
withCredentials(newCredState),
|
||||
@@ -210,22 +187,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 {
|
||||
if _, ok := status.FromError(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf(errDryRunFailed, err)
|
||||
if st := dryRunValidation(ctx, newCatalogState, opts); st != nil {
|
||||
return nil, st.Err()
|
||||
}
|
||||
|
||||
// Replace the existing credential state.
|
||||
@@ -265,21 +232,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 {
|
||||
if _, ok := status.FromError(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf(errDryRunFailed, err)
|
||||
if st := dryRunValidation(ctx, catalogState, opts); st != nil {
|
||||
return nil, st.Err()
|
||||
}
|
||||
|
||||
persistedProto, err := catalogState.toProto()
|
||||
@@ -423,23 +382,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 {
|
||||
if _, ok := status.FromError(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf(errDryRunFailed, err)
|
||||
}
|
||||
|
||||
return &pb.OnCreateSetResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -493,21 +447,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 {
|
||||
if _, ok := status.FromError(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf(errDryRunFailed, err)
|
||||
if st := dryRunValidation(ctx, catalogState, opts, describeInstanceFilters...); st != nil {
|
||||
return nil, st.Err()
|
||||
}
|
||||
|
||||
return &pb.OnUpdateSetResponse{}, nil
|
||||
@@ -596,14 +546,10 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
|
||||
for i, query := range queries {
|
||||
output, err := checkHosts(ctx, catalogState, query.Input, opts, catalogAttributes.TargetAccount)
|
||||
if err != nil {
|
||||
// Mapped AWS failures are already gRPC statuses from checkHosts.
|
||||
if _, ok := status.FromError(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
if catalogAttributes.TargetAccount != nil {
|
||||
return nil, status.Errorf(codes.Unknown, "unable to list hosts for host set %q: %v", query.Id, err)
|
||||
return nil, errors.BadRequestStatusf("unable to list hosts for host set %q: %s", query.Id, err)
|
||||
}
|
||||
return nil, status.Errorf(codes.Unknown, "error retrieving host results for host set id %q: %v", query.Id, err)
|
||||
return nil, errors.BadRequestStatusf("error retrieving host results for host set id %q: %s", query.Id, err)
|
||||
}
|
||||
|
||||
queries[i].Output = output
|
||||
@@ -617,7 +563,7 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
|
||||
for _, instance := range reservation.Instances {
|
||||
host, err := awsInstanceToHost(instance, catalogAttributes)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unknown, "error processing host results for host set id %q: %v", query.Id, err)
|
||||
return nil, errors.BadRequestStatusf("error processing host results for host set id %q: %s", query.Id, err)
|
||||
}
|
||||
|
||||
queries[i].OutputHosts = append(queries[i].OutputHosts, host)
|
||||
@@ -666,11 +612,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]
|
||||
@@ -699,13 +645,9 @@ func buildFilters(attrs *SetAttributes) ([]types.Filter, error) {
|
||||
}
|
||||
|
||||
func buildDescribeInstancesInput(attrs *SetAttributes, dryRun bool) (*ec2.DescribeInstancesInput, error) {
|
||||
if attrs == nil {
|
||||
attrs = &SetAttributes{}
|
||||
}
|
||||
|
||||
filters, err := buildFilters(attrs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("error building filters: %w", err)
|
||||
}
|
||||
|
||||
return &ec2.DescribeInstancesInput{
|
||||
@@ -792,31 +734,25 @@ func checkHosts(ctx context.Context,
|
||||
// use the principal account to execute the DescribeInstances command
|
||||
ec2Client, err = principalCatalogState.EC2Client(ctx, opts...)
|
||||
if err != nil {
|
||||
if st, _ := errors.ParseAWSError("EC2 client setup", err); st.Code() != codes.Unknown {
|
||||
return nil, st.Err()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// use the target account to execute the DescribeInstances command
|
||||
ec2Client, err = ec2ClientForTarget(ctx, principalCatalogState, target, opts)
|
||||
if err != nil {
|
||||
if _, ok := status.FromError(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("EC2 client setup failed for target account: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
output, err := ec2Client.DescribeInstances(ctx, input)
|
||||
if err != nil {
|
||||
if errors.IsDryRunSuccess(err) {
|
||||
// DryRunOperation: request would have succeeded; not a failure.
|
||||
var awsErr smithy.APIError
|
||||
ok := goerrors.As(err, &awsErr)
|
||||
if ok && awsErr.ErrorCode() == dryRunOperationErrorCode {
|
||||
// successful DryRun validation, no response necessary, no error thrown
|
||||
return nil, nil
|
||||
}
|
||||
if st, _ := errors.ParseAWSError("DescribeInstances", err); st.Code() != codes.Unknown {
|
||||
return nil, st.Err()
|
||||
}
|
||||
|
||||
if target != nil {
|
||||
return nil, fmt.Errorf("EC2 DescribeInstances failed for target account: %w", err)
|
||||
}
|
||||
@@ -839,9 +775,6 @@ func ec2ClientForTarget(
|
||||
// principal identity).
|
||||
principalCfg, err := principalCatalogState.GenerateCredentialChain(ctx)
|
||||
if err != nil {
|
||||
if st, _ := errors.ParseAWSError("EC2 client setup for target account", err); st.Code() != codes.Unknown {
|
||||
return nil, st.Err()
|
||||
}
|
||||
return nil, fmt.Errorf("principal AWS configuration could not be loaded: %w", err)
|
||||
}
|
||||
if principalCfg == nil {
|
||||
@@ -855,6 +788,9 @@ func ec2ClientForTarget(
|
||||
provider := stscreds.NewAssumeRoleProvider(stsClient, target.RoleArn, func(o *stscreds.AssumeRoleOptions) {
|
||||
if target.RoleSessionName != "" {
|
||||
o.RoleSessionName = target.RoleSessionName
|
||||
} else {
|
||||
// STS may reject the AssumeRole request if session name is not provided.
|
||||
o.RoleSessionName = defaultSessionName
|
||||
}
|
||||
if target.RoleExternalId != "" {
|
||||
o.ExternalID = &target.RoleExternalId
|
||||
@@ -893,6 +829,58 @@ func ec2ClientForTarget(
|
||||
return ec2.NewFromConfig(targetCfg, ec2Opts...), nil
|
||||
}
|
||||
|
||||
// dryRunValidationTimeout is the maximum time to wait for credentials to
|
||||
// propagate across AWS services after rotation before giving up.
|
||||
const dryRunValidationTimeout = 30 * time.Second
|
||||
|
||||
// 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.
|
||||
//
|
||||
// After credential rotation, IAM keys may be valid for STS (GetCallerIdentity)
|
||||
// but not yet propagated to EC2. This function retries on AuthFailure to
|
||||
// tolerate that eventual consistency window.
|
||||
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))
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(dryRunValidationTimeout)
|
||||
delay := time.Second
|
||||
for {
|
||||
_, err = ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{Filters: filters})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthFailure indicates the credentials exist but haven't propagated
|
||||
// to EC2 yet — a transient eventual consistency issue after rotation.
|
||||
// Retry until the deadline.
|
||||
if strings.Contains(err.Error(), errors.AwsErrorAuthFailure) && time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
// Wait before retrying to give credentials time to propagate.
|
||||
case <-ctx.Done():
|
||||
// The caller cancelled — break out of the select.
|
||||
break
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
return status.New(codes.FailedPrecondition, fmt.Sprintf("aws describe instances failed: %s", err))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
+137
-430
@@ -16,11 +16,9 @@ import (
|
||||
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
|
||||
"github.com/aws/aws-sdk-go-v2/service/iam"
|
||||
iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
|
||||
"github.com/aws/aws-sdk-go-v2/service/sts"
|
||||
stsTypes "github.com/aws/aws-sdk-go-v2/service/sts/types"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
||||
awserrors "github.com/hashicorp/boundary-plugin-aws/internal/errors"
|
||||
pluginErrors "github.com/hashicorp/boundary-plugin-aws/internal/errors"
|
||||
"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"
|
||||
@@ -31,38 +29,6 @@ import (
|
||||
"google.golang.org/protobuf/testing/protocmp"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
// testRoleARN is a syntactically valid ARN used for role_arn test cases.
|
||||
// All STS calls against it are handled by injected mocks — no real AWS
|
||||
// account is contacted.
|
||||
const testRoleARN = "arn:aws:iam::123456789012:role/BoundaryTestRole"
|
||||
|
||||
// validAssumeRoleOutput returns a mock AssumeRoleOutput with synthetic
|
||||
// temporary credentials for use with WithSTSAPIFunc.
|
||||
func validAssumeRoleOutput() *sts.AssumeRoleOutput {
|
||||
return &sts.AssumeRoleOutput{
|
||||
Credentials: &stsTypes.Credentials{
|
||||
AccessKeyId: aws.String("ASIAmockassumedkey"),
|
||||
SecretAccessKey: aws.String("mockassumedsecret"),
|
||||
SessionToken: aws.String("mockassumedsessiontoken"),
|
||||
Expiration: aws.Time(time.Now().Add(time.Hour)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// catalogAttrsStruct builds a catalog attributes structpb for the given
|
||||
// region. roleArn is written only when non-empty. disableRotation must be
|
||||
// set explicitly to avoid implicit rotation behaviour in tests.
|
||||
func catalogAttrsStruct(region, roleArn string, disableRotation bool) *structpb.Struct {
|
||||
fields := map[string]*structpb.Value{
|
||||
credential.ConstRegion: structpb.NewStringValue(region),
|
||||
credential.ConstDisableCredentialRotation: structpb.NewBoolValue(disableRotation),
|
||||
}
|
||||
if roleArn != "" {
|
||||
fields[credential.ConstRoleArn] = structpb.NewStringValue(roleArn)
|
||||
}
|
||||
return &structpb.Struct{Fields: fields}
|
||||
}
|
||||
|
||||
|
||||
func TestPluginOnCreateCatalogSuccess(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -173,13 +139,17 @@ func TestPluginOnCreateCatalogSuccess(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
// role_arn set, no static keys. AssumeRole mock succeeds.
|
||||
// Persisted secrets are empty — dynamic credentials are not stored.
|
||||
name: "usingAssumeRole",
|
||||
req: &pb.OnCreateCatalogRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: catalogAttrsStruct("us-east-1", testRoleARN, true),
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstRegion: structpb.NewStringValue("us-east-1"),
|
||||
credential.ConstRoleArn: structpb.NewStringValue("arn:0123:test:rolearn"),
|
||||
credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -189,13 +159,6 @@ func TestPluginOnCreateCatalogSuccess(t *testing.T) {
|
||||
testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}),
|
||||
)),
|
||||
},
|
||||
credOpts: []credential.AwsCredentialPersistedStateOption{
|
||||
credential.WithStateTestOpts([]awsutil.Option{
|
||||
awsutil.WithSTSAPIFunc(awsutil.NewMockSTS(
|
||||
awsutil.WithAssumeRoleOutput(validAssumeRoleOutput()),
|
||||
)),
|
||||
}),
|
||||
},
|
||||
expRsp: &pb.OnCreateCatalogResponse{Persisted: &pb.HostCatalogPersisted{Secrets: &structpb.Struct{}}},
|
||||
},
|
||||
}
|
||||
@@ -353,20 +316,6 @@ func TestPluginOnUpdateCatalogSuccess(t *testing.T) {
|
||||
testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}),
|
||||
)),
|
||||
},
|
||||
credOpts: []credential.AwsCredentialPersistedStateOption{
|
||||
credential.WithStateTestOpts([]awsutil.Option{
|
||||
awsutil.WithSTSAPIFunc(awsutil.NewMockSTS(
|
||||
awsutil.WithAssumeRoleOutput(&sts.AssumeRoleOutput{
|
||||
Credentials: &stsTypes.Credentials{
|
||||
AccessKeyId: aws.String("ASIAfoobar"),
|
||||
SecretAccessKey: aws.String("secretkeyfoobar"),
|
||||
SessionToken: aws.String("sessiontokenfoobar"),
|
||||
Expiration: aws.Time(time.Now().Add(time.Hour)),
|
||||
},
|
||||
}),
|
||||
)),
|
||||
}),
|
||||
},
|
||||
expRsp: &pb.OnUpdateCatalogResponse{
|
||||
Persisted: &pb.HostCatalogPersisted{Secrets: &structpb.Struct{}},
|
||||
},
|
||||
@@ -473,16 +422,6 @@ func TestPluginOnUpdateCatalogSuccess(t *testing.T) {
|
||||
credOpts: []credential.AwsCredentialPersistedStateOption{
|
||||
credential.WithStateTestOpts([]awsutil.Option{
|
||||
awsutil.WithIAMAPIFunc(awsutil.NewMockIAM()),
|
||||
awsutil.WithSTSAPIFunc(awsutil.NewMockSTS(
|
||||
awsutil.WithAssumeRoleOutput(&sts.AssumeRoleOutput{
|
||||
Credentials: &stsTypes.Credentials{
|
||||
AccessKeyId: aws.String("ASIAfoobar"),
|
||||
SecretAccessKey: aws.String("secretkeyfoobar"),
|
||||
SessionToken: aws.String("sessiontokenfoobar"),
|
||||
Expiration: aws.Time(time.Now().Add(time.Hour)),
|
||||
},
|
||||
}),
|
||||
)),
|
||||
}),
|
||||
},
|
||||
expRsp: &pb.OnUpdateCatalogResponse{
|
||||
@@ -804,72 +743,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,
|
||||
},
|
||||
{
|
||||
name: "dry run status error preserves gRPC code",
|
||||
req: &pb.OnCreateCatalogRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Secrets: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"),
|
||||
credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
|
||||
},
|
||||
},
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
|
||||
credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
catalogOpts: []awsCatalogPersistedStateOption{
|
||||
withTestEC2APIFunc(newTestMockEC2(
|
||||
nil,
|
||||
testMockEC2WithDescribeInstancesError(awserrors.TestAwsError("AccessDenied", "not authorized to perform ec2:DescribeInstances")),
|
||||
)),
|
||||
},
|
||||
// checkHosts wraps the AWS error via ParseAWSError into a gRPC
|
||||
// PermissionDenied status. OnCreateCatalog's dry-run error handler
|
||||
// should preserve that status code, not re-wrap it as Unknown.
|
||||
expectedErrContains: "invalid credentials",
|
||||
expectedErrCode: codes.PermissionDenied,
|
||||
},
|
||||
{
|
||||
// BUG FIX: role_arn set, no static keys. AssumeRole mock returns
|
||||
// an error. ValidateCreds now eagerly calls Retrieve() which forces
|
||||
// sts:AssumeRole, so the error surfaces here at creation time.
|
||||
// Previously this succeeded silently via the ambient credential chain.
|
||||
name: "role_arn AssumeRole error — correctly rejected",
|
||||
req: &pb.OnCreateCatalogRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: catalogAttrsStruct("us-east-1", testRoleARN, true),
|
||||
},
|
||||
},
|
||||
},
|
||||
credOpts: []credential.AwsCredentialPersistedStateOption{
|
||||
credential.WithStateTestOpts([]awsutil.Option{
|
||||
awsutil.WithSTSAPIFunc(awsutil.NewMockSTS(
|
||||
awsutil.WithAssumeRoleError(errors.New("simulated AssumeRole failure")),
|
||||
)),
|
||||
}),
|
||||
},
|
||||
catalogOpts: []awsCatalogPersistedStateOption{
|
||||
withTestEC2APIFunc(newTestMockEC2(
|
||||
nil,
|
||||
testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}),
|
||||
)),
|
||||
},
|
||||
expectedErrContains: "simulated AssumeRole failure",
|
||||
expectedErrCode: codes.Unknown,
|
||||
expectedErrContains: "aws describe instances failed: DescribeInstances error",
|
||||
expectedErrCode: codes.FailedPrecondition,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1120,11 +995,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",
|
||||
@@ -1159,27 +1031,14 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
// ValidateCreds is called for DynamicAWS credentials before the dry-run
|
||||
// DescribeInstances. Provide a mock STS that returns a successful AssumeRole
|
||||
// so the validate step passes and execution reaches the EC2 mock.
|
||||
credOpts: []credential.AwsCredentialPersistedStateOption{
|
||||
credential.WithStateTestOpts([]awsutil.Option{
|
||||
awsutil.WithSTSAPIFunc(awsutil.NewMockSTS(
|
||||
awsutil.WithAssumeRoleOutput(validAssumeRoleOutput()),
|
||||
)),
|
||||
}),
|
||||
},
|
||||
catalogOpts: []awsCatalogPersistedStateOption{
|
||||
withTestEC2APIFunc(newTestMockEC2(
|
||||
nil,
|
||||
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",
|
||||
@@ -1353,53 +1212,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,
|
||||
},
|
||||
{
|
||||
name: "final dry run gRPC status error preserves code",
|
||||
req: &pb.OnUpdateCatalogRequest{
|
||||
CurrentCatalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
NewCatalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Persisted: &pb.HostCatalogPersisted{
|
||||
Secrets: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"),
|
||||
credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
|
||||
credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
catalogOpts: []awsCatalogPersistedStateOption{
|
||||
withTestEC2APIFunc(newTestMockEC2(
|
||||
nil,
|
||||
testMockEC2WithDescribeInstancesError(awserrors.TestAwsError("AccessDenied", "not authorized to perform ec2:DescribeInstances")),
|
||||
)),
|
||||
},
|
||||
// ParseAWSError maps AccessDenied to PermissionDenied. The dry-run
|
||||
// error handler should preserve that gRPC status, not wrap it as Unknown.
|
||||
expectedErrContains: "invalid credentials",
|
||||
expectedErrCode: codes.PermissionDenied,
|
||||
expectedErrContains: "aws describe instances failed: oops there was an error",
|
||||
expectedErrCode: codes.FailedPrecondition,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1752,57 +1566,8 @@ func TestPluginOnCreateSetErr(t *testing.T) {
|
||||
testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)),
|
||||
)),
|
||||
},
|
||||
expectedErrContains: testDescribeInstancesError,
|
||||
expectedErrCode: codes.Unknown,
|
||||
},
|
||||
{
|
||||
name: "dry run gRPC status error preserves code",
|
||||
req: &pb.OnCreateSetRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
|
||||
credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Persisted: &pb.HostCatalogPersisted{
|
||||
Secrets: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"),
|
||||
credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Set: &hostsets.HostSet{
|
||||
Id: "foobar",
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
ConstDescribeInstancesFilters: structpb.NewListValue(
|
||||
&structpb.ListValue{
|
||||
Values: []*structpb.Value{
|
||||
structpb.NewStringValue("tag-key=foo"),
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
catalogOpts: []awsCatalogPersistedStateOption{
|
||||
withTestEC2APIFunc(newTestMockEC2(
|
||||
nil,
|
||||
testMockEC2WithDescribeInstancesError(awserrors.TestAwsError("AccessDenied", "not authorized to perform ec2:DescribeInstances")),
|
||||
)),
|
||||
},
|
||||
// ParseAWSError maps AccessDenied to PermissionDenied. The dry-run
|
||||
// error handler should preserve that gRPC status, not wrap it as Unknown.
|
||||
expectedErrContains: "invalid credentials",
|
||||
expectedErrCode: codes.PermissionDenied,
|
||||
expectedErrContains: fmt.Sprintf("aws describe instances failed: %s", testDescribeInstancesError),
|
||||
expectedErrCode: codes.FailedPrecondition,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2064,56 +1829,8 @@ func TestPluginOnUpdateSetErr(t *testing.T) {
|
||||
testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)),
|
||||
)),
|
||||
},
|
||||
expectedErrContains: testDescribeInstancesError,
|
||||
expectedErrCode: codes.Unknown,
|
||||
},
|
||||
{
|
||||
name: "dry run gRPC status error preserves code",
|
||||
req: &pb.OnUpdateSetRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
|
||||
credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Persisted: &pb.HostCatalogPersisted{
|
||||
Secrets: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"),
|
||||
credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
|
||||
},
|
||||
},
|
||||
},
|
||||
NewSet: &hostsets.HostSet{
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
ConstDescribeInstancesFilters: structpb.NewListValue(
|
||||
&structpb.ListValue{
|
||||
Values: []*structpb.Value{
|
||||
structpb.NewStringValue("tag-key=foo"),
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
catalogOpts: []awsCatalogPersistedStateOption{
|
||||
withTestEC2APIFunc(newTestMockEC2(
|
||||
nil,
|
||||
testMockEC2WithDescribeInstancesError(awserrors.TestAwsError("AccessDenied", "not authorized to perform ec2:DescribeInstances")),
|
||||
)),
|
||||
},
|
||||
// ParseAWSError maps AccessDenied to PermissionDenied. The dry-run
|
||||
// error handler should preserve that gRPC status, not wrap it as Unknown.
|
||||
expectedErrContains: "invalid credentials",
|
||||
expectedErrCode: codes.PermissionDenied,
|
||||
expectedErrContains: fmt.Sprintf("aws describe instances failed: %s", testDescribeInstancesError),
|
||||
expectedErrCode: codes.FailedPrecondition,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2571,7 +2288,7 @@ func TestPluginListHostsErr(t *testing.T) {
|
||||
)),
|
||||
},
|
||||
expectedErrContains: fmt.Sprintf("error retrieving host results for host set id \"foobar\": %s", testDescribeInstancesError),
|
||||
expectedErrCode: codes.Unknown,
|
||||
expectedErrCode: codes.InvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "target attributes missing target_role_arn",
|
||||
@@ -2637,7 +2354,7 @@ func TestPluginListHostsErr(t *testing.T) {
|
||||
}),
|
||||
},
|
||||
expectedErrContains: "unable to list hosts for host set \"foobar\": EC2 client setup failed for target account: test EC2 client error",
|
||||
expectedErrCode: codes.Unknown,
|
||||
expectedErrCode: codes.InvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "target DescribeInstances error",
|
||||
@@ -2665,7 +2382,7 @@ func TestPluginListHostsErr(t *testing.T) {
|
||||
)),
|
||||
},
|
||||
expectedErrContains: "unable to list hosts for host set \"foobar\": EC2 DescribeInstances failed for target account: DescribeInstances error",
|
||||
expectedErrCode: codes.Unknown,
|
||||
expectedErrCode: codes.InvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "awsInstanceToHost error",
|
||||
@@ -2726,115 +2443,7 @@ func TestPluginListHostsErr(t *testing.T) {
|
||||
)),
|
||||
},
|
||||
expectedErrContains: "error processing host results for host set id \"foobar\": response integrity error: missing instance id",
|
||||
expectedErrCode: codes.Unknown,
|
||||
},
|
||||
{
|
||||
name: "DescribeInstances AccessDenied",
|
||||
req: &pb.ListHostsRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Persisted: testListHostsPersisted(),
|
||||
Sets: []*hostsets.HostSet{
|
||||
testListHostsSet("foobar", "tag-key=foo"),
|
||||
},
|
||||
},
|
||||
catalogOpts: []awsCatalogPersistedStateOption{
|
||||
withTestEC2APIFunc(newTestMockEC2(
|
||||
nil,
|
||||
testMockEC2WithDescribeInstancesError(awserrors.TestAwsError("AccessDenied", "not authorized to perform ec2:DescribeInstances")),
|
||||
)),
|
||||
},
|
||||
expectedErrContains: "invalid credentials",
|
||||
expectedErrCode: codes.PermissionDenied,
|
||||
},
|
||||
{
|
||||
name: "DescribeInstances UnauthorizedOperation",
|
||||
req: &pb.ListHostsRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Persisted: testListHostsPersisted(),
|
||||
Sets: []*hostsets.HostSet{
|
||||
testListHostsSet("foobar", "tag-key=foo"),
|
||||
},
|
||||
},
|
||||
catalogOpts: []awsCatalogPersistedStateOption{
|
||||
withTestEC2APIFunc(newTestMockEC2(
|
||||
nil,
|
||||
testMockEC2WithDescribeInstancesError(awserrors.TestAwsError("UnauthorizedOperation", "You are not authorized to perform this operation.")),
|
||||
)),
|
||||
},
|
||||
expectedErrContains: "invalid credentials",
|
||||
expectedErrCode: codes.PermissionDenied,
|
||||
},
|
||||
{
|
||||
name: "target DescribeInstances AccessDenied",
|
||||
req: &pb.ListHostsRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
|
||||
credential.ConstTargetRoleArn: structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"),
|
||||
credential.ConstTargetRegion: structpb.NewStringValue("eu-west-1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Persisted: testListHostsPersisted(),
|
||||
Sets: []*hostsets.HostSet{
|
||||
testListHostsSet("foobar", "tag-key=foo"),
|
||||
},
|
||||
},
|
||||
catalogOpts: []awsCatalogPersistedStateOption{
|
||||
withTestEC2APIFunc(newTestMockEC2(
|
||||
nil,
|
||||
testMockEC2WithDescribeInstancesError(awserrors.TestAwsError("AccessDenied", "not authorized to perform ec2:DescribeInstances")),
|
||||
)),
|
||||
},
|
||||
expectedErrContains: "invalid credentials",
|
||||
expectedErrCode: codes.PermissionDenied,
|
||||
},
|
||||
{
|
||||
name: "target EC2 client AccessDenied",
|
||||
req: &pb.ListHostsRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
|
||||
credential.ConstTargetRoleArn: structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Persisted: testListHostsPersisted(),
|
||||
Sets: []*hostsets.HostSet{
|
||||
testListHostsSet("foobar", "tag-key=foo"),
|
||||
},
|
||||
},
|
||||
catalogOpts: []awsCatalogPersistedStateOption{
|
||||
withTestEC2APIFunc(func(...aws.Config) (EC2API, error) {
|
||||
return nil, awserrors.TestAwsError("AccessDenied", "not authorized to assume role")
|
||||
}),
|
||||
},
|
||||
// ec2ClientForTarget returns testEC2APIFunc errors unparsed; checkHosts
|
||||
// only preserves existing gRPC statuses, so this surfaces as Unknown.
|
||||
expectedErrContains: "unable to list hosts for host set \"foobar\": EC2 client setup failed for target account: api error AccessDenied: not authorized to assume role",
|
||||
expectedErrCode: codes.Unknown,
|
||||
expectedErrCode: codes.InvalidArgument,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3025,21 +2634,6 @@ func TestBuildDescribeInstancesInput(t *testing.T) {
|
||||
require.Equal(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("nil attrs, dry run", func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
actual, err := buildDescribeInstancesInput(nil, true)
|
||||
require.NoError(err)
|
||||
require.Equal(&ec2.DescribeInstancesInput{
|
||||
DryRun: aws.Bool(true),
|
||||
Filters: []types.Filter{
|
||||
{
|
||||
Name: aws.String("instance-state-name"),
|
||||
Values: []string{string(types.InstanceStateNameRunning)},
|
||||
},
|
||||
},
|
||||
}, actual)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAwsInstanceToHost(t *testing.T) {
|
||||
@@ -3478,3 +3072,116 @@ 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)
|
||||
})
|
||||
|
||||
t.Run("authFailureThenSuccess", func(t *testing.T) {
|
||||
// Simulates the IAM eventual consistency window: EC2 returns AuthFailure
|
||||
// twice before the credentials propagate and the call succeeds.
|
||||
state := &testMockEC2State{}
|
||||
st := dryRunValidation(context.Background(), &awsCatalogPersistedState{
|
||||
AwsCredentialPersistedState: &credential.AwsCredentialPersistedState{
|
||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
||||
AccessKey: "AKIAfoo",
|
||||
SecretKey: "baz",
|
||||
},
|
||||
},
|
||||
testEC2APIFunc: newTestMockEC2(state,
|
||||
testMockEC2WithDescribeInstancesErrorSequence(
|
||||
fmt.Errorf("%s: credentials not yet propagated", pluginErrors.AwsErrorAuthFailure),
|
||||
fmt.Errorf("%s: credentials not yet propagated", pluginErrors.AwsErrorAuthFailure),
|
||||
),
|
||||
testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}),
|
||||
),
|
||||
}, []ec2Option{})
|
||||
require.Nil(t, st)
|
||||
require.Equal(t, 3, state.DescribeInstancesCallCount)
|
||||
})
|
||||
|
||||
t.Run("authFailureContextCancelled", func(t *testing.T) {
|
||||
// If the context is cancelled while waiting to retry, the function should
|
||||
// return a FailedPrecondition error immediately rather than hanging.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately so the first retry select hits ctx.Done()
|
||||
st := dryRunValidation(ctx, &awsCatalogPersistedState{
|
||||
AwsCredentialPersistedState: &credential.AwsCredentialPersistedState{
|
||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
||||
AccessKey: "AKIAfoo",
|
||||
SecretKey: "baz",
|
||||
},
|
||||
},
|
||||
testEC2APIFunc: newTestMockEC2(nil,
|
||||
testMockEC2WithDescribeInstancesError(fmt.Errorf("%s: credentials not yet propagated", pluginErrors.AwsErrorAuthFailure)),
|
||||
),
|
||||
}, []ec2Option{})
|
||||
require.NotNil(t, st)
|
||||
require.Equal(t, codes.FailedPrecondition.String(), st.Code().String())
|
||||
})
|
||||
|
||||
t.Run("nonAuthFailureNoRetry", func(t *testing.T) {
|
||||
// A non-AuthFailure error should fail immediately without retrying.
|
||||
state := &testMockEC2State{}
|
||||
st := dryRunValidation(context.Background(), &awsCatalogPersistedState{
|
||||
AwsCredentialPersistedState: &credential.AwsCredentialPersistedState{
|
||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
||||
AccessKey: "AKIAfoo",
|
||||
SecretKey: "baz",
|
||||
},
|
||||
},
|
||||
testEC2APIFunc: newTestMockEC2(state,
|
||||
testMockEC2WithDescribeInstancesError(fmt.Errorf("UnauthorizedOperation: access denied")),
|
||||
),
|
||||
}, []ec2Option{})
|
||||
require.NotNil(t, st)
|
||||
require.Equal(t, codes.FailedPrecondition.String(), st.Code().String())
|
||||
require.Equal(t, 1, state.DescribeInstancesCallCount)
|
||||
})
|
||||
}
|
||||
@@ -43,6 +43,10 @@ type testMockEC2 struct {
|
||||
|
||||
DescribeInstancesOutput *ec2.DescribeInstancesOutput
|
||||
DescribeInstancesError error
|
||||
|
||||
// DescribeInstancesErrorSequence, if set, returns each error in order on
|
||||
// successive calls. Once exhausted, DescribeInstancesOutput is returned.
|
||||
DescribeInstancesErrorSequence []error
|
||||
}
|
||||
|
||||
type testMockEC2Option func(m *testMockEC2) error
|
||||
@@ -61,6 +65,16 @@ func testMockEC2WithDescribeInstancesError(e error) testMockEC2Option {
|
||||
}
|
||||
}
|
||||
|
||||
// testMockEC2WithDescribeInstancesErrorSequence sets a sequence of errors to
|
||||
// return on successive DescribeInstances calls. Once the sequence is exhausted,
|
||||
// the mock returns DescribeInstancesOutput (nil error).
|
||||
func testMockEC2WithDescribeInstancesErrorSequence(errs ...error) testMockEC2Option {
|
||||
return func(m *testMockEC2) error {
|
||||
m.DescribeInstancesErrorSequence = errs
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newTestMockEC2(state *testMockEC2State, opts ...testMockEC2Option) ec2APIFunc {
|
||||
return func(cfgs ...aws.Config) (EC2API, error) {
|
||||
m := &testMockEC2{
|
||||
@@ -96,6 +110,12 @@ func (m *testMockEC2) DescribeInstances(ctx context.Context, input *ec2.Describe
|
||||
m.State.DescribeInstancesInputs = append(m.State.DescribeInstancesInputs, input)
|
||||
}
|
||||
|
||||
if len(m.DescribeInstancesErrorSequence) > 0 {
|
||||
err := m.DescribeInstancesErrorSequence[0]
|
||||
m.DescribeInstancesErrorSequence = m.DescribeInstancesErrorSequence[1:]
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if m.DescribeInstancesError != nil {
|
||||
return nil, m.DescribeInstancesError
|
||||
}
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
// 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
+370
-173
@@ -3,24 +3,21 @@
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
const (
|
||||
envTargetAccessKeyId = "TARGET_AWS_ACCESS_KEY_ID"
|
||||
envTargetSecretAccessKey = "TARGET_AWS_SECRET_ACCESS_KEY"
|
||||
envTargetSessionToken = "TARGET_AWS_SESSION_TOKEN"
|
||||
envTargetRegion = "TARGET_AWS_REGION"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
const expectedEc2InstanceCount = 5
|
||||
@@ -38,29 +35,7 @@ func TestHostPlugin(t *testing.T) {
|
||||
}
|
||||
|
||||
require := require.New(t)
|
||||
|
||||
// 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)
|
||||
tf, err := NewTestTerraformer("testdata/host")
|
||||
require.NoError(err)
|
||||
require.NotNil(tf)
|
||||
|
||||
@@ -162,9 +137,16 @@ 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 := buildExpectedTagInstancesMap(ec2InstanceTags, expectedTags)
|
||||
|
||||
requireTagInstancesMatchProvisioned(t, ec2InstanceIds, expectedTagInstancesMap)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cases := [][]string{
|
||||
{expectedTags[0]},
|
||||
@@ -186,162 +168,377 @@ 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)
|
||||
}
|
||||
|
||||
// ********************
|
||||
// * AssumeRole - Happy Path & Error Cases
|
||||
// ********************
|
||||
assumeRoleArn, err := tf.GetOutputString("assume_role_arn")
|
||||
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)
|
||||
|
||||
noTrustRoleArn, err := tf.GetOutputString("assume_role_no_trust_arn")
|
||||
reqSecrets, err := structpb.NewStruct(map[string]any{
|
||||
credential.ConstAccessKeyId: accessKeyId,
|
||||
credential.ConstSecretAccessKey: secretAccessKey,
|
||||
})
|
||||
require.NoError(err)
|
||||
|
||||
missingPermRoleArn, err := tf.GetOutputString("assume_role_no_ec2_permission_arn")
|
||||
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)
|
||||
}
|
||||
|
||||
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"},
|
||||
})
|
||||
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"},
|
||||
})
|
||||
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"},
|
||||
})
|
||||
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"},
|
||||
})
|
||||
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"},
|
||||
})
|
||||
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)
|
||||
|
||||
// ********************
|
||||
// * 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")
|
||||
// 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)
|
||||
|
||||
crossRolePrincipalNoTrustArn, err := tf.GetOutputString("cross_role_principal_no_trust_arn")
|
||||
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)
|
||||
}
|
||||
|
||||
crossRoleTargetNoTrustArn, err := tf.GetOutputString("cross_role_target_no_trust_arn")
|
||||
return (time.Time{}).Format(time.RFC3339Nano)
|
||||
}(),
|
||||
})
|
||||
require.NoError(err)
|
||||
|
||||
crossRoleTargetNoEc2PermissionArn, err := tf.GetOutputString("cross_role_target_no_ec2_permission_arn")
|
||||
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)
|
||||
}
|
||||
|
||||
// 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"},
|
||||
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,
|
||||
})
|
||||
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)
|
||||
reqSecrets, err := structpb.NewStruct(map[string]any{
|
||||
credential.ConstAccessKeyId: accessKeyId,
|
||||
credential.ConstSecretAccessKey: secretAccessKey,
|
||||
})
|
||||
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)
|
||||
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)
|
||||
}(),
|
||||
})
|
||||
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")
|
||||
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 {
|
||||
crossAccountPrincipalArn, err := tf.GetOutputString("cross_account_principal_arn")
|
||||
require.NoError(err)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
crossAccountPrincipalNoTrustArn, err := tf.GetOutputString("cross_account_principal_no_trust_arn")
|
||||
require.NoError(err)
|
||||
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)
|
||||
|
||||
crossAccountTargetArn, err := tf.GetOutputString("cross_account_target_arn")
|
||||
require.NoError(err)
|
||||
// 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)
|
||||
}
|
||||
|
||||
crossAccountTargetNoTrustArn, err := tf.GetOutputString("cross_account_target_no_trust_arn")
|
||||
require.NoError(err)
|
||||
|
||||
crossAccountTargetNoEc2PermissionArn, err := tf.GetOutputString("cross_account_target_no_ec2_permission_arn")
|
||||
require.NoError(err)
|
||||
|
||||
targetEc2InstanceIds, err := tf.GetOutputSlice("target_instance_ids")
|
||||
require.NoError(err)
|
||||
require.Len(targetEc2InstanceIds, expectedEc2InstanceCount)
|
||||
|
||||
targetEc2InstanceTags, err := tf.GetOutputMap("target_instance_tags")
|
||||
require.NoError(err)
|
||||
expectedTargetTagInstancesMap := buildExpectedTagInstancesMap(targetEc2InstanceTags, expectedTags)
|
||||
|
||||
requireTagInstancesMatchProvisioned(t, targetEc2InstanceIds, expectedTargetTagInstancesMap)
|
||||
|
||||
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"},
|
||||
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)},
|
||||
})
|
||||
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"},
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
-206
@@ -1,206 +0,0 @@
|
||||
# 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
@@ -1,322 +0,0 @@
|
||||
# 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
|
||||
}
|
||||
}
|
||||
+7
-29
@@ -39,10 +39,6 @@ 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
|
||||
@@ -57,9 +53,8 @@ type TestTerraformer struct {
|
||||
|
||||
// NewTestTerraformer initializes a Terraform directory. It just
|
||||
// returns an initialized project, it does not perform any actions on
|
||||
// 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) {
|
||||
// it.
|
||||
func NewTestTerraformer(workDir string) (*TestTerraformer, error) {
|
||||
tfPath, err := exec.LookPath("terraform")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LookPath error: %w", err)
|
||||
@@ -69,24 +64,10 @@ func NewTestTerraformer(workDir string, vars ...map[string]string) (*TestTerrafo
|
||||
return nil, errors.New("workDir is empty")
|
||||
}
|
||||
|
||||
tf := &TestTerraformer{
|
||||
return &TestTerraformer{
|
||||
tfPath: tfPath,
|
||||
workDir: workDir,
|
||||
}
|
||||
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
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Deploy runs the following commands, in order: init, apply, plan,
|
||||
@@ -233,8 +214,7 @@ func (t *TestTerraformer) init() error {
|
||||
|
||||
// apply runs "terraform apply" in the project's working directory.
|
||||
func (t *TestTerraformer) apply() error {
|
||||
args := append([]string{"apply", "-input=false", "-auto-approve"}, t.varArgs()...)
|
||||
cmd := exec.Command(t.tfPath, args...)
|
||||
cmd := exec.Command(t.tfPath, "apply", "-input=false", "-auto-approve")
|
||||
cmd.Dir = t.workDir
|
||||
stdoutStderr, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -246,8 +226,7 @@ func (t *TestTerraformer) apply() error {
|
||||
|
||||
// destroy runs "terraform destroy" in the project's working directory.
|
||||
func (t *TestTerraformer) destroy() error {
|
||||
args := append([]string{"destroy", "-input=false", "-auto-approve"}, t.varArgs()...)
|
||||
cmd := exec.Command(t.tfPath, args...)
|
||||
cmd := exec.Command(t.tfPath, "destroy", "-input=false", "-auto-approve")
|
||||
cmd.Dir = t.workDir
|
||||
stdoutStderr, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -263,8 +242,7 @@ 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 {
|
||||
args := append([]string{"plan", "-input=false", "-out=plan.tfplan"}, t.varArgs()...)
|
||||
cmd := exec.Command(t.tfPath, args...)
|
||||
cmd := exec.Command(t.tfPath, "plan", "-input=false", "-out=plan.tfplan")
|
||||
cmd.Dir = t.workDir
|
||||
stdoutStderr, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user