mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
fix: resolve wrapped AWS error codes (#560)
This commit is contained in:
@@ -522,6 +522,38 @@ func ValidErrorCode(code string) string {
|
||||
return ErrorServerInternal
|
||||
}
|
||||
|
||||
// ResolveErrorCode returns the first registered AWS error code in err's unwrap tree.
|
||||
func ResolveErrorCode(err error) (string, bool) {
|
||||
if err == nil {
|
||||
return "", false
|
||||
}
|
||||
code := err.Error()
|
||||
if _, ok := ErrorLookup[code]; ok {
|
||||
return code, true
|
||||
}
|
||||
|
||||
if joined, ok := err.(interface{ Unwrap() []error }); ok {
|
||||
for _, inner := range joined.Unwrap() {
|
||||
if code, found := ResolveErrorCode(inner); found {
|
||||
return code, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
if wrapped, ok := err.(interface{ Unwrap() error }); ok {
|
||||
return ResolveErrorCode(wrapped.Unwrap())
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ValidErrorCodeFromError resolves err or returns ErrorServerInternal.
|
||||
func ValidErrorCodeFromError(err error) string {
|
||||
if code, ok := ResolveErrorCode(err); ok {
|
||||
return code
|
||||
}
|
||||
return ErrorServerInternal
|
||||
}
|
||||
|
||||
// HasErrorCode reports whether s is exactly a registered AWS error code.
|
||||
// Use it to decide whether an error string may be surfaced to clients verbatim
|
||||
// (mapped to its real HTTP status) rather than wrapped/sanitized to 500.
|
||||
|
||||
@@ -70,6 +70,47 @@ func TestValidErrorCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveErrorCode(t *testing.T) {
|
||||
known := errors.New(ErrorInsufficientAddressCapacity)
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{name: "nil", err: nil},
|
||||
{name: "exact code", err: known, want: ErrorInsufficientAddressCapacity, wantOK: true},
|
||||
{name: "single wrapper", err: fmt.Errorf("allocate public address: %w", known), want: ErrorInsufficientAddressCapacity, wantOK: true},
|
||||
{name: "nested wrappers", err: fmt.Errorf("launch on node: %w", fmt.Errorf("prepare instance: %w", known)), want: ErrorInsufficientAddressCapacity, wantOK: true},
|
||||
{name: "joined errors", err: errors.Join(errors.New("opaque"), known), want: ErrorInsufficientAddressCapacity, wantOK: true},
|
||||
{name: "first joined code wins", err: errors.Join(errors.New(ErrorAuthFailure), known), want: ErrorAuthFailure, wantOK: true},
|
||||
{name: "unknown error", err: errors.New("opaque")},
|
||||
{name: "code substring is not exact", err: errors.New("launch failed: " + ErrorInsufficientAddressCapacity)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := ResolveErrorCode(tt.err)
|
||||
if got != tt.want || ok != tt.wantOK {
|
||||
t.Errorf("ResolveErrorCode() = (%q, %v), want (%q, %v)", got, ok, tt.want, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidErrorCodeFromError(t *testing.T) {
|
||||
wrapped := fmt.Errorf("launch: %w", errors.New(ErrorInsufficientInstanceCapacity))
|
||||
if got := ValidErrorCodeFromError(wrapped); got != ErrorInsufficientInstanceCapacity {
|
||||
t.Errorf("ValidErrorCodeFromError() = %q, want %q", got, ErrorInsufficientInstanceCapacity)
|
||||
}
|
||||
if got := ValidErrorCodeFromError(errors.New("opaque")); got != ErrorServerInternal {
|
||||
t.Errorf("ValidErrorCodeFromError() = %q, want %q", got, ErrorServerInternal)
|
||||
}
|
||||
if got := ValidErrorCodeFromError(nil); got != ErrorServerInternal {
|
||||
t.Errorf("ValidErrorCodeFromError(nil) = %q, want %q", got, ErrorServerInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasErrorCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -91,7 +91,7 @@ func handleNATSRequest[I any, O any](serviceFn func(context.Context, *I, string)
|
||||
// in the journal too.
|
||||
slog.ErrorContext(ctx, "handleNATSRequest: service call failed", "subject", msg.Subject, "err", err)
|
||||
utils.MarkSpanError(span, err)
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
respondWithJSON(msg, output)
|
||||
@@ -120,7 +120,7 @@ func handleNATSRequestWithPrincipal[I any, O any](serviceFn func(context.Context
|
||||
output, err := serviceFn(ctx, input, accountID, principalARN)
|
||||
if err != nil {
|
||||
utils.MarkSpanError(span, err)
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
respondWithJSON(msg, output)
|
||||
@@ -176,7 +176,7 @@ func (d *Daemon) handleEC2Events(msg *nats.Msg) {
|
||||
endOpSpan(opSpan, err)
|
||||
if err != nil {
|
||||
utils.MarkSpanError(span, err)
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(fmt.Appendf(nil, `{"status":"running","instanceId":"%s"}`, instance.ID)); err != nil {
|
||||
@@ -188,7 +188,7 @@ func (d *Daemon) handleEC2Events(msg *nats.Msg) {
|
||||
endOpSpan(opSpan, err)
|
||||
if err != nil {
|
||||
utils.MarkSpanError(span, err)
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond([]byte(`{}`)); err != nil {
|
||||
@@ -204,7 +204,7 @@ func (d *Daemon) handleEC2Events(msg *nats.Msg) {
|
||||
endOpSpan(opSpan, err)
|
||||
if err != nil {
|
||||
utils.MarkSpanError(span, err)
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond([]byte(`{}`)); err != nil {
|
||||
|
||||
@@ -58,7 +58,7 @@ func (d *Daemon) handleEC2CreateCapacityReservation(msg *nats.Msg) {
|
||||
}
|
||||
|
||||
if err := d.resourceMgr.CreateReservation(rec); err != nil {
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ func (d *Daemon) handleAttachNetworkInterface(ctx context.Context, msg *nats.Msg
|
||||
|
||||
record, err := d.vpcService.GetENIRecord(accountID, eniID)
|
||||
if err != nil {
|
||||
respondWithError(msg, errorCodeFor(err))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
if record.Status == "in-use" && record.InstanceId != instance.ID {
|
||||
@@ -49,7 +49,7 @@ func (d *Daemon) handleAttachNetworkInterface(ctx context.Context, msg *nats.Msg
|
||||
|
||||
attachmentID, err := d.vpcService.AttachENI(accountID, eniID, instance.ID, deviceIndex)
|
||||
if err != nil {
|
||||
respondWithError(msg, errorCodeFor(err))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
if err := d.vpcService.UpdateENI(accountID, eniID, func(r *handlers_ec2_vpc.ENIRecord) {
|
||||
@@ -117,7 +117,7 @@ func (d *Daemon) handleDetachNetworkInterface(ctx context.Context, msg *nats.Msg
|
||||
|
||||
record, err := d.vpcService.FindENIByAttachment(accountID, attachmentID)
|
||||
if err != nil {
|
||||
respondWithError(msg, errorCodeFor(err))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
if record.InstanceId != instance.ID {
|
||||
@@ -191,15 +191,6 @@ func eniHotplugErrorCode(err error) string {
|
||||
}
|
||||
}
|
||||
|
||||
// errorCodeFor passes through errors whose message is already a valid AWS
|
||||
// code (the convention VPCServiceImpl uses for its sentinel errors).
|
||||
func errorCodeFor(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return awserrors.ValidErrorCode(err.Error())
|
||||
}
|
||||
|
||||
// publishENIHotplugEvent emits the per-instance NATS event on best-effort
|
||||
// basis — observers (Phase 4 ecs-agent, future vpcd subscribers) read it
|
||||
// for fanout. A publish failure does not roll back the pipeline.
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
func (d *Daemon) handleAssociateIamInstanceProfile(ctx context.Context, msg *nats.Msg, command types.EC2InstanceCommand, instance *vm.VM) {
|
||||
result, err := d.instanceService.AssociateIamInstanceProfile(ctx, instance, command)
|
||||
if err != nil {
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
respondWithJSON(msg, result)
|
||||
|
||||
@@ -130,7 +130,7 @@ func (d *Daemon) handleEC2CreateImage(msg *nats.Msg) {
|
||||
output, err := d.imageService.CreateImageFromInstance(params, accountID)
|
||||
if err != nil {
|
||||
slog.Error("CreateImage: service failed", "instanceId", instanceID, "err", err)
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ func (d *Daemon) handleSetInstanceTags(ctx context.Context, msg *nats.Msg, comma
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
@@ -119,7 +119,7 @@ func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
|
||||
if reservationID != "" {
|
||||
if it, ok := d.resourceMgr.instanceTypes[aws.StringValue(input.InstanceType)]; ok {
|
||||
if vErr := d.resourceMgr.ValidateReservationTarget(reservationID, accountID, it); vErr != nil {
|
||||
respondWithError(msg, awserrors.ValidErrorCode(vErr.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(vErr))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
|
||||
reservation, instances, instanceType, err := d.instanceService.PrepareRunInstances(ctx, input, accountID, reservationID)
|
||||
endOpSpan(prepSpan, err)
|
||||
if err != nil {
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ func (d *Daemon) handleSetSpotLineage(ctx context.Context, msg *nats.Msg, comman
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -222,6 +223,30 @@ func TestHandleNATSRequest_ServiceError(t *testing.T) {
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, errResp["Code"])
|
||||
}
|
||||
|
||||
func TestHandleNATSRequest_WrappedAWSServiceError(t *testing.T) {
|
||||
nc, err := nats.Connect(sharedNATSURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
serviceFn := func(_ context.Context, _ *testInput, _ string) (*testOutput, error) {
|
||||
cause := errors.New(awserrors.ErrorInsufficientAddressCapacity)
|
||||
return nil, fmt.Errorf("allocate public address: %w", cause)
|
||||
}
|
||||
|
||||
sub, err := nc.Subscribe("test.err.wrapped", handleNATSRequest(serviceFn))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
reqData, err := json.Marshal(testInput{Name: "world"})
|
||||
require.NoError(t, err)
|
||||
reply, err := nc.Request("test.err.wrapped", reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var errResp map[string]any
|
||||
require.NoError(t, json.Unmarshal(reply.Data, &errResp))
|
||||
assert.Equal(t, awserrors.ErrorInsufficientAddressCapacity, errResp["Code"])
|
||||
}
|
||||
|
||||
// --- Handler wrapper tests (representative set via NATS round-trip) ---
|
||||
|
||||
func TestHandleEC2CreateKeyPair_RoundTrip(t *testing.T) {
|
||||
|
||||
@@ -189,7 +189,7 @@ func (d *Daemon) handleEC2ModifyVolume(msg *nats.Msg) {
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "handleEC2ModifyVolume service.ModifyVolume failed", "err", err)
|
||||
utils.MarkSpanError(span, err)
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ func runInstancesInner(ctx context.Context, input *ec2.RunInstancesInput, natsCo
|
||||
reservationPtr, err := distributeInstances(ctx, input, natsConn, accountID, expectedNodes)
|
||||
if err != nil {
|
||||
// Distinguish "unknown type" from "no capacity" via DescribeInstanceTypes.
|
||||
if err.Error() == awserrors.ErrorInsufficientInstanceCapacity {
|
||||
if code, ok := awserrors.ResolveErrorCode(err); ok && code == awserrors.ErrorInsufficientInstanceCapacity {
|
||||
if !isKnownInstanceType(ctx, natsConn, *input.InstanceType) {
|
||||
return reservation, errors.New(awserrors.ErrorInvalidInstanceType)
|
||||
}
|
||||
|
||||
@@ -437,11 +437,11 @@ func extractClientError(results []nodeLaunchResult) error {
|
||||
if r.Err == nil {
|
||||
continue
|
||||
}
|
||||
inner := errors.Unwrap(r.Err)
|
||||
if inner == nil {
|
||||
code, ok := awserrors.ResolveErrorCode(r.Err)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch inner.Error() {
|
||||
switch code {
|
||||
case awserrors.ErrorInsufficientInstanceCapacity,
|
||||
awserrors.ErrorInvalidAMIIDNotFound,
|
||||
awserrors.ErrorInvalidAMIIDMalformed,
|
||||
@@ -451,7 +451,7 @@ func extractClientError(results []nodeLaunchResult) error {
|
||||
awserrors.ErrorInvalidGroupNotFound,
|
||||
awserrors.ErrorInvalidParameterValue,
|
||||
awserrors.ErrorSecurityGroupsPerInterfaceLimitExceeded:
|
||||
return inner
|
||||
return errors.New(code)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -371,6 +371,27 @@ func TestExtractClientError_AMINotFound(t *testing.T) {
|
||||
assert.Equal(t, awserrors.ErrorInvalidAMIIDNotFound, err.Error())
|
||||
}
|
||||
|
||||
func TestExtractClientError_NestedWrappedCode(t *testing.T) {
|
||||
inner := errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
wrapped := fmt.Errorf("launch on node-1: %w", fmt.Errorf("prepare instance: %w", inner))
|
||||
results := []nodeLaunchResult{
|
||||
{NodeID: "node-1", Err: wrapped},
|
||||
}
|
||||
|
||||
err := extractClientError(results)
|
||||
require.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestExtractClientError_JoinedCode(t *testing.T) {
|
||||
joined := errors.Join(assert.AnError, errors.New(awserrors.ErrorInvalidGroupNotFound))
|
||||
results := []nodeLaunchResult{
|
||||
{NodeID: "node-1", Err: fmt.Errorf("launch on node-1: %w", joined)},
|
||||
}
|
||||
|
||||
err := extractClientError(results)
|
||||
require.EqualError(t, err, awserrors.ErrorInvalidGroupNotFound)
|
||||
}
|
||||
|
||||
func TestExtractClientError_KeyPairNotFound(t *testing.T) {
|
||||
inner := errors.New(awserrors.ErrorInvalidKeyPairNotFound)
|
||||
wrapped := fmt.Errorf("launch on node-1: %w", inner)
|
||||
|
||||
@@ -550,15 +550,13 @@ func (gw *GatewayConfig) ErrorHandler(w http.ResponseWriter, r *http.Request, er
|
||||
slog.Debug("ErrorHandler", "service", svc, "error", err.Error())
|
||||
|
||||
var requestId = uuid.NewString()
|
||||
var errorMsg = awserrors.ErrorMessage{}
|
||||
|
||||
if _, exists := awserrors.ErrorLookup[err.Error()]; !exists {
|
||||
code, exists := awserrors.ResolveErrorCode(err)
|
||||
if !exists {
|
||||
slog.Warn("Unknown error code", "error", err.Error())
|
||||
err = errors.New(awserrors.ErrorInternalError)
|
||||
code = awserrors.ErrorInternalError
|
||||
}
|
||||
|
||||
errorMsg = awserrors.ErrorLookup[err.Error()]
|
||||
|
||||
errorMsg := awserrors.ErrorLookup[code]
|
||||
if errorMsg.HTTPCode == 0 {
|
||||
errorMsg.HTTPCode = 500
|
||||
}
|
||||
@@ -566,8 +564,8 @@ func (gw *GatewayConfig) ErrorHandler(w http.ResponseWriter, r *http.Request, er
|
||||
// EKS, ECR, ACM, ECS, tagging, and bedrock/bedrock-runtime use AWS JSON 1.1;
|
||||
// query/XML services fall through.
|
||||
if svc == "eks" || svc == "ecr" || svc == "acm" || svc == "ecs" || svc == "tagging" || svc == "bedrock" || svc == "bedrock-runtime" {
|
||||
body := GenerateEKSErrorResponse(err.Error(), errorMsg.Message, requestId)
|
||||
slog.Debug("Generated JSON error response", "service", svc, "error", err.Error(), "json", string(body), "requestId", requestId)
|
||||
body := GenerateEKSErrorResponse(code, errorMsg.Message, requestId)
|
||||
slog.Debug("Generated JSON error response", "service", svc, "error", err, "code", code, "json", string(body), "requestId", requestId)
|
||||
w.Header().Set("Content-Type", eksJSONContentType)
|
||||
w.WriteHeader(errorMsg.HTTPCode)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
@@ -578,12 +576,12 @@ func (gw *GatewayConfig) ErrorHandler(w http.ResponseWriter, r *http.Request, er
|
||||
|
||||
var xmlError []byte
|
||||
if svc == "iam" || svc == "sts" || svc == "elasticloadbalancing" {
|
||||
xmlError = GenerateIAMErrorResponse(err.Error(), errorMsg.Message, requestId)
|
||||
xmlError = GenerateIAMErrorResponse(code, errorMsg.Message, requestId)
|
||||
} else {
|
||||
xmlError = GenerateEC2ErrorResponse(err.Error(), errorMsg.Message, requestId)
|
||||
xmlError = GenerateEC2ErrorResponse(code, errorMsg.Message, requestId)
|
||||
}
|
||||
|
||||
slog.Debug("Generated error response", "error", err.Error(), "xml", string(xmlError), "requestId", requestId)
|
||||
slog.Debug("Generated error response", "error", err, "code", code, "xml", string(xmlError), "requestId", requestId)
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(errorMsg.HTTPCode)
|
||||
|
||||
@@ -199,6 +199,27 @@ func TestErrorHandler_UnknownError(t *testing.T) {
|
||||
assert.Contains(t, xmlStr, "<Errors>")
|
||||
}
|
||||
|
||||
func TestErrorHandler_WrappedErrorCode(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true}
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), ctxService, "ec2")
|
||||
r = r.WithContext(ctx)
|
||||
cause := errors.New(awserrors.ErrorInsufficientAddressCapacity)
|
||||
gw.ErrorHandler(w, r, fmt.Errorf("launch on node-1: %w", fmt.Errorf("allocate address: %w", cause)))
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
resp := doRequest(handler, req)
|
||||
assert.Equal(t, 503, resp.StatusCode)
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
xmlStr := string(body)
|
||||
assert.Contains(t, xmlStr, "<Code>"+awserrors.ErrorInsufficientAddressCapacity+"</Code>")
|
||||
assert.Contains(t, xmlStr, awserrors.ErrorLookup[awserrors.ErrorInsufficientAddressCapacity].Message)
|
||||
assert.NotContains(t, xmlStr, "launch on node-1")
|
||||
}
|
||||
|
||||
func TestErrorHandler_ELBv2Service(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true}
|
||||
|
||||
|
||||
@@ -748,12 +748,7 @@ func (s *InstanceServiceImpl) PrepareRunInstances(ctx context.Context, input *ec
|
||||
s.resourceMgr.ReleaseToReservation(reservationID, instanceType)
|
||||
}
|
||||
}
|
||||
errCode := awserrors.ErrorServerInternal
|
||||
if lastRunErr != nil {
|
||||
if _, ok := awserrors.ErrorLookup[lastRunErr.Error()]; ok {
|
||||
errCode = lastRunErr.Error()
|
||||
}
|
||||
}
|
||||
errCode := awserrors.ValidErrorCodeFromError(lastRunErr)
|
||||
slog.ErrorContext(ctx, "PrepareRunInstances: failed to create minimum instances", "created", len(instances), "minCount", minCount, "err", errCode)
|
||||
return nil, nil, nil, errors.New(errCode)
|
||||
}
|
||||
|
||||
@@ -3154,6 +3154,23 @@ func TestPrepareRunInstances_ENICreateFailureDeallocates(t *testing.T) {
|
||||
require.Len(t, prov.deallocated, 1, "ENI failure must trigger deallocate")
|
||||
}
|
||||
|
||||
func TestPrepareRunInstances_WrappedENICreateErrorPreservesCode(t *testing.T) {
|
||||
cause := errors.New(awserrors.ErrorInvalidSubnetIDNotFound)
|
||||
eni := &fakeENICreator{createErr: fmt.Errorf("create primary ENI: %w", cause)}
|
||||
svc, prov := prepareSvcWithENI(t, eni, nil)
|
||||
|
||||
_, _, _, err := svc.PrepareRunInstances(context.Background(), &ec2.RunInstancesInput{
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
ImageId: aws.String("ami-1"),
|
||||
SubnetId: aws.String("subnet-missing"),
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
}, "acc", "")
|
||||
|
||||
require.EqualError(t, err, awserrors.ErrorInvalidSubnetIDNotFound)
|
||||
require.Len(t, prov.deallocated, 1)
|
||||
}
|
||||
|
||||
// TestPrepareRunInstances_ENIAttachFailureRollsBack verifies that an AttachENI
|
||||
// failure deletes the auto-created ENI, deallocates capacity, and drops the
|
||||
// instance from the reservation.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/mulgadc/spinifex/spinifex/network/external"
|
||||
"github.com/mulgadc/spinifex/spinifex/network/external/dhcp"
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -82,7 +83,7 @@ func (m *ExternalIPAM) EnableDHCP(client *dhcp.NATSClient) error {
|
||||
func (m *ExternalIPAM) AllocateIP(ctx context.Context, region, az, purpose, allocID, eniID, instanceID string) (string, string, error) {
|
||||
pool := m.findPool(region, az)
|
||||
if pool == nil {
|
||||
return "", "", fmt.Errorf("InsufficientAddressCapacity: no external pool available for region=%q az=%q", region, az)
|
||||
return "", "", fmt.Errorf("no external pool available for region=%q az=%q: %w", region, az, errors.New(awserrors.ErrorInsufficientAddressCapacity))
|
||||
}
|
||||
ip, err := m.AllocateFromPool(ctx, pool.Name, purpose, allocID, eniID, instanceID)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/mulgadc/spinifex/spinifex/network/external"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -380,7 +381,10 @@ func TestExternalIPAM_NoPoolAvailable(t *testing.T) {
|
||||
|
||||
// No pool matches eu-west
|
||||
_, _, err := ipam.AllocateIP(context.Background(), "eu-west-1", "eu-west-1a", PurposeENIPublic, "", "eni-1", "i-1")
|
||||
assert.ErrorContains(t, err, "InsufficientAddressCapacity")
|
||||
assert.ErrorContains(t, err, `region="eu-west-1" az="eu-west-1a"`)
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorInsufficientAddressCapacity, code)
|
||||
}
|
||||
|
||||
func TestExternalIPAM_FindPoolByName_NotFound(t *testing.T) {
|
||||
|
||||
@@ -931,7 +931,7 @@ func (s *EKSServiceImpl) launchWorkersCAS(ctx context.Context, acctKV nats.KeyVa
|
||||
// Surface a client-facing code (e.g. InsufficientInstanceCapacity)
|
||||
// verbatim so the gateway maps its real status; wrap only opaque
|
||||
// internal failures, which stay 500.
|
||||
if awserrors.HasErrorCode(err.Error()) {
|
||||
if _, ok := awserrors.ResolveErrorCode(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("launch workers: %w", err)
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers_eks
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -846,6 +847,15 @@ func TestUpdateNodegroupConfig_CapacityErrorSurfacesCode(t *testing.T) {
|
||||
assert.True(t, awserrors.HasErrorCode(err.Error()))
|
||||
})
|
||||
|
||||
t.Run("wrapped capacity code preserved", func(t *testing.T) {
|
||||
wrapped := fmt.Errorf("launch on node-1: %w", errors.New(awserrors.ErrorInsufficientInstanceCapacity))
|
||||
err := scaleUpWithRunErr(t, wrapped)
|
||||
require.EqualError(t, err, wrapped.Error())
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorInsufficientInstanceCapacity, code)
|
||||
})
|
||||
|
||||
t.Run("opaque error wrapped", func(t *testing.T) {
|
||||
err := scaleUpWithRunErr(t, errors.New("kvm: out of host memory"))
|
||||
require.Error(t, err)
|
||||
|
||||
+2
-1
@@ -9,6 +9,7 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/mulgadc/spinifex/spinifex/migrate"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -339,5 +340,5 @@ func nextAvailableIP(record *PoolRecord) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("InsufficientAddressCapacity: pool %s exhausted", record.PoolName)
|
||||
return "", fmt.Errorf("pool %s exhausted: %w", record.PoolName, errors.New(awserrors.ErrorInsufficientAddressCapacity))
|
||||
}
|
||||
|
||||
+5
-1
@@ -6,6 +6,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -111,7 +112,10 @@ func TestStaticPool_Exhaustion(t *testing.T) {
|
||||
_, err = a.Allocate(ctx, AllocateRequest{PoolName: "tiny"})
|
||||
require.NoError(t, err)
|
||||
_, err = a.Allocate(ctx, AllocateRequest{PoolName: "tiny"})
|
||||
assert.ErrorContains(t, err, "InsufficientAddressCapacity")
|
||||
assert.ErrorContains(t, err, "pool tiny exhausted")
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorInsufficientAddressCapacity, code)
|
||||
}
|
||||
|
||||
func TestStaticPool_CASConflict(t *testing.T) {
|
||||
|
||||
@@ -48,7 +48,7 @@ func handleEKSTokenVerify(verifier presignedVerifier) nats.MsgHandler {
|
||||
// A verify failure is the common, expected case for a forged/replayed
|
||||
// token — log at debug so a port-scan does not flood the error log.
|
||||
slog.Debug("EKS token verify rejected", "cluster", req.ClusterName, "err", err)
|
||||
respondTokenVerifyErr(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondTokenVerifyErr(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+17
-2
@@ -220,6 +220,21 @@ func PrincipalARNFromMsg(msg *nats.Msg) string {
|
||||
return msg.Header.Get(PrincipalARNHeader)
|
||||
}
|
||||
|
||||
// decodedNATSError retains a response's diagnostic message while exposing its
|
||||
// sanitized AWS wire code to unwrap-aware classifiers.
|
||||
type decodedNATSError struct {
|
||||
code error
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *decodedNATSError) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e *decodedNATSError) Unwrap() error {
|
||||
return e.code
|
||||
}
|
||||
|
||||
// NATSRequest performs a NATS request-response with JSON marshaling. It sends
|
||||
// with X-Account-ID (plus any extra headers), unmarshals the successful response
|
||||
// into Out, and carries ctx's trace context onto the wire: it opens a client span
|
||||
@@ -258,7 +273,7 @@ func NATSRequest[Out any](ctx context.Context, conn *nats.Conn, subject string,
|
||||
responseError, err := ValidateErrorPayload(msg.Data)
|
||||
if err != nil {
|
||||
if responseError.Message != nil && *responseError.Message != "" {
|
||||
return nil, errors.New(*responseError.Message)
|
||||
return nil, &decodedNATSError{code: errors.New(*responseError.Code), message: *responseError.Message}
|
||||
}
|
||||
return nil, errors.New(*responseError.Code)
|
||||
}
|
||||
@@ -292,7 +307,7 @@ func ServeNATSRequestCtx[I any, O any](msg *nats.Msg, fn func(context.Context, *
|
||||
out, err := fn(ctx, input)
|
||||
if err != nil {
|
||||
MarkSpanError(span, err)
|
||||
respondNATS(msg, GenerateErrorPayloadWithMessage(awserrors.ValidErrorCode(err.Error()), err.Error()))
|
||||
respondNATS(msg, GenerateErrorPayloadWithMessage(awserrors.ValidErrorCodeFromError(err), err.Error()))
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(out)
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
@@ -278,7 +280,10 @@ func TestNATSRequest_ErrorResponse(t *testing.T) {
|
||||
type Resp struct{}
|
||||
_, err = NATSRequest[Resp](context.Background(), nc, "test.fail", struct{}{}, 2*time.Second, "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "InvalidParameterValue")
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, code)
|
||||
}
|
||||
|
||||
func TestNATSRequest_ErrorResponseSurfacesMessage(t *testing.T) {
|
||||
@@ -300,6 +305,34 @@ func TestNATSRequest_ErrorResponseSurfacesMessage(t *testing.T) {
|
||||
_, err = NATSRequest[Resp](context.Background(), nc, "test.fail.msg", struct{}{}, 2*time.Second, "")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, reason, err.Error())
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, code)
|
||||
}
|
||||
|
||||
func TestServeNATSRequest_WrappedErrorPreservesCodeAndMessage(t *testing.T) {
|
||||
ns := startTestNATSServer(t)
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
const message = "launch on node-1: InsufficientAddressCapacity"
|
||||
_, err = nc.Subscribe("test.serve.error", func(msg *nats.Msg) {
|
||||
ServeNATSRequest(msg, func(_ *struct{}) (*struct{}, error) {
|
||||
cause := errors.New(awserrors.ErrorInsufficientAddressCapacity)
|
||||
return nil, fmt.Errorf("launch on node-1: %w", cause)
|
||||
})
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, nc.Flush())
|
||||
|
||||
_, err = NATSRequest[struct{}](context.Background(), nc, "test.serve.error", struct{}{}, 2*time.Second, "")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, message, err.Error())
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorInsufficientAddressCapacity, code)
|
||||
}
|
||||
|
||||
func TestNATSRequest_NoResponders(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user