go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/buildbucket/appengine/tasks/notification.go (about) 1 // Copyright 2020 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 tasks 16 17 import ( 18 "context" 19 "strconv" 20 21 "cloud.google.com/go/pubsub" 22 "go.chromium.org/luci/common/data/stringset" 23 "google.golang.org/protobuf/encoding/protojson" 24 "google.golang.org/protobuf/proto" 25 26 pb "go.chromium.org/luci/buildbucket/proto" 27 "go.chromium.org/luci/common/errors" 28 "go.chromium.org/luci/common/logging" 29 "go.chromium.org/luci/common/retry/transient" 30 "go.chromium.org/luci/gae/service/datastore" 31 "go.chromium.org/luci/server/tq" 32 33 "go.chromium.org/luci/buildbucket/appengine/internal/clients" 34 "go.chromium.org/luci/buildbucket/appengine/internal/compression" 35 "go.chromium.org/luci/buildbucket/appengine/model" 36 taskdefs "go.chromium.org/luci/buildbucket/appengine/tasks/defs" 37 "go.chromium.org/luci/buildbucket/protoutil" 38 ) 39 40 // TODO(crbug.com/1410912): Remove the it once flutter-dashboard is able to 41 // handle the new format. 42 var pyPusbubCallbackAllowlist = stringset.NewFromSlice( 43 "projects/flutter-dashboard/topics/luci-builds", 44 "projects/flutter-dashboard/topics/luci-builds-prod", 45 ) 46 47 // notifyPubSub enqueues tasks to Python side. 48 func notifyPubSub(ctx context.Context, task *taskdefs.NotifyPubSub) error { 49 if task.GetBuildId() == 0 { 50 return errors.Reason("build_id is required").Err() 51 } 52 return tq.AddTask(ctx, &tq.Task{ 53 Payload: task, 54 }) 55 } 56 57 // NotifyPubSub enqueues tasks to notify Pub/Sub about the given build. 58 func NotifyPubSub(ctx context.Context, b *model.Build) error { 59 // TODO(crbug.com/1406393#c5): Stop pushing into Python side `builds` topic 60 // once all subscribers moved away. 61 if err := notifyPubSub(ctx, &taskdefs.NotifyPubSub{ 62 BuildId: b.ID, 63 }); err != nil { 64 return errors.Annotate(err, "failed to enqueue global pubsub notification task: %d", b.ID).Err() 65 } 66 67 if err := tq.AddTask(ctx, &tq.Task{ 68 Payload: &taskdefs.NotifyPubSubGoProxy{ 69 BuildId: b.ID, 70 Project: b.Proto.GetBuilder().GetProject(), 71 }, 72 }); err != nil { 73 return errors.Annotate(err, "failed to enqueue NotifyPubSubGoProxy task: %d", b.ID).Err() 74 } 75 76 if b.PubSubCallback.Topic == "" { 77 return nil 78 } 79 80 // TODO(crbug.com/1410912): Remove the it once flutter-dashboard is able to 81 // handle the new format. 82 if pyPusbubCallbackAllowlist.Has(b.PubSubCallback.Topic) { 83 logging.Warningf(ctx, "Routing to Python side to handle pubsub callback for build %d", b.ID) 84 err := notifyPubSub(ctx, &taskdefs.NotifyPubSub{BuildId: b.ID, Callback: true}) 85 return errors.Annotate(err, "failed to enqueue pubsub callback task to Python side for build: %d", b.ID).Err() 86 } 87 88 if err := tq.AddTask(ctx, &tq.Task{ 89 Payload: &taskdefs.NotifyPubSubGo{ 90 BuildId: b.ID, 91 Topic: &pb.BuildbucketCfg_Topic{Name: b.PubSubCallback.Topic}, 92 Callback: true, 93 }, 94 }); err != nil { 95 return errors.Annotate(err, "failed to enqueue Go callback pubsub notification task: %d", b.ID).Err() 96 } 97 return nil 98 } 99 100 // EnqueueNotifyPubSubGo dispatches NotifyPubSubGo tasks to send builds_v2 101 // notifications. 102 func EnqueueNotifyPubSubGo(ctx context.Context, buildID int64, project string) error { 103 // Enqueue a task for publishing to the internal global "builds_v2" topic. 104 err := tq.AddTask(ctx, &tq.Task{ 105 Payload: &taskdefs.NotifyPubSubGo{ 106 BuildId: buildID, 107 }, 108 }) 109 if err != nil { 110 return errors.Annotate(err, "failed to enqueue a notification task to builds_v2 topic for build %d", buildID).Err() 111 } 112 113 proj := &model.Project{ 114 ID: project, 115 } 116 if err := errors.Filter(datastore.Get(ctx, proj), datastore.ErrNoSuchEntity); err != nil { 117 return errors.Annotate(err, "failed to fetch project %s for %d", project, buildID).Err() 118 } 119 for _, t := range proj.CommonConfig.GetBuildsNotificationTopics() { 120 if t.Name == "" { 121 continue 122 } 123 if err := tq.AddTask(ctx, &tq.Task{ 124 Payload: &taskdefs.NotifyPubSubGo{ 125 BuildId: buildID, 126 Topic: t, 127 }, 128 }); err != nil { 129 return errors.Annotate(err, "failed to enqueue notification task: %d for external topic %s ", buildID, t.Name).Err() 130 } 131 } 132 return nil 133 } 134 135 // PublishBuildsV2Notification is the handler of notify-pubsub-go where it 136 // actually sends build notifications to the internal or external topic. 137 func PublishBuildsV2Notification(ctx context.Context, buildID int64, topic *pb.BuildbucketCfg_Topic, callback bool) error { 138 b := &model.Build{ID: buildID} 139 switch err := datastore.Get(ctx, b); { 140 case err == datastore.ErrNoSuchEntity: 141 logging.Warningf(ctx, "cannot find build %d", buildID) 142 return nil 143 case err != nil: 144 return errors.Annotate(err, "error fetching build %d", buildID).Tag(transient.Tag).Err() 145 } 146 147 p, err := b.ToProto(ctx, model.NoopBuildMask, nil) 148 if err != nil { 149 return errors.Annotate(err, "failed to convert build to proto when in publishing builds_v2 flow").Err() 150 } 151 152 // Drop input/output properties and steps, and move them into build_large_fields. 153 buildLarge := &pb.Build{ 154 Input: &pb.Build_Input{ 155 Properties: p.Input.GetProperties(), 156 }, 157 Output: &pb.Build_Output{ 158 Properties: p.Output.GetProperties(), 159 }, 160 Steps: p.Steps, 161 } 162 p.Steps = nil 163 if p.Input != nil { 164 p.Input.Properties = nil 165 } 166 if p.Output != nil { 167 p.Output.Properties = nil 168 } 169 170 buildLargeBytes, err := proto.Marshal(buildLarge) 171 if err != nil { 172 return errors.Annotate(err, "failed to marshal buildLarge").Err() 173 } 174 var compressed []byte 175 // If topic is nil or empty, it gets Compression_ZLIB. 176 switch topic.GetCompression() { 177 case pb.Compression_ZLIB: 178 compressed, err = compression.ZlibCompress(buildLargeBytes) 179 case pb.Compression_ZSTD: 180 compressed = make([]byte, 0, len(buildLargeBytes)/2) // hope for at least 2x compression 181 compressed = compression.ZstdCompress(buildLargeBytes, compressed) 182 default: 183 return tq.Fatal.Apply(errors.Reason("unsupported compression method %s", topic.GetCompression().String()).Err()) 184 } 185 if err != nil { 186 return errors.Annotate(err, "failed to compress large fields for %d", buildID).Err() 187 } 188 189 bldV2 := &pb.BuildsV2PubSub{ 190 Build: p, 191 BuildLargeFields: compressed, 192 Compression: topic.GetCompression(), 193 } 194 195 prj := b.Project // represent the project to make the pubsub call. 196 var msg proto.Message 197 msg = bldV2 198 if callback { 199 msg = &pb.PubSubCallBack{ 200 BuildPubsub: bldV2, 201 UserData: b.PubSubCallback.UserData, 202 } 203 prj = "" // represent the service to make the pubsub call. 204 } 205 206 switch { 207 case topic.GetName() != "": 208 return publishToExternalTopic(ctx, msg, generateBuildsV2Attributes(p), topic.Name, prj) 209 default: 210 // publish to the internal `builds_v2` topic. 211 return tq.AddTask(ctx, &tq.Task{ 212 Payload: bldV2, 213 }) 214 } 215 } 216 217 // publishToExternalTopic publishes the given pubsub msg to the given topic 218 // with the identity of the luciProject account or current service account. 219 func publishToExternalTopic(ctx context.Context, msg proto.Message, attrs map[string]string, topicName, luciProject string) error { 220 cloudProj, topicID, err := clients.ValidatePubSubTopicName(topicName) 221 if err != nil { 222 return tq.Fatal.Apply(err) 223 } 224 225 blob, err := (protojson.MarshalOptions{Indent: "\t"}).Marshal(msg) 226 if err != nil { 227 return errors.Annotate(err, "failed to marshal pubsub message").Tag(tq.Fatal).Err() 228 } 229 230 psClient, err := clients.NewPubsubClient(ctx, cloudProj, luciProject) 231 defer psClient.Close() 232 if err != nil { 233 return transient.Tag.Apply(err) 234 } 235 236 topic := psClient.Topic(topicID) 237 defer topic.Stop() 238 result := topic.Publish(ctx, &pubsub.Message{ 239 Data: blob, 240 Attributes: attrs, 241 }) 242 _, err = result.Get(ctx) 243 return transient.Tag.Apply(err) 244 } 245 246 func generateBuildsV2Attributes(b *pb.Build) map[string]string { 247 if b == nil { 248 return map[string]string{} 249 } 250 return map[string]string{ 251 "project": b.Builder.GetProject(), 252 "bucket": b.Builder.GetBucket(), 253 "builder": b.Builder.GetBuilder(), 254 "is_completed": strconv.FormatBool(protoutil.IsEnded(b.Status)), 255 "version": "v2", 256 } 257 }