Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f81151b58c |
No files matched your search
@@ -23,6 +23,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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
|
// awsErrorAccessDenied is returned when the credentials do not have
|
||||||
// the required permissions to do a specific action on a resource.
|
// the required permissions to do a specific action on a resource.
|
||||||
// This error will persist until the IAM Role Policy is updated by
|
// This error will persist until the IAM Role Policy is updated by
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
goerrors "errors"
|
goerrors "errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
|
"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
|
||||||
@@ -828,10 +829,18 @@ func ec2ClientForTarget(
|
|||||||
return ec2.NewFromConfig(targetCfg, ec2Opts...), nil
|
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
|
// dryRunValidation performs an AWS DescribeInstances call to verify the state's
|
||||||
// credentials, the host listing functionality as well as the filters, if any
|
// 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
|
// are passed in. This function can therefore be used for both host catalog and
|
||||||
// host set validation.
|
// 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 {
|
func dryRunValidation(ctx context.Context, state *awsCatalogPersistedState, ec2Opts []ec2Option, filters ...types.Filter) *status.Status {
|
||||||
if state == nil {
|
if state == nil {
|
||||||
return status.New(codes.InvalidArgument, "persisted state is required")
|
return status.New(codes.InvalidArgument, "persisted state is required")
|
||||||
@@ -842,12 +851,34 @@ func dryRunValidation(ctx context.Context, state *awsCatalogPersistedState, ec2O
|
|||||||
return status.New(codes.InvalidArgument, fmt.Sprintf("error getting EC2 client: %s", err))
|
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})
|
_, err = ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{Filters: filters})
|
||||||
if err != nil {
|
if err == nil {
|
||||||
return status.New(codes.FailedPrecondition, fmt.Sprintf("aws describe instances failed: %s", err))
|
return 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
|
// appendDistinct will append the elements to the slice
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
|
iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
||||||
|
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/hostcatalogs"
|
||||||
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets"
|
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets"
|
||||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||||
@@ -3121,4 +3122,66 @@ func TestDryRunValidation(t *testing.T) {
|
|||||||
}, []ec2Option{})
|
}, []ec2Option{})
|
||||||
require.Nil(t, st)
|
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
|
DescribeInstancesOutput *ec2.DescribeInstancesOutput
|
||||||
DescribeInstancesError error
|
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
|
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 {
|
func newTestMockEC2(state *testMockEC2State, opts ...testMockEC2Option) ec2APIFunc {
|
||||||
return func(cfgs ...aws.Config) (EC2API, error) {
|
return func(cfgs ...aws.Config) (EC2API, error) {
|
||||||
m := &testMockEC2{
|
m := &testMockEC2{
|
||||||
@@ -96,6 +110,12 @@ func (m *testMockEC2) DescribeInstances(ctx context.Context, input *ec2.Describe
|
|||||||
m.State.DescribeInstancesInputs = append(m.State.DescribeInstancesInputs, input)
|
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 {
|
if m.DescribeInstancesError != nil {
|
||||||
return nil, m.DescribeInstancesError
|
return nil, m.DescribeInstancesError
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user