diff --git a/spinifex/gateway/auth.go b/spinifex/gateway/auth.go index f99706b20..24a2ef7c7 100644 --- a/spinifex/gateway/auth.go +++ b/spinifex/gateway/auth.go @@ -15,10 +15,10 @@ import ( "github.com/mulgadc/bluebottle/pkg/sigv4" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" telemetry "github.com/mulgadc/spinifex/spinifex/foundation/telemetry" - gateway_eks "github.com/mulgadc/spinifex/spinifex/gateway/eks" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts" "github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch" + "github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope" ingresshttp "github.com/mulgadc/spinifex/spinifex/ingress/http" ) @@ -526,17 +526,17 @@ func (gw *GatewayConfig) writeSigV4Error(w http.ResponseWriter, r *http.Request, // the SDK chokes deserializing our XML into its shape. An unserved scope is // absent from jsonErrorService by definition, so its protocol is read off // the request itself instead. - if gw.jsonErrorService(svc) || requestSignalsJSONProtocol(r) { - w.Header().Set("Content-Type", eksJSONContentType) - w.Header().Set("X-Amzn-Errortype", jsonErrorType(errorCode)) + if gw.jsonErrorService(svc) || envelope.RequestSignalsJSONProtocol(r) { + w.Header().Set("Content-Type", envelope.JSONContentType) + w.Header().Set("X-Amzn-Errortype", envelope.JSONErrorType(errorCode)) w.WriteHeader(errorMsg.HTTPCode) - _, _ = w.Write(gateway_eks.GenerateEKSErrorResponse(errorCode, errorMsg.Message)) + _, _ = w.Write(envelope.JSONBody(errorCode, errorMsg.Message)) return } xmlError := xmlErrorBody(svc, errorCode, errorMsg.Message, requestID, r.URL.Path) - w.Header().Set("Content-Type", "application/xml") + w.Header().Set("Content-Type", envelope.XMLContentType) w.WriteHeader(errorMsg.HTTPCode) _, _ = w.Write(xmlError) } diff --git a/spinifex/gateway/auth_test.go b/spinifex/gateway/auth_test.go index 4a814b84f..7b83c62c1 100644 --- a/spinifex/gateway/auth_test.go +++ b/spinifex/gateway/auth_test.go @@ -10,6 +10,7 @@ import ( "fmt" awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi" awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers" + "github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope" authlimit "github.com/mulgadc/spinifex/spinifex/ingress/aws/ratelimit" "io" "net/http" @@ -2621,7 +2622,7 @@ func TestSigV4Auth_UnservedServiceJSONClientGetsJSONEnvelope(t *testing.T) { resp := doRequest(handler, req) require.Equal(t, http.StatusBadRequest, resp.StatusCode) - assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type")) + assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type")) assert.Equal(t, "InvalidActionException", resp.Header.Get("X-Amzn-Errortype")) body, err := io.ReadAll(resp.Body) @@ -2642,7 +2643,7 @@ func TestSigV4Auth_UnservedServiceJSONContentTypeGetsJSONEnvelope(t *testing.T) resp := doRequest(handler, req) require.Equal(t, http.StatusBadRequest, resp.StatusCode) - assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type")) + assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type")) assert.Equal(t, "InvalidActionException", resp.Header.Get("X-Amzn-Errortype")) body, err := io.ReadAll(resp.Body) @@ -2685,7 +2686,7 @@ func TestWriteSigV4Error_JSONErrorServicesUnaffectedByFallback(t *testing.T) { gw.writeSigV4Error(w, req, awserrors.ErrorSignatureDoesNotMatch, "") resp := w.Result() - assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type")) + assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type")) assert.Equal(t, "SignatureDoesNotMatchException", resp.Header.Get("X-Amzn-Errortype")) }) } diff --git a/spinifex/gateway/dispatch_seam_test.go b/spinifex/gateway/dispatch_seam_test.go index 40ea8cc4c..72a5b2770 100644 --- a/spinifex/gateway/dispatch_seam_test.go +++ b/spinifex/gateway/dispatch_seam_test.go @@ -22,6 +22,7 @@ import ( "github.com/mulgadc/spinifex/spinifex/foundation/telemetry" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" "github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch" + "github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" @@ -315,10 +316,10 @@ func TestDispatchSeam_DispatcherErrorRendersInDeclaredEnvelope(t *testing.T) { _, app := dispatchSeamApp(t, allowAllECRDocs(), fakeJSONRegistration(capt)) resp := doRequest(app, signDispatchRequest(t, fakeJSONService, []byte("{}"))) - assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type")) - assert.Equal(t, jsonErrorType(awserrors.ErrorInternalError), resp.Header.Get("X-Amzn-Errortype")) + assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type")) + assert.Equal(t, envelope.JSONErrorType(awserrors.ErrorInternalError), resp.Header.Get("X-Amzn-Errortype")) body := decodeJSONError(t, resp) - assert.Equal(t, jsonErrorType(awserrors.ErrorInternalError), body.Type) + assert.Equal(t, envelope.JSONErrorType(awserrors.ErrorInternalError), body.Type) }) t.Run("XML envelope service", func(t *testing.T) { @@ -350,7 +351,7 @@ func TestDispatchSeam_NATSDownGetsSame503AsLegacy(t *testing.T) { resp := w.Result() assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) - assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type")) + assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type")) body := decodeJSONError(t, resp) assert.Equal(t, "ServiceUnavailableException", body.Type) } diff --git a/spinifex/gateway/ecr_characterization_test.go b/spinifex/gateway/ecr_characterization_test.go index 9cddcbdff..11b4a57f1 100644 --- a/spinifex/gateway/ecr_characterization_test.go +++ b/spinifex/gateway/ecr_characterization_test.go @@ -24,6 +24,7 @@ import ( "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/mulgadc/spinifex/spinifex/foundation/telemetry" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" + "github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" @@ -127,7 +128,7 @@ func TestECRPipeline_MissingTarget_400MissingActionException(t *testing.T) { resp := doRequest(app, signECRRequest(t, "", []byte("{}"))) require.Equal(t, http.StatusBadRequest, resp.StatusCode) - assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type")) + assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type")) assert.Equal(t, "MissingActionException", resp.Header.Get("X-Amzn-Errortype")) body := decodeJSONError(t, resp) assert.Equal(t, "MissingActionException", body.Type) @@ -141,7 +142,7 @@ func TestECRPipeline_UnknownAction_400InvalidActionException(t *testing.T) { resp := doRequest(app, signECRRequest(t, target, []byte("{}"))) require.Equal(t, http.StatusBadRequest, resp.StatusCode) - assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type")) + assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type")) assert.Equal(t, "InvalidActionException", resp.Header.Get("X-Amzn-Errortype")) body := decodeJSONError(t, resp) assert.Equal(t, "InvalidActionException", body.Type) @@ -156,7 +157,7 @@ func TestECRPipeline_StubbedAction_501NotImplementedException(t *testing.T) { resp := doRequest(app, signECRRequest(t, target, []byte("{}"))) require.Equal(t, http.StatusNotImplemented, resp.StatusCode) - assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type")) + assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type")) assert.Equal(t, "NotImplementedException", resp.Header.Get("X-Amzn-Errortype")) body := decodeJSONError(t, resp) assert.Equal(t, "NotImplementedException", body.Type) @@ -318,7 +319,7 @@ func TestECRPipeline_OversizedBody_413OverHTTP(t *testing.T) { resp := doRequest(handler, req) require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode) - assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type")) + assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type")) body := decodeJSONError(t, resp) assert.Equal(t, "RequestEntityTooLargeException", body.Type) } @@ -444,7 +445,7 @@ func TestRequest_ClusterUnavailableNilConn_ECR(t *testing.T) { resp := w.Result() assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) - assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type")) + assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type")) body := decodeJSONError(t, resp) assert.Equal(t, "ServiceUnavailableException", body.Type) }) diff --git a/spinifex/gateway/eks.go b/spinifex/gateway/eks.go index 8b33dd9d9..e6e320865 100644 --- a/spinifex/gateway/eks.go +++ b/spinifex/gateway/eks.go @@ -8,7 +8,6 @@ import ( "log/slog" "net/http" "slices" - "strings" "github.com/mulgadc/bluebottle/pkg/auth" "github.com/mulgadc/spinifex/spinifex/foundation/aws/arn" @@ -16,16 +15,6 @@ import ( gateway_eks "github.com/mulgadc/spinifex/spinifex/gateway/eks" ) -// jsonErrorType derives the X-Amzn-Errortype header value for code, mirroring -// gateway_eks.GenerateEKSErrorResponse's own "Exception" suffixing so the header and the -// body's __type always agree. -func jsonErrorType(code string) string { - if strings.HasSuffix(code, "Exception") { - return code - } - return code + "Exception" -} - // eksRoute maps one HTTP method + chi path pattern to an AWS action and handler. type eksRoute = rest.Route[eksRouteHandler] diff --git a/spinifex/gateway/eks/handler.go b/spinifex/gateway/eks/handler.go index 42b6b81cb..2df73f93b 100644 --- a/spinifex/gateway/eks/handler.go +++ b/spinifex/gateway/eks/handler.go @@ -4,45 +4,17 @@ package gateway_eks import ( - "encoding/json" - "fmt" "log/slog" "net/http" - "strings" "github.com/aws/aws-sdk-go/private/protocol/json/jsonutil" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" + "github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope" ) // JSONContentType is the AWS REST-JSON 1.1 content type EKS clients expect. const JSONContentType = "application/x-amz-json-1.1" -// EKSJSONError is the AWS REST-JSON error envelope. SDKs key off __type for -// awserr.Code() and message for awserr.Message(). -type EKSJSONError struct { - Type string `json:"__type"` - Message string `json:"message"` -} - -// GenerateEKSErrorResponse marshals the AWS REST-JSON error envelope. -// The suffix "Exception" is appended idempotently — codes that already end in -// it (e.g. "ResourceNotFoundException") are left unchanged to avoid the double -// "ExceptionException" the SDK rejects. -func GenerateEKSErrorResponse(code, message string) []byte { - if !strings.HasSuffix(code, "Exception") { - code += "Exception" - } - body, err := json.Marshal(EKSJSONError{ - Type: code, - Message: message, - }) - if err != nil { - slog.Error("Failed to marshal EKS error JSON", "code", code, "err", err) - return fmt.Appendf(nil, `{"__type":"InternalErrorException","message":%q}`, message) - } - return body -} - // WriteJSONResponse serializes obj as AWS REST-JSON and writes a 200 response. // Uses jsonutil.BuildJSON (the SDK's own restjson marshaler) to honour // locationName tags that encoding/json does not understand. @@ -63,7 +35,7 @@ func WriteJSONResponse(w http.ResponseWriter, obj any) { // WriteJSONError emits the AWS REST-JSON error envelope with the given code, // message, and HTTP status. func WriteJSONError(w http.ResponseWriter, code, message string, httpStatus int) { - body := GenerateEKSErrorResponse(code, message) + body := envelope.JSONBody(code, message) w.Header().Set("Content-Type", JSONContentType) if httpStatus == 0 { httpStatus = http.StatusInternalServerError diff --git a/spinifex/gateway/eks/handler_test.go b/spinifex/gateway/eks/handler_test.go index 75fc0c6c3..a3adf2aef 100644 --- a/spinifex/gateway/eks/handler_test.go +++ b/spinifex/gateway/eks/handler_test.go @@ -12,31 +12,16 @@ import ( "github.com/stretchr/testify/require" ) -func TestGenerateEKSErrorResponse_ShapesExceptionSuffix(t *testing.T) { - body := GenerateEKSErrorResponse("ResourceNotFound", "Cluster does not exist") - var env EKSJSONError - require.NoError(t, json.Unmarshal(body, &env)) - assert.Equal(t, "ResourceNotFoundException", env.Type) - assert.Equal(t, "Cluster does not exist", env.Message) -} - -// Codes that already carry the "Exception" suffix (e.g. -// awserrors.ErrorEKSResourceNotFound = "ResourceNotFoundException") must not be -// doubled into ResourceNotFoundExceptionException, which SDK clients reject. -func TestGenerateEKSErrorResponse_DoesNotDoubleExceptionSuffix(t *testing.T) { - body := GenerateEKSErrorResponse("ResourceNotFoundException", "Cluster does not exist") - var env EKSJSONError - require.NoError(t, json.Unmarshal(body, &env)) - assert.Equal(t, eks.ErrCodeResourceNotFoundException, env.Type) -} - func TestWriteJSONError_SetsContentTypeAndStatus(t *testing.T) { w := httptest.NewRecorder() WriteJSONError(w, "NotImplemented", "Operation not implemented", http.StatusNotImplemented) assert.Equal(t, http.StatusNotImplemented, w.Code) assert.Equal(t, JSONContentType, w.Header().Get("Content-Type")) - var env EKSJSONError + var env struct { + Type string `json:"__type"` + Message string `json:"message"` + } require.NoError(t, json.Unmarshal(w.Body.Bytes(), &env)) assert.Equal(t, "NotImplementedException", env.Type) assert.Equal(t, "Operation not implemented", env.Message) diff --git a/spinifex/gateway/eks_test.go b/spinifex/gateway/eks_test.go index 2f090e139..11e9cacd2 100644 --- a/spinifex/gateway/eks_test.go +++ b/spinifex/gateway/eks_test.go @@ -2,7 +2,6 @@ package gateway import ( "context" - "encoding/json" "net/http" "net/http/httptest" "strings" @@ -215,8 +214,7 @@ func TestErrorHandler_EKSEmitsJSONNotXML(t *testing.T) { assert.Equal(t, gateway_eks.JSONContentType, w.Header().Get("Content-Type")) assert.True(t, strings.HasPrefix(w.Body.String(), "{"), "expected JSON body, got %q", w.Body.String()) - var env gateway_eks.EKSJSONError - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &env)) + env := decodeJSONError(t, w.Result()) assert.Equal(t, "NotImplementedException", env.Type) // SDKs resolve the modelled exception type from this header, not the body. assert.Equal(t, env.Type, w.Header().Get("X-Amzn-Errortype")) diff --git a/spinifex/gateway/gateway.go b/spinifex/gateway/gateway.go index 5228198bf..8283c348a 100644 --- a/spinifex/gateway/gateway.go +++ b/spinifex/gateway/gateway.go @@ -4,7 +4,6 @@ import ( "context" "crypto/x509" "encoding/json" - "encoding/xml" "errors" "fmt" awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers" @@ -38,12 +37,12 @@ import ( "github.com/mulgadc/spinifex/spinifex/foundation/aws/policy" "github.com/mulgadc/spinifex/spinifex/foundation/telemetry" gateway_bedrock "github.com/mulgadc/spinifex/spinifex/gateway/bedrock" - gateway_eks "github.com/mulgadc/spinifex/spinifex/gateway/eks" gateway_sts "github.com/mulgadc/spinifex/spinifex/gateway/sts" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" handlers_quota "github.com/mulgadc/spinifex/spinifex/handlers/quota" handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts" "github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch" + "github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope" ingresshttp "github.com/mulgadc/spinifex/spinifex/ingress/http" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -302,35 +301,6 @@ var supportedServices = map[string]bool{ "bedrock-agent-runtime": true, } -// EC2ErrorResponse is the EC2 query-API error envelope. -// aws-sdk-go v1's ec2query handler rejects the IAM-style envelope -// with SerializationError, so EC2 errors must use .... -type EC2ErrorResponse struct { - XMLName xml.Name `xml:"Response"` - Errors EC2Errors `xml:"Errors"` - RequestID string `xml:"RequestID"` -} - -type EC2Errors struct { - Error ErrorDetail `xml:"Error"` -} - -type ErrorDetail struct { - Code string `xml:"Code"` - Message string `xml:"Message"` -} - -// S3ErrorResponse is the S3 REST error envelope: a flat document rather -// than the query-API wrapper. SDKs look for a top-level on an S3 -// response and report an empty code for anything else. -type S3ErrorResponse struct { - XMLName xml.Name `xml:"Error"` - Code string `xml:"Code"` - Message string `xml:"Message"` - Resource string `xml:"Resource,omitempty"` - RequestID string `xml:"RequestId"` -} - func (gw *GatewayConfig) SetupRoutes() http.Handler { var logLevel slog.Level @@ -418,9 +388,6 @@ func (gw *GatewayConfig) throttleKeyFuncs() []ratelimit.KeyFunc { } } -// eksJSONContentType is the AWS REST-JSON 1.1 content type EKS clients expect. -const eksJSONContentType = "application/x-amz-json-1.1" - // jsonErrorService reports whether svc returns AWS JSON 1.1 errors rather than // XML. One source of truth so every error emitter agrees with ErrorHandler; an // XML body to these clients is an unparseable "InternalErrorInternal error` + requestID + ``) - } - - // Add XML header - output = append([]byte(xml.Header), output...) - - return output -} - -// IAMErrorResponse is the IAM/STS error XML envelope. -type IAMErrorResponse struct { - XMLName xml.Name `xml:"ErrorResponse"` - Error IAMErrorDetail `xml:"Error"` - RequestID string `xml:"RequestId"` -} - -type IAMErrorDetail struct { - Type string `xml:"Type"` - Code string `xml:"Code"` - Message string `xml:"Message"` -} - -// GenerateIAMErrorResponse builds the generic REST-XML/AWS-query ErrorResponse -// envelope. Originally IAM/STS-specific, it is also xmlErrorBody's default for -// every scope with no dedicated shape, including ones the gateway does not serve. -func GenerateIAMErrorResponse(code, message, requestID string) (output []byte) { - errorXml := IAMErrorResponse{ - Error: IAMErrorDetail{ - Type: "Sender", - Code: code, - Message: message, - }, - RequestID: requestID, - } - - output, err := xml.MarshalIndent(errorXml, "", " ") - if err != nil { - slog.Error("Failed to build IAM error XML", "error", err) - return []byte(xml.Header + "SenderInternalErrorInternal error" + requestID + "") - } - - output = append([]byte(xml.Header), output...) - return output -} - -// GenerateS3ErrorResponse builds the flat S3 REST error document. resource is -// the request path and is omitted when empty. -func GenerateS3ErrorResponse(code, message, requestID, resource string) (output []byte) { - errorXml := S3ErrorResponse{ - Code: code, - Message: message, - Resource: resource, - RequestID: requestID, - } - - output, err := xml.MarshalIndent(errorXml, "", " ") - if err != nil { - slog.Error("Failed to build S3 error XML", "error", err) - return []byte(xml.Header + "InternalErrorInternal error" + requestID + "") - } - - output = append([]byte(xml.Header), output...) - return output -} - // xmlErrorBody renders an error in the XML envelope svc's clients expect. The // one place the service-to-envelope mapping lives, so a service cannot be // added to some emitters and missed by others. JSON services never reach @@ -1118,18 +994,18 @@ func GenerateS3ErrorResponse(code, message, requestID, resource string) (output func xmlErrorBody(svc, code, message, requestID, resource string) []byte { switch svc { case "s3": - return GenerateS3ErrorResponse(code, message, requestID, resource) + return envelope.S3Body(code, message, requestID, resource) case "ec2", "spinifex", "": // Both speak the Action-parameter query protocol, and their clients // parse the EC2 shape, not the generic ErrorResponse. // Empty means the signature never parsed, so no service is known yet; // keep EC2's shape there rather than changing a path this is not about. - return GenerateEC2ErrorResponse(code, message, requestID) + return envelope.EC2Body(code, message, requestID) default: // iam, sts, elasticloadbalancing, rds, and any unenumerated or // unserved scope: the generic REST-XML ErrorResponse envelope, never // EC2's shape, which a REST-XML client cannot deserialize. - return GenerateIAMErrorResponse(code, message, requestID) + return envelope.IAMBody(code, message, requestID) } } diff --git a/spinifex/gateway/gateway_test.go b/spinifex/gateway/gateway_test.go index 74e9713dc..99b89c71e 100644 --- a/spinifex/gateway/gateway_test.go +++ b/spinifex/gateway/gateway_test.go @@ -17,15 +17,13 @@ import ( "testing" "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/awserr" - awscreds "github.com/aws/aws-sdk-go/aws/credentials" - awssession "github.com/aws/aws-sdk-go/aws/session" awsec2 "github.com/aws/aws-sdk-go/service/ec2" "github.com/mulgadc/bluebottle/pkg/ratelimit" clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1" "github.com/mulgadc/spinifex/internal/testkit" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" + "github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope" "github.com/nats-io/nats.go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -38,54 +36,6 @@ func doRequest(handler http.Handler, req *http.Request) *http.Response { return w.Result() } -func TestGenerateEC2ErrorResponse_Structure(t *testing.T) { - tests := []struct { - name string - code string - message string - requestID string - }{ - { - name: "standard error", - code: "InvalidParameterValue", - message: "The value supplied is not valid.", - requestID: "req-12345", - }, - { - name: "auth failure", - code: "AuthFailure", - message: "Credentials could not be validated.", - requestID: "req-auth-001", - }, - { - name: "empty fields", - code: "", - message: "", - requestID: "", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - output := GenerateEC2ErrorResponse(tc.code, tc.message, tc.requestID) - require.NotNil(t, output) - - xmlStr := string(output) - - assert.True(t, strings.HasPrefix(xmlStr, xml.Header)) - assert.Contains(t, xmlStr, ""+tc.code+"") - assert.Contains(t, xmlStr, ""+tc.requestID+"") - - // EC2 query API uses /, not ; aws-sdk-go v1 - // rejects the latter with SerializationError. - assert.Contains(t, xmlStr, "") - assert.Contains(t, xmlStr, "") - assert.Contains(t, xmlStr, "") - assert.Contains(t, xmlStr, "") - }) - } -} - // xmlRootName returns the local name of body's root element, which is what // distinguishes the S3, IAM and EC2 error envelopes from each other. func xmlRootName(t *testing.T, body []byte) string { @@ -100,32 +50,6 @@ func xmlRootName(t *testing.T, body []byte) string { } } -func TestGenerateS3ErrorResponse_FlatEnvelope(t *testing.T) { - output := GenerateS3ErrorResponse("SignatureDoesNotMatch", "bad signature", "req-s3-1", "/bucket/key.txt") - require.NotNil(t, output) - - assert.True(t, strings.HasPrefix(string(output), xml.Header)) - assert.Equal(t, "Error", xmlRootName(t, output)) - - var parsed struct { - Code string `xml:"Code"` - Message string `xml:"Message"` - Resource string `xml:"Resource"` - RequestID string `xml:"RequestId"` - } - require.NoError(t, xml.Unmarshal(output, &parsed)) - - assert.Equal(t, "SignatureDoesNotMatch", parsed.Code) - assert.Equal(t, "bad signature", parsed.Message) - assert.Equal(t, "/bucket/key.txt", parsed.Resource) - assert.Equal(t, "req-s3-1", parsed.RequestID) -} - -func TestGenerateS3ErrorResponse_OmitsEmptyResource(t *testing.T) { - output := GenerateS3ErrorResponse("AccessDenied", "denied", "req-s3-2", "") - assert.NotContains(t, string(output), "") -} - func TestXMLErrorBody_EnvelopePerService(t *testing.T) { tests := []struct { svc string @@ -154,80 +78,6 @@ func TestXMLErrorBody_EnvelopePerService(t *testing.T) { } } -func TestGenerateEC2ErrorResponse_ValidXML(t *testing.T) { - output := GenerateEC2ErrorResponse("TestCode", "Test message", "req-999") - require.NotNil(t, output) - - xmlBody := strings.TrimPrefix(string(output), xml.Header) - decoder := xml.NewDecoder(strings.NewReader(xmlBody)) - for { - _, err := decoder.Token() - if err != nil { - assert.ErrorIs(t, err, io.EOF) - break - } - } -} - -func TestGenerateIAMErrorResponse_Structure(t *testing.T) { - tests := []struct { - name string - code string - message string - requestID string - }{ - { - name: "entity not found", - code: "NoSuchEntity", - message: "The request was rejected because it referenced a resource entity that does not exist.", - requestID: "req-iam-001", - }, - { - name: "entity already exists", - code: "EntityAlreadyExists", - message: "The request was rejected because it attempted to create a resource that already exists.", - requestID: "req-iam-002", - }, - { - name: "empty fields", - code: "", - message: "", - requestID: "", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - output := GenerateIAMErrorResponse(tc.code, tc.message, tc.requestID) - require.NotNil(t, output) - - xmlStr := string(output) - - assert.True(t, strings.HasPrefix(xmlStr, xml.Header)) - assert.Contains(t, xmlStr, "") - assert.Contains(t, xmlStr, "") - assert.Contains(t, xmlStr, "Sender") - assert.Contains(t, xmlStr, ""+tc.code+"") - assert.Contains(t, xmlStr, ""+tc.requestID+"") - }) - } -} - -func TestGenerateIAMErrorResponse_ValidXML(t *testing.T) { - output := GenerateIAMErrorResponse("NoSuchEntity", "Entity not found", "req-iam-999") - require.NotNil(t, output) - - xmlBody := strings.TrimPrefix(string(output), xml.Header) - decoder := xml.NewDecoder(strings.NewReader(xmlBody)) - for { - _, err := decoder.Token() - if err != nil { - assert.ErrorIs(t, err, io.EOF) - break - } - } -} - func TestErrorHandler_IAMService(t *testing.T) { gw := &GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()} @@ -1599,7 +1449,7 @@ func TestWriteThrottleError_Bedrock(t *testing.T) { resp := w.Result() body, _ := io.ReadAll(resp.Body) - assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type"), "bedrock must get JSON, not XML") + assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"), "bedrock must get JSON, not XML") assert.Contains(t, string(body), `"__type"`) assert.NotContains(t, string(body), " envelope and -// discards the embedded code, so the EC2 / shape is required. -func TestGenerateEC2ErrorResponse_SDKRoundTrip(t *testing.T) { - const wantCode = "InvalidInstanceType" - const wantMessage = "The instance type 't2.micro' is not supported in this region." - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - body := GenerateEC2ErrorResponse(wantCode, wantMessage, "req-sdk-roundtrip") - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write(body) - })) - defer srv.Close() - - sess, err := awssession.NewSession(&aws.Config{ - Region: aws.String("us-east-1"), - Endpoint: aws.String(srv.URL), - Credentials: awscreds.NewStaticCredentials("AKIA-TEST", "secret", ""), - DisableSSL: aws.Bool(true), - // Suppress the default retry loop — error responses are not retryable - // here and waiting them out wastes test time. - MaxRetries: aws.Int(0), - }) - require.NoError(t, err) - - client := awsec2.New(sess) - _, err = client.RunInstances(&awsec2.RunInstancesInput{ - ImageId: aws.String("ami-test"), - InstanceType: aws.String("t2.micro"), - MinCount: aws.Int64(1), - MaxCount: aws.Int64(1), - }) - require.Error(t, err) - - var awsErr awserr.Error - require.ErrorAs(t, err, &awsErr, "expected awserr.Error, got %T: %v", err, err) - assert.Equal(t, wantCode, awsErr.Code()) - assert.NotEqual(t, "SerializationError", awsErr.Code(), "SDK could not parse the envelope") -} diff --git a/spinifex/ingress/aws/envelope/envelope.go b/spinifex/ingress/aws/envelope/envelope.go new file mode 100644 index 000000000..f5ab71e3b --- /dev/null +++ b/spinifex/ingress/aws/envelope/envelope.go @@ -0,0 +1,166 @@ +// Package envelope renders the EC2, IAM-style and S3 XML error envelopes and +// the AWS JSON 1.1 error body. It reads no request context and holds no +// per-service table: callers choose the shape. +package envelope + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "log/slog" + "net/http" + "strings" +) + +// JSONContentType is the AWS JSON 1.1 content type JSON-protocol error bodies +// use (EKS, ECS, ACM, tagging, the bedrock family, and any unserved scope a +// client signals JSON for). +const JSONContentType = "application/x-amz-json-1.1" + +// XMLContentType is the content type every XML error envelope uses. +const XMLContentType = "application/xml" + +// ec2ErrorResponse is the EC2 query-API error envelope. +// aws-sdk-go v1's ec2query handler rejects the IAM-style envelope +// with SerializationError, so EC2 errors must use .... +type ec2ErrorResponse struct { + XMLName xml.Name `xml:"Response"` + Errors ec2Errors `xml:"Errors"` + RequestID string `xml:"RequestID"` +} + +type ec2Errors struct { + Error errorDetail `xml:"Error"` +} + +type errorDetail struct { + Code string `xml:"Code"` + Message string `xml:"Message"` +} + +// EC2Body builds the EC2 query-API error envelope. +func EC2Body(code, message, requestID string) (output []byte) { + errorXml := ec2ErrorResponse{ + Errors: ec2Errors{ + Error: errorDetail{ + Code: code, + Message: message, + }, + }, + RequestID: requestID, + } + + output, err := xml.MarshalIndent(errorXml, "", " ") + if err != nil { + slog.Error("envelope: failed to build EC2 error XML", "error", err) + return []byte(xml.Header + `InternalErrorInternal error` + requestID + ``) + } + + return append([]byte(xml.Header), output...) +} + +// iamErrorResponse is the IAM/STS error XML envelope. +type iamErrorResponse struct { + XMLName xml.Name `xml:"ErrorResponse"` + Error iamErrorDetail `xml:"Error"` + RequestID string `xml:"RequestId"` +} + +type iamErrorDetail struct { + Type string `xml:"Type"` + Code string `xml:"Code"` + Message string `xml:"Message"` +} + +// IAMBody builds the generic REST-XML/AWS-query ErrorResponse envelope. This +// is the default shape for every XML scope with no dedicated envelope, +// including scopes the gateway does not serve. +func IAMBody(code, message, requestID string) (output []byte) { + errorXml := iamErrorResponse{ + Error: iamErrorDetail{ + Type: "Sender", + Code: code, + Message: message, + }, + RequestID: requestID, + } + + output, err := xml.MarshalIndent(errorXml, "", " ") + if err != nil { + slog.Error("envelope: failed to build IAM error XML", "error", err) + return []byte(xml.Header + "SenderInternalErrorInternal error" + requestID + "") + } + + return append([]byte(xml.Header), output...) +} + +// s3ErrorResponse is the S3 REST error envelope: a flat document +// rather than the query-API wrapper. SDKs look for a top-level +// on an S3 response and report an empty code for anything else. +type s3ErrorResponse struct { + XMLName xml.Name `xml:"Error"` + Code string `xml:"Code"` + Message string `xml:"Message"` + Resource string `xml:"Resource,omitempty"` + RequestID string `xml:"RequestId"` +} + +// S3Body builds the flat S3 REST error document. resource is the request path +// and is omitted when empty. +func S3Body(code, message, requestID, resource string) (output []byte) { + errorXml := s3ErrorResponse{ + Code: code, + Message: message, + Resource: resource, + RequestID: requestID, + } + + output, err := xml.MarshalIndent(errorXml, "", " ") + if err != nil { + slog.Error("envelope: failed to build S3 error XML", "error", err) + return []byte(xml.Header + "InternalErrorInternal error" + requestID + "") + } + + return append([]byte(xml.Header), output...) +} + +// jsonError is the AWS JSON 1.1 error envelope. SDKs key off __type for +// awserr.Code() and message for awserr.Message(). +type jsonError struct { + Type string `json:"__type"` + Message string `json:"message"` +} + +// JSONBody marshals the AWS JSON 1.1 error body. "Exception" is appended only +// when the code lacks it, avoiding the "ExceptionException" the SDK rejects. +func JSONBody(code, message string) []byte { + body, err := json.Marshal(jsonError{ + Type: JSONErrorType(code), + Message: message, + }) + if err != nil { + slog.Error("envelope: failed to marshal JSON error", "code", code, "err", err) + return fmt.Appendf(nil, `{"__type":"InternalErrorException","message":%q}`, message) + } + return body +} + +// JSONErrorType derives the X-Amzn-Errortype header value for code, mirroring +// JSONBody's own "Exception" suffixing so the header and the body's __type +// always agree. +func JSONErrorType(code string) string { + if strings.HasSuffix(code, "Exception") { + return code + } + return code + "Exception" +} + +// RequestSignalsJSONProtocol reports whether r's headers mark it as AWS +// JSON-1.x, for a scope no service table knows: JSON-1.1 actions carry +// X-Amz-Target and JSON clients send an application/x-amz-json-* type. +func RequestSignalsJSONProtocol(r *http.Request) bool { + if r.Header.Get("X-Amz-Target") != "" { + return true + } + return strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-amz-json") +} diff --git a/spinifex/ingress/aws/envelope/envelope_test.go b/spinifex/ingress/aws/envelope/envelope_test.go new file mode 100644 index 000000000..7b5abcbc0 --- /dev/null +++ b/spinifex/ingress/aws/envelope/envelope_test.go @@ -0,0 +1,272 @@ +package envelope + +import ( + "encoding/json" + "encoding/xml" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/aws/aws-sdk-go/service/eks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEC2Body_Structure(t *testing.T) { + tests := []struct { + name string + code string + message string + requestID string + }{ + { + name: "standard error", + code: "InvalidParameterValue", + message: "The value supplied is not valid.", + requestID: "req-12345", + }, + { + name: "auth failure", + code: "AuthFailure", + message: "Credentials could not be validated.", + requestID: "req-auth-001", + }, + { + name: "empty fields", + code: "", + message: "", + requestID: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + output := EC2Body(tc.code, tc.message, tc.requestID) + require.NotNil(t, output) + + xmlStr := string(output) + + assert.True(t, strings.HasPrefix(xmlStr, xml.Header)) + assert.Contains(t, xmlStr, ""+tc.code+"") + assert.Contains(t, xmlStr, ""+tc.requestID+"") + + // EC2 query API uses /, not ; aws-sdk-go v1 + // rejects the latter with SerializationError. + assert.Contains(t, xmlStr, "") + assert.Contains(t, xmlStr, "") + assert.Contains(t, xmlStr, "") + assert.Contains(t, xmlStr, "") + }) + } +} + +// xmlRootName returns the local name of body's root element, which is what +// distinguishes the S3, IAM and EC2 error envelopes from each other. +func xmlRootName(t *testing.T, body []byte) string { + t.Helper() + dec := xml.NewDecoder(strings.NewReader(string(body))) + for { + tok, err := dec.Token() + require.NoError(t, err) + if se, ok := tok.(xml.StartElement); ok { + return se.Name.Local + } + } +} + +func TestS3Body_FlatEnvelope(t *testing.T) { + output := S3Body("SignatureDoesNotMatch", "bad signature", "req-s3-1", "/bucket/key.txt") + require.NotNil(t, output) + + assert.True(t, strings.HasPrefix(string(output), xml.Header)) + assert.Equal(t, "Error", xmlRootName(t, output)) + + var parsed struct { + Code string `xml:"Code"` + Message string `xml:"Message"` + Resource string `xml:"Resource"` + RequestID string `xml:"RequestId"` + } + require.NoError(t, xml.Unmarshal(output, &parsed)) + + assert.Equal(t, "SignatureDoesNotMatch", parsed.Code) + assert.Equal(t, "bad signature", parsed.Message) + assert.Equal(t, "/bucket/key.txt", parsed.Resource) + assert.Equal(t, "req-s3-1", parsed.RequestID) +} + +func TestS3Body_OmitsEmptyResource(t *testing.T) { + output := S3Body("AccessDenied", "denied", "req-s3-2", "") + assert.NotContains(t, string(output), "") +} + +func TestEC2Body_ValidXML(t *testing.T) { + output := EC2Body("TestCode", "Test message", "req-999") + require.NotNil(t, output) + + xmlBody := strings.TrimPrefix(string(output), xml.Header) + decoder := xml.NewDecoder(strings.NewReader(xmlBody)) + for { + _, err := decoder.Token() + if err != nil { + assert.ErrorIs(t, err, io.EOF) + break + } + } +} + +func TestIAMBody_Structure(t *testing.T) { + tests := []struct { + name string + code string + message string + requestID string + }{ + { + name: "entity not found", + code: "NoSuchEntity", + message: "The request was rejected because it referenced a resource entity that does not exist.", + requestID: "req-iam-001", + }, + { + name: "entity already exists", + code: "EntityAlreadyExists", + message: "The request was rejected because it attempted to create a resource that already exists.", + requestID: "req-iam-002", + }, + { + name: "empty fields", + code: "", + message: "", + requestID: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + output := IAMBody(tc.code, tc.message, tc.requestID) + require.NotNil(t, output) + + xmlStr := string(output) + + assert.True(t, strings.HasPrefix(xmlStr, xml.Header)) + assert.Contains(t, xmlStr, "") + assert.Contains(t, xmlStr, "") + assert.Contains(t, xmlStr, "Sender") + assert.Contains(t, xmlStr, ""+tc.code+"") + assert.Contains(t, xmlStr, ""+tc.requestID+"") + }) + } +} + +func TestIAMBody_ValidXML(t *testing.T) { + output := IAMBody("NoSuchEntity", "Entity not found", "req-iam-999") + require.NotNil(t, output) + + xmlBody := strings.TrimPrefix(string(output), xml.Header) + decoder := xml.NewDecoder(strings.NewReader(xmlBody)) + for { + _, err := decoder.Token() + if err != nil { + assert.ErrorIs(t, err, io.EOF) + break + } + } +} + +// TestEC2Body_SDKRoundTrip serves the EC2 error envelope from an httptest +// server, points an aws-sdk-go v1 EC2 client at it, and asserts the SDK +// surfaces the code via awserr.Error.Code() — not SerializationError. +// aws-sdk-go v1's ec2query handler rejects the IAM envelope +// and discards the embedded code, so the EC2 / shape is +// required. +func TestEC2Body_SDKRoundTrip(t *testing.T) { + const wantCode = "InvalidInstanceType" + const wantMessage = "The instance type 't2.micro' is not supported in this region." + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + body := EC2Body(wantCode, wantMessage, "req-sdk-roundtrip") + w.Header().Set("Content-Type", XMLContentType) + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write(body) + })) + defer srv.Close() + + sess, err := session.NewSession(&aws.Config{ + Region: aws.String("us-east-1"), + Endpoint: aws.String(srv.URL), + Credentials: credentials.NewStaticCredentials("AKIA-TEST", "secret", ""), + DisableSSL: aws.Bool(true), + // Suppress the default retry loop — error responses are not retryable + // here and waiting them out wastes test time. + MaxRetries: aws.Int(0), + }) + require.NoError(t, err) + + client := ec2.New(sess) + _, err = client.RunInstances(&ec2.RunInstancesInput{ + ImageId: aws.String("ami-test"), + InstanceType: aws.String("t2.micro"), + MinCount: aws.Int64(1), + MaxCount: aws.Int64(1), + }) + require.Error(t, err) + + var reqErr awserr.Error + ok := errors.As(err, &reqErr) + require.True(t, ok, "expected an awserr.Error, got %T: %v", err, err) + assert.Equal(t, wantCode, reqErr.Code()) + assert.Equal(t, wantMessage, reqErr.Message()) +} + +func TestJSONBody_ShapesExceptionSuffix(t *testing.T) { + body := JSONBody("ResourceNotFound", "Cluster does not exist") + var env jsonError + require.NoError(t, json.Unmarshal(body, &env)) + assert.Equal(t, "ResourceNotFoundException", env.Type) + assert.Equal(t, "Cluster does not exist", env.Message) +} + +// Codes that already carry the "Exception" suffix (e.g. +// awserrors.ErrorEKSResourceNotFound = "ResourceNotFoundException") must not +// be doubled into ResourceNotFoundExceptionException, which SDK clients reject. +func TestJSONBody_DoesNotDoubleExceptionSuffix(t *testing.T) { + body := JSONBody("ResourceNotFoundException", "Cluster does not exist") + var env jsonError + require.NoError(t, json.Unmarshal(body, &env)) + assert.Equal(t, eks.ErrCodeResourceNotFoundException, env.Type) +} + +func TestJSONErrorType(t *testing.T) { + assert.Equal(t, "ThrottlingException", JSONErrorType("Throttling")) + assert.Equal(t, "ResourceNotFoundException", JSONErrorType("ResourceNotFoundException")) +} + +func TestRequestSignalsJSONProtocol(t *testing.T) { + tests := []struct { + name string + setup func(r *http.Request) + signal bool + }{ + {"X-Amz-Target present", func(r *http.Request) { r.Header.Set("X-Amz-Target", "Whatever.Action") }, true}, + {"json content type", func(r *http.Request) { r.Header.Set("Content-Type", "application/x-amz-json-1.1") }, true}, + {"neither header set", func(r *http.Request) {}, false}, + {"unrelated content type", func(r *http.Request) { r.Header.Set("Content-Type", "application/xml") }, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/", nil) + tc.setup(r) + assert.Equal(t, tc.signal, RequestSignalsJSONProtocol(r)) + }) + } +}