go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/buildbucket/appengine/taskbackendlite/run_task.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 main
    16  
    17  import (
    18  	"context"
    19  	"encoding/json"
    20  	"fmt"
    21  	"strings"
    22  	"time"
    23  
    24  	"cloud.google.com/go/pubsub"
    25  	"github.com/golang/protobuf/proto"
    26  	"google.golang.org/grpc/codes"
    27  	"google.golang.org/grpc/status"
    28  
    29  	"go.chromium.org/luci/auth/identity"
    30  	"go.chromium.org/luci/common/errors"
    31  	"go.chromium.org/luci/common/logging"
    32  	"go.chromium.org/luci/gae/service/info"
    33  	"go.chromium.org/luci/server/auth"
    34  	"go.chromium.org/luci/server/caching"
    35  
    36  	"go.chromium.org/luci/buildbucket/appengine/internal/clients"
    37  	pb "go.chromium.org/luci/buildbucket/proto"
    38  	"go.chromium.org/luci/buildbucket/protoutil"
    39  )
    40  
    41  const (
    42  	DefaultTaskCreationTimeout = 10 * time.Minute
    43  	TopicIDFormat              = "taskbackendlite-%s"
    44  )
    45  
    46  // TaskBackendLite implements pb.TaskBackendLiteServer.
    47  type TaskBackendLite struct {
    48  	pb.UnimplementedTaskBackendLiteServer
    49  }
    50  
    51  // Ensure TaskBackendLite implements pb.TaskBackendLiteServer.
    52  var _ pb.TaskBackendLiteServer = &TaskBackendLite{}
    53  
    54  // NewTaskBackendLite returns a new pb.BuildsServer.
    55  func NewTaskBackendLite() pb.TaskBackendLiteServer {
    56  	return &TaskBackendLite{}
    57  }
    58  
    59  // TaskNotification is to notify users about a task creation event.
    60  // TaskBackendLite will publish this message to Cloud pubsub in its json format.
    61  type TaskNotification struct {
    62  	BuildID         string `json:"build_id"`
    63  	StartBuildToken string `json:"start_build_token"`
    64  }
    65  
    66  // RunTask handles the request to create a task. Implements pb.TaskBackendLiteServer.
    67  func (t *TaskBackendLite) RunTask(ctx context.Context, req *pb.RunTaskRequest) (*pb.RunTaskResponse, error) {
    68  	logging.Debugf(ctx, "%q called RunTask with request %s", auth.CurrentIdentity(ctx), proto.MarshalTextString(req))
    69  	project, err := t.checkPerm(ctx)
    70  	if err != nil {
    71  		return nil, err
    72  	}
    73  
    74  	// dummyTaskID format is <BuildId>_<RequestId> to make it globally unique.
    75  	dummyTaskID := fmt.Sprintf("%s_%s", req.BuildId, req.RequestId)
    76  	resp := &pb.RunTaskResponse{
    77  		Task: &pb.Task{
    78  			Id: &pb.TaskID{
    79  				Id:     dummyTaskID,
    80  				Target: req.Target,
    81  			},
    82  			UpdateId: 1,
    83  		},
    84  	}
    85  
    86  	// Not process the same request more than once to make the entire operation idempotent.
    87  	cache := caching.GlobalCache(ctx, "taskbackendlite-run-task")
    88  	if cache == nil {
    89  		return nil, status.Errorf(codes.Internal, "cannot find the global cache")
    90  	}
    91  	taskCached, err := cache.Get(ctx, dummyTaskID)
    92  	switch {
    93  	case errors.Is(err, caching.ErrCacheMiss):
    94  	case err != nil:
    95  		return nil, status.Errorf(codes.Internal, "cannot read %s from the global cache", dummyTaskID)
    96  	case taskCached != nil:
    97  		logging.Infof(ctx, "this task(%s) has been handled before, ignoring", dummyTaskID)
    98  		return resp, nil
    99  	}
   100  
   101  	// Publish the msg into Pubsub by using the project-scoped identity.
   102  	psClient, err := clients.NewPubsubClient(ctx, info.AppID(ctx), project)
   103  	if err != nil {
   104  		return nil, status.Errorf(codes.Internal, "error when creating Pub/Sub client: %s", err)
   105  	}
   106  	defer psClient.Close()
   107  	topicID := fmt.Sprintf(TopicIDFormat, project)
   108  	topic := psClient.Topic(topicID)
   109  	defer topic.Stop()
   110  	data, err := json.MarshalIndent(&TaskNotification{
   111  		BuildID:         req.BuildId,
   112  		StartBuildToken: req.Secrets.StartBuildToken,
   113  	}, "", "  ")
   114  	if err != nil {
   115  		return nil, status.Errorf(codes.Internal, "failed to compose pubsub message: %s", err)
   116  	}
   117  
   118  	proj, bucket, builder := extractBuilderInfo(req)
   119  	result := topic.Publish(ctx, &pubsub.Message{
   120  		Data: data,
   121  		Attributes: map[string]string{
   122  			"dummy_task_id": dummyTaskID, // can be used for deduplication on the subscriber side.
   123  			"project":       proj,
   124  			"bucket":        bucket,
   125  			"builder":       builder,
   126  		},
   127  	})
   128  	if _, err = result.Get(ctx); err != nil {
   129  		switch status.Code(err) {
   130  		case codes.PermissionDenied:
   131  			return nil, status.Errorf(codes.PermissionDenied, "luci project scoped account(%s) does not have the permission to publish to topic %s", project, topicID)
   132  		case codes.NotFound:
   133  			return nil, status.Errorf(codes.InvalidArgument, "topic %s does not exist on Cloud project %s", topicID, psClient.Project())
   134  		}
   135  		return nil, status.Errorf(codes.Internal, "failed to publish pubsub message: %s", err)
   136  	}
   137  
   138  	// Save into cache
   139  	if err := cache.Set(ctx, dummyTaskID, []byte{1}, DefaultTaskCreationTimeout); err != nil {
   140  		// Ignore it. The cache is to dedup Buildbucket requests. Duplicate
   141  		// requests rarely happen. But if it returns the error back, Buildbucket
   142  		// will send the same request again, which shoots ourselves in the foot.
   143  		logging.Warningf(ctx, "failed to save the task %s into cache", dummyTaskID)
   144  	}
   145  	return resp, nil
   146  }
   147  
   148  // checkPerm checks if the caller has the correct access.
   149  // Returns PermissionDenied if the it has no permission.
   150  func (*TaskBackendLite) checkPerm(ctx context.Context) (string, error) {
   151  	s := auth.GetState(ctx)
   152  	if s == nil {
   153  		return "", status.Errorf(codes.Internal, "the auth state is not properly configured")
   154  	}
   155  	switch peer := s.PeerIdentity(); {
   156  	case strings.HasSuffix(info.AppID(ctx), "-dev") && peer == identity.Identity("user:cr-buildbucket-dev@appspot.gserviceaccount.com"): // on Dev
   157  	case !strings.HasSuffix(info.AppID(ctx), "-dev") && peer == identity.Identity("user:cr-buildbucket@appspot.gserviceaccount.com"): // on Prod
   158  	default:
   159  		return "", status.Errorf(codes.PermissionDenied, "the peer %q is not allowed to access this task backend", peer)
   160  	}
   161  
   162  	// In TaskBackendLite protocal, Buildbucket uses Project-scoped identity:
   163  	// https://chromium.googlesource.com/infra/luci/luci-go/+/574d2290aefbfe586b31bb43f89d9b2027f73a70/buildbucket/proto/backend.proto#337
   164  	user := s.User().Identity
   165  	if user.Kind() != identity.Project {
   166  		return "", status.Errorf(codes.PermissionDenied, "The caller's user identity %q is not a project identity", user)
   167  	}
   168  	return user.Value(), nil
   169  }
   170  
   171  // extractBuilderInfo extracts this RunTaskRequest's project, bucket and
   172  // builder info. If any info doesn't appear, return the empty string.
   173  func extractBuilderInfo(req *pb.RunTaskRequest) (string, string, string) {
   174  	proj, bucket, builder := "", "", ""
   175  	for _, tag := range req.BackendConfig.GetFields()["tags"].GetListValue().GetValues() {
   176  		switch key, val, _ := strings.Cut(tag.GetStringValue(), ":"); {
   177  		case key == "builder":
   178  			builder = val
   179  		case key == "buildbucket_bucket":
   180  			proj, bucket, _ = protoutil.ParseBucketID(val)
   181  		}
   182  	}
   183  	return proj, bucket, builder
   184  }