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  }