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  }