Skip to content

Commit c7b4978

Browse files
committed
chore: classify controller errors as terminal vs non-terminal
1 parent f3b2911 commit c7b4978

7 files changed

Lines changed: 187 additions & 0 deletions

File tree

pkg/controllers/nodeclass/ami.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
"sigs.k8s.io/karpenter/pkg/utils/pretty"
3232

3333
v1 "github.com/aws/karpenter-provider-aws/pkg/apis/v1"
34+
awserrors "github.com/aws/karpenter-provider-aws/pkg/errors"
3435
"github.com/aws/karpenter-provider-aws/pkg/providers/amifamily"
3536
)
3637

@@ -55,6 +56,9 @@ func (a *AMI) Reconcile(ctx context.Context, nodeClass *v1.EC2NodeClass) (reconc
5556
nodeClass.StatusConditions(status.WithClock(a.clk)).SetFalse(v1.ConditionTypeAMIsReady, "UnsupportedAlias", err.Error())
5657
return reconcile.Result{}, reconcile.TerminalError(fmt.Errorf("getting amis, %w", err))
5758
}
59+
if reason, msg, retryable := awserrors.ClassifyError(err); !retryable {
60+
nodeClass.StatusConditions(status.WithClock(a.clk)).SetFalse(v1.ConditionTypeAMIsReady, reason, msg)
61+
}
5862
return reconcile.Result{}, fmt.Errorf("getting amis, %w", err)
5963
}
6064
if len(amis) == 0 {

pkg/controllers/nodeclass/capacityreservation.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
"sigs.k8s.io/karpenter/pkg/utils/pretty"
3232

3333
v1 "github.com/aws/karpenter-provider-aws/pkg/apis/v1"
34+
awserrors "github.com/aws/karpenter-provider-aws/pkg/errors"
3435
"github.com/aws/karpenter-provider-aws/pkg/providers/capacityreservation"
3536
)
3637

@@ -53,6 +54,9 @@ func NewCapacityReservationReconciler(clk clock.Clock, provider capacityreservat
5354
func (c *CapacityReservation) Reconcile(ctx context.Context, nc *v1.EC2NodeClass) (reconcile.Result, error) {
5455
reservations, err := c.provider.List(ctx, nc.Spec.CapacityReservationSelectorTerms...)
5556
if err != nil {
57+
if reason, msg, retryable := awserrors.ClassifyError(err); !retryable {
58+
nc.StatusConditions(status.WithClock(c.clk)).SetFalse(v1.ConditionTypeCapacityReservationsReady, reason, msg)
59+
}
5660
return reconcile.Result{}, fmt.Errorf("getting capacity reservations, %w", err)
5761
}
5862
if len(reservations) == 0 {

pkg/controllers/nodeclass/instanceprofile.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ func (ip *InstanceProfile) Reconcile(ctx context.Context, nodeClass *v1.EC2NodeC
7171
profile, err := ip.instanceProfileProvider.Get(ctx, nodeClass.Status.InstanceProfile)
7272
if err != nil {
7373
if !awserrors.IsNotFound(err) {
74+
if reason, msg, retryable := awserrors.ClassifyError(err); !retryable {
75+
nodeClass.StatusConditions(status.WithClock(ip.clk)).SetFalse(v1.ConditionTypeInstanceProfileReady, reason, msg)
76+
}
7477
return reconcile.Result{}, fmt.Errorf("getting instance profile %s, %w", nodeClass.Status.InstanceProfile, err)
7578
}
7679
} else if len(profile.Roles) > 0 {
@@ -94,6 +97,9 @@ func (ip *InstanceProfile) Reconcile(ctx context.Context, nodeClass *v1.EC2NodeC
9497
// role or remove the existing role. To prevent runaway instance profile creation, we'll attempt to delete the
9598
// profile. We'll fail open here and rely on the garbage collector as a backstop.
9699
_ = ip.instanceProfileProvider.Delete(ctx, newProfileName)
100+
if reason, msg, retryable := awserrors.ClassifyError(err); !retryable {
101+
nodeClass.StatusConditions(status.WithClock(ip.clk)).SetFalse(v1.ConditionTypeInstanceProfileReady, reason, msg)
102+
}
97103
return reconcile.Result{}, fmt.Errorf("creating instance profile, %w", err)
98104
}
99105
ip.recreationCache.SetDefault(generateCacheKey(nodeClass), newProfileName)

pkg/controllers/nodeclass/securitygroup.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"sigs.k8s.io/controller-runtime/pkg/reconcile"
2828

2929
v1 "github.com/aws/karpenter-provider-aws/pkg/apis/v1"
30+
awserrors "github.com/aws/karpenter-provider-aws/pkg/errors"
3031
"github.com/aws/karpenter-provider-aws/pkg/providers/securitygroup"
3132
)
3233

@@ -45,6 +46,9 @@ func NewSecurityGroupReconciler(clk clock.Clock, securityGroupProvider securityg
4546
func (sg *SecurityGroup) Reconcile(ctx context.Context, nodeClass *v1.EC2NodeClass) (reconcile.Result, error) {
4647
securityGroups, err := sg.securityGroupProvider.List(ctx, nodeClass)
4748
if err != nil {
49+
if reason, msg, retryable := awserrors.ClassifyError(err); !retryable {
50+
nodeClass.StatusConditions(status.WithClock(sg.clk)).SetFalse(v1.ConditionTypeSecurityGroupsReady, reason, msg)
51+
}
4852
return reconcile.Result{}, fmt.Errorf("getting security groups, %w", err)
4953
}
5054
if len(securityGroups) == 0 && len(nodeClass.Spec.SecurityGroupSelectorTerms) > 0 {

pkg/controllers/nodeclass/subnet.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"sigs.k8s.io/controller-runtime/pkg/reconcile"
2828

2929
v1 "github.com/aws/karpenter-provider-aws/pkg/apis/v1"
30+
awserrors "github.com/aws/karpenter-provider-aws/pkg/errors"
3031
"github.com/aws/karpenter-provider-aws/pkg/providers/subnet"
3132
)
3233

@@ -45,6 +46,9 @@ func NewSubnetReconciler(clk clock.Clock, subnetProvider subnet.Provider) *Subne
4546
func (s *Subnet) Reconcile(ctx context.Context, nodeClass *v1.EC2NodeClass) (reconcile.Result, error) {
4647
subnets, err := s.subnetProvider.List(ctx, nodeClass)
4748
if err != nil {
49+
if reason, msg, retryable := awserrors.ClassifyError(err); !retryable {
50+
nodeClass.StatusConditions(status.WithClock(s.clk)).SetFalse(v1.ConditionTypeSubnetsReady, reason, msg)
51+
}
4852
return reconcile.Result{}, fmt.Errorf("getting subnets, %w", err)
4953
}
5054
if len(subnets) == 0 {

pkg/errors/errors.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ limitations under the License.
1515
package errors
1616

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

2021
ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
@@ -28,6 +29,10 @@ const (
2829
RunInstancesInvalidParameterValueCode = "InvalidParameterValue"
2930
DryRunOperationErrorCode = "DryRunOperation"
3031
UnauthorizedOperationErrorCode = "UnauthorizedOperation"
32+
AccessDeniedErrorCode = "AccessDenied"
33+
AccessDeniedExceptionErrorCode = "AccessDeniedException"
34+
AuthFailureErrorCode = "AuthFailure"
35+
LimitExceededErrorCode = "LimitExceeded"
3136
RateLimitingErrorCode = "RequestLimitExceeded"
3237
ServiceLinkedRoleCreationNotPermittedErrorCode = "AuthFailure.ServiceLinkedRoleCreationNotPermitted"
3338
InsufficientFreeAddressesInSubnetErrorCode = "InsufficientFreeAddressesInSubnet"
@@ -294,3 +299,33 @@ func ToReasonMessage(err error) (string, string) {
294299
}
295300
return "LaunchFailed", "Instance launch failed"
296301
}
302+
303+
// ClassifyError inspects an AWS API error and, for known terminal codes,
304+
// returns a condition Reason, a user-facing Message, and retryable=false.
305+
// Callers use retryable=false as the signal to set a Ready-style status
306+
// condition to False (rather than leaving it Unknown for a retry). Codes
307+
// that are transient (or unrecognized) return retryable=true so the caller
308+
// keeps its existing retry behavior.
309+
//
310+
// Terminal codes surfaced here:
311+
// - UnauthorizedOperation / AccessDenied / AccessDeniedException /
312+
// AuthFailure — IAM misconfiguration; retrying without operator action
313+
// will not succeed.
314+
// - LimitExceeded — the account has hit an AWS service limit; requires a
315+
// quota increase or resource cleanup to resolve.
316+
func ClassifyError(err error) (reason string, message string, retryable bool) {
317+
if err == nil {
318+
return "", "", true
319+
}
320+
var apiErr smithy.APIError
321+
if !errors.As(err, &apiErr) {
322+
return "", "", true
323+
}
324+
switch apiErr.ErrorCode() {
325+
case UnauthorizedOperationErrorCode, AccessDeniedErrorCode, AccessDeniedExceptionErrorCode, AuthFailureErrorCode:
326+
return "Unauthorized", apiErr.ErrorMessage(), false
327+
case LimitExceededErrorCode:
328+
return "LimitExceeded", apiErr.ErrorMessage(), false
329+
}
330+
return "", "", true
331+
}

pkg/errors/errors_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/*
2+
Licensed under the Apache License, Version 2.0 (the "License");
3+
you may not use this file except in compliance with the License.
4+
You may obtain a copy of the License at
5+
6+
http://www.apache.org/licenses/LICENSE-2.0
7+
8+
Unless required by applicable law or agreed to in writing, software
9+
distributed under the License is distributed on an "AS IS" BASIS,
10+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
See the License for the specific language governing permissions and
12+
limitations under the License.
13+
*/
14+
15+
package errors_test
16+
17+
import (
18+
"errors"
19+
"fmt"
20+
"testing"
21+
22+
"github.com/aws/smithy-go"
23+
24+
awserrors "github.com/aws/karpenter-provider-aws/pkg/errors"
25+
)
26+
27+
type apiErr struct {
28+
code string
29+
message string
30+
}
31+
32+
func (e *apiErr) Error() string { return fmt.Sprintf("%s: %s", e.code, e.message) }
33+
func (e *apiErr) ErrorCode() string { return e.code }
34+
func (e *apiErr) ErrorMessage() string { return e.message }
35+
func (e *apiErr) ErrorFault() smithy.ErrorFault { return smithy.FaultClient }
36+
37+
func TestClassifyError(t *testing.T) {
38+
tests := []struct {
39+
name string
40+
err error
41+
wantReason string
42+
wantMessage string
43+
wantRetryable bool
44+
}{
45+
{
46+
name: "nil error is retryable and returns empty classification",
47+
err: nil,
48+
wantReason: "",
49+
wantMessage: "",
50+
wantRetryable: true,
51+
},
52+
{
53+
name: "non-AWS error falls through as retryable",
54+
err: errors.New("boom"),
55+
wantReason: "",
56+
wantMessage: "",
57+
wantRetryable: true,
58+
},
59+
{
60+
name: "UnauthorizedOperation is terminal with Unauthorized reason",
61+
err: &apiErr{code: awserrors.UnauthorizedOperationErrorCode, message: "user is not authorized"},
62+
wantReason: "Unauthorized",
63+
wantMessage: "user is not authorized",
64+
wantRetryable: false,
65+
},
66+
{
67+
name: "AccessDenied is terminal with Unauthorized reason",
68+
err: &apiErr{code: awserrors.AccessDeniedErrorCode, message: "explicit deny"},
69+
wantReason: "Unauthorized",
70+
wantMessage: "explicit deny",
71+
wantRetryable: false,
72+
},
73+
{
74+
name: "AccessDeniedException (IAM shape) is terminal with Unauthorized reason",
75+
err: &apiErr{code: awserrors.AccessDeniedExceptionErrorCode, message: "iam explicit deny"},
76+
wantReason: "Unauthorized",
77+
wantMessage: "iam explicit deny",
78+
wantRetryable: false,
79+
},
80+
{
81+
name: "AuthFailure is terminal with Unauthorized reason",
82+
err: &apiErr{code: awserrors.AuthFailureErrorCode, message: "auth failure"},
83+
wantReason: "Unauthorized",
84+
wantMessage: "auth failure",
85+
wantRetryable: false,
86+
},
87+
{
88+
name: "LimitExceeded is terminal with LimitExceeded reason",
89+
err: &apiErr{code: awserrors.LimitExceededErrorCode, message: "instance profile limit reached"},
90+
wantReason: "LimitExceeded",
91+
wantMessage: "instance profile limit reached",
92+
wantRetryable: false,
93+
},
94+
{
95+
name: "RequestLimitExceeded (rate limiting) is retryable",
96+
err: &apiErr{code: awserrors.RateLimitingErrorCode, message: "slow down"},
97+
wantReason: "",
98+
wantMessage: "",
99+
wantRetryable: true,
100+
},
101+
{
102+
name: "InvalidParameterValue is retryable (transient/config error, not blanket-terminal)",
103+
err: &apiErr{code: awserrors.RunInstancesInvalidParameterValueCode, message: "invalid"},
104+
wantReason: "",
105+
wantMessage: "",
106+
wantRetryable: true,
107+
},
108+
{
109+
name: "wrapped terminal error is still classified as terminal",
110+
err: fmt.Errorf("listing subnets: %w", &apiErr{code: awserrors.AccessDeniedErrorCode, message: "wrapped"}),
111+
wantReason: "Unauthorized",
112+
wantMessage: "wrapped",
113+
wantRetryable: false,
114+
},
115+
}
116+
for _, tc := range tests {
117+
t.Run(tc.name, func(t *testing.T) {
118+
reason, message, retryable := awserrors.ClassifyError(tc.err)
119+
if reason != tc.wantReason {
120+
t.Errorf("reason: got %q, want %q", reason, tc.wantReason)
121+
}
122+
if message != tc.wantMessage {
123+
t.Errorf("message: got %q, want %q", message, tc.wantMessage)
124+
}
125+
if retryable != tc.wantRetryable {
126+
t.Errorf("retryable: got %v, want %v", retryable, tc.wantRetryable)
127+
}
128+
})
129+
}
130+
}

0 commit comments

Comments
 (0)