blob: b6d52ef3abca182b14371640302888488cb962dc [file] [log] [blame]
// Copyright 2022 The Fuchsia 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 main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/url"
"os"
"sort"
"strings"
"github.com/golang/protobuf/proto"
"github.com/maruel/subcommands"
"go.chromium.org/luci/auth"
buildbucketpb "go.chromium.org/luci/buildbucket/proto"
gerritpb "go.chromium.org/luci/common/proto/gerrit"
cvpb "go.chromium.org/luci/cv/api/config/v2"
"golang.org/x/exp/slices"
"go.fuchsia.dev/infra/buildbucket"
"go.fuchsia.dev/infra/flagutil"
"go.fuchsia.dev/infra/gerrit"
"go.fuchsia.dev/infra/gitiles"
"google.golang.org/genproto/protobuf/field_mask"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/structpb"
)
const rptLongDesc = `
run-postsubmit-tryjobs run all available presubmit tryjobs for a CL that are
required by postsubmit. Due to a number of reasons
outlined in go/what-belongs-in-presubmit we can't run all tryjobs on all
changes by default, so this tool is for allowing automated tooling or humans
to specify which changes to run all builders for.
`
const buildbucketHost = "cr-buildbucket.appspot.com"
func cmdRunPostsubmitTryjobs(authOpts auth.Options) *subcommands.Command {
return &subcommands.Command{
UsageLine: "run-postsubmit-tryjobs <CL URL>",
ShortDesc: "Trigger available tryjobs that are required for postsubmit for a CL.",
LongDesc: rptLongDesc,
CommandRun: func() subcommands.CommandRun {
c := &rptCmd{}
c.Init(authOpts)
return c
},
}
}
type rptCmd struct {
commonFlags
gerritChangeID int64
gerritPatchset int64
jsonOutputFile string
CommitQueueCfgPath string
LUCIConfigHost string
LUCIConfigPath string
LUCIConfigProject string
CrBuildBucketCfgPaths flagutil.RepeatedStringValue
dryRun bool
force bool
allPresubmit bool
verbose bool
}
type BuildersToTrigger struct {
Builders []string `json:"builders"`
}
type LUCIConfigurationFiles struct {
commitQueueConfig *cvpb.Config
crBuildBucketConfigs map[string]*buildbucketpb.BuildbucketCfg
}
func (c *rptCmd) Init(defaultAuthOpts auth.Options) {
c.commonFlags.Init(defaultAuthOpts)
c.Flags.Int64Var(
&c.gerritChangeID,
"gerrit-change-id",
0,
"The ChangeID of the GerritChange to trigger builders against.",
)
c.Flags.StringVar(
&c.LUCIConfigHost,
"luci-config-host",
"turquoise-internal.googlesource.com",
"Gerrit host for the project containing the LUCI configuration files.",
)
c.Flags.StringVar(
&c.LUCIConfigProject,
"luci-config-project",
"integration",
"Project name containing the LUCI configuration files.",
)
c.Flags.StringVar(
&c.CommitQueueCfgPath,
"commit-queue-cfg-path",
"infra/config/generated/turquoise/luci/commit-queue.cfg",
"Path to the LUCI commit-queue.cfg configuration file.",
)
c.Flags.Var(
&c.CrBuildBucketCfgPaths,
"cr-buildbucket-cfg-project-path",
"One or more comma-separated strings containing a project name and path to a LUCI cr-buildbucket.cfg configuration file. e.g. 'project,path/to/cr-buildbucket.cfg'",
)
c.Flags.BoolVar(
&c.allPresubmit,
"trigger-all-presubmit",
false,
"Whether to include presubmit builders not tagged for run-postsubmit-tryjobs. Projects outside Fuchsia that have not tagged their builders should enable this flag.",
)
c.Flags.BoolVar(
&c.force,
"f",
false,
"Whether to skip command line confirmation when triggering builders.",
)
c.Flags.BoolVar(
&c.verbose,
"v",
false,
"Whether to print all builders to be run.",
)
c.Flags.StringVar(
&c.jsonOutputFile,
"json-output",
"",
"Filepath to write json output to. Use '-' for stdout.",
)
c.Flags.BoolVar(
&c.dryRun,
"dry-run",
false,
"Whether to actually trigger the builders or just print out which builders would have been triggered.",
)
}
func (c *rptCmd) Parse(a subcommands.Application, args []string) error {
if len(args) != 0 && !strings.HasPrefix(args[0], "-") {
// The first positional argument is the changelink, parse it into constituent parts.
changeUrl, err := url.Parse(args[0])
if err != nil {
return err
}
gerritHost, changeID, err := gerrit.ResolveChangeUrl(changeUrl)
if err != nil {
return err
}
c.gerritHost = gerritHost
c.gerritChangeID = changeID
}
// Set placeholder value for gerritProject if it isn't set at this point.
// commonFlags.Parse includes validation for gerritProject to not be null,
// but we set it in getChangeDetails.
if c.gerritProject == "" {
c.gerritProject = "placeholder-project-name"
}
// Custom vars can't have defaults set so populate here in case it's unset.
if len(c.CrBuildBucketCfgPaths) == 0 {
c.CrBuildBucketCfgPaths = flagutil.RepeatedStringValue{
"turquoise,infra/config/generated/turquoise/luci/cr-buildbucket.cfg",
"fuchsia,infra/config/generated/fuchsia/luci/cr-buildbucket.cfg",
}
}
if err := c.commonFlags.Parse(); err != nil {
return err
}
return nil
}
func (c *rptCmd) Run(a subcommands.Application, args []string, _ subcommands.Env) int {
if err := c.Parse(a, args); err != nil {
fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err)
return 1
}
if err := c.main(); err != nil {
fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err)
return 1
}
return 0
}
func (c *rptCmd) main() error {
if !c.verbose {
log.SetOutput(io.Discard)
}
ctx := context.Background()
buildClient, err := buildbucket.NewBuildsClient(
ctx,
buildbucketHost,
c.commonFlags.parsedAuthOpts,
)
if err != nil {
return err
}
authClient, err := auth.NewAuthenticator(ctx, auth.OptionalLogin, c.commonFlags.parsedAuthOpts).Client()
if err != nil {
return fmt.Errorf("failed to get authenticated http client: %w", err)
}
gitilesClient, err := gitiles.NewClient(c.LUCIConfigHost, c.LUCIConfigProject, authClient)
if err != nil {
return err
}
lucicfg, err := c.getLUCIConfigs(ctx, *gitilesClient)
if err != nil {
return err
}
gerritClient, err := gerrit.NewClient(c.gerritHost, c.gerritProject, authClient)
if err != nil {
return err
}
if err = c.getChangeDetails(ctx, *gerritClient); err != nil {
return err
}
availablePresubmitBuilders := c.getAvailablePresubmitBuilders(lucicfg, c.gerritHost, c.gerritProject)
triggeredBuilds, err := c.getTriggeredBuildsForChange(ctx, buildClient)
if err != nil {
return err
}
triggeredBuilds = c.filterFailedBuilders(triggeredBuilds)
missingBuilders := c.getMissingBuilders(availablePresubmitBuilders, triggeredBuilds)
return c.triggerMissingBuilders(ctx, buildClient, missingBuilders)
}
// getLUCIConfigs fetches the LUCI configuration files contained at
// the project specified in c.LUCIConfigProject and c.LUCIConfigPath
func (c *rptCmd) getLUCIConfigs(ctx context.Context, gitilesClient gitiles.Client) (LUCIConfigurationFiles, error) {
log.Println("Downloading LUCI configuration files...")
// Set non-nil values for the configs.
commitQueueConfig := &cvpb.Config{}
crBuildBucketConfigs := map[string]*buildbucketpb.BuildbucketCfg{}
commitQueueFileContents, err := gitilesClient.DownloadFile(
ctx,
c.CommitQueueCfgPath,
"refs/heads/main",
)
if err != nil {
return LUCIConfigurationFiles{}, err
}
if err := proto.UnmarshalText(commitQueueFileContents, commitQueueConfig); err != nil {
return LUCIConfigurationFiles{}, err
}
for _, projectPath := range c.CrBuildBucketCfgPaths {
projectPathSplit := strings.Split(projectPath, ",")
if len(projectPathSplit) != 2 {
return LUCIConfigurationFiles{}, errors.New(
fmt.Sprintf(
"Invalid entry to cr-buildbucket-cfg-project-path, expected 'project,path/to/cr-buildbucket.cfg' got %s",
projectPath,
),
)
}
project := projectPathSplit[0]
path := projectPathSplit[1]
cfg := &buildbucketpb.BuildbucketCfg{}
CrBuildBucketFileContents, err := gitilesClient.DownloadFile(
ctx,
path,
"refs/heads/main",
)
if err != nil {
return LUCIConfigurationFiles{}, err
}
if err = proto.UnmarshalText(CrBuildBucketFileContents, cfg); err != nil {
return LUCIConfigurationFiles{}, err
}
crBuildBucketConfigs[project] = cfg
}
return LUCIConfigurationFiles{
commitQueueConfig: commitQueueConfig,
crBuildBucketConfigs: crBuildBucketConfigs,
}, nil
}
// getChangeDetails queries Gerrit for the target change to determine
// the ChangeRef, ChangeRepo, Project and Patchset (if not specified)
func (c *rptCmd) getChangeDetails(ctx context.Context, gerritClient gerrit.Client) error {
change, err := gerritClient.GetChange(
ctx,
c.gerritChangeID,
gerritpb.QueryOption_ALL_REVISIONS,
)
if err != nil {
return err
}
c.gerritPatchset = int64(change.Revisions[change.CurrentRevision].Number)
c.gerritProject = change.Project
return nil
}
// getAvailablePresubmitBuilders parses through available builders in
// presubmit and if an equivalent builder is required in postsubmit, adds it to
// the list of builders we check to trigger.
func (c *rptCmd) getAvailablePresubmitBuilders(lucicfg LUCIConfigurationFiles, gerritHost string, gerritProject string) []*buildbucketpb.BuilderID {
// Throw buckets into a map for cross referencing.
flaggedBuildersByBucketAndProjectMap := map[string]map[string]map[string]bool{}
for project, cfg := range lucicfg.crBuildBucketConfigs {
if _, ok := flaggedBuildersByBucketAndProjectMap[project]; !ok {
flaggedBuildersByBucketAndProjectMap[project] = map[string]map[string]bool{}
}
for _, bucket := range cfg.GetBuckets() {
flaggedBuildersByBucketAndProjectMap[project][bucket.GetName()] = getBuildersFlaggedForRpt(bucket)
}
}
// Find CQ for the target change.
configGroups := lucicfg.commitQueueConfig.GetConfigGroups()
var availablePresubmitBuilders []*buildbucketpb.BuilderID
for _, cfg := range configGroups {
projectMatch := cfg.GetGerrit()[0].GetProjects()[0].GetName() == gerritProject
cfgUrl, _ := url.Parse(cfg.GetGerrit()[0].GetUrl())
hostMatch := cfgUrl.Host == gerritHost
if projectMatch && hostMatch {
for _, builder := range cfg.GetVerifiers().GetTryjob().GetBuilders() {
builderSplit := strings.Split(builder.GetName(), "/")
builderID := &buildbucketpb.BuilderID{
Project: builderSplit[0],
Bucket: builderSplit[1],
Builder: builderSplit[2],
}
if c.allPresubmit || flaggedBuildersByBucketAndProjectMap[builderID.Project][builderID.Bucket][builderID.Builder] {
availablePresubmitBuilders = append(availablePresubmitBuilders, builderID)
}
}
}
}
// Sort for logging output clarity.
sort.SliceStable(availablePresubmitBuilders, func(i, j int) bool {
return availablePresubmitBuilders[i].Builder < availablePresubmitBuilders[j].Builder
})
return availablePresubmitBuilders
}
// getTriggeredBuildersForChange calls the buildbucket SearchBuilds RPC
// and reports back all builders that have already triggered against the change.
func (c *rptCmd) getTriggeredBuildsForChange(ctx context.Context, buildClient buildbucketpb.BuildsClient) ([]*buildbucketpb.Build, error) {
// Pull all builds triggered against the CL.
resp, err := buildClient.SearchBuilds(
ctx,
&buildbucketpb.SearchBuildsRequest{
Predicate: &buildbucketpb.BuildPredicate{
GerritChanges: []*buildbucketpb.GerritChange{
{
Host: c.gerritHost,
Project: c.gerritProject,
Change: c.gerritChangeID,
Patchset: c.gerritPatchset,
},
},
},
Fields: &field_mask.FieldMask{Paths: []string{
"builds.*.builder",
"builds.*.status",
}},
PageSize: 1000,
},
)
if err != nil {
return []*buildbucketpb.Build{}, err
}
return resp.GetBuilds(), nil
}
// filterFailedBuilders filters failed builders out of the triggered builders
// so that they will be re-triggered by run-postsubmit-tryjobs.
func (c *rptCmd) filterFailedBuilders(triggeredBuilds []*buildbucketpb.Build) []*buildbucketpb.Build {
var filteredBuilds []*buildbucketpb.Build
for _, build := range triggeredBuilds {
if !slices.Contains(
[]buildbucketpb.Status{
buildbucketpb.Status_FAILURE,
buildbucketpb.Status_INFRA_FAILURE,
},
build.GetStatus(),
) {
filteredBuilds = append(filteredBuilds, build)
}
}
return filteredBuilds
}
// getMissingBuilders finds the diff between availablePresubmitBuilders and the
// builds already triggered by the change.
func (c *rptCmd) getMissingBuilders(availablePresubmitBuilders []*buildbucketpb.BuilderID, triggeredPresubmitBuilds []*buildbucketpb.Build) []*buildbucketpb.BuilderID {
var missingBuilders []*buildbucketpb.BuilderID
var triggeredBuilders []string
for _, build := range triggeredPresubmitBuilds {
triggeredBuilders = append(triggeredBuilders, build.GetBuilder().GetBuilder())
}
// Find which builders haven't been triggered yet.
for _, builder := range availablePresubmitBuilders {
if !slices.Contains(triggeredBuilders, builder.GetBuilder()) && !builderSliceContains(missingBuilders, builder) {
missingBuilders = append(missingBuilders, builder)
}
}
return missingBuilders
}
// triggerMissingBuilders triggers the diff between all builders available
// and the builders that have already been triggered for the change.
func (c *rptCmd) triggerMissingBuilders(ctx context.Context, buildClient buildbucketpb.BuildsClient, missingBuilders []*buildbucketpb.BuilderID) error {
// If we have no builders to trigger, return.
if len(missingBuilders) == 0 {
log.Println("No builders found to trigger.")
return nil
}
// Print out list of builders to be triggered for logging.
for _, builder := range missingBuilders {
log.Printf("Builder: %s\n", builder.GetBuilder())
}
// Require command line confirmation. Automated tools should pass the -f flag.
if !(c.force || c.dryRun) {
// The cost has been calculated roughly on average as of 2022 to be $0.57 per builder triggered
// GCE Cost: $0.53/h
// Orchestrator builder: 0.45h
// Subbuild builder: 0.45h
// Testing Subtasks: 0.17h
fmt.Printf("You are about to trigger %d tryjobs with an estimated cost of $%.2f USD. Additionally, this will likely consume inelastic resources. Do you wish to proceed? (y/n)\n", len(missingBuilders), 0.57*float64(len(missingBuilders)))
confirm := ""
fmt.Scanln(&confirm)
if !slices.Contains([]string{"y", "Y", "yes", "Yes", "YES"}, confirm) {
fmt.Println("Process aborted.")
return nil
} else {
fmt.Println("You can skip this confirmation next time by passing the -f flag.")
}
}
// Assemble requests into a batch.
var requestBatch []*buildbucketpb.BatchRequest_Request
var triggeredBuilders []string
for _, builder := range missingBuilders {
req := &buildbucketpb.BatchRequest_Request{
Request: &buildbucketpb.BatchRequest_Request_ScheduleBuild{
ScheduleBuild: &buildbucketpb.ScheduleBuildRequest{
Builder: builder,
GerritChanges: []*buildbucketpb.GerritChange{
{
Host: c.gerritHost,
Project: c.gerritProject,
Change: c.gerritChangeID,
Patchset: c.gerritPatchset,
},
},
// Slightly below default priority.
Priority: 31,
},
},
}
triggeredBuilders = append(triggeredBuilders, builder.Builder)
requestBatch = append(requestBatch, req)
}
if !c.dryRun {
resp, err := buildClient.Batch(
ctx,
&buildbucketpb.BatchRequest{
Requests: requestBatch,
},
)
if err != nil {
return err
}
jsonResp, err := json.MarshalIndent(resp, "", " ")
if err != nil {
return err
}
log.Printf(
"BatchRequest response: \n%s",
jsonResp,
)
}
if c.jsonOutputFile != "" {
// Assemble builder data into easy to consume json output.
jsonOutput := &BuildersToTrigger{
Builders: triggeredBuilders,
}
// Marshal output to json.
var rawJSON []byte
var err error
var f io.WriteCloser = os.Stdout
if c.jsonOutputFile != "-" {
if f, err = os.Create(c.jsonOutputFile); err != nil {
return err
}
defer f.Close()
}
if c.jsonOutputFile == "-" {
rawJSON, err = json.MarshalIndent(jsonOutput, "", " ")
rawJSON = append(rawJSON, '\n')
} else {
rawJSON, err = json.Marshal(jsonOutput)
rawJSON = append(rawJSON, '\n')
}
if err != nil {
return err
}
if _, err := f.Write(rawJSON); err != nil {
return err
}
}
return nil
}
// slices.Contains doesn't really work with slices of pointers, so this helper
// performs a low effort check for inclusion.
func builderSliceContains(slice []*buildbucketpb.BuilderID, builder *buildbucketpb.BuilderID) bool {
for _, b := range slice {
if b.GetBuilder() == builder.GetBuilder() {
return true
}
}
return false
}
// getBuildersFlaggedForRpt finds builders with the `run_postsubmit_tryjobs_include` property.
func getBuildersFlaggedForRpt(bucket *buildbucketpb.Bucket) map[string]bool {
flaggedBuilders := map[string]bool{}
for _, b := range bucket.GetSwarming().GetBuilders() {
properties := &structpb.Struct{}
protojson.Unmarshal([]byte(b.GetProperties()), properties)
if builderTags, ok := properties.GetFields()["$fuchsia/builder_tags"]; ok {
if runPostsubmitTryjobsInclude, ok := builderTags.GetStructValue().GetFields()["run_postsubmit_tryjobs_include"]; ok {
flaggedBuilders[b.GetName()] = runPostsubmitTryjobsInclude.GetBoolValue()
} else {
flaggedBuilders[b.GetName()] = false
}
} else {
flaggedBuilders[b.GetName()] = false
}
}
return flaggedBuilders
}