sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/config/inrepoconfig.go (about)

     1  /*
     2  Copyright 2019 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 config
    18  
    19  import (
    20  	"encoding/json"
    21  	"errors"
    22  	"fmt"
    23  	"os"
    24  	"path"
    25  	"path/filepath"
    26  	"strings"
    27  	"time"
    28  
    29  	gitignore "github.com/denormal/go-gitignore"
    30  	"github.com/prometheus/client_golang/prometheus"
    31  	"github.com/sirupsen/logrus"
    32  	utilerrors "k8s.io/apimachinery/pkg/util/errors"
    33  	"k8s.io/apimachinery/pkg/util/sets"
    34  	gerritsource "sigs.k8s.io/prow/pkg/gerrit/source"
    35  
    36  	"sigs.k8s.io/prow/pkg/git/types"
    37  	"sigs.k8s.io/prow/pkg/git/v2"
    38  	"sigs.k8s.io/yaml"
    39  )
    40  
    41  const (
    42  	inRepoConfigFileName = ".prow.yaml"
    43  	inRepoConfigDirName  = ".prow"
    44  )
    45  
    46  var inrepoconfigRepoOpts = git.RepoOpts{
    47  	// Technically we only need inRepoConfigDirName (".prow") because the
    48  	// default "cone mode" of sparse checkouts already include files at the
    49  	// toplevel (which would include ".prow.yaml").
    50  	SparseCheckoutDirs: []string{inRepoConfigDirName},
    51  	// The sparse checkout would avoid creating another copy of Git objects
    52  	// from the mirror clone into the secondary clone.
    53  	ShareObjectsWithPrimaryClone: true,
    54  }
    55  
    56  var inrepoconfigMetrics = struct {
    57  	gitCloneDuration *prometheus.HistogramVec
    58  	gitOtherDuration *prometheus.HistogramVec
    59  }{
    60  	gitCloneDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
    61  		Name:    "inrepoconfig_git_client_acquisition_duration",
    62  		Help:    "Seconds taken for acquiring a git client (may include an initial clone operation).",
    63  		Buckets: []float64{0.5, 1, 2, 5, 10, 20, 30, 45, 60, 90, 120, 180, 300, 600, 1200},
    64  	}, []string{
    65  		"org",
    66  		"repo",
    67  	}),
    68  	gitOtherDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
    69  		Name:    "inrepoconfig_git_other_duration",
    70  		Help:    "Seconds taken after acquiring a git client and performing all other git operations (to read the ProwYAML of the repo).",
    71  		Buckets: []float64{0.1, 0.5, 1, 2, 5, 10, 20, 30, 45, 60, 90, 120, 180, 300, 600},
    72  	}, []string{
    73  		"org",
    74  		"repo",
    75  	}),
    76  }
    77  
    78  func init() {
    79  	prometheus.MustRegister(inrepoconfigMetrics.gitCloneDuration)
    80  	prometheus.MustRegister(inrepoconfigMetrics.gitOtherDuration)
    81  }
    82  
    83  // +k8s:deepcopy-gen=true
    84  
    85  // ProwYAML represents the content of a .prow.yaml file
    86  // used to version Presubmits and Postsubmits inside the tested repo.
    87  type ProwYAML struct {
    88  	Presets     []Preset     `json:"presets"`
    89  	Presubmits  []Presubmit  `json:"presubmits"`
    90  	Postsubmits []Postsubmit `json:"postsubmits"`
    91  
    92  	// ProwIgnored is a well known, unparsed field where non-Prow fields can
    93  	// be defined without conflicting with unknown field validation.
    94  	ProwIgnored *json.RawMessage `json:"prow_ignored,omitempty"`
    95  }
    96  
    97  // ProwYAMLGetter is used to retrieve a ProwYAML. Tests should provide
    98  // their own implementation and set that on the Config.
    99  type ProwYAMLGetter func(c *Config, gc git.ClientFactory, identifier, baseBranch, baseSHA string, headSHAs ...string) (*ProwYAML, error)
   100  
   101  // Verify prowYAMLGetterWithDefaults and prowYAMLGetter are both of type
   102  // ProwYAMLGetter.
   103  var _ ProwYAMLGetter = prowYAMLGetterWithDefaults
   104  var _ ProwYAMLGetter = prowYAMLGetter
   105  
   106  // InRepoConfigGetter defines a common interface that both the Moonraker client
   107  // and raw InRepoConfigCache can implement. This way, Prow components like Sub
   108  // and Gerrit can choose either one (based on runtime flags), but regardless of
   109  // the choice the surrounding code can still just call this GetProwYAML()
   110  // interface method (without being aware whether the underlying implementation
   111  // is going over the network to Moonraker or is done locally with the local
   112  // InRepoConfigCache (LRU cache)).
   113  type InRepoConfigGetter interface {
   114  	GetInRepoConfig(identifier, baseBranch string, baseSHAGetter RefGetter, headSHAGetters ...RefGetter) (*ProwYAML, error)
   115  	GetPresubmits(identifier, baseBranch string, baseSHAGetter RefGetter, headSHAGetters ...RefGetter) ([]Presubmit, error)
   116  	GetPostsubmits(identifier, baseBranch string, baseSHAGetter RefGetter, headSHAGetters ...RefGetter) ([]Postsubmit, error)
   117  }
   118  
   119  // prowYAMLGetter is like prowYAMLGetterWithDefaults, but without default values
   120  // (it does not call DefaultAndValidateProwYAML()). Its sole purpose is to allow
   121  // caching of ProwYAMLs that are retrieved purely from the inrepoconfig's repo,
   122  // __without__ having the contents modified by the main Config's own settings
   123  // (which happens mostly inside DefaultAndValidateProwYAML()). prowYAMLGetter is
   124  // only used by cache.GetPresubmits() and cache.GetPostsubmits().
   125  func prowYAMLGetter(
   126  	c *Config,
   127  	gc git.ClientFactory,
   128  	identifier string,
   129  	baseBranch string,
   130  	baseSHA string,
   131  	headSHAs ...string) (*ProwYAML, error) {
   132  
   133  	log := logrus.WithField("repo", identifier)
   134  
   135  	if gc == nil {
   136  		log.Error("prowYAMLGetter was called with a nil git client")
   137  		return nil, errors.New("gitClient is nil")
   138  	}
   139  
   140  	orgRepo := *NewOrgRepo(identifier)
   141  	if orgRepo.Repo == "" {
   142  		return nil, fmt.Errorf("didn't get two results when splitting repo identifier %q", identifier)
   143  	}
   144  
   145  	timeBeforeClone := time.Now()
   146  	repoOpts := inrepoconfigRepoOpts
   147  	// For Gerrit, the baseSHA could appear as a headSHA for postsubmits if the
   148  	// change was a fast-forward merge. So we need to dedupe it with sets.
   149  	repoOpts.NeededCommits = sets.New(baseSHA)
   150  	repoOpts.NeededCommits.Insert(headSHAs...)
   151  	if baseBranch != "" {
   152  		repoOpts.BranchesToRetarget = map[string]string{baseBranch: baseSHA}
   153  	}
   154  	repo, err := gc.ClientForWithRepoOpts(orgRepo.Org, orgRepo.Repo, repoOpts)
   155  	inrepoconfigMetrics.gitCloneDuration.WithLabelValues(orgRepo.Org, orgRepo.Repo).Observe((float64(time.Since(timeBeforeClone).Seconds())))
   156  	if err != nil {
   157  		return nil, fmt.Errorf("failed to clone repo for %q: %w", identifier, err)
   158  	}
   159  	timeAfterClone := time.Now()
   160  	defer func() {
   161  		if err := repo.Clean(); err != nil {
   162  			log.WithError(err).Error("Failed to clean up repo.")
   163  		}
   164  		inrepoconfigMetrics.gitOtherDuration.WithLabelValues(orgRepo.Org, orgRepo.Repo).Observe((float64(time.Since(timeAfterClone).Seconds())))
   165  	}()
   166  
   167  	if err := repo.Config("user.name", "prow"); err != nil {
   168  		return nil, err
   169  	}
   170  	if err := repo.Config("user.email", "prow@localhost"); err != nil {
   171  		return nil, err
   172  	}
   173  	if err := repo.Config("commit.gpgsign", "false"); err != nil {
   174  		return nil, err
   175  	}
   176  
   177  	// TODO(mpherman): This is to hopefully mittigate issue with gerrit merges. Need to come up with a solution that checks
   178  	// each CLs merge strategy as they can differ. ifNecessary is just the gerrit default
   179  	var mergeMethod types.PullRequestMergeType
   180  	if gerritsource.IsGerritOrg(identifier) {
   181  		mergeMethod = types.MergeIfNecessary
   182  	} else {
   183  		mergeMethod = c.Tide.MergeMethod(orgRepo)
   184  	}
   185  
   186  	log.WithField("merge-strategy", mergeMethod).Debug("Using merge strategy.")
   187  	if err := repo.MergeAndCheckout(baseSHA, string(mergeMethod), headSHAs...); err != nil {
   188  		return nil, fmt.Errorf("failed to merge: %w", err)
   189  	}
   190  
   191  	return ReadProwYAML(log, repo.Directory(), false)
   192  }
   193  
   194  // ReadProwYAML parses the .prow.yaml file or .prow directory, no commit checkout or defaulting is included.
   195  func ReadProwYAML(log *logrus.Entry, dir string, strict bool) (*ProwYAML, error) {
   196  	prowYAML := &ProwYAML{}
   197  	var opts []yaml.JSONOpt
   198  	if strict {
   199  		opts = append(opts, yaml.DisallowUnknownFields)
   200  	}
   201  
   202  	prowYAMLDirPath := path.Join(dir, inRepoConfigDirName)
   203  	log.Debugf("Attempting to read config files under %q.", prowYAMLDirPath)
   204  	if fileInfo, err := os.Stat(prowYAMLDirPath); !os.IsNotExist(err) && err == nil && fileInfo.IsDir() {
   205  		mergeProwYAML := func(a, b *ProwYAML) *ProwYAML {
   206  			c := &ProwYAML{}
   207  			c.Presets = append(a.Presets, b.Presets...)
   208  			c.Presubmits = append(a.Presubmits, b.Presubmits...)
   209  			c.Postsubmits = append(a.Postsubmits, b.Postsubmits...)
   210  
   211  			return c
   212  		}
   213  		prowIgnore, err := gitignore.NewRepositoryWithFile(dir, ProwIgnoreFileName)
   214  		if err != nil {
   215  			return nil, fmt.Errorf("failed to create `%s` parser: %w", ProwIgnoreFileName, err)
   216  		}
   217  		err = filepath.Walk(prowYAMLDirPath, func(p string, info os.FileInfo, err error) error {
   218  			if err != nil {
   219  				return err
   220  			}
   221  			if !info.IsDir() && (filepath.Ext(p) == ".yaml" || filepath.Ext(p) == ".yml") {
   222  				// Use 'Match' directly because 'Ignore' and 'Include' don't work properly for repositories.
   223  				match := prowIgnore.Match(p)
   224  				if match != nil && match.Ignore() {
   225  					return nil
   226  				}
   227  				log.Debugf("Reading YAML file %q", p)
   228  				bytes, err := os.ReadFile(p)
   229  				if err != nil {
   230  					return err
   231  				}
   232  				partialProwYAML := &ProwYAML{}
   233  				if err := yaml.Unmarshal(bytes, partialProwYAML, opts...); err != nil {
   234  					return fmt.Errorf("failed to unmarshal %q: %w", p, err)
   235  				}
   236  				prowYAML = mergeProwYAML(prowYAML, partialProwYAML)
   237  			}
   238  			return err
   239  		})
   240  		if err != nil {
   241  			return nil, fmt.Errorf("failed to read contents of directory %q: %w", inRepoConfigDirName, err)
   242  		}
   243  	} else {
   244  		if !os.IsNotExist(err) {
   245  			return nil, fmt.Errorf("reading %q: %w", prowYAMLDirPath, err)
   246  		}
   247  		log.WithField("file", inRepoConfigFileName).Debug("Attempting to get inreconfigfile")
   248  		prowYAMLFilePath := path.Join(dir, inRepoConfigFileName)
   249  		if _, err := os.Stat(prowYAMLFilePath); err == nil {
   250  			bytes, err := os.ReadFile(prowYAMLFilePath)
   251  			if err != nil {
   252  				return nil, fmt.Errorf("failed to read %q: %w", prowYAMLDirPath, err)
   253  			}
   254  			if err := yaml.Unmarshal(bytes, prowYAML, opts...); err != nil {
   255  				return nil, fmt.Errorf("failed to unmarshal %q: %w", prowYAMLDirPath, err)
   256  			}
   257  		} else {
   258  			if !os.IsNotExist(err) {
   259  				return nil, fmt.Errorf("failed to check if file %q exists: %w", prowYAMLDirPath, err)
   260  			}
   261  		}
   262  	}
   263  	return prowYAML, nil
   264  }
   265  
   266  // prowYAMLGetterWithDefaults is like prowYAMLGetter, but additionally sets
   267  // defaults by calling DefaultAndValidateProwYAML.
   268  func prowYAMLGetterWithDefaults(
   269  	c *Config,
   270  	gc git.ClientFactory,
   271  	identifier string,
   272  	baseBranch string,
   273  	baseSHA string,
   274  	headSHAs ...string) (*ProwYAML, error) {
   275  
   276  	prowYAML, err := prowYAMLGetter(c, gc, identifier, baseBranch, baseSHA, headSHAs...)
   277  	if err != nil {
   278  		return nil, err
   279  	}
   280  
   281  	// Mutate prowYAML to default values as necessary.
   282  	if err := DefaultAndValidateProwYAML(c, prowYAML, identifier); err != nil {
   283  		return nil, err
   284  	}
   285  
   286  	return prowYAML, nil
   287  }
   288  
   289  func DefaultAndValidateProwYAML(c *Config, p *ProwYAML, identifier string) error {
   290  	if err := defaultPresubmits(p.Presubmits, p.Presets, c, identifier); err != nil {
   291  		return err
   292  	}
   293  	if err := defaultPostsubmits(p.Postsubmits, p.Presets, c, identifier); err != nil {
   294  		return err
   295  	}
   296  	if err := c.validatePresubmits(append(p.Presubmits, c.GetPresubmitsStatic(identifier)...)); err != nil {
   297  		return err
   298  	}
   299  	if err := c.validatePostsubmits(append(p.Postsubmits, c.GetPostsubmitsStatic(identifier)...)); err != nil {
   300  		return err
   301  	}
   302  
   303  	var errs []error
   304  	for _, pre := range p.Presubmits {
   305  		if !c.InRepoConfigAllowsCluster(pre.Cluster, identifier) {
   306  			errs = append(errs, fmt.Errorf("cluster %q is not allowed for repository %q", pre.Cluster, identifier))
   307  		}
   308  	}
   309  	for _, post := range p.Postsubmits {
   310  		if !c.InRepoConfigAllowsCluster(post.Cluster, identifier) {
   311  			errs = append(errs, fmt.Errorf("cluster %q is not allowed for repository %q", post.Cluster, identifier))
   312  		}
   313  	}
   314  
   315  	if len(errs) == 0 {
   316  		log := logrus.WithField("repo", identifier)
   317  		log.Debugf("Successfully got %d presubmits and %d postsubmits.", len(p.Presubmits), len(p.Postsubmits))
   318  	}
   319  
   320  	return utilerrors.NewAggregate(errs)
   321  }
   322  
   323  // ContainsInRepoConfigPath indicates whether the specified list of changed
   324  // files (repo relative paths) includes a file that might be an inrepo config file.
   325  //
   326  // This function could report a false positive as it doesn't consider .prowignore files.
   327  // It is designed to be used to help short circuit when we know a change doesn't touch
   328  // the inrepo config.
   329  func ContainsInRepoConfigPath(files []string) bool {
   330  	for _, file := range files {
   331  		if file == inRepoConfigFileName {
   332  			return true
   333  		}
   334  		if strings.HasPrefix(file, inRepoConfigDirName+"/") {
   335  			return true
   336  		}
   337  	}
   338  	return false
   339  }