Skip to content

Commit b83bab7

Browse files
committed
fix: surface EC2 request rejections on the EC2NodeClass validation condition
1 parent 3deed39 commit b83bab7

4 files changed

Lines changed: 301 additions & 21 deletions

File tree

pkg/controllers/nodeclass/validation.go

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -51,20 +51,35 @@ import (
5151
)
5252

5353
const (
54-
requeueAfterTime = 10 * time.Minute
55-
ConditionReasonCreateFleetAuthFailed = "CreateFleetAuthCheckFailed"
56-
ConditionReasonCreateLaunchTemplateAuthFailed = "CreateLaunchTemplateAuthCheckFailed"
57-
ConditionReasonRunInstancesAuthFailed = "RunInstancesAuthCheckFailed"
58-
ConditionReasonInstanceProfileNotFound = "InstanceProfileNotFound"
59-
ConditionReasonDependenciesNotReady = "DependenciesNotReady"
60-
ConditionReasonTagValidationFailed = "TagValidationFailed"
61-
ConditionReasonDryRunDisabled = "DryRunDisabled"
54+
requeueAfterTime = 10 * time.Minute
55+
ConditionReasonCreateFleetAuthFailed = "CreateFleetAuthCheckFailed"
56+
ConditionReasonCreateLaunchTemplateAuthFailed = "CreateLaunchTemplateAuthCheckFailed"
57+
ConditionReasonRunInstancesAuthFailed = "RunInstancesAuthCheckFailed"
58+
ConditionReasonCreateFleetValidationFailed = "CreateFleetValidationFailed"
59+
ConditionReasonCreateLaunchTemplateValidationFailed = "CreateLaunchTemplateValidationFailed"
60+
ConditionReasonRunInstancesValidationFailed = "RunInstancesValidationFailed"
61+
ConditionReasonInstanceProfileNotFound = "InstanceProfileNotFound"
62+
ConditionReasonDependenciesNotReady = "DependenciesNotReady"
63+
ConditionReasonTagValidationFailed = "TagValidationFailed"
64+
ConditionReasonDryRunDisabled = "DryRunDisabled"
6265
)
6366

6467
var ValidationConditionMessages = map[string]string{
65-
ConditionReasonCreateFleetAuthFailed: "Controller isn't authorized to call ec2:CreateFleet",
66-
ConditionReasonCreateLaunchTemplateAuthFailed: "Controller isn't authorized to call ec2:CreateLaunchTemplate",
67-
ConditionReasonRunInstancesAuthFailed: "Controller isn't authorized to call ec2:RunInstances",
68+
ConditionReasonCreateFleetAuthFailed: "Controller isn't authorized to call ec2:CreateFleet",
69+
ConditionReasonCreateLaunchTemplateAuthFailed: "Controller isn't authorized to call ec2:CreateLaunchTemplate",
70+
ConditionReasonRunInstancesAuthFailed: "Controller isn't authorized to call ec2:RunInstances",
71+
ConditionReasonCreateFleetValidationFailed: "EC2 rejected the ec2:CreateFleet dry run",
72+
ConditionReasonCreateLaunchTemplateValidationFailed: "EC2 rejected the ec2:CreateLaunchTemplate request",
73+
ConditionReasonRunInstancesValidationFailed: "EC2 rejected the ec2:RunInstances dry run",
74+
}
75+
76+
// isTransientError returns true for errors that a retry can resolve without a change to the
77+
// EC2NodeClass, so validation should requeue rather than record a failure against the spec.
78+
func isTransientError(err error) bool {
79+
return awserrors.IsRateLimitedError(err) ||
80+
awserrors.IsServerError(err) ||
81+
awserrors.IsNonTerminalError(err) ||
82+
awserrors.IsInstanceProfileNotFound(err)
6883
}
6984

7085
// validationCacheEntry stores a failed validation result with both the condition reason and the
@@ -239,11 +254,17 @@ func (v *Validation) validateCreateLaunchTemplateAuthorization(
239254

240255
launchTemplates, err := v.launchTemplateProvider.EnsureAll(ctx, nodeClass, nodeClaim, instanceTypes[:1], karpv1.CapacityTypeOnDemand, tags, string(tenancyType))
241256
if err != nil {
242-
if awserrors.IsRateLimitedError(err) || awserrors.IsServerError(err) {
257+
if isTransientError(err) {
243258
return nil, reconcile.Result{Requeue: true}, nil
244259
}
245260
if awserrors.IgnoreUnauthorizedOperationError(err) != nil {
246-
// We should only ever receive UnauthorizedOperation so if we receive any other error it would be an unexpected state
261+
// EC2 also rejects requests it considers malformed, which retrying can't resolve until the
262+
// EC2NodeClass itself changes. Surface those on the status condition rather than returning an
263+
// error that is only ever logged, and logged as if it were an authorization failure.
264+
if message, ok := awserrors.ToAPIErrorMessage(err); ok {
265+
v.updateCacheOnFailure(nodeClass, tags, ConditionReasonCreateLaunchTemplateValidationFailed, message)
266+
return nil, reconcile.Result{RequeueAfter: requeueAfterTime}, nil
267+
}
247268
return nil, reconcile.Result{}, fmt.Errorf("validating ec2:CreateLaunchTemplate authorization, %w", err)
248269
}
249270
log.FromContext(ctx).Error(err, "unauthorized to call ec2:CreateLaunchTemplate")
@@ -271,12 +292,17 @@ func (v *Validation) validateCreateFleetAuthorization(
271292
if _, err := v.ec2api.CreateFleet(ctx, createFleetInput, func(o *ec2.Options) {
272293
o.Retryer = aws.NopRetryer{}
273294
}); awserrors.IgnoreDryRunError(err) != nil {
274-
if awserrors.IsRateLimitedError(err) || awserrors.IsServerError(err) {
295+
if isTransientError(err) {
275296
return reconcile.Result{Requeue: true}, nil
276297
}
277298
if awserrors.IgnoreUnauthorizedOperationError(err) != nil {
278-
// Dry run should only ever return UnauthorizedOperation or DryRunOperation so if we receive any other error
279-
// it would be an unexpected state
299+
// A dry run also fails when EC2 considers the request itself invalid, which retrying can't
300+
// resolve until the EC2NodeClass changes. Surface those on the status condition rather than
301+
// returning an error that is only ever logged, and logged as if it were an authorization failure.
302+
if message, ok := awserrors.ToAPIErrorMessage(err); ok {
303+
v.updateCacheOnFailure(nodeClass, tags, ConditionReasonCreateFleetValidationFailed, message)
304+
return reconcile.Result{RequeueAfter: requeueAfterTime}, nil
305+
}
280306
return reconcile.Result{}, fmt.Errorf("validating ec2:CreateFleet authorization, %w", err)
281307
}
282308
log.FromContext(ctx).Error(err, "unauthorized to call ec2:CreateFleet")
@@ -326,12 +352,18 @@ func (v *Validation) validateRunInstancesAuthorization(
326352
// this means there is most likely an eventual consistency issue and we just need to requeue
327353
return reconcile.Result{Requeue: true}, nil
328354
}
329-
if awserrors.IsRateLimitedError(firstSubnetErr) || awserrors.IsServerError(firstSubnetErr) {
355+
if isTransientError(firstSubnetErr) {
330356
return reconcile.Result{Requeue: true}, nil
331357
}
332358
if awserrors.IgnoreUnauthorizedOperationError(firstSubnetErr) != nil {
333-
// Dry run should only ever return UnauthorizedOperation or DryRunOperation so if we receive any other error
334-
// it would be an unexpected state
359+
// A dry run also fails when EC2 considers the request itself invalid, e.g. a block device mapping
360+
// whose volume is smaller than the AMI's snapshot. Retrying can't resolve that until the
361+
// EC2NodeClass changes, so surface it on the status condition rather than returning an error that
362+
// is only ever logged, and logged as if it were an authorization failure.
363+
if message, ok := awserrors.ToAPIErrorMessage(firstSubnetErr); ok {
364+
v.updateCacheOnFailure(nodeClass, tags, ConditionReasonRunInstancesValidationFailed, message)
365+
return reconcile.Result{RequeueAfter: requeueAfterTime}, nil
366+
}
335367
return reconcile.Result{}, fmt.Errorf("validating ec2:RunInstances authorization, %w", firstSubnetErr)
336368
}
337369
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
@@ -16,6 +16,7 @@ package nodeclass_test
1616

1717
import (
1818
"fmt"
19+
"net/http"
1920
"time"
2021

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

2627
"github.com/aws/aws-sdk-go-v2/aws"
28+
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
2729
"github.com/aws/aws-sdk-go-v2/service/ec2"
2830
ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
2931
"github.com/aws/aws-sdk-go-v2/service/eks"
3032
ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types"
3133
"github.com/aws/smithy-go"
34+
smithyhttp "github.com/aws/smithy-go/transport/http"
3235
corev1 "k8s.io/api/core/v1"
3336
"k8s.io/client-go/tools/record"
3437
karpv1 "sigs.k8s.io/karpenter/pkg/apis/v1"
@@ -289,7 +292,65 @@ var _ = Describe("NodeClass Validation Status Controller", func() {
289292
}, fake.MaxCalls(4))
290293
}, nodeclass.ConditionReasonRunInstancesAuthFailed,
291294
"Controller isn't authorized to call ec2:RunInstances: User is not authorized to perform this operation due to a service control policy"),
295+
Entry("should update status condition as NotReady when RunInstances rejects the request", func() {
296+
awsEnv.EC2API.RunInstancesBehavior.Error.Set(&smithy.GenericAPIError{
297+
Code: "InvalidBlockDeviceMapping",
298+
Message: "Volume of size 2GB is smaller than snapshot 'snap-0123456789abcdef0', expect size>= 20GB",
299+
}, fake.MaxCalls(4))
300+
}, nodeclass.ConditionReasonRunInstancesValidationFailed,
301+
"EC2 rejected the ec2:RunInstances dry run: InvalidBlockDeviceMapping: Volume of size 2GB is smaller than snapshot 'snap-0123456789abcdef0', expect size>= 20GB"),
302+
Entry("should update status condition as NotReady when CreateFleet rejects the request", func() {
303+
awsEnv.EC2API.CreateFleetBehavior.Error.Set(&smithy.GenericAPIError{
304+
Code: "InvalidParameterCombination",
305+
Message: "The parameter groupName cannot be used with the parameter subnet",
306+
}, fake.MaxCalls(1))
307+
}, nodeclass.ConditionReasonCreateFleetValidationFailed,
308+
"EC2 rejected the ec2:CreateFleet dry run: InvalidParameterCombination: The parameter groupName cannot be used with the parameter subnet"),
309+
Entry("should update status condition as NotReady when CreateLaunchTemplate rejects the request", func() {
310+
awsEnv.EC2API.CreateLaunchTemplateBehavior.Error.Set(&smithy.GenericAPIError{
311+
Code: "InvalidParameterValue",
312+
Message: "Invalid value 'not-a-device' for BlockDeviceMapping.DeviceName",
313+
}, fake.MaxCalls(1))
314+
}, nodeclass.ConditionReasonCreateLaunchTemplateValidationFailed,
315+
"EC2 rejected the ec2:CreateLaunchTemplate request: InvalidParameterValue: Invalid value 'not-a-device' for BlockDeviceMapping.DeviceName"),
292316
)
317+
It("should requeue without failing validation when RunInstances returns a server error", func() {
318+
// EC2's deserializers don't set a fault on the errors they return, so a server error is only
319+
// recognizable by the status code of the response it was decoded from.
320+
awsEnv.EC2API.RunInstancesBehavior.Error.Set(&awshttp.ResponseError{
321+
ResponseError: &smithyhttp.ResponseError{
322+
Response: &smithyhttp.Response{Response: &http.Response{StatusCode: 503}},
323+
Err: &smithy.GenericAPIError{Code: "Unavailable", Message: "The service is unavailable. Please try again shortly."},
324+
},
325+
}, fake.MaxCalls(4))
326+
ExpectApplied(ctx, env.Client, nodeClass)
327+
ExpectObjectReconciled(ctx, env.Client, controller, nodeClass)
328+
nodeClass = ExpectExists(ctx, env.Client, nodeClass)
329+
// Requeued rather than recorded: neither the success nor the failure path leaves the
330+
// condition unknown with nothing cached.
331+
Expect(nodeClass.StatusConditions().Get(v1.ConditionTypeValidationSucceeded).IsUnknown()).To(BeTrue())
332+
Expect(awsEnv.ValidationCache.Items()).To(BeEmpty())
333+
})
334+
It("should requeue without failing validation when RunInstances is throttled", func() {
335+
awsEnv.EC2API.RunInstancesBehavior.Error.Set(&smithy.GenericAPIError{
336+
Code: "EC2ThrottledException",
337+
}, fake.MaxCalls(4))
338+
ExpectApplied(ctx, env.Client, nodeClass)
339+
ExpectObjectReconciled(ctx, env.Client, controller, nodeClass)
340+
nodeClass = ExpectExists(ctx, env.Client, nodeClass)
341+
// Requeued rather than recorded: neither the success nor the failure path leaves the
342+
// condition unknown with nothing cached.
343+
Expect(nodeClass.StatusConditions().Get(v1.ConditionTypeValidationSucceeded).IsUnknown()).To(BeTrue())
344+
Expect(awsEnv.ValidationCache.Items()).To(BeEmpty())
345+
})
346+
It("should return the error when RunInstances fails with a non-AWS error", func() {
347+
awsEnv.EC2API.RunInstancesBehavior.Error.Set(fmt.Errorf("connection reset by peer"), fake.MaxCalls(4))
348+
ExpectApplied(ctx, env.Client, nodeClass)
349+
_ = ExpectObjectReconcileFailed(ctx, env.Client, controller, nodeClass)
350+
nodeClass = ExpectExists(ctx, env.Client, nodeClass)
351+
Expect(nodeClass.StatusConditions().Get(v1.ConditionTypeValidationSucceeded).IsFalse()).To(BeFalse())
352+
Expect(awsEnv.ValidationCache.Items()).To(BeEmpty())
353+
})
293354
It("should succeed RunInstances validation when first subnet returns 500 but another subnet succeeds", func() {
294355
// Fail the first RunInstances call (first subnet) with a server error,
295356
// 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)