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