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 }