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  }