go.fuchsia.dev/infra@v0.0.0-20240507153436-9b593402251b/cmd/size_diff/common.go (about)

     1  // Copyright 2021 The Fuchsia Authors.
     2  // Use of this source code is governed by a BSD-style license that can be
     3  // found in the LICENSE file.
     4  
     5  package main
     6  
     7  import (
     8  	"context"
     9  	"errors"
    10  	"fmt"
    11  	"log"
    12  	"strings"
    13  	"time"
    14  
    15  	"github.com/maruel/subcommands"
    16  	"go.chromium.org/luci/auth"
    17  	"go.chromium.org/luci/auth/client/authcli"
    18  	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
    19  	"go.chromium.org/luci/common/proto/structmask"
    20  	"go.chromium.org/luci/common/retry"
    21  	"go.chromium.org/luci/common/retry/transient"
    22  	"go.chromium.org/luci/grpc/prpc"
    23  	"go.fuchsia.dev/infra/buildbucket"
    24  	"google.golang.org/protobuf/types/known/fieldmaskpb"
    25  )
    26  
    27  // buildFieldMask identifies required fields and output properties that should
    28  // be requested from the Buildbucket API.
    29  func buildFieldMask(outputProps []string) *buildbucketpb.BuildMask {
    30  	return &buildbucketpb.BuildMask{
    31  		Fields: &fieldmaskpb.FieldMask{Paths: []string{
    32  			"id",
    33  			"status",
    34  		}},
    35  		OutputProperties: []*structmask.StructMask{{Path: outputProps}},
    36  	}
    37  }
    38  
    39  type commonFlags struct {
    40  	subcommands.CommandRunBase
    41  	authFlags authcli.Flags
    42  
    43  	parsedAuthOpts auth.Options
    44  	bbHost         string
    45  	gitilesRemote  string
    46  	baseCommit     string
    47  	builder        buildbucket.BuilderIDFlag
    48  	jsonOutput     string
    49  }
    50  
    51  func (c *commonFlags) Init(authOpts auth.Options) {
    52  	c.authFlags = authcli.Flags{}
    53  	c.authFlags.Register(&c.Flags, authOpts)
    54  	c.Flags.StringVar(&c.bbHost, "bb-host", "cr-buildbucket.appspot.com", "Buildbucket host to use.")
    55  	c.Flags.StringVar(&c.gitilesRemote, "gitiles-remote", "", "Gitiles remote for base commit.")
    56  	c.Flags.StringVar(&c.baseCommit, "base-commit", "", "Base commit as sha1.")
    57  	c.Flags.Var(&c.builder, "builder", "Fully-qualified Buildbucket CI builder name to inspect.")
    58  	c.Flags.StringVar(&c.jsonOutput, "json-output", "-", "Path to output results to.")
    59  }
    60  
    61  func (c *commonFlags) Parse() error {
    62  	var err error
    63  	c.parsedAuthOpts, err = c.authFlags.Options()
    64  	if err != nil {
    65  		return err
    66  	}
    67  	if c.gitilesRemote == "" {
    68  		return errors.New("-gitiles-remote is required")
    69  	}
    70  	c.gitilesRemote = strings.TrimPrefix(c.gitilesRemote, "https://")
    71  	if c.baseCommit == "" {
    72  		return errors.New("-base-commit is required")
    73  	}
    74  	if &c.builder == nil {
    75  		return errors.New("-builder is required")
    76  	}
    77  	if c.jsonOutput == "" {
    78  		return errors.New("-json-output is required")
    79  	}
    80  	return nil
    81  }
    82  
    83  // searchForBuild gets the CI build with gitiles commit matching the base
    84  // commit.
    85  func searchForBuild(ctx context.Context, buildsClient buildbucketpb.BuildsClient, builder *buildbucketpb.BuilderID, gitilesRemote, baseCommit string, outputProps []string) (*buildbucketpb.Build, error) {
    86  	fullBuilderName := fmt.Sprintf("%s/%s/%s", builder.Project, builder.Bucket, builder.Builder)
    87  	resp, err := buildsClient.SearchBuilds(ctx, &buildbucketpb.SearchBuildsRequest{
    88  		Predicate: &buildbucketpb.BuildPredicate{
    89  			Builder: builder,
    90  			Tags: []*buildbucketpb.StringPair{
    91  				{
    92  					Key:   "buildset",
    93  					Value: fmt.Sprintf("commit/gitiles/%s/+/%s", gitilesRemote, baseCommit),
    94  				},
    95  			},
    96  		},
    97  		Mask:     buildFieldMask(outputProps),
    98  		PageSize: int32(1),
    99  	})
   100  	if err != nil {
   101  		return nil, fmt.Errorf("failed to query builds for %s: %w", fullBuilderName, err)
   102  	}
   103  	builds := resp.Builds
   104  	if len(builds) == 0 {
   105  		return nil, fmt.Errorf("no builds found for %s", fullBuilderName)
   106  	}
   107  	if len(builds) != 1 {
   108  		return nil, fmt.Errorf("expected exactly one build, got %d", len(builds))
   109  	}
   110  	return builds[0], nil
   111  }
   112  
   113  // waitForBuildCompletion waits for the given build to complete, as CI could be
   114  // on the cusp of completion.
   115  func waitForBuildCompletion(ctx context.Context, buildsClient buildbucketpb.BuildsClient, buildID int64, bbHost string, outputProps []string) (*buildbucketpb.Build, error) {
   116  	buildLink := fmt.Sprintf("https://%s/build/%d", bbHost, buildID)
   117  	retryPolicy := transient.Only(func() retry.Iterator {
   118  		return &retry.ExponentialBackoff{
   119  			Limited: retry.Limited{
   120  				Delay:   time.Minute,
   121  				Retries: 20,
   122  			},
   123  			Multiplier: 1,
   124  		}
   125  	})
   126  	var build *buildbucketpb.Build
   127  	if err := retry.Retry(ctx, retryPolicy, func() error {
   128  		var err error
   129  		build, err = buildsClient.GetBuild(ctx, &buildbucketpb.GetBuildRequest{
   130  			Id:   buildID,
   131  			Mask: buildFieldMask(outputProps),
   132  		})
   133  		if err != nil {
   134  			return fmt.Errorf("failed to get build %d: %w", buildID, err)
   135  		}
   136  		if !hasEnded(build) {
   137  			err := buildNotSuccessfulError{
   138  				msg:    fmt.Sprintf("a finished build is needed to attempt the size diff but got status %s, see %s", build.Status, buildLink),
   139  				status: build.Status,
   140  			}
   141  			log.Printf("%s is still status %s", buildLink, build.Status)
   142  			return transient.Tag.Apply(err)
   143  		}
   144  		return nil
   145  	}, nil); err != nil {
   146  		return nil, err
   147  	}
   148  	return build, nil
   149  }
   150  
   151  // hasEnded returns whether a build has completed.
   152  func hasEnded(build *buildbucketpb.Build) bool {
   153  	bitmasked := build.Status & buildbucketpb.Status_ENDED_MASK
   154  	return bitmasked > 0
   155  }
   156  
   157  func getBuild(ctx context.Context, c commonFlags, outputProps []string) (*buildbucketpb.Build, error) {
   158  	authClient, err := auth.NewAuthenticator(ctx, auth.OptionalLogin, c.parsedAuthOpts).Client()
   159  	if err != nil {
   160  		return nil, fmt.Errorf("failed to initialize auth client: %w", err)
   161  	}
   162  
   163  	buildsClient := buildbucketpb.NewBuildsPRPCClient(&prpc.Client{
   164  		C:    authClient,
   165  		Host: c.bbHost,
   166  	})
   167  	builder, ok := c.builder.Get().(*buildbucketpb.BuilderID)
   168  	if !ok {
   169  		return nil, errors.New("builder input is not of the form project/bucket/builder")
   170  	}
   171  
   172  	build, err := searchForBuild(ctx, buildsClient, builder, c.gitilesRemote, c.baseCommit, outputProps)
   173  	if err != nil {
   174  		return nil, err
   175  	}
   176  
   177  	if hasEnded(build) {
   178  		// The build has already finished, exit early to avoid an extra RPC
   179  		// request.
   180  		return build, err
   181  	}
   182  	return waitForBuildCompletion(ctx, buildsClient, build.Id, c.bbHost, outputProps)
   183  }