blob: f36e24d3127841f2016850d14ddc895dbe98570e [file] [log] [blame]
# Copyright (c) 2011-2012 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Module that handles interactions with a Validation Pool.
The validation pool is the set of commits that are ready to be validated i.e.
ready for the commit queue to try.
"""
import json
import logging
import os
import time
import urllib
from xml.dom import minidom
from chromite.buildbot import gerrit_helper
from chromite.buildbot import lkgm_manager
from chromite.buildbot import patch as cros_patch
from chromite.lib import cros_build_lib
_BUILD_DASHBOARD = 'http://build.chromium.org/p/chromiumos'
_BUILD_INT_DASHBOARD = 'http://chromegw/i/chromeos'
def _RunCommand(cmd, dryrun):
"""Runs the specified shell cmd if dryrun=False."""
if dryrun:
logging.info('Would have run: %s', ' '.join(cmd))
else:
cros_build_lib.RunCommand(cmd, error_ok=True)
class TreeIsClosedException(Exception):
"""Raised when the tree is closed and we wanted to submit changes."""
def __init__(self):
super(TreeIsClosedException, self).__init__(
'TREE IS CLOSED. PLEASE SET TO OPEN OR THROTTLED TO COMMIT')
class FailedToSubmitAllChangesException(Exception):
"""Raised if we fail to submit any changes."""
def __init__(self, changes):
super(FailedToSubmitAllChangesException, self).__init__(
'FAILED TO SUBMIT ALL CHANGES: Could not verify that changes %s were '
'submitted' % ' '.join(str(c) for c in changes))
class ValidationPool(object):
"""Class that handles interactions with a validation pool.
This class can be used to acquire a set of commits that form a pool of
commits ready to be validated and committed.
Usage: Use ValidationPoo.AcquirePool -- a static
method that grabs the commits that are ready for validation.
"""
DEFAULT_ERROR_APPLY_MESSAGE = ('Please re-sync, rebase, and re-upload '
'your change.')
GLOBAL_DRYRUN = False
def __init__(self, internal, build_number, builder_name, is_master, dryrun,
changes=None, non_os_changes=None,
conflicting_changes=None):
"""Initializes an instance by setting default valuables to instance vars.
Generally use AcquirePool as an entry pool to a pool rather than this
method.
Args:
internal: Set to True if this is an internal validation pool.
build_number: Build number for this validation attempt.
builder_name: Builder name on buildbot dashboard.
is_master: True if this is the master builder for the Commit Queue.
dryrun: If set to True, do not submit anything to Gerrit.
Optional Args:
changes: List of changes for this validation pool.
non_manifest_changes: List of changes that are part of this validation
pool but aren't part of the cros checkout.
changes_that_failed_to_apply_earlier: Changes that failed to apply but
we're keeping around because they conflict with other changes in
flight.
"""
build_dashboard = _BUILD_DASHBOARD if not internal else _BUILD_INT_DASHBOARD
self.build_log = '%s/builders/%s/builds/%s' % (
build_dashboard, builder_name, str(build_number))
self.gerrit_helper = gerrit_helper.GerritHelper(internal)
self.is_master = is_master
self.dryrun = dryrun | self.GLOBAL_DRYRUN
# See optional args for types of changes.
self.changes = changes or []
self.non_manifest_changes = non_os_changes or []
self.changes_that_failed_to_apply_earlier = conflicting_changes or []
# Private vars only used for pickling.
self._internal = internal
self._build_number = build_number
self._builder_name = builder_name
self._content_merging_projects = None
def __getnewargs__(self):
"""Used for pickling to re-create validation pool."""
return (self._internal, self._build_number, self._builder_name,
self.is_master, self.dryrun, self.changes,
self.non_manifest_changes,
self.changes_that_failed_to_apply_earlier)
@classmethod
def _IsTreeOpen(cls, max_timeout=600):
"""Returns True if the tree is open or throttled.
At the highest level this function checks to see if the Tree is Open.
However, it also does a robustified wait as the server hosting the tree
status page is known to be somewhat flaky and these errors can be handled
with multiple retries. In addition, it waits around for the Tree to Open
based on |max_timeout| to give a greater chance of returning True as it
expects callees to want to do some operation based on a True value.
If a caller is not interested in this feature they should set |max_timeout|
to 0.
"""
state_field = 'general_state'
# Limit sleep interval to the set of 1-30
sleep_timeout = min(max(max_timeout / 5, 1), 30)
def _SleepWithExponentialBackOff(current_sleep):
"""Helper function to sleep with exponential backoff."""
time.sleep(current_sleep)
return current_sleep * 2
def _GetTreeStatus(status_url):
"""Returns the JSON dictionary response from the status url."""
max_attempts = 5
current_sleep = 1
for _ in range(max_attempts):
try:
# Check for successful response code.
response = urllib.urlopen(status_url)
if response.getcode() == 200:
return json.load(response)
# We remain robust against IOError's and retry.
except IOError:
pass
current_sleep = _SleepWithExponentialBackOff(current_sleep)
else:
# We go ahead and say the tree is open if we can't get the status.
logging.warn('Could not get a status from %s', status_url)
return {state_field: 'open'}
def _CanSubmit(json_dict):
"""Checks the json dict to determine whether the tree is open."""
return json_dict[state_field] in ['open', 'throttled']
# Check before looping with timeout.
status_url = 'https://chromiumos-status.appspot.com/current?format=json'
start_time = time.time()
if _CanSubmit(_GetTreeStatus(status_url)):
return True
# Loop until either we run out of time or the tree is open.
while (time.time() - start_time) < max_timeout:
if _CanSubmit(_GetTreeStatus(status_url)):
return True
else:
time.sleep(sleep_timeout)
return False
@classmethod
def AcquirePool(cls, internal, buildroot, build_number, builder_name, dryrun):
"""Acquires the current pool from Gerrit.
Polls Gerrit and checks for which change's are ready to be committed.
Args:
internal: If True, use gerrit-int.
buildroot: The location of the buildroot used to filter projects.
build_number: Corresponding build number for the build.
builder_name: Builder name on buildbot dashboard.
dryrun: Don't submit anything to gerrit.
Returns:
ValidationPool object.
Raises:
TreeIsClosedException: if the tree is closed.
"""
# We choose a longer wait here as we haven't committed to anything yet. By
# doing this here we can reduce the number of builder cycles.
if dryrun or cls._IsTreeOpen(max_timeout=3600):
# Only master configurations should call this method.
pool = ValidationPool(internal, build_number, builder_name, True, dryrun)
pool.gerrit_helper = gerrit_helper.GerritHelper(internal)
raw_changes = pool.gerrit_helper.GrabChangesReadyForCommit()
changes, non_manifest_changes = ValidationPool._FilterNonCrosProjects(
raw_changes, buildroot)
pool.changes, pool.non_manifest_changes = changes, non_manifest_changes
return pool
else:
raise TreeIsClosedException()
@classmethod
def AcquirePoolFromManifest(cls, manifest, internal, build_number,
builder_name, is_master, dryrun):
"""Acquires the current pool from a given manifest.
Args:
manifest: path to the manifest where the pool resides.
internal: if true, assume gerrit-int.
build_number: Corresponding build number for the build.
builder_name: Builder name on buildbot dashboard.
is_master: Boolean that indicates whether this is a pool for a master.
config or not.
dryrun: Don't submit anything to gerrit.
Returns:
ValidationPool object.
"""
pool = ValidationPool(internal, build_number, builder_name, is_master,
dryrun)
pool.gerrit_helper = gerrit_helper.GerritHelper(internal)
manifest_dom = minidom.parse(manifest)
pending_commits = manifest_dom.getElementsByTagName(
lkgm_manager.PALADIN_COMMIT_ELEMENT)
for pending_commit in pending_commits:
project = pending_commit.getAttribute(lkgm_manager.PALADIN_PROJECT_ATTR)
change = pending_commit.getAttribute(lkgm_manager.PALADIN_CHANGE_ID_ATTR)
commit = pending_commit.getAttribute(lkgm_manager.PALADIN_COMMIT_ATTR)
pool.changes.append(pool.gerrit_helper.GrabPatchFromGerrit(
project, change, commit))
return pool
@property
def ContentMergingProjects(self):
val = self._content_merging_projects
if val is None:
val = self.gerrit_helper.FindContentMergingProjects()
self._content_merging_projects = val
return val
@staticmethod
def _FilterNonCrosProjects(changes, buildroot):
"""Filters changes to a tuple of relevant changes.
There are many code reviews that are not part of Chromium OS and/or
only relevant on a different branch. This method returns a tuple of (
relevant reviews in a manifest, relevant reviews not in the manifest). Note
that this function must be run while chromite is checked out in a
repo-managed checkout.
Args:
changes: List of GerritPatch objects.
buildroot: Buildroot containing manifest to filter against.
Returns tuple of
relevant reviews in a manifest, relevant reviews not in the manifest.
"""
def IsCrosReview(change):
return (change.project.startswith('chromiumos') or
change.project.startswith('chromeos'))
# First we filter to only Chromium OS repositories.
changes = [c for c in changes if IsCrosReview(c)]
manifest_path = os.path.join(buildroot, '.repo', 'manifests/full.xml')
handler = cros_build_lib.ManifestHandler.ParseManifest(manifest_path)
projects = handler.projects
changes_in_manifest = []
changes_not_in_manifest = []
for change in changes:
branch = handler.default.get('revision')
patch_branch = 'refs/heads/%s' % change.tracking_branch
project = projects.get(change.project)
if project:
branch = project.get('revision') or branch
if branch == patch_branch:
if project:
changes_in_manifest.append(change)
else:
changes_not_in_manifest.append(change)
else:
logging.info('Filtered change %s', change)
return changes_in_manifest, changes_not_in_manifest
def ApplyPoolIntoRepo(self, buildroot):
"""Applies changes from pool into the directory specified by the buildroot.
This method applies changes in the order specified. It also respects
dependency order.
Returns:
True if we managed to apply any changes.
"""
# Sets are used for performance reasons where changes_list is used to
# maintain ordering when applying changes.
changes_that_failed_to_apply_against_other_changes = set()
changes_that_failed_to_apply_to_tot = set()
changes_applied = set()
changes_list = []
# Maps Change numbers to GerritPatch object for lookup of dependent
# changes.
change_map = dict((change.id, change) for change in self.changes)
for change in self.changes:
logging.debug('Trying change %s', change.id)
# We've already attempted this change because it was a dependent change
# of another change that was ready.
if (change in changes_that_failed_to_apply_to_tot or
change in changes_applied):
continue
# Change stacks consists of the change plus its dependencies in the order
# that they should be applied.
change_stack = [change]
apply_chain = True
deps = []
try:
deps.extend(change.GerritDependencies(buildroot))
deps.extend(change.PaladinDependencies(buildroot))
except cros_patch.MissingChangeIDException as me:
change.apply_error_message = (
'Could not apply change %s because change has a Gerrit Dependency '
'that does not contain a ChangeId. Please remove this dependency '
'or update the dependency with a ChangeId.' % change.id)
logging.error(change.apply_error_message)
logging.error(str(me))
changes_that_failed_to_apply_to_tot.add(change)
apply_chain = False
for dep in deps:
dep_change = change_map.get(dep)
if not dep_change:
# The dep may have been committed already.
try:
if not self.gerrit_helper.IsChangeCommitted(dep, must_match=False):
message = ('Could not apply change %s because dependent '
'change %s is not ready to be committed.' % (
change.id, dep))
logging.info(message)
change.apply_error_message = message
changes_that_failed_to_apply_to_tot.add(change)
apply_chain = False
break
except gerrit_helper.QueryNotSpecific:
message = ('Change %s could not be handled due to its dependency '
'%s matching multiple branches.' % (change.id, dep))
logging.info(message)
change.apply_error_message = message
changes_that_failed_to_apply_to_tot.add(change)
apply_chain = False
break
else:
change_stack.insert(0, dep_change)
# Should we apply the chain -- i.e. all deps are ready.
if not apply_chain:
continue
# Apply changes in change_stack. For chains that were aborted early,
# we still want to apply changes in change_stack because they were
# ready to be committed (o/w wouldn't have been in the change_map).
for change in change_stack:
try:
if change in changes_applied:
continue
elif change in changes_that_failed_to_apply_to_tot:
break
# If we're in dryrun mode, then 3way is always allowed.
# Otherwise, allow 3way only if the gerrit project allows it.
if self.dryrun:
trivial = False
else:
trivial = change.project not in self.ContentMergingProjects
change.Apply(buildroot, trivial=trivial)
except cros_patch.ApplyPatchException as e:
if e.type == cros_patch.ApplyPatchException.TYPE_REBASE_TO_TOT:
changes_that_failed_to_apply_to_tot.add(change)
else:
change.apply_error_message = (
'Your change conflicted with another change being tested '
'in the last validation pool. Please re-sync, rebase and '
're-upload.')
changes_that_failed_to_apply_against_other_changes.add(change)
break
else:
# We applied the change successfully.
changes_applied.add(change)
changes_list.append(change)
cros_build_lib.PrintBuildbotLink(str(change), change.url)
if self.is_master:
self.HandleApplied(change)
if changes_applied:
logging.debug('Done investigating changes. Applied %s',
' '.join([c.id for c in changes_applied]))
if changes_that_failed_to_apply_to_tot:
logging.info('Changes %s could not be applied cleanly.',
' '.join([c.id for c in changes_that_failed_to_apply_to_tot]))
self.HandleApplicationFailure(changes_that_failed_to_apply_to_tot)
self.changes = changes_list
self.changes_that_failed_to_apply_earlier = list(
changes_that_failed_to_apply_against_other_changes)
return len(self.changes) > 0
def _SubmitChanges(self, changes):
"""Submits given changes to Gerrit.
Raises:
TreeIsClosedException: if the tree is closed.
FailedToSubmitAllChangesException: if we can't submit a change.
"""
assert self.is_master, 'Non-master builder calling SubmitPool'
changes_that_failed_to_submit = []
# We use the default timeout here as while we want some robustness against
# the tree status being red i.e. flakiness, we don't want to wait too long
# as validation can become stale.
if self.dryrun or ValidationPool._IsTreeOpen():
for change in changes:
was_change_submitted = False
logging.info('Change %s will be submitted', change)
try:
self.SubmitChange(change)
was_change_submitted = self.gerrit_helper.IsChangeCommitted(
change.id, self.dryrun)
except cros_build_lib.RunCommandError:
logging.error('gerrit review --submit failed for change.')
finally:
if not was_change_submitted:
logging.error('Could not submit %s', str(change))
self.HandleCouldNotSubmit(change)
changes_that_failed_to_submit.append(change)
if changes_that_failed_to_submit:
raise FailedToSubmitAllChangesException(changes_that_failed_to_submit)
else:
raise TreeIsClosedException()
def SubmitChange(self, change):
"""Submits patch using Gerrit Review.
Args:
helper: Instance of gerrit_helper for the gerrit instance.
dryrun: If true, do not actually commit anything to Gerrit.
"""
cmd = self.gerrit_helper.GetGerritReviewCommand(['--submit', '%s,%s' % (
change.gerrit_number, change.patch_number)])
_RunCommand(cmd, self.dryrun)
def SubmitNonManifestChanges(self):
"""Commits changes to Gerrit from Pool that aren't part of the checkout.
Raises:
TreeIsClosedException: if the tree is closed.
FailedToSubmitAllChangesException: if we can't submit a change.
"""
self._SubmitChanges(self.non_manifest_changes)
def SubmitPool(self):
"""Commits changes to Gerrit from Pool. This is only called by a master.
Raises:
TreeIsClosedException: if the tree is closed.
FailedToSubmitAllChangesException: if we can't submit a change.
"""
self._SubmitChanges(self.changes)
if self.changes_that_failed_to_apply_earlier:
self.HandleApplicationFailure(self.changes_that_failed_to_apply_earlier)
def HandleApplicationFailure(self, changes):
"""Handles changes that were not able to be applied cleanly."""
for change in changes:
logging.info('Change %s did not apply cleanly.', change.id)
if self.is_master:
self.HandleCouldNotApply(change)
def HandleValidationFailure(self):
"""Handles failed changes by removing them from next Validation Pools."""
logging.info('Validation failed for all changes.')
for change in self.changes:
logging.info('Validation failed for change %s.', change)
self.HandleCouldNotVerify(change)
def HandleValidationTimeout(self):
"""Handles changes that timed out."""
logging.info('Validation timed out for all changes.')
for change in self.changes:
logging.info('Validation timed out for change %s.', change)
self._SendNotification(change,
'The Commit Queue timed out while verifying your change in '
'%(build_log)s . This means that a supporting builder did not '
'finish building your change within the specified timeout. If you '
'believe this happened in error, just re-mark your commit as ready. '
'Your change will then get automatically retried.')
self.gerrit_helper.RemoveCommitReady(change, dryrun=self.dryrun)
def _SendNotification(self, change, msg):
msg %= {'build_log':self.build_log}
PaladinMessage(msg, change, self.gerrit_helper).Send(self.dryrun)
def HandleCouldNotSubmit(self, change):
"""Handler that is called when Paladin can't submit a change.
This should be rare, but if an admin overrides the commit queue and commits
a change that conflicts with this change, it'll apply, build/validate but
receive an error when submitting.
Args:
change: GerritPatch instance to operate upon.
"""
self._SendNotification(change,
'The Commit Queue failed to submit your change in %(build_log)s . '
'This can happen if you submitted your change or someone else '
'submitted a conflicting change while your change was being tested.')
self.gerrit_helper.RemoveCommitReady(change, dryrun=self.dryrun)
def HandleCouldNotVerify(self, change):
"""Handler for when Paladin fails to validate a change.
This handler notifies set Verified-1 to the review forcing the developer
to re-upload a change that works. There are many reasons why this might be
called e.g. build or testing exception.
Args:
change: GerritPatch instance to operate upon.
"""
self._SendNotification(change,
'The Commit Queue failed to verify your change in %(build_log)s . '
'If you believe this happened in error, just re-mark your commit as '
'ready. Your change will then get automatically retried.')
self.gerrit_helper.RemoveCommitReady(change, dryrun=self.dryrun)
def HandleCouldNotApply(self, change):
"""Handler for when Paladin fails to apply a change.
This handler notifies set CodeReview-2 to the review forcing the developer
to re-upload a rebased change.
Args:
change: GerritPatch instance to operate upon.
"""
msg = 'The Commit Queue failed to apply your change in %(build_log)s . '
# This is written this way so that mox doesn't complain if/when we try
# accessing an attr that doesn't exist.
extra_msg = getattr(change, 'apply_error_message', None)
if extra_msg is None:
extra_msg = self.DEFAULT_ERROR_APPLY_MESSAGE
msg += extra_msg
self._SendNotification(change, msg)
self.gerrit_helper.RemoveCommitReady(change, dryrun=self.dryrun)
def HandleApplied(self, change):
"""Handler for when Paladin successfully applies a change.
This handler notifies a developer that their change is being tried as
part of a Paladin run defined by a build_log.
Args:
change: GerritPatch instance to operate upon.
"""
self._SendNotification(change,
'The Commit Queue has picked up your change. '
'You can follow along at %(build_log)s .')
class PaladinMessage():
"""An object that is used to send messages to developers about their changes.
"""
# URL where Paladin documentation is stored.
_PALADIN_DOCUMENTATION_URL = ('http://www.chromium.org/developers/'
'tree-sheriffs/sheriff-details-chromium-os/'
'commit-queue-overview')
def __init__(self, message, patch, helper):
self.message = message
self.patch = patch
self.helper = helper
def _ConstructPaladinMessage(self):
"""Adds any standard Paladin messaging to an existing message."""
return self.message + (' Please see %s for more information.' %
self._PALADIN_DOCUMENTATION_URL)
def Send(self, dryrun):
"""Sends the message to the developer."""
cmd = self.helper.GetGerritReviewCommand(
['-m', '"%s"' % self._ConstructPaladinMessage(),
'%s,%s' % (self.patch.gerrit_number, self.patch.patch_number)])
_RunCommand(cmd, dryrun)