sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/statusreconciler/controller.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 statusreconciler 18 19 import ( 20 "context" 21 "fmt" 22 "strings" 23 "time" 24 25 "github.com/sirupsen/logrus" 26 "k8s.io/apimachinery/pkg/util/sets" 27 prowv1 "sigs.k8s.io/prow/pkg/client/clientset/versioned/typed/prowjobs/v1" 28 "sigs.k8s.io/prow/pkg/io" 29 "sigs.k8s.io/prow/pkg/pjutil" 30 31 utilerrors "k8s.io/apimachinery/pkg/util/errors" 32 "sigs.k8s.io/prow/pkg/config" 33 configflagutil "sigs.k8s.io/prow/pkg/flagutil/config" 34 "sigs.k8s.io/prow/pkg/github" 35 "sigs.k8s.io/prow/pkg/plugins" 36 "sigs.k8s.io/prow/pkg/plugins/trigger" 37 "sigs.k8s.io/prow/pkg/statusreconciler/migrator" 38 ) 39 40 // NewController constructs a new controller to reconcile stauses on config change 41 func NewController(continueOnError bool, addedPresubmitDenylist, addedPresubmitDenylistAll sets.Set[string], opener io.Opener, configOpts configflagutil.ConfigOptions, statusURI string, prowJobClient prowv1.ProwJobInterface, githubClient github.Client, pluginAgent *plugins.ConfigAgent) *Controller { 42 sc := &statusController{ 43 logger: logrus.WithField("client", "statusController"), 44 opener: opener, 45 statusURI: statusURI, 46 configOpts: configOpts, 47 } 48 49 return &Controller{ 50 continueOnError: continueOnError, 51 addedPresubmitDenylist: addedPresubmitDenylist, 52 addedPresubmitDenylistAll: addedPresubmitDenylistAll, 53 prowJobTriggerer: &kubeProwJobTriggerer{ 54 prowJobClient: prowJobClient, 55 githubClient: githubClient, 56 configGetter: sc.Config, 57 pluginAgent: pluginAgent, 58 }, 59 githubClient: githubClient, 60 statusMigrator: &gitHubMigrator{ 61 githubClient: githubClient, 62 continueOnError: continueOnError, 63 }, 64 trustedChecker: &githubTrustedChecker{ 65 githubClient: githubClient, 66 pluginAgent: pluginAgent, 67 }, 68 statusClient: sc, 69 } 70 } 71 72 type statusMigrator interface { 73 retire(org, repo, context string, targetBranchFilter func(string) bool) error 74 migrate(org, repo, from, to string, targetBranchFilter func(string) bool) error 75 } 76 77 type gitHubMigrator struct { 78 githubClient github.Client 79 continueOnError bool 80 } 81 82 func (m *gitHubMigrator) retire(org, repo, context string, targetBranchFilter func(string) bool) error { 83 return migrator.New( 84 *migrator.RetireMode(context, "", ""), 85 m.githubClient, org, repo, targetBranchFilter, m.continueOnError, 86 ).Migrate() 87 } 88 89 func (m *gitHubMigrator) migrate(org, repo, from, to string, targetBranchFilter func(string) bool) error { 90 return migrator.New( 91 *migrator.MoveMode(from, to, ""), 92 m.githubClient, org, repo, targetBranchFilter, m.continueOnError, 93 ).Migrate() 94 } 95 96 type prowJobTriggerer interface { 97 runAndSkip(pr *github.PullRequest, requestedJobs []config.Presubmit) error 98 } 99 100 type kubeProwJobTriggerer struct { 101 prowJobClient prowv1.ProwJobInterface 102 githubClient github.Client 103 configGetter config.Getter 104 pluginAgent *plugins.ConfigAgent 105 } 106 107 func (t *kubeProwJobTriggerer) runAndSkip(pr *github.PullRequest, requestedJobs []config.Presubmit) error { 108 org, repo := pr.Base.Repo.Owner.Login, pr.Base.Repo.Name 109 baseSHA, err := t.githubClient.GetRef(org, repo, "heads/"+pr.Base.Ref) 110 if err != nil { 111 return fmt.Errorf("failed to get baseSHA: %w", err) 112 } 113 return trigger.RunRequested( 114 trigger.Client{ 115 GitHubClient: t.githubClient, 116 ProwJobClient: t.prowJobClient, 117 Config: t.configGetter(), 118 Logger: logrus.WithField("client", "trigger"), 119 }, 120 pr, baseSHA, requestedJobs, "none", 121 ) 122 } 123 124 type githubClient interface { 125 GetPullRequests(org, repo string) ([]github.PullRequest, error) 126 GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error) 127 } 128 129 type trustedChecker interface { 130 trustedPullRequest(author, org, repo string, num int) (bool, error) 131 } 132 133 type githubTrustedChecker struct { 134 githubClient github.Client 135 pluginAgent *plugins.ConfigAgent 136 } 137 138 func (c *githubTrustedChecker) trustedPullRequest(author, org, repo string, num int) (bool, error) { 139 _, trusted, err := trigger.TrustedPullRequest( 140 c.githubClient, 141 c.pluginAgent.Config().TriggerFor(org, repo), 142 author, org, repo, num, nil, 143 ) 144 return trusted, err 145 } 146 147 // Controller reconciles statuses on PRs when config changes impact blocking presubmits 148 type Controller struct { 149 continueOnError bool 150 addedPresubmitDenylist sets.Set[string] 151 addedPresubmitDenylistAll sets.Set[string] 152 prowJobTriggerer prowJobTriggerer 153 githubClient githubClient 154 statusMigrator statusMigrator 155 trustedChecker trustedChecker 156 statusClient statusClient 157 } 158 159 // Run monitors the incoming configuration changes to determine when statuses need to be 160 // reconciled on PRs in flight when blocking presubmits change 161 func (c *Controller) Run(ctx context.Context) { 162 changes, err := c.statusClient.Load() 163 if err != nil { 164 logrus.WithError(err).Error("Error loading saved status.") 165 return 166 } 167 168 for { 169 select { 170 case change := <-changes: 171 start := time.Now() 172 log := logrus.WithField("old_config_revision", change.Before.ConfigVersionSHA).WithField("config_revision", change.After.ConfigVersionSHA) 173 if err := c.reconcile(change, log); err != nil { 174 log.WithError(err).Error("Error reconciling statuses.") 175 } 176 log.WithField("duration", fmt.Sprintf("%v", time.Since(start))).Info("Statuses reconciled") 177 c.statusClient.Save() 178 case <-ctx.Done(): 179 logrus.Info("status-reconciler is shutting down...") 180 return 181 } 182 } 183 } 184 185 func (c *Controller) reconcile(delta config.Delta, log *logrus.Entry) error { 186 var errors []error 187 if err := c.triggerNewPresubmits(addedBlockingPresubmits(delta.Before.PresubmitsStatic, delta.After.PresubmitsStatic, log)); err != nil { 188 errors = append(errors, err) 189 if !c.continueOnError { 190 return utilerrors.NewAggregate(errors) 191 } 192 } 193 194 if err := c.retireRemovedContexts(removedPresubmits(delta.Before.PresubmitsStatic, delta.After.PresubmitsStatic, log)); err != nil { 195 errors = append(errors, err) 196 if !c.continueOnError { 197 return utilerrors.NewAggregate(errors) 198 } 199 } 200 201 if err := c.updateMigratedContexts(migratedBlockingPresubmits(delta.Before.PresubmitsStatic, delta.After.PresubmitsStatic, log)); err != nil { 202 errors = append(errors, err) 203 if !c.continueOnError { 204 return utilerrors.NewAggregate(errors) 205 } 206 } 207 208 return utilerrors.NewAggregate(errors) 209 } 210 211 func (c *Controller) triggerNewPresubmits(addedPresubmits map[string][]config.Presubmit, log *logrus.Entry) error { 212 var triggerErrors []error 213 for orgrepo, presubmits := range addedPresubmits { 214 if len(presubmits) == 0 { 215 continue 216 } 217 parts := strings.SplitN(orgrepo, "/", 2) 218 if n := len(parts); n != 2 { 219 triggerErrors = append(triggerErrors, fmt.Errorf("string %q can not be interpreted as org/repo", orgrepo)) 220 continue 221 } 222 223 org, repo := parts[0], parts[1] 224 if c.addedPresubmitDenylist.Has(org) || c.addedPresubmitDenylist.Has(orgrepo) || 225 c.addedPresubmitDenylistAll.Has(org) || c.addedPresubmitDenylistAll.Has(orgrepo) { 226 continue 227 } 228 prs, err := c.githubClient.GetPullRequests(org, repo) 229 if err != nil { 230 triggerErrors = append(triggerErrors, fmt.Errorf("failed to list pull requests for %s: %w", orgrepo, err)) 231 if !c.continueOnError { 232 return utilerrors.NewAggregate(triggerErrors) 233 } 234 continue 235 } 236 for _, pr := range prs { 237 if pr.Mergable != nil && !*pr.Mergable { 238 // the PR cannot be merged as it is, so the user will need to update the PR (and trigger 239 // testing via the PR push event) or re-test if the HEAD of the branch they are targeting 240 // changes (and re-trigger tests anyway) so we do not need to do anything in this case and 241 // launching jobs that instantly fail due to merge conflicts is a waste of time 242 continue 243 } 244 // we want to appropriately trigger and skip from the set of identified presubmits that were 245 // added. we know all of the presubmits we are filtering need to be forced to run, so we can 246 // enforce that with a custom filter 247 filter := pjutil.NewArbitraryFilter(func(p config.Presubmit) (shouldRun bool, forcedToRun bool, defaultBehavior bool) { 248 return true, false, true 249 }, "inline-filter") 250 org, repo, number, branch := pr.Base.Repo.Owner.Login, pr.Base.Repo.Name, pr.Number, pr.Base.Ref 251 changes := config.NewGitHubDeferredChangedFilesProvider(c.githubClient, org, repo, number) 252 logger := log.WithFields(logrus.Fields{"org": org, "repo": repo, "number": number, "branch": branch}) 253 toTrigger, err := pjutil.FilterPresubmits(filter, changes, branch, presubmits, logger) 254 if err != nil { 255 return err 256 } 257 if err := c.triggerIfTrusted(org, repo, pr, toTrigger); err != nil { 258 triggerErrors = append(triggerErrors, fmt.Errorf("failed to trigger jobs for %s#%d: %w", orgrepo, pr.Number, err)) 259 if !c.continueOnError { 260 return utilerrors.NewAggregate(triggerErrors) 261 } 262 continue 263 } 264 } 265 } 266 return utilerrors.NewAggregate(triggerErrors) 267 } 268 269 func (c *Controller) triggerIfTrusted(org, repo string, pr github.PullRequest, toTrigger []config.Presubmit) error { 270 trusted, err := c.trustedChecker.trustedPullRequest(pr.User.Login, org, repo, pr.Number) 271 if err != nil { 272 return fmt.Errorf("failed to determine if %s/%s#%d is trusted: %w", org, repo, pr.Number, err) 273 } 274 if !trusted { 275 return nil 276 } 277 var triggeredContexts []map[string]string 278 for _, presubmit := range toTrigger { 279 triggeredContexts = append(triggeredContexts, map[string]string{"job": presubmit.Name, "context": presubmit.Context}) 280 } 281 logrus.WithFields(logrus.Fields{ 282 "to-trigger": triggeredContexts, 283 "pr": pr.Number, 284 "org": org, 285 "repo": repo, 286 }).Info("Triggering and skipping new ProwJobs to create newly-required contexts.") 287 return c.prowJobTriggerer.runAndSkip(&pr, toTrigger) 288 } 289 290 func (c *Controller) retireRemovedContexts(retiredPresubmits map[string][]config.Presubmit, log *logrus.Entry) error { 291 var retireErrors []error 292 for orgrepo, presubmits := range retiredPresubmits { 293 parts := strings.SplitN(orgrepo, "/", 2) 294 if n := len(parts); n != 2 { 295 retireErrors = append(retireErrors, fmt.Errorf("string %q can not be interpreted as org/repo", orgrepo)) 296 continue 297 } 298 org, repo := parts[0], parts[1] 299 if c.addedPresubmitDenylistAll.Has(org) || c.addedPresubmitDenylistAll.Has(orgrepo) { 300 continue 301 } 302 for _, presubmit := range presubmits { 303 log.WithFields(logrus.Fields{ 304 "org": org, 305 "repo": repo, 306 "context": presubmit.Context, 307 }).Info("Retiring context.") 308 if err := c.statusMigrator.retire(org, repo, presubmit.Context, presubmit.Brancher.ShouldRun); err != nil { 309 if c.continueOnError { 310 retireErrors = append(retireErrors, err) 311 continue 312 } 313 return err 314 } 315 } 316 } 317 return utilerrors.NewAggregate(retireErrors) 318 } 319 320 func (c *Controller) updateMigratedContexts(migrations map[string][]presubmitMigration, log *logrus.Entry) error { 321 var migrateErrors []error 322 for orgrepo, migrations := range migrations { 323 parts := strings.SplitN(orgrepo, "/", 2) 324 if n := len(parts); n != 2 { 325 migrateErrors = append(migrateErrors, fmt.Errorf("string %q can not be interpreted as org/repo", orgrepo)) 326 continue 327 } 328 org, repo := parts[0], parts[1] 329 if c.addedPresubmitDenylistAll.Has(org) || c.addedPresubmitDenylistAll.Has(orgrepo) { 330 continue 331 } 332 for _, migration := range migrations { 333 log.WithFields(logrus.Fields{ 334 "org": org, 335 "repo": repo, 336 "from": migration.from.Context, 337 "to": migration.to.Context, 338 }).Info("Migrating context.") 339 if err := c.statusMigrator.migrate(org, repo, migration.from.Context, migration.to.Context, migration.from.Brancher.ShouldRun); err != nil { 340 if c.continueOnError { 341 migrateErrors = append(migrateErrors, err) 342 continue 343 } 344 return err 345 } 346 } 347 } 348 return utilerrors.NewAggregate(migrateErrors) 349 } 350 351 // addedBlockingPresubmits determines new blocking presubmits based on a 352 // config update. New blocking presubmits are either brand-new presubmits 353 // or extant presubmits that are now reporting. Previous presubmits that 354 // reported but were optional that are no longer optional require no action 355 // as their contexts will already exist on PRs. 356 func addedBlockingPresubmits(old, new map[string][]config.Presubmit, log *logrus.Entry) (map[string][]config.Presubmit, *logrus.Entry) { 357 added := map[string][]config.Presubmit{} 358 359 for repo, oldPresubmits := range old { 360 added[repo] = []config.Presubmit{} 361 for _, newPresubmit := range new[repo] { 362 if !newPresubmit.ContextRequired() || newPresubmit.NeedsExplicitTrigger() { 363 continue 364 } 365 var found bool 366 for _, oldPresubmit := range oldPresubmits { 367 if oldPresubmit.Name == newPresubmit.Name { 368 if oldPresubmit.SkipReport && !newPresubmit.SkipReport { 369 added[repo] = append(added[repo], newPresubmit) 370 log.WithFields(logrus.Fields{ 371 "repo": repo, 372 "name": oldPresubmit.Name, 373 }).Debug("Identified a newly-reporting blocking presubmit.") 374 } 375 if oldPresubmit.RunIfChanged != newPresubmit.RunIfChanged || oldPresubmit.SkipIfOnlyChanged != newPresubmit.SkipIfOnlyChanged { 376 added[repo] = append(added[repo], newPresubmit) 377 log.WithFields(logrus.Fields{ 378 "repo": repo, 379 "name": oldPresubmit.Name, 380 }).Debug("Identified a blocking presubmit running over a different set of files.") 381 } 382 found = true 383 break 384 } 385 } 386 if !found { 387 added[repo] = append(added[repo], newPresubmit) 388 log.WithFields(logrus.Fields{ 389 "repo": repo, 390 "name": newPresubmit.Name, 391 }).Debug("Identified an added blocking presubmit.") 392 } 393 } 394 } 395 396 var numAdded int 397 for _, presubmits := range added { 398 numAdded += len(presubmits) 399 } 400 log.Infof("Identified %d added blocking presubmits.", numAdded) 401 return added, log 402 } 403 404 // removedPresubmits determines stale presubmits based on a config update. 405 func removedPresubmits(old, new map[string][]config.Presubmit, log *logrus.Entry) (map[string][]config.Presubmit, *logrus.Entry) { 406 removed := map[string][]config.Presubmit{} 407 for repo, oldPresubmits := range old { 408 removed[repo] = []config.Presubmit{} 409 for _, oldPresubmit := range oldPresubmits { 410 var found bool 411 for _, newPresubmit := range new[repo] { 412 if oldPresubmit.Name == newPresubmit.Name { 413 found = true 414 break 415 } 416 } 417 if !found { 418 removed[repo] = append(removed[repo], oldPresubmit) 419 log.WithFields(logrus.Fields{ 420 "repo": repo, 421 "name": oldPresubmit.Name, 422 }).Debug("Identified a removed blocking presubmit.") 423 } 424 } 425 } 426 427 var numRemoved int 428 for _, presubmits := range removed { 429 numRemoved += len(presubmits) 430 } 431 log.Infof("Identified %d removed blocking presubmits.", numRemoved) 432 return removed, log 433 } 434 435 type presubmitMigration struct { 436 from, to config.Presubmit 437 } 438 439 // migratedBlockingPresubmits determines blocking presubmits that have had 440 // their status contexts migrated. This is a best-effort evaluation as we 441 // can only track a presubmit between configuration versions by its name. 442 // A presubmit "migration" that had its underlying job and context changed 443 // will be treated as a deletion and creation. 444 func migratedBlockingPresubmits(old, new map[string][]config.Presubmit, log *logrus.Entry) (map[string][]presubmitMigration, *logrus.Entry) { 445 migrated := map[string][]presubmitMigration{} 446 447 for repo, oldPresubmits := range old { 448 migrated[repo] = []presubmitMigration{} 449 for _, newPresubmit := range new[repo] { 450 if !newPresubmit.ContextRequired() { 451 continue 452 } 453 for _, oldPresubmit := range oldPresubmits { 454 if oldPresubmit.Context != newPresubmit.Context && oldPresubmit.Name == newPresubmit.Name { 455 migrated[repo] = append(migrated[repo], presubmitMigration{from: oldPresubmit, to: newPresubmit}) 456 log.WithFields(logrus.Fields{ 457 "repo": repo, 458 "name": oldPresubmit.Name, 459 "from": oldPresubmit.Context, 460 "to": newPresubmit.Context, 461 }).Debug("Identified a migrated blocking presubmit.") 462 } 463 } 464 } 465 } 466 467 var numMigrated int 468 for _, presubmits := range migrated { 469 numMigrated += len(presubmits) 470 } 471 log.Infof("Identified %d migrated blocking presubmits.", numMigrated) 472 return migrated, log 473 }