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 }