go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/swarming/server/pubsub/pubsub.go (about) 1 // Copyright 2023 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 // Package pubsub allows to install PubSub push handlers. 16 package pubsub 17 18 import ( 19 "context" 20 "encoding/json" 21 "io" 22 "net/http" 23 "net/url" 24 "time" 25 26 "google.golang.org/protobuf/encoding/prototext" 27 "google.golang.org/protobuf/proto" 28 29 "go.chromium.org/luci/auth/identity" 30 "go.chromium.org/luci/common/logging" 31 "go.chromium.org/luci/common/retry/transient" 32 "go.chromium.org/luci/server/auth" 33 "go.chromium.org/luci/server/auth/openid" 34 "go.chromium.org/luci/server/router" 35 ) 36 37 // HandlerOptions is a configuration of the PubSub push handler route. 38 type HandlerOptions struct { 39 // Route is a HTTP server route to install the handler under. 40 Route string 41 42 // PushServiceAccount is a service account email that PubSub uses to 43 // authenticate pushes. 44 // 45 // See https://cloud.google.com/pubsub/docs/authenticate-push-subscriptions. 46 PushServiceAccount string 47 } 48 49 // Message is a constraint on a message type for the handler callback. 50 type Message[T any] interface { 51 proto.Message 52 *T 53 } 54 55 // Metadata is all the extra information about the incoming pubsub push. 56 type Metadata struct { 57 // Subscription is the full subscription name that pushed the message. 58 Subscription string 59 // MessageID is the PubSub message ID. 60 MessageID string 61 // PublishTime is when the message was published 62 PublishTime time.Time 63 // Attributes is PubSub message attributes of the published message. 64 Attributes map[string]string 65 // Query is the query part of the HTTP request string. 66 Query url.Values 67 } 68 69 // InstallHandler installs a route that processes PubSub push messages. 70 // 71 // If the handler callback returns an error tagged with transient.Tag, the 72 // response will have HTTP 500 code, which will trigger redelivery on the 73 // message (per PubSub subscription retry policy). 74 // 75 // No errors or errors without transient.Tag result in HTTP 2xx replies. PubSub 76 // will redeliver the message. 77 func InstallHandler[T any, M Message[T]]( 78 r *router.Router, 79 opts HandlerOptions, 80 cb func(ctx context.Context, msg M, md *Metadata) error, 81 ) { 82 // Authenticate requests based on OpenID identity tokens. 83 oidcMW := router.NewMiddlewareChain( 84 auth.Authenticate(&openid.GoogleIDTokenAuthMethod{ 85 AudienceCheck: openid.AudienceMatchesHost, 86 }), 87 ) 88 89 // Expected authenticated identity of the PubSub service. 90 pusherID, err := identity.MakeIdentity("user:" + opts.PushServiceAccount) 91 if err != nil { 92 panic(err) 93 } 94 95 r.POST(opts.Route, oidcMW, func(ctx *router.Context) { handler(ctx, pusherID, cb) }) 96 } 97 98 // handler is actual POST request handler extract into a separate function for 99 // easier testing. 100 func handler[T any, M Message[T]]( 101 ctx *router.Context, 102 pusherID identity.Identity, 103 cb func(ctx context.Context, msg M, md *Metadata) error, 104 ) { 105 rctx := ctx.Request.Context() 106 107 if got := auth.CurrentIdentity(rctx); got != pusherID { 108 logging.Errorf(rctx, "Expecting ID token of %q, got %q", pusherID, got) 109 ctx.Writer.WriteHeader(http.StatusForbidden) 110 return 111 } 112 113 bodyBlob, err := io.ReadAll(ctx.Request.Body) 114 if err != nil { 115 logging.Errorf(rctx, "Failed to read request body: %s", err) 116 ctx.Writer.WriteHeader(http.StatusInternalServerError) 117 return 118 } 119 120 // Deserialize the push message wrapper. 121 var body pushRequestBody 122 if err := json.Unmarshal(bodyBlob, &body); err != nil { 123 logging.Errorf(rctx, "Bad push request body (%s):\n%s", err, bodyBlob) 124 ctx.Writer.WriteHeader(http.StatusBadRequest) 125 return 126 } 127 128 // Deserialize the message payload. 129 var msg T 130 if err := proto.Unmarshal(body.Message.Data, M(&msg)); err != nil { 131 logging.Errorf(rctx, "Failed to deserialize push message (%s):\n%s", err, bodyBlob) 132 ctx.Writer.WriteHeader(http.StatusBadRequest) 133 return 134 } 135 136 // Pass to the handler. 137 md := Metadata{ 138 MessageID: body.Message.MessageID, 139 Subscription: body.Subscription, 140 PublishTime: body.Message.PublishTime, 141 Attributes: body.Message.Attributes, 142 Query: ctx.Request.URL.Query(), 143 } 144 err = cb(rctx, &msg, &md) 145 if err == nil { 146 ctx.Writer.WriteHeader(http.StatusOK) 147 return 148 } 149 150 if transient.Tag.In(err) { 151 // Transient error, trigger a retry by returning 5xx response. 152 logging.Errorf(rctx, "Transient error: %s", err) 153 ctx.Writer.WriteHeader(http.StatusInternalServerError) 154 } else { 155 // Fatal error, do not trigger a retry by returning 2xx response. 156 logging.Errorf(rctx, "Fatal error: %s", err) 157 ctx.Writer.WriteHeader(http.StatusAccepted) 158 } 159 160 // Log details of the message that caused the error. 161 logging.Infof(rctx, "MessageID: %s", md.MessageID) 162 logging.Infof(rctx, "Subscription: %s", md.Subscription) 163 logging.Infof(rctx, "PublishTime: %s", md.PublishTime) 164 logging.Infof(rctx, "Attributes: %v", md.Attributes) 165 logging.Infof(rctx, "Query: %s", md.Query) 166 bodyPB, _ := prototext.Marshal(M(&msg)) 167 logging.Infof(rctx, "Message body:\n%s", bodyPB) 168 } 169 170 // pushRequestBody is a JSON body of a messages of a wrapped push subscription. 171 // 172 // See https://cloud.google.com/pubsub/docs/push. 173 type pushRequestBody struct { 174 Message struct { 175 Attributes map[string]string `json:"attributes,omitempty"` 176 Data []byte `json:"data"` 177 MessageID string `json:"message_id"` 178 PublishTime time.Time `json:"publish_time"` 179 } `json:"message"` 180 Subscription string `json:"subscription"` 181 }