Skip to content

Commit ed1e3ae

Browse files
committed
fix: surface EC2 request rejections on the EC2NodeClass validation condition
1 parent 6e13b3e commit ed1e3ae

4 files changed

Lines changed: 304 additions & 24 deletions

File tree

pkg/controllers/nodeclass/validation.go

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -56,23 +56,38 @@ import (
5656
)
5757

5858
const (
59-
requeueAfterTime = 10 * time.Minute
60-
ConditionReasonCreateFleetAuthFailed = "CreateFleetAuthCheckFailed"
61-
ConditionReasonCreateLaunchTemplateAuthFailed = "CreateLaunchTemplateAuthCheckFailed"
62-
ConditionReasonRunInstancesAuthFailed = "RunInstancesAuthCheckFailed"
63-
ConditionReasonInstanceProfileNotFound = "InstanceProfileNotFound"
64-
ConditionReasonDependenciesNotReady = "DependenciesNotReady"
65-
ConditionReasonTagValidationFailed = "TagValidationFailed"
66-
ConditionReasonKubeletExpressionInvalid = "KubeletExpressionInvalid"
67-
ConditionReasonKubeletExpressionEvalFailed = "KubeletExpressionEvaluationFailed"
68-
ConditionReasonKubeletExpressionsDisabled = "KubeletExpressionsDisabled"
69-
ConditionReasonDryRunDisabled = "DryRunDisabled"
59+
requeueAfterTime = 10 * time.Minute
60+
ConditionReasonCreateFleetAuthFailed = "CreateFleetAuthCheckFailed"
61+
ConditionReasonCreateLaunchTemplateAuthFailed = "CreateLaunchTemplateAuthCheckFailed"
62+
ConditionReasonRunInstancesAuthFailed = "RunInstancesAuthCheckFailed"
63+
ConditionReasonCreateFleetValidationFailed = "CreateFleetValidationFailed"
64+
ConditionReasonCreateLaunchTemplateValidationFailed = "CreateLaunchTemplateValidationFailed"
65+
ConditionReasonRunInstancesValidationFailed = "RunInstancesValidationFailed"
66+
ConditionReasonInstanceProfileNotFound = "InstanceProfileNotFound"
67+
ConditionReasonDependenciesNotReady = "DependenciesNotReady"
68+
ConditionReasonTagValidationFailed = "TagValidationFailed"
69+
ConditionReasonKubeletExpressionInvalid = "KubeletExpressionInvalid"
70+
ConditionReasonKubeletExpressionEvalFailed = "KubeletExpressionEvaluationFailed"
71+
ConditionReasonKubeletExpressionsDisabled = "KubeletExpressionsDisabled"
72+
ConditionReasonDryRunDisabled = "DryRunDisabled"
7073
)
7174

7275
var ValidationConditionMessages = map[string]string{
73-
ConditionReasonCreateFleetAuthFailed: "Controller isn't authorized to call ec2:CreateFleet",
74-
ConditionReasonCreateLaunchTemplateAuthFailed: "Controller isn't authorized to call ec2:CreateLaunchTemplate",
75-
ConditionReasonRunInstancesAuthFailed: "Controller isn't authorized to call ec2:RunInstances",
76+
ConditionReasonCreateFleetAuthFailed: "Controller isn't authorized to call ec2:CreateFleet",
77+
ConditionReasonCreateLaunchTemplateAuthFailed: "Controller isn't authorized to call ec2:CreateLaunchTemplate",
78+
ConditionReasonRunInstancesAuthFailed: "Controller isn't authorized to call ec2:RunInstances",
79+
ConditionReasonCreateFleetValidationFailed: "EC2 rejected the ec2:CreateFleet dry run",
80+
ConditionReasonCreateLaunchTemplateValidationFailed: "EC2 rejected the ec2:CreateLaunchTemplate request",
81+
ConditionReasonRunInstancesValidationFailed: "EC2 rejected the ec2:RunInstances dry run",
82+
}
83+
84+
// isTransientError returns true for errors that a retry can resolve without a change to the
85+
// EC2NodeClass, so validation should requeue rather than record a failure against the spec.
86+
func isTransientError(err error) bool {
87+
return awserrors.IsRateLimitedError(err) ||
88+
awserrors.IsServerError(err) ||
89+
awserrors.IsNonTerminalError(err) ||
90+
awserrors.IsInstanceProfileNotFound(err)
7691
}
7792

7893
// validationCacheEntry stores a failed validation result with both the condition reason and the
@@ -289,11 +304,17 @@ func (v *Validation) validateCreateLaunchTemplateAuthorization(
289304

290305
launchTemplates, err := v.launchTemplateProvider.EnsureAll(ctx, nodeClass, nodeClaim, instanceTypes[:1], karpv1.CapacityTypeOnDemand, tags, string(tenancyType))
291306
if err != nil {
292-
if awserrors.IsRateLimitedError(err) || awserrors.IsServerError(err) {
307+
if isTransientError(err) {
293308
return nil, reconcile.Result{Requeue: true}, nil
294309
}
295310
if awserrors.IgnoreUnauthorizedOperationError(err) != nil {
296-
// We should only ever receive UnauthorizedOperation so if we receive any other error it would be an unexpected state
311+
// EC2 also rejects requests it considers malformed, which retrying can't resolve until the
312+
// EC2NodeClass itself changes. Surface those on the status condition rather than returning an
313+
// error that is only ever logged, and logged as if it were an authorization failure.
314+
if message, ok := awserrors.ToAPIErrorMessage(err); ok {
315+
v.updateCacheOnFailure(nodeClass, tags, ConditionReasonCreateLaunchTemplateValidationFailed, message)
316+
return nil, reconcile.Result{RequeueAfter: requeueAfterTime}, nil
317+
}
297318
return nil, reconcile.Result{}, fmt.Errorf("validating ec2:CreateLaunchTemplate authorization, %w", err)
298319
}
299320
log.FromContext(ctx).Error(err, "unauthorized to call ec2:CreateLaunchTemplate")
@@ -321,12 +342,17 @@ func (v *Validation) validateCreateFleetAuthorization(
321342
if _, err := v.ec2api.CreateFleet(ctx, createFleetInput, func(o *ec2.Options) {
322343
o.Retryer = aws.NopRetryer{}
323344
}); awserrors.IgnoreDryRunError(err) != nil {
324-
if awserrors.IsRateLimitedError(err) || awserrors.IsServerError(err) {
345+
if isTransientError(err) {
325346
return reconcile.Result{Requeue: true}, nil
326347
}
327348
if awserrors.IgnoreUnauthorizedOperationError(err) != nil {
328-
// Dry run should only ever return UnauthorizedOperation or DryRunOperation so if we receive any other error
329-
// it would be an unexpected state
349+
// A dry run also fails when EC2 considers the request itself invalid, which retrying can't
350+
// resolve until the EC2NodeClass changes. Surface those on the status condition rather than
351+
// returning an error that is only ever logged, and logged as if it were an authorization failure.
352+
if message, ok := awserrors.ToAPIErrorMessage(err); ok {
353+
v.updateCacheOnFailure(nodeClass, tags, ConditionReasonCreateFleetValidationFailed, message)
354+
return reconcile.Result{RequeueAfter: requeueAfterTime}, nil
355+
}
330356
return reconcile.Result{}, fmt.Errorf("validating ec2:CreateFleet authorization, %w", err)
331357
}
332358
log.FromContext(ctx).Error(err, "unauthorized to call ec2:CreateFleet")
@@ -376,12 +402,18 @@ func (v *Validation) validateRunInstancesAuthorization(
376402
// this means there is most likely an eventual consistency issue and we just need to requeue
377403
return reconcile.Result{Requeue: true}, nil
378404
}
379-
if awserrors.IsRateLimitedError(firstSubnetErr) || awserrors.IsServerError(firstSubnetErr) {
405+
if isTransientError(firstSubnetErr) {
380406
return reconcile.Result{Requeue: true}, nil
381407
}
382408
if awserrors.IgnoreUnauthorizedOperationError(firstSubnetErr) != nil {
383-
// Dry run should only ever return UnauthorizedOperation or DryRunOperation so if we receive any other error
384-
// it would be an unexpected state
409+
// A dry run also fails when EC2 considers the request itself invalid, e.g. a block device mapping
410+
// whose volume is smaller than the AMI's snapshot. Retrying can't resolve that until the
411+
// EC2NodeClass changes, so surface it on the status condition rather than returning an error that
412+
// is only ever logged, and logged as if it were an authorization failure.
413+
if message, ok := awserrors.ToAPIErrorMessage(firstSubnetErr); ok {
414+
v.updateCacheOnFailure(nodeClass, tags, ConditionReasonRunInstancesValidationFailed, message)
415+
return reconcile.Result{RequeueAfter: requeueAfterTime}, nil
416+
}
385417
return reconcile.Result{}, fmt.Errorf("validating ec2:RunInstances authorization, %w", firstSubnetErr)
386418
}
387419
log.FromContext(ctx).Error(firstSubnetErr, "unauthorized to call ec2:RunInstances")

pkg/controllers/nodeclass/validation_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package nodeclass_test
1717
import (
1818
"errors"
1919
"fmt"
20+
"net/http"
2021
"time"
2122

2223
"github.com/awslabs/operatorpkg/object"
@@ -25,11 +26,13 @@ import (
2526
"k8s.io/apimachinery/pkg/util/version"
2627

2728
"github.com/aws/aws-sdk-go-v2/aws"
29+
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
2830
"github.com/aws/aws-sdk-go-v2/service/ec2"
2931
ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
3032
"github.com/aws/aws-sdk-go-v2/service/eks"
3133
ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types"
3234
"github.com/aws/smithy-go"
35+
smithyhttp "github.com/aws/smithy-go/transport/http"
3336
corev1 "k8s.io/api/core/v1"
3437
"k8s.io/apimachinery/pkg/util/intstr"
3538
"k8s.io/client-go/tools/record"
@@ -441,7 +444,65 @@ var _ = Describe("NodeClass Validation Status Controller", func() {
441444
}, fake.MaxCalls(4))
442445
}, nodeclass.ConditionReasonRunInstancesAuthFailed,
443446
"Controller isn't authorized to call ec2:RunInstances: User is not authorized to perform this operation due to a service control policy"),
447+
Entry("should update status condition as NotReady when RunInstances rejects the request", func() {
448+
awsEnv.EC2API.RunInstancesBehavior.Error.Set(&smithy.GenericAPIError{
449+
Code: "InvalidBlockDeviceMapping",
450+
Message: "Volume of size 2GB is smaller than snapshot 'snap-0123456789abcdef0', expect size>= 20GB",
451+
}, fake.MaxCalls(4))
452+
}, nodeclass.ConditionReasonRunInstancesValidationFailed,
453+
"EC2 rejected the ec2:RunInstances dry run: InvalidBlockDeviceMapping: Volume of size 2GB is smaller than snapshot 'snap-0123456789abcdef0', expect size>= 20GB"),
454+
Entry("should update status condition as NotReady when CreateFleet rejects the request", func() {
455+
awsEnv.EC2API.CreateFleetBehavior.Error.Set(&smithy.GenericAPIError{
456+
Code: "InvalidParameterCombination",
457+
Message: "The parameter groupName cannot be used with the parameter subnet",
458+
}, fake.MaxCalls(1))
459+
}, nodeclass.ConditionReasonCreateFleetValidationFailed,
460+
"EC2 rejected the ec2:CreateFleet dry run: InvalidParameterCombination: The parameter groupName cannot be used with the parameter subnet"),
461+
Entry("should update status condition as NotReady when CreateLaunchTemplate rejects the request", func() {
462+
awsEnv.EC2API.CreateLaunchTemplateBehavior.Error.Set(&smithy.GenericAPIError{
463+
Code: "InvalidParameterValue",
464+
Message: "Invalid value 'not-a-device' for BlockDeviceMapping.DeviceName",
465+
}, fake.MaxCalls(1))
466+
}, nodeclass.ConditionReasonCreateLaunchTemplateValidationFailed,
467+
"EC2 rejected the ec2:CreateLaunchTemplate request: InvalidParameterValue: Invalid value 'not-a-device' for BlockDeviceMapping.DeviceName"),
444468
)
469+
It("should requeue without failing validation when RunInstances returns a server error", func() {
470+
// EC2's deserializers don't set a fault on the errors they return, so a server error is only
471+
// recognizable by the status code of the response it was decoded from.
472+
awsEnv.EC2API.RunInstancesBehavior.Error.Set(&awshttp.ResponseError{
473+
ResponseError: &smithyhttp.ResponseError{
474+
Response: &smithyhttp.Response{Response: &http.Response{StatusCode: 503}},
475+
Err: &smithy.GenericAPIError{Code: "Unavailable", Message: "The service is unavailable. Please try again shortly."},
476+
},
477+
}, fake.MaxCalls(4))
478+
ExpectApplied(ctx, env.Client, nodeClass)
479+
ExpectObjectReconciled(ctx, env.Client, controller, nodeClass)
480+
nodeClass = ExpectExists(ctx, env.Client, nodeClass)
481+
// Requeued rather than recorded: neither the success nor the failure path leaves the
482+
// condition unknown with nothing cached.
483+
Expect(nodeClass.StatusConditions().Get(v1.ConditionTypeValidationSucceeded).IsUnknown()).To(BeTrue())
484+
Expect(awsEnv.ValidationCache.Items()).To(BeEmpty())
485+
})
486+
It("should requeue without failing validation when RunInstances is throttled", func() {
487+
awsEnv.EC2API.RunInstancesBehavior.Error.Set(&smithy.GenericAPIError{
488+
Code: "EC2ThrottledException",
489+
}, fake.MaxCalls(4))
490+
ExpectApplied(ctx, env.Client, nodeClass)
491+
ExpectObjectReconciled(ctx, env.Client, controller, nodeClass)
492+
nodeClass = ExpectExists(ctx, env.Client, nodeClass)
493+
// Requeued rather than recorded: neither the success nor the failure path leaves the
494+
// condition unknown with nothing cached.
495+
Expect(nodeClass.StatusConditions().Get(v1.ConditionTypeValidationSucceeded).IsUnknown()).To(BeTrue())
496+
Expect(awsEnv.ValidationCache.Items()).To(BeEmpty())
497+
})
498+
It("should return the error when RunInstances fails with a non-AWS error", func() {
499+
awsEnv.EC2API.RunInstancesBehavior.Error.Set(fmt.Errorf("connection reset by peer"), fake.MaxCalls(4))
500+
ExpectApplied(ctx, env.Client, nodeClass)
501+
_ = ExpectObjectReconcileFailed(ctx, env.Client, controller, nodeClass)
502+
nodeClass = ExpectExists(ctx, env.Client, nodeClass)
503+
Expect(nodeClass.StatusConditions().Get(v1.ConditionTypeValidationSucceeded).IsFalse()).To(BeFalse())
504+
Expect(awsEnv.ValidationCache.Items()).To(BeEmpty())
505+
})
445506
It("should succeed RunInstances validation when first subnet returns 500 but another subnet succeeds", func() {
446507
// Fail the first RunInstances call (first subnet) with a server error,
447508
// then let subsequent calls (remaining subnets) succeed via the default dry-run path

pkg/errors/errors.go

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,24 @@ limitations under the License.
1515
package errors
1616

1717
import (
18+
"fmt"
1819
"strings"
1920

21+
"github.com/aws/aws-sdk-go-v2/aws/retry"
2022
ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
2123
"github.com/aws/smithy-go"
2224
"github.com/samber/lo"
2325
"k8s.io/apimachinery/pkg/util/sets"
2426
)
2527

28+
// httpStatusCoder mirrors the interface the SDK's own retry policy matches on
29+
// (retry.RetryableHTTPStatusCode) — the transport error types the SDK wraps API errors in are the
30+
// only place the HTTP status code of the failed response survives.
31+
type httpStatusCoder interface {
32+
error
33+
HTTPStatusCode() int
34+
}
35+
2636
const (
2737
launchTemplateNameNotFoundCode = "InvalidLaunchTemplateName.NotFoundException"
2838
RunInstancesInvalidParameterValueCode = "InvalidParameterValue"
@@ -49,6 +59,18 @@ var (
4959
"EntityAlreadyExists",
5060
)
5161

62+
// nonTerminalErrorCodes are codes a request can fail with for reasons unrelated to the request
63+
// itself, on top of the retryable and throttle codes the SDK already defines. AuthFailure and
64+
// RequestExpired come from credential or clock skew, PendingVerification from account state, and
65+
// the launch template codes from a template being garbage collected out from under a caller.
66+
nonTerminalErrorCodes = sets.New(
67+
"AuthFailure",
68+
"RequestExpired",
69+
"PendingVerification",
70+
launchTemplateNameNotFoundCode,
71+
"InvalidLaunchTemplateId.NotFound",
72+
)
73+
5274
reservationCapacityExceededErrorCode = "ReservationCapacityExceeded"
5375

5476
// unfulfillableCapacityErrorCodes signify that capacity is temporarily unable to be launched
@@ -156,19 +178,62 @@ func IsServerError(err error) bool {
156178
if err == nil {
157179
return false
158180
}
159-
if apiErr, ok := lo.ErrorsAs[smithy.APIError](err); ok {
160-
return apiErr.ErrorFault() == smithy.FaultServer
181+
if apiErr, ok := lo.ErrorsAs[smithy.APIError](err); ok && apiErr.ErrorFault() == smithy.FaultServer {
182+
return true
183+
}
184+
// EC2's generated deserializers return a smithy.GenericAPIError with no fault set, so the fault
185+
// check above never matches an EC2 error. Fall back to the status code of the failed response,
186+
// using the same set the SDK's own retry policy considers retryable.
187+
if respErr, ok := lo.ErrorsAs[httpStatusCoder](err); ok {
188+
_, retryable := retry.DefaultRetryableHTTPStatusCodes[respErr.HTTPStatusCode()]
189+
return retryable
161190
}
162191
return false
163192
}
164193

194+
// IsNonTerminalError returns true if err is an AWS API error that a retry can resolve on its own,
195+
// without a change to the request that produced it. Callers that would otherwise treat an error as
196+
// a terminal, user-visible failure should requeue on these instead.
197+
func IsNonTerminalError(err error) bool {
198+
if err == nil {
199+
return false
200+
}
201+
apiErr, ok := lo.ErrorsAs[smithy.APIError](err)
202+
if !ok {
203+
return false
204+
}
205+
if _, ok := retry.DefaultRetryableErrorCodes[apiErr.ErrorCode()]; ok {
206+
return true
207+
}
208+
if _, ok := retry.DefaultThrottleErrorCodes[apiErr.ErrorCode()]; ok {
209+
return true
210+
}
211+
return nonTerminalErrorCodes.Has(apiErr.ErrorCode())
212+
}
213+
165214
func IgnoreServerError(err error) error {
166215
if IsServerError(err) {
167216
return nil
168217
}
169218
return err
170219
}
171220

221+
// ToAPIErrorMessage returns a "<code>: <message>" summary of err when err is, or wraps, an AWS API
222+
// error. The second return value reports whether err was an AWS API error at all.
223+
func ToAPIErrorMessage(err error) (string, bool) {
224+
if err == nil {
225+
return "", false
226+
}
227+
apiErr, ok := lo.ErrorsAs[smithy.APIError](err)
228+
if !ok {
229+
return "", false
230+
}
231+
if apiErr.ErrorMessage() == "" {
232+
return apiErr.ErrorCode(), true
233+
}
234+
return fmt.Sprintf("%s: %s", apiErr.ErrorCode(), apiErr.ErrorMessage()), true
235+
}
236+
172237
// IsUnfulfillableCapacity returns true if the Fleet err means capacity is temporarily unavailable for launching. This
173238
// could be due to account limits, insufficient ec2 capacity, etc.
174239
func IsUnfulfillableCapacity(err ec2types.CreateFleetError) bool {

0 commit comments

Comments
 (0)