sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/plugins/trigger/trigger.go (about) 1 /* 2 Copyright 2016 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 trigger 18 19 import ( 20 "context" 21 "fmt" 22 "strings" 23 "time" 24 25 "github.com/sirupsen/logrus" 26 27 apierrors "k8s.io/apimachinery/pkg/api/errors" 28 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 29 utilerrors "k8s.io/apimachinery/pkg/util/errors" 30 "k8s.io/apimachinery/pkg/util/sets" 31 "k8s.io/apimachinery/pkg/util/wait" 32 33 prowapi "sigs.k8s.io/prow/pkg/apis/prowjobs/v1" 34 "sigs.k8s.io/prow/pkg/config" 35 "sigs.k8s.io/prow/pkg/git/v2" 36 "sigs.k8s.io/prow/pkg/github" 37 "sigs.k8s.io/prow/pkg/pjutil" 38 "sigs.k8s.io/prow/pkg/pluginhelp" 39 "sigs.k8s.io/prow/pkg/plugins" 40 ) 41 42 const ( 43 // PluginName is the name of the trigger plugin 44 PluginName = "trigger" 45 ) 46 47 // untrustedReason represents a combination (by ORing the appropriate consts) of reasons 48 // why a user is not trusted by TrustedUser. It is used to generate messaging for users. 49 type untrustedReason int 50 51 const ( 52 notMember untrustedReason = 1 << iota 53 notCollaborator 54 notSecondaryMember 55 ) 56 57 // String constructs a string explaining the reason for a user's denial of trust 58 // from untrustedReason as described above. 59 func (u untrustedReason) String() string { 60 var response string 61 if u¬Member != 0 { 62 response += "User is not a member of the org. " 63 } 64 if u¬Collaborator != 0 { 65 response += "User is not a collaborator. " 66 } 67 if u¬SecondaryMember != 0 { 68 response += "User is not a member of the trusted secondary org. " 69 } 70 response += "Satisfy at least one of these conditions to make the user trusted." 71 return response 72 } 73 74 func init() { 75 plugins.RegisterGenericCommentHandler(PluginName, handleGenericCommentEvent, helpProvider) 76 plugins.RegisterPullRequestHandler(PluginName, handlePullRequest, helpProvider) 77 plugins.RegisterPushEventHandler(PluginName, handlePush, helpProvider) 78 } 79 80 func helpProvider(config *plugins.Configuration, enabledRepos []config.OrgRepo) (*pluginhelp.PluginHelp, error) { 81 configInfo := map[string]string{} 82 for _, repo := range enabledRepos { 83 trigger := config.TriggerFor(repo.Org, repo.Repo) 84 org := repo.Org 85 if trigger.TrustedOrg != "" { 86 org = trigger.TrustedOrg 87 } 88 configInfo[repo.String()] = fmt.Sprintf("The trusted GitHub organization for this repository is %q.", org) 89 } 90 yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{ 91 Triggers: []plugins.Trigger{ 92 { 93 Repos: []string{ 94 "org/repo1", 95 "org/repo2", 96 }, 97 JoinOrgURL: "https://github.com/kubernetes/community/blob/master/community-membership.md", 98 OnlyOrgMembers: true, 99 IgnoreOkToTest: true, 100 }, 101 }, 102 }) 103 if err != nil { 104 logrus.WithError(err).Warnf("cannot generate comments for %s plugin", PluginName) 105 } 106 pluginHelp := &pluginhelp.PluginHelp{ 107 Description: `The trigger plugin starts jobs in reaction to various events. 108 <br>Presubmit jobs are run automatically on pull requests that are trusted and not in a draft state with file changes matching the file filters and targeting a branch matching the branch filters. 109 <br>A pull request is considered trusted if the author is a member of the 'trusted organization' for the repository or if such a member has left an '/ok-to-test' command on the PR. 110 <br>Trigger will not automatically start jobs for a PR in draft state, and if a PR is changed to draft it cancels pending jobs. 111 <br>If jobs are not run automatically for a PR because it is not trusted or is in draft state, a trusted user can still start jobs manually via the '/test' command. 112 <br>The '/retest' command can be used to rerun jobs that have reported failure. 113 <br>Trigger starts postsubmit jobs when commits are pushed if the filters on the job match files and branches affected by that push.`, 114 Config: configInfo, 115 Snippet: yamlSnippet, 116 } 117 pluginHelp.AddCommand(pluginhelp.Command{ 118 Usage: "/ok-to-test", 119 Description: "Marks a PR as 'trusted' and starts tests.", 120 Featured: false, 121 WhoCanUse: "Members of the trusted organization for the repo.", 122 Examples: []string{"/ok-to-test"}, 123 }) 124 pluginHelp.AddCommand(pluginhelp.Command{ 125 Usage: "/test [<job name>|all]", 126 Description: "Manually starts a/all automatically triggered test job(s). Lists all possible job(s) when no jobs/an invalid job are specified.", 127 Featured: true, 128 WhoCanUse: "Anyone can trigger this command on a trusted PR.", 129 Examples: []string{"/test all", "/test pull-bazel-test"}, 130 }) 131 pluginHelp.AddCommand(pluginhelp.Command{ 132 Usage: "/retest", 133 Description: "Rerun test jobs that have failed.", 134 Featured: true, 135 WhoCanUse: "Anyone can trigger this command on a trusted PR.", 136 Examples: []string{"/retest"}, 137 }) 138 pluginHelp.AddCommand(pluginhelp.Command{ 139 Usage: "/test ?", 140 Description: "List available test job(s) for a trusted PR.", 141 Featured: true, 142 WhoCanUse: "Anyone can trigger this command on a trusted PR.", 143 Examples: []string{"/test ?"}, 144 }) 145 return pluginHelp, nil 146 } 147 148 type githubClient interface { 149 AddLabel(org, repo string, number int, label string) error 150 BotUserChecker() (func(candidate string) bool, error) 151 IsCollaborator(org, repo, user string) (bool, error) 152 IsMember(org, user string) (bool, error) 153 GetPullRequest(org, repo string, number int) (*github.PullRequest, error) 154 GetFailedActionRunsByHeadBranch(org, repo, branchName, headSHA string) ([]github.WorkflowRun, error) 155 GetRef(org, repo, ref string) (string, error) 156 CreateComment(owner, repo string, number int, comment string) error 157 ListIssueComments(owner, repo string, issue int) ([]github.IssueComment, error) 158 CreateStatus(owner, repo, ref string, status github.Status) error 159 GetCombinedStatus(org, repo, ref string) (*github.CombinedStatus, error) 160 GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error) 161 RemoveLabel(org, repo string, number int, label string) error 162 TriggerGitHubWorkflow(org, repo string, id int) error 163 TriggerFailedGitHubWorkflow(org, repo string, id int) error 164 DeleteStaleComments(org, repo string, number int, comments []github.IssueComment, isStale func(github.IssueComment) bool) error 165 GetIssueLabels(org, repo string, number int) ([]github.Label, error) 166 } 167 168 type trustedPullRequestClient interface { 169 GetIssueLabels(org, repo string, number int) ([]github.Label, error) 170 trustedUserClient 171 } 172 173 type prowJobClient interface { 174 Create(context.Context, *prowapi.ProwJob, metav1.CreateOptions) (*prowapi.ProwJob, error) 175 List(ctx context.Context, opts metav1.ListOptions) (*prowapi.ProwJobList, error) 176 Update(context.Context, *prowapi.ProwJob, metav1.UpdateOptions) (*prowapi.ProwJob, error) 177 } 178 179 // Client holds the necessary structures to work with prow via logging, github, kubernetes and its configuration. 180 // 181 // TODO(fejta): consider exporting an interface rather than a struct 182 type Client struct { 183 GitHubClient githubClient 184 ProwJobClient prowJobClient 185 Config *config.Config 186 Logger *logrus.Entry 187 GitClient git.ClientFactory 188 } 189 190 // trustedUserClient is used to check is user member and repo collaborator 191 type trustedUserClient interface { 192 IsCollaborator(org, repo, user string) (bool, error) 193 IsMember(org, user string) (bool, error) 194 BotUserChecker() (func(candidate string) bool, error) 195 } 196 197 func getClient(pc plugins.Agent) Client { 198 return Client{ 199 GitHubClient: pc.GitHubClient, 200 Config: pc.Config, 201 ProwJobClient: pc.ProwJobClient, 202 Logger: pc.Logger, 203 GitClient: pc.GitClient, 204 } 205 } 206 207 func handlePullRequest(pc plugins.Agent, pr github.PullRequestEvent) error { 208 org, repo, _ := orgRepoAuthor(pr.PullRequest) 209 return handlePR(getClient(pc), pc.PluginConfig.TriggerFor(org, repo), pr) 210 } 211 212 func handleGenericCommentEvent(pc plugins.Agent, gc github.GenericCommentEvent) error { 213 return handleGenericComment(getClient(pc), pc.PluginConfig.TriggerFor(gc.Repo.Owner.Login, gc.Repo.Name), gc) 214 } 215 216 func handlePush(pc plugins.Agent, pe github.PushEvent) error { 217 return handlePE(getClient(pc), pe) 218 } 219 220 // TrustedUserResponse is a response from TrustedUser. It contains the boolean response for trust as well 221 // a reason for denial if the user is not trusted. 222 type TrustedUserResponse struct { 223 IsTrusted bool 224 // Reason contains the reason that a user is not trusted if IsTrusted is false 225 Reason string 226 } 227 228 // TrustedUser returns true if user is trusted in repo. 229 // Trusted users are either repo collaborators, org members or trusted org members. 230 func TrustedUser(ghc trustedUserClient, onlyOrgMembers bool, trustedApps []string, trustedOrg, user, org, repo string) (TrustedUserResponse, error) { 231 errorResponse := TrustedUserResponse{IsTrusted: false} 232 okResponse := TrustedUserResponse{IsTrusted: true} 233 234 selfChecker, err := ghc.BotUserChecker() 235 if err != nil { 236 return errorResponse, fmt.Errorf("failed to check if comment came from myself: %w", err) 237 } 238 // Trust thyself 239 if selfChecker(user) { 240 return okResponse, nil 241 } 242 243 // TODO(fejta): consider dropping support for org checks in the future. 244 245 // First check if the user is an org member. This caches across all repos. 246 if member, err := ghc.IsMember(org, user); err != nil { 247 return errorResponse, fmt.Errorf("error in IsMember(%s): %w", org, err) 248 } else if member { 249 return okResponse, nil 250 } 251 252 // Next check if the user is a collaborator if that is allowed, this is more 253 // expensive as it only caches per repo. 254 if !onlyOrgMembers { 255 if ok, err := ghc.IsCollaborator(org, repo, user); err != nil { 256 return errorResponse, fmt.Errorf("error in IsCollaborator: %w", err) 257 } else if ok { 258 return okResponse, nil 259 } 260 } 261 262 // Determine if user is on trusted_apps list. 263 // This allows automatic tests execution for GitHub automations that cannot be added as collaborators. 264 for _, trustedApp := range trustedApps { 265 if tUser := strings.TrimSuffix(user, "[bot]"); tUser == trustedApp { 266 return okResponse, nil 267 } 268 } 269 270 // Determine if there is a second org to check. If there is no secondary org or they are the same, the result 271 // is the same because the user already failed the check for the primary org. 272 if trustedOrg == "" || trustedOrg == org { 273 // the if/else is only to improve error messaging 274 if onlyOrgMembers { 275 return TrustedUserResponse{IsTrusted: false, Reason: notMember.String()}, nil // No trusted org and/or it is the same 276 } 277 return TrustedUserResponse{IsTrusted: false, Reason: (notMember | notCollaborator).String()}, nil // No trusted org and/or it is the same 278 } 279 280 // Check the second trusted org. 281 member, err := ghc.IsMember(trustedOrg, user) 282 if err != nil { 283 return errorResponse, fmt.Errorf("error in IsMember(%s): %w", trustedOrg, err) 284 } else if member { 285 return okResponse, nil 286 } 287 288 // the if/else is only to improve error messaging 289 if onlyOrgMembers { 290 return TrustedUserResponse{IsTrusted: false, Reason: (notMember | notSecondaryMember).String()}, nil 291 } 292 return TrustedUserResponse{IsTrusted: false, Reason: (notMember | notSecondaryMember | notCollaborator).String()}, nil 293 } 294 295 // validateContextOverlap ensures that there will be no overlap in contexts between a set of jobs running and a set to skip 296 func validateContextOverlap(toRun, toSkip []config.Presubmit) error { 297 requestedContexts := sets.New[string]() 298 for _, job := range toRun { 299 requestedContexts.Insert(job.Context) 300 } 301 skippedContexts := sets.New[string]() 302 for _, job := range toSkip { 303 skippedContexts.Insert(job.Context) 304 } 305 if overlap := sets.List(requestedContexts.Intersection(skippedContexts)); len(overlap) > 0 { 306 return fmt.Errorf("the following contexts are both triggered and skipped: %s", strings.Join(overlap, ", ")) 307 } 308 309 return nil 310 } 311 312 // RunRequested executes the config.Presubmits that are requested 313 func RunRequested(c Client, pr *github.PullRequest, baseSHA string, requestedJobs []config.Presubmit, eventGUID string) error { 314 return runRequested(c, pr, baseSHA, requestedJobs, eventGUID, nil) 315 } 316 317 // RunRequestedWithLabels executes the config.Presubmits that are requested with the additional labels 318 func RunRequestedWithLabels(c Client, pr *github.PullRequest, baseSHA string, requestedJobs []config.Presubmit, eventGUID string, labels map[string]string) error { 319 return runRequested(c, pr, baseSHA, requestedJobs, eventGUID, labels) 320 } 321 322 func runRequested(c Client, pr *github.PullRequest, baseSHA string, requestedJobs []config.Presubmit, eventGUID string, labels map[string]string, millisecondOverride ...time.Duration) error { 323 var errors []error 324 325 // If the PR is not mergeable (e.g. due to merge conflicts),we will not trigger any jobs, 326 // to reduce the load on resources and reduce spam comments which will lead to a better review experience. 327 if pr.Mergable != nil && !*pr.Mergable { 328 return nil 329 } 330 331 for _, job := range requestedJobs { 332 c.Logger.Infof("Starting %s build.", job.Name) 333 pj := pjutil.NewPresubmit(*pr, baseSHA, job, eventGUID, labels, pjutil.RequireScheduling(c.Config.Scheduler.Enabled)) 334 c.Logger.WithFields(pjutil.ProwJobFields(&pj)).Info("Creating a new prowjob.") 335 if err := createWithRetry(context.TODO(), c.ProwJobClient, &pj, millisecondOverride...); err != nil { 336 c.Logger.WithError(err).Error("Failed to create prowjob.") 337 errors = append(errors, err) 338 } 339 } 340 return utilerrors.NewAggregate(errors) 341 } 342 343 func getPresubmits(log *logrus.Entry, gc git.ClientFactory, cfg *config.Config, orgRepo string, baseSHAGetter, headSHAGetter config.RefGetter) []config.Presubmit { 344 presubmits, err := cfg.GetPresubmits(gc, orgRepo, "", baseSHAGetter, headSHAGetter) 345 if err != nil { 346 // Fall back to static presubmits to avoid deadlocking when a presubmit is used to verify 347 // inrepoconfig. Tide will still respect errors here and not merge. 348 log.WithError(err).Debug("Failed to get presubmits") 349 presubmits = cfg.GetPresubmitsStatic(orgRepo) 350 } 351 return presubmits 352 } 353 354 func getPostsubmits(log *logrus.Entry, gc git.ClientFactory, cfg *config.Config, orgRepo string, baseSHAGetter config.RefGetter) []config.Postsubmit { 355 postsubmits, err := cfg.GetPostsubmits(gc, orgRepo, "", baseSHAGetter) 356 if err != nil { 357 // Fall back to static postsubmits, loading inrepoconfig returned an error. 358 log.WithError(err).Error("Failed to get postsubmits") 359 postsubmits = cfg.GetPostsubmitsStatic(orgRepo) 360 } 361 return postsubmits 362 } 363 364 // createWithRetry will retry the cration of a ProwJob. The Name must be set, otherwise we might end up creating it multiple times 365 // if one Create request errors but succeeds under the hood. 366 func createWithRetry(ctx context.Context, client prowJobClient, pj *prowapi.ProwJob, millisecondOverride ...time.Duration) error { 367 millisecond := time.Millisecond 368 if len(millisecondOverride) == 1 { 369 millisecond = millisecondOverride[0] 370 } 371 372 var errs []error 373 if err := wait.ExponentialBackoff(wait.Backoff{Duration: 250 * millisecond, Factor: 2.0, Jitter: 0.1, Steps: 8}, func() (bool, error) { 374 if _, err := client.Create(ctx, pj, metav1.CreateOptions{}); err != nil { 375 // Can happen if a previous request was successful but returned an error 376 if apierrors.IsAlreadyExists(err) { 377 return true, nil 378 } 379 // Store and swallow errors, if we end up timing out we will return all of them 380 errs = append(errs, err) 381 return false, nil 382 } 383 return true, nil 384 }); err != nil { 385 if err != wait.ErrWaitTimeout { 386 return err 387 } 388 return utilerrors.NewAggregate(errs) 389 } 390 391 return nil 392 }