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 }