go.fuchsia.dev/infra@v0.0.0-20240507153436-9b593402251b/cmd/cl-util/run_postsubmit_tryjobs.go (about) 1 // Copyright 2022 The Fuchsia Authors. All rights reserved. 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 "encoding/json" 10 "errors" 11 "fmt" 12 "io" 13 "log" 14 "net/url" 15 "os" 16 "slices" 17 "sort" 18 "strings" 19 20 "github.com/golang/protobuf/proto" 21 "github.com/maruel/subcommands" 22 "go.chromium.org/luci/auth" 23 buildbucketpb "go.chromium.org/luci/buildbucket/proto" 24 gerritpb "go.chromium.org/luci/common/proto/gerrit" 25 cvpb "go.chromium.org/luci/cv/api/config/v2" 26 "go.fuchsia.dev/infra/buildbucket" 27 "go.fuchsia.dev/infra/flagutil" 28 "go.fuchsia.dev/infra/gerrit" 29 "go.fuchsia.dev/infra/gitiles" 30 "google.golang.org/genproto/protobuf/field_mask" 31 "google.golang.org/protobuf/encoding/protojson" 32 "google.golang.org/protobuf/types/known/structpb" 33 ) 34 35 const rptLongDesc = ` 36 run-postsubmit-tryjobs run all available presubmit tryjobs for a CL that are 37 required by postsubmit. Due to a number of reasons 38 outlined in go/what-belongs-in-presubmit we can't run all tryjobs on all 39 changes by default, so this tool is for allowing automated tooling or humans 40 to specify which changes to run all builders for. 41 ` 42 43 const buildbucketHost = "cr-buildbucket.appspot.com" 44 45 func cmdRunPostsubmitTryjobs(authOpts auth.Options) *subcommands.Command { 46 return &subcommands.Command{ 47 UsageLine: "run-postsubmit-tryjobs <CL URL>", 48 ShortDesc: "Trigger available tryjobs that are required for postsubmit for a CL.", 49 LongDesc: rptLongDesc, 50 CommandRun: func() subcommands.CommandRun { 51 c := &rptCmd{} 52 c.Init(authOpts) 53 return c 54 }, 55 } 56 } 57 58 type rptCmd struct { 59 commonFlags 60 gerritChangeID int64 61 gerritPatchset int64 62 jsonOutputFile string 63 CommitQueueCfgPath string 64 LUCIConfigHost string 65 LUCIConfigPath string 66 LUCIConfigProject string 67 CrBuildBucketCfgPaths flagutil.RepeatedStringValue 68 dryRun bool 69 force bool 70 allPresubmit bool 71 verbose bool 72 } 73 74 type BuildersToTrigger struct { 75 Builders []string `json:"builders"` 76 } 77 78 type LUCIConfigurationFiles struct { 79 commitQueueConfig *cvpb.Config 80 crBuildBucketConfigs map[string]*buildbucketpb.BuildbucketCfg 81 } 82 83 func (c *rptCmd) Init(defaultAuthOpts auth.Options) { 84 c.commonFlags.Init(defaultAuthOpts) 85 c.Flags.Int64Var( 86 &c.gerritChangeID, 87 "gerrit-change-id", 88 0, 89 "The ChangeID of the GerritChange to trigger builders against.", 90 ) 91 c.Flags.StringVar( 92 &c.LUCIConfigHost, 93 "luci-config-host", 94 "turquoise-internal.googlesource.com", 95 "Gerrit host for the project containing the LUCI configuration files.", 96 ) 97 c.Flags.StringVar( 98 &c.LUCIConfigProject, 99 "luci-config-project", 100 "integration", 101 "Project name containing the LUCI configuration files.", 102 ) 103 c.Flags.StringVar( 104 &c.CommitQueueCfgPath, 105 "commit-queue-cfg-path", 106 "infra/config/generated/turquoise/luci/commit-queue.cfg", 107 "Path to the LUCI commit-queue.cfg configuration file.", 108 ) 109 c.Flags.Var( 110 &c.CrBuildBucketCfgPaths, 111 "cr-buildbucket-cfg-project-path", 112 "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'", 113 ) 114 c.Flags.BoolVar( 115 &c.allPresubmit, 116 "trigger-all-presubmit", 117 false, 118 "Whether to include presubmit builders not tagged for run-postsubmit-tryjobs. Projects outside Fuchsia that have not tagged their builders should enable this flag.", 119 ) 120 c.Flags.BoolVar( 121 &c.force, 122 "f", 123 false, 124 "Whether to skip command line confirmation when triggering builders.", 125 ) 126 c.Flags.BoolVar( 127 &c.verbose, 128 "v", 129 false, 130 "Whether to print all builders to be run.", 131 ) 132 c.Flags.StringVar( 133 &c.jsonOutputFile, 134 "json-output", 135 "", 136 "Filepath to write json output to. Use '-' for stdout.", 137 ) 138 c.Flags.BoolVar( 139 &c.dryRun, 140 "dry-run", 141 false, 142 "Whether to actually trigger the builders or just print out which builders would have been triggered.", 143 ) 144 } 145 146 func (c *rptCmd) Parse(a subcommands.Application, args []string) error { 147 if len(args) != 0 && !strings.HasPrefix(args[0], "-") { 148 // The first positional argument is the changelink, parse it into constituent parts. 149 changeUrl, err := url.Parse(args[0]) 150 if err != nil { 151 return err 152 } 153 154 gerritHost, changeID, err := gerrit.ResolveChangeUrl(changeUrl) 155 if err != nil { 156 return err 157 } 158 c.gerritHost = gerritHost 159 c.gerritChangeID = changeID 160 } 161 162 // Set placeholder value for gerritProject if it isn't set at this point. 163 // commonFlags.Parse includes validation for gerritProject to not be null, 164 // but we set it in getChangeDetails. 165 if c.gerritProject == "" { 166 c.gerritProject = "placeholder-project-name" 167 } 168 169 // Custom vars can't have defaults set so populate here in case it's unset. 170 if len(c.CrBuildBucketCfgPaths) == 0 { 171 c.CrBuildBucketCfgPaths = flagutil.RepeatedStringValue{ 172 "turquoise,infra/config/generated/turquoise/luci/cr-buildbucket.cfg", 173 "fuchsia,infra/config/generated/fuchsia/luci/cr-buildbucket.cfg", 174 } 175 } 176 177 if err := c.commonFlags.Parse(); err != nil { 178 return err 179 } 180 181 return nil 182 } 183 184 func (c *rptCmd) Run(a subcommands.Application, args []string, _ subcommands.Env) int { 185 if err := c.Parse(a, args); err != nil { 186 fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err) 187 return 1 188 } 189 190 if err := c.main(); err != nil { 191 fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err) 192 return 1 193 } 194 return 0 195 } 196 197 func (c *rptCmd) main() error { 198 if !c.verbose { 199 log.SetOutput(io.Discard) 200 } 201 202 ctx := context.Background() 203 204 buildClient, err := buildbucket.NewBuildsClient( 205 ctx, 206 buildbucketHost, 207 c.commonFlags.parsedAuthOpts, 208 ) 209 if err != nil { 210 return err 211 } 212 213 authClient, err := auth.NewAuthenticator(ctx, auth.OptionalLogin, c.commonFlags.parsedAuthOpts).Client() 214 if err != nil { 215 return fmt.Errorf("failed to get authenticated http client: %w", err) 216 } 217 218 gitilesClient, err := gitiles.NewClient(c.LUCIConfigHost, c.LUCIConfigProject, authClient) 219 if err != nil { 220 return err 221 } 222 223 lucicfg, err := c.getLUCIConfigs(ctx, *gitilesClient) 224 if err != nil { 225 return err 226 } 227 228 gerritClient, err := gerrit.NewClient(c.gerritHost, c.gerritProject, authClient) 229 if err != nil { 230 return err 231 } 232 233 if err = c.getChangeDetails(ctx, *gerritClient); err != nil { 234 return err 235 } 236 237 availablePresubmitBuilders := c.getAvailablePresubmitBuilders(lucicfg, c.gerritHost, c.gerritProject) 238 239 triggeredBuilds, err := c.getTriggeredBuildsForChange(ctx, buildClient) 240 if err != nil { 241 return err 242 } 243 244 triggeredBuilds = c.filterFailedBuilders(triggeredBuilds) 245 missingBuilders := c.getMissingBuilders(availablePresubmitBuilders, triggeredBuilds) 246 if err := c.writeJSONOutput(missingBuilders); err != nil { 247 return err 248 } 249 return c.triggerMissingBuilders(ctx, buildClient, missingBuilders) 250 } 251 252 // getLUCIConfigs fetches the LUCI configuration files contained at 253 // the project specified in c.LUCIConfigProject and c.LUCIConfigPath 254 func (c *rptCmd) getLUCIConfigs(ctx context.Context, gitilesClient gitiles.Client) (LUCIConfigurationFiles, error) { 255 log.Println("Downloading LUCI configuration files...") 256 // Set non-nil values for the configs. 257 commitQueueConfig := &cvpb.Config{} 258 crBuildBucketConfigs := map[string]*buildbucketpb.BuildbucketCfg{} 259 260 commitQueueFileContents, err := gitilesClient.DownloadFile( 261 ctx, 262 c.CommitQueueCfgPath, 263 "refs/heads/main", 264 ) 265 if err != nil { 266 return LUCIConfigurationFiles{}, err 267 } 268 269 if err := proto.UnmarshalText(commitQueueFileContents, commitQueueConfig); err != nil { 270 return LUCIConfigurationFiles{}, err 271 } 272 273 for _, projectPath := range c.CrBuildBucketCfgPaths { 274 projectPathSplit := strings.Split(projectPath, ",") 275 if len(projectPathSplit) != 2 { 276 return LUCIConfigurationFiles{}, errors.New( 277 fmt.Sprintf( 278 "Invalid entry to cr-buildbucket-cfg-project-path, expected 'project,path/to/cr-buildbucket.cfg' got %s", 279 projectPath, 280 ), 281 ) 282 } 283 project := projectPathSplit[0] 284 path := projectPathSplit[1] 285 286 cfg := &buildbucketpb.BuildbucketCfg{} 287 CrBuildBucketFileContents, err := gitilesClient.DownloadFile( 288 ctx, 289 path, 290 "refs/heads/main", 291 ) 292 if err != nil { 293 return LUCIConfigurationFiles{}, err 294 } 295 296 if err = proto.UnmarshalText(CrBuildBucketFileContents, cfg); err != nil { 297 return LUCIConfigurationFiles{}, err 298 } 299 300 crBuildBucketConfigs[project] = cfg 301 } 302 303 return LUCIConfigurationFiles{ 304 commitQueueConfig: commitQueueConfig, 305 crBuildBucketConfigs: crBuildBucketConfigs, 306 }, nil 307 } 308 309 // getChangeDetails queries Gerrit for the target change to determine 310 // the ChangeRef, ChangeRepo, Project and Patchset (if not specified) 311 func (c *rptCmd) getChangeDetails(ctx context.Context, gerritClient gerrit.Client) error { 312 change, err := gerritClient.GetChange( 313 ctx, 314 c.gerritChangeID, 315 gerritpb.QueryOption_ALL_REVISIONS, 316 ) 317 if err != nil { 318 return err 319 } 320 321 c.gerritPatchset = int64(change.Revisions[change.CurrentRevision].Number) 322 c.gerritProject = change.Project 323 324 return nil 325 } 326 327 // getAvailablePresubmitBuilders parses through available builders in 328 // presubmit and if an equivalent builder is required in postsubmit, adds it to 329 // the list of builders we check to trigger. 330 func (c *rptCmd) getAvailablePresubmitBuilders(lucicfg LUCIConfigurationFiles, gerritHost string, gerritProject string) []*buildbucketpb.BuilderID { 331 // Throw buckets into a map for cross referencing. 332 flaggedBuildersByBucketAndProjectMap := map[string]map[string]map[string]bool{} 333 for project, cfg := range lucicfg.crBuildBucketConfigs { 334 if _, ok := flaggedBuildersByBucketAndProjectMap[project]; !ok { 335 flaggedBuildersByBucketAndProjectMap[project] = map[string]map[string]bool{} 336 } 337 for _, bucket := range cfg.GetBuckets() { 338 flaggedBuildersByBucketAndProjectMap[project][bucket.GetName()] = getBuildersFlaggedForRpt(bucket) 339 } 340 } 341 342 // Find CQ for the target change. 343 configGroups := lucicfg.commitQueueConfig.GetConfigGroups() 344 var availablePresubmitBuilders []*buildbucketpb.BuilderID 345 346 for _, cfg := range configGroups { 347 projectMatch := cfg.GetGerrit()[0].GetProjects()[0].GetName() == gerritProject 348 cfgUrl, _ := url.Parse(cfg.GetGerrit()[0].GetUrl()) 349 hostMatch := cfgUrl.Host == gerritHost 350 if projectMatch && hostMatch { 351 for _, builder := range cfg.GetVerifiers().GetTryjob().GetBuilders() { 352 builderSplit := strings.Split(builder.GetName(), "/") 353 builderID := &buildbucketpb.BuilderID{ 354 Project: builderSplit[0], 355 Bucket: builderSplit[1], 356 Builder: builderSplit[2], 357 } 358 359 if c.allPresubmit || flaggedBuildersByBucketAndProjectMap[builderID.Project][builderID.Bucket][builderID.Builder] { 360 availablePresubmitBuilders = append(availablePresubmitBuilders, builderID) 361 } 362 } 363 } 364 } 365 366 // Sort for logging output clarity. 367 sort.SliceStable(availablePresubmitBuilders, func(i, j int) bool { 368 return availablePresubmitBuilders[i].Builder < availablePresubmitBuilders[j].Builder 369 }) 370 371 return availablePresubmitBuilders 372 } 373 374 // getTriggeredBuildersForChange calls the buildbucket SearchBuilds RPC 375 // and reports back all builders that have already triggered against the change. 376 func (c *rptCmd) getTriggeredBuildsForChange(ctx context.Context, buildClient buildbucketpb.BuildsClient) ([]*buildbucketpb.Build, error) { 377 // Pull all builds triggered against the CL. 378 resp, err := buildClient.SearchBuilds( 379 ctx, 380 &buildbucketpb.SearchBuildsRequest{ 381 Predicate: &buildbucketpb.BuildPredicate{ 382 GerritChanges: []*buildbucketpb.GerritChange{ 383 { 384 Host: c.gerritHost, 385 Project: c.gerritProject, 386 Change: c.gerritChangeID, 387 Patchset: c.gerritPatchset, 388 }, 389 }, 390 }, 391 Fields: &field_mask.FieldMask{Paths: []string{ 392 "builds.*.builder", 393 "builds.*.status", 394 }}, 395 PageSize: 1000, 396 }, 397 ) 398 if err != nil { 399 return []*buildbucketpb.Build{}, err 400 } 401 402 return resp.GetBuilds(), nil 403 } 404 405 // filterFailedBuilders filters failed builders out of the triggered builders 406 // so that they will be re-triggered by run-postsubmit-tryjobs. 407 func (c *rptCmd) filterFailedBuilders(triggeredBuilds []*buildbucketpb.Build) []*buildbucketpb.Build { 408 var filteredBuilds []*buildbucketpb.Build 409 for _, build := range triggeredBuilds { 410 if !slices.Contains( 411 []buildbucketpb.Status{ 412 buildbucketpb.Status_FAILURE, 413 buildbucketpb.Status_INFRA_FAILURE, 414 }, 415 build.GetStatus(), 416 ) { 417 filteredBuilds = append(filteredBuilds, build) 418 } 419 } 420 return filteredBuilds 421 } 422 423 // getMissingBuilders finds the diff between availablePresubmitBuilders and the 424 // builds already triggered by the change. 425 func (c *rptCmd) getMissingBuilders(availablePresubmitBuilders []*buildbucketpb.BuilderID, triggeredPresubmitBuilds []*buildbucketpb.Build) []*buildbucketpb.BuilderID { 426 var missingBuilders []*buildbucketpb.BuilderID 427 var triggeredBuilders []string 428 for _, build := range triggeredPresubmitBuilds { 429 triggeredBuilders = append(triggeredBuilders, build.GetBuilder().GetBuilder()) 430 } 431 432 // Find which builders haven't been triggered yet. 433 for _, builder := range availablePresubmitBuilders { 434 if !slices.Contains(triggeredBuilders, builder.GetBuilder()) && !builderSliceContains(missingBuilders, builder) { 435 missingBuilders = append(missingBuilders, builder) 436 } 437 } 438 439 return missingBuilders 440 } 441 442 // triggerMissingBuilders triggers the diff between all builders available 443 // and the builders that have already been triggered for the change. 444 func (c *rptCmd) triggerMissingBuilders(ctx context.Context, buildClient buildbucketpb.BuildsClient, missingBuilders []*buildbucketpb.BuilderID) error { 445 // If we have no builders to trigger, return. 446 if len(missingBuilders) == 0 { 447 log.Println("No builders found to trigger.") 448 return nil 449 } 450 451 // Print out list of builders to be triggered for logging. 452 for _, builder := range missingBuilders { 453 log.Printf("Builder: %s\n", builder.GetBuilder()) 454 } 455 456 // Require command line confirmation. Automated tools should pass the -f flag. 457 if !(c.force || c.dryRun) { 458 // The cost has been calculated roughly on average as of 2022 to be $0.57 per builder triggered 459 // GCE Cost: $0.53/h 460 // Orchestrator builder: 0.45h 461 // Subbuild builder: 0.45h 462 // Testing Subtasks: 0.17h 463 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))) 464 confirm := "" 465 fmt.Scanln(&confirm) 466 if !slices.Contains([]string{"y", "Y", "yes", "Yes", "YES"}, confirm) { 467 fmt.Println("Process aborted.") 468 return nil 469 } else { 470 fmt.Println("You can skip this confirmation next time by passing the -f flag.") 471 } 472 } 473 474 // Assemble requests into a batch. 475 var requestBatch []*buildbucketpb.BatchRequest_Request 476 for _, builder := range missingBuilders { 477 req := &buildbucketpb.BatchRequest_Request{ 478 Request: &buildbucketpb.BatchRequest_Request_ScheduleBuild{ 479 ScheduleBuild: &buildbucketpb.ScheduleBuildRequest{ 480 Builder: builder, 481 GerritChanges: []*buildbucketpb.GerritChange{ 482 { 483 Host: c.gerritHost, 484 Project: c.gerritProject, 485 Change: c.gerritChangeID, 486 Patchset: c.gerritPatchset, 487 }, 488 }, 489 // Slightly below default priority. 490 Priority: 31, 491 }, 492 }, 493 } 494 requestBatch = append(requestBatch, req) 495 } 496 497 if !c.dryRun { 498 resp, err := buildClient.Batch( 499 ctx, 500 &buildbucketpb.BatchRequest{ 501 Requests: requestBatch, 502 }, 503 ) 504 if err != nil { 505 return err 506 } 507 508 jsonResp, err := json.MarshalIndent(resp, "", " ") 509 if err != nil { 510 return err 511 } 512 513 log.Printf( 514 "BatchRequest response: \n%s", 515 jsonResp, 516 ) 517 } 518 519 return nil 520 } 521 522 func (c *rptCmd) writeJSONOutput(builders []*buildbucketpb.BuilderID) error { 523 if c.jsonOutputFile == "" { 524 return nil 525 } 526 // Assemble builder data into easy to consume json output. 527 jsonOutput := &BuildersToTrigger{Builders: []string{}} 528 for _, b := range builders { 529 jsonOutput.Builders = append(jsonOutput.Builders, b.Builder) 530 } 531 532 // Marshal output to json. 533 var rawJSON []byte 534 var err error 535 var f io.WriteCloser = os.Stdout 536 if c.jsonOutputFile != "-" { 537 if f, err = os.Create(c.jsonOutputFile); err != nil { 538 return err 539 } 540 defer f.Close() 541 } 542 543 if c.jsonOutputFile == "-" { 544 rawJSON, err = json.MarshalIndent(jsonOutput, "", " ") 545 rawJSON = append(rawJSON, '\n') 546 } else { 547 rawJSON, err = json.Marshal(jsonOutput) 548 rawJSON = append(rawJSON, '\n') 549 } 550 if err != nil { 551 return err 552 } 553 554 _, err = f.Write(rawJSON) 555 return err 556 } 557 558 // slices.Contains doesn't really work with slices of pointers, so this helper 559 // performs a low effort check for inclusion. 560 func builderSliceContains(slice []*buildbucketpb.BuilderID, builder *buildbucketpb.BuilderID) bool { 561 for _, b := range slice { 562 if b.GetBuilder() == builder.GetBuilder() { 563 return true 564 } 565 } 566 return false 567 } 568 569 // getBuildersFlaggedForRpt finds builders with the `run_postsubmit_tryjobs_include` property. 570 func getBuildersFlaggedForRpt(bucket *buildbucketpb.Bucket) map[string]bool { 571 flaggedBuilders := map[string]bool{} 572 for _, b := range bucket.GetSwarming().GetBuilders() { 573 properties := &structpb.Struct{} 574 protojson.Unmarshal([]byte(b.GetProperties()), properties) 575 if builderTags, ok := properties.GetFields()["$fuchsia/builder_tags"]; ok { 576 if runPostsubmitTryjobsInclude, ok := builderTags.GetStructValue().GetFields()["run_postsubmit_tryjobs_include"]; ok { 577 flaggedBuilders[b.GetName()] = runPostsubmitTryjobsInclude.GetBoolValue() 578 } else { 579 flaggedBuilders[b.GetName()] = false 580 } 581 } else { 582 flaggedBuilders[b.GetName()] = false 583 } 584 } 585 586 return flaggedBuilders 587 }