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 }