blob: d33b5676e5edeee99603000f30980c720cd3939e [file] [log] [blame]
# Copyright (c) 2012 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""A subclass of commands.SVN that allows more flexible error recovery.
This code is only used on the slave but it is living in common/ because it is
directly imported from buildbot/slave/bot.py."""
import os
import re
from twisted.python import log
from twisted.internet import defer
from common import chromium_utils
from buildslave.commands.base import SourceBaseCommand
from buildslave.commands.registry import commandRegistry
from buildslave import runprocess
PROJECTS_LOOKING_FOR = {
os.path.join('src'): 'got_chromium_revision',
os.path.join('src', 'native_client'): 'got_nacl_revision',
os.path.join('src', 'tools', 'swarming_client'):
'got_swarming_client_revision',
os.path.join('src', 'v8'): 'got_v8_revision',
os.path.join('src', 'third_party', 'WebKit'): 'got_webkit_revision',
os.path.join('src', 'third_party', 'webrtc'): 'got_webrtc_revision',
}
# Local errors.
class InvalidPath(Exception): pass
def FixDiffLineEnding(diff):
"""Fix patch files generated on windows and applied on mac/linux.
For files with svn:eol-style=crlf, svn diff puts CRLF in the diff hunk header.
patch on linux and mac barfs on those hunks. As usual, blame svn."""
output = ''
for line in diff.splitlines(True):
if (line.startswith('---') or line.startswith('+++') or
line.startswith('@@ ') or line.startswith('\\ No')):
# Strip any existing CRLF on the header lines
output += line.rstrip() + '\n'
else:
output += line
return output
def untangle(stdout_lines):
"""Sort the lines of stdout by the job number prefix."""
out = []
tasks = {}
UNTANGLE_RE = re.compile(r'^(\d+)>(.*)$')
for line in stdout_lines:
m = UNTANGLE_RE.match(line)
if not m:
if line: # skip empty lines
# Output untangled lines so far.
for key in sorted(tasks.iterkeys()):
out.extend(tasks[key])
tasks = {}
out.append(line)
else:
if m.group(2): # skip empty lines
tasks.setdefault(int(m.group(1)), []).append(m.group(2))
for key in sorted(tasks.iterkeys()):
out.extend(tasks[key])
return out
def extract_revisions(output):
"""Extracts revision numbers for all the dependencies checked out.
svn checkout operations finish with 'Checked out revision 16657.'
svn update operations finish the line 'At revision 16654.' when there is no
change. They finish with 'Updated to revision 16655.' otherwise.
The first project checked out gets to be set to got_revision property. This is
important since this class is not only used for chromium, it's also used for
other projects.
"""
SCM_RE = {
'git': r'^Checked out revision ([0-9a-fA-F]{40})$',
'svn': r'^(?:Checked out|At|Updated to) revision ([0-9]+)\.',
}
GCLIENT_CMD = re.compile(r'^________ running \'(.+?)\' in \'(.+?)\'$')
GCLIENT_AT_REV = re.compile(r'_____ (.+?) at (\d+|[0-9a-fA-F]{40})$')
untangled_stdout = untangle(output.splitlines(False))
# We only care about the last sync which starts with "solutions=[...".
# Look backwards to find the last gclient sync call. It's parsing the whole
# sync step in one pass here, which could include gclient revert and two
# gclient sync calls. Only the last gclient sync call should be parsed.
for i, line in enumerate(reversed(untangled_stdout)):
if line.startswith('solutions=['):
# Only keep from "solutions" line to end.
untangled_stdout = untangled_stdout[-i-1:]
break
revisions_found = {}
current_scm = None
current_project = None
reldir = None
for line in untangled_stdout:
match = GCLIENT_AT_REV.match(line)
if match:
current_project = None
current_scm = None
log.msg('gclient: %s == %s' % match.groups())
project = PROJECTS_LOOKING_FOR.get(match.group(1))
if not revisions_found:
# Set it to got_revision, independent if it's a chromium-specific
# thing.
revisions_found['got_revision'] = match.group(2)
if project:
revisions_found[project] = match.group(2)
continue
if current_scm:
# Look for revision.
match = re.match(SCM_RE[current_scm], line)
if match:
# Override any previous value, since update can happen multiple times.
log.msg(
'scm: %s (%s) == %s' % (reldir, current_project, match.group(1)))
if not revisions_found:
revisions_found['got_revision'] = match.group(1)
if current_project:
revisions_found[current_project] = match.group(1)
current_project = None
current_scm = None
continue
match = GCLIENT_CMD.match(line)
if match:
command, directory = match.groups()
parts = command.split(' ')
directory = directory.rstrip(os.path.sep) + os.path.sep
if parts[0] in SCM_RE:
current_scm = parts[0]
for part in parts:
# This code assumes absolute paths, which are easy to find in the
# argument list.
if part.startswith(directory):
reldir = part[len(directory):]
if reldir:
current_project = PROJECTS_LOOKING_FOR.get(reldir)
break
return revisions_found
def _RenameDirectoryCommand(src_dir, dest_dir):
"""Returns a command list to rename a directory (or file) using Python."""
# Use / instead of \ in paths to avoid issues with escaping.
return ['python', '-c',
'import os; '
'os.rename("%s", "%s")' %
(src_dir.replace('\\', '/'), dest_dir.replace('\\', '/'))]
def _RemoveFileCommand(filename):
"""Returns a command list to remove a directory (or file) using Python."""
# Use / instead of \ in paths to avoid issues with escaping.
return ['python', '-c',
'from common import chromium_utils; '
'chromium_utils.RemoveFile("%s")' % filename.replace('\\', '/')]
class GClient(SourceBaseCommand):
"""Source class that handles gclient checkouts.
Buildbot 8.3 changed from commands.SourceBase to SourceBaseCommand,
so this inherits from a variable set to the right item. Docs below
assume 8.3 (and this hack should be removed when pre-8.3 clients are
turned down.
In addition to the arguments handled by SourceBaseCommand, this command
reads the following keys:
['gclient_spec']:
if not None, then this specifies the text of the .gclient file to use.
this overrides any 'svnurl' argument that may also be specified.
['rm_timeout']:
if not None, a different timeout used only for the 'rm -rf' operation in
doClobber. Otherwise the svn timeout will be used for that operation too.
['svnurl']:
if not None, then this specifies the svn url to pass to 'gclient config'
to create a .gclient file.
['branch']:
if not None, then this specifies the module name to pass to 'gclient sync'
in --revision argument.
['project']:
if not None, then this specifies the module name to pass to 'gclient sync'
in --revision argument. This value overloads 'branch', and is mostly useful
for git checkouts. See also 'no_gclient_branch'.
['env']:
Augment os.environ.
['no_gclient_branch']:
If --revision is specified, don't prepend it with <branch>@. This is
necessary for git, where the solution name is 'src' and the branch name
is 'master'. Use the project attribute if there are several solution in
the .gclient file.
['no_gclient_revision']:
Do not specify the --revision argument to gclient at all.
"""
# pylint complains that __init__ in GClient's parent isn't called. This is
# because it doesn't realize we call it through GetParentClass(). Disable
# that warning next.
# pylint: disable=W0231
header = 'gclient'
def __init__(self, *args, **kwargs):
# TODO(maruel): Mainly to keep pylint happy, remove once buildbot fixed
# their style.
self.branch = None
self.srcdir = None
self.revision = None
self.env = None
self.sourcedatafile = None
self.patch = None
self.command = None
self.vcexe = None
self.svnurl = None
self.sudo_for_remove = None
self.gclient_spec = None
self.gclient_deps = None
self.rm_timeout = None
self.gclient_nohooks = False
self.was_patched = False
self.no_gclient_branch = False
self.no_gclient_revision = False
self.delete_unversioned_trees_when_updating = True
self.gclient_jobs = None
self.project = None
# TODO(maruel): Remove once buildbot 0.8.4p1 conversion is complete.
self.sourcedata = None
self.do_nothing = None
chromium_utils.GetParentClass(GClient).__init__(self, *args, **kwargs)
def setup(self, args):
"""Our implementation of command.Commands.setup() method.
The method will get all the arguments that are passed to remote command
and is invoked before start() method (that will in turn call doVCUpdate()).
"""
SourceBaseCommand.setup(self, args)
self.vcexe = self.getCommand('gclient')
self.svnurl = args['svnurl']
self.branch = args.get('branch')
self.revision = args.get('revision')
self.patch = args.get('patch')
self.sudo_for_remove = args.get('sudo_for_remove')
self.gclient_spec = args['gclient_spec']
self.gclient_deps = args.get('gclient_deps')
self.sourcedata = '%s\n' % self.svnurl
self.rm_timeout = args.get('rm_timeout', self.timeout)
self.env = args.get('env')
self.gclient_nohooks = args.get('gclient_nohooks', False)
self.env['CHROMIUM_GYP_SYNTAX_CHECK'] = '1'
self.no_gclient_branch = args.get('no_gclient_branch')
self.no_gclient_revision = args.get('no_gclient_revision', False)
self.gclient_jobs = args.get('gclient_jobs')
self.project = args.get('project', None)
def start(self):
"""Start the update process.
start() is cut-and-paste from the base class, the block calling
self.sourcedirIsPatched() and the revert support is the only functional
difference from base."""
self.sendStatus({'header': "starting " + self.header + "\n"})
self.command = None
# self.srcdir is where the VC system should put the sources
if self.mode == "copy":
self.srcdir = "source" # hardwired directory name, sorry
else:
self.srcdir = self.workdir
self.sourcedatafile = os.path.join(self.builder.basedir,
self.srcdir,
".buildbot-sourcedata")
self.do_nothing = os.path.isfile(os.path.join(self.builder.basedir,
self.srcdir,
'update.flag'))
d = defer.succeed(0)
if self.do_nothing:
# If bot update is run, we don't need to run the traditional update step.
msg = 'update.flag file found: bot_update has run and checkout is \n'
msg += 'already in a consistent state.\n'
msg += 'No actions will be performed in this step.'
self.sendStatus({'header': msg})
d.addCallback(self._sendRC)
return d
# Do we need to clobber anything?
if self.mode in ("copy", "clobber", "export"):
d.addCallback(self.doClobber, self.workdir)
if not (self.sourcedirIsUpdateable() and self.sourcedataMatches()):
# the directory cannot be updated, so we have to clobber it.
# Perhaps the master just changed modes from 'export' to
# 'update'.
d.addCallback(self.doClobber, self.srcdir)
elif self.sourcedirIsPatched():
# The directory is patched. Revert the sources.
d.addCallback(self.doRevert)
self.was_patched = True
d.addCallback(self.doVC)
if self.mode == "copy":
d.addCallback(self.doCopy)
if self.patch:
d.addCallback(self.doPatch)
# Only after the patch call the actual code to get the revision numbers.
d.addCallback(self._handleGotRevision)
if (self.patch or self.was_patched) and not self.gclient_nohooks:
# Always run doRunHooks if there *is* or there *was* a patch because
# revert is run with --nohooks and `gclient sync` will not regenerate the
# output files if the input files weren't updated..
d.addCallback(self.doRunHooks)
d.addCallbacks(self._sendRC, self._checkAbandoned)
return d
def sourcedirIsPatched(self):
return os.path.exists(os.path.join(self.builder.basedir,
self.srcdir, '.buildbot-patched'))
def sourcedirIsUpdateable(self):
# Patched directories are updatable.
return os.path.exists(os.path.join(self.builder.basedir,
self.srcdir, '.gclient'))
# TODO(pamg): consolidate these with the copies above.
def _RemoveDirectoryCommand(self, rm_dir):
"""Returns a command list to delete a directory using Python."""
# Use / instead of \ in paths to avoid issues with escaping.
cmd = ['python', '-c',
'from common import chromium_utils; '
'chromium_utils.RemoveDirectory("%s")' % rm_dir.replace('\\', '/')]
if self.sudo_for_remove:
cmd = ['sudo'] + cmd
return cmd
def doGclientUpdate(self):
"""Sync the client
"""
dirname = os.path.join(self.builder.basedir, self.srcdir)
command = [chromium_utils.GetGClientCommand(),
'sync', '--verbose', '--reset', '--manually_grab_svn_rev',
'--force', '--with_branch_heads']
if self.delete_unversioned_trees_when_updating:
command.append('--delete_unversioned_trees')
if self.gclient_jobs:
command.append('-j%d' % self.gclient_jobs)
# Don't run hooks if it was patched or there is a patch since runhooks will
# be run after.
if self.gclient_nohooks or self.patch or self.was_patched:
command.append('--nohooks')
# GClient accepts --revision argument of two types 'module@rev' and 'rev'.
if self.revision and not self.no_gclient_revision:
command.append('--revision')
# Ignore non-svn part of compound revisions.
if ':' in self.revision:
command.append(self.revision.split(':')[0])
elif (not self.branch or
self.no_gclient_branch or
'@' in str(self.revision)):
command.append(str(self.revision))
else:
# Make the revision look like branch@revision.
prefix = self.project if self.project else self.branch
command.append('%s@%s' % (prefix, self.revision))
if self.gclient_deps:
command.append('--deps=' + self.gclient_deps)
c = runprocess.RunProcess(
self.builder, command, dirname,
sendRC=False, timeout=self.timeout,
keepStdout=True, environ=self.env)
self.command = c
return c.start()
def getGclientConfigCommand(self):
"""Return the command to run the gclient config step.
"""
dirname = os.path.join(self.builder.basedir, self.srcdir)
command = [chromium_utils.GetGClientCommand(), 'config']
if self.gclient_spec:
command.append('--spec=%s' % self.gclient_spec)
else:
command.append(self.svnurl)
git_cache_dir = os.path.abspath(
os.path.join(self.builder.basedir, os.pardir, os.pardir, os.pardir,
'git_cache'))
command.append('--cache-dir=' + git_cache_dir)
c = runprocess.RunProcess(
self.builder, command, dirname,
sendRC=False, timeout=self.timeout,
keepStdout=True, environ=self.env)
return c
def doVCUpdate(self):
"""Sync the client
"""
# Make sure the .gclient is updated.
os.remove(os.path.join(self.builder.basedir, self.srcdir, '.gclient'))
c = self.getGclientConfigCommand()
self.command = c
d = c.start()
d.addCallback(self._abandonOnFailure)
d.addCallback(lambda _: self.doGclientUpdate())
return d
def doVCFull(self):
"""Setup the .gclient file and then sync
"""
chromium_utils.MaybeMakeDirectory(self.builder.basedir, self.srcdir)
c = self.getGclientConfigCommand()
self.command = c
d = c.start()
d.addCallback(self._abandonOnFailure)
d.addCallback(lambda _: self.doGclientUpdate())
return d
def doClobber(self, dummy, dirname, _=False):
"""Move the old directory aside, or delete it if that's already been done.
This function is designed to be used with a source dir. If it's called
with anything else, the caller will need to be sure to clean up the
<dirname>.dead directory once it's no longer needed.
If this is the first time we're clobbering since we last finished a
successful update or checkout, move the old directory aside so a human
can try to recover from it if desired. Otherwise -- if such a backup
directory already exists, because this isn't the first retry -- just
remove the old directory.
Args:
dummy: unused
dirname: the directory within self.builder.basedir to be clobbered
"""
old_dir = os.path.join(self.builder.basedir, dirname)
dead_dir = old_dir + '.dead'
if os.path.isdir(old_dir):
if os.path.isdir(dead_dir):
command = self._RemoveDirectoryCommand(old_dir)
else:
command = _RenameDirectoryCommand(old_dir, dead_dir)
c = runprocess.RunProcess(
self.builder, command, self.builder.basedir,
sendRC=0, timeout=self.rm_timeout,
environ=self.env)
self.command = c
# See commands.SVN.doClobber for notes about sendRC.
d = c.start()
d.addCallback(self._abandonOnFailure)
return d
return None
def doRevert(self, dummy):
"""Revert any modification done by a previous patch.
This is done in 2 parts to trap potential errors at each step. Note that
it is assumed that .orig and .rej files will be reverted, e.g. deleted by
the 'gclient revert' command. If the try bot is configured with
'global-ignores=*.orig', patch failure will occur."""
dirname = os.path.join(self.builder.basedir, self.srcdir)
command = [chromium_utils.GetGClientCommand(), 'revert', '--nohooks']
c = runprocess.RunProcess(
self.builder, command, dirname,
sendRC=False, timeout=self.timeout,
keepStdout=True, environ=self.env)
self.command = c
d = c.start()
d.addCallback(self._abandonOnFailure)
# Remove patch residues.
d.addCallback(lambda _: self._doRevertRemoveSignalFile())
return d
def _doRevertRemoveSignalFile(self):
"""Removes the file that signals that the checkout is patched.
Must be called after a revert has been done and the patch residues have
been removed."""
command = _RemoveFileCommand(os.path.join(self.builder.basedir,
self.srcdir, '.buildbot-patched'))
dirname = os.path.join(self.builder.basedir, self.srcdir)
c = runprocess.RunProcess(
self.builder, command, dirname,
sendRC=False, timeout=self.timeout,
keepStdout=True, environ=self.env)
self.command = c
d = c.start()
d.addCallback(self._abandonOnFailure)
return d
def doPatch(self, res):
patchlevel = self.patch[0]
diff = FixDiffLineEnding(self.patch[1])
# Allow overwriting the root with an environment variable.
root = self.env.get("GCLIENT_PATCH_ROOT", None)
if len(self.patch) >= 3 and root is None:
root = self.patch[2]
command = [
self.getCommand("patch"),
'-p%d' % patchlevel,
'--remove-empty-files',
'--force',
'--forward',
]
dirname = os.path.join(self.builder.basedir, self.workdir)
# Mark the directory so we don't try to update it later.
open(os.path.join(dirname, ".buildbot-patched"), "w").write("patched\n")
# Update 'dirname' with the 'root' option. Make sure it is a subdirectory
# of dirname.
if (root and
os.path.abspath(os.path.join(dirname, root)
).startswith(os.path.abspath(dirname))):
dirname = os.path.join(dirname, root)
# Now apply the patch.
c = runprocess.RunProcess(
self.builder, command, dirname,
sendRC=False, timeout=self.timeout,
initialStdin=diff, environ=self.env)
self.command = c
d = c.start()
d.addCallback(self._abandonOnFailure)
if diff.find('DEPS') != -1:
d.addCallback(self.doVCUpdateOnPatch)
d.addCallback(self._abandonOnFailure)
return d
def doVCUpdateOnPatch(self, res):
if self.revision and not self.branch and '@' not in str(self.revision):
self.branch = 'src'
self.delete_unversioned_trees_when_updating = False
return self.doVCUpdate()
def doRunHooks(self, dummy):
"""Runs "gclient runhooks" after patching."""
dirname = os.path.join(self.builder.basedir, self.srcdir)
command = [chromium_utils.GetGClientCommand(), 'runhooks']
c = runprocess.RunProcess(
self.builder, command, dirname,
sendRC=False, timeout=self.timeout,
keepStdout=True, environ=self.env)
self.command = c
d = c.start()
d.addCallback(self._abandonOnFailure)
return d
def writeSourcedata(self, res):
"""Write the sourcedata file and remove any dead source directory."""
d = None
dead_dir = os.path.join(self.builder.basedir, self.srcdir + '.dead')
if os.path.isdir(dead_dir):
msg = 'Removing dead source dir'
self.sendStatus({'header': msg + '\n'})
log.msg(msg)
command = self._RemoveDirectoryCommand(dead_dir)
c = runprocess.RunProcess(
self.builder, command, self.builder.basedir,
sendRC=0, timeout=self.rm_timeout,
environ=self.env)
self.command = c
d = c.start()
d.addCallback(self._abandonOnFailure)
open(self.sourcedatafile, 'w').write(self.sourcedata)
return d
def parseGotRevision(self):
if not hasattr(self.command, 'stdout'):
# self.command may or may not have a .stdout property. The problem is when
# buildslave.runprocess.RunProcess(keepStdout=False) is used, it doesn't
# set the property .stdout at all.
#
# It may happen depending on the order of execution as self.command only
# tracks the last run command.
return {}
return extract_revisions(self.command.stdout)
def _handleGotRevision(self, res):
"""Sends parseGotRevision() return values as status updates to the master.
"""
d = defer.maybeDeferred(self.parseGotRevision)
# parseGotRevision returns the revision dict, which is passed as the first
# argument to sendStatus.
d.addCallback(self.sendStatus)
return d
def maybeDoVCFallback(self, rc):
"""Called after doVCUpdate."""
if isinstance(rc, int) and rc == 2:
# Non-VC failure, return 2 to turn the step red.
return rc
# super
return SourceBaseCommand.maybeDoVCFallback(self, rc)
def maybeDoVCRetry(self, res):
"""Called after doVCFull."""
if isinstance(res, int) and res == 2:
# Non-VC failure, return 2 to turn the step red.
return res
# super
return SourceBaseCommand.maybeDoVCRetry(self, res)
def RegisterCommands():
"""Registers all command objects defined in this file."""
try:
# We run this code in a try because it fails with an assertion if
# the module is loaded twice.
commandRegistry['gclient'] = 'slave.chromium_commands.GClient'
return
except (AssertionError, NameError):
pass
RegisterCommands()