github.com/justinjmoses/evergreen@v0.0.0-20170530173719-1d50e381ff0d/repotracker/repotracker.go (about)

     1  package repotracker
     2  
     3  import (
     4  	"fmt"
     5  	"time"
     6  
     7  	"github.com/evergreen-ci/evergreen"
     8  	"github.com/evergreen-ci/evergreen/model"
     9  	"github.com/evergreen-ci/evergreen/model/build"
    10  	"github.com/evergreen-ci/evergreen/model/version"
    11  	"github.com/evergreen-ci/evergreen/notify"
    12  	"github.com/evergreen-ci/evergreen/thirdparty"
    13  	"github.com/evergreen-ci/evergreen/util"
    14  	"github.com/evergreen-ci/evergreen/validator"
    15  	"github.com/mongodb/grip"
    16  	"github.com/pkg/errors"
    17  	"gopkg.in/yaml.v2"
    18  )
    19  
    20  const (
    21  	// determines the default maximum number of revisions to fetch for a newly tracked repo
    22  	// if not specified in configuration file
    23  	DefaultNumNewRepoRevisionsToFetch = 200
    24  	DefaultMaxRepoRevisionsToSearch   = 50
    25  )
    26  
    27  // RepoTracker is used to manage polling repository changes and storing such
    28  // changes. It contains a number of interfaces that specify behavior required by
    29  // client implementations
    30  type RepoTracker struct {
    31  	*evergreen.Settings
    32  	*model.ProjectRef
    33  	RepoPoller
    34  }
    35  
    36  // The RepoPoller interface specifies behavior required of all repository poller
    37  // implementations
    38  type RepoPoller interface {
    39  	// Fetches the contents of a remote repository's configuration data as at
    40  	// the given revision.
    41  	GetRemoteConfig(revision string) (*model.Project, error)
    42  
    43  	// Fetches a list of all filepaths modified by a given revision.
    44  	GetChangedFiles(revision string) ([]string, error)
    45  
    46  	// Fetches all changes since the 'revision' specified - with the most recent
    47  	// revision appearing as the first element in the slice.
    48  	//
    49  	// 'maxRevisionsToSearch' determines the maximum number of revisions we
    50  	// allow to search through - in order to find 'revision' - before we give
    51  	// up. A value <= 0 implies we allow to search through till we hit the first
    52  	// revision for the project.
    53  	GetRevisionsSince(sinceRevision string, maxRevisions int) ([]model.Revision, error)
    54  	// Fetches the most recent 'numNewRepoRevisionsToFetch' revisions for a
    55  	// project - with the most recent revision appearing as the first element in
    56  	// the slice.
    57  	GetRecentRevisions(numNewRepoRevisionsToFetch int) ([]model.Revision, error)
    58  }
    59  
    60  type projectConfigError struct {
    61  	Errors   []string
    62  	Warnings []string
    63  }
    64  
    65  func (p projectConfigError) Error() string {
    66  	return "Invalid project configuration"
    67  }
    68  
    69  // The FetchRevisions method is used by a RepoTracker to run the pipeline for
    70  // tracking repositories. It performs everything from polling the repository to
    71  // persisting any changes retrieved from the repository reference.
    72  func (repoTracker *RepoTracker) FetchRevisions(numNewRepoRevisionsToFetch int) error {
    73  	settings := repoTracker.Settings
    74  	projectRef := repoTracker.ProjectRef
    75  	projectIdentifier := projectRef.String()
    76  
    77  	if !projectRef.Enabled {
    78  		grip.Infoln("Skipping disabled project:", projectRef)
    79  		return nil
    80  	}
    81  
    82  	repository, err := model.FindRepository(projectIdentifier)
    83  	if err != nil {
    84  		return errors.Wrapf(err, "error finding repository '%v'", projectIdentifier)
    85  	}
    86  
    87  	var revisions []model.Revision
    88  	var lastRevision string
    89  
    90  	if repository != nil {
    91  		lastRevision = repository.LastRevision
    92  	}
    93  
    94  	if lastRevision == "" {
    95  		// if this is the first time we're running the tracker for this project,
    96  		// fetch the most recent `numNewRepoRevisionsToFetch` revisions
    97  		grip.Debugln("No last recorded repository revision for", projectRef,
    98  			"Proceeding to fetch most recent", numNewRepoRevisionsToFetch, "revisions")
    99  		revisions, err = repoTracker.GetRecentRevisions(numNewRepoRevisionsToFetch)
   100  	} else {
   101  		grip.Debugf("Last recorded revision for %s is %s", projectRef, lastRevision)
   102  		// if the projectRef has a repotracker error then don't get the revisions
   103  		if projectRef.RepotrackerError != nil {
   104  			if projectRef.RepotrackerError.Exists {
   105  				grip.Errorln("repotracker error for base revision '%s' on project '%s/%s:%s'",
   106  					projectRef.RepotrackerError.InvalidRevision,
   107  					projectRef.Owner, projectRef.Repo, projectRef.Branch)
   108  				return nil
   109  			}
   110  		}
   111  		max := settings.RepoTracker.MaxRepoRevisionsToSearch
   112  		if max <= 0 {
   113  			max = DefaultMaxRepoRevisionsToSearch
   114  		}
   115  		revisions, err = repoTracker.GetRevisionsSince(lastRevision, max)
   116  	}
   117  
   118  	if err != nil {
   119  		grip.Errorf("error fetching revisions for repository %s: %+v", projectRef.Identifier, err)
   120  		repoTracker.sendFailureNotification(lastRevision, err)
   121  		return nil
   122  	}
   123  
   124  	if len(revisions) > 0 {
   125  		var lastVersion *version.Version
   126  		lastVersion, err = repoTracker.StoreRevisions(revisions)
   127  		if err != nil {
   128  			grip.Errorf("error storing revisions for repository %s: %+v", projectRef, err)
   129  			return err
   130  		}
   131  		err = model.UpdateLastRevision(lastVersion.Identifier, lastVersion.Revision)
   132  		if err != nil {
   133  			grip.Errorf("error updating last revision for repository %s: %+v", projectRef, err)
   134  			return err
   135  		}
   136  	}
   137  
   138  	// fetch the most recent, non-ignored version version to activate
   139  	activateVersion, err := version.FindOne(version.ByMostRecentNonignored(projectIdentifier))
   140  	if err != nil {
   141  		grip.Errorf("error getting most recent version for repository %s: %+v", projectRef, err)
   142  		return err
   143  	}
   144  	if activateVersion == nil {
   145  		grip.Warningf("no version to activate for repository %s", projectIdentifier)
   146  		return nil
   147  	}
   148  
   149  	err = repoTracker.activateElapsedBuilds(activateVersion)
   150  	if err != nil {
   151  		grip.Errorln("error activating variants:", err)
   152  		return err
   153  	}
   154  
   155  	return nil
   156  }
   157  
   158  // Activates any builds if their BatchTimes have elapsed.
   159  func (repoTracker *RepoTracker) activateElapsedBuilds(v *version.Version) (err error) {
   160  	projectId := repoTracker.ProjectRef.Identifier
   161  	hasActivated := false
   162  	now := time.Now()
   163  	for i, status := range v.BuildVariants {
   164  		// last comparison is to check that ActivateAt is actually set
   165  		if !status.Activated && now.After(status.ActivateAt) && !status.ActivateAt.IsZero() {
   166  			grip.Infof("activating variant %s for project %s, revision %s",
   167  				status.BuildVariant, projectId, v.Revision)
   168  
   169  			// Go copies the slice value, we want to modify the actual value
   170  			status.Activated = true
   171  			status.ActivateAt = now
   172  			v.BuildVariants[i] = status
   173  
   174  			b, err := build.FindOne(build.ById(status.BuildId))
   175  			if err != nil {
   176  				grip.Errorf("error retrieving build for project %s, variant %s, build %s: %+v",
   177  					projectId, status.BuildVariant, status.BuildId, err)
   178  				continue
   179  			}
   180  			grip.Infof("activating build %s for project %s, variant %s",
   181  				status.BuildId, projectId, status.BuildVariant)
   182  			// Don't need to set the version in here since we do it ourselves in a single update
   183  			if err = model.SetBuildActivation(b.Id, true, evergreen.DefaultTaskActivator); err != nil {
   184  				grip.Errorf("error activating build %s for project %s, variant %s: %+v",
   185  					b.Id, projectId, status.BuildVariant, err)
   186  				continue
   187  			}
   188  			hasActivated = true
   189  		}
   190  	}
   191  
   192  	// If any variants were activated, update the stored version so that we don't
   193  	// attempt to activate them again
   194  	if hasActivated {
   195  		return v.UpdateBuildVariants()
   196  	}
   197  	return nil
   198  }
   199  
   200  // sendFailureNotification sends a notification to the MCI Team when the
   201  // repotracker is unable to fetch revisions from a given project ref
   202  func (repoTracker *RepoTracker) sendFailureNotification(lastRevision string,
   203  	err error) {
   204  	// Send a notification to the MCI team
   205  	settings := repoTracker.Settings
   206  	max := settings.RepoTracker.MaxRepoRevisionsToSearch
   207  	if max <= 0 {
   208  		max = DefaultMaxRepoRevisionsToSearch
   209  	}
   210  	projectRef := repoTracker.ProjectRef
   211  	subject := fmt.Sprintf(notify.RepotrackerFailurePreface,
   212  		projectRef.Identifier, lastRevision)
   213  	url := fmt.Sprintf("%v/%v/%v/commits/%v", thirdparty.GithubBase,
   214  		projectRef.Owner, projectRef.Repo, projectRef.Branch)
   215  	message := fmt.Sprintf("Could not find last known revision '%v' "+
   216  		"within the most recent %v revisions at %v: %v", lastRevision, max, url, err)
   217  	nErr := notify.NotifyAdmins(subject, message, settings)
   218  	if nErr != nil {
   219  		grip.Errorf("error sending email: %+v", nErr)
   220  	}
   221  }
   222  
   223  // Verifies that the given revision order number is higher than the latest number stored for the project.
   224  func sanityCheckOrderNum(revOrderNum int, projectId string) error {
   225  	latest, err := version.FindOne(version.ByMostRecentForRequester(projectId, evergreen.RepotrackerVersionRequester))
   226  	if err != nil {
   227  		return errors.Wrap(err, "Error getting latest version")
   228  	}
   229  
   230  	// When there are no versions in the db yet, sanity check is moot
   231  	if latest != nil {
   232  		if revOrderNum <= latest.RevisionOrderNumber {
   233  			return errors.Errorf("Commit order number isn't greater than last stored version's: %v <= %v",
   234  				revOrderNum, latest.RevisionOrderNumber)
   235  		}
   236  	}
   237  	return nil
   238  }
   239  
   240  // Constructs all versions stored from recent repository revisions
   241  // The additional complexity is due to support for project modifications on patch builds.
   242  // We need to parse the remote config as it existed when each revision was created.
   243  // The return value is the most recent version created as a result of storing the revisions.
   244  // This function is idempotent with regard to storing the same version multiple times.
   245  func (repoTracker *RepoTracker) StoreRevisions(revisions []model.Revision) (newestVersion *version.Version, err error) {
   246  	defer func() {
   247  		if newestVersion != nil {
   248  			// Fetch the updated version doc, so that we include buildvariants in the result
   249  			newestVersion, err = version.FindOne(version.ById(newestVersion.Id))
   250  		}
   251  	}()
   252  	ref := repoTracker.ProjectRef
   253  
   254  	for i := len(revisions) - 1; i >= 0; i-- {
   255  		revision := revisions[i].Revision
   256  		grip.Infof("Processing revision %s in project %s", revision, ref.Identifier)
   257  
   258  		// We check if the version exists here so we can avoid fetching the github config unnecessarily
   259  		existingVersion, err := version.FindOne(version.ByProjectIdAndRevision(ref.Identifier, revisions[i].Revision))
   260  		grip.ErrorWhenf(err != nil, "Error looking up version at %s for project %s: %+v", ref.Identifier, revision, err)
   261  		if existingVersion != nil {
   262  			grip.Infof("Skipping creation of version for project %s, revision %s "+
   263  				"since we already have a record for it", ref.Identifier, revision)
   264  			// We bind newestVersion here since we still need to return the most recent
   265  			// version, even if it already exists
   266  			newestVersion = existingVersion
   267  			continue
   268  		}
   269  
   270  		// Create the stub of the version (not stored in DB yet)
   271  		v, err := NewVersionFromRevision(ref, revisions[i])
   272  		if err != nil {
   273  			grip.Infof("Error creating version for project %s: %+v", ref.Identifier, err)
   274  		}
   275  		err = sanityCheckOrderNum(v.RevisionOrderNumber, ref.Identifier)
   276  		if err != nil { // something seriously wrong (bad data in db?) so fail now
   277  			panic(err)
   278  		}
   279  		project, err := repoTracker.GetProjectConfig(revision)
   280  		if err != nil {
   281  			projectError, isProjectError := err.(projectConfigError)
   282  			if isProjectError {
   283  				if len(projectError.Warnings) > 0 {
   284  					// Store the warnings and keep going. If we don't have
   285  					// any true errors, the version will still be created.
   286  					v.Warnings = projectError.Warnings
   287  				}
   288  				if len(projectError.Errors) > 0 {
   289  					// Store just the stub version with the project errors
   290  					v.Errors = projectError.Errors
   291  					if err = v.Insert(); err != nil {
   292  						grip.Errorf("Failed storing stub version for project %s: %+v",
   293  							ref.Identifier, err)
   294  						return nil, err
   295  					}
   296  					newestVersion = v
   297  					continue
   298  				}
   299  			} else {
   300  				// Fatal error - don't store the stub
   301  				grip.Infof("Failed to get config for project %s at %s: %+v",
   302  					ref.Identifier, revision, err)
   303  				return nil, err
   304  			}
   305  		}
   306  
   307  		// We have a config, so turn it into a usable yaml string to store with the version doc
   308  		projectYamlBytes, err := yaml.Marshal(project)
   309  		if err != nil {
   310  			return nil, errors.Wrap(err, "Error marshaling config")
   311  		}
   312  		v.Config = string(projectYamlBytes)
   313  
   314  		// "Ignore" a version if all changes are to ignored files
   315  		if len(project.Ignore) > 0 {
   316  			filenames, err := repoTracker.GetChangedFiles(revision)
   317  			if err != nil {
   318  				return nil, errors.Wrap(err, "error checking GitHub for ignored files")
   319  			}
   320  			if project.IgnoresAllFiles(filenames) {
   321  				v.Ignored = true
   322  			}
   323  		}
   324  
   325  		// We rebind newestVersion each iteration, so the last binding will be the newest version
   326  		err = errors.Wrapf(createVersionItems(v, ref, project),
   327  			"Error creating version items for %s in project %s",
   328  			v.Id, ref.Identifier)
   329  		if err != nil {
   330  			grip.Error(err)
   331  			return nil, err
   332  		}
   333  		newestVersion = v
   334  	}
   335  	return newestVersion, nil
   336  }
   337  
   338  // GetProjectConfig fetches the project configuration for a given repository
   339  // returning a remote config if the project references a remote repository
   340  // configuration file - via the Identifier. Otherwise it defaults to the local
   341  // project file. An erroneous project file may be returned along with an error.
   342  func (repoTracker *RepoTracker) GetProjectConfig(revision string) (*model.Project, error) {
   343  	projectRef := repoTracker.ProjectRef
   344  	if projectRef.LocalConfig != "" {
   345  		// return the Local config from the project Ref.
   346  		p, err := model.FindProject("", projectRef)
   347  		return p, err
   348  	}
   349  	project, err := repoTracker.GetRemoteConfig(revision)
   350  	if err != nil {
   351  		// Only create a stub version on API request errors that pertain
   352  		// to actually fetching a config. Those errors currently include:
   353  		// thirdparty.APIRequestError, thirdparty.FileNotFoundError and
   354  		// thirdparty.YAMLFormatError
   355  		_, apiReqErr := err.(thirdparty.APIRequestError)
   356  		_, ymlFmtErr := err.(thirdparty.YAMLFormatError)
   357  		_, noFileErr := err.(thirdparty.FileNotFoundError)
   358  		if apiReqErr || noFileErr || ymlFmtErr {
   359  			// If there's an error getting the remote config, e.g. because it
   360  			// does not exist, we treat this the same as when the remote config
   361  			// is invalid - but add a different error message
   362  			message := fmt.Sprintf("error fetching project '%v' configuration "+
   363  				"data at revision '%v' (remote path='%v'): %v",
   364  				projectRef.Identifier, revision, projectRef.RemotePath, err)
   365  			grip.Error(message)
   366  			return nil, projectConfigError{[]string{message}, nil}
   367  		}
   368  		// If we get here then we have an infrastructural error - e.g.
   369  		// a thirdparty.APIUnmarshalError (indicating perhaps an API has
   370  		// changed), a thirdparty.ResponseReadError(problem reading an
   371  		// API response) or a thirdparty.APIResponseError (nil API
   372  		// response) - or encountered a problem in fetching a local
   373  		// configuration file. At any rate, this is bad enough that we
   374  		// want to send a notification instead of just creating a stub
   375  		// version.
   376  		var lastRevision string
   377  		repository, fErr := model.FindRepository(projectRef.Identifier)
   378  		if fErr != nil || repository == nil {
   379  			grip.Errorf("error finding repository '%s': %+v",
   380  				projectRef.Identifier, fErr)
   381  		} else {
   382  			lastRevision = repository.LastRevision
   383  		}
   384  		repoTracker.sendFailureNotification(lastRevision, err)
   385  		return nil, err
   386  	}
   387  
   388  	// check if project config is valid
   389  	verrs, err := validator.CheckProjectSyntax(project)
   390  	if err != nil {
   391  		return nil, err
   392  	}
   393  	if len(verrs) != 0 {
   394  		// We have syntax errors in the project.
   395  		// Format them, as we need to store + display them to the user
   396  		var errMessage, warnMessage string
   397  		var projectErrors, projectWarnings []string
   398  		for _, e := range verrs {
   399  			if e.Level == validator.Warning {
   400  				warnMessage += fmt.Sprintf("\n\t=> %v", e)
   401  				projectWarnings = append(projectWarnings, e.Error())
   402  			} else {
   403  				errMessage += fmt.Sprintf("\n\t=> %v", e)
   404  				projectErrors = append(projectErrors, e.Error())
   405  			}
   406  		}
   407  
   408  		grip.ErrorWhenf(len(projectErrors) > 0, "problem validating project '%s' "+
   409  			"configuration at revision '%s': %+v",
   410  			projectRef.Identifier, revision, errMessage)
   411  
   412  		grip.ErrorWhenf(len(projectWarnings) > 0, "warning validating project '%s' "+
   413  			"configuration at revision '%s': %+v", projectRef.Identifier,
   414  			revision, warnMessage)
   415  
   416  		return project, projectConfigError{projectErrors, projectWarnings}
   417  	}
   418  	return project, nil
   419  }
   420  
   421  // NewVersionFromRevision populates a new Version with metadata from a model.Revision.
   422  // Does not populate its config or store anything in the database.
   423  func NewVersionFromRevision(ref *model.ProjectRef, rev model.Revision) (*version.Version, error) {
   424  	number, err := model.GetNewRevisionOrderNumber(ref.Identifier)
   425  	if err != nil {
   426  		return nil, err
   427  	}
   428  	v := &version.Version{
   429  		Author:              rev.Author,
   430  		AuthorEmail:         rev.AuthorEmail,
   431  		Branch:              ref.Branch,
   432  		CreateTime:          rev.CreateTime,
   433  		Id:                  util.CleanName(fmt.Sprintf("%v_%v", ref.String(), rev.Revision)),
   434  		Identifier:          ref.Identifier,
   435  		Message:             rev.RevisionMessage,
   436  		Owner:               ref.Owner,
   437  		RemotePath:          ref.RemotePath,
   438  		Repo:                ref.Repo,
   439  		RepoKind:            ref.RepoKind,
   440  		Requester:           evergreen.RepotrackerVersionRequester,
   441  		Revision:            rev.Revision,
   442  		Status:              evergreen.VersionCreated,
   443  		RevisionOrderNumber: number,
   444  	}
   445  	return v, nil
   446  }
   447  
   448  // createVersionItems populates and stores all the tasks and builds for a version according to
   449  // the given project config.
   450  func createVersionItems(v *version.Version, ref *model.ProjectRef, project *model.Project) error {
   451  	// generate all task Ids so that we can easily reference them for dependencies
   452  	taskIdTable := model.NewTaskIdTable(project, v)
   453  
   454  	// create all builds for the version
   455  	for _, buildvariant := range project.BuildVariants {
   456  		if buildvariant.Disabled {
   457  			continue
   458  		}
   459  		buildId, err := model.CreateBuildFromVersion(project, v, taskIdTable, buildvariant.Name, false, nil)
   460  		if err != nil {
   461  			return errors.WithStack(err)
   462  		}
   463  
   464  		lastActivated, err := version.FindOne(version.ByLastVariantActivation(ref.Identifier, buildvariant.Name))
   465  		if err != nil {
   466  			grip.Errorln("Error getting activation time for variant", buildvariant.Name)
   467  			return errors.WithStack(err)
   468  		}
   469  
   470  		var lastActivation *time.Time
   471  		if lastActivated != nil {
   472  			for _, buildStatus := range lastActivated.BuildVariants {
   473  				if buildStatus.BuildVariant == buildvariant.Name && buildStatus.Activated {
   474  					lastActivation = &buildStatus.ActivateAt
   475  					break
   476  				}
   477  			}
   478  		}
   479  
   480  		var activateAt time.Time
   481  		if lastActivation == nil {
   482  			// if we don't have a last activation time then prepare to activate it immediately.
   483  			activateAt = time.Now()
   484  		} else {
   485  			activateAt = lastActivation.Add(time.Minute * time.Duration(ref.GetBatchTime(&buildvariant)))
   486  		}
   487  		grip.Infof("Going to activate bv %s for project %s, version %s at %s",
   488  			buildvariant.Name, ref.Identifier, v.Id, activateAt)
   489  
   490  		v.BuildIds = append(v.BuildIds, buildId)
   491  		v.BuildVariants = append(v.BuildVariants, version.BuildStatus{
   492  			BuildVariant: buildvariant.Name,
   493  			Activated:    false,
   494  			ActivateAt:   activateAt,
   495  			BuildId:      buildId,
   496  		})
   497  	}
   498  
   499  	if err := v.Insert(); err != nil {
   500  		grip.Errorf("inserting version %s: %+v", v.Id, err)
   501  		for _, buildStatus := range v.BuildVariants {
   502  			if buildErr := model.DeleteBuild(buildStatus.BuildId); buildErr != nil {
   503  				grip.Errorf("deleting build %s: %+v", buildStatus.BuildId, buildErr)
   504  			}
   505  		}
   506  		return errors.WithStack(err)
   507  	}
   508  	return nil
   509  }