go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/scheduler/appengine/task/buildbucket/buildbucket.go (about)

     1  // Copyright 2015 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 buildbucket implements tasks that run Buildbucket jobs.
    16  package buildbucket
    17  
    18  import (
    19  	"bytes"
    20  	"context"
    21  	"encoding/base64"
    22  	"encoding/json"
    23  	"fmt"
    24  	"net/url"
    25  	"regexp"
    26  	"strings"
    27  	"time"
    28  
    29  	"github.com/golang/protobuf/jsonpb"
    30  	"github.com/golang/protobuf/proto"
    31  	"google.golang.org/grpc/codes"
    32  	"google.golang.org/protobuf/types/known/structpb"
    33  
    34  	"google.golang.org/api/pubsub/v1"
    35  
    36  	"go.chromium.org/luci/appengine/tq"
    37  	bbpb "go.chromium.org/luci/buildbucket/proto"
    38  	"go.chromium.org/luci/common/api/gitiles"
    39  	"go.chromium.org/luci/common/clock"
    40  	"go.chromium.org/luci/common/data/rand/mathrand"
    41  	"go.chromium.org/luci/common/data/strpair"
    42  	"go.chromium.org/luci/common/errors"
    43  	"go.chromium.org/luci/common/logging"
    44  	"go.chromium.org/luci/common/retry/transient"
    45  	"go.chromium.org/luci/config/validation"
    46  	"go.chromium.org/luci/gae/service/info"
    47  	"go.chromium.org/luci/grpc/grpcutil"
    48  	"go.chromium.org/luci/grpc/prpc"
    49  	"go.chromium.org/luci/server/auth/realms"
    50  
    51  	"go.chromium.org/luci/scheduler/api/scheduler/v1"
    52  	"go.chromium.org/luci/scheduler/appengine/internal"
    53  	"go.chromium.org/luci/scheduler/appengine/messages"
    54  	"go.chromium.org/luci/scheduler/appengine/task"
    55  	"go.chromium.org/luci/scheduler/appengine/task/utils"
    56  )
    57  
    58  const (
    59  	// Parameters of a periodic build status check timer.
    60  	statusCheckTimerName        = "check-buildbucket-build-status"
    61  	statusCheckTimerIntervalMin = time.Minute
    62  	statusCheckTimerIntervalMax = 10 * time.Minute
    63  
    64  	// Maximum number of triggers to be emitted into $recipe_engine/scheduler
    65  	// property. See also https://crbug.com/1006914.
    66  	maxTriggersAsSchedulerProperty = 100
    67  )
    68  
    69  // TaskManager implements task.Manager interface for tasks defined with
    70  // BuildbucketTask proto message.
    71  type TaskManager struct {
    72  }
    73  
    74  // Name is part of Manager interface.
    75  func (m TaskManager) Name() string {
    76  	return "buildbucket"
    77  }
    78  
    79  // ProtoMessageType is part of Manager interface.
    80  func (m TaskManager) ProtoMessageType() proto.Message {
    81  	return (*messages.BuildbucketTask)(nil)
    82  }
    83  
    84  // Traits is part of Manager interface.
    85  func (m TaskManager) Traits() task.Traits {
    86  	return task.Traits{
    87  		Multistage: true, // we use task.StatusRunning state
    88  	}
    89  }
    90  
    91  // ValidateProtoMessage is part of Manager interface.
    92  func (m TaskManager) ValidateProtoMessage(c *validation.Context, msg proto.Message, realmID string) {
    93  	cfg, ok := msg.(*messages.BuildbucketTask)
    94  	if !ok {
    95  		c.Errorf("wrong type %T, expecting *messages.BuildbucketTask", msg)
    96  		return
    97  	}
    98  	if cfg == nil {
    99  		c.Errorf("expecting a non-empty BuildbucketTask")
   100  		return
   101  	}
   102  
   103  	// Validate 'server' field.
   104  	switch {
   105  	case cfg.Server == "":
   106  		c.Errorf("field 'server' is required")
   107  	case strings.HasPrefix(cfg.Server, "https://") || strings.HasPrefix(cfg.Server, "http://"):
   108  		c.Errorf("field 'server' should be just a host, not a URL: %q", cfg.Server)
   109  	default:
   110  		u, err := url.Parse("https://" + cfg.Server)
   111  		switch {
   112  		case err != nil:
   113  			c.Errorf("field 'server' is not a valid hostname %q: %s", cfg.Server, err)
   114  		case !u.IsAbs() || u.Path != "":
   115  			c.Errorf("field 'server' is not a valid hostname %q", cfg.Server)
   116  		}
   117  	}
   118  
   119  	// Check can derive the bucket name.
   120  	if _, err := builderID(cfg, realmID); err != nil {
   121  		c.Errorf("%s", err)
   122  	}
   123  	if cfg.Builder == "" {
   124  		c.Errorf("'builder' field is required")
   125  	}
   126  
   127  	// Validate 'properties' and 'tags'.
   128  	if err := utils.ValidateKVList("property", cfg.Properties, ':'); err != nil {
   129  		c.Enter("properties")
   130  		c.Error(err)
   131  		c.Exit()
   132  	}
   133  	if err := utils.ValidateKVList("tag", cfg.Tags, ':'); err != nil {
   134  		c.Enter("tags")
   135  		c.Error(err)
   136  		c.Exit()
   137  		return
   138  	}
   139  	// Default tags can not be overridden.
   140  	defTags := defaultTags(nil, nil, nil)
   141  	for _, kv := range utils.UnpackKVList(cfg.Tags, ':') {
   142  		if _, ok := defTags[kv.Key]; ok {
   143  			c.Errorf("tag %q is reserved", kv.Key)
   144  		}
   145  	}
   146  }
   147  
   148  // defaultTags returns map with default set of tags.
   149  //
   150  // If context is nil, only keys are set.
   151  func defaultTags(c context.Context, ctl task.Controller, cfg *messages.BuildbucketTask) map[string]string {
   152  	if c != nil {
   153  		return map[string]string{
   154  			"scheduler_invocation_id": fmt.Sprintf("%d", ctl.InvocationID()),
   155  			"scheduler_job_id":        ctl.JobID(),
   156  			"user_agent":              info.AppID(c),
   157  		}
   158  	}
   159  	return map[string]string{
   160  		"scheduler_invocation_id": "",
   161  		"scheduler_job_id":        "",
   162  		"user_agent":              "",
   163  	}
   164  }
   165  
   166  // taskData is saved in Invocation.TaskData field.
   167  type taskData struct {
   168  	BuildID int64 `json:"build_id,omitempty,string"`
   169  }
   170  
   171  // writeTaskData puts information about the task into invocation's TaskData.
   172  func writeTaskData(ctl task.Controller, td *taskData) (err error) {
   173  	if ctl.State().TaskData, err = json.Marshal(td); err != nil {
   174  		return errors.Annotate(err, "could not serialize TaskData").Err()
   175  	}
   176  	return nil
   177  }
   178  
   179  // readTaskData parses task data blob as prepared by writeTaskData.
   180  func readTaskData(ctl task.Controller) (*taskData, error) {
   181  	td := &taskData{}
   182  	if err := json.Unmarshal(ctl.State().TaskData, td); err != nil {
   183  		return nil, errors.Annotate(err, "could not parse TaskData").Err()
   184  	}
   185  	return td, nil
   186  }
   187  
   188  // LaunchTask is part of Manager interface.
   189  func (m TaskManager) LaunchTask(c context.Context, ctl task.Controller) error {
   190  	cfg := ctl.Task().(*messages.BuildbucketTask) // already validated
   191  	req := ctl.Request()
   192  
   193  	// Generate full builder ID from the config. It should succeed since the
   194  	// config has been validated already.
   195  	bid, err := builderID(cfg, ctl.RealmID())
   196  	if err != nil {
   197  		return errors.Annotate(err, "unexpected bad bucket name in the task config").Err()
   198  	}
   199  
   200  	// Join tags from all known sources. Note: no overriding here for now, tags
   201  	// with identical keys are allowed.
   202  	tags := utils.KVListFromMap(defaultTags(c, ctl, cfg)).Pack(':')
   203  	tags = append(tags, cfg.Tags...)
   204  	tags = append(tags, req.Tags...)
   205  
   206  	// Prepare properties for the build. Properties from the request override the
   207  	// ones in the config.
   208  	props := &structpb.Struct{
   209  		Fields: make(map[string]*structpb.Value, len(cfg.Properties)+len(req.Properties.GetFields())),
   210  	}
   211  	for _, kv := range utils.UnpackKVList(cfg.Properties, ':') {
   212  		props.Fields[kv.Key] = strProtoValue(kv.Value)
   213  	}
   214  	for k, v := range req.Properties.GetFields() {
   215  		props.Fields[k] = v
   216  	}
   217  
   218  	// TODO(crbug.com/981945, crbug.com/939368): re-enable in chromium
   219  	if bid.Project != "chromium" && bid.Project != "chrome" {
   220  		var err error
   221  		if props.Fields["$recipe_engine/scheduler"], err = schedulerProperty(c, ctl); err != nil {
   222  			return fmt.Errorf("failed to generate scheduled property - %s", err)
   223  		}
   224  	}
   225  
   226  	// Extract GitilesCommit from the most recent trigger, if possible.
   227  	var commit *bbpb.GitilesCommit
   228  	if last := req.LastTrigger(); last != nil {
   229  		if gt := last.GetGitiles(); gt != nil {
   230  			commit, err = triggerToCommit(gt)
   231  			if err != nil {
   232  				return errors.Annotate(err, "failed to prepare gitiles_commit").Err()
   233  			}
   234  		}
   235  	}
   236  
   237  	// Process properties and tags that were used in Buildbucket v1 API, but now
   238  	// are forbidden in Buildbucket v2 API in favor of GitilesCommit. Some LUCI
   239  	// Scheduler users still pass them via EmitTriggers.
   240  	switch commitFromTags := popCommitFromTags(ctl, props, &tags); {
   241  	case commit == nil && commitFromTags != nil:
   242  		ctl.DebugLog("Reconstructed gitiles commit from tags")
   243  		commit = commitFromTags
   244  	case commit != nil && commitFromTags != nil:
   245  		if proto.Equal(commit, commitFromTags) {
   246  			ctl.DebugLog("Popped gitiles commit info from properties and tags")
   247  		} else {
   248  			ctl.DebugLog("crbug.com/1182002: Gitiles commit from triggers doesn't match the one from tags")
   249  			ctl.DebugLog("From triggers:\n%s", protoToJSON(commit))
   250  			ctl.DebugLog("From properties:\n%s", protoToJSON(commitFromTags))
   251  			ctl.DebugLog("Using the one from tags")
   252  			commit = commitFromTags // to match pre-v2 logic
   253  		}
   254  	}
   255  
   256  	// Make sure Buildbucket can publish PubSub messages, grab the token that
   257  	// would identify this invocation when receiving PubSub notifications.
   258  	serverURL := makeServerURL(cfg.Server)
   259  	ctl.DebugLog("Preparing PubSub topic for %q", serverURL)
   260  	topic, authToken, err := ctl.PrepareTopic(c, serverURL)
   261  	if err != nil {
   262  		ctl.DebugLog("Failed to prepare PubSub topic - %s", err)
   263  		return err
   264  	}
   265  	ctl.DebugLog("PubSub topic is %q", topic)
   266  
   267  	// Prepare the request.
   268  	request := &bbpb.ScheduleBuildRequest{
   269  		RequestId:     fmt.Sprintf("%d", ctl.InvocationID()),
   270  		Builder:       bid,
   271  		Properties:    props,
   272  		GitilesCommit: commit,
   273  		Tags:          toBuildbucketPairs(tags),
   274  		Notify: &bbpb.NotificationConfig{
   275  			PubsubTopic: topic,
   276  			UserData:    nil, // set a bit later, after printing this struct
   277  		},
   278  	}
   279  
   280  	// Serialize for debug log without PubSub auth token.
   281  	ctl.DebugLog("Buildbucket request:\n%s", protoToJSON(request))
   282  	request.Notify.UserData = []byte(authToken) // can put the token now
   283  
   284  	// The next call may take a while. Dump the current log to the datastore.
   285  	// Ignore errors here, it is best effort attempt to update the log.
   286  	ctl.Save(c)
   287  
   288  	// Send the request.
   289  	var build *bbpb.Build
   290  	err = m.withBuildbucket(c, ctl, func(ctx context.Context, bb bbpb.BuildsClient) (err error) {
   291  		build, err = bb.ScheduleBuild(ctx, request)
   292  		return
   293  	})
   294  	if err != nil {
   295  		ctl.DebugLog("Failed to schedule Buildbucket build - %s", err)
   296  		return grpcutil.WrapIfTransientOr(err, codes.DeadlineExceeded)
   297  	}
   298  
   299  	// Dump the response in full to the debug log. It doesn't contain any secrets.
   300  	ctl.DebugLog("Scheduled build:\n%s", protoToJSON(build))
   301  
   302  	// Save the build ID in the invocation, will be used later to make RPCs to
   303  	// Buildbucket to check build's status.
   304  	if err := writeTaskData(ctl, &taskData{BuildID: build.Id}); err != nil {
   305  		return err
   306  	}
   307  
   308  	// Successfully launched.
   309  	ctl.State().Status = task.StatusRunning
   310  	ctl.State().ViewURL = fmt.Sprintf("%s/build/%d", serverURL, build.Id)
   311  	ctl.DebugLog("Task URL: %s", ctl.State().ViewURL)
   312  
   313  	// Check if maybe finished already? It can happen if we are retrying the call
   314  	// with the same RequestId as a finished one.
   315  	handleBuildStatus(ctl, build)
   316  
   317  	// This will schedule status check if the task is actually running.
   318  	m.checkBuildStatusLater(c, ctl)
   319  	return nil
   320  }
   321  
   322  // AbortTask is part of Manager interface.
   323  func (m TaskManager) AbortTask(c context.Context, ctl task.Controller) error {
   324  	// This can happen if the invocation is aborted before it has even started.
   325  	// We don't have buildbucket build ID yet to cancel.
   326  	//
   327  	// There's a high chance that LaunchTask is executing concurrently somewhere.
   328  	// We let it finish peacefully, by not touching the invocation state at all
   329  	// and failing with a transient error instead. This avoids a collision on
   330  	// State modification. When such collision happens, results of LaunchTask
   331  	// (including a fresh build ID) are discarded (as the engine is unable to
   332  	// "merge" conflicting mutations from two different state transitions). This
   333  	// is really bad, since this process produces orphaned Buildbucket builds.
   334  	//
   335  	// So we pick a lesser evil and make AbortTask fail transiently while
   336  	// invocation is starting.
   337  	if status := ctl.State().Status; status.Initial() {
   338  		return errors.Reason("can't abort Buildbucket invocation in state %q", status).Tag(transient.Tag).Err()
   339  	}
   340  
   341  	// Grab build ID from the blob generated in LaunchTask.
   342  	taskData, err := readTaskData(ctl)
   343  	if err != nil {
   344  		ctl.State().Status = task.StatusFailed
   345  		return err
   346  	}
   347  
   348  	// Ask Buildbucket to cancel this build.
   349  	err = m.withBuildbucket(c, ctl, func(ctx context.Context, bb bbpb.BuildsClient) error {
   350  		_, err := bb.CancelBuild(ctx, &bbpb.CancelBuildRequest{
   351  			Id:              taskData.BuildID,
   352  			SummaryMarkdown: "Canceled via LUCI Scheduler",
   353  		})
   354  		return err
   355  	})
   356  	return grpcutil.WrapIfTransientOr(err, codes.DeadlineExceeded)
   357  }
   358  
   359  // ExamineNotification is part of Manager interface.
   360  func (m TaskManager) ExamineNotification(c context.Context, msg *pubsub.PubsubMessage) string {
   361  	// Buildbucket v1 builds have the token in attributes.
   362  	if tok := msg.Attributes["auth_token"]; tok != "" {
   363  		return tok
   364  	}
   365  	// Buildbucket v2 builds have the token as "user_data" in the JSON message
   366  	// body. The message body itself is base64-encoded.
   367  	blob, err := base64.StdEncoding.DecodeString(msg.Data)
   368  	if err != nil {
   369  		logging.Warningf(c, "PubSub message data is not base64: %s", err)
   370  		return ""
   371  	}
   372  
   373  	var body struct {
   374  		UserDataLegacy string `json:"user_data,omitempty"`
   375  		UserData       []byte `json:"userData,omitempty"`
   376  	}
   377  	if err := json.Unmarshal(blob, &body); err != nil {
   378  		logging.Warningf(c, "PubSub message is not valid JSON: %s", err)
   379  		return ""
   380  	}
   381  	if body.UserData != nil {
   382  		return string(body.UserData)
   383  	}
   384  	logging.Warningf(c, "crbug.com/1410912: PubSub legacy format")
   385  	return body.UserDataLegacy
   386  }
   387  
   388  // HandleNotification is part of Manager interface.
   389  func (m TaskManager) HandleNotification(c context.Context, ctl task.Controller, msg *pubsub.PubsubMessage) error {
   390  	ctl.DebugLog("Received PubSub notification, asking Buildbucket for the build status")
   391  	return m.checkBuildStatus(c, ctl)
   392  }
   393  
   394  // HandleTimer is part of Manager interface.
   395  func (m TaskManager) HandleTimer(c context.Context, ctl task.Controller, name string, payload []byte) error {
   396  	if name == statusCheckTimerName {
   397  		if err := m.checkBuildStatus(c, ctl); err != nil {
   398  			// This is either a fatal or transient error. If it is fatal, no need to
   399  			// schedule the timer anymore. If it is transient, HandleTimer call itself
   400  			// will be retried and the timer will be rescheduled then.
   401  			return err
   402  		}
   403  		m.checkBuildStatusLater(c, ctl) // reschedule this check
   404  	}
   405  	return nil
   406  }
   407  
   408  // GetDebugState is part of Manager interface.
   409  func (m TaskManager) GetDebugState(c context.Context, ctl task.ControllerReadOnly) (*internal.DebugManagerState, error) {
   410  	return nil, fmt.Errorf("no debug state")
   411  }
   412  
   413  func makeServerURL(s string) string {
   414  	if strings.HasPrefix(s, "http://") {
   415  		// Used only in tests where we hardcode http in cfg.Server because local
   416  		// server is http not https.
   417  		return s
   418  	}
   419  	return "https://" + s
   420  }
   421  
   422  // withBuildbucket makes a Buildbucket Builds API client and calls the callback.
   423  //
   424  // The callback runs under a new context with 1 min deadline.
   425  func (m TaskManager) withBuildbucket(c context.Context, ctl task.Controller, cb func(context.Context, bbpb.BuildsClient) error) error {
   426  	c, cancel := clock.WithTimeout(c, time.Minute)
   427  	defer cancel()
   428  
   429  	prpcClient := &prpc.Client{Options: prpc.DefaultOptions()}
   430  	var err error
   431  	if prpcClient.C, err = ctl.GetClient(c); err != nil {
   432  		return err
   433  	}
   434  
   435  	cfg := ctl.Task().(*messages.BuildbucketTask)
   436  	switch {
   437  	case strings.HasPrefix(cfg.Server, "https://"):
   438  		prpcClient.Host = strings.TrimPrefix(cfg.Server, "https://")
   439  	case strings.HasPrefix(cfg.Server, "http://"):
   440  		prpcClient.Host = strings.TrimPrefix(cfg.Server, "http://")
   441  		prpcClient.Options.Insecure = true
   442  	default:
   443  		prpcClient.Host = cfg.Server
   444  	}
   445  
   446  	return cb(c, bbpb.NewBuildsClient(prpcClient))
   447  }
   448  
   449  // checkBuildStatusLater schedules a delayed call to checkBuildStatus if the
   450  // invocation is still running.
   451  //
   452  // This is a fallback mechanism in case PubSub notifications are delayed or
   453  // lost for some reason.
   454  func (m TaskManager) checkBuildStatusLater(c context.Context, ctl task.Controller) {
   455  	if !ctl.State().Status.Final() {
   456  		ctl.AddTimer(c,
   457  			randomDuration(c, statusCheckTimerIntervalMin, statusCheckTimerIntervalMax),
   458  			statusCheckTimerName,
   459  			nil)
   460  	}
   461  }
   462  
   463  // randomDuration returns a random seconds duration within the given bounds.
   464  func randomDuration(c context.Context, min, max time.Duration) time.Duration {
   465  	d := min + time.Duration(mathrand.Int63n(c, int64(max-min)))
   466  	return d.Truncate(time.Second)
   467  }
   468  
   469  func (m TaskManager) checkBuildStatus(c context.Context, ctl task.Controller) error {
   470  	switch status := ctl.State().Status; {
   471  	// This can happen if Buildbucket manages to send PubSub message before
   472  	// LaunchTask finishes. Do not touch State or DebugLog to avoid collision with
   473  	// still running LaunchTask when saving the invocation, it will only make the
   474  	// matters worse.
   475  	case status == task.StatusStarting:
   476  		return errors.New("invocation is still starting, try again later", transient.Tag, tq.Retry)
   477  	case status != task.StatusRunning:
   478  		return fmt.Errorf("unexpected invocation status %q, expecting %q", status, task.StatusRunning)
   479  	}
   480  
   481  	// Grab build ID from the blob generated in LaunchTask.
   482  	taskData, err := readTaskData(ctl)
   483  	if err != nil {
   484  		ctl.State().Status = task.StatusFailed
   485  		return err
   486  	}
   487  
   488  	// Fetch the build from Buildbucket.
   489  	var build *bbpb.Build
   490  	err = m.withBuildbucket(c, ctl, func(ctx context.Context, bb bbpb.BuildsClient) (err error) {
   491  		build, err = bb.GetBuild(ctx, &bbpb.GetBuildRequest{Id: taskData.BuildID})
   492  		return
   493  	})
   494  	if err != nil {
   495  		ctl.DebugLog("Failed to fetch build - %s", err)
   496  		err = grpcutil.WrapIfTransientOr(err, codes.DeadlineExceeded)
   497  		if !transient.Tag.In(err) {
   498  			ctl.State().Status = task.StatusFailed
   499  		}
   500  		return err
   501  	}
   502  
   503  	// Switch the invocation status according to the Build status.
   504  	handleBuildStatus(ctl, build)
   505  
   506  	// Log the final state of the build or just its status if still running (to be
   507  	// less spammy).
   508  	if ctl.State().Status.Final() {
   509  		ctl.DebugLog("Build:\n%s", protoToJSON(build))
   510  	} else {
   511  		ctl.DebugLog("Build status: %v", build.Status)
   512  	}
   513  
   514  	return nil
   515  }
   516  
   517  // handleBuildStatus adjusts the invocation state based on the build's status.
   518  func handleBuildStatus(ctl task.Controller, build *bbpb.Build) {
   519  	switch build.Status {
   520  	case bbpb.Status_SCHEDULED, bbpb.Status_STARTED:
   521  		// do nothing, the invocation is still active
   522  	case bbpb.Status_SUCCESS:
   523  		ctl.State().Status = task.StatusSucceeded
   524  	case bbpb.Status_FAILURE, bbpb.Status_INFRA_FAILURE:
   525  		ctl.State().Status = task.StatusFailed
   526  	case bbpb.Status_CANCELED:
   527  		ctl.State().Status = task.StatusAborted
   528  	default:
   529  		ctl.DebugLog("Unexpected Build status %v, marking the invocation as failed", build.Status)
   530  		ctl.State().Status = task.StatusFailed
   531  	}
   532  }
   533  
   534  // builderID derives Buildbucket v2 builder ID from the config.
   535  //
   536  // Returns an error if some fields are invalid or there's not enough
   537  // information.
   538  func builderID(cfg *messages.BuildbucketTask, realmID string) (*bbpb.BuilderID, error) {
   539  	var project, bucket string
   540  
   541  	switch {
   542  	case cfg.Bucket == "":
   543  		// Fallback to the realm. Ensure it is not a special realm.
   544  		project, bucket = realms.Split(realmID)
   545  		if bucket == realms.LegacyRealm || bucket == realms.RootRealm {
   546  			return nil, fmt.Errorf("'bucket' field for jobs in %q realm is required", bucket)
   547  		}
   548  
   549  	case strings.ContainsRune(cfg.Bucket, ':'):
   550  		// Full v2 form "<project>:<bucket>".
   551  		chunks := strings.SplitN(cfg.Bucket, ":", 2)
   552  		project, bucket = chunks[0], chunks[1]
   553  
   554  	case strings.HasPrefix(cfg.Bucket, "luci."):
   555  		// Legacy v1 bucket that matches a v2 bucket: "luci.<project>.<bucket>".
   556  		// No longer allowed.
   557  		chunks := strings.SplitN(cfg.Bucket, ".", 3)
   558  		if len(chunks) != 3 {
   559  			return nil, fmt.Errorf("bad legacy v1 'bucket' %q, need 3 components", cfg.Bucket)
   560  		}
   561  		project, bucket = chunks[1], chunks[2]
   562  
   563  		var full string
   564  		if curProject, _ := realms.Split(realmID); project != curProject {
   565  			full = fmt.Sprintf("%s:%s", project, bucket)
   566  		} else {
   567  			full = bucket
   568  		}
   569  		return nil, fmt.Errorf("legacy v1 bucket names like %q are no longer allowed, use %q instead", cfg.Bucket, full)
   570  
   571  	default:
   572  		// A v2 bucket name within the current project.
   573  		project, _ = realms.Split(realmID)
   574  		bucket = cfg.Bucket
   575  	}
   576  
   577  	if cfg.Builder == "" {
   578  		return nil, fmt.Errorf("'builder' field is required")
   579  	}
   580  
   581  	return &bbpb.BuilderID{
   582  		Project: project,
   583  		Bucket:  bucket,
   584  		Builder: cfg.Builder,
   585  	}, nil
   586  }
   587  
   588  // triggerToCommit converts a gitiles trigger to a buildbucket gitiles commit.
   589  func triggerToCommit(t *scheduler.GitilesTrigger) (*bbpb.GitilesCommit, error) {
   590  	repo, err := gitiles.NormalizeRepoURL(t.Repo, false)
   591  	if err != nil {
   592  		return nil, errors.Annotate(err, "bad repo URL %q", t.Repo).Err()
   593  	}
   594  	return &bbpb.GitilesCommit{
   595  		Host:    repo.Host,
   596  		Project: strings.TrimPrefix(repo.Path, "/"),
   597  		Id:      t.Revision,
   598  		Ref:     t.Ref,
   599  	}, nil
   600  }
   601  
   602  // popCommitFromTags tries to reconstruct GitilesCommit from tags.
   603  //
   604  // Removes gitiles commit information from properties and tags (modifying them
   605  // in-place), since Buildbucket v2 refuses to accept it there.
   606  //
   607  // See also https://chromium.googlesource.com/infra/infra/+/7a647a9d/appengine/cr-buildbucket/legacy/api.py#101
   608  //
   609  // Returns the extracted commit or nil.
   610  func popCommitFromTags(ctl task.Controller, props *structpb.Struct, tags *[]string) *bbpb.GitilesCommit {
   611  	var commit *bbpb.GitilesCommit
   612  	var ref string
   613  
   614  	// Pop all gitiles_ref and buildset tags (usually one of each). They will be
   615  	// reconstructed based on GitilesCommit by Buildbucket.
   616  	kept := (*tags)[:0]
   617  	for _, tag := range *tags {
   618  		switch k, v := strpair.Parse(tag); {
   619  		case k == "gitiles_ref":
   620  			// The last one wins (per BBv1's parse_v1_tags).
   621  			if ref != "" {
   622  				ctl.DebugLog("Ignoring extra gitiles_ref %q", ref)
   623  			}
   624  			ref = normalizeRef(v)
   625  
   626  		case k == "buildset":
   627  			// This first one wins (per BBv1's parse_v1_tags).
   628  			if commit != nil {
   629  				ctl.DebugLog("Ignoring extra buildset tag %q", tag)
   630  			} else {
   631  				if commit = parseGitilesBuildset(v); commit != nil {
   632  					ctl.DebugLog("Popped buildset tag %q", tag)
   633  				} else {
   634  					ctl.DebugLog("Ignoring unrecognized buildset tag %q", tag)
   635  				}
   636  			}
   637  
   638  		default:
   639  			kept = append(kept, tag)
   640  		}
   641  	}
   642  	*tags = kept
   643  
   644  	// Fill in `commit.Ref` based on gitiles_ref tag value.
   645  	if commit != nil {
   646  		commit.Ref = ref
   647  	} else {
   648  		ctl.DebugLog("Ignoring gitiles_ref tag without the buildset tag")
   649  	}
   650  
   651  	// Pop reserved properties. BBv2 will reject the request if they are present.
   652  	popProp := func(key string) string {
   653  		if field := props.Fields[key]; field != nil {
   654  			delete(props.Fields, key)
   655  			return field.GetStringValue()
   656  		}
   657  		return ""
   658  	}
   659  	repository := popProp("repository")
   660  	branch := normalizeRef(popProp("branch"))
   661  	revision := popProp("revision") // oddly enough, this property is actually allowed
   662  
   663  	// If we had no buildset tag, just discard the properties. They are not
   664  	// authoritative.
   665  	if commit == nil {
   666  		if repository != "" {
   667  			ctl.DebugLog("No buildset tag present, ignoring property %q: %q", "repository", repository)
   668  		}
   669  		if branch != "" {
   670  			ctl.DebugLog("No buildset tag present, ignoring property %q: %q", "branch", branch)
   671  		}
   672  		if revision != "" {
   673  			ctl.DebugLog("No buildset tag present, ignoring property %q: %q", "revision", revision)
   674  		}
   675  		return nil
   676  	}
   677  
   678  	// Log if properties disagree with information from tags.
   679  	if repository != "" {
   680  		repoURL, err := gitiles.NormalizeRepoURL(repository, false)
   681  		if err != nil {
   682  			ctl.DebugLog("Ignoring invalid property %q: %q", "repository", repository)
   683  		} else {
   684  			if repoURL.Host != commit.Host {
   685  				ctl.DebugLog("Git host in properties %q doesn't match the one in tags %q", repoURL.Host, commit.Host)
   686  			}
   687  			if proj := strings.TrimPrefix(repoURL.Path, "/"); proj != commit.Project {
   688  				ctl.DebugLog("Git project in properties %q doesn't match the one in tags %q", proj, commit.Project)
   689  			}
   690  		}
   691  	}
   692  	if branch != "" && branch != commit.Ref {
   693  		ctl.DebugLog("Git ref in properties %q doesn't match the one in tags %q", branch, commit.Ref)
   694  	}
   695  	if revision != "" && revision != commit.Id {
   696  		ctl.DebugLog("Git commit in properties %q doesn't match the one in tags %q", revision, commit.Id)
   697  	}
   698  
   699  	return commit
   700  }
   701  
   702  var gitilesBuildsetRe = regexp.MustCompile(`^commit/gitiles/([^/]+)/(.+?)/\+/([a-f0-9]+)$`)
   703  
   704  // parseGitilesBuildset parses Gitiles buildset tag into a proto.
   705  //
   706  // Example input:
   707  //
   708  //	commit/gitiles/chromium.googlesource.com/chromium/src/+/
   709  //	4fa74ef7511f4167d15a5a6d464df06e41ffbd70
   710  //
   711  // Returns nil if `t` doesn't look like a gitiles buildset.
   712  func parseGitilesBuildset(t string) *bbpb.GitilesCommit {
   713  	m := gitilesBuildsetRe.FindStringSubmatch(t)
   714  	if len(m) == 0 {
   715  		return nil
   716  	}
   717  	return &bbpb.GitilesCommit{
   718  		Host:    m[1],
   719  		Project: m[2],
   720  		Id:      m[3],
   721  	}
   722  }
   723  
   724  // normalizeRef returns either "refs/..." or "" if `ref` is empty.
   725  func normalizeRef(ref string) string {
   726  	if ref != "" && !strings.HasPrefix(ref, "refs/") {
   727  		ref = "refs/heads/" + ref
   728  	}
   729  	return ref
   730  }
   731  
   732  // toBuildbucketPairs converts a list of "key:value" to a list of StringPair.
   733  func toBuildbucketPairs(s []string) []*bbpb.StringPair {
   734  	out := make([]*bbpb.StringPair, len(s))
   735  	for i, kv := range s {
   736  		k, v := strpair.Parse(kv)
   737  		out[i] = &bbpb.StringPair{Key: k, Value: v}
   738  	}
   739  	return out
   740  }
   741  
   742  func strProtoValue(s string) *structpb.Value {
   743  	return &structpb.Value{
   744  		Kind: &structpb.Value_StringValue{
   745  			StringValue: s,
   746  		},
   747  	}
   748  }
   749  
   750  // protoToJSON is used to pretty-print proto messages in debug logs.
   751  func protoToJSON(p proto.Message) string {
   752  	var buf bytes.Buffer
   753  	if err := (&jsonpb.Marshaler{Indent: "  "}).Marshal(&buf, p); err != nil {
   754  		return fmt.Sprintf("<failed to marshal proto to JSON: %s>", err)
   755  	}
   756  	return buf.String()
   757  }
   758  
   759  // schedulerProperty returns "$recipe_engine/scheduler" property value.
   760  //
   761  // The schema of the property is defined in
   762  // https://chromium.googlesource.com/infra/luci/recipes-py/+/HEAD/recipe_modules/scheduler/__init__.py
   763  //
   764  // Note: this function is very inefficient.
   765  func schedulerProperty(ctx context.Context, ctl task.Controller) (*structpb.Value, error) {
   766  	buf := &bytes.Buffer{}
   767  
   768  	triggerList := &structpb.ListValue{}
   769  	m := &jsonpb.Marshaler{}
   770  	um := &jsonpb.Unmarshaler{}
   771  
   772  	ts := ctl.Request().IncomingTriggers
   773  	if len(ts) > maxTriggersAsSchedulerProperty {
   774  		ctl.DebugLog("Capping %d triggers passed to the build to just %d latest ones",
   775  			len(ts), maxTriggersAsSchedulerProperty)
   776  		ts = ts[len(ts)-maxTriggersAsSchedulerProperty:]
   777  	}
   778  	for _, tInternal := range ts {
   779  		buf.Reset()
   780  		tPublic := internal.ToPublicTrigger(tInternal)
   781  		if err := m.Marshal(buf, tPublic); err != nil {
   782  			return nil, err
   783  		}
   784  		tStruct := &structpb.Struct{}
   785  		if err := um.Unmarshal(buf, tStruct); err != nil {
   786  			return nil, err
   787  		}
   788  		triggerList.Values = append(triggerList.Values, &structpb.Value{
   789  			Kind: &structpb.Value_StructValue{StructValue: tStruct},
   790  		})
   791  	}
   792  
   793  	return &structpb.Value{
   794  		Kind: &structpb.Value_StructValue{
   795  			StructValue: &structpb.Struct{
   796  				Fields: map[string]*structpb.Value{
   797  					"hostname": {
   798  						Kind: &structpb.Value_StringValue{
   799  							StringValue: info.DefaultVersionHostname(ctx),
   800  						},
   801  					},
   802  					"job": {
   803  						Kind: &structpb.Value_StringValue{
   804  							StringValue: ctl.JobID(),
   805  						},
   806  					},
   807  					"invocation": {
   808  						Kind: &structpb.Value_StringValue{
   809  							StringValue: fmt.Sprintf("%d", ctl.InvocationID()),
   810  						},
   811  					},
   812  					"triggers": {
   813  						Kind: &structpb.Value_ListValue{ListValue: triggerList},
   814  					},
   815  				},
   816  			},
   817  		},
   818  	}, nil
   819  }