blob: e52e665249684201ba06e003caf0f1daee101f56 [file] [log] [blame]
// Copyright 2021 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Package buildbucket provides a Buildbucket client with helper methods for
// interacting with builds.
package buildbucket
import (
"context"
"fmt"
"infra/cmd/crosfleet/internal/common"
dutinfopb "infra/cmd/crosfleet/internal/proto"
"infra/cmd/crosfleet/internal/site"
"infra/cmdsupport/cmdlib"
"strings"
"time"
"go.chromium.org/chromiumos/infra/proto/go/test_platform"
"go.chromium.org/luci/auth/client/authcli"
buildbucketpb "go.chromium.org/luci/buildbucket/proto"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/grpc/prpc"
"google.golang.org/genproto/protobuf/field_mask"
"google.golang.org/protobuf/types/known/timestamppb"
)
const (
userAgentTagKey = "user_agent"
crosfleetUserAgent = "crosfleet"
)
var maxServiceVersion = &test_platform.ServiceVersion{
CrosfleetTool: site.VersionNumber,
}
// addServiceVersion marshals the max service version proto to a JSON-encoded
// string, adds it to the given Buildbucket property map, and returns the
// property map.
func addServiceVersion(props map[string]interface{}) map[string]interface{} {
props["$chromeos/service_version"] = map[string]interface{}{
// Convert to protoreflect.ProtoMessage for easier type comparison.
"version": maxServiceVersion.ProtoReflect().Interface(),
}
return props
}
// Client provides helper methods to interact with Buildbucket builds.
type Client struct {
client buildbucketpb.BuildsClient
builderID *buildbucketpb.BuilderID
}
// NewClient returns a new client to interact with Buildbucket builds from the
// given builder.
func NewClient(ctx context.Context, builder *buildbucketpb.BuilderID, bbService string, authFlags authcli.Flags) (*Client, error) {
httpClient, err := cmdlib.NewHTTPClient(ctx, &authFlags)
if err != nil {
return nil, err
}
prpcClient := &prpc.Client{
C: httpClient,
Host: bbService,
Options: site.DefaultPRPCOptions,
}
return &Client{
client: buildbucketpb.NewBuildsPRPCClient(prpcClient),
builderID: builder,
}, nil
}
// NewClientForTesting returns a new client with only the builderID configured.
func NewClientForTesting(builder *buildbucketpb.BuilderID) *Client {
return &Client{builderID: builder}
}
// ScheduleBuild schedules a new build (of the client's builder) with the given
// properties, tags, bot dimensions, and Buildbucket priority, and returns the
// scheduled build.
//
// Buildbucket requests take properties of type *structpb.Struct. To simplify
// the conversion from other data structures to Structs, ScheduleBuild accepts
// properties of type map[string]interface{}, where interface{} can be any of
// Go's basic types (bool, string, number type, byte, or rune), a proto message
// (in the form protoreflect.ProtoMessage), or a nested map[string]interface{}
// that fulfils the same requirements recursively.
//
// NOTE: Buildbucket priority is separate from internal swarming priority.
func (c *Client) ScheduleBuild(ctx context.Context, props map[string]interface{}, dims map[string]string, tags map[string]string, priority int32) (*buildbucketpb.Build, error) {
props = addServiceVersion(props)
propStruct, err := common.MapToStruct(props)
tags[userAgentTagKey] = crosfleetUserAgent
if err != nil {
return nil, err
}
request := &buildbucketpb.ScheduleBuildRequest{
Builder: c.builderID,
Properties: propStruct,
Dimensions: bbDims(dims),
Tags: bbTags(tags),
Priority: priority,
}
build, err := c.client.ScheduleBuild(ctx, request)
if err != nil {
return nil, errors.Annotate(err, "schedule build").Err()
}
return build, nil
}
// WaitForBuildStart polls Buildbucket to check the status of the build with
// the given ID, and returns the build once it has started the given step.
// If the build has a status other than scheduled/started, both the build and
// a printable error message are returned.
func (c *Client) WaitForBuildStepStart(ctx context.Context, id int64, stepName string) (*buildbucketpb.Build, error) {
stepStarted := false
for {
build, err := c.GetBuild(ctx, id)
if err != nil {
return nil, err
}
switch s := build.Status; s {
case buildbucketpb.Status_SCHEDULED:
time.Sleep(10 * time.Second)
case buildbucketpb.Status_STARTED:
// For the purposes of this function, it is sufficient just to check
// that the step exists (i.e. the build has reached and started the
// step), and ignore the step's current status, since we already
// confirmed the build has the overall healthy status "started".
if containsStep(build.GetSteps(), stepName) {
// Use the stepStarted var to add one more polling interval
// to rule out edge cases where the step has started starts and
// is briefly green for <10 seconds before quickly failing.
if stepStarted {
return build, nil
}
stepStarted = true
}
time.Sleep(10 * time.Second)
default:
statusString := buildbucketpb.Status_name[int32(s)]
buildSummary := ""
if build.SummaryMarkdown != "" {
buildSummary = fmt.Sprintf("Build summary: %s", build.SummaryMarkdown)
}
return build, fmt.Errorf(`build finished in unexpected state %s
%s
For more details, please visit the build page at %s`, statusString, buildSummary, c.BuildURL(build.Id))
}
}
}
// getAllBuilds performs a SearchBuilds call with the given SearchBuildsRequest,
// and returns all the builds that were found. For searches that return many
// builds, this function avoids having to deal with search pagination logic.
// This function only searches builds from the Client's builder.
func (c *Client) getAllBuilds(ctx context.Context, searchBuildsRequest *buildbucketpb.SearchBuildsRequest) ([]*buildbucketpb.Build, error) {
// The Client is only designed to interact with builds from its builderID,
// so we set that builderID within this function to keep the caller's
// request simple.
searchBuildsRequest.Predicate.Builder = c.builderID
// Reset the PageToken to ensure we start at the beginning of the results.
searchBuildsRequest.PageToken = ""
var allBuilds []*buildbucketpb.Build
for {
response, err := c.client.SearchBuilds(ctx, searchBuildsRequest)
if err != nil {
return nil, err
}
allBuilds = append(allBuilds, response.Builds...)
if response.NextPageToken == "" {
break
}
searchBuildsRequest.PageToken = response.NextPageToken
}
return allBuilds, nil
}
// GetAllBuildsForUser finds returns all builds created by the given user
// matching the given SearchBuildsRequest. This function expects the field
// "builds.*.created_by" to be included in the field mask of the given
// SearchBuildRequest.
func (c *Client) GetAllBuildsByUser(ctx context.Context, user string, searchBuildsRequest *buildbucketpb.SearchBuildsRequest) ([]*buildbucketpb.Build, error) {
allBuilds, err := c.getAllBuilds(ctx, searchBuildsRequest)
if err != nil {
return nil, err
}
var buildsByUser []*buildbucketpb.Build
for _, build := range allBuilds {
if build.CreatedBy == fmt.Sprintf("user:%s", user) {
buildsByUser = append(buildsByUser, build)
}
}
return buildsByUser, nil
}
// GetAllBuildsWithTags returns all builds with the given tags matching the
// given SearchBuildsRequest. (Technically, the SearchBuildsRequest could
// include tags in the form []*buildbucketpb.StringPair; this function simply
// allows passing tags in the simpler map[string]string form.)
func (c *Client) GetAllBuildsWithTags(ctx context.Context, tags map[string]string, searchBuildsRequest *buildbucketpb.SearchBuildsRequest) ([]*buildbucketpb.Build, error) {
// Convert tags to []*buildbucketpb.StringPair and add to search request.
searchPredicate := searchBuildsRequest.Predicate
if searchPredicate == nil {
searchPredicate = &buildbucketpb.BuildPredicate{}
}
searchPredicate.Tags = bbTags(tags)
return c.getAllBuilds(ctx, searchBuildsRequest)
}
// AnyIncompleteBuildsWithTags returns a bool indicating whether there are any
// scheduled or started builds matching the given tags. If any builds are found,
// the first ID is returned.
func (c *Client) AnyIncompleteBuildsWithTags(ctx context.Context, tags map[string]string) (bool, int64, error) {
// Search for started builds first.
builds, err := c.GetAllBuildsWithTags(ctx, tags, &buildbucketpb.SearchBuildsRequest{
Predicate: &buildbucketpb.BuildPredicate{
Status: buildbucketpb.Status_STARTED,
},
})
// If no started builds are found, search for scheduled ones.
if err == nil && len(builds) == 0 {
builds, err = c.GetAllBuildsWithTags(ctx, tags, &buildbucketpb.SearchBuildsRequest{
Predicate: &buildbucketpb.BuildPredicate{
Status: buildbucketpb.Status_SCHEDULED,
},
})
}
if err != nil {
return false, 0, err
}
if len(builds) > 0 {
return true, builds[0].Id, nil
}
return false, 0, nil
}
// CancelBuildsByUser cancels any pending or active build created after the
// given timestamp that was launched by the given user. An optional bot ID list
// can be given, which restricts cancellations to builds running on bots on the
// given list. The optional cancellation reason is used if not blank.
func (c *Client) CancelBuildsByUser(ctx context.Context, printer common.CLIPrinter, earliestCreateTime *timestamppb.Timestamp, user string, ids []string, reason string) error {
if reason == "" {
reason = "cancelled from crosfleet CLI"
}
fieldsMask := &field_mask.FieldMask{Paths: []string{
"builds.*.created_by",
"builds.*.id",
"builds.*.status",
"builds.*.infra",
}}
timeRange := &buildbucketpb.TimeRange{
StartTime: earliestCreateTime,
}
scheduledBuilds, err := c.GetAllBuildsByUser(ctx, user, &buildbucketpb.SearchBuildsRequest{
Predicate: &buildbucketpb.BuildPredicate{
CreateTime: timeRange,
Status: buildbucketpb.Status_SCHEDULED,
},
Fields: fieldsMask,
})
if err != nil {
return err
}
startedBuilds, err := c.GetAllBuildsByUser(ctx, user, &buildbucketpb.SearchBuildsRequest{
Predicate: &buildbucketpb.BuildPredicate{
CreateTime: timeRange,
Status: buildbucketpb.Status_STARTED,
},
Fields: fieldsMask,
})
if err != nil {
return err
}
var buildsToCancel []*buildbucketpb.Build
// Only filter the builds by bot ID if bot IDs were given.
if len(ids) > 0 {
for _, scheduledBuild := range scheduledBuilds {
requestedBotID := FindDimValInRequestedDims("id", scheduledBuild)
if idMatch(requestedBotID, ids) {
buildsToCancel = append(buildsToCancel, scheduledBuild)
}
}
for _, startedBuild := range startedBuilds {
provisionedBotID := FindDimValInFinalDims("id", startedBuild)
if idMatch(provisionedBotID, ids) {
buildsToCancel = append(buildsToCancel, startedBuild)
}
}
} else {
buildsToCancel = append(scheduledBuilds, startedBuilds...)
}
if len(buildsToCancel) == 0 {
printer.WriteTextStdout("No scheduled or active builds found that were launched by the current user (%s)", user)
return nil
}
var cancelledBuildIdList dutinfopb.BuildIdList
var cancellationErr error
for _, build := range buildsToCancel {
printer.WriteTextStdout("Canceling build at %s", c.BuildURL(build.Id))
_, cancellationErr = c.client.CancelBuild(ctx, &buildbucketpb.CancelBuildRequest{
Id: build.Id,
SummaryMarkdown: reason,
})
if cancellationErr != nil {
break
}
cancelledBuildIdList.Ids = append(cancelledBuildIdList.Ids, build.Id)
}
printer.WriteJSONStdout(&cancelledBuildIdList)
if cancellationErr != nil {
return err
}
return nil
}
// GetBuild gets a Buildbucket build by ID, with the given build fields
// populated. If no fields are given, all fields will be populated.
func (c *Client) GetBuild(ctx context.Context, ID int64, fields ...string) (*buildbucketpb.Build, error) {
if len(fields) == 0 {
fields = []string{"*"}
}
request := &buildbucketpb.GetBuildRequest{
Id: ID,
Fields: &field_mask.FieldMask{Paths: fields},
}
build, err := c.client.GetBuild(ctx, request)
if err != nil {
return nil, errors.Annotate(err, "get build").Err()
}
return build, nil
}
// GetLatestGreenBuild gets the latest green build for the client's builder.
// To optimize runtime, this call is only configured to populate the build's ID
// and output properties. More fields can be added to the field mask if needed.
func (c *Client) GetLatestGreenBuild(ctx context.Context) (*buildbucketpb.Build, error) {
searchBuildsRequest := &buildbucketpb.SearchBuildsRequest{
Predicate: &buildbucketpb.BuildPredicate{
Builder: c.builderID,
Status: buildbucketpb.Status_SUCCESS,
},
Fields: &field_mask.FieldMask{Paths: []string{
"builds.*.id",
"builds.*.output.properties",
}},
}
// Avoid the getAllBuilds function since it scrolls through all pages of
// the search result, and we only want the most recent build.
response, err := c.client.SearchBuilds(ctx, searchBuildsRequest)
if err != nil {
return nil, err
}
if len(response.Builds) == 0 {
return nil, fmt.Errorf("no green builds found for builder %s", c.builderID.Builder)
}
return response.Builds[0], nil
}
// BuildURL constructs the URL for the LUCI page of the build (of the client's
// builder) with the given ID.
func (c *Client) BuildURL(ID int64) string {
return fmt.Sprintf(
"https://ci.chromium.org/ui/p/%s/builders/%s/%s/b%d",
c.builderID.Project, c.builderID.Bucket, c.builderID.Builder, ID)
}
// bbDims converts the given map[string]string of bot dimensions to the
// required []*buildbucketpb.RequestedDimension type for Buildbucket requests.
func bbDims(dims map[string]string) []*buildbucketpb.RequestedDimension {
var bbDimList []*buildbucketpb.RequestedDimension
for key, val := range dims {
bbDimList = append(bbDimList, &buildbucketpb.RequestedDimension{
Key: strings.Trim(key, " "),
Value: strings.Trim(val, " "),
})
}
return bbDimList
}
// bbTags converts the given map[string]string of Buildbucket tags to the
// required []*buildbucketpb.StringPair type for Buildbucket requests.
func bbTags(tags map[string]string) []*buildbucketpb.StringPair {
var bbTagList []*buildbucketpb.StringPair
for key, val := range tags {
bbTagList = append(bbTagList, &buildbucketpb.StringPair{
Key: strings.Trim(key, " "),
Value: strings.Trim(val, " "),
})
}
return bbTagList
}
// FindDimValInRequestedDims finds the given dimension value in the build's
// requested dimensions, which are of type []*buildbucketpb.RequestedDimension.
//
// Since the dimensions looped through are not of type
// []*buildbucketpb.StringPair, some duplicate code is unfortunately required.
func FindDimValInRequestedDims(dim string, build *buildbucketpb.Build) string {
requestedDims := build.GetInfra().GetBuildbucket().GetRequestedDimensions()
for _, d := range requestedDims {
if d.GetKey() == dim {
return d.GetValue()
}
}
return ""
}
// FindDimValInFinalDims finds the given dimension value in the build's
// requested dimensions.
func FindDimValInFinalDims(dim string, build *buildbucketpb.Build) string {
provisionedDims := build.GetInfra().GetSwarming().GetBotDimensions()
return findKeyValInStringPairs(dim, provisionedDims)
}
// FindTagVal finds the given tag value in the build's tags.
func FindTagVal(tag string, build *buildbucketpb.Build) string {
return findKeyValInStringPairs(tag, build.Tags)
}
// findKeyValInStringPairs finds the given key's value in the given StringPairs.
func findKeyValInStringPairs(key string, pairs []*buildbucketpb.StringPair) string {
for _, p := range pairs {
if p.GetKey() == key {
return p.GetValue()
}
}
return ""
}
// idMatch returns true if the given ID to match is found in the given ID list.
func idMatch(idToMatch string, idList []string) bool {
for _, id := range idList {
if idToMatch == id {
return true
}
}
return false
}
// containsStep returns true if a step with the given name is found in the
// given slice of steps.
func containsStep(steps []*buildbucketpb.Step, stepName string) bool {
for _, step := range steps {
if step.Name == stepName {
return true
}
}
return false
}