sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/plugins/override/override.go (about) 1 /* 2 Copyright 2018 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 // Package override supports the /override context command. 18 package override 19 20 import ( 21 "context" 22 "fmt" 23 "regexp" 24 "sort" 25 "strings" 26 27 "github.com/sirupsen/logrus" 28 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 29 "k8s.io/apimachinery/pkg/util/sets" 30 31 prowapi "sigs.k8s.io/prow/pkg/apis/prowjobs/v1" 32 "sigs.k8s.io/prow/pkg/config" 33 "sigs.k8s.io/prow/pkg/git/v2" 34 "sigs.k8s.io/prow/pkg/github" 35 "sigs.k8s.io/prow/pkg/pjutil" 36 "sigs.k8s.io/prow/pkg/pluginhelp" 37 "sigs.k8s.io/prow/pkg/plugins" 38 "sigs.k8s.io/prow/pkg/repoowners" 39 ) 40 41 const pluginName = "override" 42 43 var ( 44 overrideRe = regexp.MustCompile(`(?mi)^/override( ([^\r\n]+))?[\r\n]?$`) 45 ) 46 47 type Context struct { 48 Context string 49 Description string 50 State string 51 } 52 53 type githubClient interface { 54 CreateComment(owner, repo string, number int, comment string) error 55 CreateStatus(org, repo, ref string, s github.Status) error 56 GetPullRequest(org, repo string, number int) (*github.PullRequest, error) 57 GetRef(org, repo, ref string) (string, error) 58 HasPermission(org, repo, user string, role ...string) (bool, error) 59 ListStatuses(org, repo, ref string) ([]github.Status, error) 60 GetBranchProtection(org, repo, branch string) (*github.BranchProtection, error) 61 ListTeams(org string) ([]github.Team, error) 62 ListTeamMembersBySlug(org, teamSlug, role string) ([]github.TeamMember, error) 63 ListCheckRuns(org, repo, ref string) (*github.CheckRunList, error) 64 CreateCheckRun(org, repo string, checkRun github.CheckRun) error 65 UsesAppAuth() bool 66 } 67 68 type prowJobClient interface { 69 Create(context.Context, *prowapi.ProwJob, metav1.CreateOptions) (*prowapi.ProwJob, error) 70 } 71 72 type ownersClient interface { 73 LoadRepoOwners(org, repo, base string) (repoowners.RepoOwner, error) 74 } 75 76 type overrideClient interface { 77 githubClient 78 prowJobClient 79 ownersClient 80 presubmits(org, repo string, baseSHAGetter config.RefGetter, headSHA string) ([]config.Presubmit, error) 81 } 82 83 type client struct { 84 ghc githubClient 85 gc git.ClientFactory 86 config *config.Config 87 ownersClient ownersClient 88 prowJobClient prowJobClient 89 } 90 91 func (c client) CreateComment(owner, repo string, number int, comment string) error { 92 return c.ghc.CreateComment(owner, repo, number, comment) 93 } 94 func (c client) CreateStatus(org, repo, ref string, s github.Status) error { 95 return c.ghc.CreateStatus(org, repo, ref, s) 96 } 97 98 func (c client) GetRef(org, repo, ref string) (string, error) { 99 return c.ghc.GetRef(org, repo, ref) 100 } 101 102 func (c client) GetPullRequest(org, repo string, number int) (*github.PullRequest, error) { 103 return c.ghc.GetPullRequest(org, repo, number) 104 } 105 func (c client) ListStatuses(org, repo, ref string) ([]github.Status, error) { 106 return c.ghc.ListStatuses(org, repo, ref) 107 } 108 func (c client) GetBranchProtection(org, repo, branch string) (*github.BranchProtection, error) { 109 return c.ghc.GetBranchProtection(org, repo, branch) 110 } 111 func (c client) HasPermission(org, repo, user string, role ...string) (bool, error) { 112 return c.ghc.HasPermission(org, repo, user, role...) 113 } 114 func (c client) ListTeams(org string) ([]github.Team, error) { 115 return c.ghc.ListTeams(org) 116 } 117 func (c client) ListTeamMembersBySlug(org, teamSlug, role string) ([]github.TeamMember, error) { 118 return c.ghc.ListTeamMembersBySlug(org, teamSlug, role) 119 } 120 func (c client) ListCheckRuns(org, teamSlug, role string) (*github.CheckRunList, error) { 121 return c.ghc.ListCheckRuns(org, teamSlug, role) 122 } 123 124 func (c client) CreateCheckRun(org, repo string, checkRun github.CheckRun) error { 125 return c.ghc.CreateCheckRun(org, repo, checkRun) 126 } 127 128 func (c client) UsesAppAuth() bool { 129 return c.ghc.UsesAppAuth() 130 } 131 132 func (c client) Create(ctx context.Context, pj *prowapi.ProwJob, o metav1.CreateOptions) (*prowapi.ProwJob, error) { 133 return c.prowJobClient.Create(ctx, pj, o) 134 } 135 136 func (c client) presubmits(org, repo string, baseSHAGetter config.RefGetter, headSHA string) ([]config.Presubmit, error) { 137 headSHAGetter := func() (string, error) { 138 return headSHA, nil 139 } 140 presubmits, err := c.config.GetPresubmits(c.gc, org+"/"+repo, "", baseSHAGetter, headSHAGetter) 141 if err != nil { 142 return nil, fmt.Errorf("failed to get presubmits: %w", err) 143 } 144 return presubmits, nil 145 } 146 147 func presubmitForContext(presubmits []config.Presubmit, context string) *config.Presubmit { 148 for _, p := range presubmits { 149 if p.Context == context { 150 return &p 151 } 152 } 153 return nil 154 } 155 156 func (c client) LoadRepoOwners(org, repo, base string) (repoowners.RepoOwner, error) { 157 return c.ownersClient.LoadRepoOwners(org, repo, base) 158 } 159 160 func init() { 161 plugins.RegisterGenericCommentHandler(pluginName, handleGenericComment, helpProvider) 162 } 163 164 func helpProvider(config *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) { 165 yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{ 166 Override: plugins.Override{ 167 AllowTopLevelOwners: true, 168 AllowedGitHubTeams: map[string][]string{ 169 "kubernetes/kubernetes": {"team1", "team2"}, 170 }, 171 }, 172 }) 173 if err != nil { 174 logrus.WithError(err).Warnf("cannot generate comments for %s plugin", pluginName) 175 } 176 pluginHelp := &pluginhelp.PluginHelp{ 177 Description: "The override plugin allows repo admins to force a github status context to pass", 178 Snippet: yamlSnippet, 179 } 180 overrideConfig := plugins.Override{} 181 if config != nil { 182 overrideConfig = config.Override 183 } 184 pluginHelp.AddCommand(pluginhelp.Command{ 185 Usage: "/override [context1] [context2]", 186 Description: "Forces github status contexts to green (multiple can be given). If the desired context has spaces, it must be quoted.", 187 Featured: false, 188 WhoCanUse: whoCanUse(overrideConfig, "", ""), 189 Examples: []string{"/override pull-repo-whatever", "/override \"test / Unit Tests\"", "/override ci/circleci", "/override deleted-job other-job"}, 190 }) 191 return pluginHelp, nil 192 } 193 194 func whoCanUse(overrideConfig plugins.Override, org, repo string) string { 195 admins := "Repo administrators" 196 owners := "" 197 teams := "" 198 199 if overrideConfig.AllowTopLevelOwners { 200 owners = ", approvers in top level OWNERS file" 201 } 202 203 if len(overrideConfig.AllowedGitHubTeams) > 0 { 204 repoRef := fmt.Sprintf("%s/%s", org, repo) 205 var allTeams []string 206 for r, allowedTeams := range overrideConfig.AllowedGitHubTeams { 207 if repoRef == "/" || r == repoRef || r == org { 208 allTeams = append(allTeams, fmt.Sprintf("%s: %s", r, strings.Join(allowedTeams, " "))) 209 } 210 } 211 sort.Strings(allTeams) 212 teams = ", and the following github teams:" + strings.Join(allTeams, ", ") 213 } 214 215 return admins + owners + teams + "." 216 } 217 218 func handleGenericComment(pc plugins.Agent, e github.GenericCommentEvent) error { 219 c := client{ 220 gc: pc.GitClient, 221 ghc: pc.GitHubClient, 222 config: pc.Config, 223 prowJobClient: pc.ProwJobClient, 224 ownersClient: pc.OwnersClient, 225 } 226 return handle(c, pc.Logger, &e, pc.PluginConfig.Override) 227 } 228 229 func authorizedUser(gc githubClient, log *logrus.Entry, org, repo, user string) bool { 230 ok, err := gc.HasPermission(org, repo, user, github.RoleAdmin) 231 if err != nil { 232 log.WithError(err).Warnf("cannot determine whether %s is an admin of %s/%s", user, org, repo) 233 return false 234 } 235 return ok 236 } 237 238 func authorizedTopLevelOwner(oc ownersClient, allowTopLevelOwners bool, log *logrus.Entry, org, repo, user string, pr *github.PullRequest) bool { 239 if allowTopLevelOwners { 240 owners, err := oc.LoadRepoOwners(org, repo, pr.Base.Ref) 241 if err != nil { 242 log.WithError(err).Warnf("cannot determine whether %s is a top level owner of %s/%s", user, org, repo) 243 return false 244 } 245 return owners.TopLevelApprovers().Has(github.NormLogin(user)) 246 } 247 return false 248 } 249 250 func validateGitHubTeamSlugs(teamSlugs map[string][]string, org, repo string, githubTeams []github.Team) error { 251 validSlugs := sets.New[string]() 252 for _, team := range githubTeams { 253 validSlugs.Insert(team.Slug) 254 } 255 invalidSlugs := sets.New[string](teamSlugs[fmt.Sprintf("%s/%s", org, repo)]...).Difference(validSlugs) 256 257 if invalidSlugs.Len() > 0 { 258 return fmt.Errorf("invalid team slug(s): %s", strings.Join(sets.List(invalidSlugs), ",")) 259 } 260 return nil 261 } 262 263 func authorizedGitHubTeamMember(gc githubClient, log *logrus.Entry, teamSlugs map[string][]string, org, repo, user string) bool { 264 teams, err := gc.ListTeams(org) 265 if err != nil { 266 log.WithError(err).Warnf("cannot get list of teams for org %s", org) 267 return false 268 } 269 if err := validateGitHubTeamSlugs(teamSlugs, org, repo, teams); err != nil { 270 log.WithError(err).Warnf("invalid team slug(s)") 271 } 272 273 slugs := teamSlugs[fmt.Sprintf("%s/%s", org, repo)] 274 slugs = append(slugs, teamSlugs[org]...) 275 for _, slug := range slugs { 276 members, err := gc.ListTeamMembersBySlug(org, slug, github.RoleAll) 277 if err != nil { 278 log.WithError(err).Warnf("cannot find members of team %s in org %s", slug, org) 279 continue 280 } 281 for _, member := range members { 282 if member.Login == user { 283 return true 284 } 285 } 286 } 287 return false 288 } 289 290 func description(user string) string { 291 return fmt.Sprintf("Overridden by %s", user) 292 } 293 294 func formatList(list []string) string { 295 var lines []string 296 for _, item := range list { 297 lines = append(lines, fmt.Sprintf(" - `%s`", item)) 298 } 299 return strings.Join(lines, "\n") 300 } 301 302 type descriptionAndState struct { 303 description string 304 state string 305 } 306 307 // Parses /override foo something 308 func parseOverrideInput(in string) []string { 309 quoted := false 310 f := strings.FieldsFunc(in, func(r rune) bool { 311 if r == '"' { 312 quoted = !quoted 313 } 314 return !quoted && r == ' ' 315 }) 316 var retval []string 317 for _, val := range f { 318 retval = append(retval, strings.Trim(strings.TrimSpace(val), `"`)) 319 } 320 321 return retval 322 } 323 324 func handle(oc overrideClient, log *logrus.Entry, e *github.GenericCommentEvent, options plugins.Override) error { 325 326 if !e.IsPR || e.IssueState != "open" || e.Action != github.GenericCommentActionCreated { 327 return nil 328 } 329 330 mat := overrideRe.FindAllStringSubmatch(e.Body, -1) 331 if len(mat) == 0 { 332 return nil // no /override commands given in the comment 333 } 334 335 org := e.Repo.Owner.Login 336 repo := e.Repo.Name 337 number := e.Number 338 user := e.User.Login 339 340 overrides := sets.New[string]() 341 for _, m := range mat { 342 if m[1] == "" { 343 resp := "/override requires failed status contexts to operate on, but none was given" 344 log.Debug(resp) 345 return oc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, resp)) 346 } 347 overrides.Insert(parseOverrideInput(m[2])...) 348 } 349 350 authorized := authorizedUser(oc, log, org, repo, user) 351 if !authorized && len(options.AllowedGitHubTeams) > 0 { 352 authorized = authorizedGitHubTeamMember(oc, log, options.AllowedGitHubTeams, org, repo, user) 353 } 354 if !authorized && !options.AllowTopLevelOwners { 355 resp := fmt.Sprintf("%s unauthorized: /override is restricted to %s", user, whoCanUse(options, org, repo)) 356 log.Debug(resp) 357 return oc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, resp)) 358 } 359 360 pr, err := oc.GetPullRequest(org, repo, number) 361 if err != nil { 362 resp := fmt.Sprintf("Cannot get PR #%d in %s/%s", number, org, repo) 363 log.WithError(err).Warn(resp) 364 return oc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, resp)) 365 } 366 367 if !authorized && !authorizedTopLevelOwner(oc, options.AllowTopLevelOwners, log, org, repo, user, pr) { 368 resp := fmt.Sprintf("%s unauthorized: /override is restricted to %s", user, whoCanUse(options, org, repo)) 369 log.Debug(resp) 370 return oc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, resp)) 371 } 372 373 sha := pr.Head.SHA 374 statuses, err := oc.ListStatuses(org, repo, sha) 375 if err != nil { 376 resp := fmt.Sprintf("Cannot get commit statuses for PR #%d in %s/%s", number, org, repo) 377 log.WithError(err).Warn(resp) 378 return oc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, resp)) 379 } 380 381 // Get CheckRuns and add them to contexts 382 var checkruns *github.CheckRunList 383 var checkrunContexts []Context 384 if oc.UsesAppAuth() { 385 checkruns, err = oc.ListCheckRuns(org, repo, sha) 386 if err != nil { 387 resp := fmt.Sprintf("Cannot get commit checkruns for PR #%d in %s/%s", number, org, repo) 388 log.WithError(err).Warn(resp) 389 return oc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, resp)) 390 } 391 392 checkrunContexts = make([]Context, len(checkruns.CheckRuns)) 393 for _, checkrun := range checkruns.CheckRuns { 394 var state string 395 if checkrun.CompletedAt == "" { 396 state = "PENDING" 397 } else if strings.ToUpper(checkrun.Conclusion) == "NEUTRAL" { 398 state = "SUCCESS" 399 } else { 400 state = strings.ToUpper(checkrun.Conclusion) 401 } 402 checkrunContexts = append(checkrunContexts, Context{ 403 Context: checkrun.Name, 404 Description: checkrun.DetailsURL, 405 State: state, 406 }) 407 } 408 409 // dedupe checkruns and pick the best one 410 checkrunContexts = deduplicateContexts(checkrunContexts) 411 } 412 413 baseSHAGetter := shaGetterFactory(oc, org, repo, pr.Base.Ref) 414 presubmits, err := oc.presubmits(org, repo, baseSHAGetter, sha) 415 if err != nil { 416 msg := "Failed to get presubmits" 417 log.WithError(err).Error(msg) 418 return oc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, msg)) 419 } 420 421 contexts := sets.New[string]() 422 for _, status := range statuses { 423 if status.State == github.StatusSuccess { 424 continue 425 } 426 427 contexts.Insert(status.Context) 428 429 for _, job := range presubmits { 430 if job.Context == status.Context { 431 contexts.Insert(job.Name) 432 break 433 } 434 } 435 } 436 437 // add all checkruns that are not successful or pending to the list of contexts being tracked 438 for _, cr := range checkrunContexts { 439 if cr.Context != "" && cr.State != "SUCCESS" && cr.State != "PENDING" { 440 contexts.Insert(cr.Context) 441 } 442 } 443 444 branch := pr.Base.Ref 445 branchProtection, err := oc.GetBranchProtection(org, repo, branch) 446 if err != nil { 447 resp := fmt.Sprintf("Cannot get branch protection for branch %s in %s/%s", branch, org, repo) 448 log.WithError(err).Warn(resp) 449 return oc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, resp)) 450 } 451 452 if branchProtection != nil && branchProtection.RequiredStatusChecks != nil { 453 for _, context := range branchProtection.RequiredStatusChecks.Contexts { 454 if !contexts.Has(context) { 455 contexts.Insert(context) 456 statuses = append(statuses, github.Status{Context: context}) 457 } 458 } 459 } 460 461 if unknown := overrides.Difference(contexts); unknown.Len() > 0 { 462 resp := fmt.Sprintf(`/override requires failed status contexts, check run or a prowjob name to operate on. 463 The following unknown contexts/checkruns were given: 464 %s 465 466 Only the following failed contexts/checkruns were expected: 467 %s 468 469 If you are trying to override a checkrun that has a space in it, you must put a double quote on the context. 470 `, formatList(sets.List(unknown)), formatList(sets.List(contexts))) 471 log.Debug(resp) 472 return oc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, resp)) 473 } 474 475 done := sets.Set[string]{} 476 contextsWithCreatedJobs := sets.Set[string]{} 477 478 defer func() { 479 if len(done) == 0 { 480 return 481 } 482 msg := fmt.Sprintf("Overrode contexts on behalf of %s: %s", user, strings.Join(sets.List(done), ", ")) 483 log.Info(msg) 484 oc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, msg)) 485 }() 486 487 for _, status := range statuses { 488 pre := presubmitForContext(presubmits, status.Context) 489 if status.State == github.StatusSuccess || !(overrides.Has(status.Context) || pre != nil && overrides.Has(pre.Name)) || contextsWithCreatedJobs.Has(status.Context) { 490 continue 491 } 492 493 // Create the overridden prow result if necessary 494 if pre != nil { 495 baseSHA, err := baseSHAGetter() 496 if err != nil { 497 resp := "Cannot get base ref of PR" 498 log.WithError(err).Warn(resp) 499 return oc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, resp)) 500 } 501 502 pj := pjutil.NewPresubmit(*pr, baseSHA, *pre, e.GUID, nil) 503 now := metav1.Now() 504 pj.Status = prowapi.ProwJobStatus{ 505 StartTime: now, 506 CompletionTime: &now, 507 State: prowapi.SuccessState, 508 Description: description(user), 509 URL: e.HTMLURL, 510 } 511 512 log.WithFields(pjutil.ProwJobFields(&pj)).Info("Creating a new prowjob.") 513 if _, err := oc.Create(context.TODO(), &pj, metav1.CreateOptions{}); err != nil { 514 resp := fmt.Sprintf("Failed to create override job for %s", status.Context) 515 log.WithError(err).Warn(resp) 516 return oc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, resp)) 517 } 518 contextsWithCreatedJobs.Insert(status.Context) 519 } 520 status.State = github.StatusSuccess 521 status.Description = description(user) 522 if err := oc.CreateStatus(org, repo, sha, status); err != nil { 523 resp := fmt.Sprintf("Cannot update PR status for context %s", status.Context) 524 log.WithError(err).Warn(resp) 525 return oc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, resp)) 526 } 527 done.Insert(status.Context) 528 } 529 530 // We want to interate over the checkrunContexts, create a new checkrun with the same name as the context and mark it as successful. 531 // Tide has logic to pick the best checkrun result 532 // Checkruns have been converted to contexts and deduped 533 if oc.UsesAppAuth() { 534 for _, checkrun := range checkrunContexts { 535 if overrides.Has(checkrun.Context) { 536 prowOverrideCR := github.CheckRun{ 537 Name: checkrun.Context, 538 HeadSHA: sha, 539 Status: "completed", 540 Conclusion: "success", 541 Output: github.CheckRunOutput{ 542 Title: fmt.Sprintf("Prow override - %s", checkrun.Context), 543 Summary: fmt.Sprintf("Prow has received override command for the %s checkrun.", checkrun.Context), 544 }, 545 } 546 if err := oc.CreateCheckRun(org, repo, prowOverrideCR); err != nil { 547 resp := fmt.Sprintf("cannot create prow-override CheckRun %v", prowOverrideCR) 548 log.WithError(err).Warn(resp) 549 return oc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, resp)) 550 } 551 done.Insert(checkrun.Context) 552 } 553 } 554 } 555 556 return nil 557 } 558 559 // shaGetterFactory is a closure to retrieve a sha once. It is not threadsafe. 560 func shaGetterFactory(oc overrideClient, org, repo, ref string) func() (string, error) { 561 var baseSHA string 562 return func() (string, error) { 563 if baseSHA != "" { 564 return baseSHA, nil 565 } 566 var err error 567 baseSHA, err = oc.GetRef(org, repo, "heads/"+ref) 568 return baseSHA, err 569 } 570 } 571 572 func isStateBetter(previous, current string) bool { 573 if current == "SUCCESS" { 574 return true 575 } 576 if current == "PENDING" && (previous == "ERROR" || previous == "FAILURE" || previous == "EXPECTED") { 577 return true 578 } 579 if previous == "EXPECTED" && (current == "ERROR" || current == "FAILURE") { 580 return true 581 } 582 583 return false 584 } 585 586 // This function deduplicates checkruns and picks the best result 587 func deduplicateContexts(contexts []Context) []Context { 588 result := map[string]descriptionAndState{} 589 for _, context := range contexts { 590 previousResult, found := result[context.Context] 591 if !found { 592 result[context.Context] = descriptionAndState{description: context.Description, state: context.State} 593 continue 594 } 595 if isStateBetter(previousResult.state, context.State) { 596 result[context.Context] = descriptionAndState{description: context.Description, state: context.State} 597 } 598 } 599 600 var resultSlice []Context 601 for name, descriptionAndState := range result { 602 resultSlice = append(resultSlice, Context{Context: name, Description: descriptionAndState.description, State: descriptionAndState.state}) 603 } 604 605 return resultSlice 606 }