diff --git a/cmd/consolelogger.go b/cmd/consolelogger.go index 624e96855..676a30ca3 100644 --- a/cmd/consolelogger.go +++ b/cmd/consolelogger.go @@ -28,7 +28,7 @@ import ( "github.com/minio/madmin-go/v3/logger/log" "github.com/minio/minio/internal/logger" "github.com/minio/minio/internal/logger/target/console" - "github.com/minio/minio/internal/logger/target/types" + types "github.com/minio/minio/internal/logger/target/loggertypes" "github.com/minio/minio/internal/pubsub" xnet "github.com/minio/pkg/v3/net" ) diff --git a/cmd/object-lambda-handlers.go b/cmd/object-lambda-handlers.go index c486e94bc..1ced5165d 100644 --- a/cmd/object-lambda-handlers.go +++ b/cmd/object-lambda-handlers.go @@ -23,6 +23,8 @@ import ( "io" "net/http" "net/url" + "strconv" + "strings" "time" "github.com/klauspost/compress/gzhttp" @@ -39,7 +41,7 @@ import ( "github.com/minio/minio/internal/logger" ) -func getLambdaEventData(bucket, object string, cred auth.Credentials, r *http.Request) (levent.Event, error) { +var getLambdaEventData = func(bucket, object string, cred auth.Credentials, r *http.Request) (levent.Event, error) { host := globalLocalNodeName secure := globalIsTLS if globalMinioEndpointURL != nil { @@ -100,80 +102,6 @@ func getLambdaEventData(bucket, object string, cred auth.Credentials, r *http.Re return eventData, nil } -var statusTextToCode = map[string]int{ - "Continue": http.StatusContinue, - "Switching Protocols": http.StatusSwitchingProtocols, - "Processing": http.StatusProcessing, - "Early Hints": http.StatusEarlyHints, - "OK": http.StatusOK, - "Created": http.StatusCreated, - "Accepted": http.StatusAccepted, - "Non-Authoritative Information": http.StatusNonAuthoritativeInfo, - "No Content": http.StatusNoContent, - "Reset Content": http.StatusResetContent, - "Partial Content": http.StatusPartialContent, - "Multi-Status": http.StatusMultiStatus, - "Already Reported": http.StatusAlreadyReported, - "IM Used": http.StatusIMUsed, - "Multiple Choices": http.StatusMultipleChoices, - "Moved Permanently": http.StatusMovedPermanently, - "Found": http.StatusFound, - "See Other": http.StatusSeeOther, - "Not Modified": http.StatusNotModified, - "Use Proxy": http.StatusUseProxy, - "Temporary Redirect": http.StatusTemporaryRedirect, - "Permanent Redirect": http.StatusPermanentRedirect, - "Bad Request": http.StatusBadRequest, - "Unauthorized": http.StatusUnauthorized, - "Payment Required": http.StatusPaymentRequired, - "Forbidden": http.StatusForbidden, - "Not Found": http.StatusNotFound, - "Method Not Allowed": http.StatusMethodNotAllowed, - "Not Acceptable": http.StatusNotAcceptable, - "Proxy Authentication Required": http.StatusProxyAuthRequired, - "Request Timeout": http.StatusRequestTimeout, - "Conflict": http.StatusConflict, - "Gone": http.StatusGone, - "Length Required": http.StatusLengthRequired, - "Precondition Failed": http.StatusPreconditionFailed, - "Request Entity Too Large": http.StatusRequestEntityTooLarge, - "Request URI Too Long": http.StatusRequestURITooLong, - "Unsupported Media Type": http.StatusUnsupportedMediaType, - "Requested Range Not Satisfiable": http.StatusRequestedRangeNotSatisfiable, - "Expectation Failed": http.StatusExpectationFailed, - "I'm a teapot": http.StatusTeapot, - "Misdirected Request": http.StatusMisdirectedRequest, - "Unprocessable Entity": http.StatusUnprocessableEntity, - "Locked": http.StatusLocked, - "Failed Dependency": http.StatusFailedDependency, - "Too Early": http.StatusTooEarly, - "Upgrade Required": http.StatusUpgradeRequired, - "Precondition Required": http.StatusPreconditionRequired, - "Too Many Requests": http.StatusTooManyRequests, - "Request Header Fields Too Large": http.StatusRequestHeaderFieldsTooLarge, - "Unavailable For Legal Reasons": http.StatusUnavailableForLegalReasons, - "Internal Server Error": http.StatusInternalServerError, - "Not Implemented": http.StatusNotImplemented, - "Bad Gateway": http.StatusBadGateway, - "Service Unavailable": http.StatusServiceUnavailable, - "Gateway Timeout": http.StatusGatewayTimeout, - "HTTP Version Not Supported": http.StatusHTTPVersionNotSupported, - "Variant Also Negotiates": http.StatusVariantAlsoNegotiates, - "Insufficient Storage": http.StatusInsufficientStorage, - "Loop Detected": http.StatusLoopDetected, - "Not Extended": http.StatusNotExtended, - "Network Authentication Required": http.StatusNetworkAuthenticationRequired, -} - -// StatusCode returns a HTTP Status code for the HTTP text. It returns -1 -// if the text is unknown. -func StatusCode(text string) int { - if code, ok := statusTextToCode[text]; ok { - return code - } - return -1 -} - func fwdHeadersToS3(h http.Header, w http.ResponseWriter) { const trim = "x-amz-fwd-header-" for k, v := range h { @@ -183,19 +111,26 @@ func fwdHeadersToS3(h http.Header, w http.ResponseWriter) { } } -func fwdStatusToAPIError(resp *http.Response) *APIError { - if status := resp.Header.Get(xhttp.AmzFwdStatus); status != "" && StatusCode(status) > -1 { - apiErr := &APIError{ - HTTPStatusCode: StatusCode(status), - Description: resp.Header.Get(xhttp.AmzFwdErrorMessage), - Code: resp.Header.Get(xhttp.AmzFwdErrorCode), - } - if apiErr.HTTPStatusCode == http.StatusOK { - return nil - } - return apiErr +func fwdStatusToAPIError(statusCode int, resp *http.Response) *APIError { + if statusCode < http.StatusBadRequest { + return nil + } + desc := resp.Header.Get(xhttp.AmzFwdErrorMessage) + if strings.TrimSpace(desc) == "" { + apiErr := errorCodes.ToAPIErr(ErrInvalidRequest) + return &apiErr + } + code := resp.Header.Get(xhttp.AmzFwdErrorCode) + if strings.TrimSpace(code) == "" { + apiErr := errorCodes.ToAPIErr(ErrInvalidRequest) + apiErr.Description = desc + return &apiErr + } + return &APIError{ + HTTPStatusCode: statusCode, + Description: desc, + Code: code, } - return nil } // GetObjectLambdaHandler - GET Object with transformed data via lambda functions @@ -262,26 +197,31 @@ func (api objectAPIHandlers) GetObjectLambdaHandler(w http.ResponseWriter, r *ht return } + statusCode := resp.StatusCode + if status := resp.Header.Get(xhttp.AmzFwdStatus); status != "" { + statusCode, err = strconv.Atoi(status) + if err != nil { + writeErrorResponse(ctx, w, APIError{ + Code: "LambdaFunctionStatusError", + HTTPStatusCode: http.StatusBadRequest, + Description: err.Error(), + }, r.URL) + return + } + } + // Set all the relevant lambda forward headers if found. fwdHeadersToS3(resp.Header, w) - if apiErr := fwdStatusToAPIError(resp); apiErr != nil { + if apiErr := fwdStatusToAPIError(statusCode, resp); apiErr != nil { writeErrorResponse(ctx, w, *apiErr, r.URL) return } - if resp.StatusCode != http.StatusOK { - writeErrorResponse(ctx, w, APIError{ - Code: "LambdaFunctionError", - HTTPStatusCode: resp.StatusCode, - Description: "unexpected failure reported from lambda function", - }, r.URL) - return - } - if !globalAPIConfig.shouldGzipObjects() { w.Header().Set(gzhttp.HeaderNoCompression, "true") } + w.WriteHeader(statusCode) io.Copy(w, resp.Body) } diff --git a/cmd/object-lambda-handlers_test.go b/cmd/object-lambda-handlers_test.go new file mode 100644 index 000000000..458034fec --- /dev/null +++ b/cmd/object-lambda-handlers_test.go @@ -0,0 +1,174 @@ +// Copyright (c) 2015-2025 MinIO, Inc. +// +// This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" + + "github.com/minio/minio-go/v7/pkg/signer" + "github.com/minio/minio/internal/auth" + "github.com/minio/minio/internal/config" + "github.com/minio/minio/internal/config/lambda" + levent "github.com/minio/minio/internal/config/lambda/event" + xhttp "github.com/minio/minio/internal/http" +) + +func TestGetObjectLambdaHandler(t *testing.T) { + testCases := []struct { + name string + statusCode int + body string + contentType string + expectStatus int + }{ + { + name: "Success 206 Partial Content", + statusCode: 206, + body: "partial-object-data", + contentType: "text/plain", + expectStatus: 206, + }, + { + name: "Success 200 OK", + statusCode: 200, + body: "full-object-data", + contentType: "application/json", + expectStatus: 200, + }, + { + name: "Client Error 400", + statusCode: 400, + body: "bad-request", + contentType: "application/xml", + expectStatus: 400, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runObjectLambdaTest(t, tc.statusCode, tc.body, tc.contentType, tc.expectStatus) + }) + } +} + +func runObjectLambdaTest(t *testing.T, lambdaStatus int, lambdaBody, contentType string, expectStatus int) { + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, + objAPITest: func(obj ObjectLayer, instanceType, bucketName string, apiRouter http.Handler, credentials auth.Credentials, t *testing.T) { + objectName := "dummy-object" + functionID := "lambda1" + functionToken := "token123" + + // Lambda mock server + lambdaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(xhttp.AmzRequestRoute, functionID) + w.Header().Set(xhttp.AmzRequestToken, functionToken) + w.Header().Set(xhttp.AmzFwdHeaderContentType, contentType) + w.Header().Set(xhttp.AmzFwdStatus, strconv.Itoa(lambdaStatus)) + w.WriteHeader(lambdaStatus) + w.Write([]byte(lambdaBody)) + })) + defer lambdaServer.Close() + + lambdaARN := "arn:minio:s3-object-lambda::lambda1:webhook" + + cfg := config.New() + cfg[config.LambdaWebhookSubSys] = map[string]config.KVS{ + functionID: { + {Key: "endpoint", Value: lambdaServer.URL}, + {Key: "enable", Value: config.EnableOn}, + }, + } + cfg[config.APISubSys] = map[string]config.KVS{ + "api": { + {Key: "gzip", Value: config.EnableOff}, + }, + } + + var err error + globalLambdaTargetList, err = lambda.FetchEnabledTargets(context.Background(), cfg, http.DefaultTransport.(*http.Transport)) + if err != nil { + t.Fatalf("failed to load lambda targets: %v", err) + } + + getLambdaEventData = func(_, _ string, _ auth.Credentials, _ *http.Request) (levent.Event, error) { + return levent.Event{ + GetObjectContext: &levent.GetObjectContext{ + OutputRoute: functionID, + OutputToken: functionToken, + InputS3URL: "http://localhost/dummy", + }, + UserRequest: levent.UserRequest{ + Headers: map[string][]string{}, + }, + UserIdentity: levent.Identity{ + PrincipalID: "test-user", + }, + }, nil + } + + body := []byte{} + req := httptest.NewRequest("GET", "/objectlambda/"+bucketName+"/"+objectName+"?lambdaArn="+url.QueryEscape(lambdaARN), bytes.NewReader(body)) + req.Form = url.Values{"lambdaArn": []string{lambdaARN}} + req.Header.Set("Host", "localhost") + req.Header.Set("X-Amz-Date", time.Now().UTC().Format("20060102T150405Z")) + sum := sha256.Sum256(body) + req.Header.Set("X-Amz-Content-Sha256", hex.EncodeToString(sum[:])) + req = signer.SignV4(*req, credentials.AccessKey, credentials.SecretKey, "", "us-east-1") + + rec := httptest.NewRecorder() + api := objectAPIHandlers{ + ObjectAPI: func() ObjectLayer { + return obj + }, + } + api.GetObjectLambdaHandler(rec, req) + + res := rec.Result() + defer res.Body.Close() + respBody, _ := io.ReadAll(res.Body) + + if res.StatusCode != expectStatus { + t.Errorf("Expected status %d, got %d", expectStatus, res.StatusCode) + } + + if contentType != "" { + if ct := res.Header.Get("Content-Type"); ct != contentType { + t.Errorf("Expected Content-Type %q, got %q", contentType, ct) + } + } + + if res.StatusCode < 400 { + if string(respBody) != lambdaBody { + t.Errorf("Expected body %q, got %q", lambdaBody, string(respBody)) + } + } + }, + endpoints: []string{"GetObject"}, + }) +} diff --git a/internal/logger/target/http/http.go b/internal/logger/target/http/http.go index 55c933e40..a877cf162 100644 --- a/internal/logger/target/http/http.go +++ b/internal/logger/target/http/http.go @@ -35,7 +35,7 @@ import ( jsoniter "github.com/json-iterator/go" xhttp "github.com/minio/minio/internal/http" xioutil "github.com/minio/minio/internal/ioutil" - "github.com/minio/minio/internal/logger/target/types" + types "github.com/minio/minio/internal/logger/target/loggertypes" "github.com/minio/minio/internal/once" "github.com/minio/minio/internal/store" xnet "github.com/minio/pkg/v3/net" diff --git a/internal/logger/target/kafka/kafka.go b/internal/logger/target/kafka/kafka.go index a6034adf4..2fb488914 100644 --- a/internal/logger/target/kafka/kafka.go +++ b/internal/logger/target/kafka/kafka.go @@ -35,7 +35,7 @@ import ( saramatls "github.com/IBM/sarama/tools/tls" xioutil "github.com/minio/minio/internal/ioutil" - "github.com/minio/minio/internal/logger/target/types" + types "github.com/minio/minio/internal/logger/target/loggertypes" "github.com/minio/minio/internal/once" "github.com/minio/minio/internal/store" xnet "github.com/minio/pkg/v3/net" diff --git a/internal/logger/target/types/targettype_string.go b/internal/logger/target/loggertypes/targettype_string.go similarity index 97% rename from internal/logger/target/types/targettype_string.go rename to internal/logger/target/loggertypes/targettype_string.go index 6aa5e3974..715a9fef1 100644 --- a/internal/logger/target/types/targettype_string.go +++ b/internal/logger/target/loggertypes/targettype_string.go @@ -1,6 +1,6 @@ // Code generated by "stringer -type=TargetType -trimprefix=Target types.go"; DO NOT EDIT. -package types +package loggertypes import "strconv" diff --git a/internal/logger/target/types/types.go b/internal/logger/target/loggertypes/types.go similarity index 98% rename from internal/logger/target/types/types.go rename to internal/logger/target/loggertypes/types.go index d20b0cc02..8d8e71041 100644 --- a/internal/logger/target/types/types.go +++ b/internal/logger/target/loggertypes/types.go @@ -15,7 +15,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package types +package loggertypes // TargetType indicates type of the target e.g. console, http, kafka type TargetType uint8 diff --git a/internal/logger/target/testlogger/testlogger.go b/internal/logger/target/testlogger/testlogger.go index 52cff89e7..d2b4149a0 100644 --- a/internal/logger/target/testlogger/testlogger.go +++ b/internal/logger/target/testlogger/testlogger.go @@ -36,7 +36,7 @@ import ( "github.com/minio/madmin-go/v3/logger/log" "github.com/minio/minio/internal/logger" - "github.com/minio/minio/internal/logger/target/types" + types "github.com/minio/minio/internal/logger/target/loggertypes" ) const ( diff --git a/internal/logger/targets.go b/internal/logger/targets.go index 26f0c7b97..b4df2e5c8 100644 --- a/internal/logger/targets.go +++ b/internal/logger/targets.go @@ -25,7 +25,7 @@ import ( "github.com/minio/minio/internal/logger/target/http" "github.com/minio/minio/internal/logger/target/kafka" - "github.com/minio/minio/internal/logger/target/types" + types "github.com/minio/minio/internal/logger/target/loggertypes" ) // Target is the entity that we will receive